diff --git a/IRIS.md b/IRIS.md index 18f60e6..677e8a7 100644 --- a/IRIS.md +++ b/IRIS.md @@ -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) diff --git a/IRIS_TODO.md b/IRIS_TODO.md index 9a6b6e0..78db53a 100644 --- a/IRIS_TODO.md +++ b/IRIS_TODO.md @@ -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 diff --git a/RUST.md b/RUST.md index 3ba8a25..d77315e 100644 --- a/RUST.md +++ b/RUST.md @@ -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`), `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` 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