Add retained paints and shared text selection

This commit is contained in:
iris committed 2026-09-10 18:35:24 -04:00
1 parent 25370731d0
commit de92fccba5
60 files changed
+2848 -1297

No files matched your search

+13 -30
View File
@@ -2,35 +2,10 @@
Only open Iris framework work lives here. Delete an item when it lands.
## Fix
- [ ] **Colours are not in a defined colour space. Fix this before a
styling pass.** Both render backends prefer an sRGB surface
(`default/render.rs` and `android/render.rs`), while `fs_main` returns
`unpack4x8unorm` palette and image bytes unchanged. An sRGB attachment
treats those values as linear and encodes them again: Mocha Crust
(17,17,27) became (73,73,91) in a desktop screenshot measured on
2026-09-06.
Neither diagnostics nor startup logging records Android's selected surface
format. A device exposing only a non-sRGB surface can hide the shared bug.
Done means defining one convention for palette bytes, decoded images,
colour emoji and the clear colour, then converting exactly once for the
selected target. Record the selected format in diagnostics, and add a GPU
test that draws known non-black, non-white pixels into an sRGB target and
reads the stored bytes back; screenshots from desktop and Android then
confirm the same Catppuccin values rather than serving as the definition.
## Build (for the port)
Framework capabilities needed by `RUST.md`'s port plan:
- [ ] **Selectable, read-only text.** P0's report and P1's transcript rows
use `TextEdit` because `Selectable` is implemented only for it. That
makes prose focusable and opens the IME over text that cannot be edited.
A display widget needs the same selection geometry and clipboard path
without a text-input accessibility role or keyboard focus.
- [ ] **Overflow ellipsis with an explicit retained end.** `TextAttrs` can
only wrap or clip, so a tool summary is cut with no mark. Parley has no
ellipsis primitive; use its line breaker to find the cut, but keep source
@@ -52,9 +27,9 @@ Framework capabilities needed by `RUST.md`'s port plan:
- [ ] **Per-range backgrounds for rich text.** (**P1**.) Inline code is
already monospace and coloured, but matching Compose's chip also needs
the glyph run's boxes so a surface can be drawn behind exactly that byte
range. `TextEdit` already computes the same geometry internally for its
selection highlight; expose one shared primitive rather than giving the
app a second text-layout path.
range. The shared `TextSelection` engine already computes the same geometry
for selection highlights; expose one shared primitive rather than giving
the app a second text-layout path.
- [ ] **A modal/dialog primitive.** (**P1**, reused by **P3** and
**P5**.) Needed for the session settings dialog, `UsageDialog`'s
equivalent, and the delete-with-`deleteForeign` confirmation with its
@@ -69,6 +44,14 @@ Framework capabilities needed by `RUST.md`'s port plan:
## Later
- [ ] **Intern independently constructed solid paint definitions.** Inline
`rect(Srgba8::...)` values currently receive a new `PaintId` each time.
Cache them by canonical linear RGBA bits, but keep `Paints::add` explicitly
unique so two semantic theme roles that start with the same value can later
change independently. Cache entries must be weak and disappear when the
last real handle releases the slot; gradients and texture paints need their
own identity rules rather than inheriting solid-value interning blindly.
- [ ] **Property/content animations.** Cosmetic, so after correctness and
parity. Keep them modular, like input; scrolling already animates through
`Widget::tick` and `UiData::animate`. A widget that does not opt in must
@@ -85,8 +68,8 @@ Framework capabilities needed by `RUST.md`'s port plan:
against `.masked_by(rect(BAR_FILL))` on the composer at the phone's own
size and density and the two are identical to the pixel. What the pair
cannot express is a clip that is not a box: `Painter::set_mask` writes
a `RectPrimitive::color(Color::NONE)` at the widget's own region, with
no radius, so `.background(rect(fill).radius(r)).masked()` draws a
a `RectPrimitive::color` using `PaintId::NONE` at the widget's own region,
with no radius, so `.background(rect(fill).radius(r)).masked()` draws a
rounded panel and then cuts its content square. Both other call sites
(`row.rs`'s fence, `tool.rs`'s raw output) are rounded, which is why
the method stands for now.
+11 -25
View File
@@ -22,8 +22,7 @@ the command and measured value when evidence matters.
next session should start. Its box below has the state.
- The port is one crate under `app-rust/`; `iris/` is only the UI framework.
- **Open across the rest of the docs**: `docs/IRIS_TODO.md` is iris's own
list (colour-space correctness is the live one), `docs/TODO.md` is the
Compose app's.
list; `docs/TODO.md` is the Compose app's.
## Desktop and phone share the code
@@ -81,7 +80,7 @@ runs inside `cargo test`.
pan, and (e) the composer clears a simulated 1000px IME inset
(`Composer::set_bottom_inset`). Each was confirmed to fail without
its subject rather than assumed: dropping `animate(id)` from
`Selection::drag` -- the phone's own "fling does nothing" defect --
`SelectionController::drag` -- the phone's own "fling does nothing" defect --
and starting the fling curve at the wall clock each fail only the
flick test; flinging on `Tapped` fails only the tap test; a 5s
`LONG_PRESS` fails only the selection test; a `set_bottom_inset` that
@@ -269,11 +268,14 @@ already got right (v2 signing, `uses-feature`, ABI splits).
Reading the Compose code for what a replacement must be able to express,
rather than what it happens to look like:
1. **The transcript is one selectable body of text.** One
`SelectionContainer` around the whole lazy list, so a selection runs from
a reply into the tool output beneath it. The framework needs selectable
read-only rich text across many rows, with the platform's selection
handles and clipboard on the phone.
1. **The transcript is one selectable body of text.** A
`SelectionController` is registered directly on the lazy list, with no
selection widget in the layout tree, so a selection runs from a reply into
the tool output beneath it. Each parent supplies either draw order or one
visual axis for its immediate children; ordering is resolved only when
selection queries it. Iris owns the selection handles because its text is
drawn into one surface, while the platform supplies the clipboard and
related system services.
2. **Rich inline text**: markdown with links (one tap detector per text,
not a node per link), inline code chips drawn behind the text, tables
with wrapping cells and a sideways scroll, syntax-highlighted fences,
@@ -442,7 +444,7 @@ buffer on **every keystroke**. So there is nothing to adopt, and adding
it would be upstream work in parley.
**And the app already does the thing incremental layout would buy.**
`RowBlocks::apply_delta` keeps one `TextEdit` per top-level markdown
`RowBlocks::apply_delta` keeps one `Text` per top-level markdown
block and re-shapes only the block a delta landed in; re-splitting the
markdown to find that block is 18µs at 18,000 characters and comparing
the blocks is 470ns. Neither is the cost.
@@ -664,12 +666,6 @@ pane is neither masked nor scrollable despite its construction comment saying
it is both. This is an `app-rust` defect, not an iris framework item.
- [ ] **P1 — session screen parity.** Continue in this order:
- [ ] **Before the next parity slice — make iris's colour pipeline
correct.** Both backends currently prefer an sRGB surface while
the shader returns palette/image bytes as
linear values; `IRIS_TODO.md` has the measured mismatch and
pass condition. Do this before judging or centralising the
app's styling. It is correctness, not cosmetic polish.
- [ ] **P1c — history paging and jump-to-latest.** Wire
`client::transcript_source` into `src/ui`:
the opening page, paging back on scroll with the cushion
@@ -725,16 +721,6 @@ it is both. This is an `app-rust` defect, not an iris framework item.
names are app content applied through iris's existing `.label()` API,
not a missing framework widget.
**`app-rust` UI defect still open**: tool-card text is not selectable.
A `TranscriptRow::Tools` has no markdown blocks, so cards can number
their own texts from zero without colliding with the row selection
keys. The risk is lifecycle: every card rebuild path must unregister
the old `TextEdit` handles before registering replacements, or the next
long press can find a freed handle. Cover result arrival, group toggle,
a call joining a run and the per-card widget swap with stale-handle
tests. This uses iris's existing selection API; it is not a framework
widget gap.
**Pass condition**: `app/ui-sandbox.sh`'s fixtures driven by
`ui-trace record --do "tap '<label>'"` — a session with the big
transcript (`AI_SANDBOX_BIG_MB`), a paused/slow-spawning one
+5 -5
View File
@@ -240,16 +240,16 @@ without a scroll-widget special case.
The transcript registers the wheel **by hand rather than calling
`LazySpan::scrollable()`**, and this is not an oversight. That helper also
registers a finger drag driving the span's own `DragGesture`, and the
transcript already has an arbiter — `Selection`, which must decide between
transcript already has an arbiter — `SelectionController`, which must decide between
panning and selecting text and so cannot let a second `DragGesture` see
the same frames. `DragGesture`'s doc states the rule: one gesture, one
arbiter, each frame delivered exactly once. The wheel handler registered
here is identical to the helper's; only the drag differs.
`Selection` is given the span by `set_scroll_area` after it exists (rows
need a `Selection`, and the span needs the rows), and hands it committed
pans and releases through `Scrollable::scroll`/`fling`. There is no
wrapper widget: `TranscriptScreen::list` is the layout (`extent`,
`SelectionController` is attached directly to the span and holds its weak
handle, then hands committed pans and releases through
`Scrollable::scroll`/`fling`. There is no wrapper widget:
`TranscriptScreen::list` is the layout (`extent`,
`key_at`, `jump_to_end`) *and* the position (`amt`, `fling`,
`is_scrolling`).
+20
View File
@@ -42,6 +42,26 @@ adjacency after a free.
Standalone images currently use `NonFiltering` sampling. Thumbnail scaling
and filtering remain image-widget decisions, not texture-storage decisions.
## Colour convention
Palette literals and decoded image bytes enter Iris as straight-alpha sRGB.
Solid paints are converted once when registered and stored in a separate GPU
paint table as linear `vec4<f32>`; rect and glyph primitives carry only the
paint-table index. `Paints::set` rewrites a slot in place, so a shared theme
can change without rebuilding widgets or primitive buffers. A rect may also
hold a `Paint` definition and resolve it to a `PaintId` on its first draw;
independently constructed definitions deliberately receive independent slots.
Standalone images, colour glyphs, and atlas pages use
`Rgba8UnormSrgb`, which makes sampling decode their RGB channels to linear
light. Shaders therefore always return linear values. Both window backends
render through an sRGB texture view in `SurfaceColorSpace::Srgb`, which
encodes exactly once on output; the clear colour is linear too. A surface
without an advertised RGBA/BGRA sRGB view and sRGB output space is rejected
rather than silently displaying a different colour pipeline. The readback
test in `iris/tests/color_space.rs` guards the solid, image, and in-place
theme-update paths end to end.
## Verification rig
`scripts/rigs/gpu-probe` requests Iris's exact feature and limit set without a