1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19pub enum CursorStyle {
20 #[default]
22 Triangle,
23 Line,
25 Dot,
27 Arrow,
29 Bracket,
31 DoubleArrow,
33}
34
35impl CursorStyle {
36 #[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 #[must_use]
51 pub const fn width(&self) -> usize {
52 1 }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58pub enum SelectionState {
59 #[default]
61 None,
62 Selected,
64 Focused,
66 FocusedSelected,
68}
69
70impl SelectionState {
71 #[must_use]
73 pub const fn is_selected(&self) -> bool {
74 matches!(self, Self::Selected | Self::FocusedSelected)
75 }
76
77 #[must_use]
79 pub const fn is_focused(&self) -> bool {
80 matches!(self, Self::Focused | Self::FocusedSelected)
81 }
82}
83
84#[derive(Debug, Clone)]
89pub struct GutterCursor {
90 selected_row: Option<usize>,
92 visible_rows: usize,
94 style: CursorStyle,
96 selected_color: Color,
98 focused_color: Color,
100 row_states: Vec<SelectionState>,
102 bounds: Rect,
104}
105
106impl Default for GutterCursor {
107 fn default() -> Self {
108 Self::new()
109 }
110}
111
112impl GutterCursor {
113 #[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), focused_color: Color::new(1.0, 0.8, 0.2, 1.0), row_states: Vec::new(),
123 bounds: Rect::default(),
124 }
125 }
126
127 #[must_use]
129 pub fn with_selected(mut self, row: usize) -> Self {
130 self.selected_row = Some(row);
131 self
132 }
133
134 #[must_use]
136 pub fn with_no_selection(mut self) -> Self {
137 self.selected_row = None;
138 self
139 }
140
141 #[must_use]
143 pub fn with_visible_rows(mut self, count: usize) -> Self {
144 self.visible_rows = count;
145 self
146 }
147
148 #[must_use]
150 pub fn with_style(mut self, style: CursorStyle) -> Self {
151 self.style = style;
152 self
153 }
154
155 #[must_use]
157 pub fn with_selected_color(mut self, color: Color) -> Self {
158 self.selected_color = color;
159 self
160 }
161
162 #[must_use]
164 pub fn with_focused_color(mut self, color: Color) -> Self {
165 self.focused_color = color;
166 self
167 }
168
169 #[must_use]
171 pub fn with_row_states(mut self, states: Vec<SelectionState>) -> Self {
172 self.row_states = states;
173 self
174 }
175
176 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 fn color_for_state(&self, state: SelectionState) -> Color {
191 match state {
192 SelectionState::None => Color::new(0.2, 0.2, 0.2, 1.0), SelectionState::Selected => self.selected_color,
194 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 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 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 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 let row_in_bounds = self.selected_row.map_or(true, |r| r < self.visible_rows);
277
278 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 #[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 let state = cursor.get_row_state(3);
326 assert_eq!(state, SelectionState::Focused);
327
328 let state_other = cursor.get_row_state(5);
330 assert_eq!(state_other, SelectionState::None);
331 }
332
333 #[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 }
347
348 #[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 #[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 #[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 #[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 #[test]
403 fn test_out_of_bounds_verification() {
404 let mut cursor = GutterCursor::new()
405 .with_selected(15) .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 #[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 #[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 assert!(none_color.r < 0.3);
528 assert_eq!(selected_color, cursor.selected_color);
530 assert_eq!(focused_color, cursor.focused_color);
532 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 }
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 }
582
583 #[test]
584 fn test_type_id() {
585 let cursor = GutterCursor::new();
586 let id = Widget::type_id(&cursor);
587 let _ = id;
588 }
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 let total = budget.paint_ms + budget.layout_ms + budget.measure_ms;
633 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}