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
+16
View File
@@ -0,0 +1,16 @@
# Rust build output
/target/
# Local editor and OS files
.DS_Store
*.swp
*.swo
.idea/
.vscode/
# Local environment files
.env
.env.*
# Runtime logs
*.log
Generated
+594
View File
@@ -0,0 +1,594 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "bitflags"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "cassowary"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "compact_str"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"rustversion",
"ryu",
"static_assertions",
]
[[package]]
name = "crossterm"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags",
"crossterm_winapi",
"mio",
"parking_lot",
"rustix",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
"winapi",
]
[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "indoc"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
"rustversion",
]
[[package]]
name = "instability"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
dependencies = [
"darling",
"indoc",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
dependencies = [
"hashbrown",
]
[[package]]
name = "mio"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "ratatui"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
dependencies = [
"bitflags",
"cassowary",
"compact_str",
"crossterm",
"indoc",
"instability",
"itertools",
"lru",
"paste",
"strum",
"unicode-segmentation",
"unicode-truncate",
"unicode-width 0.2.0",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "rustix"
version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio",
"signal-hook",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn",
]
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tui2"
version = "0.1.0"
dependencies = [
"crossterm",
"ratatui",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
[[package]]
name = "unicode-truncate"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
dependencies = [
"itertools",
"unicode-segmentation",
"unicode-width 0.1.14",
]
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "tui2"
version = "0.1.0"
edition = "2024"
[dependencies]
crossterm = "0.28"
ratatui = "0.29"
+75
View File
@@ -0,0 +1,75 @@
# tui2
`tui2` is a terminal UI launcher for other terminal UI apps. It detects common TUIs installed on your computer, lets you add your own commands, and prioritizes favorites and frequently used tools.
## Run
```bash
cargo run
```
## Controls
| Key | Action |
| --- | --- |
| `j` / Down | Move down |
| `k` / Up | Move up |
| Enter | Launch selected TUI |
| `a` | Add a custom launcher |
| `f` | Favorite or unfavorite selected launcher |
| `r` | Rescan detected and saved launchers |
| `q` / Esc | Quit |
## How Launchers Are Chosen
Launchers are sorted in this order:
1. Favorites
2. Most frequently used
3. Name
The list shows each launcher's source and usage, for example:
```text
[fav] Neovim [auto | used 4x]
Lazygit [auto | never used]
Notes [user | used once]
```
## Config Files
Custom launchers are saved here:
```text
~/.config/tui2/launchers.txt
```
Format:
```text
name<TAB>command<TAB>description
```
Usage stats and favorites are saved here:
```text
~/.config/tui2/stats.txt
```
Format:
```text
command<TAB>launch_count<TAB>favorite
```
## Code Layout
| File | Purpose |
| --- | --- |
| `src/main.rs` | App startup |
| `src/app.rs` | App state, keyboard input, add flow, launching |
| `src/ui.rs` | Ratatui layout and rendering |
| `src/launcher.rs` | Launcher model, auto-detection, config, stats, sorting |
| `src/terminal.rs` | Terminal setup/restore and shell command execution |
To add or remove auto-detected programs, edit `known_tui_programs()` in `src/launcher.rs`.
+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"),
}
}