Skip to main content

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 collapsible;
7pub mod context;
8pub mod context_menu;
9pub mod data_table;
10pub mod directory_tree;
11pub mod footer;
12pub mod header;
13pub mod input;
14pub mod label;
15pub mod layout;
16pub mod list_view;
17pub mod loading_indicator;
18pub mod log;
19pub mod markdown;
20pub mod masked_input;
21pub mod placeholder;
22pub mod progress_bar;
23pub mod radio;
24pub mod rich_log;
25pub mod screen;
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}