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:
irisandClaude Opus 5 committed 2026-09-03 21:03:15 -04:00
1 parent 359649bc73
commit acdf00ab1d
10 files changed
+336 -69

No files matched your search

@@ -71,8 +71,10 @@ fun ansiStyled(text: String, palette: AnsiPalette): AnnotatedString {
if (final == 'm') sgr = sgr.apply(params, palette)
}
}
// A bare carriage return rewrites the line; one before a newline is the other half of
// a Windows line ending and has nothing to rewrite.
// A bare carriage return rewrites the line. One before a newline is the other half
// of a Windows line ending: it rewrites nothing, and it is dropped rather than kept,
// since that pair is one line break and the return itself would draw as a stray
// control character.
c == '\r' && text.getOrNull(at + 1) != '\n' -> {
flush()
dropLine(runs)
@@ -13,8 +13,10 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -92,58 +94,64 @@ class MainActivity : ComponentActivity() {
NotificationService.sync(this)
setContent {
// Selection colours with the theme rather than at each place text is drawn: the
// transcript is one selection container, and a selection that ran from a reply into
// the code block under it would otherwise change colour halfway. See
// [AiAppSelectionColors].
MaterialTheme(colorScheme = AiAppColors) {
Surface(modifier = Modifier.fillMaxSize()) {
Box(
modifier =
// Timed like the transcript times itself, and for the same reason:
// the frame's draw phase is where Compose's measurement lands, and
// a report saying "draw is high" cannot otherwise say whether the
// cost is the transcript or the chrome around it. The keyboard is
// the case that made it matter -- every frame of the IME animation
// relays out and re-records this whole box.
Modifier.layout { measurable, constraints ->
val started = System.nanoTime()
val placeable = measurable.measure(constraints)
DebugStats.record(
"measure: the app root",
System.nanoTime() - started,
)
layout(placeable.width, placeable.height) {
val placing = System.nanoTime()
placeable.place(0, 0)
CompositionLocalProvider(LocalTextSelectionColors provides AiAppSelectionColors) {
Surface(modifier = Modifier.fillMaxSize()) {
Box(
modifier =
// Timed like the transcript times itself, and for the same reason:
// the frame's draw phase is where Compose's measurement lands, and
// a report saying "draw is high" cannot otherwise say whether the
// cost is the transcript or the chrome around it. The keyboard is
// the case that made it matter -- every frame of the IME animation
// relays out and re-records this whole box.
Modifier.layout { measurable, constraints ->
val started = System.nanoTime()
val placeable = measurable.measure(constraints)
DebugStats.record(
"place: the app root",
System.nanoTime() - placing,
"measure: the app root",
System.nanoTime() - started,
)
layout(placeable.width, placeable.height) {
val placing = System.nanoTime()
placeable.place(0, 0)
DebugStats.record(
"place: the app root",
System.nanoTime() - placing,
)
}
}
.drawWithContent {
val started = System.nanoTime()
drawContent()
DebugStats.record(
"record: the app root",
System.nanoTime() - started,
)
}
}
.drawWithContent {
val started = System.nanoTime()
drawContent()
DebugStats.record(
"record: the app root",
System.nanoTime() - started,
)
}
.fillMaxSize()
.statusBarsPadding()
// The gesture strip at the bottom of most
// phones. Without it the send row sits under
// the swipe area, where a tap is as likely to
// navigate away as to press a button.
//
// No imePadding here, deliberately: applied at the root it
// resizes this whole box on every frame of the keyboard
// animation, which re-measures, re-places and re-records every
// screen's entire tree per frame -- measured above as most of
// the frame budget. Each screen takes the keyboard itself
// (AppRoot wraps the ordinary ones; the session screen moves
// only its composer and transcript), so the per-frame cost is
// scoped to what actually moves.
.navigationBarsPadding()
) {
AppRoot(settingsVersion, openRequest, shareRequest)
.fillMaxSize()
.statusBarsPadding()
// The gesture strip at the bottom of most
// phones. Without it the send row sits under
// the swipe area, where a tap is as likely to
// navigate away as to press a button.
//
// No imePadding here, deliberately: applied at the root it
// resizes this whole box on every frame of the keyboard
// animation, which re-measures, re-places and re-records every
// screen's entire tree per frame -- measured above as most of
// the frame budget. Each screen takes the keyboard itself
// (AppRoot wraps the ordinary ones; the session screen moves
// only its composer and transcript), so the per-frame cost is
// scoped to what actually moves.
.navigationBarsPadding()
) {
AppRoot(settingsVersion, openRequest, shareRequest)
}
}
}
}
@@ -1,9 +1,13 @@
package com.example.aiapp
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.isSpecified
@@ -88,16 +92,40 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
content.buildMarkdownAnnotatedString(node, style, settings)
}
val uriHandler = LocalUriHandler.current
val onPlainTap = LocalMarkdownTap.current
val layout = remember { Ref<TextLayoutResult>() }
// The renderer's own rule for a style that names no colour: the theme's text colour.
val color = if (style.color.isSpecified) style.color else LocalMarkdownColors.current.text
BasicText(
text = text,
modifier =
modifier.pointerInput(text) {
detectTapGestures { position ->
val url = text.linkAt(layout.value, position) ?: return@detectTapGestures
uriHandler.openUri(url)
// A tap here is either a link or the card's; see [LocalMarkdownTap] for why the
// second one has to be answered from inside the text rather than left to the card.
modifier.pointerInput(text, onPlainTap) {
awaitEachGesture {
// Unconsumed is not required: something outside may already be tracking this
// press, and it is still the press that may land on a link.
awaitFirstDown(requireUnconsumed = false)
// A tap and nothing else. Null when the gesture became something somebody
// else's -- a scroll, or a press held past the long-press timeout, which is
// how a selection starts. The timeout is the load-bearing half: without it a
// press held for a second and released was still an up with nothing consumed,
// so holding a peer message to select from it shut the card instead.
val up =
withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) {
waitForUpOrCancellation()
} ?: return@awaitEachGesture
val url = text.linkAt(layout.value, up.position)
when {
url != null -> {
up.consume()
uriHandler.openUri(url)
}
onPlainTap != null -> {
up.consume()
onPlainTap()
}
}
}
},
style = style,
@@ -106,6 +134,39 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
)
}
/**
* What a tap on markdown text means when it lands on no link -- shutting the card it is drawn in,
* usually -- or null where a plain tap means nothing.
*
* A composition local because there is nowhere else to put it. The paragraphs of a message are
* composed by the renderer's own dispatch out of its component table, so nothing between a card and
* the text inside it is ours to pass a parameter through; the renderer already hands its colours,
* its typography and its components down the same way.
*
* It exists because a pointer-input node over the glyphs takes the tap and the card's own click
* handler never sees it. Measured on the emulator against an opened peer message: with a handler on
* the text -- consuming or not -- a tap on its words did nothing at all, and with the handler
* removed entirely the same tap shut the card. So a card whose body is markdown cannot be shut by
* pressing its words unless the words do the shutting, and "nothing happens when I press it" is
* indistinguishable from a card that has stopped working.
*
* Provided as a value that outlives a recomposition (see [rememberMarkdownTap]), since a fresh
* lambda per composition would invalidate every paragraph reading it.
*/
val LocalMarkdownTap = compositionLocalOf<(() -> Unit)?> { null }
/**
* [onTap] as a stable value to provide for [LocalMarkdownTap].
*
* The identity stays put while the behaviour follows the latest [onTap], which is what keeps
* providing it from invalidating the text under it on every recomposition of the card.
*/
@Composable
fun rememberMarkdownTap(onTap: () -> Unit): () -> Unit {
val latest = rememberUpdatedState(onTap)
return remember { { latest.value() } }
}
/**
* The address under [position], if a link's glyph is there rather than merely nearest to it.
*
@@ -12,6 +12,7 @@ import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -123,7 +124,12 @@ fun MemoryNote(
)
}
}
if (expanded) MarkdownText(note.text, replies, Modifier.padding(top = 4.dp))
// The words shut the card too, and have to do it themselves -- see [LocalMarkdownTap].
if (expanded) {
CompositionLocalProvider(LocalMarkdownTap provides rememberMarkdownTap(onToggle)) {
MarkdownText(note.text, replies, Modifier.padding(top = 4.dp))
}
}
}
}
}
@@ -13,6 +13,7 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -87,9 +88,14 @@ fun PeerBlockRow(unit: TranscriptUnit.PeerBlock, replies: ParsedReplies, onToggl
onPress = onToggle,
)
) {
// The gap the card's own column used to provide between its heading and its prose, and
// between one block and the next -- inside the piece, so the card's fill runs through it.
MarkdownPiece(unit.text, unit.piece, replies, Modifier.padding(top = unit.spacing))
// The words shut the card too, and have to do it themselves -- see [LocalMarkdownTap].
// Without this the card closes everywhere except on the text, which is most of it.
CompositionLocalProvider(LocalMarkdownTap provides rememberMarkdownTap(onToggle)) {
// The gap the card's own column used to provide between its heading and its prose,
// and between one block and the next -- inside the piece, so the card's fill runs
// through it.
MarkdownPiece(unit.text, unit.piece, replies, Modifier.padding(top = unit.spacing))
}
}
}
@@ -281,6 +281,13 @@ fun SessionScreen(
// have to ask whether anything is selected before they treat a tap as their own -- see
// [expanding].
val selection = rememberSelectionState()
// Read here, at composition, rather than inside [expanding] at the moment of the click. The
// container clears the selection from the very press that a card then reads as its own, a few
// milliseconds earlier and in the same event -- so a card asking the live state at its click
// always hears "nothing is selected", and a tap meant to put a selection away also shut the
// tool call the words were in. This value is whatever was true as of the last frame, which is
// what the reader was looking at when they touched the screen.
val selecting = selection.selectedTexts.isNotEmpty()
var expandedTools by remember { mutableStateOf(setOf<String>()) }
// Which runs of adjacent tool calls are open. Keyed by the first call's
// id, so a group survives more calls arriving after it.
@@ -509,7 +516,7 @@ fun SessionScreen(
* hold for only some of them.
*/
fun expanding(toggle: () -> Unit) {
if (selection.selectedTexts.isNotEmpty()) {
if (selecting) {
selection.clear()
return
}
@@ -1,5 +1,6 @@
package com.example.aiapp
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
@@ -257,6 +258,26 @@ fun ansiPalette(): AnsiPalette =
background = Mocha.Crust,
)
/**
* What a selection looks like, stated rather than left to Material's default.
*
* The default is `primary` at 40% alpha, which is a tint of whatever is behind it -- and this app
* draws text on surfaces two full steps apart. Over a reply, on Base, that reads clearly. Over a
* code block or a tool's output, on Crust, the same 40% composites to a barely-there smudge, so
* selecting a line of code looks like nothing happened even though the selection is there and
* copies correctly.
*
* Fixed and stronger, because "this is selected" is a meaning rather than decoration: a colour that
* means something must carry its own contrast instead of borrowing it from the surface it happens
* to land on. Raised only as far as it takes to read on the darkest of them -- past this the fill
* starts competing with the syntax colours it sits behind, which are the thing being read.
*/
val AiAppSelectionColors =
TextSelectionColors(
handleColor = Mocha.Mauve,
backgroundColor = Mocha.Mauve.copy(alpha = 0.55f),
)
/**
* A link. Blue is what a link is on every Catppuccin surface, and the one colour to leave alone.
*/