Compare commits

..
Author SHA1 Message Date
iris-ai 46547563c1 Take a strong widget to depend on, and use the question mark
`depend_on_size` took a bare `WidgetId`, which any id at all satisfies.
It takes a `&StrongWidget` now, so `DrawResult` carries the handle it
was drawn from rather than an id copied out of it and nothing can claim
a dependency on a widget it does not hold.

`UiSpan::outside` matched on a pair of `Option`s where `?` says it. It
was written that way while the method was still `const`, and it is not.

No call site changed. Checked: fmt, clippy and 35 tests; `tabs`, `view`,
`minimal` and `text` render unchanged, and the live sway resize round
trip still matches a cold start at each size.
2026-09-14 02:44:40 -04:00
iris-ai 53e61289f5 Say when a drawing cannot be taken out of its box, rather than guessing
`lerp_inv` returns `Option`. Inverting a lerp over a range of zero
length has no one answer, and `div_or`'s fallback picked one: the start
of the range. Measured, that is not a wrong number so much as a
plausible one -- taking a part out of a box fixed at the top of the
window hands back exactly what went in, and out of a box fixed at the
middle hands back the parent's own `rel` of 0.5 as if it were the
child's fraction. Either way the caller cannot tell that nothing was
recovered, which is the defect; the previous commit's claim that it
"returns a rel of 0" is right only for the first case.

`UiScalar::outside` and `UiSpan::outside` follow it to `Option`, and
`Remap` is the answer at the region level: `new` says whether a drawing
in one box can be put in another and `apply` then cannot fail, so
`try_reuse` asks once for a whole subtree and `reusable` goes back to
reporting only what the widget claims. That replaces the predicate the
last commit put in `reusable`, which stated the same rule in a second
place.

`DivOr` existed only for the fallback and is gone, along with
`UiRegion::outside` and `UiVec2::outside`, which had no callers once
`Remap` owned the operation. `UiRegion::axis` took `&mut self` to
return a shared reference; `Remap::new` needs it on a shared one.

This does not redraw less. `Remap::new` refuses exactly what the
predicate refused; what it buys is one statement of the rule and a
remap that cannot half-apply. Counted on a resize, with the old
wipe-everything for comparison:

  20 padded rows, 101 widgets     101 draws -> 0
  the `tabs` example               73 draws -> 0
  the `text` example               49 draws -> 48

So the saving is whole where a resize does not change any widget's
size, and nil in `text`, where both paragraphs rewrap to a different
height and the relayout that forces reaches the root. The last commit's
message oversold that case.

Checked: fmt, clippy and 35 tests. `tabs` (with the image replay),
`view` and `minimal` still byte-identical to `upstream/main`, `text`
unchanged, and the live sway resize round trip still matches a cold
start at each size.
2026-09-14 01:27:16 -04:00
iris-ai 984f482a7f Move a resized drawing instead of redrawing it
A region is a fraction of the output plus an offset and the shader
resolves it against the window every frame, so a resize already moves
the whole drawing without the CPU. Wiping the tree and drawing it again
was throwing that away.

`Painter::output_size` and `px_size` now record that a widget read
pixels, the way reading a child's size records a dependency on it, and a
resize marks only those. In the `text` example that is the two wrapping
paragraphs out of forty-odd widgets; everything else keeps its drawing
and the window uniform puts it in the right place.

Two defects the change surfaced, both of which made a resize land
somewhere a cold start would not:

`redraw` climbed to the highest reader of the changed widget and drew
from there, trusting that draw to reach back down. It does not: an
intermediate whose own box has not changed is reused as it stands and
the draw stops there. Everything between the two is now marked as well,
which is the only thing that stops the reuse. Not resize-specific --
`a_change_two_levels_under_its_reader_still_reaches_it` fails on the
mutation path too.

`mov` cannot stretch a drawing out of a box with no relative extent.
`UiScalar::within` puts a part into such a box as a plain offset from
its start, and `lerp_inv`'s divide-by-zero fallback then returns a
rel of 0 rather than saying it cannot invert, so the remap silently
leaves the drawing its old size. `OnResize::Scale` now only reuses
across a length change when the old box had a relative extent. The
underlying loss belongs to the position chain, which separates the
drawn box from the offered one; until then this is the honest
predicate.

Checked: fmt, clippy and 33 tests. `tabs` (with the image replay),
`view`, `minimal` still byte-identical to `upstream/main`, and `text`
unchanged at 1920x1200 and 900x1200. Driven live under the GPU as well:
started at 1920x1200, resized to 900x1200 and back through sway, and
each screenshot matches a cold start at that size byte for byte.
2026-09-14 01:09:03 -04:00
iris-ai 9520996623 Return the size from draw, and fold placing back into drawing
Review response.

`Painter::set_size` is gone: `Widget::draw` returns the `Size` instead, so
a widget that does not say what it used cannot compile rather than
panicking at the widget that forgot. That also settles setting it twice --
a branch that learns something late just returns a different value.

`Painter::place` and `UiRenderState::place` are gone too. `draw_inner`
already tried to reuse an active widget's drawing before redrawing it, so
`place` was `widget_within` with its own bookkeeping bolted on; drawing a
child a second time now *is* how a parent puts it where it belongs, and a
child is deduplicated in `children` because listing one twice would move
it twice. The unification also drops `place`'s use of `ActiveData::layer`,
which is the layer a widget's own `child_layer()` left the painter on
rather than the layer it was drawn into.

What `place` did unconditionally and `widget_within` did not is record the
size dependency, so `Painter::size_hint` now records one: reading a
child's length to lay out around it is reading its size, whether it came
from a draw or from a hint. `tests/retained.rs` has a parent that only
ever reads the hint, which is the case no existing widget exercises.

`()` sizes itself `Size::default()` -- rest -- rather than zero, so it is
a gap that takes an even share of a span; `WidgetPtr` with nothing in it
does the same, since it is the same situation. `Widget::on_resize`'s
default body said `Translate` while the enum's `#[default]` said `Redraw`;
it now defers to the enum.

`was` is `old` throughout, the `OnResize` variant comments are gone, and
so are two empty `impl` blocks.

Checked: fmt, clippy and 27 tests across the workspace; `tabs` on each of
its five tabs, and `tabs` with a replay that adds two images, all
byte-identical to `upstream/main`; `view` and `minimal` likewise; and the
`text` example rendered at 1920x1200 and 900x1200 to see the paragraph
reflow and its container follow.
2026-09-14 00:39:39 -04:00
iris b108645240 Say what may be done to a drawing, and default to nothing
`SizeDependence::{None, Internal, External}` becomes
`OnResize::{Scale, Translate, Redraw}`, which says what the retained path
may do rather than leaving the reader to work it out from a dependency.

`Redraw` is now the default, and that is the substance of this rather
than the naming. `Translate` was, and nothing opted into it: `SetSize`
reports one size and hands its child the whole box, so its pixels change
with the box and carrying them stretched a 100x100 rect across half the
window. A default that is only right for widgets that happen to qualify
is the same fault as an unchecked reuse flag.

