2 Commits
Author SHA1 Message Date
irisandClaude Fable 5.1 69525bd131 iris: a Rect is not size-independent, and P1a's block appearance verified
The defect P1a's screenshots found, and the one that mattered:
`Rect::is_size_independent()` answered `true`. A `Rect` fills whatever
region it is handed, so its content *is* the region -- and
`draw_inner`'s fast path, which rewrites a widget's primitives with
`r.outside(&from).within(&region)` instead of redrawing it, cannot
reproduce that once a region carries both `rel` and `abs`. What it
looked like: a fenced code block's background kept the height of the
provisional full-region draw `Span` does in its first phase, so one
fence's panel covered every block below it and every row below that,
with the text underneath laid out correctly. Likely the same cause as
RUST.md's older "the composer bar's grey background is not drawn".

Also here: a quote's bar is a `Stack` background behind padded text
rather than a two-child `Span(Dir::RIGHT)` (one widget fewer and no
provisional pass), and `transcript-ui`'s `transcript` example gains a
row holding one of every block kind -- the fixture's own heading,
paragraph, fence and table source, plus a list and a quote, which the
fixture has neither of.

docs/bench/p1a-2026-09-06/ has the pairs and docs/RUST.md's P1a box
names what still differs. The iris half is from the desktop backend
because this emulator cannot draw iris's glyphs at all (solid boxes,
reproduced on the previous commit, with Compose drawing text correctly
on the same AVD); both routes to Vulkan on this AVD were tried and both
fail. Bench stream phase, assertions live, no abort: p50 53.0ms p90
108.6ms p99 132.0ms against 52.8/108.1/137.3 before -- unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 19:30:39 -04:00
irisandClaude Fable 5.1 64f64b54e5 iris: per-block markdown appearance, syntax-highlighted fences, tappable links
P1a (docs/RUST.md). A transcript row's blocks are drawn the way
Markdown.kt draws them rather than as one flat span list:

- transcript-ui/src/markdown.rs is a *block* renderer now.
  `BlockFrame` is the whole widget vocabulary -- Plain, Verbatim (a
  dark rounded panel that pans sideways) and Quote (a bar and an
  indent) -- so a new markdown feature costs spans, not widgets.
  `frame_of` is the one place the BlockKind -> appearance mapping is
  written.
- Fences take `client_core::highlight`'s spans by language, in the
  same Catppuccin palette Theme.kt's `catppuccinSyntax()` uses, with
  the char->byte offset conversion the two index spaces need.
- Lists get the bullet ladder and coloured markers MarkdownPieces.kt
  draws, ordered lists count from the number they were written with,
  headings take Material's own ladder (24/22/16/14/12/11).
- Tables are padded monospace columns measured from the cells, with
  the header bold and a rule under it -- see docs/DECISIONS.md for
  what that trades against a real grid.
- Links carry their URL through to a tap. `GestureOutcome::Tapped`
  is new: a press that never committed to a pan or a selection, so a
  finger that flung the list past a link does not also open it.
  `iris::platform::OpenUrl` is the capability, implemented by each
  backend (xdg-open/open/start on the desktop, an ACTION_VIEW intent
  deferred to `after_input` on Android, the same shape
  `pending_show_keyboard` uses).
- `DragArbiter`/`DragGesture` take an axis, so a code fence pans
  across its own long lines through the same machine a list pans
  down its rows -- and a vertical drag starting on a fence still
  reaches the list.
- `TextEditCtx::byte_at` answers which byte a tap landed on without
  exposing the parley layout; `Rect::radius` takes a `Len`, so a
  corner can be written in dp.

Tests: 31 in transcript-ui (11 new, covering the frame mapping,
highlighting including a multibyte fence and an unknown language,
list markers, table padding and wrapping, link hit-testing), 85 in
iris (4 new on the tap-vs-drag rule and the two axes).
cargo fmt clean, clippy warning-free.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:55:46 -04:00
25 changed files with 1490 additions and 189 deletions

No files matched your search

