Merge branch 'worktree-agent-a23aa965694b9eaf5' into rustify (I5: transcript-ui, SpanStyle)

This commit is contained in:
iris committed 2026-09-05 07:56:09 -04:00
commit 22d5c6585a
16 files changed
+1910 -21

No files matched your search

+41
View File
@@ -8,6 +8,47 @@ capability that moved. Small and trivial changes do not go here.
An entry gives the date, what changed, why, and a short before/after where 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. 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) ## 2026-09-05: accessibility names via AccessKit (RUST.md's I4)
`.label()` (already in `trait_fns.rs`, previously unused anywhere in-tree) `.label()` (already in `trait_fns.rs`, previously unused anywhere in-tree)
+48
View File
@@ -170,6 +170,54 @@ order and what "done" looks like. Tick and date them in place.
behave as designed; its *append* half did not, until the fix above moved 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) masks/move_offsets out of the per-image bind group — now flat at O(1)
the same way (b) and (c) are. 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 - [ ] **Masks defined relative to each other.** Wanted: mask A multiplies
by something *and also* applies mask B — a mask can reference a parent 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 mask, the way the move chain references a parent offset. Today masks
+261 -6
View File
@@ -40,10 +40,11 @@ session spending an afternoon on them again.
keyboard gap — now explained, see below), E2 (a transcript in Masonry, keyboard gap — now explained, see below), E2 (a transcript in Masonry,
which found that Masonry has no touch-scroll on Android at all — see 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 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 Gradle-free packaging xtask, both pass conditions proved — see its own
box)**, I0a, I0b (iris builds on a pinned nightly and runs), I1 (parley + box), I0a, I0b (iris builds on a pinned nightly and runs), I1 (parley +
glyph atlas), I2 (iris on android-view), I3 (`iris::widget::List`). 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 - **E5 done, 2026-09-05.** `cargo xtask apk` (new `xtask/` crate at the
repo root, zero dependencies) replaces Gradle for packaging repo root, zero dependencies) replaces Gradle for packaging
`app/shellApp`: `cargo ndk``javac`/`d8``aapt2``zipalign` `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 full account, including the one disclosed place Gradle still runs and
what was deliberately left undone (a real-device `arm64-v8a` install, what was deliberately left undone (a real-device `arm64-v8a` install,
dex shrinking). 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** - **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 (`android-shell/` — a JNI-bridge crate on `client-core` — plus a new
Gradle module `app/shellApp/`, left deliberately separate from 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 carries the screen within the Compose baseline, it is the app's
framework and Masonry was the calibration. If it does not, the framework and Masonry was the calibration. If it does not, the
measurement says which parts of Masonry to adopt underneath it. 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), 4. Then the shell (E3), the desktop window (E4) and the packaging (E5),
which do not depend on the choice. which do not depend on the choice.
@@ -2104,9 +2148,220 @@ silently on real hardware.
immediately after the first (attach, detach, attach again) and immediately after the first (attach, detach, attach again) and
confirm the process is still alive afterward -- the detach-abort confirm the process is still alive afterward -- the detach-abort
this box's mitigation exists for. this box's mitigation exists for.
- [ ] **I5 — the transcript screen in iris.** E2's pass conditions, all - [~] **I5 — the transcript screen in iris (2026-09-05). The widget-tree
seven behaviours, against the sandbox with `--delay`. This is the half is built, tested and screenshotted; the emulator half (real
point the decision in the recommendation is made at. device numbers against the Compose baseline) is not -- ticked
partial rather than done, see "What remains" at the end of this box.**
**Where it lives.** `iris/transcript-ui/` (new workspace member,
`[lib]`), the same shape as `iris/tabs-ui`: generic over `Rsc:
HasEvents` + `Rsc::State: FocusHost` so the same `build()` can run
under winit (`transcript-ui/examples/transcript.rs`) or an
android-view cdylib later. Depends on `client-core`/`event-model` by
path (real code, matching E2's precedent) and `pulldown-cmark`
(0.13.4, current stable). Four modules: `markdown.rs` (CommonMark ->
plain text + `Vec<SpanStyle>`), `row.rs` (one `iris::widget::List`
row per folded `TranscriptRow`), `selection.rs` (cross-row
selection), `composer.rs` (the growing input field). `lib.rs`'s own
module doc has the screen's shape and the one gap it documents up
front (below).
**New iris API, added in this box and recorded in `IRIS.md`:
`SpanStyle`, per-range text styling.** This is the actual answer to
RUST.md's E2 finding against Masonry ("rich inline text -- block-level
yes, inline no, and both for the same reason":
`masonry/src/widgets/text_area.rs:43-44`'s `TextArea::edit_styles()`
returns one `StyleSet` for the whole editor, with `// TODO:
RichTextInput` beside it). `core/src/primitive/text.rs`'s
`TextBuffer` gained `spans: Vec<SpanStyle>` and `set_spans`;
`SpanStyle{range, color, family, font_size, bold, italic,
underline}` pushes into parley's `RangedBuilder` via `.push(property,
range)` instead of only `.push_default(...)`, so one `TextEdit` can
carry a heading's bigger bold font, an inline-code span's monospace
colour, a link's colour+underline and an ordinary paragraph's base
style all in the *same* wrapped, selectable buffer.
`core/src/render/atlas.rs`'s `PlacedGlyph` gained a `color: UiColor`
field (read from parley's own per-run `Style::brush`,
`core/src/primitive/text.rs`'s `TextData::place`) and
`core/src/ui/painter.rs`'s `glyphs()` now colours each glyph from
that field instead of one colour for the whole `RenderedText` --
the change that actually makes a span's colour reach the screen.
**Real bug found and fixed while wiring this in**: `TextBuilder`'s
`.spans(...)` was only threaded through `TextOutput::run` (the
read-only `Text` widget), not the sibling `TextEditOutput::run` (the
`TextEdit` every transcript row actually uses) -- a "rule that
governs a set belongs to the set, not one member" miss, per
CODE_RULES.md; found because `run-headless.sh`'s screenshot showed
*no* styling at all despite `markdown.rs`'s own unit tests passing
(they only check the string/range logic, not the render path -- see
`iris/src/widget/text/build.rs`'s `TextEditOutput::run`, now fixed).
**The seven behaviours, each shown or given a sourced reason, same
structure as E2's own accounting:**
1. **Selection spanning rows -- shown, with a scoped shortcut
recorded rather than hidden.** `selection.rs`'s `Selection`
coordinates each visible row's own `TextEditCtx::select`/
`select_all`/`deselect` (already built for one field, I2) from a
single drag that crosses row boundaries: rows between the anchor
and the pointer get `select_all()`, the row under the pointer gets
a true partial selection from whichever edge faces the anchor,
and `selected_text()` concatenates the result in row order. The
one shortcut: the *anchor* row is selected in full once the drag
leaves it, rather than "from the click point to its far edge",
because that needs the row's own laid-out size and
`TextEditCtx`'s `layout()` helper is private
(`iris/src/widget/text/edit.rs`) -- see `selection.rs`'s module
doc. Pure range-membership logic (`in_range`, mirroring
`begin`/`extend`'s row-selection arithmetic) is unit-tested
without any render harness; the widget-level wiring is not
independently screenshotted this pass (would need a synthetic
drag injected into the winit example -- not attempted, time).
2. **Rich inline text -- shown, genuinely inline this time.**
`markdown::render_markdown` folds one row's whole markdown (not
one block at a time) into one string plus spans, so a heading, a
**bold** word, *italic* text, `inline code`, and a
[link](url) inside the same paragraph render in one `TextEdit`
that still wraps and selects as a single buffer --
screenshotted, see below. Deliberately not attempted, each
recorded at the point it would have gone in `markdown.rs`'s own
doc: a background chip behind inline code (needs glyph-run
geometry `TextEdit`-internal and not exposed, the same primitive
`TextEdit::draw`'s selection highlight uses,
`iris/src/widget/text/edit.rs:99`), a tappable link (same missing
primitive), a real table layout, and per-token syntax colour
inside a fence.
3. **Bottom-anchored virtualised list, hold-the-edge on expand --
shown**, reusing I3's `List` unmodified. A `TranscriptRow::Tools`
row collapses to "N tool calls" and expands to every call's own
tool/input/output on tap; `row.rs`'s click handler calls
`List::extent(key)` to convert the tap's row-local position into
the viewport-relative position `List::note_tap` wants, exactly
the two-step contract `list.rs`'s module doc describes for
`holdTopEdge`. Not independently screenshotted mid-expand this
pass (no input-injection into the desktop example was built) --
the mechanism is the same one I3 already benchmarked
(`expand-hold`, flat at 0.10-0.11ms across N), applied to real
content instead of a synthetic row.
4. **The soft keyboard -- inherited from I2, not re-investigated.**
The composer (`composer.rs`) is an ordinary `TextEdit` with the
same `InputConnection` bridge I2 built and measured (Gboard
suggestions over real buffer content); nothing new to add here,
and no Android shell exists yet for this screen specifically to
re-verify it against (see "What remains").
5. **Platform integration -- out of scope by design**, same as E2:
E3's list, not this box's.
6. **Accessibility names -- shown for the composer, not yet for
rows.** The composer field carries `.label("Message")` (I4). Rows
do not yet carry per-row labels (a row's own text *is* its
accessible content via `TextEdit`'s `access_role`, I4, but
nothing calls `.label()` on it, so `Widgets::named()` does not
include it) -- a small, real gap, recorded as an IRIS_TODO.md
item rather than silently left, since AGENTS.md's bench scripts
depend on exactly this for driving a screen by name.
7. **Measurable frames / the render-number pass condition -- not
attempted, and unlike E2 the reason is not an absent gesture
path.** `List` demonstrably scrolls (I3's flat draws/moves,
programmatic `scroll()`) and mouse-wheel scrolling is wired here
(`lib.rs`'s `CursorSense::Scroll` on `list`). What is *not*
reachable yet is a **touch-drag pan starting on a row's own
text**: `row.rs` registers `CursorSense::click_or_drag()` on each
row's `TextEdit` for selection, and `TextEdit::draw` calls
`painter.child_layer()` (`iris/src/widget/text/edit.rs:87`), so
`core/src/sense.rs`'s `run_sensors` (which stops at the first
layer, checked innermost-first, that consumed the gesture) gives
that row first refusal on *every* frame it is pressed, not just
the frame the press started -- a row's drag-select wins the same
gesture a list-level pan would want. `lib.rs`'s own module doc
states this precisely, with the fix named (a press distance/time
arbiter deciding pan vs. select before either commits, or gating
text-drag-selection behind a long-press). This is a genuine,
diagnosed architecture gap this box's *own* two features created
by both wanting the same gesture -- not a missing primitive the
way Masonry's absent `on_pointer_event` drag handling was.
**Verification, exact commands and results (2026-09-05, this VM):**
- `cargo fmt --all -- --check`: clean.
- `cargo build --workspace --all-targets`: clean, all six workspace
members (`iris`, `iris-core`, `iris-macro`, `tabs-ui`,
`transcript-ui`, plus the excluded `android-app`).
- `cargo clippy --all-targets` and `cargo clippy -p transcript-ui
--all-targets`: zero warnings.
- `cargo test --workspace`: 28 tests in `iris`/`iris-core` (all
pre-existing, unaffected) + **9 new in `transcript-ui`** -- 5 pure
markdown tests (`bold_and_italic_produce_spans_over_the_right_range`,
`heading_gets_a_bigger_font_size_span`,
`link_is_styled_and_keeps_its_visible_text`,
`fenced_code_block_is_monospaced`, a plain-text baseline) and 4
selection tests (forward/backward/single-row range arithmetic,
plus `unregister_forgets_the_row_and_clears_a_matching_anchor`
against a real minimal `TextEdit` in the arena, no window needed --
same harness style as `list.rs`'s own tests).
- `cargo ndk -t x86_64 -P 26 build -p transcript-ui` and `... clippy
-p transcript-ui --lib`: clean (`--lib` only -- the example uses
`iris::default`, winit-only by design, same as `iris/examples/
tabs`'s own example never having an Android build of itself; the
Android-facing entry point is a separate cdylib, not built this
pass, see below). `cargo ndk ... build -p iris` / `clippy -p iris`
also re-checked clean, since this box touched `iris-core`'s text
pipeline.
- `run-headless.sh transcript --shot ... -- -p transcript-ui`:
renders. Cropped for legibility (this VM has no image viewer --
see I3's own note on the same limitation and the throwaway crop
tool used here, not committed): a full conversation with a
**bold** word, *italic* text, `inline code` in its own colour, a
`# Sure` heading rendered visibly larger and bold, a coloured link,
a monospaced fenced code block, a collapsed "▸ 3 tool calls" row,
and the composer bar at the bottom -- every one of E2's markdown
screenshot's features, now inline within single paragraphs rather
than block-per-widget. Screenshots at `/tmp/iris_i5_transcript2.png`
(full) and crops there, not committed per the standing rule against
screenshots of real content leaving this repo -- these are
synthetic rows, but the rule is kept uniform regardless.
**What remains, named rather than silently dropped (also in
IRIS_TODO.md, dated 2026-09-05):**
- **The emulator half of the pass condition was not attempted.**
`emu list` shows `emulator-5554` (AVD `ai-app`, a different
checkout) held by another session during this pass, but even with
a free emulator this needs real Android integration that does not
exist yet for this screen: a cdylib + Gradle shell the way
`iris-android-app` wraps `tabs-ui` (I2), real
`client-core::ApiClient`/`event_stream::follow_session_events`
wiring against `app/ui-sandbox.sh` with `--delay` (this crate
deliberately does not fetch anything itself, see `lib.rs`'s doc),
and then `transcript-bench.sh`'s gesture compared against the
Compose baseline. That is real, multi-part follow-on work in its
own right -- closer in size to E2/E3 than to "run one more
script" -- not something this pass's remaining time could
responsibly rush and still report honestly.
- **Touch-drag panning over a row's own text** -- behaviour 7 above.
- **Row-level accessibility names** -- behaviour 6 above.
- **A tappable link and a code-span background chip** -- behaviour 2.
- **`Selection`'s anchor-row shortcut** -- behaviour 1.
- **No syntax highlighting inside a fenced code block** -- `markdown.rs`
notes `client_core::highlight` exists and could feed this.
- **`row.rs`'s tool-row expand and `selection.rs`'s cross-row drag
are not independently screenshotted/driven** -- covered by reading
and by the primitives they reuse (I3's `List` tests, this box's
own unit tests), not by a dedicated repro this pass.
**Net for the recommendation.** Item 3 ("decide when the transcript
screen exists in both, from the measurements") still cannot be
decided by a number -- E2 could not produce one for Masonry, and I5
has not yet produced one for iris either, for an unrelated reason
(no Android harness built yet, not an absent capability). What *can*
be said structurally, updating E2's own conclusion: iris now also
demonstrates the two things E2 found Masonry structurally unable to
do at all -- cross-row selection and true per-span inline rich text
inside one wrapped, selectable buffer -- neither of which exists
anywhere in `masonry`/`masonry_core`/`xilem` today (E2's own
`grep -rln` finding). That is a second structural point in iris's
favour, alongside I2's working touch-scroll-vs-Masonry's-absent one,
still short of the render-number comparison the recommendation
ultimately wants.
## For the next agent ## For the next agent
+363 -4
View File
@@ -151,7 +151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"getrandom", "getrandom 0.3.4",
"once_cell", "once_cell",
"version_check", "version_check",
"zerocopy", "zerocopy",
@@ -534,6 +534,12 @@ dependencies = [
"arrayvec", "arrayvec",
] ]
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.8.0" version = "0.8.0"
@@ -710,6 +716,16 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "client-core"
version = "0.1.0"
dependencies = [
"event-model",
"serde",
"serde_json",
"ureq",
]
[[package]] [[package]]
name = "clipboard-win" name = "clipboard-win"
version = "5.4.1" version = "5.4.1"
@@ -755,6 +771,35 @@ dependencies = [
"crossbeam-utils", "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]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.4" version = "0.9.4"
@@ -871,6 +916,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]] [[package]]
name = "dispatch" name = "dispatch"
version = "0.2.0" version = "0.2.0"
@@ -1023,6 +1074,14 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "event-model"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]] [[package]]
name = "exr" name = "exr"
version = "1.74.0" version = "1.74.0"
@@ -1165,6 +1224,15 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" 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]] [[package]]
name = "futures-core" name = "futures-core"
version = "0.3.34" version = "0.3.34"
@@ -1239,6 +1307,26 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.3.4" version = "0.3.4"
@@ -1398,6 +1486,22 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" 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]] [[package]]
name = "icu_collections" name = "icu_collections"
version = "2.3.0" version = "2.3.0"
@@ -1526,6 +1630,27 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" 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]] [[package]]
name = "image" name = "image"
version = "0.25.9" version = "0.25.9"
@@ -1641,6 +1766,12 @@ dependencies = [
"either", "either",
] ]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]] [[package]]
name = "jni" name = "jni"
version = "0.21.1" version = "0.21.1"
@@ -1669,7 +1800,7 @@ version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [ dependencies = [
"getrandom", "getrandom 0.3.4",
"libc", "libc",
] ]
@@ -1978,6 +2109,12 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]] [[package]]
name = "num-derive" name = "num-derive"
version = "0.4.2" version = "0.4.2"
@@ -2620,6 +2757,12 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.21" version = "0.2.21"
@@ -2672,6 +2815,25 @@ dependencies = [
"syn 2.0.113", "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]] [[package]]
name = "pxfm" name = "pxfm"
version = "0.1.27" version = "0.1.27"
@@ -2746,7 +2908,7 @@ version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
dependencies = [ dependencies = [
"getrandom", "getrandom 0.3.4",
] ]
[[package]] [[package]]
@@ -2881,6 +3043,20 @@ version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" 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]] [[package]]
name = "roxmltree" name = "roxmltree"
version = "0.21.1" version = "0.21.1"
@@ -2922,6 +3098,41 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.22" version = "1.0.22"
@@ -2998,6 +3209,19 @@ dependencies = [
"syn 2.0.113", "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]] [[package]]
name = "serde_repr" name = "serde_repr"
version = "0.1.21" version = "0.1.21"
@@ -3138,6 +3362,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "swash" name = "swash"
version = "0.2.10" version = "0.2.10"
@@ -3196,7 +3426,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"getrandom", "getrandom 0.3.4",
"once_cell", "once_cell",
"rustix 1.1.3", "rustix 1.1.3",
"windows-sys 0.61.2", "windows-sys 0.61.2",
@@ -3265,6 +3495,36 @@ dependencies = [
"zune-jpeg 0.4.21", "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]] [[package]]
name = "tiny-skia" name = "tiny-skia"
version = "0.11.4" version = "0.11.4"
@@ -3371,6 +3631,16 @@ dependencies = [
"once_cell", "once_cell",
] ]
[[package]]
name = "transcript-ui"
version = "0.1.0"
dependencies = [
"client-core",
"event-model",
"iris",
"pulldown-cmark",
]
[[package]] [[package]]
name = "tree_magic_mini" name = "tree_magic_mini"
version = "3.2.2" version = "3.2.2"
@@ -3409,6 +3679,12 @@ dependencies = [
"keyboard-types", "keyboard-types",
] ]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.22" version = "1.0.22"
@@ -3427,6 +3703,62 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" 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]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"
@@ -3471,6 +3803,12 @@ dependencies = [
"winapi-util", "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]] [[package]]
name = "wasip2" name = "wasip2"
version = "1.0.1+wasi-0.2.4" version = "1.0.1+wasi-0.2.4"
@@ -3667,6 +4005,15 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "weezl" name = "weezl"
version = "0.1.12" version = "0.1.12"
@@ -4535,6 +4882,12 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.5" version = "0.2.5"
@@ -4570,6 +4923,12 @@ dependencies = [
"syn 3.0.5", "syn 3.0.5",
] ]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]] [[package]]
name = "zune-core" name = "zune-core"
version = "0.4.12" version = "0.4.12"
+6 -1
View File
@@ -73,7 +73,7 @@ name = "message_list"
harness = false harness = false
[workspace] [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 # android-app pulls in android-view, which needs the NDK sysroot to link
# -- excluded so `cargo build --workspace --all-targets` on the host stays # -- excluded so `cargo build --workspace --all-targets` on the host stays
# buildable. Cross-compile it from its own directory (its own single-crate # 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-core = { path = "core" }
iris-macro = { path = "macro" } iris-macro = { path = "macro" }
tokio = "1.49.0" 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"
+113 -6
View File
@@ -1,8 +1,9 @@
use crate::{Align, GlyphAtlas, GlyphKey, PlacedGlyph, RegionAlign, Textures, UiColor, util::Vec2}; use crate::{Align, GlyphAtlas, GlyphKey, PlacedGlyph, RegionAlign, Textures, UiColor, util::Vec2};
use parley::{ use parley::{
Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, GenericFamily, Layout, Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, FontStyle, FontWeight,
LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty, GenericFamily, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty,
}; };
use std::ops::Range;
use swash::{ use swash::{
FontRef, FontRef,
scale::{Render, ScaleContext, Source, StrikeWith}, 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<usize>,
pub color: Option<UiColor>,
pub family: Option<Family>,
/// 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<f32>,
pub bold: bool,
pub italic: bool,
pub underline: bool,
}
impl SpanStyle {
pub fn new(range: Range<usize>) -> 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)] #[derive(Clone, PartialEq)]
pub struct TextAttrs { pub struct TextAttrs {
pub color: UiColor, pub color: UiColor,
@@ -86,8 +153,12 @@ impl Default for TextAttrs {
pub struct TextBuffer { pub struct TextBuffer {
text: String, text: String,
layout: Layout<UiColor>, layout: Layout<UiColor>,
spans: Vec<SpanStyle>,
/// What the current layout was built for, so `shape` can decline to redo /// 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<f32>)>, shaped: Option<(TextAttrs, Option<f32>)>,
} }
@@ -96,10 +167,19 @@ impl TextBuffer {
Self { Self {
text: text.into(), text: text.into(),
layout: Layout::new(), layout: Layout::new(),
spans: Vec::new(),
shaped: None, 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<SpanStyle>) {
self.spans = spans;
self.shaped = None;
}
pub fn new_empty() -> Self { pub fn new_empty() -> Self {
Self::new("") Self::new("")
} }
@@ -150,6 +230,27 @@ impl TextBuffer {
attrs.line_height, attrs.line_height,
))); )));
builder.push_default(StyleProperty::Brush(attrs.color)); 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); builder.build_into(&mut self.layout, &self.text);
self.layout.break_all_lines(width); self.layout.break_all_lines(width);
self.layout self.layout
@@ -175,6 +276,7 @@ impl TextData {
let font = run.run().font(); let font = run.run().font();
let font_size = run.run().font_size(); let font_size = run.run().font_size();
let coords = run.run().normalized_coords(); 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) let Some(font_ref) = FontRef::from_index(font.data.as_ref(), font.index as usize)
else { else {
continue; continue;
@@ -227,6 +329,7 @@ impl TextData {
glyph.x.floor() + entry.left as f32, glyph.x.floor() + entry.left as f32,
glyph.y.floor() - entry.top as f32, glyph.y.floor() - entry.top as f32,
), ),
color: run_color,
}); });
} }
} }
@@ -245,11 +348,15 @@ fn hash_coords(coords: &[i16]) -> u64 {
h h
} }
/// A laid-out string, ready to draw: where each glyph goes, how big the whole /// A laid-out string, ready to draw: where each glyph goes and how big the
/// thing is, and what colour to tint the atlas with. /// whole thing is.
/// ///
/// Cheap to clone and to keep, which is the point -- a widget holds one across /// 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)] #[derive(Clone)]
pub struct RenderedText { pub struct RenderedText {
pub glyphs: std::sync::Arc<Vec<PlacedGlyph>>, pub glyphs: std::sync::Arc<Vec<PlacedGlyph>>,
+9 -1
View File
@@ -10,7 +10,7 @@
//! it, and a resize re-emits quads without touching the GPU's copy at all. //! it, and a resize re-emits quads without touching the GPU's copy at all.
use crate::{ use crate::{
PatchRect, TextureHandle, Textures, PatchRect, TextureHandle, Textures, UiColor,
util::{HashMap, Vec2}, util::{HashMap, Vec2},
}; };
use image::RgbaImage; 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. /// 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)] #[derive(Clone, Copy)]
pub struct PlacedGlyph { pub struct PlacedGlyph {
pub entry: GlyphEntry, pub entry: GlyphEntry,
pub offset: Vec2, pub offset: Vec2,
pub color: UiColor,
} }
+1 -1
View File
@@ -194,7 +194,7 @@ impl<'a> Painter<'a> {
glyph.entry.uv_min, glyph.entry.uv_min,
glyph.entry.uv_max, glyph.entry.uv_max,
glyph.entry.layer, glyph.entry.layer,
text.color, glyph.color,
flags_for(glyph.entry.is_color), flags_for(glyph.entry.is_color),
), ),
region, region,
+16 -2
View File
@@ -4,6 +4,7 @@ use std::marker::{PhantomData, Sized};
pub struct TextBuilder<State, O = TextOutput, H: WidgetOption<State> = ()> { pub struct TextBuilder<State, O = TextOutput, H: WidgetOption<State> = ()> {
pub content: String, pub content: String,
pub attrs: TextAttrs, pub attrs: TextAttrs,
pub spans: Vec<SpanStyle>,
pub hint: H, pub hint: H,
pub output: O, pub output: O,
state: PhantomData<State>, state: PhantomData<State>,
@@ -39,10 +40,19 @@ impl<State, O, H: WidgetOption<State>> TextBuilder<State, O, H> {
self.attrs.wrap = wrap; self.attrs.wrap = wrap;
self 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<SpanStyle>) -> Self {
self.spans = spans;
self
}
pub fn editable(self, mode: EditMode) -> TextBuilder<State, TextEditOutput, H> { pub fn editable(self, mode: EditMode) -> TextBuilder<State, TextEditOutput, H> {
TextBuilder { TextBuilder {
content: self.content, content: self.content,
attrs: self.attrs, attrs: self.attrs,
spans: self.spans,
hint: self.hint, hint: self.hint,
output: TextEditOutput { mode }, output: TextEditOutput { mode },
state: PhantomData, state: PhantomData,
@@ -58,6 +68,7 @@ impl<Rsc: UiRsc, O> TextBuilder<Rsc, O> {
TextBuilder { TextBuilder {
content: self.content, content: self.content,
attrs: self.attrs, attrs: self.attrs,
spans: self.spans,
hint: move |rsc: &mut Rsc| Some(hint.add_strong(rsc).any()), hint: move |rsc: &mut Rsc| Some(hint.add_strong(rsc).any()),
output: self.output, output: self.output,
state: PhantomData, state: PhantomData,
@@ -81,7 +92,8 @@ impl<Rsc: UiRsc> TextBuilderOutput<Rsc> for TextOutput {
state: &mut Rsc, state: &mut Rsc,
builder: TextBuilder<Rsc, Self, H>, builder: TextBuilder<Rsc, Self, H>,
) -> Self::Output { ) -> 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 hint = builder.hint.get(state);
let mut text = Text { let mut text = Text {
content: builder.content.into(), content: builder.content.into(),
@@ -103,7 +115,8 @@ impl<State: UiRsc> TextBuilderOutput<State> for TextEditOutput {
state: &mut State, state: &mut State,
builder: TextBuilder<State, Self, H>, builder: TextBuilder<State, Self, H>,
) -> Self::Output { ) -> Self::Output {
let buf = TextBuffer::new(&builder.content); let mut buf = TextBuffer::new(&builder.content);
buf.set_spans(builder.spans);
TextEdit::new( TextEdit::new(
TextView::new(buf, builder.attrs, builder.hint.get(state)), TextView::new(buf, builder.attrs, builder.hint.get(state)),
builder.output.mode, builder.output.mode,
@@ -125,6 +138,7 @@ pub fn wtext<State>(content: impl Into<String>) -> TextBuilder<State> {
TextBuilder { TextBuilder {
content: content.into(), content: content.into(),
attrs: TextAttrs::default(), attrs: TextAttrs::default(),
spans: Vec::new(),
hint: (), hint: (),
output: TextOutput, output: TextOutput,
state: PhantomData, state: PhantomData,
+20
View File
@@ -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 }
+122
View File
@@ -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::<Client>::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<FoldedRow> {
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<Self>,
_: Proxy<Self::Event>,
) -> 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 }
}
}
+48
View File
@@ -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<TextEdit>,
}
/// 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: HasEvents>(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::<Selectable>(())
.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)
}
+123
View File
@@ -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<client_core::transcript_fold::TranscriptRow>` 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<List>,
pub composer: composer::Composer,
selection: Rc<RefCell<Selection>>,
}
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<Rsc: HasEvents>(&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<String> {
self.selection.borrow().selected_text(rsc)
}
}
pub fn build<Rsc: HasEvents>(
rsc: &mut Rsc,
ui_state: &mut impl HasRoot,
rows: Vec<FoldedRow>,
) -> 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,
}
}
+223
View File
@@ -0,0 +1,223 @@
//! Markdown -> one plain string plus a `Vec<SpanStyle>`, for I5's row
//! builder to hand to a single `TextEdit` (`row.rs`). This is the crate's
//! answer to RUST.md's E2 finding against Masonry ("rich inline text --
//! block-level yes, inline no, and both for the same reason": `TextArea`'s
//! `StyleSet` is one style for the whole editor,
//! `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<u8>` (`core/src/lib.rs`), not the 0..1 float triples
// its brighter/darker helpers might suggest -- these are plain 0..255 RGB.
pub const CODE_COLOR: UiColor = UiColor::new(140, 217, 242, 255);
pub const LINK_COLOR: UiColor = UiColor::new(140, 190, 255, 255);
const STRIKETHROUGH_COLOR: UiColor = UiColor::new(150, 150, 150, 255);
/// 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<SpanStyle>) {
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<usize> = 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;"));
}
}
+289
View File
@@ -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: HasEvents>(
rsc: &mut Rsc,
selection: Rc<RefCell<Selection>>,
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: HasEvents>(
rsc: &mut Rsc,
selection: Rc<RefCell<Selection>>,
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: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<List>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
calls: Vec<TranscriptItem>,
) -> 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::<Vec<_>>()
.join("\n\n");
fn build_content<Rsc: HasEvents>(
rsc: &mut Rsc,
selection: Rc<RefCell<Selection>>,
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: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<List>,
selection: Rc<RefCell<Selection>>,
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()))
}
}
}
+227
View File
@@ -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<RowKey, WeakWidget<TextEdit>>,
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<TextEdit>) {
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<RowKey> = 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<RowKey> = 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<RowKey> = 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<String> {
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<RowKey> {
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());
}
}