Compare commits

..

1 Commits

Author SHA1 Message Date
Jeevitha Kannan K S
3dc5885a39
Merge 2036833405 into fa69885b6c 2024-11-17 02:38:37 +01:00
17 changed files with 823 additions and 600 deletions

109
Cargo.lock generated
View File

@ -29,6 +29,18 @@ version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" 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]] [[package]]
name = "anstyle" name = "anstyle"
version = "1.0.8" version = "1.0.8"
@ -379,23 +391,42 @@ dependencies = [
"which", "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]] [[package]]
name = "linutil_tui" name = "linutil_tui"
version = "24.10.31" version = "24.10.31"
dependencies = [ dependencies = [
"ansi-to-tui",
"anstyle",
"clap", "clap",
"linutil_core", "linutil_core 24.10.31 (registry+https://github.com/rust-lang/crates.io-index)",
"nix 0.29.0", "nix 0.29.0",
"oneshot", "oneshot",
"portable-pty", "portable-pty",
"rand", "rand",
"ratatui", "ratatui",
"temp-dir",
"textwrap",
"time", "time",
"tree-sitter-bash", "tree-sitter-bash",
"tree-sitter-highlight", "tree-sitter-highlight",
"tui-term", "tui-term",
"unicode-width 0.2.0", "unicode-width 0.2.0",
"vt100-ctt", "vt100-ctt",
"zips",
] ]
[[package]] [[package]]
@ -444,6 +475,12 @@ dependencies = [
"autocfg", "autocfg",
] ]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.0.2" version = "1.0.2"
@ -483,6 +520,16 @@ dependencies = [
"libc", "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]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.1.0" version = "0.1.0"
@ -721,18 +768,18 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.215" version = "1.0.210"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a"
dependencies = [ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]] [[package]]
name = "serde_derive" name = "serde_derive"
version = "1.0.215" version = "1.0.210"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -884,9 +931,9 @@ dependencies = [
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.87" version = "2.0.77"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -908,6 +955,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "textwrap"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23d434d3f8967a09480fb04132ebe0a3e088c173e6d0ee7897abbdf4eab0f8b9"
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.64" version = "1.0.64"
@ -997,9 +1050,9 @@ dependencies = [
[[package]] [[package]]
name = "tree-sitter" name = "tree-sitter"
version = "0.24.4" version = "0.24.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67baf55e7e1b6806063b1e51041069c90afff16afcbbccd278d899f9d84bca4" checksum = "f9871f16d6cf5c4757dcf30d5d2172a2df6987c510c017bbb7abfb7f9aa24d06"
dependencies = [ dependencies = [
"cc", "cc",
"regex", "regex",
@ -1010,9 +1063,9 @@ dependencies = [
[[package]] [[package]]
name = "tree-sitter-bash" name = "tree-sitter-bash"
version = "0.23.3" version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "329a4d48623ac337d42b1df84e81a1c9dbb2946907c102ca72db158c1964a52e" checksum = "3aa5e1c6bd02c0053f3f68edcf5d8866b38a8640584279e30fca88149ce14dda"
dependencies = [ dependencies = [
"cc", "cc",
"tree-sitter-language", "tree-sitter-language",
@ -1020,9 +1073,9 @@ dependencies = [
[[package]] [[package]]
name = "tree-sitter-highlight" name = "tree-sitter-highlight"
version = "0.24.4" version = "0.24.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f0f856de10d70a6d14d66db3648f7410c131cd49e989a863f15dda9acae6044" checksum = "48859aa39513716018d81904220960f415dbb72e071234a721304d20bf245e4c"
dependencies = [ dependencies = [
"lazy_static", "lazy_static",
"regex", "regex",
@ -1095,23 +1148,20 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]] [[package]]
name = "vt100-ctt" name = "vt100-ctt"
version = "0.16.0" version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/ChrisTitusTech/vt100-rust#39136a6232d043d8447afa7d47805b6f6baa09ee"
checksum = "298aca8af9c2d1395da31a65eb711abef2dc948e475e819212c8ed5008d52c9f"
dependencies = [ dependencies = [
"itoa", "itoa",
"log", "log",
"ratatui", "unicode-width 0.1.14",
"tui-term",
"unicode-width 0.2.0",
"vte", "vte",
] ]
[[package]] [[package]]
name = "vte" name = "vte"
version = "0.13.0" version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40eb22ae96f050e0c0d6f7ce43feeae26c348fc4dea56928ca81537cfaa6188b" checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197"
dependencies = [ dependencies = [
"arrayvec", "arrayvec",
"utf8parse", "utf8parse",
@ -1136,9 +1186,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]] [[package]]
name = "which" name = "which"
version = "7.0.0" version = "6.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9cad3279ade7346b96e38731a641d7343dd6a53d55083dd54eadfa5a1b38c6b" checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f"
dependencies = [ dependencies = [
"either", "either",
"home", "home",
@ -1269,7 +1319,7 @@ checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
name = "xtask" name = "xtask"
version = "24.10.31" version = "24.10.31"
dependencies = [ dependencies = [
"linutil_core", "linutil_core 24.10.31",
] ]
[[package]] [[package]]
@ -1292,3 +1342,14 @@ dependencies = [
"quote", "quote",
"syn", "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",
]

View File

@ -10,7 +10,7 @@ include = ["src/*.rs", "Cargo.toml", "tabs/**"]
[dependencies] [dependencies]
include_dir = "0.7.4" include_dir = "0.7.4"
temp-dir = "0.1.14" temp-dir = "0.1.14"
serde = { version = "1.0.215", features = ["derive"], default-features = false } serde = { version = "1.0.205", features = ["derive"], default-features = false }
toml = { version = "0.8.19", features = ["parse"], default-features = false } toml = { version = "0.8.19", features = ["parse"], default-features = false }
which = "7.0.0" which = "6.0.3"
ego-tree = "0.9.0" ego-tree = "0.9.0"

View File

@ -1,29 +1,15 @@
use crate::{ListNode, TabList};
use serde::Deserialize; use serde::Deserialize;
use std::{fs, path::Path, process, rc::Rc}; use std::path::Path;
use std::process;
// Struct that defines what values can be used in the toml file
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config { pub struct Config {
#[serde(default)] pub auto_execute: Vec<String>,
auto_execute: Option<Vec<String>>,
#[serde(default)]
skip_confirmation: Option<bool>,
#[serde(default)]
size_bypass: Option<bool>,
}
// 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<Rc<ListNode>>,
pub skip_confirmation: bool,
pub size_bypass: bool,
} }
impl Config { impl Config {
pub fn read_config(path: &Path, tabs: &TabList) -> ConfigValues { pub fn from_file(path: &Path) -> Self {
let content = match fs::read_to_string(path) { let content = match std::fs::read_to_string(path) {
Ok(content) => content, Ok(content) => content,
Err(e) => { Err(e) => {
eprintln!("Failed to read config file {}: {}", path.display(), e); eprintln!("Failed to read config file {}: {}", path.display(), e);
@ -31,29 +17,12 @@ impl Config {
} }
}; };
let config: Config = match toml::from_str(&content) { match toml::from_str(&content) {
Ok(config) => config, Ok(config) => config,
Err(e) => { Err(e) => {
eprintln!("Failed to parse config file: {}", e); eprintln!("Failed to parse config file: {}", e);
process::exit(1); 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<Rc<ListNode>> {
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()
})
}
} }

View File

@ -1,7 +1,3 @@
use crate::{Command, ListNode, Tab};
use ego_tree::{NodeMut, Tree};
use include_dir::{include_dir, Dir};
use serde::Deserialize;
use std::{ use std::{
fs::File, fs::File,
io::{BufRead, BufReader, Read}, io::{BufRead, BufReader, Read},
@ -10,6 +6,11 @@ use std::{
path::{Path, PathBuf}, path::{Path, PathBuf},
rc::Rc, 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; use temp_dir::TempDir;
const TAB_DATA: Dir = include_dir!("$CARGO_MANIFEST_DIR/tabs"); const TAB_DATA: Dir = include_dir!("$CARGO_MANIFEST_DIR/tabs");

View File

@ -7,7 +7,7 @@ pub use ego_tree;
use ego_tree::Tree; use ego_tree::Tree;
use std::path::PathBuf; use std::path::PathBuf;
pub use config::{Config, ConfigValues}; pub use config::Config;
pub use inner::{get_tabs, TabList}; pub use inner::{get_tabs, TabList};
#[derive(Clone, Hash, Eq, PartialEq)] #[derive(Clone, Hash, Eq, PartialEq)]
@ -38,10 +38,14 @@ pub struct ListNode {
} }
impl Tab { impl Tab {
fn find_command_by_name(&self, name: &str) -> Option<Rc<ListNode>> { pub fn find_command(&self, name: &str) -> Option<Rc<ListNode>> {
self.tree.root().descendants().find_map(|node| { self.tree.root().descendants().find_map(|node| {
let node_value = node.value(); let value = node.value();
(node_value.name == name && !node.has_children()).then_some(node_value.clone()) if value.name == name && !node.has_children() {
Some(value.clone())
} else {
None
}
}) })
} }
} }

View File

@ -19,14 +19,19 @@ oneshot = { version = "0.1.8", features = ["std"], default-features = false }
portable-pty = "0.8.1" portable-pty = "0.8.1"
ratatui = { version = "0.29.0", features = ["crossterm"], default-features = false } ratatui = { version = "0.29.0", features = ["crossterm"], default-features = false }
tui-term = { version = "0.2.0", 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 } time = { version = "0.3.36", features = ["formatting", "local-offset", "macros"], default-features = false }
unicode-width = { version = "0.2.0", default-features = false } unicode-width = { version = "0.2.0", default-features = false }
rand = { version = "0.8.5", optional = true } rand = { version = "0.8.5", optional = true }
linutil_core = { version = "24.10.31", path = "../core" } linutil_core = { version = "24.10.31" }
tree-sitter-highlight = "0.24.4" tree-sitter-highlight = "0.24.3"
tree-sitter-bash = "0.23.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"
nix = { version = "0.29.0", features = [ "user" ] } nix = { version = "0.29.0", features = [ "user" ] }
vt100-ctt = "0.16.0" vt100-ctt = { git = "https://github.com/ChrisTitusTech/vt100-rust" }
[[bin]] [[bin]]
name = "linutil" name = "linutil"

View File

@ -1,12 +1,13 @@
use crate::{float::FloatContent, hint::Shortcut, theme}; use std::borrow::Cow;
use crate::{float::FloatContent, hint::Shortcut};
use ratatui::{ use ratatui::{
crossterm::event::{KeyCode, KeyEvent, MouseEvent, MouseEventKind}, crossterm::event::{KeyCode, KeyEvent, MouseEvent, MouseEventKind},
layout::Alignment, layout::Alignment,
prelude::*, prelude::*,
symbols::border, widgets::{Block, Borders, Clear, List},
widgets::{Block, Clear, List},
}; };
use std::borrow::Cow;
pub enum ConfirmStatus { pub enum ConfirmStatus {
Confirm, Confirm,
@ -15,10 +16,9 @@ pub enum ConfirmStatus {
} }
pub struct ConfirmPrompt { pub struct ConfirmPrompt {
inner_area_height: usize, pub names: Box<[String]>,
names: Box<[String]>,
scroll: usize,
pub status: ConfirmStatus, pub status: ConfirmStatus,
scroll: usize,
} }
impl ConfirmPrompt { impl ConfirmPrompt {
@ -37,15 +37,14 @@ impl ConfirmPrompt {
.collect(); .collect();
Self { Self {
inner_area_height: 0,
names, names,
scroll: 0,
status: ConfirmStatus::None, status: ConfirmStatus::None,
scroll: 0,
} }
} }
pub fn scroll_down(&mut self) { pub fn scroll_down(&mut self) {
if self.scroll + self.inner_area_height < self.names.len() - 1 { if self.scroll < self.names.len() - 1 {
self.scroll += 1; self.scroll += 1;
} }
} }
@ -58,26 +57,19 @@ impl ConfirmPrompt {
} }
impl FloatContent for ConfirmPrompt { impl FloatContent for ConfirmPrompt {
fn draw(&mut self, frame: &mut Frame, area: Rect, theme: &theme::Theme) { fn draw(&mut self, frame: &mut Frame, area: Rect) {
let block = Block::bordered() let block = Block::default()
.border_set(border::ROUNDED) .borders(Borders::ALL)
.border_set(ratatui::symbols::border::ROUNDED)
.title(" Confirm selections ") .title(" Confirm selections ")
.title_bottom(Line::from(vec![ .title_bottom(" [y] to continue, [n] to abort ")
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_alignment(Alignment::Center)
.title_style(Style::default().bold()) .title_style(Style::default().bold())
.style(Style::default()); .style(Style::default());
let inner_area = block.inner(area); frame.render_widget(block.clone(), area);
self.inner_area_height = inner_area.height as usize;
frame.render_widget(Clear, area); let inner_area = block.inner(area);
frame.render_widget(block, area);
let paths_text = self let paths_text = self
.names .names
@ -89,6 +81,7 @@ impl FloatContent for ConfirmPrompt {
}) })
.collect::<Text>(); .collect::<Text>();
frame.render_widget(Clear, inner_area);
frame.render_widget(List::new(paths_text), inner_area); frame.render_widget(List::new(paths_text), inner_area);
} }
@ -106,21 +99,21 @@ impl FloatContent for ConfirmPrompt {
} }
fn handle_key_event(&mut self, key: &KeyEvent) -> bool { fn handle_key_event(&mut self, key: &KeyEvent) -> bool {
use ConfirmStatus::*; use KeyCode::*;
use KeyCode::{Char, Down, Esc, Up};
self.status = match key.code { self.status = match key.code {
Char('y') | Char('Y') => Confirm, Char('y') | Char('Y') => ConfirmStatus::Confirm,
Char('n') | Char('N') | Esc | Char('q') => Abort, Char('n') | Char('N') | Esc | Char('q') => ConfirmStatus::Abort,
Char('j') | Char('J') | Down => { Char('j') => {
self.scroll_down(); self.scroll_down();
None ConfirmStatus::None
} }
Char('k') | Char('K') | Up => { Char('k') => {
self.scroll_up(); self.scroll_up();
None ConfirmStatus::None
} }
_ => None, _ => ConfirmStatus::None,
}; };
false false
} }
@ -138,8 +131,8 @@ impl FloatContent for ConfirmPrompt {
Box::new([ Box::new([
Shortcut::new("Continue", ["Y", "y"]), Shortcut::new("Continue", ["Y", "y"]),
Shortcut::new("Abort", ["N", "n", "q", "Esc"]), Shortcut::new("Abort", ["N", "n", "q", "Esc"]),
Shortcut::new("Scroll up", ["k", "Up"]), Shortcut::new("Scroll up", ["k"]),
Shortcut::new("Scroll down", ["j", "Down"]), Shortcut::new("Scroll down", ["j"]),
Shortcut::new("Close linutil", ["CTRL-c"]), Shortcut::new("Close linutil", ["CTRL-c"]),
]), ]),
) )

View File

@ -2,9 +2,11 @@ use crate::{state::ListEntry, theme::Theme};
use linutil_core::{ego_tree::NodeId, Tab}; use linutil_core::{ego_tree::NodeId, Tab};
use ratatui::{ use ratatui::{
crossterm::event::{KeyCode, KeyEvent, KeyModifiers}, crossterm::event::{KeyCode, KeyEvent, KeyModifiers},
prelude::*, layout::{Position, Rect},
symbols::border, style::{Color, Style},
widgets::{Block, Paragraph}, text::Span,
widgets::{Block, Borders, Paragraph},
Frame,
}; };
use unicode_width::UnicodeWidthChar; use unicode_width::UnicodeWidthChar;
@ -15,12 +17,10 @@ pub enum SearchAction {
} }
pub struct Filter { pub struct Filter {
// Use Vec<char> to handle multi-byte characters like emojis
search_input: Vec<char>, search_input: Vec<char>,
in_search_mode: bool, in_search_mode: bool,
input_position: usize, input_position: usize,
items: Vec<ListEntry>, items: Vec<ListEntry>,
// No complex string manipulation is done with completion_preview so we can use String unlike search_input
completion_preview: Option<String>, completion_preview: Option<String>,
} }
@ -62,11 +62,13 @@ impl Filter {
.collect(); .collect();
} else { } else {
self.items.clear(); self.items.clear();
let query_lower = self.search_input.iter().collect::<String>().to_lowercase(); let query_lower = self.search_input.iter().collect::<String>().to_lowercase();
for tab in tabs { for tab in tabs.iter() {
let mut stack = vec![tab.tree.root().id()]; let mut stack = vec![tab.tree.root().id()];
while let Some(node_id) = stack.pop() { while let Some(node_id) = stack.pop() {
let node = tab.tree.get(node_id).unwrap(); let node = tab.tree.get(node_id).unwrap();
if node.value().name.to_lowercase().contains(&query_lower) if node.value().name.to_lowercase().contains(&query_lower)
&& !node.has_children() && !node.has_children()
{ {
@ -76,26 +78,31 @@ impl Filter {
has_children: false, has_children: false,
}); });
} }
stack.extend(node.children().map(|child| child.id())); stack.extend(node.children().map(|child| child.id()));
} }
} }
self.items self.items.sort_by(|a, b| a.node.name.cmp(&b.node.name));
.sort_unstable_by(|a, b| a.node.name.cmp(&b.node.name));
} }
self.update_completion_preview(); self.update_completion_preview();
} }
fn update_completion_preview(&mut self) { fn update_completion_preview(&mut self) {
self.completion_preview = if self.items.is_empty() || self.search_input.is_empty() { if self.search_input.is_empty() {
None self.completion_preview = None;
} else { return;
let input = self.search_input.iter().collect::<String>().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::<String>().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) { pub fn draw_searchbar(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
@ -116,8 +123,9 @@ impl Filter {
//Create the search bar widget //Create the search bar widget
let search_bar = Paragraph::new(display_text) let search_bar = Paragraph::new(display_text)
.block( .block(
Block::bordered() Block::default()
.border_set(border::ROUNDED) .borders(Borders::ALL)
.border_set(ratatui::symbols::border::ROUNDED)
.title(" Search "), .title(" Search "),
) )
.style(Style::default().fg(search_color)); .style(Style::default().fg(search_color));
@ -127,32 +135,24 @@ impl Filter {
// Render cursor in search bar // Render cursor in search bar
if self.in_search_mode { if self.in_search_mode {
// Calculate the visual width of search input so that completion preview can be displayed after the search input let cursor_position: usize = self.search_input[..self.input_position]
let search_input_size: u16 = self
.search_input
.iter() .iter()
.map(|c| c.width().unwrap_or(1) as u16) .map(|c| c.width().unwrap_or(1))
.sum(); .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; let y = area.y + 1;
frame.set_cursor_position(Position::new(x, y)); frame.set_cursor_position(Position::new(x, y));
if let Some(preview) = &self.completion_preview { if let Some(preview) = &self.completion_preview {
let preview_x = area.x + search_input_size + 1; let preview_span = Span::styled(preview, Style::default().fg(Color::DarkGray));
let preview_span = let preview_paragraph = Paragraph::new(preview_span).style(Style::default());
Span::styled(preview, Style::default().fg(theme.search_preview_color()));
let preview_area = Rect::new( let preview_area = Rect::new(
preview_x, x,
y, y,
(preview.len() as u16).min(area.width - search_input_size - 1), // Ensure the completion preview stays within the search bar bounds (preview.len() as u16).min(area.width - cursor_position as u16 - 1),
1, 1,
); );
frame.render_widget(Paragraph::new(preview_span), preview_area); frame.render_widget(preview_paragraph, preview_area);
} }
} }
} }
@ -220,18 +220,10 @@ impl Filter {
} }
fn complete_search(&mut self) -> SearchAction { fn complete_search(&mut self) -> SearchAction {
if self.completion_preview.is_some() { if let Some(completion) = self.completion_preview.take() {
let input = &self.search_input.iter().collect::<String>().to_lowercase(); self.search_input.extend(completion.chars());
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.input_position = self.search_input.len();
self.completion_preview = None; self.update_completion_preview();
SearchAction::Update SearchAction::Update
} else { } else {
SearchAction::None SearchAction::None

View File

@ -1,12 +1,13 @@
use crate::{hint::Shortcut, theme::Theme};
use ratatui::{ use ratatui::{
crossterm::event::{KeyCode, KeyEvent, MouseEvent}, crossterm::event::{KeyCode, KeyEvent, MouseEvent},
layout::{Constraint, Layout, Rect}, layout::{Constraint, Direction, Layout, Rect},
Frame, Frame,
}; };
use crate::hint::Shortcut;
pub trait FloatContent { pub trait FloatContent {
fn draw(&mut self, frame: &mut Frame, area: Rect, theme: &Theme); fn draw(&mut self, frame: &mut Frame, area: Rect);
fn handle_key_event(&mut self, key: &KeyEvent) -> bool; fn handle_key_event(&mut self, key: &KeyEvent) -> bool;
fn handle_mouse_event(&mut self, key: &MouseEvent) -> bool; fn handle_mouse_event(&mut self, key: &MouseEvent) -> bool;
fn is_finished(&self) -> bool; fn is_finished(&self) -> bool;
@ -29,24 +30,28 @@ impl<Content: FloatContent + ?Sized> Float<Content> {
} }
fn floating_window(&self, size: Rect) -> Rect { fn floating_window(&self, size: Rect) -> Rect {
let hor_float = Layout::horizontal([ let hor_float = Layout::default()
Constraint::Percentage((100 - self.width_percent) / 2), .constraints([
Constraint::Percentage(self.width_percent), Constraint::Percentage((100 - self.width_percent) / 2),
Constraint::Percentage((100 - self.width_percent) / 2), Constraint::Percentage(self.width_percent),
]) Constraint::Percentage((100 - self.width_percent) / 2),
.split(size)[1]; ])
.direction(Direction::Horizontal)
.split(size)[1];
Layout::vertical([ Layout::default()
Constraint::Percentage((100 - self.height_percent) / 2), .constraints([
Constraint::Percentage(self.height_percent), Constraint::Percentage((100 - self.height_percent) / 2),
Constraint::Percentage((100 - self.height_percent) / 2), Constraint::Percentage(self.height_percent),
]) Constraint::Percentage((100 - self.height_percent) / 2),
.split(hor_float)[1] ])
.direction(Direction::Vertical)
.split(hor_float)[1]
} }
pub fn draw(&mut self, frame: &mut Frame, parent_area: Rect, theme: &Theme) { pub fn draw(&mut self, frame: &mut Frame, parent_area: Rect) {
let popup_area = self.floating_window(parent_area); let popup_area = self.floating_window(parent_area);
self.content.draw(frame, popup_area, theme); self.content.draw(frame, popup_area);
} }
pub fn handle_mouse_event(&mut self, event: &MouseEvent) { pub fn handle_mouse_event(&mut self, event: &MouseEvent) {

View File

@ -1,21 +1,48 @@
use crate::{float::FloatContent, hint::Shortcut, theme::Theme}; use std::{
borrow::Cow,
collections::VecDeque,
io::{Cursor, Read as _, Seek, SeekFrom, Write as _},
};
use crate::{float::FloatContent, hint::Shortcut};
use linutil_core::Command; use linutil_core::Command;
use ratatui::{ use ratatui::{
crossterm::event::{KeyCode, KeyEvent, MouseEvent, MouseEventKind}, crossterm::event::{KeyCode, KeyEvent, MouseEvent, MouseEventKind},
prelude::*, layout::Rect,
symbols::border, style::{Style, Stylize},
widgets::{Block, Borders, Clear, Paragraph, Wrap}, text::Line,
widgets::{Block, Borders, Clear, List},
Frame,
}; };
use ansi_to_tui::IntoText;
use textwrap::wrap;
use tree_sitter_bash as hl_bash; use tree_sitter_bash as hl_bash;
use tree_sitter_highlight::{self as hl, HighlightEvent}; use tree_sitter_highlight::{self as hl, HighlightEvent};
use zips::zip_result;
pub struct FloatingText {
pub src: String,
wrapped_lines: Vec<String>,
max_line_width: usize,
v_scroll: usize,
h_scroll: usize,
mode_title: String,
wrap_words: bool,
frame_height: usize,
}
macro_rules! style { macro_rules! style {
($r:literal, $g:literal, $b:literal) => {{ ($r:literal, $g:literal, $b:literal) => {{
Style::new().fg(Color::Rgb($r, $g, $b)) use anstyle::{Color, RgbColor, Style};
Style::new().fg_color(Some(Color::Rgb(RgbColor($r, $g, $b))))
}}; }};
} }
const SYNTAX_HIGHLIGHT_STYLES: [(&str, Style); 8] = [ const SYNTAX_HIGHLIGHT_STYLES: [(&str, anstyle::Style); 8] = [
("function", style!(220, 220, 170)), // yellow ("function", style!(220, 220, 170)), // yellow
("string", style!(206, 145, 120)), // brown ("string", style!(206, 145, 120)), // brown
("property", style!(156, 220, 254)), // light blue ("property", style!(156, 220, 254)), // light blue
@ -26,176 +53,234 @@ const SYNTAX_HIGHLIGHT_STYLES: [(&str, Style); 8] = [
("number", style!(181, 206, 168)), // light green ("number", style!(181, 206, 168)), // light green
]; ];
pub struct FloatingText<'a> { fn get_highlighted_string(s: &str) -> Option<String> {
// Width, Height let mut hl_conf = hl::HighlightConfiguration::new(
inner_area_size: (usize, usize), hl_bash::LANGUAGE.into(),
mode_title: String, "bash",
// Cache the text to avoid reprocessing it every frame hl_bash::HIGHLIGHT_QUERY,
processed_text: Text<'a>, "",
// Vertical, Horizontal "",
scroll: (u16, u16), )
wrap_words: bool, .ok()?;
}
impl<'a> FloatingText<'a> { let matched_tokens = &SYNTAX_HIGHLIGHT_STYLES
pub fn new(text: String, title: &str, wrap_words: bool) -> Self { .iter()
let processed_text = Text::from(text); .map(|hl| hl.0)
.collect::<Vec<_>>();
Self { hl_conf.configure(matched_tokens);
inner_area_size: (0, 0),
mode_title: title.to_string(), let mut hl = hl::Highlighter::new();
processed_text,
scroll: (0, 0), let mut style_stack = vec![anstyle::Style::new()];
wrap_words, 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:#}"),
)?;
}
} }
} }
pub fn from_command(command: &Command, title: &str, wrap_words: bool) -> Self { let mut output = String::new();
zip_result!(
buf.seek(SeekFrom::Start(0)),
buf.read_to_string(&mut output),
)?;
Some(output)
}
#[inline]
fn get_lines(s: &str) -> Vec<&str> {
s.lines().collect::<Vec<_>>()
}
#[inline]
fn get_lines_owned(s: &str) -> Vec<String> {
get_lines(s).iter().map(|s| s.to_string()).collect()
}
impl FloatingText {
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)
};
Self {
src: text,
wrapped_lines,
mode_title: title.to_string(),
max_line_width,
v_scroll: 0,
h_scroll: 0,
wrap_words,
frame_height: 0,
}
}
pub fn from_command(command: &Command, title: String) -> Option<Self> {
let src = match command { let src = match command {
Command::Raw(cmd) => Some(cmd.clone()), Command::Raw(cmd) => Some(cmd.clone()),
Command::LocalFile { file, .. } => std::fs::read_to_string(file) Command::LocalFile { file, .. } => std::fs::read_to_string(file)
.map_err(|_| format!("File not found: {:?}", file)) .map_err(|_| format!("File not found: {:?}", file))
.ok(), .ok(),
Command::None => None, Command::None => None,
} }?;
.unwrap();
let processed_text = Self::get_highlighted_string(&src).unwrap_or_else(|| Text::from(src)); let max_line_width = 80;
let wrapped_lines = get_lines_owned(&get_highlighted_string(&src)?);
Self { Some(Self {
inner_area_size: (0, 0), src,
mode_title: title.to_string(), wrapped_lines,
processed_text, mode_title: title,
scroll: (0, 0), max_line_width,
wrap_words, h_scroll: 0,
} v_scroll: 0,
} wrap_words: false,
frame_height: 0,
fn get_highlighted_string(s: &str) -> Option<Text<'a>> { })
let matched_tokens = SYNTAX_HIGHLIGHT_STYLES
.iter()
.map(|hl| hl.0)
.collect::<Vec<_>>();
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) { fn scroll_down(&mut self) {
let max_scroll = self let visible_lines = self.frame_height.saturating_sub(2);
.processed_text if self.v_scroll + visible_lines < self.wrapped_lines.len() {
.lines self.v_scroll += 1;
.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) { fn scroll_up(&mut self) {
self.scroll.0 = self.scroll.0.saturating_sub(1); if self.v_scroll > 0 {
self.v_scroll -= 1;
}
} }
fn scroll_left(&mut self) { fn scroll_left(&mut self) {
self.scroll.1 = self.scroll.1.saturating_sub(1); if self.h_scroll > 0 {
self.h_scroll -= 1;
}
} }
fn scroll_right(&mut self) { fn scroll_right(&mut self) {
let visible_length = self.inner_area_size.0.saturating_sub(1); if self.h_scroll + 1 < self.max_line_width {
let max_scroll = if self.wrap_words { self.h_scroll += 1;
0 }
} else { }
self.processed_text
.lines fn update_wrapping(&mut self, width: usize) {
.iter() if self.max_line_width != width {
.map(|line| line.width()) self.max_line_width = width;
.max() self.wrapped_lines = if self.wrap_words {
.unwrap_or(0) wrap(&self.src, width)
.saturating_sub(visible_length) as u16 .into_iter()
}; .map(|cow| cow.into_owned())
self.scroll.1 = (self.scroll.1 + 1).min(max_scroll); .collect()
} else {
get_lines_owned(&get_highlighted_string(&self.src).unwrap_or(self.src.clone()))
};
}
} }
} }
impl<'a> FloatContent for FloatingText<'a> { impl FloatContent for FloatingText {
fn draw(&mut self, frame: &mut Frame, area: Rect, _theme: &Theme) { 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
let block = Block::default() let block = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_set(border::ROUNDED) .border_set(ratatui::symbols::border::ROUNDED)
.title(self.mode_title.as_str()) .title(self.mode_title.clone())
.title_alignment(Alignment::Center) .title_alignment(ratatui::layout::Alignment::Center)
.title_style(Style::default().reversed()) .title_style(Style::default().reversed())
.style(Style::default()); .style(Style::default());
let inner_area = block.inner(area);
self.inner_area_size = (inner_area.width as usize, inner_area.height as usize);
frame.render_widget(Clear, area); frame.render_widget(Clear, area);
frame.render_widget(block, area);
let paragraph = if self.wrap_words { frame.render_widget(block.clone(), area);
Paragraph::new(self.processed_text.clone())
.scroll(self.scroll)
.wrap(Wrap { trim: false })
} else {
Paragraph::new(self.processed_text.clone()).scroll(self.scroll)
};
frame.render_widget(paragraph, inner_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.update_wrapping(width as usize);
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::<VecDeque<_>>();
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::<Vec<_>>();
// 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);
} }
fn handle_mouse_event(&mut self, event: &MouseEvent) -> bool { fn handle_mouse_event(&mut self, event: &MouseEvent) -> bool {
@ -210,12 +295,12 @@ impl<'a> FloatContent for FloatingText<'a> {
} }
fn handle_key_event(&mut self, key: &KeyEvent) -> bool { fn handle_key_event(&mut self, key: &KeyEvent) -> bool {
use KeyCode::{Char, Down, Left, Right, Up}; use KeyCode::*;
match key.code { match key.code {
Down | Char('j') | Char('J') => self.scroll_down(), Down | Char('j') => self.scroll_down(),
Up | Char('k') | Char('K') => self.scroll_up(), Up | Char('k') => self.scroll_up(),
Left | Char('h') | Char('H') => self.scroll_left(), Left | Char('h') => self.scroll_left(),
Right | Char('l') | Char('L') => self.scroll_right(), Right | Char('l') => self.scroll_right(),
_ => {} _ => {}
} }
false false

View File

@ -1,12 +1,13 @@
use std::borrow::Cow;
use ratatui::{ use ratatui::{
style::{Style, Stylize}, style::{Style, Stylize},
text::{Line, Span}, text::{Line, Span},
}; };
use std::borrow::Cow;
pub struct Shortcut { pub struct Shortcut {
key_sequences: Vec<Span<'static>>, pub key_sequences: Vec<Span<'static>>,
desc: &'static str, pub desc: &'static str,
} }
fn add_spacing(list: Vec<Vec<Span>>) -> Line { fn add_spacing(list: Vec<Vec<Span>>) -> Line {
@ -18,7 +19,7 @@ fn add_spacing(list: Vec<Vec<Span>>) -> Line {
.collect() .collect()
} }
fn span_vec_len(span_vec: &[Span]) -> usize { pub fn span_vec_len(span_vec: &[Span]) -> usize {
span_vec.iter().rfold(0, |init, s| init + s.width()) span_vec.iter().rfold(0, |init, s| init + s.width())
} }
@ -38,7 +39,7 @@ pub fn create_shortcut_list(
let columns = (render_width as usize / (max_shortcut_width + 4)).max(1); let columns = (render_width as usize / (max_shortcut_width + 4)).max(1);
let rows = (shortcut_spans.len() + columns - 1) / columns; let rows = (shortcut_spans.len() + columns - 1) / columns;
let mut lines: Vec<Line<'static>> = Vec::with_capacity(rows); let mut lines: Vec<Line<'static>> = Vec::new();
for row in 0..rows { for row in 0..rows {
let row_spans: Vec<_> = (0..columns) let row_spans: Vec<_> = (0..columns)
@ -73,7 +74,13 @@ impl Shortcut {
let description = Span::styled(self.desc, Style::default().italic()); let description = Span::styled(self.desc, Style::default().italic());
self.key_sequences self.key_sequences
.iter() .iter()
.flat_map(|seq| [Span::raw("["), seq.clone(), Span::raw("] ")]) .flat_map(|seq| {
[
Span::default().content("["),
seq.clone(),
Span::default().content("] "),
]
})
.chain(std::iter::once(description)) .chain(std::iter::once(description))
.collect() .collect()
} }

View File

@ -8,11 +8,15 @@ mod running_command;
pub mod state; pub mod state;
mod theme; mod theme;
#[cfg(feature = "tips")] use std::{
mod tips; io::{self, stdout},
path::PathBuf,
time::Duration,
};
use crate::theme::Theme; use crate::theme::Theme;
use clap::Parser; use clap::Parser;
use ratatui::{ use ratatui::{
backend::CrosstermBackend, backend::CrosstermBackend,
crossterm::{ crossterm::{
@ -24,15 +28,10 @@ use ratatui::{
Terminal, Terminal,
}; };
use state::AppState; use state::AppState;
use std::{
io::{stdout, Result, Stdout},
path::PathBuf,
time::Duration,
};
// Linux utility toolbox // Linux utility toolbox
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
pub struct Args { struct Args {
#[arg(short, long, help = "Path to the configuration file")] #[arg(short, long, help = "Path to the configuration file")]
config: Option<PathBuf>, config: Option<PathBuf>,
#[arg(short, long, value_enum)] #[arg(short, long, value_enum)]
@ -53,10 +52,16 @@ pub struct Args {
size_bypass: bool, size_bypass: bool,
} }
fn main() -> Result<()> { fn main() -> io::Result<()> {
let args = Args::parse(); let args = Args::parse();
let mut state = AppState::new(args); let mut state = AppState::new(
args.config,
args.theme,
args.override_validation,
args.size_bypass,
args.skip_confirmation,
);
stdout().execute(EnterAlternateScreen)?; stdout().execute(EnterAlternateScreen)?;
stdout().execute(EnableMouseCapture)?; stdout().execute(EnableMouseCapture)?;
@ -77,7 +82,10 @@ fn main() -> Result<()> {
Ok(()) Ok(())
} }
fn run(terminal: &mut Terminal<CrosstermBackend<Stdout>>, state: &mut AppState) -> Result<()> { fn run(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
state: &mut AppState,
) -> io::Result<()> {
loop { loop {
terminal.draw(|frame| state.draw(frame)).unwrap(); terminal.draw(|frame| state.draw(frame)).unwrap();
// Wait for an event // Wait for an event

View File

@ -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."; Please proceed with caution and make sure you understand what each script does before executing it.";
#[cfg(unix)] #[cfg(unix)]
pub fn check_root_status<'a>() -> Option<FloatingText<'a>> { pub fn check_root_status() -> Option<FloatingText> {
(Uid::effective().is_root()).then_some(FloatingText::new( (Uid::effective().is_root()).then_some(FloatingText::new(
ROOT_WARNING.into(), ROOT_WARNING.into(),
"Root User Warning", "Root User Warning",

View File

@ -1,4 +1,4 @@
use crate::{float::FloatContent, hint::Shortcut, theme::Theme}; use crate::{float::FloatContent, hint::Shortcut};
use linutil_core::Command; use linutil_core::Command;
use oneshot::{channel, Receiver}; use oneshot::{channel, Receiver};
use portable_pty::{ use portable_pty::{
@ -6,13 +6,14 @@ use portable_pty::{
}; };
use ratatui::{ use ratatui::{
crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}, crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind},
prelude::*, layout::{Rect, Size},
symbols::border, style::{Color, Style, Stylize},
widgets::Block, text::{Line, Span},
widgets::{Block, Borders},
Frame,
}; };
use std::{ use std::{
fs::File, io::Write,
io::{Result, Write},
sync::{Arc, Mutex}, sync::{Arc, Mutex},
thread::JoinHandle, thread::JoinHandle,
}; };
@ -40,43 +41,60 @@ pub struct RunningCommand {
} }
impl FloatContent for RunningCommand { impl FloatContent for RunningCommand {
fn draw(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) { 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,
};
// Define the block for the terminal display // Define the block for the terminal display
let block = if !self.is_finished() { let block = if !self.is_finished() {
// Display a block indicating the command is running // Display a block indicating the command is running
Block::bordered() Block::default()
.border_set(border::ROUNDED) .borders(Borders::ALL)
.border_set(ratatui::symbols::border::ROUNDED)
.title_top(Line::from("Running the command....").centered()) .title_top(Line::from("Running the command....").centered())
.title_style(Style::default().reversed()) .title_style(Style::default().reversed())
.title_bottom(Line::from("Press Ctrl-C to KILL the command")) .title_bottom(Line::from("Press Ctrl-C to KILL the command"))
} else { } else {
// Display a block with the command's exit status // Display a block with the command's exit status
let title_line = if self.get_exit_status().success() { let mut title_line = if self.get_exit_status().success() {
Line::styled( Line::from(
"SUCCESS! Press <ENTER> to close this window", Span::default()
Style::default().fg(theme.success_color()).reversed(), .content("SUCCESS!")
.style(Style::default().fg(Color::Green).reversed()),
) )
} else { } else {
Line::styled( Line::from(
"FAILED! Press <ENTER> to close this window", Span::default()
Style::default().fg(theme.fail_color()).reversed(), .content("FAILED!")
.style(Style::default().fg(Color::Red).reversed()),
) )
}; };
let log_path = if let Some(log_path) = &self.log_path { title_line.push_span(
Line::from(format!(" Log saved: {} ", log_path)) Span::default()
} else { .content(" Press <ENTER> to close this window ")
Line::from(" Press 'l' to save command log ") .style(Style::default()),
}; );
Block::bordered() let mut block = Block::default()
.border_set(border::ROUNDED) .borders(Borders::ALL)
.title_top(title_line.centered()) .border_set(ratatui::symbols::border::ROUNDED)
.title_bottom(log_path.centered()) .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());
} else {
block =
block.title_bottom(Line::from(" Press 'l' to save command log ").centered());
}
block
}; };
// 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 // Process the buffer and create the pseudo-terminal widget
let screen = self.screen(inner_size); let screen = self.screen(inner_size);
let pseudo_term = PseudoTerminal::new(&screen).block(block); let pseudo_term = PseudoTerminal::new(&screen).block(block);
@ -160,7 +178,7 @@ impl FloatContent for RunningCommand {
} }
impl RunningCommand { impl RunningCommand {
pub fn new(commands: &[&Command]) -> Self { pub fn new(commands: Vec<Command>) -> Self {
let pty_system = NativePtySystem::default(); let pty_system = NativePtySystem::default();
// Build the command based on the provided Command enum variant // Build the command based on the provided Command enum variant
@ -180,10 +198,10 @@ impl RunningCommand {
if let Some(parent_directory) = file.parent() { if let Some(parent_directory) = file.parent() {
script.push_str(&format!("cd {}\n", parent_directory.display())); script.push_str(&format!("cd {}\n", parent_directory.display()));
} }
script.push_str(executable); script.push_str(&executable);
for arg in args { for arg in args {
script.push(' '); 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 script.push('\n'); // Ensures that each command is properly separated for execution preventing directory errors
} }
@ -295,7 +313,7 @@ impl RunningCommand {
} }
} }
fn save_log(&self) -> Result<String> { fn save_log(&self) -> std::io::Result<String> {
let mut log_path = std::env::temp_dir(); let mut log_path = std::env::temp_dir();
let date_format = format_description!("[year]-[month]-[day]-[hour]-[minute]-[second]"); let date_format = format_description!("[year]-[month]-[day]-[hour]-[minute]-[second]");
log_path.push(format!( log_path.push(format!(
@ -306,7 +324,7 @@ impl RunningCommand {
.unwrap() .unwrap()
)); ));
let mut file = File::create(&log_path)?; let mut file = std::fs::File::create(&log_path)?;
let buffer = self.buffer.lock().unwrap(); let buffer = self.buffer.lock().unwrap();
file.write_all(&buffer)?; file.write_all(&buffer)?;

View File

@ -7,22 +7,24 @@ use crate::{
root::check_root_status, root::check_root_status,
running_command::RunningCommand, running_command::RunningCommand,
theme::Theme, theme::Theme,
Args,
}; };
use linutil_core::{ego_tree::NodeId, Command, Config, ConfigValues, ListNode, TabList};
use linutil_core::{ego_tree::NodeId, Config, ListNode, TabList};
#[cfg(feature = "tips")]
use rand::Rng;
use ratatui::{ use ratatui::{
crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind}, crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind},
layout::Flex, layout::{Alignment, Constraint, Direction, Flex, Layout, Position, Rect},
prelude::*, style::{Style, Stylize},
symbols::border, text::{Line, Span, Text},
widgets::{Block, List, ListState, Paragraph}, widgets::{Block, Borders, List, ListState, Paragraph},
Frame,
}; };
use std::path::PathBuf;
use std::rc::Rc; use std::rc::Rc;
const MIN_WIDTH: u16 = 100; const MIN_WIDTH: u16 = 100;
const MIN_HEIGHT: u16 = 25; 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 TITLE: &str = concat!(" Linux Toolbox - ", env!("CARGO_PKG_VERSION"), " ");
const ACTIONS_GUIDE: &str = "List of important tasks performed by commands' names: const ACTIONS_GUIDE: &str = "List of important tasks performed by commands' names:
@ -45,12 +47,11 @@ pub struct AppState {
/// Selected theme /// Selected theme
theme: Theme, theme: Theme,
/// Currently focused area /// Currently focused area
focus: Focus, pub focus: Focus,
/// List of tabs /// List of tabs
tabs: TabList, tabs: TabList,
/// Current tab /// Current tab
current_tab: ListState, 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 /// 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 .." /// just the current directory, all paths that took us here, so we can "cd .."
visit_stack: Vec<(NodeId, usize)>, visit_stack: Vec<(NodeId, usize)>,
@ -62,7 +63,7 @@ pub struct AppState {
selected_commands: Vec<Rc<ListNode>>, selected_commands: Vec<Rc<ListNode>>,
drawable: bool, drawable: bool,
#[cfg(feature = "tips")] #[cfg(feature = "tips")]
tip: &'static str, tip: String,
size_bypass: bool, size_bypass: bool,
skip_confirmation: bool, skip_confirmation: bool,
} }
@ -81,7 +82,7 @@ pub struct ListEntry {
pub has_children: bool, pub has_children: bool,
} }
struct Areas { pub struct Areas {
tab_list: Rect, tab_list: Rect,
list: Rect, list: Rect,
} }
@ -94,23 +95,24 @@ enum SelectedItem {
} }
impl AppState { impl AppState {
pub fn new(args: Args) -> Self { pub fn new(
let tabs = linutil_core::get_tabs(!args.override_validation); config_path: Option<PathBuf>,
theme: Theme,
override_validation: bool,
size_bypass: bool,
skip_confirmation: bool,
) -> Self {
let tabs = linutil_core::get_tabs(!override_validation);
let root_id = tabs[0].tree.root().id(); let root_id = tabs[0].tree.root().id();
let longest_tab_display_len = tabs let auto_execute_commands = config_path.map(|path| Config::from_file(&path).auto_execute);
.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 { let mut state = Self {
areas: None, areas: None,
theme: args.theme, theme,
focus: Focus::List, focus: Focus::List,
tabs, tabs,
current_tab: ListState::default().with_selected(Some(0)), current_tab: ListState::default().with_selected(Some(0)),
longest_tab_display_len,
visit_stack: vec![(root_id, 0usize)], visit_stack: vec![(root_id, 0usize)],
selection: ListState::default().with_selected(Some(0)), selection: ListState::default().with_selected(Some(0)),
filter: Filter::new(), filter: Filter::new(),
@ -118,55 +120,40 @@ impl AppState {
selected_commands: Vec::new(), selected_commands: Vec::new(),
drawable: false, drawable: false,
#[cfg(feature = "tips")] #[cfg(feature = "tips")]
tip: crate::tips::get_random_tip(), tip: get_random_tip(),
size_bypass: args.size_bypass, size_bypass,
skip_confirmation: args.skip_confirmation, skip_confirmation,
}; };
#[cfg(unix)] #[cfg(unix)]
if let Some(root_warning) = check_root_status() { if let Some(root_warning) = check_root_status() {
state.spawn_float(root_warning, FLOAT_SIZE, FLOAT_SIZE); state.spawn_float(root_warning, 60, 40);
} }
state.update_items(); state.update_items();
if let Some(auto_execute_commands) = auto_execute_commands {
if let Some(config_path) = args.config { state.handle_initial_auto_execute(&auto_execute_commands);
let config = Config::read_config(&config_path, &state.tabs);
state.apply_config(config);
} }
state state
} }
fn apply_config(&mut self, config_values: ConfigValues) { fn handle_initial_auto_execute(&mut self, auto_execute_commands: &[String]) {
self.skip_confirmation = self.skip_confirmation || config_values.skip_confirmation; self.selected_commands = auto_execute_commands
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() .iter()
.map(|node| node.name.as_str()) .filter_map(|name| self.tabs.iter().find_map(|tab| tab.find_command(name)))
.collect(); .collect();
let prompt = ConfirmPrompt::new(&cmd_names); if !self.selected_commands.is_empty() {
self.focus = Focus::ConfirmationPrompt(Float::new( let cmd_names: Vec<_> = self
Box::new(prompt), .selected_commands
CONFIRM_PROMPT_FLOAT_SIZE, .iter()
CONFIRM_PROMPT_FLOAT_SIZE, .map(|node| node.name.as_str())
)); .collect();
let prompt = ConfirmPrompt::new(&cmd_names);
self.focus = Focus::ConfirmationPrompt(Float::new(Box::new(prompt), 40, 40));
}
} }
fn get_list_item_shortcut(&self) -> Box<[Shortcut]> { fn get_list_item_shortcut(&self) -> Box<[Shortcut]> {
@ -234,8 +221,6 @@ impl AppState {
Shortcut::new("Previous theme", ["T"]), Shortcut::new("Previous theme", ["T"]),
Shortcut::new("Next tab", ["Tab"]), Shortcut::new("Next tab", ["Tab"]),
Shortcut::new("Previous tab", ["Shift-Tab"]), Shortcut::new("Previous tab", ["Shift-Tab"]),
Shortcut::new("Important actions guide", ["g"]),
Shortcut::new("Multi-selection mode", ["v"]),
]), ]),
), ),
@ -244,24 +229,21 @@ impl AppState {
} }
} }
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) { pub fn draw(&mut self, frame: &mut Frame) {
let area = frame.area(); let terminal_size = frame.area();
self.drawable = !self.is_terminal_drawable(area);
if !self.drawable { if !self.size_bypass
&& (terminal_size.height < MIN_HEIGHT || terminal_size.width < MIN_WIDTH)
{
let warning = Paragraph::new(format!( let warning = Paragraph::new(format!(
"Terminal size too small:\nWidth = {} Height = {}\n\nMinimum size:\nWidth = {} Height = {}", "Terminal size too small:\nWidth = {} Height = {}\n\nMinimum size:\nWidth = {} Height = {}",
area.width, terminal_size.width,
area.height, terminal_size.height,
MIN_WIDTH, MIN_WIDTH,
MIN_HEIGHT, MIN_HEIGHT,
)) ))
.alignment(Alignment::Center) .alignment(Alignment::Center)
.style(Style::default().fg(self.theme.fail_color()).bold()) .style(Style::default().fg(ratatui::style::Color::Red).bold())
.wrap(ratatui::widgets::Wrap { trim: true }); .wrap(ratatui::widgets::Wrap { trim: true });
let centered_layout = Layout::default() let centered_layout = Layout::default()
@ -271,53 +253,80 @@ impl AppState {
Constraint::Length(5), Constraint::Length(5),
Constraint::Fill(1), Constraint::Fill(1),
]) ])
.split(area); .split(terminal_size);
self.drawable = false;
return frame.render_widget(warning, centered_layout[1]); return frame.render_widget(warning, centered_layout[1]);
} else {
self.drawable = true;
} }
let label_block = Block::bordered().border_set(border::Set { let label_block = Block::default()
top_left: " ", .borders(Borders::ALL)
top_right: " ", .border_set(ratatui::symbols::border::ROUNDED)
bottom_left: " ", .border_set(ratatui::symbols::border::Set {
bottom_right: " ", top_left: " ",
vertical_left: " ", top_right: " ",
vertical_right: " ", bottom_left: " ",
horizontal_top: "*", bottom_right: " ",
horizontal_bottom: "*", vertical_left: " ",
}); vertical_right: " ",
horizontal_top: "*",
horizontal_bottom: "*",
});
let str1 = "Linutil ";
let str2 = "by Chris Titus";
let label = Paragraph::new(Line::from(vec![ let label = Paragraph::new(Line::from(vec![
Span::styled("Linutil ", Style::default().bold()), Span::styled(str1, Style::default().bold()),
Span::styled("by Chris Titus", Style::default().italic()), Span::styled(str2, Style::default().italic()),
])) ]))
.block(label_block) .block(label_block)
.centered(); .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());
let (keybind_scope, shortcuts) = self.get_keybinds(); let (keybind_scope, shortcuts) = self.get_keybinds();
let keybinds_block = Block::bordered() let keybind_render_width = terminal_size.width - 2;
.title(format!(" {} ", keybind_scope))
.border_set(border::ROUNDED); let keybinds_block = Block::default()
.title(format!(" {} ", keybind_scope))
.borders(Borders::ALL)
.border_set(ratatui::symbols::border::ROUNDED);
let keybind_render_width = keybinds_block.inner(area).width;
let keybinds = create_shortcut_list(shortcuts, keybind_render_width); let keybinds = create_shortcut_list(shortcuts, keybind_render_width);
let keybind_len = keybinds.len() as u16; let n_lines = keybinds.len() as u16;
let keybind_para = Paragraph::new(Text::from_iter(keybinds)).block(keybinds_block); let keybind_para = Paragraph::new(Text::from_iter(keybinds)).block(keybinds_block);
let vertical = let vertical = Layout::default()
Layout::vertical([Constraint::Percentage(0), Constraint::Max(keybind_len + 2)]) .direction(Direction::Vertical)
.flex(Flex::Legacy) .constraints([
.split(area); Constraint::Percentage(0),
Constraint::Max(n_lines as u16 + 2),
])
.flex(Flex::Legacy)
.margin(0)
.split(frame.area());
let horizontal = Layout::horizontal([ let horizontal = Layout::default()
Constraint::Min(self.longest_tab_display_len + 5), .direction(Direction::Horizontal)
Constraint::Percentage(100), .constraints([
]) Constraint::Min(longest_tab_display_len as u16 + 5),
.split(vertical[0]); Constraint::Percentage(100),
])
.split(vertical[0]);
let left_chunks = let left_chunks = Layout::default()
Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).split(horizontal[0]); .direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(1)])
.split(horizontal[0]);
frame.render_widget(label, left_chunks[0]); frame.render_widget(label, left_chunks[0]);
self.areas = Some(Areas { self.areas = Some(Areas {
@ -337,23 +346,36 @@ impl AppState {
Style::new().fg(self.theme.tab_color()) Style::new().fg(self.theme.tab_color())
}; };
let tab_list = List::new(tabs) let list = List::new(tabs)
.block(Block::bordered().border_set(border::ROUNDED)) .block(
Block::default()
.borders(Borders::ALL)
.border_set(ratatui::symbols::border::ROUNDED),
)
.highlight_style(tab_hl_style) .highlight_style(tab_hl_style)
.highlight_symbol(self.theme.tab_icon()); .highlight_symbol(self.theme.tab_icon());
frame.render_stateful_widget(tab_list, left_chunks[1], &mut self.current_tab); frame.render_stateful_widget(list, left_chunks[1], &mut self.current_tab);
let chunks = let chunks = Layout::default()
Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).split(horizontal[1]); .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]);
self.filter.draw_searchbar(frame, chunks[0], &self.theme); self.filter.draw_searchbar(frame, chunks[0], &self.theme);
let mut items: Vec<Line> = Vec::with_capacity(self.filter.item_list().len()); let mut items: Vec<Line> = Vec::new();
let mut task_items: Vec<Line> = Vec::new();
if !self.at_root() { if !self.at_root() {
items.push( items.push(
Line::from(format!("{} ..", self.theme.dir_icon())).style(self.theme.dir_color()), 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( items.extend(self.filter.item_list().iter().map(
@ -362,37 +384,60 @@ impl AppState {
}| { }| {
let is_selected = self.selected_commands.contains(node); let is_selected = self.selected_commands.contains(node);
let (indicator, style) = if is_selected { let (indicator, style) = if is_selected {
(self.theme.multi_select_icon(), Style::new().bold()) (self.theme.multi_select_icon(), Style::default().bold())
} else { } else {
let ms_style = if self.multi_select && !node.multi_select { let ms_style = if self.multi_select && !node.multi_select {
Style::new().fg(self.theme.multi_select_disabled_color()) Style::default().fg(self.theme.multi_select_disabled_color())
} else { } else {
Style::new() Style::new()
}; };
("", ms_style) ("", ms_style)
}; };
if *has_children { if *has_children {
Line::styled( Line::from(format!(
format!("{} {}", self.theme.dir_icon(), node.name,), "{} {} {}",
self.theme.dir_color(), self.theme.dir_icon(),
) node.name,
indicator
))
.style(self.theme.dir_color())
.patch_style(style) .patch_style(style)
} else { } else {
let left_content = Line::from(format!(
format!("{} {} {}", self.theme.cmd_icon(), node.name, indicator); "{} {} {}",
let right_content = format!("{} ", node.task_list); self.theme.cmd_icon(),
let center_space = " ".repeat( node.name,
chunks[1].width as usize - left_content.len() - right_content.len(), indicator
); ))
Line::styled( .style(self.theme.cmd_color())
format!("{}{}{}", left_content, center_space, right_content),
self.theme.cmd_color(),
)
.patch_style(style) .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 { let style = if let Focus::List = self.focus {
Style::default().reversed() Style::default().reversed()
} else { } else {
@ -406,10 +451,7 @@ impl AppState {
}; };
#[cfg(feature = "tips")] #[cfg(feature = "tips")]
let bottom_title = Line::from(format!(" {} ", self.tip)) let bottom_title = Line::from(self.tip.as_str().bold().blue()).right_aligned();
.bold()
.blue()
.centered();
#[cfg(not(feature = "tips"))] #[cfg(not(feature = "tips"))]
let bottom_title = ""; let bottom_title = "";
@ -419,18 +461,27 @@ impl AppState {
let list = List::new(items) let list = List::new(items)
.highlight_style(style) .highlight_style(style)
.block( .block(
Block::bordered() Block::default()
.border_set(border::ROUNDED) .borders(Borders::ALL & !Borders::RIGHT)
.border_set(ratatui::symbols::border::ROUNDED)
.title(title) .title(title)
.title(task_list_title)
.title_bottom(bottom_title), .title_bottom(bottom_title),
) )
.scroll_padding(1); .scroll_padding(1);
frame.render_stateful_widget(list, chunks[1], &mut self.selection); 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);
match &mut self.focus { match &mut self.focus {
Focus::FloatingWindow(float) => float.draw(frame, chunks[1], &self.theme), Focus::FloatingWindow(float) => float.draw(frame, chunks[1]),
Focus::ConfirmationPrompt(prompt) => prompt.draw(frame, chunks[1], &self.theme), Focus::ConfirmationPrompt(prompt) => prompt.draw(frame, chunks[1]),
_ => {} _ => {}
} }
@ -515,10 +566,26 @@ impl AppState {
// Handle key only when Tablist or List is focused // Handle key only when Tablist or List is focused
// Prevents exiting the application even when a command is running // Prevents exiting the application even when a command is running
// Add keys here which should work on both TabList and List // Add keys here which should work on both TabList and List
if matches!(self.focus, Focus::TabList | Focus::List) if matches!(self.focus, Focus::TabList | Focus::List) {
&& self.handle_tablist_and_list_keys(key) match key.code {
{ KeyCode::Tab => {
return true; 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();
}
_ => {}
}
} }
match &mut self.focus { match &mut self.focus {
@ -559,9 +626,15 @@ impl AppState {
Focus::TabList => match key.code { Focus::TabList => match key.code {
KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => self.focus = Focus::List, KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => self.focus = Focus::List,
KeyCode::Char('j') | KeyCode::Down => self.scroll_tab_down(), KeyCode::Char('j') | KeyCode::Down => self.scroll_tab_down(),
KeyCode::Char('k') | KeyCode::Up => self.scroll_tab_up(), 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(),
_ => {} _ => {}
}, },
@ -572,6 +645,11 @@ impl AppState {
KeyCode::Char('d') | KeyCode::Char('D') => self.enable_description(), KeyCode::Char('d') | KeyCode::Char('D') => self.enable_description(),
KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => self.handle_enter(), KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => self.handle_enter(),
KeyCode::Char('h') | KeyCode::Left => self.go_back(), 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(), KeyCode::Char(' ') if self.multi_select => self.toggle_selection(),
_ => {} _ => {}
}, },
@ -581,38 +659,32 @@ impl AppState {
true true
} }
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,
}
true
}
fn scroll_down(&mut self) { fn scroll_down(&mut self) {
if let Some(selected) = self.selection.selected() { let len = self.filter.item_list().len();
if selected == self.filter.item_list().len() - 1 { if len == 0 {
self.selection.select_first(); return;
} else {
self.selection.select_next();
}
} }
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
};
self.selection.select(Some(next));
} }
fn scroll_up(&mut self) { fn scroll_up(&mut self) {
if let Some(selected) = self.selection.selected() { let len = self.filter.item_list().len();
if selected == 0 { if len == 0 {
self.selection.select_last(); return;
} 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) { fn toggle_multi_select(&mut self) {
@ -675,12 +747,11 @@ impl AppState {
fn get_selected_node(&self) -> Option<Rc<ListNode>> { fn get_selected_node(&self) -> Option<Rc<ListNode>> {
let mut selected_index = self.selection.selected().unwrap_or(0); let mut selected_index = self.selection.selected().unwrap_or(0);
if !self.at_root() && selected_index == 0 {
return None;
}
if !self.at_root() { if !self.at_root() {
if selected_index == 0 { selected_index = selected_index.saturating_sub(1);
return None;
} else {
selected_index = selected_index.saturating_sub(1);
}
} }
if let Some(item) = self.filter.item_list().get(selected_index) { if let Some(item) = self.filter.item_list().get(selected_index) {
@ -722,12 +793,12 @@ impl AppState {
pub fn selected_item_is_dir(&self) -> bool { pub fn selected_item_is_dir(&self) -> bool {
let mut selected_index = self.selection.selected().unwrap_or(0); let mut selected_index = self.selection.selected().unwrap_or(0);
if !self.at_root() && selected_index == 0 {
return false;
}
if !self.at_root() { if !self.at_root() {
if selected_index == 0 { selected_index = selected_index.saturating_sub(1);
return false;
} else {
selected_index = selected_index.saturating_sub(1);
}
} }
self.filter self.filter
@ -749,9 +820,11 @@ impl AppState {
fn enable_preview(&mut self) { fn enable_preview(&mut self) {
if let Some(list_node) = self.get_selected_node() { if let Some(list_node) = self.get_selected_node() {
let preview_title = format!("[Preview] - {}", list_node.name.as_str()); let mut preview_title = "[Preview] - ".to_string();
let preview = FloatingText::from_command(&list_node.command, &preview_title, false); preview_title.push_str(list_node.name.as_str());
self.spawn_float(preview, FLOAT_SIZE, FLOAT_SIZE); if let Some(preview) = FloatingText::from_command(&list_node.command, preview_title) {
self.spawn_float(preview, 80, 80);
}
} }
} }
@ -760,19 +833,11 @@ impl AppState {
if !command_description.is_empty() { if !command_description.is_empty() {
let description = let description =
FloatingText::new(command_description, "Command Description", true); FloatingText::new(command_description, "Command Description", true);
self.spawn_float(description, FLOAT_SIZE, FLOAT_SIZE); self.spawn_float(description, 80, 80);
} }
} }
} }
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 { fn get_selected_item_type(&self) -> SelectedItem {
if self.selected_item_is_up_dir() { if self.selected_item_is_up_dir() {
SelectedItem::UpDir SelectedItem::UpDir
@ -799,7 +864,14 @@ impl AppState {
if self.skip_confirmation { if self.skip_confirmation {
self.handle_confirm_command(); self.handle_confirm_command();
} else { } else {
self.spawn_confirmprompt(); let cmd_names = self
.selected_commands
.iter()
.map(|node| node.name.as_str())
.collect::<Vec<_>>();
let prompt = ConfirmPrompt::new(&cmd_names[..]);
self.focus = Focus::ConfirmationPrompt(Float::new(Box::new(prompt), 40, 40));
} }
} }
SelectedItem::None => {} SelectedItem::None => {}
@ -807,14 +879,14 @@ impl AppState {
} }
fn handle_confirm_command(&mut self) { fn handle_confirm_command(&mut self) {
let commands: Vec<&Command> = self let commands = self
.selected_commands .selected_commands
.iter() .iter()
.map(|node| &node.command) .map(|node| node.command.clone())
.collect(); .collect();
let command = RunningCommand::new(&commands); let command = RunningCommand::new(commands);
self.spawn_float(command, FLOAT_SIZE, FLOAT_SIZE); self.spawn_float(command, 80, 80);
self.selected_commands.clear(); self.selected_commands.clear();
} }
@ -848,21 +920,44 @@ impl AppState {
self.update_items(); 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) { fn scroll_tab_down(&mut self) {
if self.current_tab.selected().unwrap() == self.tabs.len() - 1 { let len = self.tabs.len();
self.current_tab.select_first(); let current = self.current_tab.selected().unwrap_or(0);
} else { let next = if current + 1 >= len { 0 } else { current + 1 };
self.current_tab.select_next();
} self.current_tab.select(Some(next));
self.refresh_tab(); self.refresh_tab();
} }
fn scroll_tab_up(&mut self) { fn scroll_tab_up(&mut self) {
if self.current_tab.selected().unwrap() == 0 { let len = self.tabs.len();
self.current_tab.select(Some(self.tabs.len() - 1)); let current = self.current_tab.selected().unwrap_or(0);
} else { let next = if current == 0 { len - 1 } else { current - 1 };
self.current_tab.select_previous();
} self.current_tab.select(Some(next));
self.refresh_tab(); 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])
}

View File

@ -14,91 +14,84 @@ pub enum Theme {
} }
impl Theme { impl Theme {
pub const fn dir_color(&self) -> Color { pub fn dir_color(&self) -> Color {
match self { match self {
Theme::Default => Color::Blue, Theme::Default => Color::Blue,
Theme::Compatible => Color::Blue, Theme::Compatible => Color::Blue,
} }
} }
pub const fn cmd_color(&self) -> Color { pub fn cmd_color(&self) -> Color {
match self { match self {
Theme::Default => Color::Rgb(204, 224, 208), Theme::Default => Color::Rgb(204, 224, 208),
Theme::Compatible => Color::LightGreen, Theme::Compatible => Color::LightGreen,
} }
} }
pub const fn multi_select_disabled_color(&self) -> Color { pub fn multi_select_disabled_color(&self) -> Color {
match self { match self {
Theme::Default => Color::DarkGray, Theme::Default => Color::DarkGray,
Theme::Compatible => Color::DarkGray, Theme::Compatible => Color::DarkGray,
} }
} }
pub const fn tab_color(&self) -> Color { pub fn tab_color(&self) -> Color {
match self { match self {
Theme::Default => Color::Rgb(255, 255, 85), Theme::Default => Color::Rgb(255, 255, 85),
Theme::Compatible => Color::Yellow, Theme::Compatible => Color::Yellow,
} }
} }
pub const fn dir_icon(&self) -> &'static str { pub fn dir_icon(&self) -> &'static str {
match self { match self {
Theme::Default => "", Theme::Default => "",
Theme::Compatible => "[DIR]", Theme::Compatible => "[DIR]",
} }
} }
pub const fn cmd_icon(&self) -> &'static str { pub fn cmd_icon(&self) -> &'static str {
match self { match self {
Theme::Default => "", Theme::Default => "",
Theme::Compatible => "[CMD]", Theme::Compatible => "[CMD]",
} }
} }
pub const fn tab_icon(&self) -> &'static str { pub fn tab_icon(&self) -> &'static str {
match self { match self {
Theme::Default => "", Theme::Default => "",
Theme::Compatible => ">> ", Theme::Compatible => ">> ",
} }
} }
pub const fn multi_select_icon(&self) -> &'static str { pub fn multi_select_icon(&self) -> &'static str {
match self { match self {
Theme::Default => "", Theme::Default => "",
Theme::Compatible => "*", Theme::Compatible => "*",
} }
} }
pub const fn success_color(&self) -> Color { pub fn success_color(&self) -> Color {
match self { match self {
Theme::Default => Color::Rgb(5, 255, 55), Theme::Default => Color::Rgb(199, 55, 44),
Theme::Compatible => Color::Green, Theme::Compatible => Color::Green,
} }
} }
pub const fn fail_color(&self) -> Color { pub fn fail_color(&self) -> Color {
match self { match self {
Theme::Default => Color::Rgb(199, 55, 44), Theme::Default => Color::Rgb(5, 255, 55),
Theme::Compatible => Color::Red, Theme::Compatible => Color::Red,
} }
} }
pub const fn focused_color(&self) -> Color { pub fn focused_color(&self) -> Color {
match self { match self {
Theme::Default => Color::LightBlue, Theme::Default => Color::LightBlue,
Theme::Compatible => Color::LightBlue, Theme::Compatible => Color::LightBlue,
} }
} }
pub const fn search_preview_color(&self) -> Color { pub fn unfocused_color(&self) -> Color {
match self {
Theme::Default => Color::DarkGray,
Theme::Compatible => Color::DarkGray,
}
}
pub const fn unfocused_color(&self) -> Color {
match self { match self {
Theme::Default => Color::Gray, Theme::Default => Color::Gray,
Theme::Compatible => Color::Gray, Theme::Compatible => Color::Gray,

View File

@ -1,13 +0,0 @@
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]
}