Skip to main content

saorsa_core/widget/
mod.rs

1//! Widget traits and built-in widgets.
2
3pub 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/// Result of handling an event.
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54pub enum EventResult {
55    /// The event was consumed by this widget.
56    Consumed,
57    /// The event was not handled; propagate to parent.
58    Ignored,
59}
60
61/// A widget that can render itself into a screen buffer region.
62pub trait Widget {
63    /// Render this widget into the given area of the buffer.
64    fn render(&self, area: Rect, buf: &mut ScreenBuffer);
65}
66
67/// A widget with size preferences for layout.
68pub trait SizedWidget: Widget {
69    /// The minimum size this widget needs.
70    fn min_size(&self) -> (u16, u16);
71    /// The preferred size if space is available.
72    fn preferred_size(&self) -> (u16, u16) {
73        self.min_size()
74    }
75}
76
77/// A widget that can handle input events.
78pub trait InteractiveWidget: Widget {
79    /// Handle an input event. Returns whether the event was consumed.
80    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    /// A mock widget for testing the trait.
93    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    // --- Widget module integration tests ---
128
129    #[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        // Add modal
190        let modal = Modal::new("M", 20, 5);
191        stack.push(modal.to_overlay_config(), modal.render_to_lines());
192
193        // Add toast
194        let toast = Toast::new("T").with_width(10);
195        stack.push(toast.to_overlay_config(screen), toast.render_to_lines());
196
197        // Add tooltip
198        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    // --- Phase 4.3 integration tests ---
205
206    #[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        // Tabs can hold arbitrary content
215        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        // Simulate selecting option B (deselect all, select new)
243        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        // Render after ticking
268        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        // Both should have rendered non-space chars
273        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        // Render both
289        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        // Collapse it
299        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        // Should only have last 5 data points
310        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        // First 5 positions should have bar chars
317        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        // No panic
333    }
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        // No panic, no truncation errors
356    }
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        // Space/Enter consumed by interactive widgets
368        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        // Unhandled events ignored
394        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        // No panic
421    }
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            // All should have top-left corner border char
448            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}