use std::io::{self, Stdout, Write};
use crossterm::cursor::{Hide, MoveTo, Show};
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::execute;
use crossterm::queue;
use crossterm::style::{
Attribute, Print, ResetColor, SetAttribute, SetBackgroundColor, SetForegroundColor,
};
use crossterm::terminal::{
self, disable_raw_mode, enable_raw_mode, Clear, ClearType, EnterAlternateScreen,
LeaveAlternateScreen,
};
pub type Color = crossterm::style::Color;
pub const MAX_FRAME_WIDTH: u16 = 512;
pub const MAX_FRAME_HEIGHT: u16 = 256;
pub const MAX_FRAME_CELLS: usize = MAX_FRAME_WIDTH as usize * MAX_FRAME_HEIGHT as usize;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Rect {
pub x: u16,
pub y: u16,
pub width: u16,
pub height: u16,
}
impl Rect {
pub fn right(self) -> u16 {
self.x.saturating_add(self.width)
}
pub fn bottom(self) -> u16 {
self.y.saturating_add(self.height)
}
fn contains(self, x: u16, y: u16) -> bool {
x >= self.x && x < self.right() && y >= self.y && y < self.bottom()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Style {
pub fg: Color,
pub bg: Color,
pub bold: bool,
pub dim: bool,
pub reverse: bool,
}
impl Style {
pub fn new(fg: Color, bg: Color) -> Self {
Self {
fg,
bg,
bold: false,
dim: false,
reverse: false,
}
}
pub fn fg(mut self, fg: Color) -> Self {
self.fg = fg;
self
}
pub fn bg(mut self, bg: Color) -> Self {
self.bg = bg;
self
}
pub fn bold(mut self) -> Self {
self.bold = true;
self
}
}
impl Default for Style {
fn default() -> Self {
Self::new(
Color::Rgb {
r: 226,
g: 232,
b: 240,
},
Color::Black,
)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Cell {
ch: char,
style: Style,
}
#[derive(Clone)]
pub struct Frame {
width: u16,
height: u16,
cells: Vec<Cell>,
}
impl Frame {
pub fn new(width: u16, height: u16, style: Style) -> Self {
let (width, height) = capped_dimensions(width, height);
let cell = Cell { ch: ' ', style };
Self {
width,
height,
cells: vec![cell; width as usize * height as usize],
}
}
pub fn width(&self) -> u16 {
self.width
}
pub fn height(&self) -> u16 {
self.height
}
pub fn resize(&mut self, width: u16, height: u16, style: Style) {
let (width, height) = capped_dimensions(width, height);
if self.width == width && self.height == height {
self.clear(style);
return;
}
self.width = width;
self.height = height;
self.cells = vec![Cell { ch: ' ', style }; width as usize * height as usize];
}
pub fn clear(&mut self, style: Style) {
for cell in &mut self.cells {
cell.ch = ' ';
cell.style = style;
}
}
pub fn set(&mut self, x: u16, y: u16, ch: char, style: Style) {
if x >= self.width || y >= self.height {
return;
}
let index = y as usize * self.width as usize + x as usize;
if let Some(cell) = self.cells.get_mut(index) {
*cell = Cell { ch, style };
}
}
pub fn text(&mut self, x: u16, y: u16, text: &str, style: Style) {
self.text_fit(x, y, self.width.saturating_sub(x), text, style);
}
pub fn text_fit(&mut self, x: u16, y: u16, width: u16, text: &str, style: Style) {
if y >= self.height || width == 0 {
return;
}
for (offset, ch) in text.chars().take(width as usize).enumerate() {
self.set(x.saturating_add(offset as u16), y, ch, style);
}
}
pub fn text_right(&mut self, x: u16, y: u16, width: u16, text: &str, style: Style) {
let len = text.chars().count() as u16;
let start = x.saturating_add(width.saturating_sub(len.min(width)));
self.text_fit(start, y, width.min(len), text, style);
}
pub fn text_center(&mut self, rect: Rect, y: u16, text: &str, style: Style) {
if rect.width == 0 {
return;
}
let len = text.chars().count() as u16;
let start = rect.x.saturating_add(rect.width.saturating_sub(len) / 2);
self.text_fit(start, y, rect.width.min(len), text, style);
}
pub fn fill_rect(&mut self, rect: Rect, ch: char, style: Style) {
for y in rect.y..rect.bottom().min(self.height) {
for x in rect.x..rect.right().min(self.width) {
self.set(x, y, ch, style);
}
}
}
pub fn border(&mut self, rect: Rect, style: Style, title: Option<&str>) {
if rect.width < 2 || rect.height < 2 {
return;
}
let right = rect
.right()
.saturating_sub(1)
.min(self.width.saturating_sub(1));
let bottom = rect
.bottom()
.saturating_sub(1)
.min(self.height.saturating_sub(1));
for x in rect.x..=right {
self.set(x, rect.y, '-', style);
self.set(x, bottom, '-', style);
}
for y in rect.y..=bottom {
self.set(rect.x, y, '|', style);
self.set(right, y, '|', style);
}
self.set(rect.x, rect.y, '+', style);
self.set(right, rect.y, '+', style);
self.set(rect.x, bottom, '+', style);
self.set(right, bottom, '+', style);
if let Some(title) = title {
let label = format!(" {title} ");
self.text_fit(
rect.x.saturating_add(2),
rect.y,
rect.width.saturating_sub(4),
&label,
style.bold(),
);
}
}
pub fn bar(&mut self, rect: Rect, ratio: f32, filled: Style, empty: Style, label: &str) {
if rect.width == 0 || rect.height == 0 {
return;
}
let filled_width =
((rect.width as f32 * ratio.clamp(0.0, 1.0)).round() as u16).min(rect.width);
if filled_width > 0 {
self.fill_rect(
Rect {
width: filled_width,
..rect
},
' ',
filled,
);
}
if filled_width < rect.width {
self.fill_rect(
Rect {
x: rect.x.saturating_add(filled_width),
width: rect.width.saturating_sub(filled_width),
..rect
},
' ',
empty,
);
}
self.text_center(rect, rect.y, label, filled.fg(empty.fg).bold());
}
pub fn dim_outside(&mut self, rect: Rect, fg: Color, bg: Color) {
for y in 0..self.height {
for x in 0..self.width {
if rect.contains(x, y) {
continue;
}
let index = y as usize * self.width as usize + x as usize;
if let Some(cell) = self.cells.get_mut(index) {
cell.style.fg = fg;
cell.style.bg = bg;
cell.style.dim = true;
cell.style.bold = false;
}
}
}
}
}
fn capped_dimensions(width: u16, height: u16) -> (u16, u16) {
let width = width.clamp(1, MAX_FRAME_WIDTH);
let height = height.clamp(1, MAX_FRAME_HEIGHT);
let max_height = (MAX_FRAME_CELLS / width as usize).max(1) as u16;
(width, height.min(max_height))
}
pub struct Term {
stdout: Stdout,
previous: Option<Frame>,
}
impl Term {
pub fn enter() -> io::Result<Self> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture, Hide)?;
Ok(Self {
stdout,
previous: None,
})
}
pub fn size(&self) -> io::Result<(u16, u16)> {
terminal::size()
}
pub fn draw(&mut self, frame: &Frame) -> io::Result<()> {
let full_redraw = self.previous.as_ref().map_or(true, |previous| {
previous.width != frame.width || previous.height != frame.height
});
if full_redraw {
queue!(self.stdout, Clear(ClearType::All))?;
}
let mut active_style = None;
draw_runs(
&mut self.stdout,
frame,
(!full_redraw).then_some(self.previous.as_ref()).flatten(),
&mut active_style,
)?;
queue!(self.stdout, ResetColor, SetAttribute(Attribute::Reset))?;
self.stdout.flush()?;
self.previous = Some(frame.clone());
Ok(())
}
}
impl Drop for Term {
fn drop(&mut self) {
let _ = execute!(
self.stdout,
ResetColor,
SetAttribute(Attribute::Reset),
Show,
DisableMouseCapture,
LeaveAlternateScreen
);
let _ = disable_raw_mode();
}
}
fn apply_style(stdout: &mut Stdout, style: Style) -> io::Result<()> {
queue!(
stdout,
SetAttribute(Attribute::Reset),
SetForegroundColor(style.fg),
SetBackgroundColor(style.bg)
)?;
if style.bold {
queue!(stdout, SetAttribute(Attribute::Bold))?;
}
if style.dim {
queue!(stdout, SetAttribute(Attribute::Dim))?;
}
if style.reverse {
queue!(stdout, SetAttribute(Attribute::Reverse))?;
}
Ok(())
}
fn draw_runs(
stdout: &mut Stdout,
frame: &Frame,
previous: Option<&Frame>,
active_style: &mut Option<Style>,
) -> io::Result<()> {
for y in 0..frame.height {
let mut x = 0;
while x < frame.width {
let index = y as usize * frame.width as usize + x as usize;
let cell = frame.cells[index];
let unchanged = previous
.map(|previous| previous.cells[index] == cell)
.unwrap_or(false);
if unchanged {
x += 1;
continue;
}
let start = x;
let style = cell.style;
let mut text = String::new();
while x < frame.width {
let index = y as usize * frame.width as usize + x as usize;
let cell = frame.cells[index];
let unchanged = previous
.map(|previous| previous.cells[index] == cell)
.unwrap_or(false);
if unchanged || cell.style != style {
break;
}
text.push(cell.ch);
x += 1;
}
queue!(stdout, MoveTo(start, y))?;
if *active_style != Some(style) {
apply_style(stdout, style)?;
*active_style = Some(style);
}
queue!(stdout, Print(text))?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frame_dimensions_are_capped() {
let frame = Frame::new(u16::MAX, u16::MAX, Style::default());
assert!(frame.width() <= MAX_FRAME_WIDTH);
assert!(frame.height() <= MAX_FRAME_HEIGHT);
assert!(frame.cells.len() <= MAX_FRAME_CELLS);
}
#[test]
fn resize_keeps_frame_capped() {
let mut frame = Frame::new(10, 10, Style::default());
frame.resize(u16::MAX, u16::MAX, Style::default());
assert!(frame.width() <= MAX_FRAME_WIDTH);
assert!(frame.height() <= MAX_FRAME_HEIGHT);
assert!(frame.cells.len() <= MAX_FRAME_CELLS);
}
}