Skip to main content

presentar_terminal/widgets/
gutter_cursor.rs

1//! `GutterCursor` atomic widget.
2//!
3//! Tufte-style selection indicator for row-based navigation.
4//! Reference: SPEC-024 Appendix I (Atomic Widget Mandate).
5//!
6//! # Falsification Criteria
7//! - F-ATOM-GUT-001: Y-position MUST match selected row exactly.
8//! - F-ATOM-GUT-002: Cursor MUST be visible when selection is within viewport.
9
10use presentar_core::{
11    Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event,
12    LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
13};
14use std::any::Any;
15use std::time::Duration;
16
17/// Cursor style variants.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19pub enum CursorStyle {
20    /// Triangle pointer (▶)
21    #[default]
22    Triangle,
23    /// Line indicator (│)
24    Line,
25    /// Dot (●)
26    Dot,
27    /// Arrow (→)
28    Arrow,
29    /// Bracket ([)
30    Bracket,
31    /// Double arrow (»)
32    DoubleArrow,
33}
34
35impl CursorStyle {
36    /// Get the character for this cursor style.
37    #[must_use]
38    pub const fn char(&self) -> char {
39        match self {
40            Self::Triangle => '▶',
41            Self::Line => '│',
42            Self::Dot => '●',
43            Self::Arrow => '→',
44            Self::Bracket => '[',
45            Self::DoubleArrow => '»',
46        }
47    }
48
49    /// Get the width in characters.
50    #[must_use]
51    pub const fn width(&self) -> usize {
52        1 // All styles are single character
53    }
54}
55
56/// Selection state for the cursor.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58pub enum SelectionState {
59    /// No selection
60    #[default]
61    None,
62    /// Row is selected
63    Selected,
64    /// Row is focused (keyboard navigation)
65    Focused,
66    /// Row is both selected and focused
67    FocusedSelected,
68}
69
70impl SelectionState {
71    /// Check if this state indicates selection.
72    #[must_use]
73    pub const fn is_selected(&self) -> bool {
74        matches!(self, Self::Selected | Self::FocusedSelected)
75    }
76
77    /// Check if this state indicates focus.
78    #[must_use]
79    pub const fn is_focused(&self) -> bool {
80        matches!(self, Self::Focused | Self::FocusedSelected)
81    }
82}
83
84/// `GutterCursor` - selection indicator for row-based lists.
85///
86/// Renders a visual indicator (▶, │, etc.) in the gutter to show
87/// which row is currently selected or focused.
88#[derive(Debug, Clone)]
89pub struct GutterCursor {
90    /// Currently selected row (0-indexed, relative to viewport).
91    selected_row: Option<usize>,
92    /// Total visible rows in viewport.
93    visible_rows: usize,
94    /// Cursor style.
95    style: CursorStyle,
96    /// Cursor color when selected.
97    selected_color: Color,
98    /// Cursor color when focused.
99    focused_color: Color,
100    /// Selection state per row (optional, for multi-select).
101    row_states: Vec<SelectionState>,
102    /// Cached bounds.
103    bounds: Rect,
104}
105
106impl Default for GutterCursor {
107    fn default() -> Self {
108        Self::new()
109    }
110}
111
112impl GutterCursor {
113    /// Create a new gutter cursor.
114    #[must_use]
115    pub fn new() -> Self {
116        Self {
117            selected_row: None,
118            visible_rows: 0,
119            style: CursorStyle::Triangle,
120            selected_color: Color::new(0.3, 0.8, 1.0, 1.0), // Cyan
121            focused_color: Color::new(1.0, 0.8, 0.2, 1.0),  // Gold
122            row_states: Vec::new(),
123            bounds: Rect::default(),
124        }
125    }
126
127    /// Set the selected row (viewport-relative).
128    #[must_use]
129    pub fn with_selected(mut self, row: usize) -> Self {
130        self.selected_row = Some(row);
131        self
132    }
133
134    /// Clear selection.
135    #[must_use]
136    pub fn with_no_selection(mut self) -> Self {
137        self.selected_row = None;
138        self
139    }
140
141    /// Set visible row count.
142    #[must_use]
143    pub fn with_visible_rows(mut self, count: usize) -> Self {
144        self.visible_rows = count;
145        self
146    }
147
148    /// Set cursor style.
149    #[must_use]
150    pub fn with_style(mut self, style: CursorStyle) -> Self {
151        self.style = style;
152        self
153    }
154
155    /// Set selected color.
156    #[must_use]
157    pub fn with_selected_color(mut self, color: Color) -> Self {
158        self.selected_color = color;
159        self
160    }
161
162    /// Set focused color.
163    #[must_use]
164    pub fn with_focused_color(mut self, color: Color) -> Self {
165        self.focused_color = color;
166        self
167    }
168
169    /// Set row states for multi-select.
170    #[must_use]
171    pub fn with_row_states(mut self, states: Vec<SelectionState>) -> Self {
172        self.row_states = states;
173        self
174    }
175
176    /// Get selection state for a row.
177    fn get_row_state(&self, row: usize) -> SelectionState {
178        if let Some(selected) = self.selected_row {
179            if row == selected {
180                return SelectionState::Focused;
181            }
182        }
183        self.row_states
184            .get(row)
185            .copied()
186            .unwrap_or(SelectionState::None)
187    }
188
189    /// Get color for a selection state.
190    fn color_for_state(&self, state: SelectionState) -> Color {
191        match state {
192            SelectionState::None => Color::new(0.2, 0.2, 0.2, 1.0), // Dim
193            SelectionState::Selected => self.selected_color,
194            // Focus takes priority over selection
195            SelectionState::Focused | SelectionState::FocusedSelected => self.focused_color,
196        }
197    }
198}
199
200impl Widget for GutterCursor {
201    fn type_id(&self) -> TypeId {
202        TypeId::of::<Self>()
203    }
204
205    fn measure(&self, constraints: Constraints) -> Size {
206        // Width is 1 character for the cursor
207        // Height is the number of visible rows
208        let width = 1.0f32.min(constraints.max_width);
209        let height = (self.visible_rows as f32).min(constraints.max_height);
210        constraints.constrain(Size::new(width, height))
211    }
212
213    fn layout(&mut self, bounds: Rect) -> LayoutResult {
214        self.bounds = bounds;
215        // Update visible_rows from bounds if not explicitly set
216        if self.visible_rows == 0 {
217            self.visible_rows = bounds.height as usize;
218        }
219        LayoutResult {
220            size: Size::new(bounds.width, bounds.height),
221        }
222    }
223
224    fn paint(&self, canvas: &mut dyn Canvas) {
225        if self.bounds.width < 1.0 || self.bounds.height < 1.0 {
226            return;
227        }
228
229        let cursor_char = self.style.char().to_string();
230        let visible = self.bounds.height as usize;
231
232        for row in 0..visible {
233            let state = self.get_row_state(row);
234            let y = self.bounds.y + row as f32;
235
236            // Only draw cursor for non-None states
237            if state != SelectionState::None {
238                let style = TextStyle {
239                    color: self.color_for_state(state),
240                    ..Default::default()
241                };
242                canvas.draw_text(&cursor_char, Point::new(self.bounds.x, y), &style);
243            }
244        }
245    }
246
247    fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
248        None
249    }
250
251    fn children(&self) -> &[Box<dyn Widget>] {
252        &[]
253    }
254
255    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
256        &mut []
257    }
258}
259
260impl Brick for GutterCursor {
261    fn brick_name(&self) -> &'static str {
262        "gutter_cursor"
263    }
264
265    fn assertions(&self) -> &[BrickAssertion] {
266        static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(1)];
267        ASSERTIONS
268    }
269
270    fn budget(&self) -> BrickBudget {
271        BrickBudget::uniform(1)
272    }
273
274    fn verify(&self) -> BrickVerification {
275        // F-ATOM-GUT-001: Selected row must be within visible bounds
276        let row_in_bounds = self.selected_row.map_or(true, |r| r < self.visible_rows);
277
278        // F-ATOM-GUT-002: If selected, cursor must be drawable
279        let drawable = self.bounds.width >= 1.0;
280
281        if row_in_bounds && drawable {
282            BrickVerification {
283                passed: self.assertions().to_vec(),
284                failed: vec![],
285                verification_time: Duration::from_micros(1),
286            }
287        } else {
288            BrickVerification {
289                passed: vec![],
290                failed: self
291                    .assertions()
292                    .iter()
293                    .map(|a| (a.clone(), "Selection out of bounds".to_string()))
294                    .collect(),
295                verification_time: Duration::from_micros(1),
296            }
297        }
298    }
299
300    fn to_html(&self) -> String {
301        format!(
302            "<div class=\"gutter-cursor\" data-selected=\"{}\"></div>",
303            self.selected_row.map(|r| r.to_string()).unwrap_or_default()
304        )
305    }
306
307    fn to_css(&self) -> String {
308        String::new()
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use crate::direct::{CellBuffer, DirectTerminalCanvas};
316
317    // F-ATOM-GUT-001: Y-position matches selected row
318    #[test]
319    fn test_cursor_y_position() {
320        let mut cursor = GutterCursor::new().with_selected(3).with_visible_rows(10);
321
322        cursor.layout(Rect::new(0.0, 0.0, 1.0, 10.0));
323
324        // The cursor should render at row 3
325        let state = cursor.get_row_state(3);
326        assert_eq!(state, SelectionState::Focused);
327
328        // Other rows should be None
329        let state_other = cursor.get_row_state(5);
330        assert_eq!(state_other, SelectionState::None);
331    }
332
333    // F-ATOM-GUT-002: Cursor visible when in viewport
334    #[test]
335    fn test_cursor_visibility() {
336        let mut cursor = GutterCursor::new().with_selected(5).with_visible_rows(10);
337
338        cursor.layout(Rect::new(0.0, 0.0, 1.0, 10.0));
339
340        let mut buffer = CellBuffer::new(1, 10);
341        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
342        cursor.paint(&mut canvas);
343
344        // Should render without panic
345        // In a real test we'd verify the buffer contents
346    }
347
348    // Cursor style characters
349    #[test]
350    fn test_cursor_styles() {
351        assert_eq!(CursorStyle::Triangle.char(), '▶');
352        assert_eq!(CursorStyle::Line.char(), '│');
353        assert_eq!(CursorStyle::Dot.char(), '●');
354        assert_eq!(CursorStyle::Arrow.char(), '→');
355        assert_eq!(CursorStyle::Bracket.char(), '[');
356        assert_eq!(CursorStyle::DoubleArrow.char(), '»');
357    }
358
359    // Selection state checks
360    #[test]
361    fn test_selection_state() {
362        assert!(!SelectionState::None.is_selected());
363        assert!(SelectionState::Selected.is_selected());
364        assert!(!SelectionState::Focused.is_selected());
365        assert!(SelectionState::FocusedSelected.is_selected());
366
367        assert!(!SelectionState::None.is_focused());
368        assert!(!SelectionState::Selected.is_focused());
369        assert!(SelectionState::Focused.is_focused());
370        assert!(SelectionState::FocusedSelected.is_focused());
371    }
372
373    // Multi-select row states
374    #[test]
375    fn test_multi_select() {
376        let cursor = GutterCursor::new()
377            .with_row_states(vec![
378                SelectionState::None,
379                SelectionState::Selected,
380                SelectionState::Selected,
381                SelectionState::None,
382            ])
383            .with_visible_rows(4);
384
385        assert_eq!(cursor.get_row_state(0), SelectionState::None);
386        assert_eq!(cursor.get_row_state(1), SelectionState::Selected);
387        assert_eq!(cursor.get_row_state(2), SelectionState::Selected);
388        assert_eq!(cursor.get_row_state(3), SelectionState::None);
389    }
390
391    // Brick verification
392    #[test]
393    fn test_brick_verification() {
394        let mut cursor = GutterCursor::new().with_selected(3).with_visible_rows(10);
395
396        cursor.layout(Rect::new(0.0, 0.0, 1.0, 10.0));
397        let v = cursor.verify();
398        assert!(v.failed.is_empty());
399    }
400
401    // Out of bounds selection should fail verification
402    #[test]
403    fn test_out_of_bounds_verification() {
404        let mut cursor = GutterCursor::new()
405            .with_selected(15) // Out of bounds
406            .with_visible_rows(10);
407
408        cursor.layout(Rect::new(0.0, 0.0, 1.0, 10.0));
409        let v = cursor.verify();
410        assert!(!v.failed.is_empty(), "Out of bounds selection should fail");
411    }
412
413    // No selection is valid
414    #[test]
415    fn test_no_selection_valid() {
416        let mut cursor = GutterCursor::new()
417            .with_no_selection()
418            .with_visible_rows(10);
419
420        cursor.layout(Rect::new(0.0, 0.0, 1.0, 10.0));
421        let v = cursor.verify();
422        assert!(v.failed.is_empty());
423    }
424
425    // Additional tests for coverage
426    #[test]
427    fn test_cursor_style_width() {
428        assert_eq!(CursorStyle::Triangle.width(), 1);
429        assert_eq!(CursorStyle::Line.width(), 1);
430        assert_eq!(CursorStyle::Dot.width(), 1);
431        assert_eq!(CursorStyle::Arrow.width(), 1);
432        assert_eq!(CursorStyle::Bracket.width(), 1);
433        assert_eq!(CursorStyle::DoubleArrow.width(), 1);
434    }
435
436    #[test]
437    fn test_cursor_style_default() {
438        let style = CursorStyle::default();
439        assert_eq!(style, CursorStyle::Triangle);
440    }
441
442    #[test]
443    fn test_cursor_style_debug() {
444        let style = CursorStyle::Dot;
445        let debug = format!("{:?}", style);
446        assert!(debug.contains("Dot"));
447    }
448
449    #[test]
450    fn test_cursor_style_clone() {
451        let style = CursorStyle::Arrow;
452        let cloned = style;
453        assert_eq!(style, cloned);
454    }
455
456    #[test]
457    fn test_selection_state_default() {
458        let state = SelectionState::default();
459        assert_eq!(state, SelectionState::None);
460    }
461
462    #[test]
463    fn test_selection_state_debug() {
464        let state = SelectionState::Focused;
465        let debug = format!("{:?}", state);
466        assert!(debug.contains("Focused"));
467    }
468
469    #[test]
470    fn test_selection_state_clone() {
471        let state = SelectionState::Selected;
472        let cloned = state;
473        assert_eq!(state, cloned);
474    }
475
476    #[test]
477    fn test_gutter_cursor_default() {
478        let cursor = GutterCursor::default();
479        assert!(cursor.selected_row.is_none());
480        assert_eq!(cursor.visible_rows, 0);
481    }
482
483    #[test]
484    fn test_gutter_cursor_debug() {
485        let cursor = GutterCursor::new();
486        let debug = format!("{:?}", cursor);
487        assert!(debug.contains("GutterCursor"));
488    }
489
490    #[test]
491    fn test_gutter_cursor_clone() {
492        let cursor = GutterCursor::new().with_selected(5).with_visible_rows(10);
493        let cloned = cursor;
494        assert_eq!(cloned.selected_row, Some(5));
495        assert_eq!(cloned.visible_rows, 10);
496    }
497
498    #[test]
499    fn test_with_style() {
500        let cursor = GutterCursor::new().with_style(CursorStyle::Dot);
501        assert_eq!(cursor.style, CursorStyle::Dot);
502    }
503
504    #[test]
505    fn test_with_selected_color() {
506        let color = Color::RED;
507        let cursor = GutterCursor::new().with_selected_color(color);
508        assert_eq!(cursor.selected_color, color);
509    }
510
511    #[test]
512    fn test_with_focused_color() {
513        let color = Color::GREEN;
514        let cursor = GutterCursor::new().with_focused_color(color);
515        assert_eq!(cursor.focused_color, color);
516    }
517
518    #[test]
519    fn test_color_for_state() {
520        let cursor = GutterCursor::new();
521        let none_color = cursor.color_for_state(SelectionState::None);
522        let selected_color = cursor.color_for_state(SelectionState::Selected);
523        let focused_color = cursor.color_for_state(SelectionState::Focused);
524        let focused_selected_color = cursor.color_for_state(SelectionState::FocusedSelected);
525
526        // None should be dim
527        assert!(none_color.r < 0.3);
528        // Selected should be cyan-ish
529        assert_eq!(selected_color, cursor.selected_color);
530        // Focused should be gold-ish
531        assert_eq!(focused_color, cursor.focused_color);
532        // FocusedSelected should also use focused color
533        assert_eq!(focused_selected_color, cursor.focused_color);
534    }
535
536    #[test]
537    fn test_measure() {
538        let cursor = GutterCursor::new().with_visible_rows(10);
539        let size = cursor.measure(Constraints {
540            min_width: 0.0,
541            min_height: 0.0,
542            max_width: 100.0,
543            max_height: 100.0,
544        });
545        assert!((size.width - 1.0).abs() < f32::EPSILON);
546        assert!((size.height - 10.0).abs() < f32::EPSILON);
547    }
548
549    #[test]
550    fn test_layout_sets_visible_rows() {
551        let mut cursor = GutterCursor::new();
552        cursor.layout(Rect::new(0.0, 0.0, 1.0, 15.0));
553        assert_eq!(cursor.visible_rows, 15);
554    }
555
556    #[test]
557    fn test_paint_empty_bounds() {
558        let cursor = GutterCursor::new();
559        let mut buffer = CellBuffer::new(0, 0);
560        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
561        cursor.paint(&mut canvas);
562        // Should not panic
563    }
564
565    #[test]
566    fn test_paint_with_multi_select() {
567        let mut cursor = GutterCursor::new()
568            .with_row_states(vec![
569                SelectionState::Selected,
570                SelectionState::None,
571                SelectionState::Focused,
572            ])
573            .with_visible_rows(3);
574
575        cursor.layout(Rect::new(0.0, 0.0, 1.0, 3.0));
576
577        let mut buffer = CellBuffer::new(1, 3);
578        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
579        cursor.paint(&mut canvas);
580        // Should render without panic
581    }
582
583    #[test]
584    fn test_type_id() {
585        let cursor = GutterCursor::new();
586        let id = Widget::type_id(&cursor);
587        let _ = id;
588        // Just verify it returns something
589    }
590
591    #[test]
592    fn test_event() {
593        let mut cursor = GutterCursor::new();
594        let result = cursor.event(&Event::Resize {
595            width: 100.0,
596            height: 50.0,
597        });
598        assert!(result.is_none());
599    }
600
601    #[test]
602    fn test_children() {
603        let cursor = GutterCursor::new();
604        assert!(cursor.children().is_empty());
605    }
606
607    #[test]
608    fn test_children_mut() {
609        let mut cursor = GutterCursor::new();
610        assert!(cursor.children_mut().is_empty());
611    }
612
613    #[test]
614    fn test_brick_name() {
615        let cursor = GutterCursor::new();
616        assert_eq!(cursor.brick_name(), "gutter_cursor");
617    }
618
619    #[test]
620    fn test_brick_assertions() {
621        let cursor = GutterCursor::new();
622        let assertions = cursor.assertions();
623        assert!(!assertions.is_empty());
624    }
625
626    #[test]
627    #[allow(clippy::nonminimal_bool)]
628    fn test_brick_budget() {
629        let cursor = GutterCursor::new();
630        let budget = cursor.budget();
631        // Budget is uniform(1), so total should be some positive value
632        let total = budget.paint_ms + budget.layout_ms + budget.measure_ms;
633        // Just verify budget computation doesn't panic
634        let _ = total;
635    }
636
637    #[test]
638    fn test_to_html() {
639        let cursor = GutterCursor::new().with_selected(5);
640        let html = cursor.to_html();
641        assert!(html.contains("gutter-cursor"));
642        assert!(html.contains('5'));
643    }
644
645    #[test]
646    fn test_to_html_no_selection() {
647        let cursor = GutterCursor::new();
648        let html = cursor.to_html();
649        assert!(html.contains("gutter-cursor"));
650    }
651
652    #[test]
653    fn test_to_css() {
654        let cursor = GutterCursor::new();
655        let css = cursor.to_css();
656        assert!(css.is_empty());
657    }
658
659    #[test]
660    fn test_verify_zero_width() {
661        let mut cursor = GutterCursor::new().with_selected(3).with_visible_rows(10);
662        cursor.layout(Rect::new(0.0, 0.0, 0.0, 10.0));
663        let v = cursor.verify();
664        assert!(!v.failed.is_empty(), "Zero width should fail verification");
665    }
666
667    #[test]
668    fn test_get_row_state_out_of_bounds() {
669        let cursor = GutterCursor::new().with_row_states(vec![SelectionState::Selected]);
670        let state = cursor.get_row_state(100);
671        assert_eq!(state, SelectionState::None);
672    }
673}