#![forbid(unsafe_code)]
use ftui_core::event::Event;
use ftui_core::geometry::Rect;
use ftui_render::frame::{Frame, HitData, HitId, HitRegion, HitTestResult};
use ftui_style::Style;
use std::sync::atomic::{AtomicU64, Ordering};
use crate::focus::FocusId;
use crate::modal::{BackdropConfig, MODAL_HIT_BACKDROP, MODAL_HIT_CONTENT, ModalSizeConstraints};
use crate::set_style_area;
#[cfg(test)]
use super::focus_integration::{ModalFocusCoordinator, next_focus_group_id};
#[cfg(feature = "tracing")]
use web_time::Instant;
const BASE_MODAL_Z: u32 = 1000;
const Z_INCREMENT: u32 = 10;
static MODAL_ID_COUNTER: AtomicU64 = AtomicU64::new(1);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ModalId(u64);
impl ModalId {
fn new() -> Self {
Self(MODAL_ID_COUNTER.fetch_add(1, Ordering::Relaxed))
}
#[inline]
pub const fn id(self) -> u64 {
self.0
}
}
#[derive(Debug, Clone)]
pub struct ModalResult {
pub id: ModalId,
pub data: Option<ModalResultData>,
pub focus_group_id: Option<u32>,
}
#[derive(Debug, Clone)]
pub enum ModalResultData {
Dismissed,
Confirmed,
Custom(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct FocusTrapSpec {
pub group_id: u32,
pub return_focus: Option<FocusId>,
}
pub type ModalFocusId = u64;
pub trait StackModal: Send {
fn modal_type(&self) -> &'static str {
std::any::type_name::<Self>()
}
fn render_content(&self, area: Rect, frame: &mut Frame);
fn handle_event(
&mut self,
event: &Event,
hit: Option<(HitId, HitRegion, HitData)>,
hit_id: HitId,
) -> Option<ModalResultData>;
fn size_constraints(&self) -> ModalSizeConstraints;
fn backdrop_config(&self) -> BackdropConfig;
fn close_on_escape(&self) -> bool {
true
}
fn close_on_backdrop(&self) -> bool {
true
}
fn aria_modal(&self) -> bool {
true
}
fn focusable_ids(&self) -> Option<Vec<ModalFocusId>> {
None
}
}
struct ActiveModal {
id: ModalId,
#[allow(dead_code)]
z_index: u32,
modal: Box<dyn StackModal>,
hit_id: HitId,
focus_group_id: Option<u32>,
focus_return_focus: Option<FocusId>,
}
pub struct ModalStack {
modals: Vec<ActiveModal>,
next_z: u32,
next_hit_id: u32,
}
impl Default for ModalStack {
fn default() -> Self {
Self::new()
}
}
impl ModalStack {
pub fn new() -> Self {
Self {
modals: Vec::new(),
next_z: 0,
next_hit_id: 1000, }
}
pub fn push(&mut self, modal: Box<dyn StackModal>) -> ModalId {
self.push_with_focus(modal, None)
}
pub fn push_with_focus(
&mut self,
modal: Box<dyn StackModal>,
focus_group_id: Option<u32>,
) -> ModalId {
#[cfg(feature = "tracing")]
let modal_type = modal.modal_type();
#[cfg(feature = "tracing")]
let focus_trapped = focus_group_id.is_some() && modal.aria_modal();
let id = ModalId::new();
let z_index = BASE_MODAL_Z + self.next_z;
self.next_z += Z_INCREMENT;
let hit_id = HitId::new(self.next_hit_id);
self.next_hit_id += 1;
self.modals.push(ActiveModal {
id,
z_index,
modal,
hit_id,
focus_group_id,
focus_return_focus: None,
});
#[cfg(feature = "tracing")]
tracing::debug!(
modal_id = id.id(),
modal_type,
focus_trapped,
depth = self.modals.len(),
"modal opened"
);
id
}
pub fn focus_group_id(&self, modal_id: ModalId) -> Option<u32> {
self.modals
.iter()
.find(|m| m.id == modal_id)
.and_then(|m| m.focus_group_id)
}
pub fn top_focus_group_id(&self) -> Option<u32> {
self.modals.last().and_then(|m| m.focus_group_id)
}
pub(super) fn next_focus_modal_after(&self, modal_id: ModalId) -> Option<(ModalId, u32)> {
let idx = self.modals.iter().position(|modal| modal.id == modal_id)?;
self.modals[idx + 1..]
.iter()
.find_map(|modal| modal.focus_group_id.map(|group_id| (modal.id, group_id)))
}
pub fn pop(&mut self) -> Option<ModalResult> {
let modal = self.modals.pop()?;
#[cfg(feature = "tracing")]
let modal_type = modal.modal.modal_type();
let result = ModalResult {
id: modal.id,
data: None,
focus_group_id: modal.focus_group_id,
};
#[cfg(feature = "tracing")]
tracing::debug!(
modal_id = result.id.id(),
modal_type,
depth = self.modals.len(),
"modal closed"
);
Some(result)
}
pub fn pop_id(&mut self, id: ModalId) -> Option<ModalResult> {
self.pop_id_with_restore_retarget(id, true)
}
pub(super) fn pop_id_with_restore_retarget(
&mut self,
id: ModalId,
retarget_upper_return_focus: bool,
) -> Option<ModalResult> {
let idx = self.modals.iter().position(|m| m.id == id)?;
let modal = self.modals.remove(idx);
#[cfg(feature = "tracing")]
let modal_type = modal.modal.modal_type();
if retarget_upper_return_focus
&& modal.focus_group_id.is_some()
&& let Some(upper_modal) = self.modals[idx..]
.iter_mut()
.find(|candidate| candidate.focus_group_id.is_some())
{
upper_modal.focus_return_focus = modal.focus_return_focus;
}
let result = ModalResult {
id: modal.id,
data: None,
focus_group_id: modal.focus_group_id,
};
#[cfg(feature = "tracing")]
tracing::debug!(
modal_id = result.id.id(),
modal_type,
depth = self.modals.len(),
"modal closed (pop_id)"
);
Some(result)
}
pub fn pop_all(&mut self) -> Vec<ModalResult> {
let mut results = Vec::with_capacity(self.modals.len());
while let Some(result) = self.pop() {
results.push(result);
}
results
}
pub fn top(&self) -> Option<&(dyn StackModal + 'static)> {
self.modals.last().map(|m| &*m.modal)
}
pub fn top_mut(&mut self) -> Option<&mut (dyn StackModal + 'static)> {
match self.modals.last_mut() {
Some(m) => Some(m.modal.as_mut()),
None => None,
}
}
#[inline]
pub fn is_empty(&self) -> bool {
self.modals.is_empty()
}
#[inline]
pub fn depth(&self) -> usize {
self.modals.len()
}
pub fn contains(&self, id: ModalId) -> bool {
self.modals.iter().any(|m| m.id == id)
}
pub fn top_id(&self) -> Option<ModalId> {
self.modals.last().map(|m| m.id)
}
pub fn focus_group_ids_in_order(&self) -> Vec<u32> {
self.modals
.iter()
.filter_map(|m| m.focus_group_id)
.collect()
}
pub(super) fn focus_modal_specs_in_order(&self) -> Vec<(ModalId, FocusTrapSpec)> {
self.modals
.iter()
.filter_map(|modal| {
modal.focus_group_id.map(|group_id| {
(
modal.id,
FocusTrapSpec {
group_id,
return_focus: modal.focus_return_focus,
},
)
})
})
.collect()
}
pub(super) fn set_focus_return_focus(
&mut self,
modal_id: ModalId,
return_focus: Option<FocusId>,
) -> bool {
let Some(modal) = self.modals.iter_mut().find(|modal| modal.id == modal_id) else {
return false;
};
if modal.focus_group_id.is_none() {
return false;
}
modal.focus_return_focus = return_focus;
true
}
pub fn handle_event(
&mut self,
event: &Event,
hit: Option<HitTestResult>,
) -> Option<ModalResult> {
let top_index = self.modals.len().checked_sub(1)?;
let top_owner = self.modals[top_index].id.id();
let hit_id = self.modals[top_index].hit_id;
let filtered_hit = hit.filter(|hit| hit.owner == Some(top_owner));
let top = &mut self.modals[top_index];
let id = top.id;
let focus_group_id = top.focus_group_id;
#[cfg(feature = "tracing")]
let modal_type = top.modal.modal_type();
if let Some(data) =
top.modal
.handle_event(event, filtered_hit.map(HitTestResult::into_tuple), hit_id)
{
self.modals.pop();
let result = ModalResult {
id,
data: Some(data),
focus_group_id,
};
#[cfg(feature = "tracing")]
tracing::debug!(
modal_id = result.id.id(),
modal_type,
result_data = ?result.data,
depth = self.modals.len(),
"modal closed (event)"
);
return Some(result);
}
None
}
pub fn render(&self, frame: &mut Frame, screen: Rect) {
if self.modals.is_empty() {
return;
}
let modal_count = self.modals.len();
for (i, modal) in self.modals.iter().enumerate() {
let is_top = i == modal_count - 1;
let base_opacity = modal.modal.backdrop_config().opacity;
let opacity = if is_top {
base_opacity
} else {
base_opacity * 0.5
};
#[cfg(feature = "tracing")]
let render_start = Instant::now();
#[cfg(feature = "tracing")]
let render_span = tracing::debug_span!(
"modal.render",
modal_type = modal.modal.modal_type(),
focus_trapped = (modal.focus_group_id.is_some() && modal.modal.aria_modal()),
backdrop_active = (opacity > 0.0),
render_duration_us = tracing::field::Empty,
);
#[cfg(feature = "tracing")]
let _render_guard = render_span.enter();
if opacity > 0.0 {
let bg_color = modal.modal.backdrop_config().color.with_opacity(opacity);
set_style_area(&mut frame.buffer, screen, Style::new().bg(bg_color));
}
frame.with_hit_owner(modal.id.id(), |frame| {
if !screen.is_empty() {
frame.register_hit(screen, modal.hit_id, MODAL_HIT_BACKDROP, 0);
}
let constraints = modal.modal.size_constraints();
let available = ftui_core::geometry::Size::new(screen.width, screen.height);
let size = constraints.clamp(available);
if size.width == 0 || size.height == 0 {
return;
}
let x = screen.x + (screen.width.saturating_sub(size.width)) / 2;
let y = screen.y + (screen.height.saturating_sub(size.height)) / 2;
let content_area = Rect::new(x, y, size.width, size.height);
if !content_area.is_empty() {
frame.register_hit(content_area, modal.hit_id, MODAL_HIT_CONTENT, 0);
}
modal.modal.render_content(content_area, frame);
});
#[cfg(feature = "tracing")]
{
let elapsed = render_start.elapsed();
render_span.record("render_duration_us", elapsed.as_micros() as u64);
}
}
}
}
pub struct WidgetModalEntry<W> {
widget: W,
size: ModalSizeConstraints,
backdrop: BackdropConfig,
close_on_escape: bool,
close_on_backdrop: bool,
aria_modal: bool,
focusable_ids: Option<Vec<ModalFocusId>>,
}
impl<W> WidgetModalEntry<W> {
pub fn new(widget: W) -> Self {
Self {
widget,
size: ModalSizeConstraints::new()
.min_width(30)
.max_width(60)
.min_height(10)
.max_height(20),
backdrop: BackdropConfig::default(),
close_on_escape: true,
close_on_backdrop: true,
aria_modal: true,
focusable_ids: None,
}
}
#[must_use]
pub fn size(mut self, size: ModalSizeConstraints) -> Self {
self.size = size;
self
}
#[must_use]
pub fn backdrop(mut self, backdrop: BackdropConfig) -> Self {
self.backdrop = backdrop;
self
}
#[must_use]
pub fn close_on_escape(mut self, close: bool) -> Self {
self.close_on_escape = close;
self
}
#[must_use]
pub fn close_on_backdrop(mut self, close: bool) -> Self {
self.close_on_backdrop = close;
self
}
#[must_use]
pub fn with_aria_modal(mut self, aria_modal: bool) -> Self {
self.aria_modal = aria_modal;
self
}
#[must_use]
pub fn with_focusable_ids(mut self, ids: Vec<ModalFocusId>) -> Self {
self.focusable_ids = Some(ids);
self
}
}
impl<W: crate::Widget + Send> StackModal for WidgetModalEntry<W> {
fn render_content(&self, area: Rect, frame: &mut Frame) {
self.widget.render(area, frame);
}
fn handle_event(
&mut self,
event: &Event,
hit: Option<(HitId, HitRegion, HitData)>,
hit_id: HitId,
) -> Option<ModalResultData> {
use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind};
if self.close_on_backdrop
&& let Event::Mouse(ftui_core::event::MouseEvent {
kind: ftui_core::event::MouseEventKind::Down(ftui_core::event::MouseButton::Left),
..
}) = event
&& let Some((id, region, _)) = hit
&& id == hit_id
&& region == MODAL_HIT_BACKDROP
{
return Some(ModalResultData::Dismissed);
}
if self.close_on_escape
&& let Event::Key(KeyEvent {
code: KeyCode::Escape,
kind: KeyEventKind::Press,
..
}) = event
{
return Some(ModalResultData::Dismissed);
}
None
}
fn size_constraints(&self) -> ModalSizeConstraints {
self.size
}
fn backdrop_config(&self) -> BackdropConfig {
self.backdrop
}
fn close_on_escape(&self) -> bool {
self.close_on_escape
}
fn close_on_backdrop(&self) -> bool {
self.close_on_backdrop
}
fn aria_modal(&self) -> bool {
self.aria_modal
}
fn focusable_ids(&self) -> Option<Vec<ModalFocusId>> {
self.focusable_ids.clone()
}
}
#[cfg(test)]
#[allow(dead_code)]
pub struct ModalFocusIntegration<'a> {
stack: &'a mut ModalStack,
focus: &'a mut crate::focus::FocusManager,
base_focus: Option<Option<crate::focus::FocusId>>,
}
#[cfg(test)]
#[allow(dead_code)]
impl<'a> ModalFocusIntegration<'a> {
pub fn new(stack: &'a mut ModalStack, focus: &'a mut crate::focus::FocusManager) -> Self {
let base_focus = focus.base_trap_return_focus();
Self {
stack,
focus,
base_focus,
}
}
pub fn push_with_focus(&mut self, modal: Box<dyn StackModal>) -> ModalId {
let focusable_ids = modal.focusable_ids();
let is_aria_modal = modal.aria_modal();
ModalFocusCoordinator::new(self.stack, self.focus, &mut self.base_focus)
.push_modal_with_trap(modal, focusable_ids, is_aria_modal, next_focus_group_id)
}
pub fn pop_with_focus(&mut self) -> Option<ModalResult> {
ModalFocusCoordinator::new(self.stack, self.focus, &mut self.base_focus).pop_modal()
}
pub fn pop_id_with_focus(&mut self, id: ModalId) -> Option<ModalResult> {
ModalFocusCoordinator::new(self.stack, self.focus, &mut self.base_focus).pop_modal_by_id(id)
}
pub fn pop_all_with_focus(&mut self) -> Vec<ModalResult> {
ModalFocusCoordinator::new(self.stack, self.focus, &mut self.base_focus).pop_all_modals()
}
pub fn handle_event(
&mut self,
event: &Event,
hit: Option<HitTestResult>,
) -> Option<ModalResult> {
ModalFocusCoordinator::new(self.stack, self.focus, &mut self.base_focus)
.handle_modal_event(event, hit)
}
pub fn is_focus_trapped(&self) -> bool {
self.focus.is_trapped()
}
pub fn stack(&self) -> &ModalStack {
self.stack
}
pub fn stack_mut(&mut self) -> &mut ModalStack {
self.stack
}
pub fn focus(&self) -> &crate::focus::FocusManager {
self.focus
}
pub fn focus_mut(&mut self) -> &mut crate::focus::FocusManager {
self.focus
}
pub fn resync_focus_state(&mut self) {
let mut coordinator =
ModalFocusCoordinator::new(self.stack, self.focus, &mut self.base_focus);
coordinator.rebuild_focus_traps();
coordinator.refresh_inactive_modal_return_focus_targets();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Widget;
use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
use ftui_render::cell::PackedRgba;
use ftui_render::grapheme_pool::GraphemePool;
#[cfg(feature = "tracing")]
use std::sync::{Arc, Mutex};
#[cfg(feature = "tracing")]
use tracing::Subscriber;
#[cfg(feature = "tracing")]
use tracing_subscriber::Layer;
#[cfg(feature = "tracing")]
use tracing_subscriber::layer::{Context, SubscriberExt};
#[derive(Debug, Clone)]
struct StubWidget;
impl Widget for StubWidget {
fn render(&self, _area: Rect, _frame: &mut Frame) {}
}
#[derive(Debug, Default)]
struct CloseOnAnyHitModal;
#[derive(Debug, Default)]
struct CloseOnBackdropHitModal;
#[derive(Debug, Default)]
struct CloseOnInnerHitModal;
#[derive(Debug, Default)]
struct CloseOnCollidingInnerHitModal;
impl StackModal for CloseOnAnyHitModal {
fn render_content(&self, _area: Rect, _frame: &mut Frame) {}
fn handle_event(
&mut self,
_event: &Event,
hit: Option<(HitId, HitRegion, HitData)>,
_hit_id: HitId,
) -> Option<ModalResultData> {
hit.map(|_| ModalResultData::Dismissed)
}
fn size_constraints(&self) -> ModalSizeConstraints {
ModalSizeConstraints::new()
.min_width(10)
.max_width(10)
.min_height(3)
.max_height(3)
}
fn backdrop_config(&self) -> BackdropConfig {
BackdropConfig::default()
}
fn close_on_backdrop(&self) -> bool {
false
}
}
impl StackModal for CloseOnBackdropHitModal {
fn render_content(&self, _area: Rect, _frame: &mut Frame) {}
fn handle_event(
&mut self,
event: &Event,
hit: Option<(HitId, HitRegion, HitData)>,
hit_id: HitId,
) -> Option<ModalResultData> {
if let Event::Mouse(ftui_core::event::MouseEvent {
kind: ftui_core::event::MouseEventKind::Down(ftui_core::event::MouseButton::Left),
..
}) = event
&& let Some((id, region, _)) = hit
&& id == hit_id
&& region == MODAL_HIT_BACKDROP
{
return Some(ModalResultData::Dismissed);
}
None
}
fn size_constraints(&self) -> ModalSizeConstraints {
ModalSizeConstraints::new()
.min_width(10)
.max_width(10)
.min_height(3)
.max_height(3)
}
fn backdrop_config(&self) -> BackdropConfig {
BackdropConfig::default()
}
fn close_on_backdrop(&self) -> bool {
false
}
}
impl StackModal for CloseOnInnerHitModal {
fn render_content(&self, area: Rect, frame: &mut Frame) {
if !area.is_empty() {
frame.register_hit(area, HitId::new(4242), HitRegion::Custom(99), 0);
}
}
fn handle_event(
&mut self,
_event: &Event,
hit: Option<(HitId, HitRegion, HitData)>,
_hit_id: HitId,
) -> Option<ModalResultData> {
if let Some((id, region, _)) = hit
&& id == HitId::new(4242)
&& region == HitRegion::Custom(99)
{
return Some(ModalResultData::Dismissed);
}
None
}
fn size_constraints(&self) -> ModalSizeConstraints {
ModalSizeConstraints::new()
.min_width(10)
.max_width(10)
.min_height(3)
.max_height(3)
}
fn backdrop_config(&self) -> BackdropConfig {
BackdropConfig::default()
}
fn close_on_backdrop(&self) -> bool {
false
}
}
impl StackModal for CloseOnCollidingInnerHitModal {
fn render_content(&self, area: Rect, frame: &mut Frame) {
if !area.is_empty() {
frame.register_hit(area, HitId::new(1000), HitRegion::Custom(100), 0);
}
}
fn handle_event(
&mut self,
_event: &Event,
hit: Option<(HitId, HitRegion, HitData)>,
_hit_id: HitId,
) -> Option<ModalResultData> {
if let Some((id, region, _)) = hit
&& id == HitId::new(1000)
&& region == HitRegion::Custom(100)
{
return Some(ModalResultData::Dismissed);
}
None
}
fn size_constraints(&self) -> ModalSizeConstraints {
ModalSizeConstraints::new()
.min_width(10)
.max_width(10)
.min_height(3)
.max_height(3)
}
fn backdrop_config(&self) -> BackdropConfig {
BackdropConfig::default()
}
fn close_on_backdrop(&self) -> bool {
false
}
}
#[cfg(feature = "tracing")]
#[derive(Debug, Default)]
struct TraceState {
modal_render_seen: bool,
modal_render_has_modal_type: bool,
modal_render_has_focus_trapped: bool,
modal_render_has_backdrop_active: bool,
modal_render_duration_recorded: bool,
focus_change_count: usize,
trap_push_count: usize,
trap_pop_count: usize,
}
#[cfg(feature = "tracing")]
struct TraceCapture {
state: Arc<Mutex<TraceState>>,
}
#[cfg(feature = "tracing")]
impl<S> Layer<S> for TraceCapture
where
S: Subscriber + for<'lookup> tracing_subscriber::registry::LookupSpan<'lookup>,
{
fn on_new_span(
&self,
attrs: &tracing::span::Attributes<'_>,
_id: &tracing::Id,
_ctx: Context<'_, S>,
) {
if attrs.metadata().name() != "modal.render" {
return;
}
let fields = attrs.metadata().fields();
let mut state = self.state.lock().expect("trace state lock");
state.modal_render_seen = true;
state.modal_render_has_modal_type |= fields.field("modal_type").is_some();
state.modal_render_has_focus_trapped |= fields.field("focus_trapped").is_some();
state.modal_render_has_backdrop_active |= fields.field("backdrop_active").is_some();
}
fn on_record(
&self,
id: &tracing::Id,
values: &tracing::span::Record<'_>,
ctx: Context<'_, S>,
) {
let Some(span) = ctx.span(id) else {
return;
};
if span.metadata().name() != "modal.render" {
return;
}
struct DurationVisitor {
saw_duration: bool,
}
impl tracing::field::Visit for DurationVisitor {
fn record_u64(&mut self, field: &tracing::field::Field, _value: u64) {
if field.name() == "render_duration_us" {
self.saw_duration = true;
}
}
fn record_debug(
&mut self,
field: &tracing::field::Field,
_value: &dyn std::fmt::Debug,
) {
if field.name() == "render_duration_us" {
self.saw_duration = true;
}
}
}
let mut visitor = DurationVisitor {
saw_duration: false,
};
values.record(&mut visitor);
if visitor.saw_duration {
self.state
.lock()
.expect("trace state lock")
.modal_render_duration_recorded = true;
}
}
fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
struct MessageVisitor {
message: Option<String>,
}
impl tracing::field::Visit for MessageVisitor {
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
if field.name() == "message" {
self.message = Some(value.to_owned());
}
}
fn record_debug(
&mut self,
field: &tracing::field::Field,
value: &dyn std::fmt::Debug,
) {
if field.name() == "message" {
self.message = Some(format!("{value:?}").trim_matches('"').to_owned());
}
}
}
let mut visitor = MessageVisitor { message: None };
event.record(&mut visitor);
let Some(message) = visitor.message else {
return;
};
let mut state = self.state.lock().expect("trace state lock");
match message.as_str() {
"focus.change" => state.focus_change_count += 1,
"focus.trap_push" => state.trap_push_count += 1,
"focus.trap_pop" => state.trap_pop_count += 1,
_ => {}
}
}
}
#[test]
fn empty_stack() {
let stack = ModalStack::new();
assert!(stack.is_empty());
assert_eq!(stack.depth(), 0);
assert!(stack.top().is_none());
assert!(stack.top_id().is_none());
}
#[test]
fn push_increases_depth() {
let mut stack = ModalStack::new();
let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
assert_eq!(stack.depth(), 1);
assert!(!stack.is_empty());
assert!(stack.contains(id1));
let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
assert_eq!(stack.depth(), 2);
assert!(stack.contains(id2));
assert_eq!(stack.top_id(), Some(id2));
}
#[test]
fn pop_lifo_order() {
let mut stack = ModalStack::new();
let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
let id3 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
let result = stack.pop();
assert_eq!(result.map(|r| r.id), Some(id3));
assert_eq!(stack.depth(), 2);
let result = stack.pop();
assert_eq!(result.map(|r| r.id), Some(id2));
assert_eq!(stack.depth(), 1);
let result = stack.pop();
assert_eq!(result.map(|r| r.id), Some(id1));
assert!(stack.is_empty());
}
#[test]
fn pop_empty_returns_none() {
let mut stack = ModalStack::new();
assert!(stack.pop().is_none());
}
#[test]
fn pop_by_id() {
let mut stack = ModalStack::new();
let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
let id3 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
let result = stack.pop_id(id2);
assert_eq!(result.map(|r| r.id), Some(id2));
assert_eq!(stack.depth(), 2);
assert!(!stack.contains(id2));
assert!(stack.contains(id1));
assert!(stack.contains(id3));
}
#[test]
fn pop_by_nonexistent_id() {
let mut stack = ModalStack::new();
let _id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
let fake_id = ModalId(999999);
assert!(stack.pop_id(fake_id).is_none());
assert_eq!(stack.depth(), 1);
}
#[test]
fn pop_all() {
let mut stack = ModalStack::new();
let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
let id3 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
let results = stack.pop_all();
assert_eq!(results.len(), 3);
assert_eq!(results[0].id, id3);
assert_eq!(results[1].id, id2);
assert_eq!(results[2].id, id1);
assert!(stack.is_empty());
}
#[test]
fn z_order_increasing() {
let mut stack = ModalStack::new();
stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
let z_indices: Vec<u32> = stack.modals.iter().map(|m| m.z_index).collect();
for i in 1..z_indices.len() {
assert!(
z_indices[i] > z_indices[i - 1],
"z_index should be strictly increasing"
);
}
}
#[test]
fn escape_closes_top_modal() {
let mut stack = ModalStack::new();
let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
let escape = Event::Key(KeyEvent {
code: KeyCode::Escape,
modifiers: Modifiers::empty(),
kind: KeyEventKind::Press,
});
let result = stack.handle_event(&escape, None);
assert!(result.is_some());
assert_eq!(result.unwrap().id, id2);
assert_eq!(stack.depth(), 1);
assert_eq!(stack.top_id(), Some(id1));
}
#[test]
fn render_does_not_panic() {
let mut stack = ModalStack::new();
stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
let mut pool = GraphemePool::new();
let mut frame = Frame::new(80, 24, &mut pool);
let screen = Rect::new(0, 0, 80, 24);
stack.render(&mut frame, screen);
}
#[test]
fn render_empty_stack_no_op() {
let stack = ModalStack::new();
let mut pool = GraphemePool::new();
let mut frame = Frame::new(80, 24, &mut pool);
let screen = Rect::new(0, 0, 80, 24);
stack.render(&mut frame, screen);
}
#[test]
fn contains_after_pop() {
let mut stack = ModalStack::new();
let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
assert!(stack.contains(id1));
stack.pop();
assert!(!stack.contains(id1));
}
#[test]
fn unique_modal_ids() {
let mut stack = ModalStack::new();
let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
let id3 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
assert_ne!(id1, id2);
assert_ne!(id2, id3);
assert_ne!(id1, id3);
}
#[test]
fn widget_modal_entry_builder() {
let entry = WidgetModalEntry::new(StubWidget)
.size(ModalSizeConstraints::new().min_width(40).max_width(80))
.backdrop(BackdropConfig::new(PackedRgba::rgb(0, 0, 0), 0.8))
.close_on_escape(false)
.close_on_backdrop(false);
assert!(!entry.close_on_escape);
assert!(!entry.close_on_backdrop);
assert_eq!(entry.size.min_width, Some(40));
assert_eq!(entry.size.max_width, Some(80));
}
#[test]
fn escape_disabled_does_not_close() {
let mut stack = ModalStack::new();
stack.push(Box::new(
WidgetModalEntry::new(StubWidget).close_on_escape(false),
));
let escape = Event::Key(KeyEvent {
code: KeyCode::Escape,
modifiers: Modifiers::empty(),
kind: KeyEventKind::Press,
});
let result = stack.handle_event(&escape, None);
assert!(result.is_none());
assert_eq!(stack.depth(), 1);
}
#[test]
fn backdrop_click_closes_top_modal() {
let mut stack = ModalStack::new();
let top_id = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
let click = Event::Mouse(ftui_core::event::MouseEvent::new(
ftui_core::event::MouseEventKind::Down(ftui_core::event::MouseButton::Left),
0,
0,
));
let hit = Some(HitTestResult::new(
HitId::new(1000),
MODAL_HIT_BACKDROP,
0,
Some(top_id.id()),
));
let result = stack.handle_event(&click, hit);
assert!(result.is_some());
let result = result.unwrap();
assert_eq!(result.id, top_id);
assert!(matches!(result.data, Some(ModalResultData::Dismissed)));
assert!(stack.is_empty());
}
#[test]
fn content_click_does_not_close_top_modal() {
let mut stack = ModalStack::new();
stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
let click = Event::Mouse(ftui_core::event::MouseEvent::new(
ftui_core::event::MouseEventKind::Down(ftui_core::event::MouseButton::Left),
5,
5,
));
let hit = Some(HitTestResult::new(
HitId::new(1000),
MODAL_HIT_CONTENT,
0,
Some(stack.top_id().unwrap().id()),
));
let result = stack.handle_event(&click, hit);
assert!(result.is_none());
assert_eq!(stack.depth(), 1);
}
#[test]
fn custom_modal_receives_backdrop_hit_without_builtin_auto_close() {
let mut stack = ModalStack::new();
let top_id = stack.push(Box::new(CloseOnBackdropHitModal));
let click = Event::Mouse(ftui_core::event::MouseEvent::new(
ftui_core::event::MouseEventKind::Down(ftui_core::event::MouseButton::Left),
0,
0,
));
let hit = Some(HitTestResult::new(
HitId::new(1000),
MODAL_HIT_BACKDROP,
0,
Some(top_id.id()),
));
let result = stack.handle_event(&click, hit);
assert!(result.is_some());
let result = result.unwrap();
assert_eq!(result.id, top_id);
assert!(matches!(result.data, Some(ModalResultData::Dismissed)));
assert!(stack.is_empty());
}
#[test]
fn custom_modal_receives_inner_widget_hit() {
let mut stack = ModalStack::new();
let top_id = stack.push(Box::new(CloseOnInnerHitModal));
let top_hit_id = stack.modals.last().unwrap().hit_id;
let mut pool = GraphemePool::new();
let mut frame = Frame::with_hit_grid(20, 10, &mut pool);
let screen = Rect::new(0, 0, 20, 10);
stack.render(&mut frame, screen);
let hit = frame.hit_test_detailed(10, 4);
assert_eq!(
hit,
Some(HitTestResult::new(
HitId::new(4242),
HitRegion::Custom(99),
0,
Some(top_id.id()),
))
);
assert_ne!(hit.unwrap().id, top_hit_id);
let click = Event::Mouse(ftui_core::event::MouseEvent::new(
ftui_core::event::MouseEventKind::Down(ftui_core::event::MouseButton::Left),
10,
4,
));
let result = stack.handle_event(&click, hit);
assert!(result.is_some());
let result = result.unwrap();
assert_eq!(result.id, top_id);
assert!(matches!(result.data, Some(ModalResultData::Dismissed)));
assert!(stack.is_empty());
}
#[test]
fn custom_modal_receives_inner_widget_hit_even_when_hit_id_collides_with_lower_modal() {
let mut stack = ModalStack::new();
let _lower_id = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
let top_id = stack.push(Box::new(CloseOnCollidingInnerHitModal));
let top_hit_id = stack.modals.last().unwrap().hit_id;
let mut pool = GraphemePool::new();
let mut frame = Frame::with_hit_grid(20, 10, &mut pool);
let screen = Rect::new(0, 0, 20, 10);
stack.render(&mut frame, screen);
let hit = frame.hit_test_detailed(10, 4);
assert_eq!(
hit,
Some(HitTestResult::new(
HitId::new(1000),
HitRegion::Custom(100),
0,
Some(top_id.id()),
))
);
assert_ne!(hit.unwrap().id, top_hit_id);
let click = Event::Mouse(ftui_core::event::MouseEvent::new(
ftui_core::event::MouseEventKind::Down(ftui_core::event::MouseButton::Left),
10,
4,
));
let result = stack.handle_event(&click, hit);
assert!(result.is_some());
let result = result.unwrap();
assert_eq!(result.id, top_id);
assert!(matches!(result.data, Some(ModalResultData::Dismissed)));
assert_eq!(stack.depth(), 1);
}
#[test]
fn zero_sized_modal_still_registers_backdrop_hit() {
let mut stack = ModalStack::new();
stack.push(Box::new(
WidgetModalEntry::new(StubWidget)
.size(ModalSizeConstraints::new().max_width(0).max_height(0)),
));
let hit_id = stack.modals.last().unwrap().hit_id;
let mut pool = GraphemePool::new();
let mut frame = Frame::with_hit_grid(20, 10, &mut pool);
let screen = Rect::new(0, 0, 20, 10);
stack.render(&mut frame, screen);
assert_eq!(
frame.hit_test_detailed(0, 0),
Some(HitTestResult::new(
hit_id,
MODAL_HIT_BACKDROP,
0,
Some(stack.top_id().unwrap().id()),
))
);
}
#[test]
fn foreign_lower_modal_hit_is_not_routed_to_top_modal() {
let mut stack = ModalStack::new();
let _lower_id = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
let top_id = stack.push(Box::new(CloseOnAnyHitModal));
let click = Event::Mouse(ftui_core::event::MouseEvent::new(
ftui_core::event::MouseEventKind::Down(ftui_core::event::MouseButton::Left),
0,
0,
));
let lower_backdrop_hit = Some(HitTestResult::new(
HitId::new(1000),
MODAL_HIT_BACKDROP,
0,
Some(_lower_id.id()),
));
let result = stack.handle_event(&click, lower_backdrop_hit);
assert!(result.is_none());
assert_eq!(stack.depth(), 2);
assert_eq!(stack.top_id(), Some(top_id));
}
#[test]
fn push_with_focus_tracks_group_id() {
let mut stack = ModalStack::new();
let modal_id = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(42));
assert_eq!(stack.focus_group_id(modal_id), Some(42));
assert_eq!(stack.top_focus_group_id(), Some(42));
}
#[test]
fn pop_returns_focus_group_id() {
let mut stack = ModalStack::new();
stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(99));
let result = stack.pop();
assert!(result.is_some());
assert_eq!(result.unwrap().focus_group_id, Some(99));
}
#[test]
fn pop_id_returns_focus_group_id() {
let mut stack = ModalStack::new();
let id1 = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(10));
let _id2 = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(20));
let result = stack.pop_id(id1);
assert!(result.is_some());
assert_eq!(result.unwrap().focus_group_id, Some(10));
}
#[test]
fn handle_event_returns_focus_group_id() {
let mut stack = ModalStack::new();
stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(77));
let escape = Event::Key(KeyEvent {
code: KeyCode::Escape,
modifiers: Modifiers::empty(),
kind: KeyEventKind::Press,
});
let result = stack.handle_event(&escape, None);
assert!(result.is_some());
assert_eq!(result.unwrap().focus_group_id, Some(77));
}
#[test]
fn push_without_focus_has_none_group_id() {
let mut stack = ModalStack::new();
let modal_id = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
assert_eq!(stack.focus_group_id(modal_id), None);
assert_eq!(stack.top_focus_group_id(), None);
}
#[test]
fn nested_focus_groups_track_correctly() {
let mut stack = ModalStack::new();
let _id1 = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(1));
let id2 = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(2));
let _id3 = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(3));
assert_eq!(stack.top_focus_group_id(), Some(3));
stack.pop();
assert_eq!(stack.top_focus_group_id(), Some(2));
assert_eq!(stack.focus_group_id(id2), Some(2));
}
#[test]
fn default_aria_modal_is_true() {
let entry = WidgetModalEntry::new(StubWidget);
assert!(entry.aria_modal);
}
#[test]
fn aria_modal_builder() {
let entry = WidgetModalEntry::new(StubWidget).with_aria_modal(false);
assert!(!entry.aria_modal);
}
#[test]
fn focusable_ids_builder() {
let entry = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1, 2, 3]);
assert_eq!(entry.focusable_ids, Some(vec![1, 2, 3]));
}
#[test]
fn stack_modal_aria_modal_trait() {
let entry = WidgetModalEntry::new(StubWidget);
assert!(StackModal::aria_modal(&entry));
let entry_non_aria = WidgetModalEntry::new(StubWidget).with_aria_modal(false);
assert!(!StackModal::aria_modal(&entry_non_aria));
}
#[test]
fn stack_modal_focusable_ids_trait() {
let entry = WidgetModalEntry::new(StubWidget);
assert!(StackModal::focusable_ids(&entry).is_none());
let entry_with_ids = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![10, 20]);
assert_eq!(
StackModal::focusable_ids(&entry_with_ids),
Some(vec![10, 20])
);
}
#[test]
fn focus_integration_push_creates_trap() {
use crate::focus::{FocusManager, FocusNode};
use ftui_core::geometry::Rect;
let mut stack = ModalStack::new();
let mut focus = FocusManager::new();
focus
.graph_mut()
.insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
focus
.graph_mut()
.insert(FocusNode::new(2, Rect::new(0, 1, 10, 1)));
focus
.graph_mut()
.insert(FocusNode::new(100, Rect::new(0, 10, 10, 1)));
focus.focus(100);
assert_eq!(focus.current(), Some(100));
{
let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1, 2]);
let _modal_id = integrator.push_with_focus(Box::new(modal));
assert!(integrator.is_focus_trapped());
assert_eq!(integrator.focus().current(), Some(1));
}
}
#[test]
fn focus_integration_pop_restores_focus() {
use crate::focus::{FocusManager, FocusNode};
use ftui_core::geometry::Rect;
let mut stack = ModalStack::new();
let mut focus = FocusManager::new();
focus
.graph_mut()
.insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
focus
.graph_mut()
.insert(FocusNode::new(2, Rect::new(0, 1, 10, 1)));
focus
.graph_mut()
.insert(FocusNode::new(100, Rect::new(0, 10, 10, 1)));
focus.focus(100);
assert_eq!(focus.current(), Some(100));
{
let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1, 2]);
integrator.push_with_focus(Box::new(modal));
assert!(integrator.is_focus_trapped());
let result = integrator.pop_with_focus();
assert!(result.is_some());
assert!(!integrator.is_focus_trapped());
assert_eq!(integrator.focus().current(), Some(100));
}
}
#[test]
fn focus_integration_pop_id_with_focus_preserves_top_trap_and_restores_base_after_last_pop() {
use crate::focus::{FocusManager, FocusNode};
use ftui_core::geometry::Rect;
let mut stack = ModalStack::new();
let mut focus = FocusManager::new();
focus
.graph_mut()
.insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
focus
.graph_mut()
.insert(FocusNode::new(2, Rect::new(0, 1, 10, 1)));
focus
.graph_mut()
.insert(FocusNode::new(100, Rect::new(0, 10, 10, 1)));
focus.focus(100);
{
let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
let lower = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1]);
let lower_id = integrator.push_with_focus(Box::new(lower));
let top = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![2]);
integrator.push_with_focus(Box::new(top));
let removed = integrator.pop_id_with_focus(lower_id);
assert!(removed.is_some());
assert!(integrator.is_focus_trapped());
assert_eq!(integrator.focus().current(), Some(2));
let final_result = integrator.pop_with_focus();
assert!(final_result.is_some());
assert!(!integrator.is_focus_trapped());
assert_eq!(integrator.focus().current(), Some(100));
}
}
#[test]
fn focus_integration_pop_id_with_focus_preserves_unfocused_base_across_helper_instances() {
use crate::focus::{FocusManager, FocusNode};
use ftui_core::geometry::Rect;
let mut stack = ModalStack::new();
let mut focus = FocusManager::new();
focus
.graph_mut()
.insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
focus
.graph_mut()
.insert(FocusNode::new(2, Rect::new(0, 1, 10, 1)));
let lower_id;
let upper_id;
{
let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
let lower = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1]);
let upper = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![2]);
lower_id = integrator.push_with_focus(Box::new(lower));
upper_id = integrator.push_with_focus(Box::new(upper));
assert_eq!(integrator.focus().current(), Some(2));
}
{
let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
let removed = integrator.pop_id_with_focus(lower_id);
assert_eq!(removed.map(|result| result.id), Some(lower_id));
assert_eq!(integrator.focus().current(), Some(2));
assert!(integrator.is_focus_trapped());
let closed = integrator.pop_with_focus();
assert_eq!(closed.map(|result| result.id), Some(upper_id));
}
assert_eq!(focus.current(), None);
assert!(!focus.is_trapped());
}
#[test]
fn focus_integration_resync_focus_state_recovers_after_manual_stack_mutation() {
use crate::focus::{FocusManager, FocusNode};
use ftui_core::geometry::Rect;
let mut stack = ModalStack::new();
let mut focus = FocusManager::new();
focus
.graph_mut()
.insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
focus
.graph_mut()
.insert(FocusNode::new(2, Rect::new(0, 1, 10, 1)));
focus
.graph_mut()
.insert(FocusNode::new(100, Rect::new(0, 10, 10, 1)));
focus.focus(100);
{
let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1, 2]);
integrator.push_with_focus(Box::new(modal));
assert!(integrator.is_focus_trapped());
assert_eq!(integrator.focus().current(), Some(1));
let result = integrator.stack_mut().pop();
assert!(result.is_some());
assert!(integrator.is_focus_trapped());
integrator.resync_focus_state();
assert!(!integrator.is_focus_trapped());
assert_eq!(integrator.focus().current(), Some(100));
}
}
#[test]
fn focus_integration_resync_updates_inactive_modal_restore_targets_after_manual_focus_change() {
use crate::focus::{FocusManager, FocusNode};
use ftui_core::geometry::Rect;
let mut stack = ModalStack::new();
let mut focus = FocusManager::new();
for id in 1..=4 {
focus
.graph_mut()
.insert(FocusNode::new(id, Rect::new(0, 0, 10, 1)).with_tab_index(id as i32));
}
focus.focus(1);
let upper_id;
{
let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
let lower = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![2, 3]);
integrator.push_with_focus(Box::new(lower));
integrator.focus_mut().focus(3);
let upper = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![4]);
upper_id = integrator.push_with_focus(Box::new(upper));
let _ = integrator.focus_mut().graph_mut().remove(4);
integrator.resync_focus_state();
assert_eq!(integrator.focus().current(), Some(3));
integrator.focus_mut().focus(2);
integrator.resync_focus_state();
assert_eq!(integrator.focus().current(), Some(2));
integrator
.focus_mut()
.graph_mut()
.insert(FocusNode::new(4, Rect::new(0, 0, 10, 1)).with_tab_index(4));
integrator.resync_focus_state();
assert_eq!(integrator.focus().current(), Some(4));
let result = integrator.pop_id_with_focus(upper_id);
assert_eq!(result.map(|closed| closed.id), Some(upper_id));
assert_eq!(integrator.focus().current(), Some(2));
assert!(integrator.is_focus_trapped());
}
}
#[test]
fn focus_integration_pop_skips_closed_modal_focus_ids_when_background_focus_disappears() {
use crate::focus::{FocusManager, FocusNode};
use ftui_core::geometry::Rect;
let mut stack = ModalStack::new();
let mut focus = FocusManager::new();
focus
.graph_mut()
.insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
focus
.graph_mut()
.insert(FocusNode::new(50, Rect::new(0, 1, 10, 1)));
focus
.graph_mut()
.insert(FocusNode::new(100, Rect::new(0, 10, 10, 1)));
focus.focus(100);
{
let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1]);
integrator.push_with_focus(Box::new(modal));
let _ = integrator.focus_mut().graph_mut().remove(100);
let result = integrator.pop_with_focus();
assert!(result.is_some());
assert_eq!(integrator.focus().current(), Some(50));
assert!(!integrator.is_focus_trapped());
}
}
#[test]
fn focus_integration_pop_removes_closed_modal_focus_group() {
use crate::focus::{FocusManager, FocusNode};
use ftui_core::geometry::Rect;
let mut stack = ModalStack::new();
let mut focus = FocusManager::new();
focus
.graph_mut()
.insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
focus
.graph_mut()
.insert(FocusNode::new(2, Rect::new(0, 1, 10, 1)));
focus.focus(1);
{
let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![2]);
integrator.push_with_focus(Box::new(modal));
let result = integrator.pop_with_focus().unwrap();
let group_id = result.focus_group_id.unwrap();
assert!(!integrator.focus_mut().push_trap(group_id));
assert!(!integrator.is_focus_trapped());
assert_eq!(integrator.focus().current(), Some(1));
}
}
#[test]
fn focus_integration_escape_restores_focus() {
use crate::focus::{FocusManager, FocusNode};
use ftui_core::geometry::Rect;
let mut stack = ModalStack::new();
let mut focus = FocusManager::new();
focus
.graph_mut()
.insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
focus
.graph_mut()
.insert(FocusNode::new(100, Rect::new(0, 10, 10, 1)));
focus.focus(100);
{
let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1]);
integrator.push_with_focus(Box::new(modal));
assert!(integrator.is_focus_trapped());
let escape = Event::Key(KeyEvent {
code: KeyCode::Escape,
modifiers: Modifiers::empty(),
kind: KeyEventKind::Press,
});
let result = integrator.handle_event(&escape, None);
assert!(result.is_some());
assert!(!integrator.is_focus_trapped());
assert_eq!(integrator.focus().current(), Some(100));
}
}
#[test]
fn focus_integration_applies_host_focus_events() {
use crate::focus::{FocusManager, FocusNode};
use ftui_core::geometry::Rect;
let mut stack = ModalStack::new();
let mut focus = FocusManager::new();
focus
.graph_mut()
.insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
focus
.graph_mut()
.insert(FocusNode::new(2, Rect::new(0, 1, 10, 1)));
focus.focus(2);
{
let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1]);
integrator.push_with_focus(Box::new(modal));
assert_eq!(integrator.focus().current(), Some(1));
let blur = Event::Focus(false);
assert!(integrator.handle_event(&blur, None).is_none());
assert_eq!(integrator.focus().current(), None);
let gain = Event::Focus(true);
assert!(integrator.handle_event(&gain, None).is_none());
assert_eq!(integrator.focus().current(), Some(1));
}
}
#[test]
fn focus_integration_non_aria_modal_no_trap() {
use crate::focus::{FocusManager, FocusNode};
use ftui_core::geometry::Rect;
let mut stack = ModalStack::new();
let mut focus = FocusManager::new();
focus
.graph_mut()
.insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
focus
.graph_mut()
.insert(FocusNode::new(100, Rect::new(0, 10, 10, 1)));
focus.focus(100);
{
let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
let modal = WidgetModalEntry::new(StubWidget)
.with_aria_modal(false)
.with_focusable_ids(vec![1]);
integrator.push_with_focus(Box::new(modal));
assert!(!integrator.is_focus_trapped());
}
}
#[test]
fn focus_integration_rejected_empty_trap_does_not_leave_focus_group_behind() {
use crate::focus::{FocusManager, FocusNode};
use ftui_core::geometry::Rect;
let mut stack = ModalStack::new();
let mut focus = FocusManager::new();
focus
.graph_mut()
.insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
focus.focus(1);
{
let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![]);
integrator.push_with_focus(Box::new(modal));
assert!(!integrator.is_focus_trapped());
assert!(!integrator.focus_mut().push_trap(1));
assert_eq!(integrator.focus().current(), Some(1));
}
}
#[test]
fn recreated_focus_integration_does_not_reuse_live_group_ids() {
use crate::focus::{FocusManager, FocusNode};
use ftui_core::geometry::Rect;
let mut stack = ModalStack::new();
let mut focus = FocusManager::new();
focus
.graph_mut()
.insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
focus
.graph_mut()
.insert(FocusNode::new(2, Rect::new(0, 1, 10, 1)));
focus
.graph_mut()
.insert(FocusNode::new(100, Rect::new(0, 10, 10, 1)));
focus.focus(100);
let first_group_id = {
let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1]);
let modal_id = integrator.push_with_focus(Box::new(modal));
integrator.stack().focus_group_id(modal_id).unwrap()
};
let second_group_id = {
let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![2]);
let modal_id = integrator.push_with_focus(Box::new(modal));
integrator.stack().focus_group_id(modal_id).unwrap()
};
assert_ne!(first_group_id, second_group_id);
{
let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
let top = integrator.pop_with_focus().unwrap();
assert_eq!(top.focus_group_id, Some(second_group_id));
assert!(integrator.is_focus_trapped());
assert_eq!(integrator.focus().current(), Some(1));
let lower = integrator.pop_with_focus().unwrap();
assert_eq!(lower.focus_group_id, Some(first_group_id));
assert!(!integrator.is_focus_trapped());
assert_eq!(integrator.focus().current(), Some(100));
}
}
#[test]
fn focus_integration_does_not_collide_with_existing_group_ids() {
use crate::focus::{FocusManager, FocusNode};
use ftui_core::geometry::Rect;
let mut stack = ModalStack::new();
let mut focus = FocusManager::new();
focus
.graph_mut()
.insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
focus
.graph_mut()
.insert(FocusNode::new(99, Rect::new(0, 1, 10, 1)));
focus
.graph_mut()
.insert(FocusNode::new(100, Rect::new(0, 10, 10, 1)));
focus.create_group(1000, vec![99]);
focus.focus(100);
{
let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1]);
integrator.push_with_focus(Box::new(modal));
let _ = integrator.pop_with_focus().unwrap();
assert!(integrator.focus_mut().push_trap(1000));
assert_eq!(integrator.focus().current(), Some(99));
}
}
#[test]
fn focus_integration_nested_modals() {
use crate::focus::{FocusManager, FocusNode};
use ftui_core::geometry::Rect;
let mut stack = ModalStack::new();
let mut focus = FocusManager::new();
focus
.graph_mut()
.insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
focus
.graph_mut()
.insert(FocusNode::new(2, Rect::new(0, 1, 10, 1)));
focus
.graph_mut()
.insert(FocusNode::new(10, Rect::new(0, 5, 10, 1)));
focus
.graph_mut()
.insert(FocusNode::new(11, Rect::new(0, 6, 10, 1)));
focus
.graph_mut()
.insert(FocusNode::new(100, Rect::new(0, 10, 10, 1)));
focus.focus(100);
{
let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
let modal1 = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1, 2]);
integrator.push_with_focus(Box::new(modal1));
assert_eq!(integrator.focus().current(), Some(1));
let modal2 = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![10, 11]);
integrator.push_with_focus(Box::new(modal2));
assert_eq!(integrator.focus().current(), Some(10));
integrator.pop_with_focus();
assert_eq!(integrator.focus().current(), Some(1));
integrator.pop_with_focus();
assert_eq!(integrator.focus().current(), Some(100));
}
}
#[cfg(feature = "tracing")]
#[test]
fn tracing_modal_render_span_has_required_fields() {
let state = Arc::new(Mutex::new(TraceState::default()));
let _trace_test_guard = crate::tracing_test_support::acquire();
let subscriber = tracing_subscriber::registry().with(TraceCapture {
state: Arc::clone(&state),
});
let _guard = tracing::subscriber::set_default(subscriber);
tracing::callsite::rebuild_interest_cache();
let mut stack = ModalStack::new();
stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
let mut pool = GraphemePool::new();
let mut frame = Frame::new(80, 24, &mut pool);
stack.render(&mut frame, Rect::new(0, 0, 80, 24));
tracing::callsite::rebuild_interest_cache();
let snapshot = state.lock().expect("trace state lock");
assert!(snapshot.modal_render_seen, "expected modal.render span");
assert!(
snapshot.modal_render_has_modal_type,
"modal.render missing modal_type field"
);
assert!(
snapshot.modal_render_has_focus_trapped,
"modal.render missing focus_trapped field"
);
assert!(
snapshot.modal_render_has_backdrop_active,
"modal.render missing backdrop_active field"
);
assert!(
snapshot.modal_render_duration_recorded,
"modal.render did not record render_duration_us"
);
}
#[cfg(feature = "tracing")]
#[test]
fn tracing_focus_change_and_trap_events_emitted_for_modal_lifecycle() {
use crate::focus::{FocusManager, FocusNode};
let state = Arc::new(Mutex::new(TraceState::default()));
let _trace_test_guard = crate::tracing_test_support::acquire();
let subscriber = tracing_subscriber::registry().with(TraceCapture {
state: Arc::clone(&state),
});
let _guard = tracing::subscriber::set_default(subscriber);
let mut stack = ModalStack::new();
let mut focus = FocusManager::new();
focus
.graph_mut()
.insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
focus
.graph_mut()
.insert(FocusNode::new(2, Rect::new(0, 1, 10, 1)));
focus
.graph_mut()
.insert(FocusNode::new(100, Rect::new(0, 10, 10, 1)));
focus.focus(100);
tracing::callsite::rebuild_interest_cache();
{
let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1, 2]);
tracing::callsite::rebuild_interest_cache();
integrator.push_with_focus(Box::new(modal));
let escape = Event::Key(KeyEvent {
code: KeyCode::Escape,
modifiers: Modifiers::empty(),
kind: KeyEventKind::Press,
});
tracing::callsite::rebuild_interest_cache();
let _ = integrator.handle_event(&escape, None);
}
tracing::callsite::rebuild_interest_cache();
let snapshot = state.lock().expect("trace state lock");
assert!(
snapshot.focus_change_count >= 2,
"expected focus.change events for trap lifecycle, got {}",
snapshot.focus_change_count
);
assert!(
snapshot.trap_push_count >= 1,
"expected focus.trap_push event"
);
assert!(
snapshot.trap_pop_count >= 1,
"expected focus.trap_pop event"
);
}
}