fix(ui): keep scroll position when focusing scrolled editors (#1116)
This commit is contained in:
8 files changed
+155
-50
No files matched your search
+25
-7
@@ -16,6 +16,9 @@ import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.AlertDialog
|
||||
@@ -29,6 +32,7 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -58,9 +62,18 @@ fun GroupChatSettingsDialog(
|
||||
) {
|
||||
var maxMessages by remember(currentMaxMessages) { mutableStateOf(currentMaxMessages.toFloat()) }
|
||||
var maxPasses by remember(currentMaxPasses) { mutableStateOf(currentMaxPasses.toFloat()) }
|
||||
var systemPrompt by remember(currentSystemPrompt) { mutableStateOf(currentSystemPrompt.orEmpty()) }
|
||||
val systemPromptTextFieldState = rememberTextFieldState(currentSystemPrompt.orEmpty())
|
||||
var showHighLimitsConfirmation by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(currentSystemPrompt) {
|
||||
val loaded = currentSystemPrompt.orEmpty()
|
||||
if (systemPromptTextFieldState.text.toString() != loaded) {
|
||||
systemPromptTextFieldState.edit {
|
||||
replace(0, length, loaded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val isHighLimit =
|
||||
maxMessages.roundToInt() > WARN_MAX_BOT_MESSAGES ||
|
||||
maxPasses.roundToInt() > WARN_MAX_CONTINUATION_PASSES
|
||||
@@ -217,8 +230,7 @@ fun GroupChatSettingsDialog(
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
OutlinedTextField(
|
||||
value = systemPrompt,
|
||||
onValueChange = { systemPrompt = it },
|
||||
state = systemPromptTextFieldState,
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.group_chat_settings_system_prompt_hint),
|
||||
@@ -231,7 +243,7 @@ fun GroupChatSettingsDialog(
|
||||
.heightIn(min = 80.dp, max = 160.dp)
|
||||
.testTag("room_system_prompt_input"),
|
||||
textStyle = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 4,
|
||||
lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 4),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -245,7 +257,10 @@ fun GroupChatSettingsDialog(
|
||||
onSave(
|
||||
maxMessages.roundToInt(),
|
||||
maxPasses.roundToInt(),
|
||||
systemPrompt.trim().ifBlank { null },
|
||||
systemPromptTextFieldState.text
|
||||
.toString()
|
||||
.trim()
|
||||
.ifBlank { null },
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -260,7 +275,7 @@ fun GroupChatSettingsDialog(
|
||||
onClick = {
|
||||
maxMessages = DEFAULT_MAX_BOT_MESSAGES.toFloat()
|
||||
maxPasses = DEFAULT_MAX_CONTINUATION_PASSES.toFloat()
|
||||
systemPrompt = ""
|
||||
systemPromptTextFieldState.clearText()
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.group_chat_settings_reset_default))
|
||||
@@ -307,7 +322,10 @@ fun GroupChatSettingsDialog(
|
||||
onSave(
|
||||
maxMessages.roundToInt(),
|
||||
maxPasses.roundToInt(),
|
||||
systemPrompt.trim().ifBlank { null },
|
||||
systemPromptTextFieldState.text
|
||||
.toString()
|
||||
.trim()
|
||||
.ifBlank { null },
|
||||
)
|
||||
},
|
||||
colors =
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.m57.hermescontrol.ui.common
|
||||
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
|
||||
/**
|
||||
* Two-way bridge between an external [String] holder (ViewModel state, a parent
|
||||
* callback, …) and a [TextFieldState]-backed text field.
|
||||
*
|
||||
* Why not `OutlinedTextField(value:, onValueChange:)`? The value-based overload
|
||||
* keeps its selection inside the legacy `BasicTextField` implementation, so when
|
||||
* a scrolled field gains focus it scrolls the cursor into view while the cursor
|
||||
* is still at index 0 — the viewport jumps back to the top instead of staying
|
||||
* where the user tapped. `TextFieldState`-backed fields only follow the cursor
|
||||
* while the user is actively editing, so the scroll position survives the tap.
|
||||
*
|
||||
* Inbound (external → field) updates are applied only when the text actually
|
||||
* differs, which keeps ViewModel round-trips from clobbering in-progress typing;
|
||||
* outbound (field → external) updates skip the identity case so the two
|
||||
* directions can never ping-pong.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberSyncedTextFieldState(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
): TextFieldState {
|
||||
val state = rememberTextFieldState(value)
|
||||
val currentValue by rememberUpdatedState(value)
|
||||
val currentOnValueChange by rememberUpdatedState(onValueChange)
|
||||
|
||||
LaunchedEffect(state, value) {
|
||||
if (state.text.toString() != value) {
|
||||
state.edit { replace(0, length, value) }
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state) {
|
||||
snapshotFlow { state.text.toString() }
|
||||
.collect { newText ->
|
||||
if (newText != currentValue) currentOnValueChange(newText)
|
||||
}
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
@@ -51,6 +53,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
@@ -71,6 +74,7 @@ import com.m57.hermescontrol.ui.common.NavIcon
|
||||
import com.m57.hermescontrol.ui.common.SearchBar
|
||||
import com.m57.hermescontrol.ui.common.SkeletonListState
|
||||
import com.m57.hermescontrol.ui.common.ToastEffect
|
||||
import com.m57.hermescontrol.ui.common.rememberSyncedTextFieldState
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
@@ -290,6 +294,8 @@ private fun YAMLEditor(
|
||||
return
|
||||
}
|
||||
|
||||
val textFieldState = rememberSyncedTextFieldState(yamlText, onYamlTextChange)
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -297,14 +303,12 @@ private fun YAMLEditor(
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = yamlText,
|
||||
onValueChange = onYamlTextChange,
|
||||
state = textFieldState,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||
maxLines = Int.MAX_VALUE,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
@@ -936,34 +940,41 @@ private fun JsonField(
|
||||
label: String,
|
||||
onChange: (JsonElement) -> Unit,
|
||||
) {
|
||||
var text by remember(key) { mutableStateOf(value?.let(::jsonText) ?: "") }
|
||||
val textFieldState = remember(key) { TextFieldState(value?.let(::jsonText) ?: "") }
|
||||
var hasFocus by remember { mutableStateOf(false) }
|
||||
var invalid by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(value) {
|
||||
if (!hasFocus) text = value?.let(::jsonText) ?: ""
|
||||
LaunchedEffect(value, hasFocus) {
|
||||
if (!hasFocus) {
|
||||
val canonical = value?.let(::jsonText) ?: ""
|
||||
if (textFieldState.text.toString() != canonical) {
|
||||
textFieldState.edit {
|
||||
replace(0, length, canonical)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(textFieldState) {
|
||||
snapshotFlow { textFieldState.text.toString() }
|
||||
.collect { newText ->
|
||||
val parsed = runCatching { Json.parseToJsonElement(newText) }.getOrNull()
|
||||
invalid = newText.isNotBlank() && parsed == null
|
||||
if (parsed != null && newText.isNotBlank() && parsed != value) {
|
||||
onChange(parsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { newText ->
|
||||
text = newText
|
||||
val parsed = runCatching { Json.parseToJsonElement(newText) }.getOrNull()
|
||||
invalid = newText.isNotBlank() && parsed == null
|
||||
if (parsed != null && newText.isNotBlank()) {
|
||||
onChange(parsed)
|
||||
}
|
||||
},
|
||||
state = textFieldState,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 96.dp)
|
||||
.onFocusChanged {
|
||||
hasFocus = it.isFocused
|
||||
if (!it.isFocused) {
|
||||
invalid = false
|
||||
text = value?.let(::jsonText) ?: ""
|
||||
}
|
||||
if (!it.isFocused) invalid = false
|
||||
},
|
||||
isError = invalid,
|
||||
supportingText =
|
||||
@@ -974,6 +985,6 @@ private fun JsonField(
|
||||
},
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||
label = { Text(label) },
|
||||
maxLines = Int.MAX_VALUE,
|
||||
lineLimits = TextFieldLineLimits.MultiLine(minHeightInLines = 4),
|
||||
)
|
||||
}
|
||||
@@ -72,6 +72,7 @@ import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||
import com.m57.hermescontrol.ui.common.ToastEffect
|
||||
import com.m57.hermescontrol.ui.common.listContentPadding
|
||||
import com.m57.hermescontrol.ui.common.listItemSpacing
|
||||
import com.m57.hermescontrol.ui.common.rememberSyncedTextFieldState
|
||||
import com.m57.hermescontrol.util.CronExpressionFormatter
|
||||
|
||||
@Composable
|
||||
@@ -377,6 +378,11 @@ fun CronJobEditorDialog(
|
||||
state.monitor_script.isNotEmpty() || state.monitor_url.isNotEmpty() ||
|
||||
state.monitorMode != "off"
|
||||
|
||||
val promptTextFieldState =
|
||||
rememberSyncedTextFieldState(state.prompt) { onFieldChange("prompt", it) }
|
||||
val skillsTextFieldState =
|
||||
rememberSyncedTextFieldState(state.skills) { onFieldChange("skills", it) }
|
||||
|
||||
ToastEffect(toastMessage = state.toastMessage, onClearToast = onClearToast)
|
||||
|
||||
Dialog(
|
||||
@@ -506,8 +512,7 @@ fun CronJobEditorDialog(
|
||||
|
||||
// Prompt
|
||||
OutlinedTextField(
|
||||
value = state.prompt,
|
||||
onValueChange = { onFieldChange("prompt", it) },
|
||||
state = promptTextFieldState,
|
||||
label = { Text(stringResource(R.string.cron_edit_field_prompt)) },
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -531,8 +536,7 @@ fun CronJobEditorDialog(
|
||||
|
||||
// Skills
|
||||
OutlinedTextField(
|
||||
value = state.skills,
|
||||
onValueChange = { onFieldChange("skills", it) },
|
||||
state = skillsTextFieldState,
|
||||
label = { Text(stringResource(R.string.cron_edit_field_skills)) },
|
||||
placeholder = { Text(stringResource(R.string.cron_edit_hint_skills)) },
|
||||
modifier =
|
||||
|
||||
+5
-4
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
@@ -49,6 +50,7 @@ import com.m57.hermescontrol.data.model.KanbanColumn
|
||||
import com.m57.hermescontrol.data.model.KanbanProfile
|
||||
import com.m57.hermescontrol.data.model.KanbanTask
|
||||
import com.m57.hermescontrol.data.model.TaskEstimate
|
||||
import com.m57.hermescontrol.ui.common.rememberSyncedTextFieldState
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val PARKED_VALUE = "__parked__"
|
||||
@@ -68,6 +70,7 @@ fun KanbanCreateTaskDialog(
|
||||
) {
|
||||
var title by remember { mutableStateOf("") }
|
||||
var desc by remember { mutableStateOf("") }
|
||||
val descTextFieldState = rememberSyncedTextFieldState(desc) { desc = it }
|
||||
var selectedColumn by remember(defaultColumn) { mutableStateOf(defaultColumn) }
|
||||
var selectedAssignee by remember { mutableStateOf<String?>(null) }
|
||||
var priority by remember { mutableIntStateOf(0) }
|
||||
@@ -114,12 +117,10 @@ fun KanbanCreateTaskDialog(
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = desc,
|
||||
onValueChange = { desc = it },
|
||||
state = descTextFieldState,
|
||||
label = { Text(stringResource(R.string.kanban_task_desc)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 3,
|
||||
maxLines = 5,
|
||||
lineLimits = TextFieldLineLimits.MultiLine(minHeightInLines = 3, maxHeightInLines = 5),
|
||||
enabled = !isCreating,
|
||||
)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.UploadFile
|
||||
@@ -37,6 +38,7 @@ import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.McpServer
|
||||
import com.m57.hermescontrol.theme.Spacing
|
||||
import com.m57.hermescontrol.ui.common.DetailDialog
|
||||
import com.m57.hermescontrol.ui.common.rememberSyncedTextFieldState
|
||||
import com.m57.hermescontrol.ui.common.toDetailRows
|
||||
import com.m57.hermescontrol.ui.mcp.McpServersUiState
|
||||
import com.m57.hermescontrol.ui.mcp.McpServersViewModel
|
||||
@@ -112,6 +114,9 @@ fun McpDialogs(
|
||||
}
|
||||
|
||||
if (state.showImportDialog) {
|
||||
val importTextFieldState =
|
||||
rememberSyncedTextFieldState(state.importJsonInput, viewModel::updateImportJsonInput)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { viewModel.toggleImportDialog() },
|
||||
title = {
|
||||
@@ -157,8 +162,7 @@ fun McpDialogs(
|
||||
}
|
||||
Spacer(modifier = Modifier.height(spacing.xs))
|
||||
OutlinedTextField(
|
||||
value = state.importJsonInput,
|
||||
onValueChange = viewModel::updateImportJsonInput,
|
||||
state = importTextFieldState,
|
||||
placeholder = {
|
||||
Text(
|
||||
"{\n \"mcpServers\": {\n \"name\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@mcp/srv\"]\n }\n }\n}",
|
||||
@@ -170,7 +174,7 @@ fun McpDialogs(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(180.dp),
|
||||
maxLines = 10,
|
||||
lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 10),
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -16,6 +16,8 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
@@ -586,7 +588,15 @@ fun ProfilesScreen(
|
||||
|
||||
if (soulEditProfileName != null) {
|
||||
val initialText = state.selectedSoulContent ?: ""
|
||||
var soulText by remember(initialText) { mutableStateOf(initialText) }
|
||||
val soulTextFieldState = rememberTextFieldState(initialText)
|
||||
|
||||
LaunchedEffect(initialText) {
|
||||
if (soulTextFieldState.text.toString() != initialText) {
|
||||
soulTextFieldState.edit {
|
||||
replace(0, length, initialText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
@@ -615,10 +625,9 @@ fun ProfilesScreen(
|
||||
}
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
value = soulText,
|
||||
onValueChange = { soulText = it },
|
||||
state = soulTextFieldState,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 10,
|
||||
lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 10),
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -627,7 +636,7 @@ fun ProfilesScreen(
|
||||
onClick = {
|
||||
val profileName = soulEditProfileName
|
||||
if (profileName != null) {
|
||||
viewModel.saveSoul(profileName, soulText)
|
||||
viewModel.saveSoul(profileName, soulTextFieldState.text.toString())
|
||||
}
|
||||
soulEditProfileName = null
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.skills.components
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.AlertDialog
|
||||
@@ -44,13 +45,21 @@ internal fun SkillEditorDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onClearSaveSuccess: () -> Unit,
|
||||
) {
|
||||
var contentText by remember(initialContent) { mutableStateOf(initialContent.orEmpty()) }
|
||||
val textFieldState = rememberTextFieldState(initialContent.orEmpty())
|
||||
var showDiscardConfirm by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(initialContent) {
|
||||
val newContent = initialContent.orEmpty()
|
||||
if (textFieldState.text.toString() != newContent) {
|
||||
textFieldState.edit {
|
||||
replace(0, length, newContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val hasChanges =
|
||||
remember(initialContent, contentText) {
|
||||
val original = initialContent.orEmpty()
|
||||
original != contentText
|
||||
remember(initialContent, textFieldState.text) {
|
||||
initialContent.orEmpty() != textFieldState.text.toString()
|
||||
}
|
||||
|
||||
LaunchedEffect(saveSuccess) {
|
||||
@@ -95,7 +104,7 @@ internal fun SkillEditorDialog(
|
||||
actions = {
|
||||
if (!isLoading) {
|
||||
IconButton(
|
||||
onClick = { onSave(contentText) },
|
||||
onClick = { onSave(textFieldState.text.toString()) },
|
||||
enabled = !isSaving,
|
||||
) {
|
||||
if (isSaving) {
|
||||
@@ -126,8 +135,7 @@ internal fun SkillEditorDialog(
|
||||
|
||||
else -> {
|
||||
OutlinedTextField(
|
||||
value = contentText,
|
||||
onValueChange = { contentText = it },
|
||||
state = textFieldState,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
|
||||
Reference in new issue
Block a user