use super::*;
use crate::test_utils::TestBackend;
use crate::EventBuilder;
#[derive(Debug, PartialEq, Eq)]
struct SnapshotShape {
cmd_count: usize,
last_text_idx: Option<usize>,
focus_count: usize,
interaction_count: usize,
scroll_count: usize,
group_count: usize,
group_stack_len: usize,
overlay_depth: usize,
modal_active: bool,
modal_focus_start: usize,
modal_focus_count: usize,
hook_cursor: usize,
hook_states_len: usize,
dark_mode: bool,
deferred_draws_len: usize,
notification_queue_len: usize,
pending_tooltips_len: usize,
text_color_stack_len: usize,
}
fn snapshot_shape(ctx: &Context) -> SnapshotShape {
SnapshotShape {
cmd_count: ctx.commands.len(),
last_text_idx: ctx.rollback.last_text_idx,
focus_count: ctx.rollback.focus_count,
interaction_count: ctx.rollback.interaction_count,
scroll_count: ctx.rollback.scroll_count,
group_count: ctx.rollback.group_count,
group_stack_len: ctx.rollback.group_stack.len(),
overlay_depth: ctx.rollback.overlay_depth,
modal_active: ctx.rollback.modal_active,
modal_focus_start: ctx.rollback.modal_focus_start,
modal_focus_count: ctx.rollback.modal_focus_count,
hook_cursor: ctx.rollback.hook_cursor,
hook_states_len: ctx.hook_states.len(),
dark_mode: ctx.rollback.dark_mode,
deferred_draws_len: ctx.deferred_draws.len(),
notification_queue_len: ctx.rollback.notification_queue.len(),
pending_tooltips_len: ctx.pending_tooltips.len(),
text_color_stack_len: ctx.rollback.text_color_stack.len(),
}
}
#[test]
fn use_memo_type_mismatch_includes_index_and_expected_type() {
let mut state = FrameState::default();
let mut ctx = Context::new(Vec::new(), 20, 5, &mut state, Theme::dark());
ctx.hook_states.push(Box::new(42u32));
ctx.rollback.hook_cursor = 0;
let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let deps = 1u8;
let _ = ctx.use_memo(&deps, |_| 7u8);
}))
.expect_err("use_memo should panic on type mismatch");
let message = panic_message(panic);
assert!(
message.contains("Hook type mismatch at index 0"),
"panic message should include hook index, got: {message}"
);
assert!(
message.contains(std::any::type_name::<MemoSlot<u8>>()),
"panic message should include expected MemoSlot type, got: {message}"
);
assert!(
message.contains("Hooks must be called in the same order every frame."),
"panic message should explain hook ordering requirement, got: {message}"
);
}
#[test]
fn use_memo_handle_releases_borrow() {
let mut tb = TestBackend::new(20, 3);
tb.render(|ui| {
let m = ui.use_memo(&21i32, |d| d * 2);
ui.text("memo:");
let v = m.copied(ui);
ui.text(format!("{v}"));
});
tb.assert_contains("memo:");
tb.assert_contains("42");
}
#[test]
fn use_memo_recomputes_only_on_dep_change() {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
let calls = Arc::new(AtomicUsize::new(0));
let mut tb = TestBackend::new(20, 3);
let c1 = calls.clone();
tb.render(move |ui| {
let dep = ui.use_state(|| 2i32);
let d = *dep.get(ui);
let m = ui.use_memo(&d, |x| {
c1.fetch_add(1, Ordering::SeqCst);
x * 10
});
ui.text(format!("{}", m.copied(ui)));
});
assert_eq!(calls.load(Ordering::SeqCst), 1, "first frame computes once");
let c2 = calls.clone();
tb.render(move |ui| {
let dep = ui.use_state(|| 2i32);
let d = *dep.get(ui);
let m = ui.use_memo(&d, |x| {
c2.fetch_add(1, Ordering::SeqCst);
x * 10
});
ui.text(format!("{}", m.copied(ui)));
});
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"stable deps must not recompute"
);
let c3 = calls.clone();
tb.render(move |ui| {
let dep = ui.use_state(|| 2i32);
*dep.get_mut(ui) = 5; let d = *dep.get(ui);
let m = ui.use_memo(&d, |x| {
c3.fetch_add(1, Ordering::SeqCst);
x * 10
});
ui.text(format!("{}", m.copied(ui)));
});
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"changed deps must recompute"
);
tb.assert_contains("50");
}
#[test]
fn use_memo_copied_matches_get() {
let mut tb = TestBackend::new(20, 3);
tb.render(|ui| {
let m = ui.use_memo(&7i32, |d| d * 3);
assert_eq!(m.copied(ui), *m.get(ui));
ui.text(format!("{}", m.copied(ui)));
});
tb.assert_contains("21");
}
#[test]
fn use_memo_ref_still_compiles() {
let mut tb = TestBackend::new(20, 3);
tb.render(|ui| {
#[allow(deprecated)]
let v = *ui.use_memo_ref(&8i32, |d| d * 2);
assert_eq!(v, 16);
ui.text(format!("{v}"));
});
tb.assert_contains("16");
}
#[test]
fn interaction_allocator_keeps_dense_slots_and_explicit_markers() {
let mut state = FrameState::default();
let mut ctx = Context::new(Vec::new(), 20, 5, &mut state, Theme::dark());
ctx.prev_hit_map = vec![
crate::rect::Rect::new(0, 0, 1, 1),
crate::rect::Rect::new(2, 0, 1, 1),
crate::rect::Rect::new(4, 0, 1, 1),
];
ctx.mouse_pos = Some((4, 0));
ctx.click_pos = Some((4, 0));
let marked = ctx.next_interaction_id();
let skipped = ctx.reserve_interaction_slot();
let response = ctx.interaction();
assert_eq!(marked, 0);
assert_eq!(skipped, 1);
assert_eq!(ctx.rollback.interaction_count, 3);
assert!(response.clicked);
assert!(response.hovered);
assert_eq!(response.rect, crate::rect::Rect::new(4, 0, 1, 1));
assert!(matches!(
ctx.commands.as_slice(),
[Command::InteractionMarker(0), Command::InteractionMarker(2)]
));
}
#[test]
fn consume_activation_keys_claims_enter_and_space_only_when_focused() {
let events = vec![Event::key(KeyCode::Enter), Event::key_char(' ')];
let mut state = FrameState::default();
let mut ctx = Context::new(events, 20, 5, &mut state, Theme::dark());
assert!(ctx.consume_activation_keys(true));
assert_eq!(ctx.consumed, vec![true, true]);
assert!(!ctx.consume_activation_keys(false));
}
#[test]
fn left_clicks_for_interaction_filters_bounds_and_consumed_events() {
let events = vec![
Event::mouse_click(1, 1),
Event::mouse_click(8, 8),
Event::mouse_click(2, 1),
];
let mut state = FrameState::default();
let mut ctx = Context::new(events, 20, 5, &mut state, Theme::dark());
ctx.prev_hit_map = vec![crate::rect::Rect::new(0, 0, 4, 2)];
ctx.consumed[2] = true;
let (rect, clicks) = ctx
.left_clicks_for_interaction(0)
.expect("interaction rect should exist");
assert_eq!(rect, crate::rect::Rect::new(0, 0, 4, 2));
assert_eq!(clicks.len(), 1);
assert_eq!(clicks[0].0, 0);
assert_eq!(clicks[0].1.x, 1);
assert_eq!(clicks[0].1.y, 1);
}
#[test]
fn error_boundary_restores_snapshot_state_after_panic() {
let mut state = FrameState::default();
let mut ctx = Context::new(Vec::new(), 20, 5, &mut state, Theme::dark());
ctx.rollback.group_stack.push("baseline".into());
ctx.rollback
.notification_queue
.push(("keep".into(), ToastLevel::Info, 1));
ctx.pending_tooltips.push(PendingTooltip {
anchor_rect: crate::rect::Rect::new(0, 0, 1, 1),
lines: vec!["keep".into()],
});
ctx.rollback.text_color_stack.push(Some(Color::Blue));
ctx.deferred_draws.push(None);
ctx.hook_states.push(Box::new(1usize));
ctx.rollback.hook_cursor = 1;
ctx.rollback.focus_count = 2;
ctx.rollback.interaction_count = 3;
ctx.rollback.scroll_count = 4;
ctx.rollback.group_count = 1;
ctx.rollback.overlay_depth = 1;
ctx.rollback.modal_active = true;
ctx.rollback.modal_focus_start = 1;
ctx.rollback.modal_focus_count = 2;
let before = snapshot_shape(&ctx);
ctx.error_boundary_with(
|ui| {
ui.text("temp");
ui.rollback.last_text_idx = Some(99);
ui.rollback.focus_count = 10;
ui.rollback.interaction_count = 11;
ui.rollback.scroll_count = 12;
ui.rollback.group_count = 13;
ui.rollback.group_stack.push("transient".into());
ui.rollback.overlay_depth = 14;
ui.rollback.modal_active = false;
ui.rollback.modal_focus_start = 15;
ui.rollback.modal_focus_count = 16;
ui.rollback.hook_cursor = 17;
ui.hook_states.push(Box::new(2usize));
ui.rollback.dark_mode = false;
ui.deferred_draws.push(None);
ui.rollback
.notification_queue
.push(("drop".into(), ToastLevel::Error, 2));
ui.pending_tooltips.push(PendingTooltip {
anchor_rect: crate::rect::Rect::new(1, 1, 1, 1),
lines: vec!["drop".into()],
});
ui.rollback.text_color_stack.push(Some(Color::Red));
panic!("boom");
},
|_ui, _msg| {},
);
assert_eq!(snapshot_shape(&ctx), before);
}
#[test]
fn scoped_context_state_unwinds_after_group_and_modal() {
let mut state = FrameState::default();
let mut ctx = Context::new(Vec::new(), 40, 10, &mut state, Theme::dark());
let _ = ctx.group("card").border(Border::Rounded).col(|ui| {
ui.text("inside");
});
assert_eq!(ctx.rollback.group_count, 0);
assert!(ctx.rollback.group_stack.is_empty());
assert!(ctx.rollback.text_color_stack.is_empty());
let _ = ctx.modal(|ui| {
ui.text("modal");
});
assert_eq!(ctx.rollback.overlay_depth, 0);
assert!(ctx.rollback.text_color_stack.is_empty());
}
#[test]
fn emit_pending_tooltips_drains_queue_and_settles_overlay_depth() {
let mut state = FrameState::default();
let mut ctx = Context::new(Vec::new(), 40, 10, &mut state, Theme::dark());
ctx.prev_hit_map = vec![crate::rect::Rect::new(2, 1, 6, 1)];
ctx.mouse_pos = Some((3, 1));
let _ = ctx.interaction();
ctx.tooltip("Helpful tip");
assert_eq!(ctx.pending_tooltips.len(), 1);
ctx.emit_pending_tooltips();
assert!(ctx.pending_tooltips.is_empty());
assert_eq!(ctx.rollback.overlay_depth, 0);
}
#[test]
fn light_dark_uses_current_theme_mode() {
let mut dark_backend = TestBackend::new(10, 2);
dark_backend.render(|ui| {
let color = ui.light_dark(Color::Red, Color::Blue);
ui.text("X").fg(color);
});
assert_eq!(dark_backend.buffer().get(0, 0).style.fg, Some(Color::Blue));
let mut light_backend = TestBackend::new(10, 2);
light_backend.render(|ui| {
ui.set_theme(Theme::light());
let color = ui.light_dark(Color::Red, Color::Blue);
ui.text("X").fg(color);
});
assert_eq!(light_backend.buffer().get(0, 0).style.fg, Some(Color::Red));
}
#[test]
fn modal_focus_trap_tabs_only_within_modal_scope() {
let events = EventBuilder::new().key_code(KeyCode::Tab).build();
let mut state = FrameState::default();
state.focus.focus_index = 3;
state.focus.prev_focus_count = 5;
state.focus.prev_modal_active = true;
state.focus.prev_modal_focus_start = 3;
state.focus.prev_modal_focus_count = 2;
let mut ctx = Context::new(events, 40, 10, &mut state, Theme::dark());
ctx.process_focus_keys();
assert_eq!(ctx.focus_index, 4);
let outside = ctx.register_focusable();
let mut first_modal = false;
let mut second_modal = false;
let _ = ctx.modal(|ui| {
first_modal = ui.register_focusable();
second_modal = ui.register_focusable();
});
assert!(!outside, "focus should not be granted outside modal");
assert!(
!first_modal,
"first modal focusable should be unfocused at index 4"
);
assert!(
second_modal,
"second modal focusable should be focused at index 4"
);
}
#[test]
fn modal_focus_trap_shift_tab_wraps_within_modal_scope() {
let events = EventBuilder::new().key_code(KeyCode::BackTab).build();
let mut state = FrameState::default();
state.focus.focus_index = 3;
state.focus.prev_focus_count = 5;
state.focus.prev_modal_active = true;
state.focus.prev_modal_focus_start = 3;
state.focus.prev_modal_focus_count = 2;
let mut ctx = Context::new(events, 40, 10, &mut state, Theme::dark());
ctx.process_focus_keys();
assert_eq!(ctx.focus_index, 4);
let mut first_modal = false;
let mut second_modal = false;
let _ = ctx.modal(|ui| {
first_modal = ui.register_focusable();
second_modal = ui.register_focusable();
});
assert!(!first_modal);
assert!(second_modal);
}
#[test]
fn screen_helper_renders_only_current_screen() {
let mut backend = TestBackend::new(24, 3);
let mut screens = ScreenState::new("settings");
backend.render(|ui| {
ui.screen("home", &mut screens, |ui| {
ui.text("Home Screen");
});
ui.screen("settings", &mut screens, |ui| {
ui.text("Settings Screen");
});
});
let rendered = backend.to_string();
assert!(rendered.contains("Settings Screen"));
assert!(!rendered.contains("Home Screen"));
}
#[test]
fn mouse_drag_returns_drag_position() {
let events = vec![Event::mouse_drag(5, 3)];
let mut state = FrameState::default();
let ctx = Context::new(events, 20, 10, &mut state, Theme::dark());
assert_eq!(ctx.mouse_drag(), Some((5, 3)));
assert_eq!(ctx.mouse_up(), None);
assert_eq!(ctx.mouse_down(), None);
}
#[test]
fn mouse_up_returns_release_position() {
let events = vec![Event::mouse_up(7, 2)];
let mut state = FrameState::default();
let ctx = Context::new(events, 20, 10, &mut state, Theme::dark());
assert_eq!(ctx.mouse_up(), Some((7, 2)));
assert_eq!(ctx.mouse_drag(), None);
}
#[test]
fn mouse_down_button_detects_right_click() {
let events = vec![Event::Mouse(crate::event::MouseEvent {
kind: MouseKind::Down(MouseButton::Right),
x: 3,
y: 4,
modifiers: KeyModifiers::NONE,
pixel_x: None,
pixel_y: None,
})];
let mut state = FrameState::default();
let ctx = Context::new(events, 20, 10, &mut state, Theme::dark());
assert_eq!(ctx.mouse_down_button(MouseButton::Right), Some((3, 4)));
assert_eq!(ctx.mouse_down_button(MouseButton::Left), None);
assert_eq!(ctx.mouse_down(), None); }
#[test]
fn mouse_drag_respects_consumed_flag() {
let events = vec![Event::mouse_drag(5, 3)];
let mut state = FrameState::default();
let mut ctx = Context::new(events, 20, 10, &mut state, Theme::dark());
ctx.consumed[0] = true;
assert_eq!(ctx.mouse_drag(), None);
}
#[test]
fn events_filters_consumed() {
let events = vec![
Event::key_char('a'),
Event::key_char('b'),
Event::key_char('c'),
];
let mut state = FrameState::default();
let mut ctx = Context::new(events, 20, 10, &mut state, Theme::dark());
ctx.consumed[1] = true;
let visible: Vec<_> = ctx.events().collect();
assert_eq!(visible.len(), 2);
assert_eq!(visible[0], &Event::key_char('a'));
assert_eq!(visible[1], &Event::key_char('c'));
}
#[test]
fn events_blocked_by_modal_guard() {
let events = vec![Event::key_char('x')];
let mut state = FrameState::default();
let mut ctx = Context::new(events, 20, 10, &mut state, Theme::dark());
ctx.rollback.modal_active = true;
ctx.rollback.overlay_depth = 0;
assert_eq!(ctx.events().count(), 0);
assert_eq!(ctx.raw_events().count(), 1);
}
#[test]
fn draw_interactive_emits_interaction_marker_and_raw_draw() {
let mut state = FrameState::default();
let mut ctx = Context::new(Vec::new(), 40, 20, &mut state, Theme::dark());
ctx.prev_hit_map = vec![crate::rect::Rect::new(0, 0, 40, 20)];
ctx.click_pos = Some((5, 5));
let resp = ctx
.container()
.w(40)
.h(20)
.draw_interactive(|_buf, _rect| {});
assert!(resp.clicked);
let has_marker = ctx
.commands
.iter()
.any(|c| matches!(c, Command::InteractionMarker(0)));
assert!(has_marker, "draw_interactive must emit InteractionMarker");
let has_raw = ctx
.commands
.iter()
.any(|c| matches!(c, Command::RawDraw { .. }));
assert!(has_raw, "draw_interactive must emit RawDraw command");
}
#[test]
fn draw_does_not_emit_interaction_marker() {
let mut state = FrameState::default();
let mut ctx = Context::new(Vec::new(), 40, 20, &mut state, Theme::dark());
ctx.container().w(40).h(20).draw(|_buf, _rect| {});
let has_marker = ctx
.commands
.iter()
.any(|c| matches!(c, Command::InteractionMarker(_)));
assert!(
!has_marker,
"draw() should NOT emit InteractionMarker (backward compat)"
);
}
#[test]
fn grid_with_fixed_columns_emit_constraints() {
use crate::widgets::GridColumn;
let mut state = FrameState::default();
let mut ctx = Context::new(Vec::new(), 80, 24, &mut state, Theme::dark());
let _ = ctx.grid_with(
&[GridColumn::Fixed(10), GridColumn::Grow(2), GridColumn::Auto],
|ui| {
ui.text("A");
ui.text("B");
ui.text("C");
},
);
let fixed_container = ctx.commands.iter().find(|c| {
matches!(c, Command::BeginContainer(args)
if args.constraints.min_width() == Some(10)
&& args.constraints.max_width() == Some(10)
&& args.grow == 0)
});
assert!(
fixed_container.is_some(),
"Fixed(10) column should produce min_w=max_w=10, grow=0"
);
let grow_container = ctx.commands.iter().find(|c| {
matches!(c, Command::BeginContainer(args)
if args.grow == 2
&& args.constraints.min_width().is_none()
&& args.constraints.max_width().is_none())
});
assert!(
grow_container.is_some(),
"Grow(2) column should produce grow=2, no width constraints"
);
}
#[test]
fn scrollable_preserves_group_name_for_hover_registration() {
use crate::test_utils::TestBackend;
use crate::widgets::ScrollState;
let scroll = ScrollState::new();
TestBackend::new(40, 10).render(|ui| {
let resp = ui
.group("card")
.scroll_offset(scroll.offset as u32)
.col(|ui| {
ui.text("hover text");
});
let _ = resp;
let cmd = ui.commands.iter().find(|c| {
matches!(c, crate::layout::Command::BeginScrollable(a) if a.group_name.as_deref() == Some("card"))
});
assert!(
cmd.is_some(),
"BeginScrollable must carry group_name=\"card\"; #141 regression"
);
});
}
#[test]
fn scrollable_propagates_bg_color_align_justify_gap() {
use crate::style::{Align, Color, Justify};
use crate::test_utils::TestBackend;
use crate::widgets::ScrollState;
let mut scroll = ScrollState::new();
TestBackend::new(40, 10).render(|ui| {
let _ = ui
.scrollable(&mut scroll)
.bg(Color::Indexed(236))
.gap(2)
.align(Align::Center)
.justify(Justify::Center)
.col(|ui| {
ui.text("line");
});
let cmd = ui.commands.iter().find(|c| {
matches!(
c,
crate::layout::Command::BeginScrollable(a)
if a.bg_color == Some(Color::Indexed(236))
&& a.gap == 2
&& a.align == Align::Center
&& a.justify == Justify::Center
)
});
assert!(
cmd.is_some(),
"BeginScrollable must carry bg_color/gap/align/justify; #142 regression"
);
});
}
#[test]
fn group_uses_arc_str_and_single_allocation_path() {
use crate::test_utils::TestBackend;
TestBackend::new(20, 5).render(|ui| {
let _ = ui.group("ring").col(|ui| {
assert_eq!(
ui.rollback.group_stack.last().map(|a| a.as_ref()),
Some("ring")
);
ui.text("inside");
});
assert!(ui.rollback.group_stack.is_empty());
});
}
#[test]
fn is_group_hovered_uses_o1_hashset_lookup() {
let mut state = FrameState::default();
let mut ctx = Context::new(Vec::new(), 20, 5, &mut state, Theme::dark());
ctx.hovered_groups
.insert(std::sync::Arc::<str>::from("widget"));
assert!(!ctx.is_group_hovered("widget"));
ctx.mouse_pos = Some((1, 1));
assert!(ctx.is_group_hovered("widget"));
assert!(!ctx.is_group_hovered("other"));
}
#[test]
fn screen_hook_map_avoids_repeat_allocation_on_cache_hit() {
use crate::test_utils::TestBackend;
use crate::widgets::ScreenState;
let mut screens = ScreenState::new("a");
let mut backend = TestBackend::new(20, 5);
backend.render(|ui| {
ui.screen("a", &mut screens, |ui| {
let _ = ui.use_state(|| 0i32);
});
assert!(ui.screen_hook_map.contains_key("a"));
});
backend.render(|ui| {
ui.screen("a", &mut screens, |ui| {
let _ = ui.use_state(|| 0i32);
});
assert_eq!(ui.screen_hook_map.len(), 1);
});
}
#[test]
fn render_notifications_preserves_queue_after_render() {
use crate::test_utils::TestBackend;
let mut backend = TestBackend::new(40, 10);
backend.render(|ui| {
ui.notify("saved", crate::widgets::ToastLevel::Success);
assert_eq!(ui.rollback.notification_queue.len(), 1);
ui.render_notifications();
assert_eq!(ui.rollback.notification_queue.len(), 1);
});
}
#[test]
fn key_chord_matches_across_frames() {
use crate::test_utils::TestBackend;
use std::cell::Cell;
let mut tb = TestBackend::new(40, 4);
let frame1 = Cell::new(false);
let frame2 = Cell::new(false);
tb.sequence()
.key(KeyCode::Char('g'), |ui| {
frame1.set(ui.key_chord("gg"));
ui.text("hi");
})
.key(KeyCode::Char('g'), |ui| {
frame2.set(ui.key_chord("gg"));
ui.text("hi");
})
.run();
assert!(!frame1.get(), "first `g` must not complete the chord");
assert!(
frame2.get(),
"second `g` on the next frame completes the chord"
);
}
#[test]
fn key_chord_resets_on_mismatch() {
use crate::test_utils::TestBackend;
use std::cell::Cell;
fn bump(fired: &Cell<u32>, ui: &mut Context) {
if ui.key_chord("gg") {
fired.set(fired.get() + 1);
}
ui.text("hi");
}
let mut tb = TestBackend::new(40, 4);
let fired = Cell::new(0u32);
tb.sequence()
.key(KeyCode::Char('g'), |ui| bump(&fired, ui))
.key(KeyCode::Char('x'), |ui| bump(&fired, ui)) .key(KeyCode::Char('g'), |ui| bump(&fired, ui))
.key(KeyCode::Char('g'), |ui| bump(&fired, ui)) .run();
assert_eq!(
fired.get(),
1,
"chord fires once, only on the trailing `gg`"
);
}
#[test]
fn key_chord_overlap_rearm() {
use crate::test_utils::TestBackend;
use std::cell::Cell;
fn bump(fired: &Cell<u32>, ui: &mut Context) {
if ui.key_chord("gg") {
fired.set(fired.get() + 1);
}
ui.text("hi");
}
let mut tb = TestBackend::new(40, 4);
let fired = Cell::new(0u32);
tb.sequence()
.key(KeyCode::Char('g'), |ui| bump(&fired, ui))
.key(KeyCode::Char('g'), |ui| bump(&fired, ui)) .run();
assert_eq!(fired.get(), 1);
}
#[test]
fn key_chord_timeout_expires() {
let mut state = FrameState::default();
state.diagnostics.tick = 0;
let mut ctx = Context::new(vec![Event::key_char('g')], 40, 4, &mut state, Theme::dark());
assert!(!ctx.key_chord("gg"));
state.chord_states = std::mem::take(&mut ctx.chord);
assert_eq!(state.chord_states.pending, "g");
state.diagnostics.tick = crate::DEFAULT_CHORD_TIMEOUT_TICKS + 5;
let mut ctx = Context::new(Vec::new(), 40, 4, &mut state, Theme::dark());
assert!(!ctx.key_chord("gg"));
state.chord_states = std::mem::take(&mut ctx.chord);
assert_eq!(
state.chord_states.pending, "",
"stale prefix must be cleared after timeout"
);
let mut ctx = Context::new(vec![Event::key_char('g')], 40, 4, &mut state, Theme::dark());
assert!(!ctx.key_chord("gg"), "post-timeout `g` must not complete");
}
#[test]
fn key_chord_consumes_final_key() {
use crate::test_utils::TestBackend;
use std::cell::Cell;
let mut tb = TestBackend::new(40, 4);
let completed = Cell::new(false);
let leftover = Cell::new(true);
tb.sequence()
.key(KeyCode::Char('g'), |ui| {
ui.key_chord("gg");
ui.text("hi");
})
.key(KeyCode::Char('g'), |ui| {
completed.set(ui.key_chord("gg"));
leftover.set(ui.key('g'));
ui.text("hi");
})
.run();
assert!(completed.get());
assert!(!leftover.get(), "completing key must be consumed");
}
#[test]
fn key_chord_leader_notation() {
use crate::test_utils::TestBackend;
use std::cell::Cell;
let mut tb = TestBackend::new(40, 4);
let fired = Cell::new(false);
tb.sequence()
.key(KeyCode::Char(' '), |ui| {
ui.key_chord("<space>ff");
ui.text("hi");
})
.key(KeyCode::Char('f'), |ui| {
ui.key_chord("<space>ff");
ui.text("hi");
})
.key(KeyCode::Char('f'), |ui| {
fired.set(ui.key_chord("<space>ff"));
ui.text("hi");
})
.run();
assert!(fired.get(), "`<space>ff` matches space then f f");
let mut tb = TestBackend::new(40, 4);
let fired_literal = Cell::new(false);
tb.sequence()
.key(KeyCode::Char(' '), |ui| {
ui.key_chord(" ff");
ui.text("hi");
})
.key(KeyCode::Char('f'), |ui| {
ui.key_chord(" ff");
ui.text("hi");
})
.key(KeyCode::Char('f'), |ui| {
fired_literal.set(ui.key_chord(" ff"));
ui.text("hi");
})
.run();
assert!(fired_literal.get(), "literal `\" ff\"` matches identically");
}
#[test]
fn key_chord_leader_alias_token() {
use crate::test_utils::TestBackend;
use std::cell::Cell;
let mut tb = TestBackend::new(40, 4);
let fired = Cell::new(false);
tb.sequence()
.key(KeyCode::Char(' '), |ui| {
ui.key_chord("<leader>w");
ui.text("hi");
})
.key(KeyCode::Char('w'), |ui| {
fired.set(ui.key_chord("<leader>w"));
ui.text("hi");
})
.run();
assert!(fired.get(), "`<leader>w` maps the leader token to space");
}
#[test]
fn key_chord_empty_returns_false() {
let mut state = FrameState::default();
let mut ctx = Context::new(vec![Event::key_char('g')], 40, 4, &mut state, Theme::dark());
assert!(!ctx.key_chord(""), "empty sequence is always false");
}
#[test]
fn key_chord_modal_guard() {
let mut state = FrameState::default();
state.focus.prev_modal_active = true;
let events = vec![Event::key_char('g'), Event::key_char('g')];
let mut ctx = Context::new(events, 40, 4, &mut state, Theme::dark());
assert!(
!ctx.key_chord("gg"),
"modal guard suppresses chords (overlay_depth == 0)"
);
}
#[test]
#[allow(deprecated)] fn key_seq_deprecated_alias_matches_across_frames() {
use crate::test_utils::TestBackend;
use std::cell::Cell;
let mut tb = TestBackend::new(40, 4);
let frame2 = Cell::new(false);
tb.sequence()
.key(KeyCode::Char('g'), |ui| {
ui.key_seq("gg");
ui.text("hi");
})
.key(KeyCode::Char('g'), |ui| {
frame2.set(ui.key_seq("gg"));
ui.text("hi");
})
.run();
assert!(
frame2.get(),
"deprecated `key_seq` now matches across frames via `key_chord`"
);
}
fn panic_message(panic: Box<dyn std::any::Any + Send>) -> String {
if let Some(s) = panic.downcast_ref::<String>() {
s.clone()
} else if let Some(s) = panic.downcast_ref::<&str>() {
(*s).to_string()
} else {
"<non-string panic payload>".to_string()
}
}