Compare commits

..

No commits in common. "0b1bd18cef6c5577157f9da0348761c5a17a9130" and "d0a9515615d6c79b361a811cd4f01b64002509fe" have entirely different histories.

20 changed files with 822 additions and 704 deletions

93
.github/preview.tape vendored
View File

@ -1,93 +0,0 @@
# VHS documentation
#
# Output:
# Output <path>.gif Create a GIF output at the given <path>
# Output <path>.mp4 Create an MP4 output at the given <path>
# Output <path>.webm Create a WebM output at the given <path>
#
# Require:
# Require <string> Ensure a program is on the $PATH to proceed
#
# Settings:
# Set FontSize <number> Set the font size of the terminal
# Set FontFamily <string> Set the font family of the terminal
# Set Height <number> Set the height of the terminal
# Set Width <number> Set the width of the terminal
# Set LetterSpacing <float> Set the font letter spacing (tracking)
# Set LineHeight <float> Set the font line height
# Set LoopOffset <float>% Set the starting frame offset for the GIF loop
# Set Theme <json|string> Set the theme of the terminal
# Set Padding <number> Set the padding of the terminal
# Set Framerate <number> Set the framerate of the recording
# Set PlaybackSpeed <float> Set the playback speed of the recording
# Set MarginFill <file|#000000> Set the file or color the margin will be filled with.
# Set Margin <number> Set the size of the margin. Has no effect if MarginFill isn't set.
# Set BorderRadius <number> Set terminal border radius, in pixels.
# Set WindowBar <string> Set window bar type. (one of: Rings, RingsRight, Colorful, ColorfulRight)
# Set WindowBarSize <number> Set window bar size, in pixels. Default is 40.
# Set TypingSpeed <time> Set the typing speed of the terminal. Default is 50ms.
#
# Sleep:
# Sleep <time> Sleep for a set amount of <time> in seconds
#
# Type:
# Type[@<time>] "<characters>" Type <characters> into the terminal with a
# <time> delay between each character
#
# Keys:
# Escape[@<time>] [number] Press the Escape key
# Backspace[@<time>] [number] Press the Backspace key
# Delete[@<time>] [number] Press the Delete key
# Insert[@<time>] [number] Press the Insert key
# Down[@<time>] [number] Press the Down key
# Enter[@<time>] [number] Press the Enter key
# Space[@<time>] [number] Press the Space key
# Tab[@<time>] [number] Press the Tab key
# Left[@<time>] [number] Press the Left Arrow key
# Right[@<time>] [number] Press the Right Arrow key
# Up[@<time>] [number] Press the Up Arrow key
# Down[@<time>] [number] Press the Down Arrow key
# PageUp[@<time>] [number] Press the Page Up key
# PageDown[@<time>] [number] Press the Page Down key
# Ctrl+<key> Press the Control key + <key> (e.g. Ctrl+C)
#
# Display:
# Hide Hide the subsequent commands from the output
# Show Show the subsequent commands in the output
Output preview.gif
Require linutil
Require sh
Set Shell "bash"
Set FontFamily "JetBrainsMono Nerd Font"
Set FontSize 24
Set Width 1920
Set Height 1080
Sleep 1s
Type "linutil" Sleep 1s Enter
Sleep 2s
Left Sleep 1s
Down Sleep 1s
Down Sleep 1s
Down Sleep 1s
Down Sleep 1s
Right Sleep 1s
Type "/" Sleep 1s
Type@200ms "Full System Cleanup" Sleep 1s Enter
Sleep 1s
Enter Sleep 2s
Type "y" # CONFIRMATION PROMPT
Sleep 15s
Type "y" # SYSTEM CLEANUP PROMPT
Enter
Sleep 4s
Enter
Sleep 2s

View File