+33
View File
@@ -5,6 +5,39 @@ they can be judged and reversed later. Detail lives in RUST.md (and IRIS.md
for iris API changes); this file is only the summary. Newest first. Items
marked **DEFERRED** are ones the agent chose not to decide alone.
## 2026-09-06 (how a markdown block looks, P1a)
- **A table is drawn as padded monospace columns, not as a grid.** Your
call to reverse. Compose draws a real grid: cells on a tint, each
column with a 136dp floor, scrolling sideways when there are too many.
iris has no grid widget, and building one would be a widget per
markdown feature -- which is the thing the block model exists to avoid.
In a monospace face a character count *is* a pixel width, so padding
each cell to its column's width is alignment, the widths are still
measured from the cells, and a table that is too wide pans sideways
through the same mechanism a code fence already uses. The header is
bold with a rule under it, and a long cell wraps inside its column
(capped at 28 characters, which is what fits three columns across a
phone). **What it trades:** no cell borders, and a table looks like
code rather than like a table. If you want the grid, it is a new widget
and it is a day's work.
- **Three block frames, and only three.** A heading, paragraph and list
are plain text with spans; a fence and a table are a rounded panel that
does not wrap; a quote is a bar with the text padded past it.
Everything else markdown says is expressed in span styles, which cost
no widgets and no layout nodes. So a new markdown feature is a span,
not a widget.
- **A list's marker is part of the text, so a wrapped item's second line
returns to the left margin.** Compose keeps it indented by giving the
marker its own column. Doing the same here needs per-line indent in
iris's text attributes; it is written down rather than done, because
the list items in a real reply are usually one line.
- **A link opens on a tap and not on the end of a drag.** A press that
panned the transcript past a link, or that held long enough to start a
selection, does not follow it -- decided by the same gesture machine
that decides pan-versus-select, so there is one rule rather than two
that can disagree.
## 2026-09-06 (composer scroll and the streaming block model)
- **A streamed message becomes a column of per-block widgets.** Decided by
+49
View File
@@ -8,6 +8,55 @@ capability that moved. Small and trivial changes do not go here.
An entry gives the date, what changed, why, and a short before/after where
it helps judge the change without the session that made it. Newest first.
## 2026-09-06: a tap is its own gesture outcome, and opening a URL is a backend capability
Three related additions, all for following a markdown link.
**`iris::platform::OpenUrl`** is a new trait beside `attr::FocusHost`, and
has the same shape: declared in `iris`, implemented once per backend (a
detached `xdg-open`/`open`/`start` on the desktop, an `ACTION_VIEW` intent
on Android, deferred to the next view callback exactly the way
`pending_show_keyboard` is). A widget asks for the capability by bound --
`Rsc::State: FocusHost + OpenUrl` -- instead of a caller threading a
callback down through every builder. One method, not a general "run an
intent": a narrower capability is a narrower thing to get wrong. Nothing
is returned; the platform either shows a browser or does not, and both
are outside the process.
**`GestureOutcome::Tapped`** is new. `Released(None)` used to mean both
"the press ended having selected something" and "the press ended having
done nothing at all", and only the second is a tap. Any caller that acts
on a tap -- following a link -- must not also act when the finger was
panning the list past that link, so the distinction is made once, in the
gesture machine every widget already shares, rather than timed again per
widget. `DragArbiter::is_undecided()` is what answers it.
`Selection::drag` returns the outcome now instead of `()`.
**`DragArbiter`/`DragGesture` take an axis** (`::on(Axis)`; `::new()` is
still vertical). A code fence pans across its own long lines exactly the
way a transcript pans down its rows, and the two were the same state
machine with `dx` and `dy` swapped. `WidgetLike::scrollable_on(axis)`
joins `scrollable()` for the same reason. Before this, a horizontal
`Scroll` existed but could not be dragged by a finger at all -- its
arbiter only ever committed on the vertical axis.
Two smaller ones in the same pass. **`TextEditCtx::byte_at(pos, size)`**
answers which byte of the text a tap landed on, doing the same
region-relative transform `select` does, without handing out the parley
layout a caller could shape against stale text. And **`Rect::radius` now
takes a `Len`**, so a corner can be written in `dp` and come out the same
physical size on every display; a bare number still means physical pixels.
**One behaviour change worth knowing about**: `Rect::is_size_independent()`
answers `false` now. It answered `true`, and a `Rect` fills whatever
region it is given -- so `draw_inner`'s fast path, which rewrites a
widget's primitives in place instead of redrawing it, could not reproduce
what `draw` would have done. A `.background(rect(..))` behind
variable-height content kept the size of the provisional pass its parent
`Span` had drawn it at, which on the transcript screen meant one code
block's panel covering every block below it. Costs one primitive's redraw
when a rect is resized.
## 2026-09-06: a transcript row is a column of blocks, and a block is the selection unit
`transcript-ui`'s row builder used to make **one** `TextEdit` per message.
+62 -11
View File
@@ -535,22 +535,31 @@ agent ticks it here with the evidence.
`row.rs`'s `build_text_row` is where one would go, keyed to something
stable per row (its sender + a short excerpt, matching what a screen
reader announcing a chat message would say).
- [ ] **A tappable link and a background chip behind inline code.**
Both need per-range glyph geometry that `TextEditCtx` does not expose
outside `iris::widget::text` (`edit.rs`'s `layout()` helper is
private) — see `markdown.rs`'s module doc for the exact shape the fix
would take (the same primitive `TextEdit::draw`'s own selection
highlight already uses internally,
`iris/src/widget/text/edit.rs:99`).
- [x] **A tappable link** — done 2026-09-06 (P1a). `TextEditCtx::
byte_at(pos, size)` answers which byte a tap landed on without
handing out the parley layout, `GestureOutcome::Tapped` says the
press committed to neither a pan nor a selection, and
`iris::platform::OpenUrl` is the capability each backend implements
(`xdg-open`/`open`/`start`; an `ACTION_VIEW` intent on Android,
deferred to `after_input` the way `pending_show_keyboard` is).
- [ ] **A background chip behind inline code.** Still needs per-range
glyph *geometry* — a run's boxes, not one offset — which
`TextEditCtx` does not expose outside `iris::widget::text`
(`edit.rs`'s `layout()` helper is private). The same primitive
`TextEdit::draw`'s own selection highlight uses internally,
`iris/src/widget/text/edit.rs:99`. `byte_at` above deliberately did
not open that up: a tap needs one offset and a chip needs the run.
- [ ] **`Selection`'s anchor-row shortcut.** The row a drag started in
is selected in full (`select_all`) the moment the drag leaves it,
rather than "from the click point to whichever edge points away from
the drag" — needs the same private `layout()` access as the item
above. `selection.rs`'s module doc has the exact reasoning.
- [ ] **No syntax highlighting inside a fenced code block.**
`client_core::highlight` exists (built for the file explorer) and
could feed per-token `SpanStyle`s into a code block's span; wiring it
in was not attempted this pass.
- [x] **Syntax highlighting inside a fenced code block** — done
2026-09-06 (P1a). `client_core::highlight::spans_of` by language,
converted from its char indices to `SpanStyle`'s byte offsets, in
the same Catppuccin palette `Theme.kt` uses. A language the scanner
has no rules for stays plain rather than being coloured by the
nearest one's.
- [ ] **Masks defined relative to each other.** Wanted: mask A multiplies
by something *and also* applies mask B — a mask can reference a parent
@@ -570,6 +579,48 @@ agent ticks it here with the evidence.
everything, the same way input is**. Whatever the mechanism, a widget
that does not animate must pay nothing and import nothing for it.
## Found by P1a (2026-09-06)
- [x] **`Rect` claimed to be size-independent, and it is not.** A `Rect`
fills whatever region it is handed, so `draw_inner`'s size-independent
fast path -- which rewrites primitives with
`r.outside(&from).within(&region)` rather than redrawing -- could not
reproduce its `draw`, and a `.background(rect(..))` kept the size of
the *provisional* full-region pass `Span` does in phase 1. One fenced
code block's panel covered every block below it and every row below
that. Fixed in `iris/src/widget/rect.rs`; the reason is written at the
definition. Suspect the same cause for anything else tinted with a
background rect.
- [ ] **A wrapped transcript row trips `reposition`'s debug assert.**
*"widget ... is both moved by its parent's own layout (`mov`) and
repositioned within it"*, raised from `List::place`. Repro: change
`.wrap(!verbatim)` to `.wrap(true)` in `transcript-ui/src/row.rs`'s
`build_block` and run `iris/run-headless.sh transcript --shot
/tmp/x.png -- -p transcript-ui`. Survives the `Rect` fix above and is
not specific to any block kind -- it appears once the row is tall
enough. The shipping configuration does not reach it (verbatim blocks
do not wrap) and the Android bench runs clean with assertions live,
but it is a real disagreement about who owns a widget's move slot and
should be settled before more of P1 leans on `List`.
- [ ] **Desktop colours are washed out: the winit surface is sRGB and
the shader writes the palette's bytes as linear.** Mocha Crust
(17,17,27) is drawn as (73,73,91), measured off
`run-headless.sh --shot`. Android is correct, so this is the surface
format rather than the palette -- but it makes the desktop build
useless as a colour reference, which is exactly what P1a needed it for
when the emulator could not draw glyphs.
- [ ] **The emulator cannot draw iris's glyphs.** Under `-gpu host` with
Vulkan disabled (Mesa 26.2.2 / virgl -- what `emu` does on this
machine) every character renders as a solid filled box: the atlas
sample's alpha reads 1, which is what an incomplete GL texture returns
(0,0,0,1). Not new (`20303e0` does it too) and not the platform's
(Compose draws text perfectly on the same AVD in the same minute).
Enabling host Vulkan still dies at boot in gfxstream, and
`EMU_GPU=software` gives SwiftShader Vulkan on which iris **SIGSEGVs
in `surface_changed`**. Either of the last two would restore
appearance testing on Android; today it has to be done on the desktop
backend or on Iris's phone.
## Build (for the port)
Widgets `RUST.md`'s "The port, in order (decided 2026-09-05)" needs and
+135 -11
View File
@@ -5465,17 +5465,141 @@ device.
comparison fair. **Sub-order, decided by the design agent, by what
the bench fixture exercises and Compose already draws** (each is
one agent; tick and date in place):
- [ ] **P1a — markdown block rendering parity.** Now that a row is
a column of per-block widgets (`e1030d6`), draw each block
the way Compose does: headings at their sizes, fences in a
mono face with `client-core`'s `highlight` spans and a
distinct background, bullet/numbered lists with indent,
tables as aligned columns, block quotes, links as tappable
spans (opening through the platform — the "tappable link"
primitive in IRIS_TODO's "Build (for the port)"). Against
`app/bench-fixture/`, screenshot beside the Compose bench
build on the same emulator. Pure parts (block → style
mapping) tested in `client-core`/`transcript-ui`.
- [x] **P1a — markdown block rendering parity.** Done 2026-09-06.
Each top-level block is drawn in one of **three frames**
(`transcript-ui::markdown::BlockFrame`, mapped from
`BlockKind` by the pure `frame_of`): `Plain` (a paragraph,
heading, list or rule -- text and spans, no extra widget),
`Verbatim { fill }` (a fence or a table -- a rounded panel
that does not wrap and pans sideways, `CodeFence.kt`'s
`horizontalScroll`), and `Quote` (a bar behind text padded
past it). Everything else markdown can say is expressed in
`SpanStyle`s, which cost no widgets.
**What each block looks like now, against `Markdown.kt`:**
- *Headings* -- Material's own ladder, the six sizes
`markdownTypography` picks (24/22/16/14/12/11 at a 16pt
body), bold. Was a three-step 28/24/21/19.
- *Fences* -- monospace on Mocha Crust, rounded, with
`client_core::highlight`'s spans by language in the same
Catppuccin palette `Theme.kt`'s `catppuccinSyntax()` uses.
An unknown language is plain rather than coloured by the
nearest one. A fence being streamed into re-renders only
the last block (`RowBlocks::apply_delta`), so earlier
fences are never re-scanned.
- *Lists* -- the bullet ladder `MarkdownPieces.kt` draws
(disc/ring/square by depth) and ordered lists counting from
the number written, markers in Lavender.
- *Tables* -- padded monospace columns measured from the
cells, header bold, a rule under it, on Surface 0. A real
grid was rejected; docs/DECISIONS.md, 2026-09-06, has why.
- *Quotes* -- a Surface 2 bar down the left, text one shade
back from body.
- *Links* -- coloured and underlined as before, and now
**tappable**: `GestureOutcome::Tapped` (a press that
committed to neither a pan nor a selection),
`TextEditCtx::byte_at` for which byte, and
`iris::platform::OpenUrl` for the platform (`xdg-open`/
`open`/`start`; on Android an `ACTION_VIEW` intent deferred
to `after_input`, the shape `pending_show_keyboard` uses).
**Screenshots: `docs/bench/p1a-2026-09-06/`.**
`compose-heading-fence-table.png` and
`compose-fence-table.png` are the Compose `bench` build on
this checkout's AVD against `app/bench-fixture/`;
`iris-blocks.png` is iris rendering the same heading,
paragraph, link, fence and table source (plus a list and a
quote, which the fixture has neither of) from
`transcript-ui`'s own `transcript` example.
**Why the iris half is not from the emulator**, which the
pass condition asked for: **the emulator cannot draw iris's
glyphs at all.** Every character comes out as a solid filled
box of the right width -- `iris-emulator-gles-glyphs.png`.
Established as *not* this change's doing and not the app's:
the previous commit (`20303e0`) draws the same boxes, and the
Compose bench build on the same AVD in the same minute draws
text perfectly. The atlas sample's alpha reads as 1 under
`-gpu host` + `-feature -Vulkan` (Mesa 26.2.2 / virgl), which
is what an *incomplete* GL texture returns (0,0,0,1). Both
ways out were tried and both fail: `GPU_HOST_FEATURES=" "`
still dies at boot with gfxstream's documented "Format
VK_FORMAT_R8G8B8A8_UNORM is not supported ... Failed to find
memory type for ColorBuffers", and `EMU_GPU=software` does
give the guest SwiftShader Vulkan but iris **SIGSEGVs inside
`surface_changed`** on it. So the appearance half of this box
is taken on the desktop/winit backend, which renders on the
host's real GPU through `iris/run-headless.sh`.
**What still differs, pair by pair:**
1. *Colour, on the desktop shot only.* The winit surface is
sRGB and the shader writes the palette's bytes as linear,
so every fill reads ~4x lighter: Crust (17,17,27) comes out
(73,73,91), measured. Not a palette error and not present
on Android, where the previous pass measured the composer
bar at rgb(41,40,49) for a declared (40,40,46). Worth its
own item; it makes the desktop build a poor colour
reference until fixed.
2. *A list's wrapped line.* Compose lays an item out as a
marker column beside a text column, so a second line stays
indented; iris writes the marker into the same buffer, so
a wrapped line returns to the left margin. Needs per-line
indent in `TextAttrs`.
3. *A table.* Compose draws a real grid, cells wrapping at a
136dp floor; iris draws padded monospace columns. Same
information, different picture.
4. *Inline code.* Compose draws a chip behind it; iris gives
the range a monospace face and the code colour. Unchanged
by this box -- still blocked on per-range glyph geometry
(IRIS_TODO).
5. *A user message.* Compose draws it in a rounded card;
iris draws a sender label above plain text. That is the
row's own styling, P1's rather than P1a's.
**One real defect found and fixed on the way**, and it is
not a small one: **`Rect::is_size_independent()` answered
`true`.** A `Rect` fills whatever region it is given, so its
content *is* the region -- and `draw_inner`'s
size-independent fast path, which rewrites a widget's
primitives with `r.outside(&from).within(&region)` instead of
redrawing, cannot reproduce that remap once a region carries
both `rel` and `abs`. The visible result: a fenced block's
background kept the height of the **provisional full-region
draw** `Span` does in its first phase, so one fence's panel
covered every block below it *and every row below that*,
while the text underneath was laid out correctly. It answers
`false` now (`iris/src/widget/rect.rs`, with the account at
the definition). This is very likely the same family as this
file's older "the composer bar's grey background is not
drawn" note and any other `.background(rect(..))` tint.
**One defect found and left open**, with its repro:
`UiRenderState::reposition`'s debug assert -- *"widget ... is
both moved by its parent's own layout (`mov`) and
repositioned within it"* -- fires from `List::place` when a
transcript row's blocks **wrap**. Reproduce in one line:
change `.wrap(!verbatim)` to `.wrap(true)` in
`transcript-ui/src/row.rs`'s `build_block` and run
`iris/run-headless.sh transcript --shot /tmp/x.png -- -p
transcript-ui`. It is *not* caused by the `Rect` fix above
(it survives it) and not by any one block kind (bisected: it
appears once the row is tall enough). The shipping
configuration does not reach it -- verbatim blocks do not
wrap -- and neither does the Android bench, which ran clean
with the assertions live. It should be the next thing looked
at under P1, because it is a real inconsistency about who
owns a widget's move slot, not a false alarm.
**Bench, stream phase, this checkout's AVD, debug x86_64
`force-gles`, assertions live, no abort:**
`stream: 294 frames over 21.0s, late 283 (96.3%), p50 53.0ms
p90 108.6ms p99 132.0ms` against the pre-P1a
`p50 52.8ms p90 108.1ms p99 137.3ms` -- unchanged, which is
the point: block styling is span work, not layout work. The
`worst` figure is the one number that moved and it does not
reproduce: 567.3ms, 140.1ms and 664.5ms across three runs of
the same build, against 148.9ms before. Unexplained; it is a
single frame in 294 and the percentiles are flat, so it reads
as an emulator hiccup rather than a cost, but it is written
down rather than rounded off.
**Checks**: `cargo fmt --all --check` clean in both
workspaces; `cargo clippy -p iris -p iris-core -p
transcript-ui -p desktop-app -p tabs-ui --all-targets`
warning-free; `cargo test` 85 (iris, +4) + 13 (iris-core) +
31 (transcript-ui, +11) + 123 (client-core).
- [ ] **P1b — tool-call cards and grouping.** `ToolRows.kt`/
`ToolInput.kt`'s cards: a collapsed row per call with name
and a one-line summary, expand to input and output, runs of
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+1
View File
@@ -17,6 +17,7 @@ mod attr;
mod ime;
mod input;
mod insets;
mod platform;
mod render;
mod view;
+89
View File
@@ -0,0 +1,89 @@
use crate::platform::OpenUrl;
use android_view::{
View,
jni::{
JNIEnv,
objects::{JObject, JValue},
},
};
use super::view::HasAndroidUiState;
/// Android's URL opener. Like `FocusHost::focus_gained`'s keyboard, the
/// real work is a JNI call and this runs deep inside the sensor dispatch
/// with no `CallbackCtx` in reach -- so it raises a flag that
/// `IrisViewPeer::after_input` consumes, exactly as
/// `pending_show_keyboard` does.
///
/// Last request wins: two links cannot be tapped in one frame, and a URL
/// left queued from a frame that somehow never reached `after_input`
/// would open at some unrelated later tap, which is worse than dropping
/// it.
impl<T: HasAndroidUiState> OpenUrl for T {
fn open_url(&mut self, url: &str) {
self.android_state_mut().pending_open_url = Some(url.to_string());
}
}
/// `startActivity(new Intent(ACTION_VIEW, Uri.parse(url)))` on the view's
/// own context.
///
/// `FLAG_ACTIVITY_NEW_TASK` because the context here is the view's, which
/// may be an application context rather than the activity's -- Android
/// throws `AndroidRuntimeException` for a non-activity context without it,
/// and it is harmless when the context *is* an activity's.
///
/// Every failure is logged with the URL and returns; there is nothing to
/// fall back to, and the reader will see that nothing happened.
pub(super) fn open_url<'local>(env: &mut JNIEnv<'local>, view: &View<'local>, url: &str) {
match try_open_url(env, view, url) {
Ok(()) => {}
Err(e) => {
// A pending Java exception makes every later JNI call fail in
// ways nowhere near here, so it is cleared at the boundary.
let _ = env.exception_clear();
log::warn!("could not open {url}: {e}");
}
}
}
fn try_open_url<'local>(
env: &mut JNIEnv<'local>,
view: &View<'local>,
url: &str,
) -> Result<(), android_view::jni::errors::Error> {
let context = env
.call_method(&view.0, "getContext", "()Landroid/content/Context;", &[])?
.l()?;
let jurl = env.new_string(url)?;
let uri = env.call_static_method(
"android/net/Uri",
"parse",
"(Ljava/lang/String;)Landroid/net/Uri;",
&[JValue::Object(jurl.as_ref())],
)?;
let action = env.new_string("android.intent.action.VIEW")?;
let intent = env.new_object(
"android/content/Intent",
"(Ljava/lang/String;Landroid/net/Uri;)V",
&[JValue::Object(action.as_ref()), JValue::Object(&uri.l()?)],
)?;
env.call_method(
&intent,
"addFlags",
"(I)Landroid/content/Intent;",
&[JValue::Int(FLAG_ACTIVITY_NEW_TASK)],
)?;
env.call_method(
&context,
"startActivity",
"(Landroid/content/Intent;)V",
&[JValue::Object(&JObject::from(intent))],
)?;
Ok(())
}
/// `android.content.Intent.FLAG_ACTIVITY_NEW_TASK`. A constant rather than
/// a static-field read: it is part of the platform's stable ABI and
/// reading it costs two more JNI calls that can each fail.
const FLAG_ACTIVITY_NEW_TASK: i32 = 0x1000_0000;
+8
View File
@@ -54,6 +54,10 @@ pub struct AndroidUiState {
/// inside the platform-agnostic sensor dispatch with no `CallbackCtx`
/// in reach.
pub pending_show_keyboard: bool,
/// A URL a tapped link asked the platform to open, for the same
/// reason `pending_show_keyboard` is a flag rather than a call --
/// see `android/platform.rs`.
pub pending_open_url: Option<String>,
/// Window insets, filled in from outside the normal `ViewPeer` callback
/// path -- see `android/insets.rs` for why they need a registry of
/// their own.
@@ -113,6 +117,7 @@ impl AndroidUiState {
last_click: Instant::now(),
compose_len: 0,
pending_show_keyboard: false,
pending_open_url: None,
shared,
access_adapter: Default::default(),
access: AccessTree::new(),
@@ -324,6 +329,9 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
if std::mem::take(&mut ui_state.pending_show_keyboard) {
show_soft_input(&mut ctx.env, &ctx.view);
}
if let Some(url) = ui_state.pending_open_url.take() {
super::platform::open_url(&mut ctx.env, &ctx.view, &url);
}
// RUST.md's P0 box, "doesn't enter it until I hit space, and also
// doesn't move cursor forward": Gboard needs `updateSelection`
+1
View File
@@ -15,6 +15,7 @@ mod access;
mod app;
mod attr;
mod input;
mod platform;
mod render;
pub use access::*;
+33
View File
@@ -0,0 +1,33 @@
use crate::platform::OpenUrl;
use crate::prelude::HasDefaultUiState;
/// The desktop's URL opener: the platform's own "open this with whatever
/// is registered for it" command, detached so a browser starting slowly
/// cannot stall the event loop.
///
/// A command rather than a crate: `xdg-open`/`open`/`start` is what every
/// such crate shells out to anyway, and this is one call site.
impl<T: HasDefaultUiState> OpenUrl for T {
fn open_url(&mut self, url: &str) {
let (program, first): (&str, &[&str]) = if cfg!(target_os = "macos") {
("open", &[])
} else if cfg!(target_os = "windows") {
// `start` is a shell builtin, and its first argument is the
// window title -- an empty one, or a URL containing `&` ends
// up split.
("cmd", &["/C", "start", ""])
} else {
("xdg-open", &[])
};
match std::process::Command::new(program)
.args(first)
.arg(url)
.spawn()
{
Ok(_) => {}
// Named with the command that failed and the link it was for,
// since neither is recoverable from the OS error alone.
Err(e) => log::warn!("could not open {url} with {program}: {e}"),
}
}
}
+2
View File
@@ -21,6 +21,7 @@ pub mod default;
pub mod attr;
pub mod event;
pub mod platform;
pub mod sense;
pub mod state;
pub mod task;
@@ -47,6 +48,7 @@ pub mod prelude {
pub use event::*;
pub use iris_core::*;
pub use iris_macro::*;
pub use platform::*;
pub use sense::*;
pub use state::*;
pub use task::*;
+22
View File
@@ -0,0 +1,22 @@
//! Capabilities a widget tree needs from whatever is hosting it, that
//! neither iris nor the app can perform itself.
//!
//! Same shape as [`crate::attr::FocusHost`], and for the same reason: the
//! interface is declared here, below, and implemented by each backend
//! above (`default/platform.rs`, `android/platform.rs`), so a widget can
//! ask for the capability by trait bound instead of a caller threading a
//! callback down through every builder.
/// Hand a URL to whatever the platform opens URLs with.
///
/// One method rather than a general "run an intent"/"exec" surface: the
/// only thing a transcript needs is to follow a link a reader tapped, and
/// a narrower capability is a narrower thing to get wrong.
///
/// **Nothing is reported back.** There is no answer worth branching on --
/// the platform either shows a browser or does not, and both are outside
/// this process -- so failures are logged where they happen (each impl)
/// rather than turned into a `Result` every call site would discard.
pub trait OpenUrl {
fn open_url(&mut self, url: &str);
}
+127 -21
View File
@@ -487,6 +487,12 @@ enum ArbiterState {
/// caller-supplied `Instant` rather than a real clock.
pub struct DragArbiter {
state: ArbiterState,
/// Which way a pan runs. A transcript pans down its list and a code
/// fence pans across its own long lines, and the two decisions are
/// the same one with the axes swapped -- so the axis is a field
/// rather than a second copy of this state machine, and everything
/// below reads `along`/`across` instead of `dy`/`dx`.
axis: Axis,
origin: Vec2,
origin_at: Instant,
last: Vec2,
@@ -494,20 +500,28 @@ pub struct DragArbiter {
impl Default for DragArbiter {
fn default() -> Self {
Self {
state: ArbiterState::Idle,
origin: Vec2::ZERO,
origin_at: Instant::now(),
last: Vec2::ZERO,
}
Self::on(Axis::Y)
}
}
impl DragArbiter {
/// A vertical arbiter -- what a list, and every caller before the
/// axis became a field, wants.
pub fn new() -> Self {
Self::default()
}
/// An arbiter whose pan runs along `axis`.
pub fn on(axis: Axis) -> Self {
Self {
state: ArbiterState::Idle,
axis,
origin: Vec2::ZERO,
origin_at: Instant::now(),
last: Vec2::ZERO,
}
}
/// A fresh press-down at `pos`. `already_selected` is whatever the
/// caller's selection state was *before* this press -- it decides
/// whether an early horizontal move extends that selection instead of
@@ -548,25 +562,25 @@ impl DragArbiter {
match self.state {
ArbiterState::Idle => DragOutcome::Undecided,
ArbiterState::Panning => {
let dy = pos.y - self.last.y;
let along = pos.axis(self.axis) - self.last.axis(self.axis);
self.last = pos;
DragOutcome::Pan(dy)
DragOutcome::Pan(along)
}
ArbiterState::Selecting => {
self.last = pos;
DragOutcome::SelectExtend
}
ArbiterState::Undecided { already_selected } => {
let dx = pos.x - self.origin.x;
let dy = pos.y - self.origin.y;
if already_selected && dx.abs() > DRAG_SLOP && dx.abs() > dy.abs() {
let along = pos.axis(self.axis) - self.origin.axis(self.axis);
let across = pos.axis(!self.axis) - self.origin.axis(!self.axis);
if already_selected && across.abs() > DRAG_SLOP && across.abs() > along.abs() {
self.state = ArbiterState::Selecting;
self.last = pos;
DragOutcome::SelectExtend
} else if dy.abs() > DRAG_SLOP && dy.abs() >= dx.abs() {
} else if along.abs() > DRAG_SLOP && along.abs() >= across.abs() {
self.state = ArbiterState::Panning;
self.last = pos;
// `dy` here is the *whole* drag since `press_start`,
// `along` here is the *whole* drag since `press_start`,
// not since the last frame -- nothing panned while
// `Undecided` was withholding the slop, so applying it
// in full on this one frame is a visible jump the
@@ -584,10 +598,10 @@ impl DragArbiter {
// `ViewConfiguration.getScaledTouchSlop()` once from
// the first scroll past it rather than replaying the
// whole pre-threshold drag in one step.
DragOutcome::Pan(dy - DRAG_SLOP.copysign(dy))
DragOutcome::Pan(along - DRAG_SLOP.copysign(along))
} else if now.duration_since(self.origin_at) >= LONG_PRESS
&& dx.abs() <= DRAG_SLOP
&& dy.abs() <= DRAG_SLOP
&& across.abs() <= DRAG_SLOP
&& along.abs() <= DRAG_SLOP
{
self.state = ArbiterState::Selecting;
self.last = pos;
@@ -613,6 +627,15 @@ impl DragArbiter {
pub fn is_panning(&self) -> bool {
matches!(self.state, ArbiterState::Panning)
}
/// Whether a press is in flight that has committed to neither a pan
/// nor a selection -- what a release checks to tell a **tap** from
/// the end of a drag. A tap is exactly "pressed and let go without
/// ever deciding", so it is read here rather than timed separately:
/// one gesture machine, one answer.
pub fn is_undecided(&self) -> bool {
matches!(self.state, ArbiterState::Undecided { .. })
}
}
/// What a [`DragGesture`] decided this frame -- [`DragOutcome`] plus the
@@ -626,6 +649,14 @@ pub enum GestureOutcome {
Pan(f32),
SelectStart,
SelectExtend,
/// The press ended without ever committing to a pan or a selection --
/// a tap. Distinct from `Released(None)`, which is the end of a
/// gesture that *did* commit (a selection, or a pan too slow to
/// fling): a caller acting on a tap -- following a markdown link --
/// must not also act when the finger was panning the list past that
/// link, which is the tap-vs-drag rule this enum exists to state
/// once for every caller rather than per widget.
Tapped,
/// The drag ended -- `PressEnd` or the capture's own terminal `Drop`.
/// `Some(velocity)` only if the gesture had committed to panning
/// (never a tap, a long-press selection, or one still `Undecided`);
@@ -661,8 +692,13 @@ impl Default for DragGesture {
impl DragGesture {
pub fn new() -> Self {
Self::on(Axis::Y)
}
/// A gesture whose pan runs along `axis` -- see [`DragArbiter::on`].
pub fn on(axis: Axis) -> Self {
Self {
arbiter: DragArbiter::new(),
arbiter: DragArbiter::on(axis),
velocity: VelocityTracker::new(),
}
}
@@ -700,14 +736,16 @@ impl DragGesture {
self.dispatch(render, id, pos_window, now)
}
CursorSense::Drop | CursorSense::PressEnd(_) => {
let released = if self.arbiter.is_panning() {
Some(self.velocity.velocity())
let outcome = if self.arbiter.is_panning() {
GestureOutcome::Released(Some(self.velocity.velocity()))
} else if self.arbiter.is_undecided() {
GestureOutcome::Tapped
} else {
None
GestureOutcome::Released(None)
};
self.arbiter.release();
render.release_pointer();
GestureOutcome::Released(released)
outcome
}
// See `DragArbiter::update`'s own doc: a `Pressing` frame can
// arrive with no matching `PressStart` if the touch-down
@@ -1292,6 +1330,74 @@ mod drag_arbiter_tests {
assert!(!a.is_idle());
}
/// The tap-vs-drag rule a markdown link is followed by
/// (`transcript-ui`'s `row.rs`): a press that never committed is a
/// tap, and a press that panned or selected is not -- read from this
/// one machine rather than timed a second time beside it.
#[test]
fn a_press_that_never_moved_is_still_undecided_at_release() {
let mut a = DragArbiter::new();
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
a.update(Vec2::new(1.0, 1.0), t(10));
assert!(a.is_undecided());
assert!(!a.is_panning());
}
/// The half the tap rule had no reason to touch: a gesture that
/// panned must not also read as a tap when the finger comes up over
/// the link it started on.
#[test]
fn a_press_that_panned_is_not_undecided_at_release() {
let mut a = DragArbiter::new();
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
a.update(Vec2::new(0.0, 40.0), t(10));
assert!(a.is_panning());
assert!(!a.is_undecided());
}
/// A long press that grew a selection is not a tap either.
#[test]
fn a_long_press_that_selected_is_not_undecided() {
let mut a = DragArbiter::new();
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
assert_eq!(
a.update(Vec2::new(0.0, 1.0), t(LONG_PRESS.as_millis() as u64 + 10)),
DragOutcome::SelectStart
);
assert!(!a.is_undecided());
}
/// Both axes are one machine with the axis passed in: a horizontal
/// arbiter (a code fence panning across its own long lines) pans on
/// exactly the drag a vertical one ignores, and ignores the one it
/// pans on.
#[test]
fn a_horizontal_arbiter_pans_on_the_drag_a_vertical_one_ignores() {
let mut across = DragArbiter::on(Axis::X);
across.press_start(Vec2::new(0.0, 0.0), t(0), false);
assert_eq!(
across.update(Vec2::new(20.0, 0.0), t(10)),
DragOutcome::Pan(12.0)
);
let mut down = DragArbiter::new();
down.press_start(Vec2::new(0.0, 0.0), t(0), false);
assert_eq!(
down.update(Vec2::new(20.0, 0.0), t(10)),
DragOutcome::Undecided
);
// ...and a vertical drag over the horizontal one stays undecided,
// which is what lets the list behind a code fence still be
// panned by a finger that started on the fence.
let mut across = DragArbiter::on(Axis::X);
across.press_start(Vec2::new(0.0, 0.0), t(0), false);
assert_eq!(
across.update(Vec2::new(0.0, 20.0), t(10)),
DragOutcome::Undecided
);
}
#[test]
fn release_resets_to_idle() {
let mut a = DragArbiter::new();
+2 -1
View File
@@ -88,7 +88,7 @@ impl Scroll {
snap_end: true,
container_len: 0.0,
content_len: 0.0,
gesture: DragGesture::new(),
gesture: DragGesture::on(axis),
}
}
@@ -138,6 +138,7 @@ impl Scroll {
// the content follows the finger.
GestureOutcome::Pan(dy) => self.scroll(dy),
GestureOutcome::Undecided
| GestureOutcome::Tapped
| GestureOutcome::SelectStart
| GestureOutcome::SelectExtend
| GestureOutcome::Released(_) => {}
+37 -6
View File
@@ -3,7 +3,13 @@ use crate::prelude::*;
#[derive(Clone, Copy)]
pub struct Rect {
pub color: UiColor,
pub radius: f32,
/// A `Len` rather than a raw `f32` so a corner can be written in `dp`
/// and come out the same physical size on every display -- resolved
/// against `Painter::density` in [`Rect::draw`], the same place every
/// other `dp` is resolved. A plain number still works and still means
/// physical pixels (`impl<N: UiNum> From<N> for Len`), which is what
/// a hairline wants.
pub radius: Len,
pub thickness: f32,
pub inner_radius: f32,
}
@@ -12,7 +18,7 @@ impl Rect {
pub fn new(color: UiColor) -> Self {
Self {
color,
radius: 0.0,
radius: Len::ZERO,
inner_radius: 0.0,
thickness: 0.0,
}
@@ -21,8 +27,8 @@ impl Rect {
self.color = color;
self
}
pub fn radius(mut self, radius: impl UiNum) -> Self {
self.radius = radius.to_f32();
pub fn radius(mut self, radius: impl Into<Len>) -> Self {
self.radius = radius.into();
self
}
}
@@ -31,15 +37,40 @@ impl Widget for Rect {
fn draw(&mut self, painter: &mut Painter) -> Size {
painter.primitive(RectPrimitive {
color: self.color,
radius: self.radius,
// `rel` has no meaning for a corner (a rect that fills its
// parent has no length of its own to take a fraction of), so
// only the `abs`/`dp` halves are folded.
radius: self.radius.fold_dp(painter.density()).abs,
thickness: self.thickness,
inner_radius: self.inner_radius,
});
Size::REST // fills whatever it was given -- used == available
}
/// **No** -- despite drawing one primitive and nothing else.
///
/// `is_size_independent` asks whether the widget's *content* is
/// unaffected by how big a region it was given, so that
/// `draw_inner` may keep the primitives it already has and rewrite
/// their regions in place. A `Rect`'s content **is** its region: it
/// returns `Size::REST` and fills whatever it was handed, so the fast
/// path's `r.outside(&from).within(&region)` remap has to reproduce
/// the whole of `draw` -- and it does not, because a region carries
/// `rel` and `abs` components that the round trip cannot recover
/// separately.
///
/// What that looked like: a fenced code block's background
/// (`transcript-ui`'s `BlockFrame::Verbatim`, a `Rect` behind a
/// `Pad` in a `Stack`) kept the height of the *provisional* full-
/// region draw `Span` does in its first phase, so one fence's panel
/// covered every block below it -- and every row below that -- while
/// the text itself was laid out correctly. Visible in
/// `docs/bench/p1a-2026-09-06/`'s history and reproduced by this
/// crate's `transcript` example. Answering `false` costs a redraw of
/// one primitive when a rect is resized, which is what the fast path
/// was saving.
fn is_size_independent(&self) -> bool {
true // content never depends on region size
false
}
}
+23
View File
@@ -364,6 +364,29 @@ impl<'a> TextEditCtx<'a> {
self.set_caret(index);
}
/// The byte offset in the text that `pos` (in the same window-space
/// coordinates a `CursorSense` reports, with `size` the region the
/// event was measured against) lands on.
///
/// The one thing a caller outside this module needs to turn a tap into
/// a *range* of the text -- which markdown link is under the finger,
/// which inline-code chip was pressed. `layout()` is private because a
/// caller holding a parley `Layout` could shape it against stale text;
/// this hands back the answer rather than the layout, and does the
/// same region-relative transform [`select`](Self::select) does, so
/// the two cannot disagree about where a point is.
///
/// Parley clamps a point outside the laid-out text to the nearest
/// cursor position, so a tap in the field's padding answers with the
/// nearest offset rather than failing -- a caller wanting "was this
/// actually *on* something" checks its own ranges, which is what
/// makes a tap in the padding hit no link.
pub fn byte_at(&mut self, pos: Vec2, size: Vec2) -> usize {
let pos = pos - self.text.region().top_left().to_abs(size);
let layout = self.layout();
Selection::from_point(layout, pos.x, pos.y).focus().index()
}
pub fn select_all(&mut self) {
let len = self.text.view.buf.text().len();
if len == 0 {
+12 -3
View File
@@ -85,10 +85,19 @@ widget_trait! {
}
fn scrollable(self) -> impl WidgetIdFn<Rsc, Scroll> where Rsc: HasEvents {
self.scrollable_on(Axis::Y)
}
// `scrollable` along `axis`. A code fence pans across its own long
// lines exactly the way a transcript pans down its rows, so the two
// are one function with the axis passed in rather than a second copy
// -- `DragArbiter::on` is the other half. (A `///` doc comment here
// is not accepted by `widget_trait!`, which parses its body itself.)
fn scrollable_on(self, axis: Axis) -> impl WidgetIdFn<Rsc, Scroll> where Rsc: HasEvents {
move |state| {
Scroll::new(self.add_strong(state), Axis::Y)
.on(CursorSense::Scroll, |ctx, rsc| {
let delta = ctx.data.scroll_delta.y * 50.0;
Scroll::new(self.add_strong(state), axis)
.on(CursorSense::Scroll, move |ctx, rsc| {
let delta = ctx.data.scroll_delta.axis(axis) * 50.0;
ctx.widget(rsc).scroll(delta);
})
// A finger drag, through the same `DragGesture` the
+39 -5
View File
@@ -91,14 +91,48 @@ fn synthetic_rows() -> Vec<FoldedRow> {
},
]),
msg(6, true, "Looks good, thanks!"),
msg(
7,
false,
"You're welcome. Let me know if you'd like anything else.",
),
// Every block kind `client_core::markdown_blocks` names, in one
// row, so P1a's appearance can be looked at against the Compose
// app's without a server (docs/RUST.md's P1a box). The heading,
// paragraph, fence and table are the *same source* the bench
// fixture carries (`app/bench-fixture/generate.py`), so the two
// screenshots differ only in the renderer; the list and the quote
// are extra, because the fixture has neither.
msg(7, false, BLOCK_SAMPLER),
]
}
/// One of each markdown block, for the P1a screenshot pair. See
/// [`synthetic_rows`].
const BLOCK_SAMPLER: &str = "\
## What changed
Iris **fold** render measure session window anchor context transcript \
iris measure iris scroll call transcript layout *cursor* context, and a \
[bench](https://example.com/bench) link.
```rust
fn fold_event(items: Vec<Item>, seq: u64) -> Vec<Item> {
// a comment worth keeping: this is the fold the app's own screen runs
let mut out = items;
out.push(Item::new(seq));
out
}
```
| column | value |
|---|---|
| a | measure place draw tool call token context window anchor |
- one bullet
- another, with `inline code`
- nested one level
1. first numbered
2. second numbered
> A quoted line, to show the bar and the indent.
";
impl DefaultAppState for Client {
fn new(
mut ui_state: DefaultUiState,
+16 -6
View File
@@ -1,6 +1,6 @@
//! The transcript screen, in iris -- RUST.md's I5. Built the same way
//! `tabs-ui` is: its own crate, generic over `Rsc: HasEvents` +
//! `Rsc::State: FocusHost`, so the winit example (`iris/examples/
//! `Rsc::State: FocusHost + OpenUrl`, so the winit example (`iris/examples/
//! transcript.rs`) and an eventual `iris-android-app`-style cdylib call the
//! same [`build`]. See RUST.md's I5 box for the full account of what is
//! and is not proved yet, and this doc for the shape.
@@ -83,7 +83,7 @@ impl TranscriptScreen {
/// newest content when it already was (I3).
pub fn push_row<Rsc: HasEvents>(&self, rsc: &mut Rsc, row: &FoldedRow)
where
Rsc::State: FocusHost,
Rsc::State: FocusHost + OpenUrl,
{
let (key, widget, blocks) = row::build_row(rsc, self.list, self.selection.clone(), row);
(self.list)(rsc).push_back(ListRow::new(key, widget));
@@ -97,7 +97,7 @@ impl TranscriptScreen {
/// `Single`, or a change `RowBlocks::apply_delta` will not take.
fn apply_tail_delta<Rsc: HasEvents>(&self, rsc: &mut Rsc, key: RowKey, row: &FoldedRow) -> bool
where
Rsc::State: FocusHost,
Rsc::State: FocusHost + OpenUrl,
{
let FoldedRow::Single(item) = row else {
return false;
@@ -156,7 +156,7 @@ impl TranscriptScreen {
old: &[client_core::transcript_fold::TranscriptItem],
new: &[client_core::transcript_fold::TranscriptItem],
) where
Rsc::State: FocusHost,
Rsc::State: FocusHost + OpenUrl,
{
use client_core::transcript_fold::group_tool_runs;
@@ -246,7 +246,7 @@ pub fn build<Rsc: HasEvents>(
rows: Vec<FoldedRow>,
) -> TranscriptScreen
where
Rsc::State: FocusHost,
Rsc::State: FocusHost + OpenUrl,
{
let (screen, tree) = build_tree(rsc, rows);
ui_state.set_root(tree);
@@ -265,7 +265,7 @@ pub fn build_tree<Rsc: HasEvents>(
rows: Vec<FoldedRow>,
) -> (TranscriptScreen, StrongWidget)
where
Rsc::State: FocusHost,
Rsc::State: FocusHost + OpenUrl,
{
let selection = Rc::new(RefCell::new(Selection::new()));
let list = List::new(Axis::Y).add(rsc);
@@ -504,6 +504,16 @@ mod apply_tests {
struct TestFocus {
focus: Option<WeakWidget<TextEdit>>,
}
/// The headless stand-in for `iris::platform::OpenUrl`'s real
/// backends. Nothing in these tests taps a link -- the tap-vs-drag
/// rule that decides whether one is followed is `iris`'s own
/// (`sense_tests.rs`'s `a_press_released_without_moving_is_a_tap`),
/// and which link is under a byte offset is `markdown.rs`'s -- so
/// this only exists to satisfy the bound.
impl OpenUrl for TestFocus {
fn open_url(&mut self, _url: &str) {}
}
impl FocusHost for TestFocus {
fn recent_click(&mut self) -> bool {
false
+644 -85
View File
@@ -1,14 +1,25 @@
//! Markdown -> one plain string plus a `Vec<SpanStyle>`, for I5's row
//! builder to hand to a single `TextEdit` (`row.rs`). This is the crate's
//! answer to RUST.md's E2 finding against Masonry ("rich inline text --
//! block-level yes, inline no, and both for the same reason": `TextArea`'s
//! `StyleSet` is one style for the whole editor,
//! One markdown **block** (`client_core::markdown_blocks::Block`) rendered
//! for display: the plain text to draw, the [`SpanStyle`]s that style it,
//! the links inside it, and the [`BlockFrame`] the row builder puts around
//! it.
//!
//! This is the crate's answer to RUST.md's E2 finding against Masonry
//! ("rich inline text -- block-level yes, inline no, and both for the same
//! reason": `TextArea`'s `StyleSet` is one style for the whole editor,
//! `masonry/src/widgets/text_area.rs:43-44`'s `// TODO: RichTextInput`
//! beside it). iris's `SpanStyle` (`core/src/primitive/text.rs`, added for
//! this box) is per-range, so bold/italic/inline-code/links/headings inside
//! one wrapped paragraph render in their own style *and* the paragraph
//! still wraps and selects as one buffer -- there is no second widget per
//! span the way E2's block-level `Prose`-per-heading was.
//! beside it). iris's `SpanStyle` (`core/src/primitive/text.rs`) is
//! per-range, so bold/italic/inline-code/links inside one wrapped
//! paragraph render in their own style *and* the paragraph still wraps and
//! selects as one buffer.
//!
//! **Three widget shapes, not one per markdown feature** ([`BlockFrame`]).
//! A heading, a paragraph and a list are all *text with spans*; a fence
//! and a table are *verbatim text on a dark surface that pans sideways*;
//! a quote is *text behind a coloured bar*. Everything else markdown can
//! say is expressed in the spans, which cost no widgets and no layout
//! nodes. `app/.../Markdown.kt`'s component table is the reference for the
//! sizes and colours; docs/DECISIONS.md's 2026-09-06 entry records where
//! this deliberately differs.
//!
//! **What this deliberately does not attempt**, each for a reason recorded
//! here rather than silently dropped (see IRIS_TODO.md's dated entries for
@@ -16,52 +27,190 @@
//! - **No background chip behind inline code.** Drawing one needs the
//! glyph run's own geometry (the way `TextEdit::draw`'s selection
//! highlight uses `selection.geometry(layout)`,
//! `iris/src/widget/text/edit.rs:99`), which is `TextEdit`-internal and
//! not exposed to a caller building spans externally. `SpanStyle` gives
//! the code range a monospace family and a dimmer text colour instead --
//! visually distinct, just not chip-shaped.
//! - **A link is styled (colour + underline) but not tappable.** Following
//! it needs the same kind of per-range hit-testing a chip's background
//! would (which byte range did the tap land in, then look up its URL),
//! which is exactly the same missing primitive.
//! - **Tables render as plain paragraphs of their cell text**, no columns.
//! `pulldown_cmark::Tag::Table` is walked but not laid out -- a real grid
//! needs its own widget, out of scope for a row builder.
//! - **A fenced code block's language is not syntax-highlighted.**
//! `client-core::highlight` exists and could feed per-token `SpanStyle`s,
//! but wiring it in is real work belonging to whoever needs it next
//! (IRIS_TODO.md).
//! `iris/src/widget/text/edit.rs:99`), which is `TextEdit`-internal.
//! `SpanStyle` gives the code range a monospace family and the
//! palette's code colour instead -- visually distinct, just not
//! chip-shaped.
//! - **A list's indent is written in spaces**, not measured. Compose lays
//! an item out as a marker column beside a text column, which keeps a
//! wrapped second line aligned under the first; here the marker is part
//! of the same buffer, so a wrapped line returns to the left margin.
//! Doing better needs per-line indent in `TextAttrs`, which nothing else
//! wants yet.
//!
//! A heading's `SpanStyle::font_size` override does not also raise its
//! `line_height` (a buffer has one, set from the *base* font size in
//! `TextAttrs`), so a heading's own line looks slightly tighter than a
//! paragraph's -- visible, not incorrect, and not fixed here since it needs
//! `SpanStyle` to carry line-height too, which nothing in this crate needed
//! badly enough yet to justify.
//! paragraph's -- visible, not incorrect, and not fixed here since it
//! needs `SpanStyle` to carry line-height too.
use client_core::highlight::{self, Kind, Language};
use client_core::markdown_blocks::{Block, BlockKind};
use iris::prelude::*;
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use std::ops::Range;
// `UiColor` is `Color<u8>` (`core/src/lib.rs`), not the 0..1 float triples
// its brighter/darker helpers might suggest -- these are plain 0..255 RGB.
pub const CODE_COLOR: UiColor = UiColor::new(140, 217, 242, 255);
pub const LINK_COLOR: UiColor = UiColor::new(140, 190, 255, 255);
const STRIKETHROUGH_COLOR: UiColor = UiColor::new(150, 150, 150, 255);
// Catppuccin Mocha, the same values `app/.../Theme.kt` maps onto
// Material's roles, so a block drawn here and the same block drawn by the
// Compose app are the same colour rather than nearly.
const fn mocha(hex: u32) -> UiColor {
UiColor::new(
((hex >> 16) & 0xff) as u8,
((hex >> 8) & 0xff) as u8,
(hex & 0xff) as u8,
255,
)
}
/// A block-level separator: two blocks never run into each other with no
/// gap, but an empty `out` (the very first block) gets no leading blank.
/// Body text: Mocha Text, the Compose app's `onSurface`.
pub const TEXT_COLOR: UiColor = mocha(0xCDD6F4);
/// Inline code, and a fence with no language to highlight it by.
pub const CODE_COLOR: UiColor = mocha(0xCDD6F4);
/// A link. "Blue is what a link is on every Catppuccin surface, and the
/// one colour to leave alone" (`Theme.kt`'s `linkColor`).
pub const LINK_COLOR: UiColor = mocha(0x89B4FA);
/// A list's bullets and numbers: structure rather than words, so the
/// items of a list can be counted without reading them (`listMarkerColor`).
pub const MARKER_COLOR: UiColor = mocha(0xB4BEFE);
/// What every verbatim thing in this app sits on -- Mocha Crust, one step
/// *below* the page rather than above it (`Theme.kt`'s `rawSurface`).
pub const VERBATIM_BACKGROUND: UiColor = mocha(0x11111B);
/// A table's fill: Surface 0, the Compose app's `surfaceVariant`.
pub const TABLE_BACKGROUND: UiColor = mocha(0x313244);
/// A quote's bar and its text: the bar carries the structure, and the
/// words step back one shade from body text so a quote reads as quoted
/// without being hard to read.
pub const QUOTE_BAR_COLOR: UiColor = mocha(0x585B70);
pub const QUOTE_TEXT_COLOR: UiColor = mocha(0xA6ADC8);
const STRIKETHROUGH_COLOR: UiColor = mocha(0x6C7086);
/// Catppuccin Mocha as the highlighter's palette -- the same mapping
/// `Theme.kt`'s `catppuccinSyntax()` uses, so a `kotlin` fence is the same
/// colours in both apps.
fn syntax_color(kind: Kind) -> UiColor {
match kind {
Kind::Keyword => mocha(0xCBA6F7),
Kind::String => mocha(0xA6E3A1),
Kind::Literal => mocha(0xFAB387),
Kind::Comment => mocha(0x6C7086),
Kind::Metadata => mocha(0xF9E2AF),
Kind::Punctuation => mocha(0xA6ADC8),
Kind::Mark => mocha(0x89DCEB),
}
}
/// What a row builder puts *around* a block's text widget. Three, not one
/// per markdown feature -- see the module doc.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BlockFrame {
/// Text and nothing else: a paragraph, a heading, a list, a rule.
Plain,
/// A dark rounded panel whose text does not wrap -- long lines pan
/// sideways, the way `CodeFence.kt`'s `horizontalScroll` does. Carries
/// its own fill, since a fence and a table are drawn on different
/// ones.
Verbatim { fill: UiColor },
/// A coloured bar down the left edge and an indent past it.
Quote,
}
/// The frame a block kind is drawn in. Pure, and the *only* place the
/// mapping is written: a new `BlockKind` shows up here as a compile error
/// rather than silently taking prose's appearance.
pub fn frame_of(kind: BlockKind) -> BlockFrame {
match kind {
BlockKind::Code => BlockFrame::Verbatim {
fill: VERBATIM_BACKGROUND,
},
BlockKind::Table => BlockFrame::Verbatim {
fill: TABLE_BACKGROUND,
},
BlockKind::Quote => BlockFrame::Quote,
BlockKind::Paragraph | BlockKind::Heading | BlockKind::List | BlockKind::Other => {
BlockFrame::Plain
}
}
}
/// A tappable range of a block's text and where it points.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Link {
/// Byte range into [`Rendered::text`].
pub range: Range<usize>,
pub url: String,
}
/// One block, ready to draw. Not `Debug`: `SpanStyle` is not, and adding
/// it there for this would be a change to iris for a test's benefit.
#[derive(Clone, Default)]
pub struct Rendered {
pub text: String,
pub spans: Vec<SpanStyle>,
pub links: Vec<Link>,
}
impl Rendered {
/// The link `byte` falls inside, if any -- what a tap resolves
/// through. Half-open, so the offset one past a link's last character
/// (where a tap just after it lands) is *not* in it.
pub fn link_at(&self, byte: usize) -> Option<&Link> {
self.links.iter().find(|l| l.range.contains(&byte))
}
}
/// The heading ladder, in points at a 16pt body: it starts near the body
/// text and descends, because these are headings inside a chat message
/// rather than the top of a document. The numbers are Material's
/// `headlineSmall`/`titleLarge`/`titleMedium`/`titleSmall`/`labelMedium`/
/// `labelSmall`, which is what `Markdown.kt`'s `markdownTypography` picks
/// -- kept as literals rather than derived from `base_size` so the two
/// apps agree exactly.
fn heading_size(level: HeadingLevel) -> f32 {
match level {
HeadingLevel::H1 => 24.0,
HeadingLevel::H2 => 22.0,
HeadingLevel::H3 => 16.0,
HeadingLevel::H4 => 14.0,
HeadingLevel::H5 => 12.0,
HeadingLevel::H6 => 11.0,
}
}
/// The bullet at each depth, cycling past the third: a disc, a ring, a
/// square -- the ladder a browser draws, so a nested list is told from its
/// parent by the glyph as well as by the indent. Same three
/// `MarkdownPieces.kt` uses.
const BULLETS: [&str; 3] = ["\u{2022} ", "\u{25e6} ", "\u{25aa} "];
/// A block-level separator inside one block's own text (a list item's
/// paragraphs, a quote's): two never run into each other with no gap, but
/// an empty `out` gets no leading blank.
fn ensure_blank_line(out: &mut String) {
if !out.is_empty() && !out.ends_with("\n\n") {
while out.ends_with('\n') {
out.pop();
}
out.push_str("\n\n");
}
}
fn heading_size(level: HeadingLevel) -> f32 {
match level {
HeadingLevel::H1 => 28.0,
HeadingLevel::H2 => 24.0,
HeadingLevel::H3 => 21.0,
_ => 19.0,
fn ensure_line(out: &mut String) {
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
}
/// One top-level block, rendered. `base_size` is the row's ordinary
/// paragraph font size; a heading overrides it per span.
pub fn render_block(block: &Block, base_size: f32) -> Rendered {
match block.kind {
// A table is the one block markdown states as a grid and iris has
// no grid widget for. Rendered as padded monospace instead --
// see [`table_text`].
BlockKind::Table => table_text(&block.source),
_ => render_markdown(&block.source, base_size),
}
}
@@ -69,19 +218,27 @@ fn heading_size(level: HeadingLevel) -> f32 {
/// style it. `base_size` is the row's ordinary paragraph font size, needed
/// only so a heading's override is relative to it rather than a hardcoded
/// absolute the caller cannot retune.
pub fn render_markdown(src: &str, base_size: f32) -> (String, Vec<SpanStyle>) {
let _ = base_size; // headings use fixed sizes today; kept for callers that may want relative sizing later
pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
let _ = base_size; // headings use the fixed Material ladder; see `heading_size`
let mut out = String::new();
let mut spans = Vec::new();
let mut links = Vec::new();
// Stack of start byte offsets for whatever inline/block styling is
// currently open -- pulldown-cmark's `Start`/`End` events are always
// balanced and each `End` already names its own kind (`TagEnd`), so a
// plain offset stack (rather than a tree, or repeating the kind here
// too) is enough.
let mut open: Vec<usize> = Vec::new();
let mut list_depth: u32 = 0;
// too) is enough. A link's destination rides along beside its offset,
// since `TagEnd::Link` does not carry it.
let mut open: Vec<(usize, Option<String>)> = Vec::new();
// One entry per open list: `Some(next number)` for an ordered list,
// `None` for a bulleted one. Depth is this vector's length, which is
// what picks the bullet glyph.
let mut lists: Vec<Option<u64>> = Vec::new();
// The language of the fence currently open, so `TagEnd::CodeBlock` can
// highlight what was collected between the two.
let mut fence_language: Option<Language> = None;
let parser = Parser::new_ext(src, Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES);
let parser = Parser::new_ext(src, options());
for event in parser {
match event {
Event::Start(tag) => match tag {
@@ -89,16 +246,35 @@ pub fn render_markdown(src: &str, base_size: f32) -> (String, Vec<SpanStyle>) {
| Tag::Emphasis
| Tag::Strong
| Tag::Strikethrough
| Tag::Link { .. } => open.push(out.len()),
Tag::CodeBlock(_) => {
| Tag::Image { .. } => open.push((out.len(), None)),
Tag::Link { dest_url, .. } => open.push((out.len(), Some(dest_url.to_string()))),
Tag::CodeBlock(kind) => {
fence_language = match &kind {
CodeBlockKind::Fenced(info) => {
// Only the first word: "rust,ignore" and
// "console session" are both written.
highlight::fence_language(info.split_whitespace().next())
}
CodeBlockKind::Indented => None,
};
ensure_blank_line(&mut out);
open.push(out.len());
open.push((out.len(), None));
}
Tag::Item => {
out.push_str(&" ".repeat(list_depth.saturating_sub(1) as usize));
out.push_str("\u{2022} ");
ensure_line(&mut out);
let depth = lists.len().max(1);
out.push_str(&" ".repeat(depth - 1));
let start = out.len();
match lists.last_mut() {
Some(Some(n)) => {
out.push_str(&format!("{n}. "));
*n += 1;
}
_ => out.push_str(BULLETS[(depth - 1) % BULLETS.len()]),
}
spans.push(SpanStyle::new(start..out.len()).color(MARKER_COLOR));
}
Tag::List(_) => list_depth += 1,
Tag::List(first) => lists.push(first),
Tag::Paragraph | Tag::BlockQuote(_) => ensure_blank_line(&mut out),
_ => {}
},
@@ -113,11 +289,19 @@ pub fn render_markdown(src: &str, base_size: f32) -> (String, Vec<SpanStyle>) {
| TagEnd::Strong
| TagEnd::Strikethrough
| TagEnd::Link
| TagEnd::Image
| TagEnd::CodeBlock),
) => {
let Some(start) = open.pop() else {
let Some((start, dest)) = open.pop() else {
continue;
};
if matches!(tag_end, TagEnd::CodeBlock) {
// A fence's trailing newline is the fence marker's, not
// the code's -- kept and it draws an empty last line.
while out.ends_with('\n') {
out.pop();
}
}
let range = start..out.len();
if range.is_empty() {
continue;
@@ -131,15 +315,27 @@ pub fn render_markdown(src: &str, base_size: f32) -> (String, Vec<SpanStyle>) {
TagEnd::Strikethrough => {
spans.push(SpanStyle::new(range).color(STRIKETHROUGH_COLOR));
}
TagEnd::Link => {
spans.push(SpanStyle::new(range).color(LINK_COLOR).underline());
// An image draws as its alt text until the port has a
// transcript image widget (IRIS_TODO's "scaled
// thumbnail"); marked as a link so it is at least
// followable rather than silently inert.
TagEnd::Link | TagEnd::Image => {
spans.push(SpanStyle::new(range.clone()).color(LINK_COLOR).underline());
if let Some(url) = dest {
links.push(Link { range, url });
}
}
TagEnd::CodeBlock => {
spans.push(
SpanStyle::new(range)
SpanStyle::new(range.clone())
.family(Family::Monospace)
.color(CODE_COLOR),
);
// After the monospace span, so the per-token
// colours win where they overlap it.
if let Some(language) = fence_language.take() {
highlight_into(&mut spans, &out, range, language);
}
}
_ => unreachable!("filtered by the outer match arm"),
}
@@ -160,64 +356,427 @@ pub fn render_markdown(src: &str, base_size: f32) -> (String, Vec<SpanStyle>) {
Event::SoftBreak => out.push(' '),
Event::HardBreak => out.push('\n'),
Event::Rule => {
if !out.ends_with('\n') {
out.push('\n');
}
ensure_line(&mut out);
out.push_str("\u{2500}\u{2500}\u{2500}\n");
}
Event::End(TagEnd::List(_)) => list_depth = list_depth.saturating_sub(1),
Event::TaskListMarker(done) => {
let start = out.len();
out.push_str(if done { "[x] " } else { "[ ] " });
spans.push(SpanStyle::new(start..out.len()).color(MARKER_COLOR));
}
Event::End(TagEnd::List(_)) => {
lists.pop();
}
_ => {}
}
}
(out, spans)
while out.ends_with('\n') {
out.pop();
}
// A span left pointing past the text a later trim shortened would draw
// against nothing; markdown that ends inside an open emphasis is
// ordinary mid-stream input, not a defect.
spans.retain(|s| s.range.end <= out.len());
links.retain(|l| l.range.end <= out.len());
Rendered {
text: out,
spans,
links,
}
}
/// The same option set `client_core::markdown_blocks` splits with, so a
/// block boundary there and the styling here cannot disagree about what
/// the source means.
fn options() -> Options {
Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS
}
/// `client_core::highlight`'s spans for the code at `range` inside `text`,
/// appended to `spans`.
///
/// The highlighter indexes **chars** and `SpanStyle` indexes **bytes**
/// (`highlight`'s module doc), so the offsets are walked once rather than
/// converted per span -- a fence is scanned on every delta that lands in
/// it, and it is the only block a delta re-renders.
fn highlight_into(spans: &mut Vec<SpanStyle>, text: &str, range: Range<usize>, language: Language) {
let code = &text[range.clone()];
// char index -> byte offset within `code`, plus the end, so a span's
// `end` is always in range.
let bytes: Vec<usize> = code
.char_indices()
.map(|(i, _)| i)
.chain(std::iter::once(code.len()))
.collect();
for span in highlight::spans_of(code, language) {
let (Some(&start), Some(&end)) = (bytes.get(span.start), bytes.get(span.end)) else {
debug_assert!(
false,
"highlight span {}..{} outside {} chars of code",
span.start,
span.end,
bytes.len() - 1
);
continue;
};
spans.push(
SpanStyle::new(range.start + start..range.start + end)
.family(Family::Monospace)
.color(syntax_color(span.kind)),
);
}
}
/// The widest a table column is allowed to get before its cells wrap
/// inside it, in characters. Chosen the way `Markdown.kt`'s 136dp
/// `tableCellWidth` was -- what fits three columns across a phone -- but
/// counted in monospace characters, which is the unit a padded table has:
/// three 28-character columns plus separators is about 90 characters,
/// which is what a 16pt mono face gives on a 1080px phone before the
/// sideways pan starts.
const TABLE_MAX_COL: usize = 28;
/// A GFM table as **padded monospace columns**, with the header bold and a
/// rule under it.
///
/// iris has no grid widget, and building one for the one block kind that
/// needs it would be a widget per markdown feature -- what this crate's
/// module doc says it will not do. A monospace face makes character counts
/// and pixel widths the same thing, so padding each cell to its column's
/// width *is* alignment, the column widths are measured from the cells,
/// and the block reuses `BlockFrame::Verbatim`'s sideways pan for a table
/// too wide to fit. docs/DECISIONS.md, 2026-09-06, has what this trades.
pub fn table_text(src: &str) -> Rendered {
let rows = table_cells(src);
if rows.is_empty() {
return Rendered::default();
}
let columns = rows.iter().map(Vec::len).max().unwrap_or(0);
// Each cell wrapped to the cap first, so a column's width is the
// widest *line* it will actually draw rather than the longest cell.
let wrapped: Vec<Vec<Vec<String>>> = rows
.iter()
.map(|row| row.iter().map(|c| wrap_cell(c, TABLE_MAX_COL)).collect())
.collect();
let widths: Vec<usize> = (0..columns)
.map(|c| {
wrapped
.iter()
.filter_map(|row| row.get(c))
.flat_map(|lines| lines.iter())
.map(|l| l.chars().count())
.max()
.unwrap_or(0)
})
.collect();
let mut out = String::new();
let mut spans = Vec::new();
for (r, row) in wrapped.iter().enumerate() {
let height = row.iter().map(Vec::len).max().unwrap_or(1);
let start = out.len();
for line in 0..height {
if !out.is_empty() {
out.push('\n');
}
for (c, width) in widths.iter().enumerate() {
if c > 0 {
out.push_str(" ");
}
let text = row.get(c).and_then(|l| l.get(line)).map(String::as_str);
let text = text.unwrap_or("");
out.push_str(text);
// The last column is not padded: trailing spaces widen
// the block's measured width for nothing.
if c + 1 < widths.len() {
for _ in text.chars().count()..*width {
out.push(' ');
}
}
}
}
if r == 0 {
spans.push(SpanStyle::new(start..out.len()).bold());
out.push('\n');
let rule: usize = widths.iter().sum::<usize>() + 2 * widths.len().saturating_sub(1);
let rule_start = out.len();
out.extend(std::iter::repeat_n('\u{2500}', rule));
spans.push(SpanStyle::new(rule_start..out.len()).color(QUOTE_BAR_COLOR));
}
}
Rendered {
text: out,
spans,
links: Vec::new(),
}
}
/// The cells of a GFM table, row by row, as their plain text.
fn table_cells(src: &str) -> Vec<Vec<String>> {
let mut rows: Vec<Vec<String>> = Vec::new();
let mut cell = String::new();
let mut in_cell = false;
for event in Parser::new_ext(src, options()) {
match event {
Event::Start(Tag::TableHead) | Event::Start(Tag::TableRow) => rows.push(Vec::new()),
Event::Start(Tag::TableCell) => {
cell.clear();
in_cell = true;
}
Event::End(TagEnd::TableCell) => {
in_cell = false;
if let Some(row) = rows.last_mut() {
row.push(cell.trim().to_string());
}
}
Event::Text(text) | Event::Code(text) if in_cell => cell.push_str(&text),
Event::SoftBreak | Event::HardBreak if in_cell => cell.push(' '),
_ => {}
}
}
rows.retain(|r| !r.is_empty());
rows
}
/// `text` broken onto lines of at most `width` characters, at spaces where
/// there are any. A word longer than the column is left over-long rather
/// than cut mid-word: the column then widens for it, which is visible and
/// correct, where cutting would silently lose characters.
fn wrap_cell(text: &str, width: usize) -> Vec<String> {
let mut lines = Vec::new();
let mut line = String::new();
for word in text.split_whitespace() {
let extra = if line.is_empty() { 0 } else { 1 };
if !line.is_empty() && line.chars().count() + extra + word.chars().count() > width {
lines.push(std::mem::take(&mut line));
}
if !line.is_empty() {
line.push(' ');
}
line.push_str(word);
}
lines.push(line);
lines
}
#[cfg(test)]
mod tests {
use super::*;
use client_core::markdown_blocks::split_blocks;
fn block(src: &str) -> Rendered {
let blocks = split_blocks(src);
assert_eq!(blocks.len(), 1, "test wants exactly one block: {blocks:?}");
render_block(&blocks[0], 16.0)
}
#[test]
fn plain_paragraph_has_no_spans() {
let (text, spans) = render_markdown("just some words", 16.0);
assert_eq!(text, "just some words");
assert!(spans.is_empty());
let r = render_markdown("just some words", 16.0);
assert_eq!(r.text, "just some words");
assert!(r.spans.is_empty());
}
#[test]
fn bold_and_italic_produce_spans_over_the_right_range() {
let (text, spans) = render_markdown("a **bold** and *italic* word", 16.0);
assert_eq!(text, "a bold and italic word");
let bold = spans.iter().find(|s| s.bold && !s.italic).unwrap();
assert_eq!(&text[bold.range.clone()], "bold");
let italic = spans.iter().find(|s| s.italic).unwrap();
assert_eq!(&text[italic.range.clone()], "italic");
let r = render_markdown("a **bold** and *italic* word", 16.0);
assert_eq!(r.text, "a bold and italic word");
let bold = r.spans.iter().find(|s| s.bold && !s.italic).unwrap();
assert_eq!(&r.text[bold.range.clone()], "bold");
let italic = r.spans.iter().find(|s| s.italic).unwrap();
assert_eq!(&r.text[italic.range.clone()], "italic");
}
#[test]
fn heading_gets_a_bigger_font_size_span() {
let (text, spans) = render_markdown("# A Title\n\nbody text", 16.0);
assert!(text.starts_with("A Title"));
let heading = spans.iter().find(|s| s.font_size.is_some()).unwrap();
assert_eq!(&text[heading.range.clone()], "A Title");
assert_eq!(heading.font_size, Some(28.0));
let r = render_markdown("# A Title", 16.0);
assert!(r.text.starts_with("A Title"));
let heading = r.spans.iter().find(|s| s.font_size.is_some()).unwrap();
assert_eq!(&r.text[heading.range.clone()], "A Title");
assert_eq!(heading.font_size, Some(24.0));
}
/// Every level draws at its own size, so two levels of nesting are
/// never the same -- `Markdown.kt`'s reason for the ladder.
#[test]
fn every_heading_level_is_a_different_size() {
let mut sizes = Vec::new();
for level in 1..=6 {
let src = format!("{} h", "#".repeat(level));
let r = render_markdown(&src, 16.0);
sizes.push(r.spans.iter().find_map(|s| s.font_size).unwrap());
}
let mut sorted = sizes.clone();
sorted.sort_by(|a, b| b.partial_cmp(a).unwrap());
sorted.dedup();
assert_eq!(sizes, sorted, "the ladder must descend with no repeats");
}
#[test]
fn link_is_styled_and_keeps_its_visible_text() {
let (text, spans) = render_markdown("see [the docs](https://example.com) for more", 16.0);
assert!(text.contains("the docs"));
fn a_link_keeps_its_text_and_its_url_and_can_be_hit() {
let r = render_markdown("see [the docs](https://example.com) for more", 16.0);
assert!(r.text.contains("the docs"));
assert!(
!text.contains("example.com"),
!r.text.contains("example.com"),
"the URL should not leak into the visible text"
);
let link = spans.iter().find(|s| s.underline).unwrap();
assert_eq!(&text[link.range.clone()], "the docs");
let link = r.spans.iter().find(|s| s.underline).unwrap();
assert_eq!(&r.text[link.range.clone()], "the docs");
let at = r.text.find("docs").unwrap();
assert_eq!(r.link_at(at).unwrap().url, "https://example.com");
assert!(r.link_at(0).is_none(), "the word 'see' is not the link");
let past = r.text.find("for").unwrap();
assert!(r.link_at(past).is_none());
}
#[test]
fn fenced_code_block_is_monospaced() {
let (text, spans) = render_markdown("before\n\n```\nlet x = 1;\n```\n\nafter", 16.0);
let code = spans.iter().find(|s| s.family.is_some()).unwrap();
assert!(text[code.range.clone()].contains("let x = 1;"));
fn fenced_code_block_is_monospaced_and_highlighted_by_its_language() {
let r = block("```rust\nlet x = 1; // note\n```");
assert_eq!(r.text, "let x = 1; // note");
let keyword = r
.spans
.iter()
.find(|s| s.color == Some(syntax_color(Kind::Keyword)))
.expect("a rust fence colours its keywords");
assert_eq!(&r.text[keyword.range.clone()], "let");
let comment = r
.spans
.iter()
.find(|s| s.color == Some(syntax_color(Kind::Comment)))
.unwrap();
assert_eq!(&r.text[comment.range.clone()], "// note");
assert!(r.spans.iter().all(|s| s.range.end <= r.text.len()));
}
/// The half the change had no reason to touch: a fence in a language
/// the highlighter has no rules for must be plain rather than
/// coloured by the nearest language's (`CodeFence.kt`'s
/// `fenceLanguage` doc).
#[test]
fn a_fence_in_an_unknown_language_is_monospace_and_uncoloured() {
let r = block("```brainfuck\nlet x = 1;\n```");
assert_eq!(r.text, "let x = 1;");
assert_eq!(r.spans.len(), 1);
// `Family` is not `Debug`, so this is `assert!` rather than
// `assert_eq!`.
assert!(r.spans[0].family == Some(Family::Monospace));
assert_eq!(r.spans[0].color, Some(CODE_COLOR));
}
/// Multi-byte characters are where a char-indexed highlighter and a
/// byte-indexed span list disagree if the conversion is missing.
#[test]
fn highlight_spans_are_byte_offsets_even_with_multibyte_code() {
let r = block("```rust\nlet s = \"café ☕\"; // é\n```");
for span in &r.spans {
assert!(
r.text.is_char_boundary(span.range.start)
&& r.text.is_char_boundary(span.range.end),
"span {:?} is not on a char boundary of {:?}",
span.range,
r.text
);
}
let string = r
.spans
.iter()
.find(|s| s.color == Some(syntax_color(Kind::String)))
.unwrap();
assert_eq!(&r.text[string.range.clone()], "\"café ☕\"");
}
#[test]
fn an_unterminated_fence_still_renders_what_arrived() {
let r = block("```rust\nlet x = 1;");
assert_eq!(r.text, "let x = 1;");
assert!(
r.spans
.iter()
.any(|s| s.color == Some(syntax_color(Kind::Keyword)))
);
}
#[test]
fn a_bulleted_list_gets_a_marker_per_item_and_indents_nesting() {
let r = block("- one\n- two\n - deep");
assert_eq!(r.text, "\u{2022} one\n\u{2022} two\n \u{25e6} deep");
let markers: Vec<_> = r
.spans
.iter()
.filter(|s| s.color == Some(MARKER_COLOR))
.map(|s| r.text[s.range.clone()].to_string())
.collect();
assert_eq!(markers, ["\u{2022} ", "\u{2022} ", "\u{25e6} "]);
}
#[test]
fn a_numbered_list_counts_from_the_number_it_was_written_with() {
let r = block("3. three\n4. four");
assert_eq!(r.text, "3. three\n4. four");
let markers: Vec<_> = r
.spans
.iter()
.filter(|s| s.color == Some(MARKER_COLOR))
.map(|s| r.text[s.range.clone()].to_string())
.collect();
assert_eq!(markers, ["3. ", "4. "]);
}
#[test]
fn a_quote_is_its_text_and_takes_the_quote_frame() {
let blocks = split_blocks("> quoted words\n> still quoted");
assert_eq!(frame_of(blocks[0].kind), BlockFrame::Quote);
let r = render_block(&blocks[0], 16.0);
assert_eq!(r.text, "quoted words still quoted");
}
#[test]
fn each_block_kind_maps_to_the_frame_it_is_drawn_in() {
use BlockKind::*;
assert_eq!(frame_of(Paragraph), BlockFrame::Plain);
assert_eq!(frame_of(Heading), BlockFrame::Plain);
assert_eq!(frame_of(List), BlockFrame::Plain);
assert_eq!(frame_of(Other), BlockFrame::Plain);
assert_eq!(frame_of(Quote), BlockFrame::Quote);
assert!(matches!(frame_of(Code), BlockFrame::Verbatim { .. }));
assert!(matches!(frame_of(Table), BlockFrame::Verbatim { .. }));
assert_ne!(
frame_of(Code),
frame_of(Table),
"a fence and a table sit on different fills"
);
}
#[test]
fn a_table_pads_its_columns_to_the_widest_cell() {
let r = block("| a | bb |\n|---|---|\n| cccc | d |");
let lines: Vec<&str> = r.text.lines().collect();
assert_eq!(lines[0], "a bb");
assert_eq!(lines[1], "\u{2500}".repeat(8));
assert_eq!(lines[2], "cccc d");
let bold = r.spans.iter().find(|s| s.bold).unwrap();
assert_eq!(&r.text[bold.range.clone()], "a bb");
}
/// The fixture's own table shape: a long cell wraps inside its column
/// instead of making the row one enormous line.
#[test]
fn a_long_table_cell_wraps_inside_its_column() {
let long = "one two three four five six seven eight nine ten eleven twelve";
let r = block(&format!("| k | v |\n|---|---|\n| a | {long} |"));
for line in r.text.lines() {
assert!(
line.chars().count() <= TABLE_MAX_COL + 1 + 2 + 1,
"line too wide: {line:?}"
);
}
assert!(r.text.contains("twelve"));
}
#[test]
fn a_task_list_marks_its_boxes() {
let r = block("- [x] done\n- [ ] not");
assert!(r.text.contains("[x] done"));
assert!(r.text.contains("[ ] not"));
}
}
+145 -38
View File
@@ -23,7 +23,7 @@
//! collapsed summary and the full detail -- the same two-step contract
//! `list.rs`'s module doc describes for `AGENTS.md`'s `holdTopEdge`.
use crate::markdown::render_markdown;
use crate::markdown::{BlockFrame, Link, frame_of, render_block};
use crate::selection::{SelKey, Selection};
use client_core::markdown_blocks::{Block, BlockKind, common_prefix, split_blocks};
use client_core::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
@@ -133,6 +133,10 @@ pub struct RowBlocks {
/// comparison and not an assumption.
blocks: Vec<Block>,
fields: Vec<WeakWidget<TextEdit>>,
/// Each block's links, shared with its own tap handler so a delta
/// replaces what the handler reads instead of re-registering it.
/// One entry per field, which `apply_delta` asserts.
links: Vec<Rc<RefCell<Vec<Link>>>>,
column: WeakWidget<Span>,
/// The sender label the row was built with. A delta that changes it is
/// not a delta into the same message, so it falls back to a rebuild.
@@ -154,30 +158,63 @@ fn display_blocks(markdown_src: &str) -> Vec<Block> {
}
}
/// The room a fence or a table's text gets inside its panel, and the
/// gap between a quote's bar and its words. `CodeFence.kt` charges the
/// renderer's `codeBlock` padding inside the tinted box and 8dp above and
/// below it; the vertical half is `BLOCK_GAP_DP`'s job here, since the
/// column already separates blocks.
const FRAME_PAD_DP: f32 = 10.0;
/// The bar down a quote's left edge.
const QUOTE_BAR_DP: f32 = 3.0;
/// A verbatim panel's corner, matching the renderer's own rounded fence.
const FRAME_RADIUS_DP: f32 = 8.0;
/// One block's own `TextEdit`, registered with `selection` under
/// `(row, block)` and wired to `Selection::drag` -- the block is the
/// selection unit (`selection::SelKey`).
fn build_block_field<Rsc: HasEvents>(
/// selection unit (`selection::SelKey`) -- plus whatever
/// [`BlockFrame`] its kind is drawn in.
///
/// Returns the field (which `apply_delta` writes into), the widget the
/// column actually holds (the field, or the field inside its frame), and
/// the block's links, shared with the tap handler so a delta can replace
/// them without rebuilding the handler.
fn build_block<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<List>,
selection: Rc<RefCell<Selection>>,
key: SelKey,
source: &str,
) -> WeakWidget<TextEdit>
block: &Block,
) -> (WeakWidget<TextEdit>, StrongWidget, Rc<RefCell<Vec<Link>>>)
where
Rsc::State: FocusHost,
Rsc::State: FocusHost + OpenUrl,
{
let (text, spans) = render_markdown(source, BASE_SIZE);
let field = wtext(text)
.spans(spans)
let frame = frame_of(block.kind);
let rendered = render_block(block, BASE_SIZE);
let links = Rc::new(RefCell::new(rendered.links));
let verbatim = matches!(frame, BlockFrame::Verbatim { .. });
let field = wtext(rendered.text)
.spans(rendered.spans)
.editable(EditMode::MultiLine)
.text_align(Align::LEFT)
.wrap(true)
// A fence and a table say what they mean by where their
// characters sit, so they pan sideways rather than wrap
// (`CodeFence.kt`'s `horizontalScroll`) -- and a table is padded
// in *characters*, which only lines up in a monospace face.
.wrap(!verbatim)
.family(if verbatim {
Family::Monospace
} else {
Family::SansSerif
})
.size(BASE_SIZE)
.color(UiColor::WHITE)
.color(match frame {
BlockFrame::Quote => crate::markdown::QUOTE_TEXT_COLOR,
_ => crate::markdown::TEXT_COLOR,
})
.add(rsc);
selection.borrow_mut().register(key, field);
let tap_links = links.clone();
field
// `| CursorSense::unclick()` on top of the usual click-or-drag set
// -- this block's own registration only ever needs to see a
@@ -192,19 +229,69 @@ where
.on(
CursorSense::click_or_drag() | CursorSense::unclick(),
move |ctx, rsc| {
selection.borrow_mut().drag(
let (pos, size, cursor) = (ctx.data.pos, ctx.data.size, ctx.data.cursor.pos);
let outcome = selection.borrow_mut().drag(
rsc,
list,
Some((key, ctx.data.pos, ctx.data.size)),
ctx.data.cursor.pos,
Some((key, pos, size)),
cursor,
ctx.data.sense,
Instant::now(),
ctx.data.render,
);
// A *tap*, decided by the same `DragArbiter` the pan and
// the selection are: a gesture that panned the list past
// this link, or held long enough to select, must not also
// follow it (`GestureOutcome::Tapped`'s doc).
if outcome == GestureOutcome::Tapped {
let byte = field.edit(rsc).byte_at(cursor, size);
let url = tap_links
.borrow()
.iter()
.find(|l| l.range.contains(&byte))
.map(|l| l.url.clone());
if let Some(url) = url {
log::info!("iris link: opening {url}");
<Rsc::State as OpenUrl>::open_url(ctx.state, &url);
}
}
},
)
.add(rsc);
field
// The column holds the *framed* widget; the field is what
// `apply_delta` writes into and what `Selection` resolves. Keeping
// the two apart is what lets a fence gain a background without the
// delta path knowing anything about frames.
let framed = match frame {
BlockFrame::Plain => field.width(rest(1)).add_strong(rsc).any(),
BlockFrame::Verbatim { fill } => field
.scrollable_on(Axis::X)
.masked()
.pad(dp(FRAME_PAD_DP))
.background(rect(fill).radius(dp(FRAME_RADIUS_DP)))
.width(rest(1))
.add_strong(rsc)
.any(),
// A `Stack` (through `background`) rather than a two-child
// `Span(Dir::RIGHT)`: the bar is drawn behind text padded past
// it, which is the same picture with one widget fewer and
// without `Span`'s provisional full-region pass. That pass is
// also what first surfaced the `mov`-then-`reposition` assert
// docs/RUST.md's P1a box records as still open, so the shape
// with fewer passes is the one to prefer here.
BlockFrame::Quote => field
.width(rest(1))
.pad(Padding {
left: dp(QUOTE_BAR_DP + FRAME_PAD_DP),
..Padding::ZERO
})
.background(rect(crate::markdown::QUOTE_BAR_COLOR).width(dp(QUOTE_BAR_DP)))
.width(rest(1))
.add_strong(rsc)
.any(),
};
(field, framed, links)
}
/// Build a row from a sender label plus markdown source: a column of one
@@ -225,15 +312,18 @@ fn build_text_row<Rsc: HasEvents>(
markdown_src: &str,
) -> (StrongWidget, RowBlocks)
where
Rsc::State: FocusHost,
Rsc::State: FocusHost + OpenUrl,
{
let blocks = display_blocks(markdown_src);
let mut column = Span::empty(Dir::DOWN).gap(dp(BLOCK_GAP_DP));
let mut fields = Vec::with_capacity(blocks.len());
let mut links = Vec::with_capacity(blocks.len());
for (i, block) in blocks.iter().enumerate() {
let field = build_block_field(rsc, list, selection.clone(), (key, i as u32), &block.source);
let (field, framed, block_links) =
build_block(rsc, list, selection.clone(), (key, i as u32), block);
fields.push(field);
column.push(field.width(rest(1)).add_strong(rsc).any());
links.push(block_links);
column.push(framed);
}
let column = column.add(rsc);
@@ -264,6 +354,7 @@ where
RowBlocks {
blocks,
fields,
links,
column,
sender: sender.map(str::to_string),
},
@@ -291,7 +382,7 @@ impl RowBlocks {
markdown_src: &str,
) -> bool
where
Rsc::State: FocusHost,
Rsc::State: FocusHost + OpenUrl,
{
if self.sender.as_deref() != sender {
return false;
@@ -305,32 +396,48 @@ impl RowBlocks {
if new_blocks.len() < self.blocks.len() || common + 1 < self.blocks.len() {
return false;
}
// A block's *frame* is built around its widget once and never
// rewritten, so a block whose kind changed under the delta (the
// paragraph that a `|---|` line turns into a table) cannot take
// this path -- it would keep prose's appearance with a table's
// text in it. Only the last block can differ at all, by the check
// above.
if new_blocks.len() == self.blocks.len()
&& common < self.blocks.len()
&& new_blocks[common].kind != self.blocks[common].kind
{
return false;
}
debug_assert!(
self.fields.len() == self.blocks.len(),
"one field per block: {} fields, {} blocks",
self.fields.len() == self.blocks.len() && self.links.len() == self.blocks.len(),
"one field and one link list per block: {} fields, {} links, {} blocks",
self.fields.len(),
self.links.len(),
self.blocks.len()
);
for (i, block) in new_blocks.iter().enumerate().skip(common) {
let (text, spans) = render_markdown(&block.source, BASE_SIZE);
match self.fields.get(i) {
Some(field) => field.edit(rsc).set_with_spans(&text, spans),
None => {
let field = build_block_field(
rsc,
list,
selection.clone(),
(key, i as u32),
&block.source,
);
match (self.fields.get(i), self.links.get(i)) {
(Some(field), Some(links)) => {
let rendered = render_block(block, BASE_SIZE);
field
.edit(rsc)
.set_with_spans(&rendered.text, rendered.spans);
// Replaced together with the text: a link range left
// over from the previous delta points into a string
// that no longer exists.
*links.borrow_mut() = rendered.links;
}
_ => {
let (field, framed, links) =
build_block(rsc, list, selection.clone(), (key, i as u32), block);
self.fields.push(field);
let child = field.width(rest(1)).add_strong(rsc).any();
self.links.push(links);
// `get_mut` marks the column dirty, which is what gets
// the new block drawn; its removal half is the row's
// own, since the column owns the child strongly.
if let Some(column) = rsc.ui_mut().widgets.get_mut(&self.column) {
column.push(child);
column.push(framed);
}
}
}
@@ -348,7 +455,7 @@ fn build_single<Rsc: HasEvents>(
item: &TranscriptItem,
) -> (StrongWidget, RowBlocks)
where
Rsc::State: FocusHost,
Rsc::State: FocusHost + OpenUrl,
{
let (sender, markdown_src) = item_content(item);
build_text_row(rsc, list, selection, key, sender, &markdown_src)
@@ -366,7 +473,7 @@ fn build_tools<Rsc: HasEvents>(
calls: Vec<TranscriptItem>,
) -> StrongWidget
where
Rsc::State: FocusHost,
Rsc::State: FocusHost + OpenUrl,
{
let expanded = Rc::new(RefCell::new(false));
// `.add_strong` (not `.add`) because nothing else in the tree holds a
@@ -401,7 +508,7 @@ where
full: &str,
) -> StrongWidget
where
Rsc::State: FocusHost,
Rsc::State: FocusHost + OpenUrl,
{
// Every block of the previous content goes first: collapsing a
// five-block expansion back to a one-line summary registers only
@@ -462,7 +569,7 @@ pub fn build_row<Rsc: HasEvents>(
row: &FoldedRow,
) -> (RowKey, StrongWidget, Option<RowBlocks>)
where
Rsc::State: FocusHost,
Rsc::State: FocusHost + OpenUrl,
{
match row {
FoldedRow::Single(item) => {
+10 -2
View File
@@ -245,6 +245,10 @@ impl Selection {
/// than the last one. `render` is `CursorData`'s own field -- what
/// `DragGesture` needs to take pointer capture.
#[allow(clippy::too_many_arguments)]
/// Returns what the gesture decided this frame, so a caller with its
/// own meaning for a *tap* -- a row's link handler -- reads it from
/// the one arbiter that already knows, rather than timing a second
/// one beside it (which would disagree the moment either changed).
pub fn drag(
&mut self,
ui: &mut impl UiRsc,
@@ -254,7 +258,7 @@ impl Selection {
sense: CursorSense,
now: Instant,
render: &UiRenderState,
) {
) -> GestureOutcome {
if matches!(sense, CursorSense::PressStart(_)) {
// A fresh touch-down cancels any fling still coasting from
// the previous gesture -- `List::fling`'s own doc, and
@@ -291,8 +295,12 @@ impl Selection {
// tap/long-press that never left `Undecided` -- exactly what
// `DragGesture`'s `Some(v)` already encodes.
GestureOutcome::Released(Some(v)) => list(ui).fling(-v),
GestureOutcome::Released(None) => {}
// A tap is nobody's business here -- `row.rs` reads it from
// the returned outcome and follows a link if one was under
// the finger.
GestureOutcome::Released(None) | GestureOutcome::Tapped => {}
}
outcome
}
/// The concatenated selected text, in row order, `None` if nothing is