diff --git a/docs/IRIS.md b/docs/IRIS.md index 6e470f7..23ca1c3 100644 --- a/docs/IRIS.md +++ b/docs/IRIS.md @@ -8,6 +8,63 @@ capability that moved. Small and trivial changes do not go here. An entry gives the date, what changed, why, and a short before/after where it helps judge the change without the session that made it. Newest first. +## 2026-09-08: a gesture can be cancelled, and the pointer belongs to the input handler + +Two changes to how a drag ends, from defects on Iris's phone (a code +fence panned sideways made the transcript jump on the next tap, and made +the fence itself snap back). + +**`CursorSense::Cancel`, and `GestureOutcome::Cancelled`.** Taking +pointer capture cuts every other widget off from the press completely -- +no `PressEnd`, no `Drop` -- so anything else tracking that press was left +with a gesture open at an origin belonging to a finger long gone, and the +next touch anywhere was measured from it. A widget that loses a capture +race is now told, exactly once. It is a separate sense from `Drop` +deliberately: `Drop` means "your gesture finished" and callers act on it +(a fling, a tap, a link followed), which is precisely wrong here. + +**`CursorSense::drag_senses()`** is what a widget driving a `DragGesture` +registers -- the frames plus `unclick`, `Drop` and `Cancel`. Both ways a +gesture can end, stated once rather than remembered per call site; +forgetting `Drop` is what left a `Scroll` panning from a stale position. + +**The pointer's state left `UiRenderState`.** `capture_pointer`, +`release_pointer` and `captured_pointer` are gone from it. Capture and +the pressed set are `PointerInput` -- the cursor senses' `Event::Global`, +a new associated type for state an event owns that belongs to no single +widget -- held by the event manager that runs the dispatch and reached +by `&mut`, with no lock anywhere. A handler asks through +`ctx.data.pointer` (`PointerRequests`: `capture(id)`, `release()`, +`holder()`). + + // before -- interior mutability on whatever structure was reachable + ctx.data.render.capture_pointer(id); + // after + ctx.data.pointer.capture(id); + +`DragGesture::handle` and `Scroll::drag` take `&PointerRequests` where +they took `&UiRenderState`. `task_on` also lost a `Data: Send` bound it +never needed -- the future it spawns never sees the event's data, and +that bound was the whole reason the pointer state had been behind a +`Mutex`. + +## 2026-09-08: `mark(dir, dp, colour)` -- a drawn triangle, and a scroll area's opening edge + +**`iris::widget::mark`** draws a filled, antialiased triangle pointing +along a `Dir`, at a size in dp. It replaces the disclosure codepoints +U+25B8/25BE/25B4, which were a bet that the platform's fonts have them -- +once iris stopped bundling its own faces, Iris's phone drew an empty box. +It rasterises one oversampled bitmap into the ordinary texture path and +scales it into the box asked for, so no new primitive was needed and it +is correct at any density. + +**`scrollable_on` now opens at the beginning of its content, and +`scrollable_to_end(axis)` is the other one** -- pinned to the end and +staying there while the content grows, which is what a composer wants and +what everything did before. A code fence was opening at the end of its +longest line, in the middle of a word. `Scroll::new` takes the edge as a +third argument rather than deciding for its caller. + ## 2026-09-08: masks have a shape -- `.masked_by(shape)`, and clipping applies to touch A mask no longer carries a rectangle. It carries **the slot of a diff --git a/iris/src/widget/mark.rs b/iris/src/widget/mark.rs new file mode 100644 index 0000000..17c4b96 --- /dev/null +++ b/iris/src/widget/mark.rs @@ -0,0 +1,192 @@ +//! A small filled triangle, drawn rather than typed -- a disclosure +//! marker, and the first thing in iris that draws a shape text cannot. +//! +//! It exists because the alternative was a font codepoint. `tool.rs` used +//! U+25B8/25BE/25B4 in whatever face resolved, which worked only while +//! iris bundled its own fonts; once text moved to the platform's +//! collection (2026-09-07) Iris's phone drew an empty box where the mark +//! should be, and this machine drew a dot. UI_RULES: "don't rely on +//! characters the platform might not have -- ship the glyph or the asset +//! rather than hoping." +//! +//! Rasterised into iris's ordinary texture path rather than needing a new +//! primitive: iris has rects, text and textures, and a triangle is not +//! expressible as any number of rects without a staircase edge. One +//! oversampled bitmap per mark is drawn scaled into the box the caller +//! asks for, so the same texture is correct at any density -- which is +//! also why it is built at construction, where the density is not known +//! yet, and scaled at draw, where it is. + +use crate::prelude::*; +use image::{Rgba, RgbaImage}; + +/// The bitmap's own size. Generous enough that a 12dp mark at density 3 +/// (36px) is still sampling *down*, which is what keeps the diagonal +/// clean; small enough that a handful of them cost nothing (48x48 RGBA is +/// 9 KB, and a card draws one). +const TEXTURE_PX: u32 = 48; +/// Subsamples per pixel per axis when measuring how much of a pixel the +/// triangle covers. 4x4 is the point where the edge stops looking stepped +/// at these sizes. +const SUBSAMPLES: u32 = 4; + +pub struct Mark { + handle: TextureHandle, + /// The box the triangle is drawn into, in dp -- resolved against the + /// density at draw time, so one mark is the same physical size on any + /// screen. + size_dp: f32, +} + +impl Widget for Mark { + fn draw(&mut self, painter: &mut Painter) -> Size { + let px = self.size_dp * painter.density(); + let size = Vec2::new(px, px); + painter.texture_within(&self.handle, size.align(Align::CENTER)); + Size::abs(size) + } + + fn is_size_independent(&self) -> bool { + true // its size is its own, not a share of what it was offered + } +} + +/// A filled triangle `size_dp` across, pointing along `dir`, in `color` -- +/// what a row uses to say "this opens" and "this is open". +pub fn mark(dir: Dir, size_dp: f32, color: UiColor) -> impl WidgetFn { + let image = rasterise(dir, color); + move |state| Mark { + handle: state.ui_mut().textures.add(image), + size_dp, + } +} + +/// The triangle, as coverage: for each pixel, how much of it the shape +/// covers, measured by subsampling rather than by an analytic edge +/// function -- one bitmap is built per mark in the whole program, so the +/// simple method is the right one. +fn rasterise(dir: Dir, color: UiColor) -> RgbaImage { + let corners = corners(dir); + let n = TEXTURE_PX as f32; + let step = 1.0 / SUBSAMPLES as f32; + RgbaImage::from_fn(TEXTURE_PX, TEXTURE_PX, |x, y| { + let mut inside = 0u32; + for sy in 0..SUBSAMPLES { + for sx in 0..SUBSAMPLES { + let p = Vec2::new( + (x as f32 + (sx as f32 + 0.5) * step) / n, + (y as f32 + (sy as f32 + 0.5) * step) / n, + ); + if contains(&corners, p) { + inside += 1; + } + } + } + let coverage = inside as f32 / (SUBSAMPLES * SUBSAMPLES) as f32; + // Premultiplied is wrong for this pipeline (`Textures::add` takes + // ordinary RGBA and the shader samples it straight), so the colour + // stays put and only alpha carries the coverage. + Rgba([ + color.r, + color.g, + color.b, + (color.a as f32 * coverage).round() as u8, + ]) + }) +} + +/// The triangle's three corners in unit space, inset a little from the +/// bitmap's edge so its own antialiasing is never clipped by the texture +/// border, and squat rather than equilateral -- the proportions of the +/// disclosure triangles this replaces. +fn corners(dir: Dir) -> [Vec2; 3] { + const NEAR: f32 = 0.12; + const FAR: f32 = 0.88; + // Across the direction of travel, the base spans the full width; along + // it, the tip is at the far end. + let (base, tip) = match dir.sign { + Sign::Pos => (NEAR, FAR), + Sign::Neg => (FAR, NEAR), + }; + let mid = 0.5; + match dir.axis { + Axis::Y => [ + Vec2::new(NEAR, base), + Vec2::new(FAR, base), + Vec2::new(mid, tip), + ], + Axis::X => [ + Vec2::new(base, NEAR), + Vec2::new(base, FAR), + Vec2::new(tip, mid), + ], + } +} + +/// Whether `p` is inside the triangle, by the sign of the cross product +/// against each edge. The corners above are given in a consistent winding +/// per direction, so "all three the same sign" is the test -- written as +/// "never both signs" so a point exactly on an edge counts as inside +/// rather than falling through a crack between two triangles. +fn contains(t: &[Vec2; 3], p: Vec2) -> bool { + let side = |a: Vec2, b: Vec2| (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x); + let (d0, d1, d2) = (side(t[0], t[1]), side(t[1], t[2]), side(t[2], t[0])); + let neg = d0 < 0.0 || d1 < 0.0 || d2 < 0.0; + let pos = d0 > 0.0 || d1 > 0.0 || d2 > 0.0; + !(neg && pos) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The tip points where it was asked to, and the base is at the other + /// end -- checked as coverage rather than by eye, because the whole + /// reason this widget exists is that nobody looked at the glyph it + /// replaces on the device that lacked it. + #[test] + fn a_mark_points_along_its_direction() { + let at = |f: f32| (TEXTURE_PX as f32 * f) as u32; + let (mid, near_tip, past_base) = (at(0.5), at(0.6), at(0.02)); + let (far_tip, far_base) = (at(0.4), at(0.98)); + // Not the tip pixel itself, which is a point and covers nothing: + // a little back from it, where the triangle has width. And just + // *outside* the base, which no part of the shape reaches. + for (name, dir, tip, base) in [ + ("down", Dir::DOWN, (mid, near_tip), (mid, past_base)), + ("up", Dir::UP, (mid, far_tip), (mid, far_base)), + ("right", Dir::RIGHT, (near_tip, mid), (past_base, mid)), + ("left", Dir::LEFT, (far_tip, mid), (far_base, mid)), + ] { + let img = rasterise(dir, UiColor::WHITE); + // Just behind the tip is solid; the same distance past the + // base's outer edge is empty. A triangle drawn the wrong way + // round passes neither. + let inside = img.get_pixel(tip.0, tip.1)[3]; + let outside = img.get_pixel(base.0, base.1)[3]; + assert!( + inside > 200, + "{name}: the pixel at the tip should be covered, alpha={inside}" + ); + assert!( + outside < 40, + "{name}: the pixel past the base should be clear, alpha={outside}" + ); + } + } + + /// A corner of the bitmap is never covered, whichever way the mark + /// points -- what says the shape is a triangle rather than a filled + /// box, and that the inset in `corners` is keeping its antialiasing + /// inside the texture. + #[test] + fn a_mark_leaves_its_corners_clear() { + for dir in [Dir::DOWN, Dir::UP, Dir::LEFT, Dir::RIGHT] { + let img = rasterise(dir, UiColor::WHITE); + let last = TEXTURE_PX - 1; + for (x, y) in [(0, 0), (last, 0), (0, last), (last, last)] { + assert_eq!(img.get_pixel(x, y)[3], 0, "corner ({x},{y}) is covered"); + } + } + } +} diff --git a/iris/src/widget/mod.rs b/iris/src/widget/mod.rs index 9131788..8892759 100644 --- a/iris/src/widget/mod.rs +++ b/iris/src/widget/mod.rs @@ -1,5 +1,6 @@ mod image; mod list; +mod mark; mod mask; mod position; mod ptr; @@ -9,6 +10,7 @@ mod trait_fns; pub use image::*; pub use list::*; +pub use mark::*; pub use mask::*; pub use position::*; pub use ptr::*; diff --git a/iris/transcript-ui/src/tool.rs b/iris/transcript-ui/src/tool.rs index 96a6222..031e377 100644 --- a/iris/transcript-ui/src/tool.rs +++ b/iris/transcript-ui/src/tool.rs @@ -123,9 +123,12 @@ const _: () = assert!(OUTPUT_LINES > 0 && OUTPUT_BYTES > 0); /// glyph should still land, just not necessarily monospaced. IRIS_TODO's /// "a drawn chevron" has the real fix, which needs a line primitive iris /// does not have. -const CLOSED_MARK: &str = "\u{25b8}"; -const OPEN_MARK: &str = "\u{25be}"; -const UP_MARK: &str = "\u{25b4}"; +/// The disclosure marks, drawn (`iris::widget::mark`) rather than typed. +/// They were the codepoints U+25B8/25BE/25B4 until 2026-09-08, which is a +/// bet on the platform's fonts having them -- Iris's phone drew an empty +/// box and this machine drew a dot once iris stopped bundling its own +/// faces. +const MARK_DP: f32 = 9.0; /// Which cards the reader has opened, and which have had their whole /// output asked for. @@ -449,12 +452,11 @@ where let mut header = Span::empty(Dir::RIGHT).gap(dp(GAP_DP)); header.push( - text( - if open { OPEN_MARK } else { CLOSED_MARK }, - BODY_SIZE, + mark( + if open { Dir::DOWN } else { Dir::RIGHT }, + MARK_DP, MUTED_COLOR, ) - .family(Family::Monospace) .add_strong(rsc) .any(), ); @@ -615,8 +617,7 @@ where { let strong = WidgetPtr::new().add_strong(rsc); let ptr = strong.weak(); - let mark = text(UP_MARK, BODY_SIZE, MUTED_COLOR) - .family(Family::Monospace) + let mark = mark(Dir::UP, MARK_DP, MUTED_COLOR) .center() .width(rest(1)) .pad(dp(CARD_PAD_DP))