Another project
1
fork

Configure Feed

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

feat(ui): hotkey capture widget

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

+499
+499
crates/bone-ui/src/widgets/hotkey_capture.rs
··· 1 + use crate::frame::{FrameCtx, InteractDeclaration}; 2 + use crate::hit_test::{Interaction, Sense}; 3 + use crate::hotkey::KeyChord; 4 + use crate::input::{KeyCode, KeyEvent, ModifierMask, NamedKey}; 5 + use crate::layout::LayoutRect; 6 + use crate::strings::StringKey; 7 + use crate::theme::{Border, Step12, StrokeWidth}; 8 + use crate::widget_id::WidgetId; 9 + 10 + use super::paint::{LabelText, WidgetPaint}; 11 + use super::visuals::push_focus_ring; 12 + 13 + #[derive(Clone, Debug, Default, PartialEq, Eq)] 14 + pub struct HotkeyCaptureState { 15 + pub recording: bool, 16 + pub chord: Option<KeyChord>, 17 + } 18 + 19 + #[derive(Debug, PartialEq)] 20 + pub struct HotkeyCapture<'state> { 21 + pub id: WidgetId, 22 + pub rect: LayoutRect, 23 + pub placeholder: StringKey, 24 + pub state: &'state mut HotkeyCaptureState, 25 + pub disabled: bool, 26 + } 27 + 28 + impl<'state> HotkeyCapture<'state> { 29 + #[must_use] 30 + pub fn new( 31 + id: WidgetId, 32 + rect: LayoutRect, 33 + placeholder: StringKey, 34 + state: &'state mut HotkeyCaptureState, 35 + ) -> Self { 36 + Self { 37 + id, 38 + rect, 39 + placeholder, 40 + state, 41 + disabled: false, 42 + } 43 + } 44 + 45 + #[must_use] 46 + pub fn disabled(self, disabled: bool) -> Self { 47 + Self { disabled, ..self } 48 + } 49 + } 50 + 51 + #[derive(Clone, Debug, PartialEq)] 52 + pub struct HotkeyCaptureResponse { 53 + pub interaction: Interaction, 54 + pub captured: Option<KeyChord>, 55 + pub paint: Vec<WidgetPaint>, 56 + } 57 + 58 + #[must_use] 59 + pub fn show_hotkey_capture( 60 + ctx: &mut FrameCtx<'_>, 61 + capture: HotkeyCapture<'_>, 62 + ) -> HotkeyCaptureResponse { 63 + let HotkeyCapture { 64 + id, 65 + rect, 66 + placeholder, 67 + state, 68 + disabled, 69 + } = capture; 70 + let interactive = !disabled; 71 + let interaction = ctx.interact( 72 + InteractDeclaration::new(id, rect, Sense::INTERACTIVE) 73 + .focusable(interactive) 74 + .disabled(!interactive) 75 + .active(state.recording), 76 + ); 77 + let click = interactive && interaction.click(); 78 + let live_focused = ctx.is_focused(id); 79 + if click { 80 + state.recording = !state.recording; 81 + } else if interactive && !live_focused { 82 + state.recording = false; 83 + } 84 + let mut captured = None; 85 + if interactive && state.recording { 86 + let pending = core::mem::take(&mut ctx.input.keys_pressed); 87 + let unconsumed = pending.into_iter().fold(Vec::new(), |mut acc, event| { 88 + if !state.recording || !is_recordable(event) { 89 + acc.push(event); 90 + return acc; 91 + } 92 + if matches!(event.code, KeyCode::Named(NamedKey::Escape)) 93 + && event.modifiers == ModifierMask::NONE 94 + { 95 + state.recording = false; 96 + return acc; 97 + } 98 + let chord = KeyChord::from(event); 99 + state.chord = Some(chord); 100 + state.recording = false; 101 + captured = Some(chord); 102 + acc 103 + }); 104 + ctx.input.keys_pressed = unconsumed; 105 + } 106 + let paint = build_paint( 107 + ctx, 108 + rect, 109 + label_text(placeholder, state.chord, state.recording), 110 + state.recording, 111 + disabled, 112 + interaction, 113 + live_focused, 114 + ); 115 + HotkeyCaptureResponse { 116 + interaction, 117 + captured, 118 + paint, 119 + } 120 + } 121 + 122 + fn label_text(placeholder: StringKey, chord: Option<KeyChord>, recording: bool) -> LabelText { 123 + if recording { 124 + return LabelText::Key(placeholder); 125 + } 126 + chord.map_or_else( 127 + || LabelText::Key(placeholder), 128 + |chord| LabelText::Owned(chord.to_string()), 129 + ) 130 + } 131 + 132 + fn is_recordable(event: KeyEvent) -> bool { 133 + let plain_focus_key = event.modifiers == ModifierMask::NONE 134 + && matches!( 135 + event.code, 136 + KeyCode::Named(NamedKey::Tab | NamedKey::Enter | NamedKey::Space) 137 + ); 138 + !plain_focus_key 139 + } 140 + 141 + fn build_paint( 142 + ctx: &FrameCtx<'_>, 143 + rect: LayoutRect, 144 + label: LabelText, 145 + recording: bool, 146 + disabled: bool, 147 + interaction: Interaction, 148 + live_focused: bool, 149 + ) -> Vec<WidgetPaint> { 150 + let neutral = ctx.theme.colors.neutral; 151 + let radius = ctx.theme.radius.sm; 152 + let hovered = interaction.hover(); 153 + let fill = if disabled { 154 + neutral.step(Step12::SUBTLE_BG) 155 + } else if recording { 156 + ctx.theme.colors.accent.step(Step12::SELECTED_BG) 157 + } else if hovered { 158 + neutral.step(Step12::HOVER_BG) 159 + } else { 160 + neutral.step(Step12::ELEMENT_BG) 161 + }; 162 + let border = Border { 163 + width: StrokeWidth::HAIRLINE, 164 + color: neutral.step(if recording { 165 + Step12::HOVER_BORDER 166 + } else { 167 + Step12::BORDER 168 + }), 169 + }; 170 + let mut paint = vec![ 171 + WidgetPaint::Surface { 172 + rect, 173 + fill, 174 + border: Some(border), 175 + radius, 176 + elevation: None, 177 + }, 178 + WidgetPaint::Label { 179 + rect, 180 + text: label, 181 + color: ctx.theme.colors.text_primary(), 182 + role: ctx.theme.typography.label, 183 + }, 184 + ]; 185 + push_focus_ring(ctx, &mut paint, rect, radius, live_focused); 186 + paint 187 + } 188 + 189 + #[cfg(test)] 190 + mod tests { 191 + use std::sync::Arc; 192 + 193 + use super::{HotkeyCapture, HotkeyCaptureState, show_hotkey_capture}; 194 + use crate::focus::FocusManager; 195 + use crate::frame::FrameCtx; 196 + use crate::hit_test::{HitFrame, HitState}; 197 + use crate::hotkey::{HotkeyTable, KeyChord}; 198 + use crate::input::{ 199 + FrameInstant, InputSnapshot, KeyChar, KeyCode, KeyEvent, ModifierMask, NamedKey, 200 + }; 201 + use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 202 + use crate::strings::{StringKey, StringTable}; 203 + use crate::theme::Theme; 204 + use crate::widget_id::{WidgetId, WidgetKey}; 205 + 206 + const PLACEHOLDER: StringKey = StringKey::new("hotkey.placeholder"); 207 + 208 + fn id_widget() -> WidgetId { 209 + WidgetId::ROOT.child(WidgetKey::new("hotkey")) 210 + } 211 + 212 + fn rect() -> LayoutRect { 213 + LayoutRect::new( 214 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 215 + LayoutSize::new(LayoutPx::new(120.0), LayoutPx::new(24.0)), 216 + ) 217 + } 218 + 219 + fn run( 220 + state: &mut HotkeyCaptureState, 221 + focus: &mut FocusManager, 222 + snap: &mut InputSnapshot, 223 + ) -> super::HotkeyCaptureResponse { 224 + let theme = Arc::new(Theme::light()); 225 + let table = HotkeyTable::new(); 226 + let mut hits = HitFrame::new(); 227 + let prev = HitState::new(); 228 + let widget = HotkeyCapture::new(id_widget(), rect(), PLACEHOLDER, state); 229 + let mut ctx = FrameCtx::new( 230 + theme, 231 + snap, 232 + focus, 233 + &table, 234 + StringTable::empty(), 235 + &mut hits, 236 + &prev, 237 + ); 238 + show_hotkey_capture(&mut ctx, widget) 239 + } 240 + 241 + fn focused_at_widget() -> FocusManager { 242 + let mut focus = FocusManager::new(); 243 + focus.register_focusable(id_widget()); 244 + focus.request_focus(id_widget()); 245 + focus.end_frame(); 246 + focus 247 + } 248 + 249 + #[test] 250 + fn ctrl_s_records_chord() { 251 + let mut state = HotkeyCaptureState { 252 + recording: true, 253 + chord: None, 254 + }; 255 + let mut focus = focused_at_widget(); 256 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 257 + snap.keys_pressed.push(KeyEvent::new( 258 + KeyCode::Char(KeyChar::from_char('s')), 259 + ModifierMask::CTRL, 260 + )); 261 + let response = run(&mut state, &mut focus, &mut snap); 262 + let chord = KeyChord::new(KeyCode::Char(KeyChar::from_char('s')), ModifierMask::CTRL); 263 + assert_eq!(response.captured, Some(chord)); 264 + assert_eq!(state.chord, Some(chord)); 265 + assert!(!state.recording, "recording stops after capture"); 266 + } 267 + 268 + #[test] 269 + fn escape_cancels_recording_without_capture() { 270 + let mut state = HotkeyCaptureState { 271 + recording: true, 272 + chord: None, 273 + }; 274 + let mut focus = focused_at_widget(); 275 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 276 + snap.keys_pressed.push(KeyEvent::new( 277 + KeyCode::Named(NamedKey::Escape), 278 + ModifierMask::NONE, 279 + )); 280 + let response = run(&mut state, &mut focus, &mut snap); 281 + assert!(response.captured.is_none()); 282 + assert!(!state.recording); 283 + } 284 + 285 + #[test] 286 + fn plain_tab_passes_through_for_focus_traversal() { 287 + let mut state = HotkeyCaptureState { 288 + recording: true, 289 + chord: None, 290 + }; 291 + let mut focus = focused_at_widget(); 292 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 293 + let tab = KeyEvent::new(KeyCode::Named(NamedKey::Tab), ModifierMask::NONE); 294 + snap.keys_pressed.push(tab); 295 + let response = run(&mut state, &mut focus, &mut snap); 296 + assert!(response.captured.is_none()); 297 + assert_eq!(snap.keys_pressed, vec![tab], "Tab not consumed"); 298 + assert!(state.recording, "still listening"); 299 + } 300 + 301 + #[test] 302 + fn ctrl_tab_is_recordable() { 303 + let mut state = HotkeyCaptureState { 304 + recording: true, 305 + chord: None, 306 + }; 307 + let mut focus = focused_at_widget(); 308 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 309 + snap.keys_pressed.push(KeyEvent::new( 310 + KeyCode::Named(NamedKey::Tab), 311 + ModifierMask::CTRL, 312 + )); 313 + let response = run(&mut state, &mut focus, &mut snap); 314 + let chord = KeyChord::new(KeyCode::Named(NamedKey::Tab), ModifierMask::CTRL); 315 + assert_eq!(response.captured, Some(chord)); 316 + } 317 + 318 + #[test] 319 + fn losing_focus_stops_recording() { 320 + let mut state = HotkeyCaptureState { 321 + recording: true, 322 + chord: None, 323 + }; 324 + let mut focus = FocusManager::new(); 325 + focus.register_focusable(id_widget()); 326 + focus.end_frame(); 327 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 328 + let response = run(&mut state, &mut focus, &mut snap); 329 + assert!(response.captured.is_none()); 330 + assert!(!state.recording); 331 + } 332 + 333 + #[test] 334 + fn shift_letter_records_with_modifier() { 335 + let mut state = HotkeyCaptureState { 336 + recording: true, 337 + chord: None, 338 + }; 339 + let mut focus = focused_at_widget(); 340 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 341 + snap.keys_pressed.push(KeyEvent::new( 342 + KeyCode::Char(KeyChar::from_char('q')), 343 + ModifierMask::SHIFT | ModifierMask::CTRL, 344 + )); 345 + let _ = run(&mut state, &mut focus, &mut snap); 346 + let chord = KeyChord::new( 347 + KeyCode::Char(KeyChar::from_char('q')), 348 + ModifierMask::SHIFT | ModifierMask::CTRL, 349 + ); 350 + assert_eq!(state.chord, Some(chord)); 351 + } 352 + 353 + #[test] 354 + fn captured_chord_paints_as_owned_label_text() { 355 + let chord = KeyChord::new(KeyCode::Char(KeyChar::from_char('s')), ModifierMask::CTRL); 356 + let mut state = HotkeyCaptureState { 357 + recording: false, 358 + chord: Some(chord), 359 + }; 360 + let mut focus = FocusManager::new(); 361 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 362 + let response = run(&mut state, &mut focus, &mut snap); 363 + let owned = response.paint.iter().find_map(|p| match p { 364 + super::WidgetPaint::Label { 365 + text: super::LabelText::Owned(s), 366 + .. 367 + } => Some(s.clone()), 368 + _ => None, 369 + }); 370 + assert_eq!(owned.as_deref(), Some("Ctrl+S")); 371 + } 372 + 373 + #[test] 374 + fn first_recordable_event_wins_when_multiple_arrive() { 375 + let mut state = HotkeyCaptureState { 376 + recording: true, 377 + chord: None, 378 + }; 379 + let mut focus = focused_at_widget(); 380 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 381 + let ctrl_s = KeyEvent::new(KeyCode::Char(KeyChar::from_char('s')), ModifierMask::CTRL); 382 + let ctrl_t = KeyEvent::new(KeyCode::Char(KeyChar::from_char('t')), ModifierMask::CTRL); 383 + snap.keys_pressed = vec![ctrl_s, ctrl_t]; 384 + let response = run(&mut state, &mut focus, &mut snap); 385 + assert_eq!( 386 + response.captured, 387 + Some(KeyChord::from(ctrl_s)), 388 + "first recordable event captured", 389 + ); 390 + assert_eq!(snap.keys_pressed, vec![ctrl_t], "later events preserved"); 391 + assert!(!state.recording); 392 + } 393 + 394 + #[test] 395 + fn escape_does_not_capture_subsequent_event() { 396 + let mut state = HotkeyCaptureState { 397 + recording: true, 398 + chord: None, 399 + }; 400 + let mut focus = focused_at_widget(); 401 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 402 + let escape = KeyEvent::new(KeyCode::Named(NamedKey::Escape), ModifierMask::NONE); 403 + let ctrl_s = KeyEvent::new(KeyCode::Char(KeyChar::from_char('s')), ModifierMask::CTRL); 404 + snap.keys_pressed = vec![escape, ctrl_s]; 405 + let response = run(&mut state, &mut focus, &mut snap); 406 + assert!(response.captured.is_none(), "Escape cancels"); 407 + assert_eq!( 408 + snap.keys_pressed, 409 + vec![ctrl_s], 410 + "post-Escape event preserved" 411 + ); 412 + assert!(!state.recording); 413 + } 414 + 415 + #[test] 416 + fn unbound_capture_paints_placeholder_key() { 417 + let mut state = HotkeyCaptureState::default(); 418 + let mut focus = FocusManager::new(); 419 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 420 + let response = run(&mut state, &mut focus, &mut snap); 421 + let key = response.paint.iter().find_map(|p| match p { 422 + super::WidgetPaint::Label { 423 + text: super::LabelText::Key(k), 424 + .. 425 + } => Some(*k), 426 + _ => None, 427 + }); 428 + assert_eq!(key, Some(PLACEHOLDER)); 429 + } 430 + 431 + #[test] 432 + fn first_click_starts_recording_despite_one_frame_focus_delay() { 433 + use crate::hit_test::resolve; 434 + use crate::input::{PointerButton, PointerButtonMask, PointerSample}; 435 + 436 + let theme = Arc::new(Theme::light()); 437 + let table = HotkeyTable::new(); 438 + let mut focus = FocusManager::new(); 439 + let mut state = HotkeyCaptureState::default(); 440 + let mut prev = HitState::new(); 441 + 442 + let pointer = LayoutPos::new(LayoutPx::new(10.0), LayoutPx::new(10.0)); 443 + let press = { 444 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 445 + s.pointer = Some(PointerSample::new(pointer)); 446 + s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 447 + s 448 + }; 449 + let release = { 450 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 451 + s.pointer = Some(PointerSample::new(pointer)); 452 + s.buttons_released = PointerButtonMask::just(PointerButton::Primary); 453 + s 454 + }; 455 + let idle = { 456 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 457 + s.pointer = Some(PointerSample::new(pointer)); 458 + s 459 + }; 460 + 461 + [press, release, idle].into_iter().for_each(|mut snap| { 462 + let mut hits = HitFrame::new(); 463 + let widget = HotkeyCapture::new(id_widget(), rect(), PLACEHOLDER, &mut state); 464 + { 465 + let mut ctx = FrameCtx::new( 466 + theme.clone(), 467 + &mut snap, 468 + &mut focus, 469 + &table, 470 + StringTable::empty(), 471 + &mut hits, 472 + &prev, 473 + ); 474 + let _ = show_hotkey_capture(&mut ctx, widget); 475 + } 476 + prev = resolve(&prev, &hits, &snap, focus.focused()); 477 + }); 478 + 479 + assert!( 480 + state.recording, 481 + "single click on a fresh widget must start recording even though focus seats next frame", 482 + ); 483 + assert_eq!(focus.focused(), Some(id_widget())); 484 + } 485 + 486 + #[test] 487 + fn losing_focus_after_recording_started_cancels() { 488 + let mut state = HotkeyCaptureState { 489 + recording: true, 490 + chord: None, 491 + }; 492 + let mut focus = FocusManager::new(); 493 + focus.register_focusable(id_widget()); 494 + focus.end_frame(); 495 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 496 + let _ = run(&mut state, &mut focus, &mut snap); 497 + assert!(!state.recording); 498 + } 499 + }