This commit is contained in:
Austin
2026-05-04 10:45:20 -05:00
commit 26639484d7
9 changed files with 1912 additions and 0 deletions
+269
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+41
View File
@@ -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()))
}
+253
View File
@@ -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"),
}
}