initial commit
This commit is contained in:
parent
212a05d71a
commit
e1427912f5
80 changed files with 8684 additions and 0 deletions
40
tui/Cargo.toml
Normal file
40
tui/Cargo.toml
Normal file
|
@ -0,0 +1,40 @@
|
|||
[package]
|
||||
name = "linutil_tui"
|
||||
description = "Pika's fork from https://christitustech.github.io/linutil to serve as his own tui for his scripts"
|
||||
documentation = "https://christitustech.github.io/linutil"
|
||||
readme = "../README.md"
|
||||
edition = "2021"
|
||||
license.workspace = true
|
||||
repository = "https://github.com/ChrisTitusTech/linutil/tree/main/tui"
|
||||
version.workspace = true
|
||||
include = ["src/*.rs", "Cargo.toml", "build.rs", "cool_tips.txt", "../man/linutil.1"]
|
||||
build = "build.rs"
|
||||
|
||||
[features]
|
||||
default = ["tips"]
|
||||
tips = ["rand"]
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.5.19", features = ["derive"] }
|
||||
crossterm = "0.28.1"
|
||||
ego-tree = { workspace = true }
|
||||
oneshot = "0.1.8"
|
||||
portable-pty = "0.8.1"
|
||||
ratatui = "0.28.1"
|
||||
tui-term = "0.1.12"
|
||||
temp-dir = "0.1.14"
|
||||
unicode-width = "0.2.0"
|
||||
rand = { version = "0.8.5", optional = true }
|
||||
linutil_core = { path = "../core", version = "24.9.28" }
|
||||
tree-sitter-highlight = "0.24.2"
|
||||
tree-sitter-bash = "0.23.1"
|
||||
anstyle = "1.0.8"
|
||||
ansi-to-tui = "6.0.0"
|
||||
zips = "0.1.7"
|
||||
|
||||
[build-dependencies]
|
||||
chrono = "0.4.33"
|
||||
|
||||
[[bin]]
|
||||
name = "linutil"
|
||||
path = "src/main.rs"
|
7
tui/build.rs
Normal file
7
tui/build.rs
Normal file
|
@ -0,0 +1,7 @@
|
|||
fn main() {
|
||||
// Add current date as a variable to be displayed in the 'Linux Toolbox' text.
|
||||
println!(
|
||||
"cargo:rustc-env=BUILD_DATE={}",
|
||||
chrono::Local::now().format("%Y-%m-%d")
|
||||
);
|
||||
}
|
239
tui/cool_tips.txt
Normal file
239
tui/cool_tips.txt
Normal file
|
@ -0,0 +1,239 @@
|
|||
ls: Lists files and directories in the current directory.
|
||||
cd: Changes the current directory.
|
||||
pwd: Prints the current working directory.
|
||||
mkdir: Creates a new directory.
|
||||
rmdir: Removes an empty directory.
|
||||
cp: Copies files or directories.
|
||||
mv: Moves or renames files or directories.
|
||||
rm: Removes files or directories.
|
||||
touch: Creates an empty file or updates the timestamp of an existing file.
|
||||
cat: Displays the content of a file.
|
||||
less: Views the content of a file, one page at a time.
|
||||
head: Displays the first few lines of a file.
|
||||
tail: Displays the last few lines of a file.
|
||||
find: Searches for files in a directory hierarchy.
|
||||
grep: Searches for a specific pattern in files.
|
||||
chmod: Changes file or directory permissions.
|
||||
chown: Changes file or directory ownership.
|
||||
ln: Creates hard or symbolic links to files.
|
||||
df: Displays disk space usage.
|
||||
du: Shows disk usage of files and directories.
|
||||
top: Displays real-time system processes.
|
||||
ps: Shows running processes.
|
||||
kill: Terminates a process by PID.
|
||||
man: Displays the manual page for a command.
|
||||
history: Shows the history of commands you've run.
|
||||
sudo: Executes a command with superuser privileges.
|
||||
apt-get: Installs, updates, or removes packages on Debian-based systems.
|
||||
yum: Installs, updates, or removes packages on Red Hat-based systems.
|
||||
wget: Downloads files from the web.
|
||||
curl: Transfers data from or to a server using various protocols.
|
||||
ssh: Connects to a remote machine securely via SSH.
|
||||
ping: Tests connectivity to a network host.
|
||||
ifconfig: Displays or configures network interfaces.
|
||||
ip: Displays and manages network interfaces, routing, and more.
|
||||
netstat: Displays network connections, routing tables, and interface statistics.
|
||||
echo: Prints text to the terminal or writes text to files.
|
||||
date: Displays or sets the system date and time.
|
||||
shutdown: Shuts down or reboots the system.
|
||||
reboot: Reboots the system.
|
||||
alias: Creates a shortcut for a command.
|
||||
diff: Compares the contents of two files line by line.
|
||||
cmp: Compares two files byte by byte.
|
||||
sort: Sorts the lines of a file alphabetically or numerically.
|
||||
uniq: Removes duplicate lines from a sorted file.
|
||||
wc: Counts words, lines, and characters in a file.
|
||||
cut: Cuts sections from each line of a file or output.
|
||||
tr: Translates or deletes characters from input.
|
||||
xargs: Builds and executes command lines from standard input.
|
||||
tee: Reads from standard input and writes to both standard output and files.
|
||||
basename: Strips directory and suffix from filenames.
|
||||
dirname: Extracts the directory path from a file path.
|
||||
read: Reads input from the user or file.
|
||||
tar: Archives and extracts files using tar format.
|
||||
zip: Compresses files into a zip archive.
|
||||
unzip: Extracts files from a zip archive.
|
||||
gzip: Compresses files using the gzip format.
|
||||
gunzip: Decompresses gzip-compressed files.
|
||||
rsync: Synchronizes files and directories between two locations.
|
||||
iptables: Configures the IP packet filter rules of the Linux kernel.
|
||||
ufw: Simplified firewall management tool (Uncomplicated Firewall).
|
||||
systemctl: Controls the systemd system and service manager.
|
||||
journalctl: Views systemd logs.
|
||||
dmesg: Displays kernel ring buffer messages.
|
||||
who: Shows who is logged into the system.
|
||||
last: Displays a list of last logged-in users.
|
||||
at: Schedules a one-time task to run at a specific time.
|
||||
awk: Pattern scanning and processing language used for text processing.
|
||||
sed: Stream editor for filtering and transforming text.
|
||||
chroot: Changes the root directory for a command or shell.
|
||||
lsof: Lists open files and the processes that opened them.
|
||||
nc: A versatile networking tool often used for testing and debugging.
|
||||
sftp: A secure file transfer program over SSH.
|
||||
ncdu: Disk usage analyzer with a user-friendly interface.
|
||||
dig: Performs DNS lookups.
|
||||
nslookup: Queries DNS information.
|
||||
hostname: Displays or sets the system’s hostname.
|
||||
curl ifconfig.me: Gets your public IP address.
|
||||
adduser: Adds a new user to the system.
|
||||
deluser: Removes a user from the system.
|
||||
groupadd: Adds a new group to the system.
|
||||
usermod: Modifies user account details.
|
||||
groups: Displays the groups the current user belongs to.
|
||||
sudo su: Switches to the root user.
|
||||
nohup: Runs a command that will continue running after logging out.
|
||||
jobs: Lists all active jobs in the current session.
|
||||
fg: Brings a background job to the foreground.
|
||||
bg: Resumes a stopped job in the background.
|
||||
ctrl + z: Pauses a foreground job, allowing it to run in the background.
|
||||
locate: Quickly finds files by name.
|
||||
updatedb: Updates the database used by the locate command.
|
||||
alias ll='ls -la': Creates an alias ll for a long-format list of files.
|
||||
unalias: Removes an alias for a command.
|
||||
export: Sets or exports environment variables.
|
||||
env: Displays the current environment variables.
|
||||
crontab: Manages cron jobs for automating tasks.
|
||||
watch: Repeatedly runs a command at regular intervals.
|
||||
vmstat: Reports virtual memory statistics.
|
||||
mpstat: Displays CPU usage statistics.
|
||||
htop: An interactive process viewer (more user-friendly than top).
|
||||
uptime: Displays the system uptime and load average.
|
||||
ulimit: Displays or sets resource limits for user processes.
|
||||
ip link: Manages network interfaces.
|
||||
ss: A faster alternative to netstat for displaying network connections.
|
||||
traceroute: Traces the route packets take to a network host.
|
||||
ping6: Tests connectivity to a network host using IPv6.
|
||||
scp: Securely copies files between hosts over SSH.
|
||||
bc: A command-line calculator.
|
||||
dd: Converts and copies files, useful for creating disk images.
|
||||
arp: Displays or modifies the system's ARP table.
|
||||
md5sum: Computes and verifies MD5 hashes.
|
||||
sha256sum: Computes and verifies SHA-256 hashes.
|
||||
hostnamectl: Controls the system's hostname.
|
||||
ip a: Displays IP addresses of the system's network interfaces.
|
||||
ip r: Displays the routing table.
|
||||
journalctl -f: Follows the system logs in real time.
|
||||
tshark: A command-line network packet analyzer.
|
||||
lspci: Lists PCI devices connected to the system.
|
||||
lsusb: Lists USB devices connected to the system.
|
||||
modprobe: Adds or removes modules from the Linux kernel.
|
||||
parted: A disk partitioning tool.
|
||||
mkfs: Creates a file system on a partition or device.
|
||||
fsck: Checks and repairs a file system.
|
||||
tune2fs: Adjusts tunable file system parameters on ext filesystems.
|
||||
swapoff: Disables swap space on a device.
|
||||
swapon: Enables swap space on a device.
|
||||
fuser: Identifies processes using files or sockets.
|
||||
nmcli: Command-line tool for managing NetworkManager.
|
||||
w: Displays logged-in users and their active processes.
|
||||
wall: Sends a message to all logged-in users.
|
||||
passwd: Changes user passwords.
|
||||
stat: Displays detailed information about a file or file system.
|
||||
chage: Changes user password expiration information.
|
||||
pmap: Reports memory map of a process.
|
||||
ionice: Sets or gets I/O scheduling class and priority.
|
||||
nc (netcat): Reads and writes data across network connections.
|
||||
dstat: Combines and displays system resource statistics, such as CPU, disk, and network usage.
|
||||
sar: Collects, reports, or saves system activity information.
|
||||
iostat: Reports CPU and I/O statistics for devices and partitions.
|
||||
iotop: Displays real-time disk I/O usage by processes.
|
||||
inotifywait: Waits for changes to files or directories using inotify.
|
||||
inotifywatch: Watches for changes in a file or directory using inotify.
|
||||
nice: Runs a command with a modified scheduling priority.
|
||||
renice: Alters the priority of running processes.
|
||||
lsblk: Lists information about block devices.
|
||||
hdparm: Configures and displays information about SATA/IDE devices.
|
||||
smartctl: Monitors the health of hard drives using SMART.
|
||||
fallocate: Preallocates space to a file.
|
||||
wipe: Securely erases files or partitions.
|
||||
file: Determines file type based on content.
|
||||
shred: Securely deletes a file by overwriting it.
|
||||
ncftpput: Uploads files to an FTP server.
|
||||
ncftpget: Downloads files from an FTP server.
|
||||
ufw: Simplified firewall utility for managing iptables rules.
|
||||
ethtool: Configures and displays Ethernet device settings.
|
||||
brctl: Manages Ethernet bridges.
|
||||
xkill: Terminates a window by clicking on it.
|
||||
xrandr: Configures display screen resolution, rotation, and reflection.
|
||||
xset: Manages X display settings.
|
||||
xdg-open: Opens a file or URL in the user's preferred application.
|
||||
apropos: Searches the manual page names and descriptions for keywords.
|
||||
systemd-analyze: Displays system boot performance statistics.
|
||||
timedatectl: Manages system time and date settings.
|
||||
fwupdmgr: Firmware update manager for updating hardware firmware.
|
||||
lscpu: Displays information about the CPU architecture.
|
||||
getfacl: Displays file access control lists (ACLs).
|
||||
setfacl: Sets file access control lists (ACLs).
|
||||
pv: Monitors the progress of data through a pipeline.
|
||||
logrotate: Manages the automatic rotation and compression of log files.
|
||||
xclip: Interfaces with the X clipboard from the command line.
|
||||
cups: Manages printers (Common Unix Printing System).
|
||||
lp: Sends a file to the printer.
|
||||
lprm: Removes print jobs from the queue.
|
||||
lpstat: Displays the status of the print system.
|
||||
strace: Traces system calls and signals.
|
||||
tcpdump: Captures network traffic for analysis.
|
||||
envsubst: Substitutes environment variables in shell commands.
|
||||
tput: Initializes terminal capabilities, such as clearing the screen.
|
||||
xargs: Builds and executes command lines from standard input.
|
||||
dmesg | tail: Displays recent kernel messages (useful for hardware or system errors).
|
||||
lsns: Lists all active Linux namespaces.
|
||||
ss -tuln: Lists open network ports (TCP and UDP).
|
||||
iptables-save: Outputs current iptables rules to a file.
|
||||
iptables-restore: Restores iptables rules from a file.
|
||||
tac: Displays a file in reverse line order (opposite of cat).
|
||||
nl: Numbers the lines of a file.
|
||||
yes: Repeatedly outputs a string until stopped (e.g., yes y).
|
||||
split: Splits a file into pieces.
|
||||
csplit: Splits a file into pieces based on context.
|
||||
paste: Merges lines of files side by side.
|
||||
comm: Compares two sorted files line by line.
|
||||
shuf: Shuffles lines of text in random order.
|
||||
factor: Prints the prime factors of a given number.
|
||||
seq: Prints numbers in a sequence.
|
||||
pr: Converts text files for printing, adding headers and footers.
|
||||
column: Formats output into columns.
|
||||
od: Displays files in octal, decimal, hexadecimal, or ASCII.
|
||||
hexdump: Displays files in hexadecimal format.
|
||||
xxd: Creates a hex dump of a file or converts a hex dump back to binary.
|
||||
watch: Runs a command repeatedly, displaying the output and updates.
|
||||
timeout: Runs a command with a time limit.
|
||||
stdbuf: Alters the buffering of input/output for a command.
|
||||
rename: Renames files using a regular expression.
|
||||
prlimit: Displays or modifies resource limits of running processes.
|
||||
uuidgen: Generates a new universally unique identifier (UUID).
|
||||
vipw: Safely edits the /etc/passwd file.
|
||||
vigr: Safely edits the /etc/group file.
|
||||
getent: Retrieves entries from databases like passwd, group, or hosts.
|
||||
addgroup: Creates a new user group.
|
||||
pwgen: Generates random passwords.
|
||||
expire: Forces a password change after a specific period.
|
||||
showmount: Displays information about an NFS server.
|
||||
exportfs: Maintains the NFS server's exported file systems.
|
||||
rpcinfo: Displays information about RPC services on a networked system.
|
||||
lsmod: Lists currently loaded kernel modules.
|
||||
insmod: Inserts a module into the Linux kernel.
|
||||
rmmod: Removes a module from the Linux kernel.
|
||||
depmod: Generates modules dependency and map files.
|
||||
kmod: Interfaces with kernel modules from the command line.
|
||||
e2fsck: Checks the integrity of an ext2/ext3/ext4 file system.
|
||||
blkid: Displays block device attributes, including UUID.
|
||||
mount: Mounts a file system.
|
||||
umount: Unmounts a file system.
|
||||
parted: A command-line partition editor.
|
||||
gparted: A graphical partition editor (based on parted).
|
||||
cryptsetup: Manages encrypted devices.
|
||||
losetup: Configures loopback devices.
|
||||
mkswap: Sets up a swap area on a device or file.
|
||||
tmux: Terminal multiplexer for managing multiple terminal sessions.
|
||||
finger: Displays user information (if installed).
|
||||
lastb: Shows failed login attempts.
|
||||
pidof: Finds the process ID (PID) of a running program.
|
||||
pgrep: Searches for processes by name.
|
||||
curl -I: Fetches the HTTP headers from a URL.
|
||||
chattr: Changes file attributes on a Linux file system.
|
||||
lsattr: Lists file attributes on a Linux file system.
|
||||
join: Joins lines of two files based on a common field.
|
||||
tree: Displays a directory structure in a tree-like format.
|
||||
col: Filters reverse line feeds from input.
|
||||
free: Displays memory usage.
|
126
tui/src/confirmation.rs
Normal file
126
tui/src/confirmation.rs
Normal file
|
@ -0,0 +1,126 @@
|
|||
use std::borrow::Cow;
|
||||
|
||||
use crate::{float::FloatContent, hint::Shortcut};
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use ratatui::{
|
||||
layout::Alignment,
|
||||
prelude::*,
|
||||
widgets::{Block, Borders, Clear, List},
|
||||
};
|
||||
|
||||
pub enum ConfirmStatus {
|
||||
Confirm,
|
||||
Abort,
|
||||
None,
|
||||
}
|
||||
|
||||
pub struct ConfirmPrompt {
|
||||
pub names: Box<[String]>,
|
||||
pub status: ConfirmStatus,
|
||||
scroll: usize,
|
||||
}
|
||||
|
||||
impl ConfirmPrompt {
|
||||
pub fn new(names: &[&str]) -> Self {
|
||||
let max_count_str = format!("{}", names.len());
|
||||
let names = names
|
||||
.iter()
|
||||
.zip(1..)
|
||||
.map(|(name, n)| {
|
||||
let count_str = format!("{n}");
|
||||
let space_str = (0..(max_count_str.len() - count_str.len()))
|
||||
.map(|_| ' ')
|
||||
.collect::<String>();
|
||||
format!("{space_str}{n}. {name}")
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
names,
|
||||
status: ConfirmStatus::None,
|
||||
scroll: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_down(&mut self) {
|
||||
if self.scroll < self.names.len() - 1 {
|
||||
self.scroll += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_up(&mut self) {
|
||||
if self.scroll > 0 {
|
||||
self.scroll -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FloatContent for ConfirmPrompt {
|
||||
fn draw(&mut self, frame: &mut Frame, area: Rect) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Confirm selections ")
|
||||
.title_bottom(" [y] to continue, [n] 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);
|
||||
|
||||
let paths_text = self
|
||||
.names
|
||||
.iter()
|
||||
.skip(self.scroll)
|
||||
.map(|p| {
|
||||
let span = Span::from(Cow::<'_, str>::Borrowed(p));
|
||||
Line::from(span).style(Style::default())
|
||||
})
|
||||
.collect::<Text>();
|
||||
|
||||
frame.render_widget(Clear, inner_area);
|
||||
frame.render_widget(List::new(paths_text), inner_area);
|
||||
}
|
||||
|
||||
fn handle_key_event(&mut self, key: &KeyEvent) -> bool {
|
||||
use KeyCode::*;
|
||||
self.status = match key.code {
|
||||
Char('y') | Char('Y') => ConfirmStatus::Confirm,
|
||||
Char('n') | Char('N') | Esc => ConfirmStatus::Abort,
|
||||
Char('j') => {
|
||||
self.scroll_down();
|
||||
ConfirmStatus::None
|
||||
}
|
||||
Char('k') => {
|
||||
self.scroll_up();
|
||||
ConfirmStatus::None
|
||||
}
|
||||
_ => ConfirmStatus::None,
|
||||
};
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn is_finished(&self) -> bool {
|
||||
use ConfirmStatus::*;
|
||||
match self.status {
|
||||
Confirm | Abort => true,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_shortcut_list(&self) -> (&str, Box<[Shortcut]>) {
|
||||
(
|
||||
"Confirmation prompt",
|
||||
Box::new([
|
||||
Shortcut::new("Continue", ["Y", "y"]),
|
||||
Shortcut::new("Abort", ["N", "n"]),
|
||||
Shortcut::new("Scroll up", ["j"]),
|
||||
Shortcut::new("Scroll down", ["k"]),
|
||||
Shortcut::new("Close linutil", ["CTRL-c", "q"]),
|
||||
]),
|
||||
)
|
||||
}
|
||||
}
|
167
tui/src/filter.rs
Normal file
167
tui/src/filter.rs
Normal file
|
@ -0,0 +1,167 @@
|
|||
use crate::{state::ListEntry, theme::Theme};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ego_tree::NodeId;
|
||||
use linutil_core::Tab;
|
||||
use ratatui::{
|
||||
layout::{Position, Rect},
|
||||
style::Style,
|
||||
text::Span,
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
pub enum SearchAction {
|
||||
None,
|
||||
Exit,
|
||||
Update,
|
||||
}
|
||||
|
||||
pub struct Filter {
|
||||
search_input: Vec<char>,
|
||||
in_search_mode: bool,
|
||||
input_position: usize,
|
||||
items: Vec<ListEntry>,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
search_input: vec![],
|
||||
in_search_mode: false,
|
||||
input_position: 0,
|
||||
items: vec![],
|
||||
}
|
||||
}
|
||||
pub fn item_list(&self) -> &[ListEntry] {
|
||||
&self.items
|
||||
}
|
||||
pub fn activate_search(&mut self) {
|
||||
self.in_search_mode = true;
|
||||
}
|
||||
pub fn deactivate_search(&mut self) {
|
||||
self.in_search_mode = false;
|
||||
}
|
||||
pub fn update_items(&mut self, tabs: &[Tab], current_tab: usize, node: NodeId) {
|
||||
if self.search_input.is_empty() {
|
||||
let curr = tabs[current_tab].tree.get(node).unwrap();
|
||||
|
||||
self.items = curr
|
||||
.children()
|
||||
.map(|node| ListEntry {
|
||||
node: node.value().clone(),
|
||||
id: node.id(),
|
||||
has_children: node.has_children(),
|
||||
})
|
||||
.collect();
|
||||
} else {
|
||||
self.items.clear();
|
||||
|
||||
let query_lower = self.search_input.iter().collect::<String>().to_lowercase();
|
||||
for tab in tabs.iter() {
|
||||
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()
|
||||
{
|
||||
self.items.push(ListEntry {
|
||||
node: node.value().clone(),
|
||||
id: node.id(),
|
||||
has_children: false,
|
||||
});
|
||||
}
|
||||
|
||||
stack.extend(node.children().map(|child| child.id()));
|
||||
}
|
||||
}
|
||||
self.items.sort_by(|a, b| a.node.name.cmp(&b.node.name));
|
||||
}
|
||||
}
|
||||
pub fn draw_searchbar(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
|
||||
//Set the search bar text (If empty use the placeholder)
|
||||
let display_text = if !self.in_search_mode && self.search_input.is_empty() {
|
||||
Span::raw("Press / to search")
|
||||
} else {
|
||||
Span::raw(self.search_input.iter().collect::<String>())
|
||||
};
|
||||
|
||||
let search_color = if self.in_search_mode {
|
||||
theme.focused_color()
|
||||
} else {
|
||||
theme.unfocused_color()
|
||||
};
|
||||
|
||||
//Create the search bar widget
|
||||
let search_bar = Paragraph::new(display_text)
|
||||
.block(Block::default().borders(Borders::ALL).title("Search"))
|
||||
.style(Style::default().fg(search_color));
|
||||
|
||||
//Render the search bar (First chunk of the screen)
|
||||
frame.render_widget(search_bar, area);
|
||||
|
||||
// Render cursor in search bar
|
||||
if self.in_search_mode {
|
||||
let cursor_position: usize = self.search_input[..self.input_position]
|
||||
.iter()
|
||||
.map(|c| c.width().unwrap_or(1))
|
||||
.sum();
|
||||
let x = area.x + cursor_position as u16 + 1;
|
||||
let y = area.y + 1;
|
||||
frame.set_cursor_position(Position::new(x, y));
|
||||
}
|
||||
}
|
||||
// Handles key events. Returns true if search must be exited
|
||||
pub fn handle_key(&mut self, event: &KeyEvent) -> SearchAction {
|
||||
//Insert user input into the search bar
|
||||
match event.code {
|
||||
KeyCode::Char('c') if event.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
return self.exit_search()
|
||||
}
|
||||
KeyCode::Char(c) => self.insert_char(c),
|
||||
KeyCode::Backspace => self.remove_previous(),
|
||||
KeyCode::Delete => self.remove_next(),
|
||||
KeyCode::Left => return self.cursor_left(),
|
||||
KeyCode::Right => return self.cursor_right(),
|
||||
KeyCode::Enter => return SearchAction::Exit,
|
||||
KeyCode::Esc => return self.exit_search(),
|
||||
_ => return SearchAction::None,
|
||||
};
|
||||
SearchAction::Update
|
||||
}
|
||||
|
||||
fn exit_search(&mut self) -> SearchAction {
|
||||
self.input_position = 0;
|
||||
self.search_input.clear();
|
||||
SearchAction::Exit
|
||||
}
|
||||
|
||||
fn cursor_left(&mut self) -> SearchAction {
|
||||
self.input_position = self.input_position.saturating_sub(1);
|
||||
SearchAction::None
|
||||
}
|
||||
fn cursor_right(&mut self) -> SearchAction {
|
||||
if self.input_position < self.search_input.len() {
|
||||
self.input_position += 1;
|
||||
}
|
||||
SearchAction::None
|
||||
}
|
||||
fn insert_char(&mut self, input: char) {
|
||||
self.search_input.insert(self.input_position, input);
|
||||
self.cursor_right();
|
||||
}
|
||||
fn remove_previous(&mut self) {
|
||||
let current = self.input_position;
|
||||
if current > 0 {
|
||||
self.search_input.remove(current - 1);
|
||||
self.cursor_left();
|
||||
}
|
||||
}
|
||||
fn remove_next(&mut self) {
|
||||
let current = self.input_position;
|
||||
if current < self.search_input.len() {
|
||||
self.search_input.remove(current);
|
||||
}
|
||||
}
|
||||
}
|
76
tui/src/float.rs
Normal file
76
tui/src/float.rs
Normal file
|
@ -0,0 +1,76 @@
|
|||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::hint::Shortcut;
|
||||
|
||||
pub trait FloatContent {
|
||||
fn draw(&mut self, frame: &mut Frame, area: Rect);
|
||||
fn handle_key_event(&mut self, key: &KeyEvent) -> bool;
|
||||
fn is_finished(&self) -> bool;
|
||||
fn get_shortcut_list(&self) -> (&str, Box<[Shortcut]>);
|
||||
}
|
||||
|
||||
pub struct Float<Content: FloatContent + ?Sized> {
|
||||
pub content: Box<Content>,
|
||||
width_percent: u16,
|
||||
height_percent: u16,
|
||||
}
|
||||
|
||||
impl<Content: FloatContent + ?Sized> Float<Content> {
|
||||
pub fn new(content: Box<Content>, width_percent: u16, height_percent: u16) -> Self {
|
||||
Self {
|
||||
content,
|
||||
width_percent,
|
||||
height_percent,
|
||||
}
|
||||
}
|
||||
|
||||
fn floating_window(&self, size: Rect) -> Rect {
|
||||
let hor_float = Layout::default()
|
||||
.constraints([
|
||||
Constraint::Percentage((100 - self.width_percent) / 2),
|
||||
Constraint::Percentage(self.width_percent),
|
||||
Constraint::Percentage((100 - self.width_percent) / 2),
|
||||
])
|
||||
.direction(Direction::Horizontal)
|
||||
.split(size)[1];
|
||||
|
||||
Layout::default()
|
||||
.constraints([
|
||||
Constraint::Percentage((100 - self.height_percent) / 2),
|
||||
Constraint::Percentage(self.height_percent),
|
||||
Constraint::Percentage((100 - self.height_percent) / 2),
|
||||
])
|
||||
.direction(Direction::Vertical)
|
||||
.split(hor_float)[1]
|
||||
}
|
||||
|
||||
pub fn draw(&mut self, frame: &mut Frame, parent_area: Rect) {
|
||||
let popup_area = self.floating_window(parent_area);
|
||||
self.content.draw(frame, popup_area);
|
||||
}
|
||||
|
||||
// Returns true if the floating window is finished.
|
||||
pub fn handle_key_event(&mut self, key: &KeyEvent) -> bool {
|
||||
match key.code {
|
||||
KeyCode::Enter
|
||||
| KeyCode::Char('p')
|
||||
| KeyCode::Char('d')
|
||||
| KeyCode::Char('g')
|
||||
| KeyCode::Char('q')
|
||||
| KeyCode::Esc
|
||||
if self.content.is_finished() =>
|
||||
{
|
||||
true
|
||||
}
|
||||
_ => self.content.handle_key_event(key),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_shortcut_list(&self) -> (&str, Box<[Shortcut]>) {
|
||||
self.content.get_shortcut_list()
|
||||
}
|
||||
}
|
291
tui/src/floating_text.rs
Normal file
291
tui/src/floating_text.rs
Normal file
|
@ -0,0 +1,291 @@
|
|||
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 crossterm::event::{KeyCode, KeyEvent};
|
||||
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Style, Stylize},
|
||||
text::Line,
|
||||
widgets::{Block, Borders, Clear, List},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use ansi_to_tui::IntoText;
|
||||
|
||||
use tree_sitter_bash as hl_bash;
|
||||
use tree_sitter_highlight::{self as hl, HighlightEvent};
|
||||
use zips::zip_result;
|
||||
|
||||
pub struct FloatingText {
|
||||
pub src: Vec<String>,
|
||||
max_line_width: usize,
|
||||
v_scroll: usize,
|
||||
h_scroll: usize,
|
||||
mode_title: String,
|
||||
}
|
||||
|
||||
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))))
|
||||
}};
|
||||
}
|
||||
|
||||
const SYNTAX_HIGHLIGHT_STYLES: [(&str, anstyle::Style); 8] = [
|
||||
("function", style!(220, 220, 170)), // yellow
|
||||
("string", style!(206, 145, 120)), // brown
|
||||
("property", style!(156, 220, 254)), // light blue
|
||||
("comment", style!(92, 131, 75)), // green
|
||||
("embedded", style!(206, 145, 120)), // blue (string expansions)
|
||||
("constant", style!(79, 193, 255)), // dark blue
|
||||
("keyword", style!(197, 134, 192)), // magenta
|
||||
("number", style!(181, 206, 168)), // light green
|
||||
];
|
||||
|
||||
fn get_highlighted_string(s: &str) -> Option<String> {
|
||||
let mut hl_conf = hl::HighlightConfiguration::new(
|
||||
hl_bash::LANGUAGE.into(),
|
||||
"bash",
|
||||
hl_bash::HIGHLIGHT_QUERY,
|
||||
"",
|
||||
"",
|
||||
)
|
||||
.ok()?;
|
||||
|
||||
let matched_tokens = &SYNTAX_HIGHLIGHT_STYLES
|
||||
.iter()
|
||||
.map(|hl| hl.0)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
hl_conf.configure(matched_tokens);
|
||||
|
||||
let mut hl = hl::Highlighter::new();
|
||||
|
||||
let mut style_stack = vec![anstyle::Style::new()];
|
||||
let src = s.as_bytes();
|
||||
|
||||
let events = hl.highlight(&hl_conf, src, None, |_| None).ok()?;
|
||||
|
||||
let mut buf = Cursor::new(vec![]);
|
||||
|
||||
for event in events {
|
||||
match event.unwrap() {
|
||||
HighlightEvent::HighlightStart(h) => {
|
||||
style_stack.push(SYNTAX_HIGHLIGHT_STYLES.get(h.0)?.1);
|
||||
}
|
||||
|
||||
HighlightEvent::HighlightEnd => {
|
||||
style_stack.pop();
|
||||
}
|
||||
|
||||
HighlightEvent::Source { start, end } => {
|
||||
let style = style_stack.last()?;
|
||||
zip_result!(
|
||||
write!(&mut buf, "{}", style),
|
||||
buf.write_all(&src[start..end]),
|
||||
write!(&mut buf, "{style:#}"),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut output = String::new();
|
||||
|
||||
zip_result!(
|
||||
buf.seek(SeekFrom::Start(0)),
|
||||
buf.read_to_string(&mut output),
|
||||
)?;
|
||||
|
||||
Some(output)
|
||||
}
|
||||
|
||||
macro_rules! max_width {
|
||||
($($lines:tt)+) => {{
|
||||
$($lines)+.iter().fold(0, |accum, val| accum.max(val.len()))
|
||||
}}
|
||||
}
|
||||
|
||||
#[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) -> Self {
|
||||
let src = get_lines(&text)
|
||||
.into_iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let max_line_width = max_width!(src);
|
||||
Self {
|
||||
src,
|
||||
mode_title: title.to_string(),
|
||||
max_line_width,
|
||||
v_scroll: 0,
|
||||
h_scroll: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_command(command: &Command, title: String) -> Option<Self> {
|
||||
let (max_line_width, src) = match command {
|
||||
Command::Raw(cmd) => {
|
||||
// just apply highlights directly
|
||||
(max_width!(get_lines(cmd)), Some(cmd.clone()))
|
||||
}
|
||||
Command::LocalFile { file, .. } => {
|
||||
// have to read from tmp dir to get cmd src
|
||||
let raw = std::fs::read_to_string(file)
|
||||
.map_err(|_| format!("File not found: {:?}", file))
|
||||
.unwrap();
|
||||
|
||||
(max_width!(get_lines(&raw)), Some(raw))
|
||||
}
|
||||
|
||||
// If command is a folder, we don't display a preview
|
||||
Command::None => (0usize, None),
|
||||
};
|
||||
|
||||
let src = get_lines_owned(&get_highlighted_string(&src?)?);
|
||||
|
||||
Some(Self {
|
||||
src,
|
||||
mode_title: title,
|
||||
max_line_width,
|
||||
h_scroll: 0,
|
||||
v_scroll: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn scroll_down(&mut self) {
|
||||
if self.v_scroll + 1 < self.src.len() {
|
||||
self.v_scroll += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn scroll_up(&mut self) {
|
||||
if self.v_scroll > 0 {
|
||||
self.v_scroll -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn scroll_left(&mut self) {
|
||||
if self.h_scroll > 0 {
|
||||
self.h_scroll -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn scroll_right(&mut self) {
|
||||
if self.h_scroll + 1 < self.max_line_width {
|
||||
self.h_scroll += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FloatContent for FloatingText {
|
||||
fn draw(&mut self, frame: &mut Frame, area: Rect) {
|
||||
// Define the Block with a border and background color
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(self.mode_title.clone())
|
||||
.title_alignment(ratatui::layout::Alignment::Center)
|
||||
.title_style(Style::default().reversed())
|
||||
.style(Style::default());
|
||||
|
||||
// Draw the Block first
|
||||
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 { height, .. } = inner_area;
|
||||
let lines = self
|
||||
.src
|
||||
.iter()
|
||||
.skip(self.v_scroll)
|
||||
.take(height as usize)
|
||||
.flat_map(|l| l.into_text().unwrap())
|
||||
.map(|line| {
|
||||
let mut skipped = 0;
|
||||
let mut spans = line
|
||||
.into_iter()
|
||||
.skip_while(|span| {
|
||||
let skip = (skipped + span.content.len()) <= self.h_scroll;
|
||||
if skip {
|
||||
skipped += span.content.len();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
.collect::<VecDeque<_>>();
|
||||
|
||||
if spans.is_empty() {
|
||||
Line::raw(Cow::Owned(String::new()))
|
||||
} else {
|
||||
if skipped < self.h_scroll {
|
||||
let to_split = spans.pop_front().unwrap();
|
||||
let new_content = to_split.content.clone().into_owned()
|
||||
[self.h_scroll - skipped..]
|
||||
.to_owned();
|
||||
spans.push_front(to_split.content(Cow::Owned(new_content)));
|
||||
}
|
||||
|
||||
Line::from(Vec::from(spans))
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Create list widget
|
||||
let list = List::new(lines)
|
||||
.block(Block::default())
|
||||
.highlight_style(Style::default().reversed());
|
||||
|
||||
// Clear the text underneath the floats rendered area
|
||||
frame.render_widget(Clear, inner_area);
|
||||
|
||||
// Render the list inside the bordered area
|
||||
frame.render_widget(list, inner_area);
|
||||
}
|
||||
|
||||
fn handle_key_event(&mut self, key: &KeyEvent) -> bool {
|
||||
use KeyCode::*;
|
||||
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(),
|
||||
_ => {}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_finished(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn get_shortcut_list(&self) -> (&str, Box<[Shortcut]>) {
|
||||
(
|
||||
&self.mode_title,
|
||||
Box::new([
|
||||
Shortcut::new("Scroll down", ["j", "Down"]),
|
||||
Shortcut::new("Scroll up", ["k", "Up"]),
|
||||
Shortcut::new("Scroll left", ["h", "Left"]),
|
||||
Shortcut::new("Scroll right", ["l", "Right"]),
|
||||
Shortcut::new("Close window", ["Enter", "p", "q", "d", "g"]),
|
||||
]),
|
||||
)
|
||||
}
|
||||
}
|
96
tui/src/hint.rs
Normal file
96
tui/src/hint.rs
Normal file
|
@ -0,0 +1,96 @@
|
|||
use std::borrow::Cow;
|
||||
|
||||
use ratatui::{
|
||||
style::{Style, Stylize},
|
||||
text::{Line, Span},
|
||||
};
|
||||
|
||||
pub struct Shortcut {
|
||||
pub key_sequences: Vec<Span<'static>>,
|
||||
pub desc: &'static str,
|
||||
}
|
||||
|
||||
fn add_spacing(list: Vec<Vec<Span>>) -> Line {
|
||||
list.into_iter()
|
||||
.flat_map(|mut s| {
|
||||
s.push(Span::default().content(" "));
|
||||
s
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn span_vec_len(span_vec: &[Span]) -> usize {
|
||||
span_vec.iter().rfold(0, |init, s| init + s.width())
|
||||
}
|
||||
|
||||
pub fn create_shortcut_list(
|
||||
shortcuts: impl IntoIterator<Item = Shortcut>,
|
||||
render_width: u16,
|
||||
) -> Box<[Line<'static>]> {
|
||||
let hints = shortcuts.into_iter().collect::<Box<[Shortcut]>>();
|
||||
|
||||
let mut shortcut_spans: Vec<Vec<Span<'static>>> = hints.iter().map(|h| h.to_spans()).collect();
|
||||
|
||||
let mut lines: Vec<Line<'static>> = vec![];
|
||||
|
||||
loop {
|
||||
let split_idx = shortcut_spans
|
||||
.iter()
|
||||
.scan(0usize, |total_len, s| {
|
||||
// take at least one so that we guarantee that we drain the list
|
||||
// otherwise, this might lock up if there's a shortcut that exceeds the window width
|
||||
if *total_len == 0 {
|
||||
*total_len += span_vec_len(s) + 4;
|
||||
Some(())
|
||||
} else {
|
||||
*total_len += span_vec_len(s);
|
||||
if *total_len > render_width as usize {
|
||||
None
|
||||
} else {
|
||||
*total_len += 4;
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
})
|
||||
.count();
|
||||
|
||||
let rest = shortcut_spans.split_off(split_idx);
|
||||
lines.push(add_spacing(shortcut_spans));
|
||||
|
||||
if rest.is_empty() {
|
||||
break;
|
||||
} else {
|
||||
shortcut_spans = rest;
|
||||
}
|
||||
}
|
||||
|
||||
lines.into_boxed_slice()
|
||||
}
|
||||
|
||||
impl Shortcut {
|
||||
pub fn new<const N: usize>(desc: &'static str, key_sequences: [&'static str; N]) -> Self {
|
||||
Self {
|
||||
key_sequences: key_sequences
|
||||
.iter()
|
||||
.map(|s| Span::styled(Cow::<'static, str>::Borrowed(s), Style::default().bold()))
|
||||
.collect(),
|
||||
desc,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_spans(&self) -> Vec<Span<'static>> {
|
||||
let mut ret: Vec<_> = self
|
||||
.key_sequences
|
||||
.iter()
|
||||
.flat_map(|seq| {
|
||||
[
|
||||
Span::default().content("["),
|
||||
seq.clone(),
|
||||
Span::default().content("] "),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
ret.push(Span::styled(self.desc, Style::default().italic()));
|
||||
ret
|
||||
}
|
||||
}
|
84
tui/src/main.rs
Normal file
84
tui/src/main.rs
Normal file
|
@ -0,0 +1,84 @@
|
|||
mod confirmation;
|
||||
mod filter;
|
||||
mod float;
|
||||
mod floating_text;
|
||||
mod hint;
|
||||
mod running_command;
|
||||
pub mod state;
|
||||
mod theme;
|
||||
|
||||
use std::{
|
||||
io::{self, stdout},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crate::theme::Theme;
|
||||
use clap::Parser;
|
||||
use crossterm::{
|
||||
event::{self, DisableMouseCapture, Event, KeyEventKind},
|
||||
style::ResetColor,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
ExecutableCommand,
|
||||
};
|
||||
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||
use state::AppState;
|
||||
|
||||
// Linux utility toolbox
|
||||
#[derive(Debug, Parser)]
|
||||
struct Args {
|
||||
#[arg(short, long, value_enum)]
|
||||
#[arg(default_value_t = Theme::Default)]
|
||||
#[arg(help = "Set the theme to use in the application")]
|
||||
theme: Theme,
|
||||
#[arg(long, default_value_t = false)]
|
||||
#[clap(help = "Show all available options, disregarding compatibility checks (UNSAFE)")]
|
||||
override_validation: bool,
|
||||
}
|
||||
|
||||
fn main() -> io::Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
let mut state = AppState::new(args.theme, args.override_validation);
|
||||
|
||||
stdout().execute(EnterAlternateScreen)?;
|
||||
enable_raw_mode()?;
|
||||
let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
|
||||
terminal.clear()?;
|
||||
|
||||
run(&mut terminal, &mut state)?;
|
||||
|
||||
// restore terminal
|
||||
disable_raw_mode()?;
|
||||
terminal.backend_mut().execute(LeaveAlternateScreen)?;
|
||||
terminal.backend_mut().execute(DisableMouseCapture)?;
|
||||
terminal.backend_mut().execute(ResetColor)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run(
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
state: &mut AppState,
|
||||
) -> io::Result<()> {
|
||||
loop {
|
||||
terminal.draw(|frame| state.draw(frame)).unwrap();
|
||||
// Wait for an event
|
||||
if !event::poll(Duration::from_millis(10))? {
|
||||
continue;
|
||||
}
|
||||
|
||||
// It's guaranteed that the `read()` won't block when the `poll()`
|
||||
// function returns `true`
|
||||
if let Event::Key(key) = event::read()? {
|
||||
// We are only interested in Press and Repeat events
|
||||
if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !state.handle_key(&key) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
327
tui/src/running_command.rs
Normal file
327
tui/src/running_command.rs
Normal file
|
@ -0,0 +1,327 @@
|
|||
use crate::{float::FloatContent, hint::Shortcut};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use linutil_core::Command;
|
||||
use oneshot::{channel, Receiver};
|
||||
use portable_pty::{
|
||||
ChildKiller, CommandBuilder, ExitStatus, MasterPty, NativePtySystem, PtySize, PtySystem,
|
||||
};
|
||||
use ratatui::{
|
||||
layout::{Rect, Size},
|
||||
style::{Color, Style, Stylize},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders},
|
||||
Frame,
|
||||
};
|
||||
use std::{
|
||||
io::Write,
|
||||
sync::{Arc, Mutex},
|
||||
thread::JoinHandle,
|
||||
};
|
||||
use tui_term::{
|
||||
vt100::{self, Screen},
|
||||
widget::PseudoTerminal,
|
||||
};
|
||||
|
||||
pub struct RunningCommand {
|
||||
/// A buffer to save all the command output (accumulates, until the command exits)
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
/// A handle for the thread running the command
|
||||
command_thread: Option<JoinHandle<ExitStatus>>,
|
||||
/// A handle to kill the running process; it's an option because it can only be used once
|
||||
child_killer: Option<Receiver<Box<dyn ChildKiller + Send + Sync>>>,
|
||||
/// A join handle for the thread that reads command output and sends it to the main thread
|
||||
_reader_thread: JoinHandle<()>,
|
||||
/// Virtual terminal (pty) handle, used for resizing the pty
|
||||
pty_master: Box<dyn MasterPty + Send>,
|
||||
/// Used for sending keys to the emulated terminal
|
||||
writer: Box<dyn Write + Send>,
|
||||
/// Only set after the process has ended
|
||||
status: Option<ExitStatus>,
|
||||
scroll_offset: usize,
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
// 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)
|
||||
.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()),
|
||||
)
|
||||
} else {
|
||||
Line::from(
|
||||
Span::default()
|
||||
.content("FAILED!")
|
||||
.style(Style::default().fg(Color::Red).reversed()),
|
||||
)
|
||||
};
|
||||
|
||||
title_line.push_span(
|
||||
Span::default()
|
||||
.content(" press <ENTER> to close this window ")
|
||||
.style(Style::default()),
|
||||
);
|
||||
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title_top(title_line.centered())
|
||||
};
|
||||
|
||||
// Process the buffer and create the pseudo-terminal widget
|
||||
let screen = self.screen(inner_size);
|
||||
let pseudo_term = PseudoTerminal::new(&screen).block(block);
|
||||
|
||||
// Render the widget on the frame
|
||||
frame.render_widget(pseudo_term, area);
|
||||
}
|
||||
|
||||
/// Handle key events of the running command "window". Returns true when the "window" should be
|
||||
/// closed
|
||||
fn handle_key_event(&mut self, key: &KeyEvent) -> bool {
|
||||
match key.code {
|
||||
// Handle Ctrl-C to kill the command
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
self.kill_child();
|
||||
}
|
||||
// Close the window when Enter is pressed and the command is finished
|
||||
KeyCode::Enter if self.is_finished() => {
|
||||
return true;
|
||||
}
|
||||
KeyCode::PageUp => {
|
||||
self.scroll_offset = self.scroll_offset.saturating_add(10);
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(10);
|
||||
}
|
||||
// Pass other key events to the terminal
|
||||
_ => self.handle_passthrough_key_event(key),
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_finished(&self) -> bool {
|
||||
// Check if the command thread has finished
|
||||
if let Some(command_thread) = &self.command_thread {
|
||||
command_thread.is_finished()
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn get_shortcut_list(&self) -> (&str, Box<[Shortcut]>) {
|
||||
if self.is_finished() {
|
||||
(
|
||||
"Finished command",
|
||||
Box::new([
|
||||
Shortcut::new("Close window", ["Enter", "q"]),
|
||||
Shortcut::new("Scroll up", ["Page up"]),
|
||||
Shortcut::new("Scroll down", ["Page down"]),
|
||||
]),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"Running command",
|
||||
Box::new([
|
||||
Shortcut::new("Kill the command", ["CTRL-c"]),
|
||||
Shortcut::new("Scroll up", ["Page up"]),
|
||||
Shortcut::new("Scroll down", ["Page down"]),
|
||||
]),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RunningCommand {
|
||||
pub fn new(commands: Vec<Command>) -> Self {
|
||||
let pty_system = NativePtySystem::default();
|
||||
|
||||
// Build the command based on the provided Command enum variant
|
||||
let mut cmd: CommandBuilder = CommandBuilder::new("sh");
|
||||
cmd.arg("-c");
|
||||
|
||||
// All the merged commands are passed as a single argument to reduce the overhead of rebuilding the command arguments for each and every command
|
||||
let mut script = String::new();
|
||||
for command in commands {
|
||||
match command {
|
||||
Command::Raw(prompt) => script.push_str(&format!("{}\n", prompt)),
|
||||
Command::LocalFile {
|
||||
executable,
|
||||
args,
|
||||
file,
|
||||
} => {
|
||||
if let Some(parent_directory) = file.parent() {
|
||||
script.push_str(&format!("cd {}\n", parent_directory.display()));
|
||||
}
|
||||
script.push_str(&executable);
|
||||
for arg in args {
|
||||
script.push(' ');
|
||||
script.push_str(&arg);
|
||||
}
|
||||
script.push('\n'); // Ensures that each command is properly separated for execution preventing directory errors
|
||||
}
|
||||
Command::None => panic!("Command::None was treated as a command"),
|
||||
}
|
||||
}
|
||||
|
||||
cmd.arg(script);
|
||||
|
||||
// Open a pseudo-terminal with initial size
|
||||
let pair = pty_system
|
||||
.openpty(PtySize {
|
||||
rows: 24, // Initial number of rows (will be updated dynamically)
|
||||
cols: 80, // Initial number of columns (will be updated dynamically)
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let (tx, rx) = channel();
|
||||
// Thread waiting for the child to complete
|
||||
let command_handle = std::thread::spawn(move || {
|
||||
let mut child = pair.slave.spawn_command(cmd).unwrap();
|
||||
let killer = child.clone_killer();
|
||||
tx.send(killer).unwrap();
|
||||
child.wait().unwrap()
|
||||
});
|
||||
|
||||
let mut reader = pair.master.try_clone_reader().unwrap(); // This is a reader, this is where we
|
||||
|
||||
// A buffer, shared between the thread that reads the command output, and the main tread.
|
||||
// The main thread only reads the contents
|
||||
let command_buffer: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let reader_handle = {
|
||||
// Arc is just a reference, so we can create an owned copy without any problem
|
||||
let command_buffer = command_buffer.clone();
|
||||
// The closure below moves all variables used into it, so we can no longer use them,
|
||||
// that's why command_buffer.clone(), because we need to use command_buffer later
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
let size = reader.read(&mut buf).unwrap(); // Can block here
|
||||
if size == 0 {
|
||||
break; // EOF
|
||||
}
|
||||
let mut mutex = command_buffer.lock(); // Only lock the mutex after the read is
|
||||
// done, to minimise the time it is opened
|
||||
let command_buffer = mutex.as_mut().unwrap();
|
||||
command_buffer.extend_from_slice(&buf[0..size]);
|
||||
// The mutex is closed here automatically
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let writer = pair.master.take_writer().unwrap();
|
||||
Self {
|
||||
buffer: command_buffer,
|
||||
command_thread: Some(command_handle),
|
||||
child_killer: Some(rx),
|
||||
_reader_thread: reader_handle,
|
||||
pty_master: pair.master,
|
||||
writer,
|
||||
status: None,
|
||||
scroll_offset: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn screen(&mut self, size: Size) -> Screen {
|
||||
// Resize the emulated pty
|
||||
self.pty_master
|
||||
.resize(PtySize {
|
||||
rows: size.height,
|
||||
cols: size.width,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// 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
|
||||
// way, and doesn't cost that much
|
||||
let mut parser = vt100::Parser::new(size.height, size.width, 200);
|
||||
let mutex = self.buffer.lock();
|
||||
let buffer = mutex.as_ref().unwrap();
|
||||
parser.process(buffer);
|
||||
// Adjust the screen content based on the scroll offset
|
||||
parser.screen_mut().set_scrollback(self.scroll_offset);
|
||||
parser.screen().clone()
|
||||
}
|
||||
|
||||
/// This function will block if the command is not finished
|
||||
fn get_exit_status(&mut self) -> ExitStatus {
|
||||
if self.command_thread.is_some() {
|
||||
let handle = self.command_thread.take().unwrap();
|
||||
let exit_status = handle.join().unwrap();
|
||||
self.status = Some(exit_status.clone());
|
||||
exit_status
|
||||
} else {
|
||||
self.status.as_ref().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Send SIGHUB signal, *not* SIGKILL or SIGTERM, to the child process
|
||||
pub fn kill_child(&mut self) {
|
||||
if !self.is_finished() {
|
||||
let mut killer = self.child_killer.take().unwrap().recv().unwrap();
|
||||
killer.kill().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert the KeyEvent to pty key codes, and send them to the virtual terminal
|
||||
fn handle_passthrough_key_event(&mut self, key: &KeyEvent) {
|
||||
let input_bytes = match key.code {
|
||||
KeyCode::Char(ch) => {
|
||||
let raw_utf8 = || ch.to_string().into_bytes();
|
||||
|
||||
match ch.to_ascii_uppercase() {
|
||||
_ if key.modifiers != KeyModifiers::CONTROL => raw_utf8(),
|
||||
// https://github.com/fyne-io/terminal/blob/master/input.go
|
||||
// https://gist.github.com/ConnerWill/d4b6c776b509add763e17f9f113fd25b
|
||||
'2' | '@' | ' ' => vec![0],
|
||||
'3' | '[' => vec![27],
|
||||
'4' | '\\' => vec![28],
|
||||
'5' | ']' => vec![29],
|
||||
'6' | '^' => vec![30],
|
||||
'7' | '-' | '_' => vec![31],
|
||||
c if ('A'..='_').contains(&c) => {
|
||||
let ascii_val = c as u8;
|
||||
let ascii_to_send = ascii_val - 64;
|
||||
vec![ascii_to_send]
|
||||
}
|
||||
_ => raw_utf8(),
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => vec![b'\n'],
|
||||
KeyCode::Backspace => vec![0x7f],
|
||||
KeyCode::Left => vec![27, 91, 68],
|
||||
KeyCode::Right => vec![27, 91, 67],
|
||||
KeyCode::Up => vec![27, 91, 65],
|
||||
KeyCode::Down => vec![27, 91, 66],
|
||||
KeyCode::Tab => vec![9],
|
||||
KeyCode::Home => vec![27, 91, 72],
|
||||
KeyCode::End => vec![27, 91, 70],
|
||||
KeyCode::BackTab => vec![27, 91, 90],
|
||||
KeyCode::Delete => vec![27, 91, 51, 126],
|
||||
KeyCode::Insert => vec![27, 91, 50, 126],
|
||||
KeyCode::Esc => vec![27],
|
||||
_ => return,
|
||||
};
|
||||
// Send the keycodes to the virtual terminal
|
||||
let _ = self.writer.write_all(&input_bytes);
|
||||
}
|
||||
}
|
757
tui/src/state.rs
Normal file
757
tui/src/state.rs
Normal file
|
@ -0,0 +1,757 @@
|
|||
use crate::{
|
||||
confirmation::{ConfirmPrompt, ConfirmStatus},
|
||||
filter::{Filter, SearchAction},
|
||||
float::{Float, FloatContent},
|
||||
floating_text::FloatingText,
|
||||
hint::{create_shortcut_list, Shortcut},
|
||||
running_command::RunningCommand,
|
||||
theme::Theme,
|
||||
};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use ego_tree::NodeId;
|
||||
use linutil_core::{ListNode, Tab};
|
||||
#[cfg(feature = "tips")]
|
||||
use rand::Rng;
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Flex, Layout},
|
||||
style::{Style, Stylize},
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Block, Borders, List, ListState, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use std::rc::Rc;
|
||||
use temp_dir::TempDir;
|
||||
|
||||
const MIN_WIDTH: u16 = 77;
|
||||
const MIN_HEIGHT: u16 = 19;
|
||||
const TITLE: &str = concat!("Linux Toolbox - ", env!("BUILD_DATE"));
|
||||
const ACTIONS_GUIDE: &str = "List of important tasks performed by commands' names:
|
||||
|
||||
D - disk modifications (ex. partitioning) (privileged)
|
||||
FI - flatpak installation
|
||||
FM - file modification
|
||||
I - installation (privileged)
|
||||
MP - package manager actions
|
||||
SI - full system installation
|
||||
SS - systemd actions (privileged)
|
||||
RP - package removal
|
||||
|
||||
P* - privileged *
|
||||
";
|
||||
|
||||
pub struct AppState {
|
||||
/// This must be passed to retain the temp dir until the end of the program
|
||||
_temp_dir: TempDir,
|
||||
/// Selected theme
|
||||
theme: Theme,
|
||||
/// Currently focused area
|
||||
pub focus: Focus,
|
||||
/// List of tabs
|
||||
tabs: Vec<Tab>,
|
||||
/// Current tab
|
||||
current_tab: ListState,
|
||||
/// 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>,
|
||||
/// This is the state associated with the list widget, used to display the selection in the
|
||||
/// widget
|
||||
selection: ListState,
|
||||
filter: Filter,
|
||||
multi_select: bool,
|
||||
selected_commands: Vec<Rc<ListNode>>,
|
||||
drawable: bool,
|
||||
#[cfg(feature = "tips")]
|
||||
tip: &'static str,
|
||||
}
|
||||
|
||||
pub enum Focus {
|
||||
Search,
|
||||
TabList,
|
||||
List,
|
||||
FloatingWindow(Float<dyn FloatContent>),
|
||||
ConfirmationPrompt(Float<ConfirmPrompt>),
|
||||
}
|
||||
|
||||
pub struct ListEntry {
|
||||
pub node: Rc<ListNode>,
|
||||
pub id: NodeId,
|
||||
pub has_children: bool,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(theme: Theme, override_validation: bool) -> Self {
|
||||
let (temp_dir, tabs) = linutil_core::get_tabs(!override_validation);
|
||||
let root_id = tabs[0].tree.root().id();
|
||||
|
||||
let mut state = Self {
|
||||
_temp_dir: temp_dir,
|
||||
theme,
|
||||
focus: Focus::List,
|
||||
tabs,
|
||||
current_tab: ListState::default().with_selected(Some(0)),
|
||||
visit_stack: vec![root_id],
|
||||
selection: ListState::default().with_selected(Some(0)),
|
||||
filter: Filter::new(),
|
||||
multi_select: false,
|
||||
selected_commands: Vec::new(),
|
||||
drawable: false,
|
||||
#[cfg(feature = "tips")]
|
||||
tip: get_random_tip(),
|
||||
};
|
||||
|
||||
state.update_items();
|
||||
state
|
||||
}
|
||||
|
||||
fn get_list_item_shortcut(&self) -> Box<[Shortcut]> {
|
||||
if self.selected_item_is_dir() {
|
||||
Box::new([Shortcut::new("Go to selected dir", ["l", "Right", "Enter"])])
|
||||
} else {
|
||||
Box::new([
|
||||
Shortcut::new("Run selected command", ["l", "Right", "Enter"]),
|
||||
Shortcut::new("Enable preview", ["p"]),
|
||||
Shortcut::new("Command Description", ["d"]),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_keybinds(&self) -> (&str, Box<[Shortcut]>) {
|
||||
match self.focus {
|
||||
Focus::Search => (
|
||||
"Search bar",
|
||||
Box::new([
|
||||
Shortcut::new("Abort search", ["Esc", "CTRL-c"]),
|
||||
Shortcut::new("Search", ["Enter"]),
|
||||
]),
|
||||
),
|
||||
|
||||
Focus::List => {
|
||||
let mut hints = Vec::new();
|
||||
hints.push(Shortcut::new("Exit linutil", ["q", "CTRL-c"]));
|
||||
|
||||
if self.at_root() {
|
||||
hints.push(Shortcut::new("Focus tab list", ["h", "Left"]));
|
||||
hints.extend(self.get_list_item_shortcut());
|
||||
} else if self.selected_item_is_up_dir() {
|
||||
hints.push(Shortcut::new(
|
||||
"Go to parent directory",
|
||||
["l", "Right", "Enter", "h", "Left"],
|
||||
));
|
||||
} else {
|
||||
hints.push(Shortcut::new("Go to parent directory", ["h", "Left"]));
|
||||
hints.extend(self.get_list_item_shortcut());
|
||||
}
|
||||
|
||||
hints.push(Shortcut::new("Select item above", ["k", "Up"]));
|
||||
hints.push(Shortcut::new("Select item below", ["j", "Down"]));
|
||||
hints.push(Shortcut::new("Next theme", ["t"]));
|
||||
hints.push(Shortcut::new("Previous theme", ["T"]));
|
||||
|
||||
if self.is_current_tab_multi_selectable() {
|
||||
hints.push(Shortcut::new("Toggle multi-selection mode", ["v"]));
|
||||
hints.push(Shortcut::new("Select multiple commands", ["Space"]));
|
||||
}
|
||||
|
||||
hints.push(Shortcut::new("Next tab", ["Tab"]));
|
||||
hints.push(Shortcut::new("Previous tab", ["Shift-Tab"]));
|
||||
hints.push(Shortcut::new("Important actions guide", ["g"]));
|
||||
|
||||
("Command list", hints.into_boxed_slice())
|
||||
}
|
||||
|
||||
Focus::TabList => (
|
||||
"Tab list",
|
||||
Box::new([
|
||||
Shortcut::new("Exit linutil", ["q", "CTRL-c"]),
|
||||
Shortcut::new("Focus action list", ["l", "Right", "Enter"]),
|
||||
Shortcut::new("Select item above", ["k", "Up"]),
|
||||
Shortcut::new("Select item below", ["j", "Down"]),
|
||||
Shortcut::new("Next theme", ["t"]),
|
||||
Shortcut::new("Previous theme", ["T"]),
|
||||
Shortcut::new("Next tab", ["Tab"]),
|
||||
Shortcut::new("Previous tab", ["Shift-Tab"]),
|
||||
]),
|
||||
),
|
||||
|
||||
Focus::FloatingWindow(ref float) => float.get_shortcut_list(),
|
||||
Focus::ConfirmationPrompt(ref prompt) => prompt.get_shortcut_list(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw(&mut self, frame: &mut Frame) {
|
||||
let terminal_size = frame.area();
|
||||
|
||||
if terminal_size.width < MIN_WIDTH || terminal_size.height < MIN_HEIGHT {
|
||||
let warning = Paragraph::new(format!(
|
||||
"Terminal size too small:\nWidth = {} Height = {}\n\nMinimum size:\nWidth = {} Height = {}",
|
||||
terminal_size.width,
|
||||
terminal_size.height,
|
||||
MIN_WIDTH,
|
||||
MIN_HEIGHT,
|
||||
))
|
||||
.alignment(Alignment::Center)
|
||||
.style(Style::default().fg(ratatui::style::Color::Red).bold())
|
||||
.wrap(ratatui::widgets::Wrap { trim: true });
|
||||
|
||||
let centered_layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(5),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.split(terminal_size);
|
||||
|
||||
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::Set {
|
||||
top_left: " ",
|
||||
top_right: " ",
|
||||
bottom_left: " ",
|
||||
bottom_right: " ",
|
||||
vertical_left: " ",
|
||||
vertical_right: " ",
|
||||
horizontal_top: "*",
|
||||
horizontal_bottom: "*",
|
||||
});
|
||||
let str1 = "scriptui ";
|
||||
let str2 = "by pika";
|
||||
let label = Paragraph::new(Line::from(vec![
|
||||
Span::styled(str1, Style::default().bold()),
|
||||
Span::styled(str2, 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());
|
||||
|
||||
let (keybind_scope, shortcuts) = self.get_keybinds();
|
||||
|
||||
let keybind_render_width = terminal_size.width - 2;
|
||||
|
||||
let keybinds_block = Block::default()
|
||||
.title(format!(" {} ", keybind_scope))
|
||||
.borders(Borders::all());
|
||||
|
||||
let keybinds = create_shortcut_list(shortcuts, keybind_render_width);
|
||||
let n_lines = keybinds.len() as u16;
|
||||
|
||||
let keybind_para = Paragraph::new(Text::from_iter(keybinds)).block(keybinds_block);
|
||||
|
||||
let vertical = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Percentage(0),
|
||||
Constraint::Max(n_lines as u16 + 2),
|
||||
])
|
||||
.flex(Flex::Legacy)
|
||||
.margin(0)
|
||||
.split(frame.area());
|
||||
|
||||
let horizontal = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Min(longest_tab_display_len as u16 + 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]);
|
||||
frame.render_widget(label, left_chunks[0]);
|
||||
|
||||
let tabs = self
|
||||
.tabs
|
||||
.iter()
|
||||
.map(|tab| tab.name.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let tab_hl_style = if let Focus::TabList = self.focus {
|
||||
Style::default().reversed().fg(self.theme.tab_color())
|
||||
} else {
|
||||
Style::new().fg(self.theme.tab_color())
|
||||
};
|
||||
|
||||
let list = List::new(tabs)
|
||||
.block(Block::default().borders(Borders::ALL))
|
||||
.highlight_style(tab_hl_style)
|
||||
.highlight_symbol(self.theme.tab_icon());
|
||||
frame.render_stateful_widget(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]);
|
||||
|
||||
self.filter.draw_searchbar(frame, chunks[0], &self.theme);
|
||||
|
||||
let mut items: Vec<Line> = Vec::new();
|
||||
let mut task_items: Vec<Line> = Vec::new();
|
||||
|
||||
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(
|
||||
|ListEntry {
|
||||
node, has_children, ..
|
||||
}| {
|
||||
let is_selected = self.selected_commands.contains(node);
|
||||
let (indicator, style) = if is_selected {
|
||||
(self.theme.multi_select_icon(), Style::default().bold())
|
||||
} else {
|
||||
("", Style::new())
|
||||
};
|
||||
if *has_children {
|
||||
Line::from(format!(
|
||||
"{} {} {}",
|
||||
self.theme.dir_icon(),
|
||||
node.name,
|
||||
indicator
|
||||
))
|
||||
.style(self.theme.dir_color())
|
||||
} else {
|
||||
Line::from(format!(
|
||||
"{} {} {}",
|
||||
self.theme.cmd_icon(),
|
||||
node.name,
|
||||
indicator
|
||||
))
|
||||
.style(self.theme.cmd_color())
|
||||
.patch_style(style)
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
task_items.extend(self.filter.item_list().iter().map(
|
||||
|ListEntry {
|
||||
node, has_children, ..
|
||||
}| {
|
||||
if *has_children {
|
||||
Line::from(" ").style(self.theme.dir_color())
|
||||
} else {
|
||||
Line::from(format!("{} ", node.task_list))
|
||||
.alignment(Alignment::Right)
|
||||
.style(self.theme.cmd_color())
|
||||
.bold()
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
let style = if let Focus::List = self.focus {
|
||||
Style::default().reversed()
|
||||
} else {
|
||||
Style::new()
|
||||
};
|
||||
|
||||
let title = if self.multi_select {
|
||||
&format!("{} [Multi-Select]", TITLE)
|
||||
} else {
|
||||
TITLE
|
||||
};
|
||||
|
||||
#[cfg(feature = "tips")]
|
||||
let bottom_title = Line::from(self.tip.bold().blue()).right_aligned();
|
||||
#[cfg(not(feature = "tips"))]
|
||||
let bottom_title = "";
|
||||
|
||||
let task_list_title = Line::from("Important Actions ").right_aligned();
|
||||
|
||||
// Create the list widget with items
|
||||
let list = List::new(items)
|
||||
.highlight_style(style)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL & !Borders::RIGHT)
|
||||
.title(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)
|
||||
.title(task_list_title),
|
||||
);
|
||||
|
||||
frame.render_stateful_widget(disclaimer_list, 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]),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
frame.render_widget(keybind_para, vertical[1]);
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: &KeyEvent) -> bool {
|
||||
// This should be defined first to allow closing
|
||||
// the application even when not drawable ( If terminal is small )
|
||||
// Exit on 'q' or 'Ctrl-c' input
|
||||
if matches!(
|
||||
self.focus,
|
||||
Focus::TabList | Focus::List | Focus::ConfirmationPrompt(_)
|
||||
) && (key.code == KeyCode::Char('q')
|
||||
|| key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// If UI is not drawable returning true will mark as the key handled
|
||||
if !self.drawable {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
match &mut self.focus {
|
||||
Focus::FloatingWindow(command) => {
|
||||
if command.handle_key_event(key) {
|
||||
self.focus = Focus::List;
|
||||
}
|
||||
}
|
||||
|
||||
Focus::ConfirmationPrompt(confirm) => {
|
||||
confirm.content.handle_key_event(key);
|
||||
match confirm.content.status {
|
||||
ConfirmStatus::Abort => {
|
||||
self.focus = Focus::List;
|
||||
// selected command was pushed to selection list if multi-select was
|
||||
// enabled, need to clear it to prevent state corruption
|
||||
if !self.multi_select {
|
||||
self.selected_commands.clear()
|
||||
}
|
||||
}
|
||||
ConfirmStatus::Confirm => self.handle_confirm_command(),
|
||||
ConfirmStatus::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
Focus::Search => match self.filter.handle_key(key) {
|
||||
SearchAction::Exit => self.exit_search(),
|
||||
SearchAction::Update => self.update_items(),
|
||||
SearchAction::None => {}
|
||||
},
|
||||
|
||||
Focus::TabList => match key.code {
|
||||
KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => self.focus = Focus::List,
|
||||
|
||||
KeyCode::Char('j') | KeyCode::Down
|
||||
if self.current_tab.selected().unwrap() + 1 < self.tabs.len() =>
|
||||
{
|
||||
self.current_tab.select_next();
|
||||
self.refresh_tab();
|
||||
}
|
||||
|
||||
KeyCode::Char('k') | KeyCode::Up => {
|
||||
self.current_tab.select_previous();
|
||||
self.refresh_tab();
|
||||
}
|
||||
|
||||
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(),
|
||||
_ => {}
|
||||
},
|
||||
|
||||
Focus::List if key.kind != KeyEventKind::Release => match key.code {
|
||||
KeyCode::Char('j') | KeyCode::Down => self.selection.select_next(),
|
||||
KeyCode::Char('k') | KeyCode::Up => self.selection.select_previous(),
|
||||
KeyCode::Char('p') | KeyCode::Char('P') => self.enable_preview(),
|
||||
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(),
|
||||
_ => {}
|
||||
},
|
||||
|
||||
_ => (),
|
||||
};
|
||||
true
|
||||
}
|
||||
|
||||
fn toggle_multi_select(&mut self) {
|
||||
if self.is_current_tab_multi_selectable() {
|
||||
self.multi_select = !self.multi_select;
|
||||
if !self.multi_select {
|
||||
self.selected_commands.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_selection(&mut self) {
|
||||
if let Some(command) = self.get_selected_node() {
|
||||
if self.selected_commands.contains(&command) {
|
||||
self.selected_commands.retain(|c| c != &command);
|
||||
} else {
|
||||
self.selected_commands.push(command);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_current_tab_multi_selectable(&self) -> bool {
|
||||
let index = self.current_tab.selected().unwrap_or(0);
|
||||
self.tabs
|
||||
.get(index)
|
||||
.map_or(false, |tab| tab.multi_selectable)
|
||||
}
|
||||
|
||||
fn update_items(&mut self) {
|
||||
self.filter.update_items(
|
||||
&self.tabs,
|
||||
self.current_tab.selected().unwrap(),
|
||||
*self.visit_stack.last().unwrap(),
|
||||
);
|
||||
if !self.is_current_tab_multi_selectable() {
|
||||
self.multi_select = false;
|
||||
self.selected_commands.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks either the current tree node is the root node (can we go up the tree or no)
|
||||
/// Returns `true` if we can't go up the tree (we are at the tree root)
|
||||
/// else returns `false`
|
||||
pub fn at_root(&self) -> bool {
|
||||
self.visit_stack.len() == 1
|
||||
}
|
||||
|
||||
fn go_back(&mut self) {
|
||||
if self.at_root() {
|
||||
self.focus = Focus::TabList;
|
||||
} else {
|
||||
self.enter_parent_directory();
|
||||
}
|
||||
}
|
||||
|
||||
fn enter_parent_directory(&mut self) {
|
||||
self.visit_stack.pop();
|
||||
self.selection.select(Some(0));
|
||||
self.update_items();
|
||||
}
|
||||
|
||||
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() {
|
||||
selected_index = selected_index.saturating_sub(1);
|
||||
}
|
||||
|
||||
if let Some(item) = self.filter.item_list().get(selected_index) {
|
||||
if !item.has_children {
|
||||
return Some(item.node.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn get_selected_description(&self) -> Option<String> {
|
||||
self.get_selected_node()
|
||||
.map(|node| node.description.clone())
|
||||
}
|
||||
|
||||
pub fn go_to_selected_dir(&mut self) {
|
||||
let mut selected_index = self.selection.selected().unwrap_or(0);
|
||||
|
||||
if !self.at_root() && selected_index == 0 {
|
||||
self.enter_parent_directory();
|
||||
return;
|
||||
}
|
||||
|
||||
if !self.at_root() {
|
||||
selected_index = selected_index.saturating_sub(1);
|
||||
}
|
||||
|
||||
if let Some(item) = self.filter.item_list().get(selected_index) {
|
||||
if item.has_children {
|
||||
self.visit_stack.push(item.id);
|
||||
self.selection.select(Some(0));
|
||||
self.update_items();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected_item_is_dir(&self) -> bool {
|
||||
let mut selected_index = self.selection.selected().unwrap_or(0);
|
||||
|
||||
if !self.at_root() && selected_index == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
if !self.at_root() {
|
||||
selected_index = selected_index.saturating_sub(1);
|
||||
}
|
||||
|
||||
self.filter
|
||||
.item_list()
|
||||
.get(selected_index)
|
||||
.map_or(false, |item| item.has_children)
|
||||
}
|
||||
|
||||
pub fn selected_item_is_cmd(&self) -> bool {
|
||||
// Any item that is not a directory or up directory (..) must be a command
|
||||
self.selection.selected().is_some()
|
||||
&& !(self.selected_item_is_up_dir() || self.selected_item_is_dir())
|
||||
}
|
||||
|
||||
pub fn selected_item_is_up_dir(&self) -> bool {
|
||||
let selected_index = self.selection.selected().unwrap_or(0);
|
||||
|
||||
!self.at_root() && selected_index == 0
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn enable_description(&mut self) {
|
||||
if let Some(command_description) = self.get_selected_description() {
|
||||
let description = FloatingText::new(command_description, "Command Description");
|
||||
self.spawn_float(description, 80, 80);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_enter(&mut self) {
|
||||
if self.selected_item_is_cmd() {
|
||||
if self.selected_commands.is_empty() {
|
||||
if let Some(node) = self.get_selected_node() {
|
||||
self.selected_commands.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
} else {
|
||||
self.go_to_selected_dir();
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_confirm_command(&mut self) {
|
||||
let commands = self
|
||||
.selected_commands
|
||||
.iter()
|
||||
.map(|node| node.command.clone())
|
||||
.collect();
|
||||
|
||||
let command = RunningCommand::new(commands);
|
||||
self.spawn_float(command, 80, 80);
|
||||
self.selected_commands.clear();
|
||||
}
|
||||
|
||||
fn spawn_float<T: FloatContent + 'static>(&mut self, float: T, width: u16, height: u16) {
|
||||
self.focus = Focus::FloatingWindow(Float::new(Box::new(float), width, height));
|
||||
}
|
||||
|
||||
fn enter_search(&mut self) {
|
||||
self.focus = Focus::Search;
|
||||
self.filter.activate_search();
|
||||
self.selection.select(None);
|
||||
}
|
||||
|
||||
fn exit_search(&mut self) {
|
||||
self.selection.select(Some(0));
|
||||
self.focus = Focus::List;
|
||||
self.filter.deactivate_search();
|
||||
self.update_items();
|
||||
}
|
||||
|
||||
fn refresh_tab(&mut self) {
|
||||
self.visit_stack = vec![self.tabs[self.current_tab.selected().unwrap()]
|
||||
.tree
|
||||
.root()
|
||||
.id()];
|
||||
self.selection.select(Some(0));
|
||||
self.update_items();
|
||||
}
|
||||
|
||||
fn toggle_task_list_guide(&mut self) {
|
||||
self.spawn_float(
|
||||
FloatingText::new(ACTIONS_GUIDE.to_string(), "Important Actions Guide"),
|
||||
80,
|
||||
80,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "tips")]
|
||||
const TIPS: &str = include_str!("../cool_tips.txt");
|
||||
|
||||
#[cfg(feature = "tips")]
|
||||
fn get_random_tip() -> &'static str {
|
||||
let tips: Vec<&str> = TIPS.lines().collect();
|
||||
if tips.is_empty() {
|
||||
return "";
|
||||
}
|
||||
|
||||
let mut rng = rand::thread_rng();
|
||||
let random_index = rng.gen_range(0..tips.len());
|
||||
tips[random_index]
|
||||
}
|
107
tui/src/theme.rs
Normal file
107
tui/src/theme.rs
Normal file
|
@ -0,0 +1,107 @@
|
|||
use clap::ValueEnum;
|
||||
use ratatui::style::Color;
|
||||
|
||||
// Add the Theme name here for a new theme
|
||||
// This is more secure than the previous list
|
||||
// We cannot index out of bounds, and we are giving
|
||||
// names to our various themes, making it very clear
|
||||
// This will make it easy to add new themes
|
||||
#[derive(Clone, Debug, PartialEq, Default, ValueEnum, Copy)]
|
||||
pub enum Theme {
|
||||
#[default]
|
||||
Default,
|
||||
Compatible,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
pub fn dir_color(&self) -> Color {
|
||||
match self {
|
||||
Theme::Default => Color::Blue,
|
||||
Theme::Compatible => Color::Blue,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cmd_color(&self) -> Color {
|
||||
match self {
|
||||
Theme::Default => Color::Rgb(204, 224, 208),
|
||||
Theme::Compatible => Color::LightGreen,
|
||||
}
|
||||
}
|
||||
|
||||
pub 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 {
|
||||
match self {
|
||||
Theme::Default => " ",
|
||||
Theme::Compatible => "[DIR]",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cmd_icon(&self) -> &'static str {
|
||||
match self {
|
||||
Theme::Default => " ",
|
||||
Theme::Compatible => "[CMD]",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tab_icon(&self) -> &'static str {
|
||||
match self {
|
||||
Theme::Default => " ",
|
||||
Theme::Compatible => ">> ",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn multi_select_icon(&self) -> &'static str {
|
||||
match self {
|
||||
Theme::Default => "",
|
||||
Theme::Compatible => "*",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn success_color(&self) -> Color {
|
||||
match self {
|
||||
Theme::Default => Color::Rgb(199, 55, 44),
|
||||
Theme::Compatible => Color::Green,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fail_color(&self) -> Color {
|
||||
match self {
|
||||
Theme::Default => Color::Rgb(5, 255, 55),
|
||||
Theme::Compatible => Color::Red,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn focused_color(&self) -> Color {
|
||||
match self {
|
||||
Theme::Default => Color::LightBlue,
|
||||
Theme::Compatible => Color::LightBlue,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unfocused_color(&self) -> Color {
|
||||
match self {
|
||||
Theme::Default => Color::Gray,
|
||||
Theme::Compatible => Color::Gray,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
pub fn next(&mut self) {
|
||||
let position = *self as usize;
|
||||
let types = Theme::value_variants();
|
||||
*self = types[(position + 1) % types.len()];
|
||||
}
|
||||
|
||||
pub fn prev(&mut self) {
|
||||
let position = *self as usize;
|
||||
let types = Theme::value_variants();
|
||||
*self = types[(position + types.len() - 1) % types.len()];
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue