Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
deb9c1b6d7 | ||
|
|
9d13f15bee | ||
|
|
43ce8c7d02 | ||
|
|
c8ac669f95 | ||
|
|
32b10383d8 | ||
|
|
00d2230b84 |
No files matched your search
Generated
+228
-245
File diff suppressed because it is too large.
Load diff
+5
-3
@@ -20,7 +20,7 @@ tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] }
|
|||||||
tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread", "time"] }
|
tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread", "time"] }
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
members = ["core", "macro"]
|
members = ["core", "macro", "rig-input"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -29,9 +29,9 @@ edition = "2024"
|
|||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
pollster = "0.4.0"
|
pollster = "0.4.0"
|
||||||
winit = "0.30.12"
|
winit = "0.30.12"
|
||||||
wgpu = "28.0.0"
|
wgpu = "30.0.1"
|
||||||
bytemuck = "1.23.1"
|
bytemuck = "1.23.1"
|
||||||
image = "0.25.6"
|
image = "0.25.10"
|
||||||
parley = "0.11.1"
|
parley = "0.11.1"
|
||||||
swash = "0.2.10"
|
swash = "0.2.10"
|
||||||
fxhash = "0.2.1"
|
fxhash = "0.2.1"
|
||||||
@@ -40,3 +40,5 @@ arboard = "3.6.1"
|
|||||||
iris-core = { path = "core" }
|
iris-core = { path = "core" }
|
||||||
iris-macro = { path = "macro" }
|
iris-macro = { path = "macro" }
|
||||||
tokio = "1.49.0"
|
tokio = "1.49.0"
|
||||||
|
wayland-client = "0.31.15"
|
||||||
|
wayland-protocols-wlr = { version = "0.3.12", features = ["client"] }
|
||||||
@@ -79,6 +79,7 @@ type EventData<Rsc, E> = (E, Rc<dyn for<'a> EventFn<Rsc, <E as Event>::Data<'a>>
|
|||||||
pub struct TypeEventManager<Rsc: HasEvents, E: Event> {
|
pub struct TypeEventManager<Rsc: HasEvents, E: Event> {
|
||||||
// TODO: reduce visiblity!!
|
// TODO: reduce visiblity!!
|
||||||
pub active: HashMap<LayerId, HashMap<WidgetId, E::State>>,
|
pub active: HashMap<LayerId, HashMap<WidgetId, E::State>>,
|
||||||
|
pub global: E::Global,
|
||||||
map: HashMap<WidgetId, Vec<EventData<Rsc, E>>>,
|
map: HashMap<WidgetId, Vec<EventData<Rsc, E>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +108,7 @@ impl<Rsc: HasEvents, E: Event> Default for TypeEventManager<Rsc, E> {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
active: Default::default(),
|
active: Default::default(),
|
||||||
|
global: Default::default(),
|
||||||
map: Default::default(),
|
map: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,11 +140,13 @@ impl<Rsc: HasEvents + 'static, E: Event> TypeEventManager<Rsc, E> {
|
|||||||
pub fn run_fn<'a>(
|
pub fn run_fn<'a>(
|
||||||
&mut self,
|
&mut self,
|
||||||
id: impl IdLike,
|
id: impl IdLike,
|
||||||
) -> impl for<'b> FnOnce(EventCtx<'_, Rsc, E::Data<'b>>, &mut Rsc) + 'a {
|
) -> impl for<'b> FnOnce(EventCtx<'_, Rsc, E::Data<'b>>, &mut Rsc) -> bool + 'a {
|
||||||
let fs = self.map.get(&id.id()).cloned().unwrap_or_default();
|
let fs = self.map.get(&id.id()).cloned().unwrap_or_default();
|
||||||
move |ctx, rsc| {
|
move |ctx, rsc| {
|
||||||
|
let mut consumed = false;
|
||||||
for (e, f) in fs {
|
for (e, f) in fs {
|
||||||
if let Some(data) = e.should_run(&ctx.data) {
|
if let Some(data) = e.should_run(&ctx.data) {
|
||||||
|
consumed |= e.consumes(&data);
|
||||||
f(
|
f(
|
||||||
EventCtx {
|
EventCtx {
|
||||||
state: ctx.state,
|
state: ctx.state,
|
||||||
@@ -152,6 +156,7 @@ impl<Rsc: HasEvents + 'static, E: Event> TypeEventManager<Rsc, E> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
consumed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,10 +9,19 @@ pub use rsc::*;
|
|||||||
pub trait Event: Sized + 'static + Clone {
|
pub trait Event: Sized + 'static + Clone {
|
||||||
type Data<'a>: Clone = ();
|
type Data<'a>: Clone = ();
|
||||||
type State: Default = ();
|
type State: Default = ();
|
||||||
|
/// State the whole event type keeps, rather than one copy per widget.
|
||||||
|
type Global: Default = ();
|
||||||
#[allow(unused_variables)]
|
#[allow(unused_variables)]
|
||||||
fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option<Self::Data<'a>> {
|
fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option<Self::Data<'a>> {
|
||||||
Some(data.clone())
|
Some(data.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether having run on this data uses up whatever triggered it, so
|
||||||
|
/// nothing further should see it.
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
fn consumes(&self, data: &Self::Data<'_>) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait EventLike {
|
pub trait EventLike {
|
||||||
|
|||||||
@@ -21,12 +21,13 @@ pub trait HasEvents: Sized + UiRsc + HasState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub trait RunEvents: HasEvents {
|
pub trait RunEvents: HasEvents {
|
||||||
|
/// Whether anything that ran used up what triggered it.
|
||||||
fn run_event<E: EventLike>(
|
fn run_event<E: EventLike>(
|
||||||
&mut self,
|
&mut self,
|
||||||
id: impl IdLike,
|
id: impl IdLike,
|
||||||
data: <E::Event as Event>::Data<'_>,
|
data: <E::Event as Event>::Data<'_>,
|
||||||
state: &mut Self::State,
|
state: &mut Self::State,
|
||||||
) {
|
) -> bool {
|
||||||
let f = self.events_mut().get_type::<E>().run_fn(id);
|
let f = self.events_mut().get_type::<E>().run_fn(id);
|
||||||
f(EventCtx { state, data }, self)
|
f(EventCtx { state, data }, self)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -421,7 +421,7 @@ impl Display for UiRegion {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
pub struct PixelRegion {
|
pub struct PixelRegion {
|
||||||
pub top_left: Vec2,
|
pub top_left: Vec2,
|
||||||
pub bot_right: Vec2,
|
pub bot_right: Vec2,
|
||||||
|
|||||||
@@ -186,8 +186,8 @@ impl UiRenderNode {
|
|||||||
for source in ®istry.sources()[self.primitives.len()..] {
|
for source in ®istry.sources()[self.primitives.len()..] {
|
||||||
let render = (source.render)(device, queue);
|
let render = (source.render)(device, queue);
|
||||||
let data_layout = Self::data_layout(device, source.stride);
|
let data_layout = Self::data_layout(device, source.stride);
|
||||||
let mut groups = vec![&self.shared_layout, &data_layout];
|
let mut groups = vec![Some(&self.shared_layout), Some(&data_layout)];
|
||||||
groups.extend(render.layout());
|
groups.extend(render.layout().map(Some));
|
||||||
let layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
|
let layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
|
||||||
label: Some(source.label),
|
label: Some(source.label),
|
||||||
bind_group_layouts: &groups,
|
bind_group_layouts: &groups,
|
||||||
@@ -219,7 +219,7 @@ impl UiRenderNode {
|
|||||||
vertex: VertexState {
|
vertex: VertexState {
|
||||||
module: &module,
|
module: &module,
|
||||||
entry_point: Some("vs_main"),
|
entry_point: Some("vs_main"),
|
||||||
buffers: &[PrimitiveInstance::desc()],
|
buffers: &[Some(PrimitiveInstance::desc())],
|
||||||
compilation_options: Default::default(),
|
compilation_options: Default::default(),
|
||||||
},
|
},
|
||||||
fragment: Some(FragmentState {
|
fragment: Some(FragmentState {
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ impl UiRenderState {
|
|||||||
self.resized = true;
|
self.resized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn output_size(&self) -> Vec2 {
|
||||||
|
self.output_size
|
||||||
|
}
|
||||||
|
|
||||||
pub fn update<'a>(&mut self, root: impl Into<Option<&'a StrongWidget>>, rsc: &mut dyn UiRsc) {
|
pub fn update<'a>(&mut self, root: impl Into<Option<&'a StrongWidget>>, rsc: &mut dyn UiRsc) {
|
||||||
// safety mechanism for memory leaks; might wanna return a result instead so user can
|
// safety mechanism for memory leaks; might wanna return a result instead so user can
|
||||||
// decide whether to panic or not
|
// decide whether to panic or not
|
||||||
|
|||||||
+1
-5
@@ -10,11 +10,7 @@ struct State {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DefaultAppState for State {
|
impl DefaultAppState for State {
|
||||||
fn new(
|
fn new(mut ui_state: DefaultUiState, rsc: &mut DefaultRsc<Self>, _: Proxy<Self>) -> Self {
|
||||||
mut ui_state: DefaultUiState,
|
|
||||||
rsc: &mut DefaultRsc<Self>,
|
|
||||||
_: Proxy<Self::Event>,
|
|
||||||
) -> Self {
|
|
||||||
rect(Color::RED).set_root(rsc, &mut ui_state);
|
rect(Color::RED).set_root(rsc, &mut ui_state);
|
||||||
Self { ui_state }
|
Self { ui_state }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,7 @@ pub struct Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DefaultAppState for Client {
|
impl DefaultAppState for Client {
|
||||||
fn new(
|
fn new(mut ui_state: DefaultUiState, rsc: &mut DefaultRsc<Self>, _: Proxy<Self>) -> Self {
|
||||||
mut ui_state: DefaultUiState,
|
|
||||||
rsc: &mut DefaultRsc<Self>,
|
|
||||||
_: Proxy<Self::Event>,
|
|
||||||
) -> Self {
|
|
||||||
let rrect = rect(Color::WHITE).radius(20);
|
let rrect = rect(Color::WHITE).radius(20);
|
||||||
let pad_test = (
|
let pad_test = (
|
||||||
rrect.color(Color::BLUE),
|
rrect.color(Color::BLUE),
|
||||||
|
|||||||
+1
-5
@@ -11,11 +11,7 @@ struct State {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DefaultAppState for State {
|
impl DefaultAppState for State {
|
||||||
fn new(
|
fn new(mut ui_state: DefaultUiState, rsc: &mut DefaultRsc<Self>, _: Proxy<Self>) -> Self {
|
||||||
mut ui_state: DefaultUiState,
|
|
||||||
rsc: &mut DefaultRsc<Self>,
|
|
||||||
_: Proxy<Self::Event>,
|
|
||||||
) -> Self {
|
|
||||||
let rect = rect(Color::RED).add(rsc);
|
let rect = rect(Color::RED).add(rsc);
|
||||||
rect.task_on(CursorSense::click(), async move |mut ctx| {
|
rect.task_on(CursorSense::click(), async move |mut ctx| {
|
||||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
|||||||
+1
-5
@@ -36,11 +36,7 @@ impl Test {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DefaultAppState for State {
|
impl DefaultAppState for State {
|
||||||
fn new(
|
fn new(mut ui_state: DefaultUiState, rsc: &mut DefaultRsc<Self>, _: Proxy<Self>) -> Self {
|
||||||
mut ui_state: DefaultUiState,
|
|
||||||
rsc: &mut DefaultRsc<Self>,
|
|
||||||
_: Proxy<Self::Event>,
|
|
||||||
) -> Self {
|
|
||||||
let test = Test::new(rsc);
|
let test = Test::new(rsc);
|
||||||
|
|
||||||
test.on(CursorSense::click(), move |_, rsc| {
|
test.on(CursorSense::click(), move |_, rsc| {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[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 }
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# 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
|
||||||
Executable
+170
@@ -0,0 +1,170 @@
|
|||||||
|
#!/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"
|
||||||
+62
-49
@@ -1,10 +1,6 @@
|
|||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use arboard::Clipboard;
|
use arboard::Clipboard;
|
||||||
use std::{
|
use std::{marker::PhantomData, sync::Arc, time::Instant};
|
||||||
marker::{PhantomData, Sized},
|
|
||||||
sync::Arc,
|
|
||||||
time::Instant,
|
|
||||||
};
|
|
||||||
use winit::{
|
use winit::{
|
||||||
event::{Ime, WindowEvent},
|
event::{Ime, WindowEvent},
|
||||||
event_loop::{ActiveEventLoop, EventLoopProxy},
|
event_loop::{ActiveEventLoop, EventLoopProxy},
|
||||||
@@ -29,7 +25,35 @@ pub use sense::*;
|
|||||||
pub use state::*;
|
pub use state::*;
|
||||||
pub use task::*;
|
pub use task::*;
|
||||||
|
|
||||||
pub type Proxy<Event> = EventLoopProxy<Event>;
|
/// Sends an application's own events to its event loop. It wraps the proxy
|
||||||
|
/// rather than being one because task updates travel the same way: what an
|
||||||
|
/// application sends is its `Event`, not the loop's whole message type.
|
||||||
|
pub struct Proxy<State: DefaultAppState>(EventLoopProxy<DefaultEvent<State>>);
|
||||||
|
|
||||||
|
impl<State: DefaultAppState> Clone for Proxy<State> {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self(self.0.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<State: DefaultAppState> Proxy<State> {
|
||||||
|
pub fn send_event(&self, event: State::Event) {
|
||||||
|
let _ = self.0.send_event(DefaultEvent::User(event));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the event loop carries: the application's own events, and the
|
||||||
|
/// updates tasks send back to the ui thread.
|
||||||
|
pub enum DefaultEvent<State: DefaultAppState> {
|
||||||
|
User(State::Event),
|
||||||
|
Update(Box<dyn TaskUpdate<DefaultRsc<State>>>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<State: DefaultAppState> TaskQueue<DefaultRsc<State>> for Proxy<State> {
|
||||||
|
fn send(&self, update: Box<dyn TaskUpdate<DefaultRsc<State>>>) {
|
||||||
|
let _ = self.0.send_event(DefaultEvent::Update(update));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct DefaultUiState {
|
pub struct DefaultUiState {
|
||||||
pub root: Option<StrongWidget>,
|
pub root: Option<StrongWidget>,
|
||||||
@@ -70,9 +94,8 @@ pub trait HasDefaultUiState: Sized + 'static {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub trait DefaultAppState: HasDefaultUiState {
|
pub trait DefaultAppState: HasDefaultUiState {
|
||||||
type Event = ();
|
type Event: Send = ();
|
||||||
fn new(ui_state: DefaultUiState, rsc: &mut DefaultRsc<Self>, proxy: Proxy<Self::Event>)
|
fn new(ui_state: DefaultUiState, rsc: &mut DefaultRsc<Self>, proxy: Proxy<Self>) -> Self;
|
||||||
-> Self;
|
|
||||||
#[allow(unused_variables)]
|
#[allow(unused_variables)]
|
||||||
fn event(
|
fn event(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -105,18 +128,14 @@ pub struct DefaultRsc<State: 'static> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<State> DefaultRsc<State> {
|
impl<State> DefaultRsc<State> {
|
||||||
fn init(window: Arc<Window>) -> (Self, TaskMsgReceiver<Self>) {
|
pub fn init(queue: Arc<dyn TaskQueue<Self>>) -> Self {
|
||||||
let (tasks, recv) = Tasks::init(window);
|
Self {
|
||||||
(
|
ui: Default::default(),
|
||||||
Self {
|
events: Default::default(),
|
||||||
ui: Default::default(),
|
tasks: Tasks::init(queue),
|
||||||
events: Default::default(),
|
state: Default::default(),
|
||||||
tasks,
|
_state: Default::default(),
|
||||||
state: Default::default(),
|
}
|
||||||
_state: Default::default(),
|
|
||||||
},
|
|
||||||
recv,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_state<T: 'static>(&mut self, id: impl IdLike, data: T) -> WeakState<T> {
|
pub fn create_state<T: 'static>(&mut self, id: impl IdLike, data: T) -> WeakState<T> {
|
||||||
@@ -181,43 +200,32 @@ pub struct DefaultApp<State: DefaultAppState> {
|
|||||||
rsc: DefaultRsc<State>,
|
rsc: DefaultRsc<State>,
|
||||||
render: UiRenderState,
|
render: UiRenderState,
|
||||||
state: State,
|
state: State,
|
||||||
task_recv: TaskMsgReceiver<DefaultRsc<State>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
||||||
type Event = State::Event;
|
type Event = DefaultEvent<State>;
|
||||||
|
|
||||||
fn new(event_loop: &ActiveEventLoop, proxy: EventLoopProxy<Self::Event>) -> Self {
|
fn new(event_loop: &ActiveEventLoop, proxy: EventLoopProxy<Self::Event>) -> Self {
|
||||||
let window = event_loop
|
let window = event_loop
|
||||||
.create_window(State::window_attributes())
|
.create_window(State::window_attributes())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let default_state = DefaultUiState::new(window);
|
let default_state = DefaultUiState::new(window);
|
||||||
let (mut rsc, task_recv) = DefaultRsc::init(default_state.window.clone());
|
let mut rsc = DefaultRsc::init(Arc::new(Proxy(proxy.clone())));
|
||||||
let state = State::new(default_state, &mut rsc, proxy);
|
let state = State::new(default_state, &mut rsc, Proxy(proxy));
|
||||||
let render = UiRenderState::new();
|
let render = UiRenderState::new();
|
||||||
Self {
|
Self { rsc, state, render }
|
||||||
rsc,
|
|
||||||
state,
|
|
||||||
render,
|
|
||||||
task_recv,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn event(&mut self, event: Self::Event, _: &ActiveEventLoop) {
|
fn event(&mut self, event: Self::Event, _: &ActiveEventLoop) {
|
||||||
self.state.event(event, &mut self.rsc, &mut self.render);
|
match event {
|
||||||
|
DefaultEvent::User(event) => self.state.event(event, &mut self.rsc, &mut self.render),
|
||||||
|
DefaultEvent::Update(update) => update(&mut self.state, &mut self.rsc),
|
||||||
|
}
|
||||||
|
self.request_redraw_if_needed();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn window_event(&mut self, event: WindowEvent, event_loop: &ActiveEventLoop) {
|
fn window_event(&mut self, event: WindowEvent, event_loop: &ActiveEventLoop) {
|
||||||
let Self {
|
let Self { rsc, render, state } = self;
|
||||||
rsc,
|
|
||||||
render,
|
|
||||||
state,
|
|
||||||
task_recv,
|
|
||||||
} = self;
|
|
||||||
|
|
||||||
for update in task_recv.try_iter() {
|
|
||||||
update(state, rsc);
|
|
||||||
}
|
|
||||||
|
|
||||||
let ui_state = state.default_state_mut();
|
let ui_state = state.default_state_mut();
|
||||||
let input_changed = ui_state.input.event(&event);
|
let input_changed = ui_state.input.event(&event);
|
||||||
@@ -227,8 +235,7 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
|||||||
ui_state.focus = None;
|
ui_state.focus = None;
|
||||||
}
|
}
|
||||||
if input_changed {
|
if input_changed {
|
||||||
let window_size = ui_state.window_size();
|
render.run_sensors(rsc, state, cursor_state);
|
||||||
render.run_sensors(rsc, state, cursor_state, window_size);
|
|
||||||
}
|
}
|
||||||
let ui_state = state.default_state_mut();
|
let ui_state = state.default_state_mut();
|
||||||
if old != ui_state.focus
|
if old != ui_state.focus
|
||||||
@@ -297,11 +304,8 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
|||||||
_ => (),
|
_ => (),
|
||||||
}
|
}
|
||||||
state.window_event(event, rsc, render);
|
state.window_event(event, rsc, render);
|
||||||
let ui_state = self.state.default_state_mut();
|
self.request_redraw_if_needed();
|
||||||
if render.needs_redraw(&ui_state.root, rsc.widgets()) {
|
self.state.default_state_mut().input.end_frame();
|
||||||
ui_state.renderer.window().request_redraw();
|
|
||||||
}
|
|
||||||
ui_state.input.end_frame();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn exit(&mut self) {
|
fn exit(&mut self) {
|
||||||
@@ -309,6 +313,15 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<State: DefaultAppState> DefaultApp<State> {
|
||||||
|
fn request_redraw_if_needed(&mut self) {
|
||||||
|
let ui_state = self.state.default_state_mut();
|
||||||
|
if self.render.needs_redraw(&ui_state.root, self.rsc.widgets()) {
|
||||||
|
ui_state.renderer.window().request_redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub trait RscIdx<Rsc> {
|
pub trait RscIdx<Rsc> {
|
||||||
type Output;
|
type Output;
|
||||||
fn get(self, rsc: &Rsc) -> &Self::Output;
|
fn get(self, rsc: &Rsc) -> &Self::Output;
|
||||||
|
|||||||
+20
-4
@@ -22,7 +22,20 @@ impl UiRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn draw(&mut self) {
|
pub fn draw(&mut self) {
|
||||||
let output = self.surface.get_current_texture().unwrap();
|
let output = match self.surface.get_current_texture() {
|
||||||
|
CurrentSurfaceTexture::Success(texture) => texture,
|
||||||
|
CurrentSurfaceTexture::Suboptimal(texture) => {
|
||||||
|
self.surface.configure(&self.device, &self.config);
|
||||||
|
texture
|
||||||
|
}
|
||||||
|
CurrentSurfaceTexture::Outdated | CurrentSurfaceTexture::Lost => {
|
||||||
|
self.surface.configure(&self.device, &self.config);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CurrentSurfaceTexture::Timeout
|
||||||
|
| CurrentSurfaceTexture::Occluded
|
||||||
|
| CurrentSurfaceTexture::Validation => return,
|
||||||
|
};
|
||||||
let view = output
|
let view = output
|
||||||
.texture
|
.texture
|
||||||
.create_view(&TextureViewDescriptor::default());
|
.create_view(&TextureViewDescriptor::default());
|
||||||
@@ -46,7 +59,7 @@ impl UiRenderer {
|
|||||||
|
|
||||||
self.queue.submit(std::iter::once(encoder.finish()));
|
self.queue.submit(std::iter::once(encoder.finish()));
|
||||||
self.window.pre_present_notify();
|
self.window.pre_present_notify();
|
||||||
output.present();
|
self.queue.present(output);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resize(&mut self, size: &PhysicalSize<u32>) {
|
pub fn resize(&mut self, size: &PhysicalSize<u32>) {
|
||||||
@@ -65,9 +78,10 @@ impl UiRenderer {
|
|||||||
pub fn new(window: Arc<Window>) -> Self {
|
pub fn new(window: Arc<Window>) -> Self {
|
||||||
let size = window.inner_size();
|
let size = window.inner_size();
|
||||||
|
|
||||||
let instance = Instance::new(&InstanceDescriptor {
|
let instance = Instance::new(InstanceDescriptor {
|
||||||
backends: Backends::PRIMARY,
|
backends: Backends::PRIMARY,
|
||||||
..Default::default()
|
display: Some(Box::new(window.clone())),
|
||||||
|
..InstanceDescriptor::new_without_display_handle()
|
||||||
});
|
});
|
||||||
|
|
||||||
let surface = instance
|
let surface = instance
|
||||||
@@ -79,6 +93,7 @@ impl UiRenderer {
|
|||||||
power_preference: PowerPreference::default(),
|
power_preference: PowerPreference::default(),
|
||||||
compatible_surface: Some(&surface),
|
compatible_surface: Some(&surface),
|
||||||
force_fallback_adapter: false,
|
force_fallback_adapter: false,
|
||||||
|
apply_limit_buckets: false,
|
||||||
})
|
})
|
||||||
.block_on()
|
.block_on()
|
||||||
.expect("Could not get adapter!");
|
.expect("Could not get adapter!");
|
||||||
@@ -105,6 +120,7 @@ impl UiRenderer {
|
|||||||
let config = SurfaceConfiguration {
|
let config = SurfaceConfiguration {
|
||||||
usage: TextureUsages::RENDER_ATTACHMENT,
|
usage: TextureUsages::RENDER_ATTACHMENT,
|
||||||
format: surface_format,
|
format: surface_format,
|
||||||
|
color_space: SurfaceColorSpace::Auto,
|
||||||
width: size.width,
|
width: size.width,
|
||||||
height: size.height,
|
height: size.height,
|
||||||
present_mode: PresentMode::AutoVsync,
|
present_mode: PresentMode::AutoVsync,
|
||||||
|
|||||||
+103
-35
@@ -4,14 +4,14 @@ use std::{
|
|||||||
rc::Rc,
|
rc::Rc,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
pub enum CursorButton {
|
pub enum CursorButton {
|
||||||
Left,
|
Left,
|
||||||
Right,
|
Right,
|
||||||
Middle,
|
Middle,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
pub enum CursorSense {
|
pub enum CursorSense {
|
||||||
PressStart(CursorButton),
|
PressStart(CursorButton),
|
||||||
Pressing(CursorButton),
|
Pressing(CursorButton),
|
||||||
@@ -27,7 +27,7 @@ pub struct CursorSenses(Vec<CursorSense>);
|
|||||||
|
|
||||||
impl Event for CursorSenses {
|
impl Event for CursorSenses {
|
||||||
type Data<'a> = CursorData<'a>;
|
type Data<'a> = CursorData<'a>;
|
||||||
type State = SensorState;
|
type Global = Hovered;
|
||||||
fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option<Self::Data<'a>> {
|
fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option<Self::Data<'a>> {
|
||||||
if let Some(sense) = should_run(self, &data.cursor, data.hover) {
|
if let Some(sense) = should_run(self, &data.cursor, data.hover) {
|
||||||
let mut data = data.clone();
|
let mut data = data.clone();
|
||||||
@@ -37,6 +37,24 @@ impl Event for CursorSenses {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A press or a scroll is used up by whatever answered it, so it stops
|
||||||
|
/// there. Hovering is not: a cursor resting somewhere goes on resting.
|
||||||
|
fn consumes(&self, data: &Self::Data<'_>) -> bool {
|
||||||
|
!data.sense.position_only()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Who the cursor was inside, before and after an input. The difference is
|
||||||
|
/// whose hover has ended -- including a widget a higher layer has covered,
|
||||||
|
/// which the walk stops before reaching.
|
||||||
|
///
|
||||||
|
/// Two buffers that swap rather than one rebuilt, so an input allocates
|
||||||
|
/// nothing once they have grown.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Hovered {
|
||||||
|
was: Vec<WidgetId>,
|
||||||
|
now: Vec<WidgetId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CursorSense {
|
impl CursorSense {
|
||||||
@@ -52,6 +70,12 @@ impl CursorSense {
|
|||||||
pub fn is_dragging(&self) -> bool {
|
pub fn is_dragging(&self) -> bool {
|
||||||
matches!(self, CursorSense::Pressing(CursorButton::Left))
|
matches!(self, CursorSense::Pressing(CursorButton::Left))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// False if the sense is a button or a scroll, true if it is only about
|
||||||
|
/// where the cursor is.
|
||||||
|
fn position_only(&self) -> bool {
|
||||||
|
matches!(self, Self::HoverStart | Self::Hovering | Self::HoverEnd)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Clone)]
|
#[derive(Default, Clone)]
|
||||||
@@ -96,6 +120,12 @@ impl CursorButtons {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CursorState {
|
impl CursorState {
|
||||||
|
/// True if the cursor is only reporting where it is: no button and no
|
||||||
|
/// scroll this frame.
|
||||||
|
pub fn position_only(&self) -> bool {
|
||||||
|
self.scroll_delta == Vec2::ZERO && self.buttons.iter().all(|(_, state)| state.is_off())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn end_frame(&mut self) {
|
pub fn end_frame(&mut self) {
|
||||||
self.buttons.end_frame();
|
self.buttons.end_frame();
|
||||||
self.scroll_delta = Vec2::ZERO;
|
self.scroll_delta = Vec2::ZERO;
|
||||||
@@ -123,11 +153,6 @@ pub struct Sensor<Ctx: HasEvents, Data> {
|
|||||||
|
|
||||||
pub type SenseShape = UiRegion;
|
pub type SenseShape = UiRegion;
|
||||||
|
|
||||||
#[derive(Default, Debug)]
|
|
||||||
pub struct SensorState {
|
|
||||||
pub hover: ActivationState,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct CursorData<'a> {
|
pub struct CursorData<'a> {
|
||||||
/// where this widget was hit
|
/// where this widget was hit
|
||||||
@@ -147,7 +172,6 @@ pub trait SensorUi {
|
|||||||
rsc: &mut Rsc,
|
rsc: &mut Rsc,
|
||||||
state: &mut Rsc::State,
|
state: &mut Rsc::State,
|
||||||
cursor: CursorState,
|
cursor: CursorState,
|
||||||
window_size: Vec2,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,54 +181,98 @@ impl SensorUi for UiRenderState {
|
|||||||
rsc: &mut Rsc,
|
rsc: &mut Rsc,
|
||||||
state: &mut Rsc::State,
|
state: &mut Rsc::State,
|
||||||
cursor: CursorState,
|
cursor: CursorState,
|
||||||
window_size: Vec2,
|
|
||||||
) {
|
) {
|
||||||
// in order to remove this take, need to store active list in UiRenderState somehow
|
// in order to remove this take, need to store active list in UiRenderState somehow
|
||||||
// this would probably be done through a generic parameter that adds yet another rsc /
|
// this would probably be done through a generic parameter that adds yet another rsc /
|
||||||
// state like thing, but local to render state, and is passed to UiRsc events so you can
|
// state like thing, but local to render state, and is passed to UiRsc events so you can
|
||||||
// update it there?
|
// update it there?
|
||||||
let mut active = std::mem::take(&mut rsc.events_mut().get_type::<CursorSense>().active);
|
let active = std::mem::take(&mut rsc.events_mut().get_type::<CursorSense>().active);
|
||||||
|
let mut hovered = std::mem::take(&mut rsc.events_mut().get_type::<CursorSense>().global);
|
||||||
|
hovered.now.clear();
|
||||||
|
let position_only = cursor.position_only();
|
||||||
|
let region_of = |id| self.window_region(&id);
|
||||||
|
|
||||||
for layer in self.layers.indices().rev() {
|
for layer in self.layers.indices().rev() {
|
||||||
let mut sensed = false;
|
let mut consumed = false;
|
||||||
for (id, sensor) in active.get_mut(&layer).into_flat_iter() {
|
for id in active.get(&layer).into_flat_iter().map(|(id, _)| *id) {
|
||||||
let shape = self.active.get(id).unwrap().region;
|
let Some(region) = region_of(id) else {
|
||||||
let region = shape.to_px(window_size);
|
continue;
|
||||||
let in_shape = cursor.exists && region.contains(cursor.pos);
|
};
|
||||||
sensor.hover.update(in_shape);
|
if !cursor.exists || !region.contains(cursor.pos) {
|
||||||
if sensor.hover == ActivationState::Off {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
sensed = true;
|
hovered.now.push(id);
|
||||||
|
let hover = match hovered.was.contains(&id) {
|
||||||
let cursor = cursor.clone();
|
true => ActivationState::On,
|
||||||
|
false => ActivationState::Start,
|
||||||
let data = CursorData {
|
|
||||||
pos: cursor.pos - region.top_left,
|
|
||||||
size: region.bot_right - region.top_left,
|
|
||||||
scroll_delta: cursor.scroll_delta,
|
|
||||||
hover: sensor.hover,
|
|
||||||
cursor,
|
|
||||||
// this does not have any meaning;
|
|
||||||
// might wanna set up Event to have a prepare stage
|
|
||||||
sense: CursorSense::Hovering,
|
|
||||||
render: self,
|
|
||||||
};
|
};
|
||||||
rsc.run_event::<CursorSense>(*id, data, state);
|
// A press or a scroll stops where something answered it, so a
|
||||||
|
// button over a list does not swallow the list's scrolling.
|
||||||
|
consumed |= deliver(self, rsc, state, id, hover, &cursor, region);
|
||||||
|
// A cursor doing neither stops at whatever it is over, so
|
||||||
|
// hovering does not reach through.
|
||||||
|
consumed |= position_only;
|
||||||
}
|
}
|
||||||
if sensed {
|
// Applied after the layer, never during it: senses on one layer do
|
||||||
|
// not block each other.
|
||||||
|
if consumed {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rsc.events_mut().get_type::<CursorSense>().active = active;
|
|
||||||
|
// Whatever the cursor was inside and is not now, whether it left or a
|
||||||
|
// layer above took the input before the walk reached it. A widget that
|
||||||
|
// stopped being drawn has no region to report and is simply dropped.
|
||||||
|
for &id in &hovered.was {
|
||||||
|
if !hovered.now.contains(&id)
|
||||||
|
&& let Some(region) = region_of(id)
|
||||||
|
{
|
||||||
|
deliver(self, rsc, state, id, ActivationState::End, &cursor, region);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::mem::swap(&mut hovered.was, &mut hovered.now);
|
||||||
|
|
||||||
|
let senses = rsc.events_mut().get_type::<CursorSense>();
|
||||||
|
senses.active = active;
|
||||||
|
senses.global = hovered;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runs one widget's cursor senses, and says whether they used up the input.
|
||||||
|
fn deliver<Rsc: HasEvents>(
|
||||||
|
render: &UiRenderState,
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
state: &mut Rsc::State,
|
||||||
|
id: WidgetId,
|
||||||
|
hover: ActivationState,
|
||||||
|
cursor: &CursorState,
|
||||||
|
region: PixelRegion,
|
||||||
|
) -> bool {
|
||||||
|
let data = CursorData {
|
||||||
|
pos: cursor.pos - region.top_left,
|
||||||
|
size: region.bot_right - region.top_left,
|
||||||
|
scroll_delta: cursor.scroll_delta,
|
||||||
|
hover,
|
||||||
|
cursor: cursor.clone(),
|
||||||
|
// this does not have any meaning;
|
||||||
|
// might wanna set up Event to have a prepare stage
|
||||||
|
sense: CursorSense::Hovering,
|
||||||
|
render,
|
||||||
|
};
|
||||||
|
rsc.run_event::<CursorSense>(id, data, state)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn should_run(
|
pub fn should_run(
|
||||||
senses: &CursorSenses,
|
senses: &CursorSenses,
|
||||||
cursor: &CursorState,
|
cursor: &CursorState,
|
||||||
hover: ActivationState,
|
hover: ActivationState,
|
||||||
) -> Option<CursorSense> {
|
) -> Option<CursorSense> {
|
||||||
for sense in senses.iter() {
|
for sense in senses.iter() {
|
||||||
|
// A widget the cursor is no longer inside senses only its position:
|
||||||
|
// the press that ended its hover landed on something else.
|
||||||
|
if !hover.is_on() && !sense.position_only() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if match sense {
|
if match sense {
|
||||||
CursorSense::PressStart(button) => cursor.buttons.select(button).is_start(),
|
CursorSense::PressStart(button) => cursor.buttons.select(button).is_start(),
|
||||||
CursorSense::Pressing(button) => cursor.buttons.select(button).is_on(),
|
CursorSense::Pressing(button) => cursor.buttons.select(button).is_on(),
|
||||||
|
|||||||
+16
-34
@@ -1,11 +1,5 @@
|
|||||||
use iris_core::HasState;
|
use iris_core::HasState;
|
||||||
use std::{
|
use std::{pin::Pin, sync::Arc};
|
||||||
pin::Pin,
|
|
||||||
sync::{
|
|
||||||
Arc,
|
|
||||||
mpsc::{Receiver as SyncReceiver, Sender as SyncSender, channel as sync_channel},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
use tokio::{
|
use tokio::{
|
||||||
runtime::Runtime,
|
runtime::Runtime,
|
||||||
sync::mpsc::{
|
sync::mpsc::{
|
||||||
@@ -13,64 +7,52 @@ use tokio::{
|
|||||||
unbounded_channel as async_channel,
|
unbounded_channel as async_channel,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use winit::window::Window;
|
|
||||||
|
|
||||||
pub type TaskMsgSender<Rsc> = SyncSender<Box<dyn TaskUpdate<Rsc>>>;
|
|
||||||
pub type TaskMsgReceiver<Rsc> = SyncReceiver<Box<dyn TaskUpdate<Rsc>>>;
|
|
||||||
|
|
||||||
pub trait TaskUpdate<Rsc: HasState>: FnOnce(&mut Rsc::State, &mut Rsc) + Send {}
|
pub trait TaskUpdate<Rsc: HasState>: FnOnce(&mut Rsc::State, &mut Rsc) + Send {}
|
||||||
impl<F: FnOnce(&mut Rsc::State, &mut Rsc) + Send, Rsc: HasState> TaskUpdate<Rsc> for F {}
|
impl<F: FnOnce(&mut Rsc::State, &mut Rsc) + Send, Rsc: HasState> TaskUpdate<Rsc> for F {}
|
||||||
|
|
||||||
|
/// Hands an update from a task to the thread that owns the ui. Delivery and
|
||||||
|
/// waking are one act: a host posts the update as a message its loop already
|
||||||
|
/// carries, so nothing has to wake the loop separately, or claim a redraw to
|
||||||
|
/// be looked at.
|
||||||
|
pub trait TaskQueue<Rsc: HasState>: Send + Sync + 'static {
|
||||||
|
fn send(&self, update: Box<dyn TaskUpdate<Rsc>>);
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Tasks<Rsc: HasState> {
|
pub struct Tasks<Rsc: HasState> {
|
||||||
start: AsyncSender<BoxTask>,
|
start: AsyncSender<BoxTask>,
|
||||||
window: Arc<Window>,
|
queue: Arc<dyn TaskQueue<Rsc>>,
|
||||||
msg_send: SyncSender<Box<dyn TaskUpdate<Rsc>>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct TaskCtx<Rsc: HasState> {
|
pub struct TaskCtx<Rsc: HasState> {
|
||||||
send: TaskMsgSender<Rsc>,
|
queue: Arc<dyn TaskQueue<Rsc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<Rsc: HasState> TaskCtx<Rsc> {
|
impl<Rsc: HasState> TaskCtx<Rsc> {
|
||||||
pub fn update(&mut self, f: impl TaskUpdate<Rsc> + 'static) {
|
pub fn update(&mut self, f: impl TaskUpdate<Rsc> + 'static) {
|
||||||
let _ = self.send.send(Box::new(f));
|
self.queue.send(Box::new(f));
|
||||||
}
|
|
||||||
}
|
|
||||||
impl<Rsc: HasState + 'static> TaskCtx<Rsc> {
|
|
||||||
fn new(send: TaskMsgSender<Rsc>) -> Self {
|
|
||||||
Self { send }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type BoxTask = Pin<Box<dyn Future<Output = ()> + Send>>;
|
type BoxTask = Pin<Box<dyn Future<Output = ()> + Send>>;
|
||||||
|
|
||||||
impl<Rsc: HasState> Tasks<Rsc> {
|
impl<Rsc: HasState> Tasks<Rsc> {
|
||||||
pub fn init(window: Arc<Window>) -> (Self, TaskMsgReceiver<Rsc>) {
|
pub fn init(queue: Arc<dyn TaskQueue<Rsc>>) -> Self {
|
||||||
let (start, start_recv) = async_channel();
|
let (start, start_recv) = async_channel();
|
||||||
let (msgs, msgs_recv) = sync_channel();
|
|
||||||
std::thread::spawn(|| {
|
std::thread::spawn(|| {
|
||||||
let rt = Runtime::new().unwrap();
|
let rt = Runtime::new().unwrap();
|
||||||
rt.block_on(listen(start_recv))
|
rt.block_on(listen(start_recv))
|
||||||
});
|
});
|
||||||
(
|
Self { start, queue }
|
||||||
Self {
|
|
||||||
start,
|
|
||||||
msg_send: msgs,
|
|
||||||
window,
|
|
||||||
},
|
|
||||||
msgs_recv,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn spawn<F: AsyncFnOnce(TaskCtx<Rsc>) + 'static + std::marker::Send>(&mut self, task: F)
|
pub fn spawn<F: AsyncFnOnce(TaskCtx<Rsc>) + 'static + std::marker::Send>(&mut self, task: F)
|
||||||
where
|
where
|
||||||
F::CallOnceFuture: Send,
|
F::CallOnceFuture: Send,
|
||||||
{
|
{
|
||||||
let send = self.msg_send.clone();
|
let queue = self.queue.clone();
|
||||||
let window = self.window.clone();
|
|
||||||
let _ = self.start.send(Box::pin(async move {
|
let _ = self.start.send(Box::pin(async move {
|
||||||
task(TaskCtx::new(send)).await;
|
task(TaskCtx { queue }).await;
|
||||||
window.request_redraw();
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+320
@@ -0,0 +1,320 @@
|
|||||||
|
//! A ui with no window: build a tree, run frames, move a pointer, and read
|
||||||
|
//! back where widgets landed.
|
||||||
|
//!
|
||||||
|
//! It does not draw. A claim about pixels still needs a real surface.
|
||||||
|
|
||||||
|
use crate::prelude::*;
|
||||||
|
use std::{
|
||||||
|
sync::{
|
||||||
|
Arc,
|
||||||
|
mpsc::{Receiver, SyncSender, sync_channel},
|
||||||
|
},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// There is no loop here to post to, so updates queue until the test asks
|
||||||
|
/// for them.
|
||||||
|
struct Queue(SyncSender<Box<dyn TaskUpdate<DefaultRsc<HarnessState>>>>);
|
||||||
|
|
||||||
|
impl TaskQueue<DefaultRsc<HarnessState>> for Queue {
|
||||||
|
fn send(&self, update: Box<dyn TaskUpdate<DefaultRsc<HarnessState>>>) {
|
||||||
|
let _ = self.0.send(update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `assert_eq!` for where a frame put a widget, written as its two corners.
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! assert_corners {
|
||||||
|
($harness:expr, $id:expr, ($x0:expr, $y0:expr), ($x1:expr, $y1:expr)) => {
|
||||||
|
assert_eq!(
|
||||||
|
$harness.region(&$id).expect("widget drew nothing"),
|
||||||
|
$crate::core::PixelRegion {
|
||||||
|
top_left: $crate::core::util::Vec2::new($x0 as f32, $y0 as f32),
|
||||||
|
bot_right: $crate::core::util::Vec2::new($x1 as f32, $y1 as f32),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HasRoot for HarnessState {
|
||||||
|
fn set_root(&mut self, root: StrongWidget) {
|
||||||
|
self.root = Some(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Harness {
|
||||||
|
pub rsc: DefaultRsc<HarnessState>,
|
||||||
|
pub render: UiRenderState,
|
||||||
|
pub state: HarnessState,
|
||||||
|
updates: Receiver<Box<dyn TaskUpdate<DefaultRsc<HarnessState>>>>,
|
||||||
|
cursor: CursorState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Harness {
|
||||||
|
/// `size` is the output in physical pixels.
|
||||||
|
pub fn new(size: impl Into<Vec2>) -> Self {
|
||||||
|
// A `TaskQueue` must be `Sync`, which `mpsc::Sender` is not; the
|
||||||
|
// bound that comes with `SyncSender` is far past anything a test
|
||||||
|
// leaves unread.
|
||||||
|
let (send, updates) = sync_channel(1024);
|
||||||
|
let rsc = DefaultRsc::init(Arc::new(Queue(send)));
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize(size);
|
||||||
|
Self {
|
||||||
|
rsc,
|
||||||
|
render,
|
||||||
|
state: HarnessState::default(),
|
||||||
|
updates,
|
||||||
|
cursor: CursorState::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn size(&self) -> Vec2 {
|
||||||
|
self.render.output_size()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resize(&mut self, size: impl Into<Vec2>) {
|
||||||
|
self.render.resize(size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the root and lays it out, so a pointer event has something to hit.
|
||||||
|
pub fn set_root<T>(&mut self, widget: impl WidgetLike<DefaultRsc<HarnessState>, T>) {
|
||||||
|
widget.set_root(&mut self.rsc, &mut self.state);
|
||||||
|
self.frame();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn needs_redraw(&self) -> bool {
|
||||||
|
self.render
|
||||||
|
.needs_redraw(&self.state.root, self.rsc.widgets())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_updates(&mut self) -> usize {
|
||||||
|
let mut applied = 0;
|
||||||
|
while let Ok(update) = self.updates.try_recv() {
|
||||||
|
update(&mut self.state, &mut self.rsc);
|
||||||
|
applied += 1;
|
||||||
|
}
|
||||||
|
applied
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Waits for a task's first update, then applies everything waiting.
|
||||||
|
/// False if none arrived in time.
|
||||||
|
#[must_use]
|
||||||
|
pub fn await_update(&mut self, timeout: Duration) -> bool {
|
||||||
|
let Ok(update) = self.updates.recv_timeout(timeout) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
update(&mut self.state, &mut self.rsc);
|
||||||
|
self.apply_updates();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lays the tree out and builds its primitives.
|
||||||
|
pub fn frame(&mut self) {
|
||||||
|
self.apply_updates();
|
||||||
|
self.render.update(&self.state.root, &mut self.rsc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the last frame put a widget, or `None` if it drew nothing.
|
||||||
|
pub fn region(&self, id: &impl IdLike) -> Option<PixelRegion> {
|
||||||
|
self.render.window_region(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_to(&mut self, pos: impl Into<Vec2>) {
|
||||||
|
self.cursor.pos = pos.into();
|
||||||
|
self.cursor.exists = true;
|
||||||
|
self.sense();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn leave(&mut self) {
|
||||||
|
self.cursor.exists = false;
|
||||||
|
self.sense();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn press(&mut self, button: CursorButton) {
|
||||||
|
self.button(button).update(true);
|
||||||
|
self.sense();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn release(&mut self, button: CursorButton) {
|
||||||
|
self.button(button).update(false);
|
||||||
|
self.sense();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A wheel carries no position, so this goes wherever the cursor was last
|
||||||
|
/// moved to -- nowhere, until it has been moved.
|
||||||
|
pub fn scroll(&mut self, delta: impl Into<Vec2>) {
|
||||||
|
self.cursor.scroll_delta = delta.into();
|
||||||
|
self.sense();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn click(&mut self, pos: impl Into<Vec2>) {
|
||||||
|
self.move_to(pos);
|
||||||
|
self.press(CursorButton::Left);
|
||||||
|
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 {
|
||||||
|
CursorButton::Left => &mut buttons.left,
|
||||||
|
CursorButton::Middle => &mut buttons.middle,
|
||||||
|
CursorButton::Right => &mut buttons.right,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatches against the layout of the last frame, which is what a
|
||||||
|
/// window delivers input against too.
|
||||||
|
fn sense(&mut self) {
|
||||||
|
let cursor = self.cursor.clone();
|
||||||
|
self.render
|
||||||
|
.run_sensors(&mut self.rsc, &mut self.state, cursor);
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
pub mod default;
|
pub mod default;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
|
pub mod harness;
|
||||||
pub mod widget;
|
pub mod widget;
|
||||||
|
|
||||||
pub use iris_core as core;
|
pub use iris_core as core;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ mod max_size;
|
|||||||
mod offset;
|
mod offset;
|
||||||
mod pad;
|
mod pad;
|
||||||
mod scroll;
|
mod scroll;
|
||||||
mod sized;
|
mod set_size;
|
||||||
mod span;
|
mod span;
|
||||||
mod stack;
|
mod stack;
|
||||||
|
|
||||||
@@ -14,6 +14,6 @@ pub use max_size::*;
|
|||||||
pub use offset::*;
|
pub use offset::*;
|
||||||
pub use pad::*;
|
pub use pad::*;
|
||||||
pub use scroll::*;
|
pub use scroll::*;
|
||||||
pub use sized::*;
|
pub use set_size::*;
|
||||||
pub use span::*;
|
pub use span::*;
|
||||||
pub use stack::*;
|
pub use stack::*;
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
|
|
||||||
pub struct Sized {
|
pub struct SetSize {
|
||||||
pub inner: StrongWidget,
|
pub inner: StrongWidget,
|
||||||
pub x: Option<Len>,
|
pub x: Option<Len>,
|
||||||
pub y: Option<Len>,
|
pub y: Option<Len>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Sized {
|
impl SetSize {
|
||||||
fn apply_to_outer(&self, ctx: &mut SizeCtx) {
|
fn apply_to_outer(&self, ctx: &mut SizeCtx) {
|
||||||
if let Some(x) = self.x {
|
if let Some(x) = self.x {
|
||||||
ctx.outer.x.select_len(x.apply_rest());
|
ctx.outer.x.select_len(x.apply_rest());
|
||||||
@@ -17,7 +17,7 @@ impl Sized {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Widget for Sized {
|
impl Widget for SetSize {
|
||||||
fn draw(&mut self, painter: &mut Painter) {
|
fn draw(&mut self, painter: &mut Painter) {
|
||||||
painter.widget(&self.inner);
|
painter.widget(&self.inner);
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use std::marker::{Sized, Unsize};
|
use std::marker::Unsize;
|
||||||
|
|
||||||
pub struct WidgetPtr {
|
pub struct WidgetPtr {
|
||||||
pub inner: Option<StrongWidget>,
|
pub inner: Option<StrongWidget>,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use std::marker::{PhantomData, Sized};
|
use std::marker::PhantomData;
|
||||||
|
|
||||||
pub struct TextBuilder<State, O = TextOutput, H: WidgetOption<State> = ()> {
|
pub struct TextBuilder<State, O = TextOutput, H: WidgetOption<State> = ()> {
|
||||||
pub content: String,
|
pub content: String,
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ widget_trait! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sized(self, size: impl Into<Size>) -> impl WidgetFn<Rsc, Sized> {
|
fn sized(self, size: impl Into<Size>) -> impl WidgetFn<Rsc, SetSize> {
|
||||||
let size = size.into();
|
let size = size.into();
|
||||||
move |state| Sized {
|
move |state| SetSize {
|
||||||
inner: self.add_strong(state),
|
inner: self.add_strong(state),
|
||||||
x: Some(size.x),
|
x: Some(size.x),
|
||||||
y: Some(size.y),
|
y: Some(size.y),
|
||||||
@@ -58,18 +58,18 @@ widget_trait! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn width(self, len: impl Into<Len>) -> impl WidgetFn<Rsc, Sized> {
|
fn width(self, len: impl Into<Len>) -> impl WidgetFn<Rsc, SetSize> {
|
||||||
let len = len.into();
|
let len = len.into();
|
||||||
move |state| Sized {
|
move |state| SetSize {
|
||||||
inner: self.add_strong(state),
|
inner: self.add_strong(state),
|
||||||
x: Some(len),
|
x: Some(len),
|
||||||
y: None,
|
y: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn height(self, len: impl Into<Len>) -> impl WidgetFn<Rsc, Sized> {
|
fn height(self, len: impl Into<Len>) -> impl WidgetFn<Rsc, SetSize> {
|
||||||
let len = len.into();
|
let len = len.into();
|
||||||
move |state| Sized {
|
move |state| SetSize {
|
||||||
inner: self.add_strong(state),
|
inner: self.add_strong(state),
|
||||||
x: None,
|
x: None,
|
||||||
y: Some(len),
|
y: Some(len),
|
||||||
|
|||||||
+17
-17
@@ -4,20 +4,19 @@
|
|||||||
//!
|
//!
|
||||||
//! cargo test --release --test draw_cost -- --ignored --nocapture
|
//! cargo test --release --test draw_cost -- --ignored --nocapture
|
||||||
//!
|
//!
|
||||||
//! **Read the instruction count, not the clock.** Wall time here swings by 2x
|
//! Wall time is the wrong number to read for anything under a few percent --
|
||||||
//! between runs of one binary on this machine -- more under `cargo test` than
|
//! it varied by 2x between runs of one unchanged binary where instructions
|
||||||
//! run directly -- while instructions retired are stable to 0.1%:
|
//! retired varied by 0.1%. Count those instead:
|
||||||
//!
|
//!
|
||||||
//! perf stat -e instructions:u target/release/.../draw_cost-* --ignored
|
//! perf stat -e instructions:u target/release/.../draw_cost-* --ignored
|
||||||
//!
|
//!
|
||||||
//! Measured that way on 2026-09-13, drawing each primitive through its own
|
//! That is how `PrimitiveRender` was measured against a match in the renderer:
|
||||||
//! `PrimitiveRender` rather than a match in the renderer costs **6
|
//! 6 instructions per list drawn, against the ~5,400 wgpu spends recording
|
||||||
//! instructions per list drawn**, which is 0.1% of a frame at both 256 and
|
//! one.
|
||||||
//! 1024 layers. Recording one list into the pass costs wgpu ~5,400.
|
|
||||||
//!
|
//!
|
||||||
//! The instance is leaked on purpose. Dropping the last one makes the Vulkan
|
//! The instance is leaked deliberately. A Vulkan loader may unload the driver
|
||||||
//! loader unload Mesa's ICD, which faults when a thread that touched Vulkan
|
//! when the last one drops, which can fault as a thread that used it exits --
|
||||||
//! exits -- and libtest runs every test on a spawned thread.
|
//! and every test runs on a spawned thread.
|
||||||
|
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
@@ -30,20 +29,20 @@ use wgpu::{Color as GpuColor, *};
|
|||||||
|
|
||||||
const SIZE: u32 = 1024;
|
const SIZE: u32 = 1024;
|
||||||
const FRAMES: u32 = 200;
|
const FRAMES: u32 = 200;
|
||||||
/// Reported as the best of this many batches. The mean moves by 15% between
|
/// Reported as the best of this many batches, since the mean moves by more
|
||||||
/// runs on this machine, which is more than the thing being measured.
|
/// than the thing being measured.
|
||||||
const BATCHES: u32 = 8;
|
const BATCHES: u32 = 8;
|
||||||
|
|
||||||
fn gpu() -> Option<(Device, Queue)> {
|
fn gpu() -> Option<(Device, Queue)> {
|
||||||
// Probed rather than assumed: this machine's Vulkan device comes and goes,
|
// Probed rather than assumed: there may be no Vulkan adapter, and GL is
|
||||||
// and GL is what is left when it is gone.
|
// what is left when there is not.
|
||||||
let all = Instance::new(&InstanceDescriptor::default());
|
let all = Instance::new(InstanceDescriptor::new_without_display_handle());
|
||||||
let instance = match pollster::block_on(all.request_adapter(&RequestAdapterOptions::default()))
|
let instance = match pollster::block_on(all.request_adapter(&RequestAdapterOptions::default()))
|
||||||
{
|
{
|
||||||
Ok(_) => all,
|
Ok(_) => all,
|
||||||
Err(_) => Instance::new(&InstanceDescriptor {
|
Err(_) => Instance::new(InstanceDescriptor {
|
||||||
backends: Backends::GL,
|
backends: Backends::GL,
|
||||||
..Default::default()
|
..InstanceDescriptor::new_without_display_handle()
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
// Leaked rather than dropped: see the note at the top of the file.
|
// Leaked rather than dropped: see the note at the top of the file.
|
||||||
@@ -58,6 +57,7 @@ fn config(format: TextureFormat) -> SurfaceConfiguration {
|
|||||||
SurfaceConfiguration {
|
SurfaceConfiguration {
|
||||||
usage: TextureUsages::RENDER_ATTACHMENT,
|
usage: TextureUsages::RENDER_ATTACHMENT,
|
||||||
format,
|
format,
|
||||||
|
color_space: SurfaceColorSpace::Auto,
|
||||||
width: SIZE,
|
width: SIZE,
|
||||||
height: SIZE,
|
height: SIZE,
|
||||||
present_mode: PresentMode::Fifo,
|
present_mode: PresentMode::Fifo,
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
//! Where a frame puts things, with no window to put them in.
|
||||||
|
|
||||||
|
use iris::harness::{Harness, assert_corners};
|
||||||
|
use iris::prelude::*;
|
||||||
|
|
||||||
|
/// A fixed 100 wide, and the rest of the 400 to its neighbour.
|
||||||
|
fn two_rects(h: &mut Harness) -> (WidgetId, WidgetId) {
|
||||||
|
let left = rect(Color::RED).width(100).add(&mut h.rsc);
|
||||||
|
let right = rect(Color::BLUE).add(&mut h.rsc);
|
||||||
|
h.set_root((left, right).span(Dir::RIGHT));
|
||||||
|
(left.id(), right.id())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_span_gives_each_child_the_width_it_asked_for() {
|
||||||
|
let mut h = Harness::new((400, 200));
|
||||||
|
let (left, right) = two_rects(&mut h);
|
||||||
|
|
||||||
|
assert_corners!(h, left, (0, 0), (100, 200));
|
||||||
|
assert_corners!(h, right, (100, 0), (400, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resizing_relays_out_against_the_new_output() {
|
||||||
|
let mut h = Harness::new((400, 200));
|
||||||
|
let (left, right) = two_rects(&mut h);
|
||||||
|
|
||||||
|
h.resize((800, 100));
|
||||||
|
assert!(h.needs_redraw());
|
||||||
|
h.frame();
|
||||||
|
|
||||||
|
assert_corners!(h, left, (0, 0), (100, 100));
|
||||||
|
assert_corners!(h, right, (100, 0), (800, 100));
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
//! Which widget an input reaches.
|
||||||
|
|
||||||
|
use std::{cell::RefCell, rc::Rc};
|
||||||
|
|
||||||
|
use iris::harness::{Harness, TouchScript};
|
||||||
|
use iris::prelude::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_press_reaches_only_the_widget_under_the_cursor() {
|
||||||
|
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));
|
||||||
|
|
||||||
|
h.click((50, 100));
|
||||||
|
assert_eq!(*clicks.borrow(), ["left"]);
|
||||||
|
|
||||||
|
h.click((300, 100));
|
||||||
|
assert_eq!(*clicks.borrow(), ["left", "right"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hover_ends_when_the_cursor_leaves_the_window() {
|
||||||
|
let mut h = Harness::new((400, 200));
|
||||||
|
let hovered = Rc::new(RefCell::new(0));
|
||||||
|
let ended = Rc::new(RefCell::new(0));
|
||||||
|
|
||||||
|
let (h_count, e_count) = (hovered.clone(), ended.clone());
|
||||||
|
let widget = rect(Color::RED)
|
||||||
|
.on(CursorSense::HoverStart, move |_, _| {
|
||||||
|
*h_count.borrow_mut() += 1
|
||||||
|
})
|
||||||
|
.on(CursorSense::HoverEnd, move |_, _| {
|
||||||
|
*e_count.borrow_mut() += 1
|
||||||
|
})
|
||||||
|
.add(&mut h.rsc);
|
||||||
|
h.set_root(widget);
|
||||||
|
|
||||||
|
h.move_to((200, 100));
|
||||||
|
assert_eq!((*hovered.borrow(), *ended.borrow()), (1, 0));
|
||||||
|
|
||||||
|
// A second sample inside the same widget is not a second hover.
|
||||||
|
h.move_to((210, 100));
|
||||||
|
assert_eq!((*hovered.borrow(), *ended.borrow()), (1, 0));
|
||||||
|
|
||||||
|
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"]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
//! Input across layers: what stops at a layer, what passes through it, and
|
||||||
|
//! where hovering stops.
|
||||||
|
|
||||||
|
use std::{cell::RefCell, rc::Rc};
|
||||||
|
|
||||||
|
use iris::harness::Harness;
|
||||||
|
use iris::prelude::*;
|
||||||
|
|
||||||
|
const WINDOW: f32 = 100.0;
|
||||||
|
|
||||||
|
/// Every sense that has fired on one widget since it was last read.
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
struct Fired(Rc<RefCell<Vec<CursorSense>>>);
|
||||||
|
|
||||||
|
impl Fired {
|
||||||
|
fn take(&self) -> Vec<CursorSense> {
|
||||||
|
std::mem::take(&mut self.0.borrow_mut())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A widget filling whatever it is given, recording the senses it is sent.
|
||||||
|
fn listener(h: &mut Harness, senses: impl Into<CursorSenses>) -> (WeakWidget<Rect>, Fired) {
|
||||||
|
let fired = Fired::default();
|
||||||
|
let record = fired.clone();
|
||||||
|
let id = rect(Color::WHITE)
|
||||||
|
.on(senses.into(), move |ctx, _| {
|
||||||
|
record.0.borrow_mut().push(ctx.data.sense)
|
||||||
|
})
|
||||||
|
.add(&mut h.rsc);
|
||||||
|
(id, fired)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A widget with no senses of its own, to leave a gap beside one that has.
|
||||||
|
fn blank(h: &mut Harness) -> WeakWidget<Rect> {
|
||||||
|
rect(Color::WHITE).add(&mut h.rsc)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn harness() -> Harness {
|
||||||
|
Harness::new((WINDOW, WINDOW))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hover_stops_at_the_topmost_widget() {
|
||||||
|
let mut h = harness();
|
||||||
|
let (bottom, bottom_hover) = listener(&mut h, CursorSense::HoverStart);
|
||||||
|
let (middle, middle_hover) = listener(&mut h, CursorSense::HoverStart);
|
||||||
|
let (top, top_hover) = listener(&mut h, CursorSense::HoverStart);
|
||||||
|
h.set_root((bottom, middle, top).stack());
|
||||||
|
|
||||||
|
h.move_to((50, 50));
|
||||||
|
|
||||||
|
assert_eq!(top_hover.take(), [CursorSense::HoverStart]);
|
||||||
|
assert_eq!(
|
||||||
|
middle_hover.take(),
|
||||||
|
[],
|
||||||
|
"hover is not shared with a layer below"
|
||||||
|
);
|
||||||
|
assert_eq!(bottom_hover.take(), []);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_scroll_passes_through_every_widget_that_does_not_want_it() {
|
||||||
|
let mut h = harness();
|
||||||
|
let (list, scrolled) = listener(&mut h, CursorSense::Scroll);
|
||||||
|
let (button, clicked) = listener(&mut h, CursorSense::click());
|
||||||
|
let (overlay, overlay_clicked) = listener(&mut h, CursorSense::click());
|
||||||
|
h.set_root((list, button, overlay).stack());
|
||||||
|
|
||||||
|
h.move_to((50, 50));
|
||||||
|
h.scroll((0, 10));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
scrolled.take(),
|
||||||
|
[CursorSense::Scroll],
|
||||||
|
"two layers of click-only widgets do not stop a scroll"
|
||||||
|
);
|
||||||
|
assert_eq!(clicked.take(), []);
|
||||||
|
assert_eq!(overlay_clicked.take(), []);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hovering_a_button_above_does_not_stop_a_later_scroll() {
|
||||||
|
let mut h = harness();
|
||||||
|
let (list, scrolled) = listener(&mut h, CursorSense::Scroll);
|
||||||
|
let (button, _clicked) = listener(&mut h, CursorSense::click());
|
||||||
|
h.set_root((list, button).stack());
|
||||||
|
|
||||||
|
// The hover arrives in its own frame, as a window delivers it.
|
||||||
|
h.move_to((50, 50));
|
||||||
|
assert_eq!(scrolled.take(), []);
|
||||||
|
|
||||||
|
h.scroll((0, 10));
|
||||||
|
assert_eq!(
|
||||||
|
scrolled.take(),
|
||||||
|
[CursorSense::Scroll],
|
||||||
|
"a hover already resting on the button must not consume the wheel"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_topmost_listener_takes_a_press() {
|
||||||
|
let mut h = harness();
|
||||||
|
let (below, below_clicked) = listener(&mut h, CursorSense::click());
|
||||||
|
let (above, above_clicked) = listener(&mut h, CursorSense::click());
|
||||||
|
h.set_root((below, above).stack());
|
||||||
|
|
||||||
|
h.click((50, 50));
|
||||||
|
|
||||||
|
assert_eq!(above_clicked.take(), [CursorSense::click()]);
|
||||||
|
assert_eq!(below_clicked.take(), [], "one press goes to one widget");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_press_beside_the_button_reaches_the_layer_below() {
|
||||||
|
let mut h = harness();
|
||||||
|
let (list, list_clicked) = listener(&mut h, CursorSense::click());
|
||||||
|
// The row above the list covers it, but only its left half is the button.
|
||||||
|
let (button, button_clicked) = listener(&mut h, CursorSense::click());
|
||||||
|
let row = (button, blank(&mut h)).span(Dir::RIGHT).add(&mut h.rsc);
|
||||||
|
h.set_root((list, row).stack());
|
||||||
|
|
||||||
|
h.click((20, 50));
|
||||||
|
assert_eq!(button_clicked.take(), [CursorSense::click()]);
|
||||||
|
assert_eq!(list_clicked.take(), []);
|
||||||
|
|
||||||
|
h.click((80, 50));
|
||||||
|
assert_eq!(button_clicked.take(), [], "the cursor is not on the button");
|
||||||
|
assert_eq!(
|
||||||
|
list_clicked.take(),
|
||||||
|
[CursorSense::click()],
|
||||||
|
"a press beside the button belongs to what is under it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leaving_a_widget_still_ends_its_hover() {
|
||||||
|
let mut h = harness();
|
||||||
|
let (widget, hover) = listener(&mut h, CursorSense::HoverStart | CursorSense::HoverEnd);
|
||||||
|
h.set_root(widget);
|
||||||
|
|
||||||
|
h.move_to((50, 50));
|
||||||
|
assert_eq!(hover.take(), [CursorSense::HoverStart]);
|
||||||
|
|
||||||
|
h.leave();
|
||||||
|
assert_eq!(hover.take(), [CursorSense::HoverEnd]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leaving_a_widget_does_not_block_the_layer_below() {
|
||||||
|
let mut h = harness();
|
||||||
|
let (below, below_hover) = listener(&mut h, CursorSense::HoverStart);
|
||||||
|
// Only the left half of the layer above is a widget, so the cursor can
|
||||||
|
// leave it without leaving the one underneath.
|
||||||
|
let (above, above_hover) = listener(&mut h, CursorSense::HoverStart | CursorSense::HoverEnd);
|
||||||
|
let row = (above, blank(&mut h)).span(Dir::RIGHT).add(&mut h.rsc);
|
||||||
|
h.set_root((below, row).stack());
|
||||||
|
|
||||||
|
h.move_to((20, 50));
|
||||||
|
assert_eq!(above_hover.take(), [CursorSense::HoverStart]);
|
||||||
|
assert_eq!(below_hover.take(), [], "the layer above is over it");
|
||||||
|
|
||||||
|
h.move_to((80, 50));
|
||||||
|
assert_eq!(above_hover.take(), [CursorSense::HoverEnd]);
|
||||||
|
assert_eq!(
|
||||||
|
below_hover.take(),
|
||||||
|
[CursorSense::HoverStart],
|
||||||
|
"ending a hover above must not stop the hover below"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn covering_a_widget_ends_its_hover() {
|
||||||
|
let mut h = harness();
|
||||||
|
let (below, below_hover) = listener(&mut h, CursorSense::HoverStart | CursorSense::HoverEnd);
|
||||||
|
let (above, above_hover) = listener(&mut h, CursorSense::HoverStart);
|
||||||
|
let row = (above, blank(&mut h)).span(Dir::RIGHT).add(&mut h.rsc);
|
||||||
|
h.set_root((below, row).stack());
|
||||||
|
|
||||||
|
h.move_to((80, 50));
|
||||||
|
assert_eq!(below_hover.take(), [CursorSense::HoverStart]);
|
||||||
|
|
||||||
|
h.move_to((20, 50));
|
||||||
|
assert_eq!(above_hover.take(), [CursorSense::HoverStart]);
|
||||||
|
assert_eq!(
|
||||||
|
below_hover.take(),
|
||||||
|
[CursorSense::HoverEnd],
|
||||||
|
"a widget covered by one that took the input is no longer hovered"
|
||||||
|
);
|
||||||
|
|
||||||
|
h.move_to((80, 50));
|
||||||
|
assert_eq!(
|
||||||
|
below_hover.take(),
|
||||||
|
[CursorSense::HoverStart],
|
||||||
|
"uncovering it hovers it again"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hover_starts_and_ends_once_each() {
|
||||||
|
let mut h = harness();
|
||||||
|
// Only the left half is the widget, so the cursor can leave it without
|
||||||
|
// leaving the window.
|
||||||
|
let (widget, hover) = listener(&mut h, CursorSense::HoverStart | CursorSense::HoverEnd);
|
||||||
|
let row = (widget, blank(&mut h)).span(Dir::RIGHT).add(&mut h.rsc);
|
||||||
|
h.set_root(row);
|
||||||
|
|
||||||
|
h.move_to((20, 50));
|
||||||
|
assert_eq!(hover.take(), [CursorSense::HoverStart]);
|
||||||
|
|
||||||
|
h.move_to((30, 50));
|
||||||
|
assert_eq!(hover.take(), [], "staying inside is not a second start");
|
||||||
|
|
||||||
|
h.move_to((80, 50));
|
||||||
|
assert_eq!(hover.take(), [CursorSense::HoverEnd]);
|
||||||
|
|
||||||
|
h.move_to((90, 50));
|
||||||
|
assert_eq!(hover.take(), [], "an ended hover does not end again");
|
||||||
|
|
||||||
|
h.move_to((20, 50));
|
||||||
|
assert_eq!(
|
||||||
|
hover.take(),
|
||||||
|
[CursorSense::HoverStart],
|
||||||
|
"re-entering starts it"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
//! Scrolling moves content and stops at its ends.
|
||||||
|
|
||||||
|
use iris::harness::{Harness, assert_corners};
|
||||||
|
use iris::prelude::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_wheel_scrolls_the_content_and_stops_at_its_end() {
|
||||||
|
let mut h = Harness::new((400, 200));
|
||||||
|
// Twice the window's height, so there is 200 to scroll.
|
||||||
|
let top = rect(Color::RED).height(200).add(&mut h.rsc);
|
||||||
|
let bottom = rect(Color::BLUE).height(200).add(&mut h.rsc);
|
||||||
|
h.set_root((top, bottom).span(Dir::DOWN).scrollable());
|
||||||
|
h.move_to((200, 100));
|
||||||
|
|
||||||
|
// `Scroll` starts snapped to the end.
|
||||||
|
assert_corners!(h, top, (0, -200), (400, 0));
|
||||||
|
|
||||||
|
// The handler scales a wheel line by 50.
|
||||||
|
h.scroll((0, 1));
|
||||||
|
h.frame();
|
||||||
|
assert_corners!(h, top, (0, -150), (400, 50));
|
||||||
|
|
||||||
|
h.scroll((0, 10));
|
||||||
|
h.frame();
|
||||||
|
assert_corners!(h, top, (0, 0), (400, 200));
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
//! What a background task can change, and how it gets back to the ui.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use iris::harness::Harness;
|
||||||
|
use iris::prelude::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_task_update_reaches_the_tree() {
|
||||||
|
let mut h = Harness::new((400, 200));
|
||||||
|
let widget = rect(Color::RED).add(&mut h.rsc);
|
||||||
|
h.set_root(widget.task_on(CursorSense::click(), async move |mut ctx| {
|
||||||
|
ctx.update(move |_, rsc| widget(rsc).color = Color::BLUE);
|
||||||
|
}));
|
||||||
|
|
||||||
|
h.click((200, 100));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
h.await_update(Duration::from_secs(5)),
|
||||||
|
"the task sent no update"
|
||||||
|
);
|
||||||
|
assert_eq!(h.rsc[widget].color, Color::BLUE);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user