use std::{
cell::Cell,
collections::{HashMap, HashSet},
fmt,
rc::Rc,
};
use ratatui::{
Frame,
buffer::Buffer,
layout::{Position, Rect},
};
use crate::Theme;
use crate::backdrop::dim_background;
use super::{
ChildId, Component, Event, EventCtx, EventResult, FocusState, HoverState, KeyCode, KeyEvent,
ModalState, MouseButton, MouseEvent, MouseKind, MouseTracker, Painter, PreparedComponent,
RenderCtx, ScopeOptions, Step, TabWrap,
component::{PaintTarget, TransientMap},
};
struct FocusBinding<State, Msg> {
read: Box<dyn Fn(&State) -> &FocusState>,
on_change: Box<dyn Fn(FocusState) -> Msg>,
}
struct HoverBinding<State, Msg> {
read: Box<dyn Fn(&State) -> &HoverState>,
on_change: Box<dyn Fn(HoverState) -> Msg>,
}
struct ModalBinding<State> {
read: Box<dyn Fn(&State) -> &ModalState>,
}
enum FocusAdvance {
Move(FocusState),
Consumed,
Ignored,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LayerKind {
Modal,
Popup,
Hint,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[expect(
clippy::struct_excessive_bools,
reason = "a policy table: each flag is one independent layer behavior"
)]
struct LayerPolicy {
dims: bool,
exclusive: bool,
holds_focus: bool,
traps_keys: bool,
hit_testable: bool,
allows_focus: bool,
dismiss_on_outside_press: bool,
}
impl LayerPolicy {
const fn base() -> Self {
Self {
dims: false,
exclusive: false,
holds_focus: false,
traps_keys: false,
hit_testable: true,
allows_focus: true,
dismiss_on_outside_press: false,
}
}
}
impl LayerKind {
const fn policy(self) -> LayerPolicy {
match self {
Self::Modal => LayerPolicy {
dims: true,
exclusive: true,
holds_focus: true,
traps_keys: true,
..LayerPolicy::base()
},
Self::Popup => LayerPolicy {
dismiss_on_outside_press: true,
..LayerPolicy::base()
},
Self::Hint => LayerPolicy {
hit_testable: false,
allows_focus: false,
..LayerPolicy::base()
},
}
}
}
pub(crate) struct Node<State, Msg> {
id: ChildId,
path: Vec<ChildId>,
parent: Option<usize>,
children: Vec<usize>,
area: Rect,
options: ScopeOptions,
is_scope: bool,
self_focusable: bool,
focuses_on_click: bool,
component: Option<Box<dyn Component<State, Msg>>>,
layer: usize,
layer_kind: Option<LayerKind>,
on_dismiss: Option<Box<dyn Fn() -> Msg>>,
}
impl<State, Msg> fmt::Debug for Node<State, Msg> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Node")
.field("id", &self.id)
.field("path", &self.path)
.field("parent", &self.parent)
.field("children", &self.children)
.field("area", &self.area)
.field("options", &self.options)
.field("is_scope", &self.is_scope)
.field("self_focusable", &self.self_focusable)
.field("focuses_on_click", &self.focuses_on_click)
.field("component", &self.component.is_some())
.field("layer", &self.layer)
.field("layer_kind", &self.layer_kind)
.field("on_dismiss", &self.on_dismiss.is_some())
.finish()
}
}
pub(crate) struct Surface<State, Msg> {
nodes: Vec<Node<State, Msg>>,
roots: Vec<usize>,
layer_roots: Vec<usize>,
layer_policies: Vec<LayerPolicy>,
}
impl<State, Msg> Default for Surface<State, Msg> {
fn default() -> Self {
Self {
nodes: Vec::new(),
roots: Vec::new(),
layer_roots: Vec::new(),
layer_policies: vec![LayerPolicy::base()],
}
}
}
impl<State, Msg> fmt::Debug for Surface<State, Msg> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Surface")
.field("nodes", &self.nodes.len())
.field("roots", &self.roots.len())
.field("layer_roots", &self.layer_roots.len())
.field("layer_policies", &self.layer_policies.len())
.finish()
}
}
impl<State, Msg> Surface<State, Msg> {
fn has_hit_geometry(&self, index: usize) -> bool {
let area = self.nodes[index].area;
area.width > 0 && area.height > 0
}
fn participates(&self, index: usize) -> bool {
let node = &self.nodes[index];
(node.is_scope || self.has_hit_geometry(index))
&& node.parent.is_none_or(|parent| self.participates(parent))
}
fn children(&self, parent: Option<usize>) -> &[usize] {
parent.map_or(self.roots.as_slice(), |index| {
self.nodes[index].children.as_slice()
})
}
fn interactive(&self, index: usize) -> bool {
self.exclusive_root()
.is_none_or(|root| self.inside(index, root))
}
fn policy(&self, layer: usize) -> LayerPolicy {
self.layer_policies
.get(layer)
.copied()
.unwrap_or_else(LayerPolicy::base)
}
fn top_layer_root(&self, wants: impl Fn(LayerPolicy) -> bool) -> Option<usize> {
self.layer_roots
.iter()
.rev()
.copied()
.find(|&root| wants(self.layer_kind_policy(root)))
}
fn layer_kind_policy(&self, root: usize) -> LayerPolicy {
self.nodes[root]
.layer_kind
.map_or_else(LayerPolicy::base, LayerKind::policy)
}
fn exclusive_root(&self) -> Option<usize> {
self.top_layer_root(|policy| policy.exclusive)
}
fn focus_root(&self) -> Option<usize> {
self.top_layer_root(|policy| policy.holds_focus)
}
fn modal_roots(&self) -> impl Iterator<Item = usize> + '_ {
self.layer_roots
.iter()
.copied()
.filter(|&root| self.nodes[root].layer_kind == Some(LayerKind::Modal))
}
fn inside(&self, index: usize, root: usize) -> bool {
self.nodes[index].path.starts_with(&self.nodes[root].path)
}
fn traversal_roots(&self) -> Vec<usize> {
self.focus_root()
.map_or_else(|| self.roots.clone(), |root| vec![root])
}
fn contains_declared_path(&self, path: &[ChildId]) -> bool {
self.nodes.iter().any(|node| node.path == path)
}
fn nodes_along_path(&self, path: &[ChildId]) -> Vec<usize> {
let mut parent = None;
let mut matched = Vec::new();
for id in path {
let Some(index) = self
.children(parent)
.iter()
.copied()
.find(|&index| self.nodes[index].id == *id)
else {
break;
};
matched.push(index);
parent = Some(index);
}
matched
}
fn contains_hit_path(&self, path: &[ChildId]) -> bool {
let matched = self.nodes_along_path(path);
matched.len() == path.len()
&& matched.last().is_some_and(|&index| {
self.participates(index) && self.has_hit_geometry(index) && self.interactive(index)
})
}
fn contains_participating_path(&self, path: &[ChildId]) -> bool {
let matched = self.nodes_along_path(path);
matched.len() == path.len()
&& matched
.last()
.is_some_and(|&index| self.participates(index))
}
fn hit_index(&self, point: Position) -> Option<usize> {
let mut best: Option<(usize, usize)> = None;
for (index, node) in self.nodes.iter().enumerate() {
if self.policy(node.layer).hit_testable
&& self.interactive(index)
&& self.participates(index)
&& self.has_hit_geometry(index)
&& node.area.contains(point)
{
let key = (node.layer, index);
if best.is_none_or(|best| key > best) {
best = Some(key);
}
}
}
best.map(|(_, index)| index)
}
fn hit_path(&self, point: Position) -> Option<Vec<ChildId>> {
self.hit_index(point)
.map(|index| self.nodes[index].path.clone())
}
fn is_layer_root(&self, index: usize) -> bool {
self.nodes[index].layer_kind.is_some()
}
fn mouse_bubble_chain(&self, path: &[ChildId]) -> (Vec<usize>, bool) {
let mut matched = self.nodes_along_path(path);
if let Some(position) = matched.iter().rposition(|&index| self.is_layer_root(index)) {
matched.drain(..position);
(matched, true)
} else {
(matched, false)
}
}
fn takes_focus(&self, index: usize) -> bool {
self.participates(index)
&& self.has_hit_geometry(index)
&& self.interactive(index)
&& self.policy(self.nodes[index].layer).allows_focus
&& self.nodes[index].self_focusable
}
fn focusable(&self, index: usize) -> bool {
self.participates(index)
&& (self.takes_focus(index)
|| self.nodes[index]
.children
.iter()
.any(|&child| self.focusable(child)))
}
fn find_focusable(&self, candidates: &[usize], direction: Step) -> Option<usize> {
let mut iter = candidates.iter().copied();
match direction {
Step::Forward => iter.find(|&index| self.focusable(index)),
Step::Backward => iter.rfind(|&index| self.focusable(index)),
}
}
fn edge_child(&self, parent: Option<usize>, direction: Step) -> Option<usize> {
let candidates = parent.map_or_else(
|| self.traversal_roots(),
|index| self.nodes[index].children.clone(),
);
self.find_focusable(&candidates, direction)
}
fn extend_to_edge(&self, index: usize, direction: Step, path: &mut Vec<ChildId>) -> bool {
path.push(self.nodes[index].id.clone());
if let Some(child) = self.edge_child(Some(index), direction) {
return self.extend_to_edge(child, direction, path);
}
if self.takes_focus(index) {
true
} else {
path.pop();
false
}
}
fn edge_focus(&self, parent: Option<usize>, direction: Step) -> Option<FocusState> {
let index = self.edge_child(parent, direction)?;
self.descend_focus(index, direction)
}
fn descend_focus(&self, index: usize, direction: Step) -> Option<FocusState> {
let node_path = &self.nodes[index].path;
let mut path = node_path[..node_path.len() - 1].to_vec();
self.extend_to_edge(index, direction, &mut path)
.then(|| FocusState::intent(path))
}
fn resolve_focus(&self, stored: &FocusState) -> FocusState {
if let Some(root) = self.focus_root() {
let root_path = self.nodes[root].path.as_slice();
if !stored.path().starts_with(root_path) {
let open_intent = stored.path() == [self.nodes[root].id.clone()];
if !open_intent
&& !stored.path().is_empty()
&& !self.contains_declared_path(stored.path())
{
return stored.clone();
}
return self.descend_focus(root, Step::Forward).unwrap_or_else(|| {
if stored.path().is_empty() {
FocusState::default()
} else {
stored.clone()
}
});
}
}
if stored.path().is_empty() {
return self.edge_focus(None, Step::Forward).unwrap_or_default();
}
let matched = self.nodes_along_path(stored.path());
if matched.len() != stored.path().len() {
return stored.clone();
}
let Some(&target) = matched.last() else {
return stored.clone();
};
let Some(child) = self.edge_child(Some(target), Step::Forward) else {
return stored.clone();
};
let mut path = stored.path().to_vec();
self.extend_to_edge(child, Step::Forward, &mut path);
FocusState::intent(path)
}
fn explicit_focus(&self, path: &[ChildId]) -> Option<FocusState> {
let matched = self.nodes_along_path(path);
if matched.len() != path.len() {
return None;
}
let &target = matched.last()?;
if !matched.iter().all(|&index| self.focusable(index)) {
return None;
}
let mut focus = path.to_vec();
if let Some(child) = self.edge_child(Some(target), Step::Forward) {
self.extend_to_edge(child, Step::Forward, &mut focus);
} else if !self.takes_focus(target) {
return None;
}
Some(FocusState::intent(focus))
}
fn hover_focus(
&self,
path: &[ChildId],
focus: &FocusState,
root_options: &ScopeOptions,
) -> Option<FocusState> {
let matched = self.nodes_along_path(path);
if matched.len() != path.len() {
return None;
}
let mut boundaries = std::iter::once((root_options, matched.first().copied())).chain(
matched
.iter()
.copied()
.zip(matched.iter().copied().skip(1))
.map(|(parent, child)| (&self.nodes[parent].options, Some(child))),
);
boundaries.find_map(|(options, child)| {
let child = child?;
let child_path = &self.nodes[child].path;
(options.hover_focus && !focus.path().starts_with(child_path) && self.focusable(child))
.then(|| self.explicit_focus(child_path))
.flatten()
})
}
fn next_focus(
&self,
focus: &FocusState,
direction: Step,
root_options: &ScopeOptions,
) -> FocusAdvance {
if let Some(root) = self.focus_root() {
let root_path = self.nodes[root].path.as_slice();
if !focus.path().starts_with(root_path) {
return self
.edge_focus(None, direction)
.map_or(FocusAdvance::Consumed, FocusAdvance::Move);
}
}
let matched = self.nodes_along_path(focus.path());
if matched.len() != focus.path().len() {
let parent = matched.last().copied();
if let Some(next) = self.edge_focus(parent, direction) {
return FocusAdvance::Move(next);
}
let options = parent.map_or(root_options, |index| &self.nodes[index].options);
if options.tab_wrap == TabWrap::Wrap {
return FocusAdvance::Consumed;
}
let Some(current) = parent else {
return FocusAdvance::Ignored;
};
return self.next_from_scope(current, focus, direction, root_options);
}
let Some(current) = matched.last().copied() else {
return self
.edge_focus(None, direction)
.map_or(FocusAdvance::Ignored, FocusAdvance::Move);
};
self.next_from_scope(current, focus, direction, root_options)
}
fn next_from_scope(
&self,
mut current: usize,
focus: &FocusState,
direction: Step,
root_options: &ScopeOptions,
) -> FocusAdvance {
loop {
let parent = self.nodes[current].parent;
let siblings = self.children(parent);
let position = siblings
.iter()
.position(|&index| index == current)
.expect("focused node is registered under its parent");
let remaining = match direction {
Step::Forward => &siblings[position + 1..],
Step::Backward => &siblings[..position],
};
let next = self.find_focusable(remaining, direction);
if let Some(next) = next {
let mut path = parent.map_or_else(Vec::new, |index| self.nodes[index].path.clone());
self.extend_to_edge(next, direction, &mut path);
return FocusAdvance::Move(FocusState::intent(path));
}
let tab_wrap = if self.focus_root() == Some(current) {
TabWrap::Wrap
} else {
parent.map_or(root_options.tab_wrap, |index| {
self.nodes[index].options.tab_wrap
})
};
if tab_wrap == TabWrap::Wrap {
let Some(next) = self.edge_child(parent, direction) else {
return FocusAdvance::Consumed;
};
let mut path = parent.map_or_else(Vec::new, |index| self.nodes[index].path.clone());
self.extend_to_edge(next, direction, &mut path);
let next = FocusState::intent(path);
return if next == *focus {
FocusAdvance::Consumed
} else {
FocusAdvance::Move(next)
};
}
let Some(parent) = parent else {
return FocusAdvance::Ignored;
};
current = parent;
}
}
}
type DeferredPaint<State> = Box<dyn FnOnce(&mut Painter<'_, '_>, &State)>;
fn interaction_flags(
path: &[ChildId],
focus: &FocusState,
hover: &HoverState,
) -> (bool, bool, bool, bool) {
(
focus.path() == path,
focus.path().starts_with(path),
hover.path() == path,
hover.path().starts_with(path),
)
}
struct DisplayPath<'a>(&'a [ChildId]);
impl fmt::Display for DisplayPath<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for (position, id) in self.0.iter().enumerate() {
if position > 0 {
f.write_str("/")?;
}
write!(f, "{id}")?;
}
Ok(())
}
}
enum PassKind<State, Msg> {
Structure,
Paint { expected: Surface<State, Msg> },
}
pub(crate) struct LayerCanvas {
kind: LayerKind,
area: Rect,
pub(crate) buffer: Buffer,
painted: Vec<Rect>,
}
impl LayerCanvas {
fn new(kind: LayerKind, area: Rect) -> Self {
Self {
kind,
area,
buffer: Buffer::empty(area),
painted: Vec::new(),
}
}
pub(crate) fn mark_painted(&mut self, area: Rect) {
let clipped = area.intersection(self.area);
if clipped.width > 0 && clipped.height > 0 {
self.painted.push(clipped);
}
}
}
pub(crate) struct DeclarationEnv<'a, 'frame, State> {
pub(crate) frame: &'a mut Frame<'frame>,
pub(crate) area: Rect,
pub(crate) state: &'a State,
pub(crate) theme: &'a Theme,
pub(crate) hover: &'a HoverState,
pub(crate) transients: Option<&'a mut TransientMap>,
pub(crate) depth: usize,
}
impl<'a, 'frame, State> DeclarationEnv<'a, 'frame, State> {
fn root(
frame: &'a mut Frame<'frame>,
state: &'a State,
theme: &'a Theme,
hover: &'a HoverState,
transients: &'a mut TransientMap,
) -> Self {
Self {
area: frame.area(),
frame,
state,
theme,
hover,
transients: Some(transients),
depth: 0,
}
}
fn nested(&mut self, area: Rect) -> DeclarationEnv<'_, 'frame, State> {
DeclarationEnv {
frame: &mut *self.frame,
area,
state: self.state,
theme: self.theme,
hover: self.hover,
transients: self.transients.as_deref_mut(),
depth: self.depth + 1,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct NodeRole {
is_scope: bool,
self_focusable: bool,
focuses_on_click: bool,
}
impl NodeRole {
fn scope(self_focusable: bool) -> Self {
Self {
is_scope: true,
self_focusable,
focuses_on_click: false,
}
}
fn component(self_focusable: bool, focuses_on_click: bool) -> Self {
Self {
is_scope: false,
self_focusable,
focuses_on_click,
}
}
}
pub(crate) struct RenderPass<State, Msg> {
kind: PassKind<State, Msg>,
surface: Surface<State, Msg>,
parent_stack: Vec<usize>,
deferred: Vec<(usize, DeferredPaint<State>)>,
focus: FocusState,
hover_position: Option<Position>,
scratch: Option<Buffer>,
failed: Rc<Cell<bool>>,
layers_declared: usize,
layer_stack: Vec<usize>,
canvases: Vec<LayerCanvas>,
canvas_stack: Vec<usize>,
}
struct PoisonOnUnwind {
failed: Rc<Cell<bool>>,
armed: bool,
}
impl Drop for PoisonOnUnwind {
fn drop(&mut self) {
if self.armed {
self.failed.set(true);
}
}
}
impl<State, Msg> RenderPass<State, Msg> {
fn new(kind: PassKind<State, Msg>, focus: FocusState) -> Self {
Self {
kind,
surface: Surface::default(),
parent_stack: Vec::new(),
deferred: Vec::new(),
focus,
hover_position: None,
scratch: None,
failed: Rc::new(Cell::new(false)),
layers_declared: 0,
layer_stack: Vec::new(),
canvases: Vec::new(),
canvas_stack: Vec::new(),
}
}
fn current_layer(&self) -> usize {
self.layer_stack.last().copied().unwrap_or(0)
}
pub(crate) fn current_path(&self) -> Option<&[ChildId]> {
let &index = self.parent_stack.last()?;
Some(&self.surface.nodes[index].path)
}
pub(crate) fn active_canvas_mut(&mut self) -> Option<&mut LayerCanvas> {
let index = self.canvas_stack.last().copied()?;
Some(&mut self.canvases[index])
}
fn layer(
&mut self,
kind: LayerKind,
area: Rect,
theme: &Theme,
state: &State,
declare_root: impl FnOnce(&mut Self, usize),
) {
self.begin_layer(kind, area);
let index = self.surface.nodes.len();
self.surface.layer_roots.push(index);
declare_root(self, index);
self.surface.nodes[index].layer_kind = Some(kind);
self.end_layer(theme, state);
}
fn begin_layer(&mut self, kind: LayerKind, area: Rect) {
self.layers_declared += 1;
self.layer_stack.push(self.layers_declared);
debug_assert_eq!(self.surface.layer_policies.len(), self.layers_declared);
self.surface.layer_policies.push(kind.policy());
if self.paints() {
self.canvases.push(LayerCanvas::new(kind, area));
self.canvas_stack.push(self.canvases.len() - 1);
}
}
fn end_layer(&mut self, theme: &Theme, state: &State) {
let layer = self
.layer_stack
.pop()
.expect("end_layer closes a layer begin_layer opened");
if self.paints() {
let canvas_index = self
.canvas_stack
.pop()
.expect("paint pass opened a canvas for this layer");
self.flush_deferred_for(layer, theme, state, canvas_index);
}
}
fn flush_deferred_for(
&mut self,
layer: usize,
theme: &Theme,
state: &State,
canvas_index: usize,
) {
self.guarded(|pass| {
let mut thunks = Vec::new();
let mut index = 0;
while index < pass.deferred.len() {
if pass.deferred[index].0 == layer {
thunks.push(pass.deferred.remove(index).1);
} else {
index += 1;
}
}
for thunk in thunks {
let mut painter = Painter {
target: PaintTarget::Canvas(&mut pass.canvases[canvas_index]),
theme,
};
thunk(&mut painter, state);
}
});
}
pub(crate) const fn paints(&self) -> bool {
matches!(self.kind, PassKind::Paint { .. })
}
pub(crate) fn scratch_buffer(&mut self, area: Rect) -> &mut Buffer {
self.scratch.get_or_insert_with(|| Buffer::empty(area))
}
pub(crate) fn guarded<R>(&mut self, f: impl FnOnce(&mut Self) -> R) -> R {
let mut poison = PoisonOnUnwind {
failed: Rc::clone(&self.failed),
armed: true,
};
let result = f(self);
poison.armed = false;
result
}
fn begin_node(
&mut self,
id: ChildId,
area: Rect,
options: ScopeOptions,
role: NodeRole,
) -> usize {
let NodeRole {
is_scope,
self_focusable,
focuses_on_click,
} = role;
let parent = self.parent_stack.last().copied();
let siblings = parent.map_or(self.surface.roots.as_slice(), |index| {
self.surface.nodes[index].children.as_slice()
});
assert!(
!siblings
.iter()
.any(|&index| self.surface.nodes[index].id == id),
"duplicate child id `{id}` in one declaration scope"
);
let mut path = parent.map_or_else(Vec::new, |index| self.surface.nodes[index].path.clone());
path.push(id.clone());
let index = self.surface.nodes.len();
let layer = self.current_layer();
self.validate_against_structure(index, &path, area, &options, role, layer);
self.surface.nodes.push(Node {
id,
path,
parent,
children: Vec::new(),
area,
options,
is_scope,
self_focusable,
focuses_on_click,
component: None,
layer,
layer_kind: None,
on_dismiss: None,
});
if let Some(parent) = parent {
self.surface.nodes[parent].children.push(index);
} else {
self.surface.roots.push(index);
}
index
}
fn validate_against_structure(
&self,
index: usize,
path: &[ChildId],
area: Rect,
options: &ScopeOptions,
role: NodeRole,
layer: usize,
) {
let PassKind::Paint { expected } = &self.kind else {
return;
};
let Some(prior) = expected.nodes.get(index) else {
panic!(
"declaration closure is not idempotent: the paint pass declared `{}`, which the \
structure pass never declared; declared structure may depend on app state but \
not on the pass-computed focus flags",
DisplayPath(path)
);
};
let mismatch = if prior.path != path {
Some(format!("path `{}`", DisplayPath(&prior.path)))
} else if prior.area != area {
Some(format!("area {:?}", prior.area))
} else if prior.options != *options {
Some(format!("scope options {:?}", prior.options))
} else if prior.layer != layer {
Some(format!("layer {}", prior.layer))
} else if prior.is_scope != role.is_scope
|| prior.self_focusable != role.self_focusable
|| prior.focuses_on_click != role.focuses_on_click
{
Some("focusability".to_owned())
} else {
None
};
if let Some(differs) = mismatch {
panic!(
"declaration closure is not idempotent: `{}` was declared with {differs} in the \
structure pass but differs in the paint pass; declared structure may depend on \
app state but not on the pass-computed focus flags",
DisplayPath(path)
);
}
}
pub(crate) fn render_component(
&mut self,
id: ChildId,
component: impl Component<State, Msg> + 'static,
env: DeclarationEnv<'_, '_, State>,
) {
let state = env.state;
let prepared = self.guarded(|_| PreparedComponent::prepare(Box::new(component), state));
self.render_prepared_component(id, prepared, env);
}
pub(crate) fn render_prepared_component(
&mut self,
id: ChildId,
prepared: PreparedComponent<State, Msg>,
mut env: DeclarationEnv<'_, '_, State>,
) {
self.guarded(|pass| {
let PreparedComponent {
mut component,
options,
self_focusable,
focuses_on_click,
} = prepared;
let role = NodeRole::component(
options.focusable || self_focusable,
focuses_on_click,
);
let area = env.area;
let interaction_area = component.interaction_area(area);
assert!(
interaction_area.width == 0
|| interaction_area.height == 0
|| (interaction_area.x >= area.x
&& interaction_area.y >= area.y
&& interaction_area.right() <= area.right()
&& interaction_area.bottom() <= area.bottom()),
"Component::interaction_area returned {interaction_area:?}, which is not fully contained in paint area {area:?}"
);
let index = pass.begin_node(id, interaction_area, options, role);
pass.parent_stack.push(index);
pass.declare(env.nested(area), |ctx| component.render(ctx));
pass.parent_stack.pop();
pass.surface.nodes[index].component = Some(component);
});
}
fn assert_unique_modal_id(&self, id: &ChildId) {
assert!(
!self
.surface
.modal_roots()
.any(|index| &self.surface.nodes[index].id == id),
"duplicate modal root id `{id}`"
);
}
pub(crate) fn modal(
&mut self,
id: ChildId,
component: impl Component<State, Msg> + 'static,
env: DeclarationEnv<'_, '_, State>,
) {
self.guarded(|pass| {
pass.assert_unique_modal_id(&id);
let (area, theme, state) = (env.area, env.theme, env.state);
pass.layer(LayerKind::Modal, area, theme, state, |pass, _| {
pass.render_component(id, component, env);
});
});
}
pub(crate) fn modal_scope<'frame>(
&mut self,
id: ChildId,
options: ScopeOptions,
env: DeclarationEnv<'_, 'frame, State>,
declare: impl FnOnce(&mut RenderCtx<'_, 'frame, State, Msg>),
) {
self.guarded(|pass| {
pass.assert_unique_modal_id(&id);
let (area, theme, state) = (env.area, env.theme, env.state);
pass.layer(LayerKind::Modal, area, theme, state, |pass, _| {
pass.scope(id, options, env, declare);
});
});
}
pub(crate) fn layer_scope<'frame>(
&mut self,
id: ChildId,
kind: LayerKind,
options: ScopeOptions,
on_dismiss: Option<Box<dyn Fn() -> Msg>>,
env: DeclarationEnv<'_, 'frame, State>,
declare: impl FnOnce(&mut RenderCtx<'_, 'frame, State, Msg>),
) {
debug_assert!(
on_dismiss.is_none() || kind.policy().dismiss_on_outside_press,
"a dismiss hook on a layer kind that never dismisses"
);
self.guarded(|pass| {
let (area, theme, state) = (env.area, env.theme, env.state);
pass.layer(kind, area, theme, state, |pass, index| {
pass.scope(id, options, env, declare);
pass.surface.nodes[index].on_dismiss = on_dismiss;
});
});
}
pub(crate) fn scope<'frame>(
&mut self,
id: ChildId,
options: ScopeOptions,
mut env: DeclarationEnv<'_, 'frame, State>,
declare: impl FnOnce(&mut RenderCtx<'_, 'frame, State, Msg>),
) {
self.guarded(|pass| {
let role = NodeRole::scope(options.focusable);
let area = env.area;
let index = pass.begin_node(id, area, options, role);
pass.parent_stack.push(index);
pass.declare(env.nested(area), declare);
pass.parent_stack.pop();
});
}
fn declare<'frame>(
&mut self,
env: DeclarationEnv<'_, 'frame, State>,
declare: impl FnOnce(&mut RenderCtx<'_, 'frame, State, Msg>),
) {
let DeclarationEnv {
frame,
area,
state,
theme,
hover,
transients,
depth,
} = env;
let hover_position = self.hover_position;
let (focused, contains_focus, hovered, contains_hover) =
self.parent_stack
.last()
.map_or((false, false, false, false), |&index| {
let path = self.surface.nodes[index].path.as_slice();
interaction_flags(path, &self.focus, hover)
});
self.guarded(|pass| {
let mut ctx = RenderCtx {
frame,
area,
theme,
focused,
contains_focus,
hovered,
contains_hover,
hover_position,
hover,
transients,
depth,
pass: Some(pass),
state: Some(state),
};
declare(&mut ctx);
});
}
pub(crate) fn defer_paint(
&mut self,
paint: impl FnOnce(&mut Painter<'_, '_>, &State) + 'static,
) {
if self.paints() {
self.deferred.push((self.current_layer(), Box::new(paint)));
}
}
fn finish_frame(&mut self, frame: &mut Frame, state: &State, theme: &Theme) {
if !self.paints() {
return;
}
for canvas in &self.canvases {
if canvas.kind.policy().dims {
dim_background(frame.buffer_mut(), canvas.area, theme.background);
}
let frame_area = frame.area();
let buffer = frame.buffer_mut();
for &rect in &canvas.painted {
let rect = rect.intersection(frame_area);
for y in rect.y..rect.bottom() {
for x in rect.x..rect.right() {
if let (Some(target), Some(source)) =
(buffer.cell_mut((x, y)), canvas.buffer.cell((x, y)))
{
*target = source.clone();
}
}
}
}
}
self.guarded(|pass| {
let mut painter = Painter {
target: PaintTarget::Frame(frame),
theme,
};
for (_, thunk) in pass.deferred.drain(..) {
thunk(&mut painter, state);
}
});
}
fn assert_valid(&self) {
assert!(
!self.failed.get(),
"cannot commit a failed declaration pass"
);
assert!(
self.parent_stack.is_empty() && self.layer_stack.is_empty(),
"cannot commit a declaration pass with unclosed components or layers"
);
assert!(
self.surface
.nodes
.iter()
.all(|node| node.is_scope || node.component.is_some()),
"cannot commit a declaration pass with incomplete components"
);
if let PassKind::Paint { expected } = &self.kind {
assert!(
self.surface.nodes.len() == expected.nodes.len(),
"declaration closure is not idempotent: the structure pass declared {} nodes but \
the paint pass declared {}; declared structure may depend on app state but not \
on the pass-computed focus flags",
expected.nodes.len(),
self.surface.nodes.len(),
);
assert!(
self.surface.layer_roots == expected.layer_roots,
"declaration closure is not idempotent: the two passes declared different \
layer roots"
);
}
}
fn finish(self) -> Surface<State, Msg> {
self.assert_valid();
self.surface
}
}
pub struct Ratcn<State, Msg> {
surface: Surface<State, Msg>,
has_rendered: bool,
focus_binding: Option<FocusBinding<State, Msg>>,
hover_binding: Option<HoverBinding<State, Msg>>,
modal_binding: Option<ModalBinding<State>>,
root_options: ScopeOptions,
mouse_tracker: MouseTracker,
captures: HashMap<MouseButton, Vec<ChildId>>,
press_targets: HashMap<MouseButton, Option<Vec<ChildId>>>,
suppressed: HashSet<MouseButton>,
transients: TransientMap,
hover_validity: HoverValidity,
}
#[derive(Debug, Default)]
struct HoverValidity {
stale: Option<Vec<ChildId>>,
last_pointer: Option<Position>,
pointer_exited: bool,
}
impl HoverValidity {
fn mark_stale(&mut self, path: &[ChildId]) {
self.stale = Some(path.to_vec());
}
fn clear_stale(&mut self) {
self.stale = None;
}
fn is_stale(&self, stored: &HoverState) -> bool {
self.stale.as_deref() == Some(stored.path())
}
fn flush(&mut self, stored: &HoverState) -> bool {
let stale = self.is_stale(stored);
if stale {
self.stale = None;
}
stale
}
fn pointer_at(&mut self, position: Position) {
self.pointer_exited = false;
self.last_pointer = Some(position);
}
fn pointer_gone(&mut self) {
self.pointer_exited = true;
self.last_pointer = None;
}
}
impl<State, Msg> fmt::Debug for Ratcn<State, Msg> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Ratcn")
.field("surface", &self.surface)
.field("has_rendered", &self.has_rendered)
.field("focus_binding", &self.focus_binding.is_some())
.field("hover_binding", &self.hover_binding.is_some())
.field("modal_binding", &self.modal_binding.is_some())
.field("root_options", &self.root_options)
.field("mouse_tracker", &self.mouse_tracker)
.field("captures", &self.captures)
.field("press_targets", &self.press_targets)
.field("suppressed", &self.suppressed)
.field("transients", &self.transients.len())
.field("hover_validity", &self.hover_validity)
.finish()
}
}
impl<State, Msg> Default for Ratcn<State, Msg> {
fn default() -> Self {
Self {
surface: Surface::default(),
has_rendered: false,
focus_binding: None,
hover_binding: None,
modal_binding: None,
root_options: ScopeOptions::default(),
mouse_tracker: MouseTracker::new(),
captures: HashMap::new(),
press_targets: HashMap::new(),
suppressed: HashSet::new(),
transients: HashMap::new(),
hover_validity: HoverValidity::default(),
}
}
}
impl<State, Msg> Ratcn<State, Msg> {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub const fn has_rendered(&self) -> bool {
self.has_rendered
}
#[must_use]
pub fn focus(
mut self,
read: impl Fn(&State) -> &FocusState + 'static,
on_change: impl Fn(FocusState) -> Msg + 'static,
) -> Self {
self.focus_binding = Some(FocusBinding {
read: Box::new(read),
on_change: Box::new(on_change),
});
self
}
#[must_use]
pub fn hover(
mut self,
read: impl Fn(&State) -> &HoverState + 'static,
on_change: impl Fn(HoverState) -> Msg + 'static,
) -> Self {
self.hover_binding = Some(HoverBinding {
read: Box::new(read),
on_change: Box::new(on_change),
});
self
}
#[must_use]
pub fn modals(mut self, read: impl Fn(&State) -> &ModalState + 'static) -> Self {
self.modal_binding = Some(ModalBinding {
read: Box::new(read),
});
self
}
#[must_use]
pub fn tab_wrap(mut self, tab_wrap: TabWrap) -> Self {
self.root_options.tab_wrap = tab_wrap;
self
}
#[must_use]
pub fn hover_focus(mut self) -> Self {
self.root_options.hover_focus = true;
self
}
#[must_use]
pub fn focus_key(
mut self,
chord: impl Into<super::KeyChord>,
path: impl IntoIterator<Item = impl Into<ChildId>>,
) -> Self {
self.root_options = self.root_options.focus_key(chord, path);
self
}
fn stored_focus(&self, state: &State) -> FocusState {
self.focus_binding
.as_ref()
.map_or_else(FocusState::default, |binding| (binding.read)(state).clone())
}
pub fn render<'frame>(
&mut self,
frame: &mut Frame<'frame>,
state: &State,
theme: &Theme,
mut declare: impl FnMut(&mut RenderCtx<'_, 'frame, State, Msg>),
) {
let focus_snapshot = self.stored_focus(state);
let stored_hover = self
.hover_binding
.as_ref()
.map_or_else(HoverState::default, |binding| (binding.read)(state).clone());
let hover = self.effective_hover(&stored_hover);
let hover_position = self.hover_validity.last_pointer;
let mut structure = RenderPass::new(PassKind::Structure, focus_snapshot.clone());
structure.hover_position = hover_position;
structure.declare(
DeclarationEnv::root(frame, state, theme, &hover, &mut self.transients),
&mut declare,
);
let mut structure_surface = structure.finish();
let resolved_focus = structure_surface.resolve_focus(&focus_snapshot);
for node in &mut structure_surface.nodes {
node.component = None;
}
let mut pass = RenderPass::new(
PassKind::Paint {
expected: structure_surface,
},
resolved_focus,
);
pass.hover_position = hover_position;
pass.declare(
DeclarationEnv::root(frame, state, theme, &hover, &mut self.transients),
&mut declare,
);
pass.assert_valid();
pass.finish_frame(frame, state, theme);
let next = pass.finish();
self.assert_modal_stack(&next, state);
self.commit_surface(next, &stored_hover);
}
fn assert_modal_stack(&self, next: &Surface<State, Msg>, state: &State) {
let Some(binding) = &self.modal_binding else {
return;
};
let semantic = (binding.read)(state).ids();
let declared = next.modal_roots().map(|index| &next.nodes[index].id);
assert!(
semantic.iter().eq(declared),
"declared modal roots do not match app-owned modal ids: expected {semantic:?}"
);
}
fn commit_surface(&mut self, next: Surface<State, Msg>, stored_hover: &HoverState) {
let active_modal_changed = self
.surface
.modal_roots()
.last()
.map(|index| &self.surface.nodes[index].id)
!= next.modal_roots().last().map(|index| &next.nodes[index].id);
let previous_had_modal = self.surface.modal_roots().next().is_some();
let next_has_modal = next.modal_roots().next().is_some();
let final_modal_closed = previous_had_modal && !next_has_modal;
let previous = std::mem::replace(&mut self.surface, next);
self.has_rendered = true;
self.reconcile_hover_validity(
stored_hover,
active_modal_changed && next_has_modal,
final_modal_closed,
);
if active_modal_changed {
self.cancel_pointer_gestures();
} else {
self.captures.retain(|button, path| {
let present = self.surface.contains_participating_path(path);
if !present {
self.suppressed.insert(*button);
}
present
});
}
self.transients
.retain(|path, _| self.surface.contains_declared_path(path));
drop(previous);
}
fn reconcile_hover_validity(
&mut self,
stored_hover: &HoverState,
new_modal_covers: bool,
final_modal_closed: bool,
) {
if new_modal_covers && !stored_hover.path().is_empty() {
self.hover_validity.mark_stale(stored_hover.path());
} else if final_modal_closed || !self.hover_validity.is_stale(stored_hover) {
self.hover_validity.clear_stale();
}
if !stored_hover.path().is_empty() && !self.surface.contains_hit_path(stored_hover.path()) {
self.hover_validity.mark_stale(stored_hover.path());
}
if !stored_hover.path().is_empty()
&& self.hover_validity.last_pointer.is_some_and(|position| {
self.surface.hit_path(position).as_deref() != Some(stored_hover.path())
})
{
self.hover_validity.mark_stale(stored_hover.path());
}
}
pub fn handle_event(&mut self, event: impl TryInto<Event>, state: &State) -> EventResult<Msg> {
let Ok(event) = event.try_into() else {
return EventResult::Ignored;
};
if !self.has_rendered {
return EventResult::Ignored;
}
if !self.modal_stack_matches(state) {
if let Event::Mouse(raw) = event {
self.consume_mouse_without_routing(raw);
}
return EventResult::Consumed;
}
let result = match event {
Event::Mouse(raw) => self.handle_mouse(raw, state),
ref event => self.route_key(event, state),
};
if matches!(result, EventResult::Ignored) && self.modal_is_open() {
EventResult::Consumed
} else {
result
}
}
fn route_key(&mut self, event: &Event, state: &State) -> EventResult<Msg> {
if self.surface.nodes.is_empty() {
return EventResult::Ignored;
}
let stored_focus = self.stored_focus(state);
let focus = self.surface.resolve_focus(&stored_focus);
let chain = self.key_bubble_chain(&focus);
let routed = self.dispatch_chain(&chain, event, state, &mut None, None);
if !matches!(routed, EventResult::Ignored) {
return routed;
}
let Event::Key(key) = event else {
return EventResult::Ignored;
};
match traversal_direction(key) {
Some(direction) => {
match self
.surface
.next_focus(&focus, direction, &self.root_options)
{
FocusAdvance::Move(next) => self.focus_result(next),
FocusAdvance::Consumed => EventResult::Consumed,
FocusAdvance::Ignored => EventResult::Ignored,
}
}
None => self
.focus_key_jump(key, &chain, &focus)
.unwrap_or(EventResult::Ignored),
}
}
fn key_bubble_chain(&self, focus: &FocusState) -> Vec<usize> {
let mut matched = self.surface.nodes_along_path(focus.path());
let Some(trapping_root) = self.surface.top_layer_root(|policy| policy.traps_keys) else {
return matched;
};
match matched.iter().position(|&index| index == trapping_root) {
Some(position) => {
matched.drain(..position);
matched
}
None => vec![trapping_root],
}
}
fn focus_key_jump(
&self,
key: &KeyEvent,
chain: &[usize],
focus: &FocusState,
) -> Option<EventResult<Msg>> {
for scope in chain.iter().rev().copied().map(Some).chain([None]) {
let options = scope.map_or(&self.root_options, |index| {
&self.surface.nodes[index].options
});
for binding in &options.focus_keys {
if !binding.chord.matches(key) {
continue;
}
let mut path =
scope.map_or_else(Vec::new, |index| self.surface.nodes[index].path.clone());
path.extend(binding.path.iter().cloned());
let Some(next) = self.surface.explicit_focus(&path) else {
continue;
};
return Some(if next == *focus {
EventResult::Consumed
} else {
self.focus_result(next)
});
}
}
None
}
#[must_use]
pub fn focus_path(&self, path: &[ChildId]) -> Option<FocusState> {
self.has_rendered
.then(|| self.surface.explicit_focus(path))
.flatten()
}
#[must_use]
pub fn modal_is_open(&self) -> bool {
self.surface.modal_roots().next().is_some()
}
fn modal_stack_matches(&self, state: &State) -> bool {
self.modal_binding.as_ref().is_none_or(|binding| {
let semantic = (binding.read)(state).ids();
let retained = self
.surface
.modal_roots()
.map(|index| &self.surface.nodes[index].id);
semantic.iter().eq(retained)
})
}
fn handle_mouse(&mut self, raw: MouseEvent, state: &State) -> EventResult<Msg> {
if raw.kind == MouseKind::Exited {
return self.handle_pointer_exit(state);
}
let (pressed, released) = self.observe_pointer(raw);
let held_motion = raw.kind == MouseKind::Moved && self.mouse_tracker.has_pressed_button();
let events = self
.mouse_tracker
.feed(raw, self.releases_on_press_target(raw));
let mut result = if held_motion && events.is_empty() {
EventResult::Consumed
} else {
EventResult::Ignored
};
for mouse in events {
let next = self.deliver_mouse(mouse, state);
if let Some(button) = pressed {
self.record_press_target(button, mouse);
}
match next {
EventResult::Emit(_) => {
result = next;
break;
}
EventResult::Consumed => result = next,
EventResult::Ignored => {}
}
}
if let Some(button) = released {
self.end_gesture(button);
}
result
}
fn handle_pointer_exit(&mut self, state: &State) -> EventResult<Msg> {
self.reset_pointer_gestures();
let result = self.hover_result(None, state);
self.hover_validity.pointer_gone();
match result {
result @ EventResult::Emit(_) => result,
EventResult::Consumed | EventResult::Ignored => EventResult::Consumed,
}
}
fn deliver_mouse(&mut self, mouse: MouseEvent, state: &State) -> EventResult<Msg> {
if mouse_button(mouse.kind).is_some_and(|button| self.suppressed.contains(&button)) {
return EventResult::Consumed;
}
let routed = self.route_mouse(mouse, state);
match (mouse.kind, &routed) {
(MouseKind::Down(_), EventResult::Ignored | EventResult::Consumed) => self
.popup_dismissal(Position::new(mouse.column, mouse.row))
.map_or(routed, EventResult::Emit),
_ => routed,
}
}
fn record_press_target(&mut self, button: MouseButton, mouse: MouseEvent) {
let target = self.captures.get(&button).cloned().or_else(|| {
self.surface
.hit_path(Position::new(mouse.column, mouse.row))
});
self.press_targets.insert(button, target);
}
fn end_gesture(&mut self, button: MouseButton) {
self.captures.remove(&button);
self.press_targets.remove(&button);
self.suppressed.remove(&button);
}
fn releases_on_press_target(&self, raw: MouseEvent) -> bool {
let MouseKind::Up(button) = raw.kind else {
return false;
};
if self.captures.contains_key(&button) && self.mouse_tracker.press_moved(button) {
return false;
}
self.press_targets.get(&button).is_some_and(|pressed| {
let current = self.surface.hit_path(Position::new(raw.column, raw.row));
pressed.as_deref() == current.as_deref()
})
}
fn cancel_pointer_gestures(&mut self) {
self.suppressed.extend(self.mouse_tracker.pressed_buttons());
self.suppressed.extend(self.captures.keys().copied());
self.captures.clear();
}
fn reset_pointer_gestures(&mut self) {
self.mouse_tracker.clear();
self.captures.clear();
self.press_targets.clear();
self.suppressed.clear();
}
fn consume_mouse_without_routing(&mut self, raw: MouseEvent) {
if raw.kind == MouseKind::Exited {
self.hover_validity.pointer_gone();
self.reset_pointer_gestures();
return;
}
let (pressed, released) = self.observe_pointer(raw);
self.cancel_pointer_gestures();
let _ = self.mouse_tracker.feed(raw, false);
if let Some(button) = pressed {
self.suppressed.insert(button);
}
if let Some(button) = released {
self.suppressed.remove(&button);
}
}
fn observe_pointer(&mut self, raw: MouseEvent) -> (Option<MouseButton>, Option<MouseButton>) {
self.hover_validity
.pointer_at(Position::new(raw.column, raw.row));
let pressed = match raw.kind {
MouseKind::Down(button) => Some(button),
_ => None,
};
let released = match raw.kind {
MouseKind::Up(button) => Some(button),
_ => None,
};
(pressed, released)
}
fn dispatch_chain(
&mut self,
chain: &[usize],
event: &Event,
state: &State,
capture: &mut Option<Vec<ChildId>>,
capture_button: Option<MouseButton>,
) -> EventResult<Msg> {
for &index in chain.iter().rev() {
if !self.surface.participates(index) {
continue;
}
let path = self.surface.nodes[index].path.clone();
let area = self.surface.nodes[index].area;
let Some(component) = self.surface.nodes[index].component.as_mut() else {
continue;
};
let mut ctx = EventCtx::at(&path, area, &mut self.transients, capture, capture_button);
let result = component.handle_event(event, state, &mut ctx);
if !matches!(result, EventResult::Ignored) {
return result;
}
}
EventResult::Ignored
}
fn route_mouse(&mut self, mouse: MouseEvent, state: &State) -> EventResult<Msg> {
let path = self.pointer_target(mouse);
if mouse.kind == MouseKind::Moved
&& let Some(staged) = self.stage_motion(path.as_deref(), state)
{
return staged;
}
let Some(path) = path else {
return if mouse.kind == MouseKind::Moved {
self.hover_result(None, state)
} else {
EventResult::Ignored
};
};
let (chain, layer_confined) = self.surface.mouse_bubble_chain(&path);
let routed = self.dispatch_pointer(&chain, mouse, state);
if !matches!(routed, EventResult::Ignored) {
return routed;
}
if mouse.kind == MouseKind::Moved {
return self.hover_result(Some(path), state);
}
if let Some(focused) = self.focus_on_press(&chain, mouse, state) {
return focused;
}
if layer_confined {
return EventResult::Consumed;
}
EventResult::Ignored
}
fn pointer_target(&self, mouse: MouseEvent) -> Option<Vec<ChildId>> {
let captured = match mouse.kind {
MouseKind::Drag(button)
| MouseKind::Up(button)
| MouseKind::Click(button)
| MouseKind::DragEnd(button) => self.captures.get(&button).cloned(),
_ => None,
};
captured.or_else(|| {
self.surface
.hit_path(Position::new(mouse.column, mouse.row))
})
}
fn dispatch_pointer(
&mut self,
chain: &[usize],
mouse: MouseEvent,
state: &State,
) -> EventResult<Msg> {
let capture_button = match mouse.kind {
MouseKind::Down(button) => Some(button),
_ => None,
};
let mut capture = None;
let result = self.dispatch_chain(
chain,
&Event::Mouse(mouse),
state,
&mut capture,
capture_button,
);
if let (Some(button), Some(path)) = (capture_button, capture) {
self.captures.insert(button, path);
}
result
}
fn focus_on_press(
&mut self,
chain: &[usize],
mouse: MouseEvent,
state: &State,
) -> Option<EventResult<Msg>> {
if !matches!(
mouse.kind,
MouseKind::Down(MouseButton::Left) | MouseKind::Click(MouseButton::Left)
) {
return None;
}
let target = chain.iter().rev().copied().find(|&index| {
let focuses = match mouse.kind {
MouseKind::Down(_) => !self.surface.nodes[index].focuses_on_click,
MouseKind::Click(_) => self.surface.nodes[index].focuses_on_click,
_ => false,
};
focuses && self.surface.focusable(index)
})?;
let stored = self.stored_focus(state);
let current = self.surface.resolve_focus(&stored);
let mut path = self.surface.nodes[target].path.clone();
if let Some(child) = self.surface.edge_child(Some(target), Step::Forward) {
self.surface.extend_to_edge(child, Step::Forward, &mut path);
}
let focus = FocusState::intent(path);
Some(if focus == current {
EventResult::Consumed
} else {
self.focus_result(focus)
})
}
fn popup_dismissal(&self, point: Position) -> Option<Msg> {
let target = self.surface.hit_index(point);
let top = self
.surface
.layer_roots
.iter()
.rev()
.copied()
.find(|&root| {
self.surface
.layer_kind_policy(root)
.dismiss_on_outside_press
&& self.surface.interactive(root)
&& self.surface.participates(root)
&& target.is_none_or(|hit| !self.surface.inside(hit, root))
})?;
self.surface.nodes[top].on_dismiss.as_ref().map(|f| f())
}
fn stage_motion(
&mut self,
path: Option<&[ChildId]>,
state: &State,
) -> Option<EventResult<Msg>> {
if let Some(path) = path {
let stored = self.stored_focus(state);
let focus = self.surface.resolve_focus(&stored);
if let Some(next) = self.surface.hover_focus(path, &focus, &self.root_options) {
return Some(self.focus_result(next));
}
}
let hover = self.hover_result(path.map(<[ChildId]>::to_vec), state);
matches!(hover, EventResult::Emit(_)).then_some(hover)
}
fn hover_result(&mut self, path: Option<Vec<ChildId>>, state: &State) -> EventResult<Msg> {
let Some(binding) = self.hover_binding.as_ref() else {
return EventResult::Ignored;
};
let stored = (binding.read)(state).clone();
let next = HoverState::intent(path.unwrap_or_default());
let was_stale = self.hover_validity.flush(&stored);
if !was_stale && next == self.effective_hover(&stored) {
return EventResult::Consumed;
}
EventResult::Emit((self
.hover_binding
.as_ref()
.expect("hover binding was read at the top of this function")
.on_change)(next))
}
fn effective_hover(&self, stored: &HoverState) -> HoverState {
if self.hover_validity.pointer_exited || self.hover_validity.is_stale(stored) {
HoverState::default()
} else if !self.has_rendered || self.surface.contains_hit_path(stored.path()) {
stored.clone()
} else {
HoverState::default()
}
}
fn focus_result(&self, focus: FocusState) -> EventResult<Msg> {
self.focus_binding
.as_ref()
.map_or(EventResult::Consumed, |binding| {
EventResult::Emit((binding.on_change)(focus))
})
}
#[cfg(test)]
fn declared_paths(&self) -> Vec<Vec<ChildId>> {
self.surface
.nodes
.iter()
.map(|node| node.path.clone())
.collect()
}
}
fn traversal_direction(key: &KeyEvent) -> Option<Step> {
match key.code {
KeyCode::Tab if !key.modifiers.any() => Some(Step::Forward),
KeyCode::BackTab if !key.modifiers.ctrl && !key.modifiers.alt => Some(Step::Backward),
_ => None,
}
}
fn mouse_button(kind: MouseKind) -> Option<MouseButton> {
match kind {
MouseKind::Down(button)
| MouseKind::Up(button)
| MouseKind::Click(button)
| MouseKind::Drag(button)
| MouseKind::DragEnd(button) => Some(button),
MouseKind::Moved | MouseKind::Exited | MouseKind::Scroll(_) => None,
}
}
#[cfg(test)]
mod tests {
use std::{
collections::hash_map::DefaultHasher,
hash::{Hash, Hasher},
panic::{AssertUnwindSafe, catch_unwind},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
};
use ratatui::{Terminal, backend::TestBackend};
use super::*;
use crate::runtime::PopupOptions;
use crate::{
Button, Dialog,
runtime::{CellOffset, DragOptions, DragPhase, KeyChord, Modifiers},
};
struct Leaf;
impl Component<(), ()> for Leaf {
fn render(&mut self, _ctx: &mut RenderCtx<'_, '_, (), ()>) {}
fn handle_event(
&mut self,
_event: &Event,
_state: &(),
_ctx: &mut EventCtx<'_>,
) -> EventResult<()> {
EventResult::Ignored
}
}
struct ContextProbe {
area: Rect,
}
impl Component<u8, ()> for ContextProbe {
fn render(&mut self, ctx: &mut RenderCtx<'_, '_, u8, ()>) {
assert_eq!(ctx.area(), self.area);
assert_eq!(*ctx.state(), 7);
}
fn is_focusable(&self, _state: &u8) -> bool {
true
}
}
struct Composite;
impl Component<(), ()> for Composite {
fn render(&mut self, ctx: &mut RenderCtx<'_, '_, (), ()>) {
let area = ctx.area();
ctx.render_component(ChildId::Static("leaf"), Leaf, area);
}
fn scope_options(&self) -> ScopeOptions {
ScopeOptions::default().tab_wrap(TabWrap::Wrap)
}
}
struct PanickingLeaf;
impl Component<(), ()> for PanickingLeaf {
fn render(&mut self, _ctx: &mut RenderCtx<'_, '_, (), ()>) {
panic!("leaf render failed");
}
}
struct PanickingScopeOptions;
impl Component<(), ()> for PanickingScopeOptions {
fn render(&mut self, _ctx: &mut RenderCtx<'_, '_, (), ()>) {}
fn scope_options(&self) -> ScopeOptions {
panic!("scope options failed");
}
}
struct PanickingResolve;
impl Component<(), ()> for PanickingResolve {
fn prepare(&mut self, _state: &()) {
panic!("declaration prop resolution failed");
}
fn render(&mut self, _ctx: &mut RenderCtx<'_, '_, (), ()>) {}
}
struct PanickingFocusable;
impl Component<(), ()> for PanickingFocusable {
fn render(&mut self, _ctx: &mut RenderCtx<'_, '_, (), ()>) {}
fn is_focusable(&self, _state: &()) -> bool {
panic!("focusability failed");
}
}
struct PanickingInteractionArea;
impl Component<(), ()> for PanickingInteractionArea {
fn render(&mut self, _ctx: &mut RenderCtx<'_, '_, (), ()>) {}
fn interaction_area(&self, _area: Rect) -> Rect {
panic!("interaction area failed");
}
}
struct EscapingInteractionArea;
impl Component<(), ()> for EscapingInteractionArea {
fn render(&mut self, _ctx: &mut RenderCtx<'_, '_, (), ()>) {}
fn interaction_area(&self, area: Rect) -> Rect {
Rect::new(area.x, area.y, area.width.saturating_add(1), area.height)
}
}
struct CatchingComposite;
impl Component<(), ()> for CatchingComposite {
fn render(&mut self, ctx: &mut RenderCtx<'_, '_, (), ()>) {
let area = ctx.area();
let caught = catch_unwind(AssertUnwindSafe(|| {
ctx.render_component(ChildId::Static("panicking-child"), PanickingLeaf, area);
}));
assert!(caught.is_err());
ctx.render_component(ChildId::Static("later-child"), Leaf, area);
}
}
#[derive(Default)]
struct FocusTestState {
focus: FocusState,
}
#[derive(Default)]
struct ModalTestState {
focus: FocusState,
modals: ModalState,
}
#[derive(Debug, Clone, PartialEq)]
enum ModalTestMsg {
Routed(&'static str),
Focus(FocusState),
}
struct ModalRoute(&'static str);
impl Component<ModalTestState, ModalTestMsg> for ModalRoute {
fn render(&mut self, _ctx: &mut RenderCtx<'_, '_, ModalTestState, ModalTestMsg>) {}
fn handle_event(
&mut self,
_event: &Event,
_state: &ModalTestState,
_ctx: &mut EventCtx<'_>,
) -> EventResult<ModalTestMsg> {
EventResult::Emit(ModalTestMsg::Routed(self.0))
}
fn is_focusable(&self, _state: &ModalTestState) -> bool {
true
}
}
struct ModalFocusRoute {
rendered: FocusRenderLog,
}
impl Component<ModalTestState, ModalTestMsg> for ModalFocusRoute {
fn render(&mut self, ctx: &mut RenderCtx<'_, '_, ModalTestState, ModalTestMsg>) {
self.rendered
.lock()
.expect("modal focus render log")
.push((ctx.focused, ctx.contains_focus));
}
fn handle_event(
&mut self,
_event: &Event,
_state: &ModalTestState,
_ctx: &mut EventCtx<'_>,
) -> EventResult<ModalTestMsg> {
EventResult::Emit(ModalTestMsg::Routed("dialog"))
}
fn is_focusable(&self, _state: &ModalTestState) -> bool {
true
}
}
#[derive(Default)]
struct ButtonTimingState {
focus: FocusState,
saving: bool,
accepted_saves: usize,
}
#[derive(Debug, Clone, PartialEq)]
enum ButtonTimingMsg {
Focus(FocusState),
Save,
Replacement,
}
fn update_button_timing(state: &mut ButtonTimingState, msg: ButtonTimingMsg) -> bool {
match msg {
ButtonTimingMsg::Focus(focus) => {
state.focus = focus;
true
}
ButtonTimingMsg::Save if !state.saving => {
state.saving = true;
state.accepted_saves += 1;
true
}
ButtonTimingMsg::Save | ButtonTimingMsg::Replacement => false,
}
}
#[derive(Debug, PartialEq)]
enum FocusTestMsg {
Focus(FocusState),
Activated(Vec<ChildId>),
Parent(Vec<ChildId>),
}
type FocusRenderLog = Arc<Mutex<Vec<(bool, bool)>>>;
struct FocusLeaf {
enabled: bool,
consume_focus_key: bool,
rendered: Option<FocusRenderLog>,
}
impl FocusLeaf {
fn enabled() -> Self {
Self {
enabled: true,
consume_focus_key: false,
rendered: None,
}
}
fn disabled() -> Self {
Self {
enabled: false,
consume_focus_key: false,
rendered: None,
}
}
fn recording(rendered: FocusRenderLog) -> Self {
Self {
enabled: true,
consume_focus_key: false,
rendered: Some(rendered),
}
}
fn consuming_focus_key() -> Self {
Self {
enabled: true,
consume_focus_key: true,
rendered: None,
}
}
}
impl Component<FocusTestState, FocusTestMsg> for FocusLeaf {
fn render(&mut self, ctx: &mut RenderCtx<'_, '_, FocusTestState, FocusTestMsg>) {
if let Some(rendered) = &self.rendered {
rendered
.lock()
.expect("render log")
.push((ctx.focused, ctx.contains_focus));
}
}
fn handle_event(
&mut self,
event: &Event,
_state: &FocusTestState,
ctx: &mut EventCtx<'_>,
) -> EventResult<FocusTestMsg> {
if !self.enabled {
return EventResult::Ignored;
}
match event {
Event::Key(key) if self.consume_focus_key && key.code == KeyCode::Char('x') => {
EventResult::Consumed
}
Event::Key(key) if key.code == KeyCode::Enter => {
EventResult::Emit(FocusTestMsg::Activated(ctx.path().to_vec()))
}
_ => EventResult::Ignored,
}
}
fn is_focusable(&self, _state: &FocusTestState) -> bool {
self.enabled
}
}
struct ClickFocusLeaf;
impl Component<FocusTestState, FocusTestMsg> for ClickFocusLeaf {
fn render(&mut self, _ctx: &mut RenderCtx<'_, '_, FocusTestState, FocusTestMsg>) {}
fn is_focusable(&self, _state: &FocusTestState) -> bool {
true
}
fn focuses_on_click(&self, _state: &FocusTestState) -> bool {
true
}
}
#[derive(Clone, Copy)]
enum DownBehavior {
CaptureAndIgnore,
Consume,
Emit,
}
struct DownFocusLeaf(DownBehavior);
impl Component<FocusTestState, FocusTestMsg> for DownFocusLeaf {
fn render(&mut self, _ctx: &mut RenderCtx<'_, '_, FocusTestState, FocusTestMsg>) {}
fn handle_event(
&mut self,
event: &Event,
_state: &FocusTestState,
ctx: &mut EventCtx<'_>,
) -> EventResult<FocusTestMsg> {
if !matches!(
event,
Event::Mouse(MouseEvent {
kind: MouseKind::Down(MouseButton::Left),
..
})
) {
return EventResult::Ignored;
}
match self.0 {
DownBehavior::CaptureAndIgnore => {
ctx.capture_pointer(MouseButton::Left);
EventResult::Ignored
}
DownBehavior::Consume => EventResult::Consumed,
DownBehavior::Emit => {
EventResult::Emit(FocusTestMsg::Activated(ctx.path().to_vec()))
}
}
}
fn is_focusable(&self, _state: &FocusTestState) -> bool {
true
}
}
struct AreaAwareComposite {
expected_area: Rect,
minimum_width: u16,
rendered: Arc<AtomicBool>,
}
impl Component<FocusTestState, FocusTestMsg> for AreaAwareComposite {
fn render(&mut self, ctx: &mut RenderCtx<'_, '_, FocusTestState, FocusTestMsg>) {
assert_eq!(ctx.area(), self.expected_area);
self.rendered.store(true, Ordering::SeqCst);
ctx.render_component(ChildId::Static("child"), FocusLeaf::enabled(), ctx.area());
}
fn scope_options(&self) -> ScopeOptions {
ScopeOptions::default()
}
fn interaction_area(&self, area: Rect) -> Rect {
assert_eq!(area, self.expected_area);
if area.width >= self.minimum_width {
area
} else {
Rect::default()
}
}
}
struct FocusComposite {
parent_rendered: FocusRenderLog,
child_rendered: FocusRenderLog,
}
struct EmptyComposite {
rendered: FocusRenderLog,
self_focusable: bool,
}
impl Component<FocusTestState, FocusTestMsg> for EmptyComposite {
fn render(&mut self, ctx: &mut RenderCtx<'_, '_, FocusTestState, FocusTestMsg>) {
self.rendered
.lock()
.expect("empty composite render log")
.push((ctx.focused, ctx.contains_focus));
}
fn scope_options(&self) -> ScopeOptions {
let options = ScopeOptions::default();
if self.self_focusable {
options.focusable()
} else {
options
}
}
}
impl Component<FocusTestState, FocusTestMsg> for FocusComposite {
fn render(&mut self, ctx: &mut RenderCtx<'_, '_, FocusTestState, FocusTestMsg>) {
let area = ctx.area();
self.parent_rendered
.lock()
.expect("parent render log")
.push((ctx.focused, ctx.contains_focus));
ctx.render_component(
ChildId::Static("child"),
FocusLeaf::recording(Arc::clone(&self.child_rendered)),
area,
);
}
fn handle_event(
&mut self,
event: &Event,
_state: &FocusTestState,
ctx: &mut EventCtx<'_>,
) -> EventResult<FocusTestMsg> {
match event {
Event::Key(key) if key.code == KeyCode::Char('p') => {
EventResult::Emit(FocusTestMsg::Parent(ctx.path().to_vec()))
}
_ => EventResult::Ignored,
}
}
fn scope_options(&self) -> ScopeOptions {
ScopeOptions::default()
}
}
fn render_leaf(ratcn: &mut Ratcn<(), ()>, terminal: &mut Terminal<TestBackend>, id: &ChildId) {
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &(), &theme, |ctx| {
ctx.render_component(id.clone(), Leaf, area);
});
})
.expect("draw");
}
fn render_timing_button(
ratcn: &mut Ratcn<ButtonTimingState, ButtonTimingMsg>,
terminal: &mut Terminal<TestBackend>,
state: &ButtonTimingState,
theme: &Theme,
message: impl Fn() -> ButtonTimingMsg + 'static,
) {
let message = Rc::new(message);
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, state, theme, |ctx| {
let message = Rc::clone(&message);
ctx.render_component(
ChildId::Static("save"),
Button::new("Save")
.disabled(state.saving)
.on_press(move || message()),
area,
);
});
})
.expect("draw");
}
fn hash(id: &ChildId) -> u64 {
let mut hasher = DefaultHasher::new();
id.hash(&mut hasher);
hasher.finish()
}
#[test]
fn static_and_dynamic_ids_share_content_identity_and_allocation() {
let shared: Arc<str> = Arc::from("row:42");
let dynamic = ChildId::Dynamic(Arc::clone(&shared));
let cloned = dynamic.clone();
let static_id = ChildId::Static("row:42");
assert_eq!(static_id, dynamic);
assert_eq!(hash(&static_id), hash(&dynamic));
assert_eq!(static_id.cmp(&dynamic), std::cmp::Ordering::Equal);
let ChildId::Dynamic(cloned_shared) = cloned else {
panic!("dynamic id changed representation");
};
assert!(Arc::ptr_eq(&shared, &cloned_shared));
}
#[test]
fn render_context_reports_each_declaration_area_and_state() {
let state = 7;
let scope_area = Rect::new(1, 0, 8, 3);
let component_area = Rect::new(2, 1, 3, 1);
let modal_area = Rect::new(0, 0, 10, 3);
let mut ratcn = Ratcn::<u8, ()>::new();
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
let theme = Theme::default_dark();
let scope_contains_focus = Arc::new(Mutex::new(Vec::new()));
terminal
.draw(|frame| {
let root_area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
assert_eq!(ctx.area(), root_area);
assert_eq!(*ctx.state(), state);
assert!(!ctx.contains_focus);
let scope_contains_focus = Arc::clone(&scope_contains_focus);
ctx.scope(
ChildId::Static("scope"),
scope_area,
ScopeOptions::default(),
move |ctx| {
assert_eq!(ctx.area(), scope_area);
assert_eq!(*ctx.state(), state);
scope_contains_focus
.lock()
.expect("scope flag log")
.push(ctx.contains_focus);
ctx.render_component(
ChildId::Static("probe"),
ContextProbe {
area: component_area,
},
component_area,
);
},
);
ctx.modal(
ChildId::Static("modal"),
ContextProbe { area: modal_area },
modal_area,
);
});
})
.expect("draw");
assert_eq!(
*scope_contains_focus.lock().expect("scope flag log"),
[false, false]
);
}
#[test]
fn composite_declaration_builds_paths_and_scope_options() {
let mut ratcn = Ratcn::<(), ()>::new();
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &(), &theme, |ctx| {
ctx.render_component(ChildId::Static("composite"), Composite, area);
});
})
.expect("draw");
assert_eq!(
ratcn.declared_paths(),
vec![
vec![ChildId::Static("composite")],
vec![ChildId::Static("composite"), ChildId::Static("leaf")],
]
);
assert_eq!(ratcn.surface.roots, vec![0]);
assert_eq!(ratcn.surface.nodes[0].children, vec![1]);
assert_eq!(ratcn.surface.nodes[1].parent, Some(0));
assert_eq!(ratcn.surface.nodes[0].options.tab_wrap, TabWrap::Wrap);
assert!(
ratcn
.surface
.nodes
.iter()
.all(|node| node.component.is_some())
);
}
#[test]
fn duplicate_sibling_ids_panic_without_replacing_the_surface() {
let mut ratcn = Ratcn::<(), ()>::new();
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
render_leaf(&mut ratcn, &mut terminal, &ChildId::Static("previous"));
let theme = Theme::default_dark();
let result = catch_unwind(AssertUnwindSafe(|| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &(), &theme, |ctx| {
ctx.render_component(ChildId::Static("duplicate"), Leaf, area);
ctx.render_component(ChildId::Static("duplicate"), Leaf, area);
});
})
.expect("draw");
}));
assert!(result.is_err());
assert_eq!(
ratcn.declared_paths(),
vec![vec![ChildId::Static("previous")]]
);
}
#[test]
fn same_child_id_in_distinct_scopes_builds_and_routes_distinct_paths() {
let mut state = FocusTestState {
focus: FocusState::intent([ChildId::Static("left"), ChildId::Static("shared")]),
};
let mut ratcn =
Ratcn::new().focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus);
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
for scope in ["left", "right"] {
ctx.scope(
ChildId::Static(scope),
Rect::ZERO,
ScopeOptions::default(),
|ctx| {
ctx.render_component(
ChildId::Static("shared"),
FocusLeaf::enabled(),
area,
);
},
);
}
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Emit(FocusTestMsg::Activated(vec![
ChildId::Static("left"),
ChildId::Static("shared"),
]))
);
state.focus = FocusState::intent([ChildId::Static("right"), ChildId::Static("shared")]);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Emit(FocusTestMsg::Activated(vec![
ChildId::Static("right"),
ChildId::Static("shared"),
]))
);
}
#[test]
fn declaration_panic_does_not_replace_the_previous_surface() {
let mut ratcn = Ratcn::<(), ()>::new();
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
render_leaf(&mut ratcn, &mut terminal, &ChildId::Static("stable"));
let theme = Theme::default_dark();
let result = catch_unwind(AssertUnwindSafe(|| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &(), &theme, |ctx| {
ctx.render_component(ChildId::Static("staged"), Leaf, area);
panic!("declaration failed");
});
})
.expect("draw");
}));
assert!(result.is_err());
assert_eq!(
ratcn.declared_paths(),
vec![vec![ChildId::Static("stable")]]
);
}
#[test]
fn component_panic_does_not_replace_the_previous_surface() {
let mut ratcn = Ratcn::<(), ()>::new();
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
render_leaf(&mut ratcn, &mut terminal, &ChildId::Static("stable"));
let theme = Theme::default_dark();
let result = catch_unwind(AssertUnwindSafe(|| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &(), &theme, |ctx| {
ctx.render_component(ChildId::Static("panicking"), PanickingLeaf, area);
});
})
.expect("draw");
}));
assert!(result.is_err());
assert_eq!(
ratcn.declared_paths(),
vec![vec![ChildId::Static("stable")]]
);
}
#[test]
fn non_idempotent_declaration_names_the_divergent_path() {
let mut ratcn = Ratcn::<(), ()>::new();
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
render_leaf(&mut ratcn, &mut terminal, &ChildId::Static("stable"));
let theme = Theme::default_dark();
let mut runs = 0;
let result = catch_unwind(AssertUnwindSafe(|| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &(), &theme, |ctx| {
runs += 1;
ctx.render_component(ChildId::Static("always"), Leaf, area);
if runs > 1 {
ctx.render_component(ChildId::Static("sometimes"), Leaf, area);
}
});
})
.expect("draw");
}));
let payload = result.expect_err("a non-idempotent closure must fail the pass");
let message = payload
.downcast_ref::<String>()
.map(String::as_str)
.or_else(|| payload.downcast_ref::<&str>().copied())
.expect("string panic");
assert!(message.contains("not idempotent"), "got: {message}");
assert!(message.contains("sometimes"), "got: {message}");
assert_eq!(
ratcn.declared_paths(),
vec![vec![ChildId::Static("stable")]]
);
}
#[test]
fn structure_pass_suppresses_paint_and_the_paint_pass_draws() {
let mut ratcn = Ratcn::<(), ()>::new();
let mut terminal = Terminal::new(TestBackend::new(6, 1)).expect("terminal");
let theme = Theme::default_dark();
let mut scratch_seen = Vec::new();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &(), &theme, |ctx| {
let before = ctx.with_buffer(|buf| {
buf.cell((0, 0)).expect("probe cell").symbol().to_owned()
});
scratch_seen.push(before);
ctx.with_buffer(|buf| {
buf.cell_mut((0, 0)).expect("probe cell").set_symbol("X");
});
ctx.render_component(ChildId::Static("leaf"), Leaf, area);
});
})
.expect("draw");
assert_eq!(scratch_seen, [" ", " "]);
let buffer = terminal.backend().buffer();
assert_eq!(buffer.cell((0, 0)).expect("painted cell").symbol(), "X");
}
#[test]
fn caught_component_panic_marks_the_whole_pass_as_failed() {
let mut ratcn = Ratcn::<(), ()>::new();
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
render_leaf(&mut ratcn, &mut terminal, &ChildId::Static("stable"));
let theme = Theme::default_dark();
let result = catch_unwind(AssertUnwindSafe(|| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &(), &theme, |ctx| {
ctx.render_component(ChildId::Static("catching"), CatchingComposite, area);
});
})
.expect("draw");
}));
assert!(result.is_err());
assert_eq!(
ratcn.declared_paths(),
vec![vec![ChildId::Static("stable")]]
);
}
#[test]
fn caught_duplicate_id_panic_marks_the_whole_pass_as_failed() {
let mut ratcn = Ratcn::<(), ()>::new();
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
render_leaf(&mut ratcn, &mut terminal, &ChildId::Static("stable"));
let theme = Theme::default_dark();
let result = catch_unwind(AssertUnwindSafe(|| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &(), &theme, |ctx| {
ctx.render_component(ChildId::Static("duplicate"), Leaf, area);
let caught = catch_unwind(AssertUnwindSafe(|| {
ctx.render_component(ChildId::Static("duplicate"), Leaf, area);
}));
assert!(caught.is_err());
});
})
.expect("draw");
}));
assert!(result.is_err());
assert_eq!(
ratcn.declared_paths(),
vec![vec![ChildId::Static("stable")]]
);
}
#[test]
fn caught_scope_option_panic_marks_the_whole_pass_as_failed() {
let mut ratcn = Ratcn::<(), ()>::new();
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
render_leaf(&mut ratcn, &mut terminal, &ChildId::Static("stable"));
let theme = Theme::default_dark();
let result = catch_unwind(AssertUnwindSafe(|| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &(), &theme, |ctx| {
let caught = catch_unwind(AssertUnwindSafe(|| {
ctx.render_component(
ChildId::Static("panicking-options"),
PanickingScopeOptions,
area,
);
}));
assert!(caught.is_err());
});
})
.expect("draw");
}));
assert!(result.is_err());
assert_eq!(
ratcn.declared_paths(),
vec![vec![ChildId::Static("stable")]]
);
}
#[test]
fn caught_resolve_panic_marks_the_whole_pass_as_failed() {
let mut ratcn = Ratcn::<(), ()>::new();
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
render_leaf(&mut ratcn, &mut terminal, &ChildId::Static("stable"));
let theme = Theme::default_dark();
let result = catch_unwind(AssertUnwindSafe(|| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &(), &theme, |ctx| {
let caught = catch_unwind(AssertUnwindSafe(|| {
ctx.render_component(
ChildId::Static("panicking-resolve"),
PanickingResolve,
area,
);
}));
assert!(caught.is_err());
});
})
.expect("draw");
}));
assert!(result.is_err());
assert_eq!(
ratcn.declared_paths(),
vec![vec![ChildId::Static("stable")]]
);
}
#[test]
fn caught_focusability_panic_marks_the_whole_pass_as_failed() {
let mut ratcn = Ratcn::<(), ()>::new();
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
render_leaf(&mut ratcn, &mut terminal, &ChildId::Static("stable"));
let theme = Theme::default_dark();
let result = catch_unwind(AssertUnwindSafe(|| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &(), &theme, |ctx| {
let caught = catch_unwind(AssertUnwindSafe(|| {
ctx.render_component(
ChildId::Static("panicking-focusable"),
PanickingFocusable,
area,
);
}));
assert!(caught.is_err());
});
})
.expect("draw");
}));
assert!(result.is_err());
assert_eq!(
ratcn.declared_paths(),
vec![vec![ChildId::Static("stable")]]
);
}
#[test]
fn caught_interaction_area_panic_marks_the_whole_pass_as_failed() {
let mut ratcn = Ratcn::<(), ()>::new();
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
render_leaf(&mut ratcn, &mut terminal, &ChildId::Static("stable"));
let theme = Theme::default_dark();
let result = catch_unwind(AssertUnwindSafe(|| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &(), &theme, |ctx| {
let caught = catch_unwind(AssertUnwindSafe(|| {
ctx.render_component(
ChildId::Static("panicking-area"),
PanickingInteractionArea,
area,
);
}));
assert!(caught.is_err());
});
})
.expect("draw");
}));
assert!(result.is_err());
assert_eq!(
ratcn.declared_paths(),
vec![vec![ChildId::Static("stable")]]
);
}
#[test]
fn escaping_interaction_area_panics_without_replacing_the_surface() {
let mut ratcn = Ratcn::<(), ()>::new();
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
render_leaf(&mut ratcn, &mut terminal, &ChildId::Static("stable"));
let theme = Theme::default_dark();
let result = catch_unwind(AssertUnwindSafe(|| {
terminal
.draw(|frame| {
ratcn.render(frame, &(), &theme, |ctx| {
ctx.render_component(
ChildId::Static("escaping-area"),
EscapingInteractionArea,
Rect::new(2, 1, 4, 1),
);
});
})
.expect("draw");
}));
assert!(result.is_err());
assert_eq!(
ratcn.declared_paths(),
vec![vec![ChildId::Static("stable")]]
);
}
#[test]
fn deferred_paint_finishes_before_surface_replacement() {
let mut ratcn = Ratcn::<(), ()>::new();
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
let theme = Theme::default_dark();
let painted = Arc::new(AtomicBool::new(false));
let deferred_painted = Arc::clone(&painted);
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &(), &theme, |ctx| {
ctx.render_component(ChildId::Static("next"), Leaf, area);
let deferred_painted = Arc::clone(&deferred_painted);
ctx.defer_paint(move |_, ()| {
deferred_painted.store(true, Ordering::SeqCst);
});
});
})
.expect("draw");
assert!(painted.load(Ordering::SeqCst));
assert_eq!(ratcn.declared_paths(), vec![vec![ChildId::Static("next")]]);
}
#[test]
fn deferred_paint_panic_does_not_replace_the_previous_surface() {
let mut ratcn = Ratcn::<(), ()>::new();
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
render_leaf(&mut ratcn, &mut terminal, &ChildId::Static("stable"));
let theme = Theme::default_dark();
let result = catch_unwind(AssertUnwindSafe(|| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &(), &theme, |ctx| {
ctx.render_component(ChildId::Static("next"), Leaf, area);
ctx.defer_paint(|_, ()| panic!("deferred paint failed"));
});
})
.expect("draw");
}));
assert!(result.is_err());
assert_eq!(
ratcn.declared_paths(),
vec![vec![ChildId::Static("stable")]]
);
}
#[test]
fn startup_focus_renders_and_routes_to_the_first_focusable_leaf() {
let state = FocusTestState::default();
let rendered = Arc::new(Mutex::new(Vec::new()));
let first_rendered = Arc::clone(&rendered);
let second_rendered = Arc::clone(&rendered);
let mut ratcn = Ratcn::new()
.focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus)
.tab_wrap(TabWrap::Wrap);
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.scope(
ChildId::Static("pane"),
Rect::ZERO,
ScopeOptions::default(),
|ctx| {
ctx.render_component(
ChildId::Static("first"),
FocusLeaf::recording(Arc::clone(&first_rendered)),
area,
);
ctx.render_component(
ChildId::Static("second"),
FocusLeaf::recording(Arc::clone(&second_rendered)),
area,
);
},
);
});
})
.expect("draw");
assert_eq!(
*rendered.lock().expect("render log"),
vec![(false, false), (false, false), (true, true), (false, false)]
);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Emit(FocusTestMsg::Activated(vec![
ChildId::Static("pane"),
ChildId::Static("first"),
]))
);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab)), &state),
EventResult::Emit(FocusTestMsg::Focus(FocusState::intent([
ChildId::Static("pane"),
ChildId::Static("second"),
])))
);
}
#[test]
fn startup_focus_skips_collapsed_candidates_and_routing_agrees() {
let state = FocusTestState::default();
let collapsed = Arc::new(Mutex::new(Vec::new()));
let visible = Arc::new(Mutex::new(Vec::new()));
let mut ratcn =
Ratcn::new().focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus);
let mut terminal = Terminal::new(TestBackend::new(10, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
ctx.scope(
ChildId::Static("group"),
Rect::new(0, 0, 4, 1),
ScopeOptions::default(),
|ctx| {
ctx.render_component(
ChildId::Static("collapsed"),
FocusLeaf::recording(Arc::clone(&collapsed)),
Rect::new(0, 0, 0, 1),
);
},
);
ctx.render_component(
ChildId::Static("visible"),
FocusLeaf::recording(Arc::clone(&visible)),
Rect::new(5, 0, 4, 1),
);
});
})
.expect("draw");
assert_eq!(
*collapsed.lock().expect("collapsed render log"),
[(false, false), (false, false)]
);
assert_eq!(
*visible.lock().expect("visible render log"),
[(false, false), (true, true)]
);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Emit(FocusTestMsg::Activated(vec![ChildId::Static("visible")])),
"routing targets the leaf that actually painted focused"
);
}
#[test]
fn empty_focus_renders_and_routes_to_the_active_modal() {
let state = FocusTestState::default();
let base = Arc::new(Mutex::new(Vec::new()));
let modal = Arc::new(Mutex::new(Vec::new()));
let mut ratcn =
Ratcn::new().focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus);
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("base"),
FocusLeaf::recording(Arc::clone(&base)),
area,
);
ctx.modal(
ChildId::Static("modal"),
FocusLeaf::recording(Arc::clone(&modal)),
area,
);
});
})
.expect("draw");
assert_eq!(
*base.lock().expect("base focus log"),
[(false, false), (false, false)]
);
assert_eq!(
*modal.lock().expect("modal focus log"),
[(false, false), (true, true)]
);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Emit(FocusTestMsg::Activated(vec![ChildId::Static("modal")]))
);
}
#[test]
fn composite_reports_focus_within_and_receives_bubbled_events() {
let state = FocusTestState::default();
let parent_rendered = Arc::new(Mutex::new(Vec::new()));
let child_rendered = Arc::new(Mutex::new(Vec::new()));
let mut ratcn = Ratcn::new()
.focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus)
.tab_wrap(TabWrap::Wrap);
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("composite"),
FocusComposite {
parent_rendered: Arc::clone(&parent_rendered),
child_rendered: Arc::clone(&child_rendered),
},
area,
);
});
})
.expect("draw");
assert_eq!(
*parent_rendered.lock().expect("parent render log"),
vec![(false, false), (false, true)]
);
assert_eq!(
*child_rendered.lock().expect("child render log"),
vec![(false, false), (true, true)]
);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Char('p'))), &state),
EventResult::Emit(FocusTestMsg::Parent(vec![ChildId::Static("composite")]))
);
}
#[test]
fn empty_composite_does_not_claim_sibling_focus_but_can_focus_itself() {
let state = FocusTestState::default();
let empty_rendered = Arc::new(Mutex::new(Vec::new()));
let leaf_rendered = Arc::new(Mutex::new(Vec::new()));
let mut ratcn = Ratcn::<FocusTestState, FocusTestMsg>::new();
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("empty"),
EmptyComposite {
rendered: Arc::clone(&empty_rendered),
self_focusable: false,
},
area,
);
ctx.render_component(
ChildId::Static("leaf"),
FocusLeaf::recording(Arc::clone(&leaf_rendered)),
area,
);
});
})
.expect("draw");
assert_eq!(
*empty_rendered.lock().expect("empty composite render log"),
vec![(false, false), (false, false)]
);
assert_eq!(
*leaf_rendered.lock().expect("leaf render log"),
vec![(false, false), (true, true)]
);
empty_rendered
.lock()
.expect("empty composite render log")
.clear();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("empty"),
EmptyComposite {
rendered: Arc::clone(&empty_rendered),
self_focusable: true,
},
area,
);
});
})
.expect("draw");
assert_eq!(
*empty_rendered.lock().expect("empty composite render log"),
vec![(false, false), (true, true)]
);
}
#[test]
fn tab_and_backtab_traverse_siblings_and_honor_nested_escape_and_wrap() {
let mut state = FocusTestState {
focus: FocusState::intent([ChildId::Static("left"), ChildId::Static("a2")]),
};
let mut ratcn = Ratcn::new()
.focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus)
.tab_wrap(TabWrap::Wrap);
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
let theme = Theme::default_dark();
let render = |ratcn: &mut Ratcn<FocusTestState, FocusTestMsg>,
terminal: &mut Terminal<TestBackend>,
state: &FocusTestState,
left_wrap| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, state, &theme, |ctx| {
ctx.render_component(ChildId::Static("before"), FocusLeaf::enabled(), area);
ctx.scope(
ChildId::Static("left"),
Rect::ZERO,
ScopeOptions::default().tab_wrap(left_wrap),
|ctx| {
ctx.render_component(
ChildId::Static("a1"),
FocusLeaf::enabled(),
area,
);
ctx.render_component(
ChildId::Static("a2"),
FocusLeaf::enabled(),
area,
);
},
);
ctx.scope(
ChildId::Static("right"),
Rect::ZERO,
ScopeOptions::default(),
|ctx| {
ctx.render_component(
ChildId::Static("b1"),
FocusLeaf::enabled(),
area,
);
},
);
});
})
.expect("draw");
};
render(&mut ratcn, &mut terminal, &state, TabWrap::Escape);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::BackTab)), &state),
EventResult::Emit(FocusTestMsg::Focus(FocusState::intent([
ChildId::Static("left"),
ChildId::Static("a1"),
])))
);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab)), &state),
EventResult::Emit(FocusTestMsg::Focus(FocusState::intent([
ChildId::Static("right"),
ChildId::Static("b1"),
])))
);
state.focus = FocusState::intent([ChildId::Static("left"), ChildId::Static("a1")]);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::BackTab)), &state),
EventResult::Emit(FocusTestMsg::Focus(FocusState::intent([ChildId::Static(
"before"
),])))
);
render(&mut ratcn, &mut terminal, &state, TabWrap::Wrap);
state.focus = FocusState::intent([ChildId::Static("left"), ChildId::Static("a2")]);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab)), &state),
EventResult::Emit(FocusTestMsg::Focus(FocusState::intent([
ChildId::Static("left"),
ChildId::Static("a1"),
])))
);
state.focus = FocusState::intent([ChildId::Static("left"), ChildId::Static("a1")]);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::BackTab)), &state),
EventResult::Emit(FocusTestMsg::Focus(FocusState::intent([
ChildId::Static("left"),
ChildId::Static("a2"),
])))
);
}
#[test]
fn backtab_accepts_shift_but_ignores_ctrl_and_alt() {
let state = FocusTestState {
focus: FocusState::intent([ChildId::Static("second")]),
};
let mut ratcn =
Ratcn::new().focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus);
let mut terminal = Terminal::new(TestBackend::new(10, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component("first", FocusLeaf::enabled(), area);
ctx.render_component("second", FocusLeaf::enabled(), area);
});
})
.expect("draw");
let backtab = |modifiers| {
Event::Key(KeyEvent {
code: KeyCode::BackTab,
modifiers,
})
};
assert_eq!(
ratcn.handle_event(
backtab(Modifiers {
shift: true,
..Modifiers::NONE
}),
&state,
),
EventResult::Emit(FocusTestMsg::Focus(FocusState::intent([ChildId::Static(
"first"
),])))
);
for modifiers in [
Modifiers {
ctrl: true,
shift: true,
..Modifiers::NONE
},
Modifiers {
alt: true,
shift: true,
..Modifiers::NONE
},
] {
assert_eq!(
ratcn.handle_event(backtab(modifiers), &state),
EventResult::Ignored
);
}
}
#[test]
fn reordering_preserves_identity_and_changes_tab_order() {
let b = ChildId::Dynamic(Arc::from("b"));
let mut state = FocusTestState {
focus: FocusState::intent([ChildId::Static("items"), b.clone()]),
};
let mut ratcn = Ratcn::new()
.focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus)
.tab_wrap(TabWrap::Wrap);
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
let theme = Theme::default_dark();
let render = |ratcn: &mut Ratcn<FocusTestState, FocusTestMsg>,
terminal: &mut Terminal<TestBackend>,
state: &FocusTestState,
ids: [ChildId; 3]| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, state, &theme, |ctx| {
ctx.scope(
ChildId::Static("items"),
Rect::ZERO,
ScopeOptions::default(),
|ctx| {
for id in &ids {
ctx.render_component(id.clone(), FocusLeaf::enabled(), area);
}
},
);
});
})
.expect("draw");
};
render(
&mut ratcn,
&mut terminal,
&state,
[
ChildId::Dynamic(Arc::from("a")),
b.clone(),
ChildId::Dynamic(Arc::from("c")),
],
);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab)), &state),
EventResult::Emit(FocusTestMsg::Focus(FocusState::intent([
ChildId::Static("items"),
ChildId::Dynamic(Arc::from("c")),
])))
);
render(
&mut ratcn,
&mut terminal,
&state,
[
ChildId::Dynamic(Arc::from("c")),
b.clone(),
ChildId::Dynamic(Arc::from("a")),
],
);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Emit(FocusTestMsg::Activated(vec![ChildId::Static("items"), b,]))
);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab)), &state),
EventResult::Emit(FocusTestMsg::Focus(FocusState::intent([
ChildId::Static("items"),
ChildId::Dynamic(Arc::from("a")),
])))
);
state.focus = FocusState::default();
}
#[test]
fn absent_and_partial_focus_park_then_recover_at_scope_edges() {
let state = FocusTestState {
focus: FocusState::intent([ChildId::Static("items"), ChildId::Static("missing")]),
};
let mut ratcn = Ratcn::new()
.focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus)
.tab_wrap(TabWrap::Wrap);
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.scope(
ChildId::Static("items"),
Rect::ZERO,
ScopeOptions::default(),
|ctx| {
ctx.render_component(
ChildId::Static("first"),
FocusLeaf::enabled(),
area,
);
ctx.render_component(
ChildId::Static("last"),
FocusLeaf::enabled(),
area,
);
},
);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Ignored
);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab)), &state),
EventResult::Emit(FocusTestMsg::Focus(FocusState::intent([
ChildId::Static("items"),
ChildId::Static("first"),
])))
);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::BackTab)), &state),
EventResult::Emit(FocusTestMsg::Focus(FocusState::intent([
ChildId::Static("items"),
ChildId::Static("last"),
])))
);
}
#[test]
fn parked_future_tree_intent_resolves_when_target_reappears() {
let state = FocusTestState {
focus: FocusState::intent([ChildId::Static("items"), ChildId::Static("target")]),
};
let mut ratcn =
Ratcn::new().focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus);
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
ctx.scope(
ChildId::Static("items"),
Rect::ZERO,
ScopeOptions::default(),
|_| {},
);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Ignored
);
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.scope(
ChildId::Static("items"),
Rect::ZERO,
ScopeOptions::default(),
|ctx| {
ctx.render_component(
ChildId::Static("target"),
FocusLeaf::enabled(),
area,
);
},
);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Emit(FocusTestMsg::Activated(vec![
ChildId::Static("items"),
ChildId::Static("target"),
]))
);
}
#[test]
fn absent_focus_escapes_an_empty_scope_but_wrap_traps_it() {
let state = FocusTestState {
focus: FocusState::intent([ChildId::Static("left"), ChildId::Static("removed")]),
};
let mut ratcn = Ratcn::new()
.focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus)
.tab_wrap(TabWrap::Wrap);
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
let theme = Theme::default_dark();
let render = |ratcn: &mut Ratcn<FocusTestState, FocusTestMsg>,
terminal: &mut Terminal<TestBackend>,
left_wrap| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.scope(
ChildId::Static("left"),
Rect::ZERO,
ScopeOptions::default().tab_wrap(left_wrap),
|_| {},
);
ctx.scope(
ChildId::Static("right"),
Rect::ZERO,
ScopeOptions::default(),
|ctx| {
ctx.render_component(
ChildId::Static("b1"),
FocusLeaf::enabled(),
area,
);
},
);
});
})
.expect("draw");
};
render(&mut ratcn, &mut terminal, TabWrap::Escape);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab)), &state),
EventResult::Emit(FocusTestMsg::Focus(FocusState::intent([
ChildId::Static("right"),
ChildId::Static("b1"),
])))
);
render(&mut ratcn, &mut terminal, TabWrap::Wrap);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab)), &state),
EventResult::Consumed
);
}
#[test]
fn scope_only_intent_descends_to_the_first_enabled_leaf() {
let mut state = FocusTestState {
focus: FocusState::intent([ChildId::Static("pane")]),
};
let mut ratcn = Ratcn::new()
.focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus)
.tab_wrap(TabWrap::Wrap);
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.scope(
ChildId::Static("pane"),
Rect::ZERO,
ScopeOptions::default(),
|ctx| {
ctx.render_component(
ChildId::Static("disabled"),
FocusLeaf::disabled(),
area,
);
ctx.render_component(
ChildId::Static("enabled"),
FocusLeaf::enabled(),
area,
);
},
);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Emit(FocusTestMsg::Activated(vec![
ChildId::Static("pane"),
ChildId::Static("enabled"),
]))
);
state.focus = FocusState::intent([ChildId::Static("pane"), ChildId::Static("disabled")]);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Ignored
);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab)), &state),
EventResult::Emit(FocusTestMsg::Focus(FocusState::intent([
ChildId::Static("pane"),
ChildId::Static("enabled"),
])))
);
}
#[test]
fn focus_keys_resolve_relative_to_the_bubbling_scope() {
let state = FocusTestState {
focus: FocusState::intent([ChildId::Static("pane"), ChildId::Static("first")]),
};
let mut ratcn = Ratcn::new()
.focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus)
.tab_wrap(TabWrap::Wrap);
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.scope(
ChildId::Static("pane"),
Rect::ZERO,
ScopeOptions::default().focus_key('x', [ChildId::Static("second")]),
|ctx| {
ctx.render_component(
ChildId::Static("first"),
FocusLeaf::enabled(),
area,
);
ctx.render_component(
ChildId::Static("second"),
FocusLeaf::enabled(),
area,
);
},
);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Char('x'))), &state),
EventResult::Emit(FocusTestMsg::Focus(FocusState::intent([
ChildId::Static("pane"),
ChildId::Static("second"),
])))
);
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.scope(
ChildId::Static("pane"),
Rect::ZERO,
ScopeOptions::default().focus_key('x', [ChildId::Static("second")]),
|ctx| {
ctx.render_component(
ChildId::Static("first"),
FocusLeaf::consuming_focus_key(),
area,
);
ctx.render_component(
ChildId::Static("second"),
FocusLeaf::enabled(),
area,
);
},
);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Char('x'))), &state),
EventResult::Consumed
);
}
#[test]
fn focus_keys_normalize_chars_but_match_ctrl_and_alt_exactly() {
let mut state = FocusTestState {
focus: FocusState::intent([ChildId::Static("first")]),
};
let mut ratcn = Ratcn::new()
.focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus)
.focus_key('c', [ChildId::Static("second")])
.focus_key(
KeyChord::from('m').ctrl().alt(),
[ChildId::Static("second")],
);
let mut terminal = Terminal::new(TestBackend::new(10, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(ChildId::Static("first"), FocusLeaf::enabled(), area);
ctx.render_component(ChildId::Static("second"), FocusLeaf::enabled(), area);
});
})
.expect("draw");
let key = |code, ctrl, alt, shift| {
Event::Key(KeyEvent {
code,
modifiers: Modifiers { ctrl, alt, shift },
})
};
let second = FocusTestMsg::Focus(FocusState::intent([ChildId::Static("second")]));
assert_eq!(
ratcn.handle_event(key(KeyCode::Char('C'), false, false, true), &state),
EventResult::Emit(second)
);
assert_eq!(
ratcn.handle_event(key(KeyCode::Char('c'), true, false, false), &state),
EventResult::Ignored
);
for (ctrl, alt) in [(false, false), (true, false), (false, true)] {
assert_eq!(
ratcn.handle_event(key(KeyCode::Char('m'), ctrl, alt, false), &state),
EventResult::Ignored
);
}
assert!(matches!(
ratcn.handle_event(key(KeyCode::Char('m'), true, true, false), &state),
EventResult::Emit(FocusTestMsg::Focus(_))
));
state.focus = FocusState::intent([ChildId::Static("second")]);
assert_eq!(
ratcn.handle_event(key(KeyCode::Char('C'), false, false, true), &state),
EventResult::Consumed,
"an already-satisfied focus shortcut must not emit redundant state"
);
}
#[test]
fn invalid_inner_focus_key_falls_back_to_the_outer_binding() {
let state = FocusTestState {
focus: FocusState::intent([ChildId::Static("pane"), ChildId::Static("first")]),
};
let mut ratcn = Ratcn::new()
.focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus)
.focus_key('x', [ChildId::Static("outside")]);
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.scope(
ChildId::Static("pane"),
Rect::ZERO,
ScopeOptions::default().focus_key('x', [ChildId::Static("missing")]),
|ctx| {
ctx.render_component(
ChildId::Static("first"),
FocusLeaf::enabled(),
area,
);
},
);
ctx.render_component(ChildId::Static("outside"), FocusLeaf::enabled(), area);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Char('x'))), &state),
EventResult::Emit(FocusTestMsg::Focus(FocusState::intent([ChildId::Static(
"outside"
),])))
);
}
#[test]
fn events_before_the_first_render_are_ignored() {
let state = FocusTestState::default();
let mut ratcn =
Ratcn::new().focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus);
assert!(!ratcn.has_rendered());
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab)), &state),
EventResult::Ignored
);
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
let theme = Theme::default_dark();
let failed = catch_unwind(AssertUnwindSafe(|| {
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |_| panic!("first render failed"));
})
.expect("failed draw");
}));
assert!(failed.is_err());
assert!(!ratcn.has_rendered());
terminal
.draw(|frame| ratcn.render(frame, &state, &theme, |_| {}))
.expect("draw");
assert!(ratcn.has_rendered());
}
#[test]
fn semantic_modal_before_the_first_render_still_ignores_events() {
let mut state = ModalTestState::default();
state
.modals
.open(ChildId::Static("dialog"), &mut state.focus)
.expect("open modal");
let mut ratcn: Ratcn<ModalTestState, ModalTestMsg> =
Ratcn::new().modals(|state: &ModalTestState| &state.modals);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab)), &state),
EventResult::Ignored
);
}
#[test]
fn button_events_use_last_rendered_disabledness_until_redraw() {
let mut state = ButtonTimingState::default();
let mut ratcn = Ratcn::new().focus(
|state: &ButtonTimingState| &state.focus,
ButtonTimingMsg::Focus,
);
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
let theme = Theme::default_dark();
let enter = Event::Key(KeyEvent::new(KeyCode::Enter));
render_timing_button(&mut ratcn, &mut terminal, &state, &theme, || {
ButtonTimingMsg::Save
});
let EventResult::Emit(first) = ratcn.handle_event(enter.clone(), &state) else {
panic!("rendered enabled button did not emit");
};
assert!(update_button_timing(&mut state, first));
let EventResult::Emit(second) = ratcn.handle_event(enter.clone(), &state) else {
panic!("old enabled declaration did not handle the second event");
};
assert!(!update_button_timing(&mut state, second));
assert_eq!(state.accepted_saves, 1);
render_timing_button(&mut ratcn, &mut terminal, &state, &theme, || {
ButtonTimingMsg::Save
});
assert_eq!(
ratcn.handle_event(enter.clone(), &state),
EventResult::Ignored
);
state.saving = false;
assert_eq!(ratcn.handle_event(enter, &state), EventResult::Ignored);
}
#[test]
fn failed_render_keeps_the_previous_button_declaration_interactive() {
let mut state = ButtonTimingState::default();
let mut ratcn = Ratcn::new().focus(
|state: &ButtonTimingState| &state.focus,
ButtonTimingMsg::Focus,
);
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
let theme = Theme::default_dark();
render_timing_button(&mut ratcn, &mut terminal, &state, &theme, || {
ButtonTimingMsg::Save
});
state.saving = true;
let result = catch_unwind(AssertUnwindSafe(|| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("save"),
Button::new("Replacement")
.disabled(true)
.on_press(|| ButtonTimingMsg::Replacement),
area,
);
panic!("failed after staging replacement button");
});
})
.expect("draw");
}));
assert!(result.is_err());
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Emit(ButtonTimingMsg::Save)
);
}
#[derive(Debug, Default)]
struct PointerState {
hover: HoverState,
}
#[derive(Default)]
struct ModalPointerState {
focus: FocusState,
hover: HoverState,
modals: ModalState,
}
#[derive(Debug, Clone, PartialEq)]
enum PointerMsg {
Hover(HoverState),
Routed(&'static str, MouseKind, usize),
Transient(usize),
Drag(DragPhase),
Dismissed,
}
#[derive(Debug, Default)]
struct HoverFocusState {
focus: FocusState,
hover: HoverState,
}
#[derive(Debug, Clone, PartialEq)]
enum HoverFocusMsg {
Focus(FocusState),
Hover(HoverState),
}
struct HoverFocusLeaf {
enabled: bool,
}
impl HoverFocusLeaf {
fn enabled() -> Self {
Self { enabled: true }
}
fn disabled() -> Self {
Self { enabled: false }
}
}
impl Component<HoverFocusState, HoverFocusMsg> for HoverFocusLeaf {
fn render(&mut self, _ctx: &mut RenderCtx<'_, '_, HoverFocusState, HoverFocusMsg>) {}
fn is_focusable(&self, _state: &HoverFocusState) -> bool {
self.enabled
}
}
struct HoverFocusComposite;
impl Component<HoverFocusState, HoverFocusMsg> for HoverFocusComposite {
fn render(&mut self, ctx: &mut RenderCtx<'_, '_, HoverFocusState, HoverFocusMsg>) {
let area = ctx.area();
ctx.render_component(ChildId::Static("leaf"), HoverFocusLeaf::enabled(), area);
}
fn scope_options(&self) -> ScopeOptions {
ScopeOptions::default()
}
}
#[derive(Default)]
struct DragTransient {
events: usize,
}
struct Draggable {
name: &'static str,
}
#[derive(Clone, Copy)]
struct LifecycleDrag {
offset: CellOffset,
can_start: bool,
}
impl Component<PointerState, PointerMsg> for LifecycleDrag {
fn render(&mut self, _ctx: &mut RenderCtx<'_, '_, PointerState, PointerMsg>) {}
fn handle_event(
&mut self,
event: &Event,
_state: &PointerState,
ctx: &mut EventCtx<'_>,
) -> EventResult<PointerMsg> {
let Event::Mouse(mouse) = event else {
return EventResult::Ignored;
};
match ctx.drag(
mouse,
DragOptions::new(self.offset).start_if(self.can_start),
) {
DragPhase::Ignored => EventResult::Ignored,
phase => EventResult::Emit(PointerMsg::Drag(phase)),
}
}
}
impl Component<PointerState, PointerMsg> for Draggable {
fn render(&mut self, _ctx: &mut RenderCtx<'_, '_, PointerState, PointerMsg>) {}
fn handle_event(
&mut self,
event: &Event,
_state: &PointerState,
ctx: &mut EventCtx<'_>,
) -> EventResult<PointerMsg> {
let Event::Mouse(mouse) = event else {
return EventResult::Ignored;
};
match mouse.kind {
MouseKind::Down(MouseButton::Left) => {
ctx.capture_pointer(MouseButton::Left);
ctx.transient::<DragTransient>().events += 1;
EventResult::Consumed
}
MouseKind::Drag(MouseButton::Left)
| MouseKind::Up(MouseButton::Left)
| MouseKind::Click(MouseButton::Left)
| MouseKind::DragEnd(MouseButton::Left) => {
let transient = ctx.transient::<DragTransient>();
transient.events += 1;
EventResult::Emit(PointerMsg::Routed(self.name, mouse.kind, transient.events))
}
_ => EventResult::Ignored,
}
}
}
struct HoverLeaf {
consume_move: bool,
rendered: Option<HoverRenderLog>,
}
type HoverRenderLog = Arc<Mutex<Vec<(bool, bool)>>>;
struct ModalHoverLeaf {
rendered: HoverRenderLog,
}
impl Component<ModalPointerState, PointerMsg> for ModalHoverLeaf {
fn render(&mut self, ctx: &mut RenderCtx<'_, '_, ModalPointerState, PointerMsg>) {
self.rendered
.lock()
.expect("modal hover render log")
.push((ctx.hovered, ctx.contains_hover));
}
}
struct ModalPointerLeaf;
impl Component<ModalPointerState, PointerMsg> for ModalPointerLeaf {
fn render(&mut self, _ctx: &mut RenderCtx<'_, '_, ModalPointerState, PointerMsg>) {}
}
impl Component<PointerState, PointerMsg> for HoverLeaf {
fn render(&mut self, ctx: &mut RenderCtx<'_, '_, PointerState, PointerMsg>) {
if let Some(rendered) = &self.rendered {
rendered
.lock()
.expect("hover render log")
.push((ctx.hovered, ctx.contains_hover));
}
}
fn handle_event(
&mut self,
event: &Event,
_state: &PointerState,
_ctx: &mut EventCtx<'_>,
) -> EventResult<PointerMsg> {
if self.consume_move
&& matches!(
event,
Event::Mouse(MouseEvent {
kind: MouseKind::Moved,
..
})
)
{
EventResult::Consumed
} else {
EventResult::Ignored
}
}
}
struct EmittingHoverLeaf;
impl Component<PointerState, PointerMsg> for EmittingHoverLeaf {
fn render(&mut self, _ctx: &mut RenderCtx<'_, '_, PointerState, PointerMsg>) {}
fn handle_event(
&mut self,
event: &Event,
_state: &PointerState,
_ctx: &mut EventCtx<'_>,
) -> EventResult<PointerMsg> {
if matches!(
event,
Event::Mouse(MouseEvent {
kind: MouseKind::Moved,
..
})
) {
EventResult::Emit(PointerMsg::Routed("move", MouseKind::Moved, 1))
} else {
EventResult::Ignored
}
}
}
struct StringTransient;
impl Component<PointerState, PointerMsg> for StringTransient {
fn render(&mut self, _ctx: &mut RenderCtx<'_, '_, PointerState, PointerMsg>) {}
fn handle_event(
&mut self,
_event: &Event,
_state: &PointerState,
ctx: &mut EventCtx<'_>,
) -> EventResult<PointerMsg> {
ctx.transient::<String>().push('x');
EventResult::Consumed
}
}
struct NumberTransient;
impl Component<PointerState, PointerMsg> for NumberTransient {
fn render(&mut self, _ctx: &mut RenderCtx<'_, '_, PointerState, PointerMsg>) {}
fn handle_event(
&mut self,
_event: &Event,
_state: &PointerState,
ctx: &mut EventCtx<'_>,
) -> EventResult<PointerMsg> {
*ctx.transient::<usize>() += 1;
EventResult::Consumed
}
}
struct TransientProbe;
impl Component<PointerState, PointerMsg> for TransientProbe {
fn render(&mut self, _ctx: &mut RenderCtx<'_, '_, PointerState, PointerMsg>) {}
fn handle_event(
&mut self,
event: &Event,
_state: &PointerState,
ctx: &mut EventCtx<'_>,
) -> EventResult<PointerMsg> {
if !matches!(
event,
Event::Mouse(MouseEvent {
kind: MouseKind::Down(_),
..
})
) {
return EventResult::Ignored;
}
let value = ctx.transient::<usize>();
*value += 1;
EventResult::Emit(PointerMsg::Transient(*value))
}
}
#[derive(Default)]
struct CleanupTransient {
dropped: Option<Arc<AtomicBool>>,
}
impl Drop for CleanupTransient {
fn drop(&mut self) {
if let Some(dropped) = &self.dropped {
dropped.store(true, Ordering::SeqCst);
}
}
}
struct CleanupComponent {
transient_dropped: Arc<AtomicBool>,
component_dropped: Arc<AtomicBool>,
armed: bool,
}
impl Drop for CleanupComponent {
fn drop(&mut self) {
if self.armed {
assert!(
self.transient_dropped.load(Ordering::SeqCst),
"transient cleanup must finish before the previous component drops"
);
self.component_dropped.store(true, Ordering::SeqCst);
}
}
}
impl Component<PointerState, PointerMsg> for CleanupComponent {
fn render(&mut self, _ctx: &mut RenderCtx<'_, '_, PointerState, PointerMsg>) {}
fn handle_event(
&mut self,
event: &Event,
_state: &PointerState,
ctx: &mut EventCtx<'_>,
) -> EventResult<PointerMsg> {
if matches!(
event,
Event::Mouse(MouseEvent {
kind: MouseKind::Down(MouseButton::Left),
..
})
) {
ctx.capture_pointer(MouseButton::Left);
ctx.transient::<CleanupTransient>().dropped =
Some(Arc::clone(&self.transient_dropped));
self.armed = true;
EventResult::Consumed
} else {
EventResult::Ignored
}
}
}
struct RouteLeaf(&'static str);
impl Component<PointerState, PointerMsg> for RouteLeaf {
fn render(&mut self, _ctx: &mut RenderCtx<'_, '_, PointerState, PointerMsg>) {}
fn handle_event(
&mut self,
event: &Event,
_state: &PointerState,
_ctx: &mut EventCtx<'_>,
) -> EventResult<PointerMsg> {
match event {
Event::Mouse(mouse) => EventResult::Emit(PointerMsg::Routed(self.0, mouse.kind, 0)),
_ => EventResult::Ignored,
}
}
}
struct RecordingPointer {
name: &'static str,
events: Arc<Mutex<Vec<(&'static str, MouseKind)>>>,
capture: bool,
}
impl Component<PointerState, PointerMsg> for RecordingPointer {
fn render(&mut self, _ctx: &mut RenderCtx<'_, '_, PointerState, PointerMsg>) {}
fn handle_event(
&mut self,
event: &Event,
_state: &PointerState,
ctx: &mut EventCtx<'_>,
) -> EventResult<PointerMsg> {
let Event::Mouse(mouse) = event else {
return EventResult::Ignored;
};
if self.capture
&& let MouseKind::Down(button) = mouse.kind
{
ctx.capture_pointer(button);
}
self.events
.lock()
.expect("pointer event log")
.push((self.name, mouse.kind));
EventResult::Consumed
}
}
fn mouse(kind: MouseKind, column: u16, row: u16) -> Event {
Event::Mouse(MouseEvent {
kind,
column,
row,
modifiers: super::super::Modifiers::NONE,
})
}
fn render_drag_surface(
ratcn: &mut Ratcn<PointerState, PointerMsg>,
terminal: &mut Terminal<TestBackend>,
state: &PointerState,
ids: &[(&'static str, &'static str, Rect)],
) {
let theme = Theme::default_dark();
terminal
.draw(|frame| {
ratcn.render(frame, state, &theme, |ctx| {
for &(id, name, area) in ids {
ctx.render_component(ChildId::Static(id), Draggable { name }, area);
}
});
})
.expect("draw");
}
fn render_lifecycle_drag(
ratcn: &mut Ratcn<PointerState, PointerMsg>,
terminal: &mut Terminal<TestBackend>,
state: &PointerState,
component: Option<LifecycleDrag>,
area: Rect,
) {
let theme = Theme::default_dark();
terminal
.draw(|frame| {
ratcn.render(frame, state, &theme, |ctx| {
if let Some(component) = component {
ctx.render_component(ChildId::Static("drag"), component, area);
}
});
})
.expect("draw");
}
#[test]
fn drag_helper_stays_captured_across_rebuild_and_ends_outside() {
let state = PointerState::default();
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(20, 4)).expect("terminal");
render_lifecycle_drag(
&mut ratcn,
&mut terminal,
&state,
Some(LifecycleDrag {
offset: CellOffset::new(3, -1),
can_start: true,
}),
Rect::new(0, 0, 4, 2),
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 1), &state),
EventResult::Emit(PointerMsg::Drag(DragPhase::Down))
);
render_lifecycle_drag(
&mut ratcn,
&mut terminal,
&state,
Some(LifecycleDrag {
offset: CellOffset::default(),
can_start: false,
}),
Rect::new(10, 0, 4, 2),
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Moved, 19, 3), &state),
EventResult::Emit(PointerMsg::Drag(DragPhase::Moved {
offset: CellOffset::new(21, 1),
position: Position::new(19, 3),
}))
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Up(MouseButton::Left), 19, 3), &state),
EventResult::Emit(PointerMsg::Drag(DragPhase::Ended {
position: Position::new(19, 3),
moved: true,
}))
);
assert!(ratcn.transients.is_empty());
assert!(!ratcn.captures.contains_key(&MouseButton::Left));
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Drag(MouseButton::Left), 19, 3), &state),
EventResult::Ignored
);
}
#[test]
fn drag_helper_path_removal_cleans_transient_and_suppresses_capture() {
let state = PointerState::default();
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(20, 4)).expect("terminal");
render_lifecycle_drag(
&mut ratcn,
&mut terminal,
&state,
Some(LifecycleDrag {
offset: CellOffset::default(),
can_start: true,
}),
Rect::new(0, 0, 4, 2),
);
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 1), &state);
render_lifecycle_drag(&mut ratcn, &mut terminal, &state, None, Rect::ZERO);
assert!(ratcn.transients.is_empty());
assert!(!ratcn.captures.contains_key(&MouseButton::Left));
assert!(ratcn.suppressed.contains(&MouseButton::Left));
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Moved, 19, 3), &state),
EventResult::Consumed
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Up(MouseButton::Left), 19, 3), &state),
EventResult::Consumed
);
assert!(!ratcn.suppressed.contains(&MouseButton::Left));
}
#[test]
fn capture_and_transient_follow_identity_through_replacement_and_reorder() {
let state = PointerState::default();
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(20, 4)).expect("terminal");
render_drag_surface(
&mut ratcn,
&mut terminal,
&state,
&[
("drag", "old", Rect::new(0, 0, 4, 2)),
("other", "other", Rect::new(5, 0, 4, 2)),
],
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 1), &state),
EventResult::Consumed
);
render_drag_surface(
&mut ratcn,
&mut terminal,
&state,
&[
("other", "other", Rect::new(5, 0, 4, 2)),
("drag", "replacement", Rect::new(10, 0, 4, 2)),
],
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Moved, 19, 3), &state),
EventResult::Emit(PointerMsg::Routed(
"replacement",
MouseKind::Drag(MouseButton::Left),
2,
))
);
}
#[test]
fn raw_release_returns_its_first_emitted_normalized_event() {
let state = PointerState::default();
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(20, 4)).expect("terminal");
render_drag_surface(
&mut ratcn,
&mut terminal,
&state,
&[("drag", "drag", Rect::new(0, 0, 4, 2))],
);
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 1), &state);
ratcn.handle_event(mouse(MouseKind::Moved, 19, 3), &state);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Up(MouseButton::Left), 19, 3), &state),
EventResult::Emit(PointerMsg::Routed(
"drag",
MouseKind::Up(MouseButton::Left),
3,
))
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Drag(MouseButton::Left), 19, 3), &state),
EventResult::Ignored
);
}
#[test]
fn pointer_exit_cancels_capture_and_stale_press_before_reentry() {
let state = PointerState::default();
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(20, 4)).expect("terminal");
render_drag_surface(
&mut ratcn,
&mut terminal,
&state,
&[("drag", "drag", Rect::new(0, 0, 4, 2))],
);
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 1), &state);
assert!(ratcn.captures.contains_key(&MouseButton::Left));
assert_eq!(ratcn.mouse_tracker.pressed_buttons(), [MouseButton::Left]);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Exited, 1, 1), &state),
EventResult::Consumed
);
assert!(ratcn.mouse_tracker.pressed_buttons().is_empty());
assert!(ratcn.captures.is_empty());
assert!(ratcn.press_targets.is_empty());
assert!(ratcn.suppressed.is_empty());
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Moved, 1, 1), &state),
EventResult::Ignored
);
}
#[test]
fn disappearing_capture_is_suppressed_through_reappearance_until_release() {
let state = PointerState::default();
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(20, 4)).expect("terminal");
render_drag_surface(
&mut ratcn,
&mut terminal,
&state,
&[("drag", "before", Rect::new(0, 0, 4, 2))],
);
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 1), &state);
render_drag_surface(&mut ratcn, &mut terminal, &state, &[]);
render_drag_surface(
&mut ratcn,
&mut terminal,
&state,
&[("drag", "after", Rect::new(0, 0, 4, 2))],
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Moved, 1, 1), &state),
EventResult::Consumed
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Up(MouseButton::Left), 1, 1), &state),
EventResult::Consumed
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 1), &state),
EventResult::Consumed
);
}
#[test]
fn deferred_paint_failure_preserves_capture_transient_and_previous_component() {
let state = PointerState::default();
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(20, 4)).expect("terminal");
render_drag_surface(
&mut ratcn,
&mut terminal,
&state,
&[("drag", "stable", Rect::new(0, 0, 4, 2))],
);
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 1), &state);
let theme = Theme::default_dark();
let result = catch_unwind(AssertUnwindSafe(|| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("drag"),
Draggable {
name: "replacement",
},
area,
);
ctx.defer_paint(|_, _| panic!("deferred paint failed"));
});
})
.expect("draw");
}));
assert!(result.is_err());
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Moved, 19, 3), &state),
EventResult::Emit(PointerMsg::Routed(
"stable",
MouseKind::Drag(MouseButton::Left),
2,
))
);
}
#[test]
fn incompatible_transient_reuse_reports_path_and_types() {
let state = PointerState::default();
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(5, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(ChildId::Static("typed"), StringTransient, area);
});
})
.expect("draw");
ratcn.handle_event(mouse(MouseKind::Moved, 0, 0), &state);
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(ChildId::Static("typed"), NumberTransient, area);
});
})
.expect("draw");
let panic = catch_unwind(AssertUnwindSafe(|| {
ratcn.handle_event(mouse(MouseKind::Moved, 0, 0), &state);
}));
let payload = panic.expect_err("incompatible transient type must panic");
let message = payload
.downcast_ref::<String>()
.map(String::as_str)
.or_else(|| payload.downcast_ref::<&str>().copied())
.expect("string panic");
assert!(message.contains("typed"));
assert!(message.contains("alloc::string::String"));
assert!(message.contains("usize"));
}
#[test]
fn successful_path_removal_drops_its_transient_state() {
let state = PointerState::default();
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(5, 2)).expect("terminal");
let theme = Theme::default_dark();
let render_probe = |ratcn: &mut Ratcn<PointerState, PointerMsg>,
terminal: &mut Terminal<TestBackend>,
present| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
if present {
ctx.render_component(ChildId::Static("probe"), TransientProbe, area);
}
});
})
.expect("draw");
};
render_probe(&mut ratcn, &mut terminal, true);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 0, 0), &state),
EventResult::Emit(PointerMsg::Transient(1))
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 0, 0), &state),
EventResult::Emit(PointerMsg::Transient(2))
);
render_probe(&mut ratcn, &mut terminal, false);
render_probe(&mut ratcn, &mut terminal, true);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 0, 0), &state),
EventResult::Emit(PointerMsg::Transient(1))
);
}
#[test]
fn capture_and_transient_cleanup_finish_before_previous_component_drop() {
let state = PointerState::default();
let transient_dropped = Arc::new(AtomicBool::new(false));
let component_dropped = Arc::new(AtomicBool::new(false));
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(5, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("cleanup"),
CleanupComponent {
transient_dropped: Arc::clone(&transient_dropped),
component_dropped: Arc::clone(&component_dropped),
armed: false,
},
area,
);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 0, 0), &state,),
EventResult::Consumed
);
terminal
.draw(|frame| ratcn.render(frame, &state, &theme, |_| {}))
.expect("draw");
assert!(transient_dropped.load(Ordering::SeqCst));
assert!(component_dropped.load(Ordering::SeqCst));
assert!(!ratcn.captures.contains_key(&MouseButton::Left));
assert!(ratcn.suppressed.contains(&MouseButton::Left));
}
#[test]
fn reverse_paint_order_routes_overlap_to_the_topmost_component() {
let state = PointerState::default();
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(5, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(ChildId::Static("bottom"), RouteLeaf("bottom"), area);
ctx.render_component(ChildId::Static("top"), RouteLeaf("top"), area);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 0, 0), &state),
EventResult::Emit(PointerMsg::Routed(
"top",
MouseKind::Down(MouseButton::Left),
0,
))
);
}
#[test]
fn successful_redraw_removes_click_target_without_retargeting_its_old_geometry() {
let state = PointerState::default();
let events = Arc::new(Mutex::new(Vec::new()));
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(10, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("removed"),
RecordingPointer {
name: "removed",
events: Arc::clone(&events),
capture: false,
},
Rect::new(0, 0, 4, 2),
);
});
})
.expect("draw");
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("other"),
RecordingPointer {
name: "other",
events: Arc::clone(&events),
capture: false,
},
Rect::new(6, 0, 4, 2),
);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 1), &state),
EventResult::Ignored
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Up(MouseButton::Left), 1, 1), &state),
EventResult::Ignored
);
assert!(events.lock().expect("pointer event log").is_empty());
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 7, 1), &state);
ratcn.handle_event(mouse(MouseKind::Up(MouseButton::Left), 7, 1), &state);
assert_eq!(
*events.lock().expect("pointer event log"),
[
("other", MouseKind::Down(MouseButton::Left)),
("other", MouseKind::Up(MouseButton::Left)),
("other", MouseKind::Click(MouseButton::Left)),
]
);
}
#[test]
fn uncaptured_click_does_not_retarget_after_successful_redraw() {
let state = PointerState::default();
let events = Arc::new(Mutex::new(Vec::new()));
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(5, 2)).expect("terminal");
let theme = Theme::default_dark();
let render = |ratcn: &mut Ratcn<PointerState, PointerMsg>,
terminal: &mut Terminal<TestBackend>,
id,
name| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static(id),
RecordingPointer {
name,
events: Arc::clone(&events),
capture: false,
},
area,
);
});
})
.expect("draw");
};
render(&mut ratcn, &mut terminal, "before", "before");
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 1), &state);
render(&mut ratcn, &mut terminal, "after", "after");
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Up(MouseButton::Left), 1, 1), &state),
EventResult::Consumed
);
assert_eq!(
*events.lock().expect("pointer event log"),
[
("before", MouseKind::Down(MouseButton::Left)),
("after", MouseKind::Up(MouseButton::Left)),
]
);
events.lock().expect("pointer event log").clear();
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 1), &state);
ratcn.handle_event(mouse(MouseKind::Up(MouseButton::Left), 1, 1), &state);
assert_eq!(
*events.lock().expect("pointer event log"),
[
("after", MouseKind::Down(MouseButton::Left)),
("after", MouseKind::Up(MouseButton::Left)),
("after", MouseKind::Click(MouseButton::Left)),
]
);
}
#[test]
fn release_after_successful_rebuild_clicks_the_same_stable_identity_once() {
let state = PointerState::default();
let events = Arc::new(Mutex::new(Vec::new()));
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(5, 2)).expect("terminal");
let theme = Theme::default_dark();
let render = |ratcn: &mut Ratcn<PointerState, PointerMsg>,
terminal: &mut Terminal<TestBackend>,
id,
name| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static(id),
RecordingPointer {
name,
events: Arc::clone(&events),
capture: true,
},
area,
);
});
})
.expect("draw");
};
render(&mut ratcn, &mut terminal, "stable", "before");
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 1), &state);
render(&mut ratcn, &mut terminal, "stable", "replacement");
ratcn.handle_event(mouse(MouseKind::Up(MouseButton::Left), 1, 1), &state);
assert_eq!(
*events.lock().expect("pointer event log"),
[
("before", MouseKind::Down(MouseButton::Left)),
("replacement", MouseKind::Up(MouseButton::Left)),
("replacement", MouseKind::Click(MouseButton::Left)),
]
);
events.lock().expect("pointer event log").clear();
render(&mut ratcn, &mut terminal, "stable", "before");
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 1), &state);
render(&mut ratcn, &mut terminal, "different", "replacement");
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Up(MouseButton::Left), 1, 1), &state),
EventResult::Consumed
);
assert_eq!(
*events.lock().expect("pointer event log"),
[("before", MouseKind::Down(MouseButton::Left))]
);
}
fn render_neighbours(
ratcn: &mut Ratcn<PointerState, PointerMsg>,
terminal: &mut Terminal<TestBackend>,
events: &Arc<Mutex<Vec<(&'static str, MouseKind)>>>,
capture: bool,
) {
let state = PointerState::default();
let theme = Theme::default_dark();
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("left"),
RecordingPointer {
name: "left",
events: Arc::clone(events),
capture,
},
Rect::new(0, 0, 4, 2),
);
ctx.render_component(
ChildId::Static("right"),
RecordingPointer {
name: "right",
events: Arc::clone(events),
capture,
},
Rect::new(6, 0, 4, 2),
);
});
})
.expect("draw");
}
#[test]
fn a_press_that_drifts_inside_one_component_still_clicks_it() {
let state = PointerState::default();
let events = Arc::new(Mutex::new(Vec::new()));
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(10, 2)).expect("terminal");
render_neighbours(&mut ratcn, &mut terminal, &events, false);
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 1), &state);
ratcn.handle_event(mouse(MouseKind::Moved, 2, 1), &state);
ratcn.handle_event(mouse(MouseKind::Up(MouseButton::Left), 2, 1), &state);
assert_eq!(
*events.lock().expect("pointer event log"),
[
("left", MouseKind::Down(MouseButton::Left)),
("left", MouseKind::Drag(MouseButton::Left)),
("left", MouseKind::Up(MouseButton::Left)),
("left", MouseKind::Click(MouseButton::Left)),
]
);
}
#[test]
fn a_press_released_on_another_component_clicks_neither() {
let state = PointerState::default();
let events = Arc::new(Mutex::new(Vec::new()));
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(10, 2)).expect("terminal");
render_neighbours(&mut ratcn, &mut terminal, &events, false);
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 1), &state);
ratcn.handle_event(mouse(MouseKind::Moved, 7, 1), &state);
ratcn.handle_event(mouse(MouseKind::Up(MouseButton::Left), 7, 1), &state);
let log = events.lock().expect("pointer event log");
assert!(
!log.iter()
.any(|(_, kind)| matches!(kind, MouseKind::Click(_))),
"a release off the press target must not click anything: {log:?}"
);
assert!(
log.contains(&("right", MouseKind::DragEnd(MouseButton::Left))),
"the gesture ends as a drag instead: {log:?}"
);
}
#[test]
fn a_claimed_gesture_that_moved_ends_as_a_drag_not_a_click() {
let state = PointerState::default();
let events = Arc::new(Mutex::new(Vec::new()));
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(10, 2)).expect("terminal");
render_neighbours(&mut ratcn, &mut terminal, &events, true);
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 1), &state);
ratcn.handle_event(mouse(MouseKind::Moved, 2, 1), &state);
ratcn.handle_event(mouse(MouseKind::Up(MouseButton::Left), 2, 1), &state);
assert_eq!(
*events.lock().expect("pointer event log"),
[
("left", MouseKind::Down(MouseButton::Left)),
("left", MouseKind::Drag(MouseButton::Left)),
("left", MouseKind::Up(MouseButton::Left)),
("left", MouseKind::DragEnd(MouseButton::Left)),
]
);
}
#[test]
fn a_claimed_press_that_never_moved_is_still_a_click() {
let state = PointerState::default();
let events = Arc::new(Mutex::new(Vec::new()));
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(10, 2)).expect("terminal");
render_neighbours(&mut ratcn, &mut terminal, &events, true);
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 1), &state);
ratcn.handle_event(mouse(MouseKind::Up(MouseButton::Left), 1, 1), &state);
assert_eq!(
*events.lock().expect("pointer event log"),
[
("left", MouseKind::Down(MouseButton::Left)),
("left", MouseKind::Up(MouseButton::Left)),
("left", MouseKind::Click(MouseButton::Left)),
]
);
}
#[test]
fn area_scope_hit_prefers_descendant_then_falls_back_to_scope() {
let mut state = HoverFocusState::default();
let mut ratcn =
Ratcn::new().hover(|state: &HoverFocusState| &state.hover, HoverFocusMsg::Hover);
let mut terminal = Terminal::new(TestBackend::new(8, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
ctx.scope(
ChildId::Static("scope"),
Rect::new(0, 0, 8, 2),
ScopeOptions::default(),
|ctx| {
ctx.render_component(
ChildId::Static("child"),
HoverFocusLeaf::enabled(),
Rect::new(0, 0, 3, 2),
);
},
);
});
})
.expect("draw");
let EventResult::Emit(HoverFocusMsg::Hover(child)) =
ratcn.handle_event(mouse(MouseKind::Moved, 1, 0), &state)
else {
panic!("descendant did not win the enclosing scope hit");
};
assert_eq!(
child.path(),
&[ChildId::Static("scope"), ChildId::Static("child")]
);
state.hover = child;
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Moved, 6, 0), &state),
EventResult::Emit(HoverFocusMsg::Hover(HoverState::intent([ChildId::Static(
"scope"
)])))
);
}
#[test]
fn focusable_decorative_scope_receives_mouse_focus_and_hover_context() {
let rendered = Arc::new(Mutex::new(Vec::new()));
let mut state = HoverFocusState {
focus: FocusState::intent([ChildId::Static("other")]),
..HoverFocusState::default()
};
let mut ratcn = Ratcn::new()
.focus(|state: &HoverFocusState| &state.focus, HoverFocusMsg::Focus)
.hover(|state: &HoverFocusState| &state.hover, HoverFocusMsg::Hover);
let mut terminal = Terminal::new(TestBackend::new(8, 2)).expect("terminal");
let theme = Theme::default_dark();
let render = |ratcn: &mut Ratcn<HoverFocusState, HoverFocusMsg>,
terminal: &mut Terminal<TestBackend>,
state: &HoverFocusState| {
terminal
.draw(|frame| {
ratcn.render(frame, state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("other"),
HoverFocusLeaf::enabled(),
Rect::new(0, 0, 2, 2),
);
let rendered = Arc::clone(&rendered);
ctx.scope(
ChildId::Static("decoration"),
Rect::new(3, 0, 5, 2),
ScopeOptions::default().focusable(),
move |ctx| {
rendered
.lock()
.expect("scope render log")
.push((ctx.hovered, ctx.contains_hover));
},
);
});
})
.expect("draw");
};
render(&mut ratcn, &mut terminal, &state);
let EventResult::Emit(HoverFocusMsg::Hover(hover)) =
ratcn.handle_event(mouse(MouseKind::Moved, 4, 0), &state)
else {
panic!("decorative scope did not receive hover");
};
state.hover = hover;
render(&mut ratcn, &mut terminal, &state);
assert_eq!(
*rendered.lock().expect("scope render log"),
[(false, false), (false, false), (true, true), (true, true)]
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 4, 0), &state),
EventResult::Emit(HoverFocusMsg::Focus(FocusState::intent([ChildId::Static(
"decoration"
)])))
);
}
#[test]
fn root_then_nested_hover_focus_attract_on_successive_moves() {
let mut state = HoverFocusState {
focus: FocusState::intent([ChildId::Static("left"), ChildId::Static("first")]),
..HoverFocusState::default()
};
let mut ratcn = Ratcn::new()
.focus(|state: &HoverFocusState| &state.focus, HoverFocusMsg::Focus)
.hover(|state: &HoverFocusState| &state.hover, HoverFocusMsg::Hover)
.hover_focus();
let mut terminal = Terminal::new(TestBackend::new(12, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
for (scope, x) in [("left", 0), ("right", 6)] {
ctx.scope(
ChildId::Static(scope),
Rect::new(x, 0, 6, 2),
ScopeOptions::default().hover_focus(),
|ctx| {
ctx.render_component(
ChildId::Static("first"),
HoverFocusLeaf::enabled(),
Rect::new(x, 0, 3, 2),
);
ctx.render_component(
ChildId::Static("second"),
HoverFocusLeaf::enabled(),
Rect::new(x + 3, 0, 3, 2),
);
},
);
}
});
})
.expect("draw");
let EventResult::Emit(HoverFocusMsg::Focus(root_focus)) =
ratcn.handle_event(mouse(MouseKind::Moved, 10, 0), &state)
else {
panic!("root boundary did not attract focus");
};
assert_eq!(
root_focus.path(),
&[ChildId::Static("right"), ChildId::Static("first")]
);
state.focus = root_focus;
let EventResult::Emit(HoverFocusMsg::Focus(nested_focus)) =
ratcn.handle_event(mouse(MouseKind::Moved, 10, 0), &state)
else {
panic!("nested boundary did not attract focus after the root");
};
assert_eq!(
nested_focus.path(),
&[ChildId::Static("right"), ChildId::Static("second")]
);
state.focus = nested_focus;
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Moved, 10, 0), &state),
EventResult::Emit(HoverFocusMsg::Hover(HoverState::intent([
ChildId::Static("right"),
ChildId::Static("second"),
])))
);
}
#[test]
fn hover_focus_is_off_by_default_and_skips_disabled_targets_and_empty_space() {
let mut state = HoverFocusState {
focus: FocusState::intent([ChildId::Static("enabled")]),
..HoverFocusState::default()
};
let mut default = Ratcn::new()
.focus(|state: &HoverFocusState| &state.focus, HoverFocusMsg::Focus)
.hover(|state: &HoverFocusState| &state.hover, HoverFocusMsg::Hover);
let mut hover_focus = Ratcn::new()
.focus(|state: &HoverFocusState| &state.focus, HoverFocusMsg::Focus)
.hover(|state: &HoverFocusState| &state.hover, HoverFocusMsg::Hover)
.hover_focus();
let mut terminal = Terminal::new(TestBackend::new(12, 2)).expect("terminal");
let theme = Theme::default_dark();
let render = |ratcn: &mut Ratcn<HoverFocusState, HoverFocusMsg>,
terminal: &mut Terminal<TestBackend>| {
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("enabled"),
HoverFocusLeaf::enabled(),
Rect::new(0, 0, 3, 2),
);
ctx.render_component(
ChildId::Static("disabled"),
HoverFocusLeaf::disabled(),
Rect::new(4, 0, 3, 2),
);
});
})
.expect("draw");
};
render(&mut default, &mut terminal);
assert_eq!(
default.handle_event(mouse(MouseKind::Moved, 5, 0), &state),
EventResult::Emit(HoverFocusMsg::Hover(HoverState::intent([ChildId::Static(
"disabled"
)])))
);
render(&mut hover_focus, &mut terminal);
let EventResult::Emit(HoverFocusMsg::Hover(disabled)) =
hover_focus.handle_event(mouse(MouseKind::Moved, 5, 0), &state)
else {
panic!("disabled target should hover without attracting focus");
};
state.hover = disabled;
assert_eq!(
hover_focus.handle_event(mouse(MouseKind::Moved, 10, 0), &state),
EventResult::Emit(HoverFocusMsg::Hover(HoverState::default()))
);
}
#[test]
fn focus_path_validates_latest_surface_focusability_and_scope_descent() {
let state = HoverFocusState::default();
let dynamic = ChildId::Dynamic(Arc::from("dynamic"));
let mut ratcn = Ratcn::<HoverFocusState, HoverFocusMsg>::new();
assert!(ratcn.focus_path(&[ChildId::Static("pane")]).is_none());
let mut terminal = Terminal::new(TestBackend::new(8, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.scope(
ChildId::Static("pane"),
Rect::ZERO,
ScopeOptions::default(),
|ctx| {
ctx.render_component(
ChildId::Static("disabled"),
HoverFocusLeaf::disabled(),
area,
);
ctx.render_component(
ChildId::Static("enabled"),
HoverFocusLeaf::enabled(),
area,
);
},
);
ctx.render_component(dynamic.clone(), HoverFocusLeaf::enabled(), area);
});
})
.expect("draw");
assert_eq!(
ratcn.focus_path(&[ChildId::Static("pane")]),
Some(FocusState::intent([
ChildId::Static("pane"),
ChildId::Static("enabled")
]))
);
assert!(
ratcn
.focus_path(&[ChildId::Static("pane"), ChildId::Static("disabled")])
.is_none()
);
assert_eq!(
ratcn.focus_path(std::slice::from_ref(&dynamic)),
Some(FocusState::intent([dynamic.clone()]))
);
assert!(ratcn.focus_path(&[ChildId::Static("missing")]).is_none());
terminal
.draw(|frame| ratcn.render(frame, &state, &theme, |_| {}))
.expect("draw");
assert!(ratcn.focus_path(std::slice::from_ref(&dynamic)).is_none());
}
#[test]
fn collapsed_components_are_excluded_and_recover_when_geometry_reappears() {
let mut state = FocusTestState {
focus: FocusState::intent([ChildId::Static("width-zero")]),
};
let mut ratcn =
Ratcn::new().focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus);
let mut terminal = Terminal::new(TestBackend::new(8, 2)).expect("terminal");
let theme = Theme::default_dark();
let render = |ratcn: &mut Ratcn<FocusTestState, FocusTestMsg>,
terminal: &mut Terminal<TestBackend>,
recovered| {
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("width-zero"),
FocusLeaf::enabled(),
if recovered {
Rect::new(0, 0, 1, 1)
} else {
Rect::new(0, 0, 0, 1)
},
);
ctx.render_component(
ChildId::Static("height-zero"),
FocusLeaf::enabled(),
Rect::new(1, 0, 1, 0),
);
ctx.render_component(
ChildId::Static("visible"),
FocusLeaf::enabled(),
Rect::new(2, 0, 1, 1),
);
ctx.scope(
ChildId::Static("group"),
Rect::ZERO,
ScopeOptions::default(),
|ctx| {
ctx.render_component(
ChildId::Static("child"),
FocusLeaf::enabled(),
Rect::new(4, 0, 1, 1),
);
},
);
});
})
.expect("draw");
};
render(&mut ratcn, &mut terminal, false);
for id in ["width-zero", "height-zero"] {
assert!(ratcn.focus_path(&[ChildId::Static(id)]).is_none());
}
assert_eq!(
ratcn.focus_path(&[ChildId::Static("group")]),
Some(FocusState::intent([
ChildId::Static("group"),
ChildId::Static("child")
]))
);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Ignored,
"a parked collapsed target must not activate"
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 0, 0), &state),
EventResult::Ignored,
"collapsed geometry must not be a mouse target"
);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab)), &state),
EventResult::Emit(FocusTestMsg::Focus(FocusState::intent([ChildId::Static(
"visible"
)])))
);
render(&mut ratcn, &mut terminal, true);
assert_eq!(
ratcn.focus_path(&[ChildId::Static("width-zero")]),
Some(FocusState::intent([ChildId::Static("width-zero")]))
);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Emit(FocusTestMsg::Activated(vec![ChildId::Static("width-zero")]))
);
state.focus = FocusState::intent([ChildId::Static("visible")]);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 0, 0), &state),
EventResult::Emit(FocusTestMsg::Focus(FocusState::intent([ChildId::Static(
"width-zero"
)])))
);
}
#[test]
fn empty_interaction_area_keeps_paint_and_identity_but_excludes_its_subtree() {
let rendered = Arc::new(AtomicBool::new(false));
let state = FocusTestState {
focus: FocusState::intent([ChildId::Static("area-aware"), ChildId::Static("child")]),
};
let mut ratcn =
Ratcn::new().focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus);
let mut terminal = Terminal::new(TestBackend::new(8, 2)).expect("terminal");
let theme = Theme::default_dark();
for (width, usable) in [(1, false), (2, true)] {
let area = Rect::new(0, 0, width, 1);
rendered.store(false, Ordering::SeqCst);
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("area-aware"),
AreaAwareComposite {
expected_area: area,
minimum_width: 2,
rendered: Arc::clone(&rendered),
},
area,
);
ctx.render_component(
ChildId::Static("visible"),
FocusLeaf::enabled(),
Rect::new(4, 0, 2, 1),
);
});
})
.expect("draw");
assert!(rendered.load(Ordering::SeqCst));
assert_eq!(
ratcn.focus_path(&[ChildId::Static("area-aware")]).is_some(),
usable
);
let enter = ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state);
assert_eq!(
matches!(enter, EventResult::Emit(FocusTestMsg::Activated(_))),
usable
);
let down = ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 0, 0), &state);
assert_eq!(!matches!(down, EventResult::Ignored), usable);
if !usable {
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab)), &state),
EventResult::Emit(FocusTestMsg::Focus(FocusState::intent([ChildId::Static(
"visible"
)])))
);
}
}
assert_eq!(
ratcn.declared_paths(),
vec![
vec![ChildId::Static("area-aware")],
vec![ChildId::Static("area-aware"), ChildId::Static("child")],
vec![ChildId::Static("visible")],
]
);
}
#[test]
fn zero_area_focusable_scope_groups_descendants_but_cannot_hold_focus_itself() {
let state = FocusTestState::default();
let mut ratcn =
Ratcn::new().focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus);
let mut terminal = Terminal::new(TestBackend::new(8, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.scope(
ChildId::Static("empty"),
Rect::ZERO,
ScopeOptions::default().focusable(),
|_| {},
);
ctx.render_component(ChildId::Static("visible"), FocusLeaf::enabled(), area);
});
})
.expect("draw");
assert!(ratcn.focus_path(&[ChildId::Static("empty")]).is_none());
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Emit(FocusTestMsg::Activated(vec![ChildId::Static("visible")]))
);
}
#[test]
fn focus_path_rejects_inactive_layers_and_descends_in_active_modal() {
let state = HoverFocusState::default();
let mut ratcn = Ratcn::<HoverFocusState, HoverFocusMsg>::new();
let mut terminal = Terminal::new(TestBackend::new(8, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(ChildId::Static("base"), HoverFocusLeaf::enabled(), area);
ctx.modal(ChildId::Static("lower"), HoverFocusComposite, area);
ctx.modal(ChildId::Static("top"), HoverFocusComposite, area);
});
})
.expect("draw");
for inactive in [ChildId::Static("base"), ChildId::Static("lower")] {
assert!(ratcn.focus_path(&[inactive]).is_none());
}
assert_eq!(
ratcn.focus_path(&[ChildId::Static("top")]),
Some(FocusState::intent([
ChildId::Static("top"),
ChildId::Static("leaf")
]))
);
}
#[test]
fn raw_button_press_focuses_then_synthesized_click_emits() {
let mut state = ButtonTimingState {
focus: FocusState::intent([ChildId::Static("first")]),
..ButtonTimingState::default()
};
let mut ratcn = Ratcn::new().focus(
|state: &ButtonTimingState| &state.focus,
ButtonTimingMsg::Focus,
);
let mut terminal = Terminal::new(TestBackend::new(20, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("first"),
Button::new("First").on_press(|| ButtonTimingMsg::Replacement),
Rect::new(0, 0, 8, 2),
);
ctx.render_component(
ChildId::Static("second"),
Button::new("Second").on_press(|| ButtonTimingMsg::Save),
Rect::new(10, 0, 8, 2),
);
});
})
.expect("draw");
for button in [MouseButton::Right, MouseButton::Middle] {
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(button), 11, 0), &state),
EventResult::Ignored
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Up(button), 11, 0), &state),
EventResult::Ignored
);
assert_eq!(state.focus.path(), &[ChildId::Static("first")]);
}
let EventResult::Emit(focus) =
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 11, 0), &state)
else {
panic!("button down did not request focus");
};
assert!(update_button_timing(&mut state, focus));
assert_eq!(state.focus.path(), &[ChildId::Static("second")]);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Moved, 11, 0), &state),
EventResult::Consumed
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Up(MouseButton::Left), 11, 0), &state),
EventResult::Emit(ButtonTimingMsg::Save)
);
}
#[test]
fn primary_down_result_controls_focus_fallback_after_capture_and_routing() {
let state = FocusTestState {
focus: FocusState::intent([ChildId::Static("first")]),
};
let mut ratcn =
Ratcn::new().focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus);
let mut terminal = Terminal::new(TestBackend::new(16, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("first"),
FocusLeaf::enabled(),
Rect::new(0, 0, 3, 1),
);
for (id, x, behavior) in [
("ignored", 4, DownBehavior::CaptureAndIgnore),
("consumed", 8, DownBehavior::Consume),
("emitted", 12, DownBehavior::Emit),
] {
ctx.render_component(
ChildId::Static(id),
DownFocusLeaf(behavior),
Rect::new(x, 0, 3, 1),
);
}
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 4, 0), &state),
EventResult::Emit(FocusTestMsg::Focus(FocusState::intent([ChildId::Static(
"ignored"
)])))
);
assert_eq!(
ratcn.captures.get(&MouseButton::Left),
Some(&vec![ChildId::Static("ignored")])
);
ratcn.handle_event(mouse(MouseKind::Exited, 4, 0), &state);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 8, 0), &state),
EventResult::Consumed
);
ratcn.handle_event(mouse(MouseKind::Exited, 8, 0), &state);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 12, 0), &state),
EventResult::Emit(FocusTestMsg::Activated(vec![ChildId::Static("emitted")]))
);
}
#[test]
fn click_focused_component_ignores_primary_down_and_focuses_on_click() {
let state = FocusTestState {
focus: FocusState::intent([ChildId::Static("first")]),
};
let mut ratcn =
Ratcn::new().focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus);
let mut terminal = Terminal::new(TestBackend::new(8, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("first"),
FocusLeaf::enabled(),
Rect::new(0, 0, 3, 2),
);
ctx.render_component(
ChildId::Static("click"),
ClickFocusLeaf,
Rect::new(4, 0, 3, 2),
);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 5, 0), &state),
EventResult::Ignored
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Up(MouseButton::Left), 5, 0), &state),
EventResult::Emit(FocusTestMsg::Focus(FocusState::intent([ChildId::Static(
"click"
)])))
);
}
#[test]
fn hover_crosses_consumes_same_target_and_clears_over_empty_space() {
let mut state = PointerState::default();
let mut ratcn = Ratcn::new().hover(|state: &PointerState| &state.hover, PointerMsg::Hover);
let mut terminal = Terminal::new(TestBackend::new(10, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
for (id, x) in [("left", 0), ("right", 5)] {
ctx.render_component(
ChildId::Static(id),
HoverLeaf {
consume_move: false,
rendered: None,
},
Rect::new(x, 0, 4, 2),
);
}
});
})
.expect("draw");
let EventResult::Emit(PointerMsg::Hover(left)) =
ratcn.handle_event(mouse(MouseKind::Moved, 1, 0), &state)
else {
panic!("first crossing did not emit hover");
};
state.hover = left;
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Moved, 2, 1), &state),
EventResult::Consumed
);
let EventResult::Emit(PointerMsg::Hover(right)) =
ratcn.handle_event(mouse(MouseKind::Moved, 6, 0), &state)
else {
panic!("target crossing did not emit hover");
};
assert_eq!(right.path(), &[ChildId::Static("right")]);
state.hover = right;
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Moved, 4, 0), &state),
EventResult::Emit(PointerMsg::Hover(HoverState::default()))
);
}
#[test]
fn pointer_exit_clears_bound_hover() {
let mut state = PointerState::default();
let mut ratcn = Ratcn::new().hover(|state: &PointerState| &state.hover, PointerMsg::Hover);
let mut terminal = Terminal::new(TestBackend::new(4, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("target"),
HoverLeaf {
consume_move: false,
rendered: None,
},
area,
);
});
})
.expect("draw");
let EventResult::Emit(PointerMsg::Hover(hover)) =
ratcn.handle_event(mouse(MouseKind::Moved, 1, 0), &state)
else {
panic!("pointer entry did not emit hover");
};
state.hover = hover;
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Exited, 1, 0), &state),
EventResult::Emit(PointerMsg::Hover(HoverState::default()))
);
}
#[test]
fn pointer_exit_during_modal_mismatch_keeps_effective_hover_empty() {
let rendered = Arc::new(Mutex::new(Vec::new()));
let mut state = ModalPointerState {
hover: HoverState::intent([ChildId::Static("base")]),
..ModalPointerState::default()
};
let mut ratcn = Ratcn::new()
.hover(|state: &ModalPointerState| &state.hover, PointerMsg::Hover)
.modals(|state: &ModalPointerState| &state.modals);
let mut terminal = Terminal::new(TestBackend::new(5, 2)).expect("terminal");
let theme = Theme::default_dark();
let render = |ratcn: &mut Ratcn<ModalPointerState, PointerMsg>,
terminal: &mut Terminal<TestBackend>,
state: &ModalPointerState| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("base"),
ModalHoverLeaf {
rendered: Arc::clone(&rendered),
},
area,
);
if state.modals.is_open("modal") {
ctx.modal(ChildId::Static("modal"), ModalPointerLeaf, area);
}
});
})
.expect("draw");
};
render(&mut ratcn, &mut terminal, &state);
state
.modals
.open("modal", &mut state.focus)
.expect("open modal");
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Exited, 1, 0), &state),
EventResult::Consumed
);
render(&mut ratcn, &mut terminal, &state);
let _ = state.modals.close(&mut state.focus);
render(&mut ratcn, &mut terminal, &state);
render(&mut ratcn, &mut terminal, &state);
assert_eq!(
rendered.lock().expect("hover render log").last(),
Some(&(false, false))
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Moved, 1, 0), &state),
EventResult::Consumed
);
render(&mut ratcn, &mut terminal, &state);
assert_eq!(
rendered.lock().expect("hover render log").last(),
Some(&(true, true))
);
}
#[test]
fn redraw_invalidates_hover_when_the_target_moves_away_from_the_pointer() {
let rendered = Arc::new(Mutex::new(Vec::new()));
let mut state = PointerState::default();
let mut ratcn = Ratcn::new().hover(|state: &PointerState| &state.hover, PointerMsg::Hover);
let mut terminal = Terminal::new(TestBackend::new(10, 2)).expect("terminal");
let theme = Theme::default_dark();
let render = |ratcn: &mut Ratcn<PointerState, PointerMsg>,
terminal: &mut Terminal<TestBackend>,
state: &PointerState,
x| {
terminal
.draw(|frame| {
ratcn.render(frame, state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("target"),
HoverLeaf {
consume_move: false,
rendered: Some(Arc::clone(&rendered)),
},
Rect::new(x, 0, 2, 1),
);
});
})
.expect("draw");
};
render(&mut ratcn, &mut terminal, &state, 0);
let EventResult::Emit(PointerMsg::Hover(hover)) =
ratcn.handle_event(mouse(MouseKind::Moved, 1, 0), &state)
else {
panic!("entering the target did not emit hover");
};
state.hover = hover;
render(&mut ratcn, &mut terminal, &state, 0);
render(&mut ratcn, &mut terminal, &state, 5);
render(&mut ratcn, &mut terminal, &state, 5);
assert_eq!(
*rendered.lock().expect("hover render log"),
[
(false, false),
(false, false),
(true, true),
(true, true),
(true, true),
(true, true),
(false, false),
(false, false),
]
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Moved, 1, 0), &state),
EventResult::Emit(PointerMsg::Hover(HoverState::default()))
);
}
#[test]
fn crossing_stages_hover_before_a_consuming_component_receives_same_path_motion() {
let mut state = PointerState::default();
let mut ratcn = Ratcn::new().hover(|state: &PointerState| &state.hover, PointerMsg::Hover);
let mut terminal = Terminal::new(TestBackend::new(5, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("consumer"),
HoverLeaf {
consume_move: true,
rendered: None,
},
area,
);
});
})
.expect("draw");
let EventResult::Emit(PointerMsg::Hover(hover)) =
ratcn.handle_event(mouse(MouseKind::Moved, 0, 0), &state)
else {
panic!("crossing motion did not stage hover");
};
state.hover = hover;
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Moved, 1, 0), &state),
EventResult::Consumed,
"same-path motion must reach the consuming component"
);
}
#[test]
fn crossing_stages_enclosing_scope_hover_before_an_emitting_component() {
let mut state = PointerState::default();
let mut ratcn = Ratcn::new().hover(|state: &PointerState| &state.hover, PointerMsg::Hover);
let mut terminal = Terminal::new(TestBackend::new(5, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.scope(
ChildId::Static("panel"),
area,
ScopeOptions::default(),
|ctx| {
ctx.render_component(
ChildId::Static("emitter"),
EmittingHoverLeaf,
area,
);
},
);
});
})
.expect("draw");
let EventResult::Emit(PointerMsg::Hover(hover)) =
ratcn.handle_event(mouse(MouseKind::Moved, 0, 0), &state)
else {
panic!("crossing motion did not stage panel hover");
};
assert_eq!(
hover.path(),
&[ChildId::Static("panel"), ChildId::Static("emitter")]
);
state.hover = hover;
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Moved, 1, 0), &state),
EventResult::Emit(PointerMsg::Routed("move", MouseKind::Moved, 1))
);
}
#[test]
fn removed_hover_stays_invalid_until_pointer_reenters_reappeared_path() {
let rendered = Arc::new(Mutex::new(Vec::new()));
let state = PointerState {
hover: HoverState::intent([ChildId::Static("target")]),
};
let mut ratcn = Ratcn::new().hover(|state: &PointerState| &state.hover, PointerMsg::Hover);
let mut terminal = Terminal::new(TestBackend::new(5, 2)).expect("terminal");
let theme = Theme::default_dark();
let render_target = |ratcn: &mut Ratcn<PointerState, PointerMsg>,
terminal: &mut Terminal<TestBackend>| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("target"),
HoverLeaf {
consume_move: false,
rendered: Some(Arc::clone(&rendered)),
},
area,
);
});
})
.expect("draw");
};
render_target(&mut ratcn, &mut terminal);
let failed_removal = catch_unwind(AssertUnwindSafe(|| {
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |_| panic!("failed removal"));
})
.expect("draw");
}));
assert!(failed_removal.is_err());
render_target(&mut ratcn, &mut terminal);
terminal
.draw(|frame| ratcn.render(frame, &state, &theme, |_| {}))
.expect("draw");
render_target(&mut ratcn, &mut terminal);
render_target(&mut ratcn, &mut terminal);
assert_eq!(
*rendered.lock().expect("hover render log"),
vec![
(true, true),
(true, true),
(true, true),
(true, true),
(false, false),
(false, false),
(false, false),
(false, false),
]
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Moved, 4, 1), &state),
EventResult::Emit(PointerMsg::Hover(HoverState::intent([ChildId::Static(
"target"
)])))
);
render_target(&mut ratcn, &mut terminal);
assert_eq!(
rendered.lock().expect("hover render log").last(),
Some(&(true, true))
);
}
#[test]
fn removed_hover_reconciles_to_empty_before_a_later_reentry() {
let mut state = PointerState {
hover: HoverState::intent([ChildId::Static("target")]),
};
let mut ratcn = Ratcn::new().hover(|state: &PointerState| &state.hover, PointerMsg::Hover);
let mut terminal = Terminal::new(TestBackend::new(5, 2)).expect("terminal");
let theme = Theme::default_dark();
let render_target = |ratcn: &mut Ratcn<PointerState, PointerMsg>,
terminal: &mut Terminal<TestBackend>,
state: &PointerState| {
terminal
.draw(|frame| {
ratcn.render(frame, state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("target"),
HoverLeaf {
consume_move: false,
rendered: None,
},
Rect::new(0, 0, 2, 1),
);
});
})
.expect("draw");
};
render_target(&mut ratcn, &mut terminal, &state);
terminal
.draw(|frame| ratcn.render(frame, &state, &theme, |_| {}))
.expect("draw");
render_target(&mut ratcn, &mut terminal, &state);
let EventResult::Emit(PointerMsg::Hover(hover)) =
ratcn.handle_event(mouse(MouseKind::Moved, 4, 1), &state)
else {
panic!("first motion after removal did not reconcile hover to empty");
};
assert!(hover.path().is_empty());
state.hover = hover;
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Moved, 1, 0), &state),
EventResult::Emit(PointerMsg::Hover(HoverState::intent([ChildId::Static(
"target"
)])))
);
}
#[test]
fn mouse_before_the_first_render_is_ignored_without_arming_the_tracker() {
let state = PointerState::default();
let mut ratcn = Ratcn::new();
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 0, 0), &state),
EventResult::Ignored
);
let mut terminal = Terminal::new(TestBackend::new(5, 2)).expect("terminal");
render_drag_surface(
&mut ratcn,
&mut terminal,
&state,
&[("drag", "drag", Rect::new(0, 0, 5, 2))],
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Moved, 0, 0), &state),
EventResult::Ignored
);
}
struct LoggingComponent {
name: &'static str,
log: Arc<Mutex<Vec<&'static str>>>,
focusable: bool,
}
impl Component<FocusTestState, FocusTestMsg> for LoggingComponent {
fn render(&mut self, _ctx: &mut RenderCtx<'_, '_, FocusTestState, FocusTestMsg>) {
self.log.lock().expect("paint log").push(self.name);
}
fn handle_event(
&mut self,
event: &Event,
_state: &FocusTestState,
ctx: &mut EventCtx<'_>,
) -> EventResult<FocusTestMsg> {
if matches!(event, Event::Key(_)) {
EventResult::Emit(FocusTestMsg::Activated(ctx.path().to_vec()))
} else {
EventResult::Ignored
}
}
fn is_focusable(&self, _state: &FocusTestState) -> bool {
self.focusable
}
}
struct FocusModal;
impl Component<FocusTestState, FocusTestMsg> for FocusModal {
fn render(&mut self, ctx: &mut RenderCtx<'_, '_, FocusTestState, FocusTestMsg>) {
let area = ctx.area();
ctx.render_component(ChildId::Static("leaf"), FocusLeaf::enabled(), area);
}
fn scope_options(&self) -> ScopeOptions {
ScopeOptions::default().tab_wrap(TabWrap::Wrap)
}
}
struct EscapeFocusModal;
impl Component<FocusTestState, FocusTestMsg> for EscapeFocusModal {
fn render(&mut self, ctx: &mut RenderCtx<'_, '_, FocusTestState, FocusTestMsg>) {
let area = ctx.area();
ctx.render_component(ChildId::Static("first"), FocusLeaf::enabled(), area);
ctx.render_component(ChildId::Static("second"), FocusLeaf::enabled(), area);
}
fn scope_options(&self) -> ScopeOptions {
ScopeOptions::default().tab_wrap(TabWrap::Escape)
}
}
struct PanickingFocusComponent;
impl Component<FocusTestState, FocusTestMsg> for PanickingFocusComponent {
fn render(&mut self, _ctx: &mut RenderCtx<'_, '_, FocusTestState, FocusTestMsg>) {
panic!("modal render failed");
}
}
struct RecordingFocusModal {
rendered: Arc<Mutex<Vec<(bool, bool)>>>,
}
impl Component<FocusTestState, FocusTestMsg> for RecordingFocusModal {
fn render(&mut self, ctx: &mut RenderCtx<'_, '_, FocusTestState, FocusTestMsg>) {
let area = ctx.area();
ctx.render_component(
ChildId::Static("leaf"),
FocusLeaf::recording(Arc::clone(&self.rendered)),
area,
);
}
fn scope_options(&self) -> ScopeOptions {
ScopeOptions::default()
}
}
#[test]
fn modal_boundaries_flush_each_layers_passive_overlays_in_stack_order() {
let state = FocusTestState::default();
let log = Arc::new(Mutex::new(Vec::new()));
let mut ratcn = Ratcn::<FocusTestState, FocusTestMsg>::new();
let mut terminal = Terminal::new(TestBackend::new(5, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("base"),
LoggingComponent {
name: "base",
log: Arc::clone(&log),
focusable: false,
},
area,
);
let base = Arc::clone(&log);
ctx.defer_paint(move |_, _| {
base.lock().expect("paint log").push("base overlay");
});
ctx.modal(
ChildId::Static("lower"),
LoggingComponent {
name: "lower",
log: Arc::clone(&log),
focusable: false,
},
area,
);
let lower = Arc::clone(&log);
ctx.defer_paint(move |_, _| {
lower.lock().expect("paint log").push("lower overlay");
});
ctx.modal(
ChildId::Static("top"),
LoggingComponent {
name: "top",
log: Arc::clone(&log),
focusable: false,
},
area,
);
let top = Arc::clone(&log);
ctx.defer_paint(move |_, _| top.lock().expect("paint log").push("top overlay"));
});
})
.expect("draw");
assert_eq!(
*log.lock().expect("paint log"),
[
"base",
"lower",
"top",
"base",
"lower",
"top",
"base overlay",
"lower overlay",
"top overlay"
]
);
assert!(ratcn.modal_is_open());
}
#[test]
fn later_modal_focus_intent_marks_only_its_descendant_focused() {
let state = FocusTestState {
focus: FocusState::intent([ChildId::Static("top")]),
};
let lower = Arc::new(Mutex::new(Vec::new()));
let top = Arc::new(Mutex::new(Vec::new()));
let mut ratcn =
Ratcn::new().focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus);
let mut terminal = Terminal::new(TestBackend::new(5, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.modal(
ChildId::Static("lower"),
RecordingFocusModal {
rendered: Arc::clone(&lower),
},
area,
);
ctx.modal(
ChildId::Static("top"),
RecordingFocusModal {
rendered: Arc::clone(&top),
},
area,
);
});
})
.expect("draw");
assert_eq!(
*lower.lock().expect("lower focus log"),
[(false, false), (false, false)]
);
assert_eq!(
*top.lock().expect("top focus log"),
[(false, false), (true, true)]
);
}
#[test]
fn top_modal_alone_receives_and_absorbs_keyboard_input() {
let state = FocusTestState::default();
let log = Arc::new(Mutex::new(Vec::new()));
let mut ratcn =
Ratcn::new().focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus);
let mut terminal = Terminal::new(TestBackend::new(5, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
for (id, name) in [("lower", "lower"), ("top", "top")] {
ctx.modal(
ChildId::Static(id),
LoggingComponent {
name,
log: Arc::clone(&log),
focusable: true,
},
area,
);
}
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Emit(FocusTestMsg::Activated(vec![ChildId::Static("top")]))
);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Char('z'))), &state),
EventResult::Emit(FocusTestMsg::Activated(vec![ChildId::Static("top")]))
);
}
#[test]
fn tab_from_base_focus_enters_and_cannot_escape_the_active_modal() {
let state = FocusTestState {
focus: FocusState::intent([ChildId::Static("base")]),
};
let mut ratcn =
Ratcn::new().focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus);
let mut terminal = Terminal::new(TestBackend::new(5, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(ChildId::Static("base"), FocusLeaf::enabled(), area);
ctx.modal(ChildId::Static("dialog"), FocusModal, area);
});
})
.expect("draw");
let expected = FocusState::intent([ChildId::Static("dialog"), ChildId::Static("leaf")]);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Emit(FocusTestMsg::Activated(expected.path().to_vec()))
);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab)), &state),
EventResult::Consumed
);
}
#[test]
fn app_restores_exact_base_focus_and_can_restore_an_absent_parked_path() {
let mut state = FocusTestState {
focus: FocusState::intent([ChildId::Static("base")]),
};
let mut ratcn =
Ratcn::new().focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus);
let mut terminal = Terminal::new(TestBackend::new(5, 2)).expect("terminal");
let theme = Theme::default_dark();
let saved = state.focus.clone();
state.focus = FocusState::intent([ChildId::Static("dialog")]);
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(ChildId::Static("base"), FocusLeaf::enabled(), area);
ctx.modal(ChildId::Static("dialog"), FocusModal, area);
});
})
.expect("draw");
state.focus = saved;
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(ChildId::Static("base"), FocusLeaf::enabled(), area);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Emit(FocusTestMsg::Activated(vec![ChildId::Static("base")]))
);
state.focus = FocusState::intent([ChildId::Static("temporarily-absent")]);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Ignored
);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab)), &state),
EventResult::Emit(FocusTestMsg::Focus(FocusState::intent([ChildId::Static(
"base"
)])))
);
}
#[test]
fn app_owned_focus_selects_each_edge_of_a_nested_modal_stack() {
let mut state = FocusTestState {
focus: FocusState::intent([ChildId::Static("top")]),
};
let mut ratcn =
Ratcn::new().focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus);
let mut terminal = Terminal::new(TestBackend::new(5, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.modal(ChildId::Static("lower"), FocusModal, area);
ctx.modal(ChildId::Static("top"), FocusModal, area);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Emit(FocusTestMsg::Activated(vec![
ChildId::Static("top"),
ChildId::Static("leaf"),
]))
);
state.focus = FocusState::intent([ChildId::Static("lower")]);
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.modal(ChildId::Static("lower"), FocusModal, area);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Emit(FocusTestMsg::Activated(vec![
ChildId::Static("lower"),
ChildId::Static("leaf"),
]))
);
}
#[test]
fn app_owned_nested_modal_history_restores_each_exact_focus_path() {
let base = FocusState::intent([ChildId::Static("base"), ChildId::Static("base-child")]);
let lower = FocusState::intent([ChildId::Static("lower"), ChildId::Static("leaf")]);
let top = FocusState::intent([ChildId::Static("top"), ChildId::Static("leaf")]);
let mut state = FocusTestState {
focus: base.clone(),
};
let mut focus_history = Vec::new();
let mut ratcn =
Ratcn::new().focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus);
let mut terminal = Terminal::new(TestBackend::new(5, 2)).expect("terminal");
let theme = Theme::default_dark();
let render = |ratcn: &mut Ratcn<FocusTestState, FocusTestMsg>,
terminal: &mut Terminal<TestBackend>,
state: &FocusTestState,
lower_open,
top_open| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, state, &theme, |ctx| {
ctx.scope(
ChildId::Static("base"),
Rect::ZERO,
ScopeOptions::default(),
|ctx| {
ctx.render_component(
ChildId::Static("base-child"),
FocusLeaf::enabled(),
area,
);
},
);
if lower_open {
ctx.modal(ChildId::Static("lower"), FocusModal, area);
}
if top_open {
ctx.modal(ChildId::Static("top"), FocusModal, area);
}
});
})
.expect("draw");
};
focus_history.push(state.focus.clone());
state.focus = lower.clone();
render(&mut ratcn, &mut terminal, &state, true, false);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Emit(FocusTestMsg::Activated(lower.path().to_vec()))
);
focus_history.push(state.focus.clone());
state.focus = top.clone();
render(&mut ratcn, &mut terminal, &state, true, true);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Emit(FocusTestMsg::Activated(top.path().to_vec()))
);
state.focus = focus_history.pop().expect("lower modal focus history");
assert_eq!(state.focus, lower);
render(&mut ratcn, &mut terminal, &state, true, false);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Emit(FocusTestMsg::Activated(lower.path().to_vec()))
);
state.focus = focus_history.pop().expect("base focus history");
assert_eq!(state.focus, base);
render(&mut ratcn, &mut terminal, &state, false, false);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Emit(FocusTestMsg::Activated(base.path().to_vec()))
);
assert!(focus_history.is_empty());
}
#[test]
fn modal_binding_parks_absent_focus_while_fallback_still_routes() {
let mut state = ModalTestState::default();
state
.modals
.open("dialog", &mut state.focus)
.expect("open dialog");
state.focus = FocusState::intent([ChildId::Static("gone")]);
let rendered = Arc::new(Mutex::new(Vec::new()));
let mut ratcn = Ratcn::new()
.focus(|state: &ModalTestState| &state.focus, |_| unreachable!())
.modals(|state: &ModalTestState| &state.modals);
let mut terminal = Terminal::new(TestBackend::new(5, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.modal(
ChildId::Static("dialog"),
ModalFocusRoute {
rendered: Arc::clone(&rendered),
},
area,
);
});
})
.expect("draw");
assert_eq!(
*rendered.lock().expect("modal focus render log"),
[(false, false), (false, false)]
);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Emit(ModalTestMsg::Routed("dialog"))
);
}
#[test]
fn modal_binding_suppresses_opening_and_closing_gaps_then_routes_after_sync() {
let mut state = ModalTestState::default();
let mut ratcn = Ratcn::new()
.focus(|state: &ModalTestState| &state.focus, |_| unreachable!())
.modals(|state: &ModalTestState| &state.modals);
let mut terminal = Terminal::new(TestBackend::new(5, 2)).expect("terminal");
let theme = Theme::default_dark();
let render = |ratcn: &mut Ratcn<ModalTestState, ModalTestMsg>,
terminal: &mut Terminal<TestBackend>,
state: &ModalTestState| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, state, &theme, |ctx| {
ctx.render_component(ChildId::Static("base"), ModalRoute("base"), area);
if state.modals.is_open("dialog") {
ctx.modal(ChildId::Static("dialog"), ModalRoute("dialog"), area);
}
});
})
.expect("draw");
};
state
.modals
.open("dialog", &mut state.focus)
.expect("open before first render");
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Ignored,
"there is no retained surface to protect before the first render"
);
let _ = state.modals.close(&mut state.focus);
render(&mut ratcn, &mut terminal, &state);
state
.modals
.open("dialog", &mut state.focus)
.expect("open dialog");
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Consumed,
"opening gap must not reach the retained base"
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 0, 0), &state),
EventResult::Consumed
);
render(&mut ratcn, &mut terminal, &state);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Emit(ModalTestMsg::Routed("dialog"))
);
let _ = state.modals.close(&mut state.focus);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Consumed,
"closing gap must not reach the retained modal"
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Up(MouseButton::Left), 0, 0), &state),
EventResult::Consumed
);
render(&mut ratcn, &mut terminal, &state);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Emit(ModalTestMsg::Routed("base"))
);
}
#[test]
fn modal_binding_mismatch_preserves_the_previous_surface_atomically() {
let mut state = ModalTestState::default();
let mut ratcn = Ratcn::new()
.focus(|state: &ModalTestState| &state.focus, |_| unreachable!())
.modals(|state: &ModalTestState| &state.modals);
let mut terminal = Terminal::new(TestBackend::new(5, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(ChildId::Static("base"), ModalRoute("base"), area);
});
})
.expect("initial draw");
state
.modals
.open("expected", &mut state.focus)
.expect("open expected modal");
let failed = catch_unwind(AssertUnwindSafe(|| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.modal(ChildId::Static("wrong"), ModalRoute("wrong"), area);
});
})
.expect("mismatched draw");
}));
assert!(failed.is_err());
assert_eq!(ratcn.declared_paths(), vec![vec![ChildId::Static("base")]]);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Consumed
);
let _ = state.modals.close(&mut state.focus);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Emit(ModalTestMsg::Routed("base"))
);
}
#[test]
fn modal_scope_confines_events_and_focuses_its_children() {
#[derive(Debug, Default)]
struct State {
focus: FocusState,
}
#[derive(Debug, Clone, PartialEq)]
enum Msg {
Focus(FocusState),
Base,
Ok,
}
let state = State::default();
let mut ratcn = Ratcn::new().focus(|state: &State| &state.focus, Msg::Focus);
let mut terminal = Terminal::new(TestBackend::new(20, 6)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("base"),
crate::Button::new("Base").on_press(|| Msg::Base),
Rect::new(0, 0, 10, 1),
);
ctx.modal_scope(
ChildId::Static("sheet"),
area,
ScopeOptions::default(),
|ctx| {
ctx.render_component(
ChildId::Static("ok"),
crate::Button::new("OK").on_press(|| Msg::Ok),
Rect::new(2, 3, 6, 1),
);
},
);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Emit(Msg::Ok)
);
let click = |kind| {
Event::Mouse(MouseEvent {
kind,
column: 1,
row: 0,
modifiers: Modifiers::NONE,
})
};
assert_eq!(
ratcn.handle_event(click(MouseKind::Down(MouseButton::Left)), &state),
EventResult::Consumed
);
assert_eq!(
ratcn.handle_event(click(MouseKind::Up(MouseButton::Left)), &state),
EventResult::Consumed
);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Esc)), &state),
EventResult::Consumed
);
}
fn render_popup_over_leaf(
ratcn: &mut Ratcn<PointerState, PointerMsg>,
terminal: &mut Terminal<TestBackend>,
state: &PointerState,
with_dismiss: bool,
) {
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, state, &theme, |ctx| {
ctx.render_component(ChildId::Static("under"), RouteLeaf("under"), area);
let options = if with_dismiss {
PopupOptions::default().on_dismiss(|| PointerMsg::Dismissed)
} else {
PopupOptions::default()
};
ctx.popup(
ChildId::Static("panel"),
options,
Rect::new(0, 0, 5, 2),
|_| {},
);
});
})
.expect("draw");
}
#[test]
fn popup_occludes_its_footprint_and_leaves_the_rest_clickable() {
let state = PointerState::default();
let mut ratcn = Ratcn::<PointerState, PointerMsg>::new();
let mut terminal = Terminal::new(TestBackend::new(10, 2)).expect("terminal");
render_popup_over_leaf(&mut ratcn, &mut terminal, &state, false);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 0), &state),
EventResult::Consumed
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 7, 0), &state),
EventResult::Emit(PointerMsg::Routed(
"under",
MouseKind::Down(MouseButton::Left),
0
))
);
}
#[test]
fn a_popup_declared_after_a_modal_is_still_covered_by_it() {
let state = PointerState::default();
let mut ratcn = Ratcn::<PointerState, PointerMsg>::new();
let mut terminal = Terminal::new(TestBackend::new(10, 4)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
ctx.modal(
ChildId::Static("dlg"),
RouteLeaf("dlg"),
Rect::new(0, 2, 10, 2),
);
ctx.popup(
ChildId::Static("panel"),
PopupOptions::default(),
Rect::new(0, 0, 5, 1),
|ctx| {
ctx.render_component(
ChildId::Static("pi"),
RouteLeaf("pi"),
Rect::new(0, 0, 5, 1),
);
},
);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 0), &state),
EventResult::Consumed,
"the modal covers it, so the press must not reach the popup's content"
);
}
#[test]
fn a_hint_layer_is_inert_to_the_pointer_and_to_focus() {
struct FocusableLeaf;
impl Component<PointerState, PointerMsg> for FocusableLeaf {
fn render(&mut self, _ctx: &mut RenderCtx<'_, '_, PointerState, PointerMsg>) {}
fn is_focusable(&self, _state: &PointerState) -> bool {
true
}
}
let state = PointerState::default();
let mut ratcn = Ratcn::<PointerState, PointerMsg>::new();
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("button"),
RouteLeaf("button"),
Rect::new(0, 0, 6, 1),
);
ctx.hint(
ChildId::Static("tip"),
ScopeOptions::default(),
Rect::new(0, 0, 6, 1),
|ctx| {
ctx.render_component(
ChildId::Static("text"),
FocusableLeaf,
Rect::new(0, 0, 6, 1),
);
},
);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 0), &state),
EventResult::Emit(PointerMsg::Routed(
"button",
MouseKind::Down(MouseButton::Left),
0
)),
"the press passes through the hint to the control it describes"
);
assert_eq!(
ratcn.focus_path(&[ChildId::Static("tip"), ChildId::Static("text")]),
None,
"a focusable component inside a hint is still not a focus target"
);
}
#[test]
fn a_press_inside_one_popup_dismisses_its_sibling() {
let state = PointerState::default();
let mut ratcn = Ratcn::<PointerState, PointerMsg>::new();
let mut terminal = Terminal::new(TestBackend::new(12, 4)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
ctx.popup(
ChildId::Static("first"),
PopupOptions::default()
.on_dismiss(|| PointerMsg::Routed("first", MouseKind::Moved, 0)),
Rect::new(0, 0, 5, 1),
|_| {},
);
ctx.popup(
ChildId::Static("second"),
PopupOptions::default()
.on_dismiss(|| PointerMsg::Routed("second", MouseKind::Moved, 0)),
Rect::new(6, 2, 5, 1),
|_| {},
);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 7, 2), &state),
EventResult::Emit(PointerMsg::Routed("first", MouseKind::Moved, 0)),
"the press is inside `second` and outside `first`, so `first` dismisses"
);
}
#[test]
fn an_outside_press_dismisses_the_innermost_nested_popup() {
let state = PointerState::default();
let mut ratcn = Ratcn::<PointerState, PointerMsg>::new();
let mut terminal = Terminal::new(TestBackend::new(10, 4)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
ctx.popup(
ChildId::Static("outer"),
PopupOptions::default()
.on_dismiss(|| PointerMsg::Routed("outer", MouseKind::Moved, 0)),
Rect::new(0, 0, 5, 2),
|ctx| {
ctx.popup(
ChildId::Static("inner"),
PopupOptions::default().on_dismiss(|| {
PointerMsg::Routed("inner", MouseKind::Moved, 0)
}),
Rect::new(0, 0, 3, 1),
|_| {},
);
},
);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 9, 3), &state),
EventResult::Emit(PointerMsg::Routed("inner", MouseKind::Moved, 0)),
"the innermost popup is the topmost, so it is what a press outside dismisses"
);
}
#[test]
fn outside_press_emits_the_dismiss_hook_only_when_routing_stayed_silent() {
let state = PointerState::default();
let mut ratcn = Ratcn::<PointerState, PointerMsg>::new();
let mut terminal = Terminal::new(TestBackend::new(10, 4)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("button"),
RouteLeaf("button"),
Rect::new(6, 0, 4, 1),
);
ctx.popup(
ChildId::Static("panel"),
PopupOptions::default().on_dismiss(|| PointerMsg::Dismissed),
Rect::new(0, 0, 5, 2),
|_| {},
);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 8, 3), &state),
EventResult::Emit(PointerMsg::Dismissed)
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 7, 0), &state),
EventResult::Emit(PointerMsg::Routed(
"button",
MouseKind::Down(MouseButton::Left),
0
))
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 0), &state),
EventResult::Consumed
);
}
struct PopupHost;
impl Component<FocusTestState, FocusTestMsg> for PopupHost {
fn render(&mut self, ctx: &mut RenderCtx<'_, '_, FocusTestState, FocusTestMsg>) {
let area = ctx.area();
ctx.popup(
ChildId::Static("panel"),
PopupOptions::default(),
area,
|ctx| {
let area = ctx.area();
ctx.render_component(ChildId::Static("item"), FocusLeaf::enabled(), area);
},
);
}
fn handle_event(
&mut self,
event: &Event,
_state: &FocusTestState,
ctx: &mut EventCtx<'_>,
) -> EventResult<FocusTestMsg> {
if matches!(event, Event::Key(key) if key.code == KeyCode::Esc) {
EventResult::Emit(FocusTestMsg::Parent(ctx.path().to_vec()))
} else {
EventResult::Ignored
}
}
}
#[test]
fn keys_bubble_through_the_popup_root_to_the_declaring_component() {
let state = FocusTestState {
focus: FocusState::intent([
ChildId::Static("host"),
ChildId::Static("panel"),
ChildId::Static("item"),
]),
};
let mut ratcn =
Ratcn::new().focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus);
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(ChildId::Static("host"), PopupHost, area);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Emit(FocusTestMsg::Activated(vec![
ChildId::Static("host"),
ChildId::Static("panel"),
ChildId::Static("item"),
]))
);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Esc)), &state),
EventResult::Emit(FocusTestMsg::Parent(vec![ChildId::Static("host")]))
);
}
#[test]
fn popup_inside_a_modal_sits_above_it_and_routes() {
let state = PointerState::default();
let mut ratcn = Ratcn::<PointerState, PointerMsg>::new();
let mut terminal = Terminal::new(TestBackend::new(10, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(ChildId::Static("base"), RouteLeaf("base"), area);
ctx.modal_scope(
ChildId::Static("sheet"),
area,
ScopeOptions::default(),
move |ctx| {
ctx.render_component(
ChildId::Static("field"),
RouteLeaf("field"),
Rect::new(5, 0, 5, 2),
);
ctx.popup(
ChildId::Static("panel"),
PopupOptions::default(),
Rect::new(0, 0, 5, 2),
|ctx| {
ctx.render_component(
ChildId::Static("option"),
RouteLeaf("option"),
Rect::new(0, 0, 5, 2),
);
},
);
},
);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 0), &state),
EventResult::Emit(PointerMsg::Routed(
"option",
MouseKind::Down(MouseButton::Left),
0
))
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 7, 0), &state),
EventResult::Emit(PointerMsg::Routed(
"field",
MouseKind::Down(MouseButton::Left),
0
))
);
}
#[test]
fn popup_paint_composites_above_later_declared_base_siblings() {
let state = PointerState::default();
let mut ratcn = Ratcn::<PointerState, PointerMsg>::new();
let mut terminal = Terminal::new(TestBackend::new(4, 1)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.popup(
ChildId::Static("panel"),
PopupOptions::default(),
Rect::new(0, 0, 2, 1),
|ctx| {
ctx.render_widget(
ratatui::text::Line::from("PP"),
Rect::new(0, 0, 2, 1),
);
},
);
ctx.render_widget(ratatui::text::Line::from("BBBB"), area);
});
})
.expect("draw");
let buffer = terminal.backend().buffer();
assert_eq!(buffer.cell((0, 0)).expect("cell").symbol(), "P");
assert_eq!(buffer.cell((1, 0)).expect("cell").symbol(), "P");
assert_eq!(buffer.cell((2, 0)).expect("cell").symbol(), "B");
}
#[test]
fn a_caught_panic_in_the_paint_pass_alone_fails_the_pass() {
let mut ratcn = Ratcn::<(), ()>::new();
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
render_leaf(&mut ratcn, &mut terminal, &ChildId::Static("stable"));
let theme = Theme::default_dark();
let mut runs = 0;
let result = catch_unwind(AssertUnwindSafe(|| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &(), &theme, |ctx| {
runs += 1;
if runs == 1 {
ctx.render_component(ChildId::Static("leaf"), Leaf, area);
} else {
let caught = catch_unwind(AssertUnwindSafe(|| {
ctx.render_component(ChildId::Static("leaf"), PanickingLeaf, area);
}));
assert!(caught.is_err());
}
});
})
.expect("draw");
}));
assert!(result.is_err());
assert_eq!(
ratcn.declared_paths(),
vec![vec![ChildId::Static("stable")]]
);
}
fn render_bound_nested_modal(
ratcn: &mut Ratcn<ModalTestState, ModalTestMsg>,
terminal: &mut Terminal<TestBackend>,
state: &ModalTestState,
rendered: &FocusRenderLog,
) {
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, state, &theme, |ctx| {
let rendered = Arc::clone(rendered);
ctx.scope(
ChildId::Static("pane"),
area,
ScopeOptions::default(),
move |ctx| {
ctx.modal_scope(
ChildId::Static("sheet"),
area,
ScopeOptions::default(),
move |ctx| {
let area = ctx.area();
ctx.render_component(
ChildId::Static("inner"),
ModalFocusLeaf {
rendered: Arc::clone(&rendered),
},
area,
);
},
);
},
);
});
})
.expect("draw");
}
#[test]
fn bound_nested_modal_keeps_valid_in_modal_focus() {
let mut state = ModalTestState::default();
let mut focus = state.focus.clone();
state
.modals
.open(ChildId::Static("sheet"), &mut focus)
.expect("open modal");
state.focus = focus;
let rendered = Arc::new(Mutex::new(Vec::new()));
let mut ratcn = Ratcn::new()
.focus(|state: &ModalTestState| &state.focus, ModalTestMsg::Focus)
.modals(|state: &ModalTestState| &state.modals);
let mut terminal = Terminal::new(TestBackend::new(20, 6)).expect("terminal");
render_bound_nested_modal(&mut ratcn, &mut terminal, &state, &rendered);
assert_eq!(
*rendered.lock().expect("inner render log"),
[(false, false), (true, true)]
);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Emit(ModalTestMsg::Routed("inner"))
);
state.focus = FocusState::intent([
ChildId::Static("pane"),
ChildId::Static("sheet"),
ChildId::Static("inner"),
]);
rendered.lock().expect("inner render log").clear();
render_bound_nested_modal(&mut ratcn, &mut terminal, &state, &rendered);
assert_eq!(
*rendered.lock().expect("inner render log"),
[(true, true), (true, true)]
);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Emit(ModalTestMsg::Routed("inner"))
);
}
struct ModalFocusLeaf {
rendered: FocusRenderLog,
}
impl Component<ModalTestState, ModalTestMsg> for ModalFocusLeaf {
fn render(&mut self, ctx: &mut RenderCtx<'_, '_, ModalTestState, ModalTestMsg>) {
self.rendered
.lock()
.expect("render log")
.push((ctx.focused, ctx.contains_focus));
}
fn handle_event(
&mut self,
event: &Event,
_state: &ModalTestState,
_ctx: &mut EventCtx<'_>,
) -> EventResult<ModalTestMsg> {
if matches!(event, Event::Key(key) if key.code == KeyCode::Enter) {
EventResult::Emit(ModalTestMsg::Routed("inner"))
} else {
EventResult::Ignored
}
}
fn is_focusable(&self, _state: &ModalTestState) -> bool {
true
}
}
struct ClickLeaf(&'static str);
impl Component<PointerState, PointerMsg> for ClickLeaf {
fn render(&mut self, _ctx: &mut RenderCtx<'_, '_, PointerState, PointerMsg>) {}
fn handle_event(
&mut self,
event: &Event,
_state: &PointerState,
_ctx: &mut EventCtx<'_>,
) -> EventResult<PointerMsg> {
match event {
Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::Click(_)) => {
EventResult::Emit(PointerMsg::Routed(self.0, mouse.kind, 0))
}
_ => EventResult::Ignored,
}
}
}
#[test]
fn one_physical_click_dismisses_the_popup_and_presses_the_button() {
let state = PointerState::default();
let mut ratcn = Ratcn::<PointerState, PointerMsg>::new();
let mut terminal = Terminal::new(TestBackend::new(10, 2)).expect("terminal");
let theme = Theme::default_dark();
let render = |ratcn: &mut Ratcn<PointerState, PointerMsg>,
terminal: &mut Terminal<TestBackend>,
popup_open: bool| {
terminal
.draw(|frame| {
ratcn.render(frame, &PointerState::default(), &theme, |ctx| {
ctx.render_component(
ChildId::Static("button"),
ClickLeaf("button"),
Rect::new(6, 0, 4, 1),
);
if popup_open {
ctx.popup(
ChildId::Static("panel"),
PopupOptions::default().on_dismiss(|| PointerMsg::Dismissed),
Rect::new(0, 0, 5, 2),
|_| {},
);
}
});
})
.expect("draw");
};
render(&mut ratcn, &mut terminal, true);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 7, 0), &state),
EventResult::Emit(PointerMsg::Dismissed)
);
render(&mut ratcn, &mut terminal, false);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Up(MouseButton::Left), 7, 0), &state),
EventResult::Emit(PointerMsg::Routed(
"button",
MouseKind::Click(MouseButton::Left),
0
))
);
}
#[test]
fn nested_modal_scope_behaves_like_a_root_declared_one() {
let state = FocusTestState::default();
let rendered = Arc::new(Mutex::new(Vec::new()));
let mut ratcn =
Ratcn::new().focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus);
let mut terminal = Terminal::new(TestBackend::new(20, 6)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(ChildId::Static("base"), FocusLeaf::enabled(), area);
let rendered = Arc::clone(&rendered);
ctx.scope(
ChildId::Static("pane"),
area,
ScopeOptions::default(),
move |ctx| {
ctx.modal_scope(
ChildId::Static("sheet"),
area,
ScopeOptions::default(),
move |ctx| {
let area = ctx.area();
ctx.render_component(
ChildId::Static("inner"),
FocusLeaf::recording(rendered),
area,
);
},
);
},
);
});
})
.expect("draw");
assert_eq!(
*rendered.lock().expect("inner render log"),
[(false, false), (true, true)]
);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Emit(FocusTestMsg::Activated(vec![
ChildId::Static("pane"),
ChildId::Static("sheet"),
ChildId::Static("inner"),
]))
);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Char('x'))), &state),
EventResult::Consumed
);
}
#[test]
fn failed_modal_pass_preserves_the_previous_stack() {
let state = FocusTestState::default();
let log = Arc::new(Mutex::new(Vec::new()));
let mut ratcn = Ratcn::<FocusTestState, FocusTestMsg>::new();
let mut terminal = Terminal::new(TestBackend::new(5, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.modal(
ChildId::Static("stable"),
LoggingComponent {
name: "stable",
log: Arc::clone(&log),
focusable: false,
},
area,
);
});
})
.expect("draw");
let failed = catch_unwind(AssertUnwindSafe(|| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.modal(
ChildId::Static("replacement"),
PanickingFocusComponent,
area,
);
});
})
.expect("draw");
}));
assert!(failed.is_err());
assert!(ratcn.modal_is_open());
assert_eq!(
ratcn.declared_paths(),
vec![vec![ChildId::Static("stable")]]
);
}
#[test]
fn modal_is_the_component_root() {
let state = FocusTestState {
focus: FocusState::default(),
};
let mut ratcn =
Ratcn::new().focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus);
let mut terminal = Terminal::new(TestBackend::new(5, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.modal(ChildId::Static("dialog"), FocusModal, area);
});
})
.expect("draw");
assert_eq!(
ratcn.declared_paths(),
vec![
vec![ChildId::Static("dialog")],
vec![ChildId::Static("dialog"), ChildId::Static("leaf"),],
]
);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Emit(FocusTestMsg::Activated(vec![
ChildId::Static("dialog"),
ChildId::Static("leaf"),
]))
);
}
#[test]
fn zero_area_modal_is_retained_but_excluded_from_keyboard_fallback() {
let state = FocusTestState::default();
let log = Arc::new(Mutex::new(Vec::new()));
let mut ratcn =
Ratcn::new().focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus);
let mut terminal = Terminal::new(TestBackend::new(5, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
ctx.modal(
ChildId::Static("dialog"),
LoggingComponent {
name: "dialog",
log: Arc::clone(&log),
focusable: false,
},
Rect::ZERO,
);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Consumed
);
assert_eq!(
ratcn.declared_paths(),
vec![vec![ChildId::Static("dialog")]]
);
assert_eq!(*log.lock().expect("modal log"), ["dialog", "dialog"]);
}
#[test]
fn modal_wraps_focus_outside_the_component_boundary() {
let state = FocusTestState {
focus: FocusState::intent([ChildId::Static("dialog"), ChildId::Static("second")]),
};
let mut ratcn =
Ratcn::new().focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus);
let mut terminal = Terminal::new(TestBackend::new(5, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.modal(ChildId::Static("dialog"), EscapeFocusModal, area);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab)), &state),
EventResult::Emit(FocusTestMsg::Focus(FocusState::intent([
ChildId::Static("dialog"),
ChildId::Static("first"),
])))
);
}
#[test]
fn caught_modal_boundary_failure_is_sticky_and_atomic() {
let mut ratcn = Ratcn::<(), ()>::new();
let mut terminal = Terminal::new(TestBackend::new(5, 2)).expect("terminal");
render_leaf(&mut ratcn, &mut terminal, &ChildId::Static("stable"));
let theme = Theme::default_dark();
let failed = catch_unwind(AssertUnwindSafe(|| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &(), &theme, |ctx| {
ctx.defer_paint(|_, ()| panic!("base overlay failed"));
let caught = catch_unwind(AssertUnwindSafe(|| {
ctx.modal(ChildId::Static("modal"), Leaf, area);
}));
assert!(caught.is_err());
});
})
.expect("draw");
}));
assert!(failed.is_err());
assert_eq!(
ratcn.declared_paths(),
vec![vec![ChildId::Static("stable")]]
);
}
#[test]
fn caught_lower_modal_overlay_flush_failure_preserves_retained_interaction() {
let state = PointerState::default();
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(10, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.modal(
ChildId::Static("stable"),
Draggable { name: "stable" },
area,
);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 1), &state),
EventResult::Consumed
);
let failed = catch_unwind(AssertUnwindSafe(|| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.modal(
ChildId::Static("replacement"),
Draggable {
name: "replacement",
},
area,
);
ctx.defer_paint(|_, _| panic!("lower modal overlay failed"));
let caught = catch_unwind(AssertUnwindSafe(|| {
ctx.modal(ChildId::Static("top"), RouteLeaf("top"), area);
}));
assert!(caught.is_err());
});
})
.expect("draw");
}));
assert!(failed.is_err());
assert!(ratcn.modal_is_open());
assert_eq!(
ratcn.declared_paths(),
vec![vec![ChildId::Static("stable")]]
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Moved, 9, 1), &state),
EventResult::Emit(PointerMsg::Routed(
"stable",
MouseKind::Drag(MouseButton::Left),
2,
))
);
}
#[test]
fn modal_transition_cancels_base_capture_through_release() {
let state = PointerState::default();
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(10, 2)).expect("terminal");
render_drag_surface(
&mut ratcn,
&mut terminal,
&state,
&[("drag", "base", Rect::new(0, 0, 5, 2))],
);
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 1), &state);
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(ChildId::Static("drag"), Draggable { name: "base" }, area);
ctx.modal(ChildId::Static("modal"), RouteLeaf("modal"), area);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Moved, 9, 1), &state),
EventResult::Consumed
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Up(MouseButton::Left), 9, 1), &state),
EventResult::Consumed
);
render_drag_surface(
&mut ratcn,
&mut terminal,
&state,
&[("drag", "base", Rect::new(0, 0, 5, 2))],
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Drag(MouseButton::Left), 9, 1), &state),
EventResult::Ignored
);
}
#[test]
fn covered_hover_becomes_eligible_after_close_without_motion() {
let rendered = Arc::new(Mutex::new(Vec::new()));
let state = PointerState {
hover: HoverState::intent([ChildId::Static("base")]),
};
let mut ratcn = Ratcn::new().hover(|state: &PointerState| &state.hover, PointerMsg::Hover);
let mut terminal = Terminal::new(TestBackend::new(5, 2)).expect("terminal");
let theme = Theme::default_dark();
let mut render = |ratcn: &mut Ratcn<PointerState, PointerMsg>, modal| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("base"),
HoverLeaf {
consume_move: false,
rendered: Some(Arc::clone(&rendered)),
},
area,
);
if modal {
ctx.modal(ChildId::Static("modal"), RouteLeaf("modal"), area);
}
});
})
.expect("draw");
};
render(&mut ratcn, true);
render(&mut ratcn, true);
render(&mut ratcn, false);
assert_eq!(
rendered.lock().expect("hover log").last(),
Some(&(false, false))
);
render(&mut ratcn, false);
assert_eq!(
rendered.lock().expect("hover log").last(),
Some(&(true, true))
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Moved, 1, 1), &state),
EventResult::Consumed
);
}
#[test]
fn passive_overlay_never_becomes_a_hit_target() {
let state = PointerState::default();
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(5, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(ChildId::Static("base"), RouteLeaf("base"), area);
ctx.defer_paint(|painter, _| {
painter.with_buffer(|buf| {
buf[(0, 0)].set_symbol("overlay");
});
});
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 0, 0), &state),
EventResult::Emit(PointerMsg::Routed(
"base",
MouseKind::Down(MouseButton::Left),
0,
))
);
}
#[test]
fn duplicate_modal_root_ids_fail_before_entering_their_layer() {
let theme = Theme::default_dark();
let mut terminal = Terminal::new(TestBackend::new(5, 2)).expect("terminal");
for ids in [&["a", "a"][..], &["a", "b", "a"][..]] {
let pending_overlay = Arc::new(AtomicBool::new(false));
let mut ratcn = Ratcn::<(), ()>::new();
render_leaf(&mut ratcn, &mut terminal, &ChildId::Static("stable"));
let failed = catch_unwind(AssertUnwindSafe(|| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &(), &theme, |ctx| {
for (position, id) in ids.iter().enumerate() {
ctx.modal(ChildId::Static(id), Leaf, area);
if position + 1 == ids.len() - 1 {
let painted = Arc::clone(&pending_overlay);
ctx.defer_paint(move |_, ()| {
painted.store(true, Ordering::SeqCst);
});
}
}
});
})
.expect("draw");
}));
assert!(failed.is_err());
assert!(!pending_overlay.load(Ordering::SeqCst));
assert_eq!(
ratcn.declared_paths(),
vec![vec![ChildId::Static("stable")]]
);
}
}
#[test]
fn base_and_modal_root_id_collision_fails_before_base_overlay_flush() {
let painted = Arc::new(AtomicBool::new(false));
let mut ratcn = Ratcn::<(), ()>::new();
let mut terminal = Terminal::new(TestBackend::new(5, 2)).expect("terminal");
render_leaf(&mut ratcn, &mut terminal, &ChildId::Static("stable"));
let theme = Theme::default_dark();
let failed = catch_unwind(AssertUnwindSafe(|| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &(), &theme, |ctx| {
ctx.render_component(ChildId::Static("same"), Leaf, area);
let deferred = Arc::clone(&painted);
ctx.defer_paint(move |_, ()| deferred.store(true, Ordering::SeqCst));
ctx.modal(ChildId::Static("same"), Leaf, area);
});
})
.expect("draw");
}));
assert!(failed.is_err());
assert!(!painted.load(Ordering::SeqCst));
assert_eq!(
ratcn.declared_paths(),
vec![vec![ChildId::Static("stable")]]
);
}
#[test]
fn modal_tab_recovers_parked_focus_and_absorbs_wrapped_resolved_focus() {
let mut state = FocusTestState {
focus: FocusState::intent([ChildId::Static("parked")]),
};
let mut ratcn =
Ratcn::new().focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus);
let mut terminal = Terminal::new(TestBackend::new(10, 3)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.modal(
ChildId::Static("modal"),
Dialog::new()
.tab_wrap(TabWrap::Escape)
.on_dismiss(|| FocusTestMsg::Activated(vec![])),
area,
);
});
})
.expect("draw");
let recovered = FocusState::intent([ChildId::Static("modal")]);
for code in [KeyCode::Tab, KeyCode::BackTab] {
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(code)), &state),
EventResult::Emit(FocusTestMsg::Focus(recovered.clone()))
);
}
state.focus = FocusState::default();
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab)), &state),
EventResult::Consumed
);
}
#[test]
fn a_modal_nested_inside_another_is_the_top_of_the_stack() {
let state = FocusTestState {
focus: FocusState::intent([
ChildId::Static("outer"),
ChildId::Static("inner"),
ChildId::Static("leaf"),
]),
};
let mut ratcn =
Ratcn::new().focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus);
let mut terminal = Terminal::new(TestBackend::new(10, 4)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.modal_scope(
ChildId::Static("outer"),
area,
ScopeOptions::default().tab_wrap(TabWrap::Wrap),
|ctx| {
ctx.render_component(
ChildId::Static("outerleaf"),
FocusLeaf::enabled(),
area,
);
ctx.modal_scope(
ChildId::Static("inner"),
area,
ScopeOptions::default(),
|ctx| {
ctx.render_component(
ChildId::Static("leaf"),
FocusLeaf::enabled(),
area,
);
},
);
},
);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab)), &state),
EventResult::Consumed,
"the inner modal has one focusable leaf, so Tab wraps onto it"
);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Emit(FocusTestMsg::Activated(vec![
ChildId::Static("outer"),
ChildId::Static("inner"),
ChildId::Static("leaf"),
]))
);
}
#[test]
fn a_nested_modal_matches_the_app_stack_in_open_order() {
let mut state = ModalTestState::default();
state
.modals
.open(ChildId::Static("outer"), &mut state.focus)
.expect("open outer");
state
.modals
.open(ChildId::Static("inner"), &mut state.focus)
.expect("open inner");
let mut ratcn: Ratcn<ModalTestState, ModalTestMsg> =
Ratcn::new().modals(|state: &ModalTestState| &state.modals);
let mut terminal = Terminal::new(TestBackend::new(10, 4)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.modal_scope(
ChildId::Static("outer"),
area,
ScopeOptions::default(),
|ctx| {
ctx.modal_scope(
ChildId::Static("inner"),
area,
ScopeOptions::default(),
|_| {},
);
},
);
});
})
.expect("declaring the stack in the order it was opened must render");
}
#[test]
fn tab_reaches_an_open_modal_from_a_parked_path_in_a_wrapping_scope() {
for wrap in [TabWrap::Escape, TabWrap::Wrap] {
let state = FocusTestState {
focus: FocusState::intent([ChildId::Static("pane"), ChildId::Static("gone")]),
};
let mut ratcn =
Ratcn::new().focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus);
let mut terminal = Terminal::new(TestBackend::new(20, 6)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.scope(
ChildId::Static("pane"),
Rect::new(0, 0, 20, 2),
ScopeOptions::default().tab_wrap(wrap),
|ctx| {
ctx.render_component(
ChildId::Static("inside"),
FocusLeaf::enabled(),
Rect::new(0, 0, 10, 1),
);
},
);
ctx.modal(ChildId::Static("dlg"), FocusModal, area);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab)), &state),
EventResult::Emit(FocusTestMsg::Focus(FocusState::intent([
ChildId::Static("dlg"),
ChildId::Static("leaf"),
]))),
"Tab must enter the modal whatever the covered scope's wrap is ({wrap:?})"
);
}
}
#[test]
fn a_zero_area_modal_still_absorbs_keys() {
let state = FocusTestState {
focus: FocusState::intent([ChildId::Static("outside")]),
};
let mut ratcn =
Ratcn::new().focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus);
let mut terminal = Terminal::new(TestBackend::new(20, 4)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("outside"),
Button::new("Outside")
.on_press(|| FocusTestMsg::Activated(vec![ChildId::Static("outside")])),
Rect::new(0, 0, 12, 1),
);
ctx.modal(ChildId::Static("modal"), FocusLeaf::enabled(), Rect::ZERO);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Consumed
);
}
#[test]
fn a_root_focus_key_cannot_escape_an_open_modal() {
let state = FocusTestState {
focus: FocusState::intent([ChildId::Static("modal")]),
};
let mut ratcn = Ratcn::new()
.focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus)
.focus_key(KeyChord::from('1').alt(), [ChildId::Static("outside")]);
let mut terminal = Terminal::new(TestBackend::new(20, 6)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("outside"),
Button::new("Outside")
.on_press(|| FocusTestMsg::Activated(vec![ChildId::Static("outside")])),
Rect::new(0, 0, 12, 1),
);
ctx.modal(
ChildId::Static("modal"),
Dialog::new().on_dismiss(|| FocusTestMsg::Activated(vec![])),
area,
);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(
Event::Key(KeyEvent {
code: KeyCode::Char('1'),
modifiers: Modifiers {
alt: true,
..Modifiers::NONE
},
}),
&state,
),
EventResult::Consumed,
"the binding names a target the modal covers, so it does not fire"
);
}
#[test]
fn a_modal_with_no_focusable_content_still_absorbs_keys() {
let state = FocusTestState {
focus: FocusState::intent([ChildId::Static("outside")]),
};
let mut ratcn =
Ratcn::new().focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus);
let mut terminal = Terminal::new(TestBackend::new(30, 10)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("outside"),
Button::new("Outside")
.on_press(|| FocusTestMsg::Activated(vec![ChildId::Static("outside")])),
Rect::new(0, 0, 12, 1),
);
ctx.modal(
ChildId::Static("modal"),
Dialog::new().title("Notice").description("Wait."),
area,
);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Enter)), &state),
EventResult::Consumed,
"the modal layer absorbs the key; the button beneath must not press"
);
}
#[test]
fn handlerless_modal_dialog_never_becomes_the_focus_target() {
let mut state = FocusTestState {
focus: FocusState::default(),
};
let mut ratcn =
Ratcn::new().focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus);
let mut terminal = Terminal::new(TestBackend::new(30, 10)).expect("terminal");
let theme = Theme::default_dark();
let draw = |terminal: &mut Terminal<TestBackend>,
ratcn: &mut Ratcn<FocusTestState, FocusTestMsg>,
state: &FocusTestState| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, state, &theme, |ctx| {
ctx.modal(
ChildId::Static("modal"),
Dialog::new()
.action(
ChildId::Static("ok"),
Button::new("OK").on_press(|| {
FocusTestMsg::Activated(vec![ChildId::Static("ok")])
}),
)
.action(
ChildId::Static("cancel"),
Button::new("Cancel").on_press(|| {
FocusTestMsg::Activated(vec![ChildId::Static("cancel")])
}),
),
area,
);
});
})
.expect("draw");
};
draw(&mut terminal, &mut ratcn, &state);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab)), &state),
EventResult::Emit(FocusTestMsg::Focus(FocusState::intent([
ChildId::Static("modal"),
ChildId::Static("cancel"),
])))
);
state.focus = FocusState::intent([ChildId::Static("modal"), ChildId::Static("cancel")]);
draw(&mut terminal, &mut ratcn, &state);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab)), &state),
EventResult::Emit(FocusTestMsg::Focus(FocusState::intent([
ChildId::Static("modal"),
ChildId::Static("ok"),
]))),
"the default Wrap cycles among the actions, never onto the dialog"
);
assert_eq!(
ratcn.handle_event(Event::Key(KeyEvent::new(KeyCode::Esc)), &state),
EventResult::Consumed
);
}
#[test]
fn top_modal_push_and_pop_cancel_capture_through_release() {
let state = PointerState::default();
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(10, 2)).expect("terminal");
let theme = Theme::default_dark();
let mut render = |ratcn: &mut Ratcn<PointerState, PointerMsg>, top: bool| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.modal(ChildId::Static("lower"), Draggable { name: "lower" }, area);
if top {
ctx.modal(ChildId::Static("top"), Draggable { name: "top" }, area);
}
});
})
.expect("draw");
};
render(&mut ratcn, false);
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 1), &state);
render(&mut ratcn, true);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Moved, 9, 1), &state),
EventResult::Consumed
);
ratcn.handle_event(mouse(MouseKind::Up(MouseButton::Left), 9, 1), &state);
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 1), &state);
render(&mut ratcn, false);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Moved, 9, 1), &state),
EventResult::Consumed
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Up(MouseButton::Left), 9, 1), &state),
EventResult::Consumed
);
}
#[test]
fn same_top_modal_identity_retains_capture_when_lower_stack_changes() {
let state = PointerState::default();
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(10, 2)).expect("terminal");
let theme = Theme::default_dark();
let mut render = |ratcn: &mut Ratcn<PointerState, PointerMsg>, lower, top_name| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.modal(ChildId::Static(lower), RouteLeaf("lower"), area);
ctx.modal(ChildId::Static("top"), Draggable { name: top_name }, area);
});
})
.expect("draw");
};
render(&mut ratcn, "lower-a", "before");
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 1), &state);
render(&mut ratcn, "lower-b", "after");
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Moved, 9, 1), &state),
EventResult::Emit(PointerMsg::Routed(
"after",
MouseKind::Drag(MouseButton::Left),
2,
))
);
}
#[test]
fn same_top_modal_identity_retains_hover_when_lower_stack_changes() {
let rendered = Arc::new(Mutex::new(Vec::new()));
let mut state = PointerState::default();
let mut ratcn = Ratcn::new().hover(|state: &PointerState| &state.hover, PointerMsg::Hover);
let mut terminal = Terminal::new(TestBackend::new(10, 2)).expect("terminal");
let theme = Theme::default_dark();
let mut render =
|ratcn: &mut Ratcn<PointerState, PointerMsg>, state: &PointerState, lower| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, state, &theme, |ctx| {
ctx.modal(ChildId::Static(lower), RouteLeaf("lower"), area);
ctx.modal(
ChildId::Static("top"),
HoverLeaf {
consume_move: false,
rendered: Some(Arc::clone(&rendered)),
},
area,
);
});
})
.expect("draw");
};
render(&mut ratcn, &state, "lower-a");
state.hover = HoverState::intent([ChildId::Static("top")]);
render(&mut ratcn, &state, "lower-a");
render(&mut ratcn, &state, "lower-b");
render(&mut ratcn, &state, "lower-b");
assert_eq!(
rendered.lock().expect("hover log").last(),
Some(&(true, true))
);
}
#[test]
fn modal_transition_cancels_uncaptured_click_through_release() {
let state = PointerState::default();
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(10, 2)).expect("terminal");
let theme = Theme::default_dark();
let mut render = |ratcn: &mut Ratcn<PointerState, PointerMsg>, modal| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("base"),
Button::new("Base").on_press(|| {
PointerMsg::Routed("base", MouseKind::Click(MouseButton::Left), 0)
}),
area,
);
if modal {
ctx.modal(
ChildId::Static("modal"),
Button::new("Modal").on_press(|| {
PointerMsg::Routed(
"modal",
MouseKind::Click(MouseButton::Left),
0,
)
}),
area,
);
}
});
})
.expect("draw");
};
render(&mut ratcn, false);
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 0), &state);
render(&mut ratcn, true);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Up(MouseButton::Left), 1, 0), &state),
EventResult::Consumed
);
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 0), &state);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Up(MouseButton::Left), 1, 0), &state),
EventResult::Emit(PointerMsg::Routed(
"modal",
MouseKind::Click(MouseButton::Left),
0,
))
);
}
#[test]
fn repeated_descendant_ids_render_focus_only_on_the_complete_path() {
let state = FocusTestState {
focus: FocusState::intent([ChildId::Static("left"), ChildId::Static("shared")]),
};
let left = Arc::new(Mutex::new(Vec::new()));
let right = Arc::new(Mutex::new(Vec::new()));
let mut ratcn =
Ratcn::new().focus(|state: &FocusTestState| &state.focus, FocusTestMsg::Focus);
let mut terminal = Terminal::new(TestBackend::new(10, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
for (id, rendered) in [("left", &left), ("right", &right)] {
let rendered = Arc::clone(rendered);
ctx.scope(
ChildId::Static(id),
area,
ScopeOptions::default(),
move |ctx| {
ctx.render_component(
ChildId::Static("shared"),
FocusLeaf::recording(rendered),
area,
);
},
);
}
});
})
.expect("draw");
assert_eq!(
*left.lock().expect("left log"),
[(true, true), (true, true)]
);
assert_eq!(
*right.lock().expect("right log"),
[(false, false), (false, false)]
);
}
#[test]
fn repeated_descendant_ids_render_hover_only_on_the_complete_path() {
let state = PointerState {
hover: HoverState::intent([ChildId::Static("left"), ChildId::Static("shared")]),
};
let left = Arc::new(Mutex::new(Vec::new()));
let right = Arc::new(Mutex::new(Vec::new()));
let mut ratcn = Ratcn::new().hover(|state: &PointerState| &state.hover, PointerMsg::Hover);
let mut terminal = Terminal::new(TestBackend::new(10, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
for (id, rendered) in [("left", &left), ("right", &right)] {
let rendered = Arc::clone(rendered);
ctx.scope(
ChildId::Static(id),
area,
ScopeOptions::default(),
move |ctx| {
ctx.render_component(
ChildId::Static("shared"),
HoverLeaf {
consume_move: false,
rendered: Some(rendered),
},
area,
);
},
);
}
});
})
.expect("draw");
assert_eq!(
*left.lock().expect("left log"),
[(true, true), (true, true)]
);
assert_eq!(
*right.lock().expect("right log"),
[(false, false), (false, false)]
);
}
#[test]
fn modal_mismatch_release_clears_the_pre_transition_press() {
let mut state = ModalTestState::default();
let mut ratcn = Ratcn::new()
.focus(|state: &ModalTestState| &state.focus, |_| unreachable!())
.modals(|state: &ModalTestState| &state.modals);
let mut terminal = Terminal::new(TestBackend::new(10, 2)).expect("terminal");
let theme = Theme::default_dark();
let mut render = |ratcn: &mut Ratcn<ModalTestState, ModalTestMsg>,
state: &ModalTestState| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("base"),
Button::new("Base").on_press(|| ModalTestMsg::Routed("base")),
area,
);
if state.modals.is_open("dialog") {
ctx.modal(
ChildId::Static("dialog"),
Button::new("Dialog").on_press(|| ModalTestMsg::Routed("dialog")),
area,
);
}
});
})
.expect("draw");
};
render(&mut ratcn, &state);
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 0), &state);
state
.modals
.open("dialog", &mut state.focus)
.expect("open dialog");
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Up(MouseButton::Left), 1, 0), &state),
EventResult::Consumed
);
render(&mut ratcn, &state);
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 0), &state);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Up(MouseButton::Left), 1, 0), &state),
EventResult::Emit(ModalTestMsg::Routed("dialog"))
);
state.modals.close(&mut state.focus).expect("close dialog");
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 0), &state),
EventResult::Consumed
);
state
.modals
.open("dialog", &mut state.focus)
.expect("reopen before redraw");
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Up(MouseButton::Left), 1, 0), &state),
EventResult::Consumed
);
}
}