Another project
0

Configure Feed

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

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