use std::any::{Any, TypeId};
use std::collections::{HashMap, VecDeque};
use std::time::{Duration, Instant};
use ratatui_core::{buffer::Buffer, layout::Rect};
use crate::component::{Component, EventResult, Tracked, VStack};
use crate::context::{ContextMap, ProvidedContexts, SavedContext};
use crate::element::{ElementEntry, Elements};
use crate::frame::Frame;
use crate::node::{
CallSite, Effect, EffectKind, Layout, Node, NodeArena, NodeId, TypedEffectHandler,
WidthConstraint,
};
pub struct Renderer {
nodes: NodeArena,
root: NodeId,
width: u16,
focused: Option<NodeId>,
cursor_hint: Option<(u16, u16)>,
effects: HashMap<NodeId, Vec<Effect>>,
context: ContextMap,
saved_focus: HashMap<NodeId, Option<NodeId>>,
needs_refresh: bool,
probe_buffer: Option<Buffer>,
}
impl Renderer {
pub fn new(width: u16) -> Self {
let mut nodes = NodeArena::new();
let root = nodes.alloc(Node::new(VStack));
nodes[root].state.clear_dirty();
Self {
nodes,
root,
width,
focused: None,
cursor_hint: None,
effects: HashMap::new(),
context: ContextMap::new(),
saved_focus: HashMap::new(),
probe_buffer: None,
needs_refresh: false,
}
}
pub fn root(&self) -> NodeId {
self.root
}
pub fn append_child<C: Component>(&mut self, parent: NodeId, component: C) -> NodeId {
let layout = component.layout();
let width_constraint = component.width_constraint();
let mut node = Node::new(component);
node.parent = Some(parent);
node.layout = layout;
node.width_constraint = width_constraint;
let id = self.nodes.alloc(node);
self.nodes[parent].children.push(id);
id
}
pub fn swap_component<C: Component>(&mut self, id: NodeId, component: C) {
let layout = component.layout();
let width_constraint = component.width_constraint();
self.nodes[id].component = Box::new(component);
self.nodes[id].layout = layout;
self.nodes[id].width_constraint = width_constraint;
}
pub fn push<C: Component>(&mut self, component: C) -> NodeId {
self.append_child(self.root, component)
}
pub fn state_mut<C: Component>(&mut self, id: NodeId) -> &mut Tracked<C::State> {
let node = &mut self.nodes[id];
node.state
.as_any_mut()
.downcast_mut::<Tracked<C::State>>()
.expect("state type mismatch in state_mut")
}
pub fn freeze(&mut self, id: NodeId) {
self.nodes[id].frozen = true;
}
#[cfg(test)]
pub fn set_layout(&mut self, id: NodeId, layout: Layout) {
self.nodes[id].layout = layout;
}
pub fn children(&self, id: NodeId) -> &[NodeId] {
&self.nodes[id].children
}
pub fn node_key(&self, id: NodeId) -> Option<&str> {
self.nodes[id].key.as_deref()
}
pub fn node_last_height(&self, id: NodeId) -> u16 {
self.nodes[id].last_height.unwrap_or(0)
}
pub fn set_focus(&mut self, id: NodeId) {
if let Some(scope_id) = self.find_scope_for(id) {
let current_outside = self
.focused
.is_none_or(|f| !self.is_in_subtree(f, scope_id));
if current_outside {
self.saved_focus.entry(scope_id).or_insert(self.focused);
}
}
self.focused = Some(id);
}
pub fn clear_focus(&mut self) {
self.focused = None;
}
pub fn focus(&self) -> Option<NodeId> {
self.focused
}
pub fn handle_event(&mut self, event: &crossterm::event::Event) -> EventResult {
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
if let Event::Key(KeyEvent {
code,
kind: KeyEventKind::Press,
modifiers,
..
}) = event
{
let is_tab = *code == KeyCode::Tab && !modifiers.contains(KeyModifiers::SHIFT);
let is_backtab = *code == KeyCode::BackTab
|| (*code == KeyCode::Tab && modifiers.contains(KeyModifiers::SHIFT));
if (is_tab || is_backtab) && self.cycle_focus(is_backtab) {
return EventResult::Consumed;
}
}
let Some(focused) = self.focused else {
return EventResult::Ignored;
};
let path = self.path_to_node(focused);
for &id in &path {
let node = &mut self.nodes[id];
if node.frozen {
continue;
}
let result = if let Some(ref hook) = node.hook_capture {
hook.call(
event,
node.component.props_as_any(),
node.state.as_any_mut(),
)
} else {
let state_any = node.state.as_any_mut();
node.component.handle_event_capture_erased(event, state_any)
};
if result == EventResult::Consumed {
return EventResult::Consumed;
}
}
for &id in path.iter().rev() {
let node = &mut self.nodes[id];
if node.frozen {
continue;
}
let result = if let Some(ref hook) = node.hook_event {
hook.call(
event,
node.component.props_as_any(),
node.state.as_any_mut(),
)
} else {
let state_any = node.state.as_any_mut();
node.component.handle_event_erased(event, state_any)
};
if result == EventResult::Consumed {
return EventResult::Consumed;
}
}
EventResult::Ignored
}
fn path_to_node(&self, target: NodeId) -> Vec<NodeId> {
let mut path = Vec::new();
let mut current = Some(target);
while let Some(id) = current {
path.push(id);
current = self.nodes[id].parent;
}
path.reverse();
path
}
fn is_node_focusable(&self, id: NodeId) -> bool {
let node = &self.nodes[id];
if let Some(focusable) = node.hook_focusable {
return focusable;
}
let state = node.state.inner_as_any();
node.component.is_focusable_erased(state)
}
fn focusable_nodes(&self) -> Vec<NodeId> {
let mut result = Vec::new();
self.collect_focusable(self.root, &mut result);
result
}
fn collect_focusable(&self, id: NodeId, result: &mut Vec<NodeId>) {
let node = &self.nodes[id];
if node.frozen {
return;
}
if self.is_node_focusable(id) {
result.push(id);
}
for &child in &node.children {
self.collect_focusable(child, result);
}
}
fn find_scope_for(&self, node_id: NodeId) -> Option<NodeId> {
let mut current = Some(node_id);
while let Some(id) = current {
if self.nodes[id].focus_scope {
return Some(id);
}
current = self.nodes[id].parent;
}
None
}
fn focusable_nodes_in_scope(&self, scope_id: NodeId) -> Vec<NodeId> {
let mut result = Vec::new();
self.collect_focusable_scoped(scope_id, scope_id, &mut result);
result
}
fn collect_focusable_scoped(&self, id: NodeId, scope_id: NodeId, result: &mut Vec<NodeId>) {
let node = &self.nodes[id];
if node.frozen {
return;
}
if self.is_node_focusable(id) {
result.push(id);
}
if id != scope_id && node.focus_scope {
return;
}
for &child in &node.children {
self.collect_focusable_scoped(child, scope_id, result);
}
}
fn is_in_subtree(&self, node_id: NodeId, subtree_root: NodeId) -> bool {
let mut current = Some(node_id);
while let Some(id) = current {
if id == subtree_root {
return true;
}
current = self.nodes[id].parent;
}
false
}
fn cycle_focus(&mut self, reverse: bool) -> bool {
let scope = self.focused.and_then(|f| self.find_scope_for(f));
let focusable = match scope {
Some(scope_id) => self.focusable_nodes_in_scope(scope_id),
None => self.focusable_nodes(),
};
if focusable.is_empty() {
return false;
}
let current_idx = self
.focused
.and_then(|f| focusable.iter().position(|&id| id == f));
let next_idx = match current_idx {
Some(idx) => {
if focusable.len() == 1 {
return false;
}
if reverse {
if idx == 0 {
focusable.len() - 1
} else {
idx - 1
}
} else {
(idx + 1) % focusable.len()
}
}
None => 0, };
self.set_focus(focusable[next_idx]);
true
}
pub fn remove(&mut self, id: NodeId) {
assert!(id != self.root, "cannot remove root node");
if let Some(parent) = self.nodes[id].parent {
self.nodes[parent].children.retain(|&child| child != id);
self.nodes[parent].force_dirty = true;
self.nodes[parent].last_height = None;
}
self.tombstone_subtree(id);
}
pub fn rebuild(&mut self, parent: NodeId, elements: Elements) {
self.needs_refresh = false;
self.reconcile_children(parent, elements.into_items());
}
pub fn find_by_key(&self, parent: NodeId, key: &str) -> Option<NodeId> {
self.nodes[parent]
.children
.iter()
.find(|&&child_id| self.nodes[child_id].key.as_deref() == Some(key))
.copied()
}
#[track_caller]
pub fn register_tick<C: Component>(
&mut self,
id: NodeId,
interval: Duration,
handler: impl Fn(&mut C::State) + Send + Sync + 'static,
) {
let effects = self.effects.entry(id).or_default();
effects.retain(|e| !matches!(e.kind, EffectKind::Interval { .. }));
effects.push(Effect {
handler: Box::new(TypedEffectHandler::<C, C::State> {
handler: Box::new(move |_props: &C, state: &mut Tracked<C::State>| {
use std::ops::DerefMut;
handler(state.deref_mut());
}),
}),
kind: EffectKind::Interval {
interval,
last_tick: Instant::now(),
},
call_site: CallSite::from_location(std::panic::Location::caller()),
});
}
pub fn unregister_tick(&mut self, id: NodeId) {
if let Some(effects) = self.effects.get_mut(&id) {
effects.retain(|e| !matches!(e.kind, EffectKind::Interval { .. }));
if effects.is_empty() {
self.effects.remove(&id);
}
}
}
#[track_caller]
pub fn on_mount<C: Component>(
&mut self,
id: NodeId,
handler: impl Fn(&mut C::State) + Send + Sync + 'static,
) {
self.effects.entry(id).or_default().push(Effect {
handler: Box::new(TypedEffectHandler::<C, C::State> {
handler: Box::new(move |_props: &C, state: &mut Tracked<C::State>| {
use std::ops::DerefMut;
handler(state.deref_mut());
}),
}),
kind: EffectKind::OnMount,
call_site: CallSite::from_location(std::panic::Location::caller()),
});
}
#[track_caller]
pub fn on_unmount<C: Component>(
&mut self,
id: NodeId,
handler: impl Fn(&mut C::State) + Send + Sync + 'static,
) {
let effects = self.effects.entry(id).or_default();
effects.retain(|e| !matches!(e.kind, EffectKind::OnUnmount));
effects.push(Effect {
handler: Box::new(TypedEffectHandler::<C, C::State> {
handler: Box::new(move |_props: &C, state: &mut Tracked<C::State>| {
use std::ops::DerefMut;
handler(state.deref_mut());
}),
}),
kind: EffectKind::OnUnmount,
call_site: CallSite::from_location(std::panic::Location::caller()),
});
}
pub fn tick(&mut self) -> bool {
let now = Instant::now();
let mut due: Vec<(NodeId, usize)> = Vec::new();
for (&id, effects) in &self.effects {
for (idx, effect) in effects.iter().enumerate() {
if let EffectKind::Interval {
last_tick,
interval,
} = &effect.kind
&& now.duration_since(*last_tick) >= *interval
{
due.push((id, idx));
}
}
}
if due.is_empty() {
return false;
}
for (id, idx) in due {
let mut effects = self.effects.remove(&id).unwrap();
let effect = &mut effects[idx];
if let EffectKind::Interval {
ref mut last_tick, ..
} = effect.kind
{
*last_tick = now;
}
let node = &mut self.nodes[id];
effects[idx]
.handler
.call(node.component.props_as_any(), node.state.as_any_mut());
self.effects.insert(id, effects);
}
self.needs_refresh = true;
true
}
pub fn has_active(&self) -> bool {
self.effects.values().any(|effects| {
effects
.iter()
.any(|e| matches!(e.kind, EffectKind::Interval { .. }))
})
}
fn fire_mount(&mut self, id: NodeId) {
if self.nodes[id].autofocus && self.is_node_focusable(id) {
self.set_focus(id);
}
if let Some(effects) = self.effects.remove(&id) {
let (mounts, remaining): (Vec<_>, Vec<_>) = effects
.into_iter()
.partition(|e| matches!(e.kind, EffectKind::OnMount));
for effect in mounts {
let node = &mut self.nodes[id];
effect
.handler
.call(node.component.props_as_any(), node.state.as_any_mut());
}
if self.nodes[id].state.is_dirty() {
self.needs_refresh = true;
}
if !remaining.is_empty() {
self.effects.insert(id, remaining);
}
}
}
fn fire_unmount(&mut self, id: NodeId) {
if let Some(effects) = self.effects.remove(&id) {
let (unmounts, remaining): (Vec<_>, Vec<_>) = effects
.into_iter()
.partition(|e| matches!(e.kind, EffectKind::OnUnmount));
for effect in unmounts {
let node = &mut self.nodes[id];
effect
.handler
.call(node.component.props_as_any(), node.state.as_any_mut());
}
if !remaining.is_empty() {
self.effects.insert(id, remaining);
}
}
}
fn reconcile_children(&mut self, parent: NodeId, new_entries: Vec<ElementEntry>) {
let old_children: Vec<NodeId> = std::mem::take(&mut self.nodes[parent].children);
let mut old_by_key: HashMap<String, NodeId> = HashMap::new();
let mut old_unkeyed: VecDeque<NodeId> = VecDeque::new();
for &child_id in &old_children {
if let Some(ref key) = self.nodes[child_id].key {
old_by_key.insert(key.clone(), child_id);
} else {
old_unkeyed.push_back(child_id);
}
}
let mut new_children: Vec<NodeId> = Vec::with_capacity(new_entries.len());
for entry in new_entries {
let matched = if let Some(ref key) = entry.key {
match old_by_key.remove(key) {
Some(old_id) if self.nodes[old_id].element_type_id == Some(entry.type_id) => {
Some(old_id)
}
Some(old_id) => {
self.tombstone_subtree(old_id);
None
}
None => None,
}
} else {
if let Some(front_id) = old_unkeyed.pop_front() {
if self.nodes[front_id].element_type_id == Some(entry.type_id) {
Some(front_id)
} else {
self.tombstone_subtree(front_id);
None
}
} else {
None
}
};
let node_id = if let Some(old_id) = matched {
entry.element.update(self, old_id);
self.nodes[old_id].parent = Some(parent);
self.nodes[old_id].width_constraint =
resolve_width_constraint(&self.nodes[old_id], entry.width_constraint);
self.nodes[old_id].has_slot = entry.children.is_some();
self.nodes[old_id].force_dirty = true;
let (provided, resolved) = self.update_node(old_id, entry.children);
let saved = self.push_context(provided);
if let Some(els) = resolved {
self.reconcile_children(old_id, els.into_items());
}
self.pop_context(saved);
old_id
} else {
let id = entry.element.build(self, parent);
self.nodes[id].element_type_id = Some(entry.type_id);
self.nodes[id].key = entry.key;
self.nodes[id].has_slot = entry.children.is_some();
self.nodes[id].width_constraint =
resolve_width_constraint(&self.nodes[id], entry.width_constraint);
let (provided, resolved) = self.update_node(id, entry.children);
self.fire_mount(id);
let saved = self.push_context(provided);
if let Some(els) = resolved {
self.build_elements(id, els);
}
self.pop_context(saved);
id
};
new_children.push(node_id);
}
for old_id in old_unkeyed {
self.tombstone_subtree(old_id);
}
for (_, old_id) in old_by_key {
self.tombstone_subtree(old_id);
}
self.nodes[parent].children = new_children;
self.nodes[parent].force_dirty = true;
self.nodes[parent].last_height = None;
}
fn build_elements(&mut self, parent: NodeId, elements: Elements) {
for entry in elements.into_items() {
let node_id = entry.element.build(self, parent);
self.nodes[node_id].element_type_id = Some(entry.type_id);
self.nodes[node_id].key = entry.key;
self.nodes[node_id].has_slot = entry.children.is_some();
self.nodes[node_id].width_constraint =
resolve_width_constraint(&self.nodes[node_id], entry.width_constraint);
let (provided, resolved) = self.update_node(node_id, entry.children);
self.fire_mount(node_id);
let saved = self.push_context(provided);
if let Some(els) = resolved {
self.build_elements(node_id, els);
}
self.pop_context(saved);
}
}
fn update_node(
&mut self,
id: NodeId,
slot: Option<Elements>,
) -> (ProvidedContexts, Option<Elements>) {
let children = slot.unwrap_or_default();
let (mut output, result) = {
let context = &self.context;
let node = &mut self.nodes[id];
node.component
.update_erased(node.state.as_any_mut(), context, children)
};
if output.effects.is_empty() {
self.effects.remove(&id);
} else {
if let Some(old_effects) = self.effects.get(&id) {
for new_eff in output.effects.iter_mut() {
if let EffectKind::Interval {
last_tick: new_lt, ..
} = &mut new_eff.kind
&& let Some(old_eff) = old_effects.iter().find(|old| {
old.call_site == new_eff.call_site
&& matches!(old.kind, EffectKind::Interval { .. })
})
&& let EffectKind::Interval {
last_tick: old_lt, ..
} = &old_eff.kind
{
*new_lt = *old_lt;
}
}
}
self.effects.insert(id, output.effects);
}
self.nodes[id].autofocus = output.autofocus;
self.nodes[id].focus_scope = output.focus_scope;
self.nodes[id].hook_focusable = output.focusable;
self.nodes[id].hook_cursor = output.cursor_hook;
self.nodes[id].hook_event = output.event_hook;
self.nodes[id].hook_capture = output.capture_hook;
if let Some(layout) = output.layout {
self.nodes[id].layout = layout;
}
if let Some(wc) = output.width_constraint {
self.nodes[id].width_constraint = wc;
}
self.nodes[id].hook_height_hint = output.height_hint;
self.nodes[id].hook_desired_height = output.desired_height_hook;
let elements = if result.is_empty() && self.nodes[id].children.is_empty() {
None
} else {
Some(result)
};
(output.provided, elements)
}
fn refresh_dirty_views(&mut self) {
if !self.needs_refresh {
return;
}
self.needs_refresh = false;
let dirty: Vec<NodeId> = self.collect_dirty_views(self.root);
for id in dirty {
if !self.nodes.is_live(id) {
continue;
}
let (provided, resolved) = self.update_node(id, None);
let saved = self.push_context(provided);
if let Some(els) = resolved {
self.reconcile_children(id, els.into_items());
}
self.pop_context(saved);
}
}
fn collect_dirty_views(&self, id: NodeId) -> Vec<NodeId> {
let mut result = Vec::new();
let children: Vec<NodeId> = self.nodes[id].children.clone();
for child_id in children {
result.extend(self.collect_dirty_views(child_id));
let node = &self.nodes[child_id];
if !node.frozen
&& node.element_type_id.is_some()
&& !node.has_slot
&& node.state.is_dirty()
{
result.push(child_id);
}
}
result
}
#[cfg(test)]
fn apply_lifecycle(&mut self, id: NodeId) -> Vec<(TypeId, Box<dyn Any + Send + Sync>)> {
let (provided, _) = self.update_node(id, None);
provided
}
fn tombstone_subtree(&mut self, id: NodeId) {
if self.nodes[id].focus_scope {
let focus_inside = self.focused.is_some_and(|f| self.is_in_subtree(f, id));
if focus_inside {
let restored = self.saved_focus.remove(&id).flatten();
self.focused = restored.filter(|&r| {
self.nodes.is_live(r) && !self.is_in_subtree(r, id)
});
if self.focused.is_none() {
let parent_scope = self.nodes[id].parent.and_then(|p| self.find_scope_for(p));
let candidates = match parent_scope {
Some(ps) => self.focusable_nodes_in_scope(ps),
None => self.focusable_nodes(),
};
if let Some(fallback) =
candidates.into_iter().find(|&n| !self.is_in_subtree(n, id))
{
self.set_focus(fallback);
}
}
} else {
self.saved_focus.remove(&id);
}
}
let children = std::mem::take(&mut self.nodes[id].children);
for child_id in children {
self.tombstone_subtree(child_id);
}
self.fire_unmount(id);
self.effects.remove(&id);
if self.focused == Some(id) {
self.focused = None;
}
self.saved_focus.remove(&id);
self.nodes.free(id);
}
pub fn set_root_context<T: Any + Send + Sync>(&mut self, value: T) {
self.context.insert(TypeId::of::<T>(), Box::new(value));
}
pub(crate) fn set_root_context_raw(
&mut self,
type_id: TypeId,
value: Box<dyn Any + Send + Sync>,
) {
self.context.insert(type_id, value);
}
fn push_context(&mut self, provided: ProvidedContexts) -> SavedContext {
let mut saved = Vec::with_capacity(provided.len());
for (type_id, value) in provided {
let old = self.context.insert(type_id, value);
saved.push((type_id, old));
}
saved
}
fn pop_context(&mut self, saved: SavedContext) {
for (type_id, old) in saved {
match old {
Some(v) => {
self.context.insert(type_id, v);
}
None => {
self.context.remove(&type_id);
}
}
}
}
pub fn set_width(&mut self, width: u16) {
if self.width != width {
self.width = width;
for node in self.nodes.iter_mut() {
node.cached_buffer = None;
node.last_height = None;
if !node.frozen {
node.force_dirty = true;
}
}
}
}
pub fn width(&self) -> u16 {
self.width
}
pub fn render(&mut self) -> Frame {
self.refresh_dirty_views();
let total_height = self.measure_height(self.root, self.width);
if total_height == 0 || self.width == 0 {
self.cursor_hint = None;
return Frame::new(Buffer::empty(Rect::new(0, 0, self.width, 0)));
}
let area = Rect::new(0, 0, self.width, total_height);
let mut buffer = Buffer::empty(area);
self.render_node(self.root, area, &mut buffer);
self.cursor_hint = None;
if let Some(focused) = self.focused {
let node = &self.nodes[focused];
if let Some(layout_rect) = node.layout_rect {
let cursor_pos = if let Some(ref hook) = node.hook_cursor {
hook.call(
layout_rect,
node.component.props_as_any(),
node.state.inner_as_any(),
)
} else {
let state = node.state.inner_as_any();
node.component.cursor_position_erased(layout_rect, state)
};
if let Some((rel_col, rel_row)) = cursor_pos {
self.cursor_hint = Some((layout_rect.x + rel_col, layout_rect.y + rel_row));
}
}
}
Frame::new(buffer)
}
pub fn cursor_hint(&self) -> Option<(u16, u16)> {
self.cursor_hint
}
fn measure_height(&mut self, id: NodeId, width: u16) -> u16 {
let node = &self.nodes[id];
if node.frozen {
return node.last_height.unwrap_or(0);
}
let hook_height = if let Some(ref hook) = node.hook_desired_height
&& let Some(h) = hook.call(
width,
node.component.props_as_any(),
node.state.inner_as_any(),
) {
Some(h)
} else {
node.hook_height_hint
};
if let Some(h) = hook_height
&& !node.is_container()
{
self.nodes[id].last_height = Some(h);
return h;
}
let height = if node.is_container() {
let insets = node
.component
.content_inset_erased(node.state.inner_as_any());
let inner_width = width.saturating_sub(insets.horizontal());
let children: Vec<NodeId> = node.children.clone();
let layout = node.layout;
let children_height = match layout {
Layout::Vertical => children
.iter()
.map(|&child| self.measure_height(child, inner_width))
.sum(),
Layout::Horizontal => {
let constraints: Vec<WidthConstraint> = children
.iter()
.map(|&cid| self.nodes[cid].width_constraint)
.collect();
let widths = allocate_widths(&constraints, inner_width);
children
.iter()
.zip(widths.iter())
.map(|(&child, &w)| self.measure_height(child, w))
.max()
.unwrap_or(0)
}
};
hook_height.unwrap_or(children_height + insets.vertical())
} else {
let needs_measure =
node.force_dirty || node.state.is_dirty() || node.last_height.is_none();
if !needs_measure {
return node.last_height.unwrap_or(0);
}
let state = self.nodes[id].state.inner_as_any();
if let Some(h) = self.nodes[id].component.desired_height_erased(width, state) {
h
} else {
let mut probe_height = INITIAL_PROBE_HEIGHT;
loop {
let probe_area = Rect::new(0, 0, width, probe_height);
let mut probe_buf = self.take_or_alloc_probe(probe_area);
let state = self.nodes[id].state.inner_as_any();
self.nodes[id]
.component
.render_erased(probe_area, &mut probe_buf, state);
let h = scan_content_height(&probe_buf, width, probe_height);
if h < probe_height || probe_height >= MAX_PROBE_HEIGHT {
#[cfg(debug_assertions)]
if h == MAX_PROBE_HEIGHT {
eprintln!(
"eye_declare: component content may exceed MAX_PROBE_HEIGHT \
({MAX_PROBE_HEIGHT} rows); output will be truncated. \
Override desired_height() to declare the height explicitly."
);
}
if h < probe_height && h > 0 {
let trimmed_area = Rect::new(0, 0, width, h);
let mut trimmed = Buffer::empty(trimmed_area);
copy_buffer(&probe_buf, &mut trimmed, trimmed_area);
self.nodes[id].cached_buffer = Some(trimmed);
self.probe_buffer = Some(probe_buf);
} else {
self.nodes[id].cached_buffer = Some(probe_buf);
}
self.nodes[id].probe_rendered = true;
break h;
}
self.probe_buffer = Some(probe_buf);
probe_height = (probe_height * 2).min(MAX_PROBE_HEIGHT);
}
}
};
self.nodes[id].last_height = Some(height);
height
}
fn render_node(&mut self, id: NodeId, area: Rect, buffer: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
self.nodes[id].layout_rect = Some(area);
let node = &self.nodes[id];
let is_container = node.is_container();
let needs_render = node.force_dirty || node.state.is_dirty();
if node.frozen || (!is_container && !needs_render) {
if let Some(ref cached) = node.cached_buffer {
copy_buffer(cached, buffer, area);
}
return;
}
if is_container {
let state = self.nodes[id].state.inner_as_any();
self.nodes[id].component.render_erased(area, buffer, state);
let insets = self.nodes[id]
.component
.content_inset_erased(self.nodes[id].state.inner_as_any());
let inner = Rect::new(
area.x.saturating_add(insets.left),
area.y.saturating_add(insets.top),
area.width.saturating_sub(insets.horizontal()),
area.height.saturating_sub(insets.vertical()),
);
let children: Vec<NodeId> = self.nodes[id].children.clone();
let layout = self.nodes[id].layout;
match layout {
Layout::Vertical => {
let mut y_offset = inner.y;
let y_end = inner.y.saturating_add(inner.height);
for child_id in &children {
let remaining = y_end.saturating_sub(y_offset);
if remaining == 0 {
break;
}
let child_height = self.nodes[*child_id]
.last_height
.unwrap_or(0)
.min(remaining);
if child_height == 0 {
continue;
}
let child_area = Rect::new(inner.x, y_offset, inner.width, child_height);
self.render_node(*child_id, child_area, buffer);
y_offset = y_offset.saturating_add(child_height);
}
}
Layout::Horizontal => {
let constraints: Vec<WidthConstraint> = children
.iter()
.map(|&cid| self.nodes[cid].width_constraint)
.collect();
let widths = allocate_widths(&constraints, inner.width);
let mut x_offset = inner.x;
for (child_id, &child_width) in children.iter().zip(widths.iter()) {
if child_width == 0 {
continue;
}
let child_area = Rect::new(x_offset, inner.y, child_width, inner.height);
self.render_node(*child_id, child_area, buffer);
x_offset = x_offset.saturating_add(child_width);
}
}
}
let mut node_buf = Buffer::empty(area);
copy_buffer_region(buffer, &mut node_buf, area);
self.nodes[id].cached_buffer = Some(node_buf);
self.nodes[id].last_height = Some(area.height);
self.nodes[id].state.clear_dirty();
self.nodes[id].force_dirty = false;
} else {
if self.nodes[id].probe_rendered {
if let Some(ref cached) = self.nodes[id].cached_buffer {
copy_buffer(cached, buffer, area);
}
self.nodes[id].probe_rendered = false;
} else {
let state = self.nodes[id].state.inner_as_any();
self.nodes[id].component.render_erased(area, buffer, state);
}
let mut node_buf = Buffer::empty(area);
copy_buffer_region(buffer, &mut node_buf, area);
self.nodes[id].cached_buffer = Some(node_buf);
self.nodes[id].last_height = Some(area.height);
self.nodes[id].state.clear_dirty();
self.nodes[id].force_dirty = false;
}
}
}
fn resolve_width_constraint(node: &Node, entry_constraint: WidthConstraint) -> WidthConstraint {
let comp = node.component.width_constraint_erased();
if comp != WidthConstraint::default() {
comp
} else {
entry_constraint
}
}
fn allocate_widths(constraints: &[WidthConstraint], total: u16) -> Vec<u16> {
let fixed_sum: u16 = constraints
.iter()
.filter_map(|c| match c {
WidthConstraint::Fixed(w) => Some(*w),
_ => None,
})
.sum();
let fill_count = constraints
.iter()
.filter(|c| matches!(c, WidthConstraint::Fill))
.count() as u16;
let remaining = total.saturating_sub(fixed_sum);
let per_fill = if fill_count > 0 {
remaining / fill_count
} else {
0
};
let mut remainder = if fill_count > 0 {
remaining % fill_count
} else {
0
};
constraints
.iter()
.map(|c| match c {
WidthConstraint::Fixed(w) => (*w).min(total),
WidthConstraint::Fill => {
let extra = if remainder > 0 {
remainder -= 1;
1
} else {
0
};
per_fill + extra
}
})
.collect()
}
impl Renderer {
fn take_or_alloc_probe(&mut self, area: Rect) -> Buffer {
if let Some(buf) = self.probe_buffer.take()
&& *buf.area() == area
{
let mut buf = buf;
buf.reset();
return buf;
}
Buffer::empty(area)
}
}
const INITIAL_PROBE_HEIGHT: u16 = 64;
const MAX_PROBE_HEIGHT: u16 = 512;
fn scan_content_height(buf: &Buffer, width: u16, max_height: u16) -> u16 {
use ratatui_core::buffer::Cell;
let empty = Cell::EMPTY;
for y in (0..max_height).rev() {
for x in 0..width {
if let Some(cell) = buf.cell((x, y))
&& cell != &empty
{
return y + 1;
}
}
}
0
}
fn copy_buffer(src: &Buffer, dst: &mut Buffer, area: Rect) {
let src_area = src.area;
for y in 0..area.height {
for x in 0..area.width {
let src_x = src_area.x + x;
let src_y = src_area.y + y;
let dst_x = area.x + x;
let dst_y = area.y + y;
if src_x < src_area.x + src_area.width
&& src_y < src_area.y + src_area.height
&& dst_x < dst.area.x + dst.area.width
&& dst_y < dst.area.y + dst.area.height
{
dst[(dst_x, dst_y)] = src[(src_x, src_y)].clone();
}
}
}
}
fn copy_buffer_region(src: &Buffer, dst: &mut Buffer, region: Rect) {
for y in region.y..region.y + region.height {
for x in region.x..region.x + region.width {
if x < src.area.x + src.area.width
&& y < src.area.y + src.area.height
&& x < dst.area.x + dst.area.width
&& y < dst.area.y + dst.area.height
{
dst[(x, y)] = src[(x, y)].clone();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::component::Component;
use ratatui_core::text::Line;
use ratatui_widgets::paragraph::Paragraph;
struct TextBlock;
impl Component for TextBlock {
type State = Vec<String>;
fn render(&self, area: Rect, buf: &mut Buffer, state: &Self::State) {
let text: Vec<Line> = state.iter().map(|s| Line::raw(s.as_str())).collect();
let para = Paragraph::new(text);
ratatui_core::widgets::Widget::render(para, area, buf);
}
fn initial_state(&self) -> Option<Vec<String>> {
Some(vec![])
}
}
#[test]
fn scan_content_height_empty_buffer() {
let buf = Buffer::empty(Rect::new(0, 0, 10, 64));
assert_eq!(scan_content_height(&buf, 10, 64), 0);
}
#[test]
fn scan_content_height_single_row() {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 64));
buf.set_string(0, 0, "hello", ratatui_core::style::Style::default());
assert_eq!(scan_content_height(&buf, 10, 64), 1);
}
#[test]
fn scan_content_height_content_in_middle() {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 64));
buf.set_string(0, 0, "line1", ratatui_core::style::Style::default());
buf.set_string(0, 4, "line5", ratatui_core::style::Style::default());
assert_eq!(scan_content_height(&buf, 10, 64), 5);
}
#[test]
fn scan_content_height_styled_space() {
use ratatui_core::style::{Color, Style};
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 64));
buf.set_style(Rect::new(0, 2, 5, 1), Style::default().bg(Color::Red));
assert_eq!(scan_content_height(&buf, 10, 64), 3);
}
#[test]
fn probe_render_measures_tall_component() {
#[derive(Default)]
struct TallBlock {
lines: usize,
}
impl Component for TallBlock {
type State = ();
fn render(&self, area: Rect, buf: &mut Buffer, _state: &()) {
for y in 0..(self.lines as u16).min(area.height) {
buf.set_string(
area.x,
area.y + y,
"x",
ratatui_core::style::Style::default(),
);
}
}
}
let mut r = Renderer::new(10);
r.push(TallBlock { lines: 100 });
let frame = r.render();
assert_eq!(frame.area().height, 100);
}
#[test]
fn use_height_hint_skips_probe_render() {
#[derive(Default)]
struct FixedHeight;
impl Component for FixedHeight {
type State = ();
fn update(
&self,
hooks: &mut Hooks<Self, ()>,
_state: &(),
_children: Elements,
) -> Elements {
hooks.use_height_hint(3);
let mut els = Elements::new();
els.add(crate::Canvas::new(|area: Rect, buf: &mut Buffer| {
for y in 0..area.height {
buf.set_string(
area.x,
area.y + y,
"x",
ratatui_core::style::Style::default(),
);
}
}));
els
}
}
let mut r = Renderer::new(10);
let container = r.push(VStack);
let mut els = Elements::new();
els.add(FixedHeight);
r.rebuild(container, els);
let frame = r.render();
assert_eq!(frame.area().height, 3);
}
#[test]
fn render_empty_renderer() {
let mut r = Renderer::new(80);
let frame = r.render();
assert_eq!(frame.area().height, 0);
}
#[test]
fn render_single_component() {
let mut r = Renderer::new(10);
let id = r.push(TextBlock);
r.state_mut::<TextBlock>(id).push("hello".to_string());
let frame = r.render();
assert_eq!(frame.area().height, 1);
assert_eq!(frame.area().width, 10);
let buf = frame.buffer();
assert_eq!(buf[(0, 0)].symbol(), "h");
}
#[test]
fn render_two_components_stacked() {
let mut r = Renderer::new(10);
let id1 = r.push(TextBlock);
let id2 = r.push(TextBlock);
r.state_mut::<TextBlock>(id1).push("top".to_string());
r.state_mut::<TextBlock>(id2).push("bot".to_string());
let frame = r.render();
assert_eq!(frame.area().height, 2);
let buf = frame.buffer();
assert_eq!(buf[(0, 0)].symbol(), "t");
assert_eq!(buf[(0, 1)].symbol(), "b");
}
#[test]
fn dirty_flag_cleared_after_render() {
let mut r = Renderer::new(10);
let id = r.push(TextBlock);
r.state_mut::<TextBlock>(id).push("hello".to_string());
assert!(r.nodes[id].state.is_dirty());
let _ = r.render();
assert!(!r.nodes[id].state.is_dirty());
}
#[test]
fn frozen_component_uses_cached_buffer() {
let mut r = Renderer::new(10);
let id = r.push(TextBlock);
r.state_mut::<TextBlock>(id).push("hello".to_string());
let _frame1 = r.render();
r.freeze(id);
let frame2 = r.render();
assert_eq!(frame2.area().height, 1);
assert_eq!(frame2.buffer()[(0, 0)].symbol(), "h");
}
#[test]
fn component_height_changes_with_state() {
let mut r = Renderer::new(10);
let id = r.push(TextBlock);
let frame1 = r.render();
assert_eq!(frame1.area().height, 0);
r.state_mut::<TextBlock>(id).push("line1".to_string());
let frame2 = r.render();
assert_eq!(frame2.area().height, 1);
r.state_mut::<TextBlock>(id).push("line2".to_string());
let frame3 = r.render();
assert_eq!(frame3.area().height, 2);
}
#[test]
fn root_exists() {
let r = Renderer::new(80);
let root = r.root();
assert_eq!(root, NodeId(0));
assert!(r.children(root).is_empty());
}
#[test]
fn append_child_creates_tree() {
let mut r = Renderer::new(10);
let root = r.root();
let child = r.append_child(root, TextBlock);
assert_eq!(r.children(root), &[child]);
}
#[test]
fn nested_containers() {
let mut r = Renderer::new(10);
let container = r.push(VStack);
let child1 = r.append_child(container, TextBlock);
let child2 = r.append_child(container, TextBlock);
r.state_mut::<TextBlock>(child1).push("first".to_string());
r.state_mut::<TextBlock>(child2).push("second".to_string());
let frame = r.render();
assert_eq!(frame.area().height, 2);
let buf = frame.buffer();
assert_eq!(buf[(0, 0)].symbol(), "f"); assert_eq!(buf[(0, 1)].symbol(), "s"); }
#[test]
fn deeply_nested_tree() {
let mut r = Renderer::new(10);
let outer = r.push(VStack);
let inner = r.append_child(outer, VStack);
let text = r.append_child(inner, TextBlock);
r.state_mut::<TextBlock>(text).push("deep".to_string());
let frame = r.render();
assert_eq!(frame.area().height, 1);
assert_eq!(frame.buffer()[(0, 0)].symbol(), "d");
}
#[test]
fn mixed_flat_and_nested() {
let mut r = Renderer::new(10);
let flat = r.push(TextBlock);
r.state_mut::<TextBlock>(flat).push("flat".to_string());
let container = r.push(VStack);
let nested1 = r.append_child(container, TextBlock);
let nested2 = r.append_child(container, TextBlock);
r.state_mut::<TextBlock>(nested1).push("nest1".to_string());
r.state_mut::<TextBlock>(nested2).push("nest2".to_string());
let frame = r.render();
assert_eq!(frame.area().height, 3);
let buf = frame.buffer();
assert_eq!(buf[(0, 0)].symbol(), "f"); assert_eq!(buf[(0, 1)].symbol(), "n"); assert_eq!(buf[(0, 2)].symbol(), "n"); }
#[test]
fn remove_node() {
let mut r = Renderer::new(10);
let id1 = r.push(TextBlock);
let id2 = r.push(TextBlock);
r.state_mut::<TextBlock>(id1).push("keep".to_string());
r.state_mut::<TextBlock>(id2).push("remove".to_string());
let frame1 = r.render();
assert_eq!(frame1.area().height, 2);
r.remove(id2);
let frame2 = r.render();
assert_eq!(frame2.area().height, 1);
assert_eq!(frame2.buffer()[(0, 0)].symbol(), "k"); }
#[test]
fn remove_container_removes_children() {
let mut r = Renderer::new(10);
let container = r.push(VStack);
let child = r.append_child(container, TextBlock);
r.state_mut::<TextBlock>(child).push("gone".to_string());
let frame1 = r.render();
assert_eq!(frame1.area().height, 1);
r.remove(container);
let frame2 = r.render();
assert_eq!(frame2.area().height, 0);
}
struct InputCapture;
impl Component for InputCapture {
type State = String;
fn render(&self, area: Rect, buf: &mut Buffer, state: &Self::State) {
let line = ratatui_core::text::Line::raw(state.as_str());
ratatui_core::widgets::Widget::render(Paragraph::new(line), area, buf);
}
fn handle_event(
&self,
event: &crossterm::event::Event,
state: &mut Tracked<Self::State>,
) -> EventResult {
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind};
if let Event::Key(KeyEvent {
code: KeyCode::Char(c),
kind: KeyEventKind::Press,
..
}) = event
{
state.push(*c);
EventResult::Consumed
} else {
EventResult::Ignored
}
}
fn initial_state(&self) -> Option<String> {
Some(String::new())
}
}
fn key_event(c: char) -> crossterm::event::Event {
crossterm::event::Event::Key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(c),
crossterm::event::KeyModifiers::empty(),
))
}
#[test]
fn event_delivered_to_focused_component() {
let mut r = Renderer::new(10);
let id = r.push(InputCapture);
r.set_focus(id);
let result = r.handle_event(&key_event('a'));
assert_eq!(result, EventResult::Consumed);
let state = r.state_mut::<InputCapture>(id);
assert_eq!(&**state, "a");
}
#[test]
fn no_focus_returns_ignored() {
let mut r = Renderer::new(10);
let _id = r.push(InputCapture);
let result = r.handle_event(&key_event('a'));
assert_eq!(result, EventResult::Ignored);
}
#[test]
fn event_bubbles_to_parent() {
let mut r = Renderer::new(10);
let parent = r.push(InputCapture);
let child = r.append_child(parent, TextBlock);
r.state_mut::<TextBlock>(child).push("child".to_string());
r.set_focus(child);
let result = r.handle_event(&key_event('x'));
assert_eq!(result, EventResult::Consumed);
let state = r.state_mut::<InputCapture>(parent);
assert_eq!(&**state, "x");
}
#[test]
fn frozen_component_skipped_in_bubble() {
let mut r = Renderer::new(10);
let parent = r.push(InputCapture);
let child = r.append_child(parent, TextBlock);
r.state_mut::<TextBlock>(child).push("child".to_string());
let _ = r.render(); r.freeze(parent);
r.set_focus(child);
let result = r.handle_event(&key_event('x'));
assert_eq!(result, EventResult::Ignored);
}
#[test]
fn event_marks_state_dirty() {
let mut r = Renderer::new(10);
let id = r.push(InputCapture);
r.set_focus(id);
r.state_mut::<InputCapture>(id).push('x');
let _ = r.render();
assert!(!r.nodes[id].state.is_dirty());
r.handle_event(&key_event('a'));
assert!(r.nodes[id].state.is_dirty());
}
#[test]
fn noop_handler_does_not_mark_dirty() {
let mut r = Renderer::new(10);
let parent = r.push(VStack);
let child = r.append_child(parent, InputCapture);
r.state_mut::<InputCapture>(child).push('x'); r.set_focus(child);
let _ = r.render(); assert!(!r.nodes[parent].state.is_dirty());
assert!(!r.nodes[child].state.is_dirty());
r.handle_event(&key_event('a'));
assert!(
!r.nodes[parent].state.is_dirty(),
"no-op capture handler must not set dirty"
);
assert!(r.nodes[child].state.is_dirty());
}
#[test]
fn focus_can_be_changed() {
let mut r = Renderer::new(10);
let id1 = r.push(InputCapture);
let id2 = r.push(InputCapture);
r.set_focus(id1);
r.handle_event(&key_event('a'));
assert_eq!(&**r.state_mut::<InputCapture>(id1), "a");
assert_eq!(&**r.state_mut::<InputCapture>(id2), "");
r.set_focus(id2);
r.handle_event(&key_event('b'));
assert_eq!(&**r.state_mut::<InputCapture>(id1), "a");
assert_eq!(&**r.state_mut::<InputCapture>(id2), "b");
}
struct FocusableItem;
impl Component for FocusableItem {
type State = String;
fn render(&self, area: Rect, buf: &mut Buffer, state: &Self::State) {
let line = ratatui_core::text::Line::raw(state.as_str());
ratatui_core::widgets::Widget::render(Paragraph::new(line), area, buf);
}
fn is_focusable(&self, _state: &Self::State) -> bool {
true
}
fn initial_state(&self) -> Option<String> {
Some("item".to_string())
}
}
fn tab_event() -> crossterm::event::Event {
crossterm::event::Event::Key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Tab,
crossterm::event::KeyModifiers::empty(),
))
}
fn backtab_event() -> crossterm::event::Event {
crossterm::event::Event::Key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::BackTab,
crossterm::event::KeyModifiers::SHIFT,
))
}
#[test]
fn tab_cycles_through_focusable_nodes() {
let mut r = Renderer::new(10);
let _non_focusable = r.push(TextBlock); let f1 = r.push(FocusableItem);
let f2 = r.push(FocusableItem);
let f3 = r.push(FocusableItem);
r.state_mut::<TextBlock>(_non_focusable)
.push("header".to_string());
r.handle_event(&tab_event());
assert_eq!(r.focus(), Some(f1));
r.handle_event(&tab_event());
assert_eq!(r.focus(), Some(f2));
r.handle_event(&tab_event());
assert_eq!(r.focus(), Some(f3));
r.handle_event(&tab_event());
assert_eq!(r.focus(), Some(f1));
}
#[test]
fn backtab_cycles_reverse() {
let mut r = Renderer::new(10);
let f1 = r.push(FocusableItem);
let f2 = r.push(FocusableItem);
let f3 = r.push(FocusableItem);
r.set_focus(f1);
r.handle_event(&backtab_event());
assert_eq!(r.focus(), Some(f3));
r.handle_event(&backtab_event());
assert_eq!(r.focus(), Some(f2));
}
#[test]
fn tab_skips_frozen_nodes() {
let mut r = Renderer::new(10);
let f1 = r.push(FocusableItem);
let f2 = r.push(FocusableItem);
let f3 = r.push(FocusableItem);
let _ = r.render(); r.freeze(f2);
r.set_focus(f1);
r.handle_event(&tab_event());
assert_eq!(r.focus(), Some(f3));
}
#[test]
fn tab_with_no_focusable_nodes_falls_through() {
let mut r = Renderer::new(10);
let _id = r.push(TextBlock); r.state_mut::<TextBlock>(_id).push("text".to_string());
let result = r.handle_event(&tab_event());
assert_eq!(r.focus(), None);
assert_eq!(result, EventResult::Ignored);
}
#[test]
fn tab_with_single_focusable_falls_through() {
let mut r = Renderer::new(10);
let f1 = r.push(FocusableItem);
let result = r.handle_event(&tab_event());
assert_eq!(r.focus(), Some(f1));
assert_eq!(result, EventResult::Consumed);
let result = r.handle_event(&tab_event());
assert_eq!(r.focus(), Some(f1));
assert_eq!(result, EventResult::Ignored);
}
struct ScopeContainer;
impl Component for ScopeContainer {
type State = ();
fn render(&self, _area: Rect, _buf: &mut Buffer, _state: &()) {}
fn initial_state(&self) -> Option<()> {
Some(())
}
fn lifecycle(&self, hooks: &mut Hooks<Self, ()>, _state: &()) {
hooks.use_focus_scope();
}
}
struct AutofocusItem;
impl Component for AutofocusItem {
type State = String;
fn render(&self, area: Rect, buf: &mut Buffer, state: &Self::State) {
let line = ratatui_core::text::Line::raw(state.as_str());
ratatui_core::widgets::Widget::render(Paragraph::new(line), area, buf);
}
fn is_focusable(&self, _state: &Self::State) -> bool {
true
}
fn initial_state(&self) -> Option<String> {
Some("autofocus-item".to_string())
}
fn lifecycle(&self, hooks: &mut Hooks<Self, Self::State>, _state: &Self::State) {
hooks.use_autofocus();
}
}
#[test]
fn tab_cycles_within_scope_only() {
let mut r = Renderer::new(20);
let f_outside = r.push(FocusableItem);
let scope = r.push(ScopeContainer);
r.apply_lifecycle(scope); let f1 = r.append_child(scope, FocusableItem);
let f2 = r.append_child(scope, FocusableItem);
r.set_focus(f1);
r.handle_event(&tab_event());
assert_eq!(r.focus(), Some(f2));
r.handle_event(&tab_event());
assert_eq!(r.focus(), Some(f1));
let _ = f_outside;
}
#[test]
fn tab_in_scope_with_single_focusable_falls_through() {
let mut r = Renderer::new(20);
let scope = r.push(ScopeContainer);
r.apply_lifecycle(scope);
let f1 = r.append_child(scope, FocusableItem);
r.set_focus(f1);
let result = r.handle_event(&tab_event());
assert_eq!(r.focus(), Some(f1));
assert_eq!(result, EventResult::Ignored);
}
#[test]
fn backtab_cycles_within_scope() {
let mut r = Renderer::new(20);
let _f_outside = r.push(FocusableItem);
let scope = r.push(ScopeContainer);
r.apply_lifecycle(scope);
let f1 = r.append_child(scope, FocusableItem);
let f2 = r.append_child(scope, FocusableItem);
let f3 = r.append_child(scope, FocusableItem);
r.set_focus(f1);
r.handle_event(&backtab_event());
assert_eq!(r.focus(), Some(f3));
r.handle_event(&backtab_event());
assert_eq!(r.focus(), Some(f2));
}
#[test]
fn removing_scope_restores_previous_focus() {
let mut r = Renderer::new(20);
let f_outside = r.push(FocusableItem);
r.set_focus(f_outside);
let scope = r.push(ScopeContainer);
let _f_in = r.append_child(scope, AutofocusItem);
let provided = r.apply_lifecycle(scope);
let saved = r.push_context(provided);
let provided_child = r.apply_lifecycle(_f_in);
r.fire_mount(_f_in);
let saved2 = r.push_context(provided_child);
r.pop_context(saved2);
r.pop_context(saved);
assert_eq!(r.focus(), Some(_f_in));
r.remove(scope);
assert_eq!(r.focus(), Some(f_outside));
}
#[test]
fn nested_scopes_inner_removed_restores_to_outer() {
let mut r = Renderer::new(20);
let f_outside = r.push(FocusableItem);
r.set_focus(f_outside);
let outer_scope = r.push(ScopeContainer);
let f_outer = r.append_child(outer_scope, AutofocusItem);
let provided = r.apply_lifecycle(outer_scope);
let saved = r.push_context(provided);
let provided_child = r.apply_lifecycle(f_outer);
r.fire_mount(f_outer);
let saved2 = r.push_context(provided_child);
r.pop_context(saved2);
assert_eq!(r.focus(), Some(f_outer));
let inner_scope = r.append_child(outer_scope, ScopeContainer);
let f_inner = r.append_child(inner_scope, AutofocusItem);
let provided_inner_scope = r.apply_lifecycle(inner_scope);
let saved3 = r.push_context(provided_inner_scope);
let provided_inner_child = r.apply_lifecycle(f_inner);
r.fire_mount(f_inner);
let saved4 = r.push_context(provided_inner_child);
r.pop_context(saved4);
r.pop_context(saved3);
r.pop_context(saved);
assert_eq!(r.focus(), Some(f_inner));
r.remove(inner_scope);
assert_eq!(r.focus(), Some(f_outer));
r.remove(outer_scope);
assert_eq!(r.focus(), Some(f_outside));
}
#[test]
fn scope_removed_with_invalid_saved_focus_falls_back() {
let mut r = Renderer::new(20);
let f0 = r.push(FocusableItem);
let f1 = r.push(FocusableItem);
r.set_focus(f1);
let scope = r.push(ScopeContainer);
let _f_in = r.append_child(scope, AutofocusItem);
let provided = r.apply_lifecycle(scope);
let saved = r.push_context(provided);
let provided_child = r.apply_lifecycle(_f_in);
r.fire_mount(_f_in);
let saved2 = r.push_context(provided_child);
r.pop_context(saved2);
r.pop_context(saved);
assert_eq!(r.focus(), Some(_f_in));
r.remove(f1);
r.remove(scope);
assert_eq!(r.focus(), Some(f0));
}
#[test]
fn scope_removed_with_no_remaining_focusable_clears() {
let mut r = Renderer::new(20);
let f_outside = r.push(FocusableItem);
r.set_focus(f_outside);
let scope = r.push(ScopeContainer);
let _f_in = r.append_child(scope, AutofocusItem);
let provided = r.apply_lifecycle(scope);
let saved = r.push_context(provided);
let provided_child = r.apply_lifecycle(_f_in);
r.fire_mount(_f_in);
let saved2 = r.push_context(provided_child);
r.pop_context(saved2);
r.pop_context(saved);
r.remove(f_outside);
r.remove(scope);
assert_eq!(r.focus(), None);
}
#[test]
fn programmatic_set_focus_crosses_scope_boundaries() {
let mut r = Renderer::new(20);
let f_outside = r.push(FocusableItem);
let scope = r.push(ScopeContainer);
r.apply_lifecycle(scope);
let f_in = r.append_child(scope, FocusableItem);
r.set_focus(f_in);
assert_eq!(r.focus(), Some(f_in));
r.set_focus(f_outside);
assert_eq!(r.focus(), Some(f_outside));
r.set_focus(f_in);
assert_eq!(r.focus(), Some(f_in));
}
#[test]
fn set_focus_into_scope_saves_pre_scope_focus() {
let mut r = Renderer::new(20);
let f_outside = r.push(FocusableItem);
r.set_focus(f_outside);
let scope = r.push(ScopeContainer);
r.apply_lifecycle(scope);
let f_in = r.append_child(scope, FocusableItem);
r.set_focus(f_in);
assert_eq!(r.focus(), Some(f_in));
r.remove(scope);
assert_eq!(r.focus(), Some(f_outside));
}
#[test]
fn tab_into_scope_saves_pre_scope_focus() {
let mut r = Renderer::new(20);
let f_outside = r.push(FocusableItem);
let scope = r.push(ScopeContainer);
r.apply_lifecycle(scope);
let f_in = r.append_child(scope, FocusableItem);
r.set_focus(f_outside);
r.handle_event(&tab_event());
assert_eq!(r.focus(), Some(f_in));
r.remove(scope);
assert_eq!(r.focus(), Some(f_outside));
}
struct CaptureShortcut;
impl Component for CaptureShortcut {
type State = Vec<String>;
fn render(&self, area: Rect, buf: &mut Buffer, state: &Self::State) {
let text: Vec<Line> = state.iter().map(|s| Line::raw(s.as_str())).collect();
let para = Paragraph::new(text);
ratatui_core::widgets::Widget::render(para, area, buf);
}
fn handle_event_capture(
&self,
event: &crossterm::event::Event,
state: &mut Tracked<Self::State>,
) -> EventResult {
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
if let Event::Key(KeyEvent {
code: KeyCode::Char('n'),
kind: KeyEventKind::Press,
modifiers,
..
}) = event
&& modifiers.contains(KeyModifiers::CONTROL)
{
state.push("capture:ctrl-n".to_string());
return EventResult::Consumed;
}
EventResult::Ignored
}
fn initial_state(&self) -> Option<Vec<String>> {
Some(vec![])
}
}
fn ctrl_n_event() -> crossterm::event::Event {
crossterm::event::Event::Key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('n'),
crossterm::event::KeyModifiers::CONTROL,
))
}
#[test]
fn capture_phase_intercepts_before_bubble() {
let mut r = Renderer::new(10);
let parent = r.push(CaptureShortcut);
let child = r.append_child(parent, InputCapture);
r.set_focus(child);
let result = r.handle_event(&key_event('a'));
assert_eq!(result, EventResult::Consumed);
assert_eq!(&**r.state_mut::<InputCapture>(child), "a");
assert!(r.state_mut::<CaptureShortcut>(parent).is_empty());
let result = r.handle_event(&ctrl_n_event());
assert_eq!(result, EventResult::Consumed);
assert_eq!(&**r.state_mut::<InputCapture>(child), "a"); assert_eq!(
&**r.state_mut::<CaptureShortcut>(parent),
&["capture:ctrl-n".to_string()]
);
}
#[test]
fn capture_consumed_prevents_bubble() {
let mut r = Renderer::new(10);
let grandparent = r.push(CaptureShortcut);
let parent = r.append_child(grandparent, InputCapture);
let child = r.append_child(parent, TextBlock);
r.state_mut::<TextBlock>(child).push("child".to_string());
r.set_focus(child);
let result = r.handle_event(&ctrl_n_event());
assert_eq!(result, EventResult::Consumed);
assert_eq!(
&**r.state_mut::<CaptureShortcut>(grandparent),
&["capture:ctrl-n".to_string()]
);
assert_eq!(&**r.state_mut::<InputCapture>(parent), ""); }
#[test]
fn frozen_node_skipped_in_capture() {
let mut r = Renderer::new(10);
let parent = r.push(CaptureShortcut);
let child = r.append_child(parent, TextBlock);
r.state_mut::<TextBlock>(child).push("child".to_string());
r.set_focus(child);
let _ = r.render();
r.freeze(parent);
let result = r.handle_event(&ctrl_n_event());
assert_eq!(result, EventResult::Ignored);
assert!(r.state_mut::<CaptureShortcut>(parent).is_empty());
}
#[test]
fn focused_node_participates_in_capture() {
struct SelfCapture;
impl Component for SelfCapture {
type State = Vec<String>;
fn render(&self, _area: Rect, _buf: &mut Buffer, _state: &Self::State) {}
fn is_focusable(&self, _state: &Self::State) -> bool {
true
}
fn handle_event_capture(
&self,
event: &crossterm::event::Event,
state: &mut Tracked<Self::State>,
) -> EventResult {
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind};
if let Event::Key(KeyEvent {
code: KeyCode::Char(c),
kind: KeyEventKind::Press,
..
}) = event
{
state.push(format!("capture:{c}"));
EventResult::Consumed
} else {
EventResult::Ignored
}
}
fn handle_event(
&self,
event: &crossterm::event::Event,
state: &mut Tracked<Self::State>,
) -> EventResult {
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind};
if let Event::Key(KeyEvent {
code: KeyCode::Char(c),
kind: KeyEventKind::Press,
..
}) = event
{
state.push(format!("bubble:{c}"));
EventResult::Consumed
} else {
EventResult::Ignored
}
}
fn initial_state(&self) -> Option<Vec<String>> {
Some(vec![])
}
}
let mut r = Renderer::new(10);
let id = r.push(SelfCapture);
r.set_focus(id);
r.handle_event(&key_event('x'));
let state = r.state_mut::<SelfCapture>(id);
assert_eq!(&**state, &["capture:x".to_string()]);
}
#[test]
fn capture_ignores_then_bubble_handles() {
let mut r = Renderer::new(10);
let parent = r.push(CaptureShortcut);
let child = r.append_child(parent, InputCapture);
r.set_focus(child);
let result = r.handle_event(&key_event('z'));
assert_eq!(result, EventResult::Consumed);
assert_eq!(&**r.state_mut::<InputCapture>(child), "z");
assert!(r.state_mut::<CaptureShortcut>(parent).is_empty());
}
use crate::element::{Element, Elements};
struct TestTextEl {
lines: Vec<String>,
}
impl TestTextEl {
fn new(text: &str) -> Self {
Self {
lines: vec![text.to_string()],
}
}
}
impl Element for TestTextEl {
fn build(self: Box<Self>, renderer: &mut Renderer, parent: NodeId) -> NodeId {
let id = renderer.append_child(parent, TextBlock);
for line in self.lines {
renderer.state_mut::<TextBlock>(id).push(line);
}
id
}
fn update(self: Box<Self>, renderer: &mut Renderer, node_id: NodeId) {
let state = renderer.state_mut::<TextBlock>(node_id);
state.clear();
for line in self.lines {
state.push(line);
}
}
}
#[test]
fn rebuild_replaces_children() {
let mut r = Renderer::new(10);
let container = r.push(VStack);
let c1 = r.append_child(container, TextBlock);
r.state_mut::<TextBlock>(c1).push("old1".to_string());
let c2 = r.append_child(container, TextBlock);
r.state_mut::<TextBlock>(c2).push("old2".to_string());
let frame1 = r.render();
assert_eq!(frame1.area().height, 2);
let mut els = Elements::new();
els.add_element(TestTextEl::new("new1"));
r.rebuild(container, els);
let frame2 = r.render();
assert_eq!(frame2.area().height, 1);
assert_eq!(frame2.buffer()[(0, 0)].symbol(), "n"); }
#[test]
fn rebuild_with_nested_children() {
let mut r = Renderer::new(10);
let container = r.push(VStack);
let mut inner = Elements::new();
inner.add_element(TestTextEl::new("child1"));
inner.add_element(TestTextEl::new("child2"));
let mut els = Elements::new();
els.add_with_children(VStack, inner);
r.rebuild(container, els);
let frame = r.render();
assert_eq!(frame.area().height, 2);
assert_eq!(frame.buffer()[(0, 0)].symbol(), "c"); assert_eq!(frame.buffer()[(0, 1)].symbol(), "c"); }
#[test]
fn rebuild_with_empty_elements_clears_children() {
let mut r = Renderer::new(10);
let container = r.push(VStack);
let c = r.append_child(container, TextBlock);
r.state_mut::<TextBlock>(c).push("exists".to_string());
let frame1 = r.render();
assert_eq!(frame1.area().height, 1);
r.rebuild(container, Elements::new());
let frame2 = r.render();
assert_eq!(frame2.area().height, 0);
}
#[test]
fn rebuild_view_function_pattern() {
fn view(thinking: bool) -> Elements {
let mut els = Elements::new();
if thinking {
els.add_element(TestTextEl::new("thinking..."));
}
els.add_element(TestTextEl::new("message"));
els
}
let mut r = Renderer::new(10);
let container = r.push(VStack);
r.rebuild(container, view(true));
let frame1 = r.render();
assert_eq!(frame1.area().height, 2);
assert_eq!(frame1.buffer()[(0, 0)].symbol(), "t");
r.rebuild(container, view(false));
let frame2 = r.render();
assert_eq!(frame2.area().height, 1);
assert_eq!(frame2.buffer()[(0, 0)].symbol(), "m"); }
#[test]
fn rebuild_with_group() {
let mut r = Renderer::new(10);
let container = r.push(VStack);
let mut children = Elements::new();
children.add_element(TestTextEl::new("grouped1"));
children.add_element(TestTextEl::new("grouped2"));
let mut els = Elements::new();
els.add_element(TestTextEl::new("before"));
els.group(children);
els.add_element(TestTextEl::new("after"));
r.rebuild(container, els);
let frame = r.render();
assert_eq!(frame.area().height, 4);
assert_eq!(frame.buffer()[(0, 0)].symbol(), "b"); assert_eq!(frame.buffer()[(0, 1)].symbol(), "g"); assert_eq!(frame.buffer()[(0, 2)].symbol(), "g"); assert_eq!(frame.buffer()[(0, 3)].symbol(), "a"); }
#[test]
fn custom_element_impl_works() {
struct CustomWidget;
impl Component for CustomWidget {
type State = String;
fn render(&self, area: Rect, buf: &mut Buffer, state: &Self::State) {
let line = ratatui_core::text::Line::raw(state.as_str());
ratatui_core::widgets::Widget::render(Paragraph::new(line), area, buf);
}
fn initial_state(&self) -> Option<String> {
Some(String::new())
}
}
struct CustomWidgetEl {
config: String,
}
impl Element for CustomWidgetEl {
fn build(self: Box<Self>, renderer: &mut Renderer, parent: NodeId) -> NodeId {
let id = renderer.append_child(parent, CustomWidget);
let state = renderer.state_mut::<CustomWidget>(id);
state.push_str(&self.config);
id
}
}
let mut r = Renderer::new(10);
let container = r.push(VStack);
let mut els = Elements::new();
els.add_element(CustomWidgetEl {
config: "custom!".to_string(),
});
r.rebuild(container, els);
let frame = r.render();
assert_eq!(frame.area().height, 1);
assert_eq!(frame.buffer()[(0, 0)].symbol(), "c"); }
struct CounterWidget;
impl Component for CounterWidget {
type State = (String, usize);
fn render(&self, area: Rect, buf: &mut Buffer, state: &Self::State) {
let line = ratatui_core::text::Line::raw(state.0.as_str());
ratatui_core::widgets::Widget::render(Paragraph::new(line), area, buf);
}
fn initial_state(&self) -> Option<(String, usize)> {
Some((String::new(), 0))
}
}
struct CounterEl {
label: String,
}
impl CounterEl {
fn new(label: &str) -> Self {
Self {
label: label.to_string(),
}
}
}
impl Element for CounterEl {
fn build(self: Box<Self>, renderer: &mut Renderer, parent: NodeId) -> NodeId {
let id = renderer.append_child(parent, CounterWidget);
let state = renderer.state_mut::<CounterWidget>(id);
state.0 = self.label;
state.1 = 1; id
}
fn update(self: Box<Self>, renderer: &mut Renderer, node_id: NodeId) {
let state = renderer.state_mut::<CounterWidget>(node_id);
state.0 = self.label;
}
}
#[test]
fn reconciliation_reuses_same_type_at_same_position() {
let mut r = Renderer::new(10);
let container = r.push(VStack);
let mut els = Elements::new();
els.add_element(CounterEl::new("first"));
r.rebuild(container, els);
let children_1 = r.children(container).to_vec();
assert_eq!(children_1.len(), 1);
let first_id = children_1[0];
assert_eq!(r.state_mut::<CounterWidget>(first_id).1, 1);
let mut els = Elements::new();
els.add_element(CounterEl::new("updated"));
r.rebuild(container, els);
let children_2 = r.children(container).to_vec();
assert_eq!(children_2.len(), 1);
assert_eq!(children_2[0], first_id);
assert_eq!(&r.state_mut::<CounterWidget>(first_id).0, "updated");
assert_eq!(r.state_mut::<CounterWidget>(first_id).1, 1);
}
#[test]
fn reconciliation_type_mismatch_creates_new_node() {
let mut r = Renderer::new(10);
let container = r.push(VStack);
let mut els = Elements::new();
els.add_element(CounterEl::new("counter"));
r.rebuild(container, els);
let old_id = r.children(container)[0];
assert_eq!(r.state_mut::<CounterWidget>(old_id).1, 1);
let mut els = Elements::new();
els.add_element(TestTextEl::new("text"));
r.rebuild(container, els);
let new_id = r.children(container)[0];
let state = r.state_mut::<TextBlock>(new_id);
assert_eq!(state.len(), 1);
assert_eq!(state[0], "text");
}
#[test]
fn keyed_elements_survive_position_change() {
let mut r = Renderer::new(10);
let container = r.push(VStack);
let mut els = Elements::new();
els.add_element(CounterEl::new("A")).key("a");
els.add_element(CounterEl::new("B")).key("b");
r.rebuild(container, els);
let children_1 = r.children(container).to_vec();
let id_a = children_1[0];
let id_b = children_1[1];
let mut els = Elements::new();
els.add_element(CounterEl::new("B")).key("b");
els.add_element(CounterEl::new("A")).key("a");
r.rebuild(container, els);
let children_2 = r.children(container).to_vec();
assert_eq!(children_2[0], id_b);
assert_eq!(children_2[1], id_a);
assert_eq!(r.state_mut::<CounterWidget>(id_a).1, 1);
assert_eq!(r.state_mut::<CounterWidget>(id_b).1, 1);
}
#[test]
fn keyed_element_removed_and_added() {
let mut r = Renderer::new(10);
let container = r.push(VStack);
let mut els = Elements::new();
els.add_element(CounterEl::new("A")).key("a");
els.add_element(CounterEl::new("B")).key("b");
els.add_element(CounterEl::new("C")).key("c");
r.rebuild(container, els);
let id_a = r.children(container)[0];
let id_c = r.children(container)[2];
let mut els = Elements::new();
els.add_element(CounterEl::new("A")).key("a");
els.add_element(CounterEl::new("C")).key("c");
r.rebuild(container, els);
let children = r.children(container).to_vec();
assert_eq!(children.len(), 2);
assert_eq!(children[0], id_a); assert_eq!(children[1], id_c); }
#[test]
fn find_by_key_works() {
let mut r = Renderer::new(10);
let container = r.push(VStack);
let mut els = Elements::new();
els.add_element(CounterEl::new("alpha")).key("first");
els.add_element(CounterEl::new("beta")).key("second");
els.add_element(CounterEl::new("gamma")); r.rebuild(container, els);
assert_eq!(
r.find_by_key(container, "first"),
Some(r.children(container)[0])
);
assert_eq!(
r.find_by_key(container, "second"),
Some(r.children(container)[1])
);
assert_eq!(r.find_by_key(container, "third"), None);
assert_eq!(r.find_by_key(container, "gamma"), None); }
#[test]
fn reconciliation_preserves_local_state() {
let mut r = Renderer::new(10);
let container = r.push(VStack);
let mut els = Elements::new();
els.add_element(CounterEl::new("item")).key("c");
r.rebuild(container, els);
let id = r.find_by_key(container, "c").unwrap();
r.state_mut::<CounterWidget>(id).1 = 42;
let mut els = Elements::new();
els.add_element(CounterEl::new("updated-item")).key("c");
r.rebuild(container, els);
let id_after = r.find_by_key(container, "c").unwrap();
assert_eq!(id, id_after); assert_eq!(&r.state_mut::<CounterWidget>(id).0, "updated-item"); assert_eq!(r.state_mut::<CounterWidget>(id).1, 42); }
#[test]
fn reconciliation_nested_children() {
let mut r = Renderer::new(10);
let container = r.push(VStack);
let mut inner = Elements::new();
inner.add_element(CounterEl::new("A")).key("a");
inner.add_element(CounterEl::new("B")).key("b");
let mut els = Elements::new();
els.add_with_children(VStack, inner).key("group");
r.rebuild(container, els);
let group_id = r.find_by_key(container, "group").unwrap();
let id_a = r.find_by_key(group_id, "a").unwrap();
let id_b = r.find_by_key(group_id, "b").unwrap();
let mut inner = Elements::new();
inner.add_element(CounterEl::new("A-updated")).key("a");
inner.add_element(CounterEl::new("C")).key("c");
let mut els = Elements::new();
els.add_with_children(VStack, inner).key("group");
r.rebuild(container, els);
assert_eq!(r.find_by_key(container, "group").unwrap(), group_id);
let id_a_after = r.find_by_key(group_id, "a").unwrap();
assert_eq!(id_a_after, id_a);
assert_eq!(&r.state_mut::<CounterWidget>(id_a).0, "A-updated");
assert_eq!(r.find_by_key(group_id, "b"), None);
let id_c = r.find_by_key(group_id, "c").unwrap();
assert_ne!(id_c, id_b); }
#[test]
fn empty_rebuild_clears_with_reconciliation() {
let mut r = Renderer::new(10);
let container = r.push(VStack);
let mut els = Elements::new();
els.add_element(CounterEl::new("something")).key("x");
r.rebuild(container, els);
assert_eq!(r.children(container).len(), 1);
r.rebuild(container, Elements::new());
assert_eq!(r.children(container).len(), 0);
}
use std::time::Duration;
#[test]
fn register_tick_fires_handler() {
let mut r = Renderer::new(10);
let id = r.push(TextBlock);
r.state_mut::<TextBlock>(id).push("hello".to_string());
r.register_tick::<TextBlock>(id, Duration::from_millis(1), |state| {
state.push("ticked".to_string());
});
assert!(r.has_active());
std::thread::sleep(Duration::from_millis(5));
let fired = r.tick();
assert!(fired);
assert_eq!(r.state_mut::<TextBlock>(id).len(), 2);
}
#[test]
fn tick_respects_interval() {
let mut r = Renderer::new(10);
let id = r.push(TextBlock);
r.state_mut::<TextBlock>(id).push("hello".to_string());
r.register_tick::<TextBlock>(id, Duration::from_secs(60), |state| {
state.push("ticked".to_string());
});
let fired = r.tick();
assert!(!fired);
assert_eq!(r.state_mut::<TextBlock>(id).len(), 1);
}
#[test]
fn unregister_tick_prevents_firing() {
let mut r = Renderer::new(10);
let id = r.push(TextBlock);
r.state_mut::<TextBlock>(id).push("hello".to_string());
r.register_tick::<TextBlock>(id, Duration::from_millis(1), |state| {
state.push("ticked".to_string());
});
assert!(r.has_active());
r.unregister_tick(id);
assert!(!r.has_active());
std::thread::sleep(Duration::from_millis(5));
let fired = r.tick();
assert!(!fired);
}
#[test]
fn tombstone_cleans_up_ticks() {
let mut r = Renderer::new(10);
let id = r.push(TextBlock);
r.state_mut::<TextBlock>(id).push("hello".to_string());
r.register_tick::<TextBlock>(id, Duration::from_millis(1), |_| {});
assert!(r.has_active());
r.remove(id);
assert!(!r.has_active());
}
#[test]
fn rebuild_cleans_up_removed_ticks() {
let mut r = Renderer::new(10);
let container = r.push(VStack);
let mut els = Elements::new();
els.add_element(CounterEl::new("item")).key("x");
r.rebuild(container, els);
let id = r.find_by_key(container, "x").unwrap();
r.register_tick::<CounterWidget>(id, Duration::from_millis(1), |state| {
state.1 += 1;
});
assert!(r.has_active());
r.rebuild(container, Elements::new());
assert!(!r.has_active());
}
#[test]
fn has_active_reflects_registrations() {
let mut r = Renderer::new(10);
assert!(!r.has_active());
let id = r.push(TextBlock);
r.state_mut::<TextBlock>(id).push("hello".to_string());
assert!(!r.has_active());
r.register_tick::<TextBlock>(id, Duration::from_millis(80), |_| {});
assert!(r.has_active());
r.unregister_tick(id);
assert!(!r.has_active());
}
#[test]
fn spinner_build_auto_registers_tick() {
let mut r = Renderer::new(10);
let container = r.push(VStack);
let mut els = Elements::new();
els.add(crate::components::spinner::Spinner::new("Loading..."));
r.rebuild(container, els);
assert!(r.has_active());
}
#[test]
fn spinner_done_build_no_tick() {
let mut r = Renderer::new(10);
let container = r.push(VStack);
let mut els = Elements::new();
els.add(crate::components::spinner::Spinner::new("Done").done("Completed"));
r.rebuild(container, els);
assert!(!r.has_active());
}
#[test]
fn spinner_update_to_done_unregisters() {
let mut r = Renderer::new(10);
let container = r.push(VStack);
let mut els = Elements::new();
els.add(crate::components::spinner::Spinner::new("Loading..."))
.key("s");
r.rebuild(container, els);
assert!(r.has_active());
let mut els = Elements::new();
els.add(crate::components::spinner::Spinner::new("Done").done("Completed"))
.key("s");
r.rebuild(container, els);
assert!(!r.has_active());
}
#[test]
fn spinner_animates_after_tick_without_rebuild() {
let mut r = Renderer::new(30);
let container = r.push(VStack);
let mut els = Elements::new();
els.add(crate::components::spinner::Spinner::new("Loading..."))
.key("s");
r.rebuild(container, els);
let frame = r.render();
let first_symbol = frame.buffer()[(0, 0)].symbol().to_string();
assert_eq!(first_symbol, "⠋");
std::thread::sleep(std::time::Duration::from_millis(100));
let ticked = r.tick();
assert!(ticked, "interval should have fired");
let frame2 = r.render();
let second_symbol = frame2.buffer()[(0, 0)].symbol().to_string();
assert_ne!(
second_symbol, first_symbol,
"spinner should have advanced to a new frame"
);
}
struct LifecycleEl {
label: String,
mount_marker: String,
unmount_marker: String,
}
impl LifecycleEl {
fn new(label: &str) -> Self {
Self {
label: label.to_string(),
mount_marker: format!("mounted:{}", label),
unmount_marker: format!("unmounted:{}", label),
}
}
}
struct LifecycleWidget;
#[derive(Default)]
struct LifecycleState {
log: Vec<String>,
mount_marker: String,
unmount_marker: String,
}
impl Component for LifecycleWidget {
type State = LifecycleState;
fn render(&self, area: Rect, buf: &mut Buffer, state: &Self::State) {
let text = state.log.join(", ");
let line = ratatui_core::text::Line::raw(text);
ratatui_core::widgets::Widget::render(Paragraph::new(line), area, buf);
}
fn initial_state(&self) -> Option<LifecycleState> {
Some(LifecycleState {
log: Vec::new(),
mount_marker: String::new(),
unmount_marker: String::new(),
})
}
fn lifecycle(&self, hooks: &mut Hooks<Self, LifecycleState>, state: &LifecycleState) {
let mount_marker = state.mount_marker.clone();
if !mount_marker.is_empty() {
hooks.use_mount(move |_props, s| {
s.log.push(mount_marker.clone());
});
}
let unmount_marker = state.unmount_marker.clone();
if !unmount_marker.is_empty() {
hooks.use_unmount(move |_props, s| {
s.log.push(unmount_marker.clone());
});
}
}
}
impl Element for LifecycleEl {
fn build(self: Box<Self>, renderer: &mut Renderer, parent: NodeId) -> NodeId {
let id = renderer.append_child(parent, LifecycleWidget);
let state = renderer.state_mut::<LifecycleWidget>(id);
state.log.push(self.label.clone());
state.mount_marker = self.mount_marker;
state.unmount_marker = self.unmount_marker;
id
}
fn update(self: Box<Self>, renderer: &mut Renderer, node_id: NodeId) {
let state = renderer.state_mut::<LifecycleWidget>(node_id);
state
.log
.retain(|s| s.starts_with("mounted:") || s.starts_with("unmounted:"));
state.log.push(self.label.clone());
state.mount_marker = self.mount_marker;
state.unmount_marker = self.unmount_marker;
}
}
#[test]
fn mount_fires_after_build() {
let mut r = Renderer::new(10);
let container = r.push(VStack);
let mut els = Elements::new();
els.add_element(LifecycleEl::new("hello")).key("a");
r.rebuild(container, els);
let id = r.find_by_key(container, "a").unwrap();
let state = r.state_mut::<LifecycleWidget>(id);
assert!(state.log.contains(&"hello".to_string()));
assert!(state.log.contains(&"mounted:hello".to_string()));
}
#[test]
fn mount_fires_only_once_not_on_reuse() {
let mut r = Renderer::new(10);
let container = r.push(VStack);
let mut els = Elements::new();
els.add_element(LifecycleEl::new("v1")).key("a");
r.rebuild(container, els);
let id = r.find_by_key(container, "a").unwrap();
let mount_count = r
.state_mut::<LifecycleWidget>(id)
.log
.iter()
.filter(|s| s.starts_with("mounted:"))
.count();
assert_eq!(mount_count, 1);
let mut els = Elements::new();
els.add_element(LifecycleEl::new("v2")).key("a");
r.rebuild(container, els);
let state = r.state_mut::<LifecycleWidget>(id);
let mount_count = state
.log
.iter()
.filter(|s| s.starts_with("mounted:"))
.count();
assert_eq!(mount_count, 1); }
#[test]
fn unmount_fires_on_tombstone() {
use std::sync::{Arc, Mutex};
let log: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let mut r = Renderer::new(10);
let container = r.push(VStack);
let mut els = Elements::new();
els.add_element(LifecycleEl::new("bye")).key("a");
r.rebuild(container, els);
let id = r.find_by_key(container, "a").unwrap();
let log_clone = log.clone();
r.on_unmount::<LifecycleWidget>(id, move |_state| {
log_clone.lock().unwrap().push("unmounted".to_string());
});
r.rebuild(container, Elements::new());
let entries = log.lock().unwrap();
assert!(entries.contains(&"unmounted".to_string()));
}
#[test]
fn unmount_parent_fires_before_children() {
use std::sync::{Arc, Mutex};
let order: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let mut r = Renderer::new(10);
let container = r.push(VStack);
let parent_id = r.append_child(container, LifecycleWidget);
r.state_mut::<LifecycleWidget>(parent_id)
.log
.push("parent".to_string());
let child_id = r.append_child(parent_id, LifecycleWidget);
r.state_mut::<LifecycleWidget>(child_id)
.log
.push("child".to_string());
let order_clone = order.clone();
r.on_unmount::<LifecycleWidget>(parent_id, move |_state| {
order_clone.lock().unwrap().push("parent".to_string());
});
let order_clone = order.clone();
r.on_unmount::<LifecycleWidget>(child_id, move |_state| {
order_clone.lock().unwrap().push("child".to_string());
});
r.remove(parent_id);
let fired = order.lock().unwrap();
assert_eq!(&*fired, &["child", "parent"]);
}
#[test]
fn tombstone_cleans_up_all_effects() {
let mut r = Renderer::new(10);
let id = r.push(TextBlock);
r.state_mut::<TextBlock>(id).push("hello".to_string());
r.register_tick::<TextBlock>(id, Duration::from_millis(1), |_| {});
r.on_unmount::<TextBlock>(id, |_| {});
assert!(r.has_active());
r.remove(id);
assert!(!r.has_active());
}
#[test]
fn multiple_effects_per_node() {
let mut r = Renderer::new(10);
let container = r.push(VStack);
let mut els = Elements::new();
els.add_element(HookedCounterEl {
label: "multi".into(),
})
.key("m");
r.rebuild(container, els);
let id = r.find_by_key(container, "m").unwrap();
assert!(r.has_active());
std::thread::sleep(Duration::from_millis(5));
r.tick();
assert!(r.state_mut::<HookedCounter>(id).1 > 0);
r.rebuild(container, Elements::new());
assert!(!r.has_active());
}
use crate::node::WidthConstraint;
#[test]
fn allocate_widths_all_fill() {
let constraints = vec![WidthConstraint::Fill, WidthConstraint::Fill];
let widths = super::allocate_widths(&constraints, 80);
assert_eq!(widths, vec![40, 40]);
}
#[test]
fn allocate_widths_fill_with_remainder() {
let constraints = vec![
WidthConstraint::Fill,
WidthConstraint::Fill,
WidthConstraint::Fill,
];
let widths = super::allocate_widths(&constraints, 80);
assert_eq!(widths, vec![27, 27, 26]);
assert_eq!(widths.iter().sum::<u16>(), 80);
}
#[test]
fn allocate_widths_fixed_plus_fill() {
let constraints = vec![WidthConstraint::Fixed(2), WidthConstraint::Fill];
let widths = super::allocate_widths(&constraints, 80);
assert_eq!(widths, vec![2, 78]);
}
#[test]
fn allocate_widths_fixed_exceeds_total() {
let constraints = vec![
WidthConstraint::Fixed(50),
WidthConstraint::Fixed(50),
WidthConstraint::Fill,
];
let widths = super::allocate_widths(&constraints, 80);
assert_eq!(widths[2], 0);
}
#[test]
fn allocate_widths_single_fill() {
let constraints = vec![WidthConstraint::Fill];
let widths = super::allocate_widths(&constraints, 80);
assert_eq!(widths, vec![80]);
}
#[test]
fn hstack_measure_height_uses_max() {
let mut r = Renderer::new(20);
let container = r.push(VStack);
let hstack = r.append_child(container, crate::component::HStack);
r.set_layout(hstack, crate::node::Layout::Horizontal);
let child1 = r.append_child(hstack, TextBlock);
r.state_mut::<TextBlock>(child1).push("line1".to_string());
r.state_mut::<TextBlock>(child1).push("line2".to_string());
let child2 = r.append_child(hstack, TextBlock);
r.state_mut::<TextBlock>(child2).push("one".to_string());
let frame = r.render();
assert_eq!(frame.area().height, 2);
}
#[test]
fn hstack_renders_side_by_side() {
let mut r = Renderer::new(20);
let container = r.push(VStack);
let mut row = Elements::new();
row.add_element(TestTextEl::new(">"))
.width(WidthConstraint::Fixed(2));
row.add_element(TestTextEl::new("hello"));
let mut els = Elements::new();
els.hstack(row);
r.rebuild(container, els);
let frame = r.render();
let buf = frame.buffer();
assert_eq!(buf[(0, 0)].symbol(), ">");
assert_eq!(buf[(2, 0)].symbol(), "h");
assert_eq!(buf[(3, 0)].symbol(), "e");
}
#[test]
fn hstack_two_fill_columns() {
let mut r = Renderer::new(20);
let container = r.push(VStack);
let mut row = Elements::new();
row.add_element(TestTextEl::new("left"));
row.add_element(TestTextEl::new("right"));
let mut els = Elements::new();
els.hstack(row);
r.rebuild(container, els);
let frame = r.render();
let buf = frame.buffer();
assert_eq!(buf[(0, 0)].symbol(), "l");
assert_eq!(buf[(10, 0)].symbol(), "r");
}
#[test]
fn hstack_nested_in_vstack() {
let mut r = Renderer::new(20);
let container = r.push(VStack);
let mut els = Elements::new();
els.add_element(TestTextEl::new("above"));
let mut row = Elements::new();
row.add_element(TestTextEl::new("$"))
.width(WidthConstraint::Fixed(2));
row.add_element(TestTextEl::new("cmd"));
els.hstack(row);
els.add_element(TestTextEl::new("below"));
r.rebuild(container, els);
let frame = r.render();
assert_eq!(frame.area().height, 3);
let buf = frame.buffer();
assert_eq!(buf[(0, 0)].symbol(), "a"); assert_eq!(buf[(0, 1)].symbol(), "$"); assert_eq!(buf[(2, 1)].symbol(), "c"); assert_eq!(buf[(0, 2)].symbol(), "b"); }
#[test]
fn hstack_reconciliation_preserves_width() {
let mut r = Renderer::new(20);
let container = r.push(VStack);
let mut row = Elements::new();
row.add_element(TestTextEl::new(">"))
.width(WidthConstraint::Fixed(2));
row.add_element(TestTextEl::new("v1"));
let mut els = Elements::new();
els.hstack(row).key("row");
r.rebuild(container, els);
let frame1 = r.render();
assert_eq!(frame1.buffer()[(2, 0)].symbol(), "v");
let mut row = Elements::new();
row.add_element(TestTextEl::new("$"))
.width(WidthConstraint::Fixed(2));
row.add_element(TestTextEl::new("v2"));
let mut els = Elements::new();
els.hstack(row).key("row");
r.rebuild(container, els);
let frame2 = r.render();
assert_eq!(frame2.buffer()[(0, 0)].symbol(), "$");
assert_eq!(frame2.buffer()[(2, 0)].symbol(), "v"); }
use crate::insets::Insets;
struct PaddedBox;
impl Component for PaddedBox {
type State = Insets;
fn render(&self, area: Rect, buf: &mut Buffer, _state: &Self::State) {
if area.width > 0 && area.height > 0 {
buf[(area.x, area.y)].set_symbol("+");
if area.width > 1 {
buf[(area.x + area.width - 1, area.y)].set_symbol("+");
}
if area.height > 1 {
buf[(area.x, area.y + area.height - 1)].set_symbol("+");
if area.width > 1 {
buf[(area.x + area.width - 1, area.y + area.height - 1)].set_symbol("+");
}
}
}
}
fn content_inset(&self, state: &Self::State) -> Insets {
*state
}
fn initial_state(&self) -> Option<Insets> {
Some(Insets::ZERO)
}
}
#[test]
fn zero_insets_children_get_full_area() {
let mut r = Renderer::new(10);
let container = r.push(VStack);
let padded = r.append_child(container, PaddedBox);
let child = r.append_child(padded, TextBlock);
r.state_mut::<TextBlock>(child).push("hello".to_string());
let frame = r.render();
assert_eq!(frame.area().height, 1);
assert_eq!(frame.buffer()[(0, 0)].symbol(), "h"); }
#[test]
fn uniform_insets_shrink_child_area() {
let mut r = Renderer::new(20);
let container = r.push(VStack);
let padded = r.append_child(container, PaddedBox);
**r.state_mut::<PaddedBox>(padded) = Insets::all(1);
let child = r.append_child(padded, TextBlock);
r.state_mut::<TextBlock>(child).push("hello".to_string());
let frame = r.render();
assert_eq!(frame.area().height, 3);
let buf = frame.buffer();
assert_eq!(buf[(0, 0)].symbol(), "+");
assert_eq!(buf[(1, 1)].symbol(), "h");
assert_eq!(buf[(2, 1)].symbol(), "e");
}
#[test]
fn insets_reduce_inner_width() {
let mut r = Renderer::new(20);
let container = r.push(VStack);
let padded = r.append_child(container, PaddedBox);
**r.state_mut::<PaddedBox>(padded) = Insets::new().left(3).right(2);
let child = r.append_child(padded, TextBlock);
r.state_mut::<TextBlock>(child).push("hello".to_string());
let frame = r.render();
assert_eq!(frame.area().height, 1);
let buf = frame.buffer();
assert_eq!(buf[(3, 0)].symbol(), "h");
assert_eq!(buf[(4, 0)].symbol(), "e");
}
#[test]
fn insets_with_hstack_children() {
let mut r = Renderer::new(20);
let container = r.push(VStack);
let padded = r.append_child(container, PaddedBox);
**r.state_mut::<PaddedBox>(padded) = Insets::all(1);
r.set_layout(padded, Layout::Horizontal);
let child1 = r.append_child(padded, TextBlock);
r.state_mut::<TextBlock>(child1).push("L".to_string());
let child2 = r.append_child(padded, TextBlock);
r.state_mut::<TextBlock>(child2).push("R".to_string());
let frame = r.render();
assert_eq!(frame.area().height, 3);
let buf = frame.buffer();
assert_eq!(buf[(1, 1)].symbol(), "L");
assert_eq!(buf[(10, 1)].symbol(), "R");
}
#[test]
fn nested_insets() {
let mut r = Renderer::new(20);
let container = r.push(VStack);
let outer = r.append_child(container, PaddedBox);
**r.state_mut::<PaddedBox>(outer) = Insets::all(1);
let inner = r.append_child(outer, PaddedBox);
**r.state_mut::<PaddedBox>(inner) = Insets::all(1);
let child = r.append_child(inner, TextBlock);
r.state_mut::<TextBlock>(child).push("deep".to_string());
let frame = r.render();
assert_eq!(frame.area().height, 5);
let buf = frame.buffer();
assert_eq!(buf[(0, 0)].symbol(), "+");
assert_eq!(buf[(1, 1)].symbol(), "+");
assert_eq!(buf[(2, 2)].symbol(), "d");
}
struct LabeledRow;
#[derive(Default)]
struct LabeledRowState {
prefix: String,
label: String,
}
impl Component for LabeledRow {
type State = LabeledRowState;
fn render(&self, _area: Rect, _buf: &mut Buffer, _state: &Self::State) {}
fn initial_state(&self) -> Option<LabeledRowState> {
Some(LabeledRowState {
prefix: String::new(),
label: String::new(),
})
}
fn view(&self, state: &Self::State, _children: Elements) -> Elements {
let mut row = Elements::new();
row.add_element(TestTextEl::new(&state.prefix))
.width(WidthConstraint::Fixed(2));
row.add_element(TestTextEl::new(&state.label));
let mut els = Elements::new();
els.hstack(row);
els
}
}
struct LabeledRowEl {
prefix: String,
label: String,
}
impl LabeledRowEl {
fn new(prefix: &str, label: &str) -> Self {
Self {
prefix: prefix.to_string(),
label: label.to_string(),
}
}
}
impl Element for LabeledRowEl {
fn build(self: Box<Self>, renderer: &mut Renderer, parent: NodeId) -> NodeId {
let id = renderer.append_child(parent, LabeledRow);
renderer.set_layout(id, crate::node::Layout::Horizontal);
let state = renderer.state_mut::<LabeledRow>(id);
state.prefix = self.prefix;
state.label = self.label;
id
}
fn update(self: Box<Self>, renderer: &mut Renderer, node_id: NodeId) {
let state = renderer.state_mut::<LabeledRow>(node_id);
state.prefix = self.prefix;
state.label = self.label;
}
}
#[test]
fn composite_generates_own_children() {
let mut r = Renderer::new(20);
let container = r.push(VStack);
let mut els = Elements::new();
els.add_element(LabeledRowEl::new(">", "hello"));
r.rebuild(container, els);
let frame = r.render();
assert_eq!(frame.area().height, 1);
let buf = frame.buffer();
assert_eq!(buf[(0, 0)].symbol(), ">");
assert_eq!(buf[(2, 0)].symbol(), "h"); }
#[test]
fn composite_children_reconciled_across_rebuilds() {
let mut r = Renderer::new(20);
let container = r.push(VStack);
let mut els = Elements::new();
els.add_element(LabeledRowEl::new(">", "v1")).key("row");
r.rebuild(container, els);
let row_id = r.find_by_key(container, "row").unwrap();
let mut els = Elements::new();
els.add_element(LabeledRowEl::new("$", "v2")).key("row");
r.rebuild(container, els);
assert_eq!(r.find_by_key(container, "row").unwrap(), row_id);
let frame = r.render();
let buf = frame.buffer();
assert_eq!(buf[(0, 0)].symbol(), "$"); assert_eq!(buf[(2, 0)].symbol(), "v"); }
#[test]
fn default_passthrough_still_works() {
let mut r = Renderer::new(10);
let container = r.push(VStack);
let mut inner = Elements::new();
inner.add_element(TestTextEl::new("child1"));
inner.add_element(TestTextEl::new("child2"));
let mut els = Elements::new();
els.add_with_children(VStack, inner);
r.rebuild(container, els);
let frame = r.render();
assert_eq!(frame.area().height, 2);
assert_eq!(frame.buffer()[(0, 0)].symbol(), "c"); assert_eq!(frame.buffer()[(0, 1)].symbol(), "c"); }
struct BannerComponent;
impl Component for BannerComponent {
type State = String;
fn render(&self, _area: Rect, _buf: &mut Buffer, _state: &Self::State) {}
fn initial_state(&self) -> Option<String> {
Some(String::new())
}
fn view(&self, state: &Self::State, children: Elements) -> Elements {
let mut els = Elements::new();
els.add_element(TestTextEl::new(state));
for _entry in children.into_items() {
els.add_element(TestTextEl::new("slot"));
}
els
}
}
struct BannerEl {
title: String,
}
impl Element for BannerEl {
fn build(self: Box<Self>, renderer: &mut Renderer, parent: NodeId) -> NodeId {
let id = renderer.append_child(parent, BannerComponent);
let state = renderer.state_mut::<BannerComponent>(id);
state.push_str(&self.title);
id
}
fn update(self: Box<Self>, renderer: &mut Renderer, node_id: NodeId) {
let state = renderer.state_mut::<BannerComponent>(node_id);
state.clear();
state.push_str(&self.title);
}
}
#[test]
fn composite_wraps_slot_children() {
let mut r = Renderer::new(20);
let container = r.push(VStack);
let mut slot = Elements::new();
slot.add_element(TestTextEl::new("content"));
let mut els = Elements::new();
els.add_element_with_children(
BannerEl {
title: "TITLE".into(),
},
slot,
);
r.rebuild(container, els);
let frame = r.render();
assert_eq!(frame.area().height, 2);
assert_eq!(frame.buffer()[(0, 0)].symbol(), "T"); assert_eq!(frame.buffer()[(0, 1)].symbol(), "s"); }
#[test]
fn nested_composites() {
let mut r = Renderer::new(20);
let container = r.push(VStack);
let mut els = Elements::new();
els.add_element(TestTextEl::new("above"));
els.add_element(LabeledRowEl::new(">", "nested"));
els.add_element(TestTextEl::new("below"));
r.rebuild(container, els);
let frame = r.render();
assert_eq!(frame.area().height, 3);
let buf = frame.buffer();
assert_eq!(buf[(0, 0)].symbol(), "a"); assert_eq!(buf[(0, 1)].symbol(), ">"); assert_eq!(buf[(2, 1)].symbol(), "n"); assert_eq!(buf[(0, 2)].symbol(), "b"); }
use crate::hooks::Hooks;
struct HookedCounter;
impl Component for HookedCounter {
type State = (String, usize);
fn render(&self, area: Rect, buf: &mut Buffer, state: &Self::State) {
let line = ratatui_core::text::Line::raw(state.0.as_str());
ratatui_core::widgets::Widget::render(Paragraph::new(line), area, buf);
}
fn initial_state(&self) -> Option<(String, usize)> {
Some((String::new(), 0))
}
fn lifecycle(&self, hooks: &mut Hooks<Self, (String, usize)>, state: &(String, usize)) {
if state.0 != "stop" {
hooks.use_interval(Duration::from_millis(1), |_props, s| {
s.1 += 1;
});
}
}
}
struct HookedCounterEl {
label: String,
}
impl Element for HookedCounterEl {
fn build(self: Box<Self>, renderer: &mut Renderer, parent: NodeId) -> NodeId {
let id = renderer.append_child(parent, HookedCounter);
let state = renderer.state_mut::<HookedCounter>(id);
state.0 = self.label;
id
}
fn update(self: Box<Self>, renderer: &mut Renderer, node_id: NodeId) {
let state = renderer.state_mut::<HookedCounter>(node_id);
state.0 = self.label;
}
}
#[test]
fn lifecycle_registers_effects_on_build() {
let mut r = Renderer::new(10);
let container = r.push(VStack);
let mut els = Elements::new();
els.add_element(HookedCounterEl {
label: "active".into(),
});
r.rebuild(container, els);
assert!(r.has_active());
}
#[test]
fn lifecycle_clears_effects_on_update() {
let mut r = Renderer::new(10);
let container = r.push(VStack);
let mut els = Elements::new();
els.add_element(HookedCounterEl {
label: "active".into(),
})
.key("c");
r.rebuild(container, els);
assert!(r.has_active());
let mut els = Elements::new();
els.add_element(HookedCounterEl {
label: "stop".into(),
})
.key("c");
r.rebuild(container, els);
assert!(!r.has_active());
}
#[test]
fn lifecycle_interval_fires() {
let mut r = Renderer::new(10);
let container = r.push(VStack);
let mut els = Elements::new();
els.add_element(HookedCounterEl { label: "go".into() })
.key("c");
r.rebuild(container, els);
let id = r.find_by_key(container, "c").unwrap();
assert_eq!(r.state_mut::<HookedCounter>(id).1, 0);
std::thread::sleep(Duration::from_millis(5));
r.tick();
assert!(r.state_mut::<HookedCounter>(id).1 > 0); }
#[test]
fn conditional_effect_matches_by_call_site_not_position() {
struct ShiftTest;
impl Component for ShiftTest {
type State = (String, usize, usize);
fn render(&self, _area: Rect, _buf: &mut Buffer, _state: &Self::State) {}
fn initial_state(&self) -> Option<Self::State> {
Some(("both".into(), 0, 0))
}
fn lifecycle(&self, hooks: &mut Hooks<Self, Self::State>, state: &Self::State) {
if state.0 == "both" {
hooks.use_interval(Duration::from_secs(60), |_props, s| {
s.1 += 1;
});
}
hooks.use_interval(Duration::from_millis(1), |_props, s| {
s.2 += 1;
});
}
}
struct ShiftTestEl {
mode: String,
}
impl Element for ShiftTestEl {
fn build(self: Box<Self>, renderer: &mut Renderer, parent: NodeId) -> NodeId {
let id = renderer.append_child(parent, ShiftTest);
renderer.state_mut::<ShiftTest>(id).0 = self.mode;
id
}
fn update(self: Box<Self>, renderer: &mut Renderer, node_id: NodeId) {
renderer.state_mut::<ShiftTest>(node_id).0 = self.mode;
}
}
let mut r = Renderer::new(10);
let container = r.push(VStack);
let mut els = Elements::new();
els.add_element(ShiftTestEl {
mode: "both".into(),
})
.key("c");
r.rebuild(container, els);
std::thread::sleep(Duration::from_millis(10));
r.tick();
let id = r.find_by_key(container, "c").unwrap();
let fast_count_after_tick = r.state_mut::<ShiftTest>(id).2;
assert!(fast_count_after_tick > 0, "fast interval should have fired");
let mut els = Elements::new();
els.add_element(ShiftTestEl { mode: "one".into() }).key("c");
r.rebuild(container, els);
r.tick();
assert_eq!(
r.state_mut::<ShiftTest>(id).2,
fast_count_after_tick,
"fast interval should NOT have fired again immediately after rebuild — \
with call-site matching it keeps its own recent last_tick, \
not the slow interval's stale one from position 0"
);
}
#[test]
fn lifecycle_with_mount_and_unmount() {
use std::sync::{Arc, Mutex};
let mut r = Renderer::new(10);
let container = r.push(VStack);
struct MountTracker;
impl Component for MountTracker {
type State = Vec<String>;
fn render(&self, area: Rect, buf: &mut Buffer, state: &Self::State) {
let text = state.join(",");
let line = ratatui_core::text::Line::raw(text);
ratatui_core::widgets::Widget::render(Paragraph::new(line), area, buf);
}
fn initial_state(&self) -> Option<Vec<String>> {
Some(Vec::new())
}
fn lifecycle(&self, hooks: &mut Hooks<Self, Vec<String>>, _state: &Vec<String>) {
hooks.use_mount(|_props, s| s.push("mounted".into()));
hooks.use_unmount(|_props, s| s.push("unmounted".into()));
}
}
struct MountTrackerEl;
impl Element for MountTrackerEl {
fn build(self: Box<Self>, renderer: &mut Renderer, parent: NodeId) -> NodeId {
let id = renderer.append_child(parent, MountTracker);
renderer.state_mut::<MountTracker>(id).push("built".into());
id
}
fn update(self: Box<Self>, _renderer: &mut Renderer, _node_id: NodeId) {}
}
let mut els = Elements::new();
els.add_element(MountTrackerEl).key("mt");
r.rebuild(container, els);
let id = r.find_by_key(container, "mt").unwrap();
let state = r.state_mut::<MountTracker>(id);
assert!(state.contains(&"built".to_string()));
assert!(state.contains(&"mounted".to_string()));
let unmount_log: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let log_clone = unmount_log.clone();
r.on_unmount::<MountTracker>(id, move |_state| {
log_clone.lock().unwrap().push("unmounted".to_string());
});
r.rebuild(container, Elements::new());
let entries = unmount_log.lock().unwrap();
assert!(entries.contains(&"unmounted".to_string()));
}
struct ContextProvider {
value: String,
}
impl Component for ContextProvider {
type State = ();
fn render(&self, _area: Rect, _buf: &mut Buffer, _state: &()) {}
fn lifecycle(&self, hooks: &mut crate::hooks::Hooks<Self, ()>, _state: &()) {
hooks.provide_context(self.value.clone());
}
}
crate::impl_slot_children!(ContextProvider);
struct ContextConsumer;
#[derive(Default)]
struct ContextConsumerState {
received: Option<String>,
}
impl Component for ContextConsumer {
type State = ContextConsumerState;
fn render(&self, area: Rect, buf: &mut Buffer, state: &Self::State) {
if let Some(ref val) = state.received {
let text: Vec<Line> = vec![Line::raw(val.as_str())];
let para = Paragraph::new(text);
ratatui_core::widgets::Widget::render(para, area, buf);
}
}
fn lifecycle(
&self,
hooks: &mut crate::hooks::Hooks<Self, Self::State>,
_state: &Self::State,
) {
hooks.use_context::<String>(|value, _props, state| {
state.received = value.cloned();
});
}
}
#[test]
fn context_provider_to_consumer() {
let mut r = Renderer::new(20);
let container = r.push(VStack);
let mut els = Elements::new();
let mut children = Elements::new();
children.add(ContextConsumer).key("consumer");
els.add_with_children(
ContextProvider {
value: "hello from context".into(),
},
children,
)
.key("provider");
r.rebuild(container, els);
let consumer_id = r.find_by_key(container, "provider").unwrap();
let inner_consumer = r
.children(consumer_id)
.iter()
.find(|&&id| r.node_key(id) == Some("consumer"))
.copied()
.unwrap();
let state = r.state_mut::<ContextConsumer>(inner_consumer);
assert_eq!(state.received.as_deref(), Some("hello from context"));
}
#[test]
fn context_absent_passes_none() {
let mut r = Renderer::new(20);
let container = r.push(VStack);
let mut els = Elements::new();
els.add(ContextConsumer).key("consumer");
r.rebuild(container, els);
let consumer_id = r.find_by_key(container, "consumer").unwrap();
let state = r.state_mut::<ContextConsumer>(consumer_id);
assert_eq!(state.received, None);
}
#[test]
fn context_shadowing() {
let mut r = Renderer::new(20);
let container = r.push(VStack);
let mut inner_children = Elements::new();
inner_children.add(ContextConsumer).key("inner-consumer");
let mut inner_provider_els = Elements::new();
inner_provider_els
.add_with_children(
ContextProvider {
value: "inner".into(),
},
inner_children,
)
.key("inner-provider");
let mut outer_children = Elements::new();
outer_children.splice(inner_provider_els);
outer_children.add(ContextConsumer).key("outer-consumer");
let mut els = Elements::new();
els.add_with_children(
ContextProvider {
value: "outer".into(),
},
outer_children,
)
.key("outer-provider");
r.rebuild(container, els);
let outer_provider_id = r.find_by_key(container, "outer-provider").unwrap();
let inner_provider_id = r.find_by_key(outer_provider_id, "inner-provider").unwrap();
let inner_consumer_id = r.find_by_key(inner_provider_id, "inner-consumer").unwrap();
let state = r.state_mut::<ContextConsumer>(inner_consumer_id);
assert_eq!(state.received.as_deref(), Some("inner"));
let outer_consumer_id = r.find_by_key(outer_provider_id, "outer-consumer").unwrap();
let state = r.state_mut::<ContextConsumer>(outer_consumer_id);
assert_eq!(state.received.as_deref(), Some("outer"));
}
#[test]
fn root_context_available_to_all() {
let mut r = Renderer::new(20);
r.set_root_context("root-value".to_string());
let container = r.push(VStack);
let mut els = Elements::new();
els.add(ContextConsumer).key("consumer");
r.rebuild(container, els);
let consumer_id = r.find_by_key(container, "consumer").unwrap();
let state = r.state_mut::<ContextConsumer>(consumer_id);
assert_eq!(state.received.as_deref(), Some("root-value"));
}
#[test]
fn context_updates_on_rebuild() {
let mut r = Renderer::new(20);
let container = r.push(VStack);
let mut els = Elements::new();
let mut children = Elements::new();
children.add(ContextConsumer).key("consumer");
els.add_with_children(ContextProvider { value: "v1".into() }, children)
.key("provider");
r.rebuild(container, els);
let provider_id = r.find_by_key(container, "provider").unwrap();
let consumer_id = r.find_by_key(provider_id, "consumer").unwrap();
let state = r.state_mut::<ContextConsumer>(consumer_id);
assert_eq!(state.received.as_deref(), Some("v1"));
let mut els = Elements::new();
let mut children = Elements::new();
children.add(ContextConsumer).key("consumer");
els.add_with_children(ContextProvider { value: "v2".into() }, children)
.key("provider");
r.rebuild(container, els);
let provider_id = r.find_by_key(container, "provider").unwrap();
let consumer_id = r.find_by_key(provider_id, "consumer").unwrap();
let state = r.state_mut::<ContextConsumer>(consumer_id);
assert_eq!(state.received.as_deref(), Some("v2"));
}
#[test]
fn view_component_renders_via_canvas() {
use crate::components::canvas::Canvas;
#[derive(Default)]
struct MyComp;
impl Component for MyComp {
type State = ();
fn view(&self, _state: &(), _children: Elements) -> Elements {
let mut els = Elements::new();
els.add(Canvas::new(|area: Rect, buf: &mut Buffer| {
let para = Paragraph::new("hello view");
ratatui_core::widgets::Widget::render(para, area, buf);
}));
els
}
}
let mut r = Renderer::new(20);
let root = r.root();
let mut els = Elements::new();
els.add(MyComp);
r.rebuild(root, els);
let frame = r.render();
assert_eq!(frame.buffer()[(0, 0)].symbol(), "h"); assert_eq!(frame.buffer()[(5, 0)].symbol(), " ");
assert_eq!(frame.buffer()[(6, 0)].symbol(), "v"); }
#[test]
fn view_component_with_slot_children() {
use crate::components::view::View;
#[derive(Default)]
struct Card {
title: String,
}
impl Component for Card {
type State = ();
fn view(&self, _state: &(), children: Elements) -> Elements {
let mut els = Elements::new();
els.add_with_children(
View {
border: Some(ratatui_widgets::borders::BorderType::Plain),
title: Some(self.title.clone()),
..View::default()
},
children,
);
els
}
}
crate::impl_slot_children!(Card);
let mut r = Renderer::new(30);
let root = r.root();
let mut els = Elements::new();
let mut card_children = Elements::new();
card_children.add(crate::components::text::Text::unstyled("inside"));
els.add_with_children(
Card {
title: "My Card".into(),
},
card_children,
);
r.rebuild(root, els);
let frame = r.render();
assert_eq!(frame.buffer()[(0, 0)].symbol(), "┌");
assert_eq!(frame.buffer()[(1, 0)].symbol(), "M"); assert_eq!(frame.buffer()[(1, 1)].symbol(), "i"); }
#[test]
fn view_component_renders_view_children() {
#[derive(Default)]
struct Transparent;
impl Component for Transparent {
type State = ();
fn view(&self, _state: &(), _children: Elements) -> Elements {
let mut els = Elements::new();
els.add(crate::components::text::Text::unstyled("from view"));
els
}
}
let mut r = Renderer::new(30);
let root = r.root();
let mut els = Elements::new();
els.add(Transparent);
r.rebuild(root, els);
let frame = r.render();
assert_eq!(frame.buffer()[(0, 0)].symbol(), "f"); }
#[test]
fn view_component_reconciles_on_rebuild() {
use crate::components::canvas::Canvas;
#[derive(Default)]
struct Counter {
label: String,
}
impl Component for Counter {
type State = ();
fn view(&self, _state: &(), _children: Elements) -> Elements {
let label = self.label.clone();
let mut els = Elements::new();
els.add(Canvas::new(move |area: Rect, buf: &mut Buffer| {
let para = Paragraph::new(label.as_str());
ratatui_core::widgets::Widget::render(para, area, buf);
}));
els
}
}
let mut r = Renderer::new(20);
let root = r.root();
let mut els = Elements::new();
els.add(Counter {
label: "count: 0".into(),
})
.key("counter");
r.rebuild(root, els);
let frame = r.render();
assert_eq!(frame.buffer()[(7, 0)].symbol(), "0");
let mut els = Elements::new();
els.add(Counter {
label: "count: 1".into(),
})
.key("counter");
r.rebuild(root, els);
let frame = r.render();
assert_eq!(frame.buffer()[(7, 0)].symbol(), "1"); }
#[test]
fn view_component_empty_elements_produces_zero_height() {
#[derive(Default)]
struct Empty;
impl Component for Empty {
type State = ();
fn view(&self, _state: &(), _children: Elements) -> Elements {
Elements::new()
}
}
let mut r = Renderer::new(20);
let root = r.root();
let mut els = Elements::new();
els.add(Empty);
r.rebuild(root, els);
let frame = r.render();
assert_eq!(frame.buffer().area.height, 0);
}
#[test]
fn legacy_components_still_work_with_view_on_trait() {
#[derive(Default)]
struct OldSchool;
impl Component for OldSchool {
type State = ();
fn render(&self, area: Rect, buf: &mut Buffer, _state: &()) {
let para = Paragraph::new("old school");
ratatui_core::widgets::Widget::render(para, area, buf);
}
}
let mut r = Renderer::new(20);
let root = r.root();
r.append_child(root, OldSchool);
let frame = r.render();
assert_eq!(frame.buffer()[(0, 0)].symbol(), "o"); }
}