diff --git a/Cargo.lock b/Cargo.lock index 4a39fa88..8fd9ac21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,18 +29,6 @@ version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" -[[package]] -name = "ansi-to-tui" -version = "7.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67555e1f1ece39d737e28c8a017721287753af3f93225e4a445b29ccb0f5912c" -dependencies = [ - "nom", - "ratatui", - "smallvec", - "thiserror", -] - [[package]] name = "anstyle" version = "1.0.8" @@ -391,42 +379,23 @@ dependencies = [ "which", ] -[[package]] -name = "linutil_core" -version = "24.10.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2990ea580e635f6700ae19bd0f5fa60c7037799908da476b0c233b9e514c1481" -dependencies = [ - "ego-tree", - "include_dir", - "serde", - "temp-dir", - "toml", - "which", -] - [[package]] name = "linutil_tui" version = "24.10.31" dependencies = [ - "ansi-to-tui", - "anstyle", "clap", - "linutil_core 24.10.31 (registry+https://github.com/rust-lang/crates.io-index)", + "linutil_core", "nix 0.29.0", "oneshot", "portable-pty", "rand", "ratatui", - "temp-dir", - "textwrap", "time", "tree-sitter-bash", "tree-sitter-highlight", "tui-term", "unicode-width 0.2.0", "vt100-ctt", - "zips", ] [[package]] @@ -475,12 +444,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "mio" version = "1.0.2" @@ -520,16 +483,6 @@ dependencies = [ "libc", ] -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "num-conv" version = "0.1.0" @@ -768,18 +721,18 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.210" +version = "1.0.215" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" +checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.210" +version = "1.0.215" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" +checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" dependencies = [ "proc-macro2", "quote", @@ -931,9 +884,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.77" +version = "2.0.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed" +checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" dependencies = [ "proc-macro2", "quote", @@ -955,12 +908,6 @@ dependencies = [ "libc", ] -[[package]] -name = "textwrap" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d434d3f8967a09480fb04132ebe0a3e088c173e6d0ee7897abbdf4eab0f8b9" - [[package]] name = "thiserror" version = "1.0.64" @@ -1050,9 +997,9 @@ dependencies = [ [[package]] name = "tree-sitter" -version = "0.24.3" +version = "0.24.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9871f16d6cf5c4757dcf30d5d2172a2df6987c510c017bbb7abfb7f9aa24d06" +checksum = "b67baf55e7e1b6806063b1e51041069c90afff16afcbbccd278d899f9d84bca4" dependencies = [ "cc", "regex", @@ -1063,9 +1010,9 @@ dependencies = [ [[package]] name = "tree-sitter-bash" -version = "0.23.1" +version = "0.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aa5e1c6bd02c0053f3f68edcf5d8866b38a8640584279e30fca88149ce14dda" +checksum = "329a4d48623ac337d42b1df84e81a1c9dbb2946907c102ca72db158c1964a52e" dependencies = [ "cc", "tree-sitter-language", @@ -1073,9 +1020,9 @@ dependencies = [ [[package]] name = "tree-sitter-highlight" -version = "0.24.3" +version = "0.24.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48859aa39513716018d81904220960f415dbb72e071234a721304d20bf245e4c" +checksum = "7f0f856de10d70a6d14d66db3648f7410c131cd49e989a863f15dda9acae6044" dependencies = [ "lazy_static", "regex", @@ -1148,20 +1095,23 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vt100-ctt" -version = "0.15.3" -source = "git+https://github.com/ChrisTitusTech/vt100-rust#39136a6232d043d8447afa7d47805b6f6baa09ee" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298aca8af9c2d1395da31a65eb711abef2dc948e475e819212c8ed5008d52c9f" dependencies = [ "itoa", "log", - "unicode-width 0.1.14", + "ratatui", + "tui-term", + "unicode-width 0.2.0", "vte", ] [[package]] name = "vte" -version = "0.11.1" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197" +checksum = "40eb22ae96f050e0c0d6f7ce43feeae26c348fc4dea56928ca81537cfaa6188b" dependencies = [ "arrayvec", "utf8parse", @@ -1186,9 +1136,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "which" -version = "6.0.3" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" +checksum = "c9cad3279ade7346b96e38731a641d7343dd6a53d55083dd54eadfa5a1b38c6b" dependencies = [ "either", "home", @@ -1319,7 +1269,7 @@ checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" name = "xtask" version = "24.10.31" dependencies = [ - "linutil_core 24.10.31", + "linutil_core", ] [[package]] @@ -1342,14 +1292,3 @@ dependencies = [ "quote", "syn", ] - -[[package]] -name = "zips" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba09194204fda6b1e206faf9096a3c0658ddf7606560f6edce112da3fcc9b111" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] diff --git a/core/Cargo.toml b/core/Cargo.toml index f07d4b77..a3839919 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -10,7 +10,7 @@ include = ["src/*.rs", "Cargo.toml", "tabs/**"] [dependencies] include_dir = "0.7.4" temp-dir = "0.1.14" -serde = { version = "1.0.205", features = ["derive"], default-features = false } +serde = { version = "1.0.215", features = ["derive"], default-features = false } toml = { version = "0.8.19", features = ["parse"], default-features = false } -which = "6.0.3" +which = "7.0.0" ego-tree = "0.9.0" diff --git a/core/src/config.rs b/core/src/config.rs index d4f5e5c5..1c2b1de0 100644 --- a/core/src/config.rs +++ b/core/src/config.rs @@ -1,15 +1,29 @@ +use crate::{ListNode, TabList}; use serde::Deserialize; -use std::path::Path; -use std::process; +use std::{fs, path::Path, process, rc::Rc}; +// Struct that defines what values can be used in the toml file #[derive(Deserialize)] +#[serde(deny_unknown_fields)] pub struct Config { - pub auto_execute: Vec, + #[serde(default)] + auto_execute: Option>, + #[serde(default)] + skip_confirmation: Option, + #[serde(default)] + size_bypass: Option, +} + +// Struct that holds the parsed values from the toml so that it can be applied in the AppState +pub struct ConfigValues { + pub auto_execute_commands: Vec>, + pub skip_confirmation: bool, + pub size_bypass: bool, } impl Config { - pub fn from_file(path: &Path) -> Self { - let content = match std::fs::read_to_string(path) { + pub fn read_config(path: &Path, tabs: &TabList) -> ConfigValues { + let content = match fs::read_to_string(path) { Ok(content) => content, Err(e) => { eprintln!("Failed to read config file {}: {}", path.display(), e); @@ -17,12 +31,29 @@ impl Config { } }; - match toml::from_str(&content) { + let config: Config = match toml::from_str(&content) { Ok(config) => config, Err(e) => { eprintln!("Failed to parse config file: {}", e); process::exit(1); } + }; + + ConfigValues { + auto_execute_commands: config.auto_execute_commands(tabs), + skip_confirmation: config.skip_confirmation.unwrap_or(false), + size_bypass: config.size_bypass.unwrap_or(false), } } + + fn auto_execute_commands(&self, tabs: &TabList) -> Vec> { + self.auto_execute + .as_ref() + .map_or_else(Vec::new, |commands| { + commands + .iter() + .filter_map(|name| tabs.iter().find_map(|tab| tab.find_command_by_name(name))) + .collect() + }) + } } diff --git a/core/src/inner.rs b/core/src/inner.rs index 9d2e7162..0fadae80 100644 --- a/core/src/inner.rs +++ b/core/src/inner.rs @@ -1,3 +1,7 @@ +use crate::{Command, ListNode, Tab}; +use ego_tree::{NodeMut, Tree}; +use include_dir::{include_dir, Dir}; +use serde::Deserialize; use std::{ fs::File, io::{BufRead, BufReader, Read}, @@ -6,11 +10,6 @@ use std::{ path::{Path, PathBuf}, rc::Rc, }; - -use crate::{Command, ListNode, Tab}; -use ego_tree::{NodeMut, Tree}; -use include_dir::{include_dir, Dir}; -use serde::Deserialize; use temp_dir::TempDir; const TAB_DATA: Dir = include_dir!("$CARGO_MANIFEST_DIR/tabs"); diff --git a/core/src/lib.rs b/core/src/lib.rs index 986d9ac1..852a3ab6 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -7,7 +7,7 @@ pub use ego_tree; use ego_tree::Tree; use std::path::PathBuf; -pub use config::Config; +pub use config::{Config, ConfigValues}; pub use inner::{get_tabs, TabList}; #[derive(Clone, Hash, Eq, PartialEq)] @@ -38,14 +38,10 @@ pub struct ListNode { } impl Tab { - pub fn find_command(&self, name: &str) -> Option> { + fn find_command_by_name(&self, name: &str) -> Option> { self.tree.root().descendants().find_map(|node| { - let value = node.value(); - if value.name == name && !node.has_children() { - Some(value.clone()) - } else { - None - } + let node_value = node.value(); + (node_value.name == name && !node.has_children()).then_some(node_value.clone()) }) } } diff --git a/tui/Cargo.toml b/tui/Cargo.toml index 35a02a30..afcf9ed1 100644 --- a/tui/Cargo.toml +++ b/tui/Cargo.toml @@ -19,19 +19,14 @@ oneshot = { version = "0.1.8", features = ["std"], default-features = false } portable-pty = "0.8.1" ratatui = { version = "0.29.0", features = ["crossterm"], default-features = false } tui-term = { version = "0.2.0", default-features = false } -temp-dir = "0.1.14" time = { version = "0.3.36", features = ["formatting", "local-offset", "macros"], default-features = false } unicode-width = { version = "0.2.0", default-features = false } rand = { version = "0.8.5", optional = true } -linutil_core = { version = "24.10.31" } -tree-sitter-highlight = "0.24.3" -tree-sitter-bash = "0.23.1" -textwrap = { version = "0.16.1", default-features = false } -anstyle = { version = "1.0.8", default-features = false } -ansi-to-tui = { version = "7.0.0", default-features = false } -zips = "0.1.7" +linutil_core = { version = "24.10.31", path = "../core" } +tree-sitter-highlight = "0.24.4" +tree-sitter-bash = "0.23.3" nix = { version = "0.29.0", features = [ "user" ] } -vt100-ctt = { git = "https://github.com/ChrisTitusTech/vt100-rust" } +vt100-ctt = "0.16.0" [[bin]] name = "linutil" diff --git a/tui/src/confirmation.rs b/tui/src/confirmation.rs index 2ed5898b..3a4e9578 100644 --- a/tui/src/confirmation.rs +++ b/tui/src/confirmation.rs @@ -1,13 +1,12 @@ -use std::borrow::Cow; - -use crate::{float::FloatContent, hint::Shortcut}; - +use crate::{float::FloatContent, hint::Shortcut, theme}; use ratatui::{ crossterm::event::{KeyCode, KeyEvent, MouseEvent, MouseEventKind}, layout::Alignment, prelude::*, - widgets::{Block, Borders, Clear, List}, + symbols::border, + widgets::{Block, Clear, List}, }; +use std::borrow::Cow; pub enum ConfirmStatus { Confirm, @@ -16,9 +15,10 @@ pub enum ConfirmStatus { } pub struct ConfirmPrompt { - pub names: Box<[String]>, - pub status: ConfirmStatus, + inner_area_height: usize, + names: Box<[String]>, scroll: usize, + pub status: ConfirmStatus, } impl ConfirmPrompt { @@ -37,14 +37,15 @@ impl ConfirmPrompt { .collect(); Self { + inner_area_height: 0, names, - status: ConfirmStatus::None, scroll: 0, + status: ConfirmStatus::None, } } pub fn scroll_down(&mut self) { - if self.scroll < self.names.len() - 1 { + if self.scroll + self.inner_area_height < self.names.len() - 1 { self.scroll += 1; } } @@ -57,19 +58,26 @@ impl ConfirmPrompt { } impl FloatContent for ConfirmPrompt { - fn draw(&mut self, frame: &mut Frame, area: Rect) { - let block = Block::default() - .borders(Borders::ALL) - .border_set(ratatui::symbols::border::ROUNDED) + fn draw(&mut self, frame: &mut Frame, area: Rect, theme: &theme::Theme) { + let block = Block::bordered() + .border_set(border::ROUNDED) .title(" Confirm selections ") - .title_bottom(" [y] to continue, [n] to abort ") + .title_bottom(Line::from(vec![ + Span::raw(" ["), + Span::styled("y", Style::default().fg(theme.success_color())), + Span::raw("] to continue ["), + Span::styled("n", Style::default().fg(theme.fail_color())), + Span::raw("] to abort "), + ])) .title_alignment(Alignment::Center) .title_style(Style::default().bold()) .style(Style::default()); - frame.render_widget(block.clone(), area); - let inner_area = block.inner(area); + self.inner_area_height = inner_area.height as usize; + + frame.render_widget(Clear, area); + frame.render_widget(block, area); let paths_text = self .names @@ -81,7 +89,6 @@ impl FloatContent for ConfirmPrompt { }) .collect::(); - frame.render_widget(Clear, inner_area); frame.render_widget(List::new(paths_text), inner_area); } @@ -99,21 +106,21 @@ impl FloatContent for ConfirmPrompt { } fn handle_key_event(&mut self, key: &KeyEvent) -> bool { - use KeyCode::*; + use ConfirmStatus::*; + use KeyCode::{Char, Down, Esc, Up}; self.status = match key.code { - Char('y') | Char('Y') => ConfirmStatus::Confirm, - Char('n') | Char('N') | Esc | Char('q') => ConfirmStatus::Abort, - Char('j') => { + Char('y') | Char('Y') => Confirm, + Char('n') | Char('N') | Esc | Char('q') => Abort, + Char('j') | Char('J') | Down => { self.scroll_down(); - ConfirmStatus::None + None } - Char('k') => { + Char('k') | Char('K') | Up => { self.scroll_up(); - ConfirmStatus::None + None } - _ => ConfirmStatus::None, + _ => None, }; - false } @@ -131,8 +138,8 @@ impl FloatContent for ConfirmPrompt { Box::new([ Shortcut::new("Continue", ["Y", "y"]), Shortcut::new("Abort", ["N", "n", "q", "Esc"]), - Shortcut::new("Scroll up", ["k"]), - Shortcut::new("Scroll down", ["j"]), + Shortcut::new("Scroll up", ["k", "Up"]), + Shortcut::new("Scroll down", ["j", "Down"]), Shortcut::new("Close linutil", ["CTRL-c"]), ]), ) diff --git a/tui/src/filter.rs b/tui/src/filter.rs index f44e89a1..734db164 100644 --- a/tui/src/filter.rs +++ b/tui/src/filter.rs @@ -2,11 +2,9 @@ use crate::{state::ListEntry, theme::Theme}; use linutil_core::{ego_tree::NodeId, Tab}; use ratatui::{ crossterm::event::{KeyCode, KeyEvent, KeyModifiers}, - layout::{Position, Rect}, - style::{Color, Style}, - text::Span, - widgets::{Block, Borders, Paragraph}, - Frame, + prelude::*, + symbols::border, + widgets::{Block, Paragraph}, }; use unicode_width::UnicodeWidthChar; @@ -17,10 +15,12 @@ pub enum SearchAction { } pub struct Filter { + // Use Vec to handle multi-byte characters like emojis search_input: Vec, in_search_mode: bool, input_position: usize, items: Vec, + // No complex string manipulation is done with completion_preview so we can use String unlike search_input completion_preview: Option, } @@ -62,13 +62,11 @@ impl Filter { .collect(); } else { self.items.clear(); - let query_lower = self.search_input.iter().collect::().to_lowercase(); - for tab in tabs.iter() { + for tab in tabs { let mut stack = vec![tab.tree.root().id()]; while let Some(node_id) = stack.pop() { let node = tab.tree.get(node_id).unwrap(); - if node.value().name.to_lowercase().contains(&query_lower) && !node.has_children() { @@ -78,31 +76,26 @@ impl Filter { has_children: false, }); } - stack.extend(node.children().map(|child| child.id())); } } - self.items.sort_by(|a, b| a.node.name.cmp(&b.node.name)); + self.items + .sort_unstable_by(|a, b| a.node.name.cmp(&b.node.name)); } - self.update_completion_preview(); } fn update_completion_preview(&mut self) { - if self.search_input.is_empty() { - self.completion_preview = None; - return; + self.completion_preview = if self.items.is_empty() || self.search_input.is_empty() { + None + } else { + let input = self.search_input.iter().collect::().to_lowercase(); + self.items.iter().find_map(|item| { + let item_name_lower = item.node.name.to_lowercase(); + (item_name_lower.starts_with(&input)) + .then_some(item_name_lower[input.len()..].to_string()) + }) } - - let input = self.search_input.iter().collect::().to_lowercase(); - self.completion_preview = self.items.iter().find_map(|item| { - let item_name_lower = item.node.name.to_lowercase(); - if item_name_lower.starts_with(&input) { - Some(item_name_lower[input.len()..].to_string()) - } else { - None - } - }); } pub fn draw_searchbar(&self, frame: &mut Frame, area: Rect, theme: &Theme) { @@ -123,9 +116,8 @@ impl Filter { //Create the search bar widget let search_bar = Paragraph::new(display_text) .block( - Block::default() - .borders(Borders::ALL) - .border_set(ratatui::symbols::border::ROUNDED) + Block::bordered() + .border_set(border::ROUNDED) .title(" Search "), ) .style(Style::default().fg(search_color)); @@ -135,24 +127,32 @@ impl Filter { // Render cursor in search bar if self.in_search_mode { - let cursor_position: usize = self.search_input[..self.input_position] + // Calculate the visual width of search input so that completion preview can be displayed after the search input + let search_input_size: u16 = self + .search_input .iter() - .map(|c| c.width().unwrap_or(1)) + .map(|c| c.width().unwrap_or(1) as u16) .sum(); - let x = area.x + cursor_position as u16 + 1; + + let cursor_position: u16 = self.search_input[..self.input_position] + .iter() + .map(|c| c.width().unwrap_or(1) as u16) + .sum(); + let x = area.x + cursor_position + 1; let y = area.y + 1; frame.set_cursor_position(Position::new(x, y)); if let Some(preview) = &self.completion_preview { - let preview_span = Span::styled(preview, Style::default().fg(Color::DarkGray)); - let preview_paragraph = Paragraph::new(preview_span).style(Style::default()); + let preview_x = area.x + search_input_size + 1; + let preview_span = + Span::styled(preview, Style::default().fg(theme.search_preview_color())); let preview_area = Rect::new( - x, + preview_x, y, - (preview.len() as u16).min(area.width - cursor_position as u16 - 1), + (preview.len() as u16).min(area.width - search_input_size - 1), // Ensure the completion preview stays within the search bar bounds 1, ); - frame.render_widget(preview_paragraph, preview_area); + frame.render_widget(Paragraph::new(preview_span), preview_area); } } } @@ -220,10 +220,18 @@ impl Filter { } fn complete_search(&mut self) -> SearchAction { - if let Some(completion) = self.completion_preview.take() { - self.search_input.extend(completion.chars()); + if self.completion_preview.is_some() { + let input = &self.search_input.iter().collect::().to_lowercase(); + if let Some(search_completion) = self + .items + .iter() + .find(|item| item.node.name.to_lowercase().starts_with(input)) + { + self.search_input = search_completion.node.name.chars().collect(); + } + self.input_position = self.search_input.len(); - self.update_completion_preview(); + self.completion_preview = None; SearchAction::Update } else { SearchAction::None diff --git a/tui/src/float.rs b/tui/src/float.rs index 4d6ac006..9f29bff3 100644 --- a/tui/src/float.rs +++ b/tui/src/float.rs @@ -1,13 +1,12 @@ +use crate::{hint::Shortcut, theme::Theme}; use ratatui::{ crossterm::event::{KeyCode, KeyEvent, MouseEvent}, - layout::{Constraint, Direction, Layout, Rect}, + layout::{Constraint, Layout, Rect}, Frame, }; -use crate::hint::Shortcut; - pub trait FloatContent { - fn draw(&mut self, frame: &mut Frame, area: Rect); + fn draw(&mut self, frame: &mut Frame, area: Rect, theme: &Theme); fn handle_key_event(&mut self, key: &KeyEvent) -> bool; fn handle_mouse_event(&mut self, key: &MouseEvent) -> bool; fn is_finished(&self) -> bool; @@ -30,28 +29,24 @@ impl Float { } fn floating_window(&self, size: Rect) -> Rect { - let hor_float = Layout::default() - .constraints([ - Constraint::Percentage((100 - self.width_percent) / 2), - Constraint::Percentage(self.width_percent), - Constraint::Percentage((100 - self.width_percent) / 2), - ]) - .direction(Direction::Horizontal) - .split(size)[1]; + let hor_float = Layout::horizontal([ + Constraint::Percentage((100 - self.width_percent) / 2), + Constraint::Percentage(self.width_percent), + Constraint::Percentage((100 - self.width_percent) / 2), + ]) + .split(size)[1]; - Layout::default() - .constraints([ - Constraint::Percentage((100 - self.height_percent) / 2), - Constraint::Percentage(self.height_percent), - Constraint::Percentage((100 - self.height_percent) / 2), - ]) - .direction(Direction::Vertical) - .split(hor_float)[1] + Layout::vertical([ + Constraint::Percentage((100 - self.height_percent) / 2), + Constraint::Percentage(self.height_percent), + Constraint::Percentage((100 - self.height_percent) / 2), + ]) + .split(hor_float)[1] } - pub fn draw(&mut self, frame: &mut Frame, parent_area: Rect) { + pub fn draw(&mut self, frame: &mut Frame, parent_area: Rect, theme: &Theme) { let popup_area = self.floating_window(parent_area); - self.content.draw(frame, popup_area); + self.content.draw(frame, popup_area, theme); } pub fn handle_mouse_event(&mut self, event: &MouseEvent) { diff --git a/tui/src/floating_text.rs b/tui/src/floating_text.rs index 6a2546cc..c2077de5 100644 --- a/tui/src/floating_text.rs +++ b/tui/src/floating_text.rs @@ -1,48 +1,21 @@ -use std::{ - borrow::Cow, - collections::VecDeque, - io::{Cursor, Read as _, Seek, SeekFrom, Write as _}, -}; - -use crate::{float::FloatContent, hint::Shortcut}; - +use crate::{float::FloatContent, hint::Shortcut, theme::Theme}; use linutil_core::Command; - use ratatui::{ crossterm::event::{KeyCode, KeyEvent, MouseEvent, MouseEventKind}, - layout::Rect, - style::{Style, Stylize}, - text::Line, - widgets::{Block, Borders, Clear, List}, - Frame, + prelude::*, + symbols::border, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, }; - -use ansi_to_tui::IntoText; - -use textwrap::wrap; use tree_sitter_bash as hl_bash; use tree_sitter_highlight::{self as hl, HighlightEvent}; -use zips::zip_result; - -pub struct FloatingText { - pub src: String, - wrapped_lines: Vec, - max_line_width: usize, - v_scroll: usize, - h_scroll: usize, - mode_title: String, - wrap_words: bool, - frame_height: usize, -} macro_rules! style { ($r:literal, $g:literal, $b:literal) => {{ - use anstyle::{Color, RgbColor, Style}; - Style::new().fg_color(Some(Color::Rgb(RgbColor($r, $g, $b)))) + Style::new().fg(Color::Rgb($r, $g, $b)) }}; } -const SYNTAX_HIGHLIGHT_STYLES: [(&str, anstyle::Style); 8] = [ +const SYNTAX_HIGHLIGHT_STYLES: [(&str, Style); 8] = [ ("function", style!(220, 220, 170)), // yellow ("string", style!(206, 145, 120)), // brown ("property", style!(156, 220, 254)), // light blue @@ -53,234 +26,176 @@ const SYNTAX_HIGHLIGHT_STYLES: [(&str, anstyle::Style); 8] = [ ("number", style!(181, 206, 168)), // light green ]; -fn get_highlighted_string(s: &str) -> Option { - let mut hl_conf = hl::HighlightConfiguration::new( - hl_bash::LANGUAGE.into(), - "bash", - hl_bash::HIGHLIGHT_QUERY, - "", - "", - ) - .ok()?; - - let matched_tokens = &SYNTAX_HIGHLIGHT_STYLES - .iter() - .map(|hl| hl.0) - .collect::>(); - - hl_conf.configure(matched_tokens); - - let mut hl = hl::Highlighter::new(); - - let mut style_stack = vec![anstyle::Style::new()]; - let src = s.as_bytes(); - - let events = hl.highlight(&hl_conf, src, None, |_| None).ok()?; - - let mut buf = Cursor::new(vec![]); - - for event in events { - match event.unwrap() { - HighlightEvent::HighlightStart(h) => { - style_stack.push(SYNTAX_HIGHLIGHT_STYLES.get(h.0)?.1); - } - - HighlightEvent::HighlightEnd => { - style_stack.pop(); - } - - HighlightEvent::Source { start, end } => { - let style = style_stack.last()?; - zip_result!( - write!(&mut buf, "{}", style), - buf.write_all(&src[start..end]), - write!(&mut buf, "{style:#}"), - )?; - } - } - } - - let mut output = String::new(); - - zip_result!( - buf.seek(SeekFrom::Start(0)), - buf.read_to_string(&mut output), - )?; - - Some(output) +pub struct FloatingText<'a> { + // Width, Height + inner_area_size: (usize, usize), + mode_title: String, + // Cache the text to avoid reprocessing it every frame + processed_text: Text<'a>, + // Vertical, Horizontal + scroll: (u16, u16), + wrap_words: bool, } -#[inline] -fn get_lines(s: &str) -> Vec<&str> { - s.lines().collect::>() -} - -#[inline] -fn get_lines_owned(s: &str) -> Vec { - get_lines(s).iter().map(|s| s.to_string()).collect() -} - -impl FloatingText { +impl<'a> FloatingText<'a> { pub fn new(text: String, title: &str, wrap_words: bool) -> Self { - let max_line_width = 80; - let wrapped_lines = if wrap_words { - wrap(&text, max_line_width) - .into_iter() - .map(|cow| cow.into_owned()) - .collect() - } else { - get_lines_owned(&text) - }; + let processed_text = Text::from(text); Self { - src: text, - wrapped_lines, + inner_area_size: (0, 0), mode_title: title.to_string(), - max_line_width, - v_scroll: 0, - h_scroll: 0, + processed_text, + scroll: (0, 0), wrap_words, - frame_height: 0, } } - pub fn from_command(command: &Command, title: String) -> Option { + pub fn from_command(command: &Command, title: &str, wrap_words: bool) -> Self { let src = match command { Command::Raw(cmd) => Some(cmd.clone()), Command::LocalFile { file, .. } => std::fs::read_to_string(file) .map_err(|_| format!("File not found: {:?}", file)) .ok(), Command::None => None, - }?; + } + .unwrap(); - let max_line_width = 80; - let wrapped_lines = get_lines_owned(&get_highlighted_string(&src)?); + let processed_text = Self::get_highlighted_string(&src).unwrap_or_else(|| Text::from(src)); - Some(Self { - src, - wrapped_lines, - mode_title: title, - max_line_width, - h_scroll: 0, - v_scroll: 0, - wrap_words: false, - frame_height: 0, - }) + Self { + inner_area_size: (0, 0), + mode_title: title.to_string(), + processed_text, + scroll: (0, 0), + wrap_words, + } + } + + fn get_highlighted_string(s: &str) -> Option> { + let matched_tokens = SYNTAX_HIGHLIGHT_STYLES + .iter() + .map(|hl| hl.0) + .collect::>(); + + let mut lines = Vec::with_capacity(s.lines().count()); + let mut current_line = Vec::new(); + let mut style_stack = vec![Style::default()]; + + let mut hl_conf = hl::HighlightConfiguration::new( + hl_bash::LANGUAGE.into(), + "bash", + hl_bash::HIGHLIGHT_QUERY, + "", + "", + ) + .ok()?; + + hl_conf.configure(&matched_tokens); + + let mut hl = hl::Highlighter::new(); + let events = hl.highlight(&hl_conf, s.as_bytes(), None, |_| None).ok()?; + + for event in events { + match event.ok()? { + HighlightEvent::HighlightStart(h) => { + style_stack.push(SYNTAX_HIGHLIGHT_STYLES.get(h.0)?.1); + } + + HighlightEvent::HighlightEnd => { + style_stack.pop(); + } + + HighlightEvent::Source { start, end } => { + let style = *style_stack.last()?; + let content = &s[start..end]; + + for part in content.split_inclusive('\n') { + if let Some(stripped) = part.strip_suffix('\n') { + // Push the text that is before '\n' and then start a new line + // After a new line clear the current line to start a new one + current_line.push(Span::styled(stripped.to_owned(), style)); + lines.push(Line::from(current_line.to_owned())); + current_line.clear(); + } else { + current_line.push(Span::styled(part.to_owned(), style)); + } + } + } + } + } + + // Makes sure last line of the file is pushed + // If no newline at the end of the file we need to push the last line + if !current_line.is_empty() { + lines.push(Line::from(current_line)); + } + + if lines.is_empty() { + return None; + } + + Some(Text::from(lines)) } fn scroll_down(&mut self) { - let visible_lines = self.frame_height.saturating_sub(2); - if self.v_scroll + visible_lines < self.wrapped_lines.len() { - self.v_scroll += 1; - } + let max_scroll = self + .processed_text + .lines + .len() + .saturating_sub(self.inner_area_size.1) as u16; + self.scroll.0 = (self.scroll.0 + 1).min(max_scroll); } fn scroll_up(&mut self) { - if self.v_scroll > 0 { - self.v_scroll -= 1; - } + self.scroll.0 = self.scroll.0.saturating_sub(1); } fn scroll_left(&mut self) { - if self.h_scroll > 0 { - self.h_scroll -= 1; - } + self.scroll.1 = self.scroll.1.saturating_sub(1); } fn scroll_right(&mut self) { - if self.h_scroll + 1 < self.max_line_width { - self.h_scroll += 1; - } - } - - fn update_wrapping(&mut self, width: usize) { - if self.max_line_width != width { - self.max_line_width = width; - self.wrapped_lines = if self.wrap_words { - wrap(&self.src, width) - .into_iter() - .map(|cow| cow.into_owned()) - .collect() - } else { - get_lines_owned(&get_highlighted_string(&self.src).unwrap_or(self.src.clone())) - }; - } + let visible_length = self.inner_area_size.0.saturating_sub(1); + let max_scroll = if self.wrap_words { + 0 + } else { + self.processed_text + .lines + .iter() + .map(|line| line.width()) + .max() + .unwrap_or(0) + .saturating_sub(visible_length) as u16 + }; + self.scroll.1 = (self.scroll.1 + 1).min(max_scroll); } } -impl FloatContent for FloatingText { - fn draw(&mut self, frame: &mut Frame, area: Rect) { - self.frame_height = area.height as usize; - - // Define the Block with a border and background color +impl<'a> FloatContent for FloatingText<'a> { + fn draw(&mut self, frame: &mut Frame, area: Rect, _theme: &Theme) { let block = Block::default() .borders(Borders::ALL) - .border_set(ratatui::symbols::border::ROUNDED) - .title(self.mode_title.clone()) - .title_alignment(ratatui::layout::Alignment::Center) + .border_set(border::ROUNDED) + .title(self.mode_title.as_str()) + .title_alignment(Alignment::Center) .title_style(Style::default().reversed()) .style(Style::default()); - frame.render_widget(Clear, area); - - frame.render_widget(block.clone(), area); - - // Calculate the inner area to ensure text is not drawn over the border let inner_area = block.inner(area); - let Rect { width, height, .. } = inner_area; + self.inner_area_size = (inner_area.width as usize, inner_area.height as usize); - self.update_wrapping(width as usize); + frame.render_widget(Clear, area); + frame.render_widget(block, area); - let lines = self - .wrapped_lines - .iter() - .skip(self.v_scroll) - .take(height as usize) - .flat_map(|l| { - if self.wrap_words { - vec![Line::raw(l.clone())] - } else { - l.into_text().unwrap().lines - } - }) - .map(|line| { - let mut skipped = 0; - let mut spans = line - .into_iter() - .skip_while(|span| { - let skip = (skipped + span.content.len()) <= self.h_scroll; - if skip { - skipped += span.content.len(); - true - } else { - false - } - }) - .collect::>(); + let paragraph = if self.wrap_words { + Paragraph::new(self.processed_text.clone()) + .scroll(self.scroll) + .wrap(Wrap { trim: false }) + } else { + Paragraph::new(self.processed_text.clone()).scroll(self.scroll) + }; - if spans.is_empty() { - Line::raw(Cow::Owned(String::new())) - } else { - if skipped < self.h_scroll { - let to_split = spans.pop_front().unwrap(); - let new_content = to_split.content.clone().into_owned() - [self.h_scroll - skipped..] - .to_owned(); - spans.push_front(to_split.content(Cow::Owned(new_content))); - } - - Line::from(Vec::from(spans)) - } - }) - .collect::>(); - - // Create list widget - let list = List::new(lines) - .block(Block::default()) - .highlight_style(Style::default().reversed()); - - // Render the list inside the bordered area - frame.render_widget(list, inner_area); + frame.render_widget(paragraph, inner_area); } fn handle_mouse_event(&mut self, event: &MouseEvent) -> bool { @@ -295,12 +210,12 @@ impl FloatContent for FloatingText { } fn handle_key_event(&mut self, key: &KeyEvent) -> bool { - use KeyCode::*; + use KeyCode::{Char, Down, Left, Right, Up}; match key.code { - Down | Char('j') => self.scroll_down(), - Up | Char('k') => self.scroll_up(), - Left | Char('h') => self.scroll_left(), - Right | Char('l') => self.scroll_right(), + Down | Char('j') | Char('J') => self.scroll_down(), + Up | Char('k') | Char('K') => self.scroll_up(), + Left | Char('h') | Char('H') => self.scroll_left(), + Right | Char('l') | Char('L') => self.scroll_right(), _ => {} } false diff --git a/tui/src/hint.rs b/tui/src/hint.rs index 82c265c8..0f77f7a5 100644 --- a/tui/src/hint.rs +++ b/tui/src/hint.rs @@ -1,13 +1,12 @@ -use std::borrow::Cow; - use ratatui::{ style::{Style, Stylize}, text::{Line, Span}, }; +use std::borrow::Cow; pub struct Shortcut { - pub key_sequences: Vec>, - pub desc: &'static str, + key_sequences: Vec>, + desc: &'static str, } fn add_spacing(list: Vec>) -> Line { @@ -19,7 +18,7 @@ fn add_spacing(list: Vec>) -> Line { .collect() } -pub fn span_vec_len(span_vec: &[Span]) -> usize { +fn span_vec_len(span_vec: &[Span]) -> usize { span_vec.iter().rfold(0, |init, s| init + s.width()) } @@ -39,7 +38,7 @@ pub fn create_shortcut_list( let columns = (render_width as usize / (max_shortcut_width + 4)).max(1); let rows = (shortcut_spans.len() + columns - 1) / columns; - let mut lines: Vec> = Vec::new(); + let mut lines: Vec> = Vec::with_capacity(rows); for row in 0..rows { let row_spans: Vec<_> = (0..columns) @@ -74,13 +73,7 @@ impl Shortcut { let description = Span::styled(self.desc, Style::default().italic()); self.key_sequences .iter() - .flat_map(|seq| { - [ - Span::default().content("["), - seq.clone(), - Span::default().content("] "), - ] - }) + .flat_map(|seq| [Span::raw("["), seq.clone(), Span::raw("] ")]) .chain(std::iter::once(description)) .collect() } diff --git a/tui/src/main.rs b/tui/src/main.rs index d391f9e6..ee0c3235 100644 --- a/tui/src/main.rs +++ b/tui/src/main.rs @@ -8,15 +8,11 @@ mod running_command; pub mod state; mod theme; -use std::{ - io::{self, stdout}, - path::PathBuf, - time::Duration, -}; +#[cfg(feature = "tips")] +mod tips; use crate::theme::Theme; use clap::Parser; - use ratatui::{ backend::CrosstermBackend, crossterm::{ @@ -28,10 +24,15 @@ use ratatui::{ Terminal, }; use state::AppState; +use std::{ + io::{stdout, Result, Stdout}, + path::PathBuf, + time::Duration, +}; // Linux utility toolbox #[derive(Debug, Parser)] -struct Args { +pub struct Args { #[arg(short, long, help = "Path to the configuration file")] config: Option, #[arg(short, long, value_enum)] @@ -52,16 +53,10 @@ struct Args { size_bypass: bool, } -fn main() -> io::Result<()> { +fn main() -> Result<()> { let args = Args::parse(); - let mut state = AppState::new( - args.config, - args.theme, - args.override_validation, - args.size_bypass, - args.skip_confirmation, - ); + let mut state = AppState::new(args); stdout().execute(EnterAlternateScreen)?; stdout().execute(EnableMouseCapture)?; @@ -82,10 +77,7 @@ fn main() -> io::Result<()> { Ok(()) } -fn run( - terminal: &mut Terminal>, - state: &mut AppState, -) -> io::Result<()> { +fn run(terminal: &mut Terminal>, state: &mut AppState) -> Result<()> { loop { terminal.draw(|frame| state.draw(frame)).unwrap(); // Wait for an event diff --git a/tui/src/root.rs b/tui/src/root.rs index 1b02b938..46432d06 100644 --- a/tui/src/root.rs +++ b/tui/src/root.rs @@ -8,7 +8,7 @@ This means you have full system access and commands can potentially damage your Please proceed with caution and make sure you understand what each script does before executing it."; #[cfg(unix)] -pub fn check_root_status() -> Option { +pub fn check_root_status<'a>() -> Option> { (Uid::effective().is_root()).then_some(FloatingText::new( ROOT_WARNING.into(), "Root User Warning", diff --git a/tui/src/running_command.rs b/tui/src/running_command.rs index d45c37c5..d2342826 100644 --- a/tui/src/running_command.rs +++ b/tui/src/running_command.rs @@ -1,4 +1,4 @@ -use crate::{float::FloatContent, hint::Shortcut}; +use crate::{float::FloatContent, hint::Shortcut, theme::Theme}; use linutil_core::Command; use oneshot::{channel, Receiver}; use portable_pty::{ @@ -6,14 +6,13 @@ use portable_pty::{ }; use ratatui::{ crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}, - layout::{Rect, Size}, - style::{Color, Style, Stylize}, - text::{Line, Span}, - widgets::{Block, Borders}, - Frame, + prelude::*, + symbols::border, + widgets::Block, }; use std::{ - io::Write, + fs::File, + io::{Result, Write}, sync::{Arc, Mutex}, thread::JoinHandle, }; @@ -41,60 +40,43 @@ pub struct RunningCommand { } impl FloatContent for RunningCommand { - fn draw(&mut self, frame: &mut Frame, area: Rect) { - // Calculate the inner size of the terminal area, considering borders - let inner_size = Size { - width: area.width - 2, // Adjust for border width - height: area.height - 2, - }; - + fn draw(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) { // Define the block for the terminal display let block = if !self.is_finished() { // Display a block indicating the command is running - Block::default() - .borders(Borders::ALL) - .border_set(ratatui::symbols::border::ROUNDED) + Block::bordered() + .border_set(border::ROUNDED) .title_top(Line::from("Running the command....").centered()) .title_style(Style::default().reversed()) .title_bottom(Line::from("Press Ctrl-C to KILL the command")) } else { // Display a block with the command's exit status - let mut title_line = if self.get_exit_status().success() { - Line::from( - Span::default() - .content("SUCCESS!") - .style(Style::default().fg(Color::Green).reversed()), + let title_line = if self.get_exit_status().success() { + Line::styled( + "SUCCESS! Press to close this window", + Style::default().fg(theme.success_color()).reversed(), ) } else { - Line::from( - Span::default() - .content("FAILED!") - .style(Style::default().fg(Color::Red).reversed()), + Line::styled( + "FAILED! Press to close this window", + Style::default().fg(theme.fail_color()).reversed(), ) }; - title_line.push_span( - Span::default() - .content(" Press to close this window ") - .style(Style::default()), - ); - - let mut block = Block::default() - .borders(Borders::ALL) - .border_set(ratatui::symbols::border::ROUNDED) - .title_top(title_line.centered()); - - if let Some(log_path) = &self.log_path { - block = - block.title_bottom(Line::from(format!(" Log saved: {} ", log_path)).centered()); + let log_path = if let Some(log_path) = &self.log_path { + Line::from(format!(" Log saved: {} ", log_path)) } else { - block = - block.title_bottom(Line::from(" Press 'l' to save command log ").centered()); - } + Line::from(" Press 'l' to save command log ") + }; - block + Block::bordered() + .border_set(border::ROUNDED) + .title_top(title_line.centered()) + .title_bottom(log_path.centered()) }; + // Calculate the inner size of the terminal area, considering borders + let inner_size = block.inner(area).as_size(); // Process the buffer and create the pseudo-terminal widget let screen = self.screen(inner_size); let pseudo_term = PseudoTerminal::new(&screen).block(block); @@ -178,7 +160,7 @@ impl FloatContent for RunningCommand { } impl RunningCommand { - pub fn new(commands: Vec) -> Self { + pub fn new(commands: &[&Command]) -> Self { let pty_system = NativePtySystem::default(); // Build the command based on the provided Command enum variant @@ -198,10 +180,10 @@ impl RunningCommand { if let Some(parent_directory) = file.parent() { script.push_str(&format!("cd {}\n", parent_directory.display())); } - script.push_str(&executable); + script.push_str(executable); for arg in args { script.push(' '); - script.push_str(&arg); + script.push_str(arg); } script.push('\n'); // Ensures that each command is properly separated for execution preventing directory errors } @@ -313,7 +295,7 @@ impl RunningCommand { } } - fn save_log(&self) -> std::io::Result { + fn save_log(&self) -> Result { let mut log_path = std::env::temp_dir(); let date_format = format_description!("[year]-[month]-[day]-[hour]-[minute]-[second]"); log_path.push(format!( @@ -324,7 +306,7 @@ impl RunningCommand { .unwrap() )); - let mut file = std::fs::File::create(&log_path)?; + let mut file = File::create(&log_path)?; let buffer = self.buffer.lock().unwrap(); file.write_all(&buffer)?; diff --git a/tui/src/state.rs b/tui/src/state.rs index 7f96aee9..cec9cc3c 100644 --- a/tui/src/state.rs +++ b/tui/src/state.rs @@ -7,24 +7,22 @@ use crate::{ root::check_root_status, running_command::RunningCommand, theme::Theme, + Args, }; - -use linutil_core::{ego_tree::NodeId, Config, ListNode, TabList}; -#[cfg(feature = "tips")] -use rand::Rng; +use linutil_core::{ego_tree::NodeId, Command, Config, ConfigValues, ListNode, TabList}; use ratatui::{ crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind}, - layout::{Alignment, Constraint, Direction, Flex, Layout, Position, Rect}, - style::{Style, Stylize}, - text::{Line, Span, Text}, - widgets::{Block, Borders, List, ListState, Paragraph}, - Frame, + layout::Flex, + prelude::*, + symbols::border, + widgets::{Block, List, ListState, Paragraph}, }; -use std::path::PathBuf; use std::rc::Rc; const MIN_WIDTH: u16 = 100; const MIN_HEIGHT: u16 = 25; +const FLOAT_SIZE: u16 = 80; +const CONFIRM_PROMPT_FLOAT_SIZE: u16 = 40; const TITLE: &str = concat!(" Linux Toolbox - ", env!("CARGO_PKG_VERSION"), " "); const ACTIONS_GUIDE: &str = "List of important tasks performed by commands' names: @@ -47,11 +45,12 @@ pub struct AppState { /// Selected theme theme: Theme, /// Currently focused area - pub focus: Focus, + focus: Focus, /// List of tabs tabs: TabList, /// Current tab current_tab: ListState, + longest_tab_display_len: u16, /// This stack keeps track of our "current directory". You can think of it as `pwd`. but not /// just the current directory, all paths that took us here, so we can "cd .." visit_stack: Vec<(NodeId, usize)>, @@ -63,7 +62,7 @@ pub struct AppState { selected_commands: Vec>, drawable: bool, #[cfg(feature = "tips")] - tip: String, + tip: &'static str, size_bypass: bool, skip_confirmation: bool, } @@ -82,7 +81,7 @@ pub struct ListEntry { pub has_children: bool, } -pub struct Areas { +struct Areas { tab_list: Rect, list: Rect, } @@ -95,24 +94,23 @@ enum SelectedItem { } impl AppState { - pub fn new( - config_path: Option, - theme: Theme, - override_validation: bool, - size_bypass: bool, - skip_confirmation: bool, - ) -> Self { - let tabs = linutil_core::get_tabs(!override_validation); + pub fn new(args: Args) -> Self { + let tabs = linutil_core::get_tabs(!args.override_validation); let root_id = tabs[0].tree.root().id(); - let auto_execute_commands = config_path.map(|path| Config::from_file(&path).auto_execute); + let longest_tab_display_len = tabs + .iter() + .map(|tab| tab.name.len() + args.theme.tab_icon().len()) + .max() + .unwrap_or(22) as u16; // 22 is the length of "Linutil by Chris Titus" title let mut state = Self { areas: None, - theme, + theme: args.theme, focus: Focus::List, tabs, current_tab: ListState::default().with_selected(Some(0)), + longest_tab_display_len, visit_stack: vec![(root_id, 0usize)], selection: ListState::default().with_selected(Some(0)), filter: Filter::new(), @@ -120,40 +118,55 @@ impl AppState { selected_commands: Vec::new(), drawable: false, #[cfg(feature = "tips")] - tip: get_random_tip(), - size_bypass, - skip_confirmation, + tip: crate::tips::get_random_tip(), + size_bypass: args.size_bypass, + skip_confirmation: args.skip_confirmation, }; #[cfg(unix)] if let Some(root_warning) = check_root_status() { - state.spawn_float(root_warning, 60, 40); + state.spawn_float(root_warning, FLOAT_SIZE, FLOAT_SIZE); } state.update_items(); - if let Some(auto_execute_commands) = auto_execute_commands { - state.handle_initial_auto_execute(&auto_execute_commands); + + if let Some(config_path) = args.config { + let config = Config::read_config(&config_path, &state.tabs); + state.apply_config(config); } state } - fn handle_initial_auto_execute(&mut self, auto_execute_commands: &[String]) { - self.selected_commands = auto_execute_commands + fn apply_config(&mut self, config_values: ConfigValues) { + self.skip_confirmation = self.skip_confirmation || config_values.skip_confirmation; + self.size_bypass = self.size_bypass || config_values.size_bypass; + + if !config_values.auto_execute_commands.is_empty() { + self.selected_commands = config_values.auto_execute_commands; + self.handle_initial_auto_execute(); + } + } + + fn handle_initial_auto_execute(&mut self) { + if !self.selected_commands.is_empty() { + self.spawn_confirmprompt(); + } + } + + fn spawn_confirmprompt(&mut self) { + let cmd_names: Vec<_> = self + .selected_commands .iter() - .filter_map(|name| self.tabs.iter().find_map(|tab| tab.find_command(name))) + .map(|node| node.name.as_str()) .collect(); - if !self.selected_commands.is_empty() { - let cmd_names: Vec<_> = self - .selected_commands - .iter() - .map(|node| node.name.as_str()) - .collect(); - - let prompt = ConfirmPrompt::new(&cmd_names); - self.focus = Focus::ConfirmationPrompt(Float::new(Box::new(prompt), 40, 40)); - } + let prompt = ConfirmPrompt::new(&cmd_names); + self.focus = Focus::ConfirmationPrompt(Float::new( + Box::new(prompt), + CONFIRM_PROMPT_FLOAT_SIZE, + CONFIRM_PROMPT_FLOAT_SIZE, + )); } fn get_list_item_shortcut(&self) -> Box<[Shortcut]> { @@ -221,6 +234,8 @@ impl AppState { Shortcut::new("Previous theme", ["T"]), Shortcut::new("Next tab", ["Tab"]), Shortcut::new("Previous tab", ["Shift-Tab"]), + Shortcut::new("Important actions guide", ["g"]), + Shortcut::new("Multi-selection mode", ["v"]), ]), ), @@ -229,21 +244,24 @@ impl AppState { } } - pub fn draw(&mut self, frame: &mut Frame) { - let terminal_size = frame.area(); - - if !self.size_bypass + fn is_terminal_drawable(&mut self, terminal_size: Rect) -> bool { + !(self.size_bypass || matches!(self.focus, Focus::FloatingWindow(_))) && (terminal_size.height < MIN_HEIGHT || terminal_size.width < MIN_WIDTH) - { + } + + pub fn draw(&mut self, frame: &mut Frame) { + let area = frame.area(); + self.drawable = !self.is_terminal_drawable(area); + if !self.drawable { let warning = Paragraph::new(format!( "Terminal size too small:\nWidth = {} Height = {}\n\nMinimum size:\nWidth = {} Height = {}", - terminal_size.width, - terminal_size.height, + area.width, + area.height, MIN_WIDTH, MIN_HEIGHT, )) .alignment(Alignment::Center) - .style(Style::default().fg(ratatui::style::Color::Red).bold()) + .style(Style::default().fg(self.theme.fail_color()).bold()) .wrap(ratatui::widgets::Wrap { trim: true }); let centered_layout = Layout::default() @@ -253,80 +271,53 @@ impl AppState { Constraint::Length(5), Constraint::Fill(1), ]) - .split(terminal_size); + .split(area); - self.drawable = false; return frame.render_widget(warning, centered_layout[1]); - } else { - self.drawable = true; } - let label_block = Block::default() - .borders(Borders::ALL) - .border_set(ratatui::symbols::border::ROUNDED) - .border_set(ratatui::symbols::border::Set { - top_left: " ", - top_right: " ", - bottom_left: " ", - bottom_right: " ", - vertical_left: " ", - vertical_right: " ", - horizontal_top: "*", - horizontal_bottom: "*", - }); - let str1 = "Linutil "; - let str2 = "by Chris Titus"; + let label_block = Block::bordered().border_set(border::Set { + top_left: " ", + top_right: " ", + bottom_left: " ", + bottom_right: " ", + vertical_left: " ", + vertical_right: " ", + horizontal_top: "*", + horizontal_bottom: "*", + }); + let label = Paragraph::new(Line::from(vec![ - Span::styled(str1, Style::default().bold()), - Span::styled(str2, Style::default().italic()), + Span::styled("Linutil ", Style::default().bold()), + Span::styled("by Chris Titus", Style::default().italic()), ])) .block(label_block) - .alignment(Alignment::Center); - - let longest_tab_display_len = self - .tabs - .iter() - .map(|tab| tab.name.len() + self.theme.tab_icon().len()) - .max() - .unwrap_or(0) - .max(str1.len() + str2.len()); + .centered(); let (keybind_scope, shortcuts) = self.get_keybinds(); - let keybind_render_width = terminal_size.width - 2; - - let keybinds_block = Block::default() + let keybinds_block = Block::bordered() .title(format!(" {} ", keybind_scope)) - .borders(Borders::ALL) - .border_set(ratatui::symbols::border::ROUNDED); + .border_set(border::ROUNDED); + let keybind_render_width = keybinds_block.inner(area).width; let keybinds = create_shortcut_list(shortcuts, keybind_render_width); - let n_lines = keybinds.len() as u16; - + let keybind_len = keybinds.len() as u16; let keybind_para = Paragraph::new(Text::from_iter(keybinds)).block(keybinds_block); - let vertical = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage(0), - Constraint::Max(n_lines as u16 + 2), - ]) - .flex(Flex::Legacy) - .margin(0) - .split(frame.area()); + let vertical = + Layout::vertical([Constraint::Percentage(0), Constraint::Max(keybind_len + 2)]) + .flex(Flex::Legacy) + .split(area); - let horizontal = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Min(longest_tab_display_len as u16 + 5), - Constraint::Percentage(100), - ]) - .split(vertical[0]); + let horizontal = Layout::horizontal([ + Constraint::Min(self.longest_tab_display_len + 5), + Constraint::Percentage(100), + ]) + .split(vertical[0]); - let left_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(3), Constraint::Min(1)]) - .split(horizontal[0]); + let left_chunks = + Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).split(horizontal[0]); frame.render_widget(label, left_chunks[0]); self.areas = Some(Areas { @@ -346,36 +337,23 @@ impl AppState { Style::new().fg(self.theme.tab_color()) }; - let list = List::new(tabs) - .block( - Block::default() - .borders(Borders::ALL) - .border_set(ratatui::symbols::border::ROUNDED), - ) + let tab_list = List::new(tabs) + .block(Block::bordered().border_set(border::ROUNDED)) .highlight_style(tab_hl_style) .highlight_symbol(self.theme.tab_icon()); - frame.render_stateful_widget(list, left_chunks[1], &mut self.current_tab); + frame.render_stateful_widget(tab_list, left_chunks[1], &mut self.current_tab); - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(3), Constraint::Min(1)].as_ref()) - .split(horizontal[1]); - - let list_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(70), Constraint::Percentage(30)].as_ref()) - .split(chunks[1]); + let chunks = + Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).split(horizontal[1]); self.filter.draw_searchbar(frame, chunks[0], &self.theme); - let mut items: Vec = Vec::new(); - let mut task_items: Vec = Vec::new(); + let mut items: Vec = Vec::with_capacity(self.filter.item_list().len()); if !self.at_root() { items.push( Line::from(format!("{} ..", self.theme.dir_icon())).style(self.theme.dir_color()), ); - task_items.push(Line::from(" ").style(self.theme.dir_color())); } items.extend(self.filter.item_list().iter().map( @@ -384,60 +362,37 @@ impl AppState { }| { let is_selected = self.selected_commands.contains(node); let (indicator, style) = if is_selected { - (self.theme.multi_select_icon(), Style::default().bold()) + (self.theme.multi_select_icon(), Style::new().bold()) } else { let ms_style = if self.multi_select && !node.multi_select { - Style::default().fg(self.theme.multi_select_disabled_color()) + Style::new().fg(self.theme.multi_select_disabled_color()) } else { Style::new() }; ("", ms_style) }; if *has_children { - Line::from(format!( - "{} {} {}", - self.theme.dir_icon(), - node.name, - indicator - )) - .style(self.theme.dir_color()) + Line::styled( + format!("{} {}", self.theme.dir_icon(), node.name,), + self.theme.dir_color(), + ) .patch_style(style) } else { - Line::from(format!( - "{} {} {}", - self.theme.cmd_icon(), - node.name, - indicator - )) - .style(self.theme.cmd_color()) + let left_content = + format!("{} {} {}", self.theme.cmd_icon(), node.name, indicator); + let right_content = format!("{} ", node.task_list); + let center_space = " ".repeat( + chunks[1].width as usize - left_content.len() - right_content.len(), + ); + Line::styled( + format!("{}{}{}", left_content, center_space, right_content), + self.theme.cmd_color(), + ) .patch_style(style) } }, )); - task_items.extend(self.filter.item_list().iter().map( - |ListEntry { - node, has_children, .. - }| { - let ms_style = if self.multi_select && !node.multi_select { - Style::default().fg(self.theme.multi_select_disabled_color()) - } else { - Style::new() - }; - if *has_children { - Line::from(" ") - .style(self.theme.dir_color()) - .patch_style(ms_style) - } else { - Line::from(format!("{} ", node.task_list)) - .alignment(Alignment::Right) - .style(self.theme.cmd_color()) - .bold() - .patch_style(ms_style) - } - }, - )); - let style = if let Focus::List = self.focus { Style::default().reversed() } else { @@ -451,7 +406,10 @@ impl AppState { }; #[cfg(feature = "tips")] - let bottom_title = Line::from(self.tip.as_str().bold().blue()).right_aligned(); + let bottom_title = Line::from(format!(" {} ", self.tip)) + .bold() + .blue() + .centered(); #[cfg(not(feature = "tips"))] let bottom_title = ""; @@ -461,27 +419,18 @@ impl AppState { let list = List::new(items) .highlight_style(style) .block( - Block::default() - .borders(Borders::ALL & !Borders::RIGHT) - .border_set(ratatui::symbols::border::ROUNDED) + Block::bordered() + .border_set(border::ROUNDED) .title(title) + .title(task_list_title) .title_bottom(bottom_title), ) .scroll_padding(1); - frame.render_stateful_widget(list, list_chunks[0], &mut self.selection); - - let disclaimer_list = List::new(task_items).highlight_style(style).block( - Block::default() - .borders(Borders::ALL & !Borders::LEFT) - .border_set(ratatui::symbols::border::ROUNDED) - .title(task_list_title), - ); - - frame.render_stateful_widget(disclaimer_list, list_chunks[1], &mut self.selection); + frame.render_stateful_widget(list, chunks[1], &mut self.selection); match &mut self.focus { - Focus::FloatingWindow(float) => float.draw(frame, chunks[1]), - Focus::ConfirmationPrompt(prompt) => prompt.draw(frame, chunks[1]), + Focus::FloatingWindow(float) => float.draw(frame, chunks[1], &self.theme), + Focus::ConfirmationPrompt(prompt) => prompt.draw(frame, chunks[1], &self.theme), _ => {} } @@ -566,26 +515,10 @@ impl AppState { // Handle key only when Tablist or List is focused // Prevents exiting the application even when a command is running // Add keys here which should work on both TabList and List - if matches!(self.focus, Focus::TabList | Focus::List) { - match key.code { - KeyCode::Tab => { - if self.current_tab.selected().unwrap() == self.tabs.len() - 1 { - self.current_tab.select_first(); - } else { - self.current_tab.select_next(); - } - self.refresh_tab(); - } - KeyCode::BackTab => { - if self.current_tab.selected().unwrap() == 0 { - self.current_tab.select(Some(self.tabs.len() - 1)); - } else { - self.current_tab.select_previous(); - } - self.refresh_tab(); - } - _ => {} - } + if matches!(self.focus, Focus::TabList | Focus::List) + && self.handle_tablist_and_list_keys(key) + { + return true; } match &mut self.focus { @@ -626,15 +559,9 @@ impl AppState { Focus::TabList => match key.code { KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => self.focus = Focus::List, - KeyCode::Char('j') | KeyCode::Down => self.scroll_tab_down(), - KeyCode::Char('k') | KeyCode::Up => self.scroll_tab_up(), - KeyCode::Char('/') => self.enter_search(), - KeyCode::Char('t') => self.theme.next(), - KeyCode::Char('T') => self.theme.prev(), - KeyCode::Char('g') => self.toggle_task_list_guide(), _ => {} }, @@ -645,11 +572,6 @@ impl AppState { KeyCode::Char('d') | KeyCode::Char('D') => self.enable_description(), KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => self.handle_enter(), KeyCode::Char('h') | KeyCode::Left => self.go_back(), - KeyCode::Char('/') => self.enter_search(), - KeyCode::Char('t') => self.theme.next(), - KeyCode::Char('T') => self.theme.prev(), - KeyCode::Char('g') => self.toggle_task_list_guide(), - KeyCode::Char('v') | KeyCode::Char('V') => self.toggle_multi_select(), KeyCode::Char(' ') if self.multi_select => self.toggle_selection(), _ => {} }, @@ -659,32 +581,38 @@ impl AppState { true } - fn scroll_down(&mut self) { - let len = self.filter.item_list().len(); - if len == 0 { - return; + fn handle_tablist_and_list_keys(&mut self, key: &KeyEvent) -> bool { + match key.code { + KeyCode::Tab => self.scroll_tab_down(), + KeyCode::BackTab => self.scroll_tab_up(), + KeyCode::Char('/') => self.enter_search(), + KeyCode::Char('g') | KeyCode::Char('G') => self.enable_task_list_guide(), + KeyCode::Char('v') | KeyCode::Char('V') => self.toggle_multi_select(), + KeyCode::Char('t') => self.theme.next(), + KeyCode::Char('T') => self.theme.prev(), + _ => return false, } - let current = self.selection.selected().unwrap_or(0); - let max_index = if self.at_root() { len - 1 } else { len }; - let next = if current + 1 > max_index { - 0 - } else { - current + 1 - }; + true + } - self.selection.select(Some(next)); + fn scroll_down(&mut self) { + if let Some(selected) = self.selection.selected() { + if selected == self.filter.item_list().len() - 1 { + self.selection.select_first(); + } else { + self.selection.select_next(); + } + } } fn scroll_up(&mut self) { - let len = self.filter.item_list().len(); - if len == 0 { - return; + if let Some(selected) = self.selection.selected() { + if selected == 0 { + self.selection.select_last(); + } else { + self.selection.select_previous(); + } } - let current = self.selection.selected().unwrap_or(0); - let max_index = if self.at_root() { len - 1 } else { len }; - let next = if current == 0 { max_index } else { current - 1 }; - - self.selection.select(Some(next)); } fn toggle_multi_select(&mut self) { @@ -747,11 +675,12 @@ impl AppState { fn get_selected_node(&self) -> Option> { let mut selected_index = self.selection.selected().unwrap_or(0); - if !self.at_root() && selected_index == 0 { - return None; - } if !self.at_root() { - selected_index = selected_index.saturating_sub(1); + if selected_index == 0 { + return None; + } else { + selected_index = selected_index.saturating_sub(1); + } } if let Some(item) = self.filter.item_list().get(selected_index) { @@ -793,12 +722,12 @@ impl AppState { pub fn selected_item_is_dir(&self) -> bool { let mut selected_index = self.selection.selected().unwrap_or(0); - if !self.at_root() && selected_index == 0 { - return false; - } - if !self.at_root() { - selected_index = selected_index.saturating_sub(1); + if selected_index == 0 { + return false; + } else { + selected_index = selected_index.saturating_sub(1); + } } self.filter @@ -820,11 +749,9 @@ impl AppState { fn enable_preview(&mut self) { if let Some(list_node) = self.get_selected_node() { - let mut preview_title = "[Preview] - ".to_string(); - preview_title.push_str(list_node.name.as_str()); - if let Some(preview) = FloatingText::from_command(&list_node.command, preview_title) { - self.spawn_float(preview, 80, 80); - } + let preview_title = format!("[Preview] - {}", list_node.name.as_str()); + let preview = FloatingText::from_command(&list_node.command, &preview_title, false); + self.spawn_float(preview, FLOAT_SIZE, FLOAT_SIZE); } } @@ -833,11 +760,19 @@ impl AppState { if !command_description.is_empty() { let description = FloatingText::new(command_description, "Command Description", true); - self.spawn_float(description, 80, 80); + self.spawn_float(description, FLOAT_SIZE, FLOAT_SIZE); } } } + fn enable_task_list_guide(&mut self) { + self.spawn_float( + FloatingText::new(ACTIONS_GUIDE.to_string(), "Important Actions Guide", true), + FLOAT_SIZE, + FLOAT_SIZE, + ); + } + fn get_selected_item_type(&self) -> SelectedItem { if self.selected_item_is_up_dir() { SelectedItem::UpDir @@ -864,14 +799,7 @@ impl AppState { if self.skip_confirmation { self.handle_confirm_command(); } else { - let cmd_names = self - .selected_commands - .iter() - .map(|node| node.name.as_str()) - .collect::>(); - - let prompt = ConfirmPrompt::new(&cmd_names[..]); - self.focus = Focus::ConfirmationPrompt(Float::new(Box::new(prompt), 40, 40)); + self.spawn_confirmprompt(); } } SelectedItem::None => {} @@ -879,14 +807,14 @@ impl AppState { } fn handle_confirm_command(&mut self) { - let commands = self + let commands: Vec<&Command> = self .selected_commands .iter() - .map(|node| node.command.clone()) + .map(|node| &node.command) .collect(); - let command = RunningCommand::new(commands); - self.spawn_float(command, 80, 80); + let command = RunningCommand::new(&commands); + self.spawn_float(command, FLOAT_SIZE, FLOAT_SIZE); self.selected_commands.clear(); } @@ -920,44 +848,21 @@ impl AppState { self.update_items(); } - fn toggle_task_list_guide(&mut self) { - self.spawn_float( - FloatingText::new(ACTIONS_GUIDE.to_string(), "Important Actions Guide", true), - 80, - 80, - ); - } - fn scroll_tab_down(&mut self) { - let len = self.tabs.len(); - let current = self.current_tab.selected().unwrap_or(0); - let next = if current + 1 >= len { 0 } else { current + 1 }; - - self.current_tab.select(Some(next)); + if self.current_tab.selected().unwrap() == self.tabs.len() - 1 { + self.current_tab.select_first(); + } else { + self.current_tab.select_next(); + } self.refresh_tab(); } fn scroll_tab_up(&mut self) { - let len = self.tabs.len(); - let current = self.current_tab.selected().unwrap_or(0); - let next = if current == 0 { len - 1 } else { current - 1 }; - - self.current_tab.select(Some(next)); + if self.current_tab.selected().unwrap() == 0 { + self.current_tab.select(Some(self.tabs.len() - 1)); + } else { + self.current_tab.select_previous(); + } self.refresh_tab(); } } - -#[cfg(feature = "tips")] -const TIPS: &str = include_str!("../cool_tips.txt"); - -#[cfg(feature = "tips")] -fn get_random_tip() -> String { - let tips: Vec<&str> = TIPS.lines().collect(); - if tips.is_empty() { - return "".to_string(); - } - - let mut rng = rand::thread_rng(); - let random_index = rng.gen_range(0..tips.len()); - format!(" {} ", tips[random_index]) -} diff --git a/tui/src/theme.rs b/tui/src/theme.rs index d87e87ee..f09c6b53 100644 --- a/tui/src/theme.rs +++ b/tui/src/theme.rs @@ -14,84 +14,91 @@ pub enum Theme { } impl Theme { - pub fn dir_color(&self) -> Color { + pub const fn dir_color(&self) -> Color { match self { Theme::Default => Color::Blue, Theme::Compatible => Color::Blue, } } - pub fn cmd_color(&self) -> Color { + pub const fn cmd_color(&self) -> Color { match self { Theme::Default => Color::Rgb(204, 224, 208), Theme::Compatible => Color::LightGreen, } } - pub fn multi_select_disabled_color(&self) -> Color { + pub const fn multi_select_disabled_color(&self) -> Color { match self { Theme::Default => Color::DarkGray, Theme::Compatible => Color::DarkGray, } } - pub fn tab_color(&self) -> Color { + pub const fn tab_color(&self) -> Color { match self { Theme::Default => Color::Rgb(255, 255, 85), Theme::Compatible => Color::Yellow, } } - pub fn dir_icon(&self) -> &'static str { + pub const fn dir_icon(&self) -> &'static str { match self { Theme::Default => "  ", Theme::Compatible => "[DIR]", } } - pub fn cmd_icon(&self) -> &'static str { + pub const fn cmd_icon(&self) -> &'static str { match self { Theme::Default => "  ", Theme::Compatible => "[CMD]", } } - pub fn tab_icon(&self) -> &'static str { + pub const fn tab_icon(&self) -> &'static str { match self { Theme::Default => " ", Theme::Compatible => ">> ", } } - pub fn multi_select_icon(&self) -> &'static str { + pub const fn multi_select_icon(&self) -> &'static str { match self { Theme::Default => "", Theme::Compatible => "*", } } - pub fn success_color(&self) -> Color { + pub const fn success_color(&self) -> Color { match self { - Theme::Default => Color::Rgb(199, 55, 44), + Theme::Default => Color::Rgb(5, 255, 55), Theme::Compatible => Color::Green, } } - pub fn fail_color(&self) -> Color { + pub const fn fail_color(&self) -> Color { match self { - Theme::Default => Color::Rgb(5, 255, 55), + Theme::Default => Color::Rgb(199, 55, 44), Theme::Compatible => Color::Red, } } - pub fn focused_color(&self) -> Color { + pub const fn focused_color(&self) -> Color { match self { Theme::Default => Color::LightBlue, Theme::Compatible => Color::LightBlue, } } - pub fn unfocused_color(&self) -> Color { + pub const fn search_preview_color(&self) -> Color { + match self { + Theme::Default => Color::DarkGray, + Theme::Compatible => Color::DarkGray, + } + } + + pub const fn unfocused_color(&self) -> Color { match self { Theme::Default => Color::Gray, Theme::Compatible => Color::Gray, diff --git a/tui/src/tips.rs b/tui/src/tips.rs new file mode 100644 index 00000000..17341c79 --- /dev/null +++ b/tui/src/tips.rs @@ -0,0 +1,13 @@ +use rand::Rng; + +const TIPS: &str = include_str!("../cool_tips.txt"); + +pub fn get_random_tip() -> &'static str { + let tips: Vec<&str> = TIPS.lines().collect(); + if tips.is_empty() { + return ""; + } + + let random_index = rand::thread_rng().gen_range(0..tips.len()); + tips[random_index] +}