Skip to content

Commit adef332

Browse files
feat: disable IME extract UI and centralize singleLine keyboard behavior
1 parent 24508c2 commit adef332

11 files changed

Lines changed: 146 additions & 26 deletions
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package app.gamenative.ui.component
2+
3+
import android.view.inputmethod.EditorInfo
4+
import androidx.compose.foundation.interaction.MutableInteractionSource
5+
import androidx.compose.foundation.text.KeyboardActions
6+
import androidx.compose.foundation.text.KeyboardOptions
7+
import androidx.compose.material3.LocalTextStyle
8+
import androidx.compose.material3.OutlinedTextField
9+
import androidx.compose.material3.OutlinedTextFieldDefaults
10+
import androidx.compose.material3.TextFieldColors
11+
import androidx.compose.runtime.Composable
12+
import androidx.compose.runtime.remember
13+
import androidx.compose.ui.Modifier
14+
import androidx.compose.ui.graphics.Shape
15+
import androidx.compose.ui.platform.InterceptPlatformTextInput
16+
import androidx.compose.ui.platform.LocalFocusManager
17+
import androidx.compose.ui.platform.PlatformTextInputMethodRequest
18+
import androidx.compose.ui.text.TextStyle
19+
import androidx.compose.ui.text.input.ImeAction
20+
import androidx.compose.ui.text.input.VisualTransformation
21+
22+
/**
23+
* OutlinedTextField wrapper that sets IME_FLAG_NO_EXTRACT_UI on the EditorInfo.
24+
* Works around a GBoard bug where the composition overlay renders stale text
25+
* on mid-text deletion in Compose TextFields.
26+
*/
27+
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
28+
@Composable
29+
fun NoExtractOutlinedTextField(
30+
value: String,
31+
onValueChange: (String) -> Unit,
32+
modifier: Modifier = Modifier,
33+
enabled: Boolean = true,
34+
readOnly: Boolean = false,
35+
textStyle: TextStyle = LocalTextStyle.current,
36+
label: @Composable (() -> Unit)? = null,
37+
placeholder: @Composable (() -> Unit)? = null,
38+
leadingIcon: @Composable (() -> Unit)? = null,
39+
trailingIcon: @Composable (() -> Unit)? = null,
40+
prefix: @Composable (() -> Unit)? = null,
41+
suffix: @Composable (() -> Unit)? = null,
42+
supportingText: @Composable (() -> Unit)? = null,
43+
isError: Boolean = false,
44+
visualTransformation: VisualTransformation = VisualTransformation.None,
45+
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
46+
keyboardActions: KeyboardActions? = null,
47+
singleLine: Boolean = false,
48+
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
49+
minLines: Int = 1,
50+
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
51+
shape: Shape = OutlinedTextFieldDefaults.shape,
52+
colors: TextFieldColors = OutlinedTextFieldDefaults.colors(),
53+
) {
54+
val focusManager = LocalFocusManager.current
55+
56+
val resolvedOptions = if (keyboardOptions.imeAction == ImeAction.Default && singleLine) {
57+
keyboardOptions.copy(imeAction = ImeAction.Done)
58+
} else {
59+
keyboardOptions
60+
}
61+
62+
val resolvedActions = keyboardActions
63+
?: if (singleLine) KeyboardActions(onDone = { focusManager.clearFocus() })
64+
else KeyboardActions.Default
65+
66+
InterceptPlatformTextInput(
67+
interceptor = { request, nextHandler ->
68+
val modifiedRequest = PlatformTextInputMethodRequest { outAttributes ->
69+
request.createInputConnection(outAttributes).also {
70+
outAttributes.imeOptions = outAttributes.imeOptions or
71+
EditorInfo.IME_FLAG_NO_EXTRACT_UI
72+
}
73+
}
74+
nextHandler.startInputMethod(modifiedRequest)
75+
},
76+
) {
77+
OutlinedTextField(
78+
value = value,
79+
onValueChange = onValueChange,
80+
modifier = modifier,
81+
enabled = enabled,
82+
readOnly = readOnly,
83+
textStyle = textStyle,
84+
label = label,
85+
placeholder = placeholder,
86+
leadingIcon = leadingIcon,
87+
trailingIcon = trailingIcon,
88+
prefix = prefix,
89+
suffix = suffix,
90+
supportingText = supportingText,
91+
isError = isError,
92+
visualTransformation = visualTransformation,
93+
keyboardOptions = resolvedOptions,
94+
keyboardActions = resolvedActions,
95+
singleLine = singleLine,
96+
maxLines = maxLines,
97+
minLines = minLines,
98+
interactionSource = interactionSource,
99+
shape = shape,
100+
colors = colors,
101+
)
102+
}
103+
}

