Open the search when typing on a physical keyboard
Some checks failed
Trigger F-Droid repository rebuild / trigger (release) Has been cancelled

Typing on a physical keyboard while the home screen is shown now opens the
search page and inserts the typed character, so the launcher can be used
without touching the screen. It works for any printable key, appends the
characters in order even when the user types faster than the launcher can
recompose, and the search bar can still be tapped to edit the query with
the on-screen keyboard as usual.

While the search is driven by the keyboard, the search bar's text field is
left unfocused on purpose: a focused text field would make the system show
the on-screen keyboard, which is not wanted when there is a physical
keyboard. The handler therefore inserts and deletes characters itself,
including backspace and enter (to launch the best match).

The behaviour is controlled by a new "Search on typing" preference in
Settings -> Search, which is enabled by default.

Implementation notes:
- The key events are intercepted in SharedLauncherActivity.dispatchKeyEvent,
  which is the only place that sees hardware key events while no view has
  focus. The scaffold installs the handler while it is shown.
- Key events are not forwarded to the search bar's text field, so the
  characters are inserted into the search view model directly.
- SearchBar keeps a TextFieldValue instead of a String so that the cursor
  moves to the end of the text when the value is changed from the outside.
  Without this the next typed character ended up in front of the text.
This commit is contained in:
2026-09-10 11:45:14 +02:00
parent 7fd4a6ae74
commit 0f53f7ecdf
15 changed files with 296 additions and 8 deletions

3
.gitignore vendored
View File

@@ -295,4 +295,5 @@ fabric.properties
.idea/other.xml
.idea/studiobot.xml
.kotlin
.kotlin
keystore.properties

View File

