Skip to main content

boxmux_lib/components/
selection_styles.rs

1use crate::draw_utils::print_with_color_and_background_at;
2use crate::model::choice::Choice;
3use crate::model::common::{Bounds, ScreenBuffer};
4
5/// Selection highlighting styles for choice menus
6#[derive(Debug, Clone, PartialEq)]
7pub enum SelectionStyle {
8    /// Standard color-based highlighting (current default behavior)
9    ColorHighlight,
10    /// Inverted colors for selection
11    InvertColors,
12    /// Border/frame around selected item
13    BorderHighlight,
14    /// Arrow or pointer indicator
15    PointerIndicator,
16    /// Underline the selected text
17    UnderlineHighlight,
18    /// Bold text for selected item
19    BoldHighlight,
20    /// Combination of multiple styles
21    Combined(Vec<SelectionStyle>),
22}
23
24/// Focus indication styles for keyboard navigation
25#[derive(Debug, Clone, PartialEq)]
26pub enum FocusStyle {
27    /// No special focus indication beyond selection
28    None,
29    /// Blinking or animated focus indicator
30    Blinking,
31    /// Extra bright/intense colors when focused
32    Intensity,
33    /// Dotted border around focused area
34    DottedBorder,
35    /// Animated arrow or cursor
36    AnimatedCursor,
37}
38
39/// Visual feedback styles for user interactions
40#[derive(Debug, Clone, PartialEq)]
41pub enum FeedbackStyle {
42    /// Brief color flash on selection change
43    ColorFlash,
44    /// Smooth color transition animation
45    ColorTransition,
46    /// Visual ripple effect from interaction point
47    RippleEffect,
48    /// Slide/movement animation
49    SlideAnimation,
50    /// No visual feedback
51    None,
52}
53
54/// Configuration for choice selection styling
55#[derive(Debug, Clone)]
56pub struct SelectionStyleConfig {
57    pub selection_style: SelectionStyle,
58    pub focus_style: FocusStyle,
59    pub feedback_style: FeedbackStyle,
60    pub animation_duration_ms: u32,
61    pub intensity_factor: f32,
62    pub custom_indicators: Option<SelectionIndicators>,
63}
64
65/// Custom selection indicators and symbols
66#[derive(Debug, Clone)]
67pub struct SelectionIndicators {
68    pub pointer_symbol: String,
69    pub selection_prefix: String,
70    pub selection_suffix: String,
71    pub focus_indicator: String,
72    pub border_chars: BorderChars,
73}
74
75/// Custom border characters for selection highlighting
76#[derive(Debug, Clone)]
77pub struct BorderChars {
78    pub top_left: char,
79    pub top_right: char,
80    pub bottom_left: char,
81    pub bottom_right: char,
82    pub horizontal: char,
83    pub vertical: char,
84}
85
86impl Default for SelectionStyleConfig {
87    fn default() -> Self {
88        Self {
89            selection_style: SelectionStyle::ColorHighlight,
90            focus_style: FocusStyle::None,
91            feedback_style: FeedbackStyle::None,
92            animation_duration_ms: 200,
93            intensity_factor: 1.5,
94            custom_indicators: None,
95        }
96    }
97}
98
99impl Default for SelectionIndicators {
100    fn default() -> Self {
101        Self {
102            pointer_symbol: "▶".to_string(),
103            selection_prefix: "".to_string(),
104            selection_suffix: "".to_string(),
105            focus_indicator: "◀".to_string(),
106            border_chars: BorderChars::default(),
107        }
108    }
109}
110
111impl Default for BorderChars {
112    fn default() -> Self {
113        Self {
114            top_left: '┌',
115            top_right: '┐',
116            bottom_left: '└',
117            bottom_right: '┘',
118            horizontal: '─',
119            vertical: '│',
120        }
121    }
122}
123
124/// Enhanced selection styling component
125pub struct SelectionStyleRenderer {
126    pub id: String,
127    pub config: SelectionStyleConfig,
128}
129
130impl SelectionStyleRenderer {
131    /// Create new selection style renderer with configuration
132    pub fn new(id: String, config: SelectionStyleConfig) -> Self {
133        Self { id, config }
134    }
135
136    /// Create with default configuration
137    pub fn with_defaults(id: String) -> Self {
138        Self {
139            id,
140            config: SelectionStyleConfig::default(),
141        }
142    }
143
144    /// Render choice with enhanced selection styling
145    pub fn render_choice(
146        &self,
147        choice: &Choice,
148        bounds: &Bounds,
149        y_position: usize,
150        x_position: usize,
151        base_fg_color: &Option<String>,
152        base_bg_color: &Option<String>,
153        selected_fg_color: &Option<String>,
154        selected_bg_color: &Option<String>,
155        is_focused: bool,
156        buffer: &mut ScreenBuffer,
157    ) {
158        let (final_fg_color, final_bg_color, display_text) = self.calculate_style_colors_and_text(
159            choice,
160            base_fg_color,
161            base_bg_color,
162            selected_fg_color,
163            selected_bg_color,
164            is_focused,
165        );
166
167        // Render the main choice content
168        print_with_color_and_background_at(
169            y_position,
170            x_position,
171            &final_fg_color,
172            &final_bg_color,
173            &display_text,
174            buffer,
175        );
176
177        // Apply additional styling based on selection style
178        if choice.selected {
179            self.apply_selection_style(
180                choice,
181                bounds,
182                y_position,
183                x_position,
184                &display_text,
185                buffer,
186            );
187        }
188
189        // Apply focus indicators if focused
190        if is_focused {
191            self.apply_focus_style(bounds, y_position, x_position, &display_text, buffer);
192        }
193    }
194
195    /// Calculate final colors and text based on style configuration
196    pub fn calculate_style_colors_and_text(
197        &self,
198        choice: &Choice,
199        base_fg_color: &Option<String>,
200        base_bg_color: &Option<String>,
201        selected_fg_color: &Option<String>,
202        selected_bg_color: &Option<String>,
203        is_focused: bool,
204    ) -> (Option<String>, Option<String>, String) {
205        let mut fg_color = if choice.selected {
206            selected_fg_color.clone()
207        } else {
208            base_fg_color.clone()
209        };
210
211        let mut bg_color = if choice.selected {
212            selected_bg_color.clone()
213        } else {
214            base_bg_color.clone()
215        };
216
217        let mut display_text = choice.content.as_ref().unwrap_or(&String::new()).clone();
218
219        // Apply selection style modifications
220        if choice.selected {
221            match &self.config.selection_style {
222                SelectionStyle::InvertColors => {
223                    // Swap foreground and background colors
224                    std::mem::swap(&mut fg_color, &mut bg_color);
225                }
226                SelectionStyle::BoldHighlight => {
227                    // Use bright color variants for bold effect
228                    fg_color = self.make_color_bright(&fg_color);
229                }
230                SelectionStyle::PointerIndicator => {
231                    // Add pointer symbol prefix
232                    let default_indicators = SelectionIndicators::default();
233                    let indicators = self
234                        .config
235                        .custom_indicators
236                        .as_ref()
237                        .unwrap_or(&default_indicators);
238                    display_text = format!("{} {}", indicators.pointer_symbol, display_text);
239                }
240                SelectionStyle::Combined(styles) => {
241                    // Apply multiple styles
242                    for style in styles {
243                        match style {
244                            SelectionStyle::InvertColors => {
245                                std::mem::swap(&mut fg_color, &mut bg_color);
246                            }
247                            SelectionStyle::BoldHighlight => {
248                                fg_color = self.make_color_bright(&fg_color);
249                            }
250                            SelectionStyle::PointerIndicator => {
251                                let default_indicators = SelectionIndicators::default();
252                                let indicators = self
253                                    .config
254                                    .custom_indicators
255                                    .as_ref()
256                                    .unwrap_or(&default_indicators);
257                                display_text =
258                                    format!("{} {}", indicators.pointer_symbol, display_text);
259                            }
260                            _ => {} // Other styles handled separately
261                        }
262                    }
263                }
264                _ => {} // ColorHighlight and other styles use base colors
265            }
266        }
267
268        // Apply focus intensity if focused
269        if is_focused && matches!(self.config.focus_style, FocusStyle::Intensity) {
270            fg_color = self.apply_intensity(&fg_color);
271        }
272
273        // Add waiting state indicator if needed
274        if choice.waiting {
275            display_text = format!("{}...", display_text);
276        }
277
278        (fg_color, bg_color, display_text)
279    }
280
281    /// Apply additional selection styling (borders, underlines, etc.)
282    fn apply_selection_style(
283        &self,
284        _choice: &Choice,
285        bounds: &Bounds,
286        y_position: usize,
287        x_position: usize,
288        display_text: &str,
289        buffer: &mut ScreenBuffer,
290    ) {
291        match &self.config.selection_style {
292            SelectionStyle::BorderHighlight => {
293                self.draw_selection_border(
294                    bounds,
295                    y_position,
296                    x_position,
297                    display_text.len(),
298                    buffer,
299                );
300            }
301            SelectionStyle::UnderlineHighlight => {
302                self.draw_selection_underline(
303                    bounds,
304                    y_position,
305                    x_position,
306                    display_text.len(),
307                    buffer,
308                );
309            }
310            SelectionStyle::Combined(styles) => {
311                if styles.contains(&SelectionStyle::BorderHighlight) {
312                    self.draw_selection_border(
313                        bounds,
314                        y_position,
315                        x_position,
316                        display_text.len(),
317                        buffer,
318                    );
319                }
320                if styles.contains(&SelectionStyle::UnderlineHighlight) {
321                    self.draw_selection_underline(
322                        bounds,
323                        y_position,
324                        x_position,
325                        display_text.len(),
326                        buffer,
327                    );
328                }
329            }
330            _ => {}
331        }
332    }
333
334    /// Apply focus styling indicators
335    fn apply_focus_style(
336        &self,
337        bounds: &Bounds,
338        y_position: usize,
339        x_position: usize,
340        display_text: &str,
341        buffer: &mut ScreenBuffer,
342    ) {
343        match &self.config.focus_style {
344            FocusStyle::DottedBorder => {
345                self.draw_dotted_focus_border(
346                    bounds,
347                    y_position,
348                    x_position,
349                    display_text.len(),
350                    buffer,
351                );
352            }
353            FocusStyle::AnimatedCursor => {
354                self.draw_animated_cursor(bounds, y_position, x_position, buffer);
355            }
356            FocusStyle::Blinking => {
357                // Blinking effect would need frame timing - simplified here
358                self.draw_focus_indicator(bounds, y_position, x_position, buffer);
359            }
360            _ => {}
361        }
362    }
363
364    /// Draw border around selected choice
365    fn draw_selection_border(
366        &self,
367        bounds: &Bounds,
368        y_position: usize,
369        x_position: usize,
370        text_length: usize,
371        buffer: &mut ScreenBuffer,
372    ) {
373        let default_indicators = SelectionIndicators::default();
374        let indicators = self
375            .config
376            .custom_indicators
377            .as_ref()
378            .unwrap_or(&default_indicators);
379        let border = &indicators.border_chars;
380
381        // Only draw border if we have space and are within bounds
382        if y_position > bounds.top() && y_position < bounds.bottom() && x_position > bounds.left() {
383            // Draw corners and sides
384            if x_position > 0 {
385                print_with_color_and_background_at(
386                    y_position,
387                    x_position - 1,
388                    &Some("bright_yellow".to_string()),
389                    &Some("black".to_string()),
390                    &border.vertical.to_string(),
391                    buffer,
392                );
393            }
394
395            if x_position + text_length < bounds.right() {
396                print_with_color_and_background_at(
397                    y_position,
398                    x_position + text_length,
399                    &Some("bright_yellow".to_string()),
400                    &Some("black".to_string()),
401                    &border.vertical.to_string(),
402                    buffer,
403                );
404            }
405        }
406    }
407
408    /// Draw underline under selected choice
409    fn draw_selection_underline(
410        &self,
411        bounds: &Bounds,
412        y_position: usize,
413        x_position: usize,
414        text_length: usize,
415        buffer: &mut ScreenBuffer,
416    ) {
417        if y_position < bounds.bottom() {
418            let underline = "─".repeat(text_length);
419            print_with_color_and_background_at(
420                y_position + 1,
421                x_position,
422                &Some("bright_yellow".to_string()),
423                &Some("black".to_string()),
424                &underline,
425                buffer,
426            );
427        }
428    }
429
430    /// Draw dotted border for focus indication
431    fn draw_dotted_focus_border(
432        &self,
433        bounds: &Bounds,
434        y_position: usize,
435        x_position: usize,
436        text_length: usize,
437        buffer: &mut ScreenBuffer,
438    ) {
439        if y_position >= bounds.top()
440            && y_position <= bounds.bottom()
441            && x_position >= bounds.left()
442        {
443            // Draw dotted indicators
444            if x_position > 0 {
445                print_with_color_and_background_at(
446                    y_position,
447                    x_position - 1,
448                    &Some("bright_cyan".to_string()),
449                    &Some("black".to_string()),
450                    ":",
451                    buffer,
452                );
453            }
454
455            if x_position + text_length < bounds.right() {
456                print_with_color_and_background_at(
457                    y_position,
458                    x_position + text_length,
459                    &Some("bright_cyan".to_string()),
460                    &Some("black".to_string()),
461                    ":",
462                    buffer,
463                );
464            }
465        }
466    }
467
468    /// Draw animated cursor for focus
469    fn draw_animated_cursor(
470        &self,
471        bounds: &Bounds,
472        y_position: usize,
473        x_position: usize,
474        buffer: &mut ScreenBuffer,
475    ) {
476        if y_position >= bounds.top() && y_position <= bounds.bottom() && x_position > bounds.left()
477        {
478            let default_indicators = SelectionIndicators::default();
479            let indicators = self
480                .config
481                .custom_indicators
482                .as_ref()
483                .unwrap_or(&default_indicators);
484            print_with_color_and_background_at(
485                y_position,
486                x_position - 1,
487                &Some("bright_magenta".to_string()),
488                &Some("black".to_string()),
489                &indicators.focus_indicator,
490                buffer,
491            );
492        }
493    }
494
495    /// Draw simple focus indicator
496    fn draw_focus_indicator(
497        &self,
498        bounds: &Bounds,
499        y_position: usize,
500        x_position: usize,
501        buffer: &mut ScreenBuffer,
502    ) {
503        if y_position >= bounds.top() && y_position <= bounds.bottom() && x_position > bounds.left()
504        {
505            print_with_color_and_background_at(
506                y_position,
507                x_position - 1,
508                &Some("bright_white".to_string()),
509                &Some("black".to_string()),
510                "●",
511                buffer,
512            );
513        }
514    }
515
516    /// Make color brighter/more intense
517    fn make_color_bright(&self, color: &Option<String>) -> Option<String> {
518        match color {
519            Some(color_str) => match color_str.as_str() {
520                "red" => Some("bright_red".to_string()),
521                "green" => Some("bright_green".to_string()),
522                "blue" => Some("bright_blue".to_string()),
523                "yellow" => Some("bright_yellow".to_string()),
524                "magenta" => Some("bright_magenta".to_string()),
525                "cyan" => Some("bright_cyan".to_string()),
526                "white" => Some("bright_white".to_string()),
527                "black" => Some("bright_black".to_string()),
528                _ => Some(color_str.clone()), // Return as-is if already bright or unknown
529            },
530            None => None, // Transparent remains transparent
531        }
532    }
533
534    /// Apply intensity factor to color
535    fn apply_intensity(&self, color: &Option<String>) -> Option<String> {
536        // For now, just make it bright - could be enhanced with RGB manipulation
537        self.make_color_bright(color)
538    }
539
540    /// Get default selection style for migration from existing choice renderer
541    pub fn get_legacy_style_config() -> SelectionStyleConfig {
542        SelectionStyleConfig {
543            selection_style: SelectionStyle::ColorHighlight,
544            focus_style: FocusStyle::None,
545            feedback_style: FeedbackStyle::None,
546            animation_duration_ms: 0,
547            intensity_factor: 1.0,
548            custom_indicators: None,
549        }
550    }
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556    use crate::model::common::ScreenBuffer;
557
558    #[test]
559    fn test_selection_style_renderer_creation() {
560        let renderer = SelectionStyleRenderer::with_defaults("test".to_string());
561        assert_eq!(renderer.id, "test");
562        assert_eq!(
563            renderer.config.selection_style,
564            SelectionStyle::ColorHighlight
565        );
566    }
567
568    #[test]
569    fn test_custom_selection_style_config() {
570        let config = SelectionStyleConfig {
571            selection_style: SelectionStyle::PointerIndicator,
572            focus_style: FocusStyle::Intensity,
573            feedback_style: FeedbackStyle::ColorFlash,
574            animation_duration_ms: 300,
575            intensity_factor: 2.0,
576            custom_indicators: Some(SelectionIndicators::default()),
577        };
578
579        let renderer = SelectionStyleRenderer::new("custom".to_string(), config);
580        assert!(matches!(
581            renderer.config.selection_style,
582            SelectionStyle::PointerIndicator
583        ));
584        assert!(matches!(renderer.config.focus_style, FocusStyle::Intensity));
585    }
586
587    #[test]
588    fn test_color_bright_conversion() {
589        let renderer = SelectionStyleRenderer::with_defaults("test".to_string());
590        assert_eq!(
591            renderer.make_color_bright(&Some("red".to_string())),
592            Some("bright_red".to_string())
593        );
594        assert_eq!(
595            renderer.make_color_bright(&Some("green".to_string())),
596            Some("bright_green".to_string())
597        );
598        assert_eq!(
599            renderer.make_color_bright(&Some("bright_blue".to_string())),
600            Some("bright_blue".to_string())
601        ); // Already bright
602        assert_eq!(renderer.make_color_bright(&None), None); // Transparent stays transparent
603    }
604
605    #[test]
606    fn test_selection_indicators_default() {
607        let indicators = SelectionIndicators::default();
608        assert_eq!(indicators.pointer_symbol, "▶");
609        assert_eq!(indicators.focus_indicator, "◀");
610        assert_eq!(indicators.border_chars.top_left, '┌');
611    }
612
613    #[test]
614    fn test_combined_selection_styles() {
615        let combined = SelectionStyle::Combined(vec![
616            SelectionStyle::PointerIndicator,
617            SelectionStyle::BoldHighlight,
618            SelectionStyle::BorderHighlight,
619        ]);
620
621        if let SelectionStyle::Combined(styles) = combined {
622            assert_eq!(styles.len(), 3);
623            assert!(styles.contains(&SelectionStyle::PointerIndicator));
624            assert!(styles.contains(&SelectionStyle::BoldHighlight));
625            assert!(styles.contains(&SelectionStyle::BorderHighlight));
626        } else {
627            panic!("Expected Combined selection style");
628        }
629    }
630
631    #[test]
632    fn test_calculate_style_colors_and_text_basic() {
633        let renderer = SelectionStyleRenderer::with_defaults("test".to_string());
634        let mut choice = Choice {
635            id: "test_choice".to_string(),
636            content: Some("Test Choice".to_string()),
637            selected: true,
638            hovered: false,
639            waiting: false,
640            script: None,
641            execution_mode: crate::model::common::ExecutionMode::Immediate,
642            redirect_output: None,
643            append_output: None,
644        };
645
646        let (fg, bg, text) = renderer.calculate_style_colors_and_text(
647            &choice,
648            &Some("white".to_string()),
649            &Some("black".to_string()),
650            &Some("bright_white".to_string()),
651            &Some("blue".to_string()),
652            false,
653        );
654
655        assert_eq!(fg, Some("bright_white".to_string()));
656        assert_eq!(bg, Some("blue".to_string()));
657        assert_eq!(text, "Test Choice");
658    }
659
660    #[test]
661    fn test_calculate_style_colors_and_text_with_pointer() {
662        let config = SelectionStyleConfig {
663            selection_style: SelectionStyle::PointerIndicator,
664            focus_style: FocusStyle::None,
665            feedback_style: FeedbackStyle::None,
666            animation_duration_ms: 0,
667            intensity_factor: 1.0,
668            custom_indicators: Some(SelectionIndicators::default()),
669        };
670
671        let renderer = SelectionStyleRenderer::new("test".to_string(), config);
672        let choice = Choice {
673            id: "menu_item".to_string(),
674            content: Some("Menu Item".to_string()),
675            selected: true,
676            hovered: false,
677            waiting: false,
678            script: None,
679            execution_mode: crate::model::common::ExecutionMode::Immediate,
680            redirect_output: None,
681            append_output: None,
682        };
683
684        let (fg, bg, text) = renderer.calculate_style_colors_and_text(
685            &choice,
686            &Some("white".to_string()),
687            &Some("black".to_string()),
688            &Some("bright_white".to_string()),
689            &Some("blue".to_string()),
690            false,
691        );
692
693        assert_eq!(fg, Some("bright_white".to_string()));
694        assert_eq!(bg, Some("blue".to_string()));
695        assert_eq!(text, "▶ Menu Item");
696    }
697
698    #[test]
699    fn test_inverted_colors() {
700        let config = SelectionStyleConfig {
701            selection_style: SelectionStyle::InvertColors,
702            ..SelectionStyleConfig::default()
703        };
704
705        let renderer = SelectionStyleRenderer::new("test".to_string(), config);
706        let choice = Choice {
707            id: "inverted".to_string(),
708            content: Some("Inverted".to_string()),
709            selected: true,
710            hovered: false,
711            waiting: false,
712            script: None,
713            execution_mode: crate::model::common::ExecutionMode::Immediate,
714            redirect_output: None,
715            append_output: None,
716        };
717
718        let (fg, bg, text) = renderer.calculate_style_colors_and_text(
719            &choice,
720            &Some("white".to_string()),
721            &Some("black".to_string()),
722            &Some("bright_white".to_string()),
723            &Some("blue".to_string()),
724            false,
725        );
726
727        // Colors should be swapped
728        assert_eq!(fg, Some("blue".to_string()));
729        assert_eq!(bg, Some("bright_white".to_string()));
730        assert_eq!(text, "Inverted");
731    }
732
733    #[test]
734    fn test_waiting_state_indicator() {
735        let renderer = SelectionStyleRenderer::with_defaults("test".to_string());
736        let choice = Choice {
737            id: "loading".to_string(),
738            content: Some("Loading".to_string()),
739            selected: false,
740            hovered: false,
741            waiting: true,
742            script: None,
743            execution_mode: crate::model::common::ExecutionMode::Immediate,
744            redirect_output: None,
745            append_output: None,
746        };
747
748        let (fg, bg, text) = renderer.calculate_style_colors_and_text(
749            &choice,
750            &Some("white".to_string()),
751            &Some("black".to_string()),
752            &Some("bright_white".to_string()),
753            &Some("blue".to_string()),
754            false,
755        );
756
757        assert_eq!(text, "Loading...");
758    }
759
760    #[test]
761    fn test_focus_intensity() {
762        let config = SelectionStyleConfig {
763            selection_style: SelectionStyle::ColorHighlight,
764            focus_style: FocusStyle::Intensity,
765            ..SelectionStyleConfig::default()
766        };
767
768        let renderer = SelectionStyleRenderer::new("test".to_string(), config);
769        let choice = Choice {
770            id: "focused".to_string(),
771            content: Some("Focused".to_string()),
772            selected: true,
773            hovered: false,
774            waiting: false,
775            script: None,
776            execution_mode: crate::model::common::ExecutionMode::Immediate,
777            redirect_output: None,
778            append_output: None,
779        };
780
781        let (fg, bg, text) = renderer.calculate_style_colors_and_text(
782            &choice,
783            &Some("white".to_string()),
784            &Some("black".to_string()),
785            &Some("red".to_string()),
786            &Some("blue".to_string()),
787            true, // is_focused = true
788        );
789
790        // Should apply intensity to red -> bright_red
791        assert_eq!(fg, Some("bright_red".to_string()));
792        assert_eq!(bg, Some("blue".to_string()));
793    }
794}