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 {
46 pub label: String,
48 pub variant: ButtonVariant,
50 pub action_name: Option<String>,
54 own_id: Cell<Option<WidgetId>>,
55 pressed: Cell<bool>,
57}
58
59impl Button {
60 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 pub fn with_variant(mut self, variant: ButtonVariant) -> Self {
73 self.variant = variant;
74 self
75 }
76
77 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 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 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 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 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 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}