textual_rs/widget/
button.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum ButtonVariant {
14 #[default]
16 Default,
17 Primary,
19 Warning,
21 Error,
23 Success,
25}
26
27pub mod messages {
29 use crate::event::message::Message;
30
31 pub struct Pressed {
34 pub label: String,
36 }
37
38 impl Message for Pressed {}
39}
40
41pub struct Button {
43 pub label: String,
45 pub variant: ButtonVariant,
47 own_id: Cell<Option<WidgetId>>,
48 pressed: Cell<bool>,
50}
51
52impl Button {
53 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 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 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 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 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 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}