use std::fmt;
use std::ops::{Bound, RangeBounds};
use std::sync::Arc;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum Color {
#[default]
Default,
Indexed(u8),
Rgb(u8, u8, u8),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct Style {
pub fg: Color,
pub bg: Color,
pub bold: bool,
pub dim: bool,
pub italic: bool,
pub underline: bool,
pub reverse: bool,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum MouseMode {
#[default]
None,
Press,
PressRelease,
ButtonMotion,
AnyMotion,
}
#[derive(Debug, Clone)]
pub(crate) struct TermState {
pub(crate) title: Arc<str>,
pub(crate) alternate_screen: bool,
pub(crate) bracketed_paste: bool,
pub(crate) application_cursor: bool,
pub(crate) mouse: MouseMode,
}
impl Default for TermState {
fn default() -> Self {
Self {
title: Arc::from(""),
alternate_screen: false,
bracketed_paste: false,
application_cursor: false,
mouse: MouseMode::None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cell {
contents: String,
style: Style,
wide: bool,
wide_continuation: bool,
}
impl Cell {
pub(crate) fn new(contents: String, style: Style, wide: bool, wide_continuation: bool) -> Self {
Self {
contents,
style,
wide,
wide_continuation,
}
}
#[must_use]
pub fn contents(&self) -> &str {
&self.contents
}
#[must_use]
pub fn style(&self) -> &Style {
&self.style
}
#[must_use]
pub fn is_wide(&self) -> bool {
self.wide
}
#[must_use]
pub fn is_wide_continuation(&self) -> bool {
self.wide_continuation
}
}
#[derive(Clone)]
pub struct Screen {
cols: u16,
rows: u16,
cursor_row: u16,
cursor_col: u16,
cursor_visible: bool,
cells: Arc<[Cell]>,
state: TermState,
}
impl Screen {
pub(crate) fn from_parts(
cols: u16,
rows: u16,
cursor_row: u16,
cursor_col: u16,
cursor_visible: bool,
cells: Vec<Cell>,
state: TermState,
) -> Self {
debug_assert_eq!(cells.len(), usize::from(cols) * usize::from(rows));
Self {
cols,
rows,
cursor_row,
cursor_col,
cursor_visible,
cells: cells.into(),
state,
}
}
#[must_use]
pub fn cols(&self) -> u16 {
self.cols
}
#[must_use]
pub fn rows(&self) -> u16 {
self.rows
}
#[must_use]
pub fn size(&self) -> (u16, u16) {
(self.cols, self.rows)
}
#[must_use]
pub fn cursor(&self) -> (u16, u16, bool) {
(self.cursor_row, self.cursor_col, self.cursor_visible)
}
#[must_use]
pub fn title(&self) -> &str {
&self.state.title
}
#[must_use]
pub fn alternate_screen(&self) -> bool {
self.state.alternate_screen
}
#[must_use]
pub fn bracketed_paste(&self) -> bool {
self.state.bracketed_paste
}
#[must_use]
pub fn application_cursor(&self) -> bool {
self.state.application_cursor
}
#[must_use]
pub fn mouse_mode(&self) -> MouseMode {
self.state.mouse
}
#[must_use]
pub fn cell(&self, row: u16, col: u16) -> Option<&Cell> {
if row >= self.rows || col >= self.cols {
return None;
}
self.cells
.get(usize::from(row) * usize::from(self.cols) + usize::from(col))
}
#[must_use]
pub fn row_text(&self, row: u16) -> String {
let mut out = String::with_capacity(usize::from(self.cols));
for col in 0..self.cols {
let Some(cell) = self.cell(row, col) else {
return out;
};
if cell.is_wide_continuation() {
continue;
}
if cell.contents().is_empty() {
out.push(' ');
} else {
out.push_str(cell.contents());
}
}
out
}
#[must_use]
pub fn text(&self) -> String {
let mut out = String::new();
for row in 0..self.rows {
if row > 0 {
out.push('\n');
}
let line = self.row_text(row);
out.push_str(line.trim_end());
}
out
}
#[must_use]
pub fn contains(&self, needle: &str) -> bool {
self.text().contains(needle)
}
#[must_use]
pub fn find(&self, needle: &str) -> Option<(u16, u16)> {
if needle.is_empty() {
return Some((0, 0));
}
if !needle.contains('\n') {
for row in 0..self.rows {
let text = self.row_text(row);
if let Some(byte_off) = text.find(needle) {
return Some((row, self.col_of_byte(row, byte_off)?));
}
}
return None;
}
let segments: Vec<&str> = needle.split('\n').collect();
let extra = u16::try_from(segments.len() - 1).ok()?;
for row in 0..self.rows.checked_sub(extra)? {
let first_line = self.row_text(row);
let first = first_line.trim_end();
if !first.ends_with(segments[0]) {
continue;
}
let tail_matches = segments[1..].iter().enumerate().all(|(i, seg)| {
let line = self.row_text(row + 1 + i as u16);
let line = line.trim_end();
if i as u16 == extra - 1 {
line.starts_with(seg) } else {
line == *seg }
});
if !tail_matches {
continue;
}
return match segments.iter().position(|s| !s.is_empty()) {
Some(0) => {
let byte_off = first.len() - segments[0].len();
Some((row, self.col_of_byte(row, byte_off)?))
}
Some(k) => Some((row + u16::try_from(k).ok()?, 0)),
None => Some((row + extra, 0)),
};
}
None
}
#[must_use]
pub fn rect_text(&self, cols: impl RangeBounds<u16>, rows: impl RangeBounds<u16>) -> String {
let (col_start, col_end) = clamp_range(&cols, self.cols);
let (row_start, row_end) = clamp_range(&rows, self.rows);
let mut out = String::new();
for row in row_start..row_end {
if row > row_start {
out.push('\n');
}
let mut line = String::new();
for col in col_start..col_end {
let Some(cell) = self.cell(row, col) else {
break;
};
if cell.is_wide_continuation() {
continue;
}
if cell.contents().is_empty() {
line.push(' ');
} else {
line.push_str(cell.contents());
}
}
out.push_str(line.trim_end());
}
out
}
#[must_use]
pub fn find_by(&self, mut predicate: impl FnMut(&Cell) -> bool) -> Option<(u16, u16)> {
for row in 0..self.rows {
for col in 0..self.cols {
if predicate(self.cell(row, col)?) {
return Some((row, col));
}
}
}
None
}
fn col_of_byte(&self, row: u16, byte_off: usize) -> Option<u16> {
let mut acc = 0usize;
for col in 0..self.cols {
let cell = self.cell(row, col)?;
let len = if cell.is_wide_continuation() {
0
} else if cell.contents().is_empty() {
1 } else {
cell.contents().len()
};
if len > 0 && byte_off < acc + len {
return Some(col);
}
acc += len;
}
None
}
#[must_use]
pub fn with_styles(&self) -> ScreenWithStyles<'_> {
ScreenWithStyles { screen: self }
}
}
fn clamp_range(range: &impl RangeBounds<u16>, len: u16) -> (u16, u16) {
let start = match range.start_bound() {
Bound::Included(&s) => s,
Bound::Excluded(&s) => s.saturating_add(1),
Bound::Unbounded => 0,
};
let end = match range.end_bound() {
Bound::Included(&e) => e.saturating_add(1),
Bound::Excluded(&e) => e,
Bound::Unbounded => len,
};
(start.min(len), end.min(len))
}
impl Style {
fn is_default(&self) -> bool {
*self == Style::default()
}
fn tokens(&self) -> String {
fn color(prefix: &str, color: Color, out: &mut Vec<String>) {
match color {
Color::Default => {}
Color::Indexed(i) => out.push(format!("{prefix}={i}")),
Color::Rgb(r, g, b) => out.push(format!("{prefix}=#{r:02x}{g:02x}{b:02x}")),
}
}
let mut tokens = Vec::new();
color("fg", self.fg, &mut tokens);
color("bg", self.bg, &mut tokens);
for (on, name) in [
(self.bold, "bold"),
(self.dim, "dim"),
(self.italic, "italic"),
(self.underline, "underline"),
(self.reverse, "reverse"),
] {
if on {
tokens.push(name.to_owned());
}
}
tokens.join(" ")
}
}
#[derive(Debug, Clone, Copy)]
pub struct ScreenWithStyles<'a> {
screen: &'a Screen,
}
impl fmt::Display for ScreenWithStyles<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let screen = self.screen;
write!(f, "{screen}\n\nstyles:")?;
let mut any = false;
for row in 0..screen.rows() {
let mut spans: Vec<String> = Vec::new();
let mut run: Option<(u16, u16, Style)> = None;
for col in 0..screen.cols() {
let style = screen
.cell(row, col)
.map_or_else(Style::default, |cell| *cell.style());
match &mut run {
Some((_, end, current)) if *current == style => *end = col,
_ => {
if let Some(span) = flush(run.take()) {
spans.push(span);
}
run = Some((col, col, style));
}
}
}
if let Some(span) = flush(run) {
spans.push(span);
}
if !spans.is_empty() {
any = true;
write!(f, "\n{row}: {}", spans.join("; "))?;
}
}
if !any {
write!(f, "\n(none)")?;
}
return Ok(());
fn flush(run: Option<(u16, u16, Style)>) -> Option<String> {
let (start, end, style) = run?;
if style.is_default() {
return None;
}
let range = if start == end {
format!("{start}")
} else {
format!("{start}-{end}")
};
Some(format!("{range} {}", style.tokens()))
}
}
}
impl fmt::Debug for Screen {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Screen({self})")
}
}
impl fmt::Display for Screen {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "size: {}x{} cursor: ", self.cols, self.rows)?;
if self.cursor_visible {
write!(f, "{},{}", self.cursor_row, self.cursor_col)?;
} else {
write!(f, "hidden")?;
}
write!(f, "\n{}", self.text())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn screen(cols: u16, rows: u16, lines: &[&str]) -> Screen {
use unicode_width::UnicodeWidthChar;
let mut cells: Vec<Cell> = Vec::new();
for r in 0..usize::from(rows) {
let mut row_cells: Vec<Cell> = Vec::new();
if let Some(line) = lines.get(r) {
for ch in line.chars() {
let wide = ch.width().unwrap_or(1) == 2;
let style = Style {
bold: ch == '*',
..Style::default()
};
row_cells.push(Cell::new(ch.to_string(), style, wide, false));
if wide {
row_cells.push(Cell::new(String::new(), Style::default(), false, true));
}
}
}
assert!(row_cells.len() <= usize::from(cols), "test line too long");
while row_cells.len() < usize::from(cols) {
row_cells.push(Cell::new(String::new(), Style::default(), false, false));
}
cells.extend(row_cells);
}
Screen::from_parts(cols, rows, 1, 2, true, cells, TermState::default())
}
#[test]
fn row_text_pads_blanks_and_skips_continuations() {
let s = screen(10, 2, &["ab", "汉x"]);
assert_eq!(s.row_text(0), "ab ");
assert_eq!(s.row_text(1), "汉x ");
assert_eq!(s.row_text(9), "");
}
#[test]
fn text_strips_trailing_whitespace_per_line() {
let s = screen(10, 3, &["ab", "", "c"]);
assert_eq!(s.text(), "ab\n\nc");
}
#[test]
fn contains_matches_across_rows() {
let s = screen(10, 2, &["hello", "world"]);
assert!(s.contains("hello"));
assert!(s.contains("hello\nworld"));
assert!(!s.contains("hello world"));
}
#[test]
fn find_reports_wide_aware_columns() {
let s = screen(10, 2, &["abc", "汉字x"]);
assert_eq!(s.find("bc"), Some((0, 1)));
assert_eq!(s.find("x"), Some((1, 4)));
assert_eq!(s.find("字"), Some((1, 2)));
assert_eq!(s.find("missing"), None);
}
#[test]
fn find_locates_multi_row_needles_like_contains() {
let s = screen(10, 3, &["hello", "world", "again"]);
assert_eq!(s.find("hello\nworld"), Some((0, 0)));
assert_eq!(s.find("llo\nwor"), Some((0, 2)));
assert_eq!(s.find("world\nagain"), Some((1, 0)));
assert_eq!(s.find("o\nworld\nag"), Some((0, 4)));
assert_eq!(s.find("hello\nagain"), None); assert_eq!(s.find("hell\nworld"), None); assert_eq!(s.find("hello\nworl\nagain"), None); assert_eq!(s.find("again\nmore"), None); assert_eq!(s.find("hello \nworld"), None);
assert_eq!(s.find("\nworld"), Some((1, 0)));
assert_eq!(s.find("\n"), Some((1, 0)));
for needle in ["hello\nworld", "llo\nwor", "x\nworld", "\nagain"] {
assert_eq!(s.find(needle).is_some(), s.contains(needle), "{needle:?}");
}
}
#[test]
fn multi_row_find_reports_wide_aware_columns() {
let s = screen(10, 2, &["汉字x", "next"]);
assert_eq!(s.find("字x\nnext"), Some((0, 2)));
assert_eq!(s.find("x\nnext"), Some((0, 4)));
}
#[test]
fn rect_text_slices_columns_and_rows() {
let s = screen(10, 3, &["0123456789", "abcdefghij", "xyz"]);
assert_eq!(s.rect_text(2..5, 0..2), "234\ncde");
assert_eq!(s.rect_text(2..=4, 0..=1), "234\ncde"); assert_eq!(s.rect_text(.., 2..), "xyz"); assert_eq!(s.rect_text(8.., ..2), "89\nij");
assert_eq!(s.rect_text(0..3, 5..9), ""); assert_eq!(s.rect_text(20..30, ..1), ""); assert_eq!(s.rect_text(.., ..), s.text()); }
#[test]
fn rect_text_wide_characters_count_where_they_start() {
let s = screen(10, 1, &["汉字x"]);
assert_eq!(s.rect_text(0..2, ..), "汉");
assert_eq!(s.rect_text(1..3, ..), "字");
assert_eq!(s.rect_text(4.., ..), "x");
}
#[test]
fn find_by_scans_row_major_and_sees_styles() {
let s = screen(10, 2, &["ab*", "c"]);
assert_eq!(s.find_by(|c| c.style().bold), Some((0, 2)));
assert_eq!(s.find_by(|c| c.contents() == "c"), Some((1, 0)));
assert_eq!(s.find_by(|c| c.style().reverse), None);
}
#[test]
fn cell_and_cursor_accessors() {
let s = screen(10, 2, &["a*"]);
assert_eq!(s.cell(0, 0).unwrap().contents(), "a");
assert!(s.cell(0, 1).unwrap().style().bold);
assert!(s.cell(2, 0).is_none());
assert!(s.cell(0, 10).is_none());
assert_eq!(s.cursor(), (1, 2, true));
assert_eq!(s.size(), (10, 2));
assert_eq!((s.cols(), s.rows()), (10, 2));
}
#[test]
fn with_styles_renders_runs_in_fixed_token_order() {
use unicode_width::UnicodeWidthChar as _;
let mut cells: Vec<Cell> = Vec::new();
let styled = Style {
fg: Color::Indexed(4),
bold: true,
..Style::default()
};
for ch in ['h', 'i'] {
assert_eq!(ch.width(), Some(1));
cells.push(Cell::new(ch.to_string(), styled, false, false));
}
for _ in 2..6 {
cells.push(Cell::new(String::new(), Style::default(), false, false));
}
for ch in "plain ".chars() {
cells.push(Cell::new(ch.to_string(), Style::default(), false, false));
}
for col in 0..6 {
let style = if col == 3 {
Style {
reverse: true,
..Style::default()
}
} else {
Style::default()
};
cells.push(Cell::new(String::new(), style, false, false));
}
let screen = Screen::from_parts(6, 3, 0, 0, true, cells, TermState::default());
let rendered = screen.with_styles().to_string();
let styles_block = rendered.split("\n\nstyles:\n").nth(1).unwrap();
assert_eq!(styles_block, "0: 0-1 fg=4 bold\n2: 3 reverse");
assert!(rendered.starts_with(&screen.to_string()));
}
#[test]
fn with_styles_on_a_default_screen_says_none() {
let s = screen(10, 2, &["hello"]);
let rendered = s.with_styles().to_string();
assert!(rendered.ends_with("\n\nstyles:\n(none)"), "{rendered}");
}
#[test]
fn with_styles_renders_rgb_and_merges_adjacent_runs() {
let style = Style {
bg: Color::Rgb(0x1e, 0x1e, 0x2e),
..Style::default()
};
let mut cells: Vec<Cell> = Vec::new();
for ch in ['a', 'b', 'c'] {
cells.push(Cell::new(ch.to_string(), style, false, false));
}
cells.push(Cell::new(String::new(), Style::default(), false, false));
let screen = Screen::from_parts(4, 1, 0, 0, true, cells, TermState::default());
let rendered = screen.with_styles().to_string();
assert!(
rendered.ends_with("styles:\n0: 0-2 bg=#1e1e2e"),
"{rendered}"
);
}
#[test]
fn display_format_matches_spec() {
let s = screen(10, 2, &["hi"]);
assert_eq!(format!("{s}"), "size: 10x2 cursor: 1,2\nhi\n");
}
#[test]
fn state_accessors_report_the_captured_state_and_stay_out_of_display() {
let default = screen(4, 1, &["x"]);
assert_eq!(default.title(), "");
assert!(!default.alternate_screen());
assert!(!default.bracketed_paste());
assert!(!default.application_cursor());
assert_eq!(default.mouse_mode(), MouseMode::None);
let state = TermState {
title: Arc::from("my app"),
alternate_screen: true,
bracketed_paste: true,
application_cursor: true,
mouse: MouseMode::AnyMotion,
};
let cells = vec![Cell::new("x".into(), Style::default(), false, false)];
let s = Screen::from_parts(1, 1, 0, 0, true, cells, state);
assert_eq!(s.title(), "my app");
assert!(s.alternate_screen() && s.bracketed_paste() && s.application_cursor());
assert_eq!(s.mouse_mode(), MouseMode::AnyMotion);
assert_eq!(format!("{s}"), "size: 1x1 cursor: 0,0\nx");
}
}