Close a card by pressing its words, and let a selection be put away
A markdown paragraph took every tap that landed on its glyphs, so an opened peer message or memory note could be shut anywhere except on the text -- which is most of it, and reads as a card that has stopped working. Measured on the emulator: with a handler on the text the tap did nothing at all, and with the handler removed the same tap shut the card. The words now do the shutting, through a composition local, since the renderer composes those paragraphs out of its own component table and there is nothing between the card and them to pass a parameter through. The link handler is bounded by the long-press timeout, so holding to select is not a tap. The other half is the tap that puts a selection away, which used to shut whatever card the words were in. The container clears the selection from that same press, milliseconds before the card reads it, so the answer is taken at composition instead -- what was true when the reader touched the screen. Selection colours are the app's own. Material's 40% of primary is a tint of whatever is behind it, and over the near-black a code block sits on it composited to a smudge, so selecting a line of code looked like nothing had happened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
359649bc73
commit
acdf00ab1d
10 files changed
+336
-69
No files matched your search
@@ -0,0 +1,87 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* What survives a terminal's escape sequences, and what the styling reads as.
|
||||
*
|
||||
* Asserted as the plain text and as the style over a named substring, rather than as span offsets,
|
||||
* so a failure prints the output that was got wrong instead of a pair of numbers.
|
||||
*/
|
||||
class AnsiTest {
|
||||
private val palette =
|
||||
AnsiPalette(
|
||||
colours = (0..15).map { Color(it, 0, 0) },
|
||||
foreground = Color(1f, 1f, 1f),
|
||||
background = Color(0f, 0f, 0f),
|
||||
)
|
||||
|
||||
private fun styled(text: String) = ansiStyled(text, palette)
|
||||
|
||||
/** The style covering the first character of [word], or null where nothing styles it. */
|
||||
private fun styleOver(text: String, word: String) =
|
||||
styled(text).let { annotated ->
|
||||
val at = annotated.text.indexOf(word)
|
||||
assertTrue(at >= 0, "no \"$word\" in ${annotated.text}")
|
||||
annotated.spanStyles.firstOrNull { at >= it.start && at < it.end }?.item
|
||||
}
|
||||
|
||||
private val esc = '\u001B'
|
||||
|
||||
@Test
|
||||
fun `a colour becomes a span and the sequence itself disappears`() {
|
||||
val text = "plain ${esc}[31mred${esc}[0m plain"
|
||||
assertEquals("plain red plain", styled(text).text)
|
||||
assertEquals(palette.colours[1], styleOver(text, "red")?.color)
|
||||
assertNull(styleOver(text, "plain"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bright, background and 256-colour forms all reach the same table`() {
|
||||
assertEquals(palette.colours[9], styleOver("${esc}[91mx", "x")?.color)
|
||||
assertEquals(palette.colours[4], styleOver("${esc}[44mx", "x")?.background)
|
||||
// The first sixteen of the 256-colour table are the palette's own, so a program that
|
||||
// spells a colour either way gets the same one.
|
||||
assertEquals(palette.colours[1], styleOver("${esc}[38;5;1mx", "x")?.color)
|
||||
// And past them, xterm's cube: 16 is its black corner, 231 its white one.
|
||||
assertEquals(Color(0, 0, 0), styleOver("${esc}[38;5;16mx", "x")?.color)
|
||||
assertEquals(Color(255, 255, 255), styleOver("${esc}[38;5;231mx", "x")?.color)
|
||||
assertEquals(Color(10, 20, 30), styleOver("${esc}[38;2;10;20;30mx", "x")?.color)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `everything that is not styling is dropped rather than printed`() {
|
||||
// A cursor move, an erase, an OSC window title with its bell, and a bare two-character
|
||||
// escape. None of them mean anything in a scrolling document, and all of them would be
|
||||
// line noise if the escape alone were stripped and the body left behind.
|
||||
val text = "a${esc}[2Jb${esc}[Kc${esc}]0;a titled${esc}=e"
|
||||
assertEquals("abcde", styled(text).text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a carriage return rewrites its line, as it does on a terminal`() {
|
||||
// What a progress bar looks like: every state it passed through, ending on the last.
|
||||
assertEquals("done\n", styled("10%\r50%\rdone\n").text)
|
||||
// The line before it is untouched. A Windows line ending rewrites nothing and is not
|
||||
// kept either: it is one line break, and passing the carriage return through would draw
|
||||
// a stray control character in the middle of the output.
|
||||
assertEquals("kept\nlast", styled("kept\r\nfirst\rlast").text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a sequence cut off mid-stream takes no text with it`() {
|
||||
// Output still arriving ends anywhere, including inside an escape. The fragment goes and
|
||||
// the whole sequence arrives with the next delta.
|
||||
assertEquals("text ", styled("text ${esc}[3").text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unstyled text costs no spans at all`() {
|
||||
assertEquals(0, styled("nothing to do here").spanStyles.size)
|
||||
assertEquals(0, styled("a${esc}[2Jb").spanStyles.size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import java.time.Duration
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* The two ways a span of time is written here, and the rule each of them follows.
|
||||
*
|
||||
* Both are read off a screen to make a decision -- how long a tool call may take, how long a quota
|
||||
* has left -- so what matters is that the shortest form that answers the question is what appears.
|
||||
*/
|
||||
class DurationsTest {
|
||||
@Test
|
||||
fun `under a minute is the largest unit alone`() {
|
||||
assertEquals("30ms", formatMillis(30))
|
||||
assertEquals("999ms", formatMillis(999))
|
||||
assertEquals("1s", formatMillis(1000))
|
||||
assertEquals("2.5s", formatMillis(2500))
|
||||
// One decimal, rounded rather than cut: 2.46s is nearer two and a half than two and four.
|
||||
assertEquals("2.5s", formatMillis(2460))
|
||||
assertEquals("59.9s", formatMillis(59_900))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a minute or more is every unit that has something in it`() {
|
||||
// The figure this rule was written for: a tool timeout, which arrives as milliseconds and
|
||||
// is unreadable as 480000.
|
||||
assertEquals("8m", formatMillis(480_000))
|
||||
assertEquals("1m", formatMillis(60_000))
|
||||
assertEquals("1m 30s", formatMillis(90_000))
|
||||
assertEquals("5d 12h 4m", formatMillis(475_440_000))
|
||||
// Empty units are left out rather than written as zero: the labels say which is which,
|
||||
// and "5d 0h 4m" is only longer.
|
||||
assertEquals("5d 4m", formatMillis(432_240_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only a whole number of milliseconds is rewritten`() {
|
||||
assertEquals("8m", formatMillisText(" 480000 "))
|
||||
// A timeout a tool expressed some other way is its own words, passed through rather than
|
||||
// guessed at.
|
||||
assertEquals("2 minutes", formatMillisText("2 minutes"))
|
||||
assertEquals("", formatMillisText(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a countdown rounds up, so it never reports a minute already spent`() {
|
||||
assertEquals("3h 13m", formatSpan(Duration.ofMinutes(192).plusSeconds(50)))
|
||||
// Exactly on a minute is already the answer and is not pushed past it.
|
||||
assertEquals("3h 12m", formatSpan(Duration.ofMinutes(192)))
|
||||
assertEquals("12m", formatSpan(Duration.ofMinutes(12)))
|
||||
// Rounding up carries, so a day's worth of minutes reads as a day.
|
||||
assertEquals("1d 0h", formatSpan(Duration.ofHours(23).plusMinutes(59).plusSeconds(30)))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user