iris: mark() -- a drawn disclosure triangle, instead of a codepoint the phone lacks
The tool cards' open/closed marks were U+25B8/25BE/25B4 in whatever face resolved. That worked while iris bundled its own fonts; since the move to the platform collection on 2026-09-07 Iris's phone draws an empty box and this machine draws a dot -- UI_RULES' 'don't rely on characters the platform might not have'. iris::widget::mark rasterises one oversampled, antialiased triangle into the ordinary texture path and scales it into the box the caller asks for, so it needs no new primitive and is correct at any density. Its two tests check the shape points where it was asked to and leaves its corners clear, which is the half nobody would look at on a device that renders it wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
af1b0c5ab2
commit
e5a90c6135
4 files changed
+261
-9
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<Rsc: UiRsc>(dir: Dir, size_dp: f32, color: UiColor) -> impl WidgetFn<Rsc, Mark> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user