use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use typ_buffer::Position;
use typ_core::{Panel, RenderContext, ThemeColors};
use typ_panel_editor::EditorPanel;
use typ_panel_editor::gutter::{Gutter, GutterComponent};
fn render(panel: &mut EditorPanel, area: Rect) -> Buffer {
let theme = ThemeColors::default();
let ctx = RenderContext {
theme: &theme,
is_focused: true,
panel_index: 0,
terminal_width: area.width,
terminal_height: area.height,
};
let mut buf = Buffer::empty(area);
panel.render(area, &mut buf, &ctx);
buf
}
fn row(buf: &Buffer, y: u16) -> String {
(0..buf.area.width).map(|x| buf[(x, y)].symbol()).collect()
}
const AREA: Rect = Rect {
x: 0,
y: 0,
width: 24,
height: 6,
};
#[test]
fn the_gutter_is_as_wide_as_the_longest_line_number() {
let gutter = Gutter::default();
assert_eq!(gutter.width(9), 2);
assert_eq!(gutter.width(10), 3);
assert_eq!(gutter.width(100_000), 7);
}
#[test]
fn a_single_line_file_still_gets_a_column() {
let gutter = Gutter::default();
assert_eq!(gutter.width(1), 2, "line 1 needs one digit and a space");
}
#[test]
fn width_comes_from_the_whole_buffer_not_the_visible_lines() {
let gutter = Gutter::default();
assert_eq!(gutter.width(200), 4);
}
#[test]
fn an_empty_gutter_takes_no_cells() {
let gutter = Gutter::new(vec![]);
assert_eq!(gutter.width(50_000), 0);
}
#[test]
fn components_that_ship_empty_still_reserve_their_column() {
let gutter = Gutter::new(vec![GutterComponent::Diagnostics, GutterComponent::Diff]);
assert_eq!(gutter.width(9), 2);
}
#[test]
fn line_numbers_start_at_one() {
let mut panel = EditorPanel::from_str("alpha\nbeta\n");
let buf = render(&mut panel, AREA);
assert!(
row(&buf, 1).starts_with("│1 alpha"),
"row 1 was: {}",
row(&buf, 1)
);
assert!(
row(&buf, 2).starts_with("│2 beta"),
"row 2 was: {}",
row(&buf, 2)
);
}
#[test]
fn numbers_are_right_aligned_so_the_text_edge_stays_straight() {
let text: String = (1..=10).map(|i| format!("line {i}\n")).collect();
let mut panel = EditorPanel::from_str(&text);
let buf = render(&mut panel, AREA);
assert!(
row(&buf, 1).starts_with("│ 1 line 1"),
"row 1 was: {}",
row(&buf, 1)
);
}
#[test]
fn the_cursors_line_is_styled_differently_from_the_rest() {
let theme = ThemeColors::default();
let mut panel = EditorPanel::from_str("alpha\nbeta\n");
let buf = render(&mut panel, AREA);
let current = buf[(1, 1)].style().fg;
let other = buf[(1, 2)].style().fg;
assert_ne!(
current, other,
"the cursor's line number must stand out from the others"
);
assert_eq!(other, Some(theme.line_number_fg));
assert_eq!(current, Some(theme.line_number_current_fg));
}
#[test]
fn the_number_column_does_not_scroll_sideways_with_the_text() {
let mut panel = EditorPanel::from_str(&format!("{}\n", "x".repeat(200)));
render(&mut panel, AREA);
panel.apply_action(typ_core::Action::Move {
motion: typ_core::Motion::LineEnd,
extend: false,
});
let buf = render(&mut panel, AREA);
assert!(panel.left_col() > 0, "the text must have scrolled");
assert!(
row(&buf, 1).starts_with("│1 "),
"the gutter is fixed furniture, not part of the scrolled text: {}",
row(&buf, 1)
);
}
#[test]
fn a_click_lands_on_the_grapheme_under_the_pointer_not_gutter_width_to_its_left() {
let mut panel = EditorPanel::from_str("hello\nworld\n");
render(&mut panel, AREA);
panel.handle_mouse(
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 5,
row: 1,
modifiers: KeyModifiers::NONE,
},
AREA,
);
assert_eq!(panel.cursor(), Position { line: 0, col: 2 });
}
#[test]
fn the_terminal_cursor_is_drawn_past_the_gutter() {
let mut panel = EditorPanel::from_str("hello\n");
render(&mut panel, AREA);
panel.apply_action(typ_core::Action::Move {
motion: typ_core::Motion::Right,
extend: false,
});
let (x, y) = panel
.cursor_position(AREA)
.expect("the cursor is on screen");
assert_eq!((x, y), (4, 1));
}
#[test]
fn a_click_in_the_gutter_lands_at_the_start_of_that_line() {
let mut panel = EditorPanel::from_str("hello\nworld\n");
render(&mut panel, AREA);
panel.handle_mouse(
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 1,
row: 2,
modifiers: KeyModifiers::NONE,
},
AREA,
);
assert_eq!(
panel.cursor(),
Position { line: 1, col: 0 },
"clicking the number selects the line it labels"
);
}
#[test]
fn the_text_area_loses_exactly_the_gutters_width() {
let mut panel = EditorPanel::from_str(&format!("{}\n", "x".repeat(20)));
render(&mut panel, AREA);
panel.apply_action(typ_core::Action::Move {
motion: typ_core::Motion::LineEnd,
extend: false,
});
render(&mut panel, AREA);
assert_eq!(
panel.left_col(),
1,
"a cursor one past the last visible column scrolls by exactly one"
);
}
#[test]
fn relative_numbering_counts_distance_from_the_cursor() {
let theme = ThemeColors::default();
let gutter = Gutter::new(vec![GutterComponent::LineNumbers { relative: true }]);
let content = |line| {
gutter.render_line(line, 5, 20, &theme)[0]
.content
.to_string()
};
assert_eq!(content(3).trim(), "2", "two lines above");
assert_eq!(content(7).trim(), "2", "two lines below, same number");
assert_eq!(
content(5).trim(),
"6",
"the cursor's own line keeps its absolute number — the pair is what \
makes relative numbering useful, rather than a lone zero"
);
}
#[test]
fn relative_numbering_is_off_by_default() {
let theme = ThemeColors::default();
let absolute = Gutter::default().render_line(3, 5, 20, &theme)[0]
.content
.to_string();
assert_eq!(absolute.trim(), "4");
}