Skip to main content

presentar_terminal/widgets/
selection.rs

1//! `Selection` - Tufte-inspired selection highlighting primitives
2//!
3//! Framework widgets for making selections VISIBLE following Tufte's principle:
4//! "Differences must be immediately perceivable" (Visual Display, 1983)
5//!
6//! # Components
7//! - `RowHighlight` - Full row background + gutter indicator
8//! - `CellHighlight` - Single cell emphasis
9//! - `FocusRing` - Panel/container focus indicator
10//!
11//! # Design Principles
12//! 1. **Multiple Redundant Cues**: Color + Shape + Position (accessibility)
13//! 2. **High Contrast**: Selection must be visible in any terminal
14//! 3. **Consistent Language**: Same visual language across all widgets
15
16use presentar_core::{Canvas, Color, Point, Rect, TextStyle};
17
18// =============================================================================
19// TTOP-MATCHING SELECTION COLORS
20// =============================================================================
21// ttop uses SUBTLE selection: barely visible dark bg + ▶ gutter indicator
22// The gutter indicator (▶) is the PRIMARY visual cue, not the background
23
24/// Subtle dark selection background (matches ttop's barely-visible highlight)
25/// Just slightly brighter than DIMMED_BG to indicate selection
26pub const SELECTION_BG: Color = Color {
27    r: 0.15,
28    g: 0.12,
29    b: 0.22,
30    a: 1.0,
31}; // Subtle dark purple - barely visible, ttop-style
32
33/// Bright cyan for selection indicators (cursors, borders)
34/// This is the PRIMARY visual cue for selection (the ▶ indicator)
35pub const SELECTION_ACCENT: Color = Color {
36    r: 0.4,
37    g: 0.9,
38    b: 0.4,
39    a: 1.0,
40}; // Bright green like ttop's ▶ cursor
41
42/// Gutter indicator color (same as accent for consistency with ttop)
43pub const SELECTION_GUTTER: Color = Color {
44    r: 0.4,
45    g: 0.9,
46    b: 0.4,
47    a: 1.0,
48}; // Bright green ▶
49
50/// Dimmed background for non-selected items
51pub const DIMMED_BG: Color = Color {
52    r: 0.08,
53    g: 0.08,
54    b: 0.1,
55    a: 1.0,
56};
57
58// =============================================================================
59// ROW HIGHLIGHT
60// =============================================================================
61
62/// Tufte-compliant row highlighting with multiple visual cues
63///
64/// Visual elements:
65/// 1. Strong background color (immediate visibility)
66/// 2. Left gutter indicator `▐` or `│` (spatial cue)
67/// 3. Optional right border (framing)
68/// 4. Text color change (contrast)
69#[derive(Debug, Clone)]
70pub struct RowHighlight {
71    /// The row rectangle
72    pub bounds: Rect,
73    /// Is this row selected?
74    pub selected: bool,
75    /// Show gutter indicator
76    pub show_gutter: bool,
77    /// Gutter character (default: ▐)
78    pub gutter_char: char,
79}
80
81impl RowHighlight {
82    pub fn new(bounds: Rect, selected: bool) -> Self {
83        Self {
84            bounds,
85            selected,
86            show_gutter: true,
87            gutter_char: '▐',
88        }
89    }
90
91    pub fn with_gutter(mut self, show: bool) -> Self {
92        self.show_gutter = show;
93        self
94    }
95
96    pub fn with_gutter_char(mut self, ch: char) -> Self {
97        self.gutter_char = ch;
98        self
99    }
100
101    /// Paint the row highlight to a canvas
102    ///
103    /// CRITICAL: Always paints to clear previous frame artifacts.
104    /// Terminal buffers retain pixels - non-selected rows need explicit background.
105    pub fn paint(&self, canvas: &mut dyn Canvas) {
106        if self.selected {
107            // 1. Strong background fill for selected row
108            canvas.fill_rect(self.bounds, SELECTION_BG);
109
110            // 2. Left gutter indicator
111            if self.show_gutter {
112                canvas.draw_text(
113                    &self.gutter_char.to_string(),
114                    Point::new(self.bounds.x - 1.0, self.bounds.y),
115                    &TextStyle {
116                        color: SELECTION_GUTTER,
117                        ..Default::default()
118                    },
119                );
120            }
121        } else {
122            // Clear any previous selection artifact with dimmed background
123            canvas.fill_rect(self.bounds, DIMMED_BG);
124        }
125    }
126
127    /// Get the text style for content in this row
128    pub fn text_style(&self) -> TextStyle {
129        if self.selected {
130            TextStyle {
131                color: Color::WHITE,
132                ..Default::default()
133            }
134        } else {
135            TextStyle {
136                color: Color::new(0.85, 0.85, 0.85, 1.0),
137                ..Default::default()
138            }
139        }
140    }
141}
142
143// =============================================================================
144// FOCUS RING (Panel Focus)
145// =============================================================================
146
147/// Focus indicator for panels/containers
148///
149/// Uses Tufte's layering principle:
150/// 1. Border style change (Double vs Single)
151/// 2. Color intensity change (bright vs dim)
152/// 3. Optional indicator character (►)
153#[derive(Debug, Clone)]
154pub struct FocusRing {
155    /// Panel bounds
156    pub bounds: Rect,
157    /// Is focused?
158    pub focused: bool,
159    /// Base color (panel's theme color)
160    pub base_color: Color,
161}
162
163impl FocusRing {
164    pub fn new(bounds: Rect, focused: bool, base_color: Color) -> Self {
165        Self {
166            bounds,
167            focused,
168            base_color,
169        }
170    }
171
172    /// Get the border color based on focus state
173    pub fn border_color(&self) -> Color {
174        if self.focused {
175            // Blend with cyan accent for visibility
176            Color {
177                r: (self.base_color.r * 0.4 + SELECTION_ACCENT.r * 0.6).min(1.0),
178                g: (self.base_color.g * 0.4 + SELECTION_ACCENT.g * 0.6).min(1.0),
179                b: (self.base_color.b * 0.4 + SELECTION_ACCENT.b * 0.6).min(1.0),
180                a: 1.0,
181            }
182        } else {
183            // Dim unfocused panels
184            Color {
185                r: self.base_color.r * 0.4,
186                g: self.base_color.g * 0.4,
187                b: self.base_color.b * 0.4,
188                a: 1.0,
189            }
190        }
191    }
192
193    /// Get title prefix (► for focused)
194    pub fn title_prefix(&self) -> &'static str {
195        if self.focused {
196            "► "
197        } else {
198            ""
199        }
200    }
201}
202
203// =============================================================================
204// COLUMN HIGHLIGHT
205// =============================================================================
206
207/// Column header highlight for sortable tables
208#[derive(Debug, Clone)]
209pub struct ColumnHighlight {
210    /// Column bounds
211    pub bounds: Rect,
212    /// Is this column selected for navigation?
213    pub selected: bool,
214    /// Is this column the sort column?
215    pub sorted: bool,
216    /// Sort direction (true = descending)
217    pub sort_descending: bool,
218}
219
220impl ColumnHighlight {
221    pub fn new(bounds: Rect) -> Self {
222        Self {
223            bounds,
224            selected: false,
225            sorted: false,
226            sort_descending: true,
227        }
228    }
229
230    pub fn with_selected(mut self, selected: bool) -> Self {
231        self.selected = selected;
232        self
233    }
234
235    pub fn with_sorted(mut self, sorted: bool, descending: bool) -> Self {
236        self.sorted = sorted;
237        self.sort_descending = descending;
238        self
239    }
240
241    /// Get background color
242    pub fn background(&self) -> Option<Color> {
243        if self.selected {
244            Some(Color::new(0.15, 0.35, 0.55, 1.0))
245        } else {
246            None
247        }
248    }
249
250    /// Get sort indicator character
251    pub fn sort_indicator(&self) -> &'static str {
252        if self.sorted {
253            if self.sort_descending {
254                "▼"
255            } else {
256                "▲"
257            }
258        } else {
259            ""
260        }
261    }
262
263    /// Get text style
264    pub fn text_style(&self) -> TextStyle {
265        let color = if self.sorted {
266            SELECTION_ACCENT
267        } else if self.selected {
268            Color::WHITE
269        } else {
270            Color::new(0.6, 0.6, 0.6, 1.0)
271        };
272
273        TextStyle {
274            color,
275            ..Default::default()
276        }
277    }
278}
279
280// =============================================================================
281// CURSOR INDICATOR
282// =============================================================================
283
284/// Universal cursor/pointer indicator
285pub struct Cursor;
286
287impl Cursor {
288    /// Row cursor (appears in gutter)
289    pub const ROW: &'static str = "▶";
290
291    /// Column cursor (appears above header)
292    pub const COLUMN: &'static str = "▼";
293
294    /// Panel cursor (appears in title)
295    pub const PANEL: &'static str = "►";
296
297    /// Get cursor color
298    pub fn color() -> Color {
299        SELECTION_ACCENT
300    }
301
302    /// Paint a row cursor at position
303    pub fn paint_row(canvas: &mut dyn Canvas, pos: Point) {
304        canvas.draw_text(
305            Self::ROW,
306            pos,
307            &TextStyle {
308                color: Self::color(),
309                ..Default::default()
310            },
311        );
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    // =========================================================================
320    // COLOR CONSTANTS TESTS (ttop-matching subtle style)
321    // =========================================================================
322
323    #[test]
324    fn test_row_highlight_colors() {
325        // Selection background should be subtle dark purple (ttop-style)
326        // Just slightly different from DIMMED_BG, barely visible
327        assert!(SELECTION_BG.r < 0.25, "Selection bg should be dark");
328        assert!(
329            SELECTION_BG.b > SELECTION_BG.r,
330            "Selection bg should have purple tint"
331        );
332    }
333
334    #[test]
335    fn test_selection_accent_is_green() {
336        // ttop uses bright green for the ▶ indicator
337        assert!(SELECTION_ACCENT.g > 0.8, "Accent should be bright green");
338        assert!(
339            SELECTION_ACCENT.r > 0.3,
340            "Accent has some red for visibility"
341        );
342    }
343
344    #[test]
345    fn test_selection_gutter_matches_accent() {
346        // Gutter should match accent for consistency (ttop-style)
347        assert_eq!(SELECTION_GUTTER.r, SELECTION_ACCENT.r);
348        assert_eq!(SELECTION_GUTTER.g, SELECTION_ACCENT.g);
349        assert_eq!(SELECTION_GUTTER.b, SELECTION_ACCENT.b);
350    }
351
352    #[test]
353    fn test_dimmed_bg_is_dark() {
354        assert!(DIMMED_BG.r < 0.15);
355        assert!(DIMMED_BG.g < 0.15);
356        assert!(DIMMED_BG.b < 0.15);
357    }
358
359    // =========================================================================
360    // ROW HIGHLIGHT TESTS
361    // =========================================================================
362
363    #[test]
364    fn test_row_highlight_new() {
365        let bounds = Rect::new(0.0, 0.0, 100.0, 1.0);
366        let highlight = RowHighlight::new(bounds, true);
367
368        assert_eq!(highlight.bounds, bounds);
369        assert!(highlight.selected);
370        assert!(highlight.show_gutter);
371        assert_eq!(highlight.gutter_char, '▐');
372    }
373
374    #[test]
375    fn test_row_highlight_not_selected() {
376        let bounds = Rect::new(0.0, 0.0, 100.0, 1.0);
377        let highlight = RowHighlight::new(bounds, false);
378
379        assert!(!highlight.selected);
380    }
381
382    #[test]
383    fn test_row_highlight_with_gutter() {
384        let highlight = RowHighlight::new(Rect::default(), true).with_gutter(false);
385        assert!(!highlight.show_gutter);
386
387        let highlight2 = highlight.with_gutter(true);
388        assert!(highlight2.show_gutter);
389    }
390
391    #[test]
392    fn test_row_highlight_with_gutter_char() {
393        let highlight = RowHighlight::new(Rect::default(), true).with_gutter_char('│');
394        assert_eq!(highlight.gutter_char, '│');
395    }
396
397    #[test]
398    fn test_row_highlight_text_style_selected() {
399        let highlight = RowHighlight::new(Rect::default(), true);
400        let style = highlight.text_style();
401        assert_eq!(style.color, Color::WHITE);
402    }
403
404    #[test]
405    fn test_row_highlight_text_style_not_selected() {
406        let highlight = RowHighlight::new(Rect::default(), false);
407        let style = highlight.text_style();
408        // Should be gray-ish
409        assert!(style.color.r > 0.8);
410        assert!(style.color.g > 0.8);
411        assert!(style.color.b > 0.8);
412    }
413
414    #[test]
415    fn test_row_highlight_paint_selected() {
416        use crate::direct::{CellBuffer, DirectTerminalCanvas};
417
418        let mut buffer = CellBuffer::new(20, 5);
419        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
420
421        let bounds = Rect::new(2.0, 1.0, 10.0, 1.0);
422        let highlight = RowHighlight::new(bounds, true);
423        highlight.paint(&mut canvas);
424
425        // The gutter char should be drawn at x-1
426        // And background should be filled
427    }
428
429    #[test]
430    fn test_row_highlight_paint_not_selected() {
431        use crate::direct::{CellBuffer, DirectTerminalCanvas};
432
433        let mut buffer = CellBuffer::new(20, 5);
434        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
435
436        let bounds = Rect::new(2.0, 1.0, 10.0, 1.0);
437        let highlight = RowHighlight::new(bounds, false);
438        highlight.paint(&mut canvas);
439
440        // Should paint dimmed background
441    }
442
443    #[test]
444    fn test_row_highlight_paint_selected_no_gutter() {
445        use crate::direct::{CellBuffer, DirectTerminalCanvas};
446
447        let mut buffer = CellBuffer::new(20, 5);
448        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
449
450        let bounds = Rect::new(2.0, 1.0, 10.0, 1.0);
451        let highlight = RowHighlight::new(bounds, true).with_gutter(false);
452        highlight.paint(&mut canvas);
453
454        // Should only fill background, no gutter indicator
455    }
456
457    // =========================================================================
458    // FOCUS RING TESTS
459    // =========================================================================
460
461    #[test]
462    fn test_focus_ring_new() {
463        let bounds = Rect::new(0.0, 0.0, 50.0, 20.0);
464        let color = Color::new(0.5, 0.5, 1.0, 1.0);
465        let ring = FocusRing::new(bounds, true, color);
466
467        assert_eq!(ring.bounds, bounds);
468        assert!(ring.focused);
469        assert_eq!(ring.base_color, color);
470    }
471
472    #[test]
473    fn test_focus_ring_color_blend() {
474        let base = Color::new(0.5, 0.5, 1.0, 1.0); // Purple-ish
475        let ring = FocusRing::new(Rect::default(), true, base);
476
477        let color = ring.border_color();
478        // Should be blended toward cyan
479        assert!(color.g > base.g);
480    }
481
482    #[test]
483    fn test_focus_ring_not_focused_is_dimmed() {
484        let base = Color::new(1.0, 0.0, 0.0, 1.0); // Red
485        let ring = FocusRing::new(Rect::default(), false, base);
486
487        let color = ring.border_color();
488        // Should be dimmed to 40%
489        assert!((color.r - 0.4).abs() < 0.01);
490        assert!(color.g < 0.01);
491        assert!(color.b < 0.01);
492    }
493
494    #[test]
495    fn test_focus_ring_title_prefix_focused() {
496        let ring = FocusRing::new(Rect::default(), true, Color::WHITE);
497        assert_eq!(ring.title_prefix(), "► ");
498    }
499
500    #[test]
501    fn test_focus_ring_title_prefix_not_focused() {
502        let ring = FocusRing::new(Rect::default(), false, Color::WHITE);
503        assert_eq!(ring.title_prefix(), "");
504    }
505
506    // =========================================================================
507    // COLUMN HIGHLIGHT TESTS
508    // =========================================================================
509
510    #[test]
511    fn test_column_highlight_new() {
512        let bounds = Rect::new(10.0, 0.0, 20.0, 1.0);
513        let col = ColumnHighlight::new(bounds);
514
515        assert_eq!(col.bounds, bounds);
516        assert!(!col.selected);
517        assert!(!col.sorted);
518        assert!(col.sort_descending);
519    }
520
521    #[test]
522    fn test_column_highlight_with_selected() {
523        let col = ColumnHighlight::new(Rect::default()).with_selected(true);
524        assert!(col.selected);
525
526        let col2 = col.with_selected(false);
527        assert!(!col2.selected);
528    }
529
530    #[test]
531    fn test_column_highlight_with_sorted() {
532        let col = ColumnHighlight::new(Rect::default()).with_sorted(true, true);
533        assert!(col.sorted);
534        assert!(col.sort_descending);
535
536        let col2 = col.with_sorted(true, false);
537        assert!(col2.sorted);
538        assert!(!col2.sort_descending);
539    }
540
541    #[test]
542    fn test_column_highlight_sort_indicator_descending() {
543        let col = ColumnHighlight::new(Rect::default()).with_sorted(true, true);
544        assert_eq!(col.sort_indicator(), "▼");
545    }
546
547    #[test]
548    fn test_column_highlight_sort_indicator_ascending() {
549        let col = ColumnHighlight::new(Rect::default()).with_sorted(true, false);
550        assert_eq!(col.sort_indicator(), "▲");
551    }
552
553    #[test]
554    fn test_column_highlight_sort_indicator_not_sorted() {
555        let col = ColumnHighlight::new(Rect::default());
556        assert_eq!(col.sort_indicator(), "");
557    }
558
559    #[test]
560    fn test_column_highlight_background_selected() {
561        let col = ColumnHighlight::new(Rect::default()).with_selected(true);
562        let bg = col.background();
563        assert!(bg.is_some());
564        let bg = bg.unwrap();
565        assert!(bg.b > bg.r); // Blue-ish
566    }
567
568    #[test]
569    fn test_column_highlight_background_not_selected() {
570        let col = ColumnHighlight::new(Rect::default());
571        assert!(col.background().is_none());
572    }
573
574    #[test]
575    fn test_column_highlight_text_style_sorted() {
576        let col = ColumnHighlight::new(Rect::default()).with_sorted(true, true);
577        let style = col.text_style();
578        assert_eq!(style.color, SELECTION_ACCENT);
579    }
580
581    #[test]
582    fn test_column_highlight_text_style_selected() {
583        let col = ColumnHighlight::new(Rect::default()).with_selected(true);
584        let style = col.text_style();
585        assert_eq!(style.color, Color::WHITE);
586    }
587
588    #[test]
589    fn test_column_highlight_text_style_neither() {
590        let col = ColumnHighlight::new(Rect::default());
591        let style = col.text_style();
592        // Should be gray
593        assert!(style.color.r > 0.5 && style.color.r < 0.7);
594    }
595
596    // =========================================================================
597    // CURSOR TESTS
598    // =========================================================================
599
600    #[test]
601    fn test_cursor_constants() {
602        assert_eq!(Cursor::ROW, "▶");
603        assert_eq!(Cursor::COLUMN, "▼");
604        assert_eq!(Cursor::PANEL, "►");
605    }
606
607    #[test]
608    fn test_cursor_color() {
609        assert_eq!(Cursor::color(), SELECTION_ACCENT);
610    }
611
612    #[test]
613    fn test_cursor_paint_row() {
614        use crate::direct::{CellBuffer, DirectTerminalCanvas};
615
616        let mut buffer = CellBuffer::new(20, 5);
617        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
618
619        Cursor::paint_row(&mut canvas, Point::new(0.0, 0.0));
620        // Cursor should be painted at position
621    }
622}