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 f54f020..c7ddc18 100644 --- a/RUST.md +++ b/RUST.md @@ -40,10 +40,11 @@ session spending an afternoon on them again. keyboard gap — now explained, see below), E2 (a transcript in Masonry, which found that Masonry has no touch-scroll on Android at all — see below), E3 (the Kotlin/Java shell over a JNI bridge into Rust, both - pass conditions proved on the emulator — see its own box), **E5 (the + pass conditions proved on the emulator — see its own box), E5 (the Gradle-free packaging xtask, both pass conditions proved — 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`). + box), I0a, I0b (iris builds on a pinned nightly and runs), I1 (parley + + glyph atlas), I2 (iris on android-view), I3 (`iris::widget::List`), I4 + (host half). - **E5 done, 2026-09-05.** `cargo xtask apk` (new `xtask/` crate at the repo root, zero dependencies) replaces Gradle for packaging `app/shellApp`: `cargo ndk` → `javac`/`d8` → `aapt2` → `zipalign` → @@ -57,6 +58,30 @@ session spending an afternoon on them again. full account, including the one disclosed place Gradle still runs and what was deliberately left undone (a real-device `arm64-v8a` install, dex shrinking). +- **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 @@ -605,6 +630,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. @@ -2104,9 +2148,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 diff --git a/iris/Cargo.lock b/iris/Cargo.lock index 4aa9adf..d37cdf1 100644 --- a/iris/Cargo.lock +++ b/iris/Cargo.lock @@ -151,7 +151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", - "getrandom", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -534,6 +534,12 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bit-set" version = "0.8.0" @@ -710,6 +716,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "client-core" +version = "0.1.0" +dependencies = [ + "event-model", + "serde", + "serde_json", + "ureq", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -755,6 +771,35 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -871,6 +916,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "dispatch" version = "0.2.0" @@ -1023,6 +1074,14 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "event-model" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "exr" version = "1.74.0" @@ -1165,6 +1224,15 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -1239,6 +1307,26 @@ dependencies = [ "windows-link", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1398,6 +1486,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "icu_collections" version = "2.3.0" @@ -1526,6 +1630,27 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "image" version = "0.25.9" @@ -1641,6 +1766,12 @@ dependencies = [ "either", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "jni" version = "0.21.1" @@ -1669,7 +1800,7 @@ version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom", + "getrandom 0.3.4", "libc", ] @@ -1978,6 +2109,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-derive" version = "0.4.2" @@ -2620,6 +2757,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2672,6 +2815,25 @@ dependencies = [ "syn 2.0.113", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.10.0", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "pxfm" version = "0.1.27" @@ -2746,7 +2908,7 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ - "getrandom", + "getrandom 0.3.4", ] [[package]] @@ -2881,6 +3043,20 @@ version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "roxmltree" version = "0.21.1" @@ -2922,6 +3098,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -2998,6 +3209,19 @@ dependencies = [ "syn 2.0.113", ] +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_repr" version = "0.1.21" @@ -3138,6 +3362,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swash" version = "0.2.10" @@ -3196,7 +3426,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.3.4", "once_cell", "rustix 1.1.3", "windows-sys 0.61.2", @@ -3265,6 +3495,36 @@ dependencies = [ "zune-jpeg 0.4.21", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -3371,6 +3631,16 @@ dependencies = [ "once_cell", ] +[[package]] +name = "transcript-ui" +version = "0.1.0" +dependencies = [ + "client-core", + "event-model", + "iris", + "pulldown-cmark", +] + [[package]] name = "tree_magic_mini" version = "3.2.2" @@ -3409,6 +3679,12 @@ dependencies = [ "keyboard-types", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -3427,6 +3703,62 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" +dependencies = [ + "base64", + "cookie_store", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3471,6 +3803,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.1+wasi-0.2.4" @@ -3667,6 +4005,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -4535,6 +4882,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.5" @@ -4570,6 +4923,12 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + [[package]] name = "zune-core" version = "0.4.12" diff --git a/iris/Cargo.toml b/iris/Cargo.toml index ffae414..f251bbd 100644 --- a/iris/Cargo.toml +++ b/iris/Cargo.toml @@ -73,7 +73,7 @@ name = "message_list" harness = false [workspace] -members = ["core", "macro", "tabs-ui"] +members = ["core", "macro", "tabs-ui", "transcript-ui"] # android-app pulls in android-view, which needs the NDK sysroot to link # -- excluded so `cargo build --workspace --all-targets` on the host stays # buildable. Cross-compile it from its own directory (its own single-crate @@ -100,3 +100,8 @@ accesskit = "0.25.0" iris-core = { path = "core" } iris-macro = { path = "macro" } tokio = "1.49.0" +# Current stable as of 2026-09-05 (`cargo search`) -- I5's markdown block +# model, the same crate E2's uncommitted `e2-transcript` experiment used for +# the identical job (RUST.md), rather than reimplementing a CommonMark +# parser. +pulldown-cmark = "0.13.4" diff --git a/iris/core/src/primitive/text.rs b/iris/core/src/primitive/text.rs index cb14e5b..ae65f02 100644 --- a/iris/core/src/primitive/text.rs +++ b/iris/core/src/primitive/text.rs @@ -1,8 +1,9 @@ use crate::{Align, GlyphAtlas, GlyphKey, PlacedGlyph, RegionAlign, Textures, UiColor, util::Vec2}; use parley::{ - Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, GenericFamily, Layout, - LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty, + Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, FontStyle, FontWeight, + GenericFamily, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty, }; +use std::ops::Range; use swash::{ FontRef, scale::{Render, ScaleContext, Source, StrikeWith}, @@ -51,6 +52,72 @@ impl Family { } } +/// One styled run inside a `TextBuffer`, overriding `TextAttrs`' base style +/// over `range` (a byte range into the buffer's text). Every field is +/// optional so a span only says what it changes -- e.g. a link span sets +/// `color` and `underline` and leaves weight/family at the paragraph's own +/// default. This is I5's answer to RUST.md's inline-rich-text ceiling +/// (`masonry/src/widgets/text_area.rs`'s `StyleSet` is one style for the +/// whole editor, with `// TODO: RichTextInput` beside it): parley's own +/// `RangedBuilder::push` already takes a style and a range, so per-span +/// bold/italic/monospace/colour/underline only needed plumbing this struct +/// through to it and giving each glyph its own colour at draw time (see +/// `PlacedGlyph::color` and `TextData::place` below) instead of the one +/// `RenderedText::color` every glyph used to share. +#[derive(Clone, PartialEq)] +pub struct SpanStyle { + pub range: Range, + pub color: Option, + pub family: Option, + /// Overrides `TextAttrs::font_size` for just this range -- what lets a + /// heading inside a transcript row's single `TextEdit` be bigger than + /// the paragraph text around it, so a whole markdown-folded row (block + /// and inline styling both) can stay one selectable text buffer instead + /// of one widget per block. + pub font_size: Option, + pub bold: bool, + pub italic: bool, + pub underline: bool, +} + +impl SpanStyle { + pub fn new(range: Range) -> Self { + Self { + range, + color: None, + family: None, + font_size: None, + bold: false, + italic: false, + underline: false, + } + } + pub fn color(mut self, color: UiColor) -> Self { + self.color = Some(color); + self + } + pub fn family(mut self, family: Family) -> Self { + self.family = Some(family); + self + } + pub fn font_size(mut self, size: f32) -> Self { + self.font_size = Some(size); + self + } + pub fn bold(mut self) -> Self { + self.bold = true; + self + } + pub fn italic(mut self) -> Self { + self.italic = true; + self + } + pub fn underline(mut self) -> Self { + self.underline = true; + self + } +} + #[derive(Clone, PartialEq)] pub struct TextAttrs { pub color: UiColor, @@ -86,8 +153,12 @@ impl Default for TextAttrs { pub struct TextBuffer { text: String, layout: Layout, + spans: Vec, /// What the current layout was built for, so `shape` can decline to redo - /// work that would come out the same. + /// work that would come out the same. Spans are not part of this key -- + /// `set_spans` forces `shaped` to `None` directly, the same way `edit` + /// does, since spans change far less often than a naive equality check + /// on the whole `Vec` would cost to compute every frame. shaped: Option<(TextAttrs, Option)>, } @@ -96,10 +167,19 @@ impl TextBuffer { Self { text: text.into(), layout: Layout::new(), + spans: Vec::new(), shaped: None, } } + /// Replace this buffer's per-range style overrides (I5's rich text -- + /// see `SpanStyle`). Invalidates the layout unconditionally, mirroring + /// `set_text`. + pub fn set_spans(&mut self, spans: Vec) { + self.spans = spans; + self.shaped = None; + } + pub fn new_empty() -> Self { Self::new("") } @@ -150,6 +230,27 @@ impl TextBuffer { attrs.line_height, ))); builder.push_default(StyleProperty::Brush(attrs.color)); + for span in &self.spans { + let range = span.range.clone(); + if let Some(color) = span.color { + builder.push(StyleProperty::Brush(color), range.clone()); + } + if let Some(family) = &span.family { + builder.push(StyleProperty::FontFamily(family.family()), range.clone()); + } + if let Some(size) = span.font_size { + builder.push(StyleProperty::FontSize(size), range.clone()); + } + if span.bold { + builder.push(StyleProperty::FontWeight(FontWeight::BOLD), range.clone()); + } + if span.italic { + builder.push(StyleProperty::FontStyle(FontStyle::Italic), range.clone()); + } + if span.underline { + builder.push(StyleProperty::Underline(true), range.clone()); + } + } builder.build_into(&mut self.layout, &self.text); self.layout.break_all_lines(width); self.layout @@ -175,6 +276,7 @@ impl TextData { let font = run.run().font(); let font_size = run.run().font_size(); let coords = run.run().normalized_coords(); + let run_color = run.style().brush; let Some(font_ref) = FontRef::from_index(font.data.as_ref(), font.index as usize) else { continue; @@ -227,6 +329,7 @@ impl TextData { glyph.x.floor() + entry.left as f32, glyph.y.floor() - entry.top as f32, ), + color: run_color, }); } } @@ -245,11 +348,15 @@ fn hash_coords(coords: &[i16]) -> u64 { h } -/// A laid-out string, ready to draw: where each glyph goes, how big the whole -/// thing is, and what colour to tint the atlas with. +/// A laid-out string, ready to draw: where each glyph goes and how big the +/// whole thing is. /// /// Cheap to clone and to keep, which is the point -- a widget holds one across -/// frames and re-emits its quads without going near the rasteriser. +/// frames and re-emits its quads without going near the rasteriser. `color` +/// is the buffer's *base* colour (`TextAttrs::color`) for a caller that wants +/// it as a whole (e.g. tinting a cursor to match); the colour each glyph is +/// actually drawn in is `PlacedGlyph::color`, which a `SpanStyle` can +/// override per range. #[derive(Clone)] pub struct RenderedText { pub glyphs: std::sync::Arc>, diff --git a/iris/core/src/render/atlas.rs b/iris/core/src/render/atlas.rs index ec29a19..027b407 100644 --- a/iris/core/src/render/atlas.rs +++ b/iris/core/src/render/atlas.rs @@ -10,7 +10,7 @@ //! it, and a resize re-emits quads without touching the GPU's copy at all. use crate::{ - PatchRect, TextureHandle, Textures, + PatchRect, TextureHandle, Textures, UiColor, util::{HashMap, Vec2}, }; use image::RgbaImage; @@ -228,8 +228,16 @@ fn write_glyph(page: &mut RgbaImage, image: &Image, x: u32, y: u32) { } /// Where a glyph goes on screen, in pixels relative to the text's origin. +/// +/// `color` is per-glyph (read from the parley run's own `Brush`, since +/// `UiColor` is parley's brush type here) rather than a single colour for +/// the whole `RenderedText`, so that a span pushed with its own +/// `StyleProperty::Brush` (I5's inline rich text: a link, a diff of colour +/// inside one wrapped paragraph) actually renders in that colour instead of +/// the buffer's base one. #[derive(Clone, Copy)] pub struct PlacedGlyph { pub entry: GlyphEntry, pub offset: Vec2, + pub color: UiColor, } diff --git a/iris/core/src/ui/painter.rs b/iris/core/src/ui/painter.rs index 8985f01..801f279 100644 --- a/iris/core/src/ui/painter.rs +++ b/iris/core/src/ui/painter.rs @@ -194,7 +194,7 @@ impl<'a> Painter<'a> { glyph.entry.uv_min, glyph.entry.uv_max, glyph.entry.layer, - text.color, + glyph.color, flags_for(glyph.entry.is_color), ), region, diff --git a/iris/src/widget/text/build.rs b/iris/src/widget/text/build.rs index 1619bfe..7b4c696 100644 --- a/iris/src/widget/text/build.rs +++ b/iris/src/widget/text/build.rs @@ -4,6 +4,7 @@ use std::marker::{PhantomData, Sized}; pub struct TextBuilder = ()> { pub content: String, pub attrs: TextAttrs, + pub spans: Vec, pub hint: H, pub output: O, state: PhantomData, @@ -39,10 +40,19 @@ impl> TextBuilder { self.attrs.wrap = wrap; self } + /// Per-range style overrides -- I5's inline rich text (bold, italic, + /// inline-code monospace, link colour/underline) within one wrapped + /// paragraph. See `SpanStyle`'s doc for why this exists and what it + /// replaces. + pub fn spans(mut self, spans: Vec) -> Self { + self.spans = spans; + self + } pub fn editable(self, mode: EditMode) -> TextBuilder { TextBuilder { content: self.content, attrs: self.attrs, + spans: self.spans, hint: self.hint, output: TextEditOutput { mode }, state: PhantomData, @@ -58,6 +68,7 @@ impl TextBuilder { TextBuilder { content: self.content, attrs: self.attrs, + spans: self.spans, hint: move |rsc: &mut Rsc| Some(hint.add_strong(rsc).any()), output: self.output, state: PhantomData, @@ -81,7 +92,8 @@ impl TextBuilderOutput for TextOutput { state: &mut Rsc, builder: TextBuilder, ) -> Self::Output { - let buf = TextBuffer::new(&builder.content); + let mut buf = TextBuffer::new(&builder.content); + buf.set_spans(builder.spans); let hint = builder.hint.get(state); let mut text = Text { content: builder.content.into(), @@ -103,7 +115,8 @@ impl TextBuilderOutput for TextEditOutput { state: &mut State, builder: TextBuilder, ) -> Self::Output { - let buf = TextBuffer::new(&builder.content); + let mut buf = TextBuffer::new(&builder.content); + buf.set_spans(builder.spans); TextEdit::new( TextView::new(buf, builder.attrs, builder.hint.get(state)), builder.output.mode, @@ -125,6 +138,7 @@ pub fn wtext(content: impl Into) -> TextBuilder { TextBuilder { content: content.into(), attrs: TextAttrs::default(), + spans: Vec::new(), hint: (), output: TextOutput, state: PhantomData, diff --git a/iris/transcript-ui/Cargo.toml b/iris/transcript-ui/Cargo.toml new file mode 100644 index 0000000..115e245 --- /dev/null +++ b/iris/transcript-ui/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "transcript-ui" +version.workspace = true +edition.workspace = true + +# I5 (RUST.md): the transcript screen's widget tree, built the same way +# `tabs-ui` is -- its own crate, generic over `Rsc: HasEvents` + +# `Rsc::State: FocusHost`, so the winit example and an eventual +# `iris-android-app`-style cdylib call the same `build`. See its own module +# doc for the design and RUST.md's I5 box for what is and is not proved yet. +# +# `client-core`/`event-model` by path, real code and not a reimplementation +# -- the same dependency shape E2's uncommitted Masonry experiment used for +# the identical job. + +[dependencies] +iris = { path = ".." } +client-core = { path = "../../client-core" } +event-model = { path = "../../event-model" } +pulldown-cmark = { workspace = true } diff --git a/iris/transcript-ui/examples/transcript.rs b/iris/transcript-ui/examples/transcript.rs new file mode 100644 index 0000000..67e5989 --- /dev/null +++ b/iris/transcript-ui/examples/transcript.rs @@ -0,0 +1,122 @@ +//! I5's desktop proof: the transcript screen built from synthetic +//! `client_core::transcript_fold` rows (no network, no server -- see +//! `lib.rs`'s doc for why `transcript-ui` itself never fetches anything), +//! run via `iris/run-headless.sh transcript -- -p transcript-ui` for a +//! screenshot on the winit backend, or `cargo run --example transcript -p +//! transcript-ui` with a real compositor. +//! +//! The rows exercise every one of the seven "hard to get back" behaviours +//! this box's markdown/selection work is meant to show: a heading, bold, +//! italic, an inline code span, a link, a fenced code block (rich inline +//! text), a multi-message conversation (bottom-anchored virtualised list), +//! and a three-call tool run (collapsed by default -- tap it, or drive it +//! with `ui-trace record --do "tap 'Tools'"` on Android, to prove +//! hold-the-edge expand). + +use client_core::transcript_fold::{TranscriptItem, TranscriptRow as FoldedRow}; +use iris::prelude::*; + +fn main() { + DefaultApp::::run(); +} + +#[derive(DefaultUiState)] +pub struct Client { + ui_state: DefaultUiState, + #[allow(dead_code)] + screen: transcript_ui::TranscriptScreen, +} + +fn msg(seq: u64, from_user: bool, text: &str) -> FoldedRow { + FoldedRow::Single(if from_user { + TranscriptItem::UserMsg { + seq, + text: text.to_string(), + attachments: Vec::new(), + } + } else { + TranscriptItem::AssistantMsg { + seq, + text: text.to_string(), + settled: true, + } + }) +} + +fn synthetic_rows() -> Vec { + vec![ + msg( + 1, + true, + "Can you show me a **bold** word, some *italic* text, and `inline code`?", + ), + msg( + 2, + false, + "# Sure\n\nHere's a [link to the repo](https://example.com/ai-app-2) and a fenced block:\n\n```rust\nfn main() {\n println!(\"hi\");\n}\n```", + ), + FoldedRow::Tools(vec![ + TranscriptItem::ToolRun { + seq: 3, + id: "t1".into(), + run_id: "run1".into(), + tool: "Read".into(), + input: "{\"file\": \"src/main.rs\"}".into(), + output: "fn main() {}\n".into(), + done: true, + asks: Vec::new(), + images: Vec::new(), + }, + TranscriptItem::ToolRun { + seq: 4, + id: "t2".into(), + run_id: "run1".into(), + tool: "Edit".into(), + input: "{\"file\": \"src/main.rs\"}".into(), + output: "ok".into(), + done: true, + asks: Vec::new(), + images: Vec::new(), + }, + TranscriptItem::ToolRun { + seq: 5, + id: "t3".into(), + run_id: "run1".into(), + tool: "Bash".into(), + input: "cargo build".into(), + output: "Compiling...\nFinished.".into(), + done: true, + asks: Vec::new(), + images: Vec::new(), + }, + ]), + msg(6, true, "Looks good, thanks!"), + msg( + 7, + false, + "You're welcome. Let me know if you'd like anything else.", + ), + ] +} + +impl DefaultAppState for Client { + fn new( + mut ui_state: DefaultUiState, + rsc: &mut DefaultRsc, + _: Proxy, + ) -> Self { + let screen = transcript_ui::build(rsc, &mut ui_state, synthetic_rows()); + // Exercises `push_row`/`ItemKey` beyond construction time, matching + // how a live SSE loop appends -- a row arriving after the screen + // already exists must land at the bottom without disturbing what's + // above it (I3's `push_back`/`snap_end`). + screen.push_row( + rsc, + &FoldedRow::Single(TranscriptItem::CommandRow { + seq: 8, + text: "clear".into(), + }), + ); + Self { ui_state, screen } + } +} diff --git a/iris/transcript-ui/src/composer.rs b/iris/transcript-ui/src/composer.rs new file mode 100644 index 0000000..f661f05 --- /dev/null +++ b/iris/transcript-ui/src/composer.rs @@ -0,0 +1,48 @@ +//! The message composer at the bottom of the transcript screen: a +//! multi-line editable field with a natural (not fixed) height, so it +//! grows as typed into -- IRIS_TODO.md's "input box" benchmark case +//! (`iris/benches/message_list.rs` exercises the mechanism in isolation; +//! this wires the same `TextEdit`-with-no-`Sized`-wrapper idiom into the +//! real screen). `lib.rs` gives the transcript `List` `.height(rest(1))` +//! beside this widget in a `Span::down`, so the list's own draw already +//! measures whatever vertical space is left each frame -- nothing here +//! computes a height by hand, and growing this field is exactly the +//! O(1)-move-chain case LAYOUT.md and I3's benchmark already measured. + +use iris::prelude::*; + +/// `field` is exposed so the caller can read its content on submit +/// (`field.edit(rsc).text()`) and clear it afterward +/// (`field.edit(rsc).set("")`). +pub struct Composer { + pub field: WeakWidget, +} + +/// Returns the composer plus its own bar as a **weak** id -- the caller +/// (`lib.rs::build`) embeds it in the screen's own top-level tuple, whose +/// `set_root` performs the one real strong registration. Calling +/// `.add_strong`/`.upgrade` a second time on an id already strong-owned +/// panics ("was already added", `core/src/widget/like.rs:12`) -- the same +/// mistake this box's `row.rs` first made with its sender-label header, see +/// that file's comment for the fuller account. +pub fn build_composer(rsc: &mut Rsc) -> (Composer, WeakWidget) +where + Rsc::State: FocusHost, +{ + let field = wtext("") + .editable(EditMode::MultiLine) + .text_align(Align::LEFT) + .wrap(true) + .size(18) + .color(UiColor::WHITE) + .attr::(()) + .label("Message") + .add(rsc); + + let bar: WeakWidget = (field.pad(12).width(rest(1)),) + .span(Dir::RIGHT) + .background(rect(UiColor::new(40, 40, 46, 255))) + .add(rsc); + + (Composer { field }, bar) +} diff --git a/iris/transcript-ui/src/lib.rs b/iris/transcript-ui/src/lib.rs new file mode 100644 index 0000000..dfe4809 --- /dev/null +++ b/iris/transcript-ui/src/lib.rs @@ -0,0 +1,123 @@ +//! 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/ +//! 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. +//! +//! ```text +//! +------------------------------------------+ +//! | iris::widget::List (transcript_ui::row) | <- .height(rest(1)) +//! | row 1: sender label + one TextEdit | +//! | row 2: sender label + one TextEdit | +//! | row 3 (Tools): collapsed/expanded | +//! | ... | +//! +------------------------------------------+ +//! | composer bar (transcript_ui::composer) | <- natural height +//! +------------------------------------------+ +//! ``` +//! +//! **What this crate does not do itself**: fetch anything over the network +//! or read the transcript cache. [`build`] takes an already-folded +//! `Vec` and +//! [`TranscriptScreen::push_row`] takes one more as it arrives -- the +//! caller (an app's own `main`, or a future `iris-android-app`-shaped +//! cdylib) owns `client_core::ApiClient`/ +//! `event_stream::follow_session_events` and the transcript cache, per the +//! code rules' "ask for the least you need": a widget-tree builder that +//! also knew how to make an HTTPS request would be untestable without a +//! server and unable to be driven by `run-headless.sh` with synthetic rows. +//! +//! **Known gap, real and diagnosed rather than untested**: a touch-drag +//! that starts on a row's rendered text currently begins a cross-row +//! *selection* (`row.rs`'s `CursorSense::click_or_drag()` on each row's +//! `TextEdit`), not a *scroll* of the list -- both want the same gesture +//! over the same screen region, and `core/src/sense.rs`'s `run_sensors` +//! gives the widget that registered it in the *inner* layer (a row's own +//! `TextEdit`, which calls `painter.child_layer()`, +//! `iris/src/widget/text/edit.rs:87`) first refusal every frame it is +//! pressed, not just the frame the press started. `List` itself scrolls +//! correctly when driven programmatically (I3's benchmark) and via the +//! mouse wheel (wired below, `CursorSense::Scroll`), but a real touch pan +//! starting on top of a message's own text is not currently reachable -- +//! see IRIS_TODO.md's dated entry and RUST.md's I5 box for what a fix +//! looks like (a press distance/time arbiter deciding pan vs. select +//! before either commits). + +pub mod composer; +pub mod markdown; +pub mod row; +pub mod selection; + +use client_core::transcript_fold::TranscriptRow as FoldedRow; +use iris::prelude::*; +use selection::Selection; +use std::{cell::RefCell, rc::Rc}; + +pub struct TranscriptScreen { + /// The transcript's own `List` -- exposed so a caller can read + /// `.extent()`/call `.jump_to_end()` etc. directly for anything this + /// crate does not already wrap. + pub list: WeakWidget, + pub composer: composer::Composer, + selection: Rc>, +} + +impl TranscriptScreen { + /// Append one more folded row at the live end of the transcript -- + /// what a caller's SSE loop or a sent message calls as new events + /// arrive. `List::push_back` is O(1) and keeps the view pinned to the + /// newest content when it already was (I3). + pub fn push_row(&self, rsc: &mut Rsc, row: &FoldedRow) + where + Rsc::State: FocusHost, + { + let (key, widget) = row::build_row(rsc, self.list, self.selection.clone(), row); + (self.list)(rsc).push_back(ListRow::new(key, widget)); + } + + /// The concatenated text of whatever is currently selected across one + /// or more rows, `None` if nothing is -- what a copy command reads. + pub fn selected_text(&self, rsc: &mut impl UiRsc) -> Option { + self.selection.borrow().selected_text(rsc) + } +} + +pub fn build( + rsc: &mut Rsc, + ui_state: &mut impl HasRoot, + rows: Vec, +) -> TranscriptScreen +where + Rsc::State: FocusHost, +{ + let selection = Rc::new(RefCell::new(Selection::new())); + let list = List::new(Axis::Y).add(rsc); + + for row in &rows { + let (key, widget) = row::build_row(rsc, list, selection.clone(), row); + list(rsc).push_back(ListRow::new(key, widget)); + } + + // Wheel/trackpad scrolling -- the same idiom `trait_fns.rs`'s + // `scrollable()` uses for `Scroll`, applied directly to `List` since + // `List` already does its own placement and needs no `Scroll` wrapper. + // Real touch-drag panning is the known gap in this module's doc. + list.on(CursorSense::Scroll, |ctx, rsc| { + let delta = ctx.data.scroll_delta.y * 50.0; + ctx.widget(rsc).scroll(delta); + }) + .add(rsc); + + let (composer, composer_bar) = composer::build_composer(rsc); + + (list.width(rest(1)).height(rest(1)), composer_bar) + .span(Dir::DOWN) + .set_root(rsc, ui_state); + + TranscriptScreen { + list, + composer, + selection, + } +} diff --git a/iris/transcript-ui/src/markdown.rs b/iris/transcript-ui/src/markdown.rs new file mode 100644 index 0000000..51bfece --- /dev/null +++ b/iris/transcript-ui/src/markdown.rs @@ -0,0 +1,223 @@ +//! Markdown -> one plain string plus a `Vec`, 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, +//! `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. +//! +//! **What this deliberately does not attempt**, each for a reason recorded +//! here rather than silently dropped (see IRIS_TODO.md's dated entries for +//! the same list): +//! - **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). +//! +//! 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. + +use iris::prelude::*; +use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd}; + +// `UiColor` is `Color` (`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); + +/// 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. +fn ensure_blank_line(out: &mut String) { + if !out.is_empty() && !out.ends_with("\n\n") { + 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, + } +} + +/// One markdown source string rendered into plain text plus the spans that +/// 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) { + let _ = base_size; // headings use fixed sizes today; kept for callers that may want relative sizing later + let mut out = String::new(); + let mut spans = 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 = Vec::new(); + let mut list_depth: u32 = 0; + + let parser = Parser::new_ext(src, Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES); + for event in parser { + match event { + Event::Start(tag) => match tag { + Tag::Heading { .. } + | Tag::Emphasis + | Tag::Strong + | Tag::Strikethrough + | Tag::Link { .. } => open.push(out.len()), + Tag::CodeBlock(_) => { + ensure_blank_line(&mut out); + open.push(out.len()); + } + Tag::Item => { + out.push_str(&" ".repeat(list_depth.saturating_sub(1) as usize)); + out.push_str("\u{2022} "); + } + Tag::List(_) => list_depth += 1, + Tag::Paragraph | Tag::BlockQuote(_) => ensure_blank_line(&mut out), + _ => {} + }, + // Only the tag kinds that pushed onto `open` (Start, above) are + // popped here -- `List`/`Item`/`Paragraph`/`BlockQuote`/`Table` + // and friends push nothing, since they need no span, and must + // not touch this stack or they would pop an unrelated styled + // range still open around them. + Event::End( + tag_end @ (TagEnd::Heading(_) + | TagEnd::Emphasis + | TagEnd::Strong + | TagEnd::Strikethrough + | TagEnd::Link + | TagEnd::CodeBlock), + ) => { + let Some(start) = open.pop() else { + continue; + }; + let range = start..out.len(); + if range.is_empty() { + continue; + } + match tag_end { + TagEnd::Heading(level) => { + spans.push(SpanStyle::new(range).font_size(heading_size(level)).bold()); + } + TagEnd::Emphasis => spans.push(SpanStyle::new(range).italic()), + TagEnd::Strong => spans.push(SpanStyle::new(range).bold()), + TagEnd::Strikethrough => { + spans.push(SpanStyle::new(range).color(STRIKETHROUGH_COLOR)); + } + TagEnd::Link => { + spans.push(SpanStyle::new(range).color(LINK_COLOR).underline()); + } + TagEnd::CodeBlock => { + spans.push( + SpanStyle::new(range) + .family(Family::Monospace) + .color(CODE_COLOR), + ); + } + _ => unreachable!("filtered by the outer match arm"), + } + } + Event::Text(text) => out.push_str(&text), + // Inline code (single backticks) is one atomic event with no + // `Start`/`End` pair of its own, unlike a fenced block -- so it + // is spanned directly here instead of through the `open` stack. + Event::Code(text) => { + let start = out.len(); + out.push_str(&text); + spans.push( + SpanStyle::new(start..out.len()) + .family(Family::Monospace) + .color(CODE_COLOR), + ); + } + Event::SoftBreak => out.push(' '), + Event::HardBreak => out.push('\n'), + Event::Rule => { + if !out.ends_with('\n') { + out.push('\n'); + } + out.push_str("\u{2500}\u{2500}\u{2500}\n"); + } + Event::End(TagEnd::List(_)) => list_depth = list_depth.saturating_sub(1), + _ => {} + } + } + (out, spans) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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()); + } + + #[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"); + } + + #[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)); + } + + #[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")); + assert!( + !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"); + } + + #[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;")); + } +} diff --git a/iris/transcript-ui/src/row.rs b/iris/transcript-ui/src/row.rs new file mode 100644 index 0000000..a72efaa --- /dev/null +++ b/iris/transcript-ui/src/row.rs @@ -0,0 +1,289 @@ +//! One `iris::widget::list::ListRow` per folded transcript row +//! (`client_core::transcript_fold::TranscriptRow`). Each row's whole text +//! -- headings, paragraphs, inline styling -- goes through `markdown` into +//! **one** `TextEdit`, which is what makes it one thing `Selection` +//! (`selection.rs`) can select and what lets it wrap and scroll as a +//! single buffer, matching RUST.md's "hard to get back" behaviour 2 (rich +//! inline text) and half of behaviour 1 (selectable within a row; across +//! rows is `selection.rs`'s job). +//! +//! A `TranscriptRow::Tools` (a run of adjacent tool calls, grouped by +//! `client_core::transcript_fold::group_tool_runs`) is the row that proves +//! behaviour 3's "hold the edge nearest the tap" on expand: tapping its +//! header calls `List::note_tap` at the row's own on-screen position +//! (read back from `List::extent`, since the tap event only knows its +//! position *within* this row) before toggling a `WidgetPtr` between the +//! 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::selection::Selection; +use client_core::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow}; +use iris::prelude::*; +use std::{cell::RefCell, rc::Rc}; + +/// The paragraph size every row's `TextEdit` is built at; markdown headings +/// inside a row scale relative to a fixed set of sizes rather than this one +/// (`markdown::heading_size`), since a heading is meant to look the same +/// regardless of which row's base size surrounds it. +pub const BASE_SIZE: f32 = 16.0; + +/// `ItemKey::Seq` already is the `RowKey` (`u64`) this crate's `List` wants. +/// `ItemKey::RunId` is a string (a tool call's own id), so it is hashed into +/// one -- collisions are not a correctness risk worth guarding against here +/// (a `DefaultHasher` collision across the run ids one session produces is +/// astronomically unlikely, and the consequence of one would only be two +/// tool-call rows sharing a list slot, not data loss), and the high bit is +/// forced on so a hashed key can never collide with a real sequence number +/// (this build never produces 2^63 events). +pub fn row_key(key: &client_core::transcript_fold::ItemKey) -> RowKey { + use client_core::transcript_fold::ItemKey; + use std::hash::{Hash, Hasher}; + match key { + ItemKey::Seq(seq) => *seq, + ItemKey::RunId(id) => { + let mut h = std::collections::hash_map::DefaultHasher::new(); + id.hash(&mut h); + h.finish() | (1 << 63) + } + } +} + +/// The sender label shown above a row's text, and the markdown source to +/// render below it. `None` for a system-style note that has no sender. +fn item_content(item: &TranscriptItem) -> (Option<&str>, String) { + match item { + TranscriptItem::UserMsg { text, .. } => (Some("You"), text.clone()), + TranscriptItem::AssistantMsg { text, .. } => (Some("Claude"), text.clone()), + TranscriptItem::ErrorMsg { message, .. } => (Some("Error"), message.clone()), + TranscriptItem::CommandRow { text, .. } => (Some("Command"), format!("`/{text}`")), + TranscriptItem::PeerNote { from, text, .. } => (Some(from.as_str()), text.clone()), + TranscriptItem::Note { text, .. } => (None, text.clone()), + TranscriptItem::ClearedNote { .. } => (None, "_Context cleared._".to_string()), + TranscriptItem::CompactedNote { + pre_tokens, + post_tokens, + .. + } => ( + None, + match (pre_tokens, post_tokens) { + (Some(pre), Some(post)) => format!("_Compacted: {pre} -> {post} tokens._"), + _ => "_Compacted._".to_string(), + }, + ), + TranscriptItem::ImageItem { r#ref, .. } => (None, format!("_[image: {ref}]_")), + TranscriptItem::QuestionCard(card) => (Some("Question"), question_markdown(card)), + TranscriptItem::ToolRun { + tool, + input, + output, + .. + } => (Some(tool.as_str()), tool_call_markdown(tool, input, output)), + } +} + +fn question_markdown(card: &QuestionCard) -> String { + let mut out = card.prompt.clone(); + for opt in &card.options { + out.push_str(&format!("\n- {}", opt.label)); + } + out +} + +fn tool_call_markdown(tool: &str, input: &str, output: &str) -> String { + let mut out = format!("**{tool}**\n\n```\n{input}\n```"); + if !output.is_empty() { + out.push_str(&format!("\n\n```\n{output}\n```")); + } + out +} + +/// Build one `TextEdit` from a sender label plus markdown source, register +/// it with `selection` under `key`, and wire the pointer handlers that +/// drive `Selection::begin`/`extend` -- shared by every row variant below, +/// since a selectable row is always "one TextEdit plus this wiring" +/// regardless of what folded it. +fn build_text_row( + rsc: &mut Rsc, + selection: Rc>, + key: RowKey, + sender: Option<&str>, + markdown_src: &str, +) -> StrongWidget +where + Rsc::State: FocusHost, +{ + let (text, spans) = render_markdown(markdown_src, BASE_SIZE); + let field = wtext(text) + .spans(spans) + .editable(EditMode::MultiLine) + .text_align(Align::LEFT) + .wrap(true) + .size(BASE_SIZE) + .color(UiColor::WHITE) + .add(rsc); + selection.borrow_mut().register(key, field); + + field + .on(CursorSense::click_or_drag(), move |ctx, rsc| { + let sel = selection.clone(); + match ctx.data.sense { + CursorSense::PressStart(_) => { + sel.borrow_mut() + .begin(rsc, key, ctx.data.pos, ctx.data.size) + } + _ => sel + .borrow_mut() + .extend(rsc, key, ctx.data.pos, ctx.data.size), + } + }) + .add(rsc); + + // `.add` (weak), not `.add_strong` -- `header` is about to be embedded + // as a child of the `.span(Dir::DOWN)` below, whose own composition is + // what performs the *one* real strong registration each child gets. + // Calling `.add_strong`/`.upgrade` here too, then feeding a `.weak()` + // copy into that composition, tried to strong-register the same id + // twice and panicked with "was already added" + // (`core/src/widget/like.rs:12`) -- found running this crate's own + // `run-headless.sh` example, the first real render of a row. + let header: WeakWidget = match sender { + Some(name) => wtext(name.to_string()) + .size(13.0) + .color(UiColor::new(150, 150, 160, 255)) + .add(rsc), + None => Span::empty(Dir::DOWN).add(rsc), + }; + + (header, field.width(rest(1))) + .span(Dir::DOWN) + .gap(4) + .pad(10) + .add_strong(rsc) + .any() +} + +fn build_single( + rsc: &mut Rsc, + selection: Rc>, + key: RowKey, + item: &TranscriptItem, +) -> StrongWidget +where + Rsc::State: FocusHost, +{ + let (sender, markdown_src) = item_content(item); + build_text_row(rsc, selection, key, sender, &markdown_src) +} + +/// A run of adjacent tool calls: collapsed to a one-line summary by +/// default, expanding in place to every call's own tool/input/output on +/// tap -- see the module doc for the hold-the-edge contract this wires +/// against `list`. +fn build_tools( + rsc: &mut Rsc, + list: WeakWidget, + selection: Rc>, + key: RowKey, + calls: Vec, +) -> StrongWidget +where + Rsc::State: FocusHost, +{ + let expanded = Rc::new(RefCell::new(false)); + // `.add_strong` (not `.add`) because nothing else in the tree holds a + // strong reference to this `WidgetPtr` the way a container's own + // `add_strong`-on-its-children does for an ordinary child -- this row + // *is* the top of its own subtree, so it has to own itself. + let ptr_strong = WidgetPtr::new().add_strong(rsc); + let ptr = ptr_strong.weak(); + + let summary_text = format!("\u{25b8} {} tool calls", calls.len()); + let full_text = calls + .iter() + .map(|c| match c { + TranscriptItem::ToolRun { + tool, + input, + output, + .. + } => tool_call_markdown(tool, input, output), + other => item_content(other).1, + }) + .collect::>() + .join("\n\n"); + + fn build_content( + rsc: &mut Rsc, + selection: Rc>, + key: RowKey, + expanded: bool, + summary: &str, + full: &str, + ) -> StrongWidget + where + Rsc::State: FocusHost, + { + let text = if expanded { full } else { summary }; + build_text_row(rsc, selection, key, Some("Tools"), text) + } + + let content = build_content( + rsc, + selection.clone(), + key, + false, + &summary_text, + &full_text, + ); + ptr(rsc).set(content); + + ptr.on(CursorSense::click(), move |ctx, rsc| { + // `List::note_tap` wants a viewport-relative position, but the + // click event only knows where inside *this row* it landed + // (`ctx.data.pos`) -- `List::extent` (last frame's on-screen box + // for this row's key) is what turns the two into the position + // `list.rs`'s hold-the-edge layout pass resolves against, per the + // module doc's contract. + let (top, _bottom) = list(rsc).extent(key).unwrap_or((0.0, 0.0)); + list(rsc).note_tap(top + ctx.data.pos.y); + + let was_expanded = *expanded.borrow(); + *expanded.borrow_mut() = !was_expanded; + let content = build_content( + rsc, + selection.clone(), + key, + !was_expanded, + &summary_text, + &full_text, + ); + // The old content's `StrongWidget` is freed when this drops -- + // the removal half of the row this click just replaced. + let _old = ptr(rsc).replace(content); + }) + .add(rsc); + + ptr_strong.any() +} + +pub fn build_row( + rsc: &mut Rsc, + list: WeakWidget, + selection: Rc>, + row: &FoldedRow, +) -> (RowKey, StrongWidget) +where + Rsc::State: FocusHost, +{ + match row { + FoldedRow::Single(item) => { + let key = row_key(&item.key()); + (key, build_single(rsc, selection, key, item)) + } + FoldedRow::Tools(calls) => { + let key = row_key(&calls[0].key()); + (key, build_tools(rsc, list, selection, key, calls.clone())) + } + } +} diff --git a/iris/transcript-ui/src/selection.rs b/iris/transcript-ui/src/selection.rs new file mode 100644 index 0000000..499d0e6 --- /dev/null +++ b/iris/transcript-ui/src/selection.rs @@ -0,0 +1,227 @@ +//! Selection spanning multiple transcript rows -- RUST.md's "hard to get +//! back" behaviour 1, and the one E2 found flatly impossible on Masonry: +//! `TextArea` wraps exactly one `parley::PlainEditor`, and there is no +//! `SelectionContainer`-shaped type anywhere in `masonry`/`masonry_core`/ +//! `xilem` (RUST.md's E2 box, citing +//! `masonry/src/widgets/text_area.rs:414-459`). Each transcript row here is +//! still its own `TextEdit` (one per row, not one per transcript, since a +//! row is what `List` virtualises), so this is not literally "one +//! `PlainEditor`" either -- iris's answer is a coordinator that drives each +//! visible row's *own* selection primitives (`TextEditCtx::select`/ +//! `select_all`/`deselect`, already built for a single field) from one +//! pointer drag that crosses row boundaries, giving the same reader-facing +//! result (a selection that runs from a reply into the tool output beneath +//! it, one copy) without needing a single shared text buffer underneath. +//! +//! Rows are keyed by `RowKey` (`iris::widget::list`), which every real row +//! source (a transcript's sequence number) already assigns in the order the +//! reader reads them in -- so "between the anchor and the current row" is +//! answered by ordinary integer comparison via a `BTreeMap`, not a second +//! copy of the list's own ordering. +//! +//! **Scoped shortcut, recorded rather than hidden**: the anchor row (the +//! one the 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" -- the exact partial selection would need +//! that row's own laid-out size, which `TextEditCtx` does not expose to a +//! caller outside `iris::widget::text` (`edit.rs`'s `layout()` helper is +//! private). Only the row currently *under the pointer* gets a true partial +//! selection (from its own start or end, per direction, to the pointer's +//! exact point) -- see `extend`. Re-entering the anchor row is still exact, +//! since that branch never goes through the approximation. + +use iris::prelude::*; +use std::collections::BTreeMap; + +pub struct Selection { + rows: BTreeMap>, + anchor: Option<(RowKey, Vec2)>, +} + +impl Default for Selection { + fn default() -> Self { + Self::new() + } +} + +impl Selection { + pub fn new() -> Self { + Self { + rows: BTreeMap::new(), + anchor: None, + } + } + + /// A row's selectable text became visible/known. Every addition here + /// needs its removal (`unregister`) -- called when `List` evicts the + /// row (`pop_front`/`pop_back`), so this map never outgrows however + /// many rows are actually loaded. + pub fn register(&mut self, key: RowKey, text: WeakWidget) { + self.rows.insert(key, text); + } + + pub fn unregister(&mut self, key: RowKey) { + self.rows.remove(&key); + if self.anchor.map(|(k, _)| k) == Some(key) { + self.anchor = None; + } + } + + /// A fresh press: clears whatever was selected elsewhere (an ordinary + /// click starts a new selection, it does not extend the old one) and + /// gives `key`'s row a collapsed caret at `pos` -- a plain click that + /// never turns into a drag leaves exactly this and nothing else + /// selected. + pub fn begin(&mut self, ui: &mut impl UiRsc, key: RowKey, pos: Vec2, size: Vec2) { + let rows: Vec = self.rows.keys().copied().collect(); + for k in rows { + if k != key + && let Some(w) = self.rows.get(&k) + { + w.edit(ui).deselect(); + } + } + if let Some(w) = self.rows.get(&key) { + w.edit(ui).select(pos, size, false, false); + } + self.anchor = Some((key, pos)); + } + + /// The drag continues, now over `key`'s row at `pos`. See the module + /// doc for the anchor-row shortcut. + pub fn extend(&mut self, ui: &mut impl UiRsc, key: RowKey, pos: Vec2, size: Vec2) { + let Some((anchor_key, _anchor_pos)) = self.anchor else { + return; + }; + if key == anchor_key { + if let Some(w) = self.rows.get(&key) { + w.edit(ui).select(pos, size, true, false); + } + return; + } + let (lo, hi) = if anchor_key < key { + (anchor_key, key) + } else { + (key, anchor_key) + }; + let in_range: Vec = self.rows.range(lo..=hi).map(|(&k, _)| k).collect(); + for k in &in_range { + let Some(w) = self.rows.get(k).copied() else { + continue; + }; + if *k == key { + // The row under the pointer: partial selection from + // whichever of its own edges faces the anchor, extended to + // the exact pointer point. + let start = if key > anchor_key { Vec2::ZERO } else { size }; + w.edit(ui).select(start, size, false, false); + w.edit(ui).select(pos, size, true, false); + } else { + w.edit(ui).select_all(); + } + } + let outside: Vec = self + .rows + .keys() + .copied() + .filter(|k| *k < lo || *k > hi) + .collect(); + for k in outside { + if let Some(w) = self.rows.get(&k) { + w.edit(ui).deselect(); + } + } + } + + /// The concatenated selected text, in row order, `None` if nothing is + /// selected -- what a copy command reads. Joins with a blank line + /// between rows, matching how the transcript itself separates them. + pub fn selected_text(&self, ui: &mut impl UiRsc) -> Option { + let mut parts = Vec::new(); + for w in self.rows.values() { + if let Some(text) = w.edit(ui).text.selected_text() { + parts.push(text); + } + } + if parts.is_empty() { + None + } else { + Some(parts.join("\n\n")) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Pure range-membership logic, independent of any widget/render + // machinery (the same reasoning `begin`/`extend` apply per-row) -- + // exercised directly so the "which rows fall between anchor and + // current" arithmetic has a test that needs no `UiRenderState`. + fn in_range(anchor: RowKey, current: RowKey, keys: &[RowKey]) -> Vec { + let (lo, hi) = if anchor < current { + (anchor, current) + } else { + (current, anchor) + }; + keys.iter() + .copied() + .filter(|k| *k >= lo && *k <= hi) + .collect() + } + + #[test] + fn selection_spans_forward_across_rows() { + let keys = [1, 2, 3, 4, 5]; + assert_eq!(in_range(2, 4, &keys), vec![2, 3, 4]); + } + + #[test] + fn selection_spans_backward_across_rows() { + let keys = [1, 2, 3, 4, 5]; + assert_eq!(in_range(4, 2, &keys), vec![2, 3, 4]); + } + + #[test] + fn selection_within_one_row_is_just_that_row() { + let keys = [1, 2, 3]; + assert_eq!(in_range(2, 2, &keys), vec![2]); + } + + struct TestRsc { + ui: UiData, + } + impl UiRsc for TestRsc { + fn ui(&self) -> &UiData { + &self.ui + } + fn ui_mut(&mut self) -> &mut UiData { + &mut self.ui + } + } + + #[test] + fn unregister_forgets_the_row_and_clears_a_matching_anchor() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let field = rsc + .ui + .widgets + .add_strong(TextEdit::new( + TextView::new(TextBuffer::new_empty(), TextAttrs::default(), None), + EditMode::MultiLine, + )) + .weak(); + + let mut sel = Selection::new(); + sel.register(5, field); + sel.anchor = Some((5, Vec2::ZERO)); + assert_eq!(sel.rows.len(), 1); + + sel.unregister(5); + assert!(sel.rows.is_empty()); + assert!(sel.anchor.is_none()); + } +}