`Translate` still does nothing, and now for the reason rather than the
one I gave before: `mov` translates perfectly well, but `ActiveData`'s
`region` is both the box a widget was given and the box its primitives
occupy, and `mov` remaps out of it. Keeping a drawing at its old size
while the box grows leaves those two disagreeing, and the next move
stretches it. Separating them is what the offset chain does.

Caught by rendering `tabs` against `main` rather than by a test, which is
the argument for keeping that check in the loop.
2026-09-13 23:47:12 -04:00
iris ec012c4552 Text depends on its box for where it sits, not only for shaping
Alignment was not accounted for. Glyphs anchored to the start of an axis
stay where they are when that extent changes, but the default is
`CENTER_LEFT`: a label in a box that grows taller has to re-centre, even
though its shaping is untouched. Saying `Internal` there would keep a
drawing that belongs somewhere else once the retained path can act on it.

It costs nothing today, since neither `Internal` nor `External` reuses
anything yet. It would be silent when the move chain lands, which is why
the classification wants to be right while it is being written rather
than when something starts trusting it.
2026-09-13 23:28:47 -04:00
iris f192f75b25 Size a widget while drawing it, not in a pass of its own
`desired_width`/`desired_height`, `WidgetAxisFns`, `SizeCtx` and the size
cache are gone. A widget states what it used with `Painter::set_size`
while it draws, and `Painter::widget` hands back a `DrawResult` whose
`size()` both reads the child and records that this widget's size depends
on it. Reading nothing keeps the parent independent of what the child came
to.

`Span` is what the change is for. It takes each child's `size_hint` where
there is one, draws only the children that cannot answer, allocates the
flexible space, then places everything -- which deletes `desired_ortho`,
whose own comment said it "literally copies draw ... which makes this slow
and not cool".

Invalidation follows the dependency edges the draw recorded: a widget that
needs redrawing hands off to the highest ancestor that read its size,
instead of re-running a measurement to find out whether anything changed.

`Widget::size_dependence(axis)` says how much of its box a widget's
drawing depends on -- none of it, its own extent, or the whole box -- so
the retained path can keep a drawing and write a new box into it. Asked
per axis, because wrapped text depends on the width it is offered and not
on the height. `Internal` does not yet buy more than `External`: keeping a
drawing when only the room around it changed is a translation, which waits
for the move chain.

`tests/retained.rs` covers the second frame rather than the first, which
is where the bugs were: a placed child that was not recorded as one got
pruned as departed on the next draw.

`examples/text.rs` is new, since wrapping was the one thing here with no
way to see it on its own.
2026-09-13 22:51:05 -04:00
20 changed files with 64 additions and 839 deletions

No files matched your search