@ -67,10 +67,9 @@ jobs:
uses: peter-evans/create-pull-request@v7.0.5 uses: peter-evans/create-pull-request@v7.0.5
with: with:
commit-message: Preview for ${{ env.tag_name }} commit-message: Preview for ${{ env.tag_name }}
token: ${{ secrets.PAT_TOKEN }}
branch: feature/preview-${{ env.tag_name }} branch: feature/preview-${{ env.tag_name }}
title: "Update preview for ${{ env.tag_name }}" title: "Update preview for ${{ env.tag_name }}"
labels: |
documentation
body: | body: |
Automated PR to update preview gif for version ${{ env.tag_name }} Automated PR to update preview gif for version ${{ env.tag_name }}
![preview](https://raw.githubusercontent.com/${{ github.repository }}/feature/preview-${{ env.tag_name }}/.github/preview.gif) ![preview](https://raw.githubusercontent.com/${{ github.repository }}/feature/preview-${{ env.tag_name }}/.github/preview.gif)

101
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 = "anstream" name = "anstream"
version = "0.6.18" version = "0.6.18"
@ -426,7 +438,7 @@ checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1"
[[package]] [[package]]
name = "linutil_core" name = "linutil_core"
version = "24.10.31" version = "24.9.28"
dependencies = [ dependencies = [
"ego-tree", "ego-tree",
"include_dir", "include_dir",
@ -438,8 +450,10 @@ dependencies = [
[[package]] [[package]]
name = "linutil_tui" name = "linutil_tui"
version = "24.10.31" version = "24.9.28"
dependencies = [ dependencies = [
"ansi-to-tui",
"anstyle",
"clap", "clap",
"linutil_core", "linutil_core",
"nix 0.29.0", "nix 0.29.0",
@ -447,12 +461,14 @@ dependencies = [
"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", "zips",
] ]
[[package]] [[package]]
@ -501,6 +517,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"
@ -540,6 +562,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"
@ -778,18 +810,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",
@ -947,9 +979,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",
@ -971,6 +1003,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"
@ -1060,9 +1098,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",
@ -1073,9 +1111,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",
@ -1083,9 +1121,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",
@ -1107,6 +1145,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72af159125ce32b02ceaced6cffae6394b0e6b6dfd4dc164a6c59a2db9b3c0b0" checksum = "72af159125ce32b02ceaced6cffae6394b0e6b6dfd4dc164a6c59a2db9b3c0b0"
dependencies = [ dependencies = [
"ratatui", "ratatui",
"vt100",
] ]
[[package]] [[package]]
@ -1157,24 +1196,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]] [[package]]
name = "vt100-ctt" name = "vt100"
version = "0.16.0" version = "0.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/ChrisTitusTech/vt100-rust#e41fb3d8fb5fd01dd2d076c9a25823a31656012f"
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",
@ -1199,9 +1235,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",
@ -1339,7 +1375,7 @@ checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
[[package]] [[package]]
name = "xtask" name = "xtask"
version = "24.10.31" version = "24.9.28"
dependencies = [ dependencies = [
"linutil_core", "linutil_core",
] ]
@ -1364,3 +1400,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

@ -1,6 +1,6 @@
[workspace.package] [workspace.package]
license = "MIT" license = "MIT"
version = "24.10.31" version = "24.9.28"
edition = "2021" edition = "2021"
[workspace] [workspace]
@ -8,6 +8,9 @@ members = ["tui", "core", "xtask"]
default-members = ["tui", "core"] default-members = ["tui", "core"]
resolver = "2" resolver = "2"
[patch.crates-io]
vt100 = { git = "https://github.com/ChrisTitusTech/vt100-rust" }
[profile.release] [profile.release]
opt-level = "z" opt-level = "z"
debug = false debug = false

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

@ -18,15 +18,19 @@ clap = { version = "4.5.20", features = ["derive"] }
oneshot = { version = "0.1.8", features = ["std"], default-features = false } 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 = "0.2.0"
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.9.28", path = "../core" }
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"
[[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()
.constraints([
Constraint::Percentage((100 - self.width_percent) / 2), Constraint::Percentage((100 - self.width_percent) / 2),
Constraint::Percentage(self.width_percent), Constraint::Percentage(self.width_percent),
Constraint::Percentage((100 - self.width_percent) / 2), Constraint::Percentage((100 - self.width_percent) / 2),
]) ])
.direction(Direction::Horizontal)
.split(size)[1]; .split(size)[1];
Layout::vertical([ Layout::default()
.constraints([
Constraint::Percentage((100 - self.height_percent) / 2), Constraint::Percentage((100 - self.height_percent) / 2),
Constraint::Percentage(self.height_percent), Constraint::Percentage(self.height_percent),
Constraint::Percentage((100 - self.height_percent) / 2), Constraint::Percentage((100 - self.height_percent) / 2),
]) ])
.direction(Direction::Vertical)
.split(hor_float)[1] .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,61 +53,7 @@ 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
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( let mut hl_conf = hl::HighlightConfiguration::new(
hl_bash::LANGUAGE.into(), hl_bash::LANGUAGE.into(),
"bash", "bash",
@ -90,13 +63,24 @@ impl<'a> FloatingText<'a> {
) )
.ok()?; .ok()?;
hl_conf.configure(&matched_tokens); let matched_tokens = &SYNTAX_HIGHLIGHT_STYLES
.iter()
.map(|hl| hl.0)
.collect::<Vec<_>>();
hl_conf.configure(matched_tokens);
let mut hl = hl::Highlighter::new(); let mut hl = hl::Highlighter::new();
let events = hl.highlight(&hl_conf, s.as_bytes(), None, |_| None).ok()?;
let mut style_stack = vec![anstyle::Style::new()];
let src = s.as_bytes();
let events = hl.highlight(&hl_conf, src, None, |_| None).ok()?;
let mut buf = Cursor::new(vec![]);
for event in events { for event in events {
match event.ok()? { match event.unwrap() {
HighlightEvent::HighlightStart(h) => { HighlightEvent::HighlightStart(h) => {
style_stack.push(SYNTAX_HIGHLIGHT_STYLES.get(h.0)?.1); style_stack.push(SYNTAX_HIGHLIGHT_STYLES.get(h.0)?.1);
} }
@ -106,96 +90,197 @@ impl<'a> FloatingText<'a> {
} }
HighlightEvent::Source { start, end } => { HighlightEvent::Source { start, end } => {
let style = *style_stack.last()?; let style = style_stack.last()?;
let content = &s[start..end]; zip_result!(
write!(&mut buf, "{}", style),
buf.write_all(&src[start..end]),
write!(&mut buf, "{style:#}"),
)?;
}
}
}
for part in content.split_inclusive('\n') { let mut output = String::new();
if let Some(stripped) = part.strip_suffix('\n') {
// Push the text that is before '\n' and then start a new line zip_result!(
// After a new line clear the current line to start a new one buf.seek(SeekFrom::Start(0)),
current_line.push(Span::styled(stripped.to_owned(), style)); buf.read_to_string(&mut output),
lines.push(Line::from(current_line.to_owned())); )?;
current_line.clear();
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 { } else {
current_line.push(Span::styled(part.to_owned(), style)); 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,
} }
} }
// Makes sure last line of the file is pushed pub fn from_command(command: &Command, title: String) -> Option<Self> {
// If no newline at the end of the file we need to push the last line let src = match command {
if !current_line.is_empty() { Command::Raw(cmd) => Some(cmd.clone()),
lines.push(Line::from(current_line)); Command::LocalFile { file, .. } => std::fs::read_to_string(file)
} .map_err(|_| format!("File not found: {:?}", file))
.ok(),
Command::None => None,
}?;
if lines.is_empty() { let max_line_width = 80;
return None; let wrapped_lines = get_lines_owned(&get_highlighted_string(&src)?);
}
Some(Text::from(lines)) Some(Self {
src,
wrapped_lines,
mode_title: title,
max_line_width,
h_scroll: 0,
v_scroll: 0,
wrap_words: false,
frame_height: 0,
})
} }
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 }
}
fn update_wrapping(&mut self, width: usize) {
if self.max_line_width != width {
self.max_line_width = width;
self.wrapped_lines = if self.wrap_words {
wrap(&self.src, width)
.into_iter()
.map(|cow| cow.into_owned())
.collect()
} else { } else {
self.processed_text get_lines_owned(&get_highlighted_string(&self.src).unwrap_or(self.src.clone()))
.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<'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) // Calculate the inner area to ensure text is not drawn over the border
.wrap(Wrap { trim: false }) 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 { } else {
Paragraph::new(self.processed_text.clone()).scroll(self.scroll) 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<_>>();
frame.render_widget(paragraph, inner_area); 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,20 +6,22 @@ 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,
}; };
use time::{macros::format_description, OffsetDateTime}; use time::{macros::format_description, OffsetDateTime};
use tui_term::widget::PseudoTerminal; use tui_term::{
use vt100_ctt::{Parser, Screen}; vt100::{self, Screen},
widget::PseudoTerminal,
};
pub struct RunningCommand { pub struct RunningCommand {
/// A buffer to save all the command output (accumulates, until the command exits) /// A buffer to save all the command output (accumulates, until the command exits)
buffer: Arc<Mutex<Vec<u8>>>, buffer: Arc<Mutex<Vec<u8>>>,
@ -40,43 +42,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()
.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());
} else { } else {
Line::from(" Press 'l' to save command log ") block =
block.title_bottom(Line::from(" Press 'l' to save command log ").centered());
}
block
}; };
Block::bordered()
.border_set(border::ROUNDED)
.title_top(title_line.centered())
.title_bottom(log_path.centered())
};
// Calculate the inner size of the terminal area, considering borders
let inner_size = block.inner(area).as_size();
// Process the buffer and create the pseudo-terminal widget // 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 +179,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 +199,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
} }
@ -266,7 +285,7 @@ impl RunningCommand {
// Process the buffer with a parser with the current screen size // Process the buffer with a parser with the current screen size
// We don't actually need to create a new parser every time, but it is so much easier this // We don't actually need to create a new parser every time, but it is so much easier this
// way, and doesn't cost that much // way, and doesn't cost that much
let mut parser = Parser::new(size.height, size.width, 1000); let mut parser = vt100::Parser::new(size.height, size.width, 1000);
let mutex = self.buffer.lock(); let mutex = self.buffer.lock();
let buffer = mutex.as_ref().unwrap(); let buffer = mutex.as_ref().unwrap();
parser.process(buffer); parser.process(buffer);
@ -295,7 +314,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 +325,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,43 +120,31 @@ 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; .iter()
.filter_map(|name| self.tabs.iter().find_map(|tab| tab.find_command(name)))
.collect();
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() { if !self.selected_commands.is_empty() {
self.spawn_confirmprompt();
}
}
fn spawn_confirmprompt(&mut self) {
let cmd_names: Vec<_> = self let cmd_names: Vec<_> = self
.selected_commands .selected_commands
.iter() .iter()
@ -162,11 +152,8 @@ impl AppState {
.collect(); .collect();
let prompt = ConfirmPrompt::new(&cmd_names); let prompt = ConfirmPrompt::new(&cmd_names);
self.focus = Focus::ConfirmationPrompt(Float::new( self.focus = Focus::ConfirmationPrompt(Float::new(Box::new(prompt), 40, 40));
Box::new(prompt), }
CONFIRM_PROMPT_FLOAT_SIZE,
CONFIRM_PROMPT_FLOAT_SIZE,
));
} }
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,12 +253,18 @@ 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()
.borders(Borders::ALL)
.border_set(ratatui::symbols::border::ROUNDED)
.border_set(ratatui::symbols::border::Set {
top_left: " ", top_left: " ",
top_right: " ", top_right: " ",
bottom_left: " ", bottom_left: " ",
@ -286,38 +274,59 @@ impl AppState {
horizontal_top: "*", horizontal_top: "*",
horizontal_bottom: "*", 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)
.constraints([
Constraint::Percentage(0),
Constraint::Max(n_lines as u16 + 2),
])
.flex(Flex::Legacy) .flex(Flex::Legacy)
.split(area); .margin(0)
.split(frame.area());
let horizontal = Layout::horizontal([ let horizontal = Layout::default()
Constraint::Min(self.longest_tab_display_len + 5), .direction(Direction::Horizontal)
.constraints([
Constraint::Min(longest_tab_display_len as u16 + 5),
Constraint::Percentage(100), Constraint::Percentage(100),
]) ])
.split(vertical[0]); .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;
}
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 { } else {
self.selection.select_next(); 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() { if !self.at_root() && selected_index == 0 {
if selected_index == 0 {
return None; return None;
} else {
selected_index = selected_index.saturating_sub(1);
} }
if !self.at_root() {
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() { if !self.at_root() && selected_index == 0 {
if selected_index == 0 {
return false; return false;
} else {
selected_index = selected_index.saturating_sub(1);
} }
if !self.at_root() {
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 scroll_tab_down(&mut self) { fn toggle_task_list_guide(&mut self) {
if self.current_tab.selected().unwrap() == self.tabs.len() - 1 { self.spawn_float(
self.current_tab.select_first(); FloatingText::new(ACTIONS_GUIDE.to_string(), "Important Actions Guide", true),
} else { 80,
self.current_tab.select_next(); 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));
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]
}