@@ -1,5 +1,6 @@
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Properties
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
@@ -33,8 +34,8 @@ android {
applicationId = "de.mm20.launcher2"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = System.getenv("VERSION_CODE_OVERRIDE")?.toIntOrNull() ?: 2026053100
versionName = "1.40.2"
versionCode = System.getenv("VERSION_CODE_OVERRIDE")?.toIntOrNull() ?: 2026091000
versionName = "1.40.2-typing.1"
signingConfig = signingConfigs.getByName("debug")
}
@@ -45,11 +46,31 @@ android {
keyAlias = System.getenv("SIGNING_KEY_ALIAS")
keyPassword = System.getenv("SIGNING_KEY_PASSWORD")
}
// Signing config for fork builds. Credentials are read from keystore.properties in the
// project root, or from the KEYSTORE_FILE, KEYSTORE_PASSWORD, KEY_ALIAS and KEY_PASSWORD
// environment variables.
create("local") {
val props = Properties()
val propsFile = rootProject.file("keystore.properties")
if (propsFile.exists()) {
propsFile.inputStream().use { props.load(it) }
}
storeFile = file(
props.getProperty("storeFile")
?: System.getenv("KEYSTORE_FILE")
?: "keystore.jks"
)
storePassword = props.getProperty("storePassword") ?: System.getenv("KEYSTORE_PASSWORD")
keyAlias = props.getProperty("keyAlias") ?: System.getenv("KEY_ALIAS")
keyPassword = props.getProperty("keyPassword") ?: System.getenv("KEY_PASSWORD")
}
}
buildTypes {
release {
applicationIdSuffix = ".release"
signingConfig = signingConfigs.getByName("local")
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(

View File

@@ -25,6 +25,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -39,8 +40,10 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.preferences.SearchBarStyle
import de.mm20.launcher2.ui.R
@@ -133,6 +136,17 @@ fun SearchBar(
else 1f
}
// The String flavour of BasicTextField keeps the old selection when the text is changed from
// the outside, which puts the cursor in front of the existing text. Examples are searches that
// were started by typing on a physical keyboard. A TextFieldValue lets us move the cursor to the
// end of the text instead.
val textFieldValue = remember {
mutableStateOf(TextFieldValue(value, TextRange(value.length)))
}
if (textFieldValue.value.text != value) {
textFieldValue.value = TextFieldValue(value, TextRange(value.length))
}
LauncherCard(
modifier = modifier
.alpha(opacity),
@@ -168,7 +182,7 @@ fun SearchBar(
BasicTextField(
modifier = Modifier
.onFocusChanged {
if (it.hasFocus) onFocus()
if (it.hasFocus) onFocus() else onUnfocus()
}
.focusRequester(focusRequester)
.fillMaxWidth()
@@ -179,8 +193,11 @@ fun SearchBar(
color = contentColor
),
singleLine = true,
value = value,
onValueChange = onValueChange,
value = textFieldValue.value,
onValueChange = {
textFieldValue.value = it
onValueChange(it.text)
},
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Go,

View File

@@ -6,6 +6,7 @@ import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.content.res.Resources
import android.os.Bundle
import android.view.KeyEvent
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
@@ -33,6 +34,7 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.flowWithLifecycle
@@ -466,6 +468,34 @@ abstract class SharedLauncherActivity(
var pauseTime = 0L
/**
* Key event handler that is installed by the launcher scaffold while it is shown. It allows
* the launcher to react to key events that are not dispatched to any view, e.g. to open the
* search when the user starts typing on a physical keyboard.
*
* The handler runs on the main thread and returns true if it has consumed the event.
*/
internal var launcherKeyEventHandler: ((KeyEvent) -> Boolean)? = null
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val handler = launcherKeyEventHandler
if (handler != null) {
// dispatchKeyEvent must not throw, otherwise unhandled input would crash the launcher.
val handled = runCatching { handler(event) }.getOrDefault(false)
if (handled) return true
}
return super.dispatchKeyEvent(event)
}
/**
* Hide the on-screen keyboard. Used when the search was opened by typing on a physical
* keyboard, where the soft keyboard would only cover the search results.
*/
internal fun hideSoftKeyboard() {
WindowCompat.getInsetsController(window, window.decorView)
.hide(WindowInsetsCompat.Type.ime())
}
/**
* True if the scaffold was on home screen when the activity was paused.
*/

View File

@@ -50,6 +50,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
@@ -109,6 +110,7 @@ import de.mm20.launcher2.ui.launcher.scaffold.components.SearchComponent
import de.mm20.launcher2.ui.launcher.search.SearchVM
import de.mm20.launcher2.ui.launcher.search.filters.KeyboardFilterBar
import de.mm20.launcher2.ui.launcher.searchbar.LauncherSearchBar
import de.mm20.launcher2.ui.launcher.searchbar.rememberTypeToSearchHandler
import de.mm20.launcher2.ui.theme.transparency.transparency
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
@@ -325,6 +327,20 @@ internal class LauncherScaffoldState(
var isSearchBarFocused by mutableStateOf(config.homeComponent is SearchComponent)
/**
* True while the search bar's text field actually has input focus in the Compose hierarchy.
* Unlike [isSearchBarFocused], which only says that the search bar *should* be focused, this is
* reported by the text field itself and is used to decide whether the text field is able to
* handle key events from a physical keyboard.
*/
var isSearchBarTextFieldFocused by mutableStateOf(false)
/**
* True while the search is driven by the physical keyboard. The search bar's text field is not
* focused in that case, so that the on-screen keyboard stays hidden.
*/
var isTypingSearch by mutableStateOf(false)
val statusBarScrim by derivedStateOf {
!isAtTop
}
@@ -1053,6 +1069,19 @@ internal fun LauncherScaffold(
)
}
// Open the search when the user starts typing on a physical keyboard. The handler is
// installed on the activity, so it also receives key events while no text field is focused.
val typeToSearchHandler = rememberTypeToSearchHandler(state, searchVM)
DisposableEffect(typeToSearchHandler) {
val launcherActivity = activity as? SharedLauncherActivity
launcherActivity?.launcherKeyEventHandler = typeToSearchHandler
onDispose {
if (launcherActivity?.launcherKeyEventHandler === typeToSearchHandler) {
launcherActivity.launcherKeyEventHandler = null
}
}
}
LaunchedEffect(state.isAtTop, state.isAtBottom) {
if (state.currentProgress > 0f && state.currentProgress < 1f) {
return@LaunchedEffect
@@ -1372,10 +1401,14 @@ internal fun LauncherScaffold(
level = { state.searchBarLevel },
bottomSearchBar = config.searchBarPosition == SearchBarPosition.Bottom,
onFocusChange = {
state.isSearchBarTextFieldFocused = it
if (it) {
// The user took over with the touch screen, so the text field behaves
// as usual from now on.
state.isTypingSearch = false
scope.launch { state.onSearchBarTap() }
state.isSearchBarFocused = true
}
state.isSearchBarFocused = it
},
onKeyboardActionGo = if (launchOnEnter) {
{ searchVM.launchBestMatchOrAction(activity) }

View File

@@ -105,7 +105,9 @@ internal class SearchComponent(
override fun onPreActivate(state: LauncherScaffoldState) {
super.onPreActivate(state)
if (openKeyboard) {
// When the search was started by typing on a physical keyboard, the text field stays
// unfocused so that the on-screen keyboard stays hidden.
if (openKeyboard && !state.isTypingSearch) {
state.isSearchBarFocused = true
}
}

View File

@@ -74,6 +74,12 @@ class SearchVM : ViewModel(), KoinComponent {
val launchOnEnter = searchUiSettings.launchOnEnter
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
/**
* Whether typing on a physical keyboard while the home screen is shown opens the search.
*/
val searchOnTyping = searchUiSettings.searchOnTyping
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
private val searchService: SearchService by inject()
val searchQuery = mutableStateOf("")

View File

@@ -0,0 +1,130 @@
package de.mm20.launcher2.ui.launcher.searchbar
import android.view.KeyEvent
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import de.mm20.launcher2.ui.launcher.SharedLauncherActivity
import de.mm20.launcher2.ui.launcher.scaffold.LauncherScaffoldState
import de.mm20.launcher2.ui.launcher.scaffold.components.SearchComponent
import de.mm20.launcher2.ui.launcher.search.SearchVM
import de.mm20.launcher2.ui.launcher.sheets.LocalBottomSheetManager
import de.mm20.launcher2.ui.overlays.LocalOverlayManager
import kotlinx.coroutines.launch
/**
* The character that this key event types, or `null` if it is not a printable key press.
*
* Modifier keys, navigation keys, the backspace and delete keys as well as auto-repeat events
* return `null`.
*/
internal fun KeyEvent.typedCharacter(): Char? {
if (action != KeyEvent.ACTION_DOWN) return null
// Auto-repeat events are handled separately, see [isBackspace].
if (repeatCount > 0) return null
if (isBackspace()) return null
val unicode = unicodeChar
if (unicode == 0) return null
// Control characters (enter, tab, escape, ctrl shortcuts, ...) are not typed into the search.
if (Character.isISOControl(unicode)) return null
return unicode.toChar()
}
/**
* True for the backspace and delete keys. Auto-repeat is allowed, so that holding the key keeps
* deleting characters.
*/
internal fun KeyEvent.isBackspace(): Boolean {
if (action != KeyEvent.ACTION_DOWN) return false
return keyCode == KeyEvent.KEYCODE_DEL || keyCode == KeyEvent.KEYCODE_FORWARD_DEL
}
/**
* True for the enter key. Used to launch the highlighted search result, like the "go" key on a
* software keyboard does.
*/
internal fun KeyEvent.isEnter(): Boolean {
if (action != KeyEvent.ACTION_DOWN) return false
if (repeatCount > 0) return false
return keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER
}
/**
* Creates the key event handler that opens the search and starts typing when the user presses a key
* on a physical keyboard while the launcher home screen is shown.
*
* The handler is installed on the launcher activity, see
* [SharedLauncherActivity.launcherKeyEventHandler]. It is called for every key event before the
* event is dispatched to the view hierarchy, so it also works while no text field has focus.
*
* While the search is driven by the physical keyboard, the search bar's text field is deliberately
* left unfocused: a focused text field would make the system show the on-screen keyboard, and the
* user is typing on a physical keyboard anyway. The handler therefore inserts and deletes the
* characters itself. As soon as the user taps the search bar, the text field takes over and behaves
* like it always does.
*
* @return a handler that returns `true` if it has consumed the key event.
*/
@Composable
internal fun rememberTypeToSearchHandler(
state: LauncherScaffoldState,
searchVM: SearchVM,
): (KeyEvent) -> Boolean {
val scope = rememberCoroutineScope()
val activity = LocalActivity.current as? SharedLauncherActivity
val sheetManager = LocalBottomSheetManager.current
val overlayManager = LocalOverlayManager.current
return remember(state, searchVM, activity, sheetManager, overlayManager) {
fun handle(event: KeyEvent): Boolean {
// Read the preference here rather than capturing it in the composition, so that the
// current value is always used.
if (!searchVM.searchOnTyping.value || state.isLocked) return false
// The text field has the input focus and handles key events itself. This gives the user
// the usual editing behaviour, e.g. a text cursor that can be moved.
if (state.isSearchBarTextFieldFocused) return false
// Only start a search from the home screen or while the search page is already open
// (the user may have scrolled the results away from the search bar).
val searchOpen = state.currentComponent is SearchComponent
val onHomeScreen = !state.isSettledOnSecondaryPage && state.currentProgress == 0f
if (!searchOpen && !onHomeScreen) return false
// Never steal keys from a text field in a bottom sheet or an overlay.
if (sheetManager.isAnySheetShown()) return false
if (overlayManager.overlays.isNotEmpty()) return false
val text = searchVM.searchQuery.value
val char = event.typedCharacter()
val newText = when {
char != null -> if (searchOpen) text + char else char.toString()
// Backspace and delete are only handled for a search that the keyboard started.
event.isBackspace() && state.isTypingSearch -> text.dropLast(1)
event.isEnter() && state.isTypingSearch -> {
if (searchVM.launchOnEnter.value) {
searchVM.launchBestMatchOrAction(
activity ?: return false
)
}
return true
}
else -> return false
}
if (newText == text && char == null) return false
if (!state.isTypingSearch) {
state.isTypingSearch = true
// In case the on-screen keyboard is still visible from an earlier session.
activity?.hideSoftKeyboard()
}
if (!searchOpen) scope.launch { state.onSearchBarTap() }
searchVM.search(newText)
return true
}
::handle
}
}

View File

@@ -83,6 +83,16 @@ class LauncherBottomSheetManager(registryOwner: SavedStateRegistryOwner) :
failedGestureSheetShown.value = null
}
/**
* True if any bottom sheet is currently shown.
*/
fun isAnySheetShown(): Boolean =
customizeSearchableSheetShown.value != null
|| editFavoritesSheetShown.value
|| hiddenItemsSheetShown.value
|| editTagSheetShown.value != null
|| failedGestureSheetShown.value != null
companion object {
private const val PROVIDER = "bottom_sheet_manager"
private const val FAVORITES = "favorites"

View File

@@ -89,6 +89,7 @@ fun SearchSettingsScreen() {
val autoFocus by viewModel.autoFocus.collectAsStateWithLifecycle(null)
val launchOnEnter by viewModel.launchOnEnter.collectAsStateWithLifecycle(null)
val searchOnTyping by viewModel.searchOnTyping.collectAsStateWithLifecycle(null)
val reverseSearchResults by viewModel.reverseSearchResults.collectAsStateWithLifecycle(null)
val filterBar by viewModel.filterBar.collectAsStateWithLifecycle(null)
@@ -384,6 +385,15 @@ fun SearchSettingsScreen() {
viewModel.setLaunchOnEnter(it)
}
)
SwitchPreference(
title = stringResource(R.string.preference_search_bar_search_on_typing),
iconPadding = true,
summary = stringResource(R.string.preference_search_bar_search_on_typing_summary),
value = searchOnTyping == true,
onValueChanged = {
viewModel.setSearchOnTyping(it)
}
)
}
}
item {

View File

@@ -135,6 +135,13 @@ class SearchSettingsScreenVM : ViewModel(), KoinComponent {
searchUiSettings.setLaunchOnEnter(launchOnEnter)
}
val searchOnTyping = searchUiSettings.searchOnTyping
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
fun setSearchOnTyping(searchOnTyping: Boolean) {
searchUiSettings.setSearchOnTyping(searchOnTyping)
}
val hasAppShortcutPermission = permissionsManager.hasPermission(PermissionGroup.AppShortcuts)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
val appShortcuts = shortcutSearchSettings.enabled

View File

@@ -425,6 +425,8 @@
<string name="no_account_nextcloud">Du har ikke tilknyttet en Nextcloud-konto endnu</string>
<string name="preference_screen_buildinfo_summary">Flere oplysninger om denne udgave af denne app</string>
<string name="preference_search_bar_launch_on_enter_summary">Start fremhævet match eller hurtig handling, når du trykker gå</string>
<string name="preference_search_bar_search_on_typing">Søg ved indtastning</string>
<string name="preference_search_bar_search_on_typing_summary">Åbn søgningen og begynd at skrive, når du trykker på et fysisk tastatur</string>
<string name="search_action_message">Send besked</string>
<string name="apps_profile_work">Arbejde</string>
<plurals name="calendar_widget_running_events">

View File

@@ -675,6 +675,8 @@
<string name="preference_search_bar_auto_focus_summary">Automatically show the keyboard when opening the app drawer</string>
<string name="preference_search_bar_launch_on_enter">Launch on enter</string>
<string name="preference_search_bar_launch_on_enter_summary">Launch highlighted match or quick action upon tapping go</string>
<string name="preference_search_bar_search_on_typing">Search on typing</string>
<string name="preference_search_bar_search_on_typing_summary">Open the search and start typing when you press a key on a physical keyboard</string>
<string name="preference_hidden_items">Excluded search results</string>
<string name="preference_hidden_items_summary">Manage excluded apps and search results</string>
<string name="preference_hidden_items_reveal_button">Show reveal button</string>

View File

@@ -116,6 +116,11 @@ data class LauncherSettingsData internal constructor(
val searchBarColors: SearchBarColors = SearchBarColors.Auto,
val searchBarKeyboard: Boolean = true,
val searchLaunchOnEnter: Boolean = true,
/**
* Open the search and start typing when a printable key is pressed on a physical keyboard
* while the home screen is shown.
*/
val searchOnTyping: Boolean = true,
val searchBarBottom: Boolean = false,
val searchBarFixed: Boolean = false,

View File

@@ -52,6 +52,18 @@ class SearchUiSettings internal constructor(
}
}
/**
* Whether typing on a physical keyboard while the home screen is shown opens the search.
*/
val searchOnTyping
get() = launcherDataStore.data.map { it.searchOnTyping }.distinctUntilChanged()
fun setSearchOnTyping(searchOnTyping: Boolean) {
launcherDataStore.update {
it.copy(searchOnTyping = searchOnTyping)
}
}
val reversedResults
get() = launcherDataStore.data.map { it.searchResultsReversed }.distinctUntilChanged()