Skip to main content

textual_rs/widget/
button.rs

1//! Focusable button widget that emits a message when activated.
2use crossterm::event::{KeyCode, KeyModifiers};
3use ratatui::buffer::Buffer;
4use ratatui::layout::Rect;
5use std::cell::Cell;
6
7use super::context::AppContext;
8use super::{EventPropagation, Widget, WidgetId};
9use crate::event::keybinding::KeyBinding;
10
11/// Visual variant of a Button — affects border/text color.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum ButtonVariant {
14    /// Standard button with default theme colors.
15    #[default]
16    Default,
17    /// Primary action button, styled with the primary theme color.
18    Primary,
19    /// Warning button, styled with the warning theme color.
20    Warning,
21    /// Error/destructive button, styled with the error theme color.
22    Error,
23    /// Success/confirmation button, styled with a green accent color.
24    Success,
25}
26
27/// Messages emitted by a Button.
28pub mod messages {
29    use crate::event::message::Message;
30
31    /// Emitted when the button is pressed (Enter or Space key).
32    /// `label` carries the button's label so handlers can identify which button fired.
33    pub struct Pressed {
34        /// The label text of the button that was pressed.
35        pub label: String,
36    }
37
38    impl Message for Pressed {}
39}
40
41/// A focusable button widget that emits `messages::Pressed` on Enter/Space/click.
42///
43/// Optionally dispatches a named action to the parent widget via `with_action()`,
44/// enabling mouse clicks to trigger the same `on_action()` handlers as keybindings.
45pub struct Button {
46    /// Text displayed in the center of the button.
47    pub label: String,
48    /// Visual style variant controlling border and text colors.
49    pub variant: ButtonVariant,
50    /// Optional action name dispatched to parent on press (keyboard or mouse).
51    /// When set, the parent's `on_action(name, ctx)` is called in addition to
52    /// the `messages::Pressed` message. This unifies keyboard and mouse handling.
53    pub action_name: Option<String>,
54    own_id: Cell<Option<WidgetId>>,
55    /// Single-frame pressed state: set true on press action, cleared after render.
56    pressed: Cell<bool>,
57}
58
59impl Button {
60    /// Create a new button with the given label and the default variant.
61    pub fn new(label: impl Into<String>) -> Self {
62        Self {
63            label: label.into(),
64            variant: ButtonVariant::Default,
65            action_name: None,
66            own_id: Cell::new(None),
67            pressed: Cell::new(false),
68        }
69    }
70
71    /// Set the visual variant on this button.
72    pub fn with_variant(mut self, variant: ButtonVariant) -> Self {
73        self.variant = variant;
74        self
75    }
76
77    /// Set an action name dispatched to the parent widget on press.
78    /// This enables mouse clicks to trigger the same `on_action()` handlers as keybindings.
79    pub fn with_action(mut self, action: impl Into<String>) -> Self {
80        self.action_name = Some(action.into());
81        self
82    }
83}
84
85static BUTTON_BINDINGS: &[KeyBinding] = &[
86    KeyBinding {
87        key: KeyCode::Enter,
88        modifiers: KeyModifiers::NONE,
89        action: "press",
90        description: "Press",
91        show: false,
92    },
93    KeyBinding {
94        key: KeyCode::Char(' '),
95        modifiers: KeyModifiers::NONE,
96        action: "press",
97        description: "Press",
98        show: false,
99    },
100];
101
102impl Widget for Button {
103    fn widget_type_name(&self) -> &'static str {
104        "Button"
105    }
106
107    fn can_focus(&self) -> bool {
108        true
109    }
110
111    fn classes(&self) -> &[&str] {
112        match self.variant {
113            ButtonVariant::Primary => &["primary"],
114            ButtonVariant::Warning => &["warning"],
115            ButtonVariant::Error => &["error"],
116            ButtonVariant::Success => &["success"],
117            ButtonVariant::Default => &[],
118        }
119    }
120
121    fn default_css() -> &'static str
122    where
123        Self: Sized,
124    {
125        "Button { border: inner; min-width: 16; height: 3; min-height: 3; }"
126    }
127
128    fn on_mount(&self, id: WidgetId) {
129        self.own_id.set(Some(id));
130    }
131
132    fn on_unmount(&self, _id: WidgetId) {
133        self.own_id.set(None);
134    }
135
136    fn key_bindings(&self) -> &[KeyBinding] {
137        BUTTON_BINDINGS
138    }
139
140    fn on_event(&self, event: &dyn std::any::Any, ctx: &AppContext) -> EventPropagation {
141        use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
142        if let Some(m) = event.downcast_ref::<MouseEvent>() {
143            if matches!(m.kind, MouseEventKind::Down(MouseButton::Left)) {
144                self.on_action("press", ctx);
145                return EventPropagation::Stop;
146            }
147        }
148        EventPropagation::Continue
149    }
150
151    fn on_action(&self, action: &str, ctx: &AppContext) {
152        if action == "press" {
153            self.pressed.set(true);
154            if let Some(id) = self.own_id.get() {
155                ctx.post_message(
156                    id,
157                    messages::Pressed {
158                        label: self.label.clone(),
159                    },
160                );
161                // If an action name is set, dispatch it to the active screen.
162                // The screen is the top-level widget that handles navigation actions.
163                // This unifies keyboard and mouse: both paths trigger the same handler.
164                if let Some(ref action_name) = self.action_name {
165                    if let Some(&screen_id) = ctx.screen_stack.last() {
166                        if let Some(screen) = ctx.arena.get(screen_id) {
167                            screen.on_action(action_name, ctx);
168                        }
169                    }
170                }
171            }
172        }
173    }
174
175    fn render(&self, ctx: &AppContext, area: Rect, buf: &mut Buffer) {
176        use ratatui::style::Modifier;
177
178        if area.height == 0 || area.width == 0 {
179            return;
180        }
181        let base_style = self
182            .own_id
183            .get()
184            .map(|id| ctx.text_style(id))
185            .unwrap_or_default();
186
187        let is_pressed = self.pressed.get();
188
189        // Align label according to text-align CSS property (default: center)
190        let text_align = self
191            .own_id
192            .get()
193            .and_then(|id| ctx.computed_styles.get(id))
194            .map(|cs| cs.text_align)
195            .unwrap_or(crate::css::types::TextAlign::Center);
196        let label_len = self.label.chars().count() as u16;
197        let x = match text_align {
198            crate::css::types::TextAlign::Center => {
199                if area.width > label_len {
200                    area.x + (area.width - label_len) / 2
201                } else {
202                    area.x
203                }
204            }
205            crate::css::types::TextAlign::Right => {
206                if area.width > label_len {
207                    area.x + area.width - label_len
208                } else {
209                    area.x
210                }
211            }
212            crate::css::types::TextAlign::Left => area.x,
213        };
214        let y = if area.height > 1 {
215            area.y + area.height / 2
216        } else {
217            area.y
218        };
219        let display: String = self.label.chars().take(area.width as usize).collect();
220        let label_style = if is_pressed {
221            // Single-frame "flash" — invert the label style for pressed feedback
222            self.pressed.set(false);
223            base_style.add_modifier(Modifier::BOLD | Modifier::REVERSED)
224        } else {
225            base_style.add_modifier(Modifier::BOLD)
226        };
227        buf.set_string(x, y, &display, label_style);
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::widget::context::AppContext;
235    use crate::widget::Widget;
236    use ratatui::buffer::Buffer;
237    use ratatui::layout::Rect;
238    use ratatui::style::Color;
239
240    /// Helper: create a buffer pre-filled with a given background color.
241    fn buf_with_bg(area: Rect, bg: Color) -> Buffer {
242        let mut buf = Buffer::empty(area);
243        for y in area.y..area.y + area.height {
244            for x in area.x..area.x + area.width {
245                if let Some(cell) = buf.cell_mut((x, y)) {
246                    cell.set_bg(bg);
247                }
248            }
249        }
250        buf
251    }
252
253    #[test]
254    fn button_renders_label_centered() {
255        let bg = Color::Rgb(42, 42, 62);
256        let area = Rect::new(0, 0, 16, 3);
257        let mut buf = buf_with_bg(area, bg);
258        let ctx = AppContext::new();
259        let button = Button::new("OK");
260        button.render(&ctx, area, &mut buf);
261
262        // Middle row should contain "OK" somewhere
263        let row: String = (0..16u16)
264            .map(|x| buf[(x, 1)].symbol().to_string())
265            .collect();
266        assert!(
267            row.contains("OK"),
268            "Button label should be rendered, got: {:?}",
269            row.trim()
270        );
271    }
272}