init
This commit is contained in:
+269
@@ -0,0 +1,269 @@
|
||||
use std::{io, time::Duration};
|
||||
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEvent};
|
||||
|
||||
use crate::{
|
||||
launcher::{
|
||||
LauncherItem, build_launcher_items, config_file_path, load_custom_items, record_launch,
|
||||
save_custom_items, toggle_favorite,
|
||||
},
|
||||
terminal::{Tui, restore_terminal, run_shell_command, setup_existing_terminal},
|
||||
ui,
|
||||
};
|
||||
|
||||
pub struct App {
|
||||
pub(crate) items: Vec<LauncherItem>,
|
||||
pub(crate) selected: usize,
|
||||
pub(crate) status: String,
|
||||
pub(crate) mode: AppMode,
|
||||
should_quit: bool,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new() -> Self {
|
||||
let custom_items = load_custom_items().unwrap_or_else(|_| Vec::new());
|
||||
let items = build_launcher_items(custom_items);
|
||||
let status = format!(
|
||||
"Found {} launcher{}.",
|
||||
items.len(),
|
||||
if items.len() == 1 { "" } else { "s" }
|
||||
);
|
||||
|
||||
Self {
|
||||
items,
|
||||
selected: 0,
|
||||
status,
|
||||
mode: AppMode::Normal,
|
||||
should_quit: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(&mut self, terminal: &mut Tui) -> io::Result<()> {
|
||||
while !self.should_quit {
|
||||
terminal.draw(|frame| ui::draw_app(frame, self))?;
|
||||
|
||||
if event::poll(Duration::from_millis(150))? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
self.handle_key(key, terminal)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_key(&mut self, key: KeyEvent, terminal: &mut Tui) -> io::Result<()> {
|
||||
match self.mode {
|
||||
AppMode::Normal => self.handle_normal_key(key, terminal),
|
||||
AppMode::Adding(_) => self.handle_add_key(key),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_normal_key(&mut self, key: KeyEvent, terminal: &mut Tui) -> io::Result<()> {
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true,
|
||||
KeyCode::Char('j') | KeyCode::Down => self.select_next(),
|
||||
KeyCode::Char('k') | KeyCode::Up => self.select_previous(),
|
||||
KeyCode::Char('a') => self.start_add_form(),
|
||||
KeyCode::Char('f') => self.toggle_selected_favorite(),
|
||||
KeyCode::Char('r') => self.reload_items(),
|
||||
KeyCode::Enter => self.launch_selected(terminal)?,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_add_key(&mut self, key: KeyEvent) -> io::Result<()> {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.mode = AppMode::Normal;
|
||||
self.status = "Add cancelled.".to_string();
|
||||
}
|
||||
KeyCode::Enter => self.advance_add_form(),
|
||||
KeyCode::Backspace => {
|
||||
if let AppMode::Adding(form) = &mut self.mode {
|
||||
form.current_value_mut().pop();
|
||||
}
|
||||
}
|
||||
KeyCode::Char(character) => {
|
||||
if let AppMode::Adding(form) = &mut self.mode {
|
||||
form.current_value_mut().push(character);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn select_next(&mut self) {
|
||||
if !self.items.is_empty() {
|
||||
self.selected = (self.selected + 1) % self.items.len();
|
||||
}
|
||||
}
|
||||
|
||||
fn select_previous(&mut self) {
|
||||
if self.items.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.selected == 0 {
|
||||
self.selected = self.items.len() - 1;
|
||||
} else {
|
||||
self.selected -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn start_add_form(&mut self) {
|
||||
self.mode = AppMode::Adding(AddForm::default());
|
||||
self.status = "Adding a launcher.".to_string();
|
||||
}
|
||||
|
||||
fn advance_add_form(&mut self) {
|
||||
let AppMode::Adding(form) = &mut self.mode else {
|
||||
return;
|
||||
};
|
||||
|
||||
match form.step {
|
||||
AddStep::Name => form.step = AddStep::Command,
|
||||
AddStep::Command => form.step = AddStep::Description,
|
||||
AddStep::Description => {
|
||||
let new_item = form.to_launcher_item();
|
||||
match new_item {
|
||||
Some(item) => self.save_new_item(item),
|
||||
None => self.status = "Name and command are required.".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn save_new_item(&mut self, item: LauncherItem) {
|
||||
let mut custom_items = load_custom_items().unwrap_or_else(|_| Vec::new());
|
||||
custom_items.push(item.clone());
|
||||
|
||||
match save_custom_items(&custom_items) {
|
||||
Ok(()) => {
|
||||
self.items.push(item);
|
||||
self.selected = self.items.len() - 1;
|
||||
self.mode = AppMode::Normal;
|
||||
self.status = format!("Saved launcher to {}.", config_file_path().display());
|
||||
}
|
||||
Err(error) => {
|
||||
self.status = format!("Could not save launcher: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reload_items(&mut self) {
|
||||
let selected_command = self
|
||||
.items
|
||||
.get(self.selected)
|
||||
.map(|item| item.command.clone());
|
||||
self.reload_items_keeping(selected_command.as_deref());
|
||||
self.status = format!("Rescanned and found {} launcher(s).", self.items.len());
|
||||
}
|
||||
|
||||
fn reload_items_keeping(&mut self, selected_command: Option<&str>) {
|
||||
let custom_items = load_custom_items().unwrap_or_else(|_| Vec::new());
|
||||
self.items = build_launcher_items(custom_items);
|
||||
|
||||
self.selected = selected_command
|
||||
.and_then(|command| self.items.iter().position(|item| item.command == command))
|
||||
.unwrap_or_else(|| self.selected.min(self.items.len().saturating_sub(1)));
|
||||
}
|
||||
|
||||
fn toggle_selected_favorite(&mut self) {
|
||||
let Some(item) = self.items.get(self.selected).cloned() else {
|
||||
self.status = "Nothing to favorite. Press a to add a TUI.".to_string();
|
||||
return;
|
||||
};
|
||||
|
||||
match toggle_favorite(&item.command) {
|
||||
Ok(is_favorite) => {
|
||||
self.reload_items_keeping(Some(&item.command));
|
||||
let action = if is_favorite {
|
||||
"Favorited"
|
||||
} else {
|
||||
"Unfavorited"
|
||||
};
|
||||
self.status = format!("{action} {}.", item.name);
|
||||
}
|
||||
Err(error) => {
|
||||
self.status = format!("Could not update favorite: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_selected(&mut self, terminal: &mut Tui) -> io::Result<()> {
|
||||
let Some(item) = self.items.get(self.selected).cloned() else {
|
||||
self.status = "Nothing to launch. Press a to add a TUI.".to_string();
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if let Err(error) = record_launch(&item.command) {
|
||||
self.status = format!("Could not record launch: {error}");
|
||||
}
|
||||
|
||||
restore_terminal(terminal)?;
|
||||
let result = run_shell_command(&item.command);
|
||||
setup_existing_terminal(terminal)?;
|
||||
self.reload_items_keeping(Some(&item.command));
|
||||
|
||||
self.status = match result {
|
||||
Ok(code) => format!("{} exited with status {}.", item.name, code),
|
||||
Err(error) => format!("Could not launch {}: {}", item.name, error),
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum AppMode {
|
||||
Normal,
|
||||
Adding(AddForm),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct AddForm {
|
||||
pub(crate) name: String,
|
||||
pub(crate) command: String,
|
||||
pub(crate) description: String,
|
||||
pub(crate) step: AddStep,
|
||||
}
|
||||
|
||||
impl AddForm {
|
||||
fn current_value_mut(&mut self) -> &mut String {
|
||||
match self.step {
|
||||
AddStep::Name => &mut self.name,
|
||||
AddStep::Command => &mut self.command,
|
||||
AddStep::Description => &mut self.description,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_launcher_item(&self) -> Option<LauncherItem> {
|
||||
let name = self.name.trim();
|
||||
let command = self.command.trim();
|
||||
let description = self.description.trim();
|
||||
|
||||
if name.is_empty() || command.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let description = if description.is_empty() {
|
||||
"Added from inside the launcher."
|
||||
} else {
|
||||
description
|
||||
};
|
||||
|
||||
Some(LauncherItem::custom(name, command, description))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub(crate) enum AddStep {
|
||||
#[default]
|
||||
Name,
|
||||
Command,
|
||||
Description,
|
||||
}
|
||||
+640
@@ -0,0 +1,640 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
env, fs, io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LauncherItem {
|
||||
pub name: String,
|
||||
pub command: String,
|
||||
pub description: String,
|
||||
pub source: LauncherSource,
|
||||
pub launch_count: u32,
|
||||
pub is_favorite: bool,
|
||||
}
|
||||
|
||||
impl LauncherItem {
|
||||
fn detected(name: &str, command: &str, description: &str) -> Self {
|
||||
Self::new(name, command, description, LauncherSource::Detected)
|
||||
}
|
||||
|
||||
pub fn custom(name: &str, command: &str, description: &str) -> Self {
|
||||
Self::new(name, command, description, LauncherSource::Custom)
|
||||
}
|
||||
|
||||
fn new(name: &str, command: &str, description: &str, source: LauncherSource) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
command: command.to_string(),
|
||||
description: description.to_string(),
|
||||
source,
|
||||
launch_count: 0,
|
||||
is_favorite: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum LauncherSource {
|
||||
Detected,
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl LauncherSource {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Detected => "detected on this computer",
|
||||
Self::Custom => "added by you",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn short_label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Detected => "auto",
|
||||
Self::Custom => "user",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_launcher_items(custom_items: Vec<LauncherItem>) -> Vec<LauncherItem> {
|
||||
let stats = load_launcher_stats().unwrap_or_else(|_| HashMap::new());
|
||||
let mut used_commands = HashSet::new();
|
||||
let mut items = Vec::new();
|
||||
|
||||
for mut item in custom_items {
|
||||
apply_stats(&mut item, &stats);
|
||||
used_commands.insert(command_name(&item.command).to_string());
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
for mut item in detected_launcher_items() {
|
||||
apply_stats(&mut item, &stats);
|
||||
let command = command_name(&item.command);
|
||||
if used_commands.insert(command.to_string()) {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
sort_by_priority(&mut items);
|
||||
items
|
||||
}
|
||||
|
||||
pub fn toggle_favorite(command: &str) -> io::Result<bool> {
|
||||
let mut stats = load_launcher_stats()?;
|
||||
let command_key = command_name(command).to_string();
|
||||
let entry = stats.entry(command_key).or_default();
|
||||
entry.is_favorite = !entry.is_favorite;
|
||||
let is_favorite = entry.is_favorite;
|
||||
save_launcher_stats(&stats)?;
|
||||
Ok(is_favorite)
|
||||
}
|
||||
|
||||
pub fn record_launch(command: &str) -> io::Result<()> {
|
||||
let mut stats = load_launcher_stats()?;
|
||||
let command_key = command_name(command).to_string();
|
||||
let entry = stats.entry(command_key).or_default();
|
||||
entry.launch_count = entry.launch_count.saturating_add(1);
|
||||
save_launcher_stats(&stats)
|
||||
}
|
||||
|
||||
pub fn stats_file_path() -> PathBuf {
|
||||
config_directory().join("stats.txt")
|
||||
}
|
||||
|
||||
pub fn load_custom_items() -> io::Result<Vec<LauncherItem>> {
|
||||
let path = config_file_path();
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let contents = fs::read_to_string(path)?;
|
||||
let mut items = Vec::new();
|
||||
|
||||
for line in contents.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = line.splitn(3, '\t').collect();
|
||||
if parts.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let description = parts.get(2).copied().unwrap_or("Added from config file.");
|
||||
items.push(LauncherItem::custom(parts[0], parts[1], description));
|
||||
}
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
pub fn save_custom_items(items: &[LauncherItem]) -> io::Result<()> {
|
||||
let path = config_file_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let mut contents =
|
||||
String::from("# TUI launcher entries. Each line is: name<TAB>command<TAB>description\n");
|
||||
|
||||
for item in items {
|
||||
contents.push_str(&item.name.replace('\t', " "));
|
||||
contents.push('\t');
|
||||
contents.push_str(&item.command.replace('\t', " "));
|
||||
contents.push('\t');
|
||||
contents.push_str(&item.description.replace('\t', " "));
|
||||
contents.push('\n');
|
||||
}
|
||||
|
||||
fs::write(path, contents)
|
||||
}
|
||||
|
||||
pub fn config_file_path() -> PathBuf {
|
||||
config_directory().join("launchers.txt")
|
||||
}
|
||||
|
||||
fn config_directory() -> PathBuf {
|
||||
if let Ok(config_home) = env::var("XDG_CONFIG_HOME") {
|
||||
return PathBuf::from(config_home).join("tui2");
|
||||
}
|
||||
|
||||
if let Ok(home) = env::var("HOME") {
|
||||
return PathBuf::from(home).join(".config").join("tui2");
|
||||
}
|
||||
|
||||
PathBuf::from(".")
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct LauncherStats {
|
||||
launch_count: u32,
|
||||
is_favorite: bool,
|
||||
}
|
||||
|
||||
fn apply_stats(item: &mut LauncherItem, stats: &HashMap<String, LauncherStats>) {
|
||||
if let Some(saved_stats) = stats.get(command_name(&item.command)) {
|
||||
item.launch_count = saved_stats.launch_count;
|
||||
item.is_favorite = saved_stats.is_favorite;
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_by_priority(items: &mut [LauncherItem]) {
|
||||
items.sort_by(|left, right| {
|
||||
right
|
||||
.is_favorite
|
||||
.cmp(&left.is_favorite)
|
||||
.then(right.launch_count.cmp(&left.launch_count))
|
||||
.then(left.name.to_lowercase().cmp(&right.name.to_lowercase()))
|
||||
});
|
||||
}
|
||||
|
||||
fn load_launcher_stats() -> io::Result<HashMap<String, LauncherStats>> {
|
||||
let path = stats_file_path();
|
||||
if !path.exists() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let contents = fs::read_to_string(path)?;
|
||||
let mut stats = HashMap::new();
|
||||
|
||||
for line in contents.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = line.splitn(3, '\t').collect();
|
||||
if parts.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let launch_count = parts[1].parse().unwrap_or(0);
|
||||
let is_favorite = parts[2] == "true";
|
||||
stats.insert(
|
||||
parts[0].to_string(),
|
||||
LauncherStats {
|
||||
launch_count,
|
||||
is_favorite,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
fn save_launcher_stats(stats: &HashMap<String, LauncherStats>) -> io::Result<()> {
|
||||
let path = stats_file_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let mut rows: Vec<(&String, &LauncherStats)> = stats.iter().collect();
|
||||
rows.sort_by(|left, right| left.0.cmp(right.0));
|
||||
|
||||
let mut contents =
|
||||
String::from("# TUI usage stats. Each line is: command<TAB>launch_count<TAB>favorite\n");
|
||||
|
||||
for (command, stats) in rows {
|
||||
contents.push_str(&command.replace('\t', " "));
|
||||
contents.push('\t');
|
||||
contents.push_str(&stats.launch_count.to_string());
|
||||
contents.push('\t');
|
||||
contents.push_str(if stats.is_favorite { "true" } else { "false" });
|
||||
contents.push('\n');
|
||||
}
|
||||
|
||||
fs::write(path, contents)
|
||||
}
|
||||
|
||||
fn detected_launcher_items() -> Vec<LauncherItem> {
|
||||
known_tui_programs()
|
||||
.into_iter()
|
||||
.filter(|program| should_show_program(program.command))
|
||||
.filter(|program| command_exists(program.command))
|
||||
.map(|program| LauncherItem::detected(program.name, program.command, program.description))
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct KnownTuiProgram {
|
||||
name: &'static str,
|
||||
command: &'static str,
|
||||
description: &'static str,
|
||||
}
|
||||
|
||||
fn known_tui_programs() -> Vec<KnownTuiProgram> {
|
||||
vec![
|
||||
KnownTuiProgram {
|
||||
name: "Zellij",
|
||||
command: "zellij",
|
||||
description: "Open the Zellij terminal workspace and multiplexer.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Tmux",
|
||||
command: "tmux",
|
||||
description: "Open a tmux terminal multiplexer session.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Neovim",
|
||||
command: "nvim",
|
||||
description: "Open Neovim in the directory where this launcher was started.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Vim",
|
||||
command: "vim",
|
||||
description: "Open Vim in the directory where this launcher was started.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Helix",
|
||||
command: "hx",
|
||||
description: "Open the Helix editor in the current directory.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Micro",
|
||||
command: "micro",
|
||||
description: "Open the Micro terminal text editor.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Emacs",
|
||||
command: "emacs -nw",
|
||||
description: "Open Emacs in terminal mode.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Nano",
|
||||
command: "nano",
|
||||
description: "Open the Nano terminal text editor.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Lazygit",
|
||||
command: "lazygit",
|
||||
description: "Open a Git status and commit dashboard for the current directory.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "GitUI",
|
||||
command: "gitui",
|
||||
description: "Open a keyboard-driven Git interface.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Tig",
|
||||
command: "tig",
|
||||
description: "Browse Git history from the terminal.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Lazy Docker",
|
||||
command: "lazydocker",
|
||||
description: "Open a terminal dashboard for Docker containers and services.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Docker TUI",
|
||||
command: "docui",
|
||||
description: "Open a terminal interface for Docker resources.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "K9s",
|
||||
command: "k9s",
|
||||
description: "Open the K9s Kubernetes dashboard.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Kubectl TUI",
|
||||
command: "kui",
|
||||
description: "Open a terminal interface for Kubernetes workflows.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Stern",
|
||||
command: "stern",
|
||||
description: "Tail Kubernetes logs from multiple pods.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Yazi",
|
||||
command: "yazi",
|
||||
description: "Open the Yazi terminal file manager.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Ranger",
|
||||
command: "ranger",
|
||||
description: "Open the Ranger terminal file manager.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Lf",
|
||||
command: "lf",
|
||||
description: "Open the lf terminal file manager.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Nnn",
|
||||
command: "nnn",
|
||||
description: "Open the nnn terminal file manager.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Vifm",
|
||||
command: "vifm",
|
||||
description: "Open the Vifm dual-pane file manager.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Midnight Commander",
|
||||
command: "mc",
|
||||
description: "Open the Midnight Commander file manager.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Superfile",
|
||||
command: "spf",
|
||||
description: "Open the Superfile terminal file manager.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Btop",
|
||||
command: "btop",
|
||||
description: "Open the btop system monitor.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Bottom",
|
||||
command: "btm",
|
||||
description: "Open the Bottom system monitor.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Htop",
|
||||
command: "htop",
|
||||
description: "Open the htop process monitor.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Top",
|
||||
command: "top",
|
||||
description: "Open the standard process monitor.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Glances",
|
||||
command: "glances",
|
||||
description: "Open the Glances system monitor.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Atop",
|
||||
command: "atop",
|
||||
description: "Open the atop system and process monitor.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Iotop",
|
||||
command: "iotop",
|
||||
description: "Inspect disk I/O usage by process.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Bandwhich",
|
||||
command: "bandwhich",
|
||||
description: "Show current network usage by process and connection.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Nethogs",
|
||||
command: "nethogs",
|
||||
description: "Show network usage grouped by process.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Nload",
|
||||
command: "nload",
|
||||
description: "Monitor network throughput in the terminal.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Bluetuith",
|
||||
command: "bluetuith",
|
||||
description: "Manage Bluetooth devices from the terminal.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Impala",
|
||||
command: "impala",
|
||||
description: "Manage Wi-Fi connections from the terminal.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Glow",
|
||||
command: "glow",
|
||||
description: "Browse Markdown files in the terminal.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Broot",
|
||||
command: "broot",
|
||||
description: "Explore and search directory trees.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Dust",
|
||||
command: "dust",
|
||||
description: "Inspect disk usage with a terminal tree view.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Ncdu",
|
||||
command: "ncdu",
|
||||
description: "Inspect disk usage from the terminal.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Dua",
|
||||
command: "dua interactive",
|
||||
description: "Inspect disk usage with dua's interactive terminal view.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Gdu",
|
||||
command: "gdu",
|
||||
description: "Inspect disk usage with the gdu terminal analyzer.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Rmpc",
|
||||
command: "rmpc",
|
||||
description: "Open the rmpc terminal music client.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Newsboat",
|
||||
command: "newsboat",
|
||||
description: "Open the Newsboat RSS reader.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Aerc",
|
||||
command: "aerc",
|
||||
description: "Open the aerc terminal email client.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Neomutt",
|
||||
command: "neomutt",
|
||||
description: "Open the NeoMutt terminal email client.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Mutt",
|
||||
command: "mutt",
|
||||
description: "Open the Mutt terminal email client.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "WeeChat",
|
||||
command: "weechat",
|
||||
description: "Open the WeeChat terminal chat client.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Irssi",
|
||||
command: "irssi",
|
||||
description: "Open the Irssi terminal chat client.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Tuigreet",
|
||||
command: "tuigreet",
|
||||
description: "Open the tuigreet terminal login greeter.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Music Player",
|
||||
command: "ncmpcpp",
|
||||
description: "Open the ncmpcpp music client.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Cmus",
|
||||
command: "cmus",
|
||||
description: "Open the cmus terminal music player.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Mocp",
|
||||
command: "mocp",
|
||||
description: "Open the MOC terminal music player.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Tuir",
|
||||
command: "tuir",
|
||||
description: "Open a terminal Reddit client.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Rtv",
|
||||
command: "rtv",
|
||||
description: "Open a terminal Reddit viewer.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Twtxt",
|
||||
command: "twtxt",
|
||||
description: "Open the twtxt command line social client.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "W3m",
|
||||
command: "w3m",
|
||||
description: "Open the w3m terminal web browser.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Lynx",
|
||||
command: "lynx",
|
||||
description: "Open the Lynx terminal web browser.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "El Links",
|
||||
command: "elinks",
|
||||
description: "Open the ELinks terminal web browser.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Bashmount",
|
||||
command: "bashmount",
|
||||
description: "Mount and unmount storage devices from the terminal.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Pulsemixer",
|
||||
command: "pulsemixer",
|
||||
description: "Control PulseAudio volume from the terminal.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Alsamixer",
|
||||
command: "alsamixer",
|
||||
description: "Control ALSA audio levels from the terminal.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Termscp",
|
||||
command: "termscp",
|
||||
description: "Open a terminal file transfer client.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Lftp",
|
||||
command: "lftp",
|
||||
description: "Open the lftp terminal file transfer client.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Gdb TUI",
|
||||
command: "gdb -tui",
|
||||
description: "Open GDB in terminal UI mode.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Lldb",
|
||||
command: "lldb",
|
||||
description: "Open the LLDB debugger.",
|
||||
},
|
||||
KnownTuiProgram {
|
||||
name: "Viddy",
|
||||
command: "viddy",
|
||||
description: "Run commands repeatedly in an interactive watch view.",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn should_show_program(command: &str) -> bool {
|
||||
match command_name(command) {
|
||||
"tmux" => env::var_os("TMUX").is_none(),
|
||||
"zellij" => env::var_os("ZELLIJ").is_none(),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn command_exists(command: &str) -> bool {
|
||||
let command = command_name(command);
|
||||
let Some(paths) = env::var_os("PATH") else {
|
||||
return false;
|
||||
};
|
||||
|
||||
env::split_paths(&paths).any(|directory| is_executable_file(&directory.join(command)))
|
||||
}
|
||||
|
||||
fn command_name(command: &str) -> &str {
|
||||
command.split_whitespace().next().unwrap_or(command)
|
||||
}
|
||||
|
||||
fn is_executable_file(path: &Path) -> bool {
|
||||
let Ok(metadata) = fs::metadata(path) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if !metadata.is_file() {
|
||||
return false;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
metadata.permissions().mode() & 0o111 != 0
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
true
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
mod app;
|
||||
mod launcher;
|
||||
mod terminal;
|
||||
mod ui;
|
||||
|
||||
use std::io;
|
||||
|
||||
use app::App;
|
||||
use terminal::{restore_terminal, setup_terminal};
|
||||
|
||||
fn main() -> io::Result<()> {
|
||||
let mut terminal = setup_terminal()?;
|
||||
let app_result = App::new().run(&mut terminal);
|
||||
restore_terminal(&mut terminal)?;
|
||||
app_result
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
use std::{
|
||||
io::{self, stdout},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
use crossterm::{
|
||||
execute,
|
||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||
};
|
||||
use ratatui::{Terminal, backend::CrosstermBackend};
|
||||
|
||||
pub type Tui = Terminal<CrosstermBackend<io::Stdout>>;
|
||||
|
||||
pub fn setup_terminal() -> io::Result<Tui> {
|
||||
enable_raw_mode()?;
|
||||
execute!(stdout(), EnterAlternateScreen)?;
|
||||
|
||||
let backend = CrosstermBackend::new(stdout());
|
||||
Terminal::new(backend)
|
||||
}
|
||||
|
||||
pub fn setup_existing_terminal(terminal: &mut Tui) -> io::Result<()> {
|
||||
enable_raw_mode()?;
|
||||
execute!(terminal.backend_mut(), EnterAlternateScreen)?;
|
||||
terminal.clear()
|
||||
}
|
||||
|
||||
pub fn restore_terminal(terminal: &mut Tui) -> io::Result<()> {
|
||||
disable_raw_mode()?;
|
||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||
terminal.show_cursor()
|
||||
}
|
||||
|
||||
pub fn run_shell_command(command: &str) -> io::Result<String> {
|
||||
let status = Command::new("sh").arg("-c").arg(command).status()?;
|
||||
|
||||
Ok(status
|
||||
.code()
|
||||
.map(|code| code.to_string())
|
||||
.unwrap_or_else(|| "terminated by signal".to_string()))
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
app::{AddForm, AddStep, App, AppMode},
|
||||
launcher::config_file_path,
|
||||
};
|
||||
|
||||
pub fn draw_app(frame: &mut Frame, app: &App) {
|
||||
let page = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(8),
|
||||
Constraint::Length(6),
|
||||
])
|
||||
.split(frame.area());
|
||||
|
||||
let body = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(38), Constraint::Percentage(62)])
|
||||
.split(page[1]);
|
||||
|
||||
draw_header(frame, page[0]);
|
||||
draw_launcher_list(frame, body[0], app);
|
||||
draw_detail_panel(frame, body[1], app);
|
||||
draw_footer(frame, page[2], app);
|
||||
}
|
||||
|
||||
fn draw_header(frame: &mut Frame, area: Rect) {
|
||||
let title = Paragraph::new(Line::from(vec![
|
||||
Span::styled(
|
||||
"TUI Launcher",
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" detected apps plus your saved launchers"),
|
||||
]))
|
||||
.block(Block::default().borders(Borders::ALL));
|
||||
|
||||
frame.render_widget(title, area);
|
||||
}
|
||||
|
||||
fn draw_launcher_list(frame: &mut Frame, area: Rect, app: &App) {
|
||||
let list_items: Vec<ListItem> = app
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
let favorite = if item.is_favorite { "[fav] " } else { "" };
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
favorite,
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(item.name.as_str()),
|
||||
Span::styled(
|
||||
format!(
|
||||
" [{} | {}]",
|
||||
item.source.short_label(),
|
||||
usage_label(item.launch_count)
|
||||
),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(list_items)
|
||||
.block(Block::default().title("Launchers").borders(Borders::ALL))
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.highlight_symbol(" > ");
|
||||
|
||||
let mut state = ListState::default();
|
||||
if !app.items.is_empty() {
|
||||
state.select(Some(app.selected));
|
||||
}
|
||||
frame.render_stateful_widget(list, area, &mut state);
|
||||
}
|
||||
|
||||
fn draw_detail_panel(frame: &mut Frame, area: Rect, app: &App) {
|
||||
match &app.mode {
|
||||
AppMode::Normal => draw_selected_item(frame, area, app),
|
||||
AppMode::Adding(form) => draw_add_form(frame, area, form),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_selected_item(frame: &mut Frame, area: Rect, app: &App) {
|
||||
let details = if let Some(selected) = app.items.get(app.selected) {
|
||||
vec![
|
||||
Line::from(vec![
|
||||
Span::styled("Name: ", Style::default().add_modifier(Modifier::BOLD)),
|
||||
Span::raw(selected.name.as_str()),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled("Command: ", Style::default().add_modifier(Modifier::BOLD)),
|
||||
Span::raw(selected.command.as_str()),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled("Source: ", Style::default().add_modifier(Modifier::BOLD)),
|
||||
Span::raw(selected.source.label()),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled("Priority: ", Style::default().add_modifier(Modifier::BOLD)),
|
||||
Span::raw(priority_text(selected.is_favorite, selected.launch_count)),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(selected.description.as_str()),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
Line::from("No launchers found yet."),
|
||||
Line::from(""),
|
||||
Line::from("Press a to add one. The launcher will save it to:"),
|
||||
Line::from(config_file_path().display().to_string()),
|
||||
]
|
||||
};
|
||||
|
||||
let paragraph = Paragraph::new(details)
|
||||
.block(Block::default().title("Selected TUI").borders(Borders::ALL))
|
||||
.wrap(Wrap { trim: true });
|
||||
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
fn draw_add_form(frame: &mut Frame, area: Rect, form: &AddForm) {
|
||||
let name_style = field_style(form.step == AddStep::Name);
|
||||
let command_style = field_style(form.step == AddStep::Command);
|
||||
let description_style = field_style(form.step == AddStep::Description);
|
||||
|
||||
let details = vec![
|
||||
Line::from("Add a launcher. Press Enter to move to the next field."),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled("Name: ", Style::default().add_modifier(Modifier::BOLD)),
|
||||
Span::styled(
|
||||
editing_text(&form.name, form.step == AddStep::Name),
|
||||
name_style,
|
||||
),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled("Command: ", Style::default().add_modifier(Modifier::BOLD)),
|
||||
Span::styled(
|
||||
editing_text(&form.command, form.step == AddStep::Command),
|
||||
command_style,
|
||||
),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
"Description: ",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
editing_text(&form.description, form.step == AddStep::Description),
|
||||
description_style,
|
||||
),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from("The command can include arguments, for example: nvim ~/.config"),
|
||||
];
|
||||
|
||||
let paragraph = Paragraph::new(details)
|
||||
.block(Block::default().title("Add TUI").borders(Borders::ALL))
|
||||
.wrap(Wrap { trim: true });
|
||||
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
fn draw_footer(frame: &mut Frame, area: Rect, app: &App) {
|
||||
let help = match app.mode {
|
||||
AppMode::Normal => Line::from(vec![
|
||||
Span::styled("j/down", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(" move "),
|
||||
Span::styled("enter", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(" launch "),
|
||||
Span::styled("a", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(" add "),
|
||||
Span::styled("f", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(" favorite "),
|
||||
Span::styled("r", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(" rescan "),
|
||||
Span::styled("q/esc", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(" quit"),
|
||||
]),
|
||||
AppMode::Adding(_) => Line::from(vec![
|
||||
Span::styled("enter", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(" next/save "),
|
||||
Span::styled("backspace", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(" delete "),
|
||||
Span::styled("esc", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(" cancel"),
|
||||
]),
|
||||
};
|
||||
|
||||
let footer = Paragraph::new(vec![Line::from(app.status.as_str()), Line::from(""), help])
|
||||
.block(Block::default().title("Status").borders(Borders::ALL))
|
||||
.wrap(Wrap { trim: true });
|
||||
|
||||
frame.render_widget(footer, area);
|
||||
}
|
||||
|
||||
fn field_style(is_active: bool) -> Style {
|
||||
if is_active {
|
||||
Style::default().fg(Color::Cyan)
|
||||
} else {
|
||||
Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn editing_text(value: &str, is_active: bool) -> String {
|
||||
if is_active {
|
||||
format!("{value}_")
|
||||
} else if value.is_empty() {
|
||||
"-".to_string()
|
||||
} else {
|
||||
value.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn priority_text(is_favorite: bool, launch_count: u32) -> String {
|
||||
let favorite = if is_favorite {
|
||||
"favorite"
|
||||
} else {
|
||||
"not favorited"
|
||||
};
|
||||
|
||||
format!("{favorite}, {}", usage_label(launch_count))
|
||||
}
|
||||
|
||||
fn usage_label(launch_count: u32) -> String {
|
||||
match launch_count {
|
||||
0 => "never used".to_string(),
|
||||
1 => "used once".to_string(),
|
||||
count => format!("used {count}x"),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user