From bbaea3b5bf7b10350739bb99d51593ed439b4e15 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 21 May 2022 19:59:07 +0200 Subject: [PATCH] Create simple terminal and buffer abstractions --- Cargo.toml | 3 + examples/hello_world.rs | 35 +++++++++ src/buffer.rs | 164 ++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 10 +-- src/terminal.rs | 86 +++++++++++++++++++++ 5 files changed, 290 insertions(+), 8 deletions(-) create mode 100644 examples/hello_world.rs create mode 100644 src/buffer.rs create mode 100644 src/terminal.rs diff --git a/Cargo.toml b/Cargo.toml index 46d5c0f..b714450 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,3 +4,6 @@ version = "0.1.0" edition = "2021" [dependencies] +crossterm = "0.23.2" +unicode-segmentation = "1.9.0" +unicode-width = "0.1.9" diff --git a/examples/hello_world.rs b/examples/hello_world.rs new file mode 100644 index 0000000..0b4d6ca --- /dev/null +++ b/examples/hello_world.rs @@ -0,0 +1,35 @@ +use std::io; + +use crossterm::style::{ContentStyle, Stylize}; +use toss::buffer::Pos; +use toss::terminal::Terminal; + +fn main() -> io::Result<()> { + // Automatically enters alternate screen and enables raw mode + let mut term = Terminal::new(Box::new(io::stdout()))?; + + // Must be called before rendering, otherwise the terminal has out-of-date + // size information and will present garbage. + term.autoresize()?; + + // Render things to the buffer + let b = term.buffer(); + b.write( + Pos::new(0, 0), + "Hello world!", + ContentStyle::default().green(), + ); + b.write( + Pos::new(0, 1), + "Press any key to exit", + ContentStyle::default().on_dark_blue(), + ); + + // Show the buffer's contents on screen + term.present()?; + + // Wait for input before exiting + let _ = crossterm::event::read(); + + Ok(()) +} diff --git a/src/buffer.rs b/src/buffer.rs new file mode 100644 index 0000000..256e3ab --- /dev/null +++ b/src/buffer.rs @@ -0,0 +1,164 @@ +use crossterm::style::ContentStyle; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Size { + pub width: u16, + pub height: u16, +} + +impl Size { + pub const ZERO: Self = Self { + width: 0, + height: 0, + }; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Pos { + pub x: i32, + pub y: i32, +} + +impl Pos { + pub const ZERO: Self = Self { x: 0, y: 0 }; + + pub fn new(x: i32, y: i32) -> Self { + Self { x, y } + } +} + +#[derive(Debug, Clone)] +pub struct Cell { + pub content: Box, + pub style: ContentStyle, + pub width: u8, + pub offset: u8, +} + +impl Cell { + pub fn empty() -> Self { + Self { + content: " ".to_string().into_boxed_str(), + style: ContentStyle::default(), + width: 1, + offset: 0, + } + } +} + +pub struct Buffer { + size: Size, + data: Vec, +} + +impl Buffer { + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + Self { + size: Size::ZERO, + data: vec![], + } + } + + fn index(&self, x: u16, y: u16) -> usize { + assert!(x < self.size.width); + assert!(y < self.size.height); + + let x: usize = x.into(); + let y: usize = y.into(); + let width: usize = self.size.width.into(); + + y * width + x + } + + pub fn size(&self) -> Size { + self.size + } + + pub fn resize(&mut self, size: Size) { + let width: usize = size.width.into(); + let height: usize = size.height.into(); + let len = width * height; + + self.size = size; + self.data.clear(); + self.data.resize_with(len, Cell::empty); + } + + /// Reset all cells of the buffer to be empty and have no styling. + pub fn reset(&mut self) { + self.resize(self.size); + } + + pub fn write(&mut self, mut pos: Pos, content: &str, style: ContentStyle) { + if pos.y < 0 || pos.y >= self.size.height as i32 { + return; + } + + for grapheme in content.graphemes(true) { + let width = grapheme.width().max(1) as u8; // TODO Use actual width + if pos.x >= 0 && pos.x + width as i32 <= self.size.width as i32 { + // Grapheme fits on buffer in its entirety + let grapheme = grapheme.to_string().into_boxed_str(); + + for offset in 0..width { + let i = self.index(pos.x as u16 + offset as u16, pos.y as u16); + self.data[i] = Cell { + content: grapheme.clone(), + style, + width, + offset, + }; + } + } + + pos.x += width as i32; + } + } + + pub fn cells(&self) -> Cells<'_> { + Cells { + buffer: self, + x: 0, + y: 0, + } + } +} + +pub struct Cells<'a> { + buffer: &'a Buffer, + x: u16, + y: u16, +} + +impl<'a> Cells<'a> { + fn at(&self, x: u16, y: u16) -> &'a Cell { + assert!(x < self.buffer.size.width); + assert!(y < self.buffer.size.height); + &self.buffer.data[self.buffer.index(x, y)] + } +} + +impl<'a> Iterator for Cells<'a> { + type Item = (u16, u16, &'a Cell); + + fn next(&mut self) -> Option { + if self.y >= self.buffer.size.height { + return None; + } + + let (x, y) = (self.x, self.y); + let cell = self.at(self.x, self.y); + assert!(cell.offset == 0); + + self.x += cell.width as u16; + if self.x >= self.buffer.size.width { + self.x = 0; + self.y += 1; + } + + Some((x, y, cell)) + } +} diff --git a/src/lib.rs b/src/lib.rs index 1b4a90c..7c6e2ac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,2 @@ -#[cfg(test)] -mod tests { - #[test] - fn it_works() { - let result = 2 + 2; - assert_eq!(result, 4); - } -} +pub mod buffer; +pub mod terminal; diff --git a/src/terminal.rs b/src/terminal.rs new file mode 100644 index 0000000..482644b --- /dev/null +++ b/src/terminal.rs @@ -0,0 +1,86 @@ +use std::io::Write; +use std::{io, mem}; + +use crossterm::cursor::MoveTo; +use crossterm::style::{PrintStyledContent, StyledContent}; +use crossterm::terminal::{Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen}; +use crossterm::{ExecutableCommand, QueueableCommand}; + +use crate::buffer::{Buffer, Size}; + +pub struct Terminal { + out: Box, + /// Currently visible on screen. + prev_buffer: Buffer, + /// Buffer to render to. + curr_buffer: Buffer, + /// When the screen is updated next, it must be cleared and redrawn fully + /// instead of performing an incremental update. + full_redraw: bool, +} + +impl Drop for Terminal { + fn drop(&mut self) { + let _ = self.out.execute(LeaveAlternateScreen); + let _ = crossterm::terminal::disable_raw_mode(); + } +} + +impl Terminal { + pub fn new(out: Box) -> io::Result { + let mut result = Self { + out, + prev_buffer: Buffer::new(), + curr_buffer: Buffer::new(), + full_redraw: true, + }; + crossterm::terminal::enable_raw_mode()?; + result.out.execute(EnterAlternateScreen)?; + Ok(result) + } + + pub fn buffer(&mut self) -> &mut Buffer { + &mut self.curr_buffer + } + + pub fn autoresize(&mut self) -> io::Result<()> { + let (width, height) = crossterm::terminal::size()?; + let size = Size { width, height }; + if size != self.curr_buffer.size() { + self.prev_buffer.resize(size); + self.curr_buffer.resize(size); + self.full_redraw = true; + } + + Ok(()) + } + + /// Display the contents of the buffer on the screen and prepare rendering + /// the next frame. + pub fn present(&mut self) -> io::Result<()> { + if self.full_redraw { + io::stdout().queue(Clear(ClearType::All))?; + self.prev_buffer.reset(); + self.full_redraw = false; + } + + self.draw_differences()?; + self.out.flush()?; + + mem::swap(&mut self.prev_buffer, &mut self.curr_buffer); + + Ok(()) + } + + fn draw_differences(&mut self) -> io::Result<()> { + // TODO Only draw the differences + for (x, y, cell) in self.curr_buffer.cells() { + let content = StyledContent::new(cell.style, &cell.content as &str); + self.out + .queue(MoveTo(x, y))? + .queue(PrintStyledContent(content))?; + } + + Ok(()) + } +}