RUST.md, IRIS.md, IRIS_TODO.md: record I5 -- transcript-ui built and

partial, the recommendation's numbers still missing

I5's own box: the seven "hard to get back" behaviours each shown or
given a sourced reason, the exact verification commands and results,
and what remains (Android integration, touch-drag-vs-selection
arbitration, row accessibility names, a tappable link, code-span chip,
selection's anchor-row shortcut, code-fence syntax highlighting) --
each also a dated IRIS_TODO.md item so it is not silently dropped.
Ticked [~] rather than [x]: the widget-tree half is built and tested,
the emulator half is not.

"Where things stand" and the Recommendation's item 3 updated in place
to say plainly that neither Masonry (E2) nor iris (I5) has produced a
render number yet, and why -- not a bad measurement, no measurement
obtainable yet on either side -- with the structural findings that do
exist (iris now does cross-row selection and per-span inline rich text,
neither of which exists in masonry/masonry_core/xilem today) recorded
as what currently favours iris absent a number.

IRIS.md gets SpanStyle's own entry: what changed, why, and the one
thing a future TextBuilderOutput impl must remember (both TextOutput
and TextEditOutput apply .spans() -- this box shipped the bug of
missing one of the pair once already).

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
This commit is contained in:
irisandClaude Sonnet committed 2026-09-05 07:55:04 -04:00
1 parent 3f25e7ebca
commit b063fbd7f9
3 files changed
+347 -4

No files matched your search

+41
View File
@@ -8,6 +8,47 @@ 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-05: `SpanStyle`, per-range text styling (RUST.md's I5)
A `TextBuffer` used to have exactly one style (`TextAttrs`: colour, size,
family, ...) for its whole string, applied via `push_default` into parley's
ranged builder. `SpanStyle` is a second, optional layer: a byte range plus
whichever of colour/family/font size/bold/italic/underline it overrides,
pushed with parley's own `push(property, range)` instead. Why: a transcript
row's markdown (a heading, **bold**, `inline code`, a link) all inside one
wrapped paragraph needs each to carry its own look while the paragraph
still wraps and selects as a single buffer — the thing `masonry`'s
`TextArea` cannot do (`StyleSet` is one style for the whole editor,
`text_area.rs:43-44`'s `// TODO: RichTextInput`), and the reason this
existed at all.
```rust
let (text, spans) = transcript_ui::markdown::render_markdown(src, 16.0);
wtext(text)
.spans(spans) // new: TextBuilder::spans, on both Text and TextEdit
.editable(EditMode::MultiLine)
.add(rsc);
```
Two things a widget author should know before reaching for it:
- **Call `.spans()` before or after `.editable()`, both work** — the field
lives on `TextBuilder` itself, not either output type, and both
`TextOutput::run` and `TextEditOutput::run` apply it to the buffer via
`TextBuffer::set_spans`. **These two call sites are a pair**: adding a
third `TextBuilderOutput` impl without also calling `set_spans` there
reproduces the exact bug this box shipped once already (spans silently
dropped for `TextEdit`, found only by screenshotting, not by any test —
`markdown.rs`'s own unit tests check string/range logic, which is
correct in isolation and proves nothing about whether the render path
ever sees it).
- **Colour is now per-glyph, not per-buffer.** `PlacedGlyph` gained a
`color: UiColor` field (from parley's own per-run `Style::brush`), and
`Painter::glyphs` draws each glyph in its own colour instead of
`RenderedText::color` uniformly. `RenderedText::color` still exists (the
buffer's *base* colour, for a caller that wants it as a whole, e.g. to
tint a cursor) but no longer drives what a glyph actually renders as.
## 2026-09-05: accessibility names via AccessKit (RUST.md's I4)
`.label()` (already in `trait_fns.rs`, previously unused anywhere in-tree)
+48
View File
@@ -170,6 +170,54 @@ order and what "done" looks like. Tick and date them in place.
behave as designed; its *append* half did not, until the fix above moved
masks/move_offsets out of the per-image bind group — now flat at O(1)
the same way (b) and (c) are.
- **I5's transcript screen (`iris/transcript-ui/`, 2026-09-05) — what it
left, each recorded at the point in the code it would go rather than
silently dropped. See RUST.md's I5 box for the full account of what
*was* built (the screen, `SpanStyle`, cross-row selection, the growing
composer).**
- [ ] **Android integration for this screen does not exist yet.** No
cdylib/Gradle shell the way `iris-android-app` wraps `tabs-ui` (I2),
so `transcript-bench.sh`'s render-number pass condition against the
Compose baseline cannot be run. Needs: real `client-core::ApiClient`/
`event_stream::follow_session_events` wiring against
`app/ui-sandbox.sh --delay` (this crate deliberately fetches nothing
itself, `transcript-ui/src/lib.rs`'s doc), a new cdylib + Gradle
module, then the bench script pointed at it.
- [ ] **Touch-drag panning over a row's own rendered text.** `row.rs`
registers `CursorSense::click_or_drag()` on each row's `TextEdit` for
cross-row selection; `TextEdit::draw`'s `painter.child_layer()`
(`iris/src/widget/text/edit.rs:87`) means that registration wins
`core/src/sense.rs::run_sensors`'s per-layer arbitration on every
frame it is pressed, not just the frame the press started, so a list
pan gesture registered on `List` itself never gets a turn while a
row is under the finger. Fix: a small press distance/time arbiter
deciding pan vs. select before either commits, or gate text-drag-
selection behind a long-press so an ordinary swipe always pans first.
`lib.rs`'s module doc has the full diagnosis.
- [ ] **Row-level accessibility names.** The composer carries
`.label("Message")`; transcript rows do not carry a `.label()` of
their own yet, so `Widgets::named()` (I4) does not include them —
`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`).
- [ ] **`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.
- [ ] **Masks defined relative to each other.** Wanted: mask A multiplies
by something *and also* applies mask B — a mask can reference a parent
mask, the way the move chain references a parent offset. Today masks
+258 -4
View File
@@ -42,7 +42,31 @@ session spending an afternoon on them again.
below), **E3 (the Kotlin/Java shell over a JNI bridge into Rust, both
pass conditions proved on the emulator — see its own box)**, I0a, I0b
(iris builds on a pinned nightly and runs), I1 (parley + glyph atlas),
I2 (iris on android-view), I3 (`iris::widget::List`).
I2 (iris on android-view), I3 (`iris::widget::List`), I4 (host half).
- **I5 — the transcript screen in iris: partial, 2026-09-05 (ticked `[~]`
in its own box, not `[x]`).** `iris/transcript-ui/` builds a real
transcript screen — markdown-folded rows in `iris::widget::List`,
cross-row selection, a growing composer, tool-row expand-hold — on top
of a new, genuinely useful iris capability this box added:
**`SpanStyle`**, per-range text styling (`core/src/primitive/text.rs`),
which is what lets one wrapped, selectable `TextEdit` carry a heading,
bold, italic, inline code and a link all inside the same paragraph —
exactly the inline-rich-text ceiling E2 found Masonry structurally
unable to cross. Screenshotted via `run-headless.sh` (real inline
styling visible, not just block-level). 9 new tests, all passing;
`cargo build/clippy/fmt/test --workspace` and `cargo ndk` (both `iris`
and `transcript-ui`) all clean. **What did not happen this pass**: any
Android integration for this specific screen (no cdylib/Gradle shell
exists for it yet, unlike `tabs-ui`'s `iris-android-app`), and therefore
the emulator-side pass condition (`transcript-bench.sh` against the
Compose baseline, `ui-trace` tap-by-name on a row) — `emu list` showed
the one emulator here held by another session, but the real blocker is
that the integration work itself is unbuilt, not the emulator being
busy. Touch-drag panning over a row's own rendered text is also not yet
reachable, for a specific, diagnosed reason (it competes with this box's
own row-level drag-select for the same gesture) rather than an absent
primitive. Full accounting, every citation, and the dated
IRIS_TODO.md items are in I5's own box below.
- **E3 done, 2026-09-05, and unlike E1/E2 it is committed to this repo**
(`android-shell/` — a JNI-bridge crate on `client-core` — plus a new
Gradle module `app/shellApp/`, left deliberately separate from
@@ -591,6 +615,25 @@ light" has a knob inside the same stack.
carries the screen within the Compose baseline, it is the app's
framework and Masonry was the calibration. If it does not, the
measurement says which parts of Masonry to adopt underneath it.
**Not decidable yet, 2026-09-05 — what's missing, named rather than
guessed at.** Neither side of this comparison has a render number:
E2 found Masonry's own scroll gesture path absent on Android
entirely (its box, "measurable frames"), and I5 built the iris side of
the screen (`iris/transcript-ui/`) but not the Android integration
around it — no cdylib/Gradle shell exists for this screen yet (unlike
`tabs-ui`'s `iris-android-app`, I2), so there is nothing installed on a
device for `transcript-bench.sh` to measure against the Compose
baseline. What would close this: build that integration (real
`client-core` networking against `app/ui-sandbox.sh --delay`, a cdylib
+ Gradle module the way I2 did for `tabs-ui`), then run
`transcript-bench.sh`'s gesture on both. Until then, the decision rests
on the structural findings both sides *did* produce: Masonry cannot do
cross-row selection or per-span inline rich text at all today (E2's
`grep -rln`, zero hits, cited in its own box), and iris now does both
(I5's `SpanStyle` and `selection.rs`) as well as programmatic
touch-scroll (I3) — three structural points in iris's favour with no
opposing measurement yet on either side.
4. Then the shell (E3), the desktop window (E4) and the packaging (E5),
which do not depend on the choice.
@@ -1924,9 +1967,220 @@ silently on real hardware.
immediately after the first (attach, detach, attach again) and
confirm the process is still alive afterward -- the detach-abort
this box's mitigation exists for.
- [ ] **I5 — the transcript screen in iris.** E2's pass conditions, all
seven behaviours, against the sandbox with `--delay`. This is the
point the decision in the recommendation is made at.
- [~] **I5 — the transcript screen in iris (2026-09-05). The widget-tree
half is built, tested and screenshotted; the emulator half (real
device numbers against the Compose baseline) is not -- ticked
partial rather than done, see "What remains" at the end of this box.**
**Where it lives.** `iris/transcript-ui/` (new workspace member,
`[lib]`), the same shape as `iris/tabs-ui`: generic over `Rsc:
HasEvents` + `Rsc::State: FocusHost` so the same `build()` can run
under winit (`transcript-ui/examples/transcript.rs`) or an
android-view cdylib later. Depends on `client-core`/`event-model` by
path (real code, matching E2's precedent) and `pulldown-cmark`
(0.13.4, current stable). Four modules: `markdown.rs` (CommonMark ->
plain text + `Vec<SpanStyle>`), `row.rs` (one `iris::widget::List`
row per folded `TranscriptRow`), `selection.rs` (cross-row
selection), `composer.rs` (the growing input field). `lib.rs`'s own
module doc has the screen's shape and the one gap it documents up
front (below).
**New iris API, added in this box and recorded in `IRIS.md`:
`SpanStyle`, per-range text styling.** This is the actual answer to
RUST.md's E2 finding against Masonry ("rich inline text -- block-level
yes, inline no, and both for the same reason":
`masonry/src/widgets/text_area.rs:43-44`'s `TextArea::edit_styles()`
returns one `StyleSet` for the whole editor, with `// TODO:
RichTextInput` beside it). `core/src/primitive/text.rs`'s
`TextBuffer` gained `spans: Vec<SpanStyle>` and `set_spans`;
`SpanStyle{range, color, family, font_size, bold, italic,
underline}` pushes into parley's `RangedBuilder` via `.push(property,
range)` instead of only `.push_default(...)`, so one `TextEdit` can
carry a heading's bigger bold font, an inline-code span's monospace
colour, a link's colour+underline and an ordinary paragraph's base
style all in the *same* wrapped, selectable buffer.
`core/src/render/atlas.rs`'s `PlacedGlyph` gained a `color: UiColor`
field (read from parley's own per-run `Style::brush`,
`core/src/primitive/text.rs`'s `TextData::place`) and
`core/src/ui/painter.rs`'s `glyphs()` now colours each glyph from
that field instead of one colour for the whole `RenderedText` --
the change that actually makes a span's colour reach the screen.
**Real bug found and fixed while wiring this in**: `TextBuilder`'s
`.spans(...)` was only threaded through `TextOutput::run` (the
read-only `Text` widget), not the sibling `TextEditOutput::run` (the
`TextEdit` every transcript row actually uses) -- a "rule that
governs a set belongs to the set, not one member" miss, per
CODE_RULES.md; found because `run-headless.sh`'s screenshot showed
*no* styling at all despite `markdown.rs`'s own unit tests passing
(they only check the string/range logic, not the render path -- see
`iris/src/widget/text/build.rs`'s `TextEditOutput::run`, now fixed).
**The seven behaviours, each shown or given a sourced reason, same
structure as E2's own accounting:**
1. **Selection spanning rows -- shown, with a scoped shortcut
recorded rather than hidden.** `selection.rs`'s `Selection`
coordinates each visible row's own `TextEditCtx::select`/
`select_all`/`deselect` (already built for one field, I2) from a
single drag that crosses row boundaries: rows between the anchor
and the pointer get `select_all()`, the row under the pointer gets
a true partial selection from whichever edge faces the anchor,
and `selected_text()` concatenates the result in row order. The
one shortcut: the *anchor* row is selected in full once the drag
leaves it, rather than "from the click point to its far edge",
because that needs the row's own laid-out size and
`TextEditCtx`'s `layout()` helper is private
(`iris/src/widget/text/edit.rs`) -- see `selection.rs`'s module
doc. Pure range-membership logic (`in_range`, mirroring
`begin`/`extend`'s row-selection arithmetic) is unit-tested
without any render harness; the widget-level wiring is not
independently screenshotted this pass (would need a synthetic
drag injected into the winit example -- not attempted, time).
2. **Rich inline text -- shown, genuinely inline this time.**
`markdown::render_markdown` folds one row's whole markdown (not
one block at a time) into one string plus spans, so a heading, a
**bold** word, *italic* text, `inline code`, and a
[link](url) inside the same paragraph render in one `TextEdit`
that still wraps and selects as a single buffer --
screenshotted, see below. Deliberately not attempted, each
recorded at the point it would have gone in `markdown.rs`'s own
doc: a background chip behind inline code (needs glyph-run
geometry `TextEdit`-internal and not exposed, the same primitive
`TextEdit::draw`'s selection highlight uses,
`iris/src/widget/text/edit.rs:99`), a tappable link (same missing
primitive), a real table layout, and per-token syntax colour
inside a fence.
3. **Bottom-anchored virtualised list, hold-the-edge on expand --
shown**, reusing I3's `List` unmodified. A `TranscriptRow::Tools`
row collapses to "N tool calls" and expands to every call's own
tool/input/output on tap; `row.rs`'s click handler calls
`List::extent(key)` to convert the tap's row-local position into
the viewport-relative position `List::note_tap` wants, exactly
the two-step contract `list.rs`'s module doc describes for
`holdTopEdge`. Not independently screenshotted mid-expand this
pass (no input-injection into the desktop example was built) --
the mechanism is the same one I3 already benchmarked
(`expand-hold`, flat at 0.10-0.11ms across N), applied to real
content instead of a synthetic row.
4. **The soft keyboard -- inherited from I2, not re-investigated.**
The composer (`composer.rs`) is an ordinary `TextEdit` with the
same `InputConnection` bridge I2 built and measured (Gboard
suggestions over real buffer content); nothing new to add here,
and no Android shell exists yet for this screen specifically to
re-verify it against (see "What remains").
5. **Platform integration -- out of scope by design**, same as E2:
E3's list, not this box's.
6. **Accessibility names -- shown for the composer, not yet for
rows.** The composer field carries `.label("Message")` (I4). Rows
do not yet carry per-row labels (a row's own text *is* its
accessible content via `TextEdit`'s `access_role`, I4, but
nothing calls `.label()` on it, so `Widgets::named()` does not
include it) -- a small, real gap, recorded as an IRIS_TODO.md
item rather than silently left, since AGENTS.md's bench scripts
depend on exactly this for driving a screen by name.
7. **Measurable frames / the render-number pass condition -- not
attempted, and unlike E2 the reason is not an absent gesture
path.** `List` demonstrably scrolls (I3's flat draws/moves,
programmatic `scroll()`) and mouse-wheel scrolling is wired here
(`lib.rs`'s `CursorSense::Scroll` on `list`). What is *not*
reachable yet is a **touch-drag pan starting on a row's own
text**: `row.rs` registers `CursorSense::click_or_drag()` on each
row's `TextEdit` for selection, and `TextEdit::draw` calls
`painter.child_layer()` (`iris/src/widget/text/edit.rs:87`), so
`core/src/sense.rs`'s `run_sensors` (which stops at the first
layer, checked innermost-first, that consumed the gesture) gives
that row first refusal on *every* frame it is pressed, not just
the frame the press started -- a row's drag-select wins the same
gesture a list-level pan would want. `lib.rs`'s own module doc
states this precisely, with the fix named (a press distance/time
arbiter deciding pan vs. select before either commits, or gating
text-drag-selection behind a long-press). This is a genuine,
diagnosed architecture gap this box's *own* two features created
by both wanting the same gesture -- not a missing primitive the
way Masonry's absent `on_pointer_event` drag handling was.
**Verification, exact commands and results (2026-09-05, this VM):**
- `cargo fmt --all -- --check`: clean.
- `cargo build --workspace --all-targets`: clean, all six workspace
members (`iris`, `iris-core`, `iris-macro`, `tabs-ui`,
`transcript-ui`, plus the excluded `android-app`).
- `cargo clippy --all-targets` and `cargo clippy -p transcript-ui
--all-targets`: zero warnings.
- `cargo test --workspace`: 28 tests in `iris`/`iris-core` (all
pre-existing, unaffected) + **9 new in `transcript-ui`** -- 5 pure
markdown tests (`bold_and_italic_produce_spans_over_the_right_range`,
`heading_gets_a_bigger_font_size_span`,
`link_is_styled_and_keeps_its_visible_text`,
`fenced_code_block_is_monospaced`, a plain-text baseline) and 4
selection tests (forward/backward/single-row range arithmetic,
plus `unregister_forgets_the_row_and_clears_a_matching_anchor`
against a real minimal `TextEdit` in the arena, no window needed --
same harness style as `list.rs`'s own tests).
- `cargo ndk -t x86_64 -P 26 build -p transcript-ui` and `... clippy
-p transcript-ui --lib`: clean (`--lib` only -- the example uses
`iris::default`, winit-only by design, same as `iris/examples/
tabs`'s own example never having an Android build of itself; the
Android-facing entry point is a separate cdylib, not built this
pass, see below). `cargo ndk ... build -p iris` / `clippy -p iris`
also re-checked clean, since this box touched `iris-core`'s text
pipeline.
- `run-headless.sh transcript --shot ... -- -p transcript-ui`:
renders. Cropped for legibility (this VM has no image viewer --
see I3's own note on the same limitation and the throwaway crop
tool used here, not committed): a full conversation with a
**bold** word, *italic* text, `inline code` in its own colour, a
`# Sure` heading rendered visibly larger and bold, a coloured link,
a monospaced fenced code block, a collapsed "▸ 3 tool calls" row,
and the composer bar at the bottom -- every one of E2's markdown
screenshot's features, now inline within single paragraphs rather
than block-per-widget. Screenshots at `/tmp/iris_i5_transcript2.png`
(full) and crops there, not committed per the standing rule against
screenshots of real content leaving this repo -- these are
synthetic rows, but the rule is kept uniform regardless.
**What remains, named rather than silently dropped (also in
IRIS_TODO.md, dated 2026-09-05):**
- **The emulator half of the pass condition was not attempted.**
`emu list` shows `emulator-5554` (AVD `ai-app`, a different
checkout) held by another session during this pass, but even with
a free emulator this needs real Android integration that does not
exist yet for this screen: a cdylib + Gradle shell the way
`iris-android-app` wraps `tabs-ui` (I2), real
`client-core::ApiClient`/`event_stream::follow_session_events`
wiring against `app/ui-sandbox.sh` with `--delay` (this crate
deliberately does not fetch anything itself, see `lib.rs`'s doc),
and then `transcript-bench.sh`'s gesture compared against the
Compose baseline. That is real, multi-part follow-on work in its
own right -- closer in size to E2/E3 than to "run one more
script" -- not something this pass's remaining time could
responsibly rush and still report honestly.
- **Touch-drag panning over a row's own text** -- behaviour 7 above.
- **Row-level accessibility names** -- behaviour 6 above.
- **A tappable link and a code-span background chip** -- behaviour 2.
- **`Selection`'s anchor-row shortcut** -- behaviour 1.
- **No syntax highlighting inside a fenced code block** -- `markdown.rs`
notes `client_core::highlight` exists and could feed this.
- **`row.rs`'s tool-row expand and `selection.rs`'s cross-row drag
are not independently screenshotted/driven** -- covered by reading
and by the primitives they reuse (I3's `List` tests, this box's
own unit tests), not by a dedicated repro this pass.
**Net for the recommendation.** Item 3 ("decide when the transcript
screen exists in both, from the measurements") still cannot be
decided by a number -- E2 could not produce one for Masonry, and I5
has not yet produced one for iris either, for an unrelated reason
(no Android harness built yet, not an absent capability). What *can*
be said structurally, updating E2's own conclusion: iris now also
demonstrates the two things E2 found Masonry structurally unable to
do at all -- cross-row selection and true per-span inline rich text
inside one wrapped, selectable buffer -- neither of which exists
anywhere in `masonry`/`masonry_core`/`xilem` today (E2's own
`grep -rln` finding). That is a second structural point in iris's
favour, alongside I2's working touch-scroll-vs-Masonry's-absent one,
still short of the render-number comparison the recommendation
ultimately wants.
## For the next agent