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.
42pub struct Button {
43    /// Text displayed in the center of the button.
44    pub label: String,
45    /// Visual style variant controlling border and text colors.
46    pub variant: ButtonVariant,
47    own_id: Cell<Option<WidgetId>>,
48    /// Single-frame pressed state: set true on press action, cleared after render.
49    pressed: Cell<bool>,
50}
51
52impl Button {
53    /// Create a new button with the given label and the default variant.
54    pub fn new(label: impl Into<String>) -> Self {
55        Self {
56            label: label.into(),
57            variant: ButtonVariant::Default,
58            own_id: Cell::new(None),
59            pressed: Cell::new(false),
60        }
61    }
62
63    /// Set the visual variant on this button.
64    pub fn with_variant(mut self, variant: ButtonVariant) -> Self {
65        self.variant = variant;
66        self
67    }
68}
69
70static BUTTON_BINDINGS: &[KeyBinding] = &[
71    KeyBinding {
72        key: KeyCode::Enter,
73        modifiers: KeyModifiers::NONE,
74        action: "press",
75        description: "Press",
76        show: false,
77    },
78    KeyBinding {
79        key: KeyCode::Char(' '),
80        modifiers: KeyModifiers::NONE,
81        action: "press",
82        description: "Press",
83        show: false,
84    },
85];
86
87impl Widget for Button {
88    fn widget_type_name(&self) -> &'static str {
89        "Button"
90    }
91
92    fn can_focus(&self) -> bool {
93        true
94    }
95
96    fn default_css() -> &'static str
97    where
98        Self: Sized,
99    {
100        "Button { border: inner; min-width: 16; height: 3; min-height: 3; }"
101    }
102
103    fn on_mount(&self, id: WidgetId) {
104        self.own_id.set(Some(id));
105    }
106
107    fn on_unmount(&self, _id: WidgetId) {
108        self.own_id.set(None);
109    }
110
111    fn key_bindings(&self) -> &[KeyBinding] {
112        BUTTON_BINDINGS
113    }
114
115    fn on_event(&self, event: &dyn std::any::Any, ctx: &AppContext) -> EventPropagation {
116        use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
117        if let Some(m) = event.downcast_ref::<MouseEvent>() {
118            if matches!(m.kind, MouseEventKind::Down(MouseButton::Left)) {
119                self.on_action("press", ctx);
120                return EventPropagation::Stop;
121            }
122        }
123        EventPropagation::Continue
124    }
125
126    fn on_action(&self, action: &str, ctx: &AppContext) {
127        if action == "press" {
128            self.pressed.set(true);
129            if let Some(id) = self.own_id.get() {
130                ctx.post_message(id, messages::Pressed { label: self.label.clone() });
131            }
132        }
133    }
134
135    fn render(&self, ctx: &AppContext, area: Rect, buf: &mut Buffer) {
136        use ratatui::style::Modifier;
137
138        if area.height == 0 || area.width == 0 {
139            return;
140        }
141        let base_style = self
142            .own_id
143            .get()
144            .map(|id| ctx.text_style(id))
145            .unwrap_or_default();
146
147        let is_pressed = self.pressed.get();
148
149        // Align label according to text-align CSS property (default: center)
150        let text_align = self
151            .own_id
152            .get()
153            .and_then(|id| ctx.computed_styles.get(id))
154            .map(|cs| cs.text_align)
155            .unwrap_or(crate::css::types::TextAlign::Center);
156        let label_len = self.label.chars().count() as u16;
157        let x = match text_align {
158            crate::css::types::TextAlign::Center => {
159                if area.width > label_len {
160                    area.x + (area.width - label_len) / 2
161                } else {
162                    area.x
163                }
164            }
165            crate::css::types::TextAlign::Right => {
166                if area.width > label_len {
167                    area.x + area.width - label_len
168                } else {
169                    area.x
170                }
171            }
172            crate::css::types::TextAlign::Left => area.x,
173        };
174        let y = if area.height > 1 {
175            area.y + area.height / 2
176        } else {
177            area.y
178        };
179        let display: String = self.label.chars().take(area.width as usize).collect();
180        let label_style = if is_pressed {
181            // Single-frame "flash" — invert the label style for pressed feedback
182            self.pressed.set(false);
183            base_style.add_modifier(Modifier::BOLD | Modifier::REVERSED)
184        } else {
185            base_style.add_modifier(Modifier::BOLD)
186        };
187        buf.set_string(x, y, &display, label_style);
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use crate::widget::context::AppContext;
195    use crate::widget::Widget;
196    use ratatui::buffer::Buffer;
197    use ratatui::layout::Rect;
198    use ratatui::style::Color;
199
200    /// Helper: create a buffer pre-filled with a given background color.
201    fn buf_with_bg(area: Rect, bg: Color) -> Buffer {
202        let mut buf = Buffer::empty(area);
203        for y in area.y..area.y + area.height {
204            for x in area.x..area.x + area.width {
205                if let Some(cell) = buf.cell_mut((x, y)) {
206                    cell.set_bg(bg);
207                }
208            }
209        }
210        buf
211    }
212
213    #[test]
214    fn button_renders_label_centered() {
215        let bg = Color::Rgb(42, 42, 62);
216        let area = Rect::new(0, 0, 16, 3);
217        let mut buf = buf_with_bg(area, bg);
218        let ctx = AppContext::new();
219        let button = Button::new("OK");
220        button.render(&ctx, area, &mut buf);
221
222        // Middle row should contain "OK" somewhere
223        let row: String = (0..16u16)
224            .map(|x| buf[(x, 1)].symbol().to_string())
225            .collect();
226        assert!(
227            row.contains("OK"),
228            "Button label should be rendered, got: {:?}",
229            row.trim()
230        );
231    }
232}