From edda1b741a4a0816eb6e62eafd69fc9896603cf5 Mon Sep 17 00:00:00 2001 From: Conrad Ludgate Date: Fri, 10 Feb 2023 17:25:43 +0000 Subject: [PATCH] crossterm support (#331) * crossterm v2 * patch crossterm * fix-version * no more tui dependency * lints --- Cargo.lock | 104 +-- Cargo.toml | 8 +- atuin-client/src/import/zsh_histdb.rs | 2 +- src/command/client/search.rs | 1 - src/command/client/search/event.rs | 70 --- src/command/client/search/history_list.rs | 4 +- src/command/client/search/interactive.rs | 191 ++++-- src/main.rs | 1 + src/tui/LICENSE | 21 + src/tui/README.md | 5 + src/tui/backend/crossterm.rs | 221 +++++++ src/tui/backend/mod.rs | 20 + src/tui/buffer.rs | 732 ++++++++++++++++++++++ src/tui/layout.rs | 537 ++++++++++++++++ src/tui/mod.rs | 20 + src/tui/style.rs | 278 ++++++++ src/tui/symbols.rs | 233 +++++++ src/tui/terminal.rs | 321 ++++++++++ src/tui/text.rs | 428 +++++++++++++ src/tui/widgets/block.rs | 562 +++++++++++++++++ src/tui/widgets/mod.rs | 159 +++++ src/tui/widgets/paragraph.rs | 194 ++++++ src/tui/widgets/reflow.rs | 537 ++++++++++++++++ 23 files changed, 4468 insertions(+), 181 deletions(-) delete mode 100644 src/command/client/search/event.rs create mode 100644 src/tui/LICENSE create mode 100644 src/tui/README.md create mode 100644 src/tui/backend/crossterm.rs create mode 100644 src/tui/backend/mod.rs create mode 100644 src/tui/buffer.rs create mode 100644 src/tui/layout.rs create mode 100644 src/tui/mod.rs create mode 100644 src/tui/style.rs create mode 100644 src/tui/symbols.rs create mode 100644 src/tui/terminal.rs create mode 100644 src/tui/text.rs create mode 100644 src/tui/widgets/block.rs create mode 100644 src/tui/widgets/mod.rs create mode 100644 src/tui/widgets/paragraph.rs create mode 100644 src/tui/widgets/reflow.rs diff --git a/Cargo.lock b/Cargo.lock index 2b3fd3d..f6f768a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -77,11 +77,14 @@ dependencies = [ "atuin-common", "atuin-server", "base64 0.20.0", + "bitflags", + "cassowary", "chrono", "clap", "clap_complete", "cli-table", "crossbeam-channel", + "crossterm", "directories", "env_logger", "eyre", @@ -95,11 +98,10 @@ dependencies = [ "semver", "serde", "serde_json", - "termion", "tiny-bip39", "tokio", "tracing-subscriber", - "tui", + "unicode-segmentation", "unicode-width", "whoami", ] @@ -488,6 +490,32 @@ dependencies = [ "once_cell", ] +[[package]] +name = "crossterm" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77f67c7faacd4db07a939f55d66a983a5355358a1f17d32cc9a8d01d1266b9ce" +dependencies = [ + "bitflags", + "crossterm_winapi", + "filedescriptor", + "libc", + "mio", + "parking_lot 0.12.1", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae1b35a484aa10e07fe0638d02301c5ad24de82d310ccbd2f3693da5f09bf1c" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -627,6 +655,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "filedescriptor" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7199d965852c3bac31f779ef99cbb4537f80e952e2d6aa0ffeb30cce00f4f46e" +dependencies = [ + "libc", + "thiserror", + "winapi", +] + [[package]] name = "flume" version = "0.10.14" @@ -1294,12 +1333,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" -[[package]] -name = "numtoa" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef" - [[package]] name = "once_cell" version = "1.14.0" @@ -1535,15 +1568,6 @@ dependencies = [ "bitflags", ] -[[package]] -name = "redox_termios" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8440d8acb4fd3d277125b4bd01a6f38aee8d814b3b5fc09b3f2b825d37d3fe8f" -dependencies = [ - "redox_syscall", -] - [[package]] name = "redox_users" version = "0.4.3" @@ -1897,6 +1921,27 @@ dependencies = [ "dirs", ] +[[package]] +name = "signal-hook" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a253b5e89e2698464fc26b545c9edceb338e18a89effeeecfea192c3025be29d" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.0" @@ -2152,18 +2197,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "termion" -version = "1.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "077185e2eac69c3f8379a4298e1e07cd36beb962290d4a51199acf0fdc10607e" -dependencies = [ - "libc", - "numtoa", - "redox_syscall", - "redox_termios", -] - [[package]] name = "thiserror" version = "1.0.38" @@ -2417,19 +2450,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" -[[package]] -name = "tui" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccdd26cbd674007e649a272da4475fb666d3aa0ad0531da7136db6fab0e5bad1" -dependencies = [ - "bitflags", - "cassowary", - "termion", - "unicode-segmentation", - "unicode-width", -] - [[package]] name = "typenum" version = "1.15.0" diff --git a/Cargo.toml b/Cargo.toml index 88290e8..10b0fdf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,8 +56,7 @@ directories = "4" indicatif = "0.17.1" serde = { version = "1.0.145", features = ["derive"] } serde_json = "1.0.86" -tui = { version = "0.19", default-features = false, features = ["termion"] } -termion = "1.5" +crossterm = { version = "0.26", features = ["use-dev-tty"] } unicode-width = "0.1" itertools = "0.10.5" tokio = { version = "1", features = ["full"] } @@ -75,6 +74,11 @@ semver = "1.0.14" runtime-format = "0.1.2" tiny-bip39 = "1" +# from tui +bitflags = "1.3" +cassowary = "0.3" +unicode-segmentation = "1.2" + [dependencies.tracing-subscriber] version = "0.3" default-features = false diff --git a/atuin-client/src/import/zsh_histdb.rs b/atuin-client/src/import/zsh_histdb.rs index 16de2a7..b9bce34 100644 --- a/atuin-client/src/import/zsh_histdb.rs +++ b/atuin-client/src/import/zsh_histdb.rs @@ -221,7 +221,7 @@ mod test { println!("h: {:#?}", histdb.histdb); println!("counter: {:?}", histdb.histdb.len()); for i in histdb.histdb { - println!("{:?}", i); + println!("{i:?}"); } } } diff --git a/src/command/client/search.rs b/src/command/client/search.rs index 53471ec..9321f11 100644 --- a/src/command/client/search.rs +++ b/src/command/client/search.rs @@ -13,7 +13,6 @@ use super::history::ListMode; mod cursor; mod duration; -mod event; mod history_list; mod interactive; pub use duration::{format_duration, format_duration_into}; diff --git a/src/command/client/search/event.rs b/src/command/client/search/event.rs deleted file mode 100644 index 0e791c9..0000000 --- a/src/command/client/search/event.rs +++ /dev/null @@ -1,70 +0,0 @@ -use std::{thread, time::Duration}; - -use crossbeam_channel::unbounded; -use termion::{event::Event as TermEvent, event::Key, input::TermRead}; - -pub enum Event { - Input(I), - Tick, -} - -/// A small event handler that wrap termion input and tick events. Each event -/// type is handled in its own thread and returned to a common `Receiver` -pub struct Events { - rx: crossbeam_channel::Receiver>, -} - -#[derive(Debug, Clone, Copy)] -pub struct Config { - pub exit_key: Key, - pub tick_rate: Duration, -} - -impl Default for Config { - fn default() -> Config { - Config { - exit_key: Key::Char('q'), - tick_rate: Duration::from_millis(250), - } - } -} - -impl Events { - pub fn new() -> Events { - Events::with_config(Config::default()) - } - - pub fn with_config(config: Config) -> Events { - let (tx, rx) = unbounded(); - - { - let tx = tx.clone(); - thread::spawn(move || { - let tty = termion::get_tty().expect("Could not find tty"); - for event in tty.events().flatten() { - if let Err(err) = tx.send(Event::Input(event)) { - eprintln!("{err}"); - return; - } - } - }) - }; - - thread::spawn(move || loop { - if tx.send(Event::Tick).is_err() { - break; - } - thread::sleep(config.tick_rate); - }); - - Events { rx } - } - - pub fn next(&self) -> Result, crossbeam_channel::RecvError> { - self.rx.recv() - } - - pub fn try_next(&self) -> Result, crossbeam_channel::TryRecvError> { - self.rx.try_recv() - } -} diff --git a/src/command/client/search/history_list.rs b/src/command/client/search/history_list.rs index d74221d..f4725b0 100644 --- a/src/command/client/search/history_list.rs +++ b/src/command/client/search/history_list.rs @@ -1,12 +1,12 @@ use std::time::Duration; -use atuin_client::history::History; -use tui::{ +use crate::tui::{ buffer::Buffer, layout::Rect, style::{Color, Modifier, Style}, widgets::{Block, StatefulWidget, Widget}, }; +use atuin_client::history::History; use super::format_duration; diff --git a/src/command/client/search/interactive.rs b/src/command/client/search/interactive.rs index e0ceb09..c8ceab5 100644 --- a/src/command/client/search/interactive.rs +++ b/src/command/client/search/interactive.rs @@ -1,19 +1,22 @@ -use std::io::stdout; - -use eyre::Result; -use semver::Version; -use termion::{ - event::Event as TermEvent, event::Key, event::MouseButton, event::MouseEvent, - input::MouseTerminal, raw::IntoRawMode, screen::AlternateScreen, +use std::{ + io::{stdout, Write}, + time::Duration, }; -use tui::{ - backend::{Backend, TermionBackend}, + +use crate::tui::{ + backend::{Backend, CrosstermBackend}, layout::{Alignment, Constraint, Direction, Layout}, style::{Color, Modifier, Style}, text::{Span, Spans, Text}, widgets::{Block, BorderType, Borders, Paragraph}, Frame, Terminal, }; +use crossterm::{ + event::{self, Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent}, + execute, terminal, +}; +use eyre::Result; +use semver::Version; use unicode_width::UnicodeWidthStr; use atuin_client::{ @@ -26,7 +29,6 @@ use atuin_client::{ use super::{ cursor::Cursor, - event::{Event, Events}, history_list::{HistoryList, ListState, PREFIX_LENGTH}, }; use crate::VERSION; @@ -62,42 +64,69 @@ impl State { Ok(results) } - fn handle_input( + fn handle_input(&mut self, settings: &Settings, input: &Event, len: usize) -> Option { + match input { + Event::Key(k) => self.handle_key_input(settings, k, len), + Event::Mouse(m) => self.handle_mouse_input(*m, len), + _ => None, + } + } + + fn handle_mouse_input(&mut self, input: MouseEvent, len: usize) -> Option { + match input.kind { + event::MouseEventKind::ScrollDown => { + let i = self.results_state.selected().saturating_sub(1); + self.results_state.select(i); + } + event::MouseEventKind::ScrollUp => { + let i = self.results_state.selected() + 1; + self.results_state.select(i.min(len - 1)); + } + _ => {} + } + None + } + + fn handle_key_input( &mut self, settings: &Settings, - input: &TermEvent, + input: &KeyEvent, len: usize, ) -> Option { - match input { - TermEvent::Key(Key::Char('\t')) => {} - TermEvent::Key(Key::Ctrl('c' | 'd' | 'g')) => return Some(RETURN_ORIGINAL), - TermEvent::Key(Key::Esc) => { + let ctrl = input.modifiers.contains(KeyModifiers::CONTROL); + let alt = input.modifiers.contains(KeyModifiers::ALT); + match input.code { + KeyCode::Char('c' | 'd' | 'g') if ctrl => return Some(RETURN_ORIGINAL), + KeyCode::Esc => { return Some(match settings.exit_mode { ExitMode::ReturnOriginal => RETURN_ORIGINAL, ExitMode::ReturnQuery => RETURN_QUERY, }) } - TermEvent::Key(Key::Char('\n')) => { + KeyCode::Enter => { return Some(self.results_state.selected()); } - TermEvent::Key(Key::Alt(c @ '1'..='9')) => { + KeyCode::Char(c @ '1'..='9') if alt => { let c = c.to_digit(10)? as usize; return Some(self.results_state.selected() + c); } - TermEvent::Key(Key::Left | Key::Ctrl('h')) => { + KeyCode::Left => { self.input.left(); } - TermEvent::Key(Key::Right | Key::Ctrl('l')) => self.input.right(), - TermEvent::Key(Key::Ctrl('a') | Key::Home) => self.input.start(), - TermEvent::Key(Key::Ctrl('e') | Key::End) => self.input.end(), - TermEvent::Key(Key::Char(c)) => self.input.insert(*c), - TermEvent::Key(Key::Backspace) => { + KeyCode::Char('h') if ctrl => { + self.input.left(); + } + KeyCode::Right => self.input.right(), + KeyCode::Char('l') if ctrl => self.input.right(), + KeyCode::Char('a') if ctrl => self.input.start(), + KeyCode::Char('e') if ctrl => self.input.end(), + KeyCode::Backspace => { self.input.back(); } - TermEvent::Key(Key::Delete) => { + KeyCode::Delete => { self.input.remove(); } - TermEvent::Key(Key::Ctrl('w')) => { + KeyCode::Char('w') if ctrl => { // remove the first batch of whitespace while matches!(self.input.back(), Some(c) if c.is_whitespace()) {} while self.input.left() { @@ -108,8 +137,8 @@ impl State { self.input.remove(); } } - TermEvent::Key(Key::Ctrl('u')) => self.input.clear(), - TermEvent::Key(Key::Ctrl('r')) => { + KeyCode::Char('u') if ctrl => self.input.clear(), + KeyCode::Char('r') if ctrl => { pub static FILTER_MODES: [FilterMode; 4] = [ FilterMode::Global, FilterMode::Host, @@ -120,19 +149,24 @@ impl State { let i = (i + 1) % FILTER_MODES.len(); self.filter_mode = FILTER_MODES[i]; } - TermEvent::Key(Key::Down | Key::Ctrl('n' | 'j')) - | TermEvent::Mouse(MouseEvent::Press(MouseButton::WheelDown, _, _)) => { - if self.results_state.selected() == 0 && input.eq(&TermEvent::Key(Key::Down)) { - return Some(RETURN_ORIGINAL); - } + KeyCode::Down if self.results_state.selected() == 0 => return Some(RETURN_ORIGINAL), + KeyCode::Down => { let i = self.results_state.selected().saturating_sub(1); self.results_state.select(i); } - TermEvent::Key(Key::Up | Key::Ctrl('p' | 'k')) - | TermEvent::Mouse(MouseEvent::Press(MouseButton::WheelUp, _, _)) => { + KeyCode::Char('n' | 'j') if ctrl => { + let i = self.results_state.selected().saturating_sub(1); + self.results_state.select(i); + } + KeyCode::Up => { let i = self.results_state.selected() + 1; self.results_state.select(i.min(len - 1)); } + KeyCode::Char('p' | 'k') if ctrl => { + let i = self.results_state.selected() + 1; + self.results_state.select(i.min(len - 1)); + } + KeyCode::Char(c) => self.input.insert(c), _ => {} }; @@ -303,6 +337,45 @@ impl State { } } +struct Stdout { + stdout: std::io::Stdout, +} + +impl Stdout { + pub fn new() -> std::io::Result { + terminal::enable_raw_mode()?; + let mut stdout = stdout(); + execute!( + stdout, + terminal::EnterAlternateScreen, + event::EnableMouseCapture + )?; + Ok(Self { stdout }) + } +} + +impl Drop for Stdout { + fn drop(&mut self) { + execute!( + self.stdout, + terminal::LeaveAlternateScreen, + event::DisableMouseCapture + ) + .unwrap(); + terminal::disable_raw_mode().unwrap(); + } +} + +impl Write for Stdout { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.stdout.write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.stdout.flush() + } +} + // this is a big blob of horrible! clean it up! // for now, it works. But it'd be great if it were more easily readable, and // modular. I'd like to add some more stats and stuff at some point @@ -312,15 +385,10 @@ pub async fn history( settings: &Settings, db: &mut impl Database, ) -> Result { - let stdout = stdout().into_raw_mode()?; - let stdout = MouseTerminal::from(stdout); - let stdout = AlternateScreen::from(stdout); - let backend = TermionBackend::new(stdout); + let stdout = Stdout::new()?; + let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - // Setup event handlers - let events = Events::new(); - let mut input = Cursor::from(query.join(" ")); // Put the cursor at the end of the query by default input.end(); @@ -343,27 +411,6 @@ pub async fn history( let mut results = app.query_results(settings.search_mode, db).await?; let index = 'render: loop { - let initial_input = app.input.as_str().to_owned(); - let initial_filter_mode = app.filter_mode; - - // Handle input - if let Event::Input(input) = events.next()? { - if let Some(i) = app.handle_input(settings, &input, results.len()) { - break 'render i; - } - } - - // After we receive input process the whole event channel before query/render. - while let Ok(Event::Input(input)) = events.try_next() { - if let Some(i) = app.handle_input(settings, &input, results.len()) { - break 'render i; - } - } - - if initial_input != app.input.as_str() || initial_filter_mode != app.filter_mode { - results = app.query_results(settings.search_mode, db).await?; - } - let compact = match settings.style { atuin_client::settings::Style::Auto => { terminal.size().map(|size| size.height < 14).unwrap_or(true) @@ -376,6 +423,24 @@ pub async fn history( } else { terminal.draw(|f| app.draw(f, &results))?; } + + let initial_input = app.input.as_str().to_owned(); + let initial_filter_mode = app.filter_mode; + + if event::poll(Duration::from_millis(250))? { + loop { + if let Some(i) = app.handle_input(settings, &event::read()?, results.len()) { + break 'render i; + } + if !event::poll(Duration::ZERO)? { + break; + } + } + } + + if initial_input != app.input.as_str() || initial_filter_mode != app.filter_mode { + results = app.query_results(settings.search_mode, db).await?; + } }; if index < results.len() { diff --git a/src/main.rs b/src/main.rs index 2f81f4f..3004e0b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ use eyre::Result; use command::AtuinCmd; mod command; +mod tui; const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/src/tui/LICENSE b/src/tui/LICENSE new file mode 100644 index 0000000..7a0657c --- /dev/null +++ b/src/tui/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Florian Dehau + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/tui/README.md b/src/tui/README.md new file mode 100644 index 0000000..506bdf8 --- /dev/null +++ b/src/tui/README.md @@ -0,0 +1,5 @@ +# tui-rs + +A fork of https://crates.io/crates/tui/0.19.0 since it is now unmaintained. + +Some parts have been removed or modified for simplicity, but it is currently mostly equivalent. diff --git a/src/tui/backend/crossterm.rs b/src/tui/backend/crossterm.rs new file mode 100644 index 0000000..2cbfd6e --- /dev/null +++ b/src/tui/backend/crossterm.rs @@ -0,0 +1,221 @@ +use crate::tui::{ + backend::Backend, + buffer::Cell, + layout::Rect, + style::{Color, Modifier}, +}; +use crossterm::{ + cursor::{Hide, MoveTo, Show}, + execute, queue, + style::{ + Attribute as CAttribute, Color as CColor, Print, SetAttribute, SetBackgroundColor, + SetForegroundColor, + }, + terminal::{self, Clear, ClearType}, +}; +use std::io::{self, Write}; + +pub struct CrosstermBackend { + buffer: W, +} + +impl CrosstermBackend +where + W: Write, +{ + pub fn new(buffer: W) -> CrosstermBackend { + CrosstermBackend { buffer } + } +} + +impl Write for CrosstermBackend +where + W: Write, +{ + fn write(&mut self, buf: &[u8]) -> io::Result { + self.buffer.write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + self.buffer.flush() + } +} + +impl Backend for CrosstermBackend +where + W: Write, +{ + fn draw<'a, I>(&mut self, content: I) -> io::Result<()> + where + I: Iterator, + { + let mut fg = Color::Reset; + let mut bg = Color::Reset; + let mut modifier = Modifier::empty(); + let mut last_pos: Option<(u16, u16)> = None; + for (x, y, cell) in content { + // Move the cursor if the previous location was not (x - 1, y) + if !matches!(last_pos, Some(p) if x == p.0 + 1 && y == p.1) { + map_error(queue!(self.buffer, MoveTo(x, y)))?; + } + last_pos = Some((x, y)); + if cell.modifier != modifier { + let diff = ModifierDiff { + from: modifier, + to: cell.modifier, + }; + diff.queue(&mut self.buffer)?; + modifier = cell.modifier; + } + if cell.fg != fg { + let color = CColor::from(cell.fg); + map_error(queue!(self.buffer, SetForegroundColor(color)))?; + fg = cell.fg; + } + if cell.bg != bg { + let color = CColor::from(cell.bg); + map_error(queue!(self.buffer, SetBackgroundColor(color)))?; + bg = cell.bg; + } + + map_error(queue!(self.buffer, Print(&cell.symbol)))?; + } + + map_error(queue!( + self.buffer, + SetForegroundColor(CColor::Reset), + SetBackgroundColor(CColor::Reset), + SetAttribute(CAttribute::Reset) + )) + } + + fn hide_cursor(&mut self) -> io::Result<()> { + map_error(execute!(self.buffer, Hide)) + } + + fn show_cursor(&mut self) -> io::Result<()> { + map_error(execute!(self.buffer, Show)) + } + + fn get_cursor(&mut self) -> io::Result<(u16, u16)> { + crossterm::cursor::position() + .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string())) + } + + fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> { + map_error(execute!(self.buffer, MoveTo(x, y))) + } + + fn clear(&mut self) -> io::Result<()> { + map_error(execute!(self.buffer, Clear(ClearType::All))) + } + + fn size(&self) -> io::Result { + let (width, height) = + terminal::size().map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; + + Ok(Rect::new(0, 0, width, height)) + } + + fn flush(&mut self) -> io::Result<()> { + self.buffer.flush() + } +} + +fn map_error(error: crossterm::Result<()>) -> io::Result<()> { + error.map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string())) +} + +impl From for CColor { + fn from(color: Color) -> Self { + match color { + Color::Reset => CColor::Reset, + Color::Black => CColor::Black, + Color::Red => CColor::DarkRed, + Color::Green => CColor::DarkGreen, + Color::Yellow => CColor::DarkYellow, + Color::Blue => CColor::DarkBlue, + Color::Magenta => CColor::DarkMagenta, + Color::Cyan => CColor::DarkCyan, + Color::Gray => CColor::Grey, + Color::DarkGray => CColor::DarkGrey, + Color::LightRed => CColor::Red, + Color::LightGreen => CColor::Green, + Color::LightBlue => CColor::Blue, + Color::LightYellow => CColor::Yellow, + Color::LightMagenta => CColor::Magenta, + Color::LightCyan => CColor::Cyan, + Color::White => CColor::White, + Color::Indexed(i) => CColor::AnsiValue(i), + Color::Rgb(r, g, b) => CColor::Rgb { r, g, b }, + } + } +} + +#[derive(Debug)] +struct ModifierDiff { + pub from: Modifier, + pub to: Modifier, +} + +impl ModifierDiff { + fn queue(&self, mut w: W) -> io::Result<()> + where + W: io::Write, + { + //use crossterm::Attribute; + let removed = self.from - self.to; + if removed.contains(Modifier::REVERSED) { + map_error(queue!(w, SetAttribute(CAttribute::NoReverse)))?; + } + if removed.contains(Modifier::BOLD) { + map_error(queue!(w, SetAttribute(CAttribute::NormalIntensity)))?; + if self.to.contains(Modifier::DIM) { + map_error(queue!(w, SetAttribute(CAttribute::Dim)))?; + } + } + if removed.contains(Modifier::ITALIC) { + map_error(queue!(w, SetAttribute(CAttribute::NoItalic)))?; + } + if removed.contains(Modifier::UNDERLINED) { + map_error(queue!(w, SetAttribute(CAttribute::NoUnderline)))?; + } + if removed.contains(Modifier::DIM) { + map_error(queue!(w, SetAttribute(CAttribute::NormalIntensity)))?; + } + if removed.contains(Modifier::CROSSED_OUT) { + map_error(queue!(w, SetAttribute(CAttribute::NotCrossedOut)))?; + } + if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) { + map_error(queue!(w, SetAttribute(CAttribute::NoBlink)))?; + } + + let added = self.to - self.from; + if added.contains(Modifier::REVERSED) { + map_error(queue!(w, SetAttribute(CAttribute::Reverse)))?; + } + if added.contains(Modifier::BOLD) { + map_error(queue!(w, SetAttribute(CAttribute::Bold)))?; + } + if added.contains(Modifier::ITALIC) { + map_error(queue!(w, SetAttribute(CAttribute::Italic)))?; + } + if added.contains(Modifier::UNDERLINED) { + map_error(queue!(w, SetAttribute(CAttribute::Underlined)))?; + } + if added.contains(Modifier::DIM) { + map_error(queue!(w, SetAttribute(CAttribute::Dim)))?; + } + if added.contains(Modifier::CROSSED_OUT) { + map_error(queue!(w, SetAttribute(CAttribute::CrossedOut)))?; + } + if added.contains(Modifier::SLOW_BLINK) { + map_error(queue!(w, SetAttribute(CAttribute::SlowBlink)))?; + } + if added.contains(Modifier::RAPID_BLINK) { + map_error(queue!(w, SetAttribute(CAttribute::RapidBlink)))?; + } + + Ok(()) + } +} diff --git a/src/tui/backend/mod.rs b/src/tui/backend/mod.rs new file mode 100644 index 0000000..1a197e7 --- /dev/null +++ b/src/tui/backend/mod.rs @@ -0,0 +1,20 @@ +use std::io; + +use crate::tui::buffer::Cell; +use crate::tui::layout::Rect; + +mod crossterm; +pub use self::crossterm::CrosstermBackend; + +pub trait Backend { + fn draw<'a, I>(&mut self, content: I) -> Result<(), io::Error> + where + I: Iterator; + fn hide_cursor(&mut self) -> Result<(), io::Error>; + fn show_cursor(&mut self) -> Result<(), io::Error>; + fn get_cursor(&mut self) -> Result<(u16, u16), io::Error>; + fn set_cursor(&mut self, x: u16, y: u16) -> Result<(), io::Error>; + fn clear(&mut self) -> Result<(), io::Error>; + fn size(&self) -> Result; + fn flush(&mut self) -> Result<(), io::Error>; +} diff --git a/src/tui/buffer.rs b/src/tui/buffer.rs new file mode 100644 index 0000000..3830798 --- /dev/null +++ b/src/tui/buffer.rs @@ -0,0 +1,732 @@ +use crate::tui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Span, Spans}, +}; +use std::cmp::min; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +/// A buffer cell +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Cell { + pub symbol: String, + pub fg: Color, + pub bg: Color, + pub modifier: Modifier, +} + +impl Cell { + pub fn set_symbol(&mut self, symbol: &str) -> &mut Cell { + self.symbol.clear(); + self.symbol.push_str(symbol); + self + } + + pub fn set_char(&mut self, ch: char) -> &mut Cell { + self.symbol.clear(); + self.symbol.push(ch); + self + } + + pub fn set_fg(&mut self, color: Color) -> &mut Cell { + self.fg = color; + self + } + + pub fn set_bg(&mut self, color: Color) -> &mut Cell { + self.bg = color; + self + } + + pub fn set_style(&mut self, style: Style) -> &mut Cell { + if let Some(c) = style.fg { + self.fg = c; + } + if let Some(c) = style.bg { + self.bg = c; + } + self.modifier.insert(style.add_modifier); + self.modifier.remove(style.sub_modifier); + self + } + + pub fn style(&self) -> Style { + Style::default() + .fg(self.fg) + .bg(self.bg) + .add_modifier(self.modifier) + } + + pub fn reset(&mut self) { + self.symbol.clear(); + self.symbol.push(' '); + self.fg = Color::Reset; + self.bg = Color::Reset; + self.modifier = Modifier::empty(); + } +} + +impl Default for Cell { + fn default() -> Cell { + Cell { + symbol: " ".into(), + fg: Color::Reset, + bg: Color::Reset, + modifier: Modifier::empty(), + } + } +} + +/// A buffer that maps to the desired content of the terminal after the draw call +/// +/// No widget in the library interacts directly with the terminal. Instead each of them is required +/// to draw their state to an intermediate buffer. It is basically a grid where each cell contains +/// a grapheme, a foreground color and a background color. This grid will then be used to output +/// the appropriate escape sequences and characters to draw the UI as the user has defined it. +/// +/// # Examples: +/// +/// ``` +/// use tui::buffer::{Buffer, Cell}; +/// use tui::layout::Rect; +/// use tui::style::{Color, Style, Modifier}; +/// +/// let mut buf = Buffer::empty(Rect{x: 0, y: 0, width: 10, height: 5}); +/// buf.get_mut(0, 2).set_symbol("x"); +/// assert_eq!(buf.get(0, 2).symbol, "x"); +/// buf.set_string(3, 0, "string", Style::default().fg(Color::Red).bg(Color::White)); +/// assert_eq!(buf.get(5, 0), &Cell{ +/// symbol: String::from("r"), +/// fg: Color::Red, +/// bg: Color::White, +/// modifier: Modifier::empty() +/// }); +/// buf.get_mut(5, 0).set_char('x'); +/// assert_eq!(buf.get(5, 0).symbol, "x"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Buffer { + /// The area represented by this buffer + pub area: Rect, + /// The content of the buffer. The length of this Vec should always be equal to area.width * + /// area.height + pub content: Vec, +} + +impl Buffer { + /// Returns a Buffer with all cells set to the default one + pub fn empty(area: Rect) -> Buffer { + let cell = Cell::default(); + Buffer::filled(area, &cell) + } + + /// Returns a Buffer with all cells initialized with the attributes of the given Cell + pub fn filled(area: Rect, cell: &Cell) -> Buffer { + let size = area.area() as usize; + let mut content = Vec::with_capacity(size); + for _ in 0..size { + content.push(cell.clone()); + } + Buffer { area, content } + } + + /// Returns a Buffer containing the given lines + pub fn with_lines(lines: &[S]) -> Buffer + where + S: AsRef, + { + let height = lines.len() as u16; + let width = lines + .iter() + .map(|i| i.as_ref().width() as u16) + .max() + .unwrap_or_default(); + let mut buffer = Buffer::empty(Rect { + x: 0, + y: 0, + width, + height, + }); + for (y, line) in lines.iter().enumerate() { + buffer.set_string(0, y as u16, line, Style::default()); + } + buffer + } + + /// Returns the content of the buffer as a slice + pub fn content(&self) -> &[Cell] { + &self.content + } + + /// Returns the area covered by this buffer + pub fn area(&self) -> &Rect { + &self.area + } + + /// Returns a reference to Cell at the given coordinates + pub fn get(&self, x: u16, y: u16) -> &Cell { + let i = self.index_of(x, y); + &self.content[i] + } + + /// Returns a mutable reference to Cell at the given coordinates + pub fn get_mut(&mut self, x: u16, y: u16) -> &mut Cell { + let i = self.index_of(x, y); + &mut self.content[i] + } + + /// Returns the index in the Vec for the given global (x, y) coordinates. + /// + /// Global coordinates are offset by the Buffer's area offset (`x`/`y`). + /// + /// # Examples + /// + /// ``` + /// # use tui::buffer::Buffer; + /// # use tui::layout::Rect; + /// let rect = Rect::new(200, 100, 10, 10); + /// let buffer = Buffer::empty(rect); + /// // Global coordinates to the top corner of this buffer's area + /// assert_eq!(buffer.index_of(200, 100), 0); + /// ``` + /// + /// # Panics + /// + /// Panics when given an coordinate that is outside of this Buffer's area. + /// + /// ```should_panic + /// # use tui::buffer::Buffer; + /// # use tui::layout::Rect; + /// let rect = Rect::new(200, 100, 10, 10); + /// let buffer = Buffer::empty(rect); + /// // Top coordinate is outside of the buffer in global coordinate space, as the Buffer's area + /// // starts at (200, 100). + /// buffer.index_of(0, 0); // Panics + /// ``` + pub fn index_of(&self, x: u16, y: u16) -> usize { + debug_assert!( + x >= self.area.left() + && x < self.area.right() + && y >= self.area.top() + && y < self.area.bottom(), + "Trying to access position outside the buffer: x={}, y={}, area={:?}", + x, + y, + self.area + ); + ((y - self.area.y) * self.area.width + (x - self.area.x)) as usize + } + + /// Returns the (global) coordinates of a cell given its index + /// + /// Global coordinates are offset by the Buffer's area offset (`x`/`y`). + /// + /// # Examples + /// + /// ``` + /// # use tui::buffer::Buffer; + /// # use tui::layout::Rect; + /// let rect = Rect::new(200, 100, 10, 10); + /// let buffer = Buffer::empty(rect); + /// assert_eq!(buffer.pos_of(0), (200, 100)); + /// assert_eq!(buffer.pos_of(14), (204, 101)); + /// ``` + /// + /// # Panics + /// + /// Panics when given an index that is outside the Buffer's content. + /// + /// ```should_panic + /// # use tui::buffer::Buffer; + /// # use tui::layout::Rect; + /// let rect = Rect::new(0, 0, 10, 10); // 100 cells in total + /// let buffer = Buffer::empty(rect); + /// // Index 100 is the 101th cell, which lies outside of the area of this Buffer. + /// buffer.pos_of(100); // Panics + /// ``` + pub fn pos_of(&self, i: usize) -> (u16, u16) { + debug_assert!( + i < self.content.len(), + "Trying to get the coords of a cell outside the buffer: i={} len={}", + i, + self.content.len() + ); + ( + self.area.x + i as u16 % self.area.width, + self.area.y + i as u16 / self.area.width, + ) + } + + /// Print a string, starting at the position (x, y) + pub fn set_string(&mut self, x: u16, y: u16, string: S, style: Style) + where + S: AsRef, + { + self.set_stringn(x, y, string, usize::MAX, style); + } + + /// Print at most the first n characters of a string if enough space is available + /// until the end of the line + pub fn set_stringn( + &mut self, + x: u16, + y: u16, + string: S, + width: usize, + style: Style, + ) -> (u16, u16) + where + S: AsRef, + { + let mut index = self.index_of(x, y); + let mut x_offset = x as usize; + let graphemes = UnicodeSegmentation::graphemes(string.as_ref(), true); + let max_offset = min(self.area.right() as usize, width.saturating_add(x as usize)); + for s in graphemes { + let width = s.width(); + if width == 0 { + continue; + } + // `x_offset + width > max_offset` could be integer overflow on 32-bit machines if we + // change dimenstions to usize or u32 and someone resizes the terminal to 1x2^32. + if width > max_offset.saturating_sub(x_offset) { + break; + } + + self.content[index].set_symbol(s); + self.content[index].set_style(style); + // Reset following cells if multi-width (they would be hidden by the grapheme), + for i in index + 1..index + width { + self.content[i].reset(); + } + index += width; + x_offset += width; + } + (x_offset as u16, y) + } + + pub fn set_spans(&mut self, x: u16, y: u16, spans: &Spans<'_>, width: u16) -> (u16, u16) { + let mut remaining_width = width; + let mut x = x; + for span in &spans.0 { + if remaining_width == 0 { + break; + } + let pos = self.set_stringn( + x, + y, + span.content.as_ref(), + remaining_width as usize, + span.style, + ); + let w = pos.0.saturating_sub(x); + x = pos.0; + remaining_width = remaining_width.saturating_sub(w); + } + (x, y) + } + + pub fn set_span(&mut self, x: u16, y: u16, span: &Span<'_>, width: u16) -> (u16, u16) { + self.set_stringn(x, y, span.content.as_ref(), width as usize, span.style) + } + + #[deprecated( + since = "0.10.0", + note = "You should use styling capabilities of `Buffer::set_style`" + )] + pub fn set_background(&mut self, area: Rect, color: Color) { + for y in area.top()..area.bottom() { + for x in area.left()..area.right() { + self.get_mut(x, y).set_bg(color); + } + } + } + + pub fn set_style(&mut self, area: Rect, style: Style) { + for y in area.top()..area.bottom() { + for x in area.left()..area.right() { + self.get_mut(x, y).set_style(style); + } + } + } + + /// Resize the buffer so that the mapped area matches the given area and that the buffer + /// length is equal to area.width * area.height + pub fn resize(&mut self, area: Rect) { + let length = area.area() as usize; + if self.content.len() > length { + self.content.truncate(length); + } else { + self.content.resize(length, Cell::default()); + } + self.area = area; + } + + /// Reset all cells in the buffer + pub fn reset(&mut self) { + for c in &mut self.content { + c.reset(); + } + } + + /// Merge an other buffer into this one + pub fn merge(&mut self, other: &Buffer) { + let area = self.area.union(other.area); + let cell = Cell::default(); + self.content.resize(area.area() as usize, cell.clone()); + + // Move original content to the appropriate space + let size = self.area.area() as usize; + for i in (0..size).rev() { + let (x, y) = self.pos_of(i); + // New index in content + let k = ((y - area.y) * area.width + x - area.x) as usize; + if i != k { + self.content[k] = self.content[i].clone(); + self.content[i] = cell.clone(); + } + } + + // Push content of the other buffer into this one (may erase previous + // data) + let size = other.area.area() as usize; + for i in 0..size { + let (x, y) = other.pos_of(i); + // New index in content + let k = ((y - area.y) * area.width + x - area.x) as usize; + self.content[k] = other.content[i].clone(); + } + self.area = area; + } + + /// Builds a minimal sequence of coordinates and Cells necessary to update the UI from + /// self to other. + /// + /// We're assuming that buffers are well-formed, that is no double-width cell is followed by + /// a non-blank cell. + /// + /// # Multi-width characters handling: + /// + /// ```text + /// (Index:) `01` + /// Prev: `コ` + /// Next: `aa` + /// Updates: `0: a, 1: a' + /// ``` + /// + /// ```text + /// (Index:) `01` + /// Prev: `a ` + /// Next: `コ` + /// Updates: `0: コ` (double width symbol at index 0 - skip index 1) + /// ``` + /// + /// ```text + /// (Index:) `012` + /// Prev: `aaa` + /// Next: `aコ` + /// Updates: `0: a, 1: コ` (double width symbol at index 1 - skip index 2) + /// ``` + pub fn diff<'a>(&self, other: &'a Buffer) -> Vec<(u16, u16, &'a Cell)> { + let previous_buffer = &self.content; + let next_buffer = &other.content; + let width = self.area.width; + + let mut updates: Vec<(u16, u16, &Cell)> = vec![]; + // Cells invalidated by drawing/replacing preceeding multi-width characters: + let mut invalidated: usize = 0; + // Cells from the current buffer to skip due to preceeding multi-width characters taking their + // place (the skipped cells should be blank anyway): + let mut to_skip: usize = 0; + for (i, (current, previous)) in next_buffer.iter().zip(previous_buffer.iter()).enumerate() { + if (current != previous || invalidated > 0) && to_skip == 0 { + let x = i as u16 % width; + let y = i as u16 / width; + updates.push((x, y, &next_buffer[i])); + } + + to_skip = current.symbol.width().saturating_sub(1); + + let affected_width = std::cmp::max(current.symbol.width(), previous.symbol.width()); + invalidated = std::cmp::max(affected_width, invalidated).saturating_sub(1); + } + updates + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cell(s: &str) -> Cell { + let mut cell = Cell::default(); + cell.set_symbol(s); + cell + } + + #[test] + fn it_translates_to_and_from_coordinates() { + let rect = Rect::new(200, 100, 50, 80); + let buf = Buffer::empty(rect); + + // First cell is at the upper left corner. + assert_eq!(buf.pos_of(0), (200, 100)); + assert_eq!(buf.index_of(200, 100), 0); + + // Last cell is in the lower right. + assert_eq!(buf.pos_of(buf.content.len() - 1), (249, 179)); + assert_eq!(buf.index_of(249, 179), buf.content.len() - 1); + } + + #[test] + #[should_panic(expected = "outside the buffer")] + fn pos_of_panics_on_out_of_bounds() { + let rect = Rect::new(0, 0, 10, 10); + let buf = Buffer::empty(rect); + + // There are a total of 100 cells; zero-indexed means that 100 would be the 101st cell. + buf.pos_of(100); + } + + #[test] + #[should_panic(expected = "outside the buffer")] + fn index_of_panics_on_out_of_bounds() { + let rect = Rect::new(0, 0, 10, 10); + let buf = Buffer::empty(rect); + + // width is 10; zero-indexed means that 10 would be the 11th cell. + buf.index_of(10, 0); + } + + #[test] + fn buffer_set_string() { + let area = Rect::new(0, 0, 5, 1); + let mut buffer = Buffer::empty(area); + + // Zero-width + buffer.set_stringn(0, 0, "aaa", 0, Style::default()); + assert_eq!(buffer, Buffer::with_lines(&[" "])); + + buffer.set_string(0, 0, "aaa", Style::default()); + assert_eq!(buffer, Buffer::with_lines(&["aaa "])); + + // Width limit: + buffer.set_stringn(0, 0, "bbbbbbbbbbbbbb", 4, Style::default()); + assert_eq!(buffer, Buffer::with_lines(&["bbbb "])); + + buffer.set_string(0, 0, "12345", Style::default()); + assert_eq!(buffer, Buffer::with_lines(&["12345"])); + + // Width truncation: + buffer.set_string(0, 0, "123456", Style::default()); + assert_eq!(buffer, Buffer::with_lines(&["12345"])); + } + + #[test] + fn buffer_set_string_zero_width() { + let area = Rect::new(0, 0, 1, 1); + let mut buffer = Buffer::empty(area); + + // Leading grapheme with zero width + let s = "\u{1}a"; + buffer.set_stringn(0, 0, s, 1, Style::default()); + assert_eq!(buffer, Buffer::with_lines(&["a"])); + + // Trailing grapheme with zero with + let s = "a\u{1}"; + buffer.set_stringn(0, 0, s, 1, Style::default()); + assert_eq!(buffer, Buffer::with_lines(&["a"])); + } + + #[test] + fn buffer_set_string_double_width() { + let area = Rect::new(0, 0, 5, 1); + let mut buffer = Buffer::empty(area); + buffer.set_string(0, 0, "コン", Style::default()); + assert_eq!(buffer, Buffer::with_lines(&["コン "])); + + // Only 1 space left. + buffer.set_string(0, 0, "コンピ", Style::default()); + assert_eq!(buffer, Buffer::with_lines(&["コン "])); + } + + #[test] + fn buffer_with_lines() { + let buffer = Buffer::with_lines(&["┌────────┐", "│コンピュ│", "│ーa 上で│", "└────────┘"]); + assert_eq!(buffer.area.x, 0); + assert_eq!(buffer.area.y, 0); + assert_eq!(buffer.area.width, 10); + assert_eq!(buffer.area.height, 4); + } + + #[test] + fn buffer_diffing_empty_empty() { + let area = Rect::new(0, 0, 40, 40); + let prev = Buffer::empty(area); + let next = Buffer::empty(area); + let diff = prev.diff(&next); + assert_eq!(diff, vec![]); + } + + #[test] + fn buffer_diffing_empty_filled() { + let area = Rect::new(0, 0, 40, 40); + let prev = Buffer::empty(area); + let next = Buffer::filled(area, Cell::default().set_symbol("a")); + let diff = prev.diff(&next); + assert_eq!(diff.len(), 40 * 40); + } + + #[test] + fn buffer_diffing_filled_filled() { + let area = Rect::new(0, 0, 40, 40); + let prev = Buffer::filled(area, Cell::default().set_symbol("a")); + let next = Buffer::filled(area, Cell::default().set_symbol("a")); + let diff = prev.diff(&next); + assert_eq!(diff, vec![]); + } + + #[test] + fn buffer_diffing_single_width() { + let prev = Buffer::with_lines(&[ + " ", + "┌Title─┐ ", + "│ │ ", + "│ │ ", + "└──────┘ ", + ]); + let next = Buffer::with_lines(&[ + " ", + "┌TITLE─┐ ", + "│ │ ", + "│ │ ", + "└──────┘ ", + ]); + let diff = prev.diff(&next); + assert_eq!( + diff, + vec![ + (2, 1, &cell("I")), + (3, 1, &cell("T")), + (4, 1, &cell("L")), + (5, 1, &cell("E")), + ] + ); + } + + #[test] + #[rustfmt::skip] + fn buffer_diffing_multi_width() { + let prev = Buffer::with_lines(&[ + "┌Title─┐ ", + "└──────┘ ", + ]); + let next = Buffer::with_lines(&[ + "┌称号──┐ ", + "└──────┘ ", + ]); + let diff = prev.diff(&next); + assert_eq!( + diff, + vec![ + (1, 0, &cell("称")), + // Skipped "i" + (3, 0, &cell("号")), + // Skipped "l" + (5, 0, &cell("─")), + ] + ); + } + + #[test] + fn buffer_diffing_multi_width_offset() { + let prev = Buffer::with_lines(&["┌称号──┐"]); + let next = Buffer::with_lines(&["┌─称号─┐"]); + + let diff = prev.diff(&next); + assert_eq!( + diff, + vec![(1, 0, &cell("─")), (2, 0, &cell("称")), (4, 0, &cell("号")),] + ); + } + + #[test] + fn buffer_merge() { + let mut one = Buffer::filled( + Rect { + x: 0, + y: 0, + width: 2, + height: 2, + }, + Cell::default().set_symbol("1"), + ); + let two = Buffer::filled( + Rect { + x: 0, + y: 2, + width: 2, + height: 2, + }, + Cell::default().set_symbol("2"), + ); + one.merge(&two); + assert_eq!(one, Buffer::with_lines(&["11", "11", "22", "22"])); + } + + #[test] + fn buffer_merge2() { + let mut one = Buffer::filled( + Rect { + x: 2, + y: 2, + width: 2, + height: 2, + }, + Cell::default().set_symbol("1"), + ); + let two = Buffer::filled( + Rect { + x: 0, + y: 0, + width: 2, + height: 2, + }, + Cell::default().set_symbol("2"), + ); + one.merge(&two); + assert_eq!(one, Buffer::with_lines(&["22 ", "22 ", " 11", " 11"])); + } + + #[test] + fn buffer_merge3() { + let mut one = Buffer::filled( + Rect { + x: 3, + y: 3, + width: 2, + height: 2, + }, + Cell::default().set_symbol("1"), + ); + let two = Buffer::filled( + Rect { + x: 1, + y: 1, + width: 3, + height: 4, + }, + Cell::default().set_symbol("2"), + ); + one.merge(&two); + let mut merged = Buffer::with_lines(&["222 ", "222 ", "2221", "2221"]); + merged.area = Rect { + x: 1, + y: 1, + width: 4, + height: 4, + }; + assert_eq!(one, merged); + } +} diff --git a/src/tui/layout.rs b/src/tui/layout.rs new file mode 100644 index 0000000..369e10f --- /dev/null +++ b/src/tui/layout.rs @@ -0,0 +1,537 @@ +use std::cell::RefCell; +use std::cmp::{max, min}; +use std::collections::HashMap; + +use cassowary::strength::{REQUIRED, WEAK}; +use cassowary::WeightedRelation::{EQ, GE, LE}; +use cassowary::{Constraint as CassowaryConstraint, Expression, Solver, Variable}; + +#[derive(Debug, Hash, Clone, Copy, PartialEq, Eq)] +pub enum Corner { + TopLeft, + TopRight, + BottomRight, + BottomLeft, +} + +#[derive(Debug, Hash, Clone, PartialEq, Eq)] +pub enum Direction { + Horizontal, + Vertical, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Constraint { + // TODO: enforce range 0 - 100 + Percentage(u16), + Ratio(u32, u32), + Length(u16), + Max(u16), + Min(u16), +} + +impl Constraint { + pub fn apply(&self, length: u16) -> u16 { + match *self { + Constraint::Percentage(p) => length * p / 100, + Constraint::Ratio(num, den) => { + let r = num * u32::from(length) / den; + r as u16 + } + Constraint::Length(l) => length.min(l), + Constraint::Max(m) => length.min(m), + Constraint::Min(m) => length.max(m), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Margin { + pub vertical: u16, + pub horizontal: u16, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Alignment { + Left, + Center, + Right, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Layout { + direction: Direction, + margin: Margin, + constraints: Vec, + /// Whether the last chunk of the computed layout should be expanded to fill the available + /// space. + expand_to_fill: bool, +} + +thread_local! { + static LAYOUT_CACHE: RefCell>> = RefCell::new(HashMap::new()); +} + +impl Default for Layout { + fn default() -> Layout { + Layout { + direction: Direction::Vertical, + margin: Margin { + horizontal: 0, + vertical: 0, + }, + constraints: Vec::new(), + expand_to_fill: true, + } + } +} + +impl Layout { + pub fn constraints(mut self, constraints: C) -> Layout + where + C: Into>, + { + self.constraints = constraints.into(); + self + } + + pub fn margin(mut self, margin: u16) -> Layout { + self.margin = Margin { + horizontal: margin, + vertical: margin, + }; + self + } + + pub fn horizontal_margin(mut self, horizontal: u16) -> Layout { + self.margin.horizontal = horizontal; + self + } + + pub fn vertical_margin(mut self, vertical: u16) -> Layout { + self.margin.vertical = vertical; + self + } + + pub fn direction(mut self, direction: Direction) -> Layout { + self.direction = direction; + self + } + + pub(crate) fn expand_to_fill(mut self, expand_to_fill: bool) -> Layout { + self.expand_to_fill = expand_to_fill; + self + } + + /// Wrapper function around the cassowary-rs solver to be able to split a given + /// area into smaller ones based on the preferred widths or heights and the direction. + /// + /// # Examples + /// ``` + /// # use tui::layout::{Rect, Constraint, Direction, Layout}; + /// let chunks = Layout::default() + /// .direction(Direction::Vertical) + /// .constraints([Constraint::Length(5), Constraint::Min(0)].as_ref()) + /// .split(Rect { + /// x: 2, + /// y: 2, + /// width: 10, + /// height: 10, + /// }); + /// assert_eq!( + /// chunks, + /// vec![ + /// Rect { + /// x: 2, + /// y: 2, + /// width: 10, + /// height: 5 + /// }, + /// Rect { + /// x: 2, + /// y: 7, + /// width: 10, + /// height: 5 + /// } + /// ] + /// ); + /// + /// let chunks = Layout::default() + /// .direction(Direction::Horizontal) + /// .constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)].as_ref()) + /// .split(Rect { + /// x: 0, + /// y: 0, + /// width: 9, + /// height: 2, + /// }); + /// assert_eq!( + /// chunks, + /// vec![ + /// Rect { + /// x: 0, + /// y: 0, + /// width: 3, + /// height: 2 + /// }, + /// Rect { + /// x: 3, + /// y: 0, + /// width: 6, + /// height: 2 + /// } + /// ] + /// ); + /// ``` + pub fn split(&self, area: Rect) -> Vec { + // TODO: Maybe use a fixed size cache ? + LAYOUT_CACHE.with(|c| { + c.borrow_mut() + .entry((area, self.clone())) + .or_insert_with(|| split(area, self)) + .clone() + }) + } +} + +#[allow(clippy::too_many_lines)] +fn split(area: Rect, layout: &Layout) -> Vec { + let mut solver = Solver::new(); + let mut vars: HashMap = HashMap::new(); + let elements = layout + .constraints + .iter() + .map(|_| Element::new()) + .collect::>(); + let mut results = layout + .constraints + .iter() + .map(|_| Rect::default()) + .collect::>(); + + let dest_area = area.inner(&layout.margin); + for (i, e) in elements.iter().enumerate() { + vars.insert(e.x, (i, 0)); + vars.insert(e.y, (i, 1)); + vars.insert(e.width, (i, 2)); + vars.insert(e.height, (i, 3)); + } + let mut ccs: Vec = + Vec::with_capacity(elements.len() * 4 + layout.constraints.len() * 6); + for elt in &elements { + ccs.push(elt.width | GE(REQUIRED) | 0f64); + ccs.push(elt.height | GE(REQUIRED) | 0f64); + ccs.push(elt.left() | GE(REQUIRED) | f64::from(dest_area.left())); + ccs.push(elt.top() | GE(REQUIRED) | f64::from(dest_area.top())); + ccs.push(elt.right() | LE(REQUIRED) | f64::from(dest_area.right())); + ccs.push(elt.bottom() | LE(REQUIRED) | f64::from(dest_area.bottom())); + } + if let Some(first) = elements.first() { + ccs.push(match layout.direction { + Direction::Horizontal => first.left() | EQ(REQUIRED) | f64::from(dest_area.left()), + Direction::Vertical => first.top() | EQ(REQUIRED) | f64::from(dest_area.top()), + }); + } + if layout.expand_to_fill { + if let Some(last) = elements.last() { + ccs.push(match layout.direction { + Direction::Horizontal => last.right() | EQ(REQUIRED) | f64::from(dest_area.right()), + Direction::Vertical => last.bottom() | EQ(REQUIRED) | f64::from(dest_area.bottom()), + }); + } + } + match layout.direction { + Direction::Horizontal => { + for pair in elements.windows(2) { + ccs.push((pair[0].x + pair[0].width) | EQ(REQUIRED) | pair[1].x); + } + for (i, size) in layout.constraints.iter().enumerate() { + ccs.push(elements[i].y | EQ(REQUIRED) | f64::from(dest_area.y)); + ccs.push(elements[i].height | EQ(REQUIRED) | f64::from(dest_area.height)); + ccs.push(match *size { + Constraint::Length(v) => elements[i].width | EQ(WEAK) | f64::from(v), + Constraint::Percentage(v) => { + elements[i].width | EQ(WEAK) | (f64::from(v * dest_area.width) / 100.0) + } + Constraint::Ratio(n, d) => { + elements[i].width + | EQ(WEAK) + | (f64::from(dest_area.width) * f64::from(n) / f64::from(d)) + } + Constraint::Min(v) => elements[i].width | GE(WEAK) | f64::from(v), + Constraint::Max(v) => elements[i].width | LE(WEAK) | f64::from(v), + }); + } + } + Direction::Vertical => { + for pair in elements.windows(2) { + ccs.push((pair[0].y + pair[0].height) | EQ(REQUIRED) | pair[1].y); + } + for (i, size) in layout.constraints.iter().enumerate() { + ccs.push(elements[i].x | EQ(REQUIRED) | f64::from(dest_area.x)); + ccs.push(elements[i].width | EQ(REQUIRED) | f64::from(dest_area.width)); + ccs.push(match *size { + Constraint::Length(v) => elements[i].height | EQ(WEAK) | f64::from(v), + Constraint::Percentage(v) => { + elements[i].height | EQ(WEAK) | (f64::from(v * dest_area.height) / 100.0) + } + Constraint::Ratio(n, d) => { + elements[i].height + | EQ(WEAK) + | (f64::from(dest_area.height) * f64::from(n) / f64::from(d)) + } + Constraint::Min(v) => elements[i].height | GE(WEAK) | f64::from(v), + Constraint::Max(v) => elements[i].height | LE(WEAK) | f64::from(v), + }); + } + } + } + solver.add_constraints(&ccs).unwrap(); + for &(var, value) in solver.fetch_changes() { + let (index, attr) = vars[&var]; + let value = if value.is_sign_negative() { + 0 + } else { + value as u16 + }; + match attr { + 0 => { + results[index].x = value; + } + 1 => { + results[index].y = value; + } + 2 => { + results[index].width = value; + } + 3 => { + results[index].height = value; + } + _ => {} + } + } + + if layout.expand_to_fill { + // Fix imprecision by extending the last item a bit if necessary + if let Some(last) = results.last_mut() { + match layout.direction { + Direction::Vertical => { + last.height = dest_area.bottom() - last.y; + } + Direction::Horizontal => { + last.width = dest_area.right() - last.x; + } + } + } + } + results +} + +/// A container used by the solver inside split +struct Element { + x: Variable, + y: Variable, + width: Variable, + height: Variable, +} + +impl Element { + fn new() -> Element { + Element { + x: Variable::new(), + y: Variable::new(), + width: Variable::new(), + height: Variable::new(), + } + } + + fn left(&self) -> Variable { + self.x + } + + fn top(&self) -> Variable { + self.y + } + + fn right(&self) -> Expression { + self.x + self.width + } + + fn bottom(&self) -> Expression { + self.y + self.height + } +} + +/// A simple rectangle used in the computation of the layout and to give widgets a hint about the +/// area they are supposed to render to. +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Default)] +pub struct Rect { + pub x: u16, + pub y: u16, + pub width: u16, + pub height: u16, +} + +impl Rect { + /// Creates a new rect, with width and height limited to keep the area under max u16. + /// If clipped, aspect ratio will be preserved. + pub fn new(x: u16, y: u16, width: u16, height: u16) -> Rect { + let max_area = u16::max_value(); + let (clipped_width, clipped_height) = + if u32::from(width) * u32::from(height) > u32::from(max_area) { + let aspect_ratio = f64::from(width) / f64::from(height); + let max_area_f = f64::from(max_area); + let height_f = (max_area_f / aspect_ratio).sqrt(); + let width_f = height_f * aspect_ratio; + (width_f as u16, height_f as u16) + } else { + (width, height) + }; + Rect { + x, + y, + width: clipped_width, + height: clipped_height, + } + } + + pub fn area(self) -> u16 { + self.width * self.height + } + + pub fn left(self) -> u16 { + self.x + } + + pub fn right(self) -> u16 { + self.x.saturating_add(self.width) + } + + pub fn top(self) -> u16 { + self.y + } + + pub fn bottom(self) -> u16 { + self.y.saturating_add(self.height) + } + + pub fn inner(self, margin: &Margin) -> Rect { + if self.width < 2 * margin.horizontal || self.height < 2 * margin.vertical { + Rect::default() + } else { + Rect { + x: self.x + margin.horizontal, + y: self.y + margin.vertical, + width: self.width - 2 * margin.horizontal, + height: self.height - 2 * margin.vertical, + } + } + } + + pub fn union(self, other: Rect) -> Rect { + let x1 = min(self.x, other.x); + let y1 = min(self.y, other.y); + let x2 = max(self.x + self.width, other.x + other.width); + let y2 = max(self.y + self.height, other.y + other.height); + Rect { + x: x1, + y: y1, + width: x2 - x1, + height: y2 - y1, + } + } + + pub fn intersection(self, other: Rect) -> Rect { + let x1 = max(self.x, other.x); + let y1 = max(self.y, other.y); + let x2 = min(self.x + self.width, other.x + other.width); + let y2 = min(self.y + self.height, other.y + other.height); + Rect { + x: x1, + y: y1, + width: x2 - x1, + height: y2 - y1, + } + } + + pub fn intersects(self, other: Rect) -> bool { + self.x < other.x + other.width + && self.x + self.width > other.x + && self.y < other.y + other.height + && self.y + self.height > other.y + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_vertical_split_by_height() { + let target = Rect { + x: 2, + y: 2, + width: 10, + height: 10, + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Percentage(10), + Constraint::Max(5), + Constraint::Min(1), + ] + .as_ref(), + ) + .split(target); + + assert_eq!(target.height, chunks.iter().map(|r| r.height).sum::()); + chunks.windows(2).for_each(|w| assert!(w[0].y <= w[1].y)); + } + + #[test] + fn test_rect_size_truncation() { + for width in 256u16..300u16 { + for height in 256u16..300u16 { + let rect = Rect::new(0, 0, width, height); + rect.area(); // Should not panic. + assert!(rect.width < width || rect.height < height); + // The target dimensions are rounded down so the math will not be too precise + // but let's make sure the ratios don't diverge crazily. + assert!( + (f64::from(rect.width) / f64::from(rect.height) + - f64::from(width) / f64::from(height)) + .abs() + < 1.0 + ); + } + } + + // One dimension below 255, one above. Area above max u16. + let width = 900; + let height = 100; + let rect = Rect::new(0, 0, width, height); + assert_ne!(rect.width, 900); + assert_ne!(rect.height, 100); + assert!(rect.width < width || rect.height < height); + } + + #[test] + fn test_rect_size_preservation() { + for width in 0..256u16 { + for height in 0..256u16 { + let rect = Rect::new(0, 0, width, height); + rect.area(); // Should not panic. + assert_eq!(rect.width, width); + assert_eq!(rect.height, height); + } + } + + // One dimension below 255, one above. Area below max u16. + let rect = Rect::new(0, 0, 300, 100); + assert_eq!(rect.width, 300); + assert_eq!(rect.height, 100); + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..29fe84c --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,20 @@ +//! Fork of `tui-rs` +#![allow( + clippy::module_name_repetitions, + clippy::bool_to_int_with_if, + clippy::similar_names, + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + dead_code +)] + +pub mod backend; +pub mod buffer; +pub mod layout; +pub mod style; +pub mod symbols; +pub mod terminal; +pub mod text; +pub mod widgets; + +pub use self::terminal::{Frame, Terminal, TerminalOptions, Viewport}; diff --git a/src/tui/style.rs b/src/tui/style.rs new file mode 100644 index 0000000..4aa19d4 --- /dev/null +++ b/src/tui/style.rs @@ -0,0 +1,278 @@ +//! `style` contains the primitives used to control how your user interface will look. + +use bitflags::bitflags; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Color { + Reset, + Black, + Red, + Green, + Yellow, + Blue, + Magenta, + Cyan, + Gray, + DarkGray, + LightRed, + LightGreen, + LightYellow, + LightBlue, + LightMagenta, + LightCyan, + White, + Rgb(u8, u8, u8), + Indexed(u8), +} + +bitflags! { + /// Modifier changes the way a piece of text is displayed. + /// + /// They are bitflags so they can easily be composed. + /// + /// ## Examples + /// + /// ```rust + /// # use tui::style::Modifier; + /// + /// let m = Modifier::BOLD | Modifier::ITALIC; + /// ``` + pub struct Modifier: u16 { + const BOLD = 0b0000_0000_0001; + const DIM = 0b0000_0000_0010; + const ITALIC = 0b0000_0000_0100; + const UNDERLINED = 0b0000_0000_1000; + const SLOW_BLINK = 0b0000_0001_0000; + const RAPID_BLINK = 0b0000_0010_0000; + const REVERSED = 0b0000_0100_0000; + const HIDDEN = 0b0000_1000_0000; + const CROSSED_OUT = 0b0001_0000_0000; + } +} + +/// Style let you control the main characteristics of the displayed elements. +/// +/// ```rust +/// # use tui::style::{Color, Modifier, Style}; +/// Style::default() +/// .fg(Color::Black) +/// .bg(Color::Green) +/// .add_modifier(Modifier::ITALIC | Modifier::BOLD); +/// ``` +/// +/// It represents an incremental change. If you apply the styles S1, S2, S3 to a cell of the +/// terminal buffer, the style of this cell will be the result of the merge of S1, S2 and S3, not +/// just S3. +/// +/// ```rust +/// # use tui::style::{Color, Modifier, Style}; +/// # use tui::buffer::Buffer; +/// # use tui::layout::Rect; +/// let styles = [ +/// Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD | Modifier::ITALIC), +/// Style::default().bg(Color::Red), +/// Style::default().fg(Color::Yellow).remove_modifier(Modifier::ITALIC), +/// ]; +/// let mut buffer = Buffer::empty(Rect::new(0, 0, 1, 1)); +/// for style in &styles { +/// buffer.get_mut(0, 0).set_style(*style); +/// } +/// assert_eq!( +/// Style { +/// fg: Some(Color::Yellow), +/// bg: Some(Color::Red), +/// add_modifier: Modifier::BOLD, +/// sub_modifier: Modifier::empty(), +/// }, +/// buffer.get(0, 0).style(), +/// ); +/// ``` +/// +/// The default implementation returns a `Style` that does not modify anything. If you wish to +/// reset all properties until that point use [`Style::reset`]. +/// +/// ``` +/// # use tui::style::{Color, Modifier, Style}; +/// # use tui::buffer::Buffer; +/// # use tui::layout::Rect; +/// let styles = [ +/// Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD | Modifier::ITALIC), +/// Style::reset().fg(Color::Yellow), +/// ]; +/// let mut buffer = Buffer::empty(Rect::new(0, 0, 1, 1)); +/// for style in &styles { +/// buffer.get_mut(0, 0).set_style(*style); +/// } +/// assert_eq!( +/// Style { +/// fg: Some(Color::Yellow), +/// bg: Some(Color::Reset), +/// add_modifier: Modifier::empty(), +/// sub_modifier: Modifier::empty(), +/// }, +/// buffer.get(0, 0).style(), +/// ); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Style { + pub fg: Option, + pub bg: Option, + pub add_modifier: Modifier, + pub sub_modifier: Modifier, +} + +impl Default for Style { + fn default() -> Style { + Style { + fg: None, + bg: None, + add_modifier: Modifier::empty(), + sub_modifier: Modifier::empty(), + } + } +} + +impl Style { + /// Returns a `Style` resetting all properties. + pub fn reset() -> Style { + Style { + fg: Some(Color::Reset), + bg: Some(Color::Reset), + add_modifier: Modifier::empty(), + sub_modifier: Modifier::all(), + } + } + + /// Changes the foreground color. + /// + /// ## Examples + /// + /// ```rust + /// # use tui::style::{Color, Style}; + /// let style = Style::default().fg(Color::Blue); + /// let diff = Style::default().fg(Color::Red); + /// assert_eq!(style.patch(diff), Style::default().fg(Color::Red)); + /// ``` + pub fn fg(mut self, color: Color) -> Style { + self.fg = Some(color); + self + } + + /// Changes the background color. + /// + /// ## Examples + /// + /// ```rust + /// # use tui::style::{Color, Style}; + /// let style = Style::default().bg(Color::Blue); + /// let diff = Style::default().bg(Color::Red); + /// assert_eq!(style.patch(diff), Style::default().bg(Color::Red)); + /// ``` + pub fn bg(mut self, color: Color) -> Style { + self.bg = Some(color); + self + } + + /// Changes the text emphasis. + /// + /// When applied, it adds the given modifier to the `Style` modifiers. + /// + /// ## Examples + /// + /// ```rust + /// # use tui::style::{Color, Modifier, Style}; + /// let style = Style::default().add_modifier(Modifier::BOLD); + /// let diff = Style::default().add_modifier(Modifier::ITALIC); + /// let patched = style.patch(diff); + /// assert_eq!(patched.add_modifier, Modifier::BOLD | Modifier::ITALIC); + /// assert_eq!(patched.sub_modifier, Modifier::empty()); + /// ``` + pub fn add_modifier(mut self, modifier: Modifier) -> Style { + self.sub_modifier.remove(modifier); + self.add_modifier.insert(modifier); + self + } + + /// Changes the text emphasis. + /// + /// When applied, it removes the given modifier from the `Style` modifiers. + /// + /// ## Examples + /// + /// ```rust + /// # use tui::style::{Color, Modifier, Style}; + /// let style = Style::default().add_modifier(Modifier::BOLD | Modifier::ITALIC); + /// let diff = Style::default().remove_modifier(Modifier::ITALIC); + /// let patched = style.patch(diff); + /// assert_eq!(patched.add_modifier, Modifier::BOLD); + /// assert_eq!(patched.sub_modifier, Modifier::ITALIC); + /// ``` + pub fn remove_modifier(mut self, modifier: Modifier) -> Style { + self.add_modifier.remove(modifier); + self.sub_modifier.insert(modifier); + self + } + + /// Results in a combined style that is equivalent to applying the two individual styles to + /// a style one after the other. + /// + /// ## Examples + /// ``` + /// # use tui::style::{Color, Modifier, Style}; + /// let style_1 = Style::default().fg(Color::Yellow); + /// let style_2 = Style::default().bg(Color::Red); + /// let combined = style_1.patch(style_2); + /// assert_eq!( + /// Style::default().patch(style_1).patch(style_2), + /// Style::default().patch(combined)); + /// ``` + pub fn patch(mut self, other: Style) -> Style { + self.fg = other.fg.or(self.fg); + self.bg = other.bg.or(self.bg); + + self.add_modifier.remove(other.sub_modifier); + self.add_modifier.insert(other.add_modifier); + self.sub_modifier.remove(other.add_modifier); + self.sub_modifier.insert(other.sub_modifier); + + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn styles() -> Vec