use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use turbo_vision::core::draw::{Cell, DrawBuffer};
use turbo_vision::core::event::{
Event, EventType, KB_DOWN, KB_END, KB_ESC, KB_HOME, KB_PGDN, KB_PGUP, KB_UP, MB_LEFT_BUTTON,
};
use turbo_vision::core::geometry::{Point, Rect};
use turbo_vision::core::palette::{Attr, TvColor};
use turbo_vision::core::state::{GF_GROW_HI_X, GF_GROW_HI_Y, GrowFlags};
use turbo_vision::terminal::Terminal;
use turbo_vision::views::view::{View, write_line_to_terminal};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
struct SelPos {
row: usize,
col: usize,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum SelMode {
Stream,
Block,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
struct Selection {
anchor: SelPos,
head: SelPos,
mode: SelMode,
}
impl SelMode {
fn from_global() -> Self {
if turbo_vision::core::state::block_edit_mode() {
Self::Block
} else {
Self::Stream
}
}
}
fn reverse(attr: Attr) -> Attr {
Attr::new(attr.bg, attr.fg).with_style(attr.style)
}
fn order(a: SelPos, b: SelPos) -> (SelPos, SelPos) {
if (a.row, a.col) <= (b.row, b.col) {
(a, b)
} else {
(b, a)
}
}
fn block_bounds(sel: Selection) -> (usize, usize, usize, usize) {
let (a, b) = (sel.anchor, sel.head);
(
a.row.min(b.row),
a.row.max(b.row),
a.col.min(b.col),
a.col.max(b.col),
)
}
const PRESENTATION_SELECTORS: [char; 2] = ['\u{FE0F}', '\u{FE0E}'];
fn normalize_line(cells: &[Cell]) -> Vec<Cell> {
let mut out = Vec::with_capacity(cells.len());
let mut i = 0;
while i < cells.len() {
let cell = cells[i];
if cell.ch == '\0' {
out.push(cell);
i += 1;
continue;
}
let next = cells.get(i + 1).copied();
let selector = next.filter(|n| PRESENTATION_SELECTORS.contains(&n.ch));
let width = if let Some(sel) = selector {
let mut seq = String::with_capacity(cell.ch.len_utf8() + sel.ch.len_utf8());
seq.push(cell.ch);
seq.push(sel.ch);
seq.width()
} else {
cell.ch.width().unwrap_or(0)
};
if width == 0 {
i += if selector.is_some() { 2 } else { 1 };
continue;
}
out.push(cell);
if let Some(sel) = selector {
out.push(sel);
for _ in 2..width {
out.push(Cell::new('\0', cell.attr));
}
i += 2;
} else {
for _ in 1..width {
out.push(Cell::new('\0', cell.attr));
}
i += 1;
}
}
out
}
const WHEEL_STEP: usize = 3;
const SCROLLBAR_WIDTH: usize = 1;
pub const DEFAULT_MAX_LINES: usize = 10_000;
fn wrap_cells(cells: &[Cell], width: usize) -> Vec<Vec<Cell>> {
if width == 0 || cells.is_empty() {
return vec![cells.to_vec()];
}
let mut rows = Vec::new();
let mut rest = cells;
while rest.len() > width {
let mut break_at = None;
for i in (0..width).rev() {
if rest[i].ch.is_whitespace() {
break_at = Some(i);
break;
}
}
if let Some(i) = break_at {
rows.push(rest[..i].to_vec());
rest = &rest[i + 1..]; } else {
let mut cut = width;
if cut > 1 && rest.get(cut).is_some_and(|c| c.ch == '\0') {
cut -= 1;
}
rows.push(rest[..cut].to_vec());
rest = &rest[cut..];
}
}
rows.push(rest.to_vec());
rows
}
#[derive(Debug)]
pub struct StreamView {
bounds: Rect,
grow_mode: GrowFlags,
lines: Vec<Vec<Cell>>,
partial: Option<Vec<Cell>>,
wrapped: Vec<Vec<Cell>>,
row_counts: Vec<usize>,
partial_wrapped: Vec<Vec<Cell>>,
max_lines: usize,
top: usize,
follow: bool,
fill: Attr,
selection: Option<Selection>,
dragging_thumb: bool,
}
impl StreamView {
#[must_use]
pub fn new(bounds: Rect) -> Self {
Self {
bounds,
grow_mode: GF_GROW_HI_X | GF_GROW_HI_Y,
lines: Vec::new(),
partial: None,
wrapped: Vec::new(),
row_counts: Vec::new(),
partial_wrapped: Vec::new(),
max_lines: DEFAULT_MAX_LINES,
top: 0,
follow: true,
fill: Attr::new(TvColor::LightGray, TvColor::Black),
selection: None,
dragging_thumb: false,
}
}
fn width(&self) -> usize {
usize::try_from(self.bounds.width())
.unwrap_or(0)
.saturating_sub(SCROLLBAR_WIDTH)
}
fn scrollbar_x(&self) -> i16 {
self.bounds.b.x - 1
}
pub fn set_max_lines(&mut self, n: usize) {
self.max_lines = n.max(1);
self.trim();
}
pub fn push_line(&mut self, cells: &[Cell]) {
self.selection = None;
let normalized = normalize_line(cells);
let rows = wrap_cells(&normalized, self.width());
self.row_counts.push(rows.len());
self.wrapped.extend(rows);
self.lines.push(normalized);
self.trim();
if self.follow {
self.scroll_to_bottom();
}
}
pub fn set_partial(&mut self, cells: &[Cell]) {
let cells = normalize_line(cells);
if cells.is_empty() {
self.partial = None;
self.partial_wrapped.clear();
} else {
self.partial_wrapped = wrap_cells(&cells, self.width());
self.partial = Some(cells);
}
if self.follow {
self.scroll_to_bottom();
}
}
pub fn clear(&mut self) {
self.lines.clear();
self.partial = None;
self.wrapped.clear();
self.row_counts.clear();
self.partial_wrapped.clear();
self.top = 0;
self.follow = true;
self.selection = None;
}
#[must_use]
pub fn line_count(&self) -> usize {
self.lines.len() + usize::from(self.partial.is_some())
}
#[must_use]
pub fn row_count(&self) -> usize {
self.wrapped.len() + self.partial_wrapped.len()
}
fn page(&self) -> usize {
usize::try_from(self.bounds.height()).unwrap_or(0).max(1)
}
fn max_top(&self) -> usize {
self.row_count().saturating_sub(self.page())
}
fn rewrap(&mut self) {
let width = self.width();
self.wrapped.clear();
self.row_counts.clear();
for line in &self.lines {
let rows = wrap_cells(line, width);
self.row_counts.push(rows.len());
self.wrapped.extend(rows);
}
self.partial_wrapped = match &self.partial {
Some(cells) => wrap_cells(cells, width),
None => Vec::new(),
};
}
pub fn scroll_to_bottom(&mut self) {
self.top = self.max_top();
self.follow = true;
}
pub fn scroll_to_top(&mut self) {
self.top = 0;
self.follow = false;
}
pub fn scroll_up(&mut self, n: usize) {
self.top = self.top.saturating_sub(n);
self.follow = false;
}
pub fn scroll_down(&mut self, n: usize) {
self.set_top(self.top + n);
}
pub fn set_top(&mut self, top: usize) {
self.top = top.min(self.max_top());
self.follow = self.top == self.max_top();
}
#[must_use]
pub fn top(&self) -> usize {
self.top
}
fn track_len(&self) -> usize {
let page = self.page();
if page >= 3 { page - 2 } else { page }
}
fn track_start(&self) -> usize {
usize::from(self.page() >= 3)
}
fn thumb(&self) -> Option<(usize, usize)> {
let rows = self.row_count();
let page = self.page();
let track = self.track_len();
if rows <= page || track == 0 {
return None;
}
let len = (track * page / rows).clamp(1, track);
let usable = track - len;
let start = if usable == 0 {
0
} else {
(self.top * usable).div_ceil(self.max_top()).min(usable)
};
Some((start, len))
}
fn top_for_track_row(&self, y: i16) -> usize {
let Some((_, len)) = self.thumb() else {
return self.top;
};
let usable = self.track_len() - len;
if usable == 0 {
return self.top;
}
let rel = usize::try_from(y - self.bounds.a.y)
.unwrap_or(0)
.saturating_sub(self.track_start())
.min(usable);
rel * self.max_top() / usable
}
fn in_scrollbar(&self, pos: Point) -> bool {
pos.x == self.scrollbar_x()
&& pos.y >= self.bounds.a.y
&& pos.y < self.bounds.b.y
&& self.bounds.width() > 0
}
fn in_view(&self, pos: Point) -> bool {
pos.x >= self.bounds.a.x
&& pos.x < self.bounds.b.x
&& pos.y >= self.bounds.a.y
&& pos.y < self.bounds.b.y
}
fn scrollbar_click(&mut self, y: i16) {
let rel = usize::try_from(y - self.bounds.a.y).unwrap_or(0);
let page = self.page();
if page >= 3 && rel == 0 {
self.scroll_up(1);
} else if page >= 3 && rel == page - 1 {
self.scroll_down(1);
} else if let Some((start, len)) = self.thumb() {
let track_row = rel - self.track_start();
if track_row < start {
self.scroll_up(page);
} else if track_row >= start + len {
self.scroll_down(page);
} else {
self.dragging_thumb = true;
}
}
}
fn draw_scrollbar(&self, terminal: &mut Terminal) {
if self.bounds.width() <= 0 {
return;
}
let track_attr = Attr::new(TvColor::DarkGray, self.fill.bg);
let thumb_attr = Attr::new(TvColor::LightGray, self.fill.bg);
let page = self.page();
let thumb = self.thumb();
let track_start = self.track_start();
let x = self.scrollbar_x();
for row in 0..page {
let (ch, attr) = if page >= 3 && row == 0 {
('▲', thumb_attr)
} else if page >= 3 && row == page - 1 {
('▼', thumb_attr)
} else {
match thumb {
Some((start, len))
if row - track_start >= start && row - track_start < start + len =>
{
('█', thumb_attr)
}
_ => ('░', track_attr),
}
};
let mut buf = DrawBuffer::new(1);
buf.put_char(0, ch, attr);
let y = self.bounds.a.y + i16::try_from(row).unwrap_or(i16::MAX);
write_line_to_terminal(terminal, x, y, &buf);
}
}
#[must_use]
pub fn is_at_bottom(&self) -> bool {
self.follow
}
pub fn select_all(&mut self) {
let rows = self.row_count();
if rows == 0 {
self.selection = None;
return;
}
let last = rows - 1;
let last_len = self.row_at(last).map_or(0, Vec::len);
self.selection = Some(Selection {
anchor: SelPos { row: 0, col: 0 },
head: SelPos {
row: last,
col: last_len,
},
mode: SelMode::Stream,
});
}
pub fn set_selection(&mut self, anchor: (usize, usize), head: (usize, usize)) {
self.selection = Some(Selection {
anchor: SelPos {
row: anchor.0,
col: anchor.1,
},
head: SelPos {
row: head.0,
col: head.1,
},
mode: SelMode::from_global(),
});
}
pub fn clear_selection(&mut self) {
self.selection = None;
}
#[must_use]
pub fn has_selection(&self) -> bool {
self.selection.is_some()
}
#[must_use]
pub fn selected_text(&self) -> Option<String> {
let sel = self.selection?;
if sel.mode == SelMode::Block {
return Some(self.block_text(sel));
}
let (start, end) = order(sel.anchor, sel.head);
let mut out = String::new();
for row in start.row..=end.row {
let Some(cells) = self.row_at(row) else {
continue;
};
let from = if row == start.row { start.col } else { 0 }.min(cells.len());
let to = if row == end.row { end.col } else { cells.len() }.min(cells.len());
if row > start.row && self.row_is_logical_start(row) {
out.push('\n');
}
out.extend(
cells[from..to.max(from)]
.iter()
.map(|c| c.ch)
.filter(|&ch| ch != '\0'),
);
}
Some(out)
}
fn block_text(&self, sel: Selection) -> String {
let (top, bottom, left, right) = block_bounds(sel);
let mut out = String::new();
for row in top..=bottom {
if row > top {
out.push('\n');
}
let Some(cells) = self.row_at(row) else {
continue;
};
let from = left.min(cells.len());
let to = right.min(cells.len());
out.extend(
cells[from..to.max(from)]
.iter()
.map(|c| c.ch)
.filter(|&ch| ch != '\0'),
);
}
out
}
fn row_at(&self, idx: usize) -> Option<&Vec<Cell>> {
self.iter_rows().nth(idx)
}
fn hit(&self, pos: Point, mode: SelMode) -> Option<SelPos> {
let x = usize::try_from(pos.x - self.bounds.a.x).ok()?;
let y = usize::try_from(pos.y - self.bounds.a.y).ok()?;
if y >= self.page() || x >= self.width() {
return None;
}
let abs_row = self.top + y;
let len = self.row_at(abs_row)?.len();
let col = match mode {
SelMode::Stream => x.min(len),
SelMode::Block => x,
};
Some(SelPos { row: abs_row, col })
}
fn is_selected(&self, abs_row: usize, col: usize) -> bool {
let Some(sel) = self.selection else {
return false;
};
if sel.mode == SelMode::Block {
let (top, bottom, left, right) = block_bounds(sel);
return (top..=bottom).contains(&abs_row) && (left..right).contains(&col);
}
let (s, e) = order(sel.anchor, sel.head);
(abs_row, col) >= (s.row, s.col) && (abs_row, col) < (e.row, e.col)
}
fn row_is_logical_start(&self, abs_row: usize) -> bool {
let mut offset = 0;
for &count in &self.row_counts {
if abs_row == offset {
return true;
}
offset += count;
}
abs_row == offset
}
#[must_use]
pub fn plain_text(&self) -> String {
let mut out = String::new();
for (i, line) in self.iter_lines().enumerate() {
if i > 0 {
out.push('\n');
}
out.extend(line.iter().map(|c| c.ch).filter(|&ch| ch != '\0'));
}
out
}
#[must_use]
pub fn styled_lines(&self) -> Vec<Vec<Cell>> {
self.iter_lines().cloned().collect()
}
fn iter_lines(&self) -> impl Iterator<Item = &Vec<Cell>> {
self.lines.iter().chain(self.partial.iter())
}
fn iter_rows(&self) -> impl Iterator<Item = &Vec<Cell>> {
self.wrapped.iter().chain(self.partial_wrapped.iter())
}
fn trim(&mut self) {
if self.lines.len() > self.max_lines {
let drop = self.lines.len() - self.max_lines;
self.lines.drain(..drop);
let dropped_rows: usize = self.row_counts.drain(..drop).sum();
self.wrapped.drain(..dropped_rows);
self.top = self.top.saturating_sub(dropped_rows);
}
}
}
impl View for StreamView {
fn bounds(&self) -> Rect {
self.bounds
}
fn set_bounds(&mut self, bounds: Rect) {
let width_changed = self.bounds.width() != bounds.width();
self.bounds = bounds;
if width_changed {
self.rewrap();
}
if self.follow {
self.scroll_to_bottom();
} else {
self.top = self.top.min(self.max_top());
}
}
fn draw(&mut self, terminal: &mut Terminal) {
if self.bounds.height() <= 0 {
return;
}
let width = self.width();
let page = self.page();
let rows: Vec<&Vec<Cell>> = self.iter_rows().skip(self.top).take(page).collect();
for row in 0..page {
let mut buf = DrawBuffer::new(width);
for i in 0..width {
buf.put_char(i, ' ', self.fill);
}
if let Some(line) = rows.get(row) {
let abs_row = self.top + row;
for (i, cell) in line.iter().take(width).enumerate() {
let attr = if self.is_selected(abs_row, i) {
reverse(cell.attr)
} else {
cell.attr
};
buf.put_char(i, cell.ch, attr);
}
}
let y = self.bounds.a.y + i16::try_from(row).unwrap_or(i16::MAX);
write_line_to_terminal(terminal, self.bounds.a.x, y, &buf);
}
self.draw_scrollbar(terminal);
}
fn handle_event(&mut self, event: &mut Event) {
match event.what {
EventType::Keyboard => {
let page = self.page();
match event.key_code {
KB_UP => self.scroll_up(1),
KB_DOWN => self.scroll_down(1),
KB_PGUP => self.scroll_up(page),
KB_PGDN => self.scroll_down(page),
KB_HOME => self.scroll_to_top(),
KB_END => self.scroll_to_bottom(),
KB_ESC if self.selection.is_some() => self.clear_selection(),
_ => return,
}
event.clear();
}
EventType::MouseWheelUp if self.in_view(event.mouse.pos) => {
self.scroll_up(WHEEL_STEP);
event.clear();
}
EventType::MouseWheelDown if self.in_view(event.mouse.pos) => {
self.scroll_down(WHEEL_STEP);
event.clear();
}
EventType::MouseDown
if event.mouse.buttons & MB_LEFT_BUTTON != 0
&& self.in_scrollbar(event.mouse.pos) =>
{
self.scrollbar_click(event.mouse.pos.y);
event.clear();
}
EventType::MouseMove | EventType::MouseAuto if self.dragging_thumb => {
let top = self.top_for_track_row(event.mouse.pos.y);
self.set_top(top);
event.clear();
}
EventType::MouseDown if event.mouse.buttons & MB_LEFT_BUTTON != 0 => {
let mode = SelMode::from_global();
let Some(pos) = self.hit(event.mouse.pos, mode) else {
return;
};
self.selection = Some(Selection {
anchor: pos,
head: pos,
mode,
});
event.clear();
}
EventType::MouseMove | EventType::MouseAuto
if event.mouse.buttons & MB_LEFT_BUTTON != 0 =>
{
if let Some(mut sel) = self.selection
&& let Some(pos) = self.hit(event.mouse.pos, sel.mode)
{
sel.head = pos;
self.selection = Some(sel);
event.clear();
}
}
EventType::MouseUp => {
self.dragging_thumb = false;
if let Some(sel) = self.selection
&& sel.anchor == sel.head
{
self.selection = None;
}
event.clear();
}
_ => {}
}
}
fn grow_mode(&self) -> GrowFlags {
self.grow_mode
}
fn set_grow_mode(&mut self, grow_mode: GrowFlags) {
self.grow_mode = grow_mode;
}
fn can_focus(&self) -> bool {
true
}
fn get_palette(&self) -> Option<turbo_vision::core::palette::Palette> {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io;
use std::time::Duration;
use turbo_vision::core::palette::TvColor;
use turbo_vision::terminal::Backend;
fn line(s: &str) -> Vec<Cell> {
s.chars()
.map(|c| Cell::new(c, Attr::new(TvColor::LightGray, TvColor::Black)))
.collect()
}
fn view() -> StreamView {
StreamView::new(Rect::new(0, 0, 41, 10))
}
#[test]
fn select_all_extracts_logical_lines_without_soft_wrap_newlines() {
let mut v = StreamView::new(Rect::new(0, 0, 11, 10));
v.push_line(&line("hello"));
v.push_line(&line("abcdefghijABCDEFGHIJ")); v.select_all();
assert_eq!(
v.selected_text().unwrap(),
"hello\nabcdefghijABCDEFGHIJ",
"soft wraps within a logical line must not become newlines"
);
}
#[test]
fn stream_selection_spans_from_anchor_to_head_across_a_line_break() {
let mut v = view();
v.push_line(&line("hello"));
v.push_line(&line("world"));
v.set_selection((0, 2), (1, 3));
assert_eq!(v.selected_text().unwrap(), "llo\nwor");
}
#[test]
fn stream_selection_is_order_independent() {
let mut v = view();
v.push_line(&line("hello"));
v.push_line(&line("world"));
v.set_selection((1, 3), (0, 2)); assert_eq!(v.selected_text().unwrap(), "llo\nwor");
}
#[test]
fn no_selection_yields_no_text() {
let mut v = view();
v.push_line(&line("hello"));
assert!(v.selected_text().is_none());
assert!(!v.has_selection());
}
#[test]
fn selected_cells_render_reverse_video() {
let mut v = StreamView::new(Rect::new(0, 0, 8, 4));
v.push_line(&line("abcd"));
v.set_selection((0, 1), (0, 3)); let mut terminal = fake_terminal(20, 10);
v.draw(&mut terminal);
let a = terminal.read_cell(0, 0).unwrap(); let b = terminal.read_cell(1, 0).unwrap(); assert_eq!(b.ch, 'b');
assert_eq!(b.attr.fg, a.attr.bg, "selected fg is the normal bg");
assert_eq!(b.attr.bg, a.attr.fg, "selected bg is the normal fg");
let d = terminal.read_cell(3, 0).unwrap();
assert_eq!(d.attr.fg, a.attr.fg, "col 3 is outside [1,3), so normal");
}
#[test]
fn mouse_drag_creates_a_stream_selection() {
let mut v = view();
v.push_line(&line("hello"));
v.push_line(&line("world"));
let mut down = Event::mouse(
EventType::MouseDown,
Point::new(2, 0),
MB_LEFT_BUTTON,
false,
);
v.handle_event(&mut down);
let mut mv = Event::mouse(
EventType::MouseMove,
Point::new(3, 1),
MB_LEFT_BUTTON,
false,
);
v.handle_event(&mut mv);
let mut up = Event::mouse(EventType::MouseUp, Point::new(3, 1), 0, false);
v.handle_event(&mut up);
assert_eq!(v.selected_text().unwrap(), "llo\nwor");
}
struct BlockModeGuard(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>);
impl BlockModeGuard {
fn on() -> Self {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let guard = LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
turbo_vision::core::state::set_block_edit_mode(true);
Self(guard)
}
}
impl Drop for BlockModeGuard {
fn drop(&mut self) {
turbo_vision::core::state::set_block_edit_mode(false);
}
}
#[test]
fn block_mode_selects_a_column_band_from_every_row_it_spans() {
let _guard = BlockModeGuard::on();
let mut v = view();
v.push_line(&line("abcdef"));
v.push_line(&line("gh"));
v.push_line(&line("klmnop"));
v.set_selection((0, 2), (2, 5));
assert_eq!(
v.selected_text().unwrap(),
"cde
mno"
);
}
#[test]
fn a_block_drag_keeps_its_column_band_over_a_short_row() {
let _guard = BlockModeGuard::on();
let mut v = view();
v.push_line(&line("abcdef"));
v.push_line(&line("gh"));
v.push_line(&line("klmnop"));
let mut down = Event::mouse(
EventType::MouseDown,
Point::new(2, 0),
MB_LEFT_BUTTON,
false,
);
v.handle_event(&mut down);
let mut mv = Event::mouse(
EventType::MouseMove,
Point::new(5, 1),
MB_LEFT_BUTTON,
false,
);
v.handle_event(&mut mv);
let mut mv = Event::mouse(
EventType::MouseMove,
Point::new(5, 2),
MB_LEFT_BUTTON,
false,
);
v.handle_event(&mut mv);
let mut up = Event::mouse(EventType::MouseUp, Point::new(5, 2), 0, false);
v.handle_event(&mut up);
assert_eq!(
v.selected_text().unwrap(),
"cde
mno"
);
}
#[test]
fn block_mode_highlights_only_the_column_band() {
let _guard = BlockModeGuard::on();
let mut v = view();
v.push_line(&line("abcdef"));
v.push_line(&line("klmnop"));
v.set_selection((0, 2), (1, 5));
assert!(v.is_selected(0, 3), "cols 2..5 of row 0 are in the band");
assert!(!v.is_selected(0, 5), "col 5 is past the band");
assert!(!v.is_selected(1, 1), "col 1 is before the band");
assert!(
!v.is_selected(0, 1),
"a stream selection would have taken the whole tail of row 0"
);
}
#[test]
fn select_all_stays_a_stream_selection_in_block_mode() {
let _guard = BlockModeGuard::on();
let mut v = view();
v.push_line(&line("abcdef"));
v.push_line(&line("gh"));
v.select_all();
assert_eq!(
v.selected_text().unwrap(),
"abcdef
gh"
);
}
#[test]
fn a_plain_click_clears_any_selection() {
let mut v = view();
v.push_line(&line("hello"));
v.select_all();
assert!(v.has_selection());
let mut down = Event::mouse(
EventType::MouseDown,
Point::new(2, 0),
MB_LEFT_BUTTON,
false,
);
v.handle_event(&mut down);
let mut up = Event::mouse(EventType::MouseUp, Point::new(2, 0), 0, false);
v.handle_event(&mut up);
assert!(!v.has_selection(), "click without drag deselects");
}
#[test]
fn esc_clears_the_selection() {
let mut v = view();
v.push_line(&line("hello"));
v.select_all();
let mut esc = Event::keyboard(KB_ESC);
v.handle_event(&mut esc);
assert!(!v.has_selection());
}
#[test]
fn mutating_the_buffer_clears_the_selection() {
let mut v = view();
v.push_line(&line("hello"));
v.select_all();
assert!(v.has_selection());
v.push_line(&line("more"));
assert!(
!v.has_selection(),
"new content must drop a stale selection"
);
}
struct FakeBackend {
width: u16,
height: u16,
}
impl Backend for FakeBackend {
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn init(&mut self) -> io::Result<()> {
Ok(())
}
fn cleanup(&mut self) -> io::Result<()> {
Ok(())
}
fn size(&self) -> io::Result<(u16, u16)> {
Ok((self.width, self.height))
}
fn poll_event(&mut self, _timeout: Duration) -> io::Result<Option<Event>> {
Ok(None)
}
fn write_raw(&mut self, _data: &[u8]) -> io::Result<()> {
Ok(())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
fn show_cursor(&mut self, _x: u16, _y: u16) -> io::Result<()> {
Ok(())
}
fn hide_cursor(&mut self) -> io::Result<()> {
Ok(())
}
}
fn fake_terminal(width: u16, height: u16) -> Terminal {
Terminal::with_backend(Box::new(FakeBackend { width, height }))
.expect("fake backend never fails to init")
}
#[derive(Clone, Default)]
struct RecordingBackend {
width: u16,
height: u16,
output: std::sync::Arc<std::sync::Mutex<Vec<u8>>>,
}
impl Backend for RecordingBackend {
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn init(&mut self) -> io::Result<()> {
Ok(())
}
fn cleanup(&mut self) -> io::Result<()> {
Ok(())
}
fn size(&self) -> io::Result<(u16, u16)> {
Ok((self.width, self.height))
}
fn poll_event(&mut self, _timeout: Duration) -> io::Result<Option<Event>> {
Ok(None)
}
fn write_raw(&mut self, data: &[u8]) -> io::Result<()> {
self.output.lock().unwrap().extend_from_slice(data);
Ok(())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
fn show_cursor(&mut self, _x: u16, _y: u16) -> io::Result<()> {
Ok(())
}
fn hide_cursor(&mut self) -> io::Result<()> {
Ok(())
}
}
fn recording_terminal(
width: u16,
height: u16,
) -> (Terminal, std::sync::Arc<std::sync::Mutex<Vec<u8>>>) {
let output = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let backend = RecordingBackend {
width,
height,
output: output.clone(),
};
let terminal =
Terminal::with_backend(Box::new(backend)).expect("fake backend never fails to init");
(terminal, output)
}
fn replay_onto_grid(bytes: &[u8], grid: &mut [Vec<char>]) {
let text = std::str::from_utf8(bytes).expect("flush emits valid UTF-8");
let mut chars = text.chars().peekable();
let mut row = 0usize;
let mut col = 0usize;
while let Some(c) = chars.next() {
if c == '\u{1b}' && chars.peek() == Some(&'[') {
chars.next(); let mut params = String::new();
let mut final_byte = ' ';
for pc in chars.by_ref() {
if pc.is_ascii_digit() || pc == ';' {
params.push(pc);
} else {
final_byte = pc;
break;
}
}
if final_byte == 'H' {
let mut parts = params.split(';');
let r: usize = parts.next().and_then(|p| p.parse().ok()).unwrap_or(1);
let cix: usize = parts.next().and_then(|p| p.parse().ok()).unwrap_or(1);
row = r.saturating_sub(1);
col = cix.saturating_sub(1);
}
continue;
}
let width = c.width().unwrap_or(0);
if row < grid.len() && col < grid[row].len() {
grid[row][col] = c;
}
col += width;
}
}
#[test]
fn scrollback_cap_drops_oldest_lines() {
let mut v = view();
v.set_max_lines(3);
for i in 0..5 {
v.push_line(&line(&i.to_string()));
}
assert_eq!(v.line_count(), 3);
assert_eq!(v.plain_text(), "2\n3\n4");
}
#[test]
fn autoscroll_holds_at_bottom_while_lines_arrive() {
let mut v = view();
for i in 0..50 {
v.push_line(&line(&i.to_string()));
}
assert!(v.is_at_bottom());
}
#[test]
fn scrolling_up_releases_autoscroll_and_end_rearms_it() {
let mut v = view();
for i in 0..50 {
v.push_line(&line(&i.to_string()));
}
v.scroll_up(5);
assert!(!v.is_at_bottom());
v.push_line(&line("new"));
assert!(
!v.is_at_bottom(),
"a new line must not yank a scrolled-back reader to the bottom"
);
v.scroll_to_bottom();
assert!(v.is_at_bottom());
}
#[test]
fn partial_line_is_replaced_not_appended() {
let mut v = view();
v.set_partial(&line("par"));
v.set_partial(&line("part"));
assert_eq!(v.plain_text(), "part");
assert_eq!(v.line_count(), 1);
}
#[test]
fn plain_text_strips_attributes() {
let mut v = view();
v.push_line(&[Cell::new('x', Attr::new(TvColor::LightRed, TvColor::Blue))]);
assert_eq!(v.plain_text(), "x");
}
#[test]
fn resize_larger_while_scrolled_back_reclamps_top_to_show_a_full_page() {
let mut v = StreamView::new(Rect::new(0, 0, 40, 5));
for i in 0..50 {
v.push_line(&line(&i.to_string()));
}
v.scroll_to_top();
v.scroll_down(40);
assert!(!v.is_at_bottom());
let old_top = v.top;
assert!(old_top < v.max_top());
v.set_bounds(Rect::new(0, 0, 40, 48));
assert!(
v.top <= v.max_top(),
"top ({}) must not exceed max_top ({}) after growing",
v.top,
v.max_top()
);
let rows: Vec<&Vec<Cell>> = v.iter_rows().skip(v.top).take(v.page()).collect();
assert_eq!(
rows.len(),
v.page().min(v.row_count()),
"a full page of content should be visible after growing"
);
}
#[test]
fn draw_clips_to_bounds_width() {
let mut v = StreamView::new(Rect::new(2, 1, 9, 4));
v.push_line(&line("short"));
let mut terminal = fake_terminal(20, 10);
v.draw(&mut terminal);
for (i, expected) in "short ".chars().enumerate() {
let cell = terminal
.read_cell(2 + i16::try_from(i).unwrap_or(i16::MAX), 1)
.expect("cell within terminal bounds");
assert_eq!(cell.ch, expected);
}
assert_eq!(terminal.read_cell(8, 1).unwrap().ch, '▲');
assert_eq!(terminal.read_cell(9, 1).unwrap().ch, ' ');
assert_eq!(terminal.read_cell(2, 0).unwrap().ch, ' ');
}
const WRENCH: &str = "\u{1F6E0}\u{FE0F}";
#[test]
fn wide_character_row_paints_the_correct_total_number_of_columns() {
let mut v = StreamView::new(Rect::new(0, 0, 11, 4));
v.push_line(&line(&format!("{WRENCH} x")));
let mut terminal = fake_terminal(20, 10);
v.draw(&mut terminal);
assert_eq!(terminal.read_cell(0, 0).unwrap().ch, '\u{1F6E0}');
assert_eq!(terminal.read_cell(1, 0).unwrap().ch, '\u{FE0F}');
assert_eq!(terminal.read_cell(2, 0).unwrap().ch, ' ');
assert_eq!(terminal.read_cell(3, 0).unwrap().ch, 'x');
for x in 4..10 {
assert_eq!(terminal.read_cell(x, 0).unwrap().ch, ' ');
}
}
#[test]
fn text_after_a_wide_character_lands_at_the_right_column() {
let mut v = StreamView::new(Rect::new(0, 0, 30, 4));
v.push_line(&line(&format!("{WRENCH} Reading src/dsml.rs")));
let mut terminal = fake_terminal(30, 10);
v.draw(&mut terminal);
let expected = "\u{1F6E0}\u{FE0F} Reading src/dsml.rs";
for (i, expected_ch) in expected.chars().enumerate() {
let cell = terminal
.read_cell(i16::try_from(i).unwrap(), 0)
.expect("cell within terminal bounds");
assert_eq!(cell.ch, expected_ch, "column {i} mismatch");
}
}
#[test]
fn short_row_is_blank_padded_so_nothing_shows_through_from_beneath() {
let mut v = StreamView::new(Rect::new(0, 0, 11, 4));
v.push_line(&line("XXXXXXXXXX"));
let mut terminal = fake_terminal(20, 10);
v.draw(&mut terminal);
v.clear();
v.push_line(&line(&format!("{WRENCH}hi")));
v.draw(&mut terminal);
assert_eq!(terminal.read_cell(0, 0).unwrap().ch, '\u{1F6E0}');
assert_eq!(terminal.read_cell(1, 0).unwrap().ch, '\u{FE0F}');
assert_eq!(terminal.read_cell(2, 0).unwrap().ch, 'h');
assert_eq!(terminal.read_cell(3, 0).unwrap().ch, 'i');
for x in 4..10 {
assert_eq!(
terminal.read_cell(x, 0).unwrap().ch,
' ',
"column {x} must be blanked, not left over from the previous row"
);
}
}
#[test]
fn a_double_width_character_straddling_a_wrap_boundary_is_never_split() {
let mut v = StreamView::new(Rect::new(0, 0, 4, 4));
v.push_line(&line("ab中cd"));
assert_eq!(v.row_count(), 3, "the 6-column line wraps to three rows");
let mut terminal = fake_terminal(20, 10);
v.draw(&mut terminal);
assert_eq!(terminal.read_cell(0, 0).unwrap().ch, 'a');
assert_eq!(terminal.read_cell(1, 0).unwrap().ch, 'b');
assert_eq!(terminal.read_cell(0, 1).unwrap().ch, '中');
assert_eq!(terminal.read_cell(1, 1).unwrap().ch, '\0');
assert_eq!(terminal.read_cell(2, 1).unwrap().ch, 'c');
assert_eq!(terminal.read_cell(0, 2).unwrap().ch, 'd');
}
#[test]
fn plain_text_round_trips_a_wide_character_with_no_padding_artifacts() {
let mut v = view();
v.push_line(&line(&format!("{WRENCH} Reading src/dsml.rs")));
assert_eq!(v.plain_text(), format!("{WRENCH} Reading src/dsml.rs"));
}
#[test]
fn a_covering_window_s_flush_fully_blanks_a_row_that_held_a_wide_character() {
let (mut terminal, output) = recording_terminal(31, 4);
let mut grid = vec![vec![' '; 31]; 4];
let mut lower = StreamView::new(Rect::new(0, 0, 31, 4));
lower.push_line(&line(&format!("{WRENCH} Reading src/dsml.rs 1:500...")));
lower.draw(&mut terminal);
terminal
.flush()
.expect("flush never fails against a fake backend");
replay_onto_grid(&output.lock().unwrap(), &mut grid);
output.lock().unwrap().clear();
let mut upper = StreamView::new(Rect::new(0, 0, 31, 4));
upper.draw(&mut terminal);
terminal
.flush()
.expect("flush never fails against a fake backend");
replay_onto_grid(&output.lock().unwrap(), &mut grid);
for (col, &ch) in grid[0].iter().take(30).enumerate() {
assert_eq!(
ch, ' ',
"row 0 column {col} still shows a leftover character from \
the window underneath: {grid:?}"
);
}
}
#[test]
fn a_line_longer_than_the_width_wraps_across_the_right_number_of_rows_with_complete_content() {
let mut v = StreamView::new(Rect::new(0, 0, 11, 20));
let text = "abcdefghijklmnopqrstuvwxy";
v.push_line(&line(text));
assert_eq!(v.row_count(), 3);
assert_eq!(
v.plain_text(),
text,
"wrapping must not drop or duplicate any character"
);
let mut terminal = fake_terminal(20, 20);
v.draw(&mut terminal);
let mut rendered = String::new();
for row in 0..3 {
for col in 0..10 {
rendered.push(terminal.read_cell(col, row).unwrap().ch);
}
}
assert_eq!(rendered, "abcdefghijklmnopqrstuvwxy ");
}
#[test]
fn a_wrap_breaks_at_a_space_rather_than_mid_word_when_one_is_available() {
let mut v = StreamView::new(Rect::new(0, 0, 11, 20));
v.push_line(&line("hello world"));
assert_eq!(v.row_count(), 2);
let mut terminal = fake_terminal(20, 20);
v.draw(&mut terminal);
for (i, expected) in "hello ".chars().enumerate() {
assert_eq!(
terminal.read_cell(i16::try_from(i).unwrap(), 0).unwrap().ch,
expected
);
}
for (i, expected) in "world ".chars().enumerate() {
assert_eq!(
terminal.read_cell(i16::try_from(i).unwrap(), 1).unwrap().ch,
expected
);
}
}
#[test]
fn a_single_token_longer_than_the_width_is_broken_rather_than_truncated() {
let mut v = StreamView::new(Rect::new(0, 0, 5, 20));
v.push_line(&line("abcdefghijkl"));
assert_eq!(v.row_count(), 3); assert_eq!(
v.plain_text(),
"abcdefghijkl",
"the logical text is preserved even though it had to be broken mid-token"
);
}
#[test]
fn plain_text_returns_the_original_unwrapped_logical_lines() {
let mut v = StreamView::new(Rect::new(0, 0, 5, 20));
v.push_line(&line("a much longer line than the five-column view"));
v.push_line(&line("short"));
assert_eq!(
v.plain_text(),
"a much longer line than the five-column view\nshort",
"Save As must get the original logical lines, not this window's wrap points"
);
}
#[test]
fn resizing_narrower_then_wider_rewraps_and_content_survives_both() {
let mut v = StreamView::new(Rect::new(0, 0, 21, 20));
let text = "abcdefghijklmnopqrstuvwxyz";
v.push_line(&line(text));
assert_eq!(v.row_count(), 2);
v.set_bounds(Rect::new(0, 0, 6, 20));
assert_eq!(v.row_count(), 6); assert_eq!(v.plain_text(), text);
v.set_bounds(Rect::new(0, 0, 31, 20));
assert_eq!(v.row_count(), 1); assert_eq!(v.plain_text(), text);
}
#[test]
fn scrolling_by_page_lands_correctly_when_wrapped_rows_are_present() {
let mut v = StreamView::new(Rect::new(0, 0, 5, 5));
let text: String = (0..80).map(|i| char::from(b'a' + (i % 26))).collect();
v.push_line(&line(&text));
assert_eq!(v.row_count(), 20);
v.scroll_to_top();
assert_eq!(v.top, 0);
v.scroll_down(v.page()); assert_eq!(
v.top, 5,
"paging must move by display rows, not logical lines"
);
v.scroll_to_bottom();
assert_eq!(v.top, v.row_count() - v.page());
}
fn mouse(what: EventType, x: i16, y: i16, buttons: u8) -> Event {
Event::mouse(what, Point::new(x, y), buttons, false)
}
fn scrollable_view() -> StreamView {
let mut v = view();
for i in 0..50 {
v.push_line(&line(&i.to_string()));
}
v
}
#[test]
fn mouse_wheel_scrolls_by_a_few_rows_and_releases_autoscroll() {
let mut v = scrollable_view();
assert!(v.is_at_bottom());
let mut ev = mouse(EventType::MouseWheelUp, 5, 5, 0);
v.handle_event(&mut ev);
assert_eq!(ev.what, EventType::Nothing, "the wheel event is consumed");
assert_eq!(v.top(), 40 - WHEEL_STEP);
assert!(!v.is_at_bottom());
v.handle_event(&mut mouse(EventType::MouseWheelDown, 5, 5, 0));
assert_eq!(v.top(), 40);
assert!(
v.is_at_bottom(),
"wheeling back to the end re-arms autoscroll"
);
}
#[test]
fn mouse_wheel_outside_the_view_is_ignored() {
let mut v = scrollable_view();
let mut ev = mouse(EventType::MouseWheelUp, 60, 5, 0);
v.handle_event(&mut ev);
assert_eq!(ev.what, EventType::MouseWheelUp);
assert_eq!(v.top(), 40);
}
#[test]
fn scrollbar_arrows_step_one_row_and_track_pages() {
let mut v = scrollable_view();
let x = v.scrollbar_x();
assert_eq!(x, 40);
v.handle_event(&mut mouse(EventType::MouseDown, x, 0, MB_LEFT_BUTTON));
assert_eq!(v.top(), 39, "up arrow steps one row");
v.handle_event(&mut mouse(EventType::MouseDown, x, 9, MB_LEFT_BUTTON));
assert_eq!(v.top(), 40, "down arrow steps one row");
v.handle_event(&mut mouse(EventType::MouseDown, x, 1, MB_LEFT_BUTTON));
assert_eq!(v.top(), 30, "track above the thumb pages up");
v.scroll_to_top();
v.handle_event(&mut mouse(EventType::MouseDown, x, 8, MB_LEFT_BUTTON));
assert_eq!(v.top(), 10, "track below the thumb pages down");
}
#[test]
fn dragging_the_thumb_scrolls_and_a_click_on_the_scrollbar_never_selects() {
let mut v = scrollable_view();
let x = v.scrollbar_x();
v.scroll_to_top();
let (start, len) = v.thumb().expect("50 rows in a 10-row view scroll");
assert_eq!((start, len), (0, 1));
v.handle_event(&mut mouse(EventType::MouseDown, x, 1, MB_LEFT_BUTTON));
assert!(v.dragging_thumb);
assert!(
v.selection.is_none(),
"a scrollbar press must not start a selection"
);
v.handle_event(&mut mouse(EventType::MouseMove, x, 8, MB_LEFT_BUTTON));
assert_eq!(v.top(), 40);
assert!(v.is_at_bottom());
v.handle_event(&mut mouse(EventType::MouseMove, x, 4, MB_LEFT_BUTTON));
assert!(v.top() > 0 && v.top() < 40);
v.handle_event(&mut mouse(EventType::MouseUp, x, 4, 0));
assert!(!v.dragging_thumb);
assert!(v.selection.is_none());
}
#[test]
fn scrollbar_draws_arrows_and_a_thumb_that_tracks_the_position() {
let mut v = scrollable_view();
let x = v.scrollbar_x();
let mut terminal = fake_terminal(50, 10);
v.draw(&mut terminal);
assert_eq!(terminal.read_cell(x, 0).unwrap().ch, '▲');
assert_eq!(terminal.read_cell(x, 9).unwrap().ch, '▼');
assert_eq!(terminal.read_cell(x, 8).unwrap().ch, '█');
assert_eq!(terminal.read_cell(x, 1).unwrap().ch, '░');
v.scroll_to_top();
v.draw(&mut terminal);
assert_eq!(terminal.read_cell(x, 1).unwrap().ch, '█');
assert_eq!(terminal.read_cell(x, 8).unwrap().ch, '░');
}
#[test]
fn scrollbar_has_no_thumb_when_everything_fits() {
let mut v = view();
v.push_line(&line("one"));
assert!(v.thumb().is_none());
let mut terminal = fake_terminal(50, 10);
v.draw(&mut terminal);
for y in 1..9 {
assert_eq!(terminal.read_cell(v.scrollbar_x(), y).unwrap().ch, '░');
}
}
#[test]
fn draw_on_zero_height_view_writes_nothing() {
let mut v = StreamView::new(Rect::new(0, 0, 10, 0));
v.push_line(&line("hello"));
let mut terminal = fake_terminal(20, 10);
v.draw(&mut terminal);
for y in 0..10 {
for x in 0..20 {
assert_eq!(
terminal.read_cell(x, y).unwrap().ch,
' ',
"zero-height view must not write any cell"
);
}
}
}
}