app/src/main/java/app/gamenative/ui/component/dialog/Box64PresetsDialog.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import androidx.compose.material3.Icon
2323
import androidx.compose.material3.IconButton
2424
import androidx.compose.material3.IconButtonDefaults
2525
import androidx.compose.material3.MaterialTheme
26-
import androidx.compose.material3.OutlinedTextField
2726
import androidx.compose.material3.Scaffold
2827
import androidx.compose.material3.Text
2928
import androidx.compose.runtime.Composable
@@ -39,6 +38,7 @@ import androidx.compose.ui.unit.dp
3938
import androidx.compose.ui.window.Dialog
4039
import androidx.compose.ui.window.DialogProperties
4140
import app.gamenative.R
41+
import app.gamenative.ui.component.NoExtractOutlinedTextField
4242
import app.gamenative.ui.component.settings.SettingsEnvVars
4343
import app.gamenative.ui.theme.settingsTileColors
4444
import com.winlator.box86_64.Box86_64Preset
@@ -98,7 +98,7 @@ fun Box64PresetsDialog(
9898
.fillMaxSize()
9999
.padding(paddingValues),
100100
) {
101-
OutlinedTextField(
101+
NoExtractOutlinedTextField(
102102
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
103103
value = presetName,
104104
enabled = isCustom(),
@@ -107,6 +107,7 @@ fun Box64PresetsDialog(
107107
Box86_64PresetManager.editPreset(prefix, context, presetId, presetName, EnvVars(envVars))
108108
},
109109
label = { Text(stringResource(R.string.preset_name)) },
110+
singleLine = true,
110111
trailingIcon = {
111112
IconButton(
112113
colors = IconButtonDefaults.iconButtonColors()

app/src/main/java/app/gamenative/ui/component/dialog/ControllerBindingDialog.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import androidx.compose.ui.text.font.FontWeight
2121
import androidx.compose.ui.unit.dp
2222
import androidx.compose.ui.window.Dialog
2323
import androidx.compose.ui.window.DialogProperties
24+
import app.gamenative.ui.component.NoExtractOutlinedTextField
2425
import com.winlator.inputcontrols.Binding
2526

2627
/**
@@ -195,7 +196,7 @@ fun ControllerBindingDialog(
195196
}
196197
} else {
197198
// Expanded search field
198-
OutlinedTextField(
199+
NoExtractOutlinedTextField(
199200
value = searchQuery,
200201
onValueChange = {
201202
searchQuery = it
@@ -232,7 +233,7 @@ fun ControllerBindingDialog(
232233
.fillMaxWidth()
233234
.height(44.dp), // Even more compact when expanded
234235
singleLine = true,
235-
textStyle = MaterialTheme.typography.bodySmall
236+
textStyle = MaterialTheme.typography.bodySmall,
236237
)
237238
}
238239

app/src/main/java/app/gamenative/ui/component/dialog/EnvironmentTab.kt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import androidx.compose.material3.DropdownMenu
1313
import androidx.compose.material3.DropdownMenuItem
1414
import androidx.compose.material3.Icon
1515
import androidx.compose.material3.IconButton
16-
import androidx.compose.material3.OutlinedTextField
1716
import androidx.compose.material3.Text
1817
import androidx.compose.material3.TextButton
1918
import androidx.compose.runtime.Composable
@@ -25,6 +24,7 @@ import androidx.compose.runtime.setValue
2524
import androidx.compose.ui.Modifier
2625
import androidx.compose.ui.res.stringResource
2726
import app.gamenative.R
27+
import app.gamenative.ui.component.NoExtractOutlinedTextField
2828
import app.gamenative.ui.component.settings.SettingsCenteredLabel
2929
import app.gamenative.ui.component.settings.SettingsEnvVars
3030
import app.gamenative.ui.component.settings.SettingsMultiListDropdown
@@ -93,10 +93,11 @@ fun EnvironmentTabContent(state: ContainerConfigState) {
9393
var knownVarsMenuOpen by rememberSaveable { mutableStateOf(false) }
9494
Column {
9595
Row {
96-
OutlinedTextField(
96+
NoExtractOutlinedTextField(
9797
value = envVarName,
9898
onValueChange = { envVarName = it },
9999
label = { Text(text = stringResource(R.string.name)) },
100+
singleLine = true,
100101
trailingIcon = {
101102
IconButton(
102103
onClick = { knownVarsMenuOpen = true },
@@ -157,10 +158,11 @@ fun EnvironmentTabContent(state: ContainerConfigState) {
157158
} else {
158159
var suggestionsExpanded by remember { mutableStateOf(false) }
159160
val hasSuggestions = selectedEnvVarInfo?.selectionType == EnvVarSelectionType.SUGGESTIONS
160-
OutlinedTextField(
161+
NoExtractOutlinedTextField(
161162
value = envVarValue,
162163
onValueChange = { envVarValue = it },
163164
label = { Text(text = stringResource(R.string.value)) },
165+
singleLine = true,
164166
trailingIcon = if (hasSuggestions) {
165167
{
166168
IconButton(onClick = { suggestionsExpanded = true }) {

app/src/main/java/app/gamenative/ui/component/dialog/FEXCorePresetsDialog.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import androidx.compose.material3.Icon
2424
import androidx.compose.material3.IconButton
2525
import androidx.compose.material3.IconButtonDefaults
2626
import androidx.compose.material3.MaterialTheme
27-
import androidx.compose.material3.OutlinedTextField
2827
import androidx.compose.material3.Scaffold
2928
import androidx.compose.material3.Text
3029
import androidx.compose.runtime.Composable
@@ -41,6 +40,7 @@ import androidx.compose.ui.unit.dp
4140
import androidx.compose.ui.window.Dialog
4241
import androidx.compose.ui.window.DialogProperties
4342
import app.gamenative.R
43+
import app.gamenative.ui.component.NoExtractOutlinedTextField
4444
import app.gamenative.ui.component.settings.SettingsEnvVars
4545
import app.gamenative.ui.theme.settingsTileColors
4646
import com.winlator.core.StringUtils
@@ -112,7 +112,7 @@ fun FEXCorePresetsDialog(
112112
.fillMaxSize()
113113
.padding(paddingValues),
114114
) {
115-
OutlinedTextField(
115+
NoExtractOutlinedTextField(
116116
modifier = Modifier
117117
.fillMaxWidth()
118118
.padding(horizontal = 16.dp),
@@ -123,6 +123,7 @@ fun FEXCorePresetsDialog(
123123
FEXCorePresetManager.editPreset(context, presetId, presetName, EnvVars(envVars))
124124
},
125125
label = { Text(stringResource(R.string.preset_name)) },
126+
singleLine = true,
126127
trailingIcon = {
127128
IconButton(
128129
colors = IconButtonDefaults.iconButtonColors()

app/src/main/java/app/gamenative/ui/component/dialog/GameFeedbackDialog.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import androidx.compose.ui.unit.dp
1818
import androidx.compose.ui.window.Dialog
1919
import androidx.compose.ui.window.DialogProperties
2020
import app.gamenative.R
21+
import app.gamenative.ui.component.NoExtractOutlinedTextField
2122
import app.gamenative.ui.component.dialog.state.GameFeedbackDialogState
2223
import timber.log.Timber
2324

@@ -120,15 +121,15 @@ fun GameFeedbackDialog(
120121
}
121122

122123
// Feedback text box
123-
OutlinedTextField(
124+
NoExtractOutlinedTextField(
124125
value = state.feedbackText,
125126
onValueChange = { onStateChange(state.copy(feedbackText = it)) },
126127
label = { Text(stringResource(R.string.describe_what_happened)) },
127128
modifier = Modifier
128129
.fillMaxWidth()
129130
.heightIn(min = 100.dp)
130131
.padding(bottom = 16.dp),
131-
maxLines = 5
132+
maxLines = 5,
132133
)
133134

134135
// Discord support link

app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,30 @@ import androidx.compose.foundation.layout.Spacer
66
import androidx.compose.foundation.layout.fillMaxWidth
77
import androidx.compose.foundation.layout.padding
88
import androidx.compose.foundation.layout.width
9+
import androidx.compose.foundation.text.KeyboardActions
910
import androidx.compose.foundation.text.KeyboardOptions
1011
import androidx.compose.material3.AlertDialog
11-
import androidx.compose.material3.OutlinedTextField
1212
import androidx.compose.material3.Text
1313
import androidx.compose.material3.TextButton
1414
import androidx.compose.runtime.Composable
15+
import androidx.compose.runtime.remember
1516
import androidx.compose.runtime.getValue
1617
import androidx.compose.runtime.mutableIntStateOf
1718
import androidx.compose.runtime.mutableStateOf
1819
import androidx.compose.runtime.saveable.rememberSaveable
1920
import androidx.compose.runtime.setValue
2021
import androidx.compose.ui.Alignment
2122
import androidx.compose.ui.Modifier
23+
import androidx.compose.ui.focus.FocusRequester
24+
import androidx.compose.ui.focus.focusRequester
2225
import androidx.compose.ui.res.stringResource
2326
import androidx.compose.ui.text.TextStyle
27+
import androidx.compose.ui.text.input.ImeAction
2428
import androidx.compose.ui.text.input.KeyboardType
2529
import androidx.compose.ui.unit.dp
2630
import androidx.compose.ui.unit.sp
2731
import app.gamenative.R
32+
import app.gamenative.ui.component.NoExtractOutlinedTextField
2833
import app.gamenative.ui.component.settings.SettingsListDropdown
2934
import com.alorma.compose.settings.ui.SettingsSwitch
3035
import app.gamenative.ui.theme.settingsTileColors
@@ -64,14 +69,17 @@ fun GeneralTabContent(
6469
onDismissRequest = { state.showCustomResolutionDialog.value = false },
6570
title = { Text(text = stringResource(R.string.container_config_custom_resolution_title)) },
6671
text = {
72+
val heightFocusRequester = remember { FocusRequester() }
6773
Column {
6874
Row {
69-
OutlinedTextField(
75+
NoExtractOutlinedTextField(
7076
modifier = Modifier.width(128.dp),
7177
value = state.customScreenWidth.value,
7278
onValueChange = { state.customScreenWidth.value = it },
73-
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
79+
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Next),
80+
keyboardActions = KeyboardActions(onNext = { heightFocusRequester.requestFocus() }),
7481
label = { Text(text = stringResource(R.string.width)) },
82+
singleLine = true,
7583
)
7684
Spacer(modifier = Modifier.width(8.dp))
7785
Text(
@@ -80,12 +88,13 @@ fun GeneralTabContent(
8088
style = TextStyle(fontSize = 16.sp),
8189
)
8290
Spacer(modifier = Modifier.width(8.dp))
83-
OutlinedTextField(
84-
modifier = Modifier.width(128.dp),
91+
NoExtractOutlinedTextField(
92+
modifier = Modifier.width(128.dp).focusRequester(heightFocusRequester),
8593
value = state.customScreenHeight.value,
8694
onValueChange = { state.customScreenHeight.value = it },
8795
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
8896
label = { Text(text = stringResource(R.string.height)) },
97+
singleLine = true,
8998
)
9099
}
91100
if (state.customResolutionValidationError.value != null) {
@@ -253,12 +262,13 @@ fun GeneralTabContent(
253262
onValueChange = { state.config.value = config.copy(executablePath = it) },
254263
containerData = config,
255264
)
256-
OutlinedTextField(
265+
NoExtractOutlinedTextField(
257266
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
258267
value = config.execArgs,
259268
onValueChange = { state.config.value = config.copy(execArgs = it) },
260269
label = { Text(text = stringResource(R.string.exec_arguments)) },
261270
placeholder = { Text(text = stringResource(R.string.exec_arguments_example)) },
271+
singleLine = true,
262272
)
263273
val displayNameForLanguage: (String) -> String = { code ->
264274
when (code) {

app/src/main/java/app/gamenative/ui/component/dialog/TouchGestureSettingsDialog.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package app.gamenative.ui.component.dialog
33
import androidx.compose.foundation.layout.*
44
import androidx.compose.foundation.rememberScrollState
55
import androidx.compose.foundation.text.KeyboardOptions
6+
import app.gamenative.ui.component.NoExtractOutlinedTextField
67
import androidx.compose.foundation.verticalScroll
78
import androidx.compose.material.icons.Icons
89
import androidx.compose.material.icons.filled.Close
@@ -231,7 +232,7 @@ private fun DelayTextField(
231232
) {
232233
var text by remember(value) { mutableStateOf(value.toString()) }
233234

234-
OutlinedTextField(
235+
NoExtractOutlinedTextField(
235236
value = text,
236237
onValueChange = { newText ->
237238
// Allow only digits

app/src/main/java/app/gamenative/ui/component/settings/SettingsTextField.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@ package app.gamenative.ui.component.settings
33
import androidx.compose.foundation.layout.Row
44
import androidx.compose.foundation.layout.width
55
import androidx.compose.material3.ListItemDefaults
6-
import androidx.compose.material3.OutlinedTextField
76
import androidx.compose.runtime.Composable
87
import androidx.compose.runtime.remember
98
import androidx.compose.ui.Modifier
109
import androidx.compose.ui.focus.FocusRequester
1110
import androidx.compose.ui.focus.focusRequester
1211
import androidx.compose.ui.unit.Dp
1312
import androidx.compose.ui.unit.dp
13+
import app.gamenative.ui.component.NoExtractOutlinedTextField
1414
import com.alorma.compose.settings.ui.SettingsMenuLink
1515
import com.alorma.compose.settings.ui.base.internal.LocalSettingsGroupEnabled
1616
import com.alorma.compose.settings.ui.base.internal.SettingsTileColors
@@ -39,13 +39,14 @@ fun SettingsTextField(
3939
subtitle = subtitle,
4040
action = {
4141
Row {
42-
OutlinedTextField(
42+
NoExtractOutlinedTextField(
4343
modifier = Modifier
4444
.focusRequester(focusRequester)
4545
.width(76.dp),
4646
enabled = enabled,
4747
value = value,
4848
onValueChange = onValueChange,
49+
singleLine = true,
4950
)
5051
action?.invoke()
5152
}

0 commit comments

Comments
 (0)