use crate::event::{Point, Rect};
use crate::types::WidgetId;
#[derive(Debug, Clone, PartialEq)]
pub struct ModalEntry {
pub id: WidgetId,
pub bounds: Rect,
pub focusable: bool,
painted_this_frame: bool,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ModalStack {
entries: Vec<ModalEntry>,
}
impl ModalStack {
pub fn new() -> Self {
Self::default()
}
pub fn push(&mut self, id: WidgetId, bounds: Rect) {
self.push_focusable(id, bounds, true);
}
pub fn push_focusable(&mut self, id: WidgetId, bounds: Rect, focusable: bool) {
self.entries.retain(|e| e.id != id);
self.entries.push(ModalEntry {
id,
bounds,
focusable,
painted_this_frame: false,
});
}
pub fn push_float(&mut self, float: &crate::Float, bounds: Rect) {
self.push_focusable(float.id.clone(), bounds, float.focusable);
}
pub fn pop(&mut self, id: &WidgetId) -> bool {
let len_before = self.entries.len();
self.entries.retain(|e| e.id != *id);
self.entries.len() < len_before
}
pub fn pop_top(&mut self) -> Option<ModalEntry> {
self.entries.pop()
}
pub fn top(&self) -> Option<&ModalEntry> {
self.entries.last()
}
pub fn top_focusable(&self) -> Option<&ModalEntry> {
self.iter_top_down().find(|e| e.focusable)
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn iter_top_down(&self) -> impl Iterator<Item = &ModalEntry> {
self.entries.iter().rev()
}
pub fn hit_test(&self, point: Point) -> Option<&WidgetId> {
for entry in self.iter_top_down() {
if entry.bounds.contains(point) {
return Some(&entry.id);
}
}
None
}
pub fn mark_painted(&mut self, id: &WidgetId) {
if let Some(entry) = self.entries.iter_mut().find(|e| &e.id == id) {
entry.painted_this_frame = true;
}
}
pub fn reset_frame_paint(&mut self) {
for entry in &mut self.entries {
entry.painted_this_frame = false;
}
}
pub fn unpainted_ids(&self) -> Vec<WidgetId> {
self.entries
.iter()
.filter(|e| !e.painted_this_frame)
.map(|e| e.id.clone())
.collect()
}
#[cfg_attr(
not(all(
debug_assertions,
any(
feature = "tui",
feature = "gtk",
all(feature = "macos", target_os = "macos")
)
)),
allow(dead_code)
)]
pub(crate) fn unpainted_modal_message(id: &WidgetId) -> String {
format!(
"quadraui: modal {id:?} is registered in ModalStack (and therefore \
hit-testable) but was not painted this frame — see quadraui#455 \
(\"ModalStack drives hit-testing but not paint\")"
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn id(s: &str) -> WidgetId {
WidgetId::new(s)
}
fn pt(x: f32, y: f32) -> Point {
Point { x, y }
}
fn rect(x: f32, y: f32, w: f32, h: f32) -> Rect {
Rect {
x,
y,
width: w,
height: h,
}
}
#[test]
fn empty_stack_reports_empty() {
let s = ModalStack::new();
assert!(s.is_empty());
assert_eq!(s.len(), 0);
assert!(s.top().is_none());
assert!(s.hit_test(pt(0.0, 0.0)).is_none());
}
#[test]
fn push_adds_to_top() {
let mut s = ModalStack::new();
s.push(id("palette"), rect(10.0, 10.0, 100.0, 100.0));
assert_eq!(s.len(), 1);
assert_eq!(s.top().unwrap().id, id("palette"));
}
#[test]
fn hit_test_walks_top_down() {
let mut s = ModalStack::new();
s.push(id("palette"), rect(0.0, 0.0, 100.0, 100.0));
s.push(id("dialog"), rect(20.0, 20.0, 60.0, 60.0));
assert_eq!(s.hit_test(pt(30.0, 30.0)), Some(&id("dialog")));
assert_eq!(s.hit_test(pt(5.0, 5.0)), Some(&id("palette")));
assert_eq!(s.hit_test(pt(200.0, 200.0)), None);
}
#[test]
fn pop_by_id_removes_even_when_not_top() {
let mut s = ModalStack::new();
s.push(id("a"), rect(0.0, 0.0, 10.0, 10.0));
s.push(id("b"), rect(0.0, 0.0, 10.0, 10.0));
s.push(id("c"), rect(0.0, 0.0, 10.0, 10.0));
assert!(s.pop(&id("b")));
assert_eq!(s.len(), 2);
assert_eq!(s.top().unwrap().id, id("c"));
assert!(!s.pop(&id("missing")));
}
#[test]
fn pop_top_returns_the_popped_entry() {
let mut s = ModalStack::new();
s.push(id("x"), rect(1.0, 2.0, 3.0, 4.0));
let popped = s.pop_top().unwrap();
assert_eq!(popped.id, id("x"));
assert!(s.is_empty());
assert!(s.pop_top().is_none());
}
#[test]
fn push_with_existing_id_moves_to_top_without_duplication() {
let mut s = ModalStack::new();
s.push(id("a"), rect(0.0, 0.0, 10.0, 10.0));
s.push(id("b"), rect(0.0, 0.0, 10.0, 10.0));
s.push(id("a"), rect(5.0, 5.0, 20.0, 20.0));
assert_eq!(s.len(), 2);
assert_eq!(s.top().unwrap().id, id("a"));
assert_eq!(s.top().unwrap().bounds, rect(5.0, 5.0, 20.0, 20.0));
}
#[test]
fn hit_test_edges_are_exclusive_on_right_and_bottom() {
let mut s = ModalStack::new();
s.push(id("m"), rect(0.0, 0.0, 10.0, 10.0));
assert_eq!(s.hit_test(pt(0.0, 0.0)), Some(&id("m"))); assert!(s.hit_test(pt(10.0, 5.0)).is_none()); assert!(s.hit_test(pt(5.0, 10.0)).is_none()); }
#[test]
fn freshly_pushed_modal_is_unpainted_until_marked() {
let mut s = ModalStack::new();
s.push(id("palette"), rect(0.0, 0.0, 10.0, 10.0));
assert_eq!(s.unpainted_ids(), vec![id("palette")]);
s.mark_painted(&id("palette"));
assert!(s.unpainted_ids().is_empty());
}
#[test]
fn reset_frame_paint_clears_marks_for_the_next_frame() {
let mut s = ModalStack::new();
s.push(id("dialog"), rect(0.0, 0.0, 10.0, 10.0));
s.mark_painted(&id("dialog"));
assert!(s.unpainted_ids().is_empty());
s.reset_frame_paint();
assert_eq!(s.unpainted_ids(), vec![id("dialog")]);
}
#[test]
fn mark_painted_on_unregistered_id_is_a_no_op() {
let mut s = ModalStack::new();
s.mark_painted(&id("nothing-registered"));
assert!(s.is_empty());
assert!(s.unpainted_ids().is_empty());
}
#[test]
fn only_the_unpainted_entry_is_reported_among_several() {
let mut s = ModalStack::new();
s.push(id("palette"), rect(0.0, 0.0, 10.0, 10.0));
s.push(id("dialog"), rect(0.0, 0.0, 10.0, 10.0));
s.mark_painted(&id("palette"));
assert_eq!(s.unpainted_ids(), vec![id("dialog")]);
assert_eq!(s.hit_test(pt(5.0, 5.0)), Some(&id("dialog")));
}
#[test]
fn push_defaults_to_focusable() {
let mut s = ModalStack::new();
s.push(id("dialog"), rect(0.0, 0.0, 10.0, 10.0));
assert!(s.top().unwrap().focusable);
assert_eq!(s.top_focusable().unwrap().id, id("dialog"));
}
#[test]
fn top_focusable_skips_a_non_focusable_entry_on_top() {
let mut s = ModalStack::new();
s.push_focusable(id("menu-float"), rect(0.0, 0.0, 10.0, 10.0), true);
s.push_focusable(id("hint-float"), rect(0.0, 0.0, 10.0, 10.0), false);
assert_eq!(s.top().unwrap().id, id("hint-float"));
assert_eq!(
s.hit_test(pt(5.0, 5.0)),
Some(&id("hint-float")),
"hit-test still sees the topmost entry regardless of focusable"
);
assert_eq!(
s.top_focusable().unwrap().id,
id("menu-float"),
"keyboard focus skips the non-focusable hint and lands on the \
focusable float beneath it"
);
}
#[test]
fn top_focusable_is_none_when_every_open_entry_is_non_focusable() {
let mut s = ModalStack::new();
s.push_focusable(id("hint"), rect(0.0, 0.0, 10.0, 10.0), false);
assert!(
s.top_focusable().is_none(),
"no focusable entry open — keyboard input should fall through \
to the base layer"
);
}
#[test]
fn push_float_carries_over_the_floats_own_focusable_bit() {
use crate::layout::{Anchor, Side};
use crate::Float;
let focusable = Float::new(
id("menu-float"),
Anchor::new(rect(0.0, 0.0, 5.0, 1.0), Side::Bottom),
);
let mut hint = Float::new(
id("hint-float"),
Anchor::new(rect(0.0, 0.0, 5.0, 1.0), Side::Bottom),
);
hint.focusable = false;
let mut s = ModalStack::new();
s.push_float(&focusable, rect(0.0, 0.0, 10.0, 10.0));
s.push_float(&hint, rect(0.0, 0.0, 10.0, 10.0));
assert_eq!(s.top().unwrap().id, id("hint-float"));
assert_eq!(
s.top_focusable().unwrap().id,
id("menu-float"),
"push_float must read focusable off the Float itself, not \
default to true like push()"
);
}
#[test]
fn top_focusable_is_none_on_an_empty_stack() {
let s = ModalStack::new();
assert!(s.top_focusable().is_none());
}
#[test]
fn re_pushing_an_existing_id_clears_its_painted_mark() {
let mut s = ModalStack::new();
s.push(id("a"), rect(0.0, 0.0, 10.0, 10.0));
s.mark_painted(&id("a"));
s.push(id("a"), rect(1.0, 1.0, 5.0, 5.0));
assert_eq!(s.unpainted_ids(), vec![id("a")]);
}
}