use std::fmt;
use std::ops::{Bound, RangeBounds};
use std::sync::Arc;
use unicode_normalization::UnicodeNormalization;
use crate::graphics::GraphicsSeen;
mod diff;
mod parse;
mod render;
#[cfg(feature = "serde")]
mod serde_impl;
pub use diff::ScreenDiff;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(
feature = "serde",
derive(serde::Serialize, serde::Deserialize),
serde(rename_all = "snake_case")
)]
pub enum Color {
#[default]
Default,
Indexed(u8),
Rgb(u8, u8, u8),
}
impl fmt::Display for Color {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Color::Default => f.write_str("default"),
Color::Indexed(i) => write!(f, "{i}"),
Color::Rgb(r, g, b) => write!(f, "#{r:02x}{g:02x}{b:02x}"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Style {
pub fg: Color,
pub bg: Color,
pub bold: bool,
pub dim: bool,
pub italic: bool,
pub underline: bool,
pub reverse: bool,
pub blink: bool,
pub conceal: bool,
pub strikethrough: bool,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(
feature = "serde",
derive(serde::Serialize, serde::Deserialize),
serde(rename_all = "snake_case")
)]
pub enum MouseMode {
#[default]
None,
Press,
PressRelease,
ButtonMotion,
AnyMotion,
}
impl MouseMode {
fn bit(self) -> u8 {
match self {
MouseMode::None => 0,
MouseMode::Press => 1,
MouseMode::PressRelease => 2,
MouseMode::ButtonMotion => 4,
MouseMode::AnyMotion => 8,
}
}
const TRACKING: [MouseMode; 4] = [
MouseMode::Press,
MouseMode::PressRelease,
MouseMode::ButtonMotion,
MouseMode::AnyMotion,
];
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct MouseModes(u8);
impl MouseModes {
pub(crate) fn from_bits(bits: u8) -> Self {
Self(bits)
}
#[must_use]
pub fn contains(self, mode: MouseMode) -> bool {
match mode {
MouseMode::None => self.is_empty(),
tracking => self.0 & tracking.bit() != 0,
}
}
#[must_use]
pub fn is_empty(self) -> bool {
self.0 == 0
}
#[must_use]
pub fn len(self) -> usize {
self.0.count_ones() as usize
}
pub fn iter(self) -> impl Iterator<Item = MouseMode> {
MouseMode::TRACKING
.into_iter()
.filter(move |mode| self.0 & mode.bit() != 0)
}
}
impl fmt::Debug for MouseModes {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_set().entries(self.iter()).finish()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[non_exhaustive]
#[cfg_attr(
feature = "serde",
derive(serde::Serialize, serde::Deserialize),
serde(rename_all = "snake_case")
)]
pub enum CursorShape {
#[default]
Default,
Block,
Underline,
Bar,
}
#[derive(Clone, Copy)]
pub struct Unsupported<'a> {
retained: &'a [Arc<str>],
overflow: u64,
}
impl<'a> Unsupported<'a> {
#[must_use]
pub fn is_empty(&self) -> bool {
self.retained.is_empty() && self.overflow == 0
}
#[must_use]
pub fn len(&self) -> usize {
self.retained.len()
}
pub fn iter(&self) -> impl ExactSizeIterator<Item = &'a str> + 'a {
self.retained.iter().map(|shape| &**shape)
}
#[must_use]
pub fn contains(&self, sequence: &str) -> bool {
self.retained.iter().any(|shape| &**shape == sequence)
}
#[must_use]
pub fn overflow(&self) -> u64 {
self.overflow
}
}
impl<'a> IntoIterator for Unsupported<'a> {
type Item = &'a str;
type IntoIter = UnsupportedIter<'a>;
fn into_iter(self) -> Self::IntoIter {
UnsupportedIter(self.retained.iter())
}
}
#[derive(Debug, Clone)]
pub struct UnsupportedIter<'a>(std::slice::Iter<'a, Arc<str>>);
impl<'a> Iterator for UnsupportedIter<'a> {
type Item = &'a str;
fn next(&mut self) -> Option<&'a str> {
self.0.next().map(|shape| &**shape)
}
fn size_hint(&self) -> (usize, Option<usize>) {
self.0.size_hint()
}
}
impl ExactSizeIterator for UnsupportedIter<'_> {}
impl fmt::Debug for Unsupported<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_list().entries(self.iter()).finish()?;
if self.overflow > 0 {
write!(f, " (+{} more)", self.overflow)?;
}
Ok(())
}
}
impl PartialEq for Unsupported<'_> {
fn eq(&self, other: &Self) -> bool {
self.overflow == other.overflow && self.iter().eq(other.iter())
}
}
impl Eq for Unsupported<'_> {}
impl PartialEq<[&str]> for Unsupported<'_> {
fn eq(&self, other: &[&str]) -> bool {
self.overflow == 0 && self.iter().eq(other.iter().copied())
}
}
impl<const N: usize> PartialEq<[&str; N]> for Unsupported<'_> {
fn eq(&self, other: &[&str; N]) -> bool {
*self == other[..]
}
}
impl PartialEq<&[&str]> for Unsupported<'_> {
fn eq(&self, other: &&[&str]) -> bool {
*self == **other
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Clipboard {
targets: Arc<str>,
text: Option<Arc<str>>,
}
impl Clipboard {
pub(crate) fn new(targets: &str, text: Option<String>) -> Self {
Self {
targets: Arc::from(targets),
text: text.map(Arc::from),
}
}
#[must_use]
pub fn text(&self) -> Option<&str> {
self.text.as_deref()
}
#[must_use]
pub fn targets(&self) -> &str {
&self.targets
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Link {
uri: Arc<str>,
id: Option<Arc<str>>,
label: Option<Arc<str>>,
closed: bool,
}
impl Link {
pub(crate) fn open(uri: &str, id: Option<&str>) -> Self {
Self {
uri: Arc::from(uri),
id: id.map(Arc::from),
label: None,
closed: false,
}
}
pub(crate) fn close(&mut self, label: Option<String>) {
self.label = label.map(Arc::from);
self.closed = true;
}
#[must_use]
pub fn uri(&self) -> &str {
&self.uri
}
#[must_use]
pub fn id(&self) -> Option<&str> {
self.id.as_deref()
}
#[must_use]
pub fn label(&self) -> Option<&str> {
self.label.as_deref()
}
#[must_use]
pub fn closed(&self) -> bool {
self.closed
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
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,
pub(crate) mouse_modes: MouseModes,
pub(crate) clipboard: Option<Arc<Clipboard>>,
pub(crate) bells: u64,
pub(crate) focus_events: bool,
pub(crate) cursor_style: Option<u8>,
pub(crate) links: Arc<Vec<Link>>,
pub(crate) graphics: GraphicsSeen,
pub(crate) repaints: u64,
pub(crate) scrollback: Arc<[Arc<str>]>,
pub(crate) scrollback_cells: Option<Arc<[Arc<[Cell]>]>>,
pub(crate) wrapped: Arc<[bool]>,
pub(crate) insert_mode: bool,
pub(crate) unsupported: Arc<[Arc<str>]>,
pub(crate) unsupported_overflow: u64,
pub(crate) visual_bells: u64,
}
impl Default for TermState {
fn default() -> Self {
Self {
title: Arc::from(""),
alternate_screen: false,
bracketed_paste: false,
application_cursor: false,
mouse: MouseMode::None,
mouse_modes: MouseModes::default(),
clipboard: None,
bells: 0,
focus_events: false,
cursor_style: None,
links: Arc::new(Vec::new()),
graphics: GraphicsSeen::default(),
repaints: 0,
scrollback: Arc::from([] as [Arc<str>; 0]),
scrollback_cells: None,
wrapped: Arc::from([] as [bool; 0]),
insert_mode: false,
unsupported: Arc::from([] as [Arc<str>; 0]),
unsupported_overflow: 0,
visual_bells: 0,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
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
}
}
pub(crate) fn same_cursor(a: (u16, u16, bool), b: (u16, u16, bool)) -> bool {
match (a.2, b.2) {
(false, false) => true,
(true, true) => (a.0, a.1) == (b.0, b.1),
_ => false,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Location {
Screen {
row: u16,
col: u16,
},
History {
row: usize,
col: u16,
},
}
impl Location {
#[must_use]
pub fn is_on_screen(self) -> bool {
matches!(self, Location::Screen { .. })
}
#[must_use]
pub fn is_in_history(self) -> bool {
matches!(self, Location::History { .. })
}
#[must_use]
pub fn col(self) -> u16 {
match self {
Location::Screen { col, .. } | Location::History { col, .. } => col,
}
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct Screen {
cols: u16,
rows: u16,
cursor_row: u16,
cursor_col: u16,
cursor_visible: bool,
cells: Arc<[Cell]>,
state: Arc<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: Arc::new(state),
}
}
pub(crate) fn same_picture(&self, other: &Screen) -> bool {
self.cols == other.cols
&& self.rows == other.rows
&& same_cursor(self.cursor(), other.cursor())
&& (Arc::ptr_eq(&self.cells, &other.cells) || self.cells == other.cells)
}
pub(crate) fn with_repaints(mut self, repaints: u64) -> Self {
Arc::make_mut(&mut self.state).repaints = repaints;
self
}
#[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 cursor_visible(&self) -> bool {
self.cursor_visible
}
#[must_use]
pub fn cursor_shape(&self) -> CursorShape {
match self.state.cursor_style {
None => CursorShape::Default,
Some(0..=2) => CursorShape::Block,
Some(3..=4) => CursorShape::Underline,
Some(5..=6) => CursorShape::Bar,
Some(_) => CursorShape::Default,
}
}
#[must_use]
pub fn cursor_blink(&self) -> Option<bool> {
match self.state.cursor_style {
Some(0 | 1 | 3 | 5) => Some(true),
Some(2 | 4 | 6) => Some(false),
_ => None,
}
}
#[must_use]
pub fn links(&self) -> &[Link] {
&self.state.links
}
#[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 focus_events(&self) -> bool {
self.state.focus_events
}
#[must_use]
pub fn mouse_mode(&self) -> MouseMode {
self.state.mouse
}
#[must_use]
pub fn mouse_modes(&self) -> MouseModes {
self.state.mouse_modes
}
#[must_use]
pub fn clipboard(&self) -> Option<&Clipboard> {
self.state.clipboard.as_deref()
}
#[must_use]
pub fn repaints(&self) -> u64 {
self.state.repaints
}
#[must_use]
pub fn bells(&self) -> u64 {
self.state.bells
}
#[must_use]
pub fn graphics(&self) -> GraphicsSeen {
self.state.graphics.clone()
}
#[must_use]
pub fn unsupported(&self) -> Unsupported<'_> {
Unsupported {
retained: &self.state.unsupported,
overflow: self.state.unsupported_overflow,
}
}
#[must_use]
pub fn visual_bells(&self) -> u64 {
self.state.visual_bells
}
#[must_use]
pub fn insert_mode(&self) -> bool {
self.state.insert_mode
}
#[must_use]
pub fn row_wrapped(&self, row: u16) -> bool {
self.state
.wrapped
.get(usize::from(row))
.copied()
.unwrap_or(false)
}
#[must_use]
pub fn logical_text(&self) -> String {
let mut out = String::new();
let mut line = String::new();
for row in 0..self.rows {
line.push_str(&self.row_text(row));
if self.row_wrapped(row) {
continue;
}
if !out.is_empty() {
out.push('\n');
}
out.push_str(line.trim_end());
line.clear();
}
if !line.is_empty() {
if !out.is_empty() {
out.push('\n');
}
out.push_str(line.trim_end());
}
out
}
#[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 {
assert!(
row < self.rows,
"row_text: row {row} is outside the {}-row screen",
self.rows
);
let mut out = String::with_capacity(usize::from(self.cols));
for col in 0..self.cols {
let cell = self.cell(row, col).expect("row and column are in bounds");
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 scrollback_rows(&self) -> usize {
self.state.scrollback.len()
}
#[must_use]
pub fn scrollback_text(&self) -> String {
let mut out = String::new();
for (i, row) in self.state.scrollback.iter().enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str(row);
}
out
}
#[must_use]
pub fn styled_scrollback(&self) -> bool {
self.state.scrollback_cells.is_some()
}
#[must_use]
pub fn scrollback_cell(&self, row: usize, col: u16) -> Option<&Cell> {
self.state
.scrollback_cells
.as_ref()?
.get(row)?
.get(usize::from(col))
}
#[must_use]
pub fn locate(&self, needle: &str) -> Option<Location> {
if let Some((row, col)) = self.find(needle) {
return Some(Location::Screen { row, col });
}
if needle.is_empty() || needle.contains('\n') {
return None;
}
let needle = nfc(needle);
for (row, line) in self.state.scrollback.iter().enumerate() {
let folded = nfc(line);
let Some(byte_off) = folded.find(needle.as_str()) else {
continue;
};
let col = unicode_width::UnicodeWidthStr::width(&folded[..byte_off]);
return Some(Location::History {
row,
col: u16::try_from(col).unwrap_or(u16::MAX),
});
}
None
}
#[must_use]
pub fn full_text(&self) -> String {
let mut out = self.scrollback_text();
if !out.is_empty() {
out.push('\n');
}
out.push_str(&self.text());
out
}
#[must_use]
pub fn contains(&self, needle: &str) -> bool {
if self.is_ascii() && needle.is_ascii() {
return self.text().contains(needle);
}
self.nfc_text().contains(&nfc(needle))
}
#[must_use]
pub fn find(&self, needle: &str) -> Option<(u16, u16)> {
let mut first = None;
self.for_each_match(needle, |at| {
first = Some(at);
false
});
first
}
#[must_use]
pub fn find_all(&self, needle: &str) -> Vec<(u16, u16)> {
let mut all = Vec::new();
self.for_each_match(needle, |at| {
all.push(at);
true
});
all
}
fn for_each_match(&self, needle: &str, mut visit: impl FnMut((u16, u16)) -> bool) {
if needle.is_empty() {
visit((0, 0));
return;
}
let needle = &self.fold(needle);
if !needle.contains('\n') {
for row in 0..self.rows {
let (text, cols) = self.searchable_row(row);
for (byte_off, _) in text.trim_end().match_indices(needle.as_str()) {
let Some(&col) = cols.get(byte_off) else {
return;
};
if !visit((row, col)) {
return;
}
}
}
return;
}
let segments: Vec<&str> = needle.split('\n').collect();
let Ok(extra) = u16::try_from(segments.len() - 1) else {
return;
};
self.for_each_multirow_match(&segments, |row| {
let (first_line, cols) = self.searchable_row(row);
let first = first_line.trim_end();
let at = match segments.iter().position(|s| !s.is_empty()) {
Some(0) => {
let byte_off = first.len() - segments[0].len();
match cols.get(byte_off) {
Some(&col) => (row, col),
None => return false,
}
}
Some(k) => match u16::try_from(k) {
Ok(k) => (row + k, 0),
Err(_) => return false,
},
None => (row + extra, 0),
};
visit(at)
});
}
fn for_each_multirow_match(&self, segments: &[&str], mut visit: impl FnMut(u16) -> bool) {
let Ok(extra) = u16::try_from(segments.len().saturating_sub(1)) else {
return;
};
let Some(last_start) = self.rows.checked_sub(extra) else {
return;
};
let mut row = 0;
while row < last_start {
let (first_line, _) = self.searchable_row(row);
let first = first_line.trim_end();
let tail_matches = || {
segments[1..].iter().enumerate().all(|(i, seg)| {
let (line, _) = self.searchable_row(row + 1 + i as u16);
let line = line.trim_end();
if i as u16 == extra - 1 {
line.starts_with(seg) } else {
line == *seg }
})
};
if !first.ends_with(segments[0]) || !tail_matches() {
row += 1;
continue;
}
if !visit(row) {
return;
}
row += extra;
}
}
#[must_use]
pub fn mask_rect(&self, cols: impl RangeBounds<u16>, rows: impl RangeBounds<u16>) -> Screen {
let (col_start, col_end) = clamp_range(&cols, self.cols, "column");
let (row_start, row_end) = clamp_range(&rows, self.rows, "row");
self.masked(
|row, col, _| {
(row_start..row_end).contains(&row) && (col_start..col_end).contains(&col)
},
None,
)
}
#[must_use]
pub fn mask_matching(&self, pattern: &str, fill: char) -> Screen {
let pattern = self.fold(pattern);
if pattern.contains('\n') {
return self.masked_multiline(&pattern, fill);
}
self.masked_spans(
|hay| {
hay.match_indices(pattern.as_str())
.map(|(start, _)| (start, start + pattern.len()))
.collect()
},
fill,
)
}
fn masked_multiline(&self, needle: &str, fill: char) -> Screen {
let segments: Vec<&str> = needle.split('\n').collect();
let Ok(extra) = u16::try_from(segments.len().saturating_sub(1)) else {
return self.clone();
};
let width = usize::from(self.cols);
let mut hits = vec![false; self.cells.len()];
self.for_each_multirow_match(&segments, |row| {
for (index, segment) in segments.iter().enumerate() {
let at = row + index as u16;
let (text, cols) = self.searchable_row(at);
let line = text.trim_end();
let (start, end) = if index == 0 {
(line.len().saturating_sub(segment.len()), line.len())
} else if index as u16 == extra {
(0, segment.len())
} else {
(0, line.len())
};
for byte in start..end {
if let Some(&col) = cols.get(byte) {
hits[usize::from(at) * width + usize::from(col)] = true;
}
}
}
true
});
self.apply_mask(hits, Some(fill))
}
#[must_use]
pub fn mask_cells(&self, mut predicate: impl FnMut(&Cell) -> bool) -> Screen {
self.masked(|_, _, cell| predicate(cell), None)
}
fn masked(&self, mut hit: impl FnMut(u16, u16, &Cell) -> bool, fill: Option<char>) -> Screen {
let width = usize::from(self.cols);
let hits: Vec<bool> = self
.cells
.iter()
.enumerate()
.map(|(i, cell)| {
let row = u16::try_from(i / width).unwrap_or(u16::MAX);
let col = u16::try_from(i % width).unwrap_or(u16::MAX);
hit(row, col, cell)
})
.collect();
self.apply_mask(hits, fill)
}
fn masked_spans(
&self,
mut spans: impl FnMut(&str) -> Vec<(usize, usize)>,
fill: char,
) -> Screen {
let width = usize::from(self.cols);
let mut hits = vec![false; self.cells.len()];
for row in 0..self.rows {
let (text, cols) = self.searchable_row(row);
for (start, end) in spans(text.trim_end()) {
for byte in start..end {
if let Some(&col) = cols.get(byte) {
hits[usize::from(row) * width + usize::from(col)] = true;
}
}
}
}
self.apply_mask(hits, Some(fill))
}
fn apply_mask(&self, mut hits: Vec<bool>, fill: Option<char>) -> Screen {
if let Some(fill) = fill {
assert_eq!(
unicode_width::UnicodeWidthChar::width(fill),
Some(1),
"a mask fill must be one column wide, and {fill:?} is not"
);
}
let width = usize::from(self.cols);
for i in 0..hits.len() {
if !hits[i] {
continue;
}
if self.cells[i].is_wide() && (i + 1) % width != 0 && i + 1 < hits.len() {
hits[i + 1] = true;
}
if self.cells[i].is_wide_continuation() && i % width != 0 {
hits[i - 1] = true;
}
}
let contents = fill.map_or_else(String::new, String::from);
let cells: Vec<Cell> = self
.cells
.iter()
.zip(&hits)
.map(|(cell, &hit)| {
if hit {
Cell::new(contents.clone(), *cell.style(), false, false)
} else {
cell.clone()
}
})
.collect();
Screen {
cols: self.cols,
rows: self.rows,
cursor_row: self.cursor_row,
cursor_col: self.cursor_col,
cursor_visible: self.cursor_visible,
cells: cells.into(),
state: Arc::clone(&self.state),
}
}
#[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, "column");
let (row_start, row_end) = clamp_range(&rows, self.rows, "row");
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 is_ascii(&self) -> bool {
self.cells.iter().all(|c| c.contents().is_ascii())
}
fn fold(&self, needle: &str) -> String {
if self.is_ascii() && needle.is_ascii() {
needle.to_owned()
} else {
nfc(needle)
}
}
fn nfc_text(&self) -> String {
let mut out = String::new();
for row in 0..self.rows {
if row > 0 {
out.push('\n');
}
let (line, _) = self.searchable_row(row);
out.push_str(line.trim_end());
}
out
}
fn searchable_row(&self, row: u16) -> (String, Vec<u16>) {
let ascii = self.is_ascii();
let mut text = String::with_capacity(usize::from(self.cols));
let mut cols = Vec::with_capacity(usize::from(self.cols));
for col in 0..self.cols {
let Some(cell) = self.cell(row, col) else {
break;
};
if cell.is_wide_continuation() {
continue;
}
let before = text.len();
if cell.contents().is_empty() {
text.push(' ');
} else if ascii {
text.push_str(cell.contents());
} else {
text.extend(cell.contents().nfc());
}
cols.resize(text.len(), col);
debug_assert!(text.len() > before || cell.is_wide_continuation());
}
cols.push(self.cols.saturating_sub(1));
(text, cols)
}
#[must_use]
pub fn with_styles(&self) -> ScreenWithStyles<'_> {
ScreenWithStyles { screen: self }
}
}
#[cfg(feature = "regex")]
#[cfg_attr(docsrs, doc(cfg(feature = "regex")))]
impl Screen {
#[must_use]
pub fn find_match(&self, re: ®ex::Regex) -> Option<(u16, u16, String)> {
let mut first = None;
self.for_each_regex_match(re, |m| {
first = Some(m);
false
});
first
}
#[must_use]
pub fn find_all_matches(&self, re: ®ex::Regex) -> Vec<(u16, u16, String)> {
let mut all = Vec::new();
self.for_each_regex_match(re, |m| {
all.push(m);
true
});
all
}
#[must_use]
pub fn matches(&self, re: ®ex::Regex) -> bool {
self.find_match(re).is_some()
}
#[must_use]
pub fn mask_matches(&self, re: ®ex::Regex, fill: char) -> Screen {
self.masked_spans(
|hay| re.find_iter(hay).map(|m| (m.start(), m.end())).collect(),
fill,
)
}
fn for_each_regex_match(
&self,
re: ®ex::Regex,
mut visit: impl FnMut((u16, u16, String)) -> bool,
) {
for row in 0..self.rows {
let (text, cols) = self.searchable_row(row);
for m in re.find_iter(text.trim_end()) {
let Some(&col) = cols.get(m.start()) else {
return;
};
if !visit((row, col, m.as_str().to_owned())) {
return;
}
}
}
}
}
fn nfc(s: &str) -> String {
s.nfc().collect()
}
fn clamp_range(range: &impl RangeBounds<u16>, len: u16, axis: &str) -> (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,
};
assert!(
start <= end,
"rect_text: {axis} range starts at {start} but ends at {end}"
);
(start.min(len), end.min(len))
}
impl Style {
pub(crate) fn is_default(&self) -> bool {
*self == Style::default()
}
pub(crate) fn tokens(&self) -> String {
fn color(prefix: &str, color: Color, out: &mut Vec<String>) {
if color != Color::Default {
out.push(format!("{prefix}={color}"));
}
}
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.blink, "blink"),
(self.reverse, "reverse"),
(self.conceal, "conceal"),
(self.strikethrough, "strikethrough"),
] {
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() {
if ch.width().unwrap_or(1) == 0 {
if let Some(last) = row_cells.last_mut() {
let joined = format!("{}{ch}", last.contents());
*last = Cell::new(joined, *last.style(), last.is_wide(), false);
continue;
}
}
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, 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 ");
}
#[test]
#[should_panic(expected = "row_text: row 9 is outside the 2-row screen")]
fn row_text_rejects_out_of_bounds_rows() {
let _ = screen(10, 2, &["ab", "汉x"]).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 needles_match_across_normalization_forms() {
let nfc = "caf\u{e9}";
let nfd = "cafe\u{301}";
let on_nfd = screen(10, 1, &[nfd]);
assert!(on_nfd.contains(nfc), "NFC needle must find NFD text");
assert!(on_nfd.contains(nfd));
assert_eq!(on_nfd.find(nfc), Some((0, 0)));
assert_eq!(on_nfd.find(nfd), Some((0, 0)));
let on_nfc = screen(10, 1, &[nfc]);
assert!(on_nfc.contains(nfd), "NFD needle must find NFC text");
assert!(on_nfc.contains(nfc));
assert_eq!(on_nfc.find(nfd), Some((0, 0)));
assert_eq!(on_nfd.text(), nfd);
assert_eq!(on_nfc.text(), nfc);
assert_ne!(on_nfd.text(), on_nfc.text());
}
#[test]
fn a_folded_match_does_not_split_a_composed_character() {
let on_nfd = screen(10, 1, &["cafe\u{301}"]);
assert!(!on_nfd.contains("cafe"), "the screen shows caf\u{e9}");
assert!(on_nfd.contains("caf"));
}
#[test]
fn folded_matches_still_report_real_columns() {
let s = screen(10, 1, &["e\u{301}xyMARK"]);
assert_eq!(s.find("MARK"), Some((0, 3)));
assert_eq!(s.find("x"), Some((0, 1)));
let wide = screen(10, 1, &["\u{6c49}e\u{301}Z"]);
assert_eq!(wide.find("Z"), Some((0, 3)));
}
#[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 single_row_find_agrees_with_contains_about_trailing_padding() {
let s = screen(10, 2, &["Total:", "a b"]);
for needle in [
"Total:", "Total: ", "Total: ", " ", " ", "otal: ", "a b", "a b ", "b ",
] {
assert_eq!(s.find(needle).is_some(), s.contains(needle), "{needle:?}");
}
assert_eq!(s.find("Total:"), Some((0, 0)));
assert_eq!(s.find("Total: "), None);
assert_eq!(s.find("a b"), Some((1, 0))); assert_eq!(s.find(" "), Some((1, 1))); }
#[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]
#[should_panic(expected = "column range starts at 3 but ends at 0")]
fn a_reversed_column_range_panics() {
let s = screen(10, 3, &["0123456789", "abcdefghij", "xyz"]);
let (from, to) = (3, 0);
let _ = s.rect_text(from..to, 0..2);
}
#[test]
#[should_panic(expected = "row range starts at 2 but ends at 0")]
fn a_reversed_row_range_panics() {
let s = screen(10, 3, &["0123456789", "abcdefghij", "xyz"]);
let (from, to) = (2, 0);
let _ = s.rect_text(0..3, from..to);
}
#[test]
fn out_of_range_bounds_still_clamp() {
let s = screen(10, 3, &["0123456789", "abcdefghij", "xyz"]);
assert_eq!(s.rect_text(8..99, ..1), "89");
assert_eq!(s.rect_text(.., 1..99), "abcdefghij\nxyz");
assert_eq!(s.rect_text(5..5, ..), "\n\n"); }
#[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 equality_is_the_same_observation_not_the_same_rendering() {
let a = screen(10, 2, &["hello"]);
assert_eq!(a, a.clone(), "a clone observes the same instant");
assert_eq!(a, screen(10, 2, &["hello"]), "built alike, equal");
assert_ne!(a, screen(10, 2, &["hullo"]));
let cells = vec![Cell::new("x".into(), Style::default(), false, false)];
let quiet = Screen::from_parts(1, 1, 0, 0, true, cells.clone(), TermState::default());
let rung = Screen::from_parts(
1,
1,
0,
0,
true,
cells,
TermState {
bells: 1,
..TermState::default()
},
);
assert_eq!(quiet.to_string(), rung.to_string());
assert_ne!(quiet, rung);
let bold = vec![Cell::new(
"x".into(),
Style {
bold: true,
..Style::default()
},
false,
false,
)];
let styled = Screen::from_parts(1, 1, 0, 0, true, bold, TermState::default());
assert_eq!(quiet.to_string(), styled.to_string());
assert_ne!(quiet, styled);
assert_ne!(
quiet.with_styles().to_string(),
styled.with_styles().to_string()
);
}
#[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 the_cursor_shape_is_invisible_in_the_rendering() {
let render = |cursor_style| {
let state = TermState {
cursor_style,
..TermState::default()
};
let cells = vec![Cell::new("x".into(), Style::default(), false, false)];
Screen::from_parts(1, 1, 0, 0, true, cells, state).to_string()
};
let plain = render(None);
for ps in 0u8..=6 {
assert_eq!(
render(Some(ps)),
plain,
"DECSCUSR {ps} changed the rendering"
);
}
let linked = TermState {
links: Arc::new(vec![Link::open("https://example.invalid/a", Some("7"))]),
..TermState::default()
};
let cells = vec![Cell::new("x".into(), Style::default(), false, false)];
assert_eq!(
Screen::from_parts(1, 1, 0, 0, true, cells, linked).to_string(),
plain,
"a hyperlink changed the rendering"
);
let state = TermState {
cursor_style: Some(5),
..TermState::default()
};
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.cursor_shape(), CursorShape::Bar);
assert_eq!(s.cursor_blink(), Some(true));
}
#[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);
assert!(default.clipboard().is_none());
assert_eq!(default.cursor_shape(), CursorShape::Default);
assert_eq!(default.cursor_blink(), None);
assert!(default.links().is_empty());
let state = TermState {
title: Arc::from("my app"),
alternate_screen: true,
bracketed_paste: true,
application_cursor: true,
mouse: MouseMode::AnyMotion,
mouse_modes: MouseModes::from_bits(0b1110),
clipboard: Some(Arc::new(Clipboard::new("c", Some("copied".into())))),
bells: 3,
focus_events: true,
cursor_style: Some(6),
links: Arc::new(vec![{
let mut l = Link::open("https://example.invalid/a", Some("7"));
l.close(Some("docs".into()));
l
}]),
graphics: GraphicsSeen::for_test(1, 1, 2, 160),
repaints: 9,
scrollback: Arc::from([Arc::from("scrolled away")]),
..TermState::default()
};
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);
let modes = s.mouse_modes();
assert_eq!(
modes.iter().collect::<Vec<_>>(),
[
MouseMode::PressRelease,
MouseMode::ButtonMotion,
MouseMode::AnyMotion
]
);
assert!(modes.contains(MouseMode::ButtonMotion) && !modes.contains(MouseMode::Press));
assert!(!modes.contains(MouseMode::None) && !modes.is_empty() && modes.len() == 3);
assert_eq!(
format!("{modes:?}"),
"{PressRelease, ButtonMotion, AnyMotion}"
);
assert!(
default.mouse_modes().is_empty() && default.mouse_modes().contains(MouseMode::None)
);
let clip = s.clipboard().expect("captured");
assert_eq!((clip.targets(), clip.text()), ("c", Some("copied")));
assert_eq!(s.scrollback_rows(), 1);
assert_eq!(s.bells(), 3);
assert_eq!(s.cursor_shape(), CursorShape::Bar);
assert_eq!(s.cursor_blink(), Some(false));
let link = &s.links()[0];
assert_eq!(link.uri(), "https://example.invalid/a");
assert_eq!(
(link.id(), link.label(), link.closed()),
(Some("7"), Some("docs"), true)
);
assert!(s.focus_events());
assert_eq!(s.repaints(), 9);
assert_eq!(s.graphics().kitty(), 1);
assert_eq!(s.graphics().sixel(), 1);
assert_eq!(s.graphics().total(), 2);
assert_eq!(s.graphics().deletes(), 2);
assert_eq!(s.graphics().bytes(), 160);
assert!(!s.graphics().is_empty());
assert!(GraphicsSeen::default().is_empty());
assert_eq!(s.scrollback_text(), "scrolled away");
assert_eq!(s.full_text(), "scrolled away\nx");
assert_eq!(format!("{s}"), "size: 1x1 cursor: 0,0\nx");
}
}