Another project
1
fork

Configure Feed

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

test(ui): focus + key ordering

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

+299
+299
crates/bone-ui/tests/key_ordering.rs
··· 1 + use core::num::NonZeroU32; 2 + use std::sync::Arc; 3 + 4 + use bone_ui::{ 5 + ActionId, FocusManager, FrameCtx, FrameInstant, HitFrame, HitState, HotkeyBinding, HotkeyScope, 6 + HotkeyScopes, HotkeyTable, InputSnapshot, KeyChar, KeyChord, KeyCode, KeyEvent, ModifierMask, 7 + StringKey, StringTable, Theme, WidgetId, WidgetKey, 8 + widgets::{ 9 + AlwaysValid, HotkeyCapture, HotkeyCaptureState, MemoryClipboard, TextInput, TextInputState, 10 + show_hotkey_capture, show_text_input, 11 + }, 12 + }; 13 + 14 + const FIELD_LABEL: StringKey = StringKey::new("ordering.field"); 15 + 16 + fn field_id() -> WidgetId { 17 + WidgetId::ROOT.child(WidgetKey::new("ordered_field")) 18 + } 19 + 20 + fn rect() -> bone_ui::layout::LayoutRect { 21 + use bone_ui::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 22 + LayoutRect::new( 23 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 24 + LayoutSize::new(LayoutPx::new(120.0), LayoutPx::new(24.0)), 25 + ) 26 + } 27 + 28 + fn action(n: u32) -> ActionId { 29 + let Some(nz) = NonZeroU32::new(n) else { 30 + panic!("test action id must be non-zero"); 31 + }; 32 + ActionId::new(nz) 33 + } 34 + 35 + fn focused_field() -> FocusManager { 36 + let mut focus = FocusManager::new(); 37 + focus.register_focusable(field_id()); 38 + focus.request_focus(field_id()); 39 + focus.end_frame(); 40 + focus 41 + } 42 + 43 + fn ctrl(c: char) -> KeyEvent { 44 + KeyEvent::new(KeyCode::Char(KeyChar::from_char(c)), ModifierMask::CTRL) 45 + } 46 + 47 + #[test] 48 + fn text_input_before_dispatch_eats_overlapping_editing_chord() { 49 + let theme = Arc::new(Theme::light()); 50 + let select_all_global = action(1); 51 + let Ok(table) = HotkeyTable::try_from_bindings(vec![HotkeyBinding::new( 52 + KeyChord::new(KeyCode::Char(KeyChar::from_char('a')), ModifierMask::CTRL), 53 + HotkeyScope::Global, 54 + select_all_global, 55 + )]) else { 56 + panic!("registration must succeed"); 57 + }; 58 + let mut focus = focused_field(); 59 + let mut hits = HitFrame::new(); 60 + let prev = HitState::new(); 61 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 62 + input.keys_pressed.push(ctrl('a')); 63 + let mut state = TextInputState::from_text("hello"); 64 + let mut clipboard = MemoryClipboard::default(); 65 + let scopes = HotkeyScopes::from_outer_to_inner([HotkeyScope::Global]); 66 + 67 + let actions = { 68 + let mut ctx = FrameCtx::new( 69 + theme, 70 + &mut input, 71 + &mut focus, 72 + &table, 73 + StringTable::empty(), 74 + &mut hits, 75 + &prev, 76 + ); 77 + let widget = TextInput { 78 + id: field_id(), 79 + rect: rect(), 80 + placeholder: FIELD_LABEL, 81 + state: &mut state, 82 + disabled: false, 83 + validator: AlwaysValid, 84 + }; 85 + let _ = show_text_input(&mut ctx, widget, &mut clipboard); 86 + ctx.dispatch_hotkeys(&scopes) 87 + }; 88 + 89 + assert!( 90 + actions.is_empty(), 91 + "TextInput must claim Ctrl+A before dispatch sees it", 92 + ); 93 + assert_eq!(state.selection.min().value(), 0); 94 + assert_eq!(state.selection.max().value(), 5); 95 + } 96 + 97 + #[test] 98 + fn dispatch_after_text_input_still_routes_unowned_chord() { 99 + let theme = Arc::new(Theme::light()); 100 + let save_global = action(7); 101 + let Ok(table) = HotkeyTable::try_from_bindings(vec![HotkeyBinding::new( 102 + KeyChord::new(KeyCode::Char(KeyChar::from_char('s')), ModifierMask::CTRL), 103 + HotkeyScope::Global, 104 + save_global, 105 + )]) else { 106 + panic!("registration must succeed"); 107 + }; 108 + let mut focus = focused_field(); 109 + let mut hits = HitFrame::new(); 110 + let prev = HitState::new(); 111 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 112 + input.keys_pressed.push(ctrl('s')); 113 + let mut state = TextInputState::from_text("hello"); 114 + let mut clipboard = MemoryClipboard::default(); 115 + let scopes = HotkeyScopes::from_outer_to_inner([HotkeyScope::Global]); 116 + 117 + let actions = { 118 + let mut ctx = FrameCtx::new( 119 + theme, 120 + &mut input, 121 + &mut focus, 122 + &table, 123 + StringTable::empty(), 124 + &mut hits, 125 + &prev, 126 + ); 127 + let widget = TextInput { 128 + id: field_id(), 129 + rect: rect(), 130 + placeholder: FIELD_LABEL, 131 + state: &mut state, 132 + disabled: false, 133 + validator: AlwaysValid, 134 + }; 135 + let _ = show_text_input(&mut ctx, widget, &mut clipboard); 136 + ctx.dispatch_hotkeys(&scopes) 137 + }; 138 + 139 + assert_eq!(actions, vec![save_global]); 140 + assert_eq!(state.text, "hello"); 141 + } 142 + 143 + #[test] 144 + fn dispatch_first_breaks_text_editing_when_overlap_exists() { 145 + let theme = Arc::new(Theme::light()); 146 + let select_all_global = action(1); 147 + let Ok(table) = HotkeyTable::try_from_bindings(vec![HotkeyBinding::new( 148 + KeyChord::new(KeyCode::Char(KeyChar::from_char('a')), ModifierMask::CTRL), 149 + HotkeyScope::Global, 150 + select_all_global, 151 + )]) else { 152 + panic!("registration must succeed"); 153 + }; 154 + let mut focus = focused_field(); 155 + let mut hits = HitFrame::new(); 156 + let prev = HitState::new(); 157 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 158 + input.keys_pressed.push(ctrl('a')); 159 + let mut state = TextInputState::from_text("hello"); 160 + let mut clipboard = MemoryClipboard::default(); 161 + let scopes = HotkeyScopes::from_outer_to_inner([HotkeyScope::Global]); 162 + 163 + let (actions, selection_min, selection_max) = { 164 + let mut ctx = FrameCtx::new( 165 + theme, 166 + &mut input, 167 + &mut focus, 168 + &table, 169 + StringTable::empty(), 170 + &mut hits, 171 + &prev, 172 + ); 173 + let actions = ctx.dispatch_hotkeys(&scopes); 174 + let widget = TextInput { 175 + id: field_id(), 176 + rect: rect(), 177 + placeholder: FIELD_LABEL, 178 + state: &mut state, 179 + disabled: false, 180 + validator: AlwaysValid, 181 + }; 182 + let _ = show_text_input(&mut ctx, widget, &mut clipboard); 183 + ( 184 + actions, 185 + state.selection.min().value(), 186 + state.selection.max().value(), 187 + ) 188 + }; 189 + 190 + assert_eq!( 191 + actions, 192 + vec![select_all_global], 193 + "dispatch-first absorbs the chord, intentional opposite ordering", 194 + ); 195 + assert_eq!( 196 + selection_min, selection_max, 197 + "TextInput cannot select-all because dispatch already drained Ctrl+A", 198 + ); 199 + } 200 + 201 + #[test] 202 + fn hotkey_capture_before_dispatch_claims_recordable_chord() { 203 + let theme = Arc::new(Theme::light()); 204 + let save_global = action(11); 205 + let Ok(table) = HotkeyTable::try_from_bindings(vec![HotkeyBinding::new( 206 + KeyChord::new(KeyCode::Char(KeyChar::from_char('s')), ModifierMask::CTRL), 207 + HotkeyScope::Global, 208 + save_global, 209 + )]) else { 210 + panic!("registration must succeed"); 211 + }; 212 + let mut focus = focused_field(); 213 + let mut hits = HitFrame::new(); 214 + let prev = HitState::new(); 215 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 216 + input.keys_pressed.push(ctrl('s')); 217 + let mut state = HotkeyCaptureState { 218 + recording: true, 219 + chord: None, 220 + }; 221 + let scopes = HotkeyScopes::from_outer_to_inner([HotkeyScope::Global]); 222 + 223 + let (actions, captured) = { 224 + let mut ctx = FrameCtx::new( 225 + theme, 226 + &mut input, 227 + &mut focus, 228 + &table, 229 + StringTable::empty(), 230 + &mut hits, 231 + &prev, 232 + ); 233 + let widget = HotkeyCapture::new(field_id(), rect(), FIELD_LABEL, &mut state); 234 + let response = show_hotkey_capture(&mut ctx, widget); 235 + let actions = ctx.dispatch_hotkeys(&scopes); 236 + (actions, response.captured) 237 + }; 238 + 239 + assert_eq!( 240 + captured, 241 + Some(KeyChord::new( 242 + KeyCode::Char(KeyChar::from_char('s')), 243 + ModifierMask::CTRL, 244 + )), 245 + ); 246 + assert!( 247 + actions.is_empty(), 248 + "HotkeyCapture must claim Ctrl+S before dispatch sees it", 249 + ); 250 + } 251 + 252 + #[test] 253 + fn dispatch_before_hotkey_capture_steals_chord() { 254 + let theme = Arc::new(Theme::light()); 255 + let save_global = action(11); 256 + let Ok(table) = HotkeyTable::try_from_bindings(vec![HotkeyBinding::new( 257 + KeyChord::new(KeyCode::Char(KeyChar::from_char('s')), ModifierMask::CTRL), 258 + HotkeyScope::Global, 259 + save_global, 260 + )]) else { 261 + panic!("registration must succeed"); 262 + }; 263 + let mut focus = focused_field(); 264 + let mut hits = HitFrame::new(); 265 + let prev = HitState::new(); 266 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 267 + input.keys_pressed.push(ctrl('s')); 268 + let mut state = HotkeyCaptureState { 269 + recording: true, 270 + chord: None, 271 + }; 272 + let scopes = HotkeyScopes::from_outer_to_inner([HotkeyScope::Global]); 273 + 274 + let (actions, captured) = { 275 + let mut ctx = FrameCtx::new( 276 + theme, 277 + &mut input, 278 + &mut focus, 279 + &table, 280 + StringTable::empty(), 281 + &mut hits, 282 + &prev, 283 + ); 284 + let actions = ctx.dispatch_hotkeys(&scopes); 285 + let widget = HotkeyCapture::new(field_id(), rect(), FIELD_LABEL, &mut state); 286 + let response = show_hotkey_capture(&mut ctx, widget); 287 + (actions, response.captured) 288 + }; 289 + 290 + assert_eq!( 291 + actions, 292 + vec![save_global], 293 + "dispatch-first routes the chord to its action, intentional opposite ordering", 294 + ); 295 + assert!( 296 + captured.is_none(), 297 + "capture cannot record because dispatch already drained Ctrl+S", 298 + ); 299 + }