iris: Scroll pans on a finger drag; a vertical drag in a focused field scrolls rather than selects
IRIS_TODO.md's "the composer has no touch-drag scroll". `Scroll::drag`
takes its pan from the same `sense::DragGesture` `List` is driven by --
arbitration, DRAG_SLOP, velocity and pointer capture all stay in sense.rs
and only what a committed pan *means* is decided per caller -- and
`WidgetLike::scrollable()` registers it beside the wheel handler it already
registered, so every scroll area pans on a finger with nothing added at the
call site. No fling: `Scroll` has no per-frame tick to animate one and the
areas it wraps are at most a screenful. `Scroll::amt()` exposes the pan
position.
`attr.rs`'s `on_press` treated an already-focused field as the plain
click_or_drag case, so every Pressing frame extended a selection. It now
applies the same DRAG_SLOP rule its unfocused branch already did: a press
past the slop vertically abandons its pending selection for the rest of the
gesture, so the scroll area around the field wins it. That is Android
EditText's own behaviour and it is what lets a swipe up over the composer
scroll instead of dragging a highlight through what you typed.
Also fixed, found doing it: `ActiveData::mask` stored the mask a widget
*set* rather than the one it was drawn *under*, and `redraw` feeds that
field back in as the inherited mask -- so a targeted redraw of any `Masked`
handed it its own mask and aborted on `set_mask`'s nested-mask assert. A
real abort on the emulator, `assertion failed: self.mask == MaskIdx::NONE`.
And the per-frame orphan guard from 76b1f99 is now a count comparison
(O(active widgets)); the O(primitives) walk only runs to build the failure
message, because running it per frame made a debug build on the emulator too
slow to finish a bench run at all.
Tests: four in scroll.rs (pan past the slop, a tap inside it, a horizontal
drag, the end clamp), `a_finger_drag_over_a_scroll_area_pans_it` in
sense_tests.rs driving the whole registration/dispatch/capture path (fails
with "got 0" without the new registration), and
`redrawing_a_masked_widget_does_not_nest_its_own_mask` in layout_tests.rs
(aborts on the pre-fix code).
The composer itself is deliberately still not `.scrollable()`: `Scroll`
measures against the window rather than its own offered box, so inside the
`MaxSize` capping it at six lines it pans the field out of the bar --
measured, reverted and written down in RUST.md and DECISIONS.md.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
76b1f99277
commit
fb6b459c2c
13 files changed
+608
-31
No files matched your search
@@ -5,6 +5,29 @@ 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 (stale-primitives and touch-scroll pass)
|
||||||
|
|
||||||
|
- **A vertical drag inside a focused composer now scrolls rather than
|
||||||
|
selects.** Android's own `EditText` does this -- a vertical drag scrolls
|
||||||
|
the field, and only a long press starts a selection -- so the platform
|
||||||
|
decided it. What it costs: you can no longer drag straight down inside
|
||||||
|
the composer to select several lines of what you typed; use a long press
|
||||||
|
and then drag, or drag sideways. Say if that trade is wrong for you.
|
||||||
|
- **`Scroll` gets a finger pan but no fling.** `List` flings; a scroll area
|
||||||
|
does not, because it has no per-frame tick to animate one and the areas
|
||||||
|
it wraps are at most a screenful (Android does not fling a six-line text
|
||||||
|
box either). Easy to add later if a scroll area ever wraps something long.
|
||||||
|
- **The composer still does not scroll its overflowed text**, though the
|
||||||
|
mechanism it needs is now in place. Wrapping the field in `.scrollable()`
|
||||||
|
was tried and reverted the same day: `Scroll` measures its content and
|
||||||
|
container against the *window*, so inside the `MaxSize` that caps the
|
||||||
|
composer at six lines the two are in different spaces and the field pans
|
||||||
|
itself entirely out of the bar (measured on the emulator with 474
|
||||||
|
characters in it -- the bar collapsed to its padding). Fixing that means
|
||||||
|
`Scroll` measuring against its own offered box, which is a change to a
|
||||||
|
widget the transcript and the bench shell both use, so it is its own
|
||||||
|
piece of work rather than a rider on this one.
|
||||||
|
|
||||||
## 2026-09-06 (defect pass)
|
## 2026-09-06 (defect pass)
|
||||||
|
|
||||||
- **The keyboard-open diagnostics overlay is gone; the capture only
|
- **The keyboard-open diagnostics overlay is gone; the capture only
|
||||||
|
|||||||
@@ -8,6 +8,48 @@ 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: `Scroll` pans on a finger drag, and a vertical drag in a focused text field no longer selects
|
||||||
|
|
||||||
|
Three related public changes, all in aid of IRIS_TODO.md's "the composer
|
||||||
|
has no touch-drag scroll".
|
||||||
|
|
||||||
|
**`Scroll::drag(render, id, sense, pos_window, now)` is new**, and
|
||||||
|
`WidgetLike::scrollable()` now registers it alongside the wheel handler it
|
||||||
|
already registered -- so anything built with `.scrollable()` pans on a
|
||||||
|
finger drag with no extra wiring at the call site. It goes through the same
|
||||||
|
`sense::DragGesture` that `transcript-ui::Selection::drag` drives `List`
|
||||||
|
with (arbitration, `DRAG_SLOP`, velocity, pointer capture), rather than a
|
||||||
|
second copy of that widget's wiring: `DragGesture` owns the mechanics and
|
||||||
|
each caller decides only what a committed pan *means*. `Scroll::amt()` is
|
||||||
|
new too, the read-only pan position a test or a scroll indicator needs.
|
||||||
|
|
||||||
|
There is deliberately **no fling** on `Scroll`. Unlike `List` it has no
|
||||||
|
per-frame tick to animate one with (`List::set_redraw_handle`/`tick_fling`),
|
||||||
|
and the areas it wraps today are at most a screenful, where Android does not
|
||||||
|
fling either. The released velocity is dropped rather than approximated.
|
||||||
|
|
||||||
|
**A vertical drag inside an already-focused `TextEdit` no longer extends a
|
||||||
|
selection.** `iris::attr`'s `on_press` used to treat a focused field as the
|
||||||
|
plain `click_or_drag` case -- every `Pressing` frame updated the selection.
|
||||||
|
It now applies the same `DRAG_SLOP` rule the *unfocused* branch already
|
||||||
|
applied: a press that moves past the slop vertically abandons its pending
|
||||||
|
selection for the rest of the gesture, so the scroll area around the field
|
||||||
|
gets the drag instead. Horizontal drag-to-select is unchanged, and a long
|
||||||
|
press still starts a selection. This is Android's own `EditText` behaviour
|
||||||
|
(a vertical drag scrolls; only a long press selects), and it is what makes
|
||||||
|
"swipe up over the composer to scroll the transcript" work without dragging
|
||||||
|
a highlight through the message you were typing.
|
||||||
|
|
||||||
|
**`UiRenderState::orphaned_primitives()` is new**, and `update` now
|
||||||
|
`debug_assert!`s (debug builds only) that nothing is orphaned. An orphan is
|
||||||
|
a primitive still bound for the GPU that no live `ActiveData` names -- a
|
||||||
|
copy nothing can move, clip or free. That was the doubled `Compacted:` row
|
||||||
|
on the phone; see the same date's commit `76b1f99` and docs/RUST.md. The
|
||||||
|
per-frame guard is a count comparison (O(active widgets)); the walk that
|
||||||
|
names the offenders only runs when the counts disagree, because the walk is
|
||||||
|
O(primitives) and made a debug build on a phone too slow to finish a
|
||||||
|
benchmark run.
|
||||||
|
|
||||||
## 2026-09-06: a tap on a text field always leaves a caret
|
## 2026-09-06: a tap on a text field always leaves a caret
|
||||||
|
|
||||||
`TextEditCtx::select` used to compare the tap position against the
|
`TextEditCtx::select` used to compare the tap position against the
|
||||||
|
|||||||
+24
-8
@@ -201,12 +201,19 @@ agent takes them without colliding with that pass's `bench_client.rs`/
|
|||||||
a capped/scrollable height, bottom padding tied to the IME/nav-bar
|
a capped/scrollable height, bottom padding tied to the IME/nav-bar
|
||||||
inset) -- structurally in place and unit-tested, but its own visual
|
inset) -- structurally in place and unit-tested, but its own visual
|
||||||
correctness cannot be screenshotted until text actually renders.
|
correctness cannot be screenshotted until text actually renders.
|
||||||
- [ ] **The composer has no touch-drag scroll for overflowing text.** The
|
- [~] **The composer has no touch-drag scroll for overflowing text.**
|
||||||
2026-09-06 rebuild caps the field at ~6 lines and wraps it in
|
**The mechanism is in, the composer is not, 2026-09-06.** `Scroll::drag`
|
||||||
`.scrollable()` for a wheel/trackpad scroll, but a real finger drag over
|
takes its pan from the same `sense::DragGesture` `List` uses and
|
||||||
text that has overflowed the cap does not scroll it -- `Scroll`'s touch
|
`WidgetLike::scrollable()` registers it beside the wheel handler, so
|
||||||
handling is a follow-up, the same shape `List`'s own touch-drag pan
|
every scroll area in the codebase now pans on a finger (no fling -- see
|
||||||
needed before I3/I5.
|
IRIS.md). A vertical drag inside a focused field no longer extends a
|
||||||
|
selection, matching Android's `EditText`. But the composer field is
|
||||||
|
**not** wrapped in `.scrollable()` -- the note above was describing
|
||||||
|
intent, not the code -- and wrapping it was tried and reverted: `Scroll`
|
||||||
|
measures against the window rather than its own offered box, so inside
|
||||||
|
the `MaxSize` that caps the field at six lines it pans itself out of the
|
||||||
|
bar entirely (emulator, 474 characters, the bar collapsed to its
|
||||||
|
padding). docs/RUST.md's plan box has the numbers and the next step.
|
||||||
|
|
||||||
## From the phone, 2026-09-06, 11:39 (build delivered 02:07, commit 543f6d9)
|
## From the phone, 2026-09-06, 11:39 (build delivered 02:07, commit 543f6d9)
|
||||||
|
|
||||||
@@ -247,8 +254,17 @@ agent ticks it here with the evidence.
|
|||||||
field the composer may still read as pixels or dp; a stale value from
|
field the composer may still read as pixels or dp; a stale value from
|
||||||
before the first `on_insets_changed`. Reproduce with the phone's
|
before the first `on_insets_changed`. Reproduce with the phone's
|
||||||
screen size and density on the emulator before guessing.
|
screen size and density on the emulator before guessing.
|
||||||
- [ ] **"Swiping still gets caught by the grey bar but keeps working
|
- [~] **"Swiping still gets caught by the grey bar but keeps working
|
||||||
after I go past it."** Not closeable from the emulator, annotated
|
after I go past it."** Improved 2026-09-06 by the focused-field rule
|
||||||
|
below, still needs her phone to close. `attr.rs`'s `on_press` treated an
|
||||||
|
already-focused composer as the plain drag-to-select case, so a swipe
|
||||||
|
starting inside it dragged a highlight through the typed text for the
|
||||||
|
whole gesture; it now abandons that the moment the press passes
|
||||||
|
`DRAG_SLOP` vertically (Android `EditText`'s own rule), which removes one
|
||||||
|
of the two things that made the bar feel like it caught the swipe. The
|
||||||
|
residual `DRAG_SLOP` measured from the boundary crossing, described
|
||||||
|
below, is unchanged. Original note follows.
|
||||||
|
Not closeable from the emulator, annotated
|
||||||
2026-09-06 after the `DragGesture` merge. `attr.rs`'s `on_press` never
|
2026-09-06 after the `DragGesture` merge. `attr.rs`'s `on_press` never
|
||||||
calls `capture_pointer` and never consumes a `Pressing` frame past
|
calls `capture_pointer` and never consumes a `Pressing` frame past
|
||||||
`DRAG_SLOP` (it just stops watching), so once the finger's *current*
|
`DRAG_SLOP` (it just stops watching), so once the finger's *current*
|
||||||
|
|||||||
+84
-14
@@ -226,20 +226,90 @@ closes it.
|
|||||||
at the top of `update` that it is empty between frames. Her build
|
at the top of `update` that it is empty between frames. Her build
|
||||||
has both.
|
has both.
|
||||||
|
|
||||||
- [ ] **Stale primitives, the phone's half** — the `Compacted:` row
|
- [x] **Stale primitives, the phone's half** — **root-caused and fixed
|
||||||
drawn twice, overlapping, and once more below the composer
|
2026-09-06, commit `76b1f99`.** It was neither `Span::draw` nor
|
||||||
(`docs/bench/iris-phone-v2-2026-09-06.md`). Everything the box
|
`List`: `UiRenderState::draw_inner` *read* `needs_redraw` without
|
||||||
above lists as ruled out is ruled out; what is left needs the
|
consuming it, and used it to skip the whole `if let Some(active)`
|
||||||
phone, and it is worth noting the row is drawn *below the
|
block — **including the `remove(id, false)` that frees a redrawn
|
||||||
composer*, i.e. outside the list's own clip, which points at
|
widget's previous primitives**. So a widget that was both already
|
||||||
`List`'s row placement rather than at `Span::draw`'s two-phase
|
active and marked dirty, and was reached by an **ancestor's** draw
|
||||||
placement the earlier passes assumed. The `iris surface:`/`iris
|
rather than by `redraw_updates` picking it first (the order a
|
||||||
insets:` log lines added 2026-09-06 are in the build she is
|
`HashSet` makes arbitrary, which is why it was intermittent), wrote
|
||||||
holding, so her next `adb logcat | grep -i iris` says what the
|
a second full set of primitives and then had `active.insert`
|
||||||
frame it happens on was doing.
|
overwrite the only handles that could ever have freed the first
|
||||||
- [ ] **Composer touch-drag scroll** for overflowed text — now that
|
set. Those instances stay in the layer's buffer for the life of the
|
||||||
dragging is a default-input `DragGesture`, `Scroll` should get its
|
process, with a leaked move slot and leaked mask refs, redrawn every
|
||||||
touch pan from the same mechanism `List` uses, not a copy.
|
frame at whatever region they last had — and `List` sets no mask, so
|
||||||
|
a row measured at `GENEROUS_PADDING` leaves its ghost outside the
|
||||||
|
list's own box, which is the copy below the composer.
|
||||||
|
`Painter::draw_twice` (`List::place`'s measurement pass) reaches
|
||||||
|
`draw_inner` twice for one id in one frame and so hits the same
|
||||||
|
fault with no ancestor involved.
|
||||||
|
**Fix**: consume the mark at the top of `draw_inner` — this call *is*
|
||||||
|
the redraw it asked for — and free the old primitives on the dirty
|
||||||
|
path too.
|
||||||
|
**Why the earlier passes could not see it**: `replacing_the_last_row_
|
||||||
|
many_times_does_not_leak_primitives` counts *widgets*, and the
|
||||||
|
orphan's owner is very much alive; it is an earlier set of that same
|
||||||
|
widget's primitives that is stranded.
|
||||||
|
**Guard**: `UiRenderState::orphaned_primitives()` names every live
|
||||||
|
instance no `ActiveData` owns, and `update` `debug_assert!`s it empty
|
||||||
|
every frame in debug builds. The per-frame form is a *count*
|
||||||
|
comparison (`primitive_counts_agree`, O(active widgets)); the
|
||||||
|
O(primitives) walk only runs to build the failure message, because
|
||||||
|
running it per frame made a debug build on the emulator too slow to
|
||||||
|
finish a bench run at all (260s timeout, no report).
|
||||||
|
**Test**: `an_ancestor_redrawing_a_dirty_row_leaves_no_stale_copy`
|
||||||
|
(`iris/src/widget/list.rs`), which fails on the pre-fix code with
|
||||||
|
`1 primitive(s) survived their own widget's redraw`.
|
||||||
|
**Emulator evidence, 2026-09-06** (this checkout's `ai-app-2` AVD,
|
||||||
|
`build-apk.sh debug --abi x86_64 --features "transcript-screen bench
|
||||||
|
force-gles"`, a **debug** build so the guard is live): a complete
|
||||||
|
`run-bench.sh` run — 3,142 frames over 147s across the fling, the
|
||||||
|
400-event stream (which is 400 `apply` calls including the fixture's
|
||||||
|
compaction event), the typing and the keyboard phases — with the
|
||||||
|
assert firing zero times and `logcat` showing no abort. That is the
|
||||||
|
whole of the phone's reported scenario exercised with the invariant
|
||||||
|
checked on every frame.
|
||||||
|
- [~] **Composer touch-drag scroll** for overflowed text — **the
|
||||||
|
mechanism is done, the composer is not.** `Scroll::drag`
|
||||||
|
(`iris/src/widget/position/scroll.rs`) takes its pan from the same
|
||||||
|
`sense::DragGesture` `List` is driven by, and
|
||||||
|
`WidgetLike::scrollable()` registers it beside the wheel handler, so
|
||||||
|
every `.scrollable()` in the codebase pans on a finger with nothing
|
||||||
|
added at the call site. No fling (`Scroll` has no per-frame tick and
|
||||||
|
the areas it wraps are at most a screenful) — see IRIS.md and
|
||||||
|
DECISIONS.md. A vertical drag inside a *focused* field no longer
|
||||||
|
extends a selection either (`attr.rs`'s `on_press` now applies the
|
||||||
|
same `DRAG_SLOP` rule its unfocused branch already did), which is
|
||||||
|
Android `EditText`'s own behaviour and what lets the scroll area
|
||||||
|
around a field win the gesture.
|
||||||
|
**Tests**: four in `scroll.rs` (pan past the slop, a tap inside it,
|
||||||
|
a horizontal drag, the end clamp) plus
|
||||||
|
`a_finger_drag_over_a_scroll_area_pans_it` in `sense_tests.rs`, which
|
||||||
|
drives the whole path — `scrollable()`'s registration, `run_sensors`'
|
||||||
|
dispatch, `Scroll::drag`, arbitration and pointer capture — and fails
|
||||||
|
with `got 0` if the registration is removed.
|
||||||
|
**What is left, with the measurement**: wrapping the composer's field
|
||||||
|
in `.scrollable().masked()` was tried and reverted the same day.
|
||||||
|
`Scroll` resolves `content_len`/`container_len` against
|
||||||
|
`Painter::output_size` — the whole window — so inside the `MaxSize`
|
||||||
|
that caps the composer at six lines the two are in different spaces
|
||||||
|
and the field pans itself entirely out of the bar: measured on the
|
||||||
|
emulator with 474 characters in it (`iris text render: ...
|
||||||
|
size=(1016.7, 623.7)` against a 441px cap) the bar collapsed to its
|
||||||
|
padding with no text in it. Making `Scroll` measure against its own
|
||||||
|
offered box is the next step, and it touches a widget the transcript
|
||||||
|
and the bench shell both use.
|
||||||
|
One real bug **was** found and fixed on the way (`ActiveData::mask`
|
||||||
|
stored the mask a widget *set* rather than the one it was drawn
|
||||||
|
*under*, and `redraw` feeds that field straight back in as the
|
||||||
|
inherited mask — so a targeted redraw of any `Masked` handed it its
|
||||||
|
own mask and aborted on `set_mask`'s nested-mask assert; that is a
|
||||||
|
real abort on the emulator, `assertion failed: self.mask ==
|
||||||
|
MaskIdx::NONE`, reproduced as
|
||||||
|
`redrawing_a_masked_widget_does_not_nest_its_own_mask` in
|
||||||
|
`layout_tests.rs`).
|
||||||
- [ ] **Streaming re-layout** (IRIS_TODO.md's last section) — after the
|
- [ ] **Streaming re-layout** (IRIS_TODO.md's last section) — after the
|
||||||
above, since they make the stream phase unrepresentative today.
|
above, since they make the stream phase unrepresentative today.
|
||||||
- [x] **client-core prerequisites for P1, in parallel** (pure Rust,
|
- [x] **client-core prerequisites for P1, in parallel** (pure Rust,
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ overlapping, and once more below the composer bar: primitives of a
|
|||||||
replaced/removed row surviving in the GPU buffers, the same shape as the
|
replaced/removed row surviving in the GPU buffers, the same shape as the
|
||||||
header drawn twice after a keyboard resize.
|
header drawn twice after a keyboard resize.
|
||||||
|
|
||||||
|
**Root-caused and fixed 2026-09-06** (commit `76b1f99`): the diagnosis in
|
||||||
|
that sentence was right and the location was not -- `UiRenderState::
|
||||||
|
draw_inner` read the `needs_redraw` mark without consuming it and skipped
|
||||||
|
the branch that frees a redrawn widget's old primitives. docs/RUST.md's
|
||||||
|
"Stale primitives, the phone's half" box has the full account, the guard
|
||||||
|
(`orphaned_primitives`, `debug_assert`ed every frame) and the emulator run
|
||||||
|
that exercises it.
|
||||||
|
|
||||||
```
|
```
|
||||||
iris bench report
|
iris bench report
|
||||||
per phase:
|
per phase:
|
||||||
|
|||||||
@@ -278,6 +278,14 @@ impl Primitives {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many instances are still bound for the GPU -- the O(1) half of
|
||||||
|
/// the orphan check, so the O(primitives) walk below only runs on a
|
||||||
|
/// frame that already looks wrong. See
|
||||||
|
/// [`crate::UiRenderState::orphaned_primitives`].
|
||||||
|
pub fn live_count(&self) -> usize {
|
||||||
|
(self.instances.len() - self.free.len()) + (self.images.len() - self.image_free.len())
|
||||||
|
}
|
||||||
|
|
||||||
/// Every instance that is still bound for the GPU, as `(inst_idx,
|
/// Every instance that is still bound for the GPU, as `(inst_idx,
|
||||||
/// owner, is_image)` -- everything except the slots already handed to
|
/// owner, is_image)` -- everything except the slots already handed to
|
||||||
/// [`Self::free`] and waiting for [`Self::apply_free`] to compact them
|
/// [`Self::free`] and waiting for [`Self::apply_free`] to compact them
|
||||||
|
|||||||
@@ -140,11 +140,7 @@ impl UiRenderState {
|
|||||||
self.redraw_updates(rsc);
|
self.redraw_updates(rsc);
|
||||||
}
|
}
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
debug_assert!(
|
debug_assert!(self.primitive_counts_agree(), "{}", self.orphan_report(rsc),);
|
||||||
self.orphaned_primitives().is_empty(),
|
|
||||||
"{}",
|
|
||||||
self.orphan_report(rsc),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn redraw_all(&mut self, root: Option<&StrongWidget>, rsc: &mut dyn UiRsc) {
|
fn redraw_all(&mut self, root: Option<&StrongWidget>, rsc: &mut dyn UiRsc) {
|
||||||
@@ -291,6 +287,16 @@ impl UiRenderState {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The mask this widget was drawn *under*, kept aside because
|
||||||
|
// `Painter::set_mask` overwrites `painter.mask` with the widget's
|
||||||
|
// own new one -- and `ActiveData::mask`'s only consumer is
|
||||||
|
// `redraw`, which feeds it back in as the *inherited* mask. Storing
|
||||||
|
// the set one instead handed a `Masked` its own mask on every
|
||||||
|
// targeted redraw, tripping `set_mask`'s nested-mask assert:
|
||||||
|
// `assertion failed: self.mask == MaskIdx::NONE`, an abort the
|
||||||
|
// first time the composer's scroll area was redrawn on the
|
||||||
|
// emulator.
|
||||||
|
let inherited_mask = mask;
|
||||||
let mut painter = Painter {
|
let mut painter = Painter {
|
||||||
state: self,
|
state: self,
|
||||||
region,
|
region,
|
||||||
@@ -314,7 +320,7 @@ impl UiRenderState {
|
|||||||
state: _,
|
state: _,
|
||||||
rsc: _,
|
rsc: _,
|
||||||
region,
|
region,
|
||||||
mask,
|
mask: _,
|
||||||
move_slot,
|
move_slot,
|
||||||
textures,
|
textures,
|
||||||
primitives,
|
primitives,
|
||||||
@@ -331,7 +337,7 @@ impl UiRenderState {
|
|||||||
textures,
|
textures,
|
||||||
primitives,
|
primitives,
|
||||||
children,
|
children,
|
||||||
mask,
|
mask: inherited_mask,
|
||||||
layer,
|
layer,
|
||||||
size,
|
size,
|
||||||
move_slot,
|
move_slot,
|
||||||
@@ -534,6 +540,19 @@ impl UiRenderState {
|
|||||||
orphans
|
orphans
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether every primitive still bound for the GPU is owned by a live
|
||||||
|
/// widget, decided by counting rather than by walking: an orphan is a
|
||||||
|
/// live instance no `ActiveData` names, so it can only ever make the
|
||||||
|
/// live count exceed the owned one. O(active widgets) -- a few dozen --
|
||||||
|
/// against [`Self::orphaned_primitives`]'s O(primitives), which on a
|
||||||
|
/// transcript is tens of thousands and made a debug build on a phone
|
||||||
|
/// too slow to finish a benchmark run.
|
||||||
|
fn primitive_counts_agree(&self) -> bool {
|
||||||
|
let live: usize = self.layers.iter().map(|(_, p)| p.live_count()).sum();
|
||||||
|
let owned: usize = self.active.values().map(|a| a.primitives.len()).sum();
|
||||||
|
live == owned
|
||||||
|
}
|
||||||
|
|
||||||
/// The message [`Self::update`]'s orphan assert prints -- built here
|
/// The message [`Self::update`]'s orphan assert prints -- built here
|
||||||
/// rather than inline so the (allocating, O(primitives)) work only
|
/// rather than inline so the (allocating, O(primitives)) work only
|
||||||
/// happens on the failing path.
|
/// happens on the failing path.
|
||||||
|
|||||||
+33
-2
@@ -129,8 +129,39 @@ fn on_press(
|
|||||||
sense: CursorSense,
|
sense: CursorSense,
|
||||||
) {
|
) {
|
||||||
if state.is_focused(id) {
|
if state.is_focused(id) {
|
||||||
let recent = matches!(sense, CursorSense::PressStart(_)) && state.recent_click();
|
// Already focused, so there is no keyboard to withhold -- but a
|
||||||
id.edit(rsc).select(pos, size, sense.is_dragging(), recent);
|
// vertical drag still is not a selection. Android's own `EditText`
|
||||||
|
// scrolls its overflowed text on a vertical drag and starts a
|
||||||
|
// selection only from a long press; a scroll area wrapping this
|
||||||
|
// field (`Scroll::drag`) is what actually pans, and it needs the
|
||||||
|
// first frames of the gesture not to have selected anything behind
|
||||||
|
// it before it crosses `DRAG_SLOP` and takes pointer capture.
|
||||||
|
// `press_origin` carries the same meaning here as in the unfocused
|
||||||
|
// branch below -- "this gesture is still eligible", cleared the
|
||||||
|
// moment it becomes a drag -- so there is one flag, not two.
|
||||||
|
match sense {
|
||||||
|
CursorSense::PressStart(_) => {
|
||||||
|
let recent = state.recent_click();
|
||||||
|
id.edit(rsc).text.press_origin = Some(pos);
|
||||||
|
id.edit(rsc).select(pos, size, false, recent);
|
||||||
|
}
|
||||||
|
CursorSense::Pressing(_) | CursorSense::PressEnd(_) => {
|
||||||
|
let mut ctx = id.edit(rsc);
|
||||||
|
let Some(origin) = ctx.text.press_origin else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (dx, dy) = (pos.x - origin.x, pos.y - origin.y);
|
||||||
|
if dy.abs() > DRAG_SLOP && dy.abs() >= dx.abs() {
|
||||||
|
ctx.text.press_origin = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if matches!(sense, CursorSense::PressEnd(_)) {
|
||||||
|
ctx.text.press_origin = None;
|
||||||
|
}
|
||||||
|
ctx.select(pos, size, true, false);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,37 @@ fn hit_testing_follows_a_scrolled_widget() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `ActiveData::mask` is the mask a widget was drawn **under**, not the one
|
||||||
|
/// it set for itself -- `redraw` feeds it straight back in as the inherited
|
||||||
|
/// mask, so storing the set one hands a `Masked` its own mask the second
|
||||||
|
/// time round and trips `Painter::set_mask`'s nested-mask assert. That was
|
||||||
|
/// an abort (`assertion failed: self.mask == MaskIdx::NONE`) the first time
|
||||||
|
/// the composer's new scroll area was redrawn on the emulator; a targeted
|
||||||
|
/// redraw of a `Masked` is what any real screen does whenever anything
|
||||||
|
/// inside it changes.
|
||||||
|
#[test]
|
||||||
|
fn redrawing_a_masked_widget_does_not_nest_its_own_mask() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let (_scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 8);
|
||||||
|
let masked = rsc.ui.widgets.add_strong(Masked { inner: inner_root });
|
||||||
|
let masked_id = masked.id();
|
||||||
|
let root = masked.any();
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((800.0, 600.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
|
||||||
|
render.redraw(masked_id, &mut rsc);
|
||||||
|
render.redraw(masked_id, &mut rsc);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
render.active.get(&masked_id).unwrap().mask,
|
||||||
|
MaskIdx::NONE,
|
||||||
|
"a `Masked` at the root is drawn under no mask of its own"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_mask_stays_put_while_its_scrolled_content_moves() {
|
fn a_mask_stays_put_while_its_scrolled_content_moves() {
|
||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
|
|||||||
@@ -246,3 +246,73 @@ fn capturing_one_widget_starves_every_other_widget_of_events() {
|
|||||||
"while a's drag holds capture, b must see no hover at all"
|
"while a's drag holds capture, b must see no hover at all"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// IRIS_TODO.md's "the composer has no touch-drag scroll": `Scroll` only
|
||||||
|
/// answered a wheel, so a finger drag over overflowed text did nothing.
|
||||||
|
/// End-to-end over the real wiring -- `scrollable()`'s own registration,
|
||||||
|
/// `run_sensors`' dispatch, `Scroll::drag`, `DragGesture`'s arbitration and
|
||||||
|
/// pointer capture -- rather than only `Scroll::drag`'s own unit tests in
|
||||||
|
/// `scroll.rs`, because the registration is exactly the half those cannot
|
||||||
|
/// see.
|
||||||
|
#[test]
|
||||||
|
fn a_finger_drag_over_a_scroll_area_pans_it() {
|
||||||
|
let mut rsc = SenseRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1000px of content in a 100px window: room to pan.
|
||||||
|
let scroll_strong = rect(UiColor::WHITE)
|
||||||
|
.height(Len::abs(1000.0))
|
||||||
|
.scrollable()
|
||||||
|
.add_strong(&mut rsc);
|
||||||
|
let scroll = scroll_strong.weak();
|
||||||
|
let root = scroll_strong.any();
|
||||||
|
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((100.0, 100.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
// `Scroll` reads its content length back from the draw it just did, so
|
||||||
|
// the frame after is the first one that knows there is anything to pan
|
||||||
|
// -- the one-frame lag LAYOUT.md section 4 documents. `scroll(0.0)` is
|
||||||
|
// how `layout_tests.rs` asks for that second frame, and it also drops
|
||||||
|
// `snap_end`, leaving this parked at the start of the content.
|
||||||
|
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
assert_eq!(rsc.ui.widgets.get(&scroll).unwrap().amt(), 0.0);
|
||||||
|
|
||||||
|
let mut state = ();
|
||||||
|
let mut down = cursor_at((50.0, 80.0).into());
|
||||||
|
down.buttons.left = ActivationState::Start;
|
||||||
|
render.run_sensors(&mut rsc, &mut state, down, (100.0, 100.0).into());
|
||||||
|
assert_eq!(
|
||||||
|
rsc.ui.widgets.get(&scroll).unwrap().amt(),
|
||||||
|
0.0,
|
||||||
|
"the touch-down alone must not move anything"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Inside the slop: still a tap as far as anything can tell.
|
||||||
|
let mut nudge = cursor_at((50.0, 80.0 - (DRAG_SLOP - 1.0)).into());
|
||||||
|
nudge.buttons.left = ActivationState::On;
|
||||||
|
render.run_sensors(&mut rsc, &mut state, nudge, (100.0, 100.0).into());
|
||||||
|
assert_eq!(
|
||||||
|
rsc.ui.widgets.get(&scroll).unwrap().amt(),
|
||||||
|
0.0,
|
||||||
|
"a press inside DRAG_SLOP must not scroll"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Past it, upward: the content follows the finger up, which for this
|
||||||
|
// widget means more `amt`.
|
||||||
|
let mut drag = cursor_at((50.0, 80.0 - (DRAG_SLOP + 40.0)).into());
|
||||||
|
drag.buttons.left = ActivationState::On;
|
||||||
|
render.run_sensors(&mut rsc, &mut state, drag, (100.0, 100.0).into());
|
||||||
|
let after = rsc.ui.widgets.get(&scroll).unwrap().amt();
|
||||||
|
assert!(
|
||||||
|
(after - 40.0).abs() < 0.01,
|
||||||
|
"expected the 40px past the slop to pan it, got {after}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the gesture holds the pointer, so the rest of it reaches this
|
||||||
|
// widget even once the finger leaves its box.
|
||||||
|
assert_eq!(render.captured_pointer(), Some(scroll.id()));
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
|
use crate::sense::{DragGesture, GestureOutcome};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
pub struct Scroll {
|
pub struct Scroll {
|
||||||
inner: StrongWidget,
|
inner: StrongWidget,
|
||||||
@@ -7,6 +9,12 @@ pub struct Scroll {
|
|||||||
snap_end: bool,
|
snap_end: bool,
|
||||||
container_len: f32,
|
container_len: f32,
|
||||||
content_len: f32,
|
content_len: f32,
|
||||||
|
/// Touch panning, from the same `DragGesture` `List` is driven by
|
||||||
|
/// (`transcript-ui::Selection::drag`) rather than a second copy of its
|
||||||
|
/// wiring: arbitration, `DRAG_SLOP` and pointer capture all live in
|
||||||
|
/// `sense.rs` and only what a committed pan *means* is decided here.
|
||||||
|
/// See [`Self::drag`].
|
||||||
|
gesture: DragGesture,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Widget for Scroll {
|
impl Widget for Scroll {
|
||||||
@@ -60,6 +68,59 @@ 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed one frame of a touch gesture over this scroll area through.
|
||||||
|
/// Wired by `WidgetLike::scrollable`; a caller building a `Scroll` by
|
||||||
|
/// hand registers the same senses and calls this.
|
||||||
|
///
|
||||||
|
/// `id` is this widget's own id, which `DragGesture` takes pointer
|
||||||
|
/// capture on once the gesture commits -- so the rest of the drag
|
||||||
|
/// reaches here even after the finger has left this area, and, just as
|
||||||
|
/// importantly, stops reaching whatever is *inside* it. That is what
|
||||||
|
/// resolves a vertical drag over a focused text field: the field sees
|
||||||
|
/// the first few frames, iris::attr's `on_press` gives up its pending
|
||||||
|
/// selection the moment they pass `DRAG_SLOP` vertically, and this
|
||||||
|
/// takes the gesture over. Android's own `EditText` behaves the same
|
||||||
|
/// way -- a vertical drag scrolls, and only a long press selects.
|
||||||
|
///
|
||||||
|
/// No fling: unlike `List`, `Scroll` has no per-frame tick to animate
|
||||||
|
/// one with (`List::set_redraw_handle`/`tick_fling`), and the areas
|
||||||
|
/// this wraps today -- a six-line composer, a diagnostics pane -- are
|
||||||
|
/// at most a screenful, where Android does not fling either. The
|
||||||
|
/// released velocity is deliberately dropped rather than approximated.
|
||||||
|
pub fn drag(
|
||||||
|
&mut self,
|
||||||
|
render: &UiRenderState,
|
||||||
|
id: WidgetId,
|
||||||
|
sense: CursorSense,
|
||||||
|
pos_window: Vec2,
|
||||||
|
now: Instant,
|
||||||
|
) {
|
||||||
|
// `already_selected: false` -- a scroll area has no selection of
|
||||||
|
// its own to extend, so a horizontal drag stays `Undecided` and a
|
||||||
|
// vertical one past the slop pans, which is the whole contract
|
||||||
|
// here. A caller that *does* own a selection (the transcript's
|
||||||
|
// `Selection`) drives `DragGesture` itself instead.
|
||||||
|
match self
|
||||||
|
.gesture
|
||||||
|
.handle(render, id, sense, pos_window, now, false)
|
||||||
|
{
|
||||||
|
// `scroll(dy)`, not `scroll(-dy)` -- `Selection::drag` passes
|
||||||
|
// `-dy` to `List::scroll` because a `List`'s anchor offset and
|
||||||
|
// this widget's `amt` run in *opposite* directions (offset is
|
||||||
|
// where the anchored edge sits; `amt` is how far the content
|
||||||
|
// has been pulled up past the top), even though `List::scroll`'s
|
||||||
|
// own doc claims to mirror this one's convention. The rule that
|
||||||
|
// holds for both, and the one to check a sign against, is that
|
||||||
|
// the content follows the finger.
|
||||||
|
GestureOutcome::Pan(dy) => self.scroll(dy),
|
||||||
|
GestureOutcome::Undecided
|
||||||
|
| GestureOutcome::SelectStart
|
||||||
|
| GestureOutcome::SelectExtend
|
||||||
|
| GestureOutcome::Released(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,8 +131,179 @@ impl Scroll {
|
|||||||
self.snap_end = self.amt == len;
|
self.snap_end = self.amt == len;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How far the content has been pulled past the container's leading
|
||||||
|
/// edge, in pixels -- 0 at the start of the content. Read-only, for a
|
||||||
|
/// caller that needs to observe a pan (a test, a scroll indicator).
|
||||||
|
pub fn amt(&self) -> f32 {
|
||||||
|
self.amt
|
||||||
|
}
|
||||||
|
|
||||||
pub fn scroll(&mut self, amt: f32) {
|
pub fn scroll(&mut self, amt: f32) {
|
||||||
self.amt -= amt;
|
self.amt -= amt;
|
||||||
self.update_amt();
|
self.update_amt();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::sense::{CursorButton, DRAG_SLOP};
|
||||||
|
use iris_core::UiData;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// A scroll area with 1000px of content in a 100px box, already
|
||||||
|
/// settled somewhere in the middle so a drag has room in both
|
||||||
|
/// directions.
|
||||||
|
fn area() -> (UiData, Scroll, WidgetId) {
|
||||||
|
let mut ui = UiData::default();
|
||||||
|
let inner = ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any();
|
||||||
|
let id = inner.id();
|
||||||
|
let mut s = Scroll::new(inner, Axis::Y);
|
||||||
|
s.content_len = 1000.0;
|
||||||
|
s.container_len = 100.0;
|
||||||
|
s.amt = 400.0;
|
||||||
|
s.snap_end = false;
|
||||||
|
(ui, s, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn press(
|
||||||
|
s: &mut Scroll,
|
||||||
|
render: &UiRenderState,
|
||||||
|
id: WidgetId,
|
||||||
|
sense: CursorSense,
|
||||||
|
y: f32,
|
||||||
|
t: Instant,
|
||||||
|
) {
|
||||||
|
s.drag(render, id, sense, Vec2::new(0.0, y), t);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_vertical_finger_drag_pans_the_content_with_the_finger() {
|
||||||
|
let (_ui, mut s, id) = area();
|
||||||
|
let render = UiRenderState::new();
|
||||||
|
let t = Instant::now();
|
||||||
|
press(
|
||||||
|
&mut s,
|
||||||
|
&render,
|
||||||
|
id,
|
||||||
|
CursorSense::PressStart(CursorButton::Left),
|
||||||
|
0.0,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
// Finger down by well past the slop: the content follows it down,
|
||||||
|
// which for this widget means *less* `amt`.
|
||||||
|
press(
|
||||||
|
&mut s,
|
||||||
|
&render,
|
||||||
|
id,
|
||||||
|
CursorSense::Pressing(CursorButton::Left),
|
||||||
|
DRAG_SLOP + 30.0,
|
||||||
|
t + Duration::from_millis(20),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(s.amt - 370.0).abs() < 0.01,
|
||||||
|
"expected the 30px past the slop to be applied downward, got amt={}",
|
||||||
|
s.amt
|
||||||
|
);
|
||||||
|
// ...and the next frame's motion is a plain per-frame delta.
|
||||||
|
press(
|
||||||
|
&mut s,
|
||||||
|
&render,
|
||||||
|
id,
|
||||||
|
CursorSense::Pressing(CursorButton::Left),
|
||||||
|
DRAG_SLOP + 50.0,
|
||||||
|
t + Duration::from_millis(40),
|
||||||
|
);
|
||||||
|
assert!((s.amt - 350.0).abs() < 0.01, "amt={}", s.amt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The half the change had no reason to touch: a press that never
|
||||||
|
/// leaves the slop is a tap, and must move nothing at all -- otherwise
|
||||||
|
/// every tap on a scrollable field nudges its text.
|
||||||
|
#[test]
|
||||||
|
fn a_press_that_stays_inside_the_slop_does_not_scroll() {
|
||||||
|
let (_ui, mut s, id) = area();
|
||||||
|
let render = UiRenderState::new();
|
||||||
|
let t = Instant::now();
|
||||||
|
press(
|
||||||
|
&mut s,
|
||||||
|
&render,
|
||||||
|
id,
|
||||||
|
CursorSense::PressStart(CursorButton::Left),
|
||||||
|
0.0,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
for (i, y) in [1.0, -2.0, DRAG_SLOP - 0.5].into_iter().enumerate() {
|
||||||
|
press(
|
||||||
|
&mut s,
|
||||||
|
&render,
|
||||||
|
id,
|
||||||
|
CursorSense::Pressing(CursorButton::Left),
|
||||||
|
y,
|
||||||
|
t + Duration::from_millis(10 * (i as u64 + 1)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
press(
|
||||||
|
&mut s,
|
||||||
|
&render,
|
||||||
|
id,
|
||||||
|
CursorSense::PressEnd(CursorButton::Left),
|
||||||
|
DRAG_SLOP - 0.5,
|
||||||
|
t + Duration::from_millis(50),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(s.amt - 400.0).abs() < 0.01,
|
||||||
|
"a tap scrolled: amt={}",
|
||||||
|
s.amt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A horizontal drag is not this widget's gesture: it must stay put
|
||||||
|
/// rather than pick up the vertical noise in a sideways swipe.
|
||||||
|
#[test]
|
||||||
|
fn a_horizontal_drag_does_not_scroll() {
|
||||||
|
let (_ui, mut s, id) = area();
|
||||||
|
let render = UiRenderState::new();
|
||||||
|
let t = Instant::now();
|
||||||
|
s.drag(
|
||||||
|
&render,
|
||||||
|
id,
|
||||||
|
CursorSense::PressStart(CursorButton::Left),
|
||||||
|
Vec2::new(0.0, 0.0),
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
s.drag(
|
||||||
|
&render,
|
||||||
|
id,
|
||||||
|
CursorSense::Pressing(CursorButton::Left),
|
||||||
|
Vec2::new(120.0, 3.0),
|
||||||
|
t + Duration::from_millis(20),
|
||||||
|
);
|
||||||
|
assert!((s.amt - 400.0).abs() < 0.01, "amt={}", s.amt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Panning stops at the ends of the content rather than running off,
|
||||||
|
/// which is `update_amt`'s clamp -- checked through `drag` so the two
|
||||||
|
/// cannot drift apart.
|
||||||
|
#[test]
|
||||||
|
fn a_pan_past_the_end_clamps_instead_of_running_off() {
|
||||||
|
let (_ui, mut s, id) = area();
|
||||||
|
let render = UiRenderState::new();
|
||||||
|
let t = Instant::now();
|
||||||
|
s.drag(
|
||||||
|
&render,
|
||||||
|
id,
|
||||||
|
CursorSense::PressStart(CursorButton::Left),
|
||||||
|
Vec2::new(0.0, 0.0),
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
s.drag(
|
||||||
|
&render,
|
||||||
|
id,
|
||||||
|
CursorSense::Pressing(CursorButton::Left),
|
||||||
|
Vec2::new(0.0, 5000.0),
|
||||||
|
t + Duration::from_millis(20),
|
||||||
|
);
|
||||||
|
assert!((s.amt - 0.0).abs() < 0.01, "amt={}", s.amt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
// these methods should "not require any context" (require unit) because they're in core
|
// these methods should "not require any context" (require unit) because they're in core
|
||||||
widget_trait! {
|
widget_trait! {
|
||||||
@@ -90,6 +91,20 @@ widget_trait! {
|
|||||||
let delta = ctx.data.scroll_delta.y * 50.0;
|
let delta = ctx.data.scroll_delta.y * 50.0;
|
||||||
ctx.widget(rsc).scroll(delta);
|
ctx.widget(rsc).scroll(delta);
|
||||||
})
|
})
|
||||||
|
// A finger drag, through the same `DragGesture` the
|
||||||
|
// transcript's `List` is panned by -- `Scroll::drag`'s doc
|
||||||
|
// has the arbitration and why there is no fling. The wheel
|
||||||
|
// above and this are the two inputs of one scroll, so they
|
||||||
|
// are registered together rather than left to each caller.
|
||||||
|
.on(
|
||||||
|
CursorSense::click_or_drag() | CursorSense::unclick(),
|
||||||
|
|ctx, rsc| {
|
||||||
|
let id = ctx.widget.id();
|
||||||
|
let (sense, pos) = (ctx.data.sense, ctx.data.cursor.pos);
|
||||||
|
ctx.widget(rsc)
|
||||||
|
.drag(ctx.data.render, id, sense, pos, Instant::now());
|
||||||
|
},
|
||||||
|
)
|
||||||
.add(state)
|
.add(state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,18 @@ where
|
|||||||
// height-capped field -- not a background rect and a field drawn as
|
// height-capped field -- not a background rect and a field drawn as
|
||||||
// two independent siblings, which is what let the two disagree on
|
// two independent siblings, which is what let the two disagree on
|
||||||
// where the bar actually was.
|
// where the bar actually was.
|
||||||
|
// **Not** `.scrollable()` here yet, though IRIS_TODO.md's "the composer
|
||||||
|
// has no touch-drag scroll" wants it: `Scroll` resolves its own
|
||||||
|
// `content_len`/`container_len` against `Painter::output_size` -- the
|
||||||
|
// whole window -- so inside a `MaxSize` that has clamped the offered
|
||||||
|
// region to six lines the two are in different spaces and the field
|
||||||
|
// pans itself entirely out of the bar. Measured on this checkout's
|
||||||
|
// emulator 2026-09-06 with 474 characters in the field (`iris text
|
||||||
|
// render: ... size=(1016.7, 623.7)`, a 441px cap): the bar collapsed to
|
||||||
|
// its padding with no text in it. `Scroll::drag` -- the finger pan the
|
||||||
|
// TODO actually asks for -- is in place and exercised by the bench
|
||||||
|
// shell's report pane; what is left is `Scroll` measuring against its
|
||||||
|
// own offered box rather than the window. See docs/RUST.md.
|
||||||
let content = field
|
let content = field
|
||||||
.pad(dp(FIELD_PAD_DP))
|
.pad(dp(FIELD_PAD_DP))
|
||||||
.max_height(dp(APPROX_LINE_HEIGHT_DP * MAX_LINES + FIELD_PAD_DP * 2.0))
|
.max_height(dp(APPROX_LINE_HEIGHT_DP * MAX_LINES + FIELD_PAD_DP * 2.0))
|
||||||
|
|||||||
Reference in new issue
Block a user