refact: rust fixes and optimizations (#933)

* fix: getting locked out when running script

* Use success and fail colors and reorder imports

Use theme color instead of using ratatui::Color for running_command success and fail + search preview text color + min tui warning color, add colors for confirmation prompt, fix inverted success and fail colors

* Remove redundant code in themes

Removed redundant match statement with a function

* Fix scroll beyond list, color bleeding and refact in confirmation.rs

Remove unnecessary usage of pub in ConfirmPropmt struct fields, simplify numbering, prevent scrolling beyond list, fix color bleeding

* Implement case insensitive, fix word disappearing bug

Use regex for case insesitive finding, implement String instead of char<Vec>, fix word disappearing by recalculating the render x for preview text

* Revert "Remove redundant code in themes"

This reverts commit 3b7e859af8.

* Reference instead of passing the vector

* Revert regex and String implementation

Use Vec<char> for search_input to prevent panics when using multi-byte characters, use lowercase conversion instead of regex, Added comments for clarity

* Replace ansi and text wrapping code with ratatui

Replaced ansi related code for tree sitter highlight with direct ratatui::text. Cache the processed text in appstate to remove processing of text for every frame render.Create paragraph instead of list so that scroll and wrapping can be done without external crates. Add caps keys for handle_key_event.

* Fix conflicts

* Reference instead of borrowing commands, refact mut variables

Reference instead of borrowing commands from state, Refactor draw function variables to immutable, calculate innersize from block instead of manual definition

* Update tui/src/filter.rs

Co-authored-by: Liam <33645555+lj3954@users.noreply.github.com>

* Rendering optimizations and function refactors

Handle `find_command` inside state itself -> `get_command_by_name`. Move tips to a seperate file for modularity. Pass the whole args to state instead of seperate args. Use const for float and confirmation prompt float sizes. Add the `longest_tab_length` to appstate struct so that it will not be calculated for each frame render use static str instead String for tips. Use function for spawning confirmprompt. Merge command list and task items list rendering a single widget instead of two. Remove redundant keys in handle_key. Optimize scrolling logic. Rename `toggle_task_list_guide` -> `enable_task_list_guide`

* Cleanup

Use prelude for ratatui imports. Use const for theme functions, add
missing hints

* Update deps, remove unused temp-dir

* Add accidentally deleted preview.tape

Add labels + Wait 2sec after program ends

* Add fields to config files

Skip Confirmation, Bypass Size

* Remove accidentally commited config file

---------

Co-authored-by: Liam <33645555+lj3954@users.noreply.github.com>
This commit is contained in:
Jeevitha Kannan K S 2024-11-17 23:54:54 +05:30 committed by GitHub
parent fa69885b6c
commit ab7a67087d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 592 additions and 815 deletions

109
Cargo.lock generated
View File

@ -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",
]

View File

@ -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"

View File

@ -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<String>,
#[serde(default)]
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 {
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<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,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");

View File

@ -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<Rc<ListNode>> {
fn find_command_by_name(&self, name: &str) -> Option<Rc<ListNode>> {
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())
})
}
}

View File

@ -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"

View File

@ -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::<Text>();
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"]),
]),
)

View File

@ -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<char> to handle multi-byte characters like emojis
search_input: Vec<char>,
in_search_mode: bool,
input_position: usize,
items: Vec<ListEntry>,
// No complex string manipulation is done with completion_preview so we can use String unlike search_input
completion_preview: Option<String>,
}
@ -62,13 +62,11 @@ impl Filter {
.collect();
} else {
self.items.clear();
let query_lower = self.search_input.iter().collect::<String>().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;
}
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 {
self.completion_preview = if self.items.is_empty() || self.search_input.is_empty() {
None
} else {
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())
})
}
});
}
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::<String>().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

View File

