From ce5d4268d0108c4d77e052b77ea897ff3977825e Mon Sep 17 00:00:00 2001 From: 4di-hello <4di-hello@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:14:53 +0100 Subject: [PATCH] Settings search: long press on a result opens its screen A tap on a search result still changes the setting. A long press opens the screen that has the setting, scrolls to it and highlights it briefly, so it can be found again without searching. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VuiaMhLXwhErf9shhrr1uD --- .../keyboard/settings/SearchScreen.kt | 140 +++++++++++++++++- .../keyboard/settings/SettingsContainer.kt | 16 +- 2 files changed, 149 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt b/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt index 9b8650e66..a80836224 100644 --- a/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt @@ -3,6 +3,11 @@ package helium314.keyboard.settings import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope @@ -53,6 +58,7 @@ import androidx.compose.ui.unit.dp import helium314.keyboard.latin.R import helium314.keyboard.settings.preferences.PreferenceCategory +import helium314.keyboard.latin.utils.getActivity import helium314.keyboard.latin.utils.prefs @Composable @@ -104,7 +110,35 @@ fun SearchSettingsScreen( result.filter { it.second.isNotEmpty() } } + // jump from a long press in the search results: scroll to the setting and highlight it + val listState = androidx.compose.foundation.lazy.rememberLazyListState() + var highlightKey by remember { mutableStateOf(null) } + // fine-tune sections opened only to find the setting, and the setting to scroll to + var openedForJump by remember { mutableStateOf(emptySet()) } + var scrollKey by remember { mutableStateOf(null) } + LaunchedEffect(groups) { + val keysOnScreen = groups.flatMap { it.second } + if (scrollKey == null) { + val found = SettingsJump.take(keysOnScreen) + val step = jumpThroughFineTuneSections(context, settings, openedForJump, found) + openedForJump = step.opened + if (step.prefsChanged) (context.getActivity() as? SettingsActivity)?.prefChanged() + if (found == null) return@LaunchedEffect + scrollKey = found + // closing the other sections changes the list, scroll after that + if (step.prefsChanged) return@LaunchedEffect + } + val jumpKey = scrollKey ?: return@LaunchedEffect + scrollKey = null + if (jumpKey !in keysOnScreen) return@LaunchedEffect + val index = groups.indexOfFirst { jumpKey in it.second } + highlightKey = jumpKey + listState.animateScrollToItem(index + if (headerContent != null) 1 else 0) + kotlinx.coroutines.delay(2500) + highlightKey = null + } LazyColumn( + state = listState, modifier = Modifier .padding(innerPadding) .fillMaxSize(), @@ -128,10 +162,16 @@ fun SearchSettingsScreen( if (titleRes != null) { PreferenceCategory(stringResource(titleRes)) } - keys.forEach { key -> androidx.compose.runtime.key(key) { - SettingsActivity.settingsContainer[key]?.Preference() + val highlight by androidx.compose.animation.animateColorAsState( + if (key == highlightKey) MaterialTheme.colorScheme.primaryContainer + else androidx.compose.ui.graphics.Color.Transparent, + label = "jump highlight" + ) + Box(Modifier.background(highlight)) { + SettingsActivity.settingsContainer[key]?.Preference() + } } } } @@ -162,10 +202,104 @@ fun SearchSettingsScreen( } } }, - itemContent = { it.Preference() } + // long press on a search result opens the screen where the setting is + itemContent = { setting -> JumpOnLongPress(setting.key) { setting.Preference() } } ) } +private const val FINE_TUNE_SECTION_PREFIX = "expand_fine_tune_" + +internal class JumpStep(val opened: Set, val prefsChanged: Boolean) + +/** + * A setting in a closed fine-tune section is not in the list. Then all closed sections of the screen are + * opened; once the setting is [found], only the section that contains it stays open. If the setting is + * still missing (hidden for another reason), the sections are closed again. + */ +internal fun jumpThroughFineTuneSections( + context: android.content.Context, + settings: List, + opened: Set, + found: String?, +): JumpStep { + val prefs = context.prefs() + fun close(keys: Collection) = prefs.edit().apply { keys.forEach { putBoolean(it, false) } }.apply() + if (found != null) { + if (opened.isEmpty()) return JumpStep(emptySet(), false) + val index = settings.indexOf(found) + val owner = settings.subList(0, index.coerceAtLeast(0)).lastOrNull { it is String && it in opened } + val toClose = opened - setOfNotNull(owner as String?) + close(toClose) + return JumpStep(emptySet(), toClose.isNotEmpty()) + } + if (opened.isNotEmpty()) { + close(opened) + return JumpStep(emptySet(), true) + } + if (!SettingsJump.isPending()) return JumpStep(emptySet(), false) + val closed = settings.filterIsInstance() + .filter { it.startsWith(FINE_TUNE_SECTION_PREFIX) && !prefs.getBoolean(it, false) } + if (closed.isEmpty()) return JumpStep(emptySet(), false) + prefs.edit().apply { closed.forEach { putBoolean(it, true) } }.apply() + return JumpStep(closed.toSet(), true) +} + +/** A setting to show and highlight on its screen after a long press in the search results */ +object SettingsJump { + private const val VALID_MS = 3000L + private var key: String? = null + private var time = 0L + + fun request(key: String) { + this.key = key + time = android.os.SystemClock.uptimeMillis() + } + + fun isPending() = key != null && android.os.SystemClock.uptimeMillis() - time <= VALID_MS + + /** The requested key if it is on this screen; it is then used up */ + fun take(keysOnScreen: Collection): String? { + val k = key ?: return null + if (android.os.SystemClock.uptimeMillis() - time > VALID_MS) { key = null; return null } + if (k !in keysOnScreen) return null + key = null + return k + } +} + +@Composable +private fun JumpOnLongPress(key: String, content: @Composable () -> Unit) { + val destination = SettingsActivity.settingsContainer.screenOf(key) + if (destination == null) { + content() + return + } + val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current + Box(Modifier.pointerInput(key) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + // null on timeout: the finger stayed down without moving, a long press + val endedEarly = withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) { + while (true) { + val change = awaitPointerEvent(PointerEventPass.Initial).changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed || (change.position - down.position).getDistance() > viewConfiguration.touchSlop) break + } + true + } + if (endedEarly == null) { + haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress) + SettingsJump.request(key) + SettingsDestination.navigateTo(destination) + // the setting itself must not react to this touch + do { + val event = awaitPointerEvent(PointerEventPass.Initial) + event.changes.forEach { it.consume() } + } while (event.changes.any { it.pressed }) + } + } + }) { content() } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchScreen( diff --git a/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt b/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt index 6c6f9abed..a05a3d24c 100644 --- a/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt +++ b/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt @@ -27,15 +27,22 @@ import helium314.keyboard.settings.screens.createToolbarSettings import helium314.keyboard.latin.voice.createVoiceSettings class SettingsContainer(context: Context) { - private val list = createSettings(context) + private val settingsWithScreens = createSettings(context) + private val list = settingsWithScreens.map { it.first } private val map: Map = HashMap(list.size).apply { list.forEach { putIfAbsent(it.key, it) } } + /** settings screen (destination) that shows a setting, for jumping there from the search */ + private val screens: Map = HashMap().apply { + settingsWithScreens.forEach { (setting, destination) -> if (destination != null) putIfAbsent(setting.key, destination) } + } operator fun get(key: Any): Setting? = map[key] + fun screenOf(key: String): String? = screens[key] + // filtering could be more elaborate, but should be good enough for a start // always have all settings in search, because: // don't show disabled settings -> users confused @@ -151,10 +158,11 @@ private val modules = listOf( SettingsModule(SettingsWithoutKey.SCREEN_NAV_UPDATES, SettingsDestination.Updates, R.string.settings_screen_updates, iconRes = R.drawable.ic_settings_updates) ) -private fun createSettings(context: Context): List = buildList { +/** all settings, each with the destination of the screen that shows it (null for the navigation entries) */ +private fun createSettings(context: Context): List> = buildList { modules.forEach { module -> - add(module.createNavigationSetting(context)) - module.provider?.invoke(context)?.let { addAll(it) } + add(module.createNavigationSetting(context) to null) + module.provider?.invoke(context)?.forEach { add(it to module.destination) } } }