From e2de42f509835fcfd32b506c2a9dbbc7aa7e9324 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Rottsch=C3=A4fer?= Date: Tue, 10 Nov 2026 09:29:27 +0100 Subject: [PATCH 1/5] feat(settings): polish Arrange apps reordering Short keycap hint, Reset in Options, discard confirmation for pending edits, and keep the selected app in context with two rows above/below. Consume key repeats and restore focus so holding Up/Down no longer desyncs the highlight. --- .../apps/settings/ArrangeHomeScreen.kt | 125 ++++++++++++++---- .../interactive/sidekit/home/HomeArranger.kt | 7 +- .../sidekit/apps/settings/ArrangeHomeTest.kt | 10 +- .../sidekit/home/HomeArrangerTest.kt | 27 ++++ .../sidekit/design/components/SidekitList.kt | 12 +- 5 files changed, 146 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/settings/ArrangeHomeScreen.kt b/app/src/main/java/hrln/interactive/sidekit/apps/settings/ArrangeHomeScreen.kt index f3800f01..c3624928 100644 --- a/app/src/main/java/hrln/interactive/sidekit/apps/settings/ArrangeHomeScreen.kt +++ b/app/src/main/java/hrln/interactive/sidekit/apps/settings/ArrangeHomeScreen.kt @@ -1,14 +1,18 @@ package hrln.interactive.sidekit.apps.settings import android.view.KeyEvent +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState @@ -26,6 +30,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import hrln.interactive.sidekit.apps.sideport.registry.PortedApps +import hrln.interactive.sidekit.design.action.ActionMotion import hrln.interactive.sidekit.design.action.ActionTone import hrln.interactive.sidekit.design.action.SideAction import hrln.interactive.sidekit.design.components.* @@ -41,9 +46,10 @@ import hrln.interactive.sidekit.navigation.SubApp * * Up/Down walk the app list like Home. Reordering is a selection mode, keypad-first: Confirm on a * focused app selects it (highlighted), then Up/Down moves it through the order. The selected row - * also shows up/down arrows on its right as a direct toggle, and tapping a row selects it too. The dock is automatic — the first - * [HomePreferences.DOCK_MAX] apps are "In dock" — so reordering an app into/out of the top four - * pins/unpins it. Hidden apps are omitted so they cannot take a dock slot here. + * also shows up/down arrows on its right as a direct toggle, and tapping a row selects it too. The + * dock is automatic — the first [HomePreferences.DOCK_MAX] apps are "In dock" — so reordering an + * app into/out of the top four pins/unpins it. Hidden apps are omitted so they cannot take a dock + * slot here. */ @Composable internal fun ArrangeHomeScreen(back: () -> Unit) { @@ -59,32 +65,74 @@ internal fun ArrangeHomeScreen(back: () -> Unit) { val arranger = remember(home, known) { HomeArranger(home, known) } val order = arranger.order val selected = arranger.selected + var confirmingDiscard by remember { mutableStateOf(false) } fun done() { arranger.commit() back() } fun cancel() = back() // buffer never committed, so Back leaves the launcher unchanged + fun requestBack() { + if (arranger.hasPendingChanges) confirmingDiscard = true else cancel() + } + fun discard() { + confirmingDiscard = false + cancel() + } + BackHandler { + if (confirmingDiscard) confirmingDiscard = false else requestBack() + } // Start on the first app, not on the screen itself: a focused screen has no D-pad target, so // Up/Down would do nothing. A frame is needed because LazyColumn composes rows during layout. val firstRow = remember { FocusRequester() } + val selectedRow = remember { FocusRequester() } val list = rememberLazyListState() LaunchedEffect(Unit) { withFrameNanos { }; runCatching { firstRow.requestFocus() } } - // Moving the selected app reorders the keyed row, but focus alone does not re-run bring-into-view. - // Keep the list on the selected app so Up/Down never walks the selection off the visible page. + // Keep the selected app in context while it moves: at least two rows above and below it stay + // visible, so the user can judge its position instead of it sticking to the top of the list. + // Only scroll when that context is missing, and put the selected row third from the top. LaunchedEffect(selected, order) { - val index = selected?.let(order::indexOf) ?: return@LaunchedEffect - if (index >= 0) list.animateScrollToItem(index + 1) + val id = selected ?: return@LaunchedEffect + val index = order.indexOf(id) + if (index < 0) return@LaunchedEffect + val listIndex = index + 1 + val visible = list.layoutInfo.visibleItemsInfo + val selectedVisible = visible.any { it.index == listIndex } + val above = visible.count { it.index < listIndex } + val below = visible.count { it.index > listIndex } + if (!selectedVisible || above < 2 || below < 2) { + list.scrollToItem((listIndex - 2).coerceAtLeast(0)) + withFrameNanos { } + } + // Reordering moves the keyed row; focus can be dropped even though the row still looks + // selected. Put it back so holding Up/Down never leaves the highlight behind. + runCatching { selectedRow.requestFocus() } } + val actions = if (confirmingDiscard) { + listOf( + SideAction.cancel("cancel", "Cancel", onClick = { confirmingDiscard = false }), + SideAction("discard", "Discard", icon = SidekitIcon.Undo, tone = ActionTone.Destructive, motion = ActionMotion.Up, onClick = ::discard), + ) + } else { + listOf( + SideAction.back("back", "Settings", ::requestBack), + SideAction.forward("done", "Done", ::done, icon = SidekitIcon.Check), + ) + } + val options = if (confirmingDiscard) emptyList() else listOf( + SideAction("reset", "Reset", icon = SidekitIcon.Undo, tone = ActionTone.Muted, onClick = { arranger.reset() }), + ) SidekitScaffold( "Arrange apps", // With nothing selected, Up/Down are left to focus traversal, so the keypad walks the app // list like Home. Once an app is selected, Up/Down move that app instead (T/B on the // keypad); Left/Right, Confirm and Back are untouched so the user can still reach the // arrows and the action bar. Deselect (Confirm on the selected row) to browse freely again. + // Repeats are consumed too: otherwise holding a direction walks focus away while the row + // stays selected, leaving the highlight and the actual focus out of sync. modifier = Modifier.onPreviewKeyEvent { event -> val native = event.nativeKeyEvent - if (selected != null && native.action == KeyEvent.ACTION_DOWN && native.repeatCount == 0) { + if (selected != null && !confirmingDiscard && native.action == KeyEvent.ACTION_DOWN) { when (native.keyCode) { KeyEvent.KEYCODE_DPAD_UP -> { arranger.moveSelected(-1); true } KeyEvent.KEYCODE_DPAD_DOWN -> { arranger.moveSelected(+1); true } @@ -92,20 +140,11 @@ internal fun ArrangeHomeScreen(back: () -> Unit) { } } else false }, - actions = listOf( - SideAction.back("back", "Settings", ::cancel), - SideAction.forward("done", "Done", ::done, icon = SidekitIcon.Check), - SideAction("reset", "Reset", icon = SidekitIcon.Undo, tone = ActionTone.Muted, onClick = { arranger.reset() }), - ), + actions = actions, + options = options, ) { padding -> - SidekitList(padding, state = list) { - item { - SidekitText( - "Select apps, then move them up or down. First 4 apps are also shown in the dock", - style = SidekitTheme.typography.label, - maxLines = Int.MAX_VALUE, - ) - } + SidekitList(padding, state = list, focusBandSlots = 2) { + item { ArrangeHint() } val last = order.lastIndex itemsIndexed(order, key = { _, id -> id }) { index, id -> val label = subsById[id]?.label ?: portedById[id]?.label ?: return@itemsIndexed @@ -122,13 +161,13 @@ internal fun ArrangeHomeScreen(back: () -> Unit) { SidekitItem( title = label, modifier = Modifier.weight(1f).then( - if (index == 0) Modifier.focusRequester(firstRow) else Modifier, + when { + isSelected -> Modifier.focusRequester(selectedRow) + index == 0 -> Modifier.focusRequester(firstRow) + else -> Modifier + }, ), - secondary = when { - isSelected -> "Selected" - arranger.isInDock(id) -> "In dock" - else -> null - }, + secondary = if (isSelected) "Selected" else null, colour = if (isSelected) ItemColour.Secondary else ItemColour.Primary, onClick = { arranger.select(id) }, ) @@ -144,6 +183,36 @@ internal fun ArrangeHomeScreen(back: () -> Unit) { } } +/** The keypad hint, with key names in small grey caps so they read as keys, not prose. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ArrangeHint() { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + itemVerticalAlignment = Alignment.CenterVertically, + ) { + KeyCap("Enter") + SidekitText(": Select App,", style = SidekitTheme.typography.label) + KeyCap("Up") + SidekitText("/", style = SidekitTheme.typography.label) + KeyCap("Down") + SidekitText(": Move App", style = SidekitTheme.typography.label) + } +} + +@Composable +private fun KeyCap(label: String) { + Box( + Modifier + .clip(RoundedCornerShape(6.dp)) + .background(SidekitTheme.colors.surfaceA3) + .padding(horizontal = 8.dp, vertical = 2.dp), + ) { + SidekitText(label, style = SidekitTheme.typography.small, color = SidekitTheme.colors.light) + } +} + /** A small round arrow button; `up=false` flips the upward glyph to point down. Disabled state is dimmed. */ @Composable private fun ItemArrow(up: Boolean, enabled: Boolean, contentDescription: String, onClick: () -> Unit) { diff --git a/app/src/main/java/hrln/interactive/sidekit/home/HomeArranger.kt b/app/src/main/java/hrln/interactive/sidekit/home/HomeArranger.kt index 3f0ef55a..65b6fa7f 100644 --- a/app/src/main/java/hrln/interactive/sidekit/home/HomeArranger.kt +++ b/app/src/main/java/hrln/interactive/sidekit/home/HomeArranger.kt @@ -26,9 +26,14 @@ class HomeArranger(private val home: HomePreferences, known: Iterable) { /** Ported (or otherwise freshly-seen) ids that aren't part of the built-in default list. */ private val appendedDefaults: List = knownIds.filter { it !in builtinIds } - var order by mutableStateOf(home.homeOrderFor(knownIds)) + private val initialOrder = home.homeOrderFor(knownIds) + + var order by mutableStateOf(initialOrder) private set + /** True when the buffer differs from the order it was seeded with (Reset counts as a change). */ + val hasPendingChanges: Boolean get() = order != initialOrder + /** The app currently selected for reordering, or null when not in selection mode. */ var selected by mutableStateOf(null) private set diff --git a/app/src/test/java/hrln/interactive/sidekit/apps/settings/ArrangeHomeTest.kt b/app/src/test/java/hrln/interactive/sidekit/apps/settings/ArrangeHomeTest.kt index 17b10b65..98af2cfd 100644 --- a/app/src/test/java/hrln/interactive/sidekit/apps/settings/ArrangeHomeTest.kt +++ b/app/src/test/java/hrln/interactive/sidekit/apps/settings/ArrangeHomeTest.kt @@ -69,10 +69,14 @@ class ArrangeHomeTest { } @Test - fun `renders every app with the four pinned dock apps marked In dock`() { + fun `renders every app with the short keypad hint and no dock labels`() { arrange() - compose.onNodeWithText("Select apps, then move them up or down. First 4 apps are also shown in the dock").assertIsDisplayed() - compose.onAllNodesWithText("In dock").assertCountEquals(HomePreferences.DOCK_MAX) + compose.onNodeWithText("Enter").assertIsDisplayed() + compose.onNodeWithText(": Select App,").assertIsDisplayed() + compose.onNodeWithText("Up").assertIsDisplayed() + compose.onNodeWithText("Down").assertIsDisplayed() + compose.onNodeWithText(": Move App").assertIsDisplayed() + compose.onAllNodesWithText("In dock").assertCountEquals(0) SubApp.dock.forEach { compose.onNodeWithText(it.label).assertIsDisplayed() } compose.onNodeWithText("Photos").assertExists() } diff --git a/app/src/test/java/hrln/interactive/sidekit/home/HomeArrangerTest.kt b/app/src/test/java/hrln/interactive/sidekit/home/HomeArrangerTest.kt index 763c83c3..98925546 100644 --- a/app/src/test/java/hrln/interactive/sidekit/home/HomeArrangerTest.kt +++ b/app/src/test/java/hrln/interactive/sidekit/home/HomeArrangerTest.kt @@ -122,6 +122,33 @@ class HomeArrangerTest { assertEquals(before, arranger.order) } + @Test + fun `pending changes track the buffer, not the selection`() { + val home = HomePreferences(context) + val arranger = HomeArranger(home, known) + assertFalse(arranger.hasPendingChanges) + + arranger.select(photos) + assertFalse(arranger.hasPendingChanges) // selecting is not an edit + + arranger.moveSelected(-1) + assertTrue(arranger.hasPendingChanges) + + arranger.moveSelected(+1) // back to the seeded order + assertFalse(arranger.hasPendingChanges) + } + + @Test + fun `reset is a pending change when it differs from the seeded order`() { + val home = HomePreferences(context) + home.move(photos, 2) + val arranger = HomeArranger(home, known) + assertFalse(arranger.hasPendingChanges) + + arranger.reset() + assertTrue(arranger.hasPendingChanges) + } + @Test fun `reset keeps only the known apps, so filtered apps stay out`() { val home = HomePreferences(context) diff --git a/sidekit/src/main/java/hrln/interactive/sidekit/design/components/SidekitList.kt b/sidekit/src/main/java/hrln/interactive/sidekit/design/components/SidekitList.kt index d79cc9e9..73cb34a5 100644 --- a/sidekit/src/main/java/hrln/interactive/sidekit/design/components/SidekitList.kt +++ b/sidekit/src/main/java/hrln/interactive/sidekit/design/components/SidekitList.kt @@ -88,17 +88,23 @@ fun SidekitList( contentPadding: PaddingValues, modifier: Modifier = Modifier, state: LazyListState = rememberLazyListState(), + /** + * How many row slots above the focused row stay visible. The default keeps it in the second + * slot; screens that need more context around a focused row (Arrange apps) pass 2. + */ + focusBandSlots: Int = 1, content: LazyListScope.() -> Unit, ) { // Rows stagger their own entrance (see listFade), so the body shouldn't also fade as a block. LocalBodyEntrance.current?.listDriven = true val density = LocalDensity.current val spacing = SidekitTheme.spacing - val focusScroll = remember(contentPadding, density, spacing) { + val focusScroll = remember(contentPadding, density, spacing, focusBandSlots) { with(density) { FocusBand( top = contentPadding.calculateTopPadding().toPx(), pitch = (spacing.itemHeight + spacing.itemOuterGap).toPx(), + slots = focusBandSlots, ) } } @@ -119,9 +125,9 @@ fun SidekitList( * title, where the list fade is lightest and the action bar is far away. Moving down a long list * scrolls it a row at a time, like a wheel menu. */ -private class FocusBand(private val top: Float, private val pitch: Float) : BringIntoViewSpec { +private class FocusBand(private val top: Float, private val pitch: Float, private val slots: Int = 1) : BringIntoViewSpec { override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float { - val bandEnd = top + pitch + val bandEnd = top + pitch * slots return when { offset < top -> offset - top offset > bandEnd -> offset - bandEnd From b807769b4f4bbf08188a9495d821f9546d2f12e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Rottsch=C3=A4fer?= Date: Tue, 10 Nov 2026 09:39:20 +0100 Subject: [PATCH 2/5] fix(settings): smooth Arrange apps scrolling and restore dock labels Use requestScrollToItem so the reorder and scroll land in the same measure pass, relax context to one row above/below, restore In dock labels, and split the key hint into two explicit rows. --- .../apps/settings/ArrangeHomeScreen.kt | 73 ++++++++++--------- .../sidekit/apps/settings/ArrangeHomeTest.kt | 6 +- .../sidekit/design/components/SidekitList.kt | 12 +-- 3 files changed, 46 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/settings/ArrangeHomeScreen.kt b/app/src/main/java/hrln/interactive/sidekit/apps/settings/ArrangeHomeScreen.kt index c3624928..c94730e7 100644 --- a/app/src/main/java/hrln/interactive/sidekit/apps/settings/ArrangeHomeScreen.kt +++ b/app/src/main/java/hrln/interactive/sidekit/apps/settings/ArrangeHomeScreen.kt @@ -7,8 +7,6 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -88,24 +86,24 @@ internal fun ArrangeHomeScreen(back: () -> Unit) { val selectedRow = remember { FocusRequester() } val list = rememberLazyListState() LaunchedEffect(Unit) { withFrameNanos { }; runCatching { firstRow.requestFocus() } } - // Keep the selected app in context while it moves: at least two rows above and below it stay - // visible, so the user can judge its position instead of it sticking to the top of the list. - // Only scroll when that context is missing, and put the selected row third from the top. - LaunchedEffect(selected, order) { - val id = selected ?: return@LaunchedEffect - val index = order.indexOf(id) - if (index < 0) return@LaunchedEffect - val listIndex = index + 1 - val visible = list.layoutInfo.visibleItemsInfo - val selectedVisible = visible.any { it.index == listIndex } - val above = visible.count { it.index < listIndex } - val below = visible.count { it.index > listIndex } - if (!selectedVisible || above < 2 || below < 2) { - list.scrollToItem((listIndex - 2).coerceAtLeast(0)) - withFrameNanos { } + // Keep the selected app in context while it moves: scroll to the row just before it, so the + // selected app sits in the second slot with at least one neighbour above and the rest below. + // requestScrollToItem applies the scroll in the same measure pass as the reorder, so the row + // never draws at its old position for a frame (which looked like it sat on top of its neighbour). + val scrollTarget = selected?.let(order::indexOf)?.takeIf { it >= 0 } + var lastScrollTarget by remember { mutableStateOf(null) } + SideEffect { + if (scrollTarget == null) { + lastScrollTarget = null + } else if (scrollTarget != lastScrollTarget) { + list.requestScrollToItem(scrollTarget) + lastScrollTarget = scrollTarget } - // Reordering moves the keyed row; focus can be dropped even though the row still looks - // selected. Put it back so holding Up/Down never leaves the highlight behind. + } + LaunchedEffect(selected, order) { + if (selected == null) return@LaunchedEffect + // Let the requested scroll land first, then restore focus to the keyed row that moved. + withFrameNanos { } runCatching { selectedRow.requestFocus() } } val actions = if (confirmingDiscard) { @@ -143,7 +141,7 @@ internal fun ArrangeHomeScreen(back: () -> Unit) { actions = actions, options = options, ) { padding -> - SidekitList(padding, state = list, focusBandSlots = 2) { + SidekitList(padding, state = list) { item { ArrangeHint() } val last = order.lastIndex itemsIndexed(order, key = { _, id -> id }) { index, id -> @@ -167,7 +165,11 @@ internal fun ArrangeHomeScreen(back: () -> Unit) { else -> Modifier }, ), - secondary = if (isSelected) "Selected" else null, + secondary = when { + isSelected -> "Selected" + arranger.isInDock(id) -> "In dock" + else -> null + }, colour = if (isSelected) ItemColour.Secondary else ItemColour.Primary, onClick = { arranger.select(id) }, ) @@ -184,20 +186,25 @@ internal fun ArrangeHomeScreen(back: () -> Unit) { } /** The keypad hint, with key names in small grey caps so they read as keys, not prose. */ -@OptIn(ExperimentalLayoutApi::class) @Composable private fun ArrangeHint() { - FlowRow( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - itemVerticalAlignment = Alignment.CenterVertically, - ) { - KeyCap("Enter") - SidekitText(": Select App,", style = SidekitTheme.typography.label) - KeyCap("Up") - SidekitText("/", style = SidekitTheme.typography.label) - KeyCap("Down") - SidekitText(": Move App", style = SidekitTheme.typography.label) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + KeyCap("Enter") + SidekitText(": Select App", style = SidekitTheme.typography.label) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + KeyCap("Up") + SidekitText("/", style = SidekitTheme.typography.label) + KeyCap("Down") + SidekitText(": Move App", style = SidekitTheme.typography.label) + } } } diff --git a/app/src/test/java/hrln/interactive/sidekit/apps/settings/ArrangeHomeTest.kt b/app/src/test/java/hrln/interactive/sidekit/apps/settings/ArrangeHomeTest.kt index 98af2cfd..e9e60e9a 100644 --- a/app/src/test/java/hrln/interactive/sidekit/apps/settings/ArrangeHomeTest.kt +++ b/app/src/test/java/hrln/interactive/sidekit/apps/settings/ArrangeHomeTest.kt @@ -69,14 +69,14 @@ class ArrangeHomeTest { } @Test - fun `renders every app with the short keypad hint and no dock labels`() { + fun `renders every app with the two-row keypad hint and dock labels`() { arrange() compose.onNodeWithText("Enter").assertIsDisplayed() - compose.onNodeWithText(": Select App,").assertIsDisplayed() + compose.onNodeWithText(": Select App").assertIsDisplayed() compose.onNodeWithText("Up").assertIsDisplayed() compose.onNodeWithText("Down").assertIsDisplayed() compose.onNodeWithText(": Move App").assertIsDisplayed() - compose.onAllNodesWithText("In dock").assertCountEquals(0) + compose.onAllNodesWithText("In dock").assertCountEquals(HomePreferences.DOCK_MAX) SubApp.dock.forEach { compose.onNodeWithText(it.label).assertIsDisplayed() } compose.onNodeWithText("Photos").assertExists() } diff --git a/sidekit/src/main/java/hrln/interactive/sidekit/design/components/SidekitList.kt b/sidekit/src/main/java/hrln/interactive/sidekit/design/components/SidekitList.kt index 73cb34a5..d79cc9e9 100644 --- a/sidekit/src/main/java/hrln/interactive/sidekit/design/components/SidekitList.kt +++ b/sidekit/src/main/java/hrln/interactive/sidekit/design/components/SidekitList.kt @@ -88,23 +88,17 @@ fun SidekitList( contentPadding: PaddingValues, modifier: Modifier = Modifier, state: LazyListState = rememberLazyListState(), - /** - * How many row slots above the focused row stay visible. The default keeps it in the second - * slot; screens that need more context around a focused row (Arrange apps) pass 2. - */ - focusBandSlots: Int = 1, content: LazyListScope.() -> Unit, ) { // Rows stagger their own entrance (see listFade), so the body shouldn't also fade as a block. LocalBodyEntrance.current?.listDriven = true val density = LocalDensity.current val spacing = SidekitTheme.spacing - val focusScroll = remember(contentPadding, density, spacing, focusBandSlots) { + val focusScroll = remember(contentPadding, density, spacing) { with(density) { FocusBand( top = contentPadding.calculateTopPadding().toPx(), pitch = (spacing.itemHeight + spacing.itemOuterGap).toPx(), - slots = focusBandSlots, ) } } @@ -125,9 +119,9 @@ fun SidekitList( * title, where the list fade is lightest and the action bar is far away. Moving down a long list * scrolls it a row at a time, like a wheel menu. */ -private class FocusBand(private val top: Float, private val pitch: Float, private val slots: Int = 1) : BringIntoViewSpec { +private class FocusBand(private val top: Float, private val pitch: Float) : BringIntoViewSpec { override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float { - val bandEnd = top + pitch * slots + val bandEnd = top + pitch return when { offset < top -> offset - top offset > bandEnd -> offset - bandEnd From 0f90717ae81875c9226d0173dd0ac45d2245accc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Rottsch=C3=A4fer?= Date: Tue, 10 Nov 2026 09:49:27 +0100 Subject: [PATCH 3/5] fix(settings): make the Arrange apps hint reachable from the keypad The hint is now a focus target, so Up from the first app brings it back into view instead of stopping at the top row. Down returns to the first app. --- .../apps/settings/ArrangeHomeScreen.kt | 26 +++++++++++++++++-- .../sidekit/apps/settings/ArrangeHomeTest.kt | 14 ++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/settings/ArrangeHomeScreen.kt b/app/src/main/java/hrln/interactive/sidekit/apps/settings/ArrangeHomeScreen.kt index c94730e7..dee3e263 100644 --- a/app/src/main/java/hrln/interactive/sidekit/apps/settings/ArrangeHomeScreen.kt +++ b/app/src/main/java/hrln/interactive/sidekit/apps/settings/ArrangeHomeScreen.kt @@ -4,6 +4,7 @@ import android.view.KeyEvent import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -20,8 +21,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalContext @@ -34,6 +37,7 @@ import hrln.interactive.sidekit.design.action.SideAction import hrln.interactive.sidekit.design.components.* import hrln.interactive.sidekit.design.icons.SidekitIcon import hrln.interactive.sidekit.design.theme.SidekitTheme +import hrln.interactive.sidekit.design.theme.drawFocusRing import hrln.interactive.sidekit.home.HomeArranger import hrln.interactive.sidekit.home.HomePreferences import hrln.interactive.sidekit.navigation.SubApp @@ -185,10 +189,28 @@ internal fun ArrangeHomeScreen(back: () -> Unit) { } } -/** The keypad hint, with key names in small grey caps so they read as keys, not prose. */ +/** + * The keypad hint, with key names in small grey caps so they read as keys, not prose. + * + * It is focusable even though it does nothing: otherwise Up from the first app stops there and the + * hint can never be brought back into view after the list has scrolled down. Focusing it lets the + * list's bring-into-view scroll it back to the top; Down returns to the first app. + */ @Composable private fun ArrangeHint() { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + var focused by remember { mutableStateOf(false) } + Column( + Modifier + .fillMaxWidth() + .focusable() + .onFocusChanged { focused = it.isFocused } + .drawWithContent { + drawContent() + if (focused) drawFocusRing(1f, cornerRadius = 12.dp.toPx()) + } + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), diff --git a/app/src/test/java/hrln/interactive/sidekit/apps/settings/ArrangeHomeTest.kt b/app/src/test/java/hrln/interactive/sidekit/apps/settings/ArrangeHomeTest.kt index e9e60e9a..ccab377f 100644 --- a/app/src/test/java/hrln/interactive/sidekit/apps/settings/ArrangeHomeTest.kt +++ b/app/src/test/java/hrln/interactive/sidekit/apps/settings/ArrangeHomeTest.kt @@ -121,6 +121,20 @@ class ArrangeHomeTest { compose.onNodeWithContentDescription("Move Sideticks up").assertDoesNotExist() } + @Test + fun `keypad can reach the hint above the first app`() { + arrangeWithKeypad() + compose.onNodeWithText("Sidemessage").assertIsFocused() + + compose.onRoot().performKeyInput { pressKey(Key.DirectionUp) } + compose.waitForIdle() + compose.onNode(hasText("Enter") and hasAnyAncestor(isFocused())).assertExists() + + compose.onRoot().performKeyInput { pressKey(Key.DirectionDown) } + compose.waitForIdle() + compose.onNodeWithText("Sidemessage").assertIsFocused() + } + @Test fun `hidden apps are omitted from the rearrangement list`() { SidekitPreferences(context).hiddenApps = setOf("builtin:Sidenotes") From 790bb0ca59cef5d1e0e7d8355cdbd41c7fb4b8b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Rottsch=C3=A4fer?= Date: Tue, 10 Nov 2026 09:52:11 +0100 Subject: [PATCH 4/5] style: apply spotless to the Arrange apps changes --- .../apps/settings/ArrangeHomeScreen.kt | 55 ++++++++++++------- .../interactive/sidekit/home/HomeArranger.kt | 7 ++- .../sidekit/home/HomeArrangerTest.kt | 28 +++++----- 3 files changed, 55 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/settings/ArrangeHomeScreen.kt b/app/src/main/java/hrln/interactive/sidekit/apps/settings/ArrangeHomeScreen.kt index dee3e263..93faed7c 100644 --- a/app/src/main/java/hrln/interactive/sidekit/apps/settings/ArrangeHomeScreen.kt +++ b/app/src/main/java/hrln/interactive/sidekit/apps/settings/ArrangeHomeScreen.kt @@ -89,7 +89,10 @@ internal fun ArrangeHomeScreen(back: () -> Unit) { val firstRow = remember { FocusRequester() } val selectedRow = remember { FocusRequester() } val list = rememberLazyListState() - LaunchedEffect(Unit) { withFrameNanos { }; runCatching { firstRow.requestFocus() } } + LaunchedEffect(Unit) { + withFrameNanos { } + runCatching { firstRow.requestFocus() } + } // Keep the selected app in context while it moves: scroll to the row just before it, so the // selected app sits in the second slot with at least one neighbour above and the rest below. // requestScrollToItem applies the scroll in the same measure pass as the reorder, so the row @@ -113,17 +116,21 @@ internal fun ArrangeHomeScreen(back: () -> Unit) { val actions = if (confirmingDiscard) { listOf( SideAction.cancel("cancel", "Cancel", onClick = { confirmingDiscard = false }), - SideAction("discard", "Discard", icon = SidekitIcon.Undo, tone = ActionTone.Destructive, motion = ActionMotion.Up, onClick = ::discard), + SideAction("discard", "Discard", icon = SidekitIcon.Undo, tone = ActionTone.Destructive, motion = ActionMotion.Up, onClick = ::discard) ) } else { listOf( SideAction.back("back", "Settings", ::requestBack), - SideAction.forward("done", "Done", ::done, icon = SidekitIcon.Check), + SideAction.forward("done", "Done", ::done, icon = SidekitIcon.Check) + ) + } + val options = if (confirmingDiscard) { + emptyList() + } else { + listOf( + SideAction("reset", "Reset", icon = SidekitIcon.Undo, tone = ActionTone.Muted, onClick = { arranger.reset() }) ) } - val options = if (confirmingDiscard) emptyList() else listOf( - SideAction("reset", "Reset", icon = SidekitIcon.Undo, tone = ActionTone.Muted, onClick = { arranger.reset() }), - ) SidekitScaffold( "Arrange apps", // With nothing selected, Up/Down are left to focus traversal, so the keypad walks the app @@ -136,14 +143,24 @@ internal fun ArrangeHomeScreen(back: () -> Unit) { val native = event.nativeKeyEvent if (selected != null && !confirmingDiscard && native.action == KeyEvent.ACTION_DOWN) { when (native.keyCode) { - KeyEvent.KEYCODE_DPAD_UP -> { arranger.moveSelected(-1); true } - KeyEvent.KEYCODE_DPAD_DOWN -> { arranger.moveSelected(+1); true } + KeyEvent.KEYCODE_DPAD_UP -> { + arranger.moveSelected(-1) + true + } + + KeyEvent.KEYCODE_DPAD_DOWN -> { + arranger.moveSelected(+1) + true + } + else -> false } - } else false + } else { + false + } }, actions = actions, - options = options, + options = options ) { padding -> SidekitList(padding, state = list) { item { ArrangeHint() } @@ -158,7 +175,7 @@ internal fun ArrangeHomeScreen(back: () -> Unit) { Row( Modifier.fillMaxWidth().height(76.dp).listFade(list, index + 1), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(SidekitTheme.spacing.itemContentGap), + horizontalArrangement = Arrangement.spacedBy(SidekitTheme.spacing.itemContentGap) ) { SidekitItem( title = label, @@ -167,7 +184,7 @@ internal fun ArrangeHomeScreen(back: () -> Unit) { isSelected -> Modifier.focusRequester(selectedRow) index == 0 -> Modifier.focusRequester(firstRow) else -> Modifier - }, + } ), secondary = when { isSelected -> "Selected" @@ -175,7 +192,7 @@ internal fun ArrangeHomeScreen(back: () -> Unit) { else -> null }, colour = if (isSelected) ItemColour.Secondary else ItemColour.Primary, - onClick = { arranger.select(id) }, + onClick = { arranger.select(id) } ) if (isSelected) { Column { @@ -209,18 +226,18 @@ private fun ArrangeHint() { if (focused) drawFocusRing(1f, cornerRadius = 12.dp.toPx()) } .padding(horizontal = 8.dp, vertical = 6.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp) ) { KeyCap("Enter") SidekitText(": Select App", style = SidekitTheme.typography.label) } Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp) ) { KeyCap("Up") SidekitText("/", style = SidekitTheme.typography.label) @@ -236,7 +253,7 @@ private fun KeyCap(label: String) { Modifier .clip(RoundedCornerShape(6.dp)) .background(SidekitTheme.colors.surfaceA3) - .padding(horizontal = 8.dp, vertical = 2.dp), + .padding(horizontal = 8.dp, vertical = 2.dp) ) { SidekitText(label, style = SidekitTheme.typography.small, color = SidekitTheme.colors.light) } @@ -251,12 +268,12 @@ private fun ItemArrow(up: Boolean, enabled: Boolean, contentDescription: String, .clip(RoundedCornerShape(50)) .alpha(if (enabled) 1f else 0.3f) .then(if (enabled) Modifier.clickable(onClickLabel = contentDescription, onClick = onClick) else Modifier), - contentAlignment = Alignment.Center, + contentAlignment = Alignment.Center ) { SidekitIconImage( SidekitIcon.ArrowUpward, contentDescription = contentDescription, - modifier = if (up) Modifier else Modifier.graphicsLayer { rotationZ = 180f }, + modifier = if (up) Modifier else Modifier.graphicsLayer { rotationZ = 180f } ) } } diff --git a/app/src/main/java/hrln/interactive/sidekit/home/HomeArranger.kt b/app/src/main/java/hrln/interactive/sidekit/home/HomeArranger.kt index 65b6fa7f..575cfa01 100644 --- a/app/src/main/java/hrln/interactive/sidekit/home/HomeArranger.kt +++ b/app/src/main/java/hrln/interactive/sidekit/home/HomeArranger.kt @@ -56,7 +56,10 @@ class HomeArranger(private val home: HomePreferences, known: Iterable) { if (index < 0) return val target = (index + delta).coerceIn(0, order.lastIndex) if (target == index) return - order = order.toMutableList().apply { removeAt(index); add(target, id) } + order = order.toMutableList().apply { + removeAt(index) + add(target, id) + } } /** Moves the currently selected app by [delta]; no-op when nothing is selected. */ @@ -74,4 +77,4 @@ class HomeArranger(private val home: HomePreferences, known: Iterable) { fun commit() { home.saveOrder(order) } -} \ No newline at end of file +} diff --git a/app/src/test/java/hrln/interactive/sidekit/home/HomeArrangerTest.kt b/app/src/test/java/hrln/interactive/sidekit/home/HomeArrangerTest.kt index 98925546..b452d154 100644 --- a/app/src/test/java/hrln/interactive/sidekit/home/HomeArrangerTest.kt +++ b/app/src/test/java/hrln/interactive/sidekit/home/HomeArrangerTest.kt @@ -2,8 +2,8 @@ package hrln.interactive.sidekit.home import android.content.Context import androidx.test.core.app.ApplicationProvider -import hrln.interactive.sidekit.apps.sideport.registry.PortedApp import hrln.interactive.sidekit.apps.settings.visibilityId +import hrln.interactive.sidekit.apps.sideport.registry.PortedApp import hrln.interactive.sidekit.navigation.SubApp import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -43,7 +43,7 @@ class HomeArrangerTest { @Test fun `seeds from stored order, dock derived, freshly-seen apps appended`() { val home = HomePreferences(context) - home.move(photos, 2) // persisted order change + home.move(photos, 2) // persisted order change val arranger = HomeArranger(home, known) // home.homeOrder is the raw stored list; the arranger appends the freshly-seen ported id. @@ -79,16 +79,16 @@ class HomeArrangerTest { val home = HomePreferences(context) val arranger = HomeArranger(home, known) val defaultDock = SubApp.dock.map { it.visibilityId() } - assertEquals(defaultDock, arranger.dock) // defaults == first four (Photos at 4 is out) + assertEquals(defaultDock, arranger.dock) // defaults == first four (Photos at 4 is out) assertFalse(arranger.isInDock(photos)) assertFalse(arranger.isInDock(matrix)) - arranger.move(photos, -1) // photos 4 -> 3, enters the dock + arranger.move(photos, -1) // photos 4 -> 3, enters the dock assertTrue(arranger.isInDock(photos)) assertEquals(HomePreferences.DOCK_MAX, arranger.dock.size) assertEquals(photos, arranger.dock.last()) - arranger.move(photos, 1) // back out of the dock + arranger.move(photos, 1) // back out of the dock assertFalse(arranger.isInDock(photos)) assertEquals(defaultDock, arranger.dock) @@ -108,17 +108,17 @@ class HomeArrangerTest { assertEquals(photos, arranger.selected) assertTrue(arranger.isSelected(photos)) - arranger.moveSelected(-1) // photos 4 -> 3 + arranger.moveSelected(-1) // photos 4 -> 3 assertEquals(3, arranger.order.indexOf(photos)) - assertTrue(arranger.isSelected(photos)) // selection follows the app + assertTrue(arranger.isSelected(photos)) // selection follows the app - arranger.moveSelected(0) // no-op keeps selection and order + arranger.moveSelected(0) // no-op keeps selection and order assertEquals(3, arranger.order.indexOf(photos)) - arranger.select(photos) // deselect + arranger.select(photos) // deselect assertNull(arranger.selected) val before = arranger.order.toList() - arranger.moveSelected(1) // nothing selected -> no-op + arranger.moveSelected(1) // nothing selected -> no-op assertEquals(before, arranger.order) } @@ -129,12 +129,12 @@ class HomeArrangerTest { assertFalse(arranger.hasPendingChanges) arranger.select(photos) - assertFalse(arranger.hasPendingChanges) // selecting is not an edit + assertFalse(arranger.hasPendingChanges) // selecting is not an edit arranger.moveSelected(-1) assertTrue(arranger.hasPendingChanges) - arranger.moveSelected(+1) // back to the seeded order + arranger.moveSelected(+1) // back to the seeded order assertFalse(arranger.hasPendingChanges) } @@ -178,7 +178,7 @@ class HomeArrangerTest { fun `commit persists the order and the dock follows the top four`() { val home = HomePreferences(context) val arranger = HomeArranger(home, known) - arranger.move(photos, -1) // bring Photos into the dock + arranger.move(photos, -1) // bring Photos into the dock arranger.commit() val stored = HomePreferences(context) @@ -188,4 +188,4 @@ class HomeArrangerTest { assertTrue(stored.isInDock(photos)) assertEquals(SubApp.dock.map { it.visibilityId() }.dropLast(1) + listOf(photos), arranger.dock) } -} \ No newline at end of file +} From 4e3da190bd98da5fd2a09d609b65867c18552828 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Rottsch=C3=A4fer?= Date: Tue, 10 Nov 2026 09:57:31 +0100 Subject: [PATCH 5/5] fix(settings): use the main SideAction API for discard cancel --- .../sidekit/apps/settings/ArrangeHomeScreen.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/settings/ArrangeHomeScreen.kt b/app/src/main/java/hrln/interactive/sidekit/apps/settings/ArrangeHomeScreen.kt index 93faed7c..b7caa7a7 100644 --- a/app/src/main/java/hrln/interactive/sidekit/apps/settings/ArrangeHomeScreen.kt +++ b/app/src/main/java/hrln/interactive/sidekit/apps/settings/ArrangeHomeScreen.kt @@ -115,7 +115,14 @@ internal fun ArrangeHomeScreen(back: () -> Unit) { } val actions = if (confirmingDiscard) { listOf( - SideAction.cancel("cancel", "Cancel", onClick = { confirmingDiscard = false }), + SideAction( + "cancel", + "Cancel", + icon = SidekitIcon.ArrowLeftAlt, + tone = ActionTone.Muted, + motion = ActionMotion.Back, + onClick = { confirmingDiscard = false } + ), SideAction("discard", "Discard", icon = SidekitIcon.Undo, tone = ActionTone.Destructive, motion = ActionMotion.Up, onClick = ::discard) ) } else {