I'm trying to use an unmodifiable TextField to dis...
# compose-desktop
m
I'm trying to use an unmodifiable TextField to display the contents of a ByteArray, but it seems compose takes about half a minute to calculate the layouting of the text, instead of just having it be the width of the TextField and scrolling all the way down. How can I make this faster?
And if I resize the window, it stops being monospace
Code:
Copy code
TextField(
                        value!!,
                        {},
                        readOnly = true,  // not the case for all TextFields!
                        singleLine = false,
                        modifier = Modifier.fillMaxSize().padding(5.dp),
                        textStyle = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace),
                    )
e
r
Monospacing comes from the font, the problem you are mentioning is an alignement issue. You basically need to snap the width of your text component to a multiple of 4 characters (
\xXX
)
And for performance reason what @ephemient said. A lazy column of
Text()
will be much better for this purpose
m
Monospacing comes from the font, the problem you are mentioning is an alignement issue. You basically need to snap the width of your text component to a multiple of 4 characters
No, look at the bottom right, the 0 is not aligned under the x but half a character off to the side.
And what should I use for the cases where the text needs to be editable?
I've tried switching to the LazyColumn with Text now, but it seems to take a significantly longer amount of time to load. To reproduce, it should suffice to create an approx 4MB file with no newlines
e
you should do the line breaking yourself in that case
m
is there a good way to do this dynamically based on the size of the element and the font size/app scale?
r
@martmists how do you populate the lazy column?
m
LazyColumn { item { Text(textVariableHere) } }
e
TextMeasurer - if it's monospace the math should be easy
editing very large text is a challenging problem and Compose doesn't solve it for you :'(
r
@martmists what's
textVariable
though? How you read the file and split it?
and yeah if your goal is to build an efficient hex editor (effectively the same problem), you'll need to build a custom component or look for one
m
Essentially in a LaunchedEffect I read the file and do this:
Copy code
textVariable = fileByteArray.map { if (it >= 0x20) it.toInt().toChar() else "\\x${it.toHexString(HexFormat.UpperCase)}" }.joinToString("")
r
That's quite an expensive way of turning the byte array into your final String, but that doesn't show how you make each item select a sub-section of that string
If you are passing the whole string to each item in the lazy column, it will indeed be much slower
m
what do you mean? There's only a single item
r
Ah ok I misunderstood what you were doing. What I was suggesting earlier, and sorry I should have been much clearer, is to create one
Text()
item per row you want to display
☝️ 2
The lazy column is used to create a sliding window over the original content, so you never layout more than what you need
e
yep, and that's exactly what the sample I linked above does
r
You would thus make each
Text()
item display a sub-range for your original byte array
It would also be more efficient to preallocate a StringBuilder since you know what size the final String will have
something like
StringBuilder(sizeOfRange * 4)
(
sizeOfRange
is a number of bytes to display)
and then you append your characters; this will save a TON of allocations
m
Trying it right now, but measurer.measure still takes about 20-25 seconds (although I guess the app is responsive now), and on the larger cases several minutes.
r
yeah but you don't want to measure the whole string
the goal is to make one small string per line and measure just that
m
but I need to measure the text to know how much fits on one line
r
not with a monospace font
e
it's monospace. measure a sample character and extrapolate
m
and iirc getLineStart only works with the entire text
And for implementing it as editable, is there any good examples for that?
m
Here's my current implementation, is there anything major I missed?
Copy code
// Assumes mono text style
@Composable
fun LargeTextField(
    text: String,
    onChange: (String) -> Unit,
    beforeLoadingComplete: @Composable () -> Unit,
    readonly: Boolean = false,
    style: TextStyle = LocalTextStyle.current,
    modifier: Modifier = Modifier,
    isError: Boolean = false,
    supportingText: (@Composable () -> Unit)? = null
) {
    val measurer = rememberTextMeasurer()
    val scope = rememberCoroutineScope()
    val scroll = rememberScrollState()
    val state = rememberLazyListState()

    var displayText by remember(text) { mutableStateOf(text) }
    var displayLines by remember { mutableStateOf(emptyArray<String>()) }  // FIXME: This needs to be reset when the text parameter changes, but not when it changes from onChange() changing the text
    val textLength by derivedStateOf { displayText.length }

    val mod = if (readonly) {
        modifier.padding(end = 5.dp).border(1.dp, Color.Gray, RoundedCornerShape(4.dp)).padding(16.dp)
    } else modifier.padding(end = 5.dp)

    Box {
        BoxWithConstraints(mod, contentAlignment = Alignment.TopStart) {
            LaunchedEffect(textLength, constraints) {
                scope.launch(<http://Dispatchers.IO|Dispatchers.IO>) {
                    val s = text.substring(0 until 10_000.coerceAtMost(text.length)).replace("\n", "")
                    val res = measurer.measure(s, style, constraints = constraints)
                    var width = (0 until res.lineCount).maxOf { res.getLineEnd(it, true) - res.getLineStart(it) }
                    if (!readonly) width -= 4
                    width = width.coerceAtLeast(1)
                    var lines = text.length / width
                    if (text.length % width != 0) {
                        lines++
                    }

                    displayLines = Array(lines) {
                        val start = it * width
                        val end = ((it + 1) * width).coerceAtMost(text.length)
                        text.substring(start, end)
                    }
                }
            }

            if (displayLines.isEmpty()) {
                beforeLoadingComplete()
            } else if (readonly) {
                SelectionContainer {
                    LazyColumn(modifier = Modifier.fillMaxSize(), state = state, horizontalAlignment = Alignment.Start, verticalArrangement = <http://Arrangement.Top|Arrangement.Top>) {
                        items(displayLines) {
                            Text(it, style = style)
                        }
                    }
                }
            } else {
                // FIXME: Implement this more efficiently
                // I considered using displayLines.joinToString('\n') so the text measuring inside OutlinedTextField would be faster,
                // but then turning it back into displayText is something I don't know how to do.
                // Especially since the text itself might contain newlines.
                OutlinedTextField(
                    displayText,
                    {
                        displayText = it
                        onChange(it)
                    },
                    textStyle = style,
                    isError = isError,
                    supportingText = supportingText,
                    modifier = Modifier.verticalScroll(scroll)
                )
            }
        }

        if (displayLines.isNotEmpty()) {
            val adapter = if (readonly) {
                rememberScrollbarAdapter(state)
            } else {
                rememberScrollbarAdapter(scroll)
            }
            VerticalScrollbar(adapter, modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 2.dp).fillMaxHeight(), style = LocalScrollbarStyle.current.copy(unhoverColor = MaterialTheme.colorScheme.primaryContainer, hoverColor = MaterialTheme.colorScheme.primary))
        }
    }
}
also @ephemient I meant moreso a Compose element that allows showing text with editing with no fancy markup or selection capabilities. Kinda like a Text element that allowed clicking and typing like a TextField does.