use crate::event::Rect;
use crate::types::{Color, Modifiers, WidgetId};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Terminal {
pub id: WidgetId,
pub cells: Vec<Vec<TerminalCell>>,
#[serde(default)]
pub scrollbar: Option<TerminalScrollbar>,
}
impl Default for Terminal {
fn default() -> Self {
Self {
id: WidgetId::new(""),
cells: Vec::new(),
scrollbar: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct TerminalScrollbar {
pub total_lines: usize,
pub visible_lines: usize,
pub scroll_offset: usize,
#[serde(default)]
pub inverted: bool,
#[serde(default)]
pub width: Option<u16>,
}
impl TerminalScrollbar {
pub fn effective_scroll_offset(&self) -> usize {
if self.inverted {
self.total_lines
.saturating_sub(self.visible_lines)
.saturating_sub(self.scroll_offset)
} else {
self.scroll_offset
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TerminalCell {
pub text: String,
pub fg: Color,
pub bg: Color,
#[serde(default)]
pub bold: bool,
#[serde(default)]
pub italic: bool,
#[serde(default)]
pub underline: bool,
#[serde(default)]
pub dim: bool,
#[serde(default)]
pub selected: bool,
#[serde(default)]
pub is_cursor: bool,
#[serde(default)]
pub cursor_shape: TerminalCursorShape,
#[serde(default)]
pub cursor_blinking: bool,
#[serde(default)]
pub is_find_match: bool,
#[serde(default)]
pub is_find_active: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum TerminalCursorShape {
#[default]
Block,
Underline,
Bar,
}
impl TerminalCell {
pub fn cell_width(&self) -> u16 {
crate::text_util::display_width(&self.text) as u16
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TerminalCellSize {
pub width: f32,
pub height: f32,
}
impl TerminalCellSize {
pub fn new(width: f32, height: f32) -> Self {
Self { width, height }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TerminalHit {
Cell { row: u16, col: u16 },
Empty,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TerminalLayout {
pub viewport_width: f32,
pub viewport_height: f32,
pub cell_size: TerminalCellSize,
pub grid_rows: u16,
pub grid_cols: u16,
}
impl TerminalLayout {
pub fn hit_test(&self, x: f32, y: f32) -> TerminalHit {
if x < 0.0
|| y < 0.0
|| x >= self.viewport_width
|| y >= self.viewport_height
|| self.cell_size.width <= 0.0
|| self.cell_size.height <= 0.0
{
return TerminalHit::Empty;
}
let col = (x / self.cell_size.width).floor() as u32;
let row = (y / self.cell_size.height).floor() as u32;
if row < self.grid_rows as u32 && col < self.grid_cols as u32 {
TerminalHit::Cell {
row: row as u16,
col: col as u16,
}
} else {
TerminalHit::Empty
}
}
pub fn cell_bounds(&self, row: u16, col: u16) -> Option<Rect> {
if row >= self.grid_rows || col >= self.grid_cols {
return None;
}
Some(Rect::new(
col as f32 * self.cell_size.width,
row as f32 * self.cell_size.height,
self.cell_size.width,
self.cell_size.height,
))
}
}
impl Terminal {
pub fn dirty_rows(&self, prev: &Terminal) -> Option<Vec<usize>> {
if self.cells.len() != prev.cells.len() {
return None;
}
Some(
self.cells
.iter()
.zip(prev.cells.iter())
.enumerate()
.filter_map(|(i, (a, b))| (a != b).then_some(i))
.collect(),
)
}
pub fn layout(
&self,
viewport_width: f32,
viewport_height: f32,
cell_width: f32,
cell_height: f32,
) -> TerminalLayout {
let grid_cols = if cell_width > 0.0 {
(viewport_width / cell_width).floor().max(0.0) as u16
} else {
0
};
let grid_rows = if cell_height > 0.0 {
(viewport_height / cell_height).floor().max(0.0) as u16
} else {
0
};
TerminalLayout {
viewport_width,
viewport_height,
cell_size: TerminalCellSize::new(cell_width, cell_height),
grid_rows,
grid_cols,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TerminalSplitLayout {
pub left: Rect,
pub right: Rect,
pub divider_x: f32,
pub divider_width: f32,
pub cell_size: TerminalCellSize,
pub scrollbar: Option<Rect>,
}
impl TerminalSplitLayout {
pub fn new(
area: Rect,
left_cols: usize,
cell_width: f32,
cell_height: f32,
scrollbar_width: f32,
) -> Self {
let sb_w = if scrollbar_width > 0.0 {
scrollbar_width.min(area.width)
} else {
0.0
};
let usable_w = (area.width - sb_w).max(0.0);
let left_w = (left_cols as f32 * cell_width).min(usable_w);
let divider_x = area.x + left_w;
let divider_w = cell_width.min((usable_w - left_w).max(0.0));
let right_x = divider_x + divider_w;
let right_w = (usable_w - left_w - divider_w).max(0.0);
let scrollbar = if sb_w > 0.0 {
Some(Rect::new(area.x + usable_w, area.y, sb_w, area.height))
} else {
None
};
Self {
left: Rect::new(area.x, area.y, left_w, area.height),
right: Rect::new(right_x, area.y, right_w, area.height),
divider_x,
divider_width: divider_w,
cell_size: TerminalCellSize::new(cell_width, cell_height),
scrollbar,
}
}
pub fn hit_test(&self, x: f32, y: f32) -> TerminalSplitHit {
if y < self.left.y || y >= self.left.y + self.left.height {
return TerminalSplitHit::Outside;
}
if let Some(sb) = self.scrollbar {
if x >= sb.x && x < sb.x + sb.width {
return TerminalSplitHit::Scrollbar;
}
}
if x >= self.left.x && x < self.left.x + self.left.width {
let col = ((x - self.left.x) / self.cell_size.width).floor() as u16;
let row = ((y - self.left.y) / self.cell_size.height).floor() as u16;
TerminalSplitHit::LeftPane { col, row }
} else if x >= self.divider_x && x < self.divider_x + self.divider_width {
TerminalSplitHit::Divider
} else if x >= self.right.x && x < self.right.x + self.right.width {
let col = ((x - self.right.x) / self.cell_size.width).floor() as u16;
let row = ((y - self.right.y) / self.cell_size.height).floor() as u16;
TerminalSplitHit::RightPane { col, row }
} else {
TerminalSplitHit::Outside
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TerminalSplitHit {
LeftPane { col: u16, row: u16 },
RightPane { col: u16, row: u16 },
Divider,
Scrollbar,
Outside,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TerminalEvent {
KeyPressed { key: String, modifiers: Modifiers },
SelectStart { row: u16, col: u16 },
SelectExtend { row: u16, col: u16 },
SelectEnd,
Scroll { delta: i32 },
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[allow(dead_code)]
mod native_surface_paint {
use super::Terminal;
use crate::paint_surface::PaintSurface;
use crate::terminal_style::{
cursor_accent_rect, cursor_accent_visible, divider_geometry, resolve_cell_style,
wide_cell_advance, wide_glyph_x_scale,
};
use crate::theme::Theme;
use crate::Rect;
#[allow(clippy::too_many_arguments)]
pub(crate) fn paint(
term: &Terminal,
surface: &mut dyn PaintSurface,
theme: &Theme,
x: f32,
y: f32,
cell_area_w: f32,
cell_area_h: f32,
line_height: f32,
char_width: f32,
dirty_rows: Option<&[usize]>,
) {
for (row_idx, row) in term.cells.iter().enumerate() {
let row_y = y + row_idx as f32 * line_height;
if row_y >= y + cell_area_h {
break;
}
if let Some(rows) = dirty_rows {
if rows.binary_search(&row_idx).is_err() {
continue;
}
}
let mut cell_x = x;
let mut col = 0usize;
while col < row.len() {
let cell = &row[col];
let (cell_w, cols_advanced) = wide_cell_advance(&cell.text, char_width as f64);
let cell_w = cell_w as f32;
let is_wide = cols_advanced == 2;
if cell_x + cell_w > x + cell_area_w {
break;
}
let (cell_bg, cell_fg) = resolve_cell_style(cell, theme);
surface.surface_fill_rect(Rect::new(cell_x, row_y, cell_w, line_height), cell_bg);
if cell.text != " " && cell.text != "\0" && !cell.text.is_empty() {
let s = cell.text.as_str();
let cell_rect = Rect::new(cell_x, row_y, cell_w, line_height);
let scale_x = if is_wide {
let (natural_w, _) = surface.surface_measure_text(s);
wide_glyph_x_scale(natural_w as f64, cell_w as f64) as f32
} else {
1.0
};
surface.surface_draw_text_run_styled(
cell_rect,
s,
cell_fg,
cell.bold,
cell.italic,
cell.underline,
scale_x,
);
}
if cursor_accent_visible(cell) {
if let Some(accent) =
cursor_accent_rect(cell.cursor_shape, cell_x, row_y, cell_w, line_height)
{
surface.surface_fill_rect(accent, cell.fg);
}
}
cell_x += cell_w;
col += cols_advanced;
}
}
}
pub(crate) fn paint_divider(
surface: &mut dyn PaintSurface,
x: f32,
y: f32,
height: f32,
theme: &Theme,
) {
let g = divider_geometry(x as f64, y as f64, height as f64);
surface.surface_fill_rect(
Rect::new(g.x as f32, g.y as f32, g.width as f32, g.height as f32),
theme.separator,
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::ImagePaintResult;
use crate::event::Viewport;
use crate::primitives::terminal::{TerminalCell, TerminalCursorShape};
use crate::types::{Color, WidgetId};
use crate::Image;
#[derive(Default)]
struct RecordingSurface {
fills: Vec<(Rect, Color)>,
styled_runs: Vec<(Rect, String, Color, bool, bool, bool, f32)>,
}
impl PaintSurface for RecordingSurface {
fn surface_begin_frame(&mut self, _viewport: Viewport) {}
fn surface_end_frame(&mut self) {}
fn surface_viewport(&self) -> Viewport {
Viewport::new(200.0, 100.0, 1.0)
}
fn surface_line_height(&self) -> f32 {
20.0
}
fn surface_char_width(&self) -> f32 {
10.0
}
fn surface_measure_text(&self, text: &str) -> (f32, f32) {
(text.chars().count() as f32 * 10.0, 20.0)
}
fn surface_fill_rect(&mut self, rect: Rect, color: Color) {
self.fills.push((rect, color));
}
fn surface_fill_rounded_rect(&mut self, rect: Rect, _radius: f32, color: Color) {
self.fills.push((rect, color));
}
fn surface_stroke_rect(&mut self, _rect: Rect, _color: Color, _stroke_width: f32) {}
fn surface_stroke_rounded_rect(
&mut self,
_rect: Rect,
_radius: f32,
_color: Color,
_stroke_width: f32,
) {
}
fn surface_draw_text_run(&mut self, _rect: Rect, _text: &str, _color: Color) {}
fn surface_draw_text_run_styled(
&mut self,
rect: Rect,
text: &str,
color: Color,
bold: bool,
italic: bool,
underline: bool,
scale_x: f32,
) {
self.styled_runs.push((
rect,
text.to_string(),
color,
bold,
italic,
underline,
scale_x,
));
}
fn surface_draw_line(
&mut self,
_from: crate::Point,
_to: crate::Point,
_color: Color,
_stroke_width: f32,
) {
}
fn surface_push_clip(&mut self, _rect: Rect) {}
fn surface_pop_clip(&mut self) {}
fn surface_draw_image(&mut self, _rect: Rect, _image: &Image) -> ImagePaintResult {
ImagePaintResult::Unsupported
}
}
fn cell(ch: char, fg: Color, bg: Color) -> TerminalCell {
TerminalCell {
text: ch.to_string(),
fg,
bg,
bold: false,
italic: false,
underline: false,
dim: false,
selected: false,
is_cursor: false,
is_find_match: false,
is_find_active: false,
cursor_shape: TerminalCursorShape::Block,
cursor_blinking: false,
}
}
#[test]
fn cell_background_and_glyph_paint_at_the_grid_position() {
let fg = Color::rgb(255, 255, 255);
let bg = Color::rgb(10, 20, 30);
let term = Terminal {
id: WidgetId::new("term"),
cells: vec![vec![cell('A', fg, bg)]],
scrollbar: None,
};
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(
&term,
&mut surface,
&theme,
0.0,
0.0,
200.0,
100.0,
20.0,
10.0,
None,
);
assert!(
surface
.fills
.iter()
.any(|&(r, c)| r == Rect::new(0.0, 0.0, 10.0, 20.0) && c == bg),
"expected the cell's own background fill, fills were {:?}",
surface.fills,
);
assert!(
surface
.styled_runs
.iter()
.any(|(r, t, c, ..)| t == "A" && *c == fg && r.x == 0.0 && r.y == 0.0),
"expected the glyph painted in the cell's fg colour, got {:?}",
surface.styled_runs,
);
}
#[test]
fn blank_and_nul_cells_paint_background_only() {
let bg = Color::rgb(10, 20, 30);
let term = Terminal {
id: WidgetId::new("term"),
cells: vec![vec![
cell(' ', Color::rgb(0, 0, 0), bg),
cell('\0', Color::rgb(0, 0, 0), bg),
]],
scrollbar: None,
};
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(
&term,
&mut surface,
&theme,
0.0,
0.0,
200.0,
100.0,
20.0,
10.0,
None,
);
assert_eq!(
surface.fills.len(),
2,
"both cells should still fill their background"
);
assert!(
surface.styled_runs.is_empty(),
"space and NUL cells must not paint a glyph, got {:?}",
surface.styled_runs,
);
}
#[test]
fn bold_italic_underline_flags_reach_the_styled_run() {
let term = Terminal {
id: WidgetId::new("term"),
cells: vec![vec![TerminalCell {
bold: true,
italic: true,
underline: true,
..cell('X', Color::rgb(255, 255, 255), Color::rgb(0, 0, 0))
}]],
scrollbar: None,
};
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(
&term,
&mut surface,
&theme,
0.0,
0.0,
200.0,
100.0,
20.0,
10.0,
None,
);
let (_, _, _, bold, italic, underline, _) = surface.styled_runs[0];
assert!(bold && italic && underline);
}
#[test]
fn dim_flag_dims_the_color_reaching_the_styled_run() {
let fg = Color::rgb(200, 200, 200);
let bg = Color::rgb(0, 0, 0);
let term = Terminal {
id: WidgetId::new("term"),
cells: vec![vec![TerminalCell {
dim: true,
..cell('X', fg, bg)
}]],
scrollbar: None,
};
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(
&term,
&mut surface,
&theme,
0.0,
0.0,
200.0,
100.0,
20.0,
10.0,
None,
);
let (_, _, color, _, _, _, _) = surface.styled_runs[0];
assert_eq!(
color,
Color::rgb(100, 100, 100),
"dim cell's glyph color must be blended 50% toward its background"
);
}
#[test]
fn cursor_shape_accent_paints_a_second_smaller_fill() {
let fg = Color::rgb(200, 30, 30);
let bg = Color::rgb(10, 10, 10);
for shape in [TerminalCursorShape::Underline, TerminalCursorShape::Bar] {
let term = Terminal {
id: WidgetId::new("term"),
cells: vec![vec![TerminalCell {
is_cursor: true,
cursor_shape: shape,
..cell(' ', fg, bg)
}]],
scrollbar: None,
};
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(
&term,
&mut surface,
&theme,
0.0,
0.0,
200.0,
100.0,
20.0,
10.0,
None,
);
assert_eq!(
surface.fills.len(),
2,
"{shape:?}: expected a background fill plus one accent fill, got {:?}",
surface.fills,
);
assert!(
surface
.fills
.iter()
.any(|&(r, c)| c == bg && r.width == 10.0 && r.height == 20.0),
"{shape:?}: the full 10x20 cell background should still be the cell's own \
(uninverted) bg, got {:?}",
surface.fills,
);
assert!(
surface
.fills
.iter()
.any(|&(r, c)| c == fg && (r.width < 10.0 || r.height < 20.0)),
"{shape:?}: expected a smaller accent fill in the cell's fg colour, got {:?}",
surface.fills,
);
}
}
#[test]
fn block_cursor_shape_paints_no_accent_fill() {
let fg = Color::rgb(200, 30, 30);
let bg = Color::rgb(10, 10, 10);
let term = Terminal {
id: WidgetId::new("term"),
cells: vec![vec![TerminalCell {
is_cursor: true,
..cell(' ', fg, bg)
}]],
scrollbar: None,
};
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(
&term,
&mut surface,
&theme,
0.0,
0.0,
200.0,
100.0,
20.0,
10.0,
None,
);
assert_eq!(
surface.fills.len(),
1,
"Block cursor should paint exactly one (inverted) background fill, got {:?}",
surface.fills,
);
}
#[test]
fn dirty_rows_filter_skips_untouched_rows() {
let painted = Color::rgb(200, 200, 200);
let term = Terminal {
id: WidgetId::new("term"),
cells: vec![
vec![cell('A', painted, painted)],
vec![cell('B', painted, painted)],
],
scrollbar: None,
};
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(
&term,
&mut surface,
&theme,
0.0,
0.0,
200.0,
100.0,
20.0,
10.0,
Some(&[1]),
);
assert!(
surface.fills.iter().all(|(r, _)| r.y == 20.0),
"only row 1 (y=20) should have painted anything, fills were {:?}",
surface.fills,
);
}
#[test]
fn paint_divider_fills_the_separator_geometry() {
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint_divider(&mut surface, 10.0, 5.0, 40.0, &theme);
assert!(
surface.fills.iter().any(|&(_, c)| c == theme.separator),
"expected a fill in theme.separator, fills were {:?}",
surface.fills,
);
}
#[test]
fn combining_mark_grapheme_paints_as_one_glyph_run() {
let fg = Color::rgb(255, 255, 255);
let bg = Color::rgb(0, 0, 0);
let term = Terminal {
id: WidgetId::new("term"),
cells: vec![vec![TerminalCell {
text: "e\u{0301}".to_string(),
..cell('e', fg, bg)
}]],
scrollbar: None,
};
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(
&term,
&mut surface,
&theme,
0.0,
0.0,
200.0,
100.0,
20.0,
10.0,
None,
);
assert!(
surface
.styled_runs
.iter()
.any(|(_, t, ..)| t == "e\u{0301}"),
"expected the full grapheme cluster painted as one run, got {:?}",
surface.styled_runs,
);
}
#[test]
fn combining_mark_grapheme_advances_a_single_column() {
let fg = Color::rgb(255, 255, 255);
let bg = Color::rgb(0, 0, 0);
let row = vec![
TerminalCell {
text: "e\u{0301}".to_string(),
..cell('e', fg, bg)
},
cell('B', fg, Color::rgb(9, 9, 9)),
];
let term = Terminal {
id: WidgetId::new("term"),
cells: vec![row],
scrollbar: None,
};
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(
&term,
&mut surface,
&theme,
0.0,
0.0,
200.0,
100.0,
20.0,
10.0,
None,
);
assert!(
surface
.fills
.iter()
.any(|&(r, c)| r.x == 10.0 && c == Color::rgb(9, 9, 9)),
"second cell should start at x=10 (one column over), fills were {:?}",
surface.fills,
);
}
}
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[allow(unused_imports)]
pub(crate) use native_surface_paint::{paint, paint_divider};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_layout_basic() {
let area = Rect::new(0.0, 0.0, 80.0, 24.0);
let sl = TerminalSplitLayout::new(area, 40, 1.0, 1.0, 0.0);
assert_eq!(sl.left, Rect::new(0.0, 0.0, 40.0, 24.0));
assert_eq!(sl.divider_x, 40.0);
assert_eq!(sl.divider_width, 1.0);
assert_eq!(sl.right, Rect::new(41.0, 0.0, 39.0, 24.0));
assert!(sl.scrollbar.is_none());
}
#[test]
fn split_layout_pixel_units() {
let area = Rect::new(10.0, 5.0, 800.0, 600.0);
let sl = TerminalSplitLayout::new(area, 40, 8.0, 16.0, 0.0);
assert_eq!(sl.left, Rect::new(10.0, 5.0, 320.0, 600.0));
assert_eq!(sl.divider_x, 330.0);
assert_eq!(sl.divider_width, 8.0);
assert_eq!(sl.right, Rect::new(338.0, 5.0, 472.0, 600.0));
}
#[test]
fn split_layout_left_cols_exceed_area() {
let area = Rect::new(0.0, 0.0, 20.0, 10.0);
let sl = TerminalSplitLayout::new(area, 30, 1.0, 1.0, 0.0);
assert_eq!(sl.left.width, 20.0);
assert_eq!(sl.divider_width, 0.0);
assert_eq!(sl.right.width, 0.0);
}
#[test]
fn split_layout_zero_left_cols() {
let area = Rect::new(0.0, 0.0, 80.0, 24.0);
let sl = TerminalSplitLayout::new(area, 0, 1.0, 1.0, 0.0);
assert_eq!(sl.left.width, 0.0);
assert_eq!(sl.divider_x, 0.0);
assert_eq!(sl.divider_width, 1.0);
assert_eq!(sl.right.width, 79.0);
}
#[test]
fn split_hit_test_cell_coords() {
let area = Rect::new(0.0, 0.0, 80.0, 24.0);
let sl = TerminalSplitLayout::new(area, 40, 1.0, 1.0, 0.0);
assert_eq!(
sl.hit_test(10.0, 5.0),
TerminalSplitHit::LeftPane { col: 10, row: 5 }
);
assert_eq!(sl.hit_test(40.0, 5.0), TerminalSplitHit::Divider);
assert_eq!(
sl.hit_test(50.0, 5.0),
TerminalSplitHit::RightPane { col: 9, row: 5 }
);
assert_eq!(sl.hit_test(50.0, 30.0), TerminalSplitHit::Outside);
assert_eq!(sl.hit_test(-1.0, 5.0), TerminalSplitHit::Outside);
}
#[test]
fn split_hit_test_pixel_units() {
let area = Rect::new(10.0, 5.0, 800.0, 600.0);
let sl = TerminalSplitLayout::new(area, 40, 8.0, 16.0, 0.0);
assert_eq!(
sl.hit_test(26.0, 21.0),
TerminalSplitHit::LeftPane { col: 2, row: 1 }
);
assert_eq!(
sl.hit_test(354.0, 37.0),
TerminalSplitHit::RightPane { col: 2, row: 2 }
);
}
#[test]
fn split_layout_with_scrollbar() {
let area = Rect::new(0.0, 0.0, 80.0, 24.0);
let sl = TerminalSplitLayout::new(area, 40, 1.0, 1.0, 1.0);
let sb = sl.scrollbar.unwrap();
assert_eq!(sb.x, 79.0);
assert_eq!(sb.width, 1.0);
assert_eq!(sl.right.width, 38.0);
}
#[test]
fn split_hit_test_scrollbar() {
let area = Rect::new(0.0, 0.0, 80.0, 24.0);
let sl = TerminalSplitLayout::new(area, 40, 1.0, 1.0, 1.0);
assert_eq!(sl.hit_test(79.0, 5.0), TerminalSplitHit::Scrollbar);
assert_eq!(
sl.hit_test(78.0, 5.0),
TerminalSplitHit::RightPane { col: 37, row: 5 }
);
}
#[test]
fn effective_scroll_offset_non_inverted() {
let sb = TerminalScrollbar {
total_lines: 500,
visible_lines: 24,
scroll_offset: 100,
inverted: false,
width: None,
};
assert_eq!(sb.effective_scroll_offset(), 100);
}
#[test]
fn effective_scroll_offset_inverted_at_bottom() {
let sb = TerminalScrollbar {
total_lines: 500,
visible_lines: 24,
scroll_offset: 0,
inverted: true,
width: None,
};
assert_eq!(sb.effective_scroll_offset(), 476);
}
#[test]
fn effective_scroll_offset_inverted_at_top() {
let sb = TerminalScrollbar {
total_lines: 500,
visible_lines: 24,
scroll_offset: 476,
inverted: true,
width: None,
};
assert_eq!(sb.effective_scroll_offset(), 0);
}
#[test]
fn effective_scroll_offset_inverted_midpoint() {
let sb = TerminalScrollbar {
total_lines: 500,
visible_lines: 24,
scroll_offset: 200,
inverted: true,
width: None,
};
assert_eq!(sb.effective_scroll_offset(), 276);
}
#[test]
fn effective_scroll_offset_inverted_no_overflow() {
let sb = TerminalScrollbar {
total_lines: 24,
visible_lines: 24,
scroll_offset: 0,
inverted: true,
width: None,
};
assert_eq!(sb.effective_scroll_offset(), 0);
}
fn cell(ch: char) -> TerminalCell {
TerminalCell {
text: ch.to_string(),
fg: Color::rgb(255, 255, 255),
bg: Color::rgb(0, 0, 0),
bold: false,
italic: false,
underline: false,
dim: false,
selected: false,
is_cursor: false,
is_find_match: false,
is_find_active: false,
cursor_shape: TerminalCursorShape::Block,
cursor_blinking: false,
}
}
#[test]
fn cell_width_narrow_text_is_one() {
assert_eq!(cell('a').cell_width(), 1);
assert_eq!(cell(' ').cell_width(), 1);
}
#[test]
fn cell_width_wide_cjk_is_two() {
assert_eq!(cell('日').cell_width(), 2);
assert_eq!(cell('中').cell_width(), 2);
}
#[test]
fn cell_width_combining_mark_does_not_add_width() {
let c = TerminalCell {
text: "e\u{0301}".to_string(),
..cell('e')
};
assert_eq!(
c.cell_width(),
1,
"base char + combining accent is still one column"
);
}
fn grid(rows: &[&str]) -> Terminal {
Terminal {
id: WidgetId::new("term"),
cells: rows
.iter()
.map(|row| row.chars().map(cell).collect())
.collect(),
scrollbar: None,
}
}
#[test]
fn dirty_rows_identical_grids_are_clean() {
let prev = grid(&["abc", "def"]);
let next = grid(&["abc", "def"]);
assert_eq!(next.dirty_rows(&prev), Some(vec![]));
}
#[test]
fn dirty_rows_flags_only_changed_rows() {
let prev = grid(&["abc", "def", "ghi"]);
let next = grid(&["abc", "XYZ", "ghi"]);
assert_eq!(next.dirty_rows(&prev), Some(vec![1]));
}
#[test]
fn dirty_rows_flags_multiple_changed_rows() {
let prev = grid(&["abc", "def", "ghi"]);
let next = grid(&["AAA", "def", "GGG"]);
assert_eq!(next.dirty_rows(&prev), Some(vec![0, 2]));
}
#[test]
fn dirty_rows_none_when_row_count_differs() {
let prev = grid(&["abc", "def"]);
let next = grid(&["abc", "def", "ghi"]);
assert_eq!(next.dirty_rows(&prev), None);
}
#[test]
fn dirty_rows_row_length_change_counts_as_dirty() {
let prev = grid(&["ab", "cd"]);
let next = grid(&["abc", "cd"]);
assert_eq!(next.dirty_rows(&prev), Some(vec![0]));
}
#[test]
fn dirty_rows_overlay_flag_change_is_dirty() {
let prev = grid(&["abc"]);
let mut next = grid(&["abc"]);
next.cells[0][1].is_cursor = true;
assert_eq!(next.dirty_rows(&prev), Some(vec![0]));
}
#[test]
fn terminal_roundtrip_serde() {
let term = Terminal {
id: WidgetId::new("terminal-0"),
cells: vec![
vec![
TerminalCell {
text: "$".to_string(),
fg: Color::rgb(200, 200, 200),
bg: Color::rgb(20, 20, 20),
bold: true,
italic: false,
underline: false,
dim: false,
selected: false,
is_cursor: false,
is_find_match: false,
is_find_active: false,
cursor_shape: TerminalCursorShape::Block,
cursor_blinking: false,
},
TerminalCell {
text: "e\u{0301}".to_string(),
fg: Color::rgb(200, 200, 200),
bg: Color::rgb(20, 20, 20),
bold: false,
italic: false,
underline: false,
dim: false,
selected: false,
is_cursor: true,
is_find_match: false,
is_find_active: false,
cursor_shape: TerminalCursorShape::Block,
cursor_blinking: false,
},
],
vec![TerminalCell {
text: "m".to_string(),
fg: Color::rgb(255, 100, 50),
bg: Color::rgb(20, 20, 20),
bold: false,
italic: false,
underline: true,
dim: false,
selected: true,
is_cursor: false,
is_find_match: true,
is_find_active: false,
cursor_shape: TerminalCursorShape::Block,
cursor_blinking: false,
}],
],
scrollbar: None,
};
let json = serde_json::to_string(&term).unwrap();
let back: Terminal = serde_json::from_str(&json).unwrap();
assert_eq!(term, back);
}
#[test]
fn terminal_scrollbar_serde_round_trip() {
let term = Terminal {
id: WidgetId::new("t"),
cells: vec![vec![TerminalCell {
text: "x".to_string(),
fg: Color::rgb(200, 200, 200),
bg: Color::rgb(20, 20, 20),
bold: false,
italic: false,
underline: false,
dim: false,
selected: false,
is_cursor: false,
is_find_match: false,
is_find_active: false,
cursor_shape: TerminalCursorShape::Block,
cursor_blinking: false,
}]],
scrollbar: Some(TerminalScrollbar {
total_lines: 500,
visible_lines: 24,
scroll_offset: 100,
inverted: false,
width: None,
}),
};
let json = serde_json::to_string(&term).unwrap();
let back: Terminal = serde_json::from_str(&json).unwrap();
assert_eq!(term, back);
let sb = back.scrollbar.unwrap();
assert_eq!(sb.total_lines, 500);
assert_eq!(sb.visible_lines, 24);
assert_eq!(sb.scroll_offset, 100);
}
#[test]
fn terminal_event_roundtrip_serde() {
let events = vec![
TerminalEvent::KeyPressed {
key: "a".to_string(),
modifiers: Modifiers::default(),
},
TerminalEvent::SelectStart { row: 5, col: 10 },
TerminalEvent::SelectExtend { row: 6, col: 20 },
TerminalEvent::SelectEnd,
TerminalEvent::Scroll { delta: -3 },
];
for event in &events {
let json = serde_json::to_string(event).unwrap();
let back: TerminalEvent = serde_json::from_str(&json).unwrap();
assert_eq!(event, &back);
}
}
fn make_term(rows: usize, cols: usize) -> Terminal {
let cell = TerminalCell {
text: " ".to_string(),
fg: Color::rgb(200, 200, 200),
bg: Color::rgb(20, 20, 20),
bold: false,
italic: false,
underline: false,
dim: false,
selected: false,
is_cursor: false,
is_find_match: false,
is_find_active: false,
cursor_shape: TerminalCursorShape::Block,
cursor_blinking: false,
};
Terminal {
id: WidgetId::new("term"),
cells: (0..rows).map(|_| vec![cell.clone(); cols]).collect(),
scrollbar: None,
}
}
#[test]
fn terminal_layout_tui_cells() {
let term = make_term(24, 80);
let layout = term.layout(80.0, 24.0, 1.0, 1.0);
assert_eq!(layout.grid_rows, 24);
assert_eq!(layout.grid_cols, 80);
match layout.hit_test(5.5, 3.5) {
TerminalHit::Cell { row, col } => {
assert_eq!(row, 3);
assert_eq!(col, 5);
}
_ => panic!(),
}
}
#[test]
fn terminal_layout_pixel_cells() {
let term = make_term(37, 100);
let layout = term.layout(800.0, 600.0, 8.0, 16.0);
assert_eq!(layout.grid_cols, 100);
assert_eq!(layout.grid_rows, 37); match layout.hit_test(160.0, 48.0) {
TerminalHit::Cell { row, col } => {
assert_eq!(col, 20);
assert_eq!(row, 3);
}
_ => panic!(),
}
}
#[test]
fn terminal_layout_hit_test_outside() {
let term = make_term(10, 20);
let layout = term.layout(20.0, 10.0, 1.0, 1.0);
assert_eq!(layout.hit_test(-1.0, 5.0), TerminalHit::Empty);
assert_eq!(layout.hit_test(5.0, -1.0), TerminalHit::Empty);
assert_eq!(layout.hit_test(100.0, 5.0), TerminalHit::Empty);
}
#[test]
fn terminal_layout_cell_bounds() {
let term = make_term(10, 20);
let layout = term.layout(20.0, 10.0, 1.0, 1.0);
let r = layout.cell_bounds(3, 5).unwrap();
assert_eq!(r.x, 5.0);
assert_eq!(r.y, 3.0);
assert_eq!(r.width, 1.0);
assert_eq!(r.height, 1.0);
assert!(layout.cell_bounds(99, 0).is_none());
assert!(layout.cell_bounds(0, 99).is_none());
}
}