@ -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<Content: FloatContent + ?Sized> Float<Content> {
}
fn floating_window(&self, size: Rect) -> Rect {
let hor_float = Layout::default()
.constraints([
let hor_float = Layout::horizontal([
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];
Layout::default()
.constraints([
Layout::vertical([
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]
}
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) {

View File

@ -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<String>,
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,7 +26,61 @@ const SYNTAX_HIGHLIGHT_STYLES: [(&str, anstyle::Style); 8] = [
("number", style!(181, 206, 168)), // light green
];
fn get_highlighted_string(s: &str) -> Option<String> {
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,
}
impl<'a> FloatingText<'a> {
pub fn new(text: String, title: &str, wrap_words: bool) -> Self {
let processed_text = Text::from(text);
Self {
inner_area_size: (0, 0),
mode_title: title.to_string(),
processed_text,
scroll: (0, 0),
wrap_words,
}
}
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 processed_text = Self::get_highlighted_string(&src).unwrap_or_else(|| Text::from(src));
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<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",
@ -63,24 +90,13 @@ fn get_highlighted_string(s: &str) -> Option<String> {
)
.ok()?;
let matched_tokens = &SYNTAX_HIGHLIGHT_STYLES
.iter()
.map(|hl| hl.0)
.collect::<Vec<_>>();
hl_conf.configure(matched_tokens);
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![]);
let events = hl.highlight(&hl_conf, s.as_bytes(), None, |_| None).ok()?;
for event in events {
match event.unwrap() {
match event.ok()? {
HighlightEvent::HighlightStart(h) => {
style_stack.push(SYNTAX_HIGHLIGHT_STYLES.get(h.0)?.1);
}
@ -90,197 +106,96 @@ fn get_highlighted_string(s: &str) -> Option<String> {
}
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 style = *style_stack.last()?;
let content = &s[start..end];
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()
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 {
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,
current_line.push(Span::styled(part.to_owned(), style));
}
}
}
}
}
pub fn from_command(command: &Command, title: String) -> Option<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,
}?;
// 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));
}
let max_line_width = 80;
let wrapped_lines = get_lines_owned(&get_highlighted_string(&src)?);
if lines.is_empty() {
return None;
}
Some(Self {
src,
wrapped_lines,
mode_title: title,
max_line_width,
h_scroll: 0,
v_scroll: 0,
wrap_words: false,
frame_height: 0,
})
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()
let visible_length = self.inner_area_size.0.saturating_sub(1);
let max_scroll = if self.wrap_words {
0
} else {
get_lines_owned(&get_highlighted_string(&self.src).unwrap_or(self.src.clone()))
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())]
let paragraph = if self.wrap_words {
Paragraph::new(self.processed_text.clone())
.scroll(self.scroll)
.wrap(Wrap { trim: false })
} 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<_>>();
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::<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);
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

View File

@ -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<Span<'static>>,
pub desc: &'static str,
key_sequences: Vec<Span<'static>>,
desc: &'static str,
}
fn add_spacing(list: Vec<Vec<Span>>) -> Line {
@ -19,7 +18,7 @@ fn add_spacing(list: Vec<Vec<Span>>) -> 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<Line<'static>> = Vec::new();
let mut lines: Vec<Line<'static>> = 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()
}

View File

@ -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<PathBuf>,
#[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<CrosstermBackend<io::Stdout>>,
state: &mut AppState,
) -> io::Result<()> {
fn run(terminal: &mut Terminal<CrosstermBackend<Stdout>>, state: &mut AppState) -> Result<()> {
loop {
terminal.draw(|frame| state.draw(frame)).unwrap();
// 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.";
#[cfg(unix)]
pub fn check_root_status() -> Option<FloatingText> {
pub fn check_root_status<'a>() -> Option<FloatingText<'a>> {
(Uid::effective().is_root()).then_some(FloatingText::new(
ROOT_WARNING.into(),
"Root User Warning",

View File

@ -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 <ENTER> 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 <ENTER> to close this window",
Style::default().fg(theme.fail_color()).reversed(),
)
};
title_line.push_span(
Span::default()
.content(" Press <ENTER> 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());
}
block
Line::from(" Press 'l' to save command log ")
};
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<Command>) -> 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<String> {
fn save_log(&self) -> Result<String> {
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)?;

View File

@ -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<Rc<ListNode>>,
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<PathBuf>,
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,31 +118,43 @@ 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
.iter()
.filter_map(|name| self.tabs.iter().find_map(|tab| tab.find_command(name)))
.collect();
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()
@ -152,8 +162,11 @@ impl AppState {
.collect();
let prompt = ConfirmPrompt::new(&cmd_names);
self.focus = Focus::ConfirmationPrompt(Float::new(Box::new(prompt), 40, 40));
}
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,18 +271,12 @@ 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 {
let label_block = Block::bordered().border_set(border::Set {
top_left: " ",
top_right: " ",
bottom_left: " ",
@ -274,59 +286,38 @@ impl AppState {
horizontal_top: "*",
horizontal_bottom: "*",
});
let str1 = "Linutil ";
let str2 = "by Chris Titus";
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),
])
let vertical =
Layout::vertical([Constraint::Percentage(0), Constraint::Max(keybind_len + 2)])
.flex(Flex::Legacy)
.margin(0)
.split(frame.area());
.split(area);
let horizontal = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(longest_tab_display_len as u16 + 5),
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<Line> = Vec::new();
let mut task_items: Vec<Line> = Vec::new();
let mut items: Vec<Line> = 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,
}
true
}
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_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,12 +675,13 @@ impl AppState {
fn get_selected_node(&self) -> Option<Rc<ListNode>> {
let mut selected_index = self.selection.selected().unwrap_or(0);
if !self.at_root() && selected_index == 0 {
return None;
}
if !self.at_root() {
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) {
if !item.has_children {
@ -793,13 +722,13 @@ 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() {
if selected_index == 0 {
return false;
} else {
selected_index = selected_index.saturating_sub(1);
}
}
self.filter
.item_list()
@ -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::<Vec<_>>();
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])
}

View File

@ -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,

13
tui/src/tips.rs Normal file
View File

@ -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]
}