Generated
+16 -25
View File
@@ -567,9 +567,9 @@ dependencies = [
[[package]]
name = "dlib"
version = "0.5.3"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
dependencies = [
"libloading",
]
@@ -2158,9 +2158,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.41.0"
version = "0.38.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
dependencies = [
"memchr",
]
@@ -2353,15 +2353,6 @@ version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce"
[[package]]
name = "rig-input"
version = "0.1.0"
dependencies = [
"iris",
"wayland-client",
"wayland-protocols-wlr",
]
[[package]]
name = "roxmltree"
version = "0.21.1"
@@ -2918,9 +2909,9 @@ dependencies = [
[[package]]
name = "wayland-backend"
version = "0.3.17"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9"
dependencies = [
"cc",
"downcast-rs",
@@ -2932,9 +2923,9 @@ dependencies = [
[[package]]
name = "wayland-client"
version = "0.31.15"
version = "0.31.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec"
dependencies = [
"bitflags 2.10.0",
"rustix 1.1.3",
@@ -2966,9 +2957,9 @@ dependencies = [
[[package]]
name = "wayland-protocols"
version = "0.32.13"
version = "0.32.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3"
dependencies = [
"bitflags 2.10.0",
"wayland-backend",
@@ -2991,9 +2982,9 @@ dependencies = [
[[package]]
name = "wayland-protocols-wlr"
version = "0.3.12"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3"
dependencies = [
"bitflags 2.10.0",
"wayland-backend",
@@ -3004,9 +2995,9 @@ dependencies = [
[[package]]
name = "wayland-scanner"
version = "0.31.11"
version = "0.31.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3"
dependencies = [
"proc-macro2",
"quick-xml",
@@ -3015,9 +3006,9 @@ dependencies = [
[[package]]
name = "wayland-sys"
version = "0.31.11"
version = "0.31.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd"
dependencies = [
"dlib",
"log",
+1 -3
View File
@@ -20,7 +20,7 @@ tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] }
tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread", "time"] }
[workspace]
members = ["core", "macro", "rig-input"]
members = ["core", "macro"]
[workspace.package]
version = "0.1.0"
@@ -40,5 +40,3 @@ arboard = "3.6.1"
iris-core = { path = "core" }
iris-macro = { path = "macro" }
tokio = "1.49.0"
wayland-client = "0.31.15"
wayland-protocols-wlr = { version = "0.3.12", features = ["client"] }
+4 -46
View File
@@ -1,10 +1,11 @@
use crate::{UiRegion, util::Id, util::Vec2};
use crate::{UiRegion, util::Id};
use wgpu::*;
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable, Default)]
pub struct WindowUniform {
pub dim: Vec2,
pub width: f32,
pub height: f32,
}
#[repr(C)]
@@ -12,17 +13,15 @@ pub struct WindowUniform {
pub struct PrimitiveInstance {
pub region: UiRegion,
pub mask_idx: MaskIdx,
pub move_idx: MoveIdx,
}
impl PrimitiveInstance {
const ATTRIBS: [VertexAttribute; 6] = vertex_attr_array![
const ATTRIBS: [VertexAttribute; 5] = vertex_attr_array![
0 => Float32x2,
1 => Float32x2,
2 => Float32x2,
3 => Float32x2,
4 => Uint32,
5 => Uint32,
];
pub fn desc() -> VertexBufferLayout<'static> {
@@ -44,45 +43,4 @@ impl MaskIdx {
#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct Mask {
pub region: UiRegion,
pub move_idx: MoveIdx,
}
/// Its own type rather than another `Id<u32>`, because it sits beside
/// `MaskIdx` in an instance and the two must not be swappable.
#[repr(transparent)]
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, bytemuck::Pod, bytemuck::Zeroable)]
pub struct MoveIdx(u32);
impl MoveIdx {
pub const NONE: Self = Self(u32::MAX);
pub(crate) fn slot(idx: usize) -> Self {
Self(idx as u32)
}
pub(crate) fn idx(self) -> usize {
self.0 as usize
}
}
/// One link of the chain a primitive's position is resolved through: a
/// translation in physical pixels, and the slot it is relative to. Moving a
/// subtree writes its own slot and nothing else.
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct MoveOffset {
pub delta: Vec2,
pub parent: MoveIdx,
}
unsafe impl bytemuck::Pod for MoveOffset {}
unsafe impl bytemuck::Zeroable for MoveOffset {}
impl MoveOffset {
pub fn root(parent: MoveIdx) -> Self {
Self {
delta: Vec2::ZERO,
parent,
}
}
}
+17 -44
View File
@@ -17,7 +17,7 @@ mod texture;
mod util;
pub use atlas::*;
pub use data::{Mask, MaskIdx, MoveIdx, MoveOffset};
pub use data::{Mask, MaskIdx};
pub use primitive::*;
const PRELUDE: &str = include_str!("./shader/prelude.wgsl");
@@ -34,7 +34,6 @@ pub struct UiRenderNode {
active: Vec<usize>,
window_buffer: Buffer,
masks: ArrBuf<Mask>,
moves: ArrBuf<MoveOffset>,
}
struct RenderLayer {
@@ -128,35 +127,32 @@ impl UiRenderNode {
for primitive in &mut self.primitives {
primitive.render.update(ui);
}
let mut regroup = false;
if ui.masks.changed {
ui.masks.changed = false;
regroup |= self.masks.update(device, queue, &ui.masks[..]);
}
if ui_render.moves.changed {
ui_render.moves.changed = false;
regroup |= self.moves.update(device, queue, ui_render.moves.entries());
}
if regroup {
self.shared_group = Self::shared_group(
device,
&self.shared_layout,
&self.window_buffer,
&self.masks,
&self.moves,
);
if self.masks.update(device, queue, &ui.masks[..]) {
self.shared_group = Self::shared_group(
device,
&self.shared_layout,
&self.window_buffer,
&self.masks,
);
}
}
}
pub fn resize(&mut self, size: impl Into<Vec2>, queue: &Queue) {
let size = size.into();
let slice = &[WindowUniform { dim: size }];
let slice = &[WindowUniform {
width: size.x,
height: size.y,
}];
queue.write_buffer(&self.window_buffer, 0, bytemuck::cast_slice(slice));
}
pub fn new(device: &Device, config: &SurfaceConfiguration) -> Self {
let window_uniform = WindowUniform {
dim: Vec2::new(config.width as f32, config.height as f32),
width: config.width as f32,
height: config.height as f32,
};
let window_buffer = device.create_buffer_init(&BufferInitDescriptor {
label: Some("window"),
@@ -170,13 +166,7 @@ impl UiRenderNode {
BufferUsages::STORAGE | BufferUsages::COPY_DST,
"ui masks",
);
let moves = ArrBuf::new(
device,
BufferUsages::STORAGE | BufferUsages::COPY_DST,
"ui move offsets",
);
let shared_group =
Self::shared_group(device, &shared_layout, &window_buffer, &masks, &moves);
let shared_group = Self::shared_group(device, &shared_layout, &window_buffer, &masks);
Self {
shared_layout,
@@ -187,7 +177,6 @@ impl UiRenderNode {
layers: HashMap::default(),
active: Vec::new(),
masks,
moves,
}
}
@@ -263,8 +252,7 @@ impl UiRenderNode {
})
}
/// What every draw in the ui is given: the window, the masks and the
/// move chain every position is resolved through.
/// What every draw in the ui is given: the window and the masks.
fn shared_layout(device: &Device) -> BindGroupLayout {
device.create_bind_group_layout(&BindGroupLayoutDescriptor {
entries: &[
@@ -288,16 +276,6 @@ impl UiRenderNode {
},
count: None,
},
BindGroupLayoutEntry {
binding: 2,
visibility: ShaderStages::VERTEX | ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: BufferSize::new(size_of::<MoveOffset>() as u64),
},
count: None,
},
],
label: Some("ui shared"),
})
@@ -308,7 +286,6 @@ impl UiRenderNode {
layout: &BindGroupLayout,
window: &Buffer,
masks: &ArrBuf<Mask>,
moves: &ArrBuf<MoveOffset>,
) -> BindGroup {
device.create_bind_group(&BindGroupDescriptor {
layout,
@@ -321,10 +298,6 @@ impl UiRenderNode {
binding: 1,
resource: masks.buffer.as_entire_binding(),
},
BindGroupEntry {
binding: 2,
resource: moves.buffer.as_entire_binding(),
},
],
label: Some("ui shared"),
})
+5 -11
View File
@@ -3,7 +3,7 @@ use std::{any::TypeId, marker::PhantomData};
use crate::{
Color, TextureHandle, UiData, UiRegion, WidgetId,
render::{
data::{MaskIdx, MoveIdx, PrimitiveInstance},
data::{MaskIdx, PrimitiveInstance},
page::GlyphRender,
texture::ImageRender,
},
@@ -246,7 +246,6 @@ impl LayerDraws {
primitive,
region,
mask_idx,
move_idx,
}: PrimitiveInst<P>,
) -> PrimitiveHandle {
self.updated = true;
@@ -259,11 +258,7 @@ impl LayerDraws {
.get_or_insert_with(InstanceList::new::<P>)
.push(
id,
PrimitiveInstance {
region,
mask_idx,
move_idx,
},
PrimitiveInstance { region, mask_idx },
bytemuck::bytes_of(&primitive),
);
PrimitiveHandle {
@@ -309,7 +304,6 @@ pub struct PrimitiveInst<P> {
pub primitive: P,
pub region: UiRegion,
pub mask_idx: MaskIdx,
pub move_idx: MoveIdx,
}
pub struct PrimitiveChange {
@@ -353,7 +347,7 @@ impl RectPrimitive {
/// `color` is multiplied by the atlas alpha for a mask glyph; a colour glyph
/// takes the texel unchanged, which `GlyphEntry::IS_COLORED` selects.
#[repr(C)]
#[repr(C, align(8))]
#[derive(Debug, Copy, Clone)]
pub struct GlyphPrimitive {
pub uv_min: Vec2,
@@ -364,8 +358,8 @@ pub struct GlyphPrimitive {
pub flags: u32,
}
// Manual rather than derived: `Vec2`'s alignment leaves four bytes of padding
// here, which is how WGSL lays the struct out.
// Manual rather than derived: the align(8) leaves four bytes of padding, which
// is how WGSL lays the struct out.
unsafe impl bytemuck::Pod for GlyphPrimitive {}
unsafe impl bytemuck::Zeroable for GlyphPrimitive {}
impl Primitive for GlyphPrimitive {
+4 -37
View File
@@ -7,8 +7,6 @@
var<uniform> window: WindowUniform;
@group(0) @binding(1)
var<storage> masks: array<Mask>;
@group(0) @binding(2)
var<storage> move_offsets: array<MoveOffset>;
struct WindowUniform {
dim: vec2<f32>,
@@ -17,32 +15,6 @@ struct WindowUniform {
struct Mask {
x: UiSpan,
y: UiSpan,
move_idx: u32,
}
struct MoveOffset {
delta: vec2<f32>,
parent: u32,
}
const MOVE_NONE: u32 = 4294967295u;
// Keep in step with `iris_core::CHAIN_LIMIT`. It bounds a malformed cycle
// rather than any real tree, and the CPU walk uses the same number so both
// resolve a deep one the same way.
const CHAIN_LIMIT: u32 = 64u;
fn resolve_move(idx: u32) -> vec2<f32> {
var total = vec2<f32>(0.0, 0.0);
var at = idx;
for (var step = 0u; step < CHAIN_LIMIT; step++) {
if at == MOVE_NONE {
break;
}
let entry = move_offsets[at];
total += entry.delta;
at = entry.parent;
}
return total;
}
struct UiSpan {
@@ -61,7 +33,6 @@ struct InstanceInput {
@location(2) y_start: vec2<f32>,
@location(3) y_end: vec2<f32>,
@location(4) mask_idx: u32,
@location(5) move_idx: u32,
}
struct VertexOutput {
@@ -86,9 +57,8 @@ fn vs_main(
let bot_right_rel = vec2(in.x_end.x, in.y_end.x);
let bot_right_abs = vec2(in.x_end.y, in.y_end.y);
let moved = resolve_move(in.move_idx);
let top_left = floor(top_left_rel * window.dim) + floor(top_left_abs + moved);
let bot_right = floor(bot_right_rel * window.dim) + floor(bot_right_abs + moved);
let top_left = floor(top_left_rel * window.dim) + floor(top_left_abs);
let bot_right = floor(bot_right_rel * window.dim) + floor(bot_right_abs);
let size = bot_right - top_left;
let uv = vec2<f32>(
@@ -116,11 +86,8 @@ fn masked(in: VertexOutput, color: vec4<f32>) -> vec4<f32> {
let br = vec2(mask.x.end.rel, mask.y.end.rel);
let br_abs = vec2(mask.x.end.abs, mask.y.end.abs);
// Its own chain, not the drawn primitive's, so a stationary viewport
// clips content that moves inside it.
let moved = resolve_move(mask.move_idx);
let top_left = floor(tl * window.dim) + floor(tl_abs + moved);
let bot_right = floor(br * window.dim) + floor(br_abs + moved);
let top_left = floor(tl * window.dim) + floor(tl_abs);
let bot_right = floor(br * window.dim) + floor(br_abs);
let pos = in.clip_position.xy;
if pos.x < top_left.x || pos.x > bot_right.x || pos.y < top_left.y || pos.y > bot_right.y {
return color * 0.0;
+1 -3
View File
@@ -1,4 +1,4 @@
use crate::{LayerId, MaskIdx, MoveIdx, PrimitiveHandle, Size, TextureHandle, UiRegion, WidgetId};
use crate::{LayerId, MaskIdx, PrimitiveHandle, Size, TextureHandle, UiRegion, WidgetId};
/// important non rendering data for retained drawing
#[derive(Debug)]
@@ -15,8 +15,6 @@ pub struct ActiveData {
pub size_deps: Vec<WidgetId>,
/// Whether it read the output's size, and so is wrong when that changes.
pub reads_output: bool,
/// The move slot its primitives are positioned through.
pub move_idx: MoveIdx,
pub mask: MaskIdx,
pub layer: LayerId,
}
+1 -67
View File
@@ -1,14 +1,7 @@
use crate::{
Mask, MoveIdx, MoveOffset, PrimitiveRegistry, TextData, Textures, WeakWidget, WidgetId,
Widgets,
util::{Arena, Id, TrackedArena, Vec2},
Mask, PrimitiveRegistry, TextData, Textures, WeakWidget, WidgetId, Widgets, util::TrackedArena,
};
/// How far the shader will walk a move chain. It bounds a malformed cycle
/// rather than any real tree; `Moves::resolve` uses the same number so the
/// two agree on what a deep tree resolves to.
pub const CHAIN_LIMIT: u32 = 64;
mod active;
mod painter;
mod render_state;
@@ -27,65 +20,6 @@ pub struct UiData {
pub masks: TrackedArena<Mask, u32>,
}
/// Where each widget's drawing sits relative to its parent's slot, so moving
/// a subtree writes one entry rather than every descendant's primitives.
#[derive(Default)]
pub struct Moves {
arena: Arena<MoveOffset, u32>,
pub changed: bool,
}
impl Moves {
pub fn push(&mut self, parent: MoveIdx) -> MoveIdx {
self.changed = true;
MoveIdx::slot(self.arena.push(MoveOffset::root(parent)).idx())
}
pub fn remove(&mut self, idx: MoveIdx) {
self.changed = true;
self.arena.remove(Id::preset(idx.idx() as u32));
}
/// Sets a slot's translation, in physical pixels, relative to its parent.
pub fn set(&mut self, idx: MoveIdx, delta: Vec2) {
let entry = self.arena.get_mut(Id::preset(idx.idx() as u32));
if entry.delta != delta {
entry.delta = delta;
self.changed = true;
}
}
/// The translation a primitive in `idx` has accumulated, which is the
/// same walk the vertex shader does.
pub fn resolve(&self, idx: MoveIdx) -> Vec2 {
let mut total = Vec2::ZERO;
let mut at = idx;
for _ in 0..CHAIN_LIMIT {
if at == MoveIdx::NONE {
return total;
}
let entry = self.arena[at.idx()];
total += entry.delta;
at = entry.parent;
}
debug_assert!(
at == MoveIdx::NONE,
"a move chain longer than {CHAIN_LIMIT} resolves to the wrong place, \
and the shader stops at the same depth"
);
total
}
pub fn entries(&self) -> &[MoveOffset] {
&self.arena
}
pub fn clear(&mut self) {
self.changed = true;
self.arena = Arena::default();
}
}
pub trait UiRsc {
fn ui(&self) -> &UiData;
fn ui_mut(&mut self) -> &mut UiData;
+3 -9
View File
@@ -2,8 +2,8 @@ use crate::{
Axis, Len, RenderedText, Size, StrongWidget, TextAttrs, TextBuffer, TextData, TextureHandle,
UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, WidgetId,
render::{
GlyphPrimitive, Mask, MaskIdx, MoveIdx, Primitive, PrimitiveHandle, PrimitiveInst,
PrimitiveKind, TexturePrimitive,
GlyphPrimitive, Mask, MaskIdx, Primitive, PrimitiveHandle, PrimitiveInst, PrimitiveKind,
TexturePrimitive,
},
util::Vec2,
};
@@ -21,8 +21,6 @@ pub struct Painter<'a> {
/// The children whose size this widget read while drawing.
pub(super) size_deps: Vec<WidgetId>,
pub(super) reads_output: bool,
/// The move slot this widget's primitives are positioned through.
pub(super) move_idx: MoveIdx,
pub layer: usize,
pub(super) id: WidgetId,
}
@@ -43,7 +41,6 @@ impl<'a> Painter<'a> {
primitive,
region,
mask_idx: self.mask,
move_idx: self.move_idx,
},
);
self.push_primitive(h);
@@ -70,10 +67,7 @@ impl<'a> Painter<'a> {
pub fn set_mask(&mut self, region: UiRegion) {
assert!(self.mask == MaskIdx::NONE);
self.mask = self.rsc.ui_mut().masks.push(Mask {
region,
move_idx: self.move_idx,
});
self.mask = self.rsc.ui_mut().masks.push(Mask { region });
}
/// Draws a widget within this widget's region.
+8 -56
View File
@@ -1,6 +1,6 @@
use crate::{
ActiveData, Axis, DrawLayers, IdLike, MaskIdx, MoveIdx, Moves, OnResize, Painter, PixelRegion,
Remap, Size, StrongWidget, UiRegion, UiRsc, WidgetId, Widgets,
ActiveData, Axis, DrawLayers, IdLike, MaskIdx, OnResize, Painter, PixelRegion, Remap, Size,
StrongWidget, UiRegion, UiRsc, WidgetId, Widgets,
util::{HashMap, HashSet, Vec2, forget_ref},
};
@@ -12,10 +12,6 @@ pub struct UiRenderState {
old_root: Option<WidgetId>,
resized: bool,
draw_started: HashSet<WidgetId>,
/// A widget's move slot, which outlives any one `ActiveData`: a redraw
/// replaces that while its children go on pointing at the slot.
slots: HashMap<WidgetId, MoveIdx>,
pub moves: Moves,
}
impl UiRenderState {
@@ -27,8 +23,6 @@ impl UiRenderState {
old_root: None,
resized: false,
draw_started: Default::default(),
slots: Default::default(),
moves: Default::default(),
}
}
@@ -109,8 +103,6 @@ impl UiRenderState {
}
// draw widget
let move_idx = self.move_slot(id, parent);
self.moves.set(move_idx, Vec2::ZERO);
rsc.widgets_mut().needs_redraw.remove(&id);
self.draw_started.insert(id);
@@ -125,7 +117,6 @@ impl UiRenderState {
children: Vec::new(),
size_deps: Vec::new(),
reads_output: false,
move_idx,
rsc,
};
@@ -143,7 +134,6 @@ impl UiRenderState {
children,
size_deps,
reads_output,
move_idx,
layer,
id,
} = painter;
@@ -165,7 +155,6 @@ impl UiRenderState {
children,
size_deps,
reads_output,
move_idx,
mask,
layer,
};
@@ -182,20 +171,6 @@ impl UiRenderState {
size
}
/// The slot a widget's drawing is positioned through, made on its first
/// draw and kept until it stops being drawn.
fn move_slot(&mut self, id: WidgetId, parent: Option<WidgetId>) -> MoveIdx {
if let Some(&idx) = self.slots.get(&id) {
return idx;
}
let parent = parent
.and_then(|p| self.slots.get(&p).copied())
.unwrap_or(MoveIdx::NONE);
let idx = self.moves.push(parent);
self.slots.insert(id, idx);
idx
}
/// The drawing a widget already has, kept for a new box if the box has not
/// changed in a way it depends on.
fn try_reuse(&mut self, id: WidgetId, region: UiRegion, rsc: &dyn UiRsc) -> Option<Size> {
@@ -203,18 +178,12 @@ impl UiRenderState {
return None;
}
let active = self.active.get(&id)?;
let (size, old, slot) = (active.size, active.region, active.move_idx);
// TODO: epsilon?
if old.size() == region.size() {
// The right shape and only somewhere else, which is one slot to
// write however much is under it. Both boxes are in the
// coordinates its parent drew, so the chain above applies alike.
let moved =
region.to_px(self.output_size).top_left - old.to_px(self.output_size).top_left;
self.moves.set(slot, moved);
let (size, old) = (active.size, active.region);
if old == region {
return Some(size);
}
if !self.reusable(id, region, rsc) {
// TODO: epsilon?
if old.size() != region.size() && !self.reusable(id, region, rsc) {
return None;
}
// Its drawing stands, if the new box can be reached from the old one.
@@ -259,9 +228,6 @@ impl UiRenderState {
})
}
/// Rewrites a subtree's regions into a new box, for a change a slot
/// cannot express. Every region it rewrites is a region some slot was a
/// delta from, so those go back to zero.
fn mov(&mut self, id: WidgetId, remap: &Remap) {
let active = self.active.get_mut(&id).unwrap();
for h in &active.primitives {
@@ -269,7 +235,6 @@ impl UiRenderState {
*region = remap.apply(*region);
}
active.region = remap.apply(active.region);
self.moves.set(active.move_idx, Vec2::ZERO);
// SAFETY: children cannot be recursive
let children = unsafe { forget_ref(&active.children) };
for child in children {
@@ -303,10 +268,6 @@ impl UiRenderState {
self.remove_rec(*c, rsc);
}
}
// After the descendants, whose slots name this one as their parent.
if let Some(idx) = self.slots.remove(&id) {
self.moves.remove(idx);
}
inst
}
@@ -314,8 +275,6 @@ impl UiRenderState {
for (_, active) in self.active.drain() {
rsc.on_undraw(&active);
}
self.slots.clear();
self.moves.clear();
self.layers.clear();
rsc.widgets_mut().needs_redraw.clear();
rsc.free();
@@ -363,16 +322,9 @@ impl UiRenderState {
}
}
/// Where a widget is on screen: the box it drew against plus whatever the
/// chain above has moved since, which is the walk the vertex shader does.
pub fn window_region(&self, id: &impl IdLike) -> Option<PixelRegion> {
let active = self.active.get(&id.id())?;
let moved = self.moves.resolve(active.move_idx);
let region = active.region.to_px(self.output_size);
Some(PixelRegion {
top_left: region.top_left + moved,
bot_right: region.bot_right + moved,
})
let region = self.active.get(&id.id())?.region;
Some(region.to_px(self.output_size))
}
/// redraws a widget that's currently active (drawn)
-4
View File
@@ -34,10 +34,6 @@ impl<T, I: IdNum> Arena<T, I> {
self.tracker.free(id);
self.data[i]
}
pub fn get_mut(&mut self, id: Id<I>) -> &mut T {
&mut self.data[id.idx()]
}
}
impl<T, I: IdNum> Default for Arena<T, I> {
+1 -5
View File
@@ -1,11 +1,7 @@
use crate::util::impl_op;
use std::{hash::Hash, ops::*};
/// `align(8)` because that is WGSL's alignment for a `vec2<f32>`, so any GPU
/// struct holding one is laid out the way its shader reads it without having
/// to say so itself. Those structs still need a manual `unsafe impl Pod`,
/// since the trailing padding this introduces is what `derive(Pod)` refuses.
#[repr(C, align(8))]
#[repr(C)]
#[derive(Clone, Copy, PartialEq, Default, bytemuck::Pod, bytemuck::Zeroable)]
pub struct Vec2 {
pub x: f32,
-16
View File
@@ -1,16 +0,0 @@
[package]
name = "rig-input"
version.workspace = true
edition.workspace = true
# Replays `.touch` recordings through Wayland's virtual-pointer protocol;
# headless sway has no input devices for coordinate-driving tools to move.
[[bin]]
name = "replay-touch"
path = "src/main.rs"
[dependencies]
# Share the harness parser so both ways of replaying read a file the same.
iris = { path = ".." }
wayland-client = { workspace = true }
wayland-protocols-wlr = { workspace = true }
-141
View File
@@ -1,141 +0,0 @@
use iris::harness::{TouchAction, TouchScript};
use std::time::Duration;
use wayland_client::protocol::wl_pointer::ButtonState;
use wayland_client::protocol::{wl_registry, wl_seat};
use wayland_client::{Connection, Dispatch, QueueHandle, delegate_noop};
use wayland_protocols_wlr::virtual_pointer::v1::client::{
zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1,
zwlr_virtual_pointer_v1::ZwlrVirtualPointerV1,
};
const BTN_LEFT: u32 = 0x110;
const SETTLE: Duration = Duration::from_millis(200);
#[derive(Default)]
struct Globals {
seat: Option<wl_seat::WlSeat>,
manager: Option<ZwlrVirtualPointerManagerV1>,
}
impl Dispatch<wl_registry::WlRegistry, ()> for Globals {
fn event(
state: &mut Self,
registry: &wl_registry::WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
let wl_registry::Event::Global {
name,
interface,
version,
} = event
else {
return;
};
match interface.as_str() {
"wl_seat" => {
state.seat = Some(registry.bind(name, version.min(7), qh, ()));
}
"zwlr_virtual_pointer_manager_v1" => {
state.manager = Some(registry.bind(name, version.min(2), qh, ()));
}
_ => {}
}
}
}
delegate_noop!(Globals: ignore wl_seat::WlSeat);
delegate_noop!(Globals: ZwlrVirtualPointerManagerV1);
delegate_noop!(Globals: ZwlrVirtualPointerV1);
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let [width, height, path] = args.as_slice() else {
eprintln!("usage: replay-touch WIDTH HEIGHT FILE");
std::process::exit(2);
};
let (width, height) = (parse(width, "WIDTH"), parse(height, "HEIGHT"));
let text = std::fs::read_to_string(path)
.unwrap_or_else(|e| fail(&format!("could not read {path}: {e}")));
let script = TouchScript::parse(&text).unwrap_or_else(|e| fail(&e));
let conn = Connection::connect_to_env().unwrap_or_else(|e| {
fail(&format!(
"no wayland display ({e}); is WAYLAND_DISPLAY set?"
))
});
let mut queue = conn.new_event_queue();
let qh = queue.handle();
let display = conn.display();
display.get_registry(&qh, ());
let mut globals = Globals::default();
queue
.roundtrip(&mut globals)
.unwrap_or_else(|e| fail(&format!("wayland roundtrip failed: {e}")));
let manager = globals.manager.as_ref().unwrap_or_else(|| {
fail(
"this compositor does not offer zwlr_virtual_pointer_manager_v1, so a pointer cannot \
be synthesised; sway and every wlroots compositor do",
)
});
let pointer = manager.create_virtual_pointer(globals.seat.as_ref(), &qh, ());
// Put the pointer where the gesture starts and let the compositor
// settle before anything is pressed. Without this the press is
// dropped: sway has just learned about this pointer, and a button
// sent in the same breath as the motion that first puts it over a
// window arrives before there is a focused surface to send it to --
// winit sees `CursorEntered`, the moves and the *release*, never the
// press, so the gesture reads as a hover and nothing scrolls. Found
// by printing winit's own events; the settle is what fixed it.
if let Some(first) = script.samples.first() {
pointer.motion_absolute(0, first.pos.x as u32, first.pos.y as u32, width, height);
pointer.frame();
conn.flush()
.unwrap_or_else(|e| fail(&format!("flush: {e}")));
std::thread::sleep(SETTLE);
}
let mut previous = 0;
for sample in &script.samples {
std::thread::sleep(Duration::from_millis(sample.t_ms - previous));
previous = sample.t_ms;
let t = sample.t_ms as u32;
pointer.motion_absolute(t, sample.pos.x as u32, sample.pos.y as u32, width, height);
pointer.frame();
// The button goes in a frame of its own, *after* the motion has
// been committed. Sent in the same frame as the motion that
// first puts the pointer over the window, sway drops it: the
// client sees `CursorEntered` and the moves but never a
// `MouseInput { state: Pressed }`, so the whole gesture reads as
// a hover and nothing scrolls. Found exactly that way, by
// printing winit's events.
let state = match sample.action {
TouchAction::Down => Some(ButtonState::Pressed),
TouchAction::Up => Some(ButtonState::Released),
TouchAction::Move => None,
};
if let Some(state) = state {
pointer.button(t, BTN_LEFT, state);
pointer.frame();
}
conn.flush()
.unwrap_or_else(|e| fail(&format!("flush: {e}")));
}
pointer.destroy();
conn.flush().ok();
}
fn parse(text: &str, what: &str) -> u32 {
text.parse()
.unwrap_or_else(|_| fail(&format!("{what} is not a whole number: {text:?}")))
}
fn fail(message: &str) -> ! {
eprintln!("replay-touch: {message}");
std::process::exit(1);
}
-14
View File
@@ -1,14 +0,0 @@
# The compositor `scripts/run-headless.sh` starts, so that an example has a
# surface where there is no display. Nothing here is meant to be looked at
# directly; `grim` is.
#
# No Xwayland: winit talks Wayland natively, so an X server is a second thing
# to go wrong for no gain.
xwayland disable
# The default output, overridden per run by `--mode`. Larger than the window
# an example opens, so nothing is scaled or clipped.
output HEADLESS-1 mode 1920x1200@60Hz
default_border none
focus_follows_mouse no
-170
View File
@@ -1,170 +0,0 @@
#!/bin/sh
# Run an iris example on a machine with no display.
#
# ./scripts/run-headless.sh tabs
# ./scripts/run-headless.sh tabs --shot /tmp/tabs.png --seconds 4
# ./scripts/run-headless.sh tabs --replay taps.touch --shot /tmp/tabs.png
# ./scripts/run-headless.sh app --dir ../elsewhere --mode 1080x2424@120Hz
#
# `--dir DIR` names the workspace to build in, defaulting to this one, so a
# project that depends on iris can be run through the same rig. `--bin` runs a
# crate binary rather than an example, and takes its own argv from
# `$RUN_HEADLESS_ARGS`, word-split on purpose.
#
# `--mode` sets the output, for running something at a size other than a
# desktop's -- a phone's, say. Set every run rather than only when it changes:
# the compositor is reused between runs, so a default-shaped run after a
# custom one would otherwise inherit the other's output and quietly screenshot
# the wrong size.
#
# `--replay FILE` drives a `.touch` recording into the window through
# `replay-touch`, which reads it with the same parser `iris::harness` uses. A
# recording is `<ms> down|move|up <x> <y>` in the output's own pixels. With
# `--shot` it also writes `<shot>-before.png` from just before the gesture,
# since "it moved" is a claim about two pictures.
#
# What it supplies is a compositor for winit to open a surface on: a headless
# sway, and `grim` to screenshot it. Sway gets its own socket and runtime
# directory rather than joining whatever else is running, because it tiles --
# adding a window to someone else's compositor resizes theirs.
set -eu
need() {
command -v "$1" >/dev/null 2>&1 || {
echo "run-headless: $1 is not installed ($2)" >&2
exit 127
}
}
need sway "the compositor an example opens its window on"
need swaymsg "sway's control socket"
scripts=$(cd "$(dirname "$0")" && pwd)
root=$(cd "$scripts/.." && pwd)
workdir="$root"
cd "$root"
run="${XDG_RUNTIME_DIR:-/tmp}/iris-headless"
seconds=3
shot=""
replay=""
example=""
kind=example
mode=1920x1200@60Hz
while [ $# -gt 0 ]; do
case "$1" in
--shot) shot=$2; shift 2 ;;
--seconds) seconds=$2; shift 2 ;;
--bin) kind=bin; shift ;;
--mode) mode=$2; shift 2 ;;
--replay) replay=$2; shift 2 ;;
--dir) workdir=$(cd "$2" && pwd); shift 2 ;;
--) shift; break ;;
*) example=$1; shift ;;
esac
done
[ -n "$example" ] || { echo "usage: $0 NAME [--bin] [--dir DIR] [--mode WxH@Hz] [--replay TOUCH] [--shot PNG] [--seconds N] [-- cargo args]" >&2; exit 2; }
[ -z "$replay" ] || [ -f "$replay" ] || { echo "run-headless: no touch script at $replay" >&2; exit 2; }
[ -z "$shot" ] || need grim "the screenshot --shot writes"
mkdir -p "$run"
export SWAYSOCK="$run/sway.sock"
# Named rather than left to sway's pid-based default, so a second run reuses
# this compositor instead of starting another beside it.
if ! swaymsg -t get_version >/dev/null 2>&1; then
rm -f "$SWAYSOCK"
WLR_BACKENDS=headless WLR_LIBINPUT_NO_DEVICES=1 LIBSEAT_BACKEND=noop \
setsid sway -c "$scripts/headless.conf" >"$run/sway.log" 2>&1 &
i=0
while [ $i -lt 20 ]; do
swaymsg -t get_version >/dev/null 2>&1 && break
i=$((i + 1)); sleep 0.5
done
swaymsg -t get_version >/dev/null 2>&1 || {
echo "run-headless: compositor did not start; see $run/sway.log" >&2
exit 1
}
fi
rm -f "$run/display"
swaymsg exec -- "sh -c 'printf %s \"\$WAYLAND_DISPLAY\" > $run/display'" >/dev/null
i=0
while [ $i -lt 20 ]; do
[ -s "$run/display" ] && break
i=$((i + 1)); sleep 0.5
done
[ -s "$run/display" ] || { echo "run-headless: could not read WAYLAND_DISPLAY" >&2; exit 1; }
WAYLAND_DISPLAY=$(cat "$run/display")
export WAYLAND_DISPLAY
echo "run-headless: $WAYLAND_DISPLAY (sway $(swaymsg -t get_version --raw | sed -n 's/.*"human_readable":"\([^"]*\)".*/\1/p'))" >&2
swaymsg output HEADLESS-1 mode "$mode" >/dev/null
# The extent `replay-touch` positions against, so a script's coordinates
# are the output's own pixels.
out_w=${mode%x*}
out_h=${mode#*x}; out_h=${out_h%@*}
# Built before the app starts, so a compile error is not reported as a
# window that failed to move.
[ -z "$replay" ] || (cd "$root" && cargo build --bin replay-touch -p rig-input) >&2
cd "$workdir"
if [ "$kind" = bin ]; then
cargo build --bin "$example" "$@" >&2
bin="$workdir/target/debug/$example"
else
cargo build --example "$example" "$@" >&2
bin="$workdir/target/debug/examples/$example"
fi
# Deliberately word-split: this is the binary's own argv, not a single path.
# shellcheck disable=SC2086
"$bin" ${RUN_HEADLESS_ARGS:-} >"$run/$example.log" 2>&1 &
pid=$!
trap 'kill "$pid" 2>/dev/null || true' EXIT INT TERM
# Wait for the window to be mapped rather than for a number of seconds. A
# fixed sleep took an all-black screenshot the first time this ran, when sway
# had started in the same invocation and had not composited its output yet --
# which is indistinguishable from an app that draws nothing.
i=0
while [ $i -lt 40 ]; do
kill -0 "$pid" 2>/dev/null || break
swaymsg -t get_tree --raw 2>/dev/null | grep -q "\"pid\":$pid," && break
i=$((i + 1)); sleep 0.25
done
i=0
while [ $i -lt "$((seconds * 2))" ]; do
kill -0 "$pid" 2>/dev/null || break
i=$((i + 1)); sleep 0.5
done
if [ -n "$replay" ] && kill -0 "$pid" 2>/dev/null; then
if [ -n "$shot" ]; then
grim "${shot%.png}-before.png"
echo "run-headless: wrote ${shot%.png}-before.png (before the gesture)" >&2
fi
"$root/target/debug/replay-touch" "$out_w" "$out_h" "$replay"
# A fling outlives the finger: the gesture's own last sample is not
# when the list stops. Long enough for Android's spline to settle
# (`FlingCalculator::duration` tops out around a second and a half).
sleep 2
fi
if kill -0 "$pid" 2>/dev/null; then
[ -n "$shot" ] && grim "$shot" && echo "run-headless: wrote $shot" >&2
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
status=0
else
wait "$pid" 2>/dev/null || status=$?
echo "run-headless: $example exited early (status ${status:-0})" >&2
status=${status:-1}
fi
echo "--- $example output ---" >&2
cat "$run/$example.log" >&2
exit "$status"
-140
View File
@@ -37,81 +37,6 @@ macro_rules! assert_corners {
}
pub use crate::assert_corners;
/// One replayed pointer sample, cut down to what a window delivers: where the
/// pointer is, and whether the button changed.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TouchAction {
Down,
Move,
Up,
}
impl TouchAction {
fn parse(word: &str) -> Option<Self> {
match word {
"down" => Some(Self::Down),
"move" => Some(Self::Move),
"up" => Some(Self::Up),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct TouchSample {
pub t_ms: u64,
pub action: TouchAction,
pub pos: Vec2,
}
/// A recorded gesture, in the output's own pixels: `<ms> down|move|up <x> <y>`
/// a line, `#` to end of line ignored.
///
/// One parser for both ways of replaying a recording -- into a harness, and
/// into a real window -- so the two cannot read the same file differently.
pub struct TouchScript {
pub samples: Vec<TouchSample>,
}
impl TouchScript {
pub fn parse(text: &str) -> Result<Self, String> {
let mut samples: Vec<TouchSample> = Vec::new();
for (i, line) in text.lines().enumerate() {
let line = line.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
let at = |what: &str| format!("touch script line {}: {what}: {line:?}", i + 1);
let mut words = line.split_whitespace();
let (Some(t), Some(action), Some(x), Some(y), None) = (
words.next(),
words.next(),
words.next(),
words.next(),
words.next(),
) else {
return Err(at("expected `t_ms action x y`"));
};
let t_ms: u64 = t.parse().map_err(|_| at("t_ms is not a whole number"))?;
let action =
TouchAction::parse(action).ok_or_else(|| at("action is not down/move/up"))?;
let x: f32 = x.parse().map_err(|_| at("x is not a number"))?;
let y: f32 = y.parse().map_err(|_| at("y is not a number"))?;
if let Some(last) = samples.last()
&& t_ms < last.t_ms
{
return Err(at("samples must be in time order"));
}
samples.push(TouchSample {
t_ms,
action,
pos: Vec2::new(x, y),
});
}
Ok(Self { samples })
}
}
#[derive(Default)]
pub struct HarnessState {
pub root: Option<StrongWidget>,
@@ -235,23 +160,6 @@ impl Harness {
self.release(CursorButton::Left);
}
/// Drives a recorded gesture through the harness.
pub fn replay(&mut self, script: &TouchScript) {
for sample in &script.samples {
match sample.action {
TouchAction::Down => {
self.move_to(sample.pos);
self.press(CursorButton::Left);
}
TouchAction::Move => self.move_to(sample.pos),
TouchAction::Up => {
self.move_to(sample.pos);
self.release(CursorButton::Left);
}
}
}
}
fn button(&mut self, button: CursorButton) -> &mut ActivationState {
let buttons = &mut self.cursor.buttons;
match button {
@@ -270,51 +178,3 @@ impl Harness {
self.cursor.end_frame();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(text: &str) -> Result<Vec<(u64, TouchAction, f32, f32)>, String> {
Ok(TouchScript::parse(text)?
.samples
.iter()
.map(|s| (s.t_ms, s.action, s.pos.x, s.pos.y))
.collect())
}
#[test]
fn a_recording_is_time_action_and_a_point() {
assert_eq!(
parse("0 down 10 20\n16 move 10.5 24\n32 up 10.5 24").unwrap(),
[
(0, TouchAction::Down, 10.0, 20.0),
(16, TouchAction::Move, 10.5, 24.0),
(32, TouchAction::Up, 10.5, 24.0),
]
);
}
#[test]
fn blank_lines_and_comments_are_not_samples() {
assert_eq!(
parse("# a flick\n\n 0 down 1 2 # the finger lands\n\n").unwrap(),
[(0, TouchAction::Down, 1.0, 2.0)]
);
}
#[test]
fn a_recording_that_goes_backwards_is_rejected() {
// Replay waits out the gap between samples, so time running backwards
// would silently become no wait at all.
let err = parse("16 down 1 2\n0 up 1 2").unwrap_err();
assert!(err.contains("time order"), "{err}");
}
#[test]
fn a_line_that_is_not_a_sample_says_which_line() {
let err = parse("0 down 1 2\n16 wiggle 1 2").unwrap_err();
assert!(err.contains("line 2"), "{err}");
assert!(err.contains("down/move/up"), "{err}");
}
}
+2 -5
View File
@@ -22,8 +22,8 @@ use std::time::Instant;
use iris::prelude::*;
use iris_core::{
GlyphPrimitive, MaskIdx, MoveIdx, PrimitiveInst, RectPrimitive, TextureHandle,
TexturePrimitive, UiData, UiRegion, UiRenderNode, UiRenderState,
GlyphPrimitive, MaskIdx, PrimitiveInst, RectPrimitive, TextureHandle, TexturePrimitive, UiData,
UiRegion, UiRenderNode, UiRenderState,
};
use wgpu::{Color as GpuColor, *};
@@ -95,7 +95,6 @@ fn fill(
primitive: RectPrimitive::color(UiColor::WHITE),
region: UiRegion::FULL,
mask_idx: MaskIdx::NONE,
move_idx: MoveIdx::NONE,
},
);
render.layers.write(
@@ -112,7 +111,6 @@ fn fill(
},
region: UiRegion::FULL,
mask_idx: MaskIdx::NONE,
move_idx: MoveIdx::NONE,
},
);
}
@@ -125,7 +123,6 @@ fn fill(
primitive: TexturePrimitive::from(h),
region: UiRegion::FULL,
mask_idx: MaskIdx::NONE,
move_idx: MoveIdx::NONE,
},
);
}
-17
View File
@@ -109,20 +109,3 @@ fn a_fixed_box_is_drawn_again_rather_than_stretched() {
assert_corners!(h, panel, (0, 0), (400, 250));
}
#[test]
fn a_moved_subtree_takes_its_children_with_it() {
let mut h = Harness::new((400, 400));
let first = rect(Color::RED).height(40).add(&mut h.rsc);
let inner = rect(Color::BLUE).add(&mut h.rsc);
let row = inner.pad(10).height(40).add(&mut h.rsc);
h.set_root((first, row).span(Dir::DOWN));
assert_corners!(h, inner, (10, 50), (390, 70));
h.rsc[first].y = Some(Len::abs(80));
h.frame();
// The row is the same shape somewhere else, so one slot moved it and
// `inner`'s own region was never rewritten.
assert_corners!(h, inner, (10, 90), (390, 110));
}
+1 -26
View File
@@ -2,7 +2,7 @@
use std::{cell::RefCell, rc::Rc};
use iris::harness::{Harness, TouchScript};
use iris::harness::Harness;
use iris::prelude::*;
#[test]
@@ -58,28 +58,3 @@ fn hover_ends_when_the_cursor_leaves_the_window() {
h.leave();
assert_eq!((*hovered.borrow(), *ended.borrow()), (1, 1));
}
#[test]
fn a_recorded_gesture_presses_where_it_says() {
let mut h = Harness::new((400, 200));
let clicks = Rc::new(RefCell::new(Vec::new()));
let (on_left, on_right) = (clicks.clone(), clicks.clone());
let left = rect(Color::RED)
.width(100)
.on(CursorSense::click(), move |_, _| {
on_left.borrow_mut().push("left")
})
.add(&mut h.rsc);
let right = rect(Color::BLUE)
.on(CursorSense::click(), move |_, _| {
on_right.borrow_mut().push("right")
})
.add(&mut h.rsc);
h.set_root((left, right).span(Dir::RIGHT));
let script = TouchScript::parse("0 down 300 100\n80 up 300 100").unwrap();
h.replay(&script);
assert_eq!(*clicks.borrow(), ["right"]);
}