Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(ui): input script driver

Lewis: May this revision serve well! <lu5a@proton.me>

author
Lewis
date (May 6, 2026, 9:18 AM +0300) commit acee269e parent afab59ff change-id xwppyptl
+405 -1
+3 -1
crates/bone-ui/src/input/key.rs
··· 2 2 3 3 use serde::{Deserialize, Serialize}; 4 4 5 - #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] 5 + #[derive( 6 + Copy, Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, 7 + )] 6 8 #[serde(transparent)] 7 9 pub struct ModifierMask(u8); 8 10
+2
crates/bone-ui/src/input/mod.rs
··· 1 1 mod key; 2 2 mod pointer; 3 + pub mod script; 3 4 4 5 pub use key::{KeyChar, KeyCode, KeyEvent, ModifierMask, NamedKey}; 5 6 pub use pointer::{ 6 7 ClickCount, DoubleClickWindow, DragThreshold, FrameInstant, PointerButton, PointerButtonMask, 7 8 PointerSample, 8 9 }; 10 + pub use script::Script; 9 11 10 12 use serde::{Deserialize, Serialize}; 11 13
+260
crates/bone-ui/src/input/script.rs
··· 1 + use core::time::Duration; 2 + 3 + use crate::layout::{LayoutOffset, LayoutPos, LayoutPx}; 4 + 5 + use super::key::{KeyChar, KeyCode, KeyEvent, ModifierMask, NamedKey}; 6 + use super::pointer::{FrameInstant, PointerButton, PointerButtonMask, PointerSample}; 7 + use super::InputSnapshot; 8 + 9 + const DEFAULT_TICK: Duration = Duration::from_millis(16); 10 + const DRAG_SAMPLES: usize = 8; 11 + 12 + #[derive(Clone, Debug, PartialEq)] 13 + enum Step { 14 + Move(LayoutPos), 15 + Press(PointerButton), 16 + Release(PointerButton), 17 + Key(KeyEvent), 18 + TextChunk(String), 19 + Idle, 20 + } 21 + 22 + #[derive(Clone, Debug, PartialEq, Default)] 23 + pub struct Script { 24 + steps: Vec<Step>, 25 + cursor: Option<LayoutPos>, 26 + modifiers: ModifierMask, 27 + } 28 + 29 + impl Script { 30 + #[must_use] 31 + pub fn new() -> Self { 32 + Self::default() 33 + } 34 + 35 + #[must_use] 36 + pub fn with_modifiers(mut self, modifiers: ModifierMask) -> Self { 37 + self.modifiers = modifiers; 38 + self 39 + } 40 + 41 + #[must_use] 42 + pub fn hover(mut self, pos: LayoutPos) -> Self { 43 + self.cursor = Some(pos); 44 + self.steps.push(Step::Move(pos)); 45 + self 46 + } 47 + 48 + #[must_use] 49 + pub fn press(mut self, button: PointerButton) -> Self { 50 + self.steps.push(Step::Press(button)); 51 + self 52 + } 53 + 54 + #[must_use] 55 + pub fn release(mut self, button: PointerButton) -> Self { 56 + self.steps.push(Step::Release(button)); 57 + self 58 + } 59 + 60 + #[must_use] 61 + pub fn click(self, button: PointerButton) -> Self { 62 + self.press(button).release(button) 63 + } 64 + 65 + #[must_use] 66 + #[allow( 67 + clippy::cast_precision_loss, 68 + reason = "DRAG_SAMPLES and i are small constants, far below f32 precision boundary" 69 + )] 70 + pub fn drag(mut self, delta: LayoutOffset) -> Self { 71 + let from = self.cursor.unwrap_or(LayoutPos::ORIGIN); 72 + let denom = DRAG_SAMPLES as f32; 73 + (1..=DRAG_SAMPLES).for_each(|i| { 74 + let t = i as f32 / denom; 75 + let pos = LayoutPos::new( 76 + LayoutPx::saturating(from.x.value() + delta.dx.value() * t), 77 + LayoutPx::saturating(from.y.value() + delta.dy.value() * t), 78 + ); 79 + self.steps.push(Step::Move(pos)); 80 + self.cursor = Some(pos); 81 + }); 82 + self 83 + } 84 + 85 + #[must_use] 86 + pub fn key(mut self, code: KeyCode) -> Self { 87 + let event = KeyEvent::new(code, self.modifiers); 88 + self.steps.push(Step::Key(event)); 89 + self 90 + } 91 + 92 + #[must_use] 93 + pub fn named(self, named: NamedKey) -> Self { 94 + self.key(KeyCode::Named(named)) 95 + } 96 + 97 + #[must_use] 98 + pub fn char(self, c: char) -> Self { 99 + self.key(KeyCode::Char(KeyChar::from_char(c))) 100 + } 101 + 102 + #[must_use] 103 + pub fn type_text(mut self, text: &str) -> Self { 104 + self.steps.push(Step::TextChunk(text.to_owned())); 105 + self 106 + } 107 + 108 + #[must_use] 109 + pub fn idle(mut self) -> Self { 110 + self.steps.push(Step::Idle); 111 + self 112 + } 113 + 114 + #[must_use] 115 + pub fn frames(&self) -> Vec<InputSnapshot> { 116 + let acc = FrameAcc { 117 + cursor: None, 118 + now: FrameInstant::ZERO, 119 + modifiers: self.modifiers, 120 + }; 121 + let (frames, _) = self 122 + .steps 123 + .iter() 124 + .fold((Vec::new(), acc), |(mut frames, acc), step| { 125 + let (frame, next) = build_frame(acc, step); 126 + frames.push(frame); 127 + (frames, next) 128 + }); 129 + frames 130 + } 131 + } 132 + 133 + #[derive(Copy, Clone, Debug)] 134 + struct FrameAcc { 135 + cursor: Option<LayoutPos>, 136 + now: FrameInstant, 137 + modifiers: ModifierMask, 138 + } 139 + 140 + fn build_frame(acc: FrameAcc, step: &Step) -> (InputSnapshot, FrameAcc) { 141 + let mut input = InputSnapshot::idle(acc.now); 142 + input.modifiers = acc.modifiers; 143 + let mut next = acc; 144 + match step { 145 + Step::Move(pos) => { 146 + next.cursor = Some(*pos); 147 + input.pointer = Some(PointerSample::new(*pos)); 148 + } 149 + Step::Press(button) => { 150 + input.pointer = acc.cursor.map(PointerSample::new); 151 + input.buttons_pressed = PointerButtonMask::just(*button); 152 + } 153 + Step::Release(button) => { 154 + input.pointer = acc.cursor.map(PointerSample::new); 155 + input.buttons_released = PointerButtonMask::just(*button); 156 + } 157 + Step::Key(event) => { 158 + input.pointer = acc.cursor.map(PointerSample::new); 159 + input.keys_pressed.push(*event); 160 + } 161 + Step::TextChunk(text) => { 162 + input.pointer = acc.cursor.map(PointerSample::new); 163 + text.clone_into(&mut input.text_committed); 164 + } 165 + Step::Idle => { 166 + input.pointer = acc.cursor.map(PointerSample::new); 167 + } 168 + } 169 + next.now = FrameInstant::from_duration(acc.now.duration() + DEFAULT_TICK); 170 + (input, next) 171 + } 172 + 173 + #[cfg(test)] 174 + mod tests { 175 + use super::{DEFAULT_TICK, KeyChar, KeyCode, ModifierMask, NamedKey, PointerButton, Script}; 176 + use crate::input::FrameInstant; 177 + use crate::layout::{LayoutOffset, LayoutPos, LayoutPx}; 178 + 179 + #[test] 180 + fn empty_script_produces_no_frames() { 181 + let script = Script::new(); 182 + assert!(script.frames().is_empty()); 183 + } 184 + 185 + #[test] 186 + fn click_emits_press_then_release() { 187 + let frames = Script::new().click(PointerButton::Primary).frames(); 188 + assert_eq!(frames.len(), 2); 189 + assert!(frames[0].buttons_pressed.contains(PointerButton::Primary)); 190 + assert!(frames[0].buttons_released.is_empty()); 191 + assert!(frames[1].buttons_pressed.is_empty()); 192 + assert!(frames[1].buttons_released.contains(PointerButton::Primary)); 193 + } 194 + 195 + #[test] 196 + fn hover_then_drag_carries_cursor_forward() { 197 + let frames = Script::new() 198 + .hover(LayoutPos::new(LayoutPx::new(10.0), LayoutPx::new(20.0))) 199 + .press(PointerButton::Primary) 200 + .drag(LayoutOffset::new(LayoutPx::new(8.0), LayoutPx::new(0.0))) 201 + .release(PointerButton::Primary) 202 + .frames(); 203 + assert_eq!(frames.len(), 1 + 1 + super::DRAG_SAMPLES + 1); 204 + let drag_xs: Vec<f32> = frames[2..2 + super::DRAG_SAMPLES] 205 + .iter() 206 + .map(|f| { 207 + let Some(s) = f.pointer else { 208 + panic!("drag sample carries pointer"); 209 + }; 210 + s.position.x.value() 211 + }) 212 + .collect(); 213 + assert!( 214 + drag_xs.windows(2).all(|w| w[1] > w[0]), 215 + "drag samples must be monotonically increasing in x, got {drag_xs:?}", 216 + ); 217 + let Some(last) = frames.last() else { 218 + panic!("frames produced"); 219 + }; 220 + let Some(p) = last.pointer else { 221 + panic!("release frame must carry pointer position"); 222 + }; 223 + assert!((p.position.x.value() - 18.0).abs() < f32::EPSILON); 224 + assert!(last.buttons_released.contains(PointerButton::Primary)); 225 + } 226 + 227 + #[test] 228 + fn key_steps_attach_modifiers() { 229 + let frames = Script::new() 230 + .with_modifiers(ModifierMask::CTRL) 231 + .char('a') 232 + .frames(); 233 + assert_eq!(frames.len(), 1); 234 + assert_eq!(frames[0].keys_pressed.len(), 1); 235 + let event = frames[0].keys_pressed[0]; 236 + assert_eq!(event.code, KeyCode::Char(KeyChar::from_char('a'))); 237 + assert!(event.modifiers.contains(ModifierMask::CTRL)); 238 + } 239 + 240 + #[test] 241 + fn named_key_step_records_named_code() { 242 + let frames = Script::new().named(NamedKey::Escape).frames(); 243 + assert_eq!(frames.len(), 1); 244 + assert_eq!(frames[0].keys_pressed[0].code, KeyCode::Named(NamedKey::Escape)); 245 + } 246 + 247 + #[test] 248 + fn type_text_emits_text_committed_on_one_frame() { 249 + let frames = Script::new().type_text("hi").frames(); 250 + assert_eq!(frames.len(), 1); 251 + assert_eq!(frames[0].text_committed, "hi"); 252 + } 253 + 254 + #[test] 255 + fn ticks_advance_frame_time_by_default_interval() { 256 + let frames = Script::new().idle().idle().frames(); 257 + assert_eq!(frames[0].frame, FrameInstant::ZERO); 258 + assert_eq!(frames[1].frame, FrameInstant::from_duration(DEFAULT_TICK)); 259 + } 260 + }
+140
crates/bone-ui/tests/input_script_drive.rs
··· 1 + use std::sync::Arc; 2 + 3 + use bone_ui::hit_test::resolve; 4 + use bone_ui::layout::{LayoutOffset, LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 5 + use bone_ui::widgets::{ 6 + Button, ButtonVariant, Slider, SliderRange, SliderStep, show_button, show_slider, 7 + }; 8 + use bone_ui::{ 9 + AccessTreeBuilder, FocusManager, FrameCtx, FrameInstant, HitFrame, HitState, HotkeyTable, 10 + InputSnapshot, NamedKey, PointerButton, Script, StringKey, StringTable, Theme, WidgetId, 11 + WidgetKey, 12 + }; 13 + 14 + const LABEL: StringKey = StringKey::new("script_drive.label"); 15 + 16 + fn child(name: &'static str) -> WidgetId { 17 + WidgetId::ROOT.child(WidgetKey::new(name)) 18 + } 19 + 20 + fn rect(w: f32, h: f32) -> LayoutRect { 21 + LayoutRect::new( 22 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 23 + LayoutSize::new(LayoutPx::new(w), LayoutPx::new(h)), 24 + ) 25 + } 26 + 27 + fn run<R>( 28 + focus: &mut FocusManager, 29 + prev: &HitState, 30 + snap: &mut InputSnapshot, 31 + render: impl FnOnce(&mut FrameCtx<'_>) -> R, 32 + ) -> (R, HitState) { 33 + let theme = Arc::new(Theme::light()); 34 + let table = HotkeyTable::new(); 35 + let mut hits = HitFrame::new(); 36 + let mut a11y = AccessTreeBuilder::new(); 37 + let response = { 38 + let mut ctx = FrameCtx::new( 39 + theme, 40 + snap, 41 + focus, 42 + &table, 43 + StringTable::empty(), 44 + &mut hits, 45 + prev, 46 + &mut a11y, 47 + ); 48 + render(&mut ctx) 49 + }; 50 + let next = resolve(prev, &hits, snap, focus.focused()); 51 + (response, next) 52 + } 53 + 54 + fn drive_button(focus: &mut FocusManager, prev: &HitState, snap: &mut InputSnapshot) -> (bool, HitState) { 55 + let id = child("button"); 56 + let (response, next) = run(focus, prev, snap, |ctx| { 57 + show_button( 58 + ctx, 59 + Button::new(id, rect(80.0, 24.0), LABEL, ButtonVariant::Primary), 60 + ) 61 + }); 62 + (response.activated, next) 63 + } 64 + 65 + #[test] 66 + fn scripted_pointer_click_activates_button() { 67 + let center = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(12.0)); 68 + let frames = Script::new() 69 + .hover(center) 70 + .click(PointerButton::Primary) 71 + .idle() 72 + .frames(); 73 + let mut focus = FocusManager::new(); 74 + let mut prev = HitState::new(); 75 + let mut activations: Vec<bool> = Vec::new(); 76 + frames.into_iter().for_each(|mut frame| { 77 + let (activated, next) = drive_button(&mut focus, &prev, &mut frame); 78 + activations.push(activated); 79 + prev = next; 80 + }); 81 + assert!( 82 + activations.iter().any(|a| *a), 83 + "scripted click must produce at least one activation, got {activations:?}", 84 + ); 85 + } 86 + 87 + #[test] 88 + fn scripted_keyboard_activation_after_focus() { 89 + let id = child("button"); 90 + let mut focus = FocusManager::new(); 91 + let prev = HitState::new(); 92 + focus.request_focus(id); 93 + let mut warm = InputSnapshot::idle(FrameInstant::ZERO); 94 + let _ = drive_button(&mut focus, &prev, &mut warm); 95 + assert_eq!(focus.focused(), Some(id)); 96 + 97 + let frames = Script::new().named(NamedKey::Enter).frames(); 98 + let Some(mut snap) = frames.into_iter().next() else { 99 + panic!("Script must yield one frame for a single key step"); 100 + }; 101 + let (activated, _) = drive_button(&mut focus, &prev, &mut snap); 102 + assert!(activated, "Enter on focused button must activate it"); 103 + } 104 + 105 + #[test] 106 + fn scripted_pointer_drag_moves_slider_value() { 107 + let slider_rect = rect(200.0, 18.0); 108 + let slider_id = child("slider"); 109 + let Ok(range) = SliderRange::try_new(0.0_f64, 100.0_f64) else { 110 + unreachable!("slider range valid"); 111 + }; 112 + let Ok(step) = SliderStep::try_new(1.0_f64) else { 113 + unreachable!("slider step valid"); 114 + }; 115 + let start = LayoutPos::new(LayoutPx::new(20.0), LayoutPx::new(9.0)); 116 + let frames = Script::new() 117 + .hover(start) 118 + .press(PointerButton::Primary) 119 + .drag(LayoutOffset::new(LayoutPx::new(140.0), LayoutPx::ZERO)) 120 + .release(PointerButton::Primary) 121 + .frames(); 122 + let mut focus = FocusManager::new(); 123 + let mut prev = HitState::new(); 124 + let mut value = 0.0_f64; 125 + frames.into_iter().for_each(|mut frame| { 126 + let v = value; 127 + let (response, next) = run(&mut focus, &prev, &mut frame, |ctx| { 128 + show_slider( 129 + ctx, 130 + Slider::new(slider_id, slider_rect, LABEL, v, range, step), 131 + ) 132 + }); 133 + value = response.value; 134 + prev = next; 135 + }); 136 + assert!( 137 + value > 50.0, 138 + "scripted drag from x=20 to x=160 across [0,100] range must move value past midpoint, got {value}", 139 + ); 140 + }