textual_rs/widget/mod.rs
1//! Widget trait, widget ID type, and all built-in widget implementations.
2// Widget documentation is deferred to plan 10-04 — suppress the lint here until then.
3#![allow(missing_docs)]
4pub mod button;
5pub mod checkbox;
6pub mod screen;
7pub mod collapsible;
8pub mod context;
9pub mod context_menu;
10pub mod data_table;
11pub mod directory_tree;
12pub mod footer;
13pub mod header;
14pub mod input;
15pub mod label;
16pub mod layout;
17pub mod list_view;
18pub mod loading_indicator;
19pub mod log;
20pub mod markdown;
21pub mod masked_input;
22pub mod placeholder;
23pub mod progress_bar;
24pub mod radio;
25pub mod rich_log;
26pub mod scroll_region;
27pub mod scroll_view;
28pub mod select;
29pub mod sparkline;
30pub mod switch;
31pub mod tabs;
32pub mod text_area;
33pub mod toast;
34pub mod tree;
35pub mod tree_view;
36
37use crate::event::keybinding::KeyBinding;
38use context::AppContext;
39use ratatui::buffer::Buffer;
40use ratatui::layout::Rect;
41use slotmap::new_key_type;
42
43/// Unique identifier for a widget in the arena (slotmap generational index).
44/// Passed to `on_mount`, `on_action`, `post_message`, and `run_worker`.
45new_key_type! { pub struct WidgetId; }
46
47/// Controls whether an event continues bubbling up the widget tree after being handled.
48///
49/// Return `Stop` from `on_event` to consume the event and prevent parent widgets
50/// from seeing it. Return `Continue` to let it bubble further.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum EventPropagation {
53 /// Keep bubbling — parent widgets will also receive this event.
54 Continue,
55 /// Stop bubbling — this widget consumed the event.
56 Stop,
57}
58
59/// Core trait implemented by every UI node in textual-rs.
60///
61/// Widgets form a tree: App > Screen > Widget hierarchy. The framework
62/// manages mounting, layout, rendering, and event dispatch.
63///
64/// # Minimal implementation
65///
66/// ```no_run
67/// # use textual_rs::Widget;
68/// # use textual_rs::widget::context::AppContext;
69/// # use ratatui::{buffer::Buffer, layout::Rect};
70/// struct MyWidget;
71///
72/// impl Widget for MyWidget {
73/// fn widget_type_name(&self) -> &'static str { "MyWidget" }
74/// fn render(&self, _ctx: &AppContext, area: Rect, buf: &mut Buffer) {
75/// buf.set_string(area.x, area.y, "Hello!", ratatui::style::Style::default());
76/// }
77/// }
78/// ```
79pub trait Widget: 'static {
80 /// Paint this widget's content into the terminal buffer.
81 ///
82 /// Called every render frame by the framework. Only draw inside `area` —
83 /// it is pre-clipped to the widget's computed layout rectangle.
84 ///
85 /// Use `ctx.text_style(id)` to get the CSS-computed fg/bg style.
86 /// Use `get_untracked()` on reactive values to avoid tracking loops.
87 fn render(&self, ctx: &AppContext, area: Rect, buf: &mut Buffer);
88
89 /// Declare child widgets. Called once at mount time to build the widget tree.
90 ///
91 /// Return a `Vec<Box<dyn Widget>>` of children. The framework inserts them
92 /// into the arena and lays them out according to CSS rules.
93 /// Container widgets typically implement this; leaf widgets return `vec![]`.
94 fn compose(&self) -> Vec<Box<dyn Widget>> {
95 vec![]
96 }
97
98 /// Called when this widget is inserted into the widget tree.
99 ///
100 /// Use this to store `own_id` for later use in `on_action` or `post_message`.
101 fn on_mount(&self, _id: WidgetId) {}
102
103 /// Called when this widget is removed from the widget tree.
104 ///
105 /// Use this to clear stored `own_id` and release resources.
106 fn on_unmount(&self, _id: WidgetId) {}
107
108 /// Whether this widget participates in Tab-based focus cycling.
109 ///
110 /// Returns `false` by default. Override to return `true` for interactive widgets.
111 /// When focused, `key_bindings()` are active and a focus indicator is rendered.
112 fn can_focus(&self) -> bool {
113 false
114 }
115
116 /// Whether this screen blocks all keyboard and mouse input to screens beneath it.
117 ///
118 /// Returns `false` by default. Implement `is_modal() -> bool { true }` on any
119 /// screen widget to make it behave as a modal dialog. See also [`screen::ModalScreen`].
120 fn is_modal(&self) -> bool {
121 false
122 }
123
124 /// The CSS type selector name for this widget (e.g., `"Button"`, `"Input"`).
125 ///
126 /// Used by the CSS engine to match style rules: `Button { color: red; }`.
127 /// Must be unique per widget type. Convention: PascalCase matching the struct name.
128 fn widget_type_name(&self) -> &'static str;
129
130 /// CSS class names applied to this widget instance (e.g., `&["primary", "active"]`).
131 ///
132 /// Used for class selector rules: `.primary { background: green; }`.
133 fn classes(&self) -> &[&str] {
134 &[]
135 }
136
137 /// Element ID for this widget instance (used for `#id` CSS selectors).
138 ///
139 /// Returns `None` by default. Override to return a unique string ID.
140 fn id(&self) -> Option<&str> {
141 None
142 }
143
144 /// Built-in default CSS for this widget type (static version).
145 ///
146 /// Applied at lowest priority (before user stylesheets). Override to provide
147 /// sensible defaults like `"Button { border: heavy; height: 3; }"`.
148 fn default_css() -> &'static str
149 where
150 Self: Sized,
151 {
152 ""
153 }
154
155 /// Instance-callable version of `default_css()`. Override this alongside
156 /// `default_css()` to return the same value — this version is callable on
157 /// `dyn Widget` and used by the framework to collect default styles at mount time.
158 fn widget_default_css(&self) -> &'static str {
159 ""
160 }
161
162 /// Handle a dispatched event/message. Downcast to concrete types to handle.
163 ///
164 /// Called by the framework when an event is dispatched to this widget or bubbled
165 /// up from a child. Use `downcast_ref::<T>()` to match specific message types.
166 ///
167 /// Return `EventPropagation::Stop` to consume the event (stops bubbling).
168 /// Return `EventPropagation::Continue` to let it keep bubbling to parents.
169 fn on_event(&self, _event: &dyn std::any::Any, _ctx: &AppContext) -> EventPropagation {
170 EventPropagation::Continue
171 }
172
173 /// Declare key bindings for this widget.
174 ///
175 /// Bindings are checked when this widget has focus and a key event arrives.
176 /// Each `KeyBinding` maps a key+modifier combo to an action string.
177 /// Set `show: true` to display the binding in the Footer and command palette.
178 fn key_bindings(&self) -> &[KeyBinding] {
179 &[]
180 }
181
182 /// Handle a key binding action. Called when a key matching a binding is pressed.
183 ///
184 /// The `action` string matches the `action` field of the triggered `KeyBinding`.
185 /// Widget state must be mutated via `Cell<T>` or `Reactive<T>` since this takes `&self`.
186 fn on_action(&self, _action: &str, _ctx: &AppContext) {}
187
188 /// Override the border color for this widget based on internal state.
189 ///
190 /// Returns `Some((r, g, b))` when the widget wants to override its CSS border color
191 /// (e.g., Input with invalid content shows a red border). Returns `None` by default.
192 fn border_color_override(&self) -> Option<(u8, u8, u8)> {
193 None
194 }
195
196 /// Whether this widget is a transparent overlay (context menu, tooltip, etc.).
197 /// Overlay widgets skip paint_chrome (no background fill, no border from CSS)
198 /// and paint their own chrome in render(). This prevents overlays from
199 /// erasing the underlying screen content.
200 fn is_overlay(&self) -> bool {
201 false
202 }
203
204 /// Return context menu items for right-click. Empty vec = no context menu.
205 /// Override to provide widget-specific menu items.
206 fn context_menu_items(&self) -> Vec<context_menu::ContextMenuItem> {
207 Vec::new()
208 }
209
210 /// Return the action to trigger on mouse click, if any.
211 ///
212 /// Widgets that should activate on click (e.g. buttons, checkboxes, switches)
213 /// override this to return the same action string their Space/Enter key binding uses.
214 /// The framework calls `on_action(click_action, ctx)` after click-to-focus.
215 fn click_action(&self) -> Option<&str> {
216 None
217 }
218
219 /// Whether this widget currently has selected text.
220 ///
221 /// Used by the app event loop to route Ctrl+C to copy instead of quit
222 /// when a text widget has an active selection. Returns `false` by default.
223 fn has_text_selection(&self) -> bool {
224 false
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231 use crate::widget::context::AppContext;
232
233 /// A minimal widget for testing object-safety and arena operations
234 struct TestWidget {
235 focusable: bool,
236 }
237
238 impl Widget for TestWidget {
239 fn render(&self, _ctx: &AppContext, _area: Rect, _buf: &mut Buffer) {}
240 fn widget_type_name(&self) -> &'static str {
241 "TestWidget"
242 }
243 fn can_focus(&self) -> bool {
244 self.focusable
245 }
246 }
247
248 #[test]
249 fn app_context_new_creates_empty_arena() {
250 let ctx = AppContext::new();
251 assert_eq!(ctx.arena.len(), 0);
252 assert!(ctx.focused_widget.is_none());
253 assert!(ctx.screen_stack.is_empty());
254 }
255
256 #[test]
257 fn arena_insert_retrieve_remove() {
258 let mut ctx = AppContext::new();
259 let widget: Box<dyn Widget> = Box::new(TestWidget { focusable: false });
260
261 // Insert into arena
262 let id = ctx.arena.insert(widget);
263
264 // Retrieve by WidgetId
265 assert!(ctx.arena.contains_key(id));
266 assert_eq!(ctx.arena[id].widget_type_name(), "TestWidget");
267
268 // Remove
269 let removed = ctx.arena.remove(id);
270 assert!(removed.is_some());
271 assert!(!ctx.arena.contains_key(id));
272 }
273
274 #[test]
275 fn widget_is_object_safe_stored_as_box() {
276 // This test verifies Box<dyn Widget> compiles (object-safety check)
277 let widgets: Vec<Box<dyn Widget>> = vec![
278 Box::new(TestWidget { focusable: false }),
279 Box::new(TestWidget { focusable: true }),
280 ];
281 assert_eq!(widgets.len(), 2);
282 assert!(!widgets[0].can_focus());
283 assert!(widgets[1].can_focus());
284 }
285}