Another project
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}