use std::{
borrow::Borrow,
io::{self, Write},
};
use crate::{
crossterm::{cursor, style, terminal},
grapheme::StyledGraphemes,
};
pub struct Terminal {
pub position: (u16, u16),
anchor: (u16, u16),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct DrawPlan {
row_count: usize,
clear_position: (u16, u16),
draw_position: (u16, u16),
scroll_down: u16,
visible_height: usize,
resulting_position: (u16, u16),
}
impl DrawPlan {
fn try_new(
row_count: usize,
terminal_height: u16,
anchor: (u16, u16),
position: (u16, u16),
) -> anyhow::Result<Self> {
if row_count > terminal_height as usize {
return Err(anyhow::anyhow!("Insufficient space to display all panes"));
}
let last_terminal_row = terminal_height.saturating_sub(1);
let anchor_row = anchor.1.min(last_terminal_row);
let position_row = position.1.min(last_terminal_row);
let target_row = if row_count == 0 {
anchor_row
} else {
let row_count =
u16::try_from(row_count).expect("row count is bounded by terminal height");
anchor_row.min(terminal_height.saturating_sub(row_count))
};
let scroll_down = target_row.saturating_sub(position_row);
let draw_row = position_row.saturating_add(scroll_down);
Ok(Self {
row_count,
clear_position: (position.0, draw_row),
draw_position: (anchor.0, draw_row),
scroll_down,
visible_height: terminal_height.saturating_sub(draw_row) as usize,
resulting_position: (anchor.0, target_row),
})
}
fn scroll_up_after(self, row_index: usize) -> bool {
let completed_rows = row_index.saturating_add(1);
completed_rows < self.row_count && completed_rows >= self.visible_height.max(1)
}
}
impl Terminal {
pub fn new(position: (u16, u16)) -> Self {
Self {
position,
anchor: position,
}
}
pub fn draw(&mut self, graphemes: &[StyledGraphemes]) -> anyhow::Result<()> {
let (width, height) = terminal::size()?;
let viewable_rows = graphemes
.iter()
.map(|graphemes| graphemes.wrapped_lines(width as usize))
.filter(|rows| !rows.is_empty())
.collect::<Vec<Vec<StyledGraphemes>>>();
if height < viewable_rows.len() as u16 {
return Err(anyhow::anyhow!("Insufficient space to display all panes"));
}
let mut used = 0;
let mut panes = Vec::with_capacity(viewable_rows.len());
for (pane_index, rows) in viewable_rows.iter().enumerate() {
let max_rows = 1
.max((height as usize).saturating_sub(used + viewable_rows.len() - 1 - pane_index));
let rows = rows.iter().take(max_rows).cloned().collect::<Vec<_>>();
used += rows.len();
panes.push(rows);
}
self.draw_rows(&panes)
}
pub fn draw_rows<R>(&mut self, panes: &[Vec<R>]) -> anyhow::Result<()>
where
R: Borrow<StyledGraphemes>,
{
let (_, height) = terminal::size()?;
self.draw_rows_at_height(panes, height)
}
pub(crate) fn draw_rows_at_height<R>(
&mut self,
panes: &[Vec<R>],
height: u16,
) -> anyhow::Result<()>
where
R: Borrow<StyledGraphemes>,
{
let mut stdout = io::stdout();
self.draw_rows_to(&mut stdout, panes, height)
}
fn draw_rows_to<W, R>(
&mut self,
writer: &mut W,
panes: &[Vec<R>],
height: u16,
) -> anyhow::Result<()>
where
W: Write,
R: Borrow<StyledGraphemes>,
{
let row_count = panes.iter().map(Vec::len).sum::<usize>();
let plan = DrawPlan::try_new(row_count, height, self.anchor, self.position)?;
crossterm::queue!(
writer,
terminal::BeginSynchronizedUpdate,
terminal::DisableLineWrap,
)?;
if plan.scroll_down > 0 {
crossterm::queue!(writer, terminal::ScrollDown(plan.scroll_down))?;
}
crossterm::queue!(
writer,
cursor::MoveTo(plan.clear_position.0, plan.clear_position.1),
terminal::Clear(terminal::ClearType::FromCursorDown),
cursor::MoveTo(plan.draw_position.0, plan.draw_position.1),
)?;
for (row_index, row) in panes.iter().flatten().enumerate() {
crossterm::queue!(writer, style::Print(row.borrow().styled_display()))?;
if plan.scroll_up_after(row_index) {
crossterm::queue!(writer, terminal::ScrollUp(1))?;
}
crossterm::queue!(writer, cursor::MoveToNextLine(1))?;
}
crossterm::queue!(
writer,
terminal::EnableLineWrap,
terminal::EndSynchronizedUpdate
)?;
writer.flush()?;
self.position = plan.resulting_position;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
mod terminal {
use super::*;
mod draw_rows_to {
use super::*;
use crate::crossterm::terminal as crossterm_terminal;
fn rows(count: usize) -> Vec<Vec<StyledGraphemes>> {
vec![
(0..count)
.map(|index| StyledGraphemes::from(format!("row {index}")))
.collect(),
]
}
fn command_bytes(command: impl crate::crossterm::Command) -> Vec<u8> {
let mut output = Vec::new();
crossterm::queue!(output, command).unwrap();
output
}
fn command_offset(output: &[u8], command: impl crate::crossterm::Command) -> usize {
let command = command_bytes(command);
output
.windows(command.len())
.position(|window| window == command)
.expect("expected terminal command was not emitted")
}
#[test]
fn growing_frame_scrolls_only_after_clearing_the_previous_frame() {
let mut terminal = Terminal::new((0, 7));
let mut output = Vec::new();
terminal.draw_rows_to(&mut output, &rows(8), 10).unwrap();
let clear = command_offset(
&output,
crossterm_terminal::Clear(crossterm_terminal::ClearType::FromCursorDown),
);
let scroll = command_offset(&output, crossterm_terminal::ScrollUp(1));
assert!(clear < scroll);
assert_eq!(terminal.position, (0, 2));
}
#[test]
fn shrinking_frame_scrolls_preceding_output_back_down_before_redrawing() {
let mut terminal = Terminal::new((0, 7));
terminal
.draw_rows_to(&mut Vec::new(), &rows(8), 10)
.unwrap();
assert_eq!(terminal.position, (0, 2));
let mut output = Vec::new();
terminal.draw_rows_to(&mut output, &rows(3), 10).unwrap();
let scroll_down = command_offset(&output, crossterm_terminal::ScrollDown(5));
let clear = command_offset(
&output,
crossterm_terminal::Clear(crossterm_terminal::ClearType::FromCursorDown),
);
let draw = command_bytes(cursor::MoveTo(0, 7));
let draw = output
.windows(draw.len())
.enumerate()
.filter(|(_, window)| *window == draw)
.map(|(offset, _)| offset)
.nth(1)
.expect("expected a move from the clear position to the draw position");
assert!(scroll_down < clear);
assert!(clear < draw);
assert_eq!(terminal.position, (0, 7));
}
#[test]
fn draw_frame_controls_wrapping_inside_a_synchronized_update() {
let mut terminal = Terminal::new((0, 0));
let mut output = Vec::new();
terminal.draw_rows_to(&mut output, &rows(1), 10).unwrap();
let begin = command_offset(&output, crossterm_terminal::BeginSynchronizedUpdate);
let disable_wrap = command_offset(&output, crossterm_terminal::DisableLineWrap);
let enable_wrap = command_offset(&output, crossterm_terminal::EnableLineWrap);
let end = command_offset(&output, crossterm_terminal::EndSynchronizedUpdate);
assert!(begin < disable_wrap);
assert!(disable_wrap < enable_wrap);
assert!(enable_wrap < end);
}
#[test]
fn trailing_empty_panes_do_not_trigger_scrolling() {
let mut terminal = Terminal::new((0, 9));
let panes: Vec<Vec<StyledGraphemes>> =
vec![vec![StyledGraphemes::from("only row")], Vec::new()];
terminal.draw_rows_to(&mut Vec::new(), &panes, 10).unwrap();
assert_eq!(terminal.position, (0, 9));
}
}
}
}