1use std::cell::RefCell;
40use std::rc::Rc;
41use teksilo_i18n::lit;
42
43use teksilo_canvas::{Rect, Size, SizeProposal};
44use teksilo_core::accessibility::AccessNodeBuilder;
45use teksilo_core::binding::BindingLevel;
46use teksilo_core::build_context::BuildContext;
47use teksilo_core::event::{Key, Modifiers};
48use teksilo_core::shortcut::{CaptureHandle, KeyStroke};
49use teksilo_core::signal::Signal;
50use teksilo_core::widget::{EventContext, LayoutContext, Widget, WidgetPlacement};
51use teksilo_core::widget_id::WidgetId;
52
53use crate::button::Button;
54use crate::keystroke_format::format_keystroke;
55use crate::primitives::{HStack, Spacer, TextWidget, VStack};
56use teksilo_tokens::{TextRole, TextStyleRole};
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60enum SlotKind {
61 Primary,
62 Secondary,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67struct CaptureTarget {
68 id: &'static str,
69 slot: SlotKind,
70}
71
72#[derive(Debug, Clone)]
78pub struct ShortcutConflict {
79 pub displaced_id: String,
82 pub displaced_name: String,
84 pub keystroke: KeyStroke,
86}
87
88#[derive(Debug, Clone)]
90struct PendingRebind {
91 target_id: &'static str,
92 slot: SlotKind,
93 ks: KeyStroke,
94 conflict_id: String,
95 conflict_slot: Option<SlotKind>,
96 conflict_name: String,
97}
98
99pub struct ShortcutSettings {
106 capturing: Signal<Option<CaptureTarget>>,
109 active_handle: Rc<RefCell<Option<CaptureHandle>>>,
114 filter: Option<Signal<String>>,
120 confirm_conflicts: bool,
124 on_conflict: Option<Rc<dyn Fn(&ShortcutConflict)>>,
127 pending: Signal<Option<PendingRebind>>,
130 root_child_id: Option<WidgetId>,
131}
132
133impl Default for ShortcutSettings {
134 fn default() -> Self {
135 Self::new()
136 }
137}
138
139impl ShortcutSettings {
140 pub fn new() -> Self {
143 Self {
144 capturing: Signal::new(None),
145 active_handle: Rc::new(RefCell::new(None)),
146 filter: None,
147 confirm_conflicts: false,
148 on_conflict: None,
149 pending: Signal::new(None),
150 root_child_id: None,
151 }
152 }
153
154 pub fn with_filter(mut self, filter: Signal<String>) -> Self {
163 self.filter = Some(filter);
164 self
165 }
166
167 pub fn confirm_conflicts(mut self, yes: bool) -> Self {
174 self.confirm_conflicts = yes;
175 self
176 }
177
178 pub fn on_conflict(mut self, f: impl Fn(&ShortcutConflict) + 'static) -> Self {
187 self.on_conflict = Some(Rc::new(f));
188 self
189 }
190}
191
192impl std::fmt::Debug for ShortcutSettings {
193 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194 f.debug_struct("ShortcutSettings").finish()
195 }
196}
197
198impl Widget for ShortcutSettings {
199 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
200 ctx.shortcut_version().bind_to(
202 ctx.self_id(),
203 ctx.binding_registry(),
204 BindingLevel::Rebuild,
205 );
206 self.capturing
209 .bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
210 self.pending
213 .bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
214 if let Some(filter) = &self.filter {
216 filter.bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
217 }
218
219 let filter_needle = self
220 .filter
221 .as_ref()
222 .map(|f| f.get().trim().to_lowercase())
223 .unwrap_or_default();
224 let matches_filter = |data: &ShortcutRowData| -> bool {
225 if filter_needle.is_empty() {
226 return true;
227 }
228 let hay_lower = |s: &str| s.to_lowercase();
229 hay_lower(&data.name).contains(&filter_needle)
230 || hay_lower(data.id).contains(&filter_needle)
231 || data
232 .category
233 .map(|c| hay_lower(c).contains(&filter_needle))
234 .unwrap_or(false)
235 };
236
237 let mut rows: Vec<ShortcutRowData> = ctx
238 .shortcut_registry()
239 .iter_effective()
240 .map(|eff| ShortcutRowData {
241 id: eff.shortcut.id,
242 name: eff.shortcut.name.get(),
243 primary: eff.primary,
244 secondary: eff.secondary,
245 enabled: eff.enabled,
246 category: eff.shortcut.category,
247 has_override: ctx
248 .shortcut_registry()
249 .override_for(eff.shortcut.id)
250 .is_some(),
251 })
252 .filter(matches_filter)
253 .collect();
254 rows.sort_by(|a, b| a.category.cmp(&b.category).then(a.id.cmp(b.id)));
256
257 let capturing = self.capturing.get();
258 let pending = self.pending.get();
259 let mut column = VStack::new().spacing(4.0);
260
261 let mut last_category: Option<Option<&'static str>> = None;
262 for row in rows {
263 if last_category != Some(row.category) {
264 column = column.child(category_header(row.category));
265 last_category = Some(row.category);
266 }
267 let row_id = self.build_row(ctx, &row, capturing, pending.as_ref());
268 column = column.add_child(row_id);
269 }
270
271 let root = ctx.add(column);
272 self.root_child_id = Some(root);
273 vec![root]
274 }
275
276 fn layout_response(
277 &self,
278 proposal: SizeProposal,
279 ctx: &LayoutContext,
280 ) -> teksilo_core::widget::LayoutResponse {
281 self.root_child_id
282 .and_then(|id| ctx.child_size(id, proposal))
283 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
284 .into()
285 }
286
287 fn place_children(
288 &self,
289 bounds: Rect,
290 _proposal: SizeProposal,
291 children: &mut [WidgetPlacement],
292 _ctx: &LayoutContext,
293 ) {
294 for child in children.iter_mut() {
295 child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
296 child.size = Size::new(bounds.width, bounds.height);
297 }
298 }
299
300 fn children(&self) -> Vec<WidgetId> {
301 self.root_child_id.into_iter().collect()
302 }
303
304 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
305 builder.set_role(teksilo_core::accesskit::Role::Group);
306 builder.set_name(
307 teksilo_i18n::tr_widget!(a11y_shortcut_settings_name())
308 .resolve_now()
309 .as_str(),
310 );
311 }
312}
313
314#[derive(Debug)]
320struct LiveStatusText {
321 text: String,
322 role: TextRole,
323 child_id: Option<WidgetId>,
324}
325
326impl LiveStatusText {
327 fn new(text: impl Into<String>, role: TextRole) -> Self {
328 Self {
329 text: text.into(),
330 role,
331 child_id: None,
332 }
333 }
334}
335
336impl Widget for LiveStatusText {
337 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
338 let id = ctx.add(
339 TextWidget::new(lit!(&self.text))
340 .color(self.role)
341 .single_line()
342 .a11y_hidden(),
343 );
344 self.child_id = Some(id);
345 vec![id]
346 }
347
348 fn layout_response(
349 &self,
350 proposal: SizeProposal,
351 ctx: &LayoutContext,
352 ) -> teksilo_core::widget::LayoutResponse {
353 self.child_id
354 .and_then(|id| ctx.child_size(id, proposal))
355 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
356 .into()
357 }
358
359 fn place_children(
360 &self,
361 bounds: Rect,
362 _proposal: SizeProposal,
363 children: &mut [WidgetPlacement],
364 _ctx: &LayoutContext,
365 ) {
366 for child in children.iter_mut() {
367 child.origin = bounds.origin();
368 child.size = bounds.size();
369 }
370 }
371
372 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
373 builder.set_role(teksilo_core::accesskit::Role::Status);
374 builder.set_name(self.text.as_str());
375 builder.set_live(teksilo_core::accesskit::Live::Polite);
376 }
377
378 fn children(&self) -> Vec<WidgetId> {
379 self.child_id.into_iter().collect()
380 }
381}
382
383struct ShortcutRowData {
384 id: &'static str,
385 name: String,
386 primary: Option<KeyStroke>,
387 secondary: Option<KeyStroke>,
388 enabled: bool,
389 category: Option<&'static str>,
390 has_override: bool,
391}
392
393fn category_header(category: Option<&'static str>) -> impl Widget + 'static {
394 let label = category.unwrap_or("General");
395 TextWidget::new(lit!(label))
396 .style(TextStyleRole::BodyBold)
397 .color(TextRole::Primary)
398 .single_line()
399}
400
401impl ShortcutSettings {
402 fn build_row(
403 &self,
404 ctx: &mut BuildContext,
405 row: &ShortcutRowData,
406 capturing: Option<CaptureTarget>,
407 pending: Option<&PendingRebind>,
408 ) -> WidgetId {
409 let id = row.id;
410 let name_widget = TextWidget::new(lit!(&row.name))
417 .color(TextRole::Primary)
418 .single_line();
419
420 let primary_slot = self.slot_widget(id, SlotKind::Primary, row.primary, capturing, pending);
421 let secondary_slot =
422 self.slot_widget(id, SlotKind::Secondary, row.secondary, capturing, pending);
423
424 let reset_button = Button::new(lit!("Reset"))
425 .enabled(row.has_override)
426 .on_activate_fn(move |ctx: &mut EventContext| {
427 ctx.clear_shortcut_override(id);
428 });
429
430 let row_widget = HStack::new()
431 .spacing(8.0)
432 .child(name_widget)
433 .child(Spacer::new())
434 .child(primary_slot)
435 .child(secondary_slot)
436 .child(reset_button);
437 let row_id = ctx.add(row_widget);
438 if !row.enabled {
446 ctx.enabled_when(row_id, false);
447 }
448 row_id
449 }
450
451 fn slot_widget(
452 &self,
453 id: &'static str,
454 slot: SlotKind,
455 keystroke: Option<KeyStroke>,
456 capturing: Option<CaptureTarget>,
457 pending: Option<&PendingRebind>,
458 ) -> impl Widget + 'static {
459 let is_capturing_here = capturing == Some(CaptureTarget { id, slot });
460 let pending_here = pending
462 .filter(|p| p.target_id == id && p.slot == slot)
463 .cloned();
464 let keystroke_text = if is_capturing_here {
465 teksilo_i18n::tr_widget!(a11y_shortcut_settings_capture_hint()).resolve_now()
466 } else {
467 keystroke
468 .map(format_keystroke)
469 .unwrap_or_else(|| "—".to_string())
470 };
471
472 let slot_label = match slot {
473 SlotKind::Primary => "Rebind",
474 SlotKind::Secondary => "Rebind 2nd",
475 };
476
477 let confirm = self.confirm_conflicts;
478 let on_conflict = self.on_conflict.clone();
479 let pending_signal = self.pending.clone();
480 let rebind_button = {
481 let capturing_signal = self.capturing.clone();
482 let handle_cell = self.active_handle.clone();
483 let pending_for_cb = pending_signal.clone();
484 Button::new(lit!(slot_label)).on_activate_fn(move |ctx: &mut EventContext| {
485 let target = CaptureTarget { id, slot };
486 capturing_signal.set(Some(target));
487 let cap_for_cb = capturing_signal.clone();
488 let on_conflict = on_conflict.clone();
489 let pending_for_cb = pending_for_cb.clone();
490 let handle = ctx.begin_key_capture(move |ks, reg, _cap_ctx| {
491 handle_capture_event(
492 ks,
493 reg,
494 id,
495 slot,
496 confirm,
497 on_conflict.as_ref(),
498 &pending_for_cb,
499 );
500 cap_for_cb.set(None);
501 });
502 *handle_cell.borrow_mut() = Some(handle);
506 })
507 };
508
509 let row = HStack::new().spacing(4.0);
521 let row = if is_capturing_here {
522 row.child(LiveStatusText::new(keystroke_text, TextRole::Accent))
523 } else {
524 row.child(
525 TextWidget::new(lit!(&keystroke_text))
526 .color(TextRole::Primary)
527 .single_line(),
528 )
529 };
530
531 let Some(p) = pending_here else {
535 return row.child(rebind_button);
536 };
537
538 let warning = format!(
539 "{} is assigned to {}",
540 format_keystroke(p.ks),
541 p.conflict_name
542 );
543 let reassign = {
544 let pending_signal = pending_signal.clone();
545 let p = p.clone();
546 Button::new(lit!("Reassign")).on_activate_fn(move |ctx: &mut EventContext| {
547 match p.conflict_slot {
549 Some(SlotKind::Primary) => {
550 ctx.rebind_shortcut_primary(p.conflict_id.clone(), None)
551 }
552 Some(SlotKind::Secondary) => {
553 ctx.rebind_shortcut_secondary(p.conflict_id.clone(), None)
554 }
555 None => {}
556 }
557 match p.slot {
558 SlotKind::Primary => ctx.rebind_shortcut_primary(p.target_id, Some(p.ks)),
559 SlotKind::Secondary => ctx.rebind_shortcut_secondary(p.target_id, Some(p.ks)),
560 }
561 pending_signal.set(None);
562 })
563 };
564 let cancel = {
565 let pending_signal = pending_signal.clone();
566 Button::new(lit!("Cancel")).on_activate_fn(move |_ctx: &mut EventContext| {
567 pending_signal.set(None);
568 })
569 };
570 row.child(LiveStatusText::new(warning, TextRole::Accent))
571 .child(reassign)
572 .child(cancel)
573 }
574}
575
576fn handle_capture_event(
586 ks: KeyStroke,
587 reg: &mut teksilo_core::shortcut::ShortcutRegistry,
588 id: &'static str,
589 slot: SlotKind,
590 confirm: bool,
591 on_conflict: Option<&Rc<dyn Fn(&ShortcutConflict)>>,
592 pending: &Signal<Option<PendingRebind>>,
593) {
594 if ks.key == Key::Escape && ks.modifiers == Modifiers::NONE {
595 return; }
597 if matches!(ks.key, Key::Delete | Key::Backspace) && ks.modifiers == Modifiers::NONE {
598 match slot {
599 SlotKind::Primary => reg.rebind_primary(id, None),
600 SlotKind::Secondary => reg.rebind_secondary(id, None),
601 }
602 return;
603 }
604 let cid = reg.find_conflict(ks, Some(id)).map(|c| c.to_string());
608 if let Some(cid) = cid {
609 let conflict_slot = reg.effective(&cid).and_then(|eff| {
610 if eff.primary == Some(ks) {
611 Some(SlotKind::Primary)
612 } else if eff.secondary == Some(ks) {
613 Some(SlotKind::Secondary)
614 } else {
615 None
616 }
617 });
618 let conflict_name = reg
619 .iter_effective()
620 .find(|e| e.shortcut.id == cid)
621 .map(|e| e.shortcut.name.get())
622 .unwrap_or_else(|| cid.clone());
623
624 if let Some(cb) = on_conflict {
625 cb(&ShortcutConflict {
626 displaced_id: cid.clone(),
627 displaced_name: conflict_name.clone(),
628 keystroke: ks,
629 });
630 }
631
632 if confirm {
633 pending.set(Some(PendingRebind {
635 target_id: id,
636 slot,
637 ks,
638 conflict_id: cid,
639 conflict_slot,
640 conflict_name,
641 }));
642 return;
643 }
644
645 match conflict_slot {
648 Some(SlotKind::Primary) => reg.rebind_primary(cid, None),
649 Some(SlotKind::Secondary) => reg.rebind_secondary(cid, None),
650 None => {}
651 }
652 }
653 match slot {
654 SlotKind::Primary => reg.rebind_primary(id, Some(ks)),
655 SlotKind::Secondary => reg.rebind_secondary(id, Some(ks)),
656 }
657}
658
659#[cfg(test)]
660mod tests {
661 use super::*;
662 use teksilo_core::shortcut::{Shortcut, ShortcutRegistry};
663 use teksilo_core::widget_tree::WidgetTree;
664
665 fn apply_capture(reg: &mut ShortcutRegistry, ks: KeyStroke, id: &'static str, slot: SlotKind) {
668 handle_capture_event(ks, reg, id, slot, false, None, &Signal::new(None));
669 }
670
671 #[test]
672 fn shortcut_settings_builds_a_row_per_registered_shortcut() {
673 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
674 tree.shortcut_registry_mut().register(
675 Shortcut::new("app.save")
676 .name("Save")
677 .primary(KeyStroke::command(Key::S))
678 .build(),
679 );
680 tree.shortcut_registry_mut().register(
681 Shortcut::new("app.open")
682 .name("Open")
683 .primary(KeyStroke::command(Key::O))
684 .build(),
685 );
686 let settings = tree.add(ShortcutSettings::new());
687 tree.layout(SizeProposal::exact(900.0, 600.0));
688 let b = tree.bounds(settings);
689 assert!(b.width > 0.0 && b.height > 0.0);
690 }
691
692 #[test]
693 fn delete_during_capture_unbinds_primary_slot() {
694 let mut reg = teksilo_core::shortcut::ShortcutRegistry::new();
695 reg.register(
696 Shortcut::new("app.save")
697 .primary(KeyStroke::command(Key::S))
698 .build(),
699 );
700 apply_capture(
702 &mut reg,
703 KeyStroke::new(Key::Delete, Modifiers::NONE),
704 "app.save",
705 SlotKind::Primary,
706 );
707 assert_eq!(reg.effective("app.save").unwrap().primary, None);
708 }
709
710 #[test]
711 fn escape_during_capture_is_cancel_not_rebind() {
712 let mut reg = teksilo_core::shortcut::ShortcutRegistry::new();
713 reg.register(
714 Shortcut::new("app.save")
715 .primary(KeyStroke::command(Key::S))
716 .build(),
717 );
718 apply_capture(
719 &mut reg,
720 KeyStroke::new(Key::Escape, Modifiers::NONE),
721 "app.save",
722 SlotKind::Primary,
723 );
724 assert_eq!(
726 reg.effective("app.save").unwrap().primary,
727 Some(KeyStroke::command(Key::S))
728 );
729 }
730
731 #[test]
732 fn rebind_auto_unbinds_conflicting_shortcut() {
733 let mut reg = teksilo_core::shortcut::ShortcutRegistry::new();
734 reg.register(
735 Shortcut::new("app.save")
736 .primary(KeyStroke::command(Key::S))
737 .build(),
738 );
739 reg.register(
740 Shortcut::new("app.sync")
741 .primary(KeyStroke::command(Key::K))
742 .build(),
743 );
744 apply_capture(
746 &mut reg,
747 KeyStroke::command(Key::S),
748 "app.sync",
749 SlotKind::Primary,
750 );
751 assert_eq!(
752 reg.effective("app.sync").unwrap().primary,
753 Some(KeyStroke::command(Key::S)),
754 "sync takes the new chord"
755 );
756 assert_eq!(
757 reg.effective("app.save").unwrap().primary,
758 None,
759 "save is auto-unbound on conflict"
760 );
761 }
762
763 #[test]
764 fn rebind_auto_unbinds_conflict_on_secondary_slot() {
765 let mut reg = teksilo_core::shortcut::ShortcutRegistry::new();
766 reg.register(
767 Shortcut::new("edit.undo")
768 .primary(KeyStroke::command(Key::Z))
769 .secondary(KeyStroke::alt(Key::Backspace))
770 .build(),
771 );
772 reg.register(Shortcut::new("edit.redo").build());
773 apply_capture(
776 &mut reg,
777 KeyStroke::alt(Key::Backspace),
778 "edit.redo",
779 SlotKind::Primary,
780 );
781 assert_eq!(
782 reg.effective("edit.redo").unwrap().primary,
783 Some(KeyStroke::alt(Key::Backspace))
784 );
785 let undo = reg.effective("edit.undo").unwrap();
786 assert_eq!(undo.primary, Some(KeyStroke::command(Key::Z)));
787 assert_eq!(
788 undo.secondary, None,
789 "the conflicting secondary slot is the one auto-unbound"
790 );
791 }
792
793 #[test]
794 fn confirm_mode_defers_the_rebind_and_parks_a_pending_conflict() {
795 let mut reg = ShortcutRegistry::new();
796 reg.register(
797 Shortcut::new("app.save")
798 .name("Save")
799 .primary(KeyStroke::command(Key::S))
800 .build(),
801 );
802 reg.register(
803 Shortcut::new("app.sync")
804 .primary(KeyStroke::command(Key::K))
805 .build(),
806 );
807 let pending: Signal<Option<PendingRebind>> = Signal::new(None);
808 handle_capture_event(
811 KeyStroke::command(Key::S),
812 &mut reg,
813 "app.sync",
814 SlotKind::Primary,
815 true,
816 None,
817 &pending,
818 );
819 assert_eq!(
820 reg.effective("app.save").unwrap().primary,
821 Some(KeyStroke::command(Key::S)),
822 "save keeps its binding until the user confirms"
823 );
824 assert_eq!(
825 reg.effective("app.sync").unwrap().primary,
826 Some(KeyStroke::command(Key::K)),
827 "sync is unchanged until the user confirms"
828 );
829 let p = pending.get().expect("a pending rebind is parked");
830 assert_eq!(p.target_id, "app.sync");
831 assert_eq!(p.conflict_id, "app.save");
832 assert_eq!(p.conflict_slot, Some(SlotKind::Primary));
833 assert_eq!(p.conflict_name, "Save");
834 }
835
836 #[test]
837 fn on_conflict_callback_fires_with_displaced_shortcut() {
838 let mut reg = ShortcutRegistry::new();
839 reg.register(
840 Shortcut::new("app.save")
841 .name("Save")
842 .primary(KeyStroke::command(Key::S))
843 .build(),
844 );
845 reg.register(
846 Shortcut::new("app.sync")
847 .primary(KeyStroke::command(Key::K))
848 .build(),
849 );
850 let seen: Rc<RefCell<Option<ShortcutConflict>>> = Rc::new(RefCell::new(None));
851 let cb_seen = seen.clone();
852 let cb: Rc<dyn Fn(&ShortcutConflict)> =
853 Rc::new(move |c: &ShortcutConflict| *cb_seen.borrow_mut() = Some(c.clone()));
854 let pending: Signal<Option<PendingRebind>> = Signal::new(None);
855 handle_capture_event(
857 KeyStroke::command(Key::S),
858 &mut reg,
859 "app.sync",
860 SlotKind::Primary,
861 false,
862 Some(&cb),
863 &pending,
864 );
865 let c = seen.borrow().clone().expect("callback fired");
866 assert_eq!(c.displaced_id, "app.save");
867 assert_eq!(c.displaced_name, "Save");
868 assert_eq!(c.keystroke, KeyStroke::command(Key::S));
869 assert_eq!(reg.effective("app.save").unwrap().primary, None);
871 assert_eq!(
872 reg.effective("app.sync").unwrap().primary,
873 Some(KeyStroke::command(Key::S))
874 );
875 }
876
877 #[test]
878 fn filter_narrows_visible_rows_by_name_or_category() {
879 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
880 tree.shortcut_registry_mut().register(
881 Shortcut::new("app.save")
882 .name("Save")
883 .category("File")
884 .primary(KeyStroke::command(Key::S))
885 .build(),
886 );
887 tree.shortcut_registry_mut().register(
888 Shortcut::new("edit.bold")
889 .name("Bold")
890 .category("Format")
891 .primary(KeyStroke::command(Key::B))
892 .build(),
893 );
894 tree.shortcut_registry_mut().register(
895 Shortcut::new("edit.italic")
896 .name("Italic")
897 .category("Format")
898 .primary(KeyStroke::command(Key::I))
899 .build(),
900 );
901
902 let filter = Signal::new(String::from("format"));
903 let settings = tree.add(ShortcutSettings::new().with_filter(filter.clone()));
904 tree.layout(SizeProposal::exact(900.0, 600.0));
905
906 let before = tree.bounds(settings);
911 assert!(before.height > 0.0);
912
913 filter.set(String::new());
915 tree.layout(SizeProposal::exact(900.0, 600.0));
916 let after = tree.bounds(settings);
917 assert!(
918 after.height >= before.height,
919 "clearing filter must not shrink the widget (got {} → {})",
920 before.height,
921 after.height
922 );
923 }
924
925 #[test]
926 fn rebind_through_capture_mode_updates_registry() {
927 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
928 tree.shortcut_registry_mut().register(
929 Shortcut::new("app.save")
930 .name("Save")
931 .primary(KeyStroke::command(Key::S))
932 .build(),
933 );
934 let _settings = tree.add(ShortcutSettings::new());
935 tree.layout(SizeProposal::exact(900.0, 600.0));
936
937 let _h = tree.begin_key_capture(|ks, reg, _ctx| {
938 reg.rebind_primary("app.save", Some(ks));
939 });
940 let h = _h;
943 tree.press_key(Key::B, Modifiers::COMMAND | Modifiers::SHIFT);
944 drop(h);
945
946 assert_eq!(
947 tree.shortcut_registry()
948 .effective("app.save")
949 .unwrap()
950 .primary,
951 Some(KeyStroke::command_shift(Key::B))
952 );
953 }
954}