Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69525bd131 | ||
|
|
64f64b54e5 |
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
|
for iris API changes); this file is only the summary. Newest first. Items
|
||||||
marked **DEFERRED** are ones the agent chose not to decide alone.
|
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)
|
## 2026-09-06 (composer scroll and the streaming block model)
|
||||||
|
|
||||||
- **A streamed message becomes a column of per-block widgets.** Decided by
|
- **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
|
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-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
|
## 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.
|
`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
|
`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
|
stable per row (its sender + a short excerpt, matching what a screen
|
||||||
reader announcing a chat message would say).
|
reader announcing a chat message would say).
|
||||||
- [ ] **A tappable link and a background chip behind inline code.**
|
- [x] **A tappable link** — done 2026-09-06 (P1a). `TextEditCtx::
|
||||||
Both need per-range glyph geometry that `TextEditCtx` does not expose
|
byte_at(pos, size)` answers which byte a tap landed on without
|
||||||
outside `iris::widget::text` (`edit.rs`'s `layout()` helper is
|
handing out the parley layout, `GestureOutcome::Tapped` says the
|
||||||
private) — see `markdown.rs`'s module doc for the exact shape the fix
|
press committed to neither a pan nor a selection, and
|
||||||
would take (the same primitive `TextEdit::draw`'s own selection
|
`iris::platform::OpenUrl` is the capability each backend implements
|
||||||
highlight already uses internally,
|
(`xdg-open`/`open`/`start`; an `ACTION_VIEW` intent on Android,
|
||||||
`iris/src/widget/text/edit.rs:99`).
|
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
|
- [ ] **`Selection`'s anchor-row shortcut.** The row a drag started in
|
||||||
is selected in full (`select_all`) the moment the drag leaves it,
|
is selected in full (`select_all`) the moment the drag leaves it,
|
||||||
rather than "from the click point to whichever edge points away from
|
rather than "from the click point to whichever edge points away from
|
||||||
the drag" — needs the same private `layout()` access as the item
|
the drag" — needs the same private `layout()` access as the item
|
||||||
above. `selection.rs`'s module doc has the exact reasoning.
|
above. `selection.rs`'s module doc has the exact reasoning.
|
||||||
- [ ] **No syntax highlighting inside a fenced code block.**
|
- [x] **Syntax highlighting inside a fenced code block** — done
|
||||||
`client_core::highlight` exists (built for the file explorer) and
|
2026-09-06 (P1a). `client_core::highlight::spans_of` by language,
|
||||||
could feed per-token `SpanStyle`s into a code block's span; wiring it
|
converted from its char indices to `SpanStyle`'s byte offsets, in
|
||||||
in was not attempted this pass.
|
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
|
- [ ] **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
|
||||||
@@ -570,6 +579,48 @@ agent ticks it here with the evidence.
|
|||||||
everything, the same way input is**. Whatever the mechanism, a widget
|
everything, the same way input is**. Whatever the mechanism, a widget
|
||||||
that does not animate must pay nothing and import nothing for it.
|
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)
|
## Build (for the port)
|
||||||
|
|
||||||
Widgets `RUST.md`'s "The port, in order (decided 2026-09-05)" needs and
|
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
|
comparison fair. **Sub-order, decided by the design agent, by what
|
||||||
the bench fixture exercises and Compose already draws** (each is
|
the bench fixture exercises and Compose already draws** (each is
|
||||||
one agent; tick and date in place):
|
one agent; tick and date in place):
|
||||||
- [ ] **P1a — markdown block rendering parity.** Now that a row is
|
- [x] **P1a — markdown block rendering parity.** Done 2026-09-06.
|
||||||
a column of per-block widgets (`e1030d6`), draw each block
|
Each top-level block is drawn in one of **three frames**
|
||||||
the way Compose does: headings at their sizes, fences in a
|
(`transcript-ui::markdown::BlockFrame`, mapped from
|
||||||
mono face with `client-core`'s `highlight` spans and a
|
`BlockKind` by the pure `frame_of`): `Plain` (a paragraph,
|
||||||
distinct background, bullet/numbered lists with indent,
|
heading, list or rule -- text and spans, no extra widget),
|
||||||
tables as aligned columns, block quotes, links as tappable
|
`Verbatim { fill }` (a fence or a table -- a rounded panel
|
||||||
spans (opening through the platform — the "tappable link"
|
that does not wrap and pans sideways, `CodeFence.kt`'s
|
||||||
primitive in IRIS_TODO's "Build (for the port)"). Against
|
`horizontalScroll`), and `Quote` (a bar behind text padded
|
||||||
`app/bench-fixture/`, screenshot beside the Compose bench
|
past it). Everything else markdown can say is expressed in
|
||||||
build on the same emulator. Pure parts (block → style
|
`SpanStyle`s, which cost no widgets.
|
||||||
mapping) tested in `client-core`/`transcript-ui`.
|
**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`/
|
- [ ] **P1b — tool-call cards and grouping.** `ToolRows.kt`/
|
||||||
`ToolInput.kt`'s cards: a collapsed row per call with name
|
`ToolInput.kt`'s cards: a collapsed row per call with name
|
||||||
and a one-line summary, expand to input and output, runs of
|
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 |
@@ -17,6 +17,7 @@ mod attr;
|
|||||||
mod ime;
|
mod ime;
|
||||||
mod input;
|
mod input;
|
||||||
mod insets;
|
mod insets;
|
||||||
|
mod platform;
|
||||||
mod render;
|
mod render;
|
||||||
mod view;
|
mod view;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
use crate::platform::OpenUrl;
|
||||||
|
use android_view::{
|
||||||
|
View,
|
||||||
|
jni::{
|
||||||
|
JNIEnv,
|
||||||
|
objects::{JObject, JValue},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::view::HasAndroidUiState;
|
||||||
|
|
||||||
|
/// Android's URL opener. Like `FocusHost::focus_gained`'s keyboard, the
|
||||||
|
/// real work is a JNI call and this runs deep inside the sensor dispatch
|
||||||
|
/// with no `CallbackCtx` in reach -- so it raises a flag that
|
||||||
|
/// `IrisViewPeer::after_input` consumes, exactly as
|
||||||
|
/// `pending_show_keyboard` does.
|
||||||
|
///
|
||||||
|
/// Last request wins: two links cannot be tapped in one frame, and a URL
|
||||||
|
/// left queued from a frame that somehow never reached `after_input`
|
||||||
|
/// would open at some unrelated later tap, which is worse than dropping
|
||||||
|
/// it.
|
||||||
|
impl<T: HasAndroidUiState> OpenUrl for T {
|
||||||
|
fn open_url(&mut self, url: &str) {
|
||||||
|
self.android_state_mut().pending_open_url = Some(url.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `startActivity(new Intent(ACTION_VIEW, Uri.parse(url)))` on the view's
|
||||||
|
/// own context.
|
||||||
|
///
|
||||||
|
/// `FLAG_ACTIVITY_NEW_TASK` because the context here is the view's, which
|
||||||
|
/// may be an application context rather than the activity's -- Android
|
||||||
|
/// throws `AndroidRuntimeException` for a non-activity context without it,
|
||||||
|
/// and it is harmless when the context *is* an activity's.
|
||||||
|
///
|
||||||
|
/// Every failure is logged with the URL and returns; there is nothing to
|
||||||
|
/// fall back to, and the reader will see that nothing happened.
|
||||||
|
pub(super) fn open_url<'local>(env: &mut JNIEnv<'local>, view: &View<'local>, url: &str) {
|
||||||
|
match try_open_url(env, view, url) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) => {
|
||||||
|
// A pending Java exception makes every later JNI call fail in
|
||||||
|
// ways nowhere near here, so it is cleared at the boundary.
|
||||||
|
let _ = env.exception_clear();
|
||||||
|
log::warn!("could not open {url}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_open_url<'local>(
|
||||||
|
env: &mut JNIEnv<'local>,
|
||||||
|
view: &View<'local>,
|
||||||
|
url: &str,
|
||||||
|
) -> Result<(), android_view::jni::errors::Error> {
|
||||||
|
let context = env
|
||||||
|
.call_method(&view.0, "getContext", "()Landroid/content/Context;", &[])?
|
||||||
|
.l()?;
|
||||||
|
let jurl = env.new_string(url)?;
|
||||||
|
let uri = env.call_static_method(
|
||||||
|
"android/net/Uri",
|
||||||
|
"parse",
|
||||||
|
"(Ljava/lang/String;)Landroid/net/Uri;",
|
||||||
|
&[JValue::Object(jurl.as_ref())],
|
||||||
|
)?;
|
||||||
|
let action = env.new_string("android.intent.action.VIEW")?;
|
||||||
|
let intent = env.new_object(
|
||||||
|
"android/content/Intent",
|
||||||
|
"(Ljava/lang/String;Landroid/net/Uri;)V",
|
||||||
|
&[JValue::Object(action.as_ref()), JValue::Object(&uri.l()?)],
|
||||||
|
)?;
|
||||||
|
env.call_method(
|
||||||
|
&intent,
|
||||||
|
"addFlags",
|
||||||
|
"(I)Landroid/content/Intent;",
|
||||||
|
&[JValue::Int(FLAG_ACTIVITY_NEW_TASK)],
|
||||||
|
)?;
|
||||||
|
env.call_method(
|
||||||
|
&context,
|
||||||
|
"startActivity",
|
||||||
|
"(Landroid/content/Intent;)V",
|
||||||
|
&[JValue::Object(&JObject::from(intent))],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `android.content.Intent.FLAG_ACTIVITY_NEW_TASK`. A constant rather than
|
||||||
|
/// a static-field read: it is part of the platform's stable ABI and
|
||||||
|
/// reading it costs two more JNI calls that can each fail.
|
||||||
|
const FLAG_ACTIVITY_NEW_TASK: i32 = 0x1000_0000;
|
||||||
@@ -54,6 +54,10 @@ pub struct AndroidUiState {
|
|||||||
/// inside the platform-agnostic sensor dispatch with no `CallbackCtx`
|
/// inside the platform-agnostic sensor dispatch with no `CallbackCtx`
|
||||||
/// in reach.
|
/// in reach.
|
||||||
pub pending_show_keyboard: bool,
|
pub pending_show_keyboard: bool,
|
||||||
|
/// A URL a tapped link asked the platform to open, for the same
|
||||||
|
/// reason `pending_show_keyboard` is a flag rather than a call --
|
||||||
|
/// see `android/platform.rs`.
|
||||||
|
pub pending_open_url: Option<String>,
|
||||||
/// Window insets, filled in from outside the normal `ViewPeer` callback
|
/// Window insets, filled in from outside the normal `ViewPeer` callback
|
||||||
/// path -- see `android/insets.rs` for why they need a registry of
|
/// path -- see `android/insets.rs` for why they need a registry of
|
||||||
/// their own.
|
/// their own.
|
||||||
@@ -113,6 +117,7 @@ impl AndroidUiState {
|
|||||||
last_click: Instant::now(),
|
last_click: Instant::now(),
|
||||||
compose_len: 0,
|
compose_len: 0,
|
||||||
pending_show_keyboard: false,
|
pending_show_keyboard: false,
|
||||||
|
pending_open_url: None,
|
||||||
shared,
|
shared,
|
||||||
access_adapter: Default::default(),
|
access_adapter: Default::default(),
|
||||||
access: AccessTree::new(),
|
access: AccessTree::new(),
|
||||||
@@ -324,6 +329,9 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
|||||||
if std::mem::take(&mut ui_state.pending_show_keyboard) {
|
if std::mem::take(&mut ui_state.pending_show_keyboard) {
|
||||||
show_soft_input(&mut ctx.env, &ctx.view);
|
show_soft_input(&mut ctx.env, &ctx.view);
|
||||||
}
|
}
|
||||||
|
if let Some(url) = ui_state.pending_open_url.take() {
|
||||||
|
super::platform::open_url(&mut ctx.env, &ctx.view, &url);
|
||||||
|
}
|
||||||
|
|
||||||
// RUST.md's P0 box, "doesn't enter it until I hit space, and also
|
// RUST.md's P0 box, "doesn't enter it until I hit space, and also
|
||||||
// doesn't move cursor forward": Gboard needs `updateSelection`
|
// doesn't move cursor forward": Gboard needs `updateSelection`
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ mod access;
|
|||||||
mod app;
|
mod app;
|
||||||
mod attr;
|
mod attr;
|
||||||
mod input;
|
mod input;
|
||||||
|
mod platform;
|
||||||
mod render;
|
mod render;
|
||||||
|
|
||||||
pub use access::*;
|
pub use access::*;
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
use crate::platform::OpenUrl;
|
||||||
|
use crate::prelude::HasDefaultUiState;
|
||||||
|
|
||||||
|
/// The desktop's URL opener: the platform's own "open this with whatever
|
||||||
|
/// is registered for it" command, detached so a browser starting slowly
|
||||||
|
/// cannot stall the event loop.
|
||||||
|
///
|
||||||
|
/// A command rather than a crate: `xdg-open`/`open`/`start` is what every
|
||||||
|
/// such crate shells out to anyway, and this is one call site.
|
||||||
|
impl<T: HasDefaultUiState> OpenUrl for T {
|
||||||
|
fn open_url(&mut self, url: &str) {
|
||||||
|
let (program, first): (&str, &[&str]) = if cfg!(target_os = "macos") {
|
||||||
|
("open", &[])
|
||||||
|
} else if cfg!(target_os = "windows") {
|
||||||
|
// `start` is a shell builtin, and its first argument is the
|
||||||
|
// window title -- an empty one, or a URL containing `&` ends
|
||||||
|
// up split.
|
||||||
|
("cmd", &["/C", "start", ""])
|
||||||
|
} else {
|
||||||
|
("xdg-open", &[])
|
||||||
|
};
|
||||||
|
match std::process::Command::new(program)
|
||||||
|
.args(first)
|
||||||
|
.arg(url)
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Ok(_) => {}
|
||||||
|
// Named with the command that failed and the link it was for,
|
||||||
|
// since neither is recoverable from the OS error alone.
|
||||||
|
Err(e) => log::warn!("could not open {url} with {program}: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ pub mod default;
|
|||||||
|
|
||||||
pub mod attr;
|
pub mod attr;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
|
pub mod platform;
|
||||||
pub mod sense;
|
pub mod sense;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod task;
|
pub mod task;
|
||||||
@@ -47,6 +48,7 @@ pub mod prelude {
|
|||||||
pub use event::*;
|
pub use event::*;
|
||||||
pub use iris_core::*;
|
pub use iris_core::*;
|
||||||
pub use iris_macro::*;
|
pub use iris_macro::*;
|
||||||
|
pub use platform::*;
|
||||||
pub use sense::*;
|
pub use sense::*;
|
||||||
pub use state::*;
|
pub use state::*;
|
||||||
pub use task::*;
|
pub use task::*;
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
//! Capabilities a widget tree needs from whatever is hosting it, that
|
||||||
|
//! neither iris nor the app can perform itself.
|
||||||
|
//!
|
||||||
|
//! Same shape as [`crate::attr::FocusHost`], and for the same reason: the
|
||||||
|
//! interface is declared here, below, and implemented by each backend
|
||||||
|
//! above (`default/platform.rs`, `android/platform.rs`), so a widget can
|
||||||
|
//! ask for the capability by trait bound instead of a caller threading a
|
||||||
|
//! callback down through every builder.
|
||||||
|
|
||||||
|
/// Hand a URL to whatever the platform opens URLs with.
|
||||||
|
///
|
||||||
|
/// One method rather than a general "run an intent"/"exec" surface: the
|
||||||
|
/// only thing a transcript needs is to follow a link a reader tapped, and
|
||||||
|
/// a narrower capability is a narrower thing to get wrong.
|
||||||
|
///
|
||||||
|
/// **Nothing is reported back.** There is no answer worth branching on --
|
||||||
|
/// the platform either shows a browser or does not, and both are outside
|
||||||
|
/// this process -- so failures are logged where they happen (each impl)
|
||||||
|
/// rather than turned into a `Result` every call site would discard.
|
||||||
|
pub trait OpenUrl {
|
||||||
|
fn open_url(&mut self, url: &str);
|
||||||
|
}
|
||||||
+127
-21
@@ -487,6 +487,12 @@ enum ArbiterState {
|
|||||||
/// caller-supplied `Instant` rather than a real clock.
|
/// caller-supplied `Instant` rather than a real clock.
|
||||||
pub struct DragArbiter {
|
pub struct DragArbiter {
|
||||||
state: ArbiterState,
|
state: ArbiterState,
|
||||||
|
/// Which way a pan runs. A transcript pans down its list and a code
|
||||||
|
/// fence pans across its own long lines, and the two decisions are
|
||||||
|
/// the same one with the axes swapped -- so the axis is a field
|
||||||
|
/// rather than a second copy of this state machine, and everything
|
||||||
|
/// below reads `along`/`across` instead of `dy`/`dx`.
|
||||||
|
axis: Axis,
|
||||||
origin: Vec2,
|
origin: Vec2,
|
||||||
origin_at: Instant,
|
origin_at: Instant,
|
||||||
last: Vec2,
|
last: Vec2,
|
||||||
@@ -494,20 +500,28 @@ pub struct DragArbiter {
|
|||||||
|
|
||||||
impl Default for DragArbiter {
|
impl Default for DragArbiter {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self::on(Axis::Y)
|
||||||
state: ArbiterState::Idle,
|
|
||||||
origin: Vec2::ZERO,
|
|
||||||
origin_at: Instant::now(),
|
|
||||||
last: Vec2::ZERO,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DragArbiter {
|
impl DragArbiter {
|
||||||
|
/// A vertical arbiter -- what a list, and every caller before the
|
||||||
|
/// axis became a field, wants.
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An arbiter whose pan runs along `axis`.
|
||||||
|
pub fn on(axis: Axis) -> Self {
|
||||||
|
Self {
|
||||||
|
state: ArbiterState::Idle,
|
||||||
|
axis,
|
||||||
|
origin: Vec2::ZERO,
|
||||||
|
origin_at: Instant::now(),
|
||||||
|
last: Vec2::ZERO,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A fresh press-down at `pos`. `already_selected` is whatever the
|
/// A fresh press-down at `pos`. `already_selected` is whatever the
|
||||||
/// caller's selection state was *before* this press -- it decides
|
/// caller's selection state was *before* this press -- it decides
|
||||||
/// whether an early horizontal move extends that selection instead of
|
/// whether an early horizontal move extends that selection instead of
|
||||||
@@ -548,25 +562,25 @@ impl DragArbiter {
|
|||||||
match self.state {
|
match self.state {
|
||||||
ArbiterState::Idle => DragOutcome::Undecided,
|
ArbiterState::Idle => DragOutcome::Undecided,
|
||||||
ArbiterState::Panning => {
|
ArbiterState::Panning => {
|
||||||
let dy = pos.y - self.last.y;
|
let along = pos.axis(self.axis) - self.last.axis(self.axis);
|
||||||
self.last = pos;
|
self.last = pos;
|
||||||
DragOutcome::Pan(dy)
|
DragOutcome::Pan(along)
|
||||||
}
|
}
|
||||||
ArbiterState::Selecting => {
|
ArbiterState::Selecting => {
|
||||||
self.last = pos;
|
self.last = pos;
|
||||||
DragOutcome::SelectExtend
|
DragOutcome::SelectExtend
|
||||||
}
|
}
|
||||||
ArbiterState::Undecided { already_selected } => {
|
ArbiterState::Undecided { already_selected } => {
|
||||||
let dx = pos.x - self.origin.x;
|
let along = pos.axis(self.axis) - self.origin.axis(self.axis);
|
||||||
let dy = pos.y - self.origin.y;
|
let across = pos.axis(!self.axis) - self.origin.axis(!self.axis);
|
||||||
if already_selected && dx.abs() > DRAG_SLOP && dx.abs() > dy.abs() {
|
if already_selected && across.abs() > DRAG_SLOP && across.abs() > along.abs() {
|
||||||
self.state = ArbiterState::Selecting;
|
self.state = ArbiterState::Selecting;
|
||||||
self.last = pos;
|
self.last = pos;
|
||||||
DragOutcome::SelectExtend
|
DragOutcome::SelectExtend
|
||||||
} else if dy.abs() > DRAG_SLOP && dy.abs() >= dx.abs() {
|
} else if along.abs() > DRAG_SLOP && along.abs() >= across.abs() {
|
||||||
self.state = ArbiterState::Panning;
|
self.state = ArbiterState::Panning;
|
||||||
self.last = pos;
|
self.last = pos;
|
||||||
// `dy` here is the *whole* drag since `press_start`,
|
// `along` here is the *whole* drag since `press_start`,
|
||||||
// not since the last frame -- nothing panned while
|
// not since the last frame -- nothing panned while
|
||||||
// `Undecided` was withholding the slop, so applying it
|
// `Undecided` was withholding the slop, so applying it
|
||||||
// in full on this one frame is a visible jump the
|
// in full on this one frame is a visible jump the
|
||||||
@@ -584,10 +598,10 @@ impl DragArbiter {
|
|||||||
// `ViewConfiguration.getScaledTouchSlop()` once from
|
// `ViewConfiguration.getScaledTouchSlop()` once from
|
||||||
// the first scroll past it rather than replaying the
|
// the first scroll past it rather than replaying the
|
||||||
// whole pre-threshold drag in one step.
|
// whole pre-threshold drag in one step.
|
||||||
DragOutcome::Pan(dy - DRAG_SLOP.copysign(dy))
|
DragOutcome::Pan(along - DRAG_SLOP.copysign(along))
|
||||||
} else if now.duration_since(self.origin_at) >= LONG_PRESS
|
} else if now.duration_since(self.origin_at) >= LONG_PRESS
|
||||||
&& dx.abs() <= DRAG_SLOP
|
&& across.abs() <= DRAG_SLOP
|
||||||
&& dy.abs() <= DRAG_SLOP
|
&& along.abs() <= DRAG_SLOP
|
||||||
{
|
{
|
||||||
self.state = ArbiterState::Selecting;
|
self.state = ArbiterState::Selecting;
|
||||||
self.last = pos;
|
self.last = pos;
|
||||||
@@ -613,6 +627,15 @@ impl DragArbiter {
|
|||||||
pub fn is_panning(&self) -> bool {
|
pub fn is_panning(&self) -> bool {
|
||||||
matches!(self.state, ArbiterState::Panning)
|
matches!(self.state, ArbiterState::Panning)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a press is in flight that has committed to neither a pan
|
||||||
|
/// nor a selection -- what a release checks to tell a **tap** from
|
||||||
|
/// the end of a drag. A tap is exactly "pressed and let go without
|
||||||
|
/// ever deciding", so it is read here rather than timed separately:
|
||||||
|
/// one gesture machine, one answer.
|
||||||
|
pub fn is_undecided(&self) -> bool {
|
||||||
|
matches!(self.state, ArbiterState::Undecided { .. })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a [`DragGesture`] decided this frame -- [`DragOutcome`] plus the
|
/// What a [`DragGesture`] decided this frame -- [`DragOutcome`] plus the
|
||||||
@@ -626,6 +649,14 @@ pub enum GestureOutcome {
|
|||||||
Pan(f32),
|
Pan(f32),
|
||||||
SelectStart,
|
SelectStart,
|
||||||
SelectExtend,
|
SelectExtend,
|
||||||
|
/// The press ended without ever committing to a pan or a selection --
|
||||||
|
/// a tap. Distinct from `Released(None)`, which is the end of a
|
||||||
|
/// gesture that *did* commit (a selection, or a pan too slow to
|
||||||
|
/// fling): a caller acting on a tap -- following a markdown link --
|
||||||
|
/// must not also act when the finger was panning the list past that
|
||||||
|
/// link, which is the tap-vs-drag rule this enum exists to state
|
||||||
|
/// once for every caller rather than per widget.
|
||||||
|
Tapped,
|
||||||
/// The drag ended -- `PressEnd` or the capture's own terminal `Drop`.
|
/// The drag ended -- `PressEnd` or the capture's own terminal `Drop`.
|
||||||
/// `Some(velocity)` only if the gesture had committed to panning
|
/// `Some(velocity)` only if the gesture had committed to panning
|
||||||
/// (never a tap, a long-press selection, or one still `Undecided`);
|
/// (never a tap, a long-press selection, or one still `Undecided`);
|
||||||
@@ -661,8 +692,13 @@ impl Default for DragGesture {
|
|||||||
|
|
||||||
impl DragGesture {
|
impl DragGesture {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
|
Self::on(Axis::Y)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A gesture whose pan runs along `axis` -- see [`DragArbiter::on`].
|
||||||
|
pub fn on(axis: Axis) -> Self {
|
||||||
Self {
|
Self {
|
||||||
arbiter: DragArbiter::new(),
|
arbiter: DragArbiter::on(axis),
|
||||||
velocity: VelocityTracker::new(),
|
velocity: VelocityTracker::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -700,14 +736,16 @@ impl DragGesture {
|
|||||||
self.dispatch(render, id, pos_window, now)
|
self.dispatch(render, id, pos_window, now)
|
||||||
}
|
}
|
||||||
CursorSense::Drop | CursorSense::PressEnd(_) => {
|
CursorSense::Drop | CursorSense::PressEnd(_) => {
|
||||||
let released = if self.arbiter.is_panning() {
|
let outcome = if self.arbiter.is_panning() {
|
||||||
Some(self.velocity.velocity())
|
GestureOutcome::Released(Some(self.velocity.velocity()))
|
||||||
|
} else if self.arbiter.is_undecided() {
|
||||||
|
GestureOutcome::Tapped
|
||||||
} else {
|
} else {
|
||||||
None
|
GestureOutcome::Released(None)
|
||||||
};
|
};
|
||||||
self.arbiter.release();
|
self.arbiter.release();
|
||||||
render.release_pointer();
|
render.release_pointer();
|
||||||
GestureOutcome::Released(released)
|
outcome
|
||||||
}
|
}
|
||||||
// See `DragArbiter::update`'s own doc: a `Pressing` frame can
|
// See `DragArbiter::update`'s own doc: a `Pressing` frame can
|
||||||
// arrive with no matching `PressStart` if the touch-down
|
// arrive with no matching `PressStart` if the touch-down
|
||||||
@@ -1292,6 +1330,74 @@ mod drag_arbiter_tests {
|
|||||||
assert!(!a.is_idle());
|
assert!(!a.is_idle());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The tap-vs-drag rule a markdown link is followed by
|
||||||
|
/// (`transcript-ui`'s `row.rs`): a press that never committed is a
|
||||||
|
/// tap, and a press that panned or selected is not -- read from this
|
||||||
|
/// one machine rather than timed a second time beside it.
|
||||||
|
#[test]
|
||||||
|
fn a_press_that_never_moved_is_still_undecided_at_release() {
|
||||||
|
let mut a = DragArbiter::new();
|
||||||
|
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
|
||||||
|
a.update(Vec2::new(1.0, 1.0), t(10));
|
||||||
|
assert!(a.is_undecided());
|
||||||
|
assert!(!a.is_panning());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The half the tap rule had no reason to touch: a gesture that
|
||||||
|
/// panned must not also read as a tap when the finger comes up over
|
||||||
|
/// the link it started on.
|
||||||
|
#[test]
|
||||||
|
fn a_press_that_panned_is_not_undecided_at_release() {
|
||||||
|
let mut a = DragArbiter::new();
|
||||||
|
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
|
||||||
|
a.update(Vec2::new(0.0, 40.0), t(10));
|
||||||
|
assert!(a.is_panning());
|
||||||
|
assert!(!a.is_undecided());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A long press that grew a selection is not a tap either.
|
||||||
|
#[test]
|
||||||
|
fn a_long_press_that_selected_is_not_undecided() {
|
||||||
|
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, 1.0), t(LONG_PRESS.as_millis() as u64 + 10)),
|
||||||
|
DragOutcome::SelectStart
|
||||||
|
);
|
||||||
|
assert!(!a.is_undecided());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Both axes are one machine with the axis passed in: a horizontal
|
||||||
|
/// arbiter (a code fence panning across its own long lines) pans on
|
||||||
|
/// exactly the drag a vertical one ignores, and ignores the one it
|
||||||
|
/// pans on.
|
||||||
|
#[test]
|
||||||
|
fn a_horizontal_arbiter_pans_on_the_drag_a_vertical_one_ignores() {
|
||||||
|
let mut across = DragArbiter::on(Axis::X);
|
||||||
|
across.press_start(Vec2::new(0.0, 0.0), t(0), false);
|
||||||
|
assert_eq!(
|
||||||
|
across.update(Vec2::new(20.0, 0.0), t(10)),
|
||||||
|
DragOutcome::Pan(12.0)
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut down = DragArbiter::new();
|
||||||
|
down.press_start(Vec2::new(0.0, 0.0), t(0), false);
|
||||||
|
assert_eq!(
|
||||||
|
down.update(Vec2::new(20.0, 0.0), t(10)),
|
||||||
|
DragOutcome::Undecided
|
||||||
|
);
|
||||||
|
|
||||||
|
// ...and a vertical drag over the horizontal one stays undecided,
|
||||||
|
// which is what lets the list behind a code fence still be
|
||||||
|
// panned by a finger that started on the fence.
|
||||||
|
let mut across = DragArbiter::on(Axis::X);
|
||||||
|
across.press_start(Vec2::new(0.0, 0.0), t(0), false);
|
||||||
|
assert_eq!(
|
||||||
|
across.update(Vec2::new(0.0, 20.0), t(10)),
|
||||||
|
DragOutcome::Undecided
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn release_resets_to_idle() {
|
fn release_resets_to_idle() {
|
||||||
let mut a = DragArbiter::new();
|
let mut a = DragArbiter::new();
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ impl Scroll {
|
|||||||
snap_end: true,
|
snap_end: true,
|
||||||
container_len: 0.0,
|
container_len: 0.0,
|
||||||
content_len: 0.0,
|
content_len: 0.0,
|
||||||
gesture: DragGesture::new(),
|
gesture: DragGesture::on(axis),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +138,7 @@ impl Scroll {
|
|||||||
// the content follows the finger.
|
// the content follows the finger.
|
||||||
GestureOutcome::Pan(dy) => self.scroll(dy),
|
GestureOutcome::Pan(dy) => self.scroll(dy),
|
||||||
GestureOutcome::Undecided
|
GestureOutcome::Undecided
|
||||||
|
| GestureOutcome::Tapped
|
||||||
| GestureOutcome::SelectStart
|
| GestureOutcome::SelectStart
|
||||||
| GestureOutcome::SelectExtend
|
| GestureOutcome::SelectExtend
|
||||||
| GestureOutcome::Released(_) => {}
|
| GestureOutcome::Released(_) => {}
|
||||||
|
|||||||
+37
-6
@@ -3,7 +3,13 @@ use crate::prelude::*;
|
|||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
pub struct Rect {
|
pub struct Rect {
|
||||||
pub color: UiColor,
|
pub color: UiColor,
|
||||||
pub radius: f32,
|
/// A `Len` rather than a raw `f32` so a corner can be written in `dp`
|
||||||
|
/// and come out the same physical size on every display -- resolved
|
||||||
|
/// against `Painter::density` in [`Rect::draw`], the same place every
|
||||||
|
/// other `dp` is resolved. A plain number still works and still means
|
||||||
|
/// physical pixels (`impl<N: UiNum> From<N> for Len`), which is what
|
||||||
|
/// a hairline wants.
|
||||||
|
pub radius: Len,
|
||||||
pub thickness: f32,
|
pub thickness: f32,
|
||||||
pub inner_radius: f32,
|
pub inner_radius: f32,
|
||||||
}
|
}
|
||||||
@@ -12,7 +18,7 @@ impl Rect {
|
|||||||
pub fn new(color: UiColor) -> Self {
|
pub fn new(color: UiColor) -> Self {
|
||||||
Self {
|
Self {
|
||||||
color,
|
color,
|
||||||
radius: 0.0,
|
radius: Len::ZERO,
|
||||||
inner_radius: 0.0,
|
inner_radius: 0.0,
|
||||||
thickness: 0.0,
|
thickness: 0.0,
|
||||||
}
|
}
|
||||||
@@ -21,8 +27,8 @@ impl Rect {
|
|||||||
self.color = color;
|
self.color = color;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
pub fn radius(mut self, radius: impl UiNum) -> Self {
|
pub fn radius(mut self, radius: impl Into<Len>) -> Self {
|
||||||
self.radius = radius.to_f32();
|
self.radius = radius.into();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,15 +37,40 @@ impl Widget for Rect {
|
|||||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||||
painter.primitive(RectPrimitive {
|
painter.primitive(RectPrimitive {
|
||||||
color: self.color,
|
color: self.color,
|
||||||
radius: self.radius,
|
// `rel` has no meaning for a corner (a rect that fills its
|
||||||
|
// parent has no length of its own to take a fraction of), so
|
||||||
|
// only the `abs`/`dp` halves are folded.
|
||||||
|
radius: self.radius.fold_dp(painter.density()).abs,
|
||||||
thickness: self.thickness,
|
thickness: self.thickness,
|
||||||
inner_radius: self.inner_radius,
|
inner_radius: self.inner_radius,
|
||||||
});
|
});
|
||||||
Size::REST // fills whatever it was given -- used == available
|
Size::REST // fills whatever it was given -- used == available
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **No** -- despite drawing one primitive and nothing else.
|
||||||
|
///
|
||||||
|
/// `is_size_independent` asks whether the widget's *content* is
|
||||||
|
/// unaffected by how big a region it was given, so that
|
||||||
|
/// `draw_inner` may keep the primitives it already has and rewrite
|
||||||
|
/// their regions in place. A `Rect`'s content **is** its region: it
|
||||||
|
/// returns `Size::REST` and fills whatever it was handed, so the fast
|
||||||
|
/// path's `r.outside(&from).within(®ion)` remap has to reproduce
|
||||||
|
/// the whole of `draw` -- and it does not, because a region carries
|
||||||
|
/// `rel` and `abs` components that the round trip cannot recover
|
||||||
|
/// separately.
|
||||||
|
///
|
||||||
|
/// What that looked like: a fenced code block's background
|
||||||
|
/// (`transcript-ui`'s `BlockFrame::Verbatim`, a `Rect` behind a
|
||||||
|
/// `Pad` in a `Stack`) 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 itself was laid out correctly. Visible in
|
||||||
|
/// `docs/bench/p1a-2026-09-06/`'s history and reproduced by this
|
||||||
|
/// crate's `transcript` example. Answering `false` costs a redraw of
|
||||||
|
/// one primitive when a rect is resized, which is what the fast path
|
||||||
|
/// was saving.
|
||||||
fn is_size_independent(&self) -> bool {
|
fn is_size_independent(&self) -> bool {
|
||||||
true // content never depends on region size
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -364,6 +364,29 @@ impl<'a> TextEditCtx<'a> {
|
|||||||
self.set_caret(index);
|
self.set_caret(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The byte offset in the text that `pos` (in the same window-space
|
||||||
|
/// coordinates a `CursorSense` reports, with `size` the region the
|
||||||
|
/// event was measured against) lands on.
|
||||||
|
///
|
||||||
|
/// The one thing a caller outside this module needs to turn a tap into
|
||||||
|
/// a *range* of the text -- which markdown link is under the finger,
|
||||||
|
/// which inline-code chip was pressed. `layout()` is private because a
|
||||||
|
/// caller holding a parley `Layout` could shape it against stale text;
|
||||||
|
/// this hands back the answer rather than the layout, and does the
|
||||||
|
/// same region-relative transform [`select`](Self::select) does, so
|
||||||
|
/// the two cannot disagree about where a point is.
|
||||||
|
///
|
||||||
|
/// Parley clamps a point outside the laid-out text to the nearest
|
||||||
|
/// cursor position, so a tap in the field's padding answers with the
|
||||||
|
/// nearest offset rather than failing -- a caller wanting "was this
|
||||||
|
/// actually *on* something" checks its own ranges, which is what
|
||||||
|
/// makes a tap in the padding hit no link.
|
||||||
|
pub fn byte_at(&mut self, pos: Vec2, size: Vec2) -> usize {
|
||||||
|
let pos = pos - self.text.region().top_left().to_abs(size);
|
||||||
|
let layout = self.layout();
|
||||||
|
Selection::from_point(layout, pos.x, pos.y).focus().index()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn select_all(&mut self) {
|
pub fn select_all(&mut self) {
|
||||||
let len = self.text.view.buf.text().len();
|
let len = self.text.view.buf.text().len();
|
||||||
if len == 0 {
|
if len == 0 {
|
||||||
|
|||||||
@@ -85,10 +85,19 @@ widget_trait! {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn scrollable(self) -> impl WidgetIdFn<Rsc, Scroll> where Rsc: HasEvents {
|
fn scrollable(self) -> impl WidgetIdFn<Rsc, Scroll> where Rsc: HasEvents {
|
||||||
|
self.scrollable_on(Axis::Y)
|
||||||
|
}
|
||||||
|
|
||||||
|
// `scrollable` along `axis`. A code fence pans across its own long
|
||||||
|
// lines exactly the way a transcript pans down its rows, so the two
|
||||||
|
// are one function with the axis passed in rather than a second copy
|
||||||
|
// -- `DragArbiter::on` is the other half. (A `///` doc comment here
|
||||||
|
// is not accepted by `widget_trait!`, which parses its body itself.)
|
||||||
|
fn scrollable_on(self, axis: Axis) -> impl WidgetIdFn<Rsc, Scroll> where Rsc: HasEvents {
|
||||||
move |state| {
|
move |state| {
|
||||||
Scroll::new(self.add_strong(state), Axis::Y)
|
Scroll::new(self.add_strong(state), axis)
|
||||||
.on(CursorSense::Scroll, |ctx, rsc| {
|
.on(CursorSense::Scroll, move |ctx, rsc| {
|
||||||
let delta = ctx.data.scroll_delta.y * 50.0;
|
let delta = ctx.data.scroll_delta.axis(axis) * 50.0;
|
||||||
ctx.widget(rsc).scroll(delta);
|
ctx.widget(rsc).scroll(delta);
|
||||||
})
|
})
|
||||||
// A finger drag, through the same `DragGesture` the
|
// A finger drag, through the same `DragGesture` the
|
||||||
|
|||||||
@@ -91,14 +91,48 @@ fn synthetic_rows() -> Vec<FoldedRow> {
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
msg(6, true, "Looks good, thanks!"),
|
msg(6, true, "Looks good, thanks!"),
|
||||||
msg(
|
// Every block kind `client_core::markdown_blocks` names, in one
|
||||||
7,
|
// row, so P1a's appearance can be looked at against the Compose
|
||||||
false,
|
// app's without a server (docs/RUST.md's P1a box). The heading,
|
||||||
"You're welcome. Let me know if you'd like anything else.",
|
// paragraph, fence and table are the *same source* the bench
|
||||||
),
|
// fixture carries (`app/bench-fixture/generate.py`), so the two
|
||||||
|
// screenshots differ only in the renderer; the list and the quote
|
||||||
|
// are extra, because the fixture has neither.
|
||||||
|
msg(7, false, BLOCK_SAMPLER),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One of each markdown block, for the P1a screenshot pair. See
|
||||||
|
/// [`synthetic_rows`].
|
||||||
|
const BLOCK_SAMPLER: &str = "\
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
Iris **fold** render measure session window anchor context transcript \
|
||||||
|
iris measure iris scroll call transcript layout *cursor* context, and a \
|
||||||
|
[bench](https://example.com/bench) link.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn fold_event(items: Vec<Item>, seq: u64) -> Vec<Item> {
|
||||||
|
// a comment worth keeping: this is the fold the app's own screen runs
|
||||||
|
let mut out = items;
|
||||||
|
out.push(Item::new(seq));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| column | value |
|
||||||
|
|---|---|
|
||||||
|
| a | measure place draw tool call token context window anchor |
|
||||||
|
|
||||||
|
- one bullet
|
||||||
|
- another, with `inline code`
|
||||||
|
- nested one level
|
||||||
|
1. first numbered
|
||||||
|
2. second numbered
|
||||||
|
|
||||||
|
> A quoted line, to show the bar and the indent.
|
||||||
|
";
|
||||||
|
|
||||||
impl DefaultAppState for Client {
|
impl DefaultAppState for Client {
|
||||||
fn new(
|
fn new(
|
||||||
mut ui_state: DefaultUiState,
|
mut ui_state: DefaultUiState,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! The transcript screen, in iris -- RUST.md's I5. Built the same way
|
//! The transcript screen, in iris -- RUST.md's I5. Built the same way
|
||||||
//! `tabs-ui` is: its own crate, generic over `Rsc: HasEvents` +
|
//! `tabs-ui` is: its own crate, generic over `Rsc: HasEvents` +
|
||||||
//! `Rsc::State: FocusHost`, so the winit example (`iris/examples/
|
//! `Rsc::State: FocusHost + OpenUrl`, so the winit example (`iris/examples/
|
||||||
//! transcript.rs`) and an eventual `iris-android-app`-style cdylib call the
|
//! 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
|
//! 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.
|
//! and is not proved yet, and this doc for the shape.
|
||||||
@@ -83,7 +83,7 @@ impl TranscriptScreen {
|
|||||||
/// newest content when it already was (I3).
|
/// newest content when it already was (I3).
|
||||||
pub fn push_row<Rsc: HasEvents>(&self, rsc: &mut Rsc, row: &FoldedRow)
|
pub fn push_row<Rsc: HasEvents>(&self, rsc: &mut Rsc, row: &FoldedRow)
|
||||||
where
|
where
|
||||||
Rsc::State: FocusHost,
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
{
|
{
|
||||||
let (key, widget, blocks) = row::build_row(rsc, self.list, self.selection.clone(), row);
|
let (key, widget, blocks) = row::build_row(rsc, self.list, self.selection.clone(), row);
|
||||||
(self.list)(rsc).push_back(ListRow::new(key, widget));
|
(self.list)(rsc).push_back(ListRow::new(key, widget));
|
||||||
@@ -97,7 +97,7 @@ impl TranscriptScreen {
|
|||||||
/// `Single`, or a change `RowBlocks::apply_delta` will not take.
|
/// `Single`, or a change `RowBlocks::apply_delta` will not take.
|
||||||
fn apply_tail_delta<Rsc: HasEvents>(&self, rsc: &mut Rsc, key: RowKey, row: &FoldedRow) -> bool
|
fn apply_tail_delta<Rsc: HasEvents>(&self, rsc: &mut Rsc, key: RowKey, row: &FoldedRow) -> bool
|
||||||
where
|
where
|
||||||
Rsc::State: FocusHost,
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
{
|
{
|
||||||
let FoldedRow::Single(item) = row else {
|
let FoldedRow::Single(item) = row else {
|
||||||
return false;
|
return false;
|
||||||
@@ -156,7 +156,7 @@ impl TranscriptScreen {
|
|||||||
old: &[client_core::transcript_fold::TranscriptItem],
|
old: &[client_core::transcript_fold::TranscriptItem],
|
||||||
new: &[client_core::transcript_fold::TranscriptItem],
|
new: &[client_core::transcript_fold::TranscriptItem],
|
||||||
) where
|
) where
|
||||||
Rsc::State: FocusHost,
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
{
|
{
|
||||||
use client_core::transcript_fold::group_tool_runs;
|
use client_core::transcript_fold::group_tool_runs;
|
||||||
|
|
||||||
@@ -246,7 +246,7 @@ pub fn build<Rsc: HasEvents>(
|
|||||||
rows: Vec<FoldedRow>,
|
rows: Vec<FoldedRow>,
|
||||||
) -> TranscriptScreen
|
) -> TranscriptScreen
|
||||||
where
|
where
|
||||||
Rsc::State: FocusHost,
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
{
|
{
|
||||||
let (screen, tree) = build_tree(rsc, rows);
|
let (screen, tree) = build_tree(rsc, rows);
|
||||||
ui_state.set_root(tree);
|
ui_state.set_root(tree);
|
||||||
@@ -265,7 +265,7 @@ pub fn build_tree<Rsc: HasEvents>(
|
|||||||
rows: Vec<FoldedRow>,
|
rows: Vec<FoldedRow>,
|
||||||
) -> (TranscriptScreen, StrongWidget)
|
) -> (TranscriptScreen, StrongWidget)
|
||||||
where
|
where
|
||||||
Rsc::State: FocusHost,
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
{
|
{
|
||||||
let selection = Rc::new(RefCell::new(Selection::new()));
|
let selection = Rc::new(RefCell::new(Selection::new()));
|
||||||
let list = List::new(Axis::Y).add(rsc);
|
let list = List::new(Axis::Y).add(rsc);
|
||||||
@@ -504,6 +504,16 @@ mod apply_tests {
|
|||||||
struct TestFocus {
|
struct TestFocus {
|
||||||
focus: Option<WeakWidget<TextEdit>>,
|
focus: Option<WeakWidget<TextEdit>>,
|
||||||
}
|
}
|
||||||
|
/// The headless stand-in for `iris::platform::OpenUrl`'s real
|
||||||
|
/// backends. Nothing in these tests taps a link -- the tap-vs-drag
|
||||||
|
/// rule that decides whether one is followed is `iris`'s own
|
||||||
|
/// (`sense_tests.rs`'s `a_press_released_without_moving_is_a_tap`),
|
||||||
|
/// and which link is under a byte offset is `markdown.rs`'s -- so
|
||||||
|
/// this only exists to satisfy the bound.
|
||||||
|
impl OpenUrl for TestFocus {
|
||||||
|
fn open_url(&mut self, _url: &str) {}
|
||||||
|
}
|
||||||
|
|
||||||
impl FocusHost for TestFocus {
|
impl FocusHost for TestFocus {
|
||||||
fn recent_click(&mut self) -> bool {
|
fn recent_click(&mut self) -> bool {
|
||||||
false
|
false
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
//! Markdown -> one plain string plus a `Vec<SpanStyle>`, for I5's row
|
//! One markdown **block** (`client_core::markdown_blocks::Block`) rendered
|
||||||
//! builder to hand to a single `TextEdit` (`row.rs`). This is the crate's
|
//! for display: the plain text to draw, the [`SpanStyle`]s that style it,
|
||||||
//! answer to RUST.md's E2 finding against Masonry ("rich inline text --
|
//! the links inside it, and the [`BlockFrame`] the row builder puts around
|
||||||
//! block-level yes, inline no, and both for the same reason": `TextArea`'s
|
//! it.
|
||||||
//! `StyleSet` is one style for the whole editor,
|
//!
|
||||||
|
//! 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`
|
//! `masonry/src/widgets/text_area.rs:43-44`'s `// TODO: RichTextInput`
|
||||||
//! beside it). iris's `SpanStyle` (`core/src/primitive/text.rs`, added for
|
//! beside it). iris's `SpanStyle` (`core/src/primitive/text.rs`) is
|
||||||
//! this box) is per-range, so bold/italic/inline-code/links/headings inside
|
//! per-range, so bold/italic/inline-code/links inside one wrapped
|
||||||
//! one wrapped paragraph render in their own style *and* the paragraph
|
//! paragraph render in their own style *and* the paragraph still wraps and
|
||||||
//! still wraps and selects as one buffer -- there is no second widget per
|
//! selects as one buffer.
|
||||||
//! span the way E2's block-level `Prose`-per-heading was.
|
//!
|
||||||
|
//! **Three widget shapes, not one per markdown feature** ([`BlockFrame`]).
|
||||||
|
//! A heading, a paragraph and a list are all *text with spans*; a fence
|
||||||
|
//! and a table are *verbatim text on a dark surface that pans sideways*;
|
||||||
|
//! a quote is *text behind a coloured bar*. Everything else markdown can
|
||||||
|
//! say is expressed in the spans, which cost no widgets and no layout
|
||||||
|
//! nodes. `app/.../Markdown.kt`'s component table is the reference for the
|
||||||
|
//! sizes and colours; docs/DECISIONS.md's 2026-09-06 entry records where
|
||||||
|
//! this deliberately differs.
|
||||||
//!
|
//!
|
||||||
//! **What this deliberately does not attempt**, each for a reason recorded
|
//! **What this deliberately does not attempt**, each for a reason recorded
|
||||||
//! here rather than silently dropped (see IRIS_TODO.md's dated entries for
|
//! here rather than silently dropped (see IRIS_TODO.md's dated entries for
|
||||||
@@ -16,52 +27,190 @@
|
|||||||
//! - **No background chip behind inline code.** Drawing one needs the
|
//! - **No background chip behind inline code.** Drawing one needs the
|
||||||
//! glyph run's own geometry (the way `TextEdit::draw`'s selection
|
//! glyph run's own geometry (the way `TextEdit::draw`'s selection
|
||||||
//! highlight uses `selection.geometry(layout)`,
|
//! highlight uses `selection.geometry(layout)`,
|
||||||
//! `iris/src/widget/text/edit.rs:99`), which is `TextEdit`-internal and
|
//! `iris/src/widget/text/edit.rs:99`), which is `TextEdit`-internal.
|
||||||
//! not exposed to a caller building spans externally. `SpanStyle` gives
|
//! `SpanStyle` gives the code range a monospace family and the
|
||||||
//! the code range a monospace family and a dimmer text colour instead --
|
//! palette's code colour instead -- visually distinct, just not
|
||||||
//! visually distinct, just not chip-shaped.
|
//! chip-shaped.
|
||||||
//! - **A link is styled (colour + underline) but not tappable.** Following
|
//! - **A list's indent is written in spaces**, not measured. Compose lays
|
||||||
//! it needs the same kind of per-range hit-testing a chip's background
|
//! an item out as a marker column beside a text column, which keeps a
|
||||||
//! would (which byte range did the tap land in, then look up its URL),
|
//! wrapped second line aligned under the first; here the marker is part
|
||||||
//! which is exactly the same missing primitive.
|
//! of the same buffer, so a wrapped line returns to the left margin.
|
||||||
//! - **Tables render as plain paragraphs of their cell text**, no columns.
|
//! Doing better needs per-line indent in `TextAttrs`, which nothing else
|
||||||
//! `pulldown_cmark::Tag::Table` is walked but not laid out -- a real grid
|
//! wants yet.
|
||||||
//! 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
|
//! 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
|
//! `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
|
//! `TextAttrs`), so a heading's own line looks slightly tighter than a
|
||||||
//! paragraph's -- visible, not incorrect, and not fixed here since it needs
|
//! paragraph's -- visible, not incorrect, and not fixed here since it
|
||||||
//! `SpanStyle` to carry line-height too, which nothing in this crate needed
|
//! needs `SpanStyle` to carry line-height too.
|
||||||
//! badly enough yet to justify.
|
|
||||||
|
|
||||||
|
use client_core::highlight::{self, Kind, Language};
|
||||||
|
use client_core::markdown_blocks::{Block, BlockKind};
|
||||||
use iris::prelude::*;
|
use iris::prelude::*;
|
||||||
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
|
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
|
||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
// `UiColor` is `Color<u8>` (`core/src/lib.rs`), not the 0..1 float triples
|
// `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.
|
// its brighter/darker helpers might suggest -- these are plain 0..255 RGB.
|
||||||
pub const CODE_COLOR: UiColor = UiColor::new(140, 217, 242, 255);
|
// Catppuccin Mocha, the same values `app/.../Theme.kt` maps onto
|
||||||
pub const LINK_COLOR: UiColor = UiColor::new(140, 190, 255, 255);
|
// Material's roles, so a block drawn here and the same block drawn by the
|
||||||
const STRIKETHROUGH_COLOR: UiColor = UiColor::new(150, 150, 150, 255);
|
// Compose app are the same colour rather than nearly.
|
||||||
|
const fn mocha(hex: u32) -> UiColor {
|
||||||
|
UiColor::new(
|
||||||
|
((hex >> 16) & 0xff) as u8,
|
||||||
|
((hex >> 8) & 0xff) as u8,
|
||||||
|
(hex & 0xff) as u8,
|
||||||
|
255,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// A block-level separator: two blocks never run into each other with no
|
/// Body text: Mocha Text, the Compose app's `onSurface`.
|
||||||
/// gap, but an empty `out` (the very first block) gets no leading blank.
|
pub const TEXT_COLOR: UiColor = mocha(0xCDD6F4);
|
||||||
|
/// Inline code, and a fence with no language to highlight it by.
|
||||||
|
pub const CODE_COLOR: UiColor = mocha(0xCDD6F4);
|
||||||
|
/// A link. "Blue is what a link is on every Catppuccin surface, and the
|
||||||
|
/// one colour to leave alone" (`Theme.kt`'s `linkColor`).
|
||||||
|
pub const LINK_COLOR: UiColor = mocha(0x89B4FA);
|
||||||
|
/// A list's bullets and numbers: structure rather than words, so the
|
||||||
|
/// items of a list can be counted without reading them (`listMarkerColor`).
|
||||||
|
pub const MARKER_COLOR: UiColor = mocha(0xB4BEFE);
|
||||||
|
/// What every verbatim thing in this app sits on -- Mocha Crust, one step
|
||||||
|
/// *below* the page rather than above it (`Theme.kt`'s `rawSurface`).
|
||||||
|
pub const VERBATIM_BACKGROUND: UiColor = mocha(0x11111B);
|
||||||
|
/// A table's fill: Surface 0, the Compose app's `surfaceVariant`.
|
||||||
|
pub const TABLE_BACKGROUND: UiColor = mocha(0x313244);
|
||||||
|
/// A quote's bar and its text: the bar carries the structure, and the
|
||||||
|
/// words step back one shade from body text so a quote reads as quoted
|
||||||
|
/// without being hard to read.
|
||||||
|
pub const QUOTE_BAR_COLOR: UiColor = mocha(0x585B70);
|
||||||
|
pub const QUOTE_TEXT_COLOR: UiColor = mocha(0xA6ADC8);
|
||||||
|
const STRIKETHROUGH_COLOR: UiColor = mocha(0x6C7086);
|
||||||
|
|
||||||
|
/// Catppuccin Mocha as the highlighter's palette -- the same mapping
|
||||||
|
/// `Theme.kt`'s `catppuccinSyntax()` uses, so a `kotlin` fence is the same
|
||||||
|
/// colours in both apps.
|
||||||
|
fn syntax_color(kind: Kind) -> UiColor {
|
||||||
|
match kind {
|
||||||
|
Kind::Keyword => mocha(0xCBA6F7),
|
||||||
|
Kind::String => mocha(0xA6E3A1),
|
||||||
|
Kind::Literal => mocha(0xFAB387),
|
||||||
|
Kind::Comment => mocha(0x6C7086),
|
||||||
|
Kind::Metadata => mocha(0xF9E2AF),
|
||||||
|
Kind::Punctuation => mocha(0xA6ADC8),
|
||||||
|
Kind::Mark => mocha(0x89DCEB),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a row builder puts *around* a block's text widget. Three, not one
|
||||||
|
/// per markdown feature -- see the module doc.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum BlockFrame {
|
||||||
|
/// Text and nothing else: a paragraph, a heading, a list, a rule.
|
||||||
|
Plain,
|
||||||
|
/// A dark rounded panel whose text does not wrap -- long lines pan
|
||||||
|
/// sideways, the way `CodeFence.kt`'s `horizontalScroll` does. Carries
|
||||||
|
/// its own fill, since a fence and a table are drawn on different
|
||||||
|
/// ones.
|
||||||
|
Verbatim { fill: UiColor },
|
||||||
|
/// A coloured bar down the left edge and an indent past it.
|
||||||
|
Quote,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The frame a block kind is drawn in. Pure, and the *only* place the
|
||||||
|
/// mapping is written: a new `BlockKind` shows up here as a compile error
|
||||||
|
/// rather than silently taking prose's appearance.
|
||||||
|
pub fn frame_of(kind: BlockKind) -> BlockFrame {
|
||||||
|
match kind {
|
||||||
|
BlockKind::Code => BlockFrame::Verbatim {
|
||||||
|
fill: VERBATIM_BACKGROUND,
|
||||||
|
},
|
||||||
|
BlockKind::Table => BlockFrame::Verbatim {
|
||||||
|
fill: TABLE_BACKGROUND,
|
||||||
|
},
|
||||||
|
BlockKind::Quote => BlockFrame::Quote,
|
||||||
|
BlockKind::Paragraph | BlockKind::Heading | BlockKind::List | BlockKind::Other => {
|
||||||
|
BlockFrame::Plain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tappable range of a block's text and where it points.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Link {
|
||||||
|
/// Byte range into [`Rendered::text`].
|
||||||
|
pub range: Range<usize>,
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One block, ready to draw. Not `Debug`: `SpanStyle` is not, and adding
|
||||||
|
/// it there for this would be a change to iris for a test's benefit.
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct Rendered {
|
||||||
|
pub text: String,
|
||||||
|
pub spans: Vec<SpanStyle>,
|
||||||
|
pub links: Vec<Link>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rendered {
|
||||||
|
/// The link `byte` falls inside, if any -- what a tap resolves
|
||||||
|
/// through. Half-open, so the offset one past a link's last character
|
||||||
|
/// (where a tap just after it lands) is *not* in it.
|
||||||
|
pub fn link_at(&self, byte: usize) -> Option<&Link> {
|
||||||
|
self.links.iter().find(|l| l.range.contains(&byte))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The heading ladder, in points at a 16pt body: it starts near the body
|
||||||
|
/// text and descends, because these are headings inside a chat message
|
||||||
|
/// rather than the top of a document. The numbers are Material's
|
||||||
|
/// `headlineSmall`/`titleLarge`/`titleMedium`/`titleSmall`/`labelMedium`/
|
||||||
|
/// `labelSmall`, which is what `Markdown.kt`'s `markdownTypography` picks
|
||||||
|
/// -- kept as literals rather than derived from `base_size` so the two
|
||||||
|
/// apps agree exactly.
|
||||||
|
fn heading_size(level: HeadingLevel) -> f32 {
|
||||||
|
match level {
|
||||||
|
HeadingLevel::H1 => 24.0,
|
||||||
|
HeadingLevel::H2 => 22.0,
|
||||||
|
HeadingLevel::H3 => 16.0,
|
||||||
|
HeadingLevel::H4 => 14.0,
|
||||||
|
HeadingLevel::H5 => 12.0,
|
||||||
|
HeadingLevel::H6 => 11.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bullet at each depth, cycling past the third: a disc, a ring, a
|
||||||
|
/// square -- the ladder a browser draws, so a nested list is told from its
|
||||||
|
/// parent by the glyph as well as by the indent. Same three
|
||||||
|
/// `MarkdownPieces.kt` uses.
|
||||||
|
const BULLETS: [&str; 3] = ["\u{2022} ", "\u{25e6} ", "\u{25aa} "];
|
||||||
|
|
||||||
|
/// A block-level separator inside one block's own text (a list item's
|
||||||
|
/// paragraphs, a quote's): two never run into each other with no gap, but
|
||||||
|
/// an empty `out` gets no leading blank.
|
||||||
fn ensure_blank_line(out: &mut String) {
|
fn ensure_blank_line(out: &mut String) {
|
||||||
if !out.is_empty() && !out.ends_with("\n\n") {
|
if !out.is_empty() && !out.ends_with("\n\n") {
|
||||||
|
while out.ends_with('\n') {
|
||||||
|
out.pop();
|
||||||
|
}
|
||||||
out.push_str("\n\n");
|
out.push_str("\n\n");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn heading_size(level: HeadingLevel) -> f32 {
|
fn ensure_line(out: &mut String) {
|
||||||
match level {
|
if !out.is_empty() && !out.ends_with('\n') {
|
||||||
HeadingLevel::H1 => 28.0,
|
out.push('\n');
|
||||||
HeadingLevel::H2 => 24.0,
|
}
|
||||||
HeadingLevel::H3 => 21.0,
|
}
|
||||||
_ => 19.0,
|
|
||||||
|
/// One top-level block, rendered. `base_size` is the row's ordinary
|
||||||
|
/// paragraph font size; a heading overrides it per span.
|
||||||
|
pub fn render_block(block: &Block, base_size: f32) -> Rendered {
|
||||||
|
match block.kind {
|
||||||
|
// A table is the one block markdown states as a grid and iris has
|
||||||
|
// no grid widget for. Rendered as padded monospace instead --
|
||||||
|
// see [`table_text`].
|
||||||
|
BlockKind::Table => table_text(&block.source),
|
||||||
|
_ => render_markdown(&block.source, base_size),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,19 +218,27 @@ fn heading_size(level: HeadingLevel) -> f32 {
|
|||||||
/// style it. `base_size` is the row's ordinary paragraph font size, needed
|
/// 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
|
/// only so a heading's override is relative to it rather than a hardcoded
|
||||||
/// absolute the caller cannot retune.
|
/// absolute the caller cannot retune.
|
||||||
pub fn render_markdown(src: &str, base_size: f32) -> (String, Vec<SpanStyle>) {
|
pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
|
||||||
let _ = base_size; // headings use fixed sizes today; kept for callers that may want relative sizing later
|
let _ = base_size; // headings use the fixed Material ladder; see `heading_size`
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
let mut spans = Vec::new();
|
let mut spans = Vec::new();
|
||||||
|
let mut links = Vec::new();
|
||||||
// Stack of start byte offsets for whatever inline/block styling is
|
// Stack of start byte offsets for whatever inline/block styling is
|
||||||
// currently open -- pulldown-cmark's `Start`/`End` events are always
|
// currently open -- pulldown-cmark's `Start`/`End` events are always
|
||||||
// balanced and each `End` already names its own kind (`TagEnd`), so a
|
// balanced and each `End` already names its own kind (`TagEnd`), so a
|
||||||
// plain offset stack (rather than a tree, or repeating the kind here
|
// plain offset stack (rather than a tree, or repeating the kind here
|
||||||
// too) is enough.
|
// too) is enough. A link's destination rides along beside its offset,
|
||||||
let mut open: Vec<usize> = Vec::new();
|
// since `TagEnd::Link` does not carry it.
|
||||||
let mut list_depth: u32 = 0;
|
let mut open: Vec<(usize, Option<String>)> = Vec::new();
|
||||||
|
// One entry per open list: `Some(next number)` for an ordered list,
|
||||||
|
// `None` for a bulleted one. Depth is this vector's length, which is
|
||||||
|
// what picks the bullet glyph.
|
||||||
|
let mut lists: Vec<Option<u64>> = Vec::new();
|
||||||
|
// The language of the fence currently open, so `TagEnd::CodeBlock` can
|
||||||
|
// highlight what was collected between the two.
|
||||||
|
let mut fence_language: Option<Language> = None;
|
||||||
|
|
||||||
let parser = Parser::new_ext(src, Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES);
|
let parser = Parser::new_ext(src, options());
|
||||||
for event in parser {
|
for event in parser {
|
||||||
match event {
|
match event {
|
||||||
Event::Start(tag) => match tag {
|
Event::Start(tag) => match tag {
|
||||||
@@ -89,16 +246,35 @@ pub fn render_markdown(src: &str, base_size: f32) -> (String, Vec<SpanStyle>) {
|
|||||||
| Tag::Emphasis
|
| Tag::Emphasis
|
||||||
| Tag::Strong
|
| Tag::Strong
|
||||||
| Tag::Strikethrough
|
| Tag::Strikethrough
|
||||||
| Tag::Link { .. } => open.push(out.len()),
|
| Tag::Image { .. } => open.push((out.len(), None)),
|
||||||
Tag::CodeBlock(_) => {
|
Tag::Link { dest_url, .. } => open.push((out.len(), Some(dest_url.to_string()))),
|
||||||
|
Tag::CodeBlock(kind) => {
|
||||||
|
fence_language = match &kind {
|
||||||
|
CodeBlockKind::Fenced(info) => {
|
||||||
|
// Only the first word: "rust,ignore" and
|
||||||
|
// "console session" are both written.
|
||||||
|
highlight::fence_language(info.split_whitespace().next())
|
||||||
|
}
|
||||||
|
CodeBlockKind::Indented => None,
|
||||||
|
};
|
||||||
ensure_blank_line(&mut out);
|
ensure_blank_line(&mut out);
|
||||||
open.push(out.len());
|
open.push((out.len(), None));
|
||||||
}
|
}
|
||||||
Tag::Item => {
|
Tag::Item => {
|
||||||
out.push_str(&" ".repeat(list_depth.saturating_sub(1) as usize));
|
ensure_line(&mut out);
|
||||||
out.push_str("\u{2022} ");
|
let depth = lists.len().max(1);
|
||||||
|
out.push_str(&" ".repeat(depth - 1));
|
||||||
|
let start = out.len();
|
||||||
|
match lists.last_mut() {
|
||||||
|
Some(Some(n)) => {
|
||||||
|
out.push_str(&format!("{n}. "));
|
||||||
|
*n += 1;
|
||||||
|
}
|
||||||
|
_ => out.push_str(BULLETS[(depth - 1) % BULLETS.len()]),
|
||||||
|
}
|
||||||
|
spans.push(SpanStyle::new(start..out.len()).color(MARKER_COLOR));
|
||||||
}
|
}
|
||||||
Tag::List(_) => list_depth += 1,
|
Tag::List(first) => lists.push(first),
|
||||||
Tag::Paragraph | Tag::BlockQuote(_) => ensure_blank_line(&mut out),
|
Tag::Paragraph | Tag::BlockQuote(_) => ensure_blank_line(&mut out),
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
@@ -113,11 +289,19 @@ pub fn render_markdown(src: &str, base_size: f32) -> (String, Vec<SpanStyle>) {
|
|||||||
| TagEnd::Strong
|
| TagEnd::Strong
|
||||||
| TagEnd::Strikethrough
|
| TagEnd::Strikethrough
|
||||||
| TagEnd::Link
|
| TagEnd::Link
|
||||||
|
| TagEnd::Image
|
||||||
| TagEnd::CodeBlock),
|
| TagEnd::CodeBlock),
|
||||||
) => {
|
) => {
|
||||||
let Some(start) = open.pop() else {
|
let Some((start, dest)) = open.pop() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
if matches!(tag_end, TagEnd::CodeBlock) {
|
||||||
|
// A fence's trailing newline is the fence marker's, not
|
||||||
|
// the code's -- kept and it draws an empty last line.
|
||||||
|
while out.ends_with('\n') {
|
||||||
|
out.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
let range = start..out.len();
|
let range = start..out.len();
|
||||||
if range.is_empty() {
|
if range.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
@@ -131,15 +315,27 @@ pub fn render_markdown(src: &str, base_size: f32) -> (String, Vec<SpanStyle>) {
|
|||||||
TagEnd::Strikethrough => {
|
TagEnd::Strikethrough => {
|
||||||
spans.push(SpanStyle::new(range).color(STRIKETHROUGH_COLOR));
|
spans.push(SpanStyle::new(range).color(STRIKETHROUGH_COLOR));
|
||||||
}
|
}
|
||||||
TagEnd::Link => {
|
// An image draws as its alt text until the port has a
|
||||||
spans.push(SpanStyle::new(range).color(LINK_COLOR).underline());
|
// transcript image widget (IRIS_TODO's "scaled
|
||||||
|
// thumbnail"); marked as a link so it is at least
|
||||||
|
// followable rather than silently inert.
|
||||||
|
TagEnd::Link | TagEnd::Image => {
|
||||||
|
spans.push(SpanStyle::new(range.clone()).color(LINK_COLOR).underline());
|
||||||
|
if let Some(url) = dest {
|
||||||
|
links.push(Link { range, url });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
TagEnd::CodeBlock => {
|
TagEnd::CodeBlock => {
|
||||||
spans.push(
|
spans.push(
|
||||||
SpanStyle::new(range)
|
SpanStyle::new(range.clone())
|
||||||
.family(Family::Monospace)
|
.family(Family::Monospace)
|
||||||
.color(CODE_COLOR),
|
.color(CODE_COLOR),
|
||||||
);
|
);
|
||||||
|
// After the monospace span, so the per-token
|
||||||
|
// colours win where they overlap it.
|
||||||
|
if let Some(language) = fence_language.take() {
|
||||||
|
highlight_into(&mut spans, &out, range, language);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => unreachable!("filtered by the outer match arm"),
|
_ => unreachable!("filtered by the outer match arm"),
|
||||||
}
|
}
|
||||||
@@ -160,64 +356,427 @@ pub fn render_markdown(src: &str, base_size: f32) -> (String, Vec<SpanStyle>) {
|
|||||||
Event::SoftBreak => out.push(' '),
|
Event::SoftBreak => out.push(' '),
|
||||||
Event::HardBreak => out.push('\n'),
|
Event::HardBreak => out.push('\n'),
|
||||||
Event::Rule => {
|
Event::Rule => {
|
||||||
if !out.ends_with('\n') {
|
ensure_line(&mut out);
|
||||||
out.push('\n');
|
|
||||||
}
|
|
||||||
out.push_str("\u{2500}\u{2500}\u{2500}\n");
|
out.push_str("\u{2500}\u{2500}\u{2500}\n");
|
||||||
}
|
}
|
||||||
Event::End(TagEnd::List(_)) => list_depth = list_depth.saturating_sub(1),
|
Event::TaskListMarker(done) => {
|
||||||
|
let start = out.len();
|
||||||
|
out.push_str(if done { "[x] " } else { "[ ] " });
|
||||||
|
spans.push(SpanStyle::new(start..out.len()).color(MARKER_COLOR));
|
||||||
|
}
|
||||||
|
Event::End(TagEnd::List(_)) => {
|
||||||
|
lists.pop();
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(out, spans)
|
while out.ends_with('\n') {
|
||||||
|
out.pop();
|
||||||
|
}
|
||||||
|
// A span left pointing past the text a later trim shortened would draw
|
||||||
|
// against nothing; markdown that ends inside an open emphasis is
|
||||||
|
// ordinary mid-stream input, not a defect.
|
||||||
|
spans.retain(|s| s.range.end <= out.len());
|
||||||
|
links.retain(|l| l.range.end <= out.len());
|
||||||
|
Rendered {
|
||||||
|
text: out,
|
||||||
|
spans,
|
||||||
|
links,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same option set `client_core::markdown_blocks` splits with, so a
|
||||||
|
/// block boundary there and the styling here cannot disagree about what
|
||||||
|
/// the source means.
|
||||||
|
fn options() -> Options {
|
||||||
|
Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `client_core::highlight`'s spans for the code at `range` inside `text`,
|
||||||
|
/// appended to `spans`.
|
||||||
|
///
|
||||||
|
/// The highlighter indexes **chars** and `SpanStyle` indexes **bytes**
|
||||||
|
/// (`highlight`'s module doc), so the offsets are walked once rather than
|
||||||
|
/// converted per span -- a fence is scanned on every delta that lands in
|
||||||
|
/// it, and it is the only block a delta re-renders.
|
||||||
|
fn highlight_into(spans: &mut Vec<SpanStyle>, text: &str, range: Range<usize>, language: Language) {
|
||||||
|
let code = &text[range.clone()];
|
||||||
|
// char index -> byte offset within `code`, plus the end, so a span's
|
||||||
|
// `end` is always in range.
|
||||||
|
let bytes: Vec<usize> = code
|
||||||
|
.char_indices()
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.chain(std::iter::once(code.len()))
|
||||||
|
.collect();
|
||||||
|
for span in highlight::spans_of(code, language) {
|
||||||
|
let (Some(&start), Some(&end)) = (bytes.get(span.start), bytes.get(span.end)) else {
|
||||||
|
debug_assert!(
|
||||||
|
false,
|
||||||
|
"highlight span {}..{} outside {} chars of code",
|
||||||
|
span.start,
|
||||||
|
span.end,
|
||||||
|
bytes.len() - 1
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
spans.push(
|
||||||
|
SpanStyle::new(range.start + start..range.start + end)
|
||||||
|
.family(Family::Monospace)
|
||||||
|
.color(syntax_color(span.kind)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The widest a table column is allowed to get before its cells wrap
|
||||||
|
/// inside it, in characters. Chosen the way `Markdown.kt`'s 136dp
|
||||||
|
/// `tableCellWidth` was -- what fits three columns across a phone -- but
|
||||||
|
/// counted in monospace characters, which is the unit a padded table has:
|
||||||
|
/// three 28-character columns plus separators is about 90 characters,
|
||||||
|
/// which is what a 16pt mono face gives on a 1080px phone before the
|
||||||
|
/// sideways pan starts.
|
||||||
|
const TABLE_MAX_COL: usize = 28;
|
||||||
|
|
||||||
|
/// A GFM table as **padded monospace columns**, with the header bold and a
|
||||||
|
/// rule under it.
|
||||||
|
///
|
||||||
|
/// iris has no grid widget, and building one for the one block kind that
|
||||||
|
/// needs it would be a widget per markdown feature -- what this crate's
|
||||||
|
/// module doc says it will not do. A monospace face makes character counts
|
||||||
|
/// and pixel widths the same thing, so padding each cell to its column's
|
||||||
|
/// width *is* alignment, the column widths are measured from the cells,
|
||||||
|
/// and the block reuses `BlockFrame::Verbatim`'s sideways pan for a table
|
||||||
|
/// too wide to fit. docs/DECISIONS.md, 2026-09-06, has what this trades.
|
||||||
|
pub fn table_text(src: &str) -> Rendered {
|
||||||
|
let rows = table_cells(src);
|
||||||
|
if rows.is_empty() {
|
||||||
|
return Rendered::default();
|
||||||
|
}
|
||||||
|
let columns = rows.iter().map(Vec::len).max().unwrap_or(0);
|
||||||
|
// Each cell wrapped to the cap first, so a column's width is the
|
||||||
|
// widest *line* it will actually draw rather than the longest cell.
|
||||||
|
let wrapped: Vec<Vec<Vec<String>>> = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| row.iter().map(|c| wrap_cell(c, TABLE_MAX_COL)).collect())
|
||||||
|
.collect();
|
||||||
|
let widths: Vec<usize> = (0..columns)
|
||||||
|
.map(|c| {
|
||||||
|
wrapped
|
||||||
|
.iter()
|
||||||
|
.filter_map(|row| row.get(c))
|
||||||
|
.flat_map(|lines| lines.iter())
|
||||||
|
.map(|l| l.chars().count())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
for (r, row) in wrapped.iter().enumerate() {
|
||||||
|
let height = row.iter().map(Vec::len).max().unwrap_or(1);
|
||||||
|
let start = out.len();
|
||||||
|
for line in 0..height {
|
||||||
|
if !out.is_empty() {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
for (c, width) in widths.iter().enumerate() {
|
||||||
|
if c > 0 {
|
||||||
|
out.push_str(" ");
|
||||||
|
}
|
||||||
|
let text = row.get(c).and_then(|l| l.get(line)).map(String::as_str);
|
||||||
|
let text = text.unwrap_or("");
|
||||||
|
out.push_str(text);
|
||||||
|
// The last column is not padded: trailing spaces widen
|
||||||
|
// the block's measured width for nothing.
|
||||||
|
if c + 1 < widths.len() {
|
||||||
|
for _ in text.chars().count()..*width {
|
||||||
|
out.push(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r == 0 {
|
||||||
|
spans.push(SpanStyle::new(start..out.len()).bold());
|
||||||
|
out.push('\n');
|
||||||
|
let rule: usize = widths.iter().sum::<usize>() + 2 * widths.len().saturating_sub(1);
|
||||||
|
let rule_start = out.len();
|
||||||
|
out.extend(std::iter::repeat_n('\u{2500}', rule));
|
||||||
|
spans.push(SpanStyle::new(rule_start..out.len()).color(QUOTE_BAR_COLOR));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Rendered {
|
||||||
|
text: out,
|
||||||
|
spans,
|
||||||
|
links: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cells of a GFM table, row by row, as their plain text.
|
||||||
|
fn table_cells(src: &str) -> Vec<Vec<String>> {
|
||||||
|
let mut rows: Vec<Vec<String>> = Vec::new();
|
||||||
|
let mut cell = String::new();
|
||||||
|
let mut in_cell = false;
|
||||||
|
for event in Parser::new_ext(src, options()) {
|
||||||
|
match event {
|
||||||
|
Event::Start(Tag::TableHead) | Event::Start(Tag::TableRow) => rows.push(Vec::new()),
|
||||||
|
Event::Start(Tag::TableCell) => {
|
||||||
|
cell.clear();
|
||||||
|
in_cell = true;
|
||||||
|
}
|
||||||
|
Event::End(TagEnd::TableCell) => {
|
||||||
|
in_cell = false;
|
||||||
|
if let Some(row) = rows.last_mut() {
|
||||||
|
row.push(cell.trim().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Text(text) | Event::Code(text) if in_cell => cell.push_str(&text),
|
||||||
|
Event::SoftBreak | Event::HardBreak if in_cell => cell.push(' '),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.retain(|r| !r.is_empty());
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `text` broken onto lines of at most `width` characters, at spaces where
|
||||||
|
/// there are any. A word longer than the column is left over-long rather
|
||||||
|
/// than cut mid-word: the column then widens for it, which is visible and
|
||||||
|
/// correct, where cutting would silently lose characters.
|
||||||
|
fn wrap_cell(text: &str, width: usize) -> Vec<String> {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
let mut line = String::new();
|
||||||
|
for word in text.split_whitespace() {
|
||||||
|
let extra = if line.is_empty() { 0 } else { 1 };
|
||||||
|
if !line.is_empty() && line.chars().count() + extra + word.chars().count() > width {
|
||||||
|
lines.push(std::mem::take(&mut line));
|
||||||
|
}
|
||||||
|
if !line.is_empty() {
|
||||||
|
line.push(' ');
|
||||||
|
}
|
||||||
|
line.push_str(word);
|
||||||
|
}
|
||||||
|
lines.push(line);
|
||||||
|
lines
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use client_core::markdown_blocks::split_blocks;
|
||||||
|
|
||||||
|
fn block(src: &str) -> Rendered {
|
||||||
|
let blocks = split_blocks(src);
|
||||||
|
assert_eq!(blocks.len(), 1, "test wants exactly one block: {blocks:?}");
|
||||||
|
render_block(&blocks[0], 16.0)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn plain_paragraph_has_no_spans() {
|
fn plain_paragraph_has_no_spans() {
|
||||||
let (text, spans) = render_markdown("just some words", 16.0);
|
let r = render_markdown("just some words", 16.0);
|
||||||
assert_eq!(text, "just some words");
|
assert_eq!(r.text, "just some words");
|
||||||
assert!(spans.is_empty());
|
assert!(r.spans.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bold_and_italic_produce_spans_over_the_right_range() {
|
fn bold_and_italic_produce_spans_over_the_right_range() {
|
||||||
let (text, spans) = render_markdown("a **bold** and *italic* word", 16.0);
|
let r = render_markdown("a **bold** and *italic* word", 16.0);
|
||||||
assert_eq!(text, "a bold and italic word");
|
assert_eq!(r.text, "a bold and italic word");
|
||||||
let bold = spans.iter().find(|s| s.bold && !s.italic).unwrap();
|
let bold = r.spans.iter().find(|s| s.bold && !s.italic).unwrap();
|
||||||
assert_eq!(&text[bold.range.clone()], "bold");
|
assert_eq!(&r.text[bold.range.clone()], "bold");
|
||||||
let italic = spans.iter().find(|s| s.italic).unwrap();
|
let italic = r.spans.iter().find(|s| s.italic).unwrap();
|
||||||
assert_eq!(&text[italic.range.clone()], "italic");
|
assert_eq!(&r.text[italic.range.clone()], "italic");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn heading_gets_a_bigger_font_size_span() {
|
fn heading_gets_a_bigger_font_size_span() {
|
||||||
let (text, spans) = render_markdown("# A Title\n\nbody text", 16.0);
|
let r = render_markdown("# A Title", 16.0);
|
||||||
assert!(text.starts_with("A Title"));
|
assert!(r.text.starts_with("A Title"));
|
||||||
let heading = spans.iter().find(|s| s.font_size.is_some()).unwrap();
|
let heading = r.spans.iter().find(|s| s.font_size.is_some()).unwrap();
|
||||||
assert_eq!(&text[heading.range.clone()], "A Title");
|
assert_eq!(&r.text[heading.range.clone()], "A Title");
|
||||||
assert_eq!(heading.font_size, Some(28.0));
|
assert_eq!(heading.font_size, Some(24.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every level draws at its own size, so two levels of nesting are
|
||||||
|
/// never the same -- `Markdown.kt`'s reason for the ladder.
|
||||||
|
#[test]
|
||||||
|
fn every_heading_level_is_a_different_size() {
|
||||||
|
let mut sizes = Vec::new();
|
||||||
|
for level in 1..=6 {
|
||||||
|
let src = format!("{} h", "#".repeat(level));
|
||||||
|
let r = render_markdown(&src, 16.0);
|
||||||
|
sizes.push(r.spans.iter().find_map(|s| s.font_size).unwrap());
|
||||||
|
}
|
||||||
|
let mut sorted = sizes.clone();
|
||||||
|
sorted.sort_by(|a, b| b.partial_cmp(a).unwrap());
|
||||||
|
sorted.dedup();
|
||||||
|
assert_eq!(sizes, sorted, "the ladder must descend with no repeats");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn link_is_styled_and_keeps_its_visible_text() {
|
fn a_link_keeps_its_text_and_its_url_and_can_be_hit() {
|
||||||
let (text, spans) = render_markdown("see [the docs](https://example.com) for more", 16.0);
|
let r = render_markdown("see [the docs](https://example.com) for more", 16.0);
|
||||||
assert!(text.contains("the docs"));
|
assert!(r.text.contains("the docs"));
|
||||||
assert!(
|
assert!(
|
||||||
!text.contains("example.com"),
|
!r.text.contains("example.com"),
|
||||||
"the URL should not leak into the visible text"
|
"the URL should not leak into the visible text"
|
||||||
);
|
);
|
||||||
let link = spans.iter().find(|s| s.underline).unwrap();
|
let link = r.spans.iter().find(|s| s.underline).unwrap();
|
||||||
assert_eq!(&text[link.range.clone()], "the docs");
|
assert_eq!(&r.text[link.range.clone()], "the docs");
|
||||||
|
let at = r.text.find("docs").unwrap();
|
||||||
|
assert_eq!(r.link_at(at).unwrap().url, "https://example.com");
|
||||||
|
assert!(r.link_at(0).is_none(), "the word 'see' is not the link");
|
||||||
|
let past = r.text.find("for").unwrap();
|
||||||
|
assert!(r.link_at(past).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fenced_code_block_is_monospaced() {
|
fn fenced_code_block_is_monospaced_and_highlighted_by_its_language() {
|
||||||
let (text, spans) = render_markdown("before\n\n```\nlet x = 1;\n```\n\nafter", 16.0);
|
let r = block("```rust\nlet x = 1; // note\n```");
|
||||||
let code = spans.iter().find(|s| s.family.is_some()).unwrap();
|
assert_eq!(r.text, "let x = 1; // note");
|
||||||
assert!(text[code.range.clone()].contains("let x = 1;"));
|
let keyword = r
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.color == Some(syntax_color(Kind::Keyword)))
|
||||||
|
.expect("a rust fence colours its keywords");
|
||||||
|
assert_eq!(&r.text[keyword.range.clone()], "let");
|
||||||
|
let comment = r
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.color == Some(syntax_color(Kind::Comment)))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(&r.text[comment.range.clone()], "// note");
|
||||||
|
assert!(r.spans.iter().all(|s| s.range.end <= r.text.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The half the change had no reason to touch: a fence in a language
|
||||||
|
/// the highlighter has no rules for must be plain rather than
|
||||||
|
/// coloured by the nearest language's (`CodeFence.kt`'s
|
||||||
|
/// `fenceLanguage` doc).
|
||||||
|
#[test]
|
||||||
|
fn a_fence_in_an_unknown_language_is_monospace_and_uncoloured() {
|
||||||
|
let r = block("```brainfuck\nlet x = 1;\n```");
|
||||||
|
assert_eq!(r.text, "let x = 1;");
|
||||||
|
assert_eq!(r.spans.len(), 1);
|
||||||
|
// `Family` is not `Debug`, so this is `assert!` rather than
|
||||||
|
// `assert_eq!`.
|
||||||
|
assert!(r.spans[0].family == Some(Family::Monospace));
|
||||||
|
assert_eq!(r.spans[0].color, Some(CODE_COLOR));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Multi-byte characters are where a char-indexed highlighter and a
|
||||||
|
/// byte-indexed span list disagree if the conversion is missing.
|
||||||
|
#[test]
|
||||||
|
fn highlight_spans_are_byte_offsets_even_with_multibyte_code() {
|
||||||
|
let r = block("```rust\nlet s = \"café ☕\"; // é\n```");
|
||||||
|
for span in &r.spans {
|
||||||
|
assert!(
|
||||||
|
r.text.is_char_boundary(span.range.start)
|
||||||
|
&& r.text.is_char_boundary(span.range.end),
|
||||||
|
"span {:?} is not on a char boundary of {:?}",
|
||||||
|
span.range,
|
||||||
|
r.text
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let string = r
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.color == Some(syntax_color(Kind::String)))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(&r.text[string.range.clone()], "\"café ☕\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unterminated_fence_still_renders_what_arrived() {
|
||||||
|
let r = block("```rust\nlet x = 1;");
|
||||||
|
assert_eq!(r.text, "let x = 1;");
|
||||||
|
assert!(
|
||||||
|
r.spans
|
||||||
|
.iter()
|
||||||
|
.any(|s| s.color == Some(syntax_color(Kind::Keyword)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bulleted_list_gets_a_marker_per_item_and_indents_nesting() {
|
||||||
|
let r = block("- one\n- two\n - deep");
|
||||||
|
assert_eq!(r.text, "\u{2022} one\n\u{2022} two\n \u{25e6} deep");
|
||||||
|
let markers: Vec<_> = r
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.color == Some(MARKER_COLOR))
|
||||||
|
.map(|s| r.text[s.range.clone()].to_string())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(markers, ["\u{2022} ", "\u{2022} ", "\u{25e6} "]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_numbered_list_counts_from_the_number_it_was_written_with() {
|
||||||
|
let r = block("3. three\n4. four");
|
||||||
|
assert_eq!(r.text, "3. three\n4. four");
|
||||||
|
let markers: Vec<_> = r
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.color == Some(MARKER_COLOR))
|
||||||
|
.map(|s| r.text[s.range.clone()].to_string())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(markers, ["3. ", "4. "]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_quote_is_its_text_and_takes_the_quote_frame() {
|
||||||
|
let blocks = split_blocks("> quoted words\n> still quoted");
|
||||||
|
assert_eq!(frame_of(blocks[0].kind), BlockFrame::Quote);
|
||||||
|
let r = render_block(&blocks[0], 16.0);
|
||||||
|
assert_eq!(r.text, "quoted words still quoted");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_block_kind_maps_to_the_frame_it_is_drawn_in() {
|
||||||
|
use BlockKind::*;
|
||||||
|
assert_eq!(frame_of(Paragraph), BlockFrame::Plain);
|
||||||
|
assert_eq!(frame_of(Heading), BlockFrame::Plain);
|
||||||
|
assert_eq!(frame_of(List), BlockFrame::Plain);
|
||||||
|
assert_eq!(frame_of(Other), BlockFrame::Plain);
|
||||||
|
assert_eq!(frame_of(Quote), BlockFrame::Quote);
|
||||||
|
assert!(matches!(frame_of(Code), BlockFrame::Verbatim { .. }));
|
||||||
|
assert!(matches!(frame_of(Table), BlockFrame::Verbatim { .. }));
|
||||||
|
assert_ne!(
|
||||||
|
frame_of(Code),
|
||||||
|
frame_of(Table),
|
||||||
|
"a fence and a table sit on different fills"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_table_pads_its_columns_to_the_widest_cell() {
|
||||||
|
let r = block("| a | bb |\n|---|---|\n| cccc | d |");
|
||||||
|
let lines: Vec<&str> = r.text.lines().collect();
|
||||||
|
assert_eq!(lines[0], "a bb");
|
||||||
|
assert_eq!(lines[1], "\u{2500}".repeat(8));
|
||||||
|
assert_eq!(lines[2], "cccc d");
|
||||||
|
let bold = r.spans.iter().find(|s| s.bold).unwrap();
|
||||||
|
assert_eq!(&r.text[bold.range.clone()], "a bb");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fixture's own table shape: a long cell wraps inside its column
|
||||||
|
/// instead of making the row one enormous line.
|
||||||
|
#[test]
|
||||||
|
fn a_long_table_cell_wraps_inside_its_column() {
|
||||||
|
let long = "one two three four five six seven eight nine ten eleven twelve";
|
||||||
|
let r = block(&format!("| k | v |\n|---|---|\n| a | {long} |"));
|
||||||
|
for line in r.text.lines() {
|
||||||
|
assert!(
|
||||||
|
line.chars().count() <= TABLE_MAX_COL + 1 + 2 + 1,
|
||||||
|
"line too wide: {line:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(r.text.contains("twelve"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_task_list_marks_its_boxes() {
|
||||||
|
let r = block("- [x] done\n- [ ] not");
|
||||||
|
assert!(r.text.contains("[x] done"));
|
||||||
|
assert!(r.text.contains("[ ] not"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+145
-38
@@ -23,7 +23,7 @@
|
|||||||
//! collapsed summary and the full detail -- the same two-step contract
|
//! collapsed summary and the full detail -- the same two-step contract
|
||||||
//! `list.rs`'s module doc describes for `AGENTS.md`'s `holdTopEdge`.
|
//! `list.rs`'s module doc describes for `AGENTS.md`'s `holdTopEdge`.
|
||||||
|
|
||||||
use crate::markdown::render_markdown;
|
use crate::markdown::{BlockFrame, Link, frame_of, render_block};
|
||||||
use crate::selection::{SelKey, Selection};
|
use crate::selection::{SelKey, Selection};
|
||||||
use client_core::markdown_blocks::{Block, BlockKind, common_prefix, split_blocks};
|
use client_core::markdown_blocks::{Block, BlockKind, common_prefix, split_blocks};
|
||||||
use client_core::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
|
use client_core::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
|
||||||
@@ -133,6 +133,10 @@ pub struct RowBlocks {
|
|||||||
/// comparison and not an assumption.
|
/// comparison and not an assumption.
|
||||||
blocks: Vec<Block>,
|
blocks: Vec<Block>,
|
||||||
fields: Vec<WeakWidget<TextEdit>>,
|
fields: Vec<WeakWidget<TextEdit>>,
|
||||||
|
/// Each block's links, shared with its own tap handler so a delta
|
||||||
|
/// replaces what the handler reads instead of re-registering it.
|
||||||
|
/// One entry per field, which `apply_delta` asserts.
|
||||||
|
links: Vec<Rc<RefCell<Vec<Link>>>>,
|
||||||
column: WeakWidget<Span>,
|
column: WeakWidget<Span>,
|
||||||
/// The sender label the row was built with. A delta that changes it is
|
/// The sender label the row was built with. A delta that changes it is
|
||||||
/// not a delta into the same message, so it falls back to a rebuild.
|
/// not a delta into the same message, so it falls back to a rebuild.
|
||||||
@@ -154,30 +158,63 @@ fn display_blocks(markdown_src: &str) -> Vec<Block> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The room a fence or a table's text gets inside its panel, and the
|
||||||
|
/// gap between a quote's bar and its words. `CodeFence.kt` charges the
|
||||||
|
/// renderer's `codeBlock` padding inside the tinted box and 8dp above and
|
||||||
|
/// below it; the vertical half is `BLOCK_GAP_DP`'s job here, since the
|
||||||
|
/// column already separates blocks.
|
||||||
|
const FRAME_PAD_DP: f32 = 10.0;
|
||||||
|
/// The bar down a quote's left edge.
|
||||||
|
const QUOTE_BAR_DP: f32 = 3.0;
|
||||||
|
/// A verbatim panel's corner, matching the renderer's own rounded fence.
|
||||||
|
const FRAME_RADIUS_DP: f32 = 8.0;
|
||||||
|
|
||||||
/// One block's own `TextEdit`, registered with `selection` under
|
/// One block's own `TextEdit`, registered with `selection` under
|
||||||
/// `(row, block)` and wired to `Selection::drag` -- the block is the
|
/// `(row, block)` and wired to `Selection::drag` -- the block is the
|
||||||
/// selection unit (`selection::SelKey`).
|
/// selection unit (`selection::SelKey`) -- plus whatever
|
||||||
fn build_block_field<Rsc: HasEvents>(
|
/// [`BlockFrame`] its kind is drawn in.
|
||||||
|
///
|
||||||
|
/// Returns the field (which `apply_delta` writes into), the widget the
|
||||||
|
/// column actually holds (the field, or the field inside its frame), and
|
||||||
|
/// the block's links, shared with the tap handler so a delta can replace
|
||||||
|
/// them without rebuilding the handler.
|
||||||
|
fn build_block<Rsc: HasEvents>(
|
||||||
rsc: &mut Rsc,
|
rsc: &mut Rsc,
|
||||||
list: WeakWidget<List>,
|
list: WeakWidget<List>,
|
||||||
selection: Rc<RefCell<Selection>>,
|
selection: Rc<RefCell<Selection>>,
|
||||||
key: SelKey,
|
key: SelKey,
|
||||||
source: &str,
|
block: &Block,
|
||||||
) -> WeakWidget<TextEdit>
|
) -> (WeakWidget<TextEdit>, StrongWidget, Rc<RefCell<Vec<Link>>>)
|
||||||
where
|
where
|
||||||
Rsc::State: FocusHost,
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
{
|
{
|
||||||
let (text, spans) = render_markdown(source, BASE_SIZE);
|
let frame = frame_of(block.kind);
|
||||||
let field = wtext(text)
|
let rendered = render_block(block, BASE_SIZE);
|
||||||
.spans(spans)
|
let links = Rc::new(RefCell::new(rendered.links));
|
||||||
|
let verbatim = matches!(frame, BlockFrame::Verbatim { .. });
|
||||||
|
let field = wtext(rendered.text)
|
||||||
|
.spans(rendered.spans)
|
||||||
.editable(EditMode::MultiLine)
|
.editable(EditMode::MultiLine)
|
||||||
.text_align(Align::LEFT)
|
.text_align(Align::LEFT)
|
||||||
.wrap(true)
|
// A fence and a table say what they mean by where their
|
||||||
|
// characters sit, so they pan sideways rather than wrap
|
||||||
|
// (`CodeFence.kt`'s `horizontalScroll`) -- and a table is padded
|
||||||
|
// in *characters*, which only lines up in a monospace face.
|
||||||
|
.wrap(!verbatim)
|
||||||
|
.family(if verbatim {
|
||||||
|
Family::Monospace
|
||||||
|
} else {
|
||||||
|
Family::SansSerif
|
||||||
|
})
|
||||||
.size(BASE_SIZE)
|
.size(BASE_SIZE)
|
||||||
.color(UiColor::WHITE)
|
.color(match frame {
|
||||||
|
BlockFrame::Quote => crate::markdown::QUOTE_TEXT_COLOR,
|
||||||
|
_ => crate::markdown::TEXT_COLOR,
|
||||||
|
})
|
||||||
.add(rsc);
|
.add(rsc);
|
||||||
selection.borrow_mut().register(key, field);
|
selection.borrow_mut().register(key, field);
|
||||||
|
|
||||||
|
let tap_links = links.clone();
|
||||||
field
|
field
|
||||||
// `| CursorSense::unclick()` on top of the usual click-or-drag set
|
// `| CursorSense::unclick()` on top of the usual click-or-drag set
|
||||||
// -- this block's own registration only ever needs to see a
|
// -- this block's own registration only ever needs to see a
|
||||||
@@ -192,19 +229,69 @@ where
|
|||||||
.on(
|
.on(
|
||||||
CursorSense::click_or_drag() | CursorSense::unclick(),
|
CursorSense::click_or_drag() | CursorSense::unclick(),
|
||||||
move |ctx, rsc| {
|
move |ctx, rsc| {
|
||||||
selection.borrow_mut().drag(
|
let (pos, size, cursor) = (ctx.data.pos, ctx.data.size, ctx.data.cursor.pos);
|
||||||
|
let outcome = selection.borrow_mut().drag(
|
||||||
rsc,
|
rsc,
|
||||||
list,
|
list,
|
||||||
Some((key, ctx.data.pos, ctx.data.size)),
|
Some((key, pos, size)),
|
||||||
ctx.data.cursor.pos,
|
cursor,
|
||||||
ctx.data.sense,
|
ctx.data.sense,
|
||||||
Instant::now(),
|
Instant::now(),
|
||||||
ctx.data.render,
|
ctx.data.render,
|
||||||
);
|
);
|
||||||
|
// A *tap*, decided by the same `DragArbiter` the pan and
|
||||||
|
// the selection are: a gesture that panned the list past
|
||||||
|
// this link, or held long enough to select, must not also
|
||||||
|
// follow it (`GestureOutcome::Tapped`'s doc).
|
||||||
|
if outcome == GestureOutcome::Tapped {
|
||||||
|
let byte = field.edit(rsc).byte_at(cursor, size);
|
||||||
|
let url = tap_links
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.find(|l| l.range.contains(&byte))
|
||||||
|
.map(|l| l.url.clone());
|
||||||
|
if let Some(url) = url {
|
||||||
|
log::info!("iris link: opening {url}");
|
||||||
|
<Rsc::State as OpenUrl>::open_url(ctx.state, &url);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.add(rsc);
|
.add(rsc);
|
||||||
field
|
|
||||||
|
// The column holds the *framed* widget; the field is what
|
||||||
|
// `apply_delta` writes into and what `Selection` resolves. Keeping
|
||||||
|
// the two apart is what lets a fence gain a background without the
|
||||||
|
// delta path knowing anything about frames.
|
||||||
|
let framed = match frame {
|
||||||
|
BlockFrame::Plain => field.width(rest(1)).add_strong(rsc).any(),
|
||||||
|
BlockFrame::Verbatim { fill } => field
|
||||||
|
.scrollable_on(Axis::X)
|
||||||
|
.masked()
|
||||||
|
.pad(dp(FRAME_PAD_DP))
|
||||||
|
.background(rect(fill).radius(dp(FRAME_RADIUS_DP)))
|
||||||
|
.width(rest(1))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any(),
|
||||||
|
// A `Stack` (through `background`) rather than a two-child
|
||||||
|
// `Span(Dir::RIGHT)`: the bar is drawn behind text padded past
|
||||||
|
// it, which is the same picture with one widget fewer and
|
||||||
|
// without `Span`'s provisional full-region pass. That pass is
|
||||||
|
// also what first surfaced the `mov`-then-`reposition` assert
|
||||||
|
// docs/RUST.md's P1a box records as still open, so the shape
|
||||||
|
// with fewer passes is the one to prefer here.
|
||||||
|
BlockFrame::Quote => field
|
||||||
|
.width(rest(1))
|
||||||
|
.pad(Padding {
|
||||||
|
left: dp(QUOTE_BAR_DP + FRAME_PAD_DP),
|
||||||
|
..Padding::ZERO
|
||||||
|
})
|
||||||
|
.background(rect(crate::markdown::QUOTE_BAR_COLOR).width(dp(QUOTE_BAR_DP)))
|
||||||
|
.width(rest(1))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any(),
|
||||||
|
};
|
||||||
|
(field, framed, links)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a row from a sender label plus markdown source: a column of one
|
/// Build a row from a sender label plus markdown source: a column of one
|
||||||
@@ -225,15 +312,18 @@ fn build_text_row<Rsc: HasEvents>(
|
|||||||
markdown_src: &str,
|
markdown_src: &str,
|
||||||
) -> (StrongWidget, RowBlocks)
|
) -> (StrongWidget, RowBlocks)
|
||||||
where
|
where
|
||||||
Rsc::State: FocusHost,
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
{
|
{
|
||||||
let blocks = display_blocks(markdown_src);
|
let blocks = display_blocks(markdown_src);
|
||||||
let mut column = Span::empty(Dir::DOWN).gap(dp(BLOCK_GAP_DP));
|
let mut column = Span::empty(Dir::DOWN).gap(dp(BLOCK_GAP_DP));
|
||||||
let mut fields = Vec::with_capacity(blocks.len());
|
let mut fields = Vec::with_capacity(blocks.len());
|
||||||
|
let mut links = Vec::with_capacity(blocks.len());
|
||||||
for (i, block) in blocks.iter().enumerate() {
|
for (i, block) in blocks.iter().enumerate() {
|
||||||
let field = build_block_field(rsc, list, selection.clone(), (key, i as u32), &block.source);
|
let (field, framed, block_links) =
|
||||||
|
build_block(rsc, list, selection.clone(), (key, i as u32), block);
|
||||||
fields.push(field);
|
fields.push(field);
|
||||||
column.push(field.width(rest(1)).add_strong(rsc).any());
|
links.push(block_links);
|
||||||
|
column.push(framed);
|
||||||
}
|
}
|
||||||
let column = column.add(rsc);
|
let column = column.add(rsc);
|
||||||
|
|
||||||
@@ -264,6 +354,7 @@ where
|
|||||||
RowBlocks {
|
RowBlocks {
|
||||||
blocks,
|
blocks,
|
||||||
fields,
|
fields,
|
||||||
|
links,
|
||||||
column,
|
column,
|
||||||
sender: sender.map(str::to_string),
|
sender: sender.map(str::to_string),
|
||||||
},
|
},
|
||||||
@@ -291,7 +382,7 @@ impl RowBlocks {
|
|||||||
markdown_src: &str,
|
markdown_src: &str,
|
||||||
) -> bool
|
) -> bool
|
||||||
where
|
where
|
||||||
Rsc::State: FocusHost,
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
{
|
{
|
||||||
if self.sender.as_deref() != sender {
|
if self.sender.as_deref() != sender {
|
||||||
return false;
|
return false;
|
||||||
@@ -305,32 +396,48 @@ impl RowBlocks {
|
|||||||
if new_blocks.len() < self.blocks.len() || common + 1 < self.blocks.len() {
|
if new_blocks.len() < self.blocks.len() || common + 1 < self.blocks.len() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// A block's *frame* is built around its widget once and never
|
||||||
|
// rewritten, so a block whose kind changed under the delta (the
|
||||||
|
// paragraph that a `|---|` line turns into a table) cannot take
|
||||||
|
// this path -- it would keep prose's appearance with a table's
|
||||||
|
// text in it. Only the last block can differ at all, by the check
|
||||||
|
// above.
|
||||||
|
if new_blocks.len() == self.blocks.len()
|
||||||
|
&& common < self.blocks.len()
|
||||||
|
&& new_blocks[common].kind != self.blocks[common].kind
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
debug_assert!(
|
debug_assert!(
|
||||||
self.fields.len() == self.blocks.len(),
|
self.fields.len() == self.blocks.len() && self.links.len() == self.blocks.len(),
|
||||||
"one field per block: {} fields, {} blocks",
|
"one field and one link list per block: {} fields, {} links, {} blocks",
|
||||||
self.fields.len(),
|
self.fields.len(),
|
||||||
|
self.links.len(),
|
||||||
self.blocks.len()
|
self.blocks.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
for (i, block) in new_blocks.iter().enumerate().skip(common) {
|
for (i, block) in new_blocks.iter().enumerate().skip(common) {
|
||||||
let (text, spans) = render_markdown(&block.source, BASE_SIZE);
|
match (self.fields.get(i), self.links.get(i)) {
|
||||||
match self.fields.get(i) {
|
(Some(field), Some(links)) => {
|
||||||
Some(field) => field.edit(rsc).set_with_spans(&text, spans),
|
let rendered = render_block(block, BASE_SIZE);
|
||||||
None => {
|
field
|
||||||
let field = build_block_field(
|
.edit(rsc)
|
||||||
rsc,
|
.set_with_spans(&rendered.text, rendered.spans);
|
||||||
list,
|
// Replaced together with the text: a link range left
|
||||||
selection.clone(),
|
// over from the previous delta points into a string
|
||||||
(key, i as u32),
|
// that no longer exists.
|
||||||
&block.source,
|
*links.borrow_mut() = rendered.links;
|
||||||
);
|
}
|
||||||
|
_ => {
|
||||||
|
let (field, framed, links) =
|
||||||
|
build_block(rsc, list, selection.clone(), (key, i as u32), block);
|
||||||
self.fields.push(field);
|
self.fields.push(field);
|
||||||
let child = field.width(rest(1)).add_strong(rsc).any();
|
self.links.push(links);
|
||||||
// `get_mut` marks the column dirty, which is what gets
|
// `get_mut` marks the column dirty, which is what gets
|
||||||
// the new block drawn; its removal half is the row's
|
// the new block drawn; its removal half is the row's
|
||||||
// own, since the column owns the child strongly.
|
// own, since the column owns the child strongly.
|
||||||
if let Some(column) = rsc.ui_mut().widgets.get_mut(&self.column) {
|
if let Some(column) = rsc.ui_mut().widgets.get_mut(&self.column) {
|
||||||
column.push(child);
|
column.push(framed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -348,7 +455,7 @@ fn build_single<Rsc: HasEvents>(
|
|||||||
item: &TranscriptItem,
|
item: &TranscriptItem,
|
||||||
) -> (StrongWidget, RowBlocks)
|
) -> (StrongWidget, RowBlocks)
|
||||||
where
|
where
|
||||||
Rsc::State: FocusHost,
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
{
|
{
|
||||||
let (sender, markdown_src) = item_content(item);
|
let (sender, markdown_src) = item_content(item);
|
||||||
build_text_row(rsc, list, selection, key, sender, &markdown_src)
|
build_text_row(rsc, list, selection, key, sender, &markdown_src)
|
||||||
@@ -366,7 +473,7 @@ fn build_tools<Rsc: HasEvents>(
|
|||||||
calls: Vec<TranscriptItem>,
|
calls: Vec<TranscriptItem>,
|
||||||
) -> StrongWidget
|
) -> StrongWidget
|
||||||
where
|
where
|
||||||
Rsc::State: FocusHost,
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
{
|
{
|
||||||
let expanded = Rc::new(RefCell::new(false));
|
let expanded = Rc::new(RefCell::new(false));
|
||||||
// `.add_strong` (not `.add`) because nothing else in the tree holds a
|
// `.add_strong` (not `.add`) because nothing else in the tree holds a
|
||||||
@@ -401,7 +508,7 @@ where
|
|||||||
full: &str,
|
full: &str,
|
||||||
) -> StrongWidget
|
) -> StrongWidget
|
||||||
where
|
where
|
||||||
Rsc::State: FocusHost,
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
{
|
{
|
||||||
// Every block of the previous content goes first: collapsing a
|
// Every block of the previous content goes first: collapsing a
|
||||||
// five-block expansion back to a one-line summary registers only
|
// five-block expansion back to a one-line summary registers only
|
||||||
@@ -462,7 +569,7 @@ pub fn build_row<Rsc: HasEvents>(
|
|||||||
row: &FoldedRow,
|
row: &FoldedRow,
|
||||||
) -> (RowKey, StrongWidget, Option<RowBlocks>)
|
) -> (RowKey, StrongWidget, Option<RowBlocks>)
|
||||||
where
|
where
|
||||||
Rsc::State: FocusHost,
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
{
|
{
|
||||||
match row {
|
match row {
|
||||||
FoldedRow::Single(item) => {
|
FoldedRow::Single(item) => {
|
||||||
|
|||||||
@@ -245,6 +245,10 @@ impl Selection {
|
|||||||
/// than the last one. `render` is `CursorData`'s own field -- what
|
/// than the last one. `render` is `CursorData`'s own field -- what
|
||||||
/// `DragGesture` needs to take pointer capture.
|
/// `DragGesture` needs to take pointer capture.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
/// Returns what the gesture decided this frame, so a caller with its
|
||||||
|
/// own meaning for a *tap* -- a row's link handler -- reads it from
|
||||||
|
/// the one arbiter that already knows, rather than timing a second
|
||||||
|
/// one beside it (which would disagree the moment either changed).
|
||||||
pub fn drag(
|
pub fn drag(
|
||||||
&mut self,
|
&mut self,
|
||||||
ui: &mut impl UiRsc,
|
ui: &mut impl UiRsc,
|
||||||
@@ -254,7 +258,7 @@ impl Selection {
|
|||||||
sense: CursorSense,
|
sense: CursorSense,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
render: &UiRenderState,
|
render: &UiRenderState,
|
||||||
) {
|
) -> GestureOutcome {
|
||||||
if matches!(sense, CursorSense::PressStart(_)) {
|
if matches!(sense, CursorSense::PressStart(_)) {
|
||||||
// A fresh touch-down cancels any fling still coasting from
|
// A fresh touch-down cancels any fling still coasting from
|
||||||
// the previous gesture -- `List::fling`'s own doc, and
|
// the previous gesture -- `List::fling`'s own doc, and
|
||||||
@@ -291,8 +295,12 @@ impl Selection {
|
|||||||
// tap/long-press that never left `Undecided` -- exactly what
|
// tap/long-press that never left `Undecided` -- exactly what
|
||||||
// `DragGesture`'s `Some(v)` already encodes.
|
// `DragGesture`'s `Some(v)` already encodes.
|
||||||
GestureOutcome::Released(Some(v)) => list(ui).fling(-v),
|
GestureOutcome::Released(Some(v)) => list(ui).fling(-v),
|
||||||
GestureOutcome::Released(None) => {}
|
// A tap is nobody's business here -- `row.rs` reads it from
|
||||||
|
// the returned outcome and follows a link if one was under
|
||||||
|
// the finger.
|
||||||
|
GestureOutcome::Released(None) | GestureOutcome::Tapped => {}
|
||||||
}
|
}
|
||||||
|
outcome
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The concatenated selected text, in row order, `None` if nothing is
|
/// The concatenated selected text, in row order, `None` if nothing is
|
||||||
|
|||||||
Reference in new issue
Block a user