From c05d2850420a2c163b8f62c33a6cef7c0ae1ad8d Mon Sep 17 00:00:00 2001 From: Vladislav Stepanov <8uk.8ak@gmail.com> Date: Fri, 14 Apr 2023 23:18:58 +0400 Subject: Workspace reorder (#868) * Try different workspace structure Move main crate (atuin) to be on the same level with other crates in this workspace * extract common dependencies to the workspace definition * fix base64 v0.21 deprecation warning * questionable: update deps & fix chrono deprecations possible panic sites are unchanged, they're just more visible now * Revert "questionable: update deps & fix chrono deprecations" This reverts commit 993e60f8dea81a1625a04285a617959ad09a0866. --- src/command/client.rs | 61 - src/command/client/history.rs | 298 - src/command/client/import.rs | 152 - src/command/client/search.rs | 189 - src/command/client/search/cursor.rs | 333 -- src/command/client/search/duration.rs | 62 - src/command/client/search/engines.rs | 46 - src/command/client/search/engines/db.rs | 33 - src/command/client/search/engines/skim.rs | 145 - src/command/client/search/history_list.rs | 183 - src/command/client/search/interactive.rs | 588 -- src/command/client/stats.rs | 181 - src/command/client/sync.rs | 74 - src/command/client/sync/login.rs | 147 - src/command/client/sync/logout.rs | 19 - src/command/client/sync/register.rs | 49 - src/command/client/sync/status.rs | 35 - src/command/contributors.rs | 75 - src/command/init.rs | 144 - src/command/mod.rs | 87 - src/command/server.rs | 44 - src/main.rs | 45 - src/ratatui/.github/ISSUE_TEMPLATE/bug_report.md | 60 - src/ratatui/.github/ISSUE_TEMPLATE/config.yml | 1 - .../.github/ISSUE_TEMPLATE/feature_request.md | 32 - src/ratatui/.github/workflows/cd.yml | 19 - src/ratatui/.github/workflows/ci.yml | 76 - src/ratatui/.gitignore | 6 - src/ratatui/LICENSE | 21 - src/ratatui/README.md | 136 - src/ratatui/backend/crossterm.rs | 241 - src/ratatui/backend/mod.rs | 58 - src/ratatui/backend/termion.rs | 275 - src/ratatui/buffer.rs | 736 --- src/ratatui/layout.rs | 560 -- src/ratatui/mod.rs | 177 - src/ratatui/style.rs | 310 - src/ratatui/symbols.rs | 233 - src/ratatui/terminal.rs | 487 -- src/ratatui/text.rs | 430 -- src/ratatui/widgets/barchart.rs | 219 - src/ratatui/widgets/block.rs | 573 -- src/ratatui/widgets/canvas/line.rs | 95 - src/ratatui/widgets/canvas/map.rs | 48 - src/ratatui/widgets/canvas/mod.rs | 510 -- src/ratatui/widgets/canvas/points.rs | 30 - src/ratatui/widgets/canvas/rectangle.rs | 52 - src/ratatui/widgets/canvas/world.rs | 6299 -------------------- src/ratatui/widgets/chart.rs | 660 -- src/ratatui/widgets/clear.rs | 37 - src/ratatui/widgets/gauge.rs | 313 - src/ratatui/widgets/list.rs | 268 - src/ratatui/widgets/mod.rs | 184 - src/ratatui/widgets/paragraph.rs | 214 - src/ratatui/widgets/reflow.rs | 534 -- src/ratatui/widgets/sparkline.rs | 155 - src/ratatui/widgets/table.rs | 504 -- src/ratatui/widgets/tabs.rs | 129 - src/shell/atuin.bash | 34 - src/shell/atuin.fish | 40 - src/shell/atuin.nu | 44 - src/shell/atuin.zsh | 57 - 62 files changed, 17847 deletions(-) delete mode 100644 src/command/client.rs delete mode 100644 src/command/client/history.rs delete mode 100644 src/command/client/import.rs delete mode 100644 src/command/client/search.rs delete mode 100644 src/command/client/search/cursor.rs delete mode 100644 src/command/client/search/duration.rs delete mode 100644 src/command/client/search/engines.rs delete mode 100644 src/command/client/search/engines/db.rs delete mode 100644 src/command/client/search/engines/skim.rs delete mode 100644 src/command/client/search/history_list.rs delete mode 100644 src/command/client/search/interactive.rs delete mode 100644 src/command/client/stats.rs delete mode 100644 src/command/client/sync.rs delete mode 100644 src/command/client/sync/login.rs delete mode 100644 src/command/client/sync/logout.rs delete mode 100644 src/command/client/sync/register.rs delete mode 100644 src/command/client/sync/status.rs delete mode 100644 src/command/contributors.rs delete mode 100644 src/command/init.rs delete mode 100644 src/command/mod.rs delete mode 100644 src/command/server.rs delete mode 100644 src/main.rs delete mode 100644 src/ratatui/.github/ISSUE_TEMPLATE/bug_report.md delete mode 100644 src/ratatui/.github/ISSUE_TEMPLATE/config.yml delete mode 100644 src/ratatui/.github/ISSUE_TEMPLATE/feature_request.md delete mode 100644 src/ratatui/.github/workflows/cd.yml delete mode 100644 src/ratatui/.github/workflows/ci.yml delete mode 100644 src/ratatui/.gitignore delete mode 100644 src/ratatui/LICENSE delete mode 100644 src/ratatui/README.md delete mode 100644 src/ratatui/backend/crossterm.rs delete mode 100644 src/ratatui/backend/mod.rs delete mode 100644 src/ratatui/backend/termion.rs delete mode 100644 src/ratatui/buffer.rs delete mode 100644 src/ratatui/layout.rs delete mode 100644 src/ratatui/mod.rs delete mode 100644 src/ratatui/style.rs delete mode 100644 src/ratatui/symbols.rs delete mode 100644 src/ratatui/terminal.rs delete mode 100644 src/ratatui/text.rs delete mode 100644 src/ratatui/widgets/barchart.rs delete mode 100644 src/ratatui/widgets/block.rs delete mode 100644 src/ratatui/widgets/canvas/line.rs delete mode 100644 src/ratatui/widgets/canvas/map.rs delete mode 100644 src/ratatui/widgets/canvas/mod.rs delete mode 100644 src/ratatui/widgets/canvas/points.rs delete mode 100644 src/ratatui/widgets/canvas/rectangle.rs delete mode 100644 src/ratatui/widgets/canvas/world.rs delete mode 100644 src/ratatui/widgets/chart.rs delete mode 100644 src/ratatui/widgets/clear.rs delete mode 100644 src/ratatui/widgets/gauge.rs delete mode 100644 src/ratatui/widgets/list.rs delete mode 100644 src/ratatui/widgets/mod.rs delete mode 100644 src/ratatui/widgets/paragraph.rs delete mode 100644 src/ratatui/widgets/reflow.rs delete mode 100644 src/ratatui/widgets/sparkline.rs delete mode 100644 src/ratatui/widgets/table.rs delete mode 100644 src/ratatui/widgets/tabs.rs delete mode 100644 src/shell/atuin.bash delete mode 100644 src/shell/atuin.fish delete mode 100644 src/shell/atuin.nu delete mode 100644 src/shell/atuin.zsh (limited to 'src') diff --git a/src/command/client.rs b/src/command/client.rs deleted file mode 100644 index 2a825638..00000000 --- a/src/command/client.rs +++ /dev/null @@ -1,61 +0,0 @@ -use std::path::PathBuf; - -use clap::Subcommand; -use eyre::{Result, WrapErr}; - -use atuin_client::{database::Sqlite, settings::Settings}; -use env_logger::Builder; - -#[cfg(feature = "sync")] -mod sync; - -mod history; -mod import; -mod search; -mod stats; - -#[derive(Subcommand)] -#[command(infer_subcommands = true)] -pub enum Cmd { - /// Manipulate shell history - #[command(subcommand)] - History(history::Cmd), - - /// Import shell history from file - #[command(subcommand)] - Import(import::Cmd), - - /// Calculate statistics for your history - Stats(stats::Cmd), - - /// Interactive history search - Search(search::Cmd), - - #[cfg(feature = "sync")] - #[command(flatten)] - Sync(sync::Cmd), -} - -impl Cmd { - #[tokio::main(flavor = "current_thread")] - pub async fn run(self) -> Result<()> { - Builder::new() - .filter_level(log::LevelFilter::Off) - .parse_env("ATUIN_LOG") - .init(); - - let mut settings = Settings::new().wrap_err("could not load client settings")?; - - let db_path = PathBuf::from(settings.db_path.as_str()); - let mut db = Sqlite::new(db_path).await?; - - match self { - Self::History(history) => history.run(&settings, &mut db).await, - Self::Import(import) => import.run(&mut db).await, - Self::Stats(stats) => stats.run(&mut db, &settings).await, - Self::Search(search) => search.run(db, &mut settings).await, - #[cfg(feature = "sync")] - Self::Sync(sync) => sync.run(settings, &mut db).await, - } - } -} diff --git a/src/command/client/history.rs b/src/command/client/history.rs deleted file mode 100644 index 76c796ef..00000000 --- a/src/command/client/history.rs +++ /dev/null @@ -1,298 +0,0 @@ -use std::{ - env, - fmt::{self, Display}, - io::{StdoutLock, Write}, - time::Duration, -}; - -use atuin_common::utils; -use clap::Subcommand; -use eyre::Result; -use runtime_format::{FormatKey, FormatKeyError, ParsedFmt}; - -use atuin_client::{ - database::{current_context, Database}, - history::History, - settings::Settings, -}; - -#[cfg(feature = "sync")] -use atuin_client::sync; -use log::debug; - -use super::search::format_duration; -use super::search::format_duration_into; - -#[derive(Subcommand)] -#[command(infer_subcommands = true)] -pub enum Cmd { - /// Begins a new command in the history - Start { command: Vec }, - - /// Finishes a new command in the history (adds time, exit code) - End { - id: String, - #[arg(long, short)] - exit: i64, - }, - - /// List all items in history - List { - #[arg(long, short)] - cwd: bool, - - #[arg(long, short)] - session: bool, - - #[arg(long)] - human: bool, - - /// Show only the text of the command - #[arg(long)] - cmd_only: bool, - - /// Available variables: {command}, {directory}, {duration}, {user}, {host} and {time}. - /// Example: --format "{time} - [{duration}] - {directory}$\t{command}" - #[arg(long, short)] - format: Option, - }, - - /// Get the last command ran - Last { - #[arg(long)] - human: bool, - - /// Show only the text of the command - #[arg(long)] - cmd_only: bool, - - /// Available variables: {command}, {directory}, {duration}, {user}, {host} and {time}. - /// Example: --format "{time} - [{duration}] - {directory}$\t{command}" - #[arg(long, short)] - format: Option, - }, -} - -#[derive(Clone, Copy, Debug)] -pub enum ListMode { - Human, - CmdOnly, - Regular, -} - -impl ListMode { - pub const fn from_flags(human: bool, cmd_only: bool) -> Self { - if human { - ListMode::Human - } else if cmd_only { - ListMode::CmdOnly - } else { - ListMode::Regular - } - } -} - -#[allow(clippy::cast_sign_loss)] -pub fn print_list(h: &[History], list_mode: ListMode, format: Option<&str>) { - let w = std::io::stdout(); - let mut w = w.lock(); - - match list_mode { - ListMode::Human => print_human_list(&mut w, h, format), - ListMode::CmdOnly => print_cmd_only(&mut w, h), - ListMode::Regular => print_regular(&mut w, h, format), - } - - w.flush().expect("failed to flush history"); -} - -/// type wrapper around `History` so we can implement traits -struct FmtHistory<'a>(&'a History); - -/// defines how to format the history -impl FormatKey for FmtHistory<'_> { - #[allow(clippy::cast_sign_loss)] - fn fmt(&self, key: &str, f: &mut fmt::Formatter<'_>) -> Result<(), FormatKeyError> { - match key { - "command" => f.write_str(self.0.command.trim())?, - "directory" => f.write_str(self.0.cwd.trim())?, - "exit" => f.write_str(&self.0.exit.to_string())?, - "duration" => { - let dur = Duration::from_nanos(std::cmp::max(self.0.duration, 0) as u64); - format_duration_into(dur, f)?; - } - "time" => self.0.timestamp.format("%Y-%m-%d %H:%M:%S").fmt(f)?, - "relativetime" => { - let since = chrono::Utc::now() - self.0.timestamp; - let time = format_duration(since.to_std().unwrap_or_default()); - f.write_str(&time)?; - } - "host" => f.write_str( - self.0 - .hostname - .split_once(':') - .map_or(&self.0.hostname, |(host, _)| host), - )?, - "user" => f.write_str(self.0.hostname.split_once(':').map_or("", |(_, user)| user))?, - _ => return Err(FormatKeyError::UnknownKey), - } - Ok(()) - } -} - -fn print_list_with(w: &mut StdoutLock, h: &[History], format: &str) { - let fmt = match ParsedFmt::new(format) { - Ok(fmt) => fmt, - Err(err) => { - eprintln!("ERROR: History formatting failed with the following error: {err}"); - println!("If your formatting string contains curly braces (eg: {{var}}) you need to escape them this way: {{{{var}}."); - std::process::exit(1) - } - }; - - for h in h.iter().rev() { - writeln!(w, "{}", fmt.with_args(&FmtHistory(h))).expect("failed to write history"); - } -} - -pub fn print_human_list(w: &mut StdoutLock, h: &[History], format: Option<&str>) { - let format = format - .unwrap_or("{time} · {duration}\t{command}") - .replace("\\t", "\t"); - print_list_with(w, h, &format); -} - -pub fn print_regular(w: &mut StdoutLock, h: &[History], format: Option<&str>) { - let format = format - .unwrap_or("{time}\t{command}\t{duration}") - .replace("\\t", "\t"); - print_list_with(w, h, &format); -} - -pub fn print_cmd_only(w: &mut StdoutLock, h: &[History]) { - for h in h.iter().rev() { - writeln!(w, "{}", h.command.trim()).expect("failed to write history"); - } -} - -impl Cmd { - pub async fn run(&self, settings: &Settings, db: &mut impl Database) -> Result<()> { - let context = current_context(); - - match self { - Self::Start { command: words } => { - let command = words.join(" "); - - if command.starts_with(' ') || settings.history_filter.is_match(&command) { - return Ok(()); - } - - // It's better for atuin to silently fail here and attempt to - // store whatever is ran, than to throw an error to the terminal - let cwd = utils::get_current_dir(); - - let h = History::new(chrono::Utc::now(), command, cwd, -1, -1, None, None, None); - - // print the ID - // we use this as the key for calling end - println!("{}", h.id); - db.save(&h).await?; - Ok(()) - } - - Self::End { id, exit } => { - if id.trim() == "" { - return Ok(()); - } - - let mut h = db.load(id).await?; - - if h.duration > 0 { - debug!("cannot end history - already has duration"); - - // returning OK as this can occur if someone Ctrl-c a prompt - return Ok(()); - } - - h.exit = *exit; - h.duration = chrono::Utc::now().timestamp_nanos() - h.timestamp.timestamp_nanos(); - - db.update(&h).await?; - - if settings.should_sync()? { - #[cfg(feature = "sync")] - { - debug!("running periodic background sync"); - sync::sync(settings, false, db).await?; - } - #[cfg(not(feature = "sync"))] - debug!("not compiled with sync support"); - } else { - debug!("sync disabled! not syncing"); - } - - Ok(()) - } - - Self::List { - session, - cwd, - human, - cmd_only, - format, - } => { - let session = if *session { - Some(env::var("ATUIN_SESSION")?) - } else { - None - }; - let cwd = if *cwd { - Some(utils::get_current_dir()) - } else { - None - }; - - let history = match (session, cwd) { - (None, None) => db.list(settings.filter_mode, &context, None, false).await?, - (None, Some(cwd)) => { - let query = format!("select * from history where cwd = '{cwd}';"); - db.query_history(&query).await? - } - (Some(session), None) => { - let query = format!("select * from history where session = '{session}';"); - db.query_history(&query).await? - } - (Some(session), Some(cwd)) => { - let query = format!( - "select * from history where cwd = '{cwd}' and session = '{session}';", - ); - db.query_history(&query).await? - } - }; - - print_list( - &history, - ListMode::from_flags(*human, *cmd_only), - format.as_deref(), - ); - - Ok(()) - } - - Self::Last { - human, - cmd_only, - format, - } => { - let last = db.last().await?; - print_list( - &[last], - ListMode::from_flags(*human, *cmd_only), - format.as_deref(), - ); - - Ok(()) - } - } - } -} diff --git a/src/command/client/import.rs b/src/command/client/import.rs deleted file mode 100644 index 7abc3d44..00000000 --- a/src/command/client/import.rs +++ /dev/null @@ -1,152 +0,0 @@ -use std::env; - -use async_trait::async_trait; -use clap::Parser; -use eyre::Result; -use indicatif::ProgressBar; - -use atuin_client::{ - database::Database, - history::History, - import::{ - bash::Bash, fish::Fish, nu::Nu, nu_histdb::NuHistDb, resh::Resh, zsh::Zsh, - zsh_histdb::ZshHistDb, Importer, Loader, - }, -}; - -#[derive(Parser)] -#[command(infer_subcommands = true)] -pub enum Cmd { - /// Import history for the current shell - Auto, - - /// Import history from the zsh history file - Zsh, - /// Import history from the zsh history file - ZshHistDb, - /// Import history from the bash history file - Bash, - /// Import history from the resh history file - Resh, - /// Import history from the fish history file - Fish, - /// Import history from the nu history file - Nu, - /// Import history from the nu history file - NuHistDb, -} - -const BATCH_SIZE: usize = 100; - -impl Cmd { - pub async fn run(&self, db: &mut DB) -> Result<()> { - println!(" Atuin "); - println!("======================"); - println!(" \u{1f30d} "); - println!(" \u{1f418}\u{1f418}\u{1f418}\u{1f418} "); - println!(" \u{1f422} "); - println!("======================"); - println!("Importing history..."); - - match self { - Self::Auto => { - if cfg!(windows) { - println!("This feature does not work on windows. Please run atuin import . To view a list of shells, run atuin import."); - return Ok(()); - } - - let shell = env::var("SHELL").unwrap_or_else(|_| String::from("NO_SHELL")); - if shell.ends_with("/zsh") { - if ZshHistDb::histpath().is_ok() { - println!( - "Detected Zsh-HistDb, using :{}", - ZshHistDb::histpath().unwrap().to_str().unwrap() - ); - import::(db).await - } else { - println!("Detected ZSH"); - import::(db).await - } - } else if shell.ends_with("/fish") { - println!("Detected Fish"); - import::(db).await - } else if shell.ends_with("/bash") { - println!("Detected Bash"); - import::(db).await - } else if shell.ends_with("/nu") { - if NuHistDb::histpath().is_ok() { - println!( - "Detected Nu-HistDb, using :{}", - NuHistDb::histpath().unwrap().to_str().unwrap() - ); - import::(db).await - } else { - println!("Detected Nushell"); - import::(db).await - } - } else { - println!("cannot import {shell} history"); - Ok(()) - } - } - - Self::Zsh => import::(db).await, - Self::ZshHistDb => import::(db).await, - Self::Bash => import::(db).await, - Self::Resh => import::(db).await, - Self::Fish => import::(db).await, - Self::Nu => import::(db).await, - Self::NuHistDb => import::(db).await, - } - } -} - -pub struct HistoryImporter<'db, DB: Database> { - pb: ProgressBar, - buf: Vec, - db: &'db mut DB, -} - -impl<'db, DB: Database> HistoryImporter<'db, DB> { - fn new(db: &'db mut DB, len: usize) -> Self { - Self { - pb: ProgressBar::new(len as u64), - buf: Vec::with_capacity(BATCH_SIZE), - db, - } - } - - async fn flush(self) -> Result<()> { - if !self.buf.is_empty() { - self.db.save_bulk(&self.buf).await?; - } - self.pb.finish(); - Ok(()) - } -} - -#[async_trait] -impl<'db, DB: Database> Loader for HistoryImporter<'db, DB> { - async fn push(&mut self, hist: History) -> Result<()> { - self.pb.inc(1); - self.buf.push(hist); - if self.buf.len() == self.buf.capacity() { - self.db.save_bulk(&self.buf).await?; - self.buf.clear(); - } - Ok(()) - } -} - -async fn import(db: &mut DB) -> Result<()> { - println!("Importing history from {}", I::NAME); - - let mut importer = I::new().await?; - let len = importer.entries().await.unwrap(); - let mut loader = HistoryImporter::new(db, len); - importer.load(&mut loader).await?; - loader.flush().await?; - - println!("Import complete!"); - Ok(()) -} diff --git a/src/command/client/search.rs b/src/command/client/search.rs deleted file mode 100644 index 356ae251..00000000 --- a/src/command/client/search.rs +++ /dev/null @@ -1,189 +0,0 @@ -use atuin_common::utils; -use clap::Parser; -use eyre::Result; - -use atuin_client::{ - database::Database, - database::{current_context, OptFilters}, - history::History, - settings::{FilterMode, SearchMode, Settings}, -}; - -use super::history::ListMode; - -mod cursor; -mod duration; -mod engines; -mod history_list; -mod interactive; -pub use duration::{format_duration, format_duration_into}; - -#[allow(clippy::struct_excessive_bools)] -#[derive(Parser)] -pub struct Cmd { - /// Filter search result by directory - #[arg(long, short)] - cwd: Option, - - /// Exclude directory from results - #[arg(long = "exclude-cwd")] - exclude_cwd: Option, - - /// Filter search result by exit code - #[arg(long, short)] - exit: Option, - - /// Exclude results with this exit code - #[arg(long = "exclude-exit")] - exclude_exit: Option, - - /// Only include results added before this date - #[arg(long, short)] - before: Option, - - /// Only include results after this date - #[arg(long)] - after: Option, - - /// How many entries to return at most - #[arg(long)] - limit: Option, - - /// Offset from the start of the results - #[arg(long)] - offset: Option, - - /// Open interactive search UI - #[arg(long, short)] - interactive: bool, - - /// Allow overriding filter mode over config - #[arg(long = "filter-mode")] - filter_mode: Option, - - /// Allow overriding search mode over config - #[arg(long = "search-mode")] - search_mode: Option, - - /// Marker argument used to inform atuin that it was invoked from a shell up-key binding (hidden from help to avoid confusion) - #[arg(long = "shell-up-key-binding", hide = true)] - shell_up_key_binding: bool, - - /// Use human-readable formatting for time - #[arg(long)] - human: bool, - - query: Vec, - - /// Show only the text of the command - #[arg(long)] - cmd_only: bool, - - /// Delete anything matching this query. Will not print out the match - #[arg(long)] - delete: bool, - - /// Reverse the order of results, oldest first - #[arg(long, short)] - reverse: bool, - - /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {time}, {exit} and - /// {relativetime}. - /// Example: --format "{time} - [{duration}] - {directory}$\t{command}" - #[arg(long, short)] - format: Option, -} - -impl Cmd { - pub async fn run(self, mut db: impl Database, settings: &mut Settings) -> Result<()> { - if self.search_mode.is_some() { - settings.search_mode = self.search_mode.unwrap(); - } - if self.filter_mode.is_some() { - settings.filter_mode = self.filter_mode.unwrap(); - } - - settings.shell_up_key_binding = self.shell_up_key_binding; - - if self.interactive { - let item = interactive::history(&self.query, settings, db).await?; - eprintln!("{item}"); - } else { - let list_mode = ListMode::from_flags(self.human, self.cmd_only); - - let opt_filter = OptFilters { - exit: self.exit, - exclude_exit: self.exclude_exit, - cwd: self.cwd, - exclude_cwd: self.exclude_cwd, - before: self.before, - after: self.after, - limit: self.limit, - offset: self.offset, - reverse: self.reverse, - }; - - let mut entries = - run_non_interactive(settings, opt_filter.clone(), &self.query, &mut db).await?; - - if entries.is_empty() { - std::process::exit(1) - } - - // if we aren't deleting, print it all - if self.delete { - // delete it - // it only took me _years_ to add this - // sorry - while !entries.is_empty() { - for entry in &entries { - eprintln!("deleting {}", entry.id); - db.delete(entry.clone()).await?; - } - - entries = - run_non_interactive(settings, opt_filter.clone(), &self.query, &mut db) - .await?; - } - } else { - super::history::print_list(&entries, list_mode, self.format.as_deref()); - } - }; - Ok(()) - } -} - -// This is supposed to more-or-less mirror the command line version, so ofc -// it is going to have a lot of args -#[allow(clippy::too_many_arguments)] -async fn run_non_interactive( - settings: &Settings, - filter_options: OptFilters, - query: &[String], - db: &mut impl Database, -) -> Result> { - let dir = if filter_options.cwd.as_deref() == Some(".") { - Some(utils::get_current_dir()) - } else { - filter_options.cwd - }; - - let context = current_context(); - - let opt_filter = OptFilters { - cwd: dir, - ..filter_options - }; - - let results = db - .search( - settings.search_mode, - settings.filter_mode, - &context, - query.join(" ").as_str(), - opt_filter, - ) - .await?; - - Ok(results) -} diff --git a/src/command/client/search/cursor.rs b/src/command/client/search/cursor.rs deleted file mode 100644 index 2bce4f37..00000000 --- a/src/command/client/search/cursor.rs +++ /dev/null @@ -1,333 +0,0 @@ -use atuin_client::settings::WordJumpMode; - -pub struct Cursor { - source: String, - index: usize, -} - -impl From for Cursor { - fn from(source: String) -> Self { - Self { source, index: 0 } - } -} - -pub struct WordJumper<'a> { - word_chars: &'a str, - word_jump_mode: WordJumpMode, -} - -impl WordJumper<'_> { - fn is_word_boundary(&self, c: char, next_c: char) -> bool { - (c.is_whitespace() && !next_c.is_whitespace()) - || (!c.is_whitespace() && next_c.is_whitespace()) - || (self.word_chars.contains(c) && !self.word_chars.contains(next_c)) - || (!self.word_chars.contains(c) && self.word_chars.contains(next_c)) - } - - fn emacs_get_next_word_pos(&self, source: &str, index: usize) -> usize { - let index = (index + 1..source.len().saturating_sub(1)) - .find(|&i| self.word_chars.contains(source.chars().nth(i).unwrap())) - .unwrap_or(source.len()); - (index + 1..source.len().saturating_sub(1)) - .find(|&i| !self.word_chars.contains(source.chars().nth(i).unwrap())) - .unwrap_or(source.len()) - } - - fn emacs_get_prev_word_pos(&self, source: &str, index: usize) -> usize { - let index = (1..index) - .rev() - .find(|&i| self.word_chars.contains(source.chars().nth(i).unwrap())) - .unwrap_or(0); - (1..index) - .rev() - .find(|&i| !self.word_chars.contains(source.chars().nth(i).unwrap())) - .map_or(0, |i| i + 1) - } - - fn subl_get_next_word_pos(&self, source: &str, index: usize) -> usize { - let index = (index..source.len().saturating_sub(1)).find(|&i| { - self.is_word_boundary( - source.chars().nth(i).unwrap(), - source.chars().nth(i + 1).unwrap(), - ) - }); - if index.is_none() { - return source.len(); - } - (index.unwrap() + 1..source.len()) - .find(|&i| !source.chars().nth(i).unwrap().is_whitespace()) - .unwrap_or(source.len()) - } - - fn subl_get_prev_word_pos(&self, source: &str, index: usize) -> usize { - let index = (1..index) - .rev() - .find(|&i| !source.chars().nth(i).unwrap().is_whitespace()); - if index.is_none() { - return 0; - } - (1..index.unwrap()) - .rev() - .find(|&i| { - self.is_word_boundary( - source.chars().nth(i - 1).unwrap(), - source.chars().nth(i).unwrap(), - ) - }) - .unwrap_or(0) - } - - fn get_next_word_pos(&self, source: &str, index: usize) -> usize { - match self.word_jump_mode { - WordJumpMode::Emacs => self.emacs_get_next_word_pos(source, index), - WordJumpMode::Subl => self.subl_get_next_word_pos(source, index), - } - } - - fn get_prev_word_pos(&self, source: &str, index: usize) -> usize { - match self.word_jump_mode { - WordJumpMode::Emacs => self.emacs_get_prev_word_pos(source, index), - WordJumpMode::Subl => self.subl_get_prev_word_pos(source, index), - } - } -} - -impl Cursor { - pub fn as_str(&self) -> &str { - self.source.as_str() - } - - pub fn into_inner(self) -> String { - self.source - } - - /// Returns the string before the cursor - pub fn substring(&self) -> &str { - &self.source[..self.index] - } - - /// Returns the currently selected [`char`] - pub fn char(&self) -> Option { - self.source[self.index..].chars().next() - } - - pub fn right(&mut self) { - if self.index < self.source.len() { - loop { - self.index += 1; - if self.source.is_char_boundary(self.index) { - break; - } - } - } - } - - pub fn left(&mut self) -> bool { - if self.index > 0 { - loop { - self.index -= 1; - if self.source.is_char_boundary(self.index) { - break true; - } - } - } else { - false - } - } - - pub fn next_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) { - let word_jumper = WordJumper { - word_chars, - word_jump_mode, - }; - self.index = word_jumper.get_next_word_pos(&self.source, self.index); - } - - pub fn prev_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) { - let word_jumper = WordJumper { - word_chars, - word_jump_mode, - }; - self.index = word_jumper.get_prev_word_pos(&self.source, self.index); - } - - pub fn insert(&mut self, c: char) { - self.source.insert(self.index, c); - self.index += c.len_utf8(); - } - - pub fn remove(&mut self) -> Option { - if self.index < self.source.len() { - Some(self.source.remove(self.index)) - } else { - None - } - } - - pub fn remove_next_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) { - let word_jumper = WordJumper { - word_chars, - word_jump_mode, - }; - let next_index = word_jumper.get_next_word_pos(&self.source, self.index); - self.source.replace_range(self.index..next_index, ""); - } - - pub fn remove_prev_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) { - let word_jumper = WordJumper { - word_chars, - word_jump_mode, - }; - let next_index = word_jumper.get_prev_word_pos(&self.source, self.index); - self.source.replace_range(next_index..self.index, ""); - self.index = next_index; - } - - pub fn back(&mut self) -> Option { - if self.left() { - self.remove() - } else { - None - } - } - - pub fn clear(&mut self) { - self.source.clear(); - self.index = 0; - } - - pub fn end(&mut self) { - self.index = self.source.len(); - } - - pub fn start(&mut self) { - self.index = 0; - } -} - -#[cfg(test)] -mod cursor_tests { - use super::Cursor; - use super::*; - - static EMACS_WORD_JUMPER: WordJumper = WordJumper { - word_chars: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", - word_jump_mode: WordJumpMode::Emacs, - }; - - static SUBL_WORD_JUMPER: WordJumper = WordJumper { - word_chars: "./\\()\"'-:,.;<>~!@#$%^&*|+=[]{}`~?", - word_jump_mode: WordJumpMode::Subl, - }; - - #[test] - fn right() { - // ö is 2 bytes - let mut c = Cursor::from(String::from("öaöböcödöeöfö")); - let indices = [0, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18, 20, 20, 20, 20]; - for i in indices { - assert_eq!(c.index, i); - c.right(); - } - } - - #[test] - fn left() { - // ö is 2 bytes - let mut c = Cursor::from(String::from("öaöböcödöeöfö")); - c.end(); - let indices = [20, 18, 17, 15, 14, 12, 11, 9, 8, 6, 5, 3, 2, 0, 0, 0, 0]; - for i in indices { - assert_eq!(c.index, i); - c.left(); - } - } - - #[test] - fn test_emacs_get_next_word_pos() { - let s = String::from(" aaa ((()))bbb ((())) "); - let indices = [(0, 6), (3, 6), (7, 18), (19, 30)]; - for (i_src, i_dest) in indices { - assert_eq!(EMACS_WORD_JUMPER.get_next_word_pos(&s, i_src), i_dest); - } - assert_eq!(EMACS_WORD_JUMPER.get_next_word_pos("", 0), 0); - } - - #[test] - fn test_emacs_get_prev_word_pos() { - let s = String::from(" aaa ((()))bbb ((())) "); - let indices = [(30, 15), (29, 15), (15, 3), (3, 0)]; - for (i_src, i_dest) in indices { - assert_eq!(EMACS_WORD_JUMPER.get_prev_word_pos(&s, i_src), i_dest); - } - assert_eq!(EMACS_WORD_JUMPER.get_prev_word_pos("", 0), 0); - } - - #[test] - fn test_subl_get_next_word_pos() { - let s = String::from(" aaa ((()))bbb ((())) "); - let indices = [(0, 3), (1, 3), (3, 9), (9, 15), (15, 21), (21, 30)]; - for (i_src, i_dest) in indices { - assert_eq!(SUBL_WORD_JUMPER.get_next_word_pos(&s, i_src), i_dest); - } - assert_eq!(SUBL_WORD_JUMPER.get_next_word_pos("", 0), 0); - } - - #[test] - fn test_subl_get_prev_word_pos() { - let s = String::from(" aaa ((()))bbb ((())) "); - let indices = [(30, 21), (21, 15), (15, 9), (9, 3), (3, 0)]; - for (i_src, i_dest) in indices { - assert_eq!(SUBL_WORD_JUMPER.get_prev_word_pos(&s, i_src), i_dest); - } - assert_eq!(SUBL_WORD_JUMPER.get_prev_word_pos("", 0), 0); - } - - #[test] - fn pop() { - let mut s = String::from("öaöböcödöeöfö"); - let mut c = Cursor::from(s.clone()); - c.end(); - while !s.is_empty() { - let c1 = s.pop(); - let c2 = c.back(); - assert_eq!(c1, c2); - assert_eq!(s.as_str(), c.substring()); - } - let c1 = s.pop(); - let c2 = c.back(); - assert_eq!(c1, c2); - } - - #[test] - fn back() { - let mut c = Cursor::from(String::from("öaöböcödöeöfö")); - // move to ^ - for _ in 0..4 { - c.right(); - } - assert_eq!(c.substring(), "öaöb"); - assert_eq!(c.back(), Some('b')); - assert_eq!(c.back(), Some('ö')); - assert_eq!(c.back(), Some('a')); - assert_eq!(c.back(), Some('ö')); - assert_eq!(c.back(), None); - assert_eq!(c.as_str(), "öcödöeöfö"); - } - - #[test] - fn insert() { - let mut c = Cursor::from(String::from("öaöböcödöeöfö")); - // move to ^ - for _ in 0..4 { - c.right(); - } - assert_eq!(c.substring(), "öaöb"); - c.insert('ö'); - c.insert('g'); - c.insert('ö'); - c.insert('h'); - assert_eq!(c.substring(), "öaöbögöh"); - assert_eq!(c.as_str(), "öaöbögöhöcödöeöfö"); - } -} diff --git a/src/command/client/search/duration.rs b/src/command/client/search/duration.rs deleted file mode 100644 index 08dadb95..00000000 --- a/src/command/client/search/duration.rs +++ /dev/null @@ -1,62 +0,0 @@ -use core::fmt; -use std::{ops::ControlFlow, time::Duration}; - -#[allow(clippy::module_name_repetitions)] -pub fn format_duration_into(dur: Duration, f: &mut fmt::Formatter<'_>) -> fmt::Result { - fn item(unit: &'static str, value: u64) -> ControlFlow<(&'static str, u64)> { - if value > 0 { - ControlFlow::Break((unit, value)) - } else { - ControlFlow::Continue(()) - } - } - - // impl taken and modified from - // https://github.com/tailhook/humantime/blob/master/src/duration.rs#L295-L331 - // Copyright (c) 2016 The humantime Developers - fn fmt(f: Duration) -> ControlFlow<(&'static str, u64), ()> { - let secs = f.as_secs(); - let nanos = f.subsec_nanos(); - - let years = secs / 31_557_600; // 365.25d - let year_days = secs % 31_557_600; - let months = year_days / 2_630_016; // 30.44d - let month_days = year_days % 2_630_016; - let days = month_days / 86400; - let day_secs = month_days % 86400; - let hours = day_secs / 3600; - let minutes = day_secs % 3600 / 60; - let seconds = day_secs % 60; - - let millis = nanos / 1_000_000; - - // a difference from our impl than the original is that - // we only care about the most-significant segment of the duration. - // If the item call returns `Break`, then the `?` will early-return. - // This allows for a very consise impl - item("y", years)?; - item("mo", months)?; - item("d", days)?; - item("h", hours)?; - item("m", minutes)?; - item("s", seconds)?; - item("ms", u64::from(millis))?; - ControlFlow::Continue(()) - } - - match fmt(dur) { - ControlFlow::Break((unit, value)) => write!(f, "{value}{unit}"), - ControlFlow::Continue(()) => write!(f, "0s"), - } -} - -#[allow(clippy::module_name_repetitions)] -pub fn format_duration(f: Duration) -> String { - struct F(Duration); - impl fmt::Display for F { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - format_duration_into(self.0, f) - } - } - F(f).to_string() -} diff --git a/src/command/client/search/engines.rs b/src/command/client/search/engines.rs deleted file mode 100644 index 878b1431..00000000 --- a/src/command/client/search/engines.rs +++ /dev/null @@ -1,46 +0,0 @@ -use async_trait::async_trait; -use atuin_client::{ - database::{Context, Database}, - history::History, - settings::{FilterMode, SearchMode}, -}; -use eyre::Result; - -use super::cursor::Cursor; - -pub mod db; -pub mod skim; - -pub fn engine(search_mode: SearchMode) -> Box { - match search_mode { - SearchMode::Skim => Box::new(skim::Search::new()) as Box<_>, - mode => Box::new(db::Search(mode)) as Box<_>, - } -} - -pub struct SearchState { - pub input: Cursor, - pub filter_mode: FilterMode, - pub context: Context, -} - -#[async_trait] -pub trait SearchEngine: Send + Sync + 'static { - async fn full_query( - &mut self, - state: &SearchState, - db: &mut dyn Database, - ) -> Result>; - - async fn query(&mut self, state: &SearchState, db: &mut dyn Database) -> Result> { - if state.input.as_str().is_empty() { - Ok(db - .list(state.filter_mode, &state.context, Some(200), true) - .await? - .into_iter() - .collect::>()) - } else { - self.full_query(state, db).await - } - } -} diff --git a/src/command/client/search/engines/db.rs b/src/command/client/search/engines/db.rs deleted file mode 100644 index b4f24561..00000000 --- a/src/command/client/search/engines/db.rs +++ /dev/null @@ -1,33 +0,0 @@ -use async_trait::async_trait; -use atuin_client::{ - database::Database, database::OptFilters, history::History, settings::SearchMode, -}; -use eyre::Result; - -use super::{SearchEngine, SearchState}; - -pub struct Search(pub SearchMode); - -#[async_trait] -impl SearchEngine for Search { - async fn full_query( - &mut self, - state: &SearchState, - db: &mut dyn Database, - ) -> Result> { - Ok(db - .search( - self.0, - state.filter_mode, - &state.context, - state.input.as_str(), - OptFilters { - limit: Some(200), - ..Default::default() - }, - ) - .await? - .into_iter() - .collect::>()) - } -} diff --git a/src/command/client/search/engines/skim.rs b/src/command/client/search/engines/skim.rs deleted file mode 100644 index 76049312..00000000 --- a/src/command/client/search/engines/skim.rs +++ /dev/null @@ -1,145 +0,0 @@ -use std::path::Path; - -use async_trait::async_trait; -use atuin_client::{database::Database, history::History, settings::FilterMode}; -use chrono::Utc; -use eyre::Result; -use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher}; -use tokio::task::yield_now; - -use super::{SearchEngine, SearchState}; - -pub struct Search { - all_history: Vec<(History, i32)>, - engine: SkimMatcherV2, -} - -impl Search { - pub fn new() -> Self { - Search { - all_history: vec![], - engine: SkimMatcherV2::default(), - } - } -} - -#[async_trait] -impl SearchEngine for Search { - async fn full_query( - &mut self, - state: &SearchState, - db: &mut dyn Database, - ) -> Result> { - if self.all_history.is_empty() { - self.all_history = db.all_with_count().await.unwrap(); - } - - Ok(fuzzy_search(&self.engine, state, &self.all_history).await) - } -} - -async fn fuzzy_search( - engine: &SkimMatcherV2, - state: &SearchState, - all_history: &[(History, i32)], -) -> Vec { - let mut set = Vec::with_capacity(200); - let mut ranks = Vec::with_capacity(200); - let query = state.input.as_str(); - let now = Utc::now(); - - for (i, (history, count)) in all_history.iter().enumerate() { - if i % 256 == 0 { - yield_now().await; - } - match state.filter_mode { - FilterMode::Global => {} - FilterMode::Host if history.hostname == state.context.hostname => {} - FilterMode::Session if history.session == state.context.session => {} - FilterMode::Directory if history.cwd == state.context.cwd => {} - _ => continue, - } - #[allow(clippy::cast_lossless, clippy::cast_precision_loss)] - if let Some((score, indices)) = engine.fuzzy_indices(&history.command, query) { - let begin = indices.first().copied().unwrap_or_default(); - - let mut duration = ((now - history.timestamp).num_seconds() as f64).log2(); - if !duration.is_finite() || duration <= 1.0 { - duration = 1.0; - } - // these + X.0 just make the log result a bit smoother. - // log is very spiky towards 1-4, but I want a gradual decay. - // eg: - // log2(4) = 2, log2(5) = 2.3 (16% increase) - // log2(8) = 3, log2(9) = 3.16 (5% increase) - // log2(16) = 4, log2(17) = 4.08 (2% increase) - let count = (*count as f64 + 8.0).log2(); - let begin = (begin as f64 + 16.0).log2(); - let path = path_dist(history.cwd.as_ref(), state.context.cwd.as_ref()); - let path = (path as f64 + 8.0).log2(); - - // reduce longer durations, raise higher counts, raise matches close to the start - let score = (-score as f64) * count / path / duration / begin; - - 'insert: { - // algorithm: - // 1. find either the position that this command ranks - // 2. find the same command positioned better than our rank. - for i in 0..set.len() { - // do we out score the corrent position? - if ranks[i] > score { - ranks.insert(i, score); - set.insert(i, history.clone()); - let mut j = i + 1; - while j < set.len() { - // remove duplicates that have a worse score - if set[j].command == history.command { - ranks.remove(j); - set.remove(j); - - // break this while loop because there won't be any other - // duplicates. - break; - } - j += 1; - } - - // keep it limited - if ranks.len() > 200 { - ranks.pop(); - set.pop(); - } - - break 'insert; - } - // don't continue if this command has a better score already - if set[i].command == history.command { - break 'insert; - } - } - - if set.len() < 200 { - ranks.push(score); - set.push(history.clone()); - } - } - } - } - - set -} - -fn path_dist(a: &Path, b: &Path) -> usize { - let mut a: Vec<_> = a.components().collect(); - let b: Vec<_> = b.components().collect(); - - let mut dist = 0; - - // pop a until there's a common anscestor - while !b.starts_with(&a) { - dist += 1; - a.pop(); - } - - b.len() - a.len() + dist -} diff --git a/src/command/client/search/history_list.rs b/src/command/client/search/history_list.rs deleted file mode 100644 index eedab1a5..00000000 --- a/src/command/client/search/history_list.rs +++ /dev/null @@ -1,183 +0,0 @@ -use std::time::Duration; - -use crate::ratatui::{ - buffer::Buffer, - layout::Rect, - style::{Color, Modifier, Style}, - widgets::{Block, StatefulWidget, Widget}, -}; -use atuin_client::history::History; - -use super::format_duration; - -pub struct HistoryList<'a> { - history: &'a [History], - block: Option>, -} - -#[derive(Default)] -pub struct ListState { - offset: usize, - selected: usize, - max_entries: usize, -} - -impl ListState { - pub fn selected(&self) -> usize { - self.selected - } - - pub fn max_entries(&self) -> usize { - self.max_entries - } - - pub fn select(&mut self, index: usize) { - self.selected = index; - } -} - -impl<'a> StatefulWidget for HistoryList<'a> { - type State = ListState; - - fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { - let list_area = self.block.take().map_or(area, |b| { - let inner_area = b.inner(area); - b.render(area, buf); - inner_area - }); - - if list_area.width < 1 || list_area.height < 1 || self.history.is_empty() { - return; - } - let list_height = list_area.height as usize; - - let (start, end) = self.get_items_bounds(state.selected, state.offset, list_height); - state.offset = start; - state.max_entries = end - start; - - let mut s = DrawState { - buf, - list_area, - x: 0, - y: 0, - state, - }; - - for item in self.history.iter().skip(state.offset).take(end - start) { - s.index(); - s.duration(item); - s.time(item); - s.command(item); - - // reset line - s.y += 1; - s.x = 0; - } - } -} - -impl<'a> HistoryList<'a> { - pub fn new(history: &'a [History]) -> Self { - Self { - history, - block: None, - } - } - - pub fn block(mut self, block: Block<'a>) -> Self { - self.block = Some(block); - self - } - - fn get_items_bounds(&self, selected: usize, offset: usize, height: usize) -> (usize, usize) { - let offset = offset.min(self.history.len().saturating_sub(1)); - - let max_scroll_space = height.min(10); - if offset + height < selected + max_scroll_space { - let end = selected + max_scroll_space; - (end - height, end) - } else if selected < offset { - (selected, selected + height) - } else { - (offset, offset + height) - } - } -} - -struct DrawState<'a> { - buf: &'a mut Buffer, - list_area: Rect, - x: u16, - y: u16, - state: &'a ListState, -} - -// longest line prefix I could come up with -#[allow(clippy::cast_possible_truncation)] // we know that this is <65536 length -pub const PREFIX_LENGTH: u16 = " > 123ms 59s ago".len() as u16; - -impl DrawState<'_> { - fn index(&mut self) { - // these encode the slices of `" > "`, `" {n} "`, or `" "` in a compact form. - // Yes, this is a hack, but it makes me feel happy - static SLICES: &str = " > 1 2 3 4 5 6 7 8 9 "; - - let i = self.y as usize + self.state.offset; - let i = i.checked_sub(self.state.selected); - let i = i.unwrap_or(10).min(10) * 2; - self.draw(&SLICES[i..i + 3], Style::default()); - } - - fn duration(&mut self, h: &History) { - let status = Style::default().fg(if h.success() { - Color::Green - } else { - Color::Red - }); - let duration = Duration::from_nanos(u64::try_from(h.duration).unwrap_or(0)); - self.draw(&format_duration(duration), status); - } - - #[allow(clippy::cast_possible_truncation)] // we know that time.len() will be <6 - fn time(&mut self, h: &History) { - let style = Style::default().fg(Color::Blue); - - // Account for the chance that h.timestamp is "in the future" - // This would mean that "since" is negative, and the unwrap here - // would fail. - // If the timestamp would otherwise be in the future, display - // the time since as 0. - let since = chrono::Utc::now() - h.timestamp; - let time = format_duration(since.to_std().unwrap_or_default()); - - // pad the time a little bit before we write. this aligns things nicely - self.x = PREFIX_LENGTH - 4 - time.len() as u16; - - self.draw(&time, style); - self.draw(" ago", style); - } - - fn command(&mut self, h: &History) { - let mut style = Style::default(); - if self.y as usize + self.state.offset == self.state.selected { - style = style.fg(Color::Red).add_modifier(Modifier::BOLD); - } - - for section in h.command.split_ascii_whitespace() { - self.x += 1; - if self.x > self.list_area.width { - // Avoid attempting to draw a command section beyond the width - // of the list - return; - } - self.draw(section, style); - } - } - - fn draw(&mut self, s: &str, style: Style) { - let cx = self.list_area.left() + self.x; - let cy = self.list_area.bottom() - self.y - 1; - let w = (self.list_area.width - self.x) as usize; - self.x += self.buf.set_stringn(cx, cy, s, w, style).0 - cx; - } -} diff --git a/src/command/client/search/interactive.rs b/src/command/client/search/interactive.rs deleted file mode 100644 index 300bc791..00000000 --- a/src/command/client/search/interactive.rs +++ /dev/null @@ -1,588 +0,0 @@ -use std::{ - io::{stdout, Write}, - time::Duration, -}; - -use crossterm::{ - event::{self, Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent}, - execute, terminal, -}; -use eyre::Result; -use futures_util::FutureExt; -use semver::Version; -use unicode_width::UnicodeWidthStr; - -use atuin_client::{ - database::{current_context, Database}, - history::History, - settings::{ExitMode, FilterMode, SearchMode, Settings}, -}; - -use super::{ - cursor::Cursor, - engines::{SearchEngine, SearchState}, - history_list::{HistoryList, ListState, PREFIX_LENGTH}, -}; -use crate::ratatui::{ - backend::{Backend, CrosstermBackend}, - layout::{Alignment, Constraint, Direction, Layout}, - style::{Color, Modifier, Style}, - text::{Span, Spans, Text}, - widgets::{Block, BorderType, Borders, Paragraph}, - Frame, Terminal, TerminalOptions, Viewport, -}; -use crate::{command::client::search::engines, VERSION}; - -const RETURN_ORIGINAL: usize = usize::MAX; -const RETURN_QUERY: usize = usize::MAX - 1; - -struct State { - history_count: i64, - update_needed: Option, - results_state: ListState, - switched_search_mode: bool, - search_mode: SearchMode, - - search: SearchState, - engine: Box, -} - -impl State { - async fn query_results(&mut self, db: &mut dyn Database) -> Result> { - let results = self.engine.query(&self.search, db).await?; - self.results_state.select(0); - Ok(results) - } - - 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), - Event::Paste(d) => self.handle_paste_input(d), - _ => 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_paste_input(&mut self, input: &str) -> Option { - for i in input.chars() { - self.search.input.insert(i); - } - None - } - - #[allow(clippy::too_many_lines)] - #[allow(clippy::cognitive_complexity)] - fn handle_key_input( - &mut self, - settings: &Settings, - input: &KeyEvent, - len: usize, - ) -> Option { - if input.kind == event::KeyEventKind::Release { - return None; - } - - let ctrl = input.modifiers.contains(KeyModifiers::CONTROL); - let alt = input.modifiers.contains(KeyModifiers::ALT); - // reset the state, will be set to true later if user really did change it - self.switched_search_mode = false; - 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, - }) - } - KeyCode::Enter => { - return Some(self.results_state.selected()); - } - KeyCode::Char(c @ '1'..='9') if alt => { - let c = c.to_digit(10)? as usize; - return Some(self.results_state.selected() + c); - } - KeyCode::Left if ctrl => self - .search - .input - .prev_word(&settings.word_chars, settings.word_jump_mode), - KeyCode::Char('b') if alt => self - .search - .input - .prev_word(&settings.word_chars, settings.word_jump_mode), - KeyCode::Left => { - self.search.input.left(); - } - KeyCode::Char('h') if ctrl => { - self.search.input.left(); - } - KeyCode::Char('b') if ctrl => { - self.search.input.left(); - } - KeyCode::Right if ctrl => self - .search - .input - .next_word(&settings.word_chars, settings.word_jump_mode), - KeyCode::Char('f') if alt => self - .search - .input - .next_word(&settings.word_chars, settings.word_jump_mode), - KeyCode::Right => self.search.input.right(), - KeyCode::Char('l') if ctrl => self.search.input.right(), - KeyCode::Char('f') if ctrl => self.search.input.right(), - KeyCode::Char('a') if ctrl => self.search.input.start(), - KeyCode::Home => self.search.input.start(), - KeyCode::Char('e') if ctrl => self.search.input.end(), - KeyCode::End => self.search.input.end(), - KeyCode::Backspace if ctrl => self - .search - .input - .remove_prev_word(&settings.word_chars, settings.word_jump_mode), - KeyCode::Backspace => { - self.search.input.back(); - } - KeyCode::Delete if ctrl => self - .search - .input - .remove_next_word(&settings.word_chars, settings.word_jump_mode), - KeyCode::Delete => { - self.search.input.remove(); - } - KeyCode::Char('w') if ctrl => { - // remove the first batch of whitespace - while matches!(self.search.input.back(), Some(c) if c.is_whitespace()) {} - while self.search.input.left() { - if self.search.input.char().unwrap().is_whitespace() { - self.search.input.right(); // found whitespace, go back right - break; - } - self.search.input.remove(); - } - } - KeyCode::Char('u') if ctrl => self.search.input.clear(), - KeyCode::Char('r') if ctrl => { - pub static FILTER_MODES: [FilterMode; 4] = [ - FilterMode::Global, - FilterMode::Host, - FilterMode::Session, - FilterMode::Directory, - ]; - let i = self.search.filter_mode as usize; - let i = (i + 1) % FILTER_MODES.len(); - self.search.filter_mode = FILTER_MODES[i]; - } - KeyCode::Char('s') if ctrl => { - self.switched_search_mode = true; - self.search_mode = self.search_mode.next(settings); - self.engine = engines::engine(self.search_mode); - } - KeyCode::Down if self.results_state.selected() == 0 => { - return Some(match settings.exit_mode { - ExitMode::ReturnOriginal => RETURN_ORIGINAL, - ExitMode::ReturnQuery => RETURN_QUERY, - }) - } - KeyCode::Down => { - let i = self.results_state.selected().saturating_sub(1); - self.results_state.select(i); - } - 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.search.input.insert(c), - KeyCode::PageDown => { - let scroll_len = self.results_state.max_entries() - settings.scroll_context_lines; - let i = self.results_state.selected().saturating_sub(scroll_len); - self.results_state.select(i); - } - KeyCode::PageUp => { - let scroll_len = self.results_state.max_entries() - settings.scroll_context_lines; - let i = self.results_state.selected() + scroll_len; - self.results_state.select(i.min(len - 1)); - } - _ => {} - }; - - None - } - - #[allow(clippy::cast_possible_truncation)] - #[allow(clippy::bool_to_int_with_if)] - fn draw( - &mut self, - f: &mut Frame<'_, T>, - results: &[History], - compact: bool, - show_preview: bool, - ) { - let border_size = if compact { 0 } else { 1 }; - let preview_width = f.size().width - 2; - let preview_height = if show_preview { - let longest_command = results - .iter() - .max_by(|h1, h2| h1.command.len().cmp(&h2.command.len())); - longest_command.map_or(0, |v| { - std::cmp::min( - 4, - (v.command.len() as u16 + preview_width - 1 - border_size) - / (preview_width - border_size), - ) - }) + border_size * 2 - } else if compact { - 0 - } else { - 1 - }; - let show_help = !compact || f.size().height > 1; - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(0) - .horizontal_margin(1) - .constraints( - [ - Constraint::Length(if show_help { 1 } else { 0 }), - Constraint::Min(1), - Constraint::Length(1 + border_size), - Constraint::Length(preview_height), - ] - .as_ref(), - ) - .split(f.size()); - - let header_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints( - [ - Constraint::Ratio(1, 3), - Constraint::Ratio(1, 3), - Constraint::Ratio(1, 3), - ] - .as_ref(), - ) - .split(chunks[0]); - - let title = self.build_title(); - f.render_widget(title, header_chunks[0]); - - let help = self.build_help(); - f.render_widget(help, header_chunks[1]); - - let stats = self.build_stats(); - f.render_widget(stats, header_chunks[2]); - - let results_list = Self::build_results_list(compact, results); - f.render_stateful_widget(results_list, chunks[1], &mut self.results_state); - - let input = self.build_input(compact, chunks[2].width.into()); - f.render_widget(input, chunks[2]); - - let preview = self.build_preview(results, compact, preview_width, chunks[3].width.into()); - f.render_widget(preview, chunks[3]); - - let extra_width = UnicodeWidthStr::width(self.search.input.substring()); - - let cursor_offset = if compact { 0 } else { 1 }; - f.set_cursor( - // Put cursor past the end of the input text - chunks[2].x + extra_width as u16 + PREFIX_LENGTH + 1 + cursor_offset, - chunks[2].y + cursor_offset, - ); - } - - fn build_title(&mut self) -> Paragraph { - let title = if self.update_needed.is_some() { - let version = self.update_needed.clone().unwrap(); - - Paragraph::new(Text::from(Span::styled( - format!(" Atuin v{VERSION} - UPDATE AVAILABLE {version}"), - Style::default().add_modifier(Modifier::BOLD).fg(Color::Red), - ))) - } else { - Paragraph::new(Text::from(Span::styled( - format!(" Atuin v{VERSION}"), - Style::default().add_modifier(Modifier::BOLD), - ))) - }; - title - } - - #[allow(clippy::unused_self)] - fn build_help(&mut self) -> Paragraph { - let help = Paragraph::new(Text::from(Spans::from(vec![ - Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(" to exit"), - ]))) - .style(Style::default().fg(Color::DarkGray)) - .alignment(Alignment::Center); - help - } - - fn build_stats(&mut self) -> Paragraph { - let stats = Paragraph::new(Text::from(Span::raw(format!( - "history count: {}", - self.history_count, - )))) - .style(Style::default().fg(Color::DarkGray)) - .alignment(Alignment::Right); - stats - } - - fn build_results_list(compact: bool, results: &[History]) -> HistoryList { - let results_list = if compact { - HistoryList::new(results) - } else { - HistoryList::new(results).block( - Block::default() - .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT) - .border_type(BorderType::Rounded), - ) - }; - results_list - } - - fn build_input(&mut self, compact: bool, chunk_width: usize) -> Paragraph { - /// Max width of the UI box showing current mode - const MAX_WIDTH: usize = 14; - let (pref, mode) = if self.switched_search_mode { - (" SRCH:", self.search_mode.as_str()) - } else { - ("", self.search.filter_mode.as_str()) - }; - let mode_width = MAX_WIDTH - pref.len(); - // sanity check to ensure we don't exceed the layout limits - debug_assert!(mode_width >= mode.len(), "mode name '{mode}' is too long!"); - let input = format!("[{pref}{mode:^mode_width$}] {}", self.search.input.as_str(),); - let input = if compact { - Paragraph::new(input) - } else { - Paragraph::new(input).block( - Block::default() - .borders(Borders::LEFT | Borders::RIGHT) - .border_type(BorderType::Rounded) - .title(format!("{:─>width$}", "", width = chunk_width - 2)), - ) - }; - input - } - - fn build_preview( - &mut self, - results: &[History], - compact: bool, - preview_width: u16, - chunk_width: usize, - ) -> Paragraph { - let selected = self.results_state.selected(); - let command = if results.is_empty() { - String::new() - } else { - use itertools::Itertools as _; - let s = &results[selected].command; - s.char_indices() - .step_by(preview_width.into()) - .map(|(i, _)| i) - .chain(Some(s.len())) - .tuple_windows() - .map(|(a, b)| &s[a..b]) - .join("\n") - }; - let preview = if compact { - Paragraph::new(command).style(Style::default().fg(Color::DarkGray)) - } else { - Paragraph::new(command).block( - Block::default() - .borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT) - .border_type(BorderType::Rounded) - .title(format!("{:─>width$}", "", width = chunk_width - 2)), - ) - }; - preview - } -} - -struct Stdout { - stdout: std::io::Stdout, - inline_mode: bool, -} - -impl Stdout { - pub fn new(inline_mode: bool) -> std::io::Result { - terminal::enable_raw_mode()?; - let mut stdout = stdout(); - if !inline_mode { - execute!(stdout, terminal::EnterAlternateScreen)?; - } - execute!( - stdout, - event::EnableMouseCapture, - event::EnableBracketedPaste, - )?; - Ok(Self { - stdout, - inline_mode, - }) - } -} - -impl Drop for Stdout { - fn drop(&mut self) { - if !self.inline_mode { - execute!(self.stdout, terminal::LeaveAlternateScreen).unwrap(); - } - execute!( - self.stdout, - event::DisableMouseCapture, - event::DisableBracketedPaste, - ) - .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 -#[allow(clippy::cast_possible_truncation)] -pub async fn history( - query: &[String], - settings: &Settings, - mut db: impl Database, -) -> Result { - let stdout = Stdout::new(settings.inline_height > 0)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::with_options( - backend, - TerminalOptions { - viewport: if settings.inline_height > 0 { - Viewport::Inline(settings.inline_height) - } else { - Viewport::Fullscreen - }, - }, - )?; - - let mut input = Cursor::from(query.join(" ")); - // Put the cursor at the end of the query by default - input.end(); - - let settings2 = settings.clone(); - let update_needed = tokio::spawn(async move { settings2.needs_update().await }).fuse(); - tokio::pin!(update_needed); - - let context = current_context(); - - let history_count = db.history_count().await?; - - let mut app = State { - history_count, - results_state: ListState::default(), - update_needed: None, - switched_search_mode: false, - search_mode: settings.search_mode, - search: SearchState { - input, - context, - filter_mode: if settings.shell_up_key_binding { - settings - .filter_mode_shell_up_key_binding - .unwrap_or(settings.filter_mode) - } else { - settings.filter_mode - }, - }, - engine: engines::engine(settings.search_mode), - }; - - let mut results = app.query_results(&mut db).await?; - - let index = 'render: loop { - let compact = match settings.style { - atuin_client::settings::Style::Auto => { - terminal.size().map(|size| size.height < 14).unwrap_or(true) - } - atuin_client::settings::Style::Compact => true, - atuin_client::settings::Style::Full => false, - }; - terminal.draw(|f| app.draw(f, &results, compact, settings.show_preview))?; - - let initial_input = app.search.input.as_str().to_owned(); - let initial_filter_mode = app.search.filter_mode; - let initial_search_mode = app.search_mode; - - let event_ready = tokio::task::spawn_blocking(|| event::poll(Duration::from_millis(250))); - - tokio::select! { - event_ready = event_ready => { - if event_ready?? { - loop { - if let Some(i) = app.handle_input(settings, &event::read()?, results.len()) { - break 'render i; - } - if !event::poll(Duration::ZERO)? { - break; - } - } - } - } - update_needed = &mut update_needed => { - app.update_needed = update_needed?; - } - } - - if initial_input != app.search.input.as_str() - || initial_filter_mode != app.search.filter_mode - || initial_search_mode != app.search_mode - { - results = app.query_results(&mut db).await?; - } - }; - - if settings.inline_height > 0 { - terminal.clear()?; - } - - if index < results.len() { - // index is in bounds so we return that entry - Ok(results.swap_remove(index).command) - } else if index == RETURN_ORIGINAL { - Ok(String::new()) - } else { - // Either: - // * index == RETURN_QUERY, in which case we should return the input - // * out of bounds -> usually implies no selected entry so we return the input - Ok(app.search.input.into_inner()) - } -} diff --git a/src/command/client/stats.rs b/src/command/client/stats.rs deleted file mode 100644 index 5134f22f..00000000 --- a/src/command/client/stats.rs +++ /dev/null @@ -1,181 +0,0 @@ -use std::collections::{HashMap, HashSet}; - -use chrono::{prelude::*, Duration}; -use clap::Parser; -use crossterm::style::{Color, ResetColor, SetAttribute, SetForegroundColor}; -use eyre::{bail, Result}; -use interim::parse_date_string; - -use atuin_client::{ - database::{current_context, Database}, - history::History, - settings::{FilterMode, Settings}, -}; - -#[derive(Parser)] -#[command(infer_subcommands = true)] -pub struct Cmd { - /// compute statistics for the specified period, leave blank for statistics since the beginning - period: Vec, - - /// How many top commands to list - #[arg(long, short, default_value = "10")] - count: usize, -} - -fn compute_stats(history: &[History], count: usize) -> Result<()> { - let mut commands = HashSet::<&str>::with_capacity(history.len()); - let mut prefixes = HashMap::<&str, usize>::with_capacity(history.len()); - for i in history { - // just in case it somehow has a leading tab or space or something (legacy atuin didn't ignore space prefixes) - let command = i.command.trim(); - commands.insert(command); - *prefixes.entry(interesting_command(command)).or_default() += 1; - } - - let unique = commands.len(); - let mut top = prefixes.into_iter().collect::>(); - top.sort_unstable_by_key(|x| std::cmp::Reverse(x.1)); - top.truncate(count); - if top.is_empty() { - bail!("No commands found"); - } - - let max = top.iter().map(|x| x.1).max().unwrap(); - let num_pad = max.ilog10() as usize + 1; - - for (command, count) in top { - let gray = SetForegroundColor(Color::Grey); - let bold = SetAttribute(crossterm::style::Attribute::Bold); - - let in_ten = 10 * count / max; - print!("["); - print!("{}", SetForegroundColor(Color::Red)); - for i in 0..in_ten { - if i == 2 { - print!("{}", SetForegroundColor(Color::Yellow)); - } - if i == 5 { - print!("{}", SetForegroundColor(Color::Green)); - } - print!("▮"); - } - for _ in in_ten..10 { - print!(" "); - } - - println!("{ResetColor}] {gray}{count:num_pad$}{ResetColor} {bold}{command}{ResetColor}"); - } - println!("Total commands: {}", history.len()); - println!("Unique commands: {unique}"); - - Ok(()) -} - -impl Cmd { - pub async fn run(&self, db: &mut impl Database, settings: &Settings) -> Result<()> { - let context = current_context(); - let words = if self.period.is_empty() { - String::from("all") - } else { - self.period.join(" ") - }; - let history = if words.as_str() == "all" { - db.list(FilterMode::Global, &context, None, false).await? - } else if words.trim() == "today" { - let start = Local::now().date().and_hms(0, 0, 0); - let end = start + Duration::days(1); - db.range(start.into(), end.into()).await? - } else if words.trim() == "month" { - let end = Local::now().date().and_hms(0, 0, 0); - let start = end - Duration::days(31); - db.range(start.into(), end.into()).await? - } else if words.trim() == "week" { - let end = Local::now().date().and_hms(0, 0, 0); - let start = end - Duration::days(7); - db.range(start.into(), end.into()).await? - } else if words.trim() == "year" { - let end = Local::now().date().and_hms(0, 0, 0); - let start = end - Duration::days(365); - db.range(start.into(), end.into()).await? - } else { - let start = parse_date_string(&words, Local::now(), settings.dialect.into())?; - let end = start + Duration::days(1); - db.range(start.into(), end.into()).await? - }; - compute_stats(&history, self.count)?; - Ok(()) - } -} - -// TODO: make this configurable? -static COMMON_COMMAND_PREFIX: &[&str] = &["sudo"]; -static COMMON_SUBCOMMAND_PREFIX: &[&str] = &["cargo", "go", "git", "npm", "yarn", "pnpm"]; - -fn first_non_whitespace(s: &str) -> Option { - s.char_indices() - // find the first non whitespace char - .find(|(_, c)| !c.is_ascii_whitespace()) - // return the index of that char - .map(|(i, _)| i) -} - -fn first_whitespace(s: &str) -> usize { - s.char_indices() - // find the first whitespace char - .find(|(_, c)| c.is_ascii_whitespace()) - // return the index of that char, (or the max length of the string) - .map_or(s.len(), |(i, _)| i) -} - -fn interesting_command(mut command: &str) -> &str { - // compute command prefix - // we loop here because we might be working with a common command prefix (eg sudo) that we want to trim off - let (i, prefix) = loop { - let i = first_whitespace(command); - let prefix = &command[..i]; - - // is it a common prefix - if COMMON_COMMAND_PREFIX.contains(&prefix) { - command = command[i..].trim_start(); - if command.is_empty() { - // no commands following, just use the prefix - return prefix; - } - } else { - break (i, prefix); - } - }; - - // compute subcommand - let subcommand_indices = command - // after the end of the command prefix - .get(i..) - // find the first non whitespace character (start of subcommand) - .and_then(first_non_whitespace) - // then find the end of that subcommand - .map(|j| i + j + first_whitespace(&command[i + j..])); - - match subcommand_indices { - // if there is a subcommand and it's a common one, then count the full prefix + subcommand - Some(end) if COMMON_SUBCOMMAND_PREFIX.contains(&prefix) => &command[..end], - // otherwise just count the main command - _ => prefix, - } -} - -#[cfg(test)] -mod tests { - use super::interesting_command; - - #[test] - fn interesting_commands() { - assert_eq!(interesting_command("cargo"), "cargo"); - assert_eq!(interesting_command("cargo build foo bar"), "cargo build"); - assert_eq!( - interesting_command("sudo cargo build foo bar"), - "cargo build" - ); - assert_eq!(interesting_command("sudo"), "sudo"); - } -} diff --git a/src/command/client/sync.rs b/src/command/client/sync.rs deleted file mode 100644 index 419177a5..00000000 --- a/src/command/client/sync.rs +++ /dev/null @@ -1,74 +0,0 @@ -use clap::Subcommand; -use eyre::{Result, WrapErr}; - -use atuin_client::{database::Database, settings::Settings}; - -mod login; -mod logout; -mod register; -mod status; - -#[derive(Subcommand)] -#[command(infer_subcommands = true)] -pub enum Cmd { - /// Sync with the configured server - Sync { - /// Force re-download everything - #[arg(long, short)] - force: bool, - }, - - /// Login to the configured server - Login(login::Cmd), - - /// Log out - Logout, - - /// Register with the configured server - Register(register::Cmd), - - /// Print the encryption key for transfer to another machine - Key { - /// Switch to base64 output of the key - #[arg(long)] - base64: bool, - }, - - Status, -} - -impl Cmd { - pub async fn run(self, settings: Settings, db: &mut impl Database) -> Result<()> { - match self { - Self::Sync { force } => run(&settings, force, db).await, - Self::Login(l) => l.run(&settings).await, - Self::Logout => logout::run(&settings), - Self::Register(r) => r.run(&settings).await, - Self::Status => status::run(&settings, db).await, - Self::Key { base64 } => { - use atuin_client::encryption::{encode_key, load_key}; - let key = load_key(&settings).wrap_err("could not load encryption key")?; - - if base64 { - let encode = encode_key(key).wrap_err("could not encode encryption key")?; - println!("{encode}"); - } else { - let mnemonic = bip39::Mnemonic::from_entropy(&key.0, bip39::Language::English) - .map_err(|_| eyre::eyre!("invalid key"))?; - println!("{mnemonic}"); - } - Ok(()) - } - } - } -} - -async fn run(settings: &Settings, force: bool, db: &mut impl Database) -> Result<()> { - atuin_client::sync::sync(settings, force, db).await?; - println!( - "Sync complete! {} items in database, force: {}", - db.history_count().await?, - force - ); - Ok(()) -} diff --git a/src/command/client/sync/login.rs b/src/command/client/sync/login.rs deleted file mode 100644 index 6aa2d847..00000000 --- a/src/command/client/sync/login.rs +++ /dev/null @@ -1,147 +0,0 @@ -use std::{io, path::PathBuf}; - -use clap::Parser; -use eyre::{bail, Context, ContextCompat, Result}; -use tokio::{fs::File, io::AsyncWriteExt}; - -use atuin_client::{ - api_client, - encryption::{decode_key, encode_key, new_key, Key}, - settings::Settings, -}; -use atuin_common::api::LoginRequest; -use rpassword::prompt_password; - -#[derive(Parser)] -pub struct Cmd { - #[clap(long, short)] - pub username: Option, - - #[clap(long, short)] - pub password: Option, - - /// The encryption key for your account - #[clap(long, short)] - pub key: Option, -} - -fn get_input() -> Result { - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - Ok(input.trim_end_matches(&['\r', '\n'][..]).to_string()) -} - -impl Cmd { - pub async fn run(&self, settings: &Settings) -> Result<()> { - let session_path = settings.session_path.as_str(); - - if PathBuf::from(session_path).exists() { - println!( - "You are already logged in! Please run 'atuin logout' if you wish to login again" - ); - - return Ok(()); - } - - let username = or_user_input(&self.username, "username"); - let key = or_user_input(&self.key, "encryption key [blank to use existing key file]"); - let password = self.password.clone().unwrap_or_else(read_user_password); - - let key_path = settings.key_path.as_str(); - if key.is_empty() { - if PathBuf::from(key_path).exists() { - let bytes = fs_err::read_to_string(key_path) - .context("existing key file couldn't be read")?; - if decode_key(bytes).is_err() { - bail!("the key in existing key file was invalid"); - } - } else { - println!("No key file exists, creating a new"); - let _key = new_key(settings)?; - } - } else { - // try parse the key as a mnemonic... - let key = match bip39::Mnemonic::from_phrase(&key, bip39::Language::English) { - Ok(mnemonic) => encode_key( - Key::from_slice(mnemonic.entropy()) - .context("key was not the correct length")?, - )?, - Err(err) => { - if let Some(err) = err.downcast_ref::() { - match err { - // assume they copied in the base64 key - bip39::ErrorKind::InvalidWord => key, - bip39::ErrorKind::InvalidChecksum => { - bail!("key mnemonic was not valid") - } - bip39::ErrorKind::InvalidKeysize(_) - | bip39::ErrorKind::InvalidWordLength(_) - | bip39::ErrorKind::InvalidEntropyLength(_, _) => { - bail!("key was not the correct length") - } - } - } else { - // unknown error. assume they copied the base64 key - key - } - } - }; - - if decode_key(key.clone()).is_err() { - bail!("the specified key was invalid"); - } - - let mut file = File::create(key_path).await?; - file.write_all(key.as_bytes()).await?; - } - - let session = api_client::login( - settings.sync_address.as_str(), - LoginRequest { username, password }, - ) - .await?; - - let session_path = settings.session_path.as_str(); - let mut file = File::create(session_path).await?; - file.write_all(session.session.as_bytes()).await?; - - println!("Logged in!"); - - Ok(()) - } -} - -pub(super) fn or_user_input(value: &'_ Option, name: &'static str) -> String { - value.clone().unwrap_or_else(|| read_user_input(name)) -} - -pub(super) fn read_user_password() -> String { - let password = prompt_password("Please enter password: "); - password.expect("Failed to read from input") -} - -fn read_user_input(name: &'static str) -> String { - eprint!("Please enter {name}: "); - get_input().expect("Failed to read from input") -} - -#[cfg(test)] -mod tests { - use atuin_client::encryption::Key; - - #[test] - fn mnemonic_round_trip() { - let key = Key { - 0: [ - 3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8, 9, 7, 9, 3, 2, 3, 8, 4, 6, 2, 6, 4, 3, 3, 8, 3, - 2, 7, 9, 5, - ], - }; - let phrase = bip39::Mnemonic::from_entropy(&key.0, bip39::Language::English) - .unwrap() - .into_phrase(); - let mnemonic = bip39::Mnemonic::from_phrase(&phrase, bip39::Language::English).unwrap(); - assert_eq!(mnemonic.entropy(), &key.0); - assert_eq!(phrase, "adapt amused able anxiety mother adapt beef gaze amount else seat alcohol cage lottery avoid scare alcohol cactus school avoid coral adjust catch pink"); - } -} diff --git a/src/command/client/sync/logout.rs b/src/command/client/sync/logout.rs deleted file mode 100644 index 90b49d6d..00000000 --- a/src/command/client/sync/logout.rs +++ /dev/null @@ -1,19 +0,0 @@ -use std::path::PathBuf; - -use eyre::{Context, Result}; -use fs_err::remove_file; - -use atuin_client::settings::Settings; - -pub fn run(settings: &Settings) -> Result<()> { - let session_path = settings.session_path.as_str(); - - if PathBuf::from(session_path).exists() { - remove_file(session_path).context("Failed to remove session file")?; - println!("You have logged out!"); - } else { - println!("You are not logged in"); - } - - Ok(()) -} diff --git a/src/command/client/sync/register.rs b/src/command/client/sync/register.rs deleted file mode 100644 index 6b51fac8..00000000 --- a/src/command/client/sync/register.rs +++ /dev/null @@ -1,49 +0,0 @@ -use clap::Parser; -use eyre::Result; -use tokio::{fs::File, io::AsyncWriteExt}; - -use atuin_client::{api_client, settings::Settings}; - -#[derive(Parser)] -pub struct Cmd { - #[clap(long, short)] - pub username: Option, - - #[clap(long, short)] - pub password: Option, - - #[clap(long, short)] - pub email: Option, -} - -impl Cmd { - pub async fn run(self, settings: &Settings) -> Result<()> { - run(settings, &self.username, &self.email, &self.password).await - } -} - -pub async fn run( - settings: &Settings, - username: &Option, - email: &Option, - password: &Option, -) -> Result<()> { - use super::login::or_user_input; - let username = or_user_input(username, "username"); - let email = or_user_input(email, "email"); - let password = password - .clone() - .unwrap_or_else(super::login::read_user_password); - - let session = - api_client::register(settings.sync_address.as_str(), &username, &email, &password).await?; - - let path = settings.session_path.as_str(); - let mut file = File::create(path).await?; - file.write_all(session.session.as_bytes()).await?; - - // Create a new key, and save it to disk - let _key = atuin_client::encryption::new_key(settings)?; - - Ok(()) -} diff --git a/src/command/client/sync/status.rs b/src/command/client/sync/status.rs deleted file mode 100644 index b3e73e8e..00000000 --- a/src/command/client/sync/status.rs +++ /dev/null @@ -1,35 +0,0 @@ -use atuin_client::{ - api_client, database::Database, encryption::load_encoded_key, settings::Settings, -}; -use colored::Colorize; -use eyre::Result; - -pub async fn run(settings: &Settings, db: &impl Database) -> Result<()> { - let client = api_client::Client::new( - &settings.sync_address, - &settings.session_token, - load_encoded_key(settings)?, - )?; - - let status = client.status().await?; - let last_sync = Settings::last_sync()?; - let local_count = db.history_count().await?; - - println!("{}", "[Local]".green()); - - if settings.auto_sync { - println!("Sync frequency: {}", settings.sync_frequency); - println!("Last sync: {last_sync}"); - } - - println!("History count: {local_count}\n"); - - if settings.auto_sync { - println!("{}", "[Remote]".green()); - println!("Address: {}", settings.sync_address); - println!("Username: {}", status.username); - println!("History count: {}", status.count); - } - - Ok(()) -} diff --git a/src/command/contributors.rs b/src/command/contributors.rs deleted file mode 100644 index 6f21b5fe..00000000 --- a/src/command/contributors.rs +++ /dev/null @@ -1,75 +0,0 @@ -const CONTRIBUTORS: &str = r#" -Baptiste -Benjamin Vergnaud -Brad Robel-Forrest -Bruce Huang -Conrad Ludgate -CosmicHorror -Daniel -Ellie Huxtable -Eric Crosson -Eric Ripa -Erwin Kroon -Evan Purkhiser -Frank Hamand -Herby Gillot -Ian Smith -Ilkin Bayramli -Violet Shreve -Jakob Schrettenbrunner -Jakob-Niklas See -Jakub Jirutka -Jakub Panek -Jamie Quigley -Jannik -Jerome Ducret -Johannes Baiter -Klas Mellbourn -Laurent le Beau-Martin -Lucas Burns -Lucy -Luke Baker -Manel Vilar -Mark Wotton -Martin Indra -Martin Junghanns -Mat Jones -Michael Bianco -Michael Mior -Omer Katz -Orhun Parmaksız -Patrick -Patrick Decat -Patrick Jackson -Plamen Dimitrov -Sam Edwards -Sam Lanning -Sandro -Satyarth Sampath -Simon Elsbrock -Tobias Hunger -Trygve Aaberge -TymanWasTaken -Ubiquitous Photon -Webmaster At Cosmic DNA -Will Fancher -Yolo -Yuvi Panda -ZhiHong Li -avinassh -b3nj5m1n -c-14 -frukto -jean-santos -lchausmann -mb6ockatf -morguldir -mundry -noyez -wpbrz -xfzv -"#; - -pub fn run() { - println!("{CONTRIBUTORS}"); -} diff --git a/src/command/init.rs b/src/command/init.rs deleted file mode 100644 index a9c24b09..00000000 --- a/src/command/init.rs +++ /dev/null @@ -1,144 +0,0 @@ -use clap::{Parser, ValueEnum}; - -#[derive(Parser)] -pub struct Cmd { - shell: Shell, - - /// Disable the binding of CTRL-R to atuin - #[clap(long)] - disable_ctrl_r: bool, - - /// Disable the binding of the Up Arrow key to atuin - #[clap(long)] - disable_up_arrow: bool, -} - -#[derive(Clone, Copy, ValueEnum)] -pub enum Shell { - /// Zsh setup - Zsh, - /// Bash setup - Bash, - /// Fish setup - Fish, - /// Nu setup - Nu, -} - -impl Cmd { - fn init_zsh(&self) { - let base = include_str!("../shell/atuin.zsh"); - - println!("{base}"); - - if std::env::var("ATUIN_NOBIND").is_err() { - const BIND_CTRL_R: &str = "bindkey '^r' _atuin_search_widget"; - const BIND_UP_ARROW: &str = "bindkey '^[[A' _atuin_up_search_widget -bindkey '^[OA' _atuin_up_search_widget"; - if !self.disable_ctrl_r { - println!("{BIND_CTRL_R}"); - } - if !self.disable_up_arrow { - println!("{BIND_UP_ARROW}"); - } - } - } - - fn init_bash(&self) { - let base = include_str!("../shell/atuin.bash"); - println!("{base}"); - - if std::env::var("ATUIN_NOBIND").is_err() { - const BIND_CTRL_R: &str = r#"bind -x '"\C-r": __atuin_history'"#; - const BIND_UP_ARROW: &str = r#"bind -x '"\e[A": __atuin_history --shell-up-key-binding' -bind -x '"\eOA": __atuin_history --shell-up-key-binding'"#; - if !self.disable_ctrl_r { - println!("{BIND_CTRL_R}"); - } - if !self.disable_up_arrow { - println!("{BIND_UP_ARROW}"); - } - } - } - - fn init_fish(&self) { - let full = include_str!("../shell/atuin.fish"); - println!("{full}"); - - if std::env::var("ATUIN_NOBIND").is_err() { - const BIND_CTRL_R: &str = r"bind \cr _atuin_search"; - const BIND_UP_ARROW: &str = r"bind -k up _atuin_bind_up -bind \eOA _atuin_bind_up -bind \e\[A _atuin_bind_up"; - const BIND_CTRL_R_INS: &str = r"bind -M insert \cr _atuin_search"; - const BIND_UP_ARROW_INS: &str = r"bind -M insert -k up _atuin_bind_up -bind -M insert \eOA _atuin_bind_up -bind -M insert \e\[A _atuin_bind_up"; - - if !self.disable_ctrl_r { - println!("{BIND_CTRL_R}"); - } - if !self.disable_up_arrow { - println!("{BIND_UP_ARROW}"); - } - - println!("if bind -M insert > /dev/null 2>&1"); - if !self.disable_ctrl_r { - println!("{BIND_CTRL_R_INS}"); - } - if !self.disable_up_arrow { - println!("{BIND_UP_ARROW_INS}"); - } - println!("end"); - } - } - - fn init_nu(&self) { - let full = include_str!("../shell/atuin.nu"); - println!("{full}"); - - if std::env::var("ATUIN_NOBIND").is_err() { - const BIND_CTRL_R: &str = r#"let-env config = ( - $env.config | upsert keybindings ( - $env.config.keybindings - | append { - name: atuin - modifier: control - keycode: char_r - mode: [emacs, vi_normal, vi_insert] - event: { send: executehostcommand cmd: (_atuin_search_cmd) } - } - ) -) -"#; - const BIND_UP_ARROW: &str = r#"let-env config = ( - $env.config | upsert keybindings ( - $env.config.keybindings - | append { - name: atuin - modifier: none - keycode: up - mode: [emacs, vi_normal, vi_insert] - event: { send: executehostcommand cmd: (_atuin_search_cmd '--shell-up-key-binding') } - } - ) -) -"#; - if !self.disable_ctrl_r { - println!("{BIND_CTRL_R}"); - } - if !self.disable_up_arrow { - println!("{BIND_UP_ARROW}"); - } - } - } - - pub fn run(self) { - match self.shell { - Shell::Zsh => self.init_zsh(), - Shell::Bash => self.init_bash(), - Shell::Fish => self.init_fish(), - Shell::Nu => self.init_nu(), - } - } -} diff --git a/src/command/mod.rs b/src/command/mod.rs deleted file mode 100644 index 4ed1691a..00000000 --- a/src/command/mod.rs +++ /dev/null @@ -1,87 +0,0 @@ -use clap::{CommandFactory, Subcommand}; -use clap_complete::{generate, generate_to, Shell}; -use eyre::Result; - -#[cfg(feature = "client")] -mod client; - -#[cfg(feature = "server")] -mod server; - -mod init; - -mod contributors; - -#[derive(Subcommand)] -#[command(infer_subcommands = true)] -pub enum AtuinCmd { - #[cfg(feature = "client")] - #[command(flatten)] - Client(client::Cmd), - - /// Start an atuin server - #[cfg(feature = "server")] - #[command(subcommand)] - Server(server::Cmd), - - /// Output shell setup - Init(init::Cmd), - - /// Generate a UUID - Uuid, - - Contributors, - - /// Generate shell completions - GenCompletions { - /// Set the shell for generating completions - #[arg(long, short)] - shell: Shell, - - /// Set the output directory - #[arg(long, short)] - out_dir: Option, - }, -} - -impl AtuinCmd { - pub fn run(self) -> Result<()> { - match self { - #[cfg(feature = "client")] - Self::Client(client) => client.run(), - #[cfg(feature = "server")] - Self::Server(server) => server.run(), - Self::Contributors => { - contributors::run(); - Ok(()) - } - Self::Init(init) => { - init.run(); - Ok(()) - } - Self::Uuid => { - println!("{}", atuin_common::utils::uuid_v7().as_simple()); - Ok(()) - } - Self::GenCompletions { shell, out_dir } => { - let mut cli = crate::Atuin::command(); - - match out_dir { - Some(out_dir) => { - generate_to(shell, &mut cli, env!("CARGO_PKG_NAME"), &out_dir)?; - } - None => { - generate( - shell, - &mut cli, - env!("CARGO_PKG_NAME"), - &mut std::io::stdout(), - ); - } - } - - Ok(()) - } - } - } -} diff --git a/src/command/server.rs b/src/command/server.rs deleted file mode 100644 index 495f85d0..00000000 --- a/src/command/server.rs +++ /dev/null @@ -1,44 +0,0 @@ -use tracing_subscriber::{fmt, prelude::*, EnvFilter}; - -use clap::Parser; -use eyre::{Context, Result}; - -use atuin_server::{launch, settings::Settings}; - -#[derive(Parser)] -#[clap(infer_subcommands = true)] -pub enum Cmd { - /// Start the server - Start { - /// The host address to bind - #[clap(long)] - host: Option, - - /// The port to bind - #[clap(long, short)] - port: Option, - }, -} - -impl Cmd { - #[tokio::main] - pub async fn run(self) -> Result<()> { - tracing_subscriber::registry() - .with(fmt::layer()) - .with(EnvFilter::from_default_env()) - .init(); - - let settings = Settings::new().wrap_err("could not load server settings")?; - - match self { - Self::Start { host, port } => { - let host = host - .as_ref() - .map_or(settings.host.clone(), std::string::ToString::to_string); - let port = port.map_or(settings.port, |p| p); - - launch(settings, host, port).await - } - } - } -} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 9e570337..00000000 --- a/src/main.rs +++ /dev/null @@ -1,45 +0,0 @@ -#![warn(clippy::pedantic, clippy::nursery)] -#![allow(clippy::use_self, clippy::missing_const_for_fn)] // not 100% reliable - -use clap::Parser; -use eyre::Result; - -use command::AtuinCmd; -mod command; - -#[allow(clippy::all)] -mod ratatui; - -const VERSION: &str = env!("CARGO_PKG_VERSION"); - -static HELP_TEMPLATE: &str = "\ -{before-help}{name} {version} -{author} -{about} - -{usage-heading} - {usage} - -{all-args}{after-help}"; - -/// Magical shell history -#[derive(Parser)] -#[command( - author = "Ellie Huxtable ", - version = VERSION, - help_template(HELP_TEMPLATE), -)] -struct Atuin { - #[command(subcommand)] - atuin: AtuinCmd, -} - -impl Atuin { - fn run(self) -> Result<()> { - self.atuin.run() - } -} - -fn main() -> Result<()> { - Atuin::parse().run() -} diff --git a/src/ratatui/.github/ISSUE_TEMPLATE/bug_report.md b/src/ratatui/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 0a0f4bc6..00000000 --- a/src/ratatui/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -name: Bug report -about: Create an issue about a bug you encountered -title: '' -labels: bug -assignees: '' ---- - - - -## Description - - - -## To Reproduce - - - -## Expected behavior - - - -## Screenshots - - - -## Environment - - -- OS: -- Terminal Emulator: -- Font: -- Crate version: -- Backend: - -## Additional context - diff --git a/src/ratatui/.github/ISSUE_TEMPLATE/config.yml b/src/ratatui/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index 3ba13e0c..00000000 --- a/src/ratatui/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1 +0,0 @@ -blank_issues_enabled: false diff --git a/src/ratatui/.github/ISSUE_TEMPLATE/feature_request.md b/src/ratatui/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index ae095edb..00000000 --- a/src/ratatui/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: enhancement -assignees: '' - ---- - -## Problem - - -## Solution - - -## Alternatives - - -## Additional context - diff --git a/src/ratatui/.github/workflows/cd.yml b/src/ratatui/.github/workflows/cd.yml deleted file mode 100644 index f61e3603..00000000 --- a/src/ratatui/.github/workflows/cd.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Continuous Deployment - -on: - push: - tags: - - "v*.*.*" - -jobs: - publish: - name: Publish on crates.io - runs-on: ubuntu-latest - steps: - - name: Checkout the repository - uses: actions/checkout@v3 - - name: Publish - uses: actions-rs/cargo@v1 - with: - command: publish - args: --token ${{ secrets.CARGO_TOKEN }} diff --git a/src/ratatui/.github/workflows/ci.yml b/src/ratatui/.github/workflows/ci.yml deleted file mode 100644 index bfa363e9..00000000 --- a/src/ratatui/.github/workflows/ci.yml +++ /dev/null @@ -1,76 +0,0 @@ -on: - push: - branches: - - main - pull_request: - branches: - - main - -name: CI - -env: - CI_CARGO_MAKE_VERSION: 0.35.16 - -jobs: - test: - strategy: - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - rust: ["1.59.0", "stable"] - include: - - os: ubuntu-latest - triple: x86_64-unknown-linux-musl - - os: windows-latest - triple: x86_64-pc-windows-msvc - - os: macos-latest - triple: x86_64-apple-darwin - runs-on: ${{ matrix.os }} - steps: - - uses: hecrj/setup-rust-action@50a120e4d34903c2c1383dec0e9b1d349a9cc2b1 - with: - rust-version: ${{ matrix.rust }} - components: rustfmt,clippy - - uses: actions/checkout@v3 - - name: Install cargo-make on Linux or macOS - if: ${{ runner.os != 'windows' }} - shell: bash - run: | - curl -LO 'https://github.com/sagiegurari/cargo-make/releases/download/${{ env.CI_CARGO_MAKE_VERSION }}/cargo-make-v${{ env.CI_CARGO_MAKE_VERSION }}-${{ matrix.triple }}.zip' - unzip 'cargo-make-v${{ env.CI_CARGO_MAKE_VERSION }}-${{ matrix.triple }}.zip' - cp 'cargo-make-v${{ env.CI_CARGO_MAKE_VERSION }}-${{ matrix.triple }}/cargo-make' ~/.cargo/bin/ - cargo make --version - - name: Install cargo-make on Windows - if: ${{ runner.os == 'windows' }} - shell: bash - run: | - # `cargo-make-v0.35.16-{target}/` directory is created on Linux and macOS, but it is not creatd on Windows. - mkdir cargo-make-temporary - cd cargo-make-temporary - curl -LO 'https://github.com/sagiegurari/cargo-make/releases/download/${{ env.CI_CARGO_MAKE_VERSION }}/cargo-make-v${{ env.CI_CARGO_MAKE_VERSION }}-${{ matrix.triple }}.zip' - unzip 'cargo-make-v${{ env.CI_CARGO_MAKE_VERSION }}-${{ matrix.triple }}.zip' - cp cargo-make.exe ~/.cargo/bin/ - cd .. - cargo make --version - - name: "Format / Build / Test" - run: cargo make ci - env: - RUST_BACKTRACE: full - - lint: - runs-on: ubuntu-latest - steps: - - name: Checkout - if: github.event_name != 'pull_request' - uses: actions/checkout@v3 - - name: Checkout - if: github.event_name == 'pull_request' - uses: actions/checkout@v3 - with: - ref: ${{ github.event.pull_request.head.sha }} - - name: "Check conventional commits" - uses: crate-ci/committed@master - with: - args: "-vv" - commits: "HEAD" - - name: "Check typos" - uses: crate-ci/typos@master diff --git a/src/ratatui/.gitignore b/src/ratatui/.gitignore deleted file mode 100644 index dcb33fbb..00000000 --- a/src/ratatui/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -target -Cargo.lock -*.log -*.rs.rustfmt -.gdb_history -.idea/ diff --git a/src/ratatui/LICENSE b/src/ratatui/LICENSE deleted file mode 100644 index 7a0657cb..00000000 --- a/src/ratatui/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -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/ratatui/README.md b/src/ratatui/README.md deleted file mode 100644 index 05d4adb6..00000000 --- a/src/ratatui/README.md +++ /dev/null @@ -1,136 +0,0 @@ -# ratatui - -An actively maintained `tui`-rs fork. - -[![Build Status](https://github.com/tui-rs-revival/ratatui/workflows/CI/badge.svg)](https://github.com/tui-rs-revival/ratatui/actions?query=workflow%3ACI+) -[![Crate Status](https://img.shields.io/crates/v/ratatui.svg)](https://crates.io/crates/ratatui) -[![Docs Status](https://docs.rs/ratatui/badge.svg)](https://docs.rs/crate/ratatui/) - -Demo cast under Linux Termite with Inconsolata font 12pt - -# Install - -```toml -[dependencies] -tui = { package = "ratatui" } -``` - -# What is this fork? - -This fork was created to continue maintenance on the original TUI project. The original maintainer had created an [issue](https://github.com/fdehau/tui-rs/issues/654) explaining how he couldn't find time to continue development, which led to us creating this fork. - -With that in mind, **we the community** look forward to continuing the work started by [**Florian Dehau.**](https://github.com/fdehau) :rocket: - -In order to organize ourselves, we currently use a [discord server](https://discord.gg/pMCEU9hNEj), feel free to join and come chat ! There are also plans to implement a [matrix](https://matrix.org/) bridge in the near future. -**Discord is not a MUST to contribute,** we follow a pretty standard github centered open source workflow keeping the most important conversations on github, open an issue or PR and it will be addressed. :smile: - -Please make sure you read the updated contributing guidelines, especially if you are interested in working on a PR or issue opened in the previous repository. - -# Introduction - -`ratatui`-rs is a [Rust](https://www.rust-lang.org) library to build rich terminal -user interfaces and dashboards. It is heavily inspired by the `Javascript` -library [blessed-contrib](https://github.com/yaronn/blessed-contrib) and the -`Go` library [termui](https://github.com/gizak/termui). - -The library supports multiple backends: - -- [crossterm](https://github.com/crossterm-rs/crossterm) [default] -- [termion](https://github.com/ticki/termion) - -The library is based on the principle of immediate rendering with intermediate -buffers. This means that at each new frame you should build all widgets that are -supposed to be part of the UI. While providing a great flexibility for rich and -interactive UI, this may introduce overhead for highly dynamic content. So, the -implementation try to minimize the number of ansi escapes sequences generated to -draw the updated UI. In practice, given the speed of `Rust` the overhead rather -comes from the terminal emulator than the library itself. - -Moreover, the library does not provide any input handling nor any event system and -you may rely on the previously cited libraries to achieve such features. - -## Rust version requirements - -Since version 0.17.0, `ratatui` requires **rustc version 1.59.0 or greater**. - -# Documentation - -The documentation can be found on [docs.rs.](https://docs.rs/ratatui) - -# Demo - -The demo shown in the gif can be run with all available backends. - -``` -# crossterm -cargo run --example demo --release -- --tick-rate 200 -# termion -cargo run --example demo --no-default-features --features=termion --release -- --tick-rate 200 -``` - -where `tick-rate` is the UI refresh rate in ms. - -The UI code is in [examples/demo/ui.rs](https://github.com/tui-rs-revival/ratatui/blob/main/examples/demo/ui.rs) while the -application state is in [examples/demo/app.rs](https://github.com/tui-rs-revival/ratatui/blob/main/examples/demo/app.rs). - -If the user interface contains glyphs that are not displayed correctly by your terminal, you may want to run -the demo without those symbols: - -``` -cargo run --example demo --release -- --tick-rate 200 --enhanced-graphics false -``` - -# Widgets - -## Built in - -The library comes with the following list of widgets: - -- [Block](https://github.com/tui-rs-revival/ratatui/blob/main/examples/block.rs) -- [Gauge](https://github.com/tui-rs-revival/ratatui/blob/main/examples/gauge.rs) -- [Sparkline](https://github.com/tui-rs-revival/ratatui/blob/main/examples/sparkline.rs) -- [Chart](https://github.com/tui-rs-revival/ratatui/blob/main/examples/chart.rs) -- [BarChart](https://github.com/tui-rs-revival/ratatui/blob/main/examples/barchart.rs) -- [List](https://github.com/tui-rs-revival/ratatui/blob/main/examples/list.rs) -- [Table](https://github.com/tui-rs-revival/ratatui/blob/main/examples/table.rs) -- [Paragraph](https://github.com/tui-rs-revival/ratatui/blob/main/examples/paragraph.rs) -- [Canvas (with line, point cloud, map)](https://github.com/tui-rs-revival/ratatui/blob/main/examples/canvas.rs) -- [Tabs](https://github.com/tui-rs-revival/ratatui/blob/main/examples/tabs.rs) - -Click on each item to see the source of the example. Run the examples with with -cargo (e.g. to run the gauge example `cargo run --example gauge`), and quit by pressing `q`. - -You can run all examples by running `cargo make run-examples` (require -`cargo-make` that can be installed with `cargo install cargo-make`). - -### Third-party libraries, bootstrapping templates and widgets - -- [ansi-to-tui](https://github.com/uttarayan21/ansi-to-tui) — Convert ansi colored text to `tui::text::Text` -- [color-to-tui](https://github.com/uttarayan21/color-to-tui) — Parse hex colors to `tui::style::Color` -- [rust-tui-template](https://github.com/orhun/rust-tui-template) — A template for bootstrapping a Rust TUI application with Tui-rs & crossterm -- [simple-tui-rs](https://github.com/pmsanford/simple-tui-rs) — A simple example tui-rs app -- [tui-builder](https://github.com/jkelleyrtp/tui-builder) — Batteries-included MVC framework for Tui-rs + Crossterm apps -- [tui-clap](https://github.com/kegesch/tui-clap-rs) — Use clap-rs together with Tui-rs -- [tui-log](https://github.com/kegesch/tui-log-rs) — Example of how to use logging with Tui-rs -- [tui-logger](https://github.com/gin66/tui-logger) — Logger and Widget for Tui-rs -- [tui-realm](https://github.com/veeso/tui-realm) — Tui-rs framework to build stateful applications with a React/Elm inspired approach -- [tui-realm-treeview](https://github.com/veeso/tui-realm-treeview) — Treeview component for Tui-realm -- [tui tree widget](https://github.com/EdJoPaTo/tui-rs-tree-widget) — Tree Widget for Tui-rs -- [tui-windows](https://github.com/markatk/tui-windows-rs) — Tui-rs abstraction to handle multiple windows and their rendering -- [tui-textarea](https://github.com/rhysd/tui-textarea): Simple yet powerful multi-line text editor widget supporting several key shortcuts, undo/redo, text search, etc. -- [tui-rs-tree-widgets](https://github.com/EdJoPaTo/tui-rs-tree-widget): Widget for tree data structures. -- [tui-input](https://github.com/sayanarijit/tui-input): TUI input library supporting multiple backends and tui-rs. - -# Apps - -Check out the list of [close to 40 apps](./APPS.md) using `ratatui`! - -# Alternatives - -You might want to checkout [Cursive](https://github.com/gyscos/Cursive) for an -alternative solution to build text user interfaces in Rust. - -# License - -[MIT](LICENSE) - diff --git a/src/ratatui/backend/crossterm.rs b/src/ratatui/backend/crossterm.rs deleted file mode 100644 index 3dceb6ad..00000000 --- a/src/ratatui/backend/crossterm.rs +++ /dev/null @@ -1,241 +0,0 @@ -use crate::ratatui::{ - backend::{Backend, ClearType}, - 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}, -}; -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<()> { - self.clear_region(ClearType::All) - } - - fn clear_region(&mut self, clear_type: ClearType) -> io::Result<()> { - map_error(execute!( - self.buffer, - Clear(match clear_type { - ClearType::All => crossterm::terminal::ClearType::All, - ClearType::AfterCursor => crossterm::terminal::ClearType::FromCursorDown, - ClearType::BeforeCursor => crossterm::terminal::ClearType::FromCursorUp, - ClearType::CurrentLine => crossterm::terminal::ClearType::CurrentLine, - ClearType::UntilNewLine => crossterm::terminal::ClearType::UntilNewLine, - }) - )) - } - - fn append_lines(&mut self, n: u16) -> io::Result<()> { - for _ in 0..n { - map_error(queue!(self.buffer, Print("\n")))?; - } - self.buffer.flush() - } - - 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/ratatui/backend/mod.rs b/src/ratatui/backend/mod.rs deleted file mode 100644 index a360db18..00000000 --- a/src/ratatui/backend/mod.rs +++ /dev/null @@ -1,58 +0,0 @@ -use std::io; - -use crate::ratatui::buffer::Cell; -use crate::ratatui::layout::Rect; - -#[cfg(feature = "termion")] -mod termion; -#[cfg(feature = "termion")] -pub use self::termion::TermionBackend; - -mod crossterm; -pub use self::crossterm::CrosstermBackend; - -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum ClearType { - All, - AfterCursor, - BeforeCursor, - CurrentLine, - UntilNewLine, -} - -pub trait Backend { - fn draw<'a, I>(&mut self, content: I) -> Result<(), io::Error> - where - I: Iterator; - - /// Insert `n` line breaks to the terminal screen - fn append_lines(&mut self, n: u16) -> io::Result<()> { - // to get around the unused warning - let _n = n; - Ok(()) - } - - 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>; - - /// Clears the whole terminal screen - fn clear(&mut self) -> Result<(), io::Error>; - - /// Clears a specific region of the terminal specified by the [`ClearType`] parameter - fn clear_region(&mut self, clear_type: ClearType) -> Result<(), io::Error> { - match clear_type { - ClearType::All => self.clear(), - ClearType::AfterCursor - | ClearType::BeforeCursor - | ClearType::CurrentLine - | ClearType::UntilNewLine => Err(io::Error::new( - io::ErrorKind::Other, - format!("clear_type [{clear_type:?}] not supported with this backend"), - )), - } - } - fn size(&self) -> Result; - fn flush(&mut self) -> Result<(), io::Error>; -} diff --git a/src/ratatui/backend/termion.rs b/src/ratatui/backend/termion.rs deleted file mode 100644 index 76def792..00000000 --- a/src/ratatui/backend/termion.rs +++ /dev/null @@ -1,275 +0,0 @@ -use crate::{ - backend::{Backend, ClearType}, - buffer::Cell, - layout::Rect, - style::{Color, Modifier}, -}; -use std::{ - fmt, - io::{self, Write}, -}; - -pub struct TermionBackend -where - W: Write, -{ - stdout: W, -} - -impl TermionBackend -where - W: Write, -{ - pub fn new(stdout: W) -> TermionBackend { - TermionBackend { stdout } - } -} - -impl Write for TermionBackend -where - W: Write, -{ - fn write(&mut self, buf: &[u8]) -> io::Result { - self.stdout.write(buf) - } - - fn flush(&mut self) -> io::Result<()> { - self.stdout.flush() - } -} - -impl Backend for TermionBackend -where - W: Write, -{ - fn clear(&mut self) -> io::Result<()> { - self.clear_region(ClearType::All) - } - - fn clear_region(&mut self, clear_type: ClearType) -> io::Result<()> { - match clear_type { - ClearType::All => write!(self.stdout, "{}", termion::clear::All)?, - ClearType::AfterCursor => write!(self.stdout, "{}", termion::clear::AfterCursor)?, - ClearType::BeforeCursor => write!(self.stdout, "{}", termion::clear::BeforeCursor)?, - ClearType::CurrentLine => write!(self.stdout, "{}", termion::clear::CurrentLine)?, - ClearType::UntilNewLine => write!(self.stdout, "{}", termion::clear::UntilNewline)?, - }; - self.stdout.flush() - } - - fn append_lines(&mut self, n: u16) -> io::Result<()> { - for _ in 0..n { - writeln!(self.stdout)?; - } - self.stdout.flush() - } - - /// Hides cursor - fn hide_cursor(&mut self) -> io::Result<()> { - write!(self.stdout, "{}", termion::cursor::Hide)?; - self.stdout.flush() - } - - /// Shows cursor - fn show_cursor(&mut self) -> io::Result<()> { - write!(self.stdout, "{}", termion::cursor::Show)?; - self.stdout.flush() - } - - /// Gets cursor position (0-based index) - fn get_cursor(&mut self) -> io::Result<(u16, u16)> { - termion::cursor::DetectCursorPos::cursor_pos(&mut self.stdout).map(|(x, y)| (x - 1, y - 1)) - } - - /// Sets cursor position (0-based index) - fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> { - write!(self.stdout, "{}", termion::cursor::Goto(x + 1, y + 1))?; - self.stdout.flush() - } - - fn draw<'a, I>(&mut self, content: I) -> io::Result<()> - where - I: Iterator, - { - use std::fmt::Write; - - let mut string = String::with_capacity(content.size_hint().0 * 3); - 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) { - write!(string, "{}", termion::cursor::Goto(x + 1, y + 1)).unwrap(); - } - last_pos = Some((x, y)); - if cell.modifier != modifier { - write!( - string, - "{}", - ModifierDiff { - from: modifier, - to: cell.modifier - } - ) - .unwrap(); - modifier = cell.modifier; - } - if cell.fg != fg { - write!(string, "{}", Fg(cell.fg)).unwrap(); - fg = cell.fg; - } - if cell.bg != bg { - write!(string, "{}", Bg(cell.bg)).unwrap(); - bg = cell.bg; - } - string.push_str(&cell.symbol); - } - write!( - self.stdout, - "{}{}{}{}", - string, - Fg(Color::Reset), - Bg(Color::Reset), - termion::style::Reset, - ) - } - - /// Return the size of the terminal - fn size(&self) -> io::Result { - let terminal = termion::terminal_size()?; - Ok(Rect::new(0, 0, terminal.0, terminal.1)) - } - - fn flush(&mut self) -> io::Result<()> { - self.stdout.flush() - } -} - -struct Fg(Color); - -struct Bg(Color); - -struct ModifierDiff { - from: Modifier, - to: Modifier, -} - -impl fmt::Display for Fg { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - use termion::color::Color as TermionColor; - match self.0 { - Color::Reset => termion::color::Reset.write_fg(f), - Color::Black => termion::color::Black.write_fg(f), - Color::Red => termion::color::Red.write_fg(f), - Color::Green => termion::color::Green.write_fg(f), - Color::Yellow => termion::color::Yellow.write_fg(f), - Color::Blue => termion::color::Blue.write_fg(f), - Color::Magenta => termion::color::Magenta.write_fg(f), - Color::Cyan => termion::color::Cyan.write_fg(f), - Color::Gray => termion::color::White.write_fg(f), - Color::DarkGray => termion::color::LightBlack.write_fg(f), - Color::LightRed => termion::color::LightRed.write_fg(f), - Color::LightGreen => termion::color::LightGreen.write_fg(f), - Color::LightBlue => termion::color::LightBlue.write_fg(f), - Color::LightYellow => termion::color::LightYellow.write_fg(f), - Color::LightMagenta => termion::color::LightMagenta.write_fg(f), - Color::LightCyan => termion::color::LightCyan.write_fg(f), - Color::White => termion::color::LightWhite.write_fg(f), - Color::Indexed(i) => termion::color::AnsiValue(i).write_fg(f), - Color::Rgb(r, g, b) => termion::color::Rgb(r, g, b).write_fg(f), - } - } -} -impl fmt::Display for Bg { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - use termion::color::Color as TermionColor; - match self.0 { - Color::Reset => termion::color::Reset.write_bg(f), - Color::Black => termion::color::Black.write_bg(f), - Color::Red => termion::color::Red.write_bg(f), - Color::Green => termion::color::Green.write_bg(f), - Color::Yellow => termion::color::Yellow.write_bg(f), - Color::Blue => termion::color::Blue.write_bg(f), - Color::Magenta => termion::color::Magenta.write_bg(f), - Color::Cyan => termion::color::Cyan.write_bg(f), - Color::Gray => termion::color::White.write_bg(f), - Color::DarkGray => termion::color::LightBlack.write_bg(f), - Color::LightRed => termion::color::LightRed.write_bg(f), - Color::LightGreen => termion::color::LightGreen.write_bg(f), - Color::LightBlue => termion::color::LightBlue.write_bg(f), - Color::LightYellow => termion::color::LightYellow.write_bg(f), - Color::LightMagenta => termion::color::LightMagenta.write_bg(f), - Color::LightCyan => termion::color::LightCyan.write_bg(f), - Color::White => termion::color::LightWhite.write_bg(f), - Color::Indexed(i) => termion::color::AnsiValue(i).write_bg(f), - Color::Rgb(r, g, b) => termion::color::Rgb(r, g, b).write_bg(f), - } - } -} - -impl fmt::Display for ModifierDiff { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let remove = self.from - self.to; - if remove.contains(Modifier::REVERSED) { - write!(f, "{}", termion::style::NoInvert)?; - } - if remove.contains(Modifier::BOLD) { - // XXX: the termion NoBold flag actually enables double-underline on ECMA-48 compliant - // terminals, and NoFaint additionally disables bold... so we use this trick to get - // the right semantics. - write!(f, "{}", termion::style::NoFaint)?; - - if self.to.contains(Modifier::DIM) { - write!(f, "{}", termion::style::Faint)?; - } - } - if remove.contains(Modifier::ITALIC) { - write!(f, "{}", termion::style::NoItalic)?; - } - if remove.contains(Modifier::UNDERLINED) { - write!(f, "{}", termion::style::NoUnderline)?; - } - if remove.contains(Modifier::DIM) { - write!(f, "{}", termion::style::NoFaint)?; - - // XXX: the NoFaint flag additionally disables bold as well, so we need to re-enable it - // here if we want it. - if self.to.contains(Modifier::BOLD) { - write!(f, "{}", termion::style::Bold)?; - } - } - if remove.contains(Modifier::CROSSED_OUT) { - write!(f, "{}", termion::style::NoCrossedOut)?; - } - if remove.contains(Modifier::SLOW_BLINK) || remove.contains(Modifier::RAPID_BLINK) { - write!(f, "{}", termion::style::NoBlink)?; - } - - let add = self.to - self.from; - if add.contains(Modifier::REVERSED) { - write!(f, "{}", termion::style::Invert)?; - } - if add.contains(Modifier::BOLD) { - write!(f, "{}", termion::style::Bold)?; - } - if add.contains(Modifier::ITALIC) { - write!(f, "{}", termion::style::Italic)?; - } - if add.contains(Modifier::UNDERLINED) { - write!(f, "{}", termion::style::Underline)?; - } - if add.contains(Modifier::DIM) { - write!(f, "{}", termion::style::Faint)?; - } - if add.contains(Modifier::CROSSED_OUT) { - write!(f, "{}", termion::style::CrossedOut)?; - } - if add.contains(Modifier::SLOW_BLINK) || add.contains(Modifier::RAPID_BLINK) { - write!(f, "{}", termion::style::Blink)?; - } - - Ok(()) - } -} diff --git a/src/ratatui/buffer.rs b/src/ratatui/buffer.rs deleted file mode 100644 index b2a988b7..00000000 --- a/src/ratatui/buffer.rs +++ /dev/null @@ -1,736 +0,0 @@ -use crate::ratatui::{ - 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 ratatui::buffer::{Buffer, Cell}; -/// use ratatui::layout::Rect; -/// use ratatui::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::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: Vec) -> 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 ratatui::buffer::Buffer; - /// # use ratatui::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 ratatui::buffer::Buffer; - /// # use ratatui::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 ratatui::buffer::Buffer; - /// # use ratatui::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 ratatui::buffer::Buffer; - /// # use ratatui::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 dimensions 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, Default::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::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 mut updates: Vec<(u16, u16, &Cell)> = vec![]; - // Cells invalidated by drawing/replacing preceding multi-width characters: - let mut invalidated: usize = 0; - // Cells from the current buffer to skip due to preceding 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, y) = self.pos_of(i); - 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] - #[ignore] - #[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] - #[ignore] - #[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(vec![" "])); - - buffer.set_string(0, 0, "aaa", Style::default()); - assert_eq!(buffer, Buffer::with_lines(vec!["aaa "])); - - // Width limit: - buffer.set_stringn(0, 0, "bbbbbbbbbbbbbb", 4, Style::default()); - assert_eq!(buffer, Buffer::with_lines(vec!["bbbb "])); - - buffer.set_string(0, 0, "12345", Style::default()); - assert_eq!(buffer, Buffer::with_lines(vec!["12345"])); - - // Width truncation: - buffer.set_string(0, 0, "123456", Style::default()); - assert_eq!(buffer, Buffer::with_lines(vec!["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(vec!["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(vec!["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(vec!["コン "])); - - // Only 1 space left. - buffer.set_string(0, 0, "コンピ", Style::default()); - assert_eq!(buffer, Buffer::with_lines(vec!["コン "])); - } - - #[test] - fn buffer_with_lines() { - let buffer = - Buffer::with_lines(vec!["┌────────┐", "│コンピュ│", "│ー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(vec![ - " ", - "┌Title─┐ ", - "│ │ ", - "│ │ ", - "└──────┘ ", - ]); - let next = Buffer::with_lines(vec![ - " ", - "┌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(vec![ - "┌Title─┐ ", - "└──────┘ ", - ]); - let next = Buffer::with_lines(vec![ - "┌称号──┐ ", - "└──────┘ ", - ]); - 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(vec!["┌称号──┐"]); - let next = Buffer::with_lines(vec!["┌─称号─┐"]); - - 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(vec!["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(vec!["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(vec!["222 ", "222 ", "2221", "2221"]); - merged.area = Rect { - x: 1, - y: 1, - width: 4, - height: 4, - }; - assert_eq!(one, merged); - } -} diff --git a/src/ratatui/layout.rs b/src/ratatui/layout.rs deleted file mode 100644 index f5b14e35..00000000 --- a/src/ratatui/layout.rs +++ /dev/null @@ -1,560 +0,0 @@ -use std::cell::RefCell; -use std::cmp::{max, min}; -use std::collections::HashMap; -use std::rc::Rc; - -use cassowary::strength::{MEDIUM, REQUIRED, WEAK}; -use cassowary::WeightedRelation::*; -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, -} - -type Cache = HashMap<(Rect, Layout), Rc<[Rect]>>; -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 ratatui::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[..], - /// [ - /// 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[..], - /// [ - /// Rect { - /// x: 0, - /// y: 0, - /// width: 3, - /// height: 2 - /// }, - /// Rect { - /// x: 3, - /// y: 0, - /// width: 6, - /// height: 2 - /// } - /// ] - /// ); - /// ``` - pub fn split(&self, area: Rect) -> Rc<[Rect]> { - // 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() - }) - } -} - -fn split(area: Rect, layout: &Layout) -> Rc<[Rect]> { - let mut solver = Solver::new(); - let mut vars: HashMap = HashMap::new(); - let elements = layout - .constraints - .iter() - .map(|_| Element::new()) - .collect::>(); - let mut res = layout - .constraints - .iter() - .map(|_| Rect::default()) - .collect::>(); - - let mut results = Rc::get_mut(&mut res).expect("newly created Rc should have no shared refs"); - - 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(MEDIUM) | f64::from(v), - Constraint::Percentage(v) => { - elements[i].width | EQ(MEDIUM) | (f64::from(v * dest_area.width) / 100.0) - } - Constraint::Ratio(n, d) => { - elements[i].width - | EQ(MEDIUM) - | (f64::from(dest_area.width) * f64::from(n) / f64::from(d)) - } - Constraint::Min(v) => elements[i].width | GE(MEDIUM) | f64::from(v), - Constraint::Max(v) => elements[i].width | LE(MEDIUM) | f64::from(v), - }); - - match *size { - Constraint::Min(v) => { - ccs.push(elements[i].width | EQ(WEAK) | f64::from(v)); - } - Constraint::Max(v) => { - ccs.push(elements[i].width | EQ(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(MEDIUM) | f64::from(v), - Constraint::Percentage(v) => { - elements[i].height | EQ(MEDIUM) | (f64::from(v * dest_area.height) / 100.0) - } - Constraint::Ratio(n, d) => { - elements[i].height - | EQ(MEDIUM) - | (f64::from(dest_area.height) * f64::from(n) / f64::from(d)) - } - Constraint::Min(v) => elements[i].height | GE(MEDIUM) | f64::from(v), - Constraint::Max(v) => elements[i].height | LE(MEDIUM) | f64::from(v), - }); - - match *size { - Constraint::Min(v) => { - ccs.push(elements[i].height | EQ(WEAK) | f64::from(v)); - } - Constraint::Max(v) => { - ccs.push(elements[i].height | EQ(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; - } - } - } - } - res -} - -/// 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/ratatui/mod.rs b/src/ratatui/mod.rs deleted file mode 100644 index d7926b96..00000000 --- a/src/ratatui/mod.rs +++ /dev/null @@ -1,177 +0,0 @@ -#![allow(clippy::all)] -#![allow(warnings)] - -//! [ratatui](https://github.com/tui-rs-revival/ratatui) is a library used to build rich -//! terminal users interfaces and dashboards. -//! -//! ![](https://raw.githubusercontent.com/tui-rs-revival/ratatui/master/assets/demo.gif) -//! -//! # Get started -//! -//! ## Adding `ratatui` as a dependency -//! -//! Add the following to your `Cargo.toml`: -//! ```toml -//! [dependencies] -//! crossterm = "0.26" -//! ratatui = "0.20" -//! ``` -//! -//! The crate is using the `crossterm` backend by default that works on most platforms. But if for -//! example you want to use the `termion` backend instead. This can be done by changing your -//! dependencies specification to the following: -//! -//! ```toml -//! [dependencies] -//! termion = "1.5" -//! ratatui = { version = "0.20", default-features = false, features = ['termion'] } -//! -//! ``` -//! -//! The same logic applies for all other available backends. -//! -//! ## Creating a `Terminal` -//! -//! Every application using `ratatui` should start by instantiating a `Terminal`. It is a light -//! abstraction over available backends that provides basic functionalities such as clearing the -//! screen, hiding the cursor, etc. -//! -//! ```rust,no_run -//! use std::io; -//! use ratatui::{backend::CrosstermBackend, Terminal}; -//! -//! fn main() -> Result<(), io::Error> { -//! let stdout = io::stdout(); -//! let backend = CrosstermBackend::new(stdout); -//! let mut terminal = Terminal::new(backend)?; -//! Ok(()) -//! } -//! ``` -//! -//! If you had previously chosen `termion` as a backend, the terminal can be created in a similar -//! way: -//! -//! ```rust,ignore -//! use std::io; -//! use ratatui::{backend::TermionBackend, Terminal}; -//! use termion::raw::IntoRawMode; -//! -//! fn main() -> Result<(), io::Error> { -//! let stdout = io::stdout().into_raw_mode()?; -//! let backend = TermionBackend::new(stdout); -//! let mut terminal = Terminal::new(backend)?; -//! Ok(()) -//! } -//! ``` -//! -//! You may also refer to the examples to find out how to create a `Terminal` for each available -//! backend. -//! -//! ## Building a User Interface (UI) -//! -//! Every component of your interface will be implementing the `Widget` trait. The library comes -//! with a predefined set of widgets that should meet most of your use cases. You are also free to -//! implement your own. -//! -//! Each widget follows a builder pattern API providing a default configuration along with methods -//! to customize them. The widget is then rendered using [`Frame::render_widget`] which takes -//! your widget instance and an area to draw to. -//! -//! The following example renders a block of the size of the terminal: -//! -//! ```rust,no_run -//! use std::{io, thread, time::Duration}; -//! use ratatui::{ -//! backend::CrosstermBackend, -//! widgets::{Widget, Block, Borders}, -//! layout::{Layout, Constraint, Direction}, -//! Terminal -//! }; -//! use crossterm::{ -//! event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, -//! execute, -//! terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, -//! }; -//! -//! fn main() -> Result<(), io::Error> { -//! // setup terminal -//! enable_raw_mode()?; -//! let mut stdout = io::stdout(); -//! execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; -//! let backend = CrosstermBackend::new(stdout); -//! let mut terminal = Terminal::new(backend)?; -//! -//! terminal.draw(|f| { -//! let size = f.size(); -//! let block = Block::default() -//! .title("Block") -//! .borders(Borders::ALL); -//! f.render_widget(block, size); -//! })?; -//! -//! thread::sleep(Duration::from_millis(5000)); -//! -//! // restore terminal -//! disable_raw_mode()?; -//! execute!( -//! terminal.backend_mut(), -//! LeaveAlternateScreen, -//! DisableMouseCapture -//! )?; -//! terminal.show_cursor()?; -//! -//! Ok(()) -//! } -//! ``` -//! -//! ## Layout -//! -//! The library comes with a basic yet useful layout management object called `Layout`. As you may -//! see below and in the examples, the library makes heavy use of the builder pattern to provide -//! full customization. And `Layout` is no exception: -//! -//! ```rust,no_run -//! use ratatui::{ -//! backend::Backend, -//! layout::{Constraint, Direction, Layout}, -//! widgets::{Block, Borders}, -//! Frame, -//! }; -//! fn ui(f: &mut Frame) { -//! let chunks = Layout::default() -//! .direction(Direction::Vertical) -//! .margin(1) -//! .constraints( -//! [ -//! Constraint::Percentage(10), -//! Constraint::Percentage(80), -//! Constraint::Percentage(10) -//! ].as_ref() -//! ) -//! .split(f.size()); -//! let block = Block::default() -//! .title("Block") -//! .borders(Borders::ALL); -//! f.render_widget(block, chunks[0]); -//! let block = Block::default() -//! .title("Block 2") -//! .borders(Borders::ALL); -//! f.render_widget(block, chunks[1]); -//! } -//! ``` -//! -//! This let you describe responsive terminal UI by nesting layouts. You should note that by -//! default the computed layout tries to fill the available space completely. So if for any reason -//! you might need a blank space somewhere, try to pass an additional constraint and don't use the -//! corresponding area. - -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/ratatui/style.rs b/src/ratatui/style.rs deleted file mode 100644 index 4d74f6fc..00000000 --- a/src/ratatui/style.rs +++ /dev/null @@ -1,310 +0,0 @@ -//! `style` contains the primitives used to control how your user interface will look. - -use bitflags::bitflags; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -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 ratatui::style::Modifier; - /// - /// let m = Modifier::BOLD | Modifier::ITALIC; - /// ``` - #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] - 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 ratatui::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 ratatui::style::{Color, Modifier, Style}; -/// # use ratatui::buffer::Buffer; -/// # use ratatui::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 ratatui::style::{Color, Modifier, Style}; -/// # use ratatui::buffer::Buffer; -/// # use ratatui::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)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -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 ratatui::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 ratatui::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 ratatui::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 ratatui::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 ratatui::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