Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62199aa3a7 | ||
|
|
b133d85943 | ||
|
|
ba6817fee5 | ||
|
|
73ee63bc1b | ||
|
|
8f0aec449a | ||
|
|
6d5fd64bb0 | ||
|
|
e5880c33f4 | ||
|
|
a853eb5a4d | ||
|
|
22d5c6585a | ||
|
|
b063fbd7f9 | ||
|
|
3f25e7ebca | ||
|
|
0af4c88d08 |
No files matched your search
@@ -26,6 +26,7 @@ next (a Masonry or iris transcript screen, most likely).
|
|||||||
| `api.rs` | `Api.kt` | Partial -- see below |
|
| `api.rs` | `Api.kt` | Partial -- see below |
|
||||||
| `event_stream.rs` | `EventStream.kt` | Done |
|
| `event_stream.rs` | `EventStream.kt` | Done |
|
||||||
| `transcript_fold.rs` | `TranscriptItems.kt`, `ToolRows.kt` | Partial -- see below |
|
| `transcript_fold.rs` | `TranscriptItems.kt`, `ToolRows.kt` | Partial -- see below |
|
||||||
|
| `config.rs` | `ServerConfig.kt`'s `handleEnrollment` | New, desktop-only so far -- see below |
|
||||||
| *(not started)* | `TranscriptSource.kt` | Not started |
|
| *(not started)* | `TranscriptSource.kt` | Not started |
|
||||||
| *(not ported, and may never be)* | `TranscriptUnits.kt` | Out of scope -- see below |
|
| *(not ported, and may never be)* | `TranscriptUnits.kt` | Out of scope -- see below |
|
||||||
|
|
||||||
@@ -103,6 +104,21 @@ deciding how `event_model` itself represents "a shape I don't recognise"
|
|||||||
-- a shared-model decision affecting `server/` too, not a `client-core`-only
|
-- a shared-model decision affecting `server/` too, not a `client-core`-only
|
||||||
fix, so it is recorded here rather than silently worked around.
|
fix, so it is recorded here rather than silently worked around.
|
||||||
|
|
||||||
|
## `config.rs`: `EnrolledServer`
|
||||||
|
|
||||||
|
`EnrolledServer` (host, port, bearer token) plus `parse_link`, which reads
|
||||||
|
the exact `aiapp://enroll?host=H&port=P&token=T` deep link
|
||||||
|
`wg-app-link`'s `enroll` mints and `ServerConfig.kt`'s `handleEnrollment`
|
||||||
|
parses on the phone -- so any Rust client enrols from the same text a
|
||||||
|
phone would scan as a QR, with no second format invented for it (RUST.md's
|
||||||
|
E4, DECISIONS.md 2026-09-05). Deliberately does not decide where it is
|
||||||
|
persisted or under what file permissions -- a phone seals its token in the
|
||||||
|
Android Keystore, `iris/desktop-app/src/config.rs` writes it to
|
||||||
|
`$XDG_CONFIG_HOME/ai-app-desktop/enrollment.json` at 0600 -- since that is
|
||||||
|
caller-specific (the code rules' "ask for the least you need"). Its only
|
||||||
|
caller today is `desktop-app`; a future Android build of this crate would
|
||||||
|
be a second one, not a reason to move the type.
|
||||||
|
|
||||||
## What is not started at all
|
## What is not started at all
|
||||||
|
|
||||||
- **`TranscriptSource.kt`** -- the layer that decides whether a page comes
|
- **`TranscriptSource.kt`** -- the layer that decides whether a page comes
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Decisions taken for Iris to review
|
||||||
|
|
||||||
|
Short list of design choices made by the design agent without asking, so
|
||||||
|
they can be judged and reversed later. Detail lives in RUST.md (and IRIS.md
|
||||||
|
for iris API changes); this file is only the summary. Newest first. Items
|
||||||
|
marked **DEFERRED** are ones the agent chose not to decide alone.
|
||||||
|
|
||||||
|
## 2026-09-05
|
||||||
|
|
||||||
|
- **Touch drag on a transcript row follows Android's own rule**: a vertical
|
||||||
|
drag pans the list immediately; a stationary press held 500 ms starts a
|
||||||
|
text selection which further dragging extends; a horizontal drag while
|
||||||
|
something is already selected extends that selection without the wait.
|
||||||
|
One `DragArbiter` per list decides it (`iris/src/sense.rs`). Chosen over a
|
||||||
|
"text layer always wins" or "list always wins" rule because either loses
|
||||||
|
one of the two gestures a reader expects.
|
||||||
|
- **E4's desktop shape is a new `iris/desktop-app` crate**: a winit window
|
||||||
|
holding `transcript-ui`'s screen beside a session list, talking to a real
|
||||||
|
`ai-server` through `client-core`. It enrols by pasting the same
|
||||||
|
`aiapp://enroll?…` link a phone scans (`client-core::config::EnrolledServer`)
|
||||||
|
and keeps it owner-only under `$XDG_CONFIG_HOME/ai-app-desktop/`. The
|
||||||
|
pinned CA is a path given on the command line, not baked in. Chosen so
|
||||||
|
the phone and desktop share one enrolment format and no second one is
|
||||||
|
invented.
|
||||||
|
- **Order of remaining work**: finish the two in-flight pieces above, then
|
||||||
|
the transcript screen's Android integration and the `transcript-bench.sh`
|
||||||
|
comparison against Compose — the numbers the recommendation still lacks.
|
||||||
|
- **DEFERRED — whether to commit to iris over Masonry for `ai-app`.** Waits
|
||||||
|
on the bench numbers above; RUST.md's recommendation says what the
|
||||||
|
measurements must show.
|
||||||
@@ -8,6 +8,112 @@ 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: `transcript_ui::build_tree` (RUST.md's E4)
|
||||||
|
|
||||||
|
`transcript_ui::build` claimed the whole window (`ui_state.set_root(tree)`)
|
||||||
|
as its last step, which is right for a window that *is* the transcript
|
||||||
|
screen (the winit example, an eventual Android cdylib) and wrong for the
|
||||||
|
desktop app, which puts a session list beside it. `build_tree` is `build`
|
||||||
|
minus that last step: it returns `(TranscriptScreen, StrongWidget)` instead
|
||||||
|
of just `TranscriptScreen`, and the caller decides where the tree goes —
|
||||||
|
into `ui_state.set_root`, or into a `WidgetPtr` alongside something else
|
||||||
|
(`iris/desktop-app`'s `rebuild_transcript`). `build` is now one line calling
|
||||||
|
`build_tree` and doing the `set_root` itself, so existing callers are
|
||||||
|
unaffected.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// before, and still available, for a caller that wants to *be* the window:
|
||||||
|
let screen = transcript_ui::build(rsc, &mut ui_state, rows);
|
||||||
|
|
||||||
|
// new, for a caller embedding the screen beside something else:
|
||||||
|
let (screen, tree) = transcript_ui::build_tree(rsc, rows);
|
||||||
|
some_widget_ptr(rsc).set(tree);
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-05: `DragArbiter`, pan-vs-select for one shared touch gesture (RUST.md's I5)
|
||||||
|
|
||||||
|
New public type, `iris::sense::DragArbiter`. Why: a widget author who
|
||||||
|
registers both a list-level pan and a row-level drag-to-select on the same
|
||||||
|
touch gesture has no way to arbitrate between them — `core/src/sense.rs`'s
|
||||||
|
`run_sensors` always gives the innermost layer first refusal, so the inner
|
||||||
|
one wins every frame it is pressed, not just the frame the press started
|
||||||
|
(this is exactly what left transcript-ui's touch-drag panning unreachable
|
||||||
|
until now). `DragArbiter` is one small state machine, one instance per
|
||||||
|
gesture surface (a whole list, not per row), that a caller drives with its
|
||||||
|
own `press_start`/`update`/`release` calls and a caller-supplied `Instant`
|
||||||
|
(so it is unit-testable without a real clock or a render harness). It
|
||||||
|
decides the way Android itself does: an ordinary vertical drag pans
|
||||||
|
immediately; a stationary press held `LONG_PRESS` (500ms) starts a
|
||||||
|
selection, which any further drag then extends; a horizontal drag while
|
||||||
|
something is already selected extends it immediately, skipping the wait.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// One per list, held alongside whatever state coordinates the rows:
|
||||||
|
let mut arbiter = DragArbiter::new();
|
||||||
|
|
||||||
|
// On press-down:
|
||||||
|
arbiter.press_start(pos, Instant::now(), already_selected);
|
||||||
|
// Every frame the button/finger stays down:
|
||||||
|
match arbiter.update(pos, Instant::now()) {
|
||||||
|
DragOutcome::Pan(dy) => list.scroll(-dy),
|
||||||
|
DragOutcome::SelectStart => selection.begin(...),
|
||||||
|
DragOutcome::SelectExtend => selection.extend(...),
|
||||||
|
DragOutcome::Undecided => {}
|
||||||
|
}
|
||||||
|
// On release:
|
||||||
|
arbiter.release();
|
||||||
|
```
|
||||||
|
|
||||||
|
`transcript-ui`'s `Selection::drag` (`transcript-ui/src/selection.rs`) is
|
||||||
|
the reference caller: every row's `CursorSense::click_or_drag() |
|
||||||
|
CursorSense::unclick()` handler routes through one `Selection`-owned
|
||||||
|
arbiter instead of calling `begin`/`extend` directly, so a drag that starts
|
||||||
|
on a row's own rendered text now pans the list correctly instead of
|
||||||
|
always starting a selection. 8 new unit tests in `iris/src/sense.rs`'s
|
||||||
|
`drag_arbiter_tests` module.
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|||||||
@@ -170,6 +170,62 @@ 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.
|
||||||
|
- [x] **Touch-drag panning over a row's own rendered text — done,
|
||||||
|
2026-09-05.** `row.rs` used to register `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`) meant that
|
||||||
|
registration won `core/src/sense.rs::run_sensors`'s per-layer
|
||||||
|
arbitration on every frame it was pressed, not just the frame the
|
||||||
|
press started, so a list pan gesture registered on `List` itself never
|
||||||
|
got a turn while a row was under the finger. Fixed with
|
||||||
|
`iris::sense::DragArbiter` (recorded in `IRIS.md`), one small state
|
||||||
|
machine per list deciding pan vs. select the way Android does (a
|
||||||
|
vertical drag pans immediately; a stationary press held `LONG_PRESS`
|
||||||
|
(500ms) starts a selection which further drag extends; a horizontal
|
||||||
|
drag while something is already selected extends immediately) —
|
||||||
|
`transcript-ui/src/selection.rs`'s `Selection::drag` is the one place
|
||||||
|
every row's drag now routes through. 8 new unit tests
|
||||||
|
(`iris/src/sense.rs`'s `drag_arbiter_tests`); `cargo fmt/clippy/test
|
||||||
|
--workspace` and `cargo ndk` (both `iris` and `transcript-ui`) all
|
||||||
|
clean; `run-headless.sh` screenshot byte-identical to before the
|
||||||
|
change (38578 bytes). See RUST.md's I5 box, "Gap closed, 2026-09-05".
|
||||||
|
- [ ] **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
|
||||||
|
|||||||
@@ -36,14 +36,29 @@ session spending an afternoon on them again.
|
|||||||
|
|
||||||
## Where things stand (2026-09-05)
|
## Where things stand (2026-09-05)
|
||||||
|
|
||||||
|
- **Both of the previous note's in-flight pieces are now done, 2026-09-05.**
|
||||||
|
Design choices for both are summarised in `DECISIONS.md` at the repo
|
||||||
|
root, which is the file Iris reads for choices made without her. Next:
|
||||||
|
I5's Android integration and the bench numbers.
|
||||||
|
- **E4 done, 2026-09-05.** `iris/desktop-app`: a winit window with a
|
||||||
|
session list beside `transcript-ui`'s screen (`build_tree`), against a
|
||||||
|
real `ai-server` through `client-core`, enrolled from the same
|
||||||
|
`aiapp://enroll?...` link a phone scans. Both pass conditions held on
|
||||||
|
`app/ui-sandbox.sh` -- see E4's own box for the commands, the
|
||||||
|
screenshot, and a real streaming-duplication bug the screenshot found
|
||||||
|
and a regression test now covers.
|
||||||
|
- **The I5 touch-drag pan-vs-select gap is closed, 2026-09-05**, as a
|
||||||
|
`DragArbiter` in `iris/src/sense.rs` wired into `transcript-ui`'s
|
||||||
|
selection -- see I5's own box below, "Gap closed, 2026-09-05".
|
||||||
- **Done**: E0 (toolchain), E1 (Masonry on android-view, which found the
|
- **Done**: E0 (toolchain), E1 (Masonry on android-view, which found the
|
||||||
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 +72,33 @@ 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. Full accounting, every citation, and the dated IRIS_TODO.md items
|
||||||
|
are in I5's own box below. **Update, 2026-09-05, same day**: touch-drag
|
||||||
|
panning over a row's own rendered text, which was not yet reachable for
|
||||||
|
a specific, diagnosed reason (it competed with this box's own
|
||||||
|
row-level drag-select for the same gesture, not an absent primitive),
|
||||||
|
is now closed — a `DragArbiter` in `iris/src/sense.rs`, wired into
|
||||||
|
`transcript-ui`'s selection — see the box's "Gap closed" note. Android
|
||||||
|
integration is the one item left before this box can tick `[x]`.
|
||||||
- **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 +647,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.
|
||||||
|
|
||||||
@@ -1247,8 +1308,93 @@ accepted.
|
|||||||
errors."}` followed by the echo driver's reply -- the share
|
errors."}` followed by the echo driver's reply -- the share
|
||||||
reached the most-recently-active session as a real message, not
|
reached the most-recently-active session as a real message, not
|
||||||
a mock.
|
a mock.
|
||||||
- [ ] **E4 — the same screen on the desktop** in a winit window, from the
|
- [x] **E4 — the same screen on the desktop (2026-09-05).** A new
|
||||||
same crate, with only the layout differing.
|
`iris/desktop-app` crate (added to the `iris` workspace's members, not
|
||||||
|
excluded the way `android-app` is -- nothing here needs the NDK):
|
||||||
|
a real winit window showing a session list (`iris::widget::Span`,
|
||||||
|
rebuilt on selection) beside `transcript-ui`'s screen
|
||||||
|
(`transcript_ui::build_tree`, new this box -- see IRIS.md's
|
||||||
|
2026-09-05 entry), talking to a real `ai-server` through
|
||||||
|
`client-core`'s `ApiClient`/`UreqTransport`/`follow_session_events`.
|
||||||
|
Enrolment is `client_core::config::EnrolledServer::parse_link`
|
||||||
|
against the same `aiapp://enroll?host=H&port=P&token=T` link a phone
|
||||||
|
scans, pasted via `--link` and persisted at
|
||||||
|
`$XDG_CONFIG_HOME/ai-app-desktop/enrollment.json` (0600 --
|
||||||
|
`iris/desktop-app/src/config.rs`); the pinned CA is a `--ca PATH`
|
||||||
|
argument, never baked in (DECISIONS.md, 2026-09-05).
|
||||||
|
|
||||||
|
*Both pass-condition proofs held, against `app/ui-sandbox.sh`'s real
|
||||||
|
server.* (1) The list showed the sandbox's spawned session
|
||||||
|
("Demo session", its live status); selecting it loaded the real
|
||||||
|
transcript and the composer's `Submit` posted a message whose reply
|
||||||
|
streamed in live over SSE, both proved by two `run-headless.sh`
|
||||||
|
screenshots taken seconds apart around a real `./ui-sandbox.sh send`
|
||||||
|
-- the second showed the new turn appended under the first with
|
||||||
|
nothing duplicated or lost. (2) Screenshotted headless:
|
||||||
|
`/tmp/iris_e4_desktop.png` (1920x1200, 15.9 KB, the real first-run
|
||||||
|
state -- list populated, "Select a session." on the right, nothing
|
||||||
|
selected yet). `run-headless.sh` gained a `--bin` flag for this
|
||||||
|
(`cargo build --bin NAME` + `target/debug/NAME` instead of the
|
||||||
|
`--example` path, since `desktop-app` is a real binary a person
|
||||||
|
runs, not a demo) and `$RUN_HEADLESS_ARGS`, word-split into the
|
||||||
|
launched binary's own argv (a real CLI's flags, which no example
|
||||||
|
needed a way to pass before). Exact commands, from `iris/`:
|
||||||
|
|
||||||
|
TOKEN=$(cat "${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/sandbox-token")
|
||||||
|
LINK="aiapp://enroll?host=127.0.0.1&port=<PORT>&token=$(python3 -c \
|
||||||
|
'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1],safe=""))' "$TOKEN")"
|
||||||
|
CA="${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs/ca.pem"
|
||||||
|
RUN_HEADLESS_ARGS="--ca $CA --link $LINK" \
|
||||||
|
./run-headless.sh desktop-app --bin --shot /tmp/iris_e4_desktop.png -- -p desktop-app
|
||||||
|
|
||||||
|
**A real bug this screenshot found, not a synthetic one**: the first
|
||||||
|
attempt resumed the live SSE stream from
|
||||||
|
`items.iter().map(TranscriptItem::seq).max()` -- the *folded* item's
|
||||||
|
seq, which for a still-open `AssistantMsg` is the seq of its
|
||||||
|
*first* delta by design (`fold_event`'s own doc comment: "a row
|
||||||
|
whose identity changed with every delta would be a new row every
|
||||||
|
frame"). Resuming from there re-delivered every delta already
|
||||||
|
folded into that message, and the screenshot showed the assistant's
|
||||||
|
reply with its own tail duplicated ("You said: ... testsaid: ...
|
||||||
|
test"). Fixed by computing the resume cursor from the raw wire
|
||||||
|
`seq` of the last fetched line (`app.rs`'s `raw_seq`) instead of
|
||||||
|
from any folded item -- regression test
|
||||||
|
`the_resume_cursor_is_the_last_wire_seq_not_the_last_items_seq` in
|
||||||
|
`iris/desktop-app/src/app.rs`. Exactly the class of bug CODE_RULES
|
||||||
|
warns about under "a fix tried only on what it was meant to fix":
|
||||||
|
the bare REST fetch (no live stream yet) looked perfect on its own,
|
||||||
|
and only *resuming* a stream after it exposed the seam.
|
||||||
|
|
||||||
|
**Deliberately left simple, and why** (`app.rs`'s module doc has the
|
||||||
|
full account): every incoming SSE event refolds the session's whole
|
||||||
|
item list and rebuilds the entire right-hand widget tree from
|
||||||
|
scratch, rather than reaching for `TranscriptScreen::push_row`'s
|
||||||
|
incremental append -- `push_row` can only add a new row, and a
|
||||||
|
streaming reply is exactly a row whose text keeps changing after it
|
||||||
|
first appears. Fine at the size a desktop session's conversation
|
||||||
|
is; wrong for a long, fast-streaming one, and the real fix needs
|
||||||
|
`transcript-ui` to expose updating a row already on screen, which it
|
||||||
|
does not yet. The composer's in-progress text is saved and restored
|
||||||
|
across a rebuild so a reply streaming in while the reader is typing
|
||||||
|
a followup doesn't erase it. No history paging (I3's job, reused
|
||||||
|
as-is if this becomes permanent) and no scroll-position preservation
|
||||||
|
across a rebuild -- both named rather than silently missing.
|
||||||
|
Background network I/O runs on plain `std::thread`s reporting back
|
||||||
|
through winit's `EventLoopProxy<AppEvent>` rather than iris's own
|
||||||
|
`Tasks`/`task_on`, because `Tasks` only requests a redraw once after
|
||||||
|
its whole async closure finishes, which fits "one request, one
|
||||||
|
update" and not a live stream that needs a redraw after *each*
|
||||||
|
event it relays.
|
||||||
|
|
||||||
|
Verification: `cargo fmt --all`, `cargo clippy --workspace
|
||||||
|
--all-targets` (zero warnings), `cargo test --workspace` from
|
||||||
|
`iris/` (7 new tests in `desktop-app` -- 4 for
|
||||||
|
`config.rs`'s save/load/permissions/corruption, 3 for `app.rs`'s
|
||||||
|
transcript folding and the resume-cursor regression above -- plus
|
||||||
|
the existing 37 unchanged), and `./run-tests.sh` at the repo root
|
||||||
|
(127 passing, `client-core` alone 93 -- the `EnrolledServer` parsing
|
||||||
|
tests already existed before this box). Android is untouched by
|
||||||
|
this step, as asked.
|
||||||
- [x] **E5 — the packaging xtask (2026-09-05).** Both pass-condition
|
- [x] **E5 — the packaging xtask (2026-09-05).** Both pass-condition
|
||||||
proofs held on this checkout's own emulator: `adb install -r` of the
|
proofs held on this checkout's own emulator: `adb install -r` of the
|
||||||
xtask-built APK over the Gradle-built one succeeded, and the
|
xtask-built APK over the Gradle-built one succeeded, and the
|
||||||
@@ -2104,9 +2250,261 @@ 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 -- the
|
||||||
|
gesture-conflict half is now fixed (2026-09-05); the emulator
|
||||||
|
half is still not attempted, and unlike E2 that's 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
|
||||||
|
was *not* reachable at first was a **touch-drag pan starting on a
|
||||||
|
row's own text**: `row.rs` registered `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) gave that row first
|
||||||
|
refusal on *every* frame it was pressed, not just the frame the
|
||||||
|
press started -- a row's drag-select won the same gesture a
|
||||||
|
list-level pan would want. 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.
|
||||||
|
|
||||||
|
**Gap closed, 2026-09-05, same day.** `iris::sense::DragArbiter`
|
||||||
|
(`iris/src/sense.rs`, new public type, recorded in `IRIS.md`) is
|
||||||
|
one small state machine, one instance per gesture surface (a
|
||||||
|
whole list, not per row), driven with a caller-supplied `Instant`
|
||||||
|
so it needs no render harness to test. It decides the way
|
||||||
|
Android itself does, recorded in `DECISIONS.md`: an ordinary
|
||||||
|
vertical drag pans immediately; a stationary press held
|
||||||
|
`LONG_PRESS` (500ms) starts a selection, which any further drag
|
||||||
|
then extends; a horizontal drag while something is already
|
||||||
|
selected extends it immediately, skipping the wait.
|
||||||
|
`transcript-ui/src/selection.rs`'s new `Selection::drag` is the
|
||||||
|
one place every row's `CursorSense::click_or_drag() |
|
||||||
|
CursorSense::unclick()` handler now goes through (`row.rs`,
|
||||||
|
`build_text_row`), replacing the direct `begin`/`extend` calls
|
||||||
|
each row used to make on its own -- one arbiter shared across
|
||||||
|
every row is what keeps the decision consistent as a drag
|
||||||
|
crosses row boundaries, per `DragArbiter`'s own doc. `Pan(dy)`
|
||||||
|
calls the list's own `List::scroll` (the same method I3's
|
||||||
|
mouse-wheel handler and its own benchmark already use), so this
|
||||||
|
is not a second scroll mechanism. 8 new unit tests in
|
||||||
|
`iris/src/sense.rs`'s `drag_arbiter_tests` (vertical drag pans
|
||||||
|
immediately and keeps panning by per-frame delta; small jitter
|
||||||
|
under `DRAG_SLOP` stays undecided; a held press starts a
|
||||||
|
selection after `LONG_PRESS` and further drag extends it, even
|
||||||
|
vertical drag, once selecting; a horizontal drag with nothing yet
|
||||||
|
selected stays undecided rather than guessing; a horizontal drag
|
||||||
|
with something already selected extends immediately; a vertical
|
||||||
|
drag still pans even with a prior selection; release resets to
|
||||||
|
idle). Verification: `cargo fmt --all -- --check`, `cargo clippy
|
||||||
|
--workspace --all-targets` (zero warnings), `cargo test
|
||||||
|
--workspace` (28 pre-existing + 9 `transcript-ui` + **8 new**
|
||||||
|
`drag_arbiter_tests`, all passing), `cargo ndk -t x86_64 -P 26
|
||||||
|
build/clippy` for both `-p iris` and `-p transcript-ui --lib`
|
||||||
|
(clean), and `run-headless.sh transcript --shot ... -- -p
|
||||||
|
transcript-ui` -- byte-identical to this box's original
|
||||||
|
screenshot (38578 bytes, `cmp` confirms identical), confirming no
|
||||||
|
visual regression from the rewiring. **What this did not
|
||||||
|
attempt**: the emulator-side confirmation (a real touch swipe
|
||||||
|
over a row's text panning on-device) -- that still needs I5's own
|
||||||
|
Android integration, the one item named just above and in "What
|
||||||
|
remains" below; this pass only had the winit/host-side gesture
|
||||||
|
path to drive, since no cdylib exists yet for this screen.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
- **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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
//! What a Rust client needs to reach one enrolled server: host, port and
|
||||||
|
//! bearer token. Mirrors the shape `ServerConfig.kt`/`Api.kt`'s
|
||||||
|
//! `handleEnrollment` parses out of an `aiapp://enroll?host=H&port=P&token=T`
|
||||||
|
//! deep link -- the exact link `wg-app-link`'s `enroll` module mints and
|
||||||
|
//! `app/ui-sandbox.sh`'s banner prints, so any Rust client can enrol from
|
||||||
|
//! the same text a phone would scan as a QR, with no second format
|
||||||
|
//! invented for it (RUST.md's E4).
|
||||||
|
//!
|
||||||
|
//! What this type deliberately does not decide: where it is persisted, and
|
||||||
|
//! under what file permissions. A phone seals its token in the Android
|
||||||
|
//! Keystore; a desktop client has its own `$XDG_CONFIG_HOME/<app>/`
|
||||||
|
//! directory and its own file-mode conventions (MACHINE.md: owner-only,
|
||||||
|
//! never in the repo). Both are caller-specific, so they stay out of this
|
||||||
|
//! crate per the code rules' "ask for the least you need" -- see
|
||||||
|
//! `iris/desktop-app/src/config.rs` for the desktop instance.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// One enrolled server: reachable at `https://{host}:{port}`, authenticated
|
||||||
|
/// with `token` as a bearer header. Does not carry the pinned CA -- that is
|
||||||
|
/// a public certificate rather than a secret, and where to find it differs
|
||||||
|
/// by caller (a phone pins the one its APK was built against; a desktop
|
||||||
|
/// client is told a path).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct EnrolledServer {
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EnrolledServer {
|
||||||
|
/// Parses `aiapp://enroll?host=H&port=P&token=T` (query order does not
|
||||||
|
/// matter; unrecognised keys are ignored). `token` is percent-decoded,
|
||||||
|
/// since `ui-sandbox.sh` encodes it precisely because a raw token can
|
||||||
|
/// contain `+`, which turns into a space if left to a naive splitter.
|
||||||
|
pub fn parse_link(link: &str) -> Result<Self, String> {
|
||||||
|
let query = link.split_once('?').map(|(_, q)| q).ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"'{link}' has no query string (expected \
|
||||||
|
aiapp://enroll?host=...&port=...&token=...)"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut host = None;
|
||||||
|
let mut port = None;
|
||||||
|
let mut token = None;
|
||||||
|
for pair in query.split('&') {
|
||||||
|
let Some((key, value)) = pair.split_once('=') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let value = percent_decode(value);
|
||||||
|
match key {
|
||||||
|
"host" => host = Some(value),
|
||||||
|
"port" => port = Some(value),
|
||||||
|
"token" => token = Some(value),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let host = host.ok_or_else(|| format!("'{link}' is missing 'host'"))?;
|
||||||
|
let port_str = port.ok_or_else(|| format!("'{link}' is missing 'port'"))?;
|
||||||
|
let port: u16 = port_str
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| format!("'{link}''s port ('{port_str}') is not a number: {e}"))?;
|
||||||
|
let token = token.ok_or_else(|| format!("'{link}' is missing 'token'"))?;
|
||||||
|
|
||||||
|
Ok(Self { host, port, token })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a `client_core::api::UreqTransport` reaches this server.
|
||||||
|
pub fn base_url(&self) -> String {
|
||||||
|
format!("https://{}:{}", self.host, self.port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn percent_decode(s: &str) -> String {
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
let mut out = Vec::with_capacity(bytes.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||||
|
if let Ok(byte) =
|
||||||
|
u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16)
|
||||||
|
{
|
||||||
|
out.push(byte);
|
||||||
|
i += 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(bytes[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&out).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_host_port_and_token() {
|
||||||
|
let server =
|
||||||
|
EnrolledServer::parse_link("aiapp://enroll?host=127.0.0.1&port=8547&token=abcDEF123")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
server,
|
||||||
|
EnrolledServer {
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
port: 8547,
|
||||||
|
token: "abcDEF123".to_string(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(server.base_url(), "https://127.0.0.1:8547");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn field_order_does_not_matter() {
|
||||||
|
let server =
|
||||||
|
EnrolledServer::parse_link("aiapp://enroll?token=tok&port=443&host=example.com")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(server.host, "example.com");
|
||||||
|
assert_eq!(server.port, 443);
|
||||||
|
assert_eq!(server.token, "tok");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_percent_encoded_token_is_decoded() {
|
||||||
|
// ui-sandbox.sh's own reason for encoding: a raw '+' would
|
||||||
|
// otherwise arrive as a space.
|
||||||
|
let server =
|
||||||
|
EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=a%2Bb%2Fc").unwrap();
|
||||||
|
assert_eq!(server.token, "a+b/c");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_field_is_named_in_the_error() {
|
||||||
|
let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=1").unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.contains("token"),
|
||||||
|
"error should name the missing field: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_non_numeric_port_is_named_in_the_error() {
|
||||||
|
let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=x&token=t").unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.contains("port"),
|
||||||
|
"error should name the offending field: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
pub mod ansi;
|
pub mod ansi;
|
||||||
pub mod api;
|
pub mod api;
|
||||||
|
pub mod config;
|
||||||
pub mod event_stream;
|
pub mod event_stream;
|
||||||
pub mod highlight;
|
pub mod highlight;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
|
|||||||
Generated
+376
-4
@@ -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,25 @@ 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]]
|
||||||
|
name = "desktop-app"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"client-core",
|
||||||
|
"event-model",
|
||||||
|
"iris",
|
||||||
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
|
"transcript-ui",
|
||||||
|
"winit",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dispatch"
|
name = "dispatch"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -1023,6 +1087,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 +1237,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 +1320,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 +1499,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 +1643,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 +1779,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 +1813,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 +2122,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 +2770,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 +2828,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 +2921,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 +3056,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 +3111,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 +3222,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 +3375,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 +3439,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 +3508,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 +3644,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 +3692,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 +3716,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 +3816,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 +4018,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 +4895,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 +4936,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
@@ -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", "desktop-app"]
|
||||||
# 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"
|
||||||
@@ -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>>,
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
[package]
|
||||||
|
name = "desktop-app"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
# RUST.md's E4: the same transcript-ui screen (I5) in a winit window on the
|
||||||
|
# desktop, beside a session list, talking to a real `ai-server` through
|
||||||
|
# `client-core`'s REST + SSE clients. Enrolment reuses the phone's own
|
||||||
|
# `aiapp://enroll?...` link (`client-core::config`) rather than inventing a
|
||||||
|
# second format -- see DECISIONS.md's 2026-09-05 entry. An ordinary
|
||||||
|
# workspace member (unlike `android-app`): nothing here needs the NDK, so
|
||||||
|
# `cargo build --workspace --all-targets` at the host stays clean with it
|
||||||
|
# included.
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
iris = { path = ".." }
|
||||||
|
transcript-ui = { path = "../transcript-ui" }
|
||||||
|
client-core = { path = "../../client-core" }
|
||||||
|
event-model = { path = "../../event-model" }
|
||||||
|
# Already pulled in transitively through client-core; used directly here
|
||||||
|
# only to persist `EnrolledServer` as the app's own tiny config file (see
|
||||||
|
# `config.rs`) -- no new dependency.
|
||||||
|
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||||
|
winit = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
@@ -0,0 +1,538 @@
|
|||||||
|
//! RUST.md's E4: a session list on the left, `transcript-ui`'s screen (I5)
|
||||||
|
//! filling the rest, both against a real `ai-server` reached through
|
||||||
|
//! `client-core`. The layout is the simplest thing that shows both at
|
||||||
|
//! once -- a fixed-width column and `rest(1)` for everything else, using
|
||||||
|
//! `iris::widget::{Span, WidgetPtr}` the way `tabs-ui` already switches
|
||||||
|
//! panes, rather than anything desktop-specific:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! +-----------+--------------------------------------+
|
||||||
|
//! | session | transcript_ui::TranscriptScreen |
|
||||||
|
//! | list | (List of folded rows + composer) |
|
||||||
|
//! | (WidgetPtr| |
|
||||||
|
//! | swapped | (WidgetPtr swapped whole on session |
|
||||||
|
//! | on data) | switch or a new transcript event) |
|
||||||
|
//! +-----------+--------------------------------------+
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! **Deliberately left simple, and why**: every incoming SSE event refolds
|
||||||
|
//! the *entire* transcript (`client_core::transcript_fold::fold_event` is
|
||||||
|
//! already `O(items)` and a desktop session's conversation is small) and
|
||||||
|
//! rebuilds the whole right-hand widget tree from scratch, rather than
|
||||||
|
//! reaching for `TranscriptScreen::push_row`'s incremental append.
|
||||||
|
//! `push_row` cannot update a row already on screen -- only append a new
|
||||||
|
//! one -- and a streaming assistant reply is exactly a row whose *text*
|
||||||
|
//! keeps changing after it first appears (see `transcript-ui`'s own doc on
|
||||||
|
//! `fold_event` folding deltas into one growing item). A full rebuild
|
||||||
|
//! shows that growth correctly at the cost of redrawing everything each
|
||||||
|
//! time; fine for this proof, wrong for a long, fast-streaming transcript
|
||||||
|
//! -- the incremental path that fixes it needs `transcript-ui` to expose
|
||||||
|
//! updating a row in place, which it does not yet. The composer's
|
||||||
|
//! in-progress text survives a rebuild (`rebuild_transcript`'s
|
||||||
|
//! `in_progress` local) since the user typing a followup while a reply
|
||||||
|
//! streams in is the one case a naive rebuild would otherwise lose data
|
||||||
|
//! on.
|
||||||
|
//!
|
||||||
|
//! Background network I/O (`client_core::api`/`event_stream`, both
|
||||||
|
//! blocking by design -- see `client-core`'s `Cargo.toml`) runs on plain
|
||||||
|
//! `std::thread`s that report back through `winit`'s `EventLoopProxy`
|
||||||
|
//! (`Proxy<AppEvent>`), rather than through iris's own `Tasks`/`task_on`:
|
||||||
|
//! `Tasks` only requests a redraw once, after its whole async closure
|
||||||
|
//! finishes, which fits a single request-then-update but not a live SSE
|
||||||
|
//! loop that needs to be seen redrawing after *each* event it relays.
|
||||||
|
//! `Proxy::send_event` wakes the window's event loop immediately, once per
|
||||||
|
//! event, which is what a stream wants.
|
||||||
|
|
||||||
|
use client_core::api::{ApiClient, SessionSummary, UreqTransport};
|
||||||
|
use client_core::event_stream::{StreamItem, follow_session_events};
|
||||||
|
use client_core::transcript_fold::{TranscriptItem, fold_event, group_tool_runs};
|
||||||
|
use event_model::SeqEvent;
|
||||||
|
use iris::prelude::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
/// The session list column's width -- a fixed size for the simplest
|
||||||
|
/// layout that shows both panels at once (UI_RULES's text-truncation and
|
||||||
|
/// no-shrink rules apply to what's drawn inside it, not to this choice of
|
||||||
|
/// column width itself).
|
||||||
|
const LIST_WIDTH: f32 = 260.0;
|
||||||
|
|
||||||
|
/// Everything a background thread hands back to the window's event loop.
|
||||||
|
/// `generation` on the session-scoped variants is the generation
|
||||||
|
/// `select_session` was on when the thread started (`Client::generation`)
|
||||||
|
/// -- compared back against the current one before being applied, so a
|
||||||
|
/// slow response from a session the reader has since clicked away from
|
||||||
|
/// can't overwrite what replaced it.
|
||||||
|
enum AppEvent {
|
||||||
|
Sessions(Result<Vec<SessionSummary>, String>),
|
||||||
|
TranscriptLoaded {
|
||||||
|
session_id: String,
|
||||||
|
generation: u64,
|
||||||
|
result: Result<Vec<TranscriptItem>, String>,
|
||||||
|
},
|
||||||
|
StreamEvent {
|
||||||
|
session_id: String,
|
||||||
|
generation: u64,
|
||||||
|
event: SeqEvent,
|
||||||
|
},
|
||||||
|
StreamEnded {
|
||||||
|
session_id: String,
|
||||||
|
generation: u64,
|
||||||
|
message: Option<String>,
|
||||||
|
},
|
||||||
|
SendFailed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run() {
|
||||||
|
DefaultApp::<Client>::run();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DefaultUiState)]
|
||||||
|
struct Client {
|
||||||
|
ui_state: DefaultUiState,
|
||||||
|
api: Arc<ApiClient<UreqTransport>>,
|
||||||
|
/// A second, independent `UreqTransport` to the same server, used only
|
||||||
|
/// by `select_session`'s live-follow loop. `ApiClient` keeps its
|
||||||
|
/// transport private (rightly -- nothing outside it should reach past
|
||||||
|
/// the typed calls), so a caller that also needs the raw
|
||||||
|
/// `Transport::stream` for SSE, as this one does, builds its own
|
||||||
|
/// rather than the crate growing a getter whose only purpose would be
|
||||||
|
/// letting one caller reach around its own abstraction.
|
||||||
|
stream_transport: Arc<UreqTransport>,
|
||||||
|
proxy: Proxy<AppEvent>,
|
||||||
|
sessions: Vec<SessionSummary>,
|
||||||
|
selected: Option<String>,
|
||||||
|
items: Vec<TranscriptItem>,
|
||||||
|
list_ptr: WeakWidget<WidgetPtr>,
|
||||||
|
transcript_ptr: WeakWidget<WidgetPtr>,
|
||||||
|
screen: Option<transcript_ui::TranscriptScreen>,
|
||||||
|
/// Bumped every time the selected session changes; see `AppEvent`'s
|
||||||
|
/// doc for what it guards against.
|
||||||
|
generation: Arc<AtomicU64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DefaultAppState for Client {
|
||||||
|
type Event = AppEvent;
|
||||||
|
|
||||||
|
fn new(
|
||||||
|
mut ui_state: DefaultUiState,
|
||||||
|
rsc: &mut DefaultRsc<Self>,
|
||||||
|
proxy: Proxy<AppEvent>,
|
||||||
|
) -> Self {
|
||||||
|
// Re-validated here rather than threaded through from `main` --
|
||||||
|
// `DefaultApp::run()` takes no payload, so there is no other way
|
||||||
|
// to get `main`'s parsed CLI/config into this constructor. `main`
|
||||||
|
// already called this once to fail fast before a window opens;
|
||||||
|
// this call only fails if the filesystem changed underneath the
|
||||||
|
// process in between, which is not a case worth a nicer message.
|
||||||
|
let (server, ca_pem) = crate::load_startup_config().unwrap_or_else(|e| {
|
||||||
|
eprintln!("desktop-app: {e}");
|
||||||
|
std::process::exit(2);
|
||||||
|
});
|
||||||
|
let build_transport =
|
||||||
|
|| UreqTransport::new(server.base_url(), server.token.clone(), &ca_pem);
|
||||||
|
let (rest_transport, stream_transport) = build_transport()
|
||||||
|
.and_then(|rest| build_transport().map(|stream| (rest, stream)))
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
eprintln!(
|
||||||
|
"desktop-app: couldn't set up TLS to {}: {e}",
|
||||||
|
server.base_url()
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
let api = Arc::new(ApiClient::new(rest_transport));
|
||||||
|
let stream_transport = Arc::new(stream_transport);
|
||||||
|
|
||||||
|
let list_ptr = WidgetPtr::new().add(rsc);
|
||||||
|
let transcript_ptr = WidgetPtr::new().add(rsc);
|
||||||
|
let loading = placeholder(rsc, "Loading sessions...");
|
||||||
|
transcript_ptr(rsc).set(loading);
|
||||||
|
|
||||||
|
(list_ptr.width(LIST_WIDTH), transcript_ptr.width(rest(1)))
|
||||||
|
.span(Dir::RIGHT)
|
||||||
|
.set_root(rsc, &mut ui_state);
|
||||||
|
|
||||||
|
let client = Self {
|
||||||
|
ui_state,
|
||||||
|
api,
|
||||||
|
stream_transport,
|
||||||
|
proxy,
|
||||||
|
sessions: Vec::new(),
|
||||||
|
selected: None,
|
||||||
|
items: Vec::new(),
|
||||||
|
list_ptr,
|
||||||
|
transcript_ptr,
|
||||||
|
screen: None,
|
||||||
|
generation: Arc::new(AtomicU64::new(0)),
|
||||||
|
};
|
||||||
|
client.spawn_fetch_sessions();
|
||||||
|
client
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event(&mut self, event: AppEvent, rsc: &mut DefaultRsc<Self>, _render: &mut UiRenderState) {
|
||||||
|
match event {
|
||||||
|
AppEvent::Sessions(Ok(sessions)) => {
|
||||||
|
self.sessions = sessions;
|
||||||
|
self.rebuild_list(rsc);
|
||||||
|
if self.selected.is_none() {
|
||||||
|
self.show_message(rsc, "Select a session.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppEvent::Sessions(Err(message)) => {
|
||||||
|
self.show_message(rsc, &format!("Couldn't list sessions: {message}"));
|
||||||
|
}
|
||||||
|
AppEvent::TranscriptLoaded {
|
||||||
|
session_id,
|
||||||
|
generation,
|
||||||
|
result,
|
||||||
|
} => {
|
||||||
|
if self.current(&session_id, generation) {
|
||||||
|
match result {
|
||||||
|
Ok(items) => {
|
||||||
|
self.items = items;
|
||||||
|
self.rebuild_transcript(rsc);
|
||||||
|
}
|
||||||
|
Err(message) => {
|
||||||
|
self.show_message(
|
||||||
|
rsc,
|
||||||
|
&format!("Couldn't load {session_id}: {message}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppEvent::StreamEvent {
|
||||||
|
session_id,
|
||||||
|
generation,
|
||||||
|
event,
|
||||||
|
} => {
|
||||||
|
if self.current(&session_id, generation) {
|
||||||
|
self.items = fold_event(&self.items, &event);
|
||||||
|
self.rebuild_transcript(rsc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppEvent::StreamEnded {
|
||||||
|
session_id,
|
||||||
|
generation,
|
||||||
|
message: Some(message),
|
||||||
|
} => {
|
||||||
|
if self.current(&session_id, generation) {
|
||||||
|
eprintln!("desktop-app: {session_id}'s live connection ended: {message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppEvent::StreamEnded { .. } => {}
|
||||||
|
AppEvent::SendFailed(message) => {
|
||||||
|
eprintln!("desktop-app: couldn't send: {message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.ui_state.window.request_redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
fn current(&self, session_id: &str, generation: u64) -> bool {
|
||||||
|
self.selected.as_deref() == Some(session_id)
|
||||||
|
&& self.generation.load(Ordering::SeqCst) == generation
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the right-hand panel with a line of text -- built before
|
||||||
|
/// `transcript_ptr` is reached for, since building the message and
|
||||||
|
/// swapping it in both need `rsc` and can't overlap as one borrow.
|
||||||
|
fn show_message(&mut self, rsc: &mut DefaultRsc<Self>, message: &str) {
|
||||||
|
let widget = placeholder(rsc, message);
|
||||||
|
(self.transcript_ptr)(rsc).set(widget);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_fetch_sessions(&self) {
|
||||||
|
let api = self.api.clone();
|
||||||
|
let proxy = self.proxy.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let result = api.fetch_sessions().map_err(|e| e.to_string());
|
||||||
|
let _ = proxy.send_event(AppEvent::Sessions(result));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rebuild_list(&mut self, rsc: &mut DefaultRsc<Self>) {
|
||||||
|
let list = Span::empty(Dir::DOWN).gap(2).add(rsc);
|
||||||
|
for session in &self.sessions {
|
||||||
|
let selected = self.selected.as_deref() == Some(session.id.as_str());
|
||||||
|
let row = session_row(rsc, session, selected);
|
||||||
|
list(rsc).push(row);
|
||||||
|
}
|
||||||
|
let tree = list
|
||||||
|
.background(rect(Color::rgb(24, 24, 28)))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any();
|
||||||
|
(self.list_ptr)(rsc).set(tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selecting a session starts a fresh generation: any thread still
|
||||||
|
/// working for the previous one checks `Client::current` before
|
||||||
|
/// touching state, so a slow response for a session the reader has
|
||||||
|
/// clicked away from is silently dropped rather than overwriting what
|
||||||
|
/// replaced it.
|
||||||
|
fn select_session(&mut self, rsc: &mut DefaultRsc<Self>, session_id: String) {
|
||||||
|
let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
|
self.selected = Some(session_id.clone());
|
||||||
|
self.items.clear();
|
||||||
|
self.screen = None;
|
||||||
|
self.rebuild_list(rsc);
|
||||||
|
self.show_message(rsc, "Loading transcript...");
|
||||||
|
|
||||||
|
let api = self.api.clone();
|
||||||
|
let stream_transport = self.stream_transport.clone();
|
||||||
|
let proxy = self.proxy.clone();
|
||||||
|
let live_generation = self.generation.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
// The most recent 200 events, coalesced -- plenty for a
|
||||||
|
// desktop proof; RUST.md's I3/history-paging work is what a
|
||||||
|
// real scrollback would reuse, out of scope here (E4 is only
|
||||||
|
// "the same screen runs in a window").
|
||||||
|
let page: Result<Vec<serde_json::Value>, String> = api
|
||||||
|
.fetch_transcript_page(&session_id, None, 200, true)
|
||||||
|
.map_err(|e| e.to_string());
|
||||||
|
// The raw wire `seq` of the last line fetched -- not the seq of
|
||||||
|
// the last *folded item*. A `TranscriptItem::AssistantMsg` keeps
|
||||||
|
// the seq of the first delta it accumulated (`fold_event`'s own
|
||||||
|
// doc: "a row whose identity changed with every delta would be
|
||||||
|
// a new row every frame"), so resuming the live stream from
|
||||||
|
// that seq re-delivers every delta already folded into it,
|
||||||
|
// duplicating the tail of whatever reply was mid-stream when
|
||||||
|
// the page was fetched. Found by screenshotting a real reply
|
||||||
|
// through `run-headless.sh`: the assistant's line read "You
|
||||||
|
// said: ... testsaid: ... test", the back half being deltas 2
|
||||||
|
// through N replayed onto an already-complete message.
|
||||||
|
let after = page
|
||||||
|
.as_ref()
|
||||||
|
.ok()
|
||||||
|
.and_then(|values| raw_seq(values.last()?))
|
||||||
|
.unwrap_or(0);
|
||||||
|
let result = page.and_then(|values| fold_page(&values));
|
||||||
|
let _ = proxy.send_event(AppEvent::TranscriptLoaded {
|
||||||
|
session_id: session_id.clone(),
|
||||||
|
generation,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Follows live from here in the same thread -- sequential
|
||||||
|
// rather than a second thread, since there is nothing to do
|
||||||
|
// with the stream until the page above has been sent anyway.
|
||||||
|
let stop = || live_generation.load(Ordering::SeqCst) != generation;
|
||||||
|
if stop() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let outcome =
|
||||||
|
follow_session_events(&*stream_transport, &session_id, after, |item| match item {
|
||||||
|
StreamItem::Open | StreamItem::Reset => !stop(),
|
||||||
|
StreamItem::Event { event, .. } => {
|
||||||
|
if stop() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let _ = proxy.send_event(AppEvent::StreamEvent {
|
||||||
|
session_id: session_id.clone(),
|
||||||
|
generation,
|
||||||
|
event,
|
||||||
|
});
|
||||||
|
true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let _ = proxy.send_event(AppEvent::StreamEnded {
|
||||||
|
session_id,
|
||||||
|
generation,
|
||||||
|
message: outcome.err().map(|e| e.to_string()),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_message(&mut self, session_id: String, text: String) {
|
||||||
|
let api = self.api.clone();
|
||||||
|
let proxy = self.proxy.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
if let Err(e) = api.send_message(&session_id, &text, &[]) {
|
||||||
|
let _ = proxy.send_event(AppEvent::SendFailed(e.to_string()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rebuild_transcript(&mut self, rsc: &mut DefaultRsc<Self>) {
|
||||||
|
let in_progress = self
|
||||||
|
.screen
|
||||||
|
.as_ref()
|
||||||
|
.map(|screen| screen.composer.field.edit(rsc).text.text().to_string())
|
||||||
|
.filter(|t| !t.is_empty());
|
||||||
|
|
||||||
|
let rows = group_tool_runs(&self.items);
|
||||||
|
let (screen, tree) = transcript_ui::build_tree(rsc, rows);
|
||||||
|
|
||||||
|
if let Some(text) = in_progress {
|
||||||
|
screen.composer.field.edit(rsc).set(&text);
|
||||||
|
}
|
||||||
|
if let Some(session_id) = self.selected.clone() {
|
||||||
|
let field = screen.composer.field;
|
||||||
|
rsc.register_event(field, Submit, move |ctx, rsc| {
|
||||||
|
let text = field.edit(rsc).take();
|
||||||
|
let text = text.trim().to_string();
|
||||||
|
if !text.is_empty() {
|
||||||
|
ctx.state.send_message(session_id.clone(), text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(self.transcript_ptr)(rsc).set(tree);
|
||||||
|
self.screen = Some(screen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row in the session list: title on top, status below, highlighted
|
||||||
|
/// when it's the one currently shown.
|
||||||
|
fn session_row(
|
||||||
|
rsc: &mut DefaultRsc<Client>,
|
||||||
|
session: &SessionSummary,
|
||||||
|
selected: bool,
|
||||||
|
) -> StrongWidget {
|
||||||
|
let bg = if selected {
|
||||||
|
Color::rgb(58, 90, 138)
|
||||||
|
} else {
|
||||||
|
Color::rgb(38, 38, 44)
|
||||||
|
};
|
||||||
|
let id = session.id.clone();
|
||||||
|
let label = format!("{}\n{}", session.title, session.status);
|
||||||
|
wtext(label)
|
||||||
|
.color(Color::WHITE)
|
||||||
|
.wrap(true)
|
||||||
|
.pad(10)
|
||||||
|
.width(rest(1))
|
||||||
|
.background(rect(bg))
|
||||||
|
.on(
|
||||||
|
CursorSense::click(),
|
||||||
|
move |ctx, rsc: &mut DefaultRsc<Client>| {
|
||||||
|
ctx.state.select_session(rsc, id.clone());
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn placeholder(rsc: &mut DefaultRsc<Client>, message: &str) -> StrongWidget {
|
||||||
|
wtext(message.to_string())
|
||||||
|
.color(Color::WHITE)
|
||||||
|
.wrap(true)
|
||||||
|
.pad(16)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Folds a page of raw transcript lines (`ApiClient::fetch_transcript_page`'s
|
||||||
|
/// `Vec<Value>`) into the flat item list `client_core::transcript_fold`
|
||||||
|
/// works over. A line this build can't parse fails the whole page rather
|
||||||
|
/// than being skipped -- CODE_RULES's "an enumeration must be able to say
|
||||||
|
/// 'it broke'" -- since silently dropping one event could hide, say, the
|
||||||
|
/// user message the composer is about to look like it never sent.
|
||||||
|
fn fold_page(values: &[serde_json::Value]) -> Result<Vec<TranscriptItem>, String> {
|
||||||
|
let mut items = Vec::new();
|
||||||
|
for value in values {
|
||||||
|
let event: SeqEvent = serde_json::from_value(value.clone()).map_err(|e| {
|
||||||
|
format!("the server sent a transcript line this build couldn't parse: {e}")
|
||||||
|
})?;
|
||||||
|
items = fold_event(&items, &event);
|
||||||
|
}
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The wire `seq` a raw transcript line carries -- see `select_session`'s
|
||||||
|
/// comment on why the live-stream cursor has to be this, not a folded
|
||||||
|
/// item's `seq()`.
|
||||||
|
fn raw_seq(value: &serde_json::Value) -> Option<u64> {
|
||||||
|
value.get("seq")?.as_u64()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn line(seq: u64, json: serde_json::Value) -> serde_json::Value {
|
||||||
|
let mut obj = json;
|
||||||
|
obj["seq"] = serde_json::json!(seq);
|
||||||
|
obj["ts"] = serde_json::json!(1.0);
|
||||||
|
obj
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The regression for the bug a real `run-headless.sh` screenshot
|
||||||
|
/// found (see `select_session`'s comment): resuming the live stream
|
||||||
|
/// from the last *item's* seq re-delivers the deltas already folded
|
||||||
|
/// into a still-open assistant message, doubling its tail. `raw_seq`
|
||||||
|
/// of the last wire line must be the true high-water mark instead,
|
||||||
|
/// which for a run of deltas is higher than every item's own `seq()`.
|
||||||
|
#[test]
|
||||||
|
fn the_resume_cursor_is_the_last_wire_seq_not_the_last_items_seq() {
|
||||||
|
let values = vec![
|
||||||
|
line(1, serde_json::json!({"type": "userMessage", "text": "hi"})),
|
||||||
|
line(
|
||||||
|
2,
|
||||||
|
serde_json::json!({"type": "assistantText", "delta": "a"}),
|
||||||
|
),
|
||||||
|
line(
|
||||||
|
3,
|
||||||
|
serde_json::json!({"type": "assistantText", "delta": "b"}),
|
||||||
|
),
|
||||||
|
line(
|
||||||
|
4,
|
||||||
|
serde_json::json!({"type": "assistantText", "delta": "c"}),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let after = raw_seq(values.last().unwrap()).unwrap();
|
||||||
|
assert_eq!(after, 4);
|
||||||
|
|
||||||
|
let items = fold_page(&values).unwrap();
|
||||||
|
// The folded item keeps the *first* delta's seq (2), which is
|
||||||
|
// exactly the value that must not be used as the resume cursor.
|
||||||
|
let assistant_seq = items
|
||||||
|
.iter()
|
||||||
|
.find(|i| matches!(i, TranscriptItem::AssistantMsg { .. }))
|
||||||
|
.unwrap()
|
||||||
|
.seq();
|
||||||
|
assert_eq!(assistant_seq, 2);
|
||||||
|
assert_ne!(
|
||||||
|
after, assistant_seq,
|
||||||
|
"the fixed bug: these must differ here"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_page_folds_into_one_settled_assistant_message() {
|
||||||
|
let values = vec![
|
||||||
|
line(1, serde_json::json!({"type": "userMessage", "text": "hi"})),
|
||||||
|
line(
|
||||||
|
2,
|
||||||
|
serde_json::json!({"type": "assistantText", "delta": "hel"}),
|
||||||
|
),
|
||||||
|
line(
|
||||||
|
3,
|
||||||
|
serde_json::json!({"type": "assistantText", "delta": "lo"}),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let items = fold_page(&values).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
items,
|
||||||
|
vec![
|
||||||
|
TranscriptItem::UserMsg {
|
||||||
|
seq: 1,
|
||||||
|
text: "hi".to_string(),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
},
|
||||||
|
TranscriptItem::AssistantMsg {
|
||||||
|
seq: 2,
|
||||||
|
text: "hello".to_string(),
|
||||||
|
settled: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unparseable_line_fails_the_whole_page() {
|
||||||
|
let values = vec![serde_json::json!({"seq": 1, "ts": 1.0, "type": "not-a-real-type"})];
|
||||||
|
let err = fold_page(&values).unwrap_err();
|
||||||
|
assert!(err.contains("couldn't parse"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
//! Where the desktop app keeps the enrollment it should not have to be
|
||||||
|
//! told about a second time: `client_core::config::EnrolledServer`,
|
||||||
|
//! persisted at `$XDG_CONFIG_HOME/ai-app-desktop/enrollment.json`,
|
||||||
|
//! owner-only (0600) -- MACHINE.md's rule for anything holding a bearer
|
||||||
|
//! token, and the reason `client_core::config`'s own doc comment leaves
|
||||||
|
//! persistence and file mode to the caller.
|
||||||
|
//!
|
||||||
|
//! JSON rather than the project's usual RON: `wg-app-link`'s RON house
|
||||||
|
//! rules (`format`) are for configs a person hand-edits, and this file
|
||||||
|
//! never is one -- only this program ever writes or reads it, and
|
||||||
|
//! `serde_json` is already in the dependency graph through `client-core`,
|
||||||
|
//! so nothing new is added to reach for it.
|
||||||
|
|
||||||
|
use client_core::config::EnrolledServer;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// `$XDG_CONFIG_HOME/ai-app-desktop`, falling back to `~/.config` the way
|
||||||
|
/// the XDG basedir spec says to when the variable is unset -- the same
|
||||||
|
/// fallback `wg_app_link::xdg::config_home` uses, reimplemented here
|
||||||
|
/// rather than depended on: that helper lives in the `wg-app-link`
|
||||||
|
/// submodule, which `server/` needs but this desktop-only crate does not,
|
||||||
|
/// and pulling in a git submodule for one path join would cost more than
|
||||||
|
/// it saves.
|
||||||
|
pub fn config_dir() -> PathBuf {
|
||||||
|
let base = std::env::var_os("XDG_CONFIG_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
let home = std::env::var_os("HOME").expect("HOME must be set");
|
||||||
|
PathBuf::from(home).join(".config")
|
||||||
|
});
|
||||||
|
base.join("ai-app-desktop")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enrollment_file(dir: &Path) -> PathBuf {
|
||||||
|
dir.join("enrollment.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists `server` under `dir` (`config_dir()` for real use; a tempdir in
|
||||||
|
/// the tests below), creating it if needed, and sets the file owner-only --
|
||||||
|
/// it carries a bearer token, the same reason `server/`'s own token store
|
||||||
|
/// is 0600.
|
||||||
|
pub fn save_enrollment_in(dir: &Path, server: &EnrolledServer) -> io::Result<()> {
|
||||||
|
std::fs::create_dir_all(dir)?;
|
||||||
|
let path = enrollment_file(dir);
|
||||||
|
let json = serde_json::to_vec_pretty(server)
|
||||||
|
.expect("EnrolledServer holds nothing that fails to serialise");
|
||||||
|
std::fs::write(&path, json)?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Ok(None)` when nothing has been enrolled yet, rather than an error --
|
||||||
|
/// "not enrolled" is an ordinary first-run state, not a failure (UI_RULES'
|
||||||
|
/// "a deliberate choice is not a problem to report" applies just as well
|
||||||
|
/// to a file that simply hasn't been written yet).
|
||||||
|
pub fn load_enrollment_in(dir: &Path) -> io::Result<Option<EnrolledServer>> {
|
||||||
|
let path = enrollment_file(dir);
|
||||||
|
match std::fs::read(&path) {
|
||||||
|
Ok(bytes) => {
|
||||||
|
let server = serde_json::from_slice(&bytes).map_err(|e| {
|
||||||
|
io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
format!("{} is not a valid enrollment ({e})", path.display()),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(Some(server))
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_enrollment(server: &EnrolledServer) -> io::Result<()> {
|
||||||
|
save_enrollment_in(&config_dir(), server)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_enrollment() -> io::Result<Option<EnrolledServer>> {
|
||||||
|
load_enrollment_in(&config_dir())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_saved_enrollment_reads_back_the_same() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let server = EnrolledServer {
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
port: 8547,
|
||||||
|
token: "tok".to_string(),
|
||||||
|
};
|
||||||
|
save_enrollment_in(dir.path(), &server).unwrap();
|
||||||
|
let read_back = load_enrollment_in(dir.path()).unwrap();
|
||||||
|
assert_eq!(read_back, Some(server));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_saved_yet_is_none_not_an_error() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
assert_eq!(load_enrollment_in(dir.path()).unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn the_saved_file_is_owner_only() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let server = EnrolledServer {
|
||||||
|
host: "h".to_string(),
|
||||||
|
port: 1,
|
||||||
|
token: "t".to_string(),
|
||||||
|
};
|
||||||
|
save_enrollment_in(dir.path(), &server).unwrap();
|
||||||
|
let mode = std::fs::metadata(enrollment_file(dir.path()))
|
||||||
|
.unwrap()
|
||||||
|
.permissions()
|
||||||
|
.mode();
|
||||||
|
assert_eq!(mode & 0o777, 0o600);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_corrupt_file_is_named_in_the_error() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(enrollment_file(dir.path()), b"not json").unwrap();
|
||||||
|
let err = load_enrollment_in(dir.path()).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("enrollment.json"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
//! RUST.md's E4: the transcript screen (`transcript-ui`, I5) in a real
|
||||||
|
//! winit window on the desktop, with a session list beside it, talking to
|
||||||
|
//! a real `ai-server` over `client-core`'s REST + SSE clients. See
|
||||||
|
//! `app.rs`'s module doc for the widget tree and the event flow.
|
||||||
|
//!
|
||||||
|
//! Usage:
|
||||||
|
//!
|
||||||
|
//! desktop-app --ca /path/to/ca.pem --link 'aiapp://enroll?host=H&port=P&token=T'
|
||||||
|
//! desktop-app --ca /path/to/ca.pem # after the first run above
|
||||||
|
//!
|
||||||
|
//! `--link` is the same text `app/ui-sandbox.sh`'s banner prints and a
|
||||||
|
//! phone would scan as a QR (DECISIONS.md, 2026-09-05) -- pasted rather
|
||||||
|
//! than scanned, since a desktop has no camera to assume. It is parsed and
|
||||||
|
//! saved to `config::save_enrollment` once; later runs read it back and
|
||||||
|
//! `--link` is only needed again to enrol against a different server. The
|
||||||
|
//! CA is never persisted -- it is a public certificate whose path a
|
||||||
|
//! caller is expected to already know (`AGENTS.md`'s "prefer exercising
|
||||||
|
//! the server directly": the same `certs/ca.pem` a `curl --cacert` call
|
||||||
|
//! uses).
|
||||||
|
|
||||||
|
mod app;
|
||||||
|
mod config;
|
||||||
|
|
||||||
|
use client_core::config::EnrolledServer;
|
||||||
|
|
||||||
|
struct Args {
|
||||||
|
ca_path: std::path::PathBuf,
|
||||||
|
link: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_args() -> Result<Args, String> {
|
||||||
|
let mut ca_path = None;
|
||||||
|
let mut link = None;
|
||||||
|
let mut args = std::env::args().skip(1);
|
||||||
|
while let Some(arg) = args.next() {
|
||||||
|
match arg.as_str() {
|
||||||
|
"--ca" => {
|
||||||
|
ca_path = Some(std::path::PathBuf::from(
|
||||||
|
args.next().ok_or("--ca needs a path")?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
"--link" => link = Some(args.next().ok_or("--link needs a value")?),
|
||||||
|
other => return Err(format!("unrecognised argument '{other}'")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Args {
|
||||||
|
ca_path: ca_path.ok_or(
|
||||||
|
"--ca PATH is required (the pinned CA's certificate, e.g. \
|
||||||
|
~/.config/ai-app/certs/ca.pem)",
|
||||||
|
)?,
|
||||||
|
link,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `app.rs`'s `Client::new` needs to talk to the server: the enrolled
|
||||||
|
/// server (freshly parsed from `--link`, or read back from last time) and
|
||||||
|
/// the CA's PEM bytes. Loading is a pure function of the process's own
|
||||||
|
/// argv and config file, so it is safe to call again from `Client::new` --
|
||||||
|
/// see that call site's comment for why it is not threaded through some
|
||||||
|
/// other way (`DefaultApp::run()` takes no payload).
|
||||||
|
fn load_startup_config() -> Result<(EnrolledServer, Vec<u8>), String> {
|
||||||
|
let args = parse_args()?;
|
||||||
|
let server = match args.link {
|
||||||
|
Some(link) => {
|
||||||
|
let server = EnrolledServer::parse_link(&link)?;
|
||||||
|
config::save_enrollment(&server)
|
||||||
|
.map_err(|e| format!("couldn't save the enrollment: {e}"))?;
|
||||||
|
server
|
||||||
|
}
|
||||||
|
None => config::load_enrollment()
|
||||||
|
.map_err(|e| format!("couldn't read the saved enrollment: {e}"))?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"no server enrolled yet under {} -- pass --link 'aiapp://enroll?...' \
|
||||||
|
once (app/ui-sandbox.sh's start banner prints one)",
|
||||||
|
config::config_dir().display()
|
||||||
|
)
|
||||||
|
})?,
|
||||||
|
};
|
||||||
|
let ca_pem = std::fs::read(&args.ca_path)
|
||||||
|
.map_err(|e| format!("couldn't read the CA at {}: {e}", args.ca_path.display()))?;
|
||||||
|
Ok((server, ca_pem))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// Validated once here so a bad `--ca`/`--link` is reported on stderr
|
||||||
|
// before any window opens; `Client::new` calls this same function
|
||||||
|
// again once the window exists, so this first call is a fast-fail
|
||||||
|
// rather than the only place the values come from.
|
||||||
|
if let Err(e) = load_startup_config() {
|
||||||
|
eprintln!("desktop-app: {e}");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
app::run();
|
||||||
|
}
|
||||||
+23
-4
@@ -4,6 +4,16 @@
|
|||||||
# ./run-headless.sh tabs [-- cargo args]
|
# ./run-headless.sh tabs [-- cargo args]
|
||||||
# ./run-headless.sh tabs --shot /tmp/tabs.png --seconds 4
|
# ./run-headless.sh tabs --shot /tmp/tabs.png --seconds 4
|
||||||
#
|
#
|
||||||
|
# `--bin` runs a real crate binary instead of an example (E4's
|
||||||
|
# `desktop-app`, which is a window a person runs, not a demo) --
|
||||||
|
# `cargo build --bin NAME` instead of `--example NAME`, and
|
||||||
|
# `target/debug/NAME` instead of `target/debug/examples/NAME`. Its own
|
||||||
|
# argv (the CLI flags a real binary takes, as opposed to `cargo build`'s
|
||||||
|
# own flags after `--`) comes through `$RUN_HEADLESS_ARGS`, word-split on
|
||||||
|
# purpose -- an example never needed one, so there was nowhere to plumb it
|
||||||
|
# through positionally without disturbing the existing `-- cargo args`
|
||||||
|
# convention above.
|
||||||
|
#
|
||||||
# The VM has a virtio-gpu render node (Vulkan 1.4 through Venus, GL 4.6
|
# The VM has a virtio-gpu render node (Vulkan 1.4 through Venus, GL 4.6
|
||||||
# through virgl), so wgpu runs on the host's real GPU -- what is missing is
|
# through virgl), so wgpu runs on the host's real GPU -- what is missing is
|
||||||
# only a compositor to give winit a surface. So: a headless sway, the same
|
# only a compositor to give winit a surface. So: a headless sway, the same
|
||||||
@@ -20,16 +30,18 @@ run="${XDG_RUNTIME_DIR:-/tmp}/iris-headless"
|
|||||||
seconds=3
|
seconds=3
|
||||||
shot=""
|
shot=""
|
||||||
example=""
|
example=""
|
||||||
|
kind=example
|
||||||
|
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--shot) shot=$2; shift 2 ;;
|
--shot) shot=$2; shift 2 ;;
|
||||||
--seconds) seconds=$2; shift 2 ;;
|
--seconds) seconds=$2; shift 2 ;;
|
||||||
|
--bin) kind=bin; shift ;;
|
||||||
--) shift; break ;;
|
--) shift; break ;;
|
||||||
*) example=$1; shift ;;
|
*) example=$1; shift ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
[ -n "$example" ] || { echo "usage: $0 EXAMPLE [--shot PNG] [--seconds N] [-- cargo args]" >&2; exit 2; }
|
[ -n "$example" ] || { echo "usage: $0 NAME [--bin] [--shot PNG] [--seconds N] [-- cargo args]" >&2; exit 2; }
|
||||||
|
|
||||||
mkdir -p "$run"
|
mkdir -p "$run"
|
||||||
export SWAYSOCK="$run/sway.sock"
|
export SWAYSOCK="$run/sway.sock"
|
||||||
@@ -67,10 +79,17 @@ export WAYLAND_DISPLAY
|
|||||||
echo "run-headless: $WAYLAND_DISPLAY (sway $(swaymsg -t get_version --raw | sed -n 's/.*"human_readable":"\([^"]*\)".*/\1/p'))" >&2
|
echo "run-headless: $WAYLAND_DISPLAY (sway $(swaymsg -t get_version --raw | sed -n 's/.*"human_readable":"\([^"]*\)".*/\1/p'))" >&2
|
||||||
|
|
||||||
cd "$here"
|
cd "$here"
|
||||||
cargo build --example "$example" "$@" >&2
|
if [ "$kind" = bin ]; then
|
||||||
bin="$here/target/debug/examples/$example"
|
cargo build --bin "$example" "$@" >&2
|
||||||
|
bin="$here/target/debug/$example"
|
||||||
|
else
|
||||||
|
cargo build --example "$example" "$@" >&2
|
||||||
|
bin="$here/target/debug/examples/$example"
|
||||||
|
fi
|
||||||
|
|
||||||
"$bin" >"$run/$example.log" 2>&1 &
|
# shellcheck disable=SC2086 -- deliberately word-split: this is the
|
||||||
|
# binary's own argv, not a single path.
|
||||||
|
"$bin" ${RUN_HEADLESS_ARGS:-} >"$run/$example.log" 2>&1 &
|
||||||
pid=$!
|
pid=$!
|
||||||
trap 'kill "$pid" 2>/dev/null || true' EXIT INT TERM
|
trap 'kill "$pid" 2>/dev/null || true' EXIT INT TERM
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use crate::prelude::*;
|
|||||||
use std::{
|
use std::{
|
||||||
ops::{BitOr, Deref, DerefMut},
|
ops::{BitOr, Deref, DerefMut},
|
||||||
rc::Rc,
|
rc::Rc,
|
||||||
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq)]
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
@@ -357,3 +358,241 @@ impl BitOr<CursorSense> for CursorSenses {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How long a stationary press has to be held before it is treated as a
|
||||||
|
/// long-press rather than the start of a pan.
|
||||||
|
pub const LONG_PRESS: Duration = Duration::from_millis(500);
|
||||||
|
/// How far a press has to move, in pixels, before it counts as a drag
|
||||||
|
/// rather than jitter -- for both the pan-vs-select axis test and the
|
||||||
|
/// "did this actually move" long-press guard.
|
||||||
|
pub const DRAG_SLOP: f32 = 8.0;
|
||||||
|
|
||||||
|
/// What a [`DragArbiter`] decided a frame's drag should mean. `Undecided`
|
||||||
|
/// means neither a pan nor a selection has committed yet, so the caller
|
||||||
|
/// should do nothing observable this frame.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum DragOutcome {
|
||||||
|
Undecided,
|
||||||
|
/// Scroll the enclosing list by this many window-space pixels along
|
||||||
|
/// the drag axis (the delta since the arbiter's last decided frame).
|
||||||
|
Pan(f32),
|
||||||
|
/// A selection should begin at the arbiter's press origin.
|
||||||
|
SelectStart,
|
||||||
|
/// A selection already underway should extend to the current position.
|
||||||
|
SelectExtend,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
enum ArbiterState {
|
||||||
|
Idle,
|
||||||
|
Undecided { already_selected: bool },
|
||||||
|
Panning,
|
||||||
|
Selecting,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decides, one shared instance per gesture surface (a transcript's whole
|
||||||
|
/// row list here), whether a touch drag that starts on a row's own
|
||||||
|
/// selectable text is panning the list or extending a text selection --
|
||||||
|
/// RUST.md's I5 finding that both wanted the same `CursorSense::
|
||||||
|
/// click_or_drag()` gesture, with the inner text layer winning every frame
|
||||||
|
/// regardless of which one the reader meant. Decided the way Android
|
||||||
|
/// itself decides it, so a reader's existing muscle memory carries over:
|
||||||
|
///
|
||||||
|
/// - An ordinary vertical drag pans -- checked first, and immediately,
|
||||||
|
/// so a swipe never waits on the long-press timer.
|
||||||
|
/// - A stationary press held past [`LONG_PRESS`] starts a selection.
|
||||||
|
/// Every drag frame after that extends it, whichever direction it goes.
|
||||||
|
/// - A drag that starts **horizontally** while something is already
|
||||||
|
/// selected extends that selection right away, skipping the long-press
|
||||||
|
/// wait -- the "drag the selection handle" gesture a reader reaches for
|
||||||
|
/// once text is already highlighted.
|
||||||
|
///
|
||||||
|
/// Pure state, no rendering or widget access, so it is unit-testable
|
||||||
|
/// exactly like the rest of this module (`sense_tests.rs`'s style) with a
|
||||||
|
/// caller-supplied `Instant` rather than a real clock.
|
||||||
|
pub struct DragArbiter {
|
||||||
|
state: ArbiterState,
|
||||||
|
origin: Vec2,
|
||||||
|
origin_at: Instant,
|
||||||
|
last: Vec2,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DragArbiter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
state: ArbiterState::Idle,
|
||||||
|
origin: Vec2::ZERO,
|
||||||
|
origin_at: Instant::now(),
|
||||||
|
last: Vec2::ZERO,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DragArbiter {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fresh press-down at `pos`. `already_selected` is whatever the
|
||||||
|
/// caller's selection state was *before* this press -- it decides
|
||||||
|
/// whether an early horizontal move extends that selection instead of
|
||||||
|
/// waiting for a long-press.
|
||||||
|
pub fn press_start(&mut self, pos: Vec2, now: Instant, already_selected: bool) {
|
||||||
|
self.origin = pos;
|
||||||
|
self.origin_at = now;
|
||||||
|
self.last = pos;
|
||||||
|
self.state = ArbiterState::Undecided { already_selected };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The press continues (still down) at `pos`. Call once per frame
|
||||||
|
/// while the button/finger is down; returns what this frame means.
|
||||||
|
pub fn update(&mut self, pos: Vec2, now: Instant) -> DragOutcome {
|
||||||
|
match self.state {
|
||||||
|
ArbiterState::Idle => DragOutcome::Undecided,
|
||||||
|
ArbiterState::Panning => {
|
||||||
|
let dy = pos.y - self.last.y;
|
||||||
|
self.last = pos;
|
||||||
|
DragOutcome::Pan(dy)
|
||||||
|
}
|
||||||
|
ArbiterState::Selecting => {
|
||||||
|
self.last = pos;
|
||||||
|
DragOutcome::SelectExtend
|
||||||
|
}
|
||||||
|
ArbiterState::Undecided { already_selected } => {
|
||||||
|
let dx = pos.x - self.origin.x;
|
||||||
|
let dy = pos.y - self.origin.y;
|
||||||
|
if already_selected && dx.abs() > DRAG_SLOP && dx.abs() > dy.abs() {
|
||||||
|
self.state = ArbiterState::Selecting;
|
||||||
|
self.last = pos;
|
||||||
|
DragOutcome::SelectExtend
|
||||||
|
} else if dy.abs() > DRAG_SLOP && dy.abs() >= dx.abs() {
|
||||||
|
self.state = ArbiterState::Panning;
|
||||||
|
self.last = pos;
|
||||||
|
DragOutcome::Pan(dy)
|
||||||
|
} else if now.duration_since(self.origin_at) >= LONG_PRESS
|
||||||
|
&& dx.abs() <= DRAG_SLOP
|
||||||
|
&& dy.abs() <= DRAG_SLOP
|
||||||
|
{
|
||||||
|
self.state = ArbiterState::Selecting;
|
||||||
|
self.last = pos;
|
||||||
|
DragOutcome::SelectStart
|
||||||
|
} else {
|
||||||
|
DragOutcome::Undecided
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The press was released -- back to idle for the next one.
|
||||||
|
pub fn release(&mut self) {
|
||||||
|
self.state = ArbiterState::Idle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod drag_arbiter_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn t(ms: u64) -> Instant {
|
||||||
|
// A fixed base plus an offset, rather than `Instant::now()` per
|
||||||
|
// call -- keeps every test's timing deterministic instead of at
|
||||||
|
// the mercy of how long the test itself took to run.
|
||||||
|
Instant::now() - Duration::from_secs(3600) + Duration::from_millis(ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn small_jitter_stays_undecided() {
|
||||||
|
let mut a = DragArbiter::new();
|
||||||
|
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
|
||||||
|
assert_eq!(a.update(Vec2::new(1.0, 1.0), t(10)), DragOutcome::Undecided);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_vertical_drag_pans_immediately() {
|
||||||
|
let mut a = DragArbiter::new();
|
||||||
|
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
|
||||||
|
assert_eq!(
|
||||||
|
a.update(Vec2::new(0.0, 20.0), t(10)),
|
||||||
|
DragOutcome::Pan(20.0)
|
||||||
|
);
|
||||||
|
// Subsequent frames keep panning, by the delta since last frame.
|
||||||
|
assert_eq!(
|
||||||
|
a.update(Vec2::new(0.0, 35.0), t(20)),
|
||||||
|
DragOutcome::Pan(15.0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_horizontal_drag_with_nothing_selected_does_not_select() {
|
||||||
|
let mut a = DragArbiter::new();
|
||||||
|
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
|
||||||
|
// Horizontal movement alone, with no prior selection, is not any
|
||||||
|
// of the three named gestures -- it stays undecided rather than
|
||||||
|
// guessing (it will resolve to a long-press-selection if the
|
||||||
|
// finger then stops moving, or nothing if it lifts).
|
||||||
|
assert_eq!(
|
||||||
|
a.update(Vec2::new(20.0, 0.0), t(10)),
|
||||||
|
DragOutcome::Undecided
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_long_press_without_moving_starts_a_selection() {
|
||||||
|
let mut a = DragArbiter::new();
|
||||||
|
a.press_start(Vec2::new(5.0, 5.0), t(0), false);
|
||||||
|
assert_eq!(a.update(Vec2::new(5.0, 5.0), t(10)), DragOutcome::Undecided);
|
||||||
|
assert_eq!(
|
||||||
|
a.update(Vec2::new(6.0, 5.0), t(LONG_PRESS.as_millis() as u64 + 1)),
|
||||||
|
DragOutcome::SelectStart
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn after_a_long_press_any_further_drag_extends() {
|
||||||
|
let mut a = DragArbiter::new();
|
||||||
|
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
|
||||||
|
assert_eq!(
|
||||||
|
a.update(Vec2::new(0.0, 0.0), t(LONG_PRESS.as_millis() as u64 + 1)),
|
||||||
|
DragOutcome::SelectStart
|
||||||
|
);
|
||||||
|
// Even a vertical move now extends the selection rather than
|
||||||
|
// panning -- once a selection has started, it owns the gesture
|
||||||
|
// until release.
|
||||||
|
assert_eq!(
|
||||||
|
a.update(Vec2::new(0.0, 40.0), t(600)),
|
||||||
|
DragOutcome::SelectExtend
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_horizontal_drag_on_already_selected_text_extends_immediately() {
|
||||||
|
let mut a = DragArbiter::new();
|
||||||
|
a.press_start(Vec2::new(0.0, 0.0), t(0), true);
|
||||||
|
assert_eq!(
|
||||||
|
a.update(Vec2::new(20.0, 2.0), t(10)),
|
||||||
|
DragOutcome::SelectExtend
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_vertical_drag_still_pans_even_with_a_prior_selection() {
|
||||||
|
let mut a = DragArbiter::new();
|
||||||
|
a.press_start(Vec2::new(0.0, 0.0), t(0), true);
|
||||||
|
assert_eq!(
|
||||||
|
a.update(Vec2::new(0.0, 20.0), t(10)),
|
||||||
|
DragOutcome::Pan(20.0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn release_resets_to_idle() {
|
||||||
|
let mut a = DragArbiter::new();
|
||||||
|
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
|
||||||
|
a.update(Vec2::new(0.0, 20.0), t(10));
|
||||||
|
a.release();
|
||||||
|
assert_eq!(
|
||||||
|
a.update(Vec2::new(0.0, 999.0), t(20)),
|
||||||
|
DragOutcome::Undecided
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
//! 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.
|
||||||
|
//!
|
||||||
|
//! **Gap closed, 2026-09-05**: a touch-drag that starts on a row's
|
||||||
|
//! rendered text used to always begin a cross-row *selection* (`row.rs`'s
|
||||||
|
//! `CursorSense::click_or_drag()` on each row's `TextEdit`), never a
|
||||||
|
//! *scroll* of the list, because both wanted the same gesture over the
|
||||||
|
//! same screen region and `core/src/sense.rs`'s `run_sensors` gave the
|
||||||
|
//! widget in the *inner* layer (a row's own `TextEdit`) first refusal
|
||||||
|
//! every frame it was pressed. `row.rs` now routes every row's drag
|
||||||
|
//! through one shared `iris::sense::DragArbiter`
|
||||||
|
//! (`Selection::drag`, `selection.rs`), which decides pan vs. select the
|
||||||
|
//! way Android itself does -- see `DragArbiter`'s own doc and
|
||||||
|
//! `DECISIONS.md` for the exact rule. `List` scrolls correctly when
|
||||||
|
//! driven programmatically (I3's benchmark), via the mouse wheel (wired
|
||||||
|
//! below, `CursorSense::Scroll`), and now via a touch pan starting on a
|
||||||
|
//! row's own text too.
|
||||||
|
|
||||||
|
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 (screen, tree) = build_tree(rsc, rows);
|
||||||
|
ui_state.set_root(tree);
|
||||||
|
screen
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same widget tree [`build`] makes, without claiming the window's
|
||||||
|
/// whole root -- what a caller embedding this screen alongside something
|
||||||
|
/// else of its own needs (RUST.md's E4: a session list beside the
|
||||||
|
/// transcript on the desktop). `build` is `build_tree` plus
|
||||||
|
/// `ui_state.set_root(tree)`; kept as its own function since most callers
|
||||||
|
/// (the winit example, an eventual Android cdylib) want the screen to *be*
|
||||||
|
/// the window and don't need the strong handle back.
|
||||||
|
pub fn build_tree<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
rows: Vec<FoldedRow>,
|
||||||
|
) -> (TranscriptScreen, StrongWidget)
|
||||||
|
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);
|
||||||
|
|
||||||
|
let tree = (list.width(rest(1)).height(rest(1)), composer_bar)
|
||||||
|
.span(Dir::DOWN)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any();
|
||||||
|
|
||||||
|
(
|
||||||
|
TranscriptScreen {
|
||||||
|
list,
|
||||||
|
composer,
|
||||||
|
selection,
|
||||||
|
},
|
||||||
|
tree,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
//! 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, time::Instant};
|
||||||
|
|
||||||
|
/// 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::drag` -- shared by every row variant below, since a
|
||||||
|
/// selectable row is always "one TextEdit plus this wiring" regardless of
|
||||||
|
/// what folded it. `list` is threaded through so that same drag can pan
|
||||||
|
/// the list instead of selecting, per `Selection::drag`'s own doc.
|
||||||
|
fn build_text_row<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
list: WeakWidget<List>,
|
||||||
|
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
|
||||||
|
// `| CursorSense::unclick()` on top of the usual click-or-drag set
|
||||||
|
// -- the arbiter inside `Selection::drag` needs the release too,
|
||||||
|
// to go back to idle for the next press (`DragArbiter::release`).
|
||||||
|
.on(
|
||||||
|
CursorSense::click_or_drag() | CursorSense::unclick(),
|
||||||
|
move |ctx, rsc| {
|
||||||
|
selection.borrow_mut().drag(
|
||||||
|
rsc,
|
||||||
|
list,
|
||||||
|
key,
|
||||||
|
ctx.data.pos,
|
||||||
|
ctx.data.size,
|
||||||
|
ctx.data.cursor.pos,
|
||||||
|
ctx.data.sense,
|
||||||
|
Instant::now(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.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,
|
||||||
|
list: WeakWidget<List>,
|
||||||
|
selection: Rc<RefCell<Selection>>,
|
||||||
|
key: RowKey,
|
||||||
|
item: &TranscriptItem,
|
||||||
|
) -> StrongWidget
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost,
|
||||||
|
{
|
||||||
|
let (sender, markdown_src) = item_content(item);
|
||||||
|
build_text_row(rsc, list, 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,
|
||||||
|
list: WeakWidget<List>,
|
||||||
|
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, list, selection, key, Some("Tools"), text)
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = build_content(
|
||||||
|
rsc,
|
||||||
|
list,
|
||||||
|
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,
|
||||||
|
list,
|
||||||
|
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, list, selection, key, item))
|
||||||
|
}
|
||||||
|
FoldedRow::Tools(calls) => {
|
||||||
|
let key = row_key(&calls[0].key());
|
||||||
|
(key, build_tools(rsc, list, selection, key, calls.clone()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
//! 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, time::Instant};
|
||||||
|
|
||||||
|
pub struct Selection {
|
||||||
|
rows: BTreeMap<RowKey, WeakWidget<TextEdit>>,
|
||||||
|
anchor: Option<(RowKey, Vec2)>,
|
||||||
|
/// One arbiter shared by every row's drag handler -- RUST.md's I5
|
||||||
|
/// gesture conflict (a row's own `click_or_drag()` and a list-level
|
||||||
|
/// pan wanting the same touch gesture). See `drag` below, and
|
||||||
|
/// `iris::sense::DragArbiter`'s own doc for the decision itself.
|
||||||
|
arbiter: DragArbiter,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Selection {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Selection {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
rows: BTreeMap::new(),
|
||||||
|
anchor: None,
|
||||||
|
arbiter: DragArbiter::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether any row currently has a non-empty selection -- what a fresh
|
||||||
|
/// press consults so `drag` knows whether an early horizontal move is
|
||||||
|
/// "start dragging the selection handle" rather than an ordinary tap.
|
||||||
|
fn has_selection(&self, ui: &mut impl UiRsc) -> bool {
|
||||||
|
self.rows
|
||||||
|
.values()
|
||||||
|
.any(|w| w.edit(ui).text.selected_text().is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row's `CursorSense::click_or_drag()` handler, for every row,
|
||||||
|
/// routes its raw pointer data through here rather than calling
|
||||||
|
/// `begin`/`extend` directly -- this is the single place that decides
|
||||||
|
/// whether the gesture pans `list` or extends a selection, so the
|
||||||
|
/// decision is made once per gesture rather than independently by
|
||||||
|
/// whichever row happens to be under the finger this frame (see
|
||||||
|
/// `DragArbiter`'s own doc for why one shared instance, not one per
|
||||||
|
/// row, is what makes that consistent as a drag crosses row
|
||||||
|
/// boundaries).
|
||||||
|
///
|
||||||
|
/// `pos_row`/`size` are row-local, as `begin`/`extend` want;
|
||||||
|
/// `pos_window` is in window space, since a pan's delta has to stay
|
||||||
|
/// meaningful even when this frame's event landed on a different row
|
||||||
|
/// than the last one.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn drag(
|
||||||
|
&mut self,
|
||||||
|
ui: &mut impl UiRsc,
|
||||||
|
list: WeakWidget<List>,
|
||||||
|
key: RowKey,
|
||||||
|
pos_row: Vec2,
|
||||||
|
size: Vec2,
|
||||||
|
pos_window: Vec2,
|
||||||
|
sense: CursorSense,
|
||||||
|
now: Instant,
|
||||||
|
) {
|
||||||
|
let outcome = match sense {
|
||||||
|
CursorSense::PressStart(_) => {
|
||||||
|
let already_selected = self.has_selection(ui);
|
||||||
|
self.arbiter.press_start(pos_window, now, already_selected);
|
||||||
|
self.arbiter.update(pos_window, now)
|
||||||
|
}
|
||||||
|
CursorSense::PressEnd(_) => {
|
||||||
|
self.arbiter.release();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ => self.arbiter.update(pos_window, now),
|
||||||
|
};
|
||||||
|
match outcome {
|
||||||
|
DragOutcome::Undecided => {}
|
||||||
|
DragOutcome::Pan(dy) => list(ui).scroll(-dy),
|
||||||
|
DragOutcome::SelectStart => self.begin(ui, key, pos_row, size),
|
||||||
|
DragOutcome::SelectExtend => self.extend(ui, key, pos_row, size),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user