1pub mod border;
4pub mod collapsible;
5pub mod container;
6pub mod data_table;
7pub mod diff_view;
8pub mod directory_tree;
9pub mod form_controls;
10pub mod label;
11pub mod loading_indicator;
12pub mod markdown;
13pub mod modal;
14pub mod option_list;
15pub mod progress_bar;
16pub mod rich_log;
17pub mod select_list;
18pub mod sparkline;
19pub mod static_widget;
20pub mod tabs;
21pub mod text_area;
22pub mod toast;
23pub mod tooltip;
24pub mod tree;
25
26pub use collapsible::Collapsible;
27pub use container::{BorderStyle, Container};
28pub use data_table::{Column, DataTable};
29pub use diff_view::{DiffMode, DiffView};
30pub use directory_tree::DirectoryTree;
31pub use form_controls::{Checkbox, RadioButton, Switch};
32pub use label::{Alignment, Label};
33pub use loading_indicator::{IndicatorStyle, LoadingIndicator};
34pub use markdown::MarkdownRenderer;
35pub use modal::Modal;
36pub use option_list::OptionList;
37pub use progress_bar::{ProgressBar, ProgressMode};
38pub use rich_log::RichLog;
39pub use select_list::SelectList;
40pub use sparkline::Sparkline;
41pub use static_widget::StaticWidget;
42pub use tabs::{Tab, TabBarPosition, Tabs};
43pub use text_area::TextArea;
44pub use toast::{Toast, ToastPosition};
45pub use tooltip::Tooltip;
46pub use tree::{Tree, TreeNode};
47
48use crate::buffer::ScreenBuffer;
49use crate::event::Event;
50use crate::geometry::Rect;
51
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54pub enum EventResult {
55 Consumed,
57 Ignored,
59}
60
61pub trait Widget {
63 fn render(&self, area: Rect, buf: &mut ScreenBuffer);
65}
66
67pub trait SizedWidget: Widget {
69 fn min_size(&self) -> (u16, u16);
71 fn preferred_size(&self) -> (u16, u16) {
73 self.min_size()
74 }
75}
76
77pub trait InteractiveWidget: Widget {
79 fn handle_event(&mut self, event: &Event) -> EventResult;
81}
82
83#[cfg(test)]
84#[allow(clippy::unwrap_used)]
85mod tests {
86 use super::*;
87 use crate::cell::Cell;
88 use crate::event::{Event, KeyCode, KeyEvent};
89 use crate::geometry::{Rect, Size};
90 use crate::style::Style;
91
92 struct MockWidget {
94 text: String,
95 }
96
97 impl Widget for MockWidget {
98 fn render(&self, area: Rect, buf: &mut ScreenBuffer) {
99 for (i, ch) in self.text.chars().enumerate() {
100 let x = area.position.x + i as u16;
101 if x < area.position.x + area.size.width {
102 buf.set(
103 x,
104 area.position.y,
105 Cell::new(ch.to_string(), Style::default()),
106 );
107 }
108 }
109 }
110 }
111
112 #[test]
113 fn mock_widget_renders() {
114 let w = MockWidget { text: "hi".into() };
115 let mut buf = ScreenBuffer::new(Size::new(10, 1));
116 w.render(Rect::new(0, 0, 10, 1), &mut buf);
117 assert_eq!(buf.get(0, 0).map(|c| c.grapheme.as_str()), Some("h"));
118 assert_eq!(buf.get(1, 0).map(|c| c.grapheme.as_str()), Some("i"));
119 }
120
121 #[test]
122 fn event_result_equality() {
123 assert_eq!(EventResult::Consumed, EventResult::Consumed);
124 assert_ne!(EventResult::Consumed, EventResult::Ignored);
125 }
126
127 #[test]
130 fn modal_create_and_render() {
131 let modal = Modal::new("Test Modal", 30, 10);
132 let lines = modal.render_to_lines();
133 assert!(lines.len() == 10);
134 assert!(!lines[0].is_empty());
135 }
136
137 #[test]
138 fn toast_create_and_render() {
139 let toast = Toast::new("Notification");
140 let lines = toast.render_to_lines();
141 assert!(lines.len() == 1);
142 let text: String = lines[0].iter().map(|s| &*s.text).collect();
143 assert!(text.contains("Notification"));
144 }
145
146 #[test]
147 fn tooltip_create_and_render() {
148 let tooltip = Tooltip::new("Help text", Rect::new(10, 10, 5, 2));
149 let lines = tooltip.render_to_lines();
150 assert!(lines.len() == 1);
151 assert!(lines[0][0].text == "Help text");
152 }
153
154 #[test]
155 fn modal_pushed_to_screen_stack() {
156 use crate::overlay::ScreenStack;
157
158 let modal = Modal::new("M", 20, 5);
159 let lines = modal.render_to_lines();
160 let config = modal.to_overlay_config();
161
162 let mut stack = ScreenStack::new();
163 let id = stack.push(config, lines);
164 assert!(id > 0);
165 assert!(stack.len() == 1);
166 }
167
168 #[test]
169 fn toast_pushed_to_screen_stack() {
170 use crate::overlay::ScreenStack;
171
172 let toast = Toast::new("T").with_width(10);
173 let screen = Size::new(80, 24);
174 let lines = toast.render_to_lines();
175 let config = toast.to_overlay_config(screen);
176
177 let mut stack = ScreenStack::new();
178 stack.push(config, lines);
179 assert!(stack.len() == 1);
180 }
181
182 #[test]
183 fn multiple_overlay_types_in_stack() {
184 use crate::overlay::{Placement, ScreenStack};
185
186 let mut stack = ScreenStack::new();
187 let screen = Size::new(80, 24);
188
189 let modal = Modal::new("M", 20, 5);
191 stack.push(modal.to_overlay_config(), modal.render_to_lines());
192
193 let toast = Toast::new("T").with_width(10);
195 stack.push(toast.to_overlay_config(screen), toast.render_to_lines());
196
197 let tooltip = Tooltip::new("tip", Rect::new(10, 10, 5, 2)).with_placement(Placement::Below);
199 stack.push(tooltip.to_overlay_config(screen), tooltip.render_to_lines());
200
201 assert!(stack.len() == 3);
202 }
203
204 #[test]
207 fn tabs_with_progress_bar_content() {
208 use crate::segment::Segment;
209
210 let bar = ProgressBar::new(0.7);
211 let mut bar_buf = ScreenBuffer::new(Size::new(20, 1));
212 bar.render(Rect::new(0, 0, 20, 1), &mut bar_buf);
213
214 let tabs = Tabs::new(vec![
216 Tab::new("Status").with_content(vec![vec![Segment::new("Progress: 70%")]]),
217 Tab::new("Details").with_content(vec![vec![Segment::new("All good")]]),
218 ]);
219 assert_eq!(tabs.tab_count(), 2);
220 assert_eq!(tabs.active_tab(), 0);
221
222 let mut buf = ScreenBuffer::new(Size::new(40, 5));
223 tabs.render(Rect::new(0, 0, 40, 5), &mut buf);
224
225 let row1: String = (0..40)
226 .map(|x| buf.get(x, 1).map(|c| c.grapheme.as_str()).unwrap_or(" "))
227 .collect();
228 assert!(row1.contains("Progress: 70%"));
229 }
230
231 #[test]
232 fn form_controls_group_radio_selection() {
233 let mut radios = vec![
234 RadioButton::new("Option A").with_selected(true),
235 RadioButton::new("Option B"),
236 RadioButton::new("Option C"),
237 ];
238
239 assert!(radios[0].is_selected());
240 assert!(!radios[1].is_selected());
241
242 for r in &mut radios {
244 r.deselect();
245 }
246 radios[1].select();
247
248 assert!(!radios[0].is_selected());
249 assert!(radios[1].is_selected());
250 assert!(!radios[2].is_selected());
251 }
252
253 #[test]
254 fn animated_widgets_tick() {
255 let mut bar = ProgressBar::indeterminate();
256 let mut loader = LoadingIndicator::new();
257
258 bar.tick();
259 loader.tick();
260
261 assert!(matches!(
262 bar.mode(),
263 ProgressMode::Indeterminate { phase: 1 }
264 ));
265 assert_eq!(loader.frame(), 1);
266
267 let mut buf = ScreenBuffer::new(Size::new(20, 2));
269 bar.render(Rect::new(0, 0, 20, 1), &mut buf);
270 loader.render(Rect::new(0, 1, 20, 1), &mut buf);
271
272 assert_ne!(buf.get(0, 0).map(|c| c.grapheme.as_str()), Some(" "));
274 assert_ne!(buf.get(0, 1).map(|c| c.grapheme.as_str()), Some(" "));
275 }
276
277 #[test]
278 fn collapsible_with_option_list() {
279 let mut collapsible = Collapsible::new("Settings")
280 .with_content(vec![
281 vec![crate::segment::Segment::new("Dark Mode")],
282 vec![crate::segment::Segment::new("Sound")],
283 ])
284 .with_expanded(true);
285
286 let ol = OptionList::new(vec!["Theme".to_string(), "Language".to_string()]);
287
288 let mut buf = ScreenBuffer::new(Size::new(30, 10));
290 collapsible.render(Rect::new(0, 0, 30, 5), &mut buf);
291 ol.render(Rect::new(0, 5, 30, 5), &mut buf);
292
293 let row0: String = (0..30)
294 .map(|x| buf.get(x, 0).map(|c| c.grapheme.as_str()).unwrap_or(" "))
295 .collect();
296 assert!(row0.contains("Settings"));
297
298 collapsible.handle_event(&Event::Key(KeyEvent::plain(KeyCode::Enter)));
300 assert!(!collapsible.is_expanded());
301 }
302
303 #[test]
304 fn sparkline_live_data_push() {
305 let mut spark = Sparkline::new(vec![]).with_max_width(5);
306 for i in 0..10 {
307 spark.push(i as f32);
308 }
309 assert_eq!(spark.data().len(), 5);
311 assert_eq!(spark.data()[0], 5.0);
312 assert_eq!(spark.data()[4], 9.0);
313
314 let mut buf = ScreenBuffer::new(Size::new(10, 1));
315 spark.render(Rect::new(0, 0, 10, 1), &mut buf);
316 assert_ne!(buf.get(0, 0).map(|c| c.grapheme.as_str()), Some(" "));
318 }
319
320 #[test]
321 fn empty_widgets_render_safely() {
322 let tabs = Tabs::new(vec![]);
323 let ol = OptionList::new(vec![]);
324 let spark = Sparkline::new(vec![]);
325 let collapsible = Collapsible::new("Empty");
326
327 let mut buf = ScreenBuffer::new(Size::new(20, 10));
328 tabs.render(Rect::new(0, 0, 20, 2), &mut buf);
329 ol.render(Rect::new(0, 2, 20, 2), &mut buf);
330 spark.render(Rect::new(0, 4, 20, 2), &mut buf);
331 collapsible.render(Rect::new(0, 6, 20, 2), &mut buf);
332 }
334
335 #[test]
336 fn utf8_across_all_widgets() {
337 use crate::segment::Segment;
338
339 let tabs = Tabs::new(vec![
340 Tab::new("日本語").with_content(vec![vec![Segment::new("コンテンツ")]]),
341 ]);
342 let switch = Switch::new("暗いモード");
343 let checkbox = Checkbox::new("同意する");
344 let ol = OptionList::new(vec!["選択肢A".to_string(), "選択肢B".to_string()]);
345 let spark = Sparkline::new(vec![1.0, 2.0, 3.0]);
346 let collapsible = Collapsible::new("セクション").with_expanded(true);
347
348 let mut buf = ScreenBuffer::new(Size::new(40, 20));
349 tabs.render(Rect::new(0, 0, 40, 3), &mut buf);
350 switch.render(Rect::new(0, 3, 40, 1), &mut buf);
351 checkbox.render(Rect::new(0, 4, 40, 1), &mut buf);
352 ol.render(Rect::new(0, 5, 40, 3), &mut buf);
353 spark.render(Rect::new(0, 8, 40, 1), &mut buf);
354 collapsible.render(Rect::new(0, 9, 40, 3), &mut buf);
355 }
357
358 #[test]
359 fn event_consumption_correctness() {
360 let mut tabs = Tabs::new(vec![Tab::new("A"), Tab::new("B")]);
361 let mut switch = Switch::new("S");
362 let mut checkbox = Checkbox::new("C");
363 let mut radio = RadioButton::new("R");
364 let mut collapsible = Collapsible::new("X");
365 let mut ol = OptionList::new(vec!["1".to_string()]);
366
367 assert_eq!(
369 switch.handle_event(&Event::Key(KeyEvent::plain(KeyCode::Char(' ')))),
370 EventResult::Consumed
371 );
372 assert_eq!(
373 checkbox.handle_event(&Event::Key(KeyEvent::plain(KeyCode::Enter))),
374 EventResult::Consumed
375 );
376 assert_eq!(
377 radio.handle_event(&Event::Key(KeyEvent::plain(KeyCode::Enter))),
378 EventResult::Consumed
379 );
380 assert_eq!(
381 collapsible.handle_event(&Event::Key(KeyEvent::plain(KeyCode::Enter))),
382 EventResult::Consumed
383 );
384 assert_eq!(
385 ol.handle_event(&Event::Key(KeyEvent::plain(KeyCode::Down))),
386 EventResult::Consumed
387 );
388 assert_eq!(
389 tabs.handle_event(&Event::Key(KeyEvent::plain(KeyCode::Right))),
390 EventResult::Consumed
391 );
392
393 assert_eq!(
395 switch.handle_event(&Event::Key(KeyEvent::plain(KeyCode::F(1)))),
396 EventResult::Ignored
397 );
398 }
399
400 #[test]
401 fn zero_size_area_no_panic() {
402 let tabs = Tabs::new(vec![Tab::new("A")]);
403 let bar = ProgressBar::new(0.5);
404 let loader = LoadingIndicator::new();
405 let collapsible = Collapsible::new("C");
406 let switch = Switch::new("S");
407 let ol = OptionList::new(vec!["X".to_string()]);
408 let spark = Sparkline::new(vec![1.0]);
409
410 let mut buf = ScreenBuffer::new(Size::new(1, 1));
411 let zero = Rect::new(0, 0, 0, 0);
412
413 tabs.render(zero, &mut buf);
414 bar.render(zero, &mut buf);
415 loader.render(zero, &mut buf);
416 collapsible.render(zero, &mut buf);
417 switch.render(zero, &mut buf);
418 ol.render(zero, &mut buf);
419 spark.render(zero, &mut buf);
420 }
422
423 #[test]
424 fn style_propagation() {
425 let style = Style::default().bold(true);
426 let switch = Switch::new("Bold")
427 .with_on_style(style.clone())
428 .with_state(true);
429 let mut buf = ScreenBuffer::new(Size::new(20, 1));
430 switch.render(Rect::new(0, 0, 20, 1), &mut buf);
431
432 assert!(buf.get(0, 0).map(|c| c.style.bold).unwrap_or(false));
433 }
434
435 #[test]
436 fn border_consistency() {
437 let widgets_with_borders: Vec<Box<dyn Widget>> = vec![
438 Box::new(Tabs::new(vec![Tab::new("A")]).with_border(BorderStyle::Single)),
439 Box::new(ProgressBar::new(0.5).with_border(BorderStyle::Single)),
440 Box::new(Collapsible::new("C").with_border(BorderStyle::Single)),
441 Box::new(OptionList::new(vec!["O".to_string()]).with_border(BorderStyle::Single)),
442 ];
443
444 for widget in &widgets_with_borders {
445 let mut buf = ScreenBuffer::new(Size::new(20, 5));
446 widget.render(Rect::new(0, 0, 20, 5), &mut buf);
447 assert_eq!(
449 buf.get(0, 0).map(|c| c.grapheme.as_str()),
450 Some("┌"),
451 "Widget should render single border"
452 );
453 }
454 }
455}