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
285#[derive(Debug)]
286pub struct Cursor;
287
288impl Cursor {
289    /// Row cursor (appears in gutter)
290    pub const ROW: &'static str = "▶";
291
292    /// Column cursor (appears above header)
293    pub const COLUMN: &'static str = "▼";
294
295    /// Panel cursor (appears in title)
296    pub const PANEL: &'static str = "►";
297
298    /// Get cursor color
299    pub fn color() -> Color {
300        SELECTION_ACCENT
301    }
302
303    /// Paint a row cursor at position
304    pub fn paint_row(canvas: &mut dyn Canvas, pos: Point) {
305        canvas.draw_text(
306            Self::ROW,
307            pos,
308            &TextStyle {
309                color: Self::color(),
310                ..Default::default()
311            },
312        );
313    }
314}
315
316#[cfg(test)]
317#[allow(clippy::unwrap_used, clippy::disallowed_methods)]
318mod tests {
319    use super::*;
320
321    // =========================================================================
322    // COLOR CONSTANTS TESTS (ttop-matching subtle style)
323    // =========================================================================
324
325    #[test]
326    #[allow(clippy::assertions_on_constants)]
327    fn test_row_highlight_colors() {
328        // Selection background should be subtle dark purple (ttop-style)
329        // Just slightly different from DIMMED_BG, barely visible
330        assert!(SELECTION_BG.r < 0.25, "Selection bg should be dark");
331        assert!(
332            SELECTION_BG.b > SELECTION_BG.r,
333            "Selection bg should have purple tint"
334        );
335    }
336
337    #[test]
338    #[allow(clippy::assertions_on_constants)]
339    fn test_selection_accent_is_green() {
340        // ttop uses bright green for the ▶ indicator
341        assert!(SELECTION_ACCENT.g > 0.8, "Accent should be bright green");
342        assert!(
343            SELECTION_ACCENT.r > 0.3,
344            "Accent has some red for visibility"
345        );
346    }
347
348    #[test]
349    fn test_selection_gutter_matches_accent() {
350        // Gutter should match accent for consistency (ttop-style)
351        assert_eq!(SELECTION_GUTTER.r, SELECTION_ACCENT.r);
352        assert_eq!(SELECTION_GUTTER.g, SELECTION_ACCENT.g);
353        assert_eq!(SELECTION_GUTTER.b, SELECTION_ACCENT.b);
354    }
355
356    #[test]
357    #[allow(clippy::assertions_on_constants)]
358    fn test_dimmed_bg_is_dark() {
359        assert!(DIMMED_BG.r < 0.15);
360        assert!(DIMMED_BG.g < 0.15);
361        assert!(DIMMED_BG.b < 0.15);
362    }
363
364    // =========================================================================
365    // ROW HIGHLIGHT TESTS
366    // =========================================================================
367
368    #[test]
369    fn test_row_highlight_new() {
370        let bounds = Rect::new(0.0, 0.0, 100.0, 1.0);
371        let highlight = RowHighlight::new(bounds, true);
372
373        assert_eq!(highlight.bounds, bounds);
374        assert!(highlight.selected);
375        assert!(highlight.show_gutter);
376        assert_eq!(highlight.gutter_char, '▐');
377    }
378
379    #[test]
380    fn test_row_highlight_not_selected() {
381        let bounds = Rect::new(0.0, 0.0, 100.0, 1.0);
382        let highlight = RowHighlight::new(bounds, false);
383
384        assert!(!highlight.selected);
385    }
386
387    #[test]
388    fn test_row_highlight_with_gutter() {
389        let highlight = RowHighlight::new(Rect::default(), true).with_gutter(false);
390        assert!(!highlight.show_gutter);
391
392        let highlight2 = highlight.with_gutter(true);
393        assert!(highlight2.show_gutter);
394    }
395
396    #[test]
397    fn test_row_highlight_with_gutter_char() {
398        let highlight = RowHighlight::new(Rect::default(), true).with_gutter_char('│');
399        assert_eq!(highlight.gutter_char, '│');
400    }
401
402    #[test]
403    fn test_row_highlight_text_style_selected() {
404        let highlight = RowHighlight::new(Rect::default(), true);
405        let style = highlight.text_style();
406        assert_eq!(style.color, Color::WHITE);
407    }
408
409    #[test]
410    fn test_row_highlight_text_style_not_selected() {
411        let highlight = RowHighlight::new(Rect::default(), false);
412        let style = highlight.text_style();
413        // Should be gray-ish
414        assert!(style.color.r > 0.8);
415        assert!(style.color.g > 0.8);
416        assert!(style.color.b > 0.8);
417    }
418
419    #[test]
420    fn test_row_highlight_paint_selected() {
421        use crate::direct::{CellBuffer, DirectTerminalCanvas};
422
423        let mut buffer = CellBuffer::new(20, 5);
424        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
425
426        let bounds = Rect::new(2.0, 1.0, 10.0, 1.0);
427        let highlight = RowHighlight::new(bounds, true);
428        highlight.paint(&mut canvas);
429
430        // The gutter char should be drawn at x-1
431        // And background should be filled
432    }
433
434    #[test]
435    fn test_row_highlight_paint_not_selected() {
436        use crate::direct::{CellBuffer, DirectTerminalCanvas};
437
438        let mut buffer = CellBuffer::new(20, 5);
439        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
440
441        let bounds = Rect::new(2.0, 1.0, 10.0, 1.0);
442        let highlight = RowHighlight::new(bounds, false);
443        highlight.paint(&mut canvas);
444
445        // Should paint dimmed background
446    }
447
448    #[test]
449    fn test_row_highlight_paint_selected_no_gutter() {
450        use crate::direct::{CellBuffer, DirectTerminalCanvas};
451
452        let mut buffer = CellBuffer::new(20, 5);
453        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
454
455        let bounds = Rect::new(2.0, 1.0, 10.0, 1.0);
456        let highlight = RowHighlight::new(bounds, true).with_gutter(false);
457        highlight.paint(&mut canvas);
458
459        // Should only fill background, no gutter indicator
460    }
461
462    // =========================================================================
463    // FOCUS RING TESTS
464    // =========================================================================
465
466    #[test]
467    fn test_focus_ring_new() {
468        let bounds = Rect::new(0.0, 0.0, 50.0, 20.0);
469        let color = Color::new(0.5, 0.5, 1.0, 1.0);
470        let ring = FocusRing::new(bounds, true, color);
471
472        assert_eq!(ring.bounds, bounds);
473        assert!(ring.focused);
474        assert_eq!(ring.base_color, color);
475    }
476
477    #[test]
478    fn test_focus_ring_color_blend() {
479        let base = Color::new(0.5, 0.5, 1.0, 1.0); // Purple-ish
480        let ring = FocusRing::new(Rect::default(), true, base);
481
482        let color = ring.border_color();
483        // Should be blended toward cyan
484        assert!(color.g > base.g);
485    }
486
487    #[test]
488    fn test_focus_ring_not_focused_is_dimmed() {
489        let base = Color::new(1.0, 0.0, 0.0, 1.0); // Red
490        let ring = FocusRing::new(Rect::default(), false, base);
491
492        let color = ring.border_color();
493        // Should be dimmed to 40%
494        assert!((color.r - 0.4).abs() < 0.01);
495        assert!(color.g < 0.01);
496        assert!(color.b < 0.01);
497    }
498
499    #[test]
500    fn test_focus_ring_title_prefix_focused() {
501        let ring = FocusRing::new(Rect::default(), true, Color::WHITE);
502        assert_eq!(ring.title_prefix(), "► ");
503    }
504
505    #[test]
506    fn test_focus_ring_title_prefix_not_focused() {
507        let ring = FocusRing::new(Rect::default(), false, Color::WHITE);
508        assert_eq!(ring.title_prefix(), "");
509    }
510
511    // =========================================================================
512    // COLUMN HIGHLIGHT TESTS
513    // =========================================================================
514
515    #[test]
516    fn test_column_highlight_new() {
517        let bounds = Rect::new(10.0, 0.0, 20.0, 1.0);
518        let col = ColumnHighlight::new(bounds);
519
520        assert_eq!(col.bounds, bounds);
521        assert!(!col.selected);
522        assert!(!col.sorted);
523        assert!(col.sort_descending);
524    }
525
526    #[test]
527    fn test_column_highlight_with_selected() {
528        let col = ColumnHighlight::new(Rect::default()).with_selected(true);
529        assert!(col.selected);
530
531        let col2 = col.with_selected(false);
532        assert!(!col2.selected);
533    }
534
535    #[test]
536    fn test_column_highlight_with_sorted() {
537        let col = ColumnHighlight::new(Rect::default()).with_sorted(true, true);
538        assert!(col.sorted);
539        assert!(col.sort_descending);
540
541        let col2 = col.with_sorted(true, false);
542        assert!(col2.sorted);
543        assert!(!col2.sort_descending);
544    }
545
546    #[test]
547    fn test_column_highlight_sort_indicator_descending() {
548        let col = ColumnHighlight::new(Rect::default()).with_sorted(true, true);
549        assert_eq!(col.sort_indicator(), "▼");
550    }
551
552    #[test]
553    fn test_column_highlight_sort_indicator_ascending() {
554        let col = ColumnHighlight::new(Rect::default()).with_sorted(true, false);
555        assert_eq!(col.sort_indicator(), "▲");
556    }
557
558    #[test]
559    fn test_column_highlight_sort_indicator_not_sorted() {
560        let col = ColumnHighlight::new(Rect::default());
561        assert_eq!(col.sort_indicator(), "");
562    }
563
564    #[test]
565    fn test_column_highlight_background_selected() {
566        let col = ColumnHighlight::new(Rect::default()).with_selected(true);
567        let bg = col.background();
568        assert!(bg.is_some());
569        let bg = bg.unwrap();
570        assert!(bg.b > bg.r); // Blue-ish
571    }
572
573    #[test]
574    fn test_column_highlight_background_not_selected() {
575        let col = ColumnHighlight::new(Rect::default());
576        assert!(col.background().is_none());
577    }
578
579    #[test]
580    fn test_column_highlight_text_style_sorted() {
581        let col = ColumnHighlight::new(Rect::default()).with_sorted(true, true);
582        let style = col.text_style();
583        assert_eq!(style.color, SELECTION_ACCENT);
584    }
585
586    #[test]
587    fn test_column_highlight_text_style_selected() {
588        let col = ColumnHighlight::new(Rect::default()).with_selected(true);
589        let style = col.text_style();
590        assert_eq!(style.color, Color::WHITE);
591    }
592
593    #[test]
594    fn test_column_highlight_text_style_neither() {
595        let col = ColumnHighlight::new(Rect::default());
596        let style = col.text_style();
597        // Should be gray
598        assert!(style.color.r > 0.5 && style.color.r < 0.7);
599    }
600
601    // =========================================================================
602    // CURSOR TESTS
603    // =========================================================================
604
605    #[test]
606    fn test_cursor_constants() {
607        assert_eq!(Cursor::ROW, "▶");
608        assert_eq!(Cursor::COLUMN, "▼");
609        assert_eq!(Cursor::PANEL, "►");
610    }
611
612    #[test]
613    fn test_cursor_color() {
614        assert_eq!(Cursor::color(), SELECTION_ACCENT);
615    }
616
617    #[test]
618    fn test_cursor_paint_row() {
619        use crate::direct::{CellBuffer, DirectTerminalCanvas};
620
621        let mut buffer = CellBuffer::new(20, 5);
622        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
623
624        Cursor::paint_row(&mut canvas, Point::new(0.0, 0.0));
625        // Cursor should be painted at position
626    }
627}