stakpak_popup_widget/
lib.rs

1pub mod popup;
2pub mod position;
3pub mod tab;
4pub mod traits;
5
6use ratatui::text::Line;
7
8pub use popup::PopupWidget;
9pub use position::PopupPosition;
10pub use tab::Tab;
11pub use traits::{PopupContent, StyledLineContent, TextContent};
12
13use ratatui::style::{Color, Style};
14
15/// Text alignment options
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Alignment {
18    Left,
19    Center,
20    Right,
21}
22
23/// Configuration for popup appearance and behavior
24pub struct PopupConfig {
25    /// Whether to show a title
26    pub show_title: bool,
27    /// Title text (only used if show_title is true)
28    pub title: Option<String>,
29    /// Title style (color, modifiers, etc.)
30    pub title_style: Style,
31    /// Title alignment
32    pub title_alignment: Alignment,
33    /// Border style (color, modifiers, etc.)
34    pub border_style: Style,
35    /// Whether to show tabs
36    pub show_tabs: bool,
37    /// Tab alignment
38    pub tab_alignment: Alignment,
39    /// Tab configuration
40    pub tabs: Vec<Tab>,
41    /// Currently selected tab index
42    pub selected_tab: usize,
43    /// Popup position and size
44    pub position: PopupPosition,
45    /// Background style for content area
46    pub background_style: Style,
47    /// Popup background style (entire popup area)
48    pub popup_background_style: Style,
49    /// Tab style (for unselected tabs)
50    pub tab_style: Style,
51    /// Selected tab style
52    pub selected_tab_style: Style,
53    /// Whether to show borders around tab buttons
54    pub tab_borders: bool,
55    /// Whether to use fallback colors for unsupported terminals
56    pub use_fallback_colors: bool,
57    /// Custom terminal detection function
58    pub terminal_detector: Option<Box<dyn Fn() -> bool + Send + Sync>>,
59    /// Footer text (optional) - can be multiple lines
60    pub footer: Option<Vec<String>>,
61    /// Styled footer lines (optional) - takes precedence over footer if both are provided
62    pub styled_footer: Option<Vec<Line<'static>>>,
63    /// Footer style (color, modifiers, etc.) - applied to regular footer text
64    pub footer_style: Option<Style>,
65    /// Number of fixed lines at the top that should not scroll
66    pub fixed_header_lines: usize,
67    /// Subheader style (color, modifiers, etc.) for tab subheaders
68    pub subheader_style: Style,
69    /// Text to display between tabs (e.g., "→")
70    pub text_between_tabs: Option<String>,
71    /// Style for text between tabs
72    pub text_between_tabs_style: Style,
73}
74
75impl Default for PopupConfig {
76    fn default() -> Self {
77        Self {
78            show_title: true,
79            title: Some("Popup".to_string()),
80            title_style: Style::default()
81                .fg(Color::White)
82                .add_modifier(ratatui::style::Modifier::BOLD),
83            title_alignment: Alignment::Center,
84            border_style: Style::default().fg(Color::White),
85            show_tabs: false,
86            tab_alignment: Alignment::Left,
87            tabs: Vec::new(),
88            selected_tab: 0,
89            position: PopupPosition::Centered {
90                width: 50,
91                height: 20,
92            },
93            background_style: Style::default(),
94            popup_background_style: Style::default(),
95            tab_style: Style::default().fg(Color::Gray),
96            selected_tab_style: Style::default()
97                .fg(Color::Yellow)
98                .add_modifier(ratatui::style::Modifier::BOLD),
99            tab_borders: false,
100            use_fallback_colors: false,
101            terminal_detector: None,
102            footer: None,
103            styled_footer: None,
104            footer_style: None,
105            fixed_header_lines: 0,
106            subheader_style: Style::default()
107                .fg(Color::Gray)
108                .add_modifier(ratatui::style::Modifier::DIM),
109            text_between_tabs: None,
110            text_between_tabs_style: Style::default().fg(Color::Gray),
111        }
112    }
113}
114
115impl PopupConfig {
116    /// Create a new popup configuration
117    pub fn new() -> Self {
118        Self::default()
119    }
120
121    /// Set the title
122    pub fn title(mut self, title: impl Into<String>) -> Self {
123        self.title = Some(title.into());
124        self
125    }
126
127    /// Set the border style
128    pub fn border_style(mut self, style: Style) -> Self {
129        self.border_style = style;
130        self
131    }
132
133    /// Set the border color (convenience method)
134    pub fn border_color(mut self, color: Color) -> Self {
135        self.border_style = Style::default().fg(color);
136        self
137    }
138
139    /// Set the title style
140    pub fn title_style(mut self, style: Style) -> Self {
141        self.title_style = style;
142        self
143    }
144
145    /// Set the title color (convenience method)
146    pub fn title_color(mut self, color: Color) -> Self {
147        self.title_style = Style::default()
148            .fg(color)
149            .add_modifier(ratatui::style::Modifier::BOLD);
150        self
151    }
152
153    /// Set the tab style
154    pub fn tab_style(mut self, style: Style) -> Self {
155        self.tab_style = style;
156        self
157    }
158
159    /// Set the selected tab style
160    pub fn selected_tab_style(mut self, style: Style) -> Self {
161        self.selected_tab_style = style;
162        self
163    }
164
165    /// Set whether to show borders around tab buttons
166    pub fn tab_borders(mut self, show: bool) -> Self {
167        self.tab_borders = show;
168        self
169    }
170
171    /// Set whether to use fallback colors for unsupported terminals
172    pub fn use_fallback_colors(mut self, use_fallback: bool) -> Self {
173        self.use_fallback_colors = use_fallback;
174        self
175    }
176
177    /// Set a custom terminal detection function
178    pub fn terminal_detector<F>(mut self, detector: F) -> Self
179    where
180        F: Fn() -> bool + Send + Sync + 'static,
181    {
182        self.terminal_detector = Some(Box::new(detector));
183        self
184    }
185
186    /// Set the background style for content area
187    pub fn background_style(mut self, style: Style) -> Self {
188        self.background_style = style;
189        self
190    }
191
192    /// Set the popup background style (entire popup area)
193    pub fn popup_background_style(mut self, style: Style) -> Self {
194        self.popup_background_style = style;
195        self
196    }
197
198    /// Enable or disable tabs
199    pub fn show_tabs(mut self, show: bool) -> Self {
200        self.show_tabs = show;
201        self
202    }
203
204    /// Add a tab
205    pub fn add_tab(mut self, tab: Tab) -> Self {
206        self.tabs.push(tab);
207        self
208    }
209
210    /// Set the position
211    pub fn position(mut self, position: PopupPosition) -> Self {
212        self.position = position;
213        self
214    }
215
216    /// Set the footer text (can be multiple lines)
217    pub fn footer(mut self, footer: Option<Vec<String>>) -> Self {
218        self.footer = footer;
219        self
220    }
221
222    /// Set the styled footer lines (takes precedence over regular footer)
223    pub fn styled_footer(mut self, styled_footer: Option<Vec<Line<'static>>>) -> Self {
224        self.styled_footer = styled_footer;
225        self
226    }
227
228    /// Set the footer style
229    pub fn footer_style(mut self, footer_style: Option<Style>) -> Self {
230        self.footer_style = footer_style;
231        self
232    }
233
234    /// Set the title alignment
235    pub fn title_alignment(mut self, alignment: Alignment) -> Self {
236        self.title_alignment = alignment;
237        self
238    }
239
240    /// Set the tab alignment
241    pub fn tab_alignment(mut self, alignment: Alignment) -> Self {
242        self.tab_alignment = alignment;
243        self
244    }
245
246    /// Set the number of fixed header lines that should not scroll
247    pub fn fixed_header_lines(mut self, lines: usize) -> Self {
248        self.fixed_header_lines = lines;
249        self
250    }
251
252    /// Set text to display between tabs (e.g., "→")
253    pub fn text_between_tabs(mut self, text: Option<String>) -> Self {
254        self.text_between_tabs = text;
255        self
256    }
257
258    /// Set the style for text between tabs
259    pub fn text_between_tabs_style(mut self, style: Style) -> Self {
260        self.text_between_tabs_style = style;
261        self
262    }
263
264    /// Set the subheader style
265    pub fn subheader_style(mut self, style: Style) -> Self {
266        self.subheader_style = style;
267        self
268    }
269}
270
271/// Internal state for the popup widget
272#[derive(Debug, Clone, Default)]
273pub struct PopupState {
274    /// Scroll position for the current tab
275    pub scroll: usize,
276    /// Whether the popup is visible
277    pub visible: bool,
278    /// Selected tab index
279    pub selected_tab: usize,
280}
281
282/// Events that the popup can handle
283#[derive(Debug, Clone, PartialEq)]
284pub enum PopupEvent {
285    /// Show the popup
286    Show,
287    /// Hide the popup
288    Hide,
289    /// Toggle visibility
290    Toggle,
291    /// Scroll up
292    ScrollUp,
293    /// Scroll down
294    ScrollDown,
295    /// Page up
296    PageUp,
297    /// Page down
298    PageDown,
299    /// Switch to next tab
300    NextTab,
301    /// Switch to previous tab
302    PrevTab,
303    /// Switch to specific tab
304    SwitchTab(usize),
305    /// Handle escape key
306    Escape,
307}
308
309/// Result of handling a popup event
310#[derive(Debug, Clone, PartialEq)]
311pub enum PopupEventResult {
312    /// Event was handled
313    Handled,
314    /// Event was not handled (popup not visible or not applicable)
315    NotHandled,
316    /// Popup should be closed
317    Close,
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use crate::traits::TextContent;
324
325    #[test]
326    fn test_popup_config_builder() {
327        let config = PopupConfig::new()
328            .title("Test Popup")
329            .border_color(Color::Red)
330            .title_color(Color::Blue)
331            .show_tabs(true);
332
333        assert_eq!(config.title, Some("Test Popup".to_string()));
334        assert_eq!(config.border_style.fg, Some(Color::Red));
335        assert_eq!(config.title_style.fg, Some(Color::Blue));
336        assert!(config.show_tabs);
337    }
338
339    #[test]
340    fn test_popup_widget_creation() {
341        let config = PopupConfig::new()
342            .title("Test")
343            .position(PopupPosition::Centered {
344                width: 40,
345                height: 10,
346            });
347
348        let popup = PopupWidget::new(config);
349        assert!(!popup.is_visible());
350    }
351
352    #[test]
353    fn test_popup_with_content() {
354        let config = PopupConfig::new().title("Content Test");
355        let content = TextContent::new("Hello, World!".to_string());
356        let popup = PopupWidget::with_content(config, content);
357
358        assert!(!popup.is_visible());
359    }
360
361    #[test]
362    fn test_styled_line_content() {
363        use crate::traits::StyledLineContent;
364        use ratatui::style::{Color, Style};
365        use ratatui::text::Line;
366
367        let lines = vec![
368            (Line::from("Hello"), Style::default().fg(Color::Green)),
369            (
370                Line::from("World"),
371                Style::default()
372                    .fg(Color::Red)
373                    .add_modifier(ratatui::style::Modifier::BOLD),
374            ),
375        ];
376
377        let content = StyledLineContent::new(lines);
378        assert_eq!(content.height(), 2);
379        assert!(content.width() > 0);
380    }
381
382    #[test]
383    fn test_popup_events() {
384        let config = PopupConfig::new().title("Event Test");
385        let mut popup = PopupWidget::new(config);
386
387        // Initially not visible
388        assert!(!popup.is_visible());
389
390        // Test show event
391        let result = popup.handle_event(PopupEvent::Show);
392        assert_eq!(result, PopupEventResult::Handled);
393        assert!(popup.is_visible());
394
395        // Test hide event
396        let result = popup.handle_event(PopupEvent::Hide);
397        assert_eq!(result, PopupEventResult::Handled);
398        assert!(!popup.is_visible());
399
400        // Test toggle event
401        let result = popup.handle_event(PopupEvent::Toggle);
402        assert_eq!(result, PopupEventResult::Handled);
403        assert!(popup.is_visible());
404    }
405}