From ec6c0fcf413457d1e00d7f15460d3b5c7a1801a6 Mon Sep 17 00:00:00 2001 From: Saad Nadeem Date: Sat, 17 Oct 2026 20:46:02 -0400 Subject: [PATCH 1/2] fix: keep text field focus when clicking buttons and show hand cursor on buttons --- .../internal/ui/components/Button.kt | 6 ++- .../internal/ui/components/Common.kt | 19 +++++---- .../components/settings/NumberChainOption.kt | 41 +++++++++++++++---- .../ui/components/settings/NumberOption.kt | 18 +++++++- .../oneconfig/internal/ui/screens/Mods.kt | 4 -- .../oneconfig/internal/ui/screens/Themes.kt | 2 + 6 files changed, 68 insertions(+), 22 deletions(-) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Button.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Button.kt index 3ac45b0d1..72124e503 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Button.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Button.kt @@ -8,6 +8,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.unit.dp import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme @@ -46,7 +48,9 @@ fun IconButton( Icon( iconName, color = iconColor, - modifier = Modifier.onClick(interactionSource, enabled, onClick).then(modifier) + modifier = Modifier.onClick(interactionSource, enabled, onClick) + .then(if (enabled) Modifier.pointerHoverIcon(PointerIcon.Hand) else Modifier) + .then(modifier) // inset the glyph so the outline doesn't hug it; size bordered buttons 8dp larger to keep the icon size .then( if (hoverBorder) Modifier.border(1.dp, borderColor, LocalTheme.current.buttonShape).padding(4.dp) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Common.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Common.kt index 61863634a..d65b981c5 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Common.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Common.kt @@ -23,15 +23,16 @@ fun rememberInteractionSource() = remember { MutableInteractionSource() } fun Modifier.onClick(interactionSource: MutableInteractionSource, onClick: () -> Unit) = onClick(interactionSource, true, onClick) -fun Modifier.onClick(interactionSource: MutableInteractionSource, enabled: Boolean, onClick: () -> Unit) = clickable( - interactionSource = interactionSource, - indication = null, - enabled = enabled, - onClick = { - UiSounds.play(UiSoundEvent.CLICK) - onClick() - } -) +fun Modifier.onClick(interactionSource: MutableInteractionSource, enabled: Boolean, onClick: () -> Unit) = + focusProperties { canFocus = false }.clickable( + interactionSource = interactionSource, + indication = null, + enabled = enabled, + onClick = { + UiSounds.play(UiSoundEvent.CLICK) + onClick() + } + ) /** * Swallows press and release events before children see them so anything below cannot be clicked, dragged diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/NumberChainOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/NumberChainOption.kt index de7d3a897..80cfad469 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/NumberChainOption.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/NumberChainOption.kt @@ -85,6 +85,7 @@ fun NumberChainOption(data: NumberChainOptionData) { mutableStateOf(data.read().map { ChainEntry(idGen[0]++, it) }) } var editingId by remember(data.prop) { mutableStateOf(-1) } + var editingValue by remember(data.prop) { mutableStateOf(null) } var draggingIndex by remember { mutableStateOf(-1) } var dragAccum by remember { mutableStateOf(0f) } @@ -108,6 +109,16 @@ fun NumberChainOption(data: NumberChainOptionData) { entries = data.read().map { ChainEntry(idGen[0]++, it) } } + // clicks elsewhere in the chain leave the editor focused, so they apply its value themselves + fun closeEditor(): List { + val id = editingId + val value = editingValue + editingId = -1 + editingValue = null + if (id == -1 || value == null) return entries + return entries.map { if (it.id == id) it.copy(value = value) else it } + } + var lastTickIndex by remember { mutableStateOf(-1) } val draggableState = rememberDraggableState { delta -> if (draggingIndex == -1) return@rememberDraggableState @@ -186,7 +197,7 @@ fun NumberChainOption(data: NumberChainOptionData) { }, onDragStopped = { if (dragTargetIndex != draggingIndex && draggingIndex >= 0) { - val list = entries.toMutableList() + val list = closeEditor().toMutableList() list.add(dragTargetIndex, list.removeAt(draggingIndex)) commit(list) } @@ -205,11 +216,13 @@ fun NumberChainOption(data: NumberChainOptionData) { max = data.max, unit = data.unit, modifier = Modifier.weight(1f), + onValueChange = { editingValue = it }, onCommit = { value -> // losing focus commits and can arrive after another row claimed the editor // so only the row still being edited may write if (editingId == entry.id) { editingId = -1 + editingValue = null commit(entries.map { if (it.id == entry.id) it.copy(value = value) else it }) } }, @@ -223,7 +236,16 @@ fun NumberChainOption(data: NumberChainOptionData) { .weight(1f) .then( if (!movable) Modifier else Modifier - .onClick(rememberInteractionSource()) { editingId = entry.id } + .onClick(rememberInteractionSource()) { + val pending = closeEditor() + if (pending == entries) { + editingId = entry.id + } else { + // committing gives every entry a new id + commit(pending) + editingId = entries.getOrNull(index)?.id ?: -1 + } + } .pointerHoverIcon(PointerIcon.Hand) ), ) @@ -236,7 +258,7 @@ fun NumberChainOption(data: NumberChainOptionData) { "close", modifier = Modifier .size(14.dp) - .onClick(deleteInteraction) { commit(entries.filterNot { it.id == entry.id }) } + .onClick(deleteInteraction) { commit(closeEditor().filterNot { it.id == entry.id }) } .pointerHoverIcon(PointerIcon.Hand), color = if (deleteHovered) theme.textColor else theme.textColorSecondary, ) @@ -258,8 +280,8 @@ fun NumberChainOption(data: NumberChainOptionData) { .clip(ChainShape) .border(1.dp, theme.borderColor, ChainShape) .onClick(addInteraction) { - val values = entries.map { it.value } - commit(entries + ChainEntry(idGen[0]++, data.nextValue(values))) + val current = closeEditor() + commit(current + ChainEntry(idGen[0]++, data.nextValue(current.map { it.value }))) } .pointerHoverIcon(PointerIcon.Hand) .padding(horizontal = 10.dp, vertical = 7.dp), @@ -280,6 +302,7 @@ private fun ChainValueEditor( max: Float, unit: String?, modifier: Modifier, + onValueChange: (Float) -> Unit, onCommit: (Float) -> Unit, ) { val theme = LocalTheme.current @@ -296,11 +319,15 @@ private fun ChainValueEditor( // the field keeps room for a caret past the last digit which the unit is shifted back across val fieldWidth = with(density) { measurer.measure(text, style).size.width.toDp() } + CaretWidth - fun commit() = onCommit(text.toFloatOrNull()?.coerceIn(min, max) ?: initial) + fun parsed() = text.toFloatOrNull()?.coerceIn(min, max) ?: initial + fun commit() = onCommit(parsed()) BasicTextField( value = text, - onValueChange = { text = filterNumberInput(it) }, + onValueChange = { + text = filterNumberInput(it) + onValueChange(parsed()) + }, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { commit() }), diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/NumberOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/NumberOption.kt index 87f650230..c481ae205 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/NumberOption.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/NumberOption.kt @@ -3,6 +3,8 @@ package org.polyfrost.oneconfig.internal.ui.components.settings import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Box @@ -23,6 +25,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow @@ -122,6 +127,7 @@ fun NumberSpinner( val formatted = if (format != null && !focused) format(value) else null var text by remember(value) { mutableStateOf(formatSpinnerValue(value)) } + val focusManager = LocalFocusManager.current val arrowLo = maxOf(min, arrowMin) val arrowHi = maxOf(arrowLo, minOf(max, arrowMax)) @@ -171,7 +177,17 @@ fun NumberSpinner( .padding(start = 8.dp, top = 5.dp, bottom = 5.dp), ) - SpinnerArrows(value, arrowLo, arrowHi, step) { commit(it) } + // arrows can't take focus, so end the edit on press to let onEditEnd apply before the step does + Box( + Modifier.pointerInput(focused) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + if (focused) focusManager.clearFocus() + } + } + ) { + SpinnerArrows(value, arrowLo, arrowHi, step) { commit(it) } + } } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Mods.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Mods.kt index 433a5f4c5..e659a0e33 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Mods.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Mods.kt @@ -41,7 +41,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.draw.rotate -import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.center @@ -468,7 +467,6 @@ fun ModCard( if (interactive) { // cards never take focus, otherwise they slide in from off screen when scrolled back into view Modifier - .focusProperties { canFocus = false } .onClick(interactionSource) { openModCard(mod) } .pointerHoverIcon(PointerIcon.Hand) } else { @@ -507,7 +505,6 @@ fun ModCard( .then( if (toggle != null && interactive) { Modifier - .focusProperties { canFocus = false } .onClick(footerInteractions) { if (!enabled && toggle.needsSetup()) { open() @@ -653,7 +650,6 @@ private fun FavoriteStar( .then( if (interactive) { Modifier - .focusProperties { canFocus = false } .onClick(interactionSource) { ModFavorites.toggle(mod.id) } .pointerHoverIcon(PointerIcon.Hand) } else { diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Themes.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Themes.kt index 8c3d23815..1d36b2bc0 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Themes.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Themes.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.res.painterResource @@ -78,6 +79,7 @@ private fun ThemeCard(theme: UITheme) { modifier = Modifier .background(LocalTheme.current.modCardBackground, LocalTheme.current.backgroundShape) .border(borderWidth, borderColor, LocalTheme.current.backgroundShape) + .focusProperties { canFocus = false } .clickable(interactionSource, indication = null) { // Use clickable because onClick always plays CLICK. ThemeRegistry.activate chooses CLICK or OPEN ThemeRegistry.activate(theme) From 044d55b5d8bc9491c600c8e10d4fd7dda01d933b Mon Sep 17 00:00:00 2001 From: Saad Nadeem Date: Sat, 17 Oct 2026 20:47:13 -0400 Subject: [PATCH 2/2] feat: focus search on page changes with instant search --- .../internal/ui/compose/impls/OneConfigUIScreen.kt | 2 +- .../main/resources/assets/oneconfig/lang/en_us.json | 2 +- .../polyfrost/oneconfig/internal/ui/screens/Mods.kt | 7 ++++--- .../polyfrost/oneconfig/internal/ui/shell/Data.kt | 12 +++++++++++- .../main/resources/assets/oneconfig/en_default.lang | 2 +- 5 files changed, 18 insertions(+), 7 deletions(-) diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/impls/OneConfigUIScreen.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/impls/OneConfigUIScreen.kt index 439d27805..d40162166 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/impls/OneConfigUIScreen.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/impls/OneConfigUIScreen.kt @@ -256,7 +256,7 @@ class OneConfigUIScreen @JvmOverloads constructor( } private fun warmRoute(route: Any) { - runCatching { if (LocalNavController.isReady) LocalNavController.wrapper.navigate(route) } + runCatching { if (LocalNavController.isReady) LocalNavController.wrapper.navigate(route, clearSearch = false) } } private fun markClosed() { diff --git a/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json index 93a98ef6c..adb982ea7 100644 --- a/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json +++ b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json @@ -82,7 +82,7 @@ "oneconfig.preferences.show_opening_page_animation.description": "Plays the page animation when the menu is first opened.", "oneconfig.preferences.instant_search.title": "Instant Search", - "oneconfig.preferences.instant_search.description": "Focuses the search bar when the OneConfig menu opens.", + "oneconfig.preferences.instant_search.description": "Focuses the search bar when the OneConfig menu opens or you change pages.", "oneconfig.preferences.show_option_action_buttons.title": "Show Option Action Buttons", "oneconfig.preferences.show_option_action_buttons.description": "Shows a more actions button on active option rows. Right-click options still opens the same menu.", diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Mods.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Mods.kt index e659a0e33..acd6d2c02 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Mods.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Mods.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement @@ -247,11 +248,11 @@ fun ColumnScope.ModsGrid(category: ModCategory) { .weight(1f) // see overlayActive above: the overlay card has to be able to leave the grid while it exists .then(if (overlayActive) Modifier else Modifier.clipToBounds()) - // cards can't take focus, so clear it on press to unfocus the search field + // cards and their buttons consume their clicks, so only a click on empty space unfocuses the search field .pointerInput(focusManager) { awaitEachGesture { - awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) - focusManager.clearFocus() + awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Final) + if (waitForUpOrCancellation(PointerEventPass.Final) != null) focusManager.clearFocus() } } .reorderContainer(reorderState), diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/Data.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/Data.kt index 448d30ce3..fcfbf6d64 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/Data.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/Data.kt @@ -11,6 +11,7 @@ import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.ViewModelStore import androidx.lifecycle.ViewModelStoreOwner import androidx.navigation.NavHostController +import org.polyfrost.oneconfig.internal.OneConfigConfig import org.polyfrost.oneconfig.internal.ui.navigation.graph.ModConfigRoute import org.polyfrost.oneconfig.internal.ui.navigation.graph.ModsGraph @@ -179,7 +180,10 @@ object LocalNavController { forwardStack.clear() backStack.addLast(currentEntry.withCurrentSearch()) currentEntry = Entry(route) - if (clearSearch) ShellState.searchQuery = "" + if (clearSearch) { + ShellState.searchQuery = "" + instantSearch() + } ShellState.lastRoute = route ShellState.lastNavWasBack = false seedRouteCategory(route) @@ -232,6 +236,7 @@ object LocalNavController { applyCategory(currentEntry) ShellState.searchQuery = currentEntry.searchQuery ShellState.lastRoute = currentEntry.route + instantSearch() updateArrows() } @@ -251,10 +256,15 @@ object LocalNavController { ShellState.searchQuery = next.searchQuery ShellState.lastRoute = next.route ShellState.lastNavWasBack = false + instantSearch() updateArrows() host.navigate(next.route) } + private fun instantSearch() { + if (OneConfigConfig.instantSearch) ShellState.searchFocus = SearchFocus.SelectAll + } + /** A route that names a category such as an external deep link overrides the remembered tab */ private fun seedRouteCategory(route: Any) { val modConfig = route as? ModConfigRoute ?: return diff --git a/modules/internal/src/main/resources/assets/oneconfig/en_default.lang b/modules/internal/src/main/resources/assets/oneconfig/en_default.lang index e3706a907..a6a3bae39 100644 --- a/modules/internal/src/main/resources/assets/oneconfig/en_default.lang +++ b/modules/internal/src/main/resources/assets/oneconfig/en_default.lang @@ -61,7 +61,7 @@ oneconfig.preferences.remember_last_page.always=Always oneconfig.preferences.show_opening_page_animation.title=Show opening page animation oneconfig.preferences.show_opening_page_animation.description=Plays the page animation when the menu is first opened. oneconfig.preferences.instant_search.title=Instant Search -oneconfig.preferences.instant_search.description=Focuses the search bar when the OneConfig menu opens. +oneconfig.preferences.instant_search.description=Focuses the search bar when the OneConfig menu opens or you change pages. oneconfig.preferences.flip_top_option_order.title=Flip Top Option Order oneconfig.preferences.flip_top_option_order.description=Moves the Edit HUD and Global Search buttons to the bottom of the sidebar, above your account. oneconfig.preferences.search_distance.title=Search Distance