iris: a Rect is not size-independent, and P1a's block appearance verified
The defect P1a's screenshots found, and the one that mattered: `Rect::is_size_independent()` answered `true`. A `Rect` fills whatever region it is handed, so its content *is* the region -- and `draw_inner`'s fast path, which rewrites a widget's primitives with `r.outside(&from).within(®ion)` instead of redrawing it, cannot reproduce that once a region carries both `rel` and `abs`. What it looked like: a fenced code block's background kept the height of the provisional full-region draw `Span` does in its first phase, so one fence's panel covered every block below it and every row below that, with the text underneath laid out correctly. Likely the same cause as RUST.md's older "the composer bar's grey background is not drawn". Also here: a quote's bar is a `Stack` background behind padded text rather than a two-child `Span(Dir::RIGHT)` (one widget fewer and no provisional pass), and `transcript-ui`'s `transcript` example gains a row holding one of every block kind -- the fixture's own heading, paragraph, fence and table source, plus a list and a quote, which the fixture has neither of. docs/bench/p1a-2026-09-06/ has the pairs and docs/RUST.md's P1a box names what still differs. The iris half is from the desktop backend because this emulator cannot draw iris's glyphs at all (solid boxes, reproduced on the previous commit, with Compose drawing text correctly on the same AVD); both routes to Vulkan on this AVD were tried and both fail. Bench stream phase, assertions live, no abort: p50 53.0ms p90 108.6ms p99 132.0ms against 52.8/108.1/137.3 before -- unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
64f64b54e5
commit
69525bd131
11 files changed
+355
-34
No files matched your search
@@ -5,6 +5,39 @@ 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-06 (how a markdown block looks, P1a)
|
||||
|
||||
- **A table is drawn as padded monospace columns, not as a grid.** Your
|
||||
call to reverse. Compose draws a real grid: cells on a tint, each
|
||||
column with a 136dp floor, scrolling sideways when there are too many.
|
||||
iris has no grid widget, and building one would be a widget per
|
||||
markdown feature -- which is the thing the block model exists to avoid.
|
||||
In a monospace face a character count *is* a pixel width, so padding
|
||||
each cell to its column's width is alignment, the widths are still
|
||||
measured from the cells, and a table that is too wide pans sideways
|
||||
through the same mechanism a code fence already uses. The header is
|
||||
bold with a rule under it, and a long cell wraps inside its column
|
||||
(capped at 28 characters, which is what fits three columns across a
|
||||
phone). **What it trades:** no cell borders, and a table looks like
|
||||
code rather than like a table. If you want the grid, it is a new widget
|
||||
and it is a day's work.
|
||||
- **Three block frames, and only three.** A heading, paragraph and list
|
||||
are plain text with spans; a fence and a table are a rounded panel that
|
||||
does not wrap; a quote is a bar with the text padded past it.
|
||||
Everything else markdown says is expressed in span styles, which cost
|
||||
no widgets and no layout nodes. So a new markdown feature is a span,
|
||||
not a widget.
|
||||
- **A list's marker is part of the text, so a wrapped item's second line
|
||||
returns to the left margin.** Compose keeps it indented by giving the
|
||||
marker its own column. Doing the same here needs per-line indent in
|
||||
iris's text attributes; it is written down rather than done, because
|
||||
the list items in a real reply are usually one line.
|
||||
- **A link opens on a tap and not on the end of a drag.** A press that
|
||||
panned the transcript past a link, or that held long enough to start a
|
||||
selection, does not follow it -- decided by the same gesture machine
|
||||
that decides pan-versus-select, so there is one rule rather than two
|
||||
that can disagree.
|
||||
|
||||
## 2026-09-06 (composer scroll and the streaming block model)
|
||||
|
||||
- **A streamed message becomes a column of per-block widgets.** Decided by
|
||||
|
||||
@@ -8,6 +8,55 @@ capability that moved. Small and trivial changes do not go here.
|
||||
An entry gives the date, what changed, why, and a short before/after where
|
||||
it helps judge the change without the session that made it. Newest first.
|
||||
|
||||
## 2026-09-06: a tap is its own gesture outcome, and opening a URL is a backend capability
|
||||
|
||||
Three related additions, all for following a markdown link.
|
||||
|
||||
**`iris::platform::OpenUrl`** is a new trait beside `attr::FocusHost`, and
|
||||
has the same shape: declared in `iris`, implemented once per backend (a
|
||||
detached `xdg-open`/`open`/`start` on the desktop, an `ACTION_VIEW` intent
|
||||
on Android, deferred to the next view callback exactly the way
|
||||
`pending_show_keyboard` is). A widget asks for the capability by bound --
|
||||
`Rsc::State: FocusHost + OpenUrl` -- instead of a caller threading a
|
||||
callback down through every builder. One method, not a general "run an
|
||||
intent": a narrower capability is a narrower thing to get wrong. Nothing
|
||||
is returned; the platform either shows a browser or does not, and both
|
||||
are outside the process.
|
||||
|
||||
**`GestureOutcome::Tapped`** is new. `Released(None)` used to mean both
|
||||
"the press ended having selected something" and "the press ended having
|
||||
done nothing at all", and only the second is a tap. Any caller that acts
|
||||
on a tap -- following a link -- must not also act when the finger was
|
||||
panning the list past that link, so the distinction is made once, in the
|
||||
gesture machine every widget already shares, rather than timed again per
|
||||
widget. `DragArbiter::is_undecided()` is what answers it.
|
||||
`Selection::drag` returns the outcome now instead of `()`.
|
||||
|
||||
**`DragArbiter`/`DragGesture` take an axis** (`::on(Axis)`; `::new()` is
|
||||
still vertical). A code fence pans across its own long lines exactly the
|
||||
way a transcript pans down its rows, and the two were the same state
|
||||
machine with `dx` and `dy` swapped. `WidgetLike::scrollable_on(axis)`
|
||||
joins `scrollable()` for the same reason. Before this, a horizontal
|
||||
`Scroll` existed but could not be dragged by a finger at all -- its
|
||||
arbiter only ever committed on the vertical axis.
|
||||
|
||||
Two smaller ones in the same pass. **`TextEditCtx::byte_at(pos, size)`**
|
||||
answers which byte of the text a tap landed on, doing the same
|
||||
region-relative transform `select` does, without handing out the parley
|
||||
layout a caller could shape against stale text. And **`Rect::radius` now
|
||||
takes a `Len`**, so a corner can be written in `dp` and come out the same
|
||||
physical size on every display; a bare number still means physical pixels.
|
||||
|
||||
**One behaviour change worth knowing about**: `Rect::is_size_independent()`
|
||||
answers `false` now. It answered `true`, and a `Rect` fills whatever
|
||||
region it is given -- so `draw_inner`'s fast path, which rewrites a
|
||||
widget's primitives in place instead of redrawing it, could not reproduce
|
||||
what `draw` would have done. A `.background(rect(..))` behind
|
||||
variable-height content kept the size of the provisional pass its parent
|
||||
`Span` had drawn it at, which on the transcript screen meant one code
|
||||
block's panel covering every block below it. Costs one primitive's redraw
|
||||
when a rect is resized.
|
||||
|
||||
## 2026-09-06: a transcript row is a column of blocks, and a block is the selection unit
|
||||
|
||||
`transcript-ui`'s row builder used to make **one** `TextEdit` per message.
|
||||
|
||||
+62
-11
@@ -535,22 +535,31 @@ agent ticks it here with the evidence.
|
||||
`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`).
|
||||
- [x] **A tappable link** — done 2026-09-06 (P1a). `TextEditCtx::
|
||||
byte_at(pos, size)` answers which byte a tap landed on without
|
||||
handing out the parley layout, `GestureOutcome::Tapped` says the
|
||||
press committed to neither a pan nor a selection, and
|
||||
`iris::platform::OpenUrl` is the capability each backend implements
|
||||
(`xdg-open`/`open`/`start`; an `ACTION_VIEW` intent on Android,
|
||||
deferred to `after_input` the way `pending_show_keyboard` is).
|
||||
- [ ] **A background chip behind inline code.** Still needs per-range
|
||||
glyph *geometry* — a run's boxes, not one offset — which
|
||||
`TextEditCtx` does not expose outside `iris::widget::text`
|
||||
(`edit.rs`'s `layout()` helper is private). The same primitive
|
||||
`TextEdit::draw`'s own selection highlight uses internally,
|
||||
`iris/src/widget/text/edit.rs:99`. `byte_at` above deliberately did
|
||||
not open that up: a tap needs one offset and a chip needs the run.
|
||||
- [ ] **`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.
|
||||
- [x] **Syntax highlighting inside a fenced code block** — done
|
||||
2026-09-06 (P1a). `client_core::highlight::spans_of` by language,
|
||||
converted from its char indices to `SpanStyle`'s byte offsets, in
|
||||
the same Catppuccin palette `Theme.kt` uses. A language the scanner
|
||||
has no rules for stays plain rather than being coloured by the
|
||||
nearest one's.
|
||||
|
||||
- [ ] **Masks defined relative to each other.** Wanted: mask A multiplies
|
||||
by something *and also* applies mask B — a mask can reference a parent
|
||||
@@ -570,6 +579,48 @@ agent ticks it here with the evidence.
|
||||
everything, the same way input is**. Whatever the mechanism, a widget
|
||||
that does not animate must pay nothing and import nothing for it.
|
||||
|
||||
## Found by P1a (2026-09-06)
|
||||
|
||||
- [x] **`Rect` claimed to be size-independent, and it is not.** A `Rect`
|
||||
fills whatever region it is handed, so `draw_inner`'s size-independent
|
||||
fast path -- which rewrites primitives with
|
||||
`r.outside(&from).within(®ion)` rather than redrawing -- could not
|
||||
reproduce its `draw`, and a `.background(rect(..))` kept the size of
|
||||
the *provisional* full-region pass `Span` does in phase 1. One fenced
|
||||
code block's panel covered every block below it and every row below
|
||||
that. Fixed in `iris/src/widget/rect.rs`; the reason is written at the
|
||||
definition. Suspect the same cause for anything else tinted with a
|
||||
background rect.
|
||||
- [ ] **A wrapped transcript row trips `reposition`'s debug assert.**
|
||||
*"widget ... is both moved by its parent's own layout (`mov`) and
|
||||
repositioned within it"*, raised from `List::place`. Repro: change
|
||||
`.wrap(!verbatim)` to `.wrap(true)` in `transcript-ui/src/row.rs`'s
|
||||
`build_block` and run `iris/run-headless.sh transcript --shot
|
||||
/tmp/x.png -- -p transcript-ui`. Survives the `Rect` fix above and is
|
||||
not specific to any block kind -- it appears once the row is tall
|
||||
enough. The shipping configuration does not reach it (verbatim blocks
|
||||
do not wrap) and the Android bench runs clean with assertions live,
|
||||
but it is a real disagreement about who owns a widget's move slot and
|
||||
should be settled before more of P1 leans on `List`.
|
||||
- [ ] **Desktop colours are washed out: the winit surface is sRGB and
|
||||
the shader writes the palette's bytes as linear.** Mocha Crust
|
||||
(17,17,27) is drawn as (73,73,91), measured off
|
||||
`run-headless.sh --shot`. Android is correct, so this is the surface
|
||||
format rather than the palette -- but it makes the desktop build
|
||||
useless as a colour reference, which is exactly what P1a needed it for
|
||||
when the emulator could not draw glyphs.
|
||||
- [ ] **The emulator cannot draw iris's glyphs.** Under `-gpu host` with
|
||||
Vulkan disabled (Mesa 26.2.2 / virgl -- what `emu` does on this
|
||||
machine) every character renders as a solid filled box: the atlas
|
||||
sample's alpha reads 1, which is what an incomplete GL texture returns
|
||||
(0,0,0,1). Not new (`20303e0` does it too) and not the platform's
|
||||
(Compose draws text perfectly on the same AVD in the same minute).
|
||||
Enabling host Vulkan still dies at boot in gfxstream, and
|
||||
`EMU_GPU=software` gives SwiftShader Vulkan on which iris **SIGSEGVs
|
||||
in `surface_changed`**. Either of the last two would restore
|
||||
appearance testing on Android; today it has to be done on the desktop
|
||||
backend or on Iris's phone.
|
||||
|
||||
## Build (for the port)
|
||||
|
||||
Widgets `RUST.md`'s "The port, in order (decided 2026-09-05)" needs and
|
||||
|
||||
+135
-11
@@ -5465,17 +5465,141 @@ device.
|
||||
comparison fair. **Sub-order, decided by the design agent, by what
|
||||
the bench fixture exercises and Compose already draws** (each is
|
||||
one agent; tick and date in place):
|
||||
- [ ] **P1a — markdown block rendering parity.** Now that a row is
|
||||
a column of per-block widgets (`e1030d6`), draw each block
|
||||
the way Compose does: headings at their sizes, fences in a
|
||||
mono face with `client-core`'s `highlight` spans and a
|
||||
distinct background, bullet/numbered lists with indent,
|
||||
tables as aligned columns, block quotes, links as tappable
|
||||
spans (opening through the platform — the "tappable link"
|
||||
primitive in IRIS_TODO's "Build (for the port)"). Against
|
||||
`app/bench-fixture/`, screenshot beside the Compose bench
|
||||
build on the same emulator. Pure parts (block → style
|
||||
mapping) tested in `client-core`/`transcript-ui`.
|
||||
- [x] **P1a — markdown block rendering parity.** Done 2026-09-06.
|
||||
Each top-level block is drawn in one of **three frames**
|
||||
(`transcript-ui::markdown::BlockFrame`, mapped from
|
||||
`BlockKind` by the pure `frame_of`): `Plain` (a paragraph,
|
||||
heading, list or rule -- text and spans, no extra widget),
|
||||
`Verbatim { fill }` (a fence or a table -- a rounded panel
|
||||
that does not wrap and pans sideways, `CodeFence.kt`'s
|
||||
`horizontalScroll`), and `Quote` (a bar behind text padded
|
||||
past it). Everything else markdown can say is expressed in
|
||||
`SpanStyle`s, which cost no widgets.
|
||||
**What each block looks like now, against `Markdown.kt`:**
|
||||
- *Headings* -- Material's own ladder, the six sizes
|
||||
`markdownTypography` picks (24/22/16/14/12/11 at a 16pt
|
||||
body), bold. Was a three-step 28/24/21/19.
|
||||
- *Fences* -- monospace on Mocha Crust, rounded, with
|
||||
`client_core::highlight`'s spans by language in the same
|
||||
Catppuccin palette `Theme.kt`'s `catppuccinSyntax()` uses.
|
||||
An unknown language is plain rather than coloured by the
|
||||
nearest one. A fence being streamed into re-renders only
|
||||
the last block (`RowBlocks::apply_delta`), so earlier
|
||||
fences are never re-scanned.
|
||||
- *Lists* -- the bullet ladder `MarkdownPieces.kt` draws
|
||||
(disc/ring/square by depth) and ordered lists counting from
|
||||
the number written, markers in Lavender.
|
||||
- *Tables* -- padded monospace columns measured from the
|
||||
cells, header bold, a rule under it, on Surface 0. A real
|
||||
grid was rejected; docs/DECISIONS.md, 2026-09-06, has why.
|
||||
- *Quotes* -- a Surface 2 bar down the left, text one shade
|
||||
back from body.
|
||||
- *Links* -- coloured and underlined as before, and now
|
||||
**tappable**: `GestureOutcome::Tapped` (a press that
|
||||
committed to neither a pan nor a selection),
|
||||
`TextEditCtx::byte_at` for which byte, and
|
||||
`iris::platform::OpenUrl` for the platform (`xdg-open`/
|
||||
`open`/`start`; on Android an `ACTION_VIEW` intent deferred
|
||||
to `after_input`, the shape `pending_show_keyboard` uses).
|
||||
**Screenshots: `docs/bench/p1a-2026-09-06/`.**
|
||||
`compose-heading-fence-table.png` and
|
||||
`compose-fence-table.png` are the Compose `bench` build on
|
||||
this checkout's AVD against `app/bench-fixture/`;
|
||||
`iris-blocks.png` is iris rendering the same heading,
|
||||
paragraph, link, fence and table source (plus a list and a
|
||||
quote, which the fixture has neither of) from
|
||||
`transcript-ui`'s own `transcript` example.
|
||||
**Why the iris half is not from the emulator**, which the
|
||||
pass condition asked for: **the emulator cannot draw iris's
|
||||
glyphs at all.** Every character comes out as a solid filled
|
||||
box of the right width -- `iris-emulator-gles-glyphs.png`.
|
||||
Established as *not* this change's doing and not the app's:
|
||||
the previous commit (`20303e0`) draws the same boxes, and the
|
||||
Compose bench build on the same AVD in the same minute draws
|
||||
text perfectly. The atlas sample's alpha reads as 1 under
|
||||
`-gpu host` + `-feature -Vulkan` (Mesa 26.2.2 / virgl), which
|
||||
is what an *incomplete* GL texture returns (0,0,0,1). Both
|
||||
ways out were tried and both fail: `GPU_HOST_FEATURES=" "`
|
||||
still dies at boot with gfxstream's documented "Format
|
||||
VK_FORMAT_R8G8B8A8_UNORM is not supported ... Failed to find
|
||||
memory type for ColorBuffers", and `EMU_GPU=software` does
|
||||
give the guest SwiftShader Vulkan but iris **SIGSEGVs inside
|
||||
`surface_changed`** on it. So the appearance half of this box
|
||||
is taken on the desktop/winit backend, which renders on the
|
||||
host's real GPU through `iris/run-headless.sh`.
|
||||
**What still differs, pair by pair:**
|
||||
1. *Colour, on the desktop shot only.* The winit surface is
|
||||
sRGB and the shader writes the palette's bytes as linear,
|
||||
so every fill reads ~4x lighter: Crust (17,17,27) comes out
|
||||
(73,73,91), measured. Not a palette error and not present
|
||||
on Android, where the previous pass measured the composer
|
||||
bar at rgb(41,40,49) for a declared (40,40,46). Worth its
|
||||
own item; it makes the desktop build a poor colour
|
||||
reference until fixed.
|
||||
2. *A list's wrapped line.* Compose lays an item out as a
|
||||
marker column beside a text column, so a second line stays
|
||||
indented; iris writes the marker into the same buffer, so
|
||||
a wrapped line returns to the left margin. Needs per-line
|
||||
indent in `TextAttrs`.
|
||||
3. *A table.* Compose draws a real grid, cells wrapping at a
|
||||
136dp floor; iris draws padded monospace columns. Same
|
||||
information, different picture.
|
||||
4. *Inline code.* Compose draws a chip behind it; iris gives
|
||||
the range a monospace face and the code colour. Unchanged
|
||||
by this box -- still blocked on per-range glyph geometry
|
||||
(IRIS_TODO).
|
||||
5. *A user message.* Compose draws it in a rounded card;
|
||||
iris draws a sender label above plain text. That is the
|
||||
row's own styling, P1's rather than P1a's.
|
||||
**One real defect found and fixed on the way**, and it is
|
||||
not a small one: **`Rect::is_size_independent()` answered
|
||||
`true`.** A `Rect` fills whatever region it is given, so its
|
||||
content *is* the region -- and `draw_inner`'s
|
||||
size-independent fast path, which rewrites a widget's
|
||||
primitives with `r.outside(&from).within(®ion)` instead of
|
||||
redrawing, cannot reproduce that remap once a region carries
|
||||
both `rel` and `abs`. The visible result: a fenced block's
|
||||
background kept the height of the **provisional full-region
|
||||
draw** `Span` does in its first phase, so one fence's panel
|
||||
covered every block below it *and every row below that*,
|
||||
while the text underneath was laid out correctly. It answers
|
||||
`false` now (`iris/src/widget/rect.rs`, with the account at
|
||||
the definition). This is very likely the same family as this
|
||||
file's older "the composer bar's grey background is not
|
||||
drawn" note and any other `.background(rect(..))` tint.
|
||||
**One defect found and left open**, with its repro:
|
||||
`UiRenderState::reposition`'s debug assert -- *"widget ... is
|
||||
both moved by its parent's own layout (`mov`) and
|
||||
repositioned within it"* -- fires from `List::place` when a
|
||||
transcript row's blocks **wrap**. Reproduce in one line:
|
||||
change `.wrap(!verbatim)` to `.wrap(true)` in
|
||||
`transcript-ui/src/row.rs`'s `build_block` and run
|
||||
`iris/run-headless.sh transcript --shot /tmp/x.png -- -p
|
||||
transcript-ui`. It is *not* caused by the `Rect` fix above
|
||||
(it survives it) and not by any one block kind (bisected: it
|
||||
appears once the row is tall enough). The shipping
|
||||
configuration does not reach it -- verbatim blocks do not
|
||||
wrap -- and neither does the Android bench, which ran clean
|
||||
with the assertions live. It should be the next thing looked
|
||||
at under P1, because it is a real inconsistency about who
|
||||
owns a widget's move slot, not a false alarm.
|
||||
**Bench, stream phase, this checkout's AVD, debug x86_64
|
||||
`force-gles`, assertions live, no abort:**
|
||||
`stream: 294 frames over 21.0s, late 283 (96.3%), p50 53.0ms
|
||||
p90 108.6ms p99 132.0ms` against the pre-P1a
|
||||
`p50 52.8ms p90 108.1ms p99 137.3ms` -- unchanged, which is
|
||||
the point: block styling is span work, not layout work. The
|
||||
`worst` figure is the one number that moved and it does not
|
||||
reproduce: 567.3ms, 140.1ms and 664.5ms across three runs of
|
||||
the same build, against 148.9ms before. Unexplained; it is a
|
||||
single frame in 294 and the percentiles are flat, so it reads
|
||||
as an emulator hiccup rather than a cost, but it is written
|
||||
down rather than rounded off.
|
||||
**Checks**: `cargo fmt --all --check` clean in both
|
||||
workspaces; `cargo clippy -p iris -p iris-core -p
|
||||
transcript-ui -p desktop-app -p tabs-ui --all-targets`
|
||||
warning-free; `cargo test` 85 (iris, +4) + 13 (iris-core) +
|
||||
31 (transcript-ui, +11) + 123 (client-core).
|
||||
- [ ] **P1b — tool-call cards and grouping.** `ToolRows.kt`/
|
||||
`ToolInput.kt`'s cards: a collapsed row per call with name
|
||||
and a one-line summary, expand to input and output, runs of
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 198 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Reference in new issue
Block a user