I'm developing an desktop app in Compose. Basically I have a text editor where I can open different text files. The issue is that TextField keeps it's undo/redo state internally (as far as I know). So when I open file A, then file B, and press Ctrl+Z, file B contents change to file A. I'd like to clear undo/redo state when I open new file, or even better, keep that state per file path (but that's extension of my requirements).
My current approach to this (Disabling Ctrl + Z/Ctrl + Y)
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun TextEditorArea(
textFile: File,
fontSize: TextUnit,
fileContentsCache: MutableMap<File, String>,
modifier: Modifier = Modifier
) {
var textContent by remember { mutableStateOf(TextFieldValue("")) }
var searchQuery by remember { mutableStateOf(TextFieldValue("")) }
val coroutineScope = rememberCoroutineScope()
val searchFocusRequester = remember { FocusRequester() }
var activeFile by remember { mutableStateOf<File?>(null) }
// Initialize and update active file and text content
LaunchedEffect(key1 = textFile) {
activeFile = textFile
val loadedText = fileContentsCache[textFile] ?: textFile.readText()
textContent = TextFieldValue(AnnotatedString(loadedText))
}
// Handle text changes with debounced saving
DisposableEffect(key1 = textContent.text, key2 = activeFile) {
if (activeFile == null) return@DisposableEffect onDispose { }
val job = coroutineScope.launch {
delay(250) // Debounce time for saving
if (activeFile != null && textContent.text != fileContentsCache[activeFile]) {
activeFile!!.writeText(textContent.text)
fileContentsCache[activeFile!!] = textContent.text
}
}
onDispose {
job.cancel()
// Immediate save when disposing (e.g., switching files or closing)
coroutineScope.launch {
if (activeFile != null && textContent.text != fileContentsCache[activeFile]) {
activeFile!!.writeText(textContent.text)
fileContentsCache[activeFile!!] = textContent.text
}
}
}
}
// Update highlights based on search query
val annotatedText by derivedStateOf {
buildAnnotatedString {
append(textContent.text)
findSearchHits(textContent.text, searchQuery.text).forEach { range ->
addStyle(
style = SpanStyle(background = Color.Yellow),
start = range.start,
end = range.end
)
}
}
}
Column(modifier = modifier) {
TextField(
value = searchQuery,
onValueChange = { query ->
searchQuery = query
},
label = { Text("Search") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
.focusRequester(searchFocusRequester),
)
BasicTextField(
value = TextFieldValue(annotatedText, textContent.selection, textContent.composition),
onValueChange = { newValue ->
// Update textContent while preserving user's selection and composition
textContent = newValue
},
textStyle = TextStyle.Default.copy(
fontSize = fontSize,
fontFamily = FontFamily.Monospace
),
modifier = Modifier
.fillMaxSize()
.padding(4.dp)
.verticalScroll(rememberScrollState())
.onPreviewKeyEvent { keyEvent ->
if (keyEvent.key == Key.F && keyEvent.isCtrlPressed && keyEvent.type == KeyEventType.KeyDown) {
searchFocusRequester.requestFocus()
searchQuery = TextFieldValue(
searchQuery.text,
selection = TextRange(0, searchQuery.text.length)
)
true // Event consumed
} else if (keyEvent.type == KeyEventType.KeyDown &&
(keyEvent.isCtrlPressed && (keyEvent.key == Key.Z || keyEvent.key == Key.Y))
) {
true // Disable Ctrl + Z / Ctrl + Y
} else false
},
decorationBox = { innerTextField ->
Box(modifier = Modifier.padding(4.dp)) { innerTextField() }
}
)
}
}
Any ideas?
One alternative could be using one list for
TextFieldValueorStringand another one for redo.Result
Implementation
TextField that updates or gets values from it.
Usage