use presentar_core::{
Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event,
LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
};
use std::any::Any;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CursorStyle {
#[default]
Triangle,
Line,
Dot,
Arrow,
Bracket,
DoubleArrow,
}
impl CursorStyle {
#[must_use]
pub const fn char(&self) -> char {
match self {
Self::Triangle => '▶',
Self::Line => '│',
Self::Dot => '●',
Self::Arrow => '→',
Self::Bracket => '[',
Self::DoubleArrow => '»',
}
}
#[must_use]
pub const fn width(&self) -> usize {
1 }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SelectionState {
#[default]
None,
Selected,
Focused,
FocusedSelected,
}
impl SelectionState {
#[must_use]
pub const fn is_selected(&self) -> bool {
matches!(self, Self::Selected | Self::FocusedSelected)
}
#[must_use]
pub const fn is_focused(&self) -> bool {
matches!(self, Self::Focused | Self::FocusedSelected)
}
}
#[derive(Debug, Clone)]
pub struct GutterCursor {
selected_row: Option<usize>,
visible_rows: usize,
style: CursorStyle,
selected_color: Color,
focused_color: Color,
row_states: Vec<SelectionState>,
bounds: Rect,
}
impl Default for GutterCursor {
fn default() -> Self {
Self::new()
}
}
impl GutterCursor {
#[must_use]
pub fn new() -> Self {
Self {
selected_row: None,
visible_rows: 0,
style: CursorStyle::Triangle,
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(),
bounds: Rect::default(),
}
}
#[must_use]
pub fn with_selected(mut self, row: usize) -> Self {
self.selected_row = Some(row);
self
}
#[must_use]
pub fn with_no_selection(mut self) -> Self {
self.selected_row = None;
self
}
#[must_use]
pub fn with_visible_rows(mut self, count: usize) -> Self {
self.visible_rows = count;
self
}
#[must_use]
pub fn with_style(mut self, style: CursorStyle) -> Self {
self.style = style;
self
}
#[must_use]
pub fn with_selected_color(mut self, color: Color) -> Self {
self.selected_color = color;
self
}
#[must_use]
pub fn with_focused_color(mut self, color: Color) -> Self {
self.focused_color = color;
self
}
#[must_use]
pub fn with_row_states(mut self, states: Vec<SelectionState>) -> Self {
self.row_states = states;
self
}
fn get_row_state(&self, row: usize) -> SelectionState {
if let Some(selected) = self.selected_row {
if row == selected {
return SelectionState::Focused;
}
}
self.row_states
.get(row)
.copied()
.unwrap_or(SelectionState::None)
}
fn color_for_state(&self, state: SelectionState) -> Color {
match state {
SelectionState::None => Color::new(0.2, 0.2, 0.2, 1.0), SelectionState::Selected => self.selected_color,
SelectionState::Focused | SelectionState::FocusedSelected => self.focused_color,
}
}
}
impl Widget for GutterCursor {
fn type_id(&self) -> TypeId {
TypeId::of::<Self>()
}
fn measure(&self, constraints: Constraints) -> Size {
let width = 1.0f32.min(constraints.max_width);
let height = (self.visible_rows as f32).min(constraints.max_height);
constraints.constrain(Size::new(width, height))
}
fn layout(&mut self, bounds: Rect) -> LayoutResult {
self.bounds = bounds;
if self.visible_rows == 0 {
self.visible_rows = bounds.height as usize;
}
LayoutResult {
size: Size::new(bounds.width, bounds.height),
}
}
fn paint(&self, canvas: &mut dyn Canvas) {
if self.bounds.width < 1.0 || self.bounds.height < 1.0 {
return;
}
let cursor_char = self.style.char().to_string();
let visible = self.bounds.height as usize;
for row in 0..visible {
let state = self.get_row_state(row);
let y = self.bounds.y + row as f32;
if state != SelectionState::None {
let style = TextStyle {
color: self.color_for_state(state),
..Default::default()
};
canvas.draw_text(&cursor_char, Point::new(self.bounds.x, y), &style);
}
}
}
fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
None
}
fn children(&self) -> &[Box<dyn Widget>] {
&[]
}
fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
&mut []
}
}
impl Brick for GutterCursor {
fn brick_name(&self) -> &'static str {
"gutter_cursor"
}
fn assertions(&self) -> &[BrickAssertion] {
static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(1)];
ASSERTIONS
}
fn budget(&self) -> BrickBudget {
BrickBudget::uniform(1)
}
fn verify(&self) -> BrickVerification {
let row_in_bounds = self.selected_row.map_or(true, |r| r < self.visible_rows);
let drawable = self.bounds.width >= 1.0;
if row_in_bounds && drawable {
BrickVerification {
passed: self.assertions().to_vec(),
failed: vec![],
verification_time: Duration::from_micros(1),
}
} else {
BrickVerification {
passed: vec![],
failed: self
.assertions()
.iter()
.map(|a| (a.clone(), "Selection out of bounds".to_string()))
.collect(),
verification_time: Duration::from_micros(1),
}
}
}
fn to_html(&self) -> String {
format!(
"<div class=\"gutter-cursor\" data-selected=\"{}\"></div>",
self.selected_row.map(|r| r.to_string()).unwrap_or_default()
)
}
fn to_css(&self) -> String {
String::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::direct::{CellBuffer, DirectTerminalCanvas};
#[test]
fn test_cursor_y_position() {
let mut cursor = GutterCursor::new().with_selected(3).with_visible_rows(10);
cursor.layout(Rect::new(0.0, 0.0, 1.0, 10.0));
let state = cursor.get_row_state(3);
assert_eq!(state, SelectionState::Focused);
let state_other = cursor.get_row_state(5);
assert_eq!(state_other, SelectionState::None);
}
#[test]
fn test_cursor_visibility() {
let mut cursor = GutterCursor::new().with_selected(5).with_visible_rows(10);
cursor.layout(Rect::new(0.0, 0.0, 1.0, 10.0));
let mut buffer = CellBuffer::new(1, 10);
let mut canvas = DirectTerminalCanvas::new(&mut buffer);
cursor.paint(&mut canvas);
}
#[test]
fn test_cursor_styles() {
assert_eq!(CursorStyle::Triangle.char(), '▶');
assert_eq!(CursorStyle::Line.char(), '│');
assert_eq!(CursorStyle::Dot.char(), '●');
assert_eq!(CursorStyle::Arrow.char(), '→');
assert_eq!(CursorStyle::Bracket.char(), '[');
assert_eq!(CursorStyle::DoubleArrow.char(), '»');
}
#[test]
fn test_selection_state() {
assert!(!SelectionState::None.is_selected());
assert!(SelectionState::Selected.is_selected());
assert!(!SelectionState::Focused.is_selected());
assert!(SelectionState::FocusedSelected.is_selected());
assert!(!SelectionState::None.is_focused());
assert!(!SelectionState::Selected.is_focused());
assert!(SelectionState::Focused.is_focused());
assert!(SelectionState::FocusedSelected.is_focused());
}
#[test]
fn test_multi_select() {
let cursor = GutterCursor::new()
.with_row_states(vec![
SelectionState::None,
SelectionState::Selected,
SelectionState::Selected,
SelectionState::None,
])
.with_visible_rows(4);
assert_eq!(cursor.get_row_state(0), SelectionState::None);
assert_eq!(cursor.get_row_state(1), SelectionState::Selected);
assert_eq!(cursor.get_row_state(2), SelectionState::Selected);
assert_eq!(cursor.get_row_state(3), SelectionState::None);
}
#[test]
fn test_brick_verification() {
let mut cursor = GutterCursor::new().with_selected(3).with_visible_rows(10);
cursor.layout(Rect::new(0.0, 0.0, 1.0, 10.0));
let v = cursor.verify();
assert!(v.failed.is_empty());
}
#[test]
fn test_out_of_bounds_verification() {
let mut cursor = GutterCursor::new()
.with_selected(15) .with_visible_rows(10);
cursor.layout(Rect::new(0.0, 0.0, 1.0, 10.0));
let v = cursor.verify();
assert!(!v.failed.is_empty(), "Out of bounds selection should fail");
}
#[test]
fn test_no_selection_valid() {
let mut cursor = GutterCursor::new()
.with_no_selection()
.with_visible_rows(10);
cursor.layout(Rect::new(0.0, 0.0, 1.0, 10.0));
let v = cursor.verify();
assert!(v.failed.is_empty());
}
#[test]
fn test_cursor_style_width() {
assert_eq!(CursorStyle::Triangle.width(), 1);
assert_eq!(CursorStyle::Line.width(), 1);
assert_eq!(CursorStyle::Dot.width(), 1);
assert_eq!(CursorStyle::Arrow.width(), 1);
assert_eq!(CursorStyle::Bracket.width(), 1);
assert_eq!(CursorStyle::DoubleArrow.width(), 1);
}
#[test]
fn test_cursor_style_default() {
let style = CursorStyle::default();
assert_eq!(style, CursorStyle::Triangle);
}
#[test]
fn test_cursor_style_debug() {
let style = CursorStyle::Dot;
let debug = format!("{:?}", style);
assert!(debug.contains("Dot"));
}
#[test]
fn test_cursor_style_clone() {
let style = CursorStyle::Arrow;
let cloned = style.clone();
assert_eq!(style, cloned);
}
#[test]
fn test_selection_state_default() {
let state = SelectionState::default();
assert_eq!(state, SelectionState::None);
}
#[test]
fn test_selection_state_debug() {
let state = SelectionState::Focused;
let debug = format!("{:?}", state);
assert!(debug.contains("Focused"));
}
#[test]
fn test_selection_state_clone() {
let state = SelectionState::Selected;
let cloned = state.clone();
assert_eq!(state, cloned);
}
#[test]
fn test_gutter_cursor_default() {
let cursor = GutterCursor::default();
assert!(cursor.selected_row.is_none());
assert_eq!(cursor.visible_rows, 0);
}
#[test]
fn test_gutter_cursor_debug() {
let cursor = GutterCursor::new();
let debug = format!("{:?}", cursor);
assert!(debug.contains("GutterCursor"));
}
#[test]
fn test_gutter_cursor_clone() {
let cursor = GutterCursor::new().with_selected(5).with_visible_rows(10);
let cloned = cursor.clone();
assert_eq!(cloned.selected_row, Some(5));
assert_eq!(cloned.visible_rows, 10);
}
#[test]
fn test_with_style() {
let cursor = GutterCursor::new().with_style(CursorStyle::Dot);
assert_eq!(cursor.style, CursorStyle::Dot);
}
#[test]
fn test_with_selected_color() {
let color = Color::RED;
let cursor = GutterCursor::new().with_selected_color(color);
assert_eq!(cursor.selected_color, color);
}
#[test]
fn test_with_focused_color() {
let color = Color::GREEN;
let cursor = GutterCursor::new().with_focused_color(color);
assert_eq!(cursor.focused_color, color);
}
#[test]
fn test_color_for_state() {
let cursor = GutterCursor::new();
let none_color = cursor.color_for_state(SelectionState::None);
let selected_color = cursor.color_for_state(SelectionState::Selected);
let focused_color = cursor.color_for_state(SelectionState::Focused);
let focused_selected_color = cursor.color_for_state(SelectionState::FocusedSelected);
assert!(none_color.r < 0.3);
assert_eq!(selected_color, cursor.selected_color);
assert_eq!(focused_color, cursor.focused_color);
assert_eq!(focused_selected_color, cursor.focused_color);
}
#[test]
fn test_measure() {
let cursor = GutterCursor::new().with_visible_rows(10);
let size = cursor.measure(Constraints {
min_width: 0.0,
min_height: 0.0,
max_width: 100.0,
max_height: 100.0,
});
assert!((size.width - 1.0).abs() < f32::EPSILON);
assert!((size.height - 10.0).abs() < f32::EPSILON);
}
#[test]
fn test_layout_sets_visible_rows() {
let mut cursor = GutterCursor::new();
cursor.layout(Rect::new(0.0, 0.0, 1.0, 15.0));
assert_eq!(cursor.visible_rows, 15);
}
#[test]
fn test_paint_empty_bounds() {
let cursor = GutterCursor::new();
let mut buffer = CellBuffer::new(0, 0);
let mut canvas = DirectTerminalCanvas::new(&mut buffer);
cursor.paint(&mut canvas);
}
#[test]
fn test_paint_with_multi_select() {
let mut cursor = GutterCursor::new()
.with_row_states(vec![
SelectionState::Selected,
SelectionState::None,
SelectionState::Focused,
])
.with_visible_rows(3);
cursor.layout(Rect::new(0.0, 0.0, 1.0, 3.0));
let mut buffer = CellBuffer::new(1, 3);
let mut canvas = DirectTerminalCanvas::new(&mut buffer);
cursor.paint(&mut canvas);
}
#[test]
fn test_type_id() {
let cursor = GutterCursor::new();
let id = Widget::type_id(&cursor);
let _ = id;
}
#[test]
fn test_event() {
let mut cursor = GutterCursor::new();
let result = cursor.event(&Event::Resize {
width: 100.0,
height: 50.0,
});
assert!(result.is_none());
}
#[test]
fn test_children() {
let cursor = GutterCursor::new();
assert!(cursor.children().is_empty());
}
#[test]
fn test_children_mut() {
let mut cursor = GutterCursor::new();
assert!(cursor.children_mut().is_empty());
}
#[test]
fn test_brick_name() {
let cursor = GutterCursor::new();
assert_eq!(cursor.brick_name(), "gutter_cursor");
}
#[test]
fn test_brick_assertions() {
let cursor = GutterCursor::new();
let assertions = cursor.assertions();
assert!(!assertions.is_empty());
}
#[test]
fn test_brick_budget() {
let cursor = GutterCursor::new();
let budget = cursor.budget();
let total = budget.paint_ms + budget.layout_ms + budget.measure_ms;
assert!(total > 0 || total == 0); }
#[test]
fn test_to_html() {
let cursor = GutterCursor::new().with_selected(5);
let html = cursor.to_html();
assert!(html.contains("gutter-cursor"));
assert!(html.contains("5"));
}
#[test]
fn test_to_html_no_selection() {
let cursor = GutterCursor::new();
let html = cursor.to_html();
assert!(html.contains("gutter-cursor"));
}
#[test]
fn test_to_css() {
let cursor = GutterCursor::new();
let css = cursor.to_css();
assert!(css.is_empty());
}
#[test]
fn test_verify_zero_width() {
let mut cursor = GutterCursor::new().with_selected(3).with_visible_rows(10);
cursor.layout(Rect::new(0.0, 0.0, 0.0, 10.0));
let v = cursor.verify();
assert!(!v.failed.is_empty(), "Zero width should fail verification");
}
#[test]
fn test_get_row_state_out_of_bounds() {
let cursor = GutterCursor::new().with_row_states(vec![SelectionState::Selected]);
let state = cursor.get_row_state(100);
assert_eq!(state, SelectionState::None);
}
}