Skip to content

Commit 2dcb850

Browse files
OwenMcGirrclaude
andcommitted
Improve scanning-menu grid fit on small and short screens
- Keep navigation on one row whenever four cells meet the 52dp touch minimum - Size the page-dot slot for dots rather than a text line - Drop the title row when the highlight HUD reserves no top space, since it floats over that row - Treat mid-word line breaks as a label-fit failure so narrow phones fall back to fewer columns - Allow up to five columns where width permits, so landscape and tablets use rows less - Budget and clamp the menu against the navigation-bar-inset window area Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 28d2e3f commit 2dcb850

8 files changed

Lines changed: 154 additions & 10 deletions

File tree

app/src/main/java/com/enaboapps/switchify/screens/settings/menu/MenuCustomizationScreen.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ fun MenuCustomizationContent(screenModel: MenuCustomizationScreenModel, menuId:
169169
context = context,
170170
items = menuItems.filter { visibilityMap[it.id] ?: true },
171171
contextualCount = 0,
172-
hasTitle = MenuConstants.getTitleResource(menuId) != null,
172+
hasTitle = MenuGridMeasurer.showsTitle(context, menuId),
173173
hasNavigation = true
174174
).grid.pageCapacity
175175
val visibleIndexById = remember(menuItems, visibilityMap) {

app/src/main/java/com/enaboapps/switchify/service/menu/MenuGridLayout.kt

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ internal data class MenuGridLayout(
1818
}
1919

2020
internal object MenuGridLayoutPolicy {
21+
/**
22+
* Upper bound on content columns. Width-limited screens land well below
23+
* this; it only engages on landscape phones and tablets, where rows are
24+
* the scarce dimension.
25+
*/
26+
const val MAX_COLUMNS = 5
27+
2128
fun calculate(
2229
widthPx: Int,
2330
heightPx: Int,
@@ -29,7 +36,7 @@ internal object MenuGridLayoutPolicy {
2936
): MenuGridLayout {
3037
val width = widthPx.coerceAtLeast(1)
3138
val minimumWidth = maxOf(minimumCellWidthPx, minimumTouchPx, 1)
32-
var columns = minOf(3, itemCount.coerceAtLeast(1), (width / minimumWidth).coerceAtLeast(1))
39+
var columns = minOf(MAX_COLUMNS, itemCount.coerceAtLeast(1), (width / minimumWidth).coerceAtLeast(1))
3340
while (columns > 1 && !labelsFit(width / columns)) columns--
3441
val cellHeight = preferredCellHeightPx.coerceAtLeast(minimumTouchPx)
3542
.coerceAtMost(heightPx.coerceAtLeast(minimumTouchPx))
@@ -38,6 +45,34 @@ internal object MenuGridLayoutPolicy {
3845
}
3946
}
4047

48+
internal object MenuLabelBreaks {
49+
/**
50+
* True when a line break placed at [end] splits a word in two, i.e. there
51+
* is a word character on both sides of the break. Breaks at whitespace,
52+
* punctuation, hyphens, or between characters of scripts that legitimately
53+
* break anywhere (CJK, Thai, and similar) are not mid-word.
54+
*/
55+
fun isMidWordBreak(text: CharSequence, end: Int): Boolean {
56+
if (end <= 0 || end >= text.length) return false
57+
return isWordChar(text[end - 1]) && isWordChar(text[end])
58+
}
59+
60+
private fun isWordChar(c: Char): Boolean {
61+
if (!c.isLetterOrDigit()) return false
62+
return when (Character.UnicodeScript.of(c.code)) {
63+
Character.UnicodeScript.HAN,
64+
Character.UnicodeScript.HIRAGANA,
65+
Character.UnicodeScript.KATAKANA,
66+
Character.UnicodeScript.HANGUL,
67+
Character.UnicodeScript.THAI,
68+
Character.UnicodeScript.LAO,
69+
Character.UnicodeScript.KHMER,
70+
Character.UnicodeScript.MYANMAR -> false
71+
else -> true
72+
}
73+
}
74+
}
75+
4176
internal data class MenuPageSections<T>(
4277
val contextual: List<T>,
4378
val content: List<T>,

app/src/main/java/com/enaboapps/switchify/service/menu/MenuGridMetrics.kt

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import android.graphics.Typeface
66
import android.text.StaticLayout
77
import android.text.TextPaint
88
import android.util.TypedValue
9+
import com.enaboapps.switchify.service.menu.structure.MenuConstants
10+
import com.enaboapps.switchify.service.window.MenuHighlightHud
911
import kotlin.math.ceil
1012

1113
internal data class MenuGridMetrics(
@@ -34,8 +36,12 @@ internal object MenuGridMeasurer {
3436
val width = MenuSurfaceBudget.contentMaxWidthPx(context).coerceAtLeast(1)
3537
val minimumTouch = dp(52f)
3638
val titleHeight = if (hasTitle) sp(24f) + dp(12f) else 0
37-
val pageCountHeight = sp(20f) + dp(8f)
38-
val navigationColumns = if (width >= 4 * maxOf(minimumTouch, dp(64f))) 4 else 2
39+
// Page indicator is a row of dots, not text: 8 dp dots plus breathing room.
40+
val pageCountHeight = dp(16f)
41+
// Keep navigation on one row whenever four cells can each meet the
42+
// touch minimum; a second nav row costs more height than a content row
43+
// on narrow phones.
44+
val navigationColumns = if (width >= 4 * minimumTouch) 4 else 2
3945
val navHeight = maxOf(minimumTouch, dp(28f) + sp(size.labelTextSize.value * 2.6f) + dp(12f))
4046
val contextualHeight = maxOf(minimumTouch, sp(size.labelTextSize.value * 2.6f) + dp(16f))
4147
val chrome = titleHeight + contextualCount * contextualHeight +
@@ -56,10 +62,21 @@ internal object MenuGridMeasurer {
5662
val layout = StaticLayout.Builder.obtain(text, 0, text.length, paint,
5763
(cellWidth - dp(16f)).coerceAtLeast(1))
5864
.setIncludePad(false).setMaxLines(4).build()
59-
layout.lineCount <= 3
65+
layout.lineCount <= 3 && (0 until layout.lineCount).none { line ->
66+
MenuLabelBreaks.isMidWordBreak(text, layout.getLineEnd(line))
67+
}
6068
}
6169
}
6270
return MenuGridMetrics(grid, width, titleHeight, contextualHeight,
6371
navigationColumns, navHeight, pageCountHeight)
6472
}
73+
74+
/**
75+
* Whether the menu should render its title row. On short screens the
76+
* highlight HUD reserves no top space and floats over the top of the menu,
77+
* exactly where the title sits, so the row is dropped to give that height
78+
* back to content.
79+
*/
80+
fun showsTitle(context: Context, menuId: String?): Boolean =
81+
MenuConstants.getTitleResource(menuId) != null && MenuHighlightHud.reservedTopPx(context) > 0
6582
}

app/src/main/java/com/enaboapps/switchify/service/menu/MenuSurfaceBudget.kt

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ object MenuSurfaceBudget {
2929
* the device edge.
3030
*/
3131
fun surfaceMaxWidthPx(context: Context): Int {
32-
val screenWidthPx = ScreenUtils.getWidth(context)
32+
val screenWidthPx = usableWidthPx(context)
3333
val marginPx = ScreenUtils.dpToPx(context, SCREEN_HORIZONTAL_MARGIN_DP)
3434
return (screenWidthPx - marginPx).coerceAtLeast(0)
3535
}
@@ -40,12 +40,30 @@ object MenuSurfaceBudget {
4040
* margin.
4141
*/
4242
fun surfaceMaxHeightPx(context: Context): Int {
43-
val screenHeightPx = ScreenUtils.getHeight(context)
43+
val screenHeightPx = usableHeightPx(context)
4444
val hudReservedPx = MenuHighlightHud.reservedTopPx(context)
4545
val marginPx = ScreenUtils.dpToPx(context, SCREEN_VERTICAL_MARGIN_DP)
4646
return (screenHeightPx - hudReservedPx - marginPx).coerceAtLeast(0)
4747
}
4848

49+
/**
50+
* Window width the overlay can actually draw into: full bounds minus any
51+
* side-mounted navigation bar (landscape three-button nav).
52+
*/
53+
fun usableWidthPx(context: Context): Int {
54+
val insets = ScreenUtils.getNavigationBarInsets(context)
55+
return (ScreenUtils.getWidth(context) - insets.left - insets.right).coerceAtLeast(0)
56+
}
57+
58+
/**
59+
* Window height the overlay can actually draw into: full bounds minus the
60+
* bottom navigation bar, which the overlay window is inset by.
61+
*/
62+
fun usableHeightPx(context: Context): Int {
63+
val insets = ScreenUtils.getNavigationBarInsets(context)
64+
return (ScreenUtils.getHeight(context) - insets.bottom).coerceAtLeast(0)
65+
}
66+
4967
/**
5068
* Max width for the *content area* inside the surface (after subtracting
5169
* the surface's own horizontal padding). This is the width the list

app/src/main/java/com/enaboapps/switchify/service/menu/MenuView.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ class MenuView(val context: Context, private val menu: BaseMenu) {
9191
val content = items.filterNot { it.id in contextualIds || it === back }
9292
val close = if (menu.shouldShowNavMenuItems()) menu.buildCloseItem() else null
9393
val title = MenuConstants.getTitleResource(menu.menuId)
94+
?.takeIf { MenuGridMeasurer.showsTitle(context, menu.menuId) }
9495
var metrics = MenuGridMeasurer.measure(context, content, contextual.size,
9596
title != null, close != null || back != null)
9697
if (metrics.grid.pages(content).size > 1 && close == null && back == null) {
@@ -167,8 +168,10 @@ class MenuView(val context: Context, private val menu: BaseMenu) {
167168
}
168169

169170
private fun resizeAndRepositionMenu() {
170-
val screenWidth = ScreenUtils.getWidth(context)
171-
val screenHeight = ScreenUtils.getHeight(context)
171+
// Clamp within the area the overlay window can draw into, which
172+
// excludes the navigation bar.
173+
val screenWidth = MenuSurfaceBudget.usableWidthPx(context)
174+
val screenHeight = MenuSurfaceBudget.usableHeightPx(context)
172175
val menuWidth = baseLayout.width
173176
val menuHeight = baseLayout.height
174177

app/src/main/java/com/enaboapps/switchify/service/utils/ScreenUtils.kt

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,17 @@ import android.content.Context
44
import android.content.res.Configuration
55
import android.os.Build
66
import android.util.DisplayMetrics
7+
import android.view.WindowInsets
78
import android.view.WindowManager
89
import android.view.WindowMetrics
910

11+
/** Pixel insets of a system bar on each window edge. */
12+
data class SystemBarInsets(val left: Int, val top: Int, val right: Int, val bottom: Int) {
13+
companion object {
14+
val NONE = SystemBarInsets(0, 0, 0, 0)
15+
}
16+
}
17+
1018
/**
1119
* This class provides utility functions to get screen dimensions.
1220
*/
@@ -41,6 +49,25 @@ class ScreenUtils {
4149
}
4250
}
4351

52+
/**
53+
* Pixels the navigation bar occupies on each edge of the current window.
54+
*
55+
* Overlay windows added without FLAG_LAYOUT_NO_LIMITS are inset by the
56+
* system bars, so anything budgeted from [getWidth] / [getHeight] (which
57+
* report full window bounds on API 30+) must subtract these to stay
58+
* visible. Below API 30 the display metrics already exclude the bar.
59+
*/
60+
fun getNavigationBarInsets(context: Context): SystemBarInsets {
61+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return SystemBarInsets.NONE
62+
return try {
63+
val insets = getWindowMetrics(context).windowInsets
64+
.getInsets(WindowInsets.Type.navigationBars())
65+
SystemBarInsets(insets.left, insets.top, insets.right, insets.bottom)
66+
} catch (e: Exception) {
67+
SystemBarInsets.NONE
68+
}
69+
}
70+
4471
/**
4572
* Converts dp to pixels.
4673
*

app/src/test/java/com/enaboapps/switchify/service/menu/MenuGridLayoutTest.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ class MenuGridLayoutTest {
1515
fits: (Int) -> Boolean = { true }
1616
) = MenuGridLayoutPolicy.calculate(width, height, count, minimumWidth, cellHeight, 52, fits)
1717

18-
@Test fun usesAtMostThreeColumnsAndFitsRows() {
18+
@Test fun columnsFollowMinimumWidthAndFitRows() {
1919
val grid = layout()
2020
assertEquals(3, grid.columns)
2121
assertEquals(3, grid.rows)
@@ -29,6 +29,13 @@ class MenuGridLayoutTest {
2929
assertEquals(1, layout(count = 0).columns)
3030
}
3131

32+
@Test fun wideScreensUseUpToFiveColumns() {
33+
assertEquals(4, layout(width = 350).columns)
34+
assertEquals(5, layout(width = 400).columns)
35+
assertEquals(5, layout(width = 800).columns)
36+
assertEquals(4, layout(width = 800, count = 4).columns)
37+
}
38+
3239
@Test fun narrowOrScaledMenusReduceColumns() {
3340
assertEquals(2, layout(width = 200).columns)
3441
assertEquals(1, layout(width = 140).columns)
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.enaboapps.switchify.service.menu
2+
3+
import org.junit.Assert.assertFalse
4+
import org.junit.Assert.assertTrue
5+
import org.junit.Test
6+
7+
class MenuLabelBreaksTest {
8+
@Test fun breakInsideAWordIsMidWord() {
9+
assertTrue(MenuLabelBreaks.isMidWordBreak("Accessibility", 7))
10+
assertTrue(MenuLabelBreaks.isMidWordBreak("Tap and Hold", 6))
11+
assertTrue(MenuLabelBreaks.isMidWordBreak("Page 12", 6))
12+
}
13+
14+
@Test fun breakAtWhitespaceIsNotMidWord() {
15+
// StaticLayout keeps trailing whitespace on the preceding line.
16+
assertFalse(MenuLabelBreaks.isMidWordBreak("Tap and Hold", 4))
17+
assertFalse(MenuLabelBreaks.isMidWordBreak("Tap and Hold", 3))
18+
}
19+
20+
@Test fun breakAtPunctuationIsNotMidWord() {
21+
assertFalse(MenuLabelBreaks.isMidWordBreak("Wi-Fi", 3))
22+
assertFalse(MenuLabelBreaks.isMidWordBreak("Wi-Fi", 2))
23+
assertFalse(MenuLabelBreaks.isMidWordBreak("Volume/Media", 7))
24+
}
25+
26+
@Test fun textEdgesAreNeverMidWord() {
27+
assertFalse(MenuLabelBreaks.isMidWordBreak("Settings", 0))
28+
assertFalse(MenuLabelBreaks.isMidWordBreak("Settings", 8))
29+
assertFalse(MenuLabelBreaks.isMidWordBreak("", 0))
30+
}
31+
32+
@Test fun scriptsThatBreakBetweenCharactersAreNotMidWord() {
33+
assertFalse(MenuLabelBreaks.isMidWordBreak("設定項目", 2))
34+
assertFalse(MenuLabelBreaks.isMidWordBreak("ひらがな", 2))
35+
assertFalse(MenuLabelBreaks.isMidWordBreak("การตั้ง", 3))
36+
}
37+
}

0 commit comments

Comments
 (0)