use std::io::{self, Write};
use crossterm::QueueableCommand;
use crossterm::cursor::{MoveToColumn, MoveUp};
use crossterm::style::{Attribute, Print, SetAttribute, SetBackgroundColor, SetForegroundColor};
use crossterm::terminal::{self, ClearType};
use crate::style::{
Align, Cell, Style, StyledBlock, StyledText, is_line_break_grapheme, push_grapheme_cells,
sanitize_hyperlink_target, visit_styled_graphemes,
};
use crate::{CellRow, style as path_crate_style};
fn cols(cells: &[Cell]) -> usize {
cells.iter().map(|c| c.col_width()).sum()
}
fn repaint_prefix_for_cluster_boundary(
mut common_prefix: usize,
actual: &[Cell],
desired: &[Cell],
) -> usize {
if common_prefix == actual.len() && common_prefix == desired.len() {
return common_prefix;
}
while 0 < common_prefix {
let next_is_continuation = actual
.get(common_prefix)
.is_some_and(|cell| cell.col_width() == 0)
|| desired
.get(common_prefix)
.is_some_and(|cell| cell.col_width() == 0);
let prev_is_continuation = actual
.get(common_prefix - 1)
.is_some_and(|cell| cell.col_width() == 0)
|| desired
.get(common_prefix - 1)
.is_some_and(|cell| cell.col_width() == 0);
if !next_is_continuation && !prev_is_continuation {
break;
}
common_prefix -= 1;
}
common_prefix
}
pub struct Screen {
lines: Vec<CellRow>,
cursor_row: usize,
cursor_col: usize,
width: usize,
}
struct ChangedLineRange {
first_line: usize,
last_line: usize,
}
impl Screen {
pub fn new(width: usize) -> Self {
Self {
lines: Vec::new(),
cursor_row: 0,
cursor_col: 0,
width: width.max(1),
}
}
pub fn set_width(&mut self, width: usize) {
self.width = width.max(1);
}
pub fn width(&self) -> usize {
self.width
}
pub fn cursor_row(&self) -> usize {
self.cursor_row
}
pub fn update(
&mut self,
w: &mut impl Write,
desired_lines: &[CellRow],
desired_cursor: (usize, usize),
) -> io::Result<()> {
if desired_lines.is_empty() {
if !self.lines.is_empty() {
self.move_to(w, 0, 0)?;
w.queue(terminal::Clear(ClearType::FromCursorDown))?;
}
self.lines.clear();
self.cursor_row = 0;
self.cursor_col = 0;
return Ok(());
}
let desired_count = desired_lines.len();
for (row, desired_line) in desired_lines.iter().enumerate() {
let actual_line = self.lines.get(row);
let actual_slice = actual_line.map(|line| line.as_ref()).unwrap_or(&[]);
let desired_slice = desired_line.as_ref();
let common_prefix = actual_slice
.iter()
.zip(desired_slice.iter())
.take_while(|(a, d)| a == d)
.count();
let common_prefix =
repaint_prefix_for_cluster_boundary(common_prefix, actual_slice, desired_slice);
let is_last_desired = row == desired_count - 1;
let actual_wider = cols(actual_slice) > cols(desired_slice);
let has_extra_actual_below = is_last_desired && self.lines.len() > desired_count;
if common_prefix == actual_slice.len()
&& common_prefix == desired_slice.len()
&& !has_extra_actual_below
{
continue;
}
let prefix_cols = cols(&desired_slice[..common_prefix]);
self.move_to(w, row, prefix_cols)?;
if common_prefix < desired_slice.len() {
emit_styled_cells(w, &desired_slice[common_prefix..])?;
self.cursor_col = cols(desired_slice);
}
if has_extra_actual_below {
self.leave_pending_wrap_for_clear(w)?;
w.queue(terminal::Clear(ClearType::FromCursorDown))?;
} else if actual_wider {
w.queue(terminal::Clear(ClearType::UntilNewLine))?;
}
}
self.move_to(w, desired_cursor.0, desired_cursor.1)?;
self.lines = desired_lines.to_vec();
Ok(())
}
pub fn invalidate(&mut self) {
self.lines.clear();
self.cursor_row = 0;
self.cursor_col = 0;
}
pub fn erase_all(&mut self, w: &mut impl Write) -> io::Result<()> {
if self.cursor_row > 0 {
w.queue(MoveUp(self.cursor_row as u16))?;
}
w.queue(MoveToColumn(0))?
.queue(terminal::Clear(ClearType::FromCursorDown))?;
self.cursor_row = 0;
self.cursor_col = 0;
Ok(())
}
pub fn render_scrolling(
&mut self,
w: &mut impl Write,
all_lines: &[CellRow],
prev_viewport_top: usize,
height: usize,
desired_cursor: (usize, usize),
) -> io::Result<()> {
let total = all_lines.len();
let new_viewport_top = total.saturating_sub(height);
let Some(changed_range) = self.scrolling_changed_range(all_lines, prev_viewport_top) else {
let cursor_screen = desired_cursor.0.saturating_sub(new_viewport_top);
self.move_to(w, cursor_screen, desired_cursor.1)?;
return Ok(());
};
let render_start = changed_range.first_line.max(prev_viewport_top);
let mut viewport_top = prev_viewport_top;
self.scroll_to_render_start(w, render_start, &mut viewport_top, height)?;
self.render_changed_scrolling_lines(
w,
all_lines,
render_start,
changed_range.last_line,
&mut viewport_top,
height,
)?;
let old_end = prev_viewport_top + self.lines.len();
self.clear_shrunk_scrolling_lines(
w,
changed_range.last_line + 1,
old_end,
viewport_top,
height,
)?;
let cursor_screen = desired_cursor.0.saturating_sub(new_viewport_top);
self.move_to(w, cursor_screen, desired_cursor.1)?;
self.lines = all_lines[new_viewport_top..].to_vec();
self.cursor_row = cursor_screen;
self.cursor_col = desired_cursor.1;
Ok(())
}
fn scrolling_changed_range(
&self,
all_lines: &[CellRow],
prev_viewport_top: usize,
) -> Option<ChangedLineRange> {
let max_idx = all_lines.len().max(prev_viewport_top + self.lines.len());
let mut changed_range: Option<ChangedLineRange> = None;
for line_idx in prev_viewport_top..max_idx {
let old = self
.lines
.get(line_idx - prev_viewport_top)
.map(|line| line.as_ref());
let new = all_lines.get(line_idx).map(|line| line.as_ref());
if old != new {
changed_range = Some(match changed_range {
Some(range) => ChangedLineRange {
first_line: range.first_line,
last_line: line_idx,
},
None => ChangedLineRange {
first_line: line_idx,
last_line: line_idx,
},
});
}
}
changed_range
}
fn scroll_to_render_start(
&mut self,
w: &mut impl Write,
render_start: usize,
viewport_top: &mut usize,
height: usize,
) -> io::Result<()> {
let viewport_bottom = *viewport_top + height - 1;
if viewport_bottom < render_start {
let to_bottom = (height - 1).saturating_sub(self.cursor_row);
self.move_down_rows(w, to_bottom)?;
let scroll = render_start - viewport_bottom;
self.move_down_rows(w, scroll)?;
*viewport_top += scroll;
self.cursor_row = height - 1;
}
let start_screen_row = render_start - *viewport_top;
self.move_to(w, start_screen_row, 0)
}
fn render_changed_scrolling_lines(
&mut self,
w: &mut impl Write,
all_lines: &[CellRow],
render_start: usize,
render_last_line: usize,
viewport_top: &mut usize,
height: usize,
) -> io::Result<()> {
for line_idx in render_start..=render_last_line {
if render_start < line_idx {
self.advance_scrolling_render_row(w, viewport_top, height)?;
}
w.queue(terminal::Clear(ClearType::UntilNewLine))?;
if let Some(line) = all_lines.get(line_idx) {
emit_styled_cells(w, line)?;
}
self.cursor_col = all_lines.get(line_idx).map(|line| cols(line)).unwrap_or(0);
}
Ok(())
}
fn clear_shrunk_scrolling_lines(
&mut self,
w: &mut impl Write,
rendered_up_to: usize,
old_end: usize,
viewport_top: usize,
height: usize,
) -> io::Result<()> {
if old_end <= rendered_up_to {
return Ok(());
}
for _ in rendered_up_to..old_end.min(viewport_top + height) {
self.move_down_one(w)?;
w.queue(terminal::Clear(ClearType::UntilNewLine))?;
if self.cursor_row + 1 < height {
self.cursor_row += 1;
}
}
Ok(())
}
fn advance_scrolling_render_row(
&mut self,
w: &mut impl Write,
viewport_top: &mut usize,
height: usize,
) -> io::Result<()> {
self.move_down_one(w)?;
let screen_row = self.cursor_row + 1;
if height <= screen_row {
*viewport_top += 1;
self.cursor_row = height - 1;
} else {
self.cursor_row = screen_row;
}
Ok(())
}
fn move_down_rows(&mut self, w: &mut impl Write, rows: usize) -> io::Result<()> {
for _ in 0..rows {
self.move_down_one(w)?;
}
Ok(())
}
pub fn actual_line_count(&self) -> usize {
self.lines.len()
}
pub fn shares_row_buffer(&self, index: usize, row: &CellRow) -> bool {
self.lines
.get(index)
.is_some_and(|tracked| tracked.shares_buffer_with(row))
}
pub fn reset_to(&mut self, lines: Vec<CellRow>, cursor_row: usize, cursor_col: usize) {
self.lines = lines;
self.cursor_row = cursor_row;
self.cursor_col = cursor_col;
}
fn move_to(&mut self, w: &mut impl Write, row: usize, col: usize) -> io::Result<()> {
if row < self.cursor_row {
w.queue(MoveUp((self.cursor_row - row) as u16))?;
} else if row > self.cursor_row {
let down = row - self.cursor_row;
for _ in 0..down {
self.move_down_one(w)?;
}
}
if col != self.cursor_col {
w.queue(MoveToColumn(col as u16))?;
}
self.cursor_row = row;
self.cursor_col = col;
Ok(())
}
fn leave_pending_wrap_for_clear(&mut self, w: &mut impl Write) -> io::Result<()> {
if self.width <= self.cursor_col {
self.move_down_one(w)?;
self.cursor_row += 1;
}
Ok(())
}
fn move_down_one(&mut self, w: &mut impl Write) -> io::Result<()> {
if self.cursor_col != 0 {
w.queue(MoveToColumn(0))?;
self.cursor_col = 0;
}
w.queue(Print("\n"))?;
Ok(())
}
}
pub fn emit_styled_cells(w: &mut impl Write, cells: &[Cell]) -> io::Result<()> {
let mut current = Style::default();
let mut current_hyperlink: Option<&str> = None;
for cell in cells {
let hyperlink = cell
.hyperlink
.as_deref()
.and_then(sanitize_hyperlink_target);
if hyperlink != current_hyperlink {
if current_hyperlink.is_some() {
w.write_all(b"\x1b]8;;\x1b\\")?;
}
if let Some(target) = hyperlink {
write_osc8_open(w, target)?;
}
current_hyperlink = hyperlink;
}
if cell.style != current {
if current != Style::default() {
w.queue(SetAttribute(Attribute::Reset))?;
}
if cell.style != Style::default() {
apply_style(w, &cell.style)?;
}
current = cell.style;
}
w.queue(Print(cell.normalized().ch))?;
}
if current_hyperlink.is_some() {
w.write_all(b"\x1b]8;;\x1b\\")?;
}
if current != Style::default() {
w.queue(SetAttribute(Attribute::Reset))?;
}
Ok(())
}
fn write_osc8_open(w: &mut impl Write, target: &str) -> io::Result<()> {
if let Some(target) = sanitize_hyperlink_target(target) {
write!(w, "\x1b]8;;{target}\x1b\\")?;
}
Ok(())
}
pub fn write_osc8_hyperlink(w: &mut impl Write, label: &str, target: &str) -> io::Result<()> {
let safe_label: String = label
.chars()
.map(|ch| if ch.is_control() { '�' } else { ch })
.collect();
if sanitize_hyperlink_target(target).is_none() {
return w.write_all(safe_label.as_bytes());
}
write_osc8_open(w, target)?;
w.write_all(safe_label.as_bytes())?;
w.write_all(b"\x1b]8;;\x1b\\")
}
fn apply_style(w: &mut impl Write, style: &Style) -> io::Result<()> {
if let Some(fg) = style.fg {
w.queue(SetForegroundColor(fg))?;
}
if let Some(bg) = style.bg {
w.queue(SetBackgroundColor(bg))?;
}
if style.bold {
w.queue(SetAttribute(Attribute::Bold))?;
}
if style.underline {
w.queue(SetAttribute(Attribute::Underlined))?;
}
if style.italic {
w.queue(SetAttribute(Attribute::Italic))?;
}
if style.strikethrough {
w.queue(SetAttribute(Attribute::CrossedOut))?;
}
Ok(())
}
#[bon::builder]
pub fn layout_lines(
content: &StyledText,
width: usize,
#[builder(default = false)] preserve_last_newline: bool,
) -> Vec<Vec<Cell>> {
let width = width.max(1);
let mut logical_lines: Vec<Vec<Cell>> = vec![Vec::new()];
visit_styled_graphemes(content.spans(), |grapheme, style, hyperlink| {
if is_line_break_grapheme(grapheme) {
logical_lines.push(Vec::new());
} else {
let line = logical_lines
.last_mut()
.expect("logical_lines always has at least one entry");
push_grapheme_cells(line, grapheme, style, hyperlink);
}
});
if !preserve_last_newline
&& logical_lines.len() > 1
&& logical_lines.last().is_some_and(|l| l.is_empty())
{
logical_lines.pop();
}
let mut result: Vec<Vec<Cell>> = Vec::new();
for line in logical_lines {
if line.is_empty() {
result.push(Vec::new());
} else {
let mut row = Vec::new();
let mut skip_zero_width_suffix = false;
let mut col = 0usize;
for cell in line {
let w = cell.col_width();
if skip_zero_width_suffix && w == 0 {
continue;
}
skip_zero_width_suffix = false;
if width < w {
if !row.is_empty() {
result.push(row);
row = Vec::new();
}
row.push(Cell::new('�', cell.style).with_hyperlink(cell.hyperlink));
result.push(row);
row = Vec::new();
col = 0;
skip_zero_width_suffix = true;
continue;
}
if width < col + w && !row.is_empty() {
result.push(row);
row = Vec::new();
col = 0;
}
row.push(cell);
col += w;
}
if !row.is_empty() {
result.push(row);
}
}
}
if result.is_empty() {
result.push(Vec::new());
}
result
}
pub fn layout_block(block: &StyledBlock, width: usize) -> Vec<Vec<Cell>> {
let width = width.max(1);
let requested_ml = block.margin_left as usize;
let requested_mr = block.margin_right as usize;
let ml = requested_ml.min(width.saturating_sub(1));
let remaining_after_ml = width.saturating_sub(ml);
let mr = requested_mr.min(remaining_after_ml.saturating_sub(1));
let content_width = width.saturating_sub(ml + mr).max(1);
let fill_style = Style {
bg: block.bg,
..Style::default()
};
let fill = Cell::new(' ', fill_style);
let mut content_lines = Vec::new();
match &block.layout {
path_crate_style::BlockLayout::TwoLineElision(elision) => {
content_lines.extend(elision.layout(content_width));
}
path_crate_style::BlockLayout::Priority { line, body } => {
let priority_layout = line.layout_with_fill(content_width, fill.clone());
content_lines.push(priority_layout.row);
if priority_layout.required_items_fit && !body.is_empty() {
content_lines.extend(layout_lines().content(body).width(content_width).call());
}
}
path_crate_style::BlockLayout::Ordinary => {
content_lines.extend(
layout_lines()
.content(&block.content)
.width(content_width)
.call(),
);
}
}
if matches!(block.layout, crate::style::BlockLayout::Ordinary)
&& block.align == Align::Left
&& !block.right_content.is_empty()
&& content_lines.len() == 1
{
let right_cells = block.right_content.to_cells();
let left_cols = cols(&content_lines[0]);
let right_cols = cols(&right_cells);
if left_cols + 1 + right_cols <= content_width {
let padding = content_width - left_cols - right_cols;
content_lines[0].extend(std::iter::repeat_n(Cell::plain(' '), padding));
content_lines[0].extend(right_cells);
}
}
content_lines
.iter()
.map(|line| {
let mut row = Vec::with_capacity(width);
row.extend(std::iter::repeat_n(Cell::plain(' '), ml));
let cw = cols(line);
let padding = content_width.saturating_sub(cw);
match block.align {
Align::Left => {
row.extend(line.iter().cloned());
row.extend(std::iter::repeat_n(fill.clone(), padding));
}
Align::Center => {
let left = padding / 2;
let right = padding - left;
row.extend(std::iter::repeat_n(fill.clone(), left));
row.extend(line.iter().cloned());
row.extend(std::iter::repeat_n(fill.clone(), right));
}
}
row.extend(std::iter::repeat_n(Cell::plain(' '), mr));
if let Some(bg) = block.bg {
let content_end = row.len().saturating_sub(mr);
for cell in &mut row[ml..content_end] {
if cell.style.bg.is_none() {
cell.style.bg = Some(bg);
}
}
}
row
})
.collect()
}
#[cfg(test)]
mod tests;