Compare commits

..
11 Commits
Author SHA1 Message Date
cinco 4263f0379d 🐛 fix: handle space after colon in GitHub API JSON tag_name field 2026-03-01 19:59:12 -03:00
cinco a8f88e852c 🐛 fix: show GitHub API response when install.sh fails to fetch release
Remove -f from curl API call so HTTP errors are visible instead of
silently swallowed; print up to 400 bytes of the raw response to stderr.

Bumps version to 0.8.1.
2026-03-01 19:56:01 -03:00
cinco 0e2745acf1 ♻️ refactor: rename --create-desktop-shortcut to --setup-desktop-shortcut 2026-03-01 19:42:12 -03:00
cinco d7246298b1 feat: add desktop integration wizard and --create-desktop-shortcut
- install_desktop_integration_to() writes .desktop and downloads icon
- setup_desktop_integration_wizard() prompts and installs to XDG paths
- --create-desktop-shortcut flag to re-run at any time
- First-run wizard now also offers desktop integration after shortcut setup
- 164 tests passing
2026-03-01 19:40:15 -03:00
cinco 3aacf30697 feat: keyboard shortcut setup wizard (0.8.0)
- Add --setup-shortcut flag to configure a desktop keybinding
- Wizard runs automatically on first run after config creation
- Supports Hyprland (bindings.conf + hyprctl conflict check),
  GNOME (gsettings) and KDE (kglobalshortcutsrc)
- Hyprland: prefers omarchy-launch-tui, falls back to xdg-terminal-exec
- Conflict detection with fallback combo suggestions
- Add tmuxido.desktop and install icon + .desktop in install.sh
- 157 tests passing
2026-03-01 19:22:32 -03:00
cinco 906eec994f 📸 decrease canvas size in icon assets 2026-03-01 18:51:31 -03:00
cinco d0f6592729 📷 add tmuxido small icons 2026-03-01 18:41:12 -03:00
cinco 3f72128a25 🔖 chore: bump version to 0.7.1 and update CHANGELOG 2026-03-01 05:07:06 -03:00
cinco cc16cde540 📝 docs: add ASCII art previews for each available tmux layout in README 2026-03-01 05:07:03 -03:00
cinco db08840b64 🐛 fix: ask for layout in interactive wizard when window has multiple panes
Add `render_layout_prompt` and `parse_layout_input` to ui.rs so that
the first-run wizard asks the user to choose a tmux layout (1–5 or by
name) for each window that has 2 or more panes. Previously, layout was
always silently set to None.

Also update `render_config_created` to display the chosen layout in the
post-setup summary.

Closes: layout never being set during interactive setup
2026-03-01 05:07:00 -03:00
cinco 4ecdf96db8 📝 docs: update install URL to GitHub and fix default session description 2026-03-01 05:01:04 -03:00
17 changed files with 1703 additions and 17 deletions
+32
View File
@@ -4,6 +4,38 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [0.8.2] - 2026-03-01
### Fixed
- `install.sh`: grep pattern for `tag_name` now handles the space GitHub includes after the colon in JSON (`"tag_name": "x"` instead of `"tag_name":"x"`)
## [0.8.1] - 2026-03-01
### Fixed
- `install.sh`: removed `-f` flag from GitHub API `curl` call so HTTP error responses (rate limits, 404s) are printed instead of silently discarded; shows up to 400 bytes of the raw API response when the release tag cannot be parsed
## [0.8.0] - 2026-03-01
### Added
- Keyboard shortcut setup wizard on first run and via `tmuxido --setup-shortcut`
- Auto-detects desktop environment from `XDG_CURRENT_DESKTOP` / `HYPRLAND_INSTANCE_SIGNATURE`
- Hyprland: appends `bindd` entry to `~/.config/hypr/bindings.conf`; prefers `omarchy-launch-tui` when available, falls back to `xdg-terminal-exec`
- GNOME: registers a custom keybinding via `gsettings`
- KDE: appends a `[tmuxido]` section to `~/.config/kglobalshortcutsrc`
- Conflict detection per DE (Hyprland via `hyprctl binds -j`, KDE via config file, GNOME via gsettings); suggests next free combo from a fallback list
- `--setup-desktop-shortcut` flag to (re-)install the `.desktop` entry and icon at any time
- `shortcut` module (`src/shortcut.rs`) with full unit and integration test coverage
- Icon and `.desktop` file installed by `install.sh` and offered in the first-run wizard
## [0.7.1] - 2026-03-01
### Fixed
- Interactive setup wizard now asks for a tmux layout when a window has 2 or more panes
- Layout selection shown in post-wizard summary
### Changed
- README: Added ASCII art previews for each available tmux layout
## [0.7.0] - 2026-03-01 ## [0.7.0] - 2026-03-01
### Changed ### Changed
Generated
+1 -1
View File
@@ -864,7 +864,7 @@ dependencies = [
[[package]] [[package]]
name = "tmuxido" name = "tmuxido"
version = "0.7.0" version = "0.8.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "tmuxido" name = "tmuxido"
version = "0.7.0" version = "0.8.2"
edition = "2024" edition = "2024"
[dev-dependencies] [dev-dependencies]
+70 -7
View File
@@ -25,12 +25,13 @@ A Rust-based tool to quickly find and open projects in tmux using fzf. No extern
- Smart caching system for fast subsequent runs - Smart caching system for fast subsequent runs
- Configurable cache TTL - Configurable cache TTL
- Self-update capability (`tmuxido --update`) - Self-update capability (`tmuxido --update`)
- Keyboard shortcut setup for Hyprland, GNOME, and KDE (`tmuxido --setup-shortcut`)
- Zero external dependencies (except tmux and fzf) - Zero external dependencies (except tmux and fzf)
## Installation ## Installation
```sh ```sh
curl -fsSL https://git.cincoeuzebio.com/cinco/Tmuxido/raw/branch/main/install.sh | sh curl -fsSL https://raw.githubusercontent.com/cinco/tmuxido/main/install.sh | sh
``` ```
Installs the latest release binary to `~/.local/bin/tmuxido`. On first run, the config file is created automatically at `~/.config/tmuxido/tmuxido.toml`. Installs the latest release binary to `~/.local/bin/tmuxido`. On first run, the config file is created automatically at `~/.config/tmuxido/tmuxido.toml`.
@@ -106,6 +107,18 @@ Update tmuxido to the latest version:
tmuxido --update tmuxido --update
``` ```
Set up a desktop keyboard shortcut (Hyprland, GNOME, KDE):
```bash
tmuxido --setup-shortcut
```
Install the `.desktop` entry and icon (so tmuxido appears in app launchers like Walker/Rofi):
```bash
tmuxido --setup-desktop-shortcut
```
Both are also offered automatically on first run. Re-run them any time to reconfigure.
View help: View help:
```bash ```bash
tmuxido --help tmuxido --help
@@ -123,7 +136,7 @@ tmuxido --help
3. Presents them using fzf for selection 3. Presents them using fzf for selection
4. Creates or switches to a tmux session for the selected project 4. Creates or switches to a tmux session for the selected project
5. If a `.tmuxido.toml` config exists in the project, uses it to set up custom windows and panes 5. If a `.tmuxido.toml` config exists in the project, uses it to set up custom windows and panes
6. Otherwise, creates a default session with two windows: "editor" and "terminal" 6. Otherwise, uses the default session config from `~/.config/tmuxido/tmuxido.toml` (configured interactively on first run)
## Caching ## Caching
@@ -158,11 +171,61 @@ panes = []
### Available Layouts ### Available Layouts
- `main-horizontal` - Main pane on top, others below **`main-horizontal`** — Main pane on top, others below
- `main-vertical` - Main pane on left, others on right
- `tiled` - All panes tiled ```
- `even-horizontal` - All panes in horizontal row ┌──────────────────────┐
- `even-vertical` - All panes in vertical column │ │
│ main pane │
│ │
├──────────┬───────────┤
│ pane 2 │ pane 3 │
└──────────┴───────────┘
```
**`main-vertical`** — Main pane on left, others on right
```
┌─────────────┬────────┐
│ │ pane 2 │
│ main pane ├────────┤
│ │ pane 3 │
│ ├────────┤
│ │ pane 4 │
└─────────────┴────────┘
```
**`tiled`** — All panes tiled equally
```
┌───────────┬──────────┐
│ pane 1 │ pane 2 │
├───────────┼──────────┤
│ pane 3 │ pane 4 │
└───────────┴──────────┘
```
**`even-horizontal`** — All panes side by side
```
┌────────┬────────┬────────┐
│ │ │ │
│ pane 1 │ pane 2 │ pane 3 │
│ │ │ │
└────────┴────────┴────────┘
```
**`even-vertical`** — All panes stacked
```
┌──────────────────────┐
│ pane 1 │
├──────────────────────┤
│ pane 2 │
├──────────────────────┤
│ pane 3 │
└──────────────────────┘
```
### Panes ### Panes
Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+36 -5
View File
@@ -3,8 +3,11 @@ set -e
REPO="cinco/tmuxido" REPO="cinco/tmuxido"
BASE_URL="https://github.com" BASE_URL="https://github.com"
RAW_URL="https://raw.githubusercontent.com/$REPO/refs/heads/main"
API_URL="https://api.github.com" API_URL="https://api.github.com"
INSTALL_DIR="$HOME/.local/bin" INSTALL_DIR="$HOME/.local/bin"
ICON_DIR="$HOME/.local/share/icons/hicolor/96x96/apps"
DESKTOP_DIR="$HOME/.local/share/applications"
arch=$(uname -m) arch=$(uname -m)
case "$arch" in case "$arch" in
@@ -13,20 +16,48 @@ case "$arch" in
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;; *) echo "Unsupported architecture: $arch" >&2; exit 1 ;;
esac esac
tag=$(curl -fsSL \ api_resp=$(curl -sSL \
-H "Accept: application/vnd.github.v3+json" \ -H "Accept: application/vnd.github.v3+json" \
"$API_URL/repos/$REPO/releases/latest" \ "$API_URL/repos/$REPO/releases/latest")
| grep -o '"tag_name":"[^"]*"' | cut -d'"' -f4) tag=$(printf '%s' "$api_resp" | grep -o '"tag_name": *"[^"]*"' | grep -o '"[^"]*"$' | tr -d '"')
[ -z "$tag" ] && { echo "Could not fetch latest release" >&2; exit 1; } if [ -z "$tag" ]; then
echo "Could not fetch latest release." >&2
printf 'GitHub API response: %s\n' "$api_resp" | head -c 400 >&2
exit 1
fi
echo "Installing tmuxido $tag..." echo "Installing tmuxido $tag..."
# Binary
mkdir -p "$INSTALL_DIR" mkdir -p "$INSTALL_DIR"
curl -fsSL "$BASE_URL/$REPO/releases/download/$tag/$file" -o "$INSTALL_DIR/tmuxido" curl -fsSL "$BASE_URL/$REPO/releases/download/$tag/$file" -o "$INSTALL_DIR/tmuxido"
chmod +x "$INSTALL_DIR/tmuxido" chmod +x "$INSTALL_DIR/tmuxido"
echo "Installed: $INSTALL_DIR/tmuxido" echo " binary → $INSTALL_DIR/tmuxido"
# Icon (96×96)
mkdir -p "$ICON_DIR"
curl -fsSL "$RAW_URL/docs/assets/tmuxido-icon_96.png" -o "$ICON_DIR/tmuxido.png"
echo " icon → $ICON_DIR/tmuxido.png"
# .desktop entry
mkdir -p "$DESKTOP_DIR"
curl -fsSL "$RAW_URL/tmuxido.desktop" -o "$DESKTOP_DIR/tmuxido.desktop"
echo " desktop → $DESKTOP_DIR/tmuxido.desktop"
# Refresh desktop database if available
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true
fi
# Refresh icon cache if available
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
gtk-update-icon-cache -f -t "$HOME/.local/share/icons/hicolor" 2>/dev/null || true
fi
case ":$PATH:" in case ":$PATH:" in
*":$INSTALL_DIR:"*) ;; *":$INSTALL_DIR:"*) ;;
*) echo "Note: add $INSTALL_DIR to your PATH (e.g. export PATH=\"\$HOME/.local/bin:\$PATH\")" ;; *) echo "Note: add $INSTALL_DIR to your PATH (e.g. export PATH=\"\$HOME/.local/bin:\$PATH\")" ;;
esac esac
echo "Done! Run 'tmuxido' to get started."
+31 -2
View File
@@ -125,6 +125,16 @@ impl Config {
fs::write(&config_path, toml_string).with_context(|| { fs::write(&config_path, toml_string).with_context(|| {
format!("Failed to write config file: {}", config_path.display()) format!("Failed to write config file: {}", config_path.display())
})?; })?;
// Offer to set up a keyboard shortcut (best-effort, non-fatal)
if let Err(e) = crate::shortcut::setup_shortcut_wizard() {
eprintln!("Warning: shortcut setup failed: {}", e);
}
// Offer to install .desktop entry + icon (best-effort, non-fatal)
if let Err(e) = crate::shortcut::setup_desktop_integration_wizard() {
eprintln!("Warning: desktop integration failed: {}", e);
}
} }
Ok(config_path) Ok(config_path)
@@ -181,14 +191,19 @@ impl Config {
window_names window_names
}; };
// Configure panes for each window // Configure panes and layout for each window
let mut windows = Vec::new(); let mut windows = Vec::new();
for name in names { for name in names {
let panes = Self::prompt_for_panes(&name)?; let panes = Self::prompt_for_panes(&name)?;
let layout = if panes.len() > 1 {
ui::render_layout_prompt(&name, panes.len())?
} else {
None
};
windows.push(crate::session::Window { windows.push(crate::session::Window {
name, name,
panes, panes,
layout: None, layout,
}); });
} }
@@ -348,6 +363,20 @@ mod tests {
assert_eq!(result, vec!["editor", "terminal", "server"]); assert_eq!(result, vec!["editor", "terminal", "server"]);
} }
#[test]
fn should_use_ui_parse_functions_for_layout() {
assert_eq!(ui::parse_layout_input(""), None);
assert_eq!(
ui::parse_layout_input("1"),
Some("main-horizontal".to_string())
);
assert_eq!(
ui::parse_layout_input("main-vertical"),
Some("main-vertical".to_string())
);
assert_eq!(ui::parse_layout_input("invalid"), None);
}
#[test] #[test]
fn should_parse_config_with_windows_and_panes() { fn should_parse_config_with_windows_and_panes() {
let toml_str = r#" let toml_str = r#"
+9
View File
@@ -3,6 +3,7 @@ pub mod config;
pub mod deps; pub mod deps;
pub mod self_update; pub mod self_update;
pub mod session; pub mod session;
pub mod shortcut;
pub mod ui; pub mod ui;
pub mod update_check; pub mod update_check;
@@ -15,6 +16,14 @@ use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH; use std::time::UNIX_EPOCH;
use walkdir::WalkDir; use walkdir::WalkDir;
pub fn setup_shortcut_wizard() -> Result<()> {
shortcut::setup_shortcut_wizard()
}
pub fn setup_desktop_integration_wizard() -> Result<()> {
shortcut::setup_desktop_integration_wizard()
}
pub fn show_cache_status(config: &Config) -> Result<()> { pub fn show_cache_status(config: &Config) -> Result<()> {
if !config.cache_enabled { if !config.cache_enabled {
println!("Cache is disabled in configuration"); println!("Cache is disabled in configuration");
+22 -1
View File
@@ -7,7 +7,10 @@ use tmuxido::config::Config;
use tmuxido::deps::ensure_dependencies; use tmuxido::deps::ensure_dependencies;
use tmuxido::self_update; use tmuxido::self_update;
use tmuxido::update_check; use tmuxido::update_check;
use tmuxido::{get_projects, launch_tmux_session, show_cache_status}; use tmuxido::{
get_projects, launch_tmux_session, setup_desktop_integration_wizard, setup_shortcut_wizard,
show_cache_status,
};
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command( #[command(
@@ -30,6 +33,14 @@ struct Args {
/// Update tmuxido to the latest version /// Update tmuxido to the latest version
#[arg(long)] #[arg(long)]
update: bool, update: bool,
/// Set up a keyboard shortcut to launch tmuxido
#[arg(long)]
setup_shortcut: bool,
/// Install the .desktop entry and icon for app launcher integration
#[arg(long)]
setup_desktop_shortcut: bool,
} }
fn main() -> Result<()> { fn main() -> Result<()> {
@@ -40,6 +51,16 @@ fn main() -> Result<()> {
return self_update::self_update(); return self_update::self_update();
} }
// Handle standalone shortcut setup
if args.setup_shortcut {
return setup_shortcut_wizard();
}
// Handle standalone desktop integration setup
if args.setup_desktop_shortcut {
return setup_desktop_integration_wizard();
}
// Check that fzf and tmux are installed; offer to install if missing // Check that fzf and tmux are installed; offer to install if missing
ensure_dependencies()?; ensure_dependencies()?;
+984
View File
@@ -0,0 +1,984 @@
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
/// Desktop environment variants we support
#[derive(Debug, PartialEq, Clone)]
pub enum DesktopEnv {
Hyprland,
Gnome,
Kde,
Unknown,
}
impl std::fmt::Display for DesktopEnv {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DesktopEnv::Hyprland => write!(f, "Hyprland"),
DesktopEnv::Gnome => write!(f, "GNOME"),
DesktopEnv::Kde => write!(f, "KDE"),
DesktopEnv::Unknown => write!(f, "Unknown"),
}
}
}
/// A keyboard shortcut combo (modifiers + key), stored in uppercase internally
#[derive(Debug, Clone, PartialEq)]
pub struct KeyCombo {
pub modifiers: Vec<String>,
pub key: String,
}
impl KeyCombo {
/// Parse input like "Super+Shift+T", "super+shift+t", "SUPER+SHIFT+T"
pub fn parse(input: &str) -> Option<Self> {
let trimmed = input.trim();
if trimmed.is_empty() {
return None;
}
let parts: Vec<&str> = trimmed.split('+').collect();
if parts.len() < 2 {
return None;
}
let key = parts.last()?.trim().to_uppercase();
if key.is_empty() {
return None;
}
let modifiers: Vec<String> = parts[..parts.len() - 1]
.iter()
.map(|s| s.trim().to_uppercase())
.filter(|s| !s.is_empty())
.collect();
if modifiers.is_empty() {
return None;
}
Some(KeyCombo { modifiers, key })
}
/// Format for Hyprland binding: "SUPER SHIFT, T"
pub fn to_hyprland(&self) -> String {
let mods = self.modifiers.join(" ");
format!("{}, {}", mods, self.key)
}
/// Format for GNOME gsettings: "<Super><Shift>t"
pub fn to_gnome(&self) -> String {
let mods: String = self
.modifiers
.iter()
.map(|m| {
let mut chars = m.chars();
let capitalized = match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().to_string() + &chars.as_str().to_lowercase(),
};
format!("<{}>", capitalized)
})
.collect();
format!("{}{}", mods, self.key.to_lowercase())
}
/// Format for KDE kglobalshortcutsrc: "Meta+Shift+T"
pub fn to_kde(&self) -> String {
let mut parts: Vec<String> = self
.modifiers
.iter()
.map(|m| match m.as_str() {
"SUPER" | "WIN" | "META" => "Meta".to_string(),
other => {
let mut chars = other.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().to_string() + &chars.as_str().to_lowercase(),
}
}
})
.collect();
parts.push(self.key.clone());
parts.join("+")
}
/// Normalized string for dedup/comparison (uppercase, +separated)
pub fn normalized(&self) -> String {
let mut parts = self.modifiers.clone();
parts.push(self.key.clone());
parts.join("+")
}
}
impl std::fmt::Display for KeyCombo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let parts: Vec<String> = self
.modifiers
.iter()
.map(|m| {
let mut chars = m.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().to_string() + &chars.as_str().to_lowercase(),
}
})
.chain(std::iter::once(self.key.clone()))
.collect();
write!(f, "{}", parts.join("+"))
}
}
// ============================================================================
// Detection
// ============================================================================
/// Detect the current desktop environment from environment variables
pub fn detect_desktop() -> DesktopEnv {
let xdg = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
let has_hyprland_sig = std::env::var("HYPRLAND_INSTANCE_SIGNATURE").is_ok();
detect_from(&xdg, has_hyprland_sig)
}
fn detect_from(xdg: &str, has_hyprland_sig: bool) -> DesktopEnv {
let xdg_lower = xdg.to_lowercase();
if xdg_lower.contains("hyprland") || has_hyprland_sig {
DesktopEnv::Hyprland
} else if xdg_lower.contains("gnome") {
DesktopEnv::Gnome
} else if xdg_lower.contains("kde") || xdg_lower.contains("plasma") {
DesktopEnv::Kde
} else {
DesktopEnv::Unknown
}
}
// ============================================================================
// Hyprland
// ============================================================================
/// Path to the Hyprland bindings config file
pub fn hyprland_bindings_path() -> Result<PathBuf> {
let config_dir = dirs::config_dir().context("Could not determine config directory")?;
Ok(config_dir.join("hypr").join("bindings.conf"))
}
/// Calculate Hyprland modmask bitmask for a key combo
fn hyprland_modmask(combo: &KeyCombo) -> u32 {
let mut mask = 0u32;
for modifier in &combo.modifiers {
mask |= match modifier.as_str() {
"SHIFT" => 1,
"CAPS" => 2,
"CTRL" | "CONTROL" => 4,
"ALT" => 8,
"MOD2" => 16,
"MOD3" => 32,
"SUPER" | "WIN" | "META" => 64,
"MOD5" => 128,
_ => 0,
};
}
mask
}
/// Check if a key combo is already bound in Hyprland via `hyprctl binds -j`.
/// Returns `Some(description)` if a conflict is found, `None` otherwise.
pub fn check_hyprland_conflict(combo: &KeyCombo) -> Option<String> {
let output = std::process::Command::new("hyprctl")
.args(["binds", "-j"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let json_str = String::from_utf8(output.stdout).ok()?;
let binds: Vec<serde_json::Value> = serde_json::from_str(&json_str).ok()?;
let target_modmask = hyprland_modmask(combo);
let target_key = combo.key.to_lowercase();
for bind in &binds {
let modmask = bind["modmask"].as_u64()? as u32;
let key = bind["key"].as_str()?.to_lowercase();
if modmask == target_modmask && key == target_key {
let description = if bind["has_description"].as_bool().unwrap_or(false) {
bind["description"]
.as_str()
.unwrap_or("unknown")
.to_string()
} else {
bind["dispatcher"].as_str().unwrap_or("unknown").to_string()
};
return Some(description);
}
}
None
}
/// Determine the best launch command for Hyprland (prefers omarchy if available)
fn hyprland_launch_command() -> String {
let available = std::process::Command::new("sh")
.args(["-c", "command -v omarchy-launch-tui"])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if available {
"omarchy-launch-tui tmuxido".to_string()
} else {
"xdg-terminal-exec -e tmuxido".to_string()
}
}
/// Write a `bindd` entry to the Hyprland bindings file.
/// Skips if any line already contains "tmuxido".
pub fn write_hyprland_binding(path: &Path, combo: &KeyCombo) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
}
if path.exists() {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
if content.lines().any(|l| l.contains("tmuxido")) {
return Ok(());
}
}
let launch_cmd = hyprland_launch_command();
let line = format!(
"bindd = {}, Tmuxido, exec, {}\n",
combo.to_hyprland(),
launch_cmd
);
use std::fs::OpenOptions;
use std::io::Write;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.with_context(|| format!("Failed to open {}", path.display()))?;
file.write_all(line.as_bytes())
.with_context(|| format!("Failed to write to {}", path.display()))?;
Ok(())
}
// ============================================================================
// GNOME
// ============================================================================
/// Check if a combo conflicts with existing GNOME custom keybindings.
/// Returns `Some(name)` on conflict, `None` otherwise.
pub fn check_gnome_conflict(combo: &KeyCombo) -> Option<String> {
let gnome_binding = combo.to_gnome();
let output = std::process::Command::new("gsettings")
.args([
"get",
"org.gnome.settings-daemon.plugins.media-keys",
"custom-keybindings",
])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let list_str = String::from_utf8(output.stdout).ok()?;
let paths = parse_gsettings_list(&list_str);
for path in &paths {
let binding = run_gsettings_custom(path, "binding")?;
if binding.trim_matches('\'') == gnome_binding {
let name = run_gsettings_custom(path, "name").unwrap_or_else(|| "unknown".to_string());
return Some(name.trim_matches('\'').to_string());
}
}
None
}
fn run_gsettings_custom(path: &str, key: &str) -> Option<String> {
let schema = format!(
"org.gnome.settings-daemon.plugins.media-keys.custom-keybindings:{}",
path
);
let output = std::process::Command::new("gsettings")
.args(["get", &schema, key])
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8(output.stdout).ok()?.trim().to_string())
}
/// Parse gsettings list format `['path1', 'path2']` into a vec of path strings.
/// Also handles the GVariant empty-array notation `@as []`.
fn parse_gsettings_list(input: &str) -> Vec<String> {
let s = input.trim();
// Strip GVariant type hint if present: "@as [...]" → "[...]"
let s = s.strip_prefix("@as").map(|r| r.trim()).unwrap_or(s);
let inner = s.trim_start_matches('[').trim_end_matches(']').trim();
if inner.is_empty() {
return Vec::new();
}
inner
.split(',')
.map(|s| s.trim().trim_matches('\'').to_string())
.filter(|s| !s.is_empty())
.collect()
}
/// Write a GNOME custom keybinding using `gsettings`
pub fn write_gnome_shortcut(combo: &KeyCombo) -> Result<()> {
let base_schema = "org.gnome.settings-daemon.plugins.media-keys";
let base_path = "/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings";
let output = std::process::Command::new("gsettings")
.args(["get", base_schema, "custom-keybindings"])
.output()
.context("Failed to run gsettings")?;
let current_list = if output.status.success() {
String::from_utf8(output.stdout)?.trim().to_string()
} else {
"@as []".to_string()
};
let existing = parse_gsettings_list(&current_list);
// Find next available slot number
let slot = (0..)
.find(|n| {
let candidate = format!("{}/custom{}/", base_path, n);
!existing.contains(&candidate)
})
.expect("slot number is always findable");
let slot_path = format!("{}/custom{}/", base_path, slot);
let slot_schema = format!(
"org.gnome.settings-daemon.plugins.media-keys.custom-keybindings:{}",
slot_path
);
let mut new_list = existing.clone();
new_list.push(slot_path.clone());
let list_value = format!(
"[{}]",
new_list
.iter()
.map(|s| format!("'{}'", s))
.collect::<Vec<_>>()
.join(", ")
);
std::process::Command::new("gsettings")
.args(["set", &slot_schema, "name", "Tmuxido"])
.status()
.context("Failed to set GNOME shortcut name")?;
std::process::Command::new("gsettings")
.args(["set", &slot_schema, "binding", &combo.to_gnome()])
.status()
.context("Failed to set GNOME shortcut binding")?;
std::process::Command::new("gsettings")
.args([
"set",
&slot_schema,
"command",
"xdg-terminal-exec -e tmuxido",
])
.status()
.context("Failed to set GNOME shortcut command")?;
std::process::Command::new("gsettings")
.args(["set", base_schema, "custom-keybindings", &list_value])
.status()
.context("Failed to update GNOME custom keybindings list")?;
Ok(())
}
// ============================================================================
// KDE
// ============================================================================
/// Path to the KDE global shortcuts config file
pub fn kde_shortcuts_path() -> Result<PathBuf> {
let config_dir = dirs::config_dir().context("Could not determine config directory")?;
Ok(config_dir.join("kglobalshortcutsrc"))
}
/// Check if a key combo is already bound in `kglobalshortcutsrc`.
/// Returns `Some(section_name)` on conflict, `None` otherwise.
pub fn check_kde_conflict(path: &Path, combo: &KeyCombo) -> Option<String> {
if !path.exists() {
return None;
}
let content = std::fs::read_to_string(path).ok()?;
let kde_combo = combo.to_kde();
let mut current_section = String::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') && trimmed.ends_with(']') {
current_section = trimmed[1..trimmed.len() - 1].to_string();
continue;
}
if let Some(eq_pos) = trimmed.find('=') {
let value = &trimmed[eq_pos + 1..];
// Format: Action=Binding,AlternativeKey,Description
if let Some(binding) = value.split(',').next()
&& binding == kde_combo
{
return Some(current_section.clone());
}
}
}
None
}
/// Write a KDE global shortcut entry to `kglobalshortcutsrc`.
/// Skips if `[tmuxido]` section already exists.
pub fn write_kde_shortcut(path: &Path, combo: &KeyCombo) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
}
if path.exists() {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
if content.contains("[tmuxido]") {
return Ok(());
}
}
let entry = format!(
"\n[tmuxido]\nLaunch Tmuxido={},none,Launch Tmuxido\n",
combo.to_kde()
);
use std::fs::OpenOptions;
use std::io::Write;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.with_context(|| format!("Failed to open {}", path.display()))?;
file.write_all(entry.as_bytes())
.with_context(|| format!("Failed to write to {}", path.display()))?;
Ok(())
}
// ============================================================================
// Fallback combos and conflict resolution
// ============================================================================
const FALLBACK_COMBOS: &[&str] = &[
"Super+Shift+T",
"Super+Shift+P",
"Super+Ctrl+T",
"Super+Alt+T",
"Super+Shift+M",
"Super+Ctrl+P",
];
/// Find the first free combo from the fallback list, skipping those in `taken`.
/// `taken` should contain normalized combo strings (uppercase, `+`-separated).
pub fn find_free_combo(taken: &[String]) -> Option<KeyCombo> {
FALLBACK_COMBOS.iter().find_map(|s| {
let combo = KeyCombo::parse(s)?;
if taken.contains(&combo.normalized()) {
None
} else {
Some(combo)
}
})
}
// ============================================================================
// Main wizard
// ============================================================================
pub fn setup_shortcut_wizard() -> Result<()> {
let de = detect_desktop();
crate::ui::render_section_header("Keyboard Shortcut");
if de == DesktopEnv::Unknown {
crate::ui::render_shortcut_unknown_de();
return Ok(());
}
println!(" Detected desktop environment: {}", de);
if !crate::ui::render_shortcut_setup_prompt()? {
return Ok(());
}
let combo = loop {
let input = crate::ui::render_key_combo_prompt("Super+Shift+T")?;
let raw = if input.is_empty() {
"Super+Shift+T".to_string()
} else {
input
};
if let Some(c) = KeyCombo::parse(&raw) {
break c;
}
println!(" Invalid key combo. Use format like 'Super+Shift+T'");
};
let conflict = match &de {
DesktopEnv::Hyprland => check_hyprland_conflict(&combo),
DesktopEnv::Gnome => check_gnome_conflict(&combo),
DesktopEnv::Kde => {
let path = kde_shortcuts_path()?;
check_kde_conflict(&path, &combo)
}
DesktopEnv::Unknown => unreachable!(),
};
let final_combo = if let Some(taken_by) = conflict {
let taken_normalized = vec![combo.normalized()];
if let Some(suggestion) = find_free_combo(&taken_normalized) {
let use_suggestion = crate::ui::render_shortcut_conflict_prompt(
&combo.to_string(),
&taken_by,
&suggestion.to_string(),
)?;
if use_suggestion {
suggestion
} else {
println!(" Run 'tmuxido --setup-shortcut' again to choose a different combo.");
return Ok(());
}
} else {
println!(
" All fallback combos are taken. Run 'tmuxido --setup-shortcut' with a custom combo."
);
return Ok(());
}
} else {
combo
};
let (details, reload_hint) = match &de {
DesktopEnv::Hyprland => {
let path = hyprland_bindings_path()?;
write_hyprland_binding(&path, &final_combo)?;
(
format!("Added to {}", path.display()),
"Reload Hyprland with Super+Shift+R to activate.".to_string(),
)
}
DesktopEnv::Gnome => {
write_gnome_shortcut(&final_combo)?;
(
"Added to GNOME custom keybindings.".to_string(),
"The shortcut is active immediately.".to_string(),
)
}
DesktopEnv::Kde => {
let path = kde_shortcuts_path()?;
write_kde_shortcut(&path, &final_combo)?;
(
format!("Added to {}", path.display()),
"Log out and back in to activate the shortcut.".to_string(),
)
}
DesktopEnv::Unknown => unreachable!(),
};
crate::ui::render_shortcut_success(
&de.to_string(),
&final_combo.to_string(),
&details,
&reload_hint,
);
Ok(())
}
// ============================================================================
// Desktop integration (.desktop file + icon)
// ============================================================================
const ICON_URL: &str = "https://raw.githubusercontent.com/cinco/tmuxido/refs/heads/main/docs/assets/tmuxido-icon_96.png";
const DESKTOP_CONTENT: &str = "[Desktop Entry]
Name=Tmuxido
Comment=Quickly find and open projects in tmux
Exec=tmuxido
Icon=tmuxido
Type=Application
Categories=Development;Utility;
Terminal=true
Keywords=tmux;project;fzf;dev;
StartupWMClass=tmuxido
";
/// Path where the .desktop entry will be installed
pub fn desktop_file_path() -> Result<PathBuf> {
let data_dir = dirs::data_dir().context("Could not determine data directory")?;
Ok(data_dir.join("applications").join("tmuxido.desktop"))
}
/// Path where the 96×96 icon will be installed
pub fn icon_install_path() -> Result<PathBuf> {
let data_dir = dirs::data_dir().context("Could not determine data directory")?;
Ok(data_dir
.join("icons")
.join("hicolor")
.join("96x96")
.join("apps")
.join("tmuxido.png"))
}
/// Result of a desktop integration install
pub struct DesktopInstallResult {
pub desktop_path: PathBuf,
pub icon_path: PathBuf,
pub icon_downloaded: bool,
}
/// Write the .desktop file and download the icon to the given paths.
/// Icon download is best-effort — does not fail if curl or network is unavailable.
pub fn install_desktop_integration_to(
desktop_path: &Path,
icon_path: &Path,
) -> Result<DesktopInstallResult> {
// Write .desktop
if let Some(parent) = desktop_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {}", parent.display()))?;
}
std::fs::write(desktop_path, DESKTOP_CONTENT)
.with_context(|| format!("Failed to write {}", desktop_path.display()))?;
// Download icon (best-effort via curl)
let icon_downloaded = (|| -> Option<()> {
if let Some(parent) = icon_path.parent() {
std::fs::create_dir_all(parent).ok()?;
}
std::process::Command::new("curl")
.args(["-fsSL", ICON_URL, "-o", &icon_path.to_string_lossy()])
.status()
.ok()?
.success()
.then_some(())
})()
.is_some();
// Refresh desktop database (best-effort)
if let Some(apps_dir) = desktop_path.parent() {
let _ = std::process::Command::new("update-desktop-database")
.arg(apps_dir)
.status();
}
// Refresh icon cache (best-effort)
if icon_downloaded {
// Navigate up from …/96x96/apps → …/icons/hicolor
let hicolor_dir = icon_path
.parent()
.and_then(|p| p.parent())
.and_then(|p| p.parent());
if let Some(dir) = hicolor_dir {
let _ = std::process::Command::new("gtk-update-icon-cache")
.args(["-f", "-t", &dir.to_string_lossy()])
.status();
}
}
Ok(DesktopInstallResult {
desktop_path: desktop_path.to_path_buf(),
icon_path: icon_path.to_path_buf(),
icon_downloaded,
})
}
/// Install .desktop and icon to the standard XDG locations
pub fn install_desktop_integration() -> Result<DesktopInstallResult> {
install_desktop_integration_to(&desktop_file_path()?, &icon_install_path()?)
}
/// Interactive wizard that asks the user and then installs desktop integration
pub fn setup_desktop_integration_wizard() -> Result<()> {
crate::ui::render_section_header("Desktop Integration");
if !crate::ui::render_desktop_integration_prompt()? {
return Ok(());
}
let result = install_desktop_integration()?;
crate::ui::render_desktop_integration_success(&result);
Ok(())
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
// --- detect_desktop ---
#[test]
fn should_detect_hyprland_from_xdg_var() {
assert_eq!(detect_from("Hyprland", false), DesktopEnv::Hyprland);
assert_eq!(detect_from("hyprland", false), DesktopEnv::Hyprland);
assert_eq!(detect_from("HYPRLAND", false), DesktopEnv::Hyprland);
}
#[test]
fn should_detect_hyprland_from_signature_even_without_xdg() {
assert_eq!(detect_from("", true), DesktopEnv::Hyprland);
assert_eq!(detect_from("somethingelse", true), DesktopEnv::Hyprland);
}
#[test]
fn should_detect_gnome() {
assert_eq!(detect_from("GNOME", false), DesktopEnv::Gnome);
assert_eq!(detect_from("gnome", false), DesktopEnv::Gnome);
assert_eq!(detect_from("ubuntu:GNOME", false), DesktopEnv::Gnome);
}
#[test]
fn should_detect_kde_from_kde_xdg() {
assert_eq!(detect_from("KDE", false), DesktopEnv::Kde);
assert_eq!(detect_from("kde", false), DesktopEnv::Kde);
}
#[test]
fn should_detect_kde_from_plasma_xdg() {
assert_eq!(detect_from("Plasma", false), DesktopEnv::Kde);
assert_eq!(detect_from("plasma", false), DesktopEnv::Kde);
}
#[test]
fn should_return_unknown_for_unrecognized_de() {
assert_eq!(detect_from("", false), DesktopEnv::Unknown);
assert_eq!(detect_from("i3", false), DesktopEnv::Unknown);
assert_eq!(detect_from("sway", false), DesktopEnv::Unknown);
}
// --- KeyCombo::parse ---
#[test]
fn should_parse_title_case_combo() {
let c = KeyCombo::parse("Super+Shift+T").unwrap();
assert_eq!(c.modifiers, vec!["SUPER", "SHIFT"]);
assert_eq!(c.key, "T");
}
#[test]
fn should_parse_lowercase_combo() {
let c = KeyCombo::parse("super+shift+t").unwrap();
assert_eq!(c.modifiers, vec!["SUPER", "SHIFT"]);
assert_eq!(c.key, "T");
}
#[test]
fn should_parse_uppercase_combo() {
let c = KeyCombo::parse("SUPER+SHIFT+T").unwrap();
assert_eq!(c.modifiers, vec!["SUPER", "SHIFT"]);
assert_eq!(c.key, "T");
}
#[test]
fn should_parse_three_modifier_combo() {
let c = KeyCombo::parse("Super+Ctrl+Alt+F").unwrap();
assert_eq!(c.modifiers, vec!["SUPER", "CTRL", "ALT"]);
assert_eq!(c.key, "F");
}
#[test]
fn should_return_none_for_key_only() {
assert!(KeyCombo::parse("T").is_none());
}
#[test]
fn should_return_none_for_empty_input() {
assert!(KeyCombo::parse("").is_none());
assert!(KeyCombo::parse(" ").is_none());
}
#[test]
fn should_trim_whitespace_in_parts() {
let c = KeyCombo::parse(" Super + Shift + T ").unwrap();
assert_eq!(c.modifiers, vec!["SUPER", "SHIFT"]);
assert_eq!(c.key, "T");
}
// --- KeyCombo formatting ---
#[test]
fn should_format_for_hyprland() {
let c = KeyCombo::parse("Super+Shift+T").unwrap();
assert_eq!(c.to_hyprland(), "SUPER SHIFT, T");
}
#[test]
fn should_format_single_modifier_for_hyprland() {
let c = KeyCombo::parse("Super+T").unwrap();
assert_eq!(c.to_hyprland(), "SUPER, T");
}
#[test]
fn should_format_for_gnome() {
let c = KeyCombo::parse("Super+Shift+T").unwrap();
assert_eq!(c.to_gnome(), "<Super><Shift>t");
}
#[test]
fn should_format_ctrl_for_gnome() {
let c = KeyCombo::parse("Super+Ctrl+P").unwrap();
assert_eq!(c.to_gnome(), "<Super><Ctrl>p");
}
#[test]
fn should_format_for_kde() {
let c = KeyCombo::parse("Super+Shift+T").unwrap();
assert_eq!(c.to_kde(), "Meta+Shift+T");
}
#[test]
fn should_map_super_to_meta_for_kde() {
let c = KeyCombo::parse("Super+Ctrl+P").unwrap();
assert_eq!(c.to_kde(), "Meta+Ctrl+P");
}
#[test]
fn should_display_in_title_case() {
let c = KeyCombo::parse("SUPER+SHIFT+T").unwrap();
assert_eq!(c.to_string(), "Super+Shift+T");
}
// --- hyprland_modmask ---
#[test]
fn should_calculate_modmask_for_super_shift() {
let c = KeyCombo::parse("Super+Shift+T").unwrap();
assert_eq!(hyprland_modmask(&c), 64 + 1); // SUPER=64, SHIFT=1
}
#[test]
fn should_calculate_modmask_for_super_only() {
let c = KeyCombo::parse("Super+T").unwrap();
assert_eq!(hyprland_modmask(&c), 64);
}
#[test]
fn should_calculate_modmask_for_ctrl_alt() {
let c = KeyCombo::parse("Ctrl+Alt+T").unwrap();
assert_eq!(hyprland_modmask(&c), 4 + 8); // CTRL=4, ALT=8
}
// --- find_free_combo ---
#[test]
fn should_return_first_fallback_when_nothing_taken() {
let combo = find_free_combo(&[]).unwrap();
assert_eq!(combo.normalized(), "SUPER+SHIFT+T");
}
#[test]
fn should_skip_taken_combos() {
let taken = vec!["SUPER+SHIFT+T".to_string(), "SUPER+SHIFT+P".to_string()];
let combo = find_free_combo(&taken).unwrap();
assert_eq!(combo.normalized(), "SUPER+CTRL+T");
}
#[test]
fn should_return_none_when_all_fallbacks_taken() {
let taken: Vec<String> = FALLBACK_COMBOS
.iter()
.map(|s| KeyCombo::parse(s).unwrap().normalized())
.collect();
assert!(find_free_combo(&taken).is_none());
}
// --- parse_gsettings_list ---
#[test]
fn should_parse_empty_gsettings_list() {
assert!(parse_gsettings_list("[]").is_empty());
assert!(parse_gsettings_list("@as []").is_empty());
assert!(parse_gsettings_list(" [ ] ").is_empty());
}
#[test]
fn should_parse_gsettings_list_with_one_entry() {
let result =
parse_gsettings_list("['/org/gnome/settings-daemon/plugins/media-keys/custom0/']");
assert_eq!(
result,
vec!["/org/gnome/settings-daemon/plugins/media-keys/custom0/"]
);
}
#[test]
fn should_parse_gsettings_list_with_multiple_entries() {
let result = parse_gsettings_list("['/org/gnome/.../custom0/', '/org/gnome/.../custom1/']");
assert_eq!(result.len(), 2);
assert_eq!(result[0], "/org/gnome/.../custom0/");
assert_eq!(result[1], "/org/gnome/.../custom1/");
}
// --- check_kde_conflict ---
#[test]
fn should_return_none_when_kde_file_missing() {
let combo = KeyCombo::parse("Super+Shift+T").unwrap();
assert!(check_kde_conflict(Path::new("/nonexistent/path"), &combo).is_none());
}
// --- normalized ---
#[test]
fn should_normalize_to_uppercase_plus_separated() {
let c = KeyCombo::parse("super+shift+t").unwrap();
assert_eq!(c.normalized(), "SUPER+SHIFT+T");
}
// --- desktop integration ---
#[test]
fn should_write_desktop_file_to_given_path() {
let dir = tempfile::tempdir().unwrap();
let desktop = dir.path().join("apps").join("tmuxido.desktop");
let icon = dir.path().join("icons").join("tmuxido.png");
let result = install_desktop_integration_to(&desktop, &icon).unwrap();
assert!(result.desktop_path.exists());
let content = std::fs::read_to_string(&result.desktop_path).unwrap();
assert!(content.contains("[Desktop Entry]"));
assert!(content.contains("Exec=tmuxido"));
assert!(content.contains("Icon=tmuxido"));
assert!(content.contains("Terminal=true"));
}
#[test]
fn should_create_parent_directories_for_desktop_file() {
let dir = tempfile::tempdir().unwrap();
let desktop = dir
.path()
.join("nested")
.join("apps")
.join("tmuxido.desktop");
let icon = dir.path().join("icons").join("tmuxido.png");
install_desktop_integration_to(&desktop, &icon).unwrap();
assert!(desktop.exists());
}
#[test]
fn desktop_content_contains_required_fields() {
assert!(DESKTOP_CONTENT.contains("[Desktop Entry]"));
assert!(DESKTOP_CONTENT.contains("Name=Tmuxido"));
assert!(DESKTOP_CONTENT.contains("Exec=tmuxido"));
assert!(DESKTOP_CONTENT.contains("Icon=tmuxido"));
assert!(DESKTOP_CONTENT.contains("Type=Application"));
assert!(DESKTOP_CONTENT.contains("Terminal=true"));
assert!(DESKTOP_CONTENT.contains("StartupWMClass=tmuxido"));
}
}
+342
View File
@@ -133,6 +133,12 @@ pub fn render_config_created(
println!("{}", label_style.render(" 🪟 Default Windows:")); println!("{}", label_style.render(" 🪟 Default Windows:"));
for window in windows { for window in windows {
println!(" {}", window_style.render(&format!("{}", window.name))); println!(" {}", window_style.render(&format!("{}", window.name)));
if let Some(layout) = &window.layout {
println!(
"{}",
info_style.render(&format!(" └─ layout: {}", layout))
);
}
if !window.panes.is_empty() { if !window.panes.is_empty() {
for (i, pane) in window.panes.iter().enumerate() { for (i, pane) in window.panes.iter().enumerate() {
let pane_display = if pane.is_empty() { let pane_display = if pane.is_empty() {
@@ -299,6 +305,67 @@ pub fn render_panes_prompt(window_name: &str) -> Result<String> {
Ok(input.trim().to_string()) Ok(input.trim().to_string())
} }
/// Renders a prompt for the layout of a window with multiple panes
pub fn render_layout_prompt(window_name: &str, pane_count: usize) -> Result<Option<String>> {
let prompt_style = Style::new().bold(true).foreground(color_green());
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
let window_style = Style::new().bold(true).foreground(color_purple());
let label_style = Style::new().foreground(color_blue());
println!();
println!(
" Layout for window {} ({} panes):",
window_style.render(window_name),
label_style.render(&pane_count.to_string())
);
println!(
"{}",
hint_style.render(" Choose a pane layout (leave empty for no layout):")
);
println!(
"{}",
hint_style.render(" 1. main-horizontal — main pane on top, others below")
);
println!(
"{}",
hint_style.render(" 2. main-vertical — main pane on left, others on right")
);
println!(
"{}",
hint_style.render(" 3. tiled — all panes tiled equally")
);
println!(
"{}",
hint_style.render(" 4. even-horizontal — all panes side by side")
);
println!(
"{}",
hint_style.render(" 5. even-vertical — all panes stacked vertically")
);
print!(" {} ", prompt_style.render(" Layout (1-5 or name):"));
io::stdout().flush().context("Failed to flush stdout")?;
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.context("Failed to read input")?;
Ok(parse_layout_input(input.trim()))
}
/// Parse layout input: accepts number (1-5) or layout name; returns None for empty/invalid
pub fn parse_layout_input(input: &str) -> Option<String> {
match input.trim() {
"" => None,
"1" | "main-horizontal" => Some("main-horizontal".to_string()),
"2" | "main-vertical" => Some("main-vertical".to_string()),
"3" | "tiled" => Some("tiled".to_string()),
"4" | "even-horizontal" => Some("even-horizontal".to_string()),
"5" | "even-vertical" => Some("even-vertical".to_string()),
_ => None,
}
}
/// Renders a prompt for a pane command /// Renders a prompt for a pane command
pub fn render_pane_command_prompt(pane_name: &str) -> Result<String> { pub fn render_pane_command_prompt(pane_name: &str) -> Result<String> {
let prompt_style = Style::new().bold(true).foreground(color_green()); let prompt_style = Style::new().bold(true).foreground(color_green());
@@ -358,6 +425,194 @@ pub fn parse_cache_ttl_input(input: &str) -> Option<u64> {
trimmed.parse::<u64>().ok().filter(|&n| n > 0) trimmed.parse::<u64>().ok().filter(|&n| n > 0)
} }
// ============================================================================
// Shortcut wizard UI
// ============================================================================
/// Render warning when the desktop environment could not be detected
pub fn render_shortcut_unknown_de() {
let warning_style = Style::new().italic(true).foreground(color_orange());
println!(
"{}",
warning_style.render(" Desktop environment not detected. Skipping shortcut setup.")
);
println!(
"{}",
warning_style.render(" Run 'tmuxido --setup-shortcut' later when your DE is active.")
);
}
/// Ask the user whether to set up a keyboard shortcut. Returns `true` if yes.
pub fn render_shortcut_setup_prompt() -> Result<bool> {
let prompt_style = Style::new().bold(true).foreground(color_green());
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
println!(
"{}",
hint_style.render(" Set up a keyboard shortcut to launch tmuxido from anywhere?")
);
print!(" {} ", prompt_style.render(" Set up shortcut? (Y/n):"));
io::stdout().flush().context("Failed to flush stdout")?;
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.context("Failed to read input")?;
let trimmed = input.trim().to_lowercase();
Ok(trimmed != "n" && trimmed != "no")
}
/// Ask the user for a key combo (shows the default in brackets).
pub fn render_key_combo_prompt(default: &str) -> Result<String> {
let prompt_style = Style::new().bold(true).foreground(color_green());
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
println!(
"{}",
hint_style.render(&format!(
" Enter the key combo to launch tmuxido (default: {})",
default
))
);
println!(
"{}",
hint_style.render(" 💡 Example: Super+Shift+T, Super+Ctrl+P")
);
print!(
" {} ",
prompt_style.render(&format!(" Key combo [{}]:", default))
);
io::stdout().flush().context("Failed to flush stdout")?;
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.context("Failed to read input")?;
Ok(input.trim().to_string())
}
/// Show a conflict warning and ask whether to use the suggested alternative.
/// Returns `true` if the user accepts the suggestion.
pub fn render_shortcut_conflict_prompt(
combo: &str,
taken_by: &str,
suggestion: &str,
) -> Result<bool> {
let warning_style = Style::new().foreground(color_orange());
let prompt_style = Style::new().bold(true).foreground(color_green());
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
println!(
"{}",
warning_style.render(&format!(
" ⚠️ {} is already taken by: {}",
combo, taken_by
))
);
println!(
"{}",
hint_style.render(&format!(" Use {} instead?", suggestion))
);
print!(" {} ", prompt_style.render(" Use suggestion? (Y/n):"));
io::stdout().flush().context("Failed to flush stdout")?;
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.context("Failed to read input")?;
let trimmed = input.trim().to_lowercase();
Ok(trimmed != "n" && trimmed != "no")
}
/// Render a success message after the shortcut has been written
pub fn render_shortcut_success(de: &str, combo: &str, details: &str, reload_hint: &str) {
let success_style = Style::new().bold(true).foreground(color_green());
let label_style = Style::new().foreground(color_light_gray());
let value_style = Style::new().bold(true).foreground(color_blue());
let info_style = Style::new().foreground(color_dark_gray());
println!();
println!("{}", success_style.render(" ⌨️ Shortcut configured!"));
println!(
" {} {}",
label_style.render("Combo:"),
value_style.render(combo)
);
println!(
" {} {}",
label_style.render("Desktop:"),
value_style.render(de)
);
println!(" {}", info_style.render(details));
println!();
println!(" {}", info_style.render(reload_hint));
println!();
}
/// Ask the user whether to install the .desktop entry and icon
pub fn render_desktop_integration_prompt() -> Result<bool> {
let prompt_style = Style::new().bold(true).foreground(color_green());
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
println!(
"{}",
hint_style.render(
" Install a .desktop entry so tmuxido appears in app launchers (Walker, Rofi, etc.)?"
)
);
println!(
"{}",
hint_style
.render(" Also downloads the 96×96 icon from GitHub (requires internet access).")
);
print!(
" {} ",
prompt_style.render(" Install desktop entry? (Y/n):")
);
io::stdout().flush().context("Failed to flush stdout")?;
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.context("Failed to read input")?;
let trimmed = input.trim().to_lowercase();
Ok(trimmed != "n" && trimmed != "no")
}
/// Render a success message after desktop integration is installed
pub fn render_desktop_integration_success(result: &crate::shortcut::DesktopInstallResult) {
let success_style = Style::new().bold(true).foreground(color_green());
let label_style = Style::new().foreground(color_light_gray());
let value_style = Style::new().bold(true).foreground(color_blue());
let warn_style = Style::new().italic(true).foreground(color_orange());
println!();
println!("{}", success_style.render(" 🖥️ Desktop entry installed!"));
println!(
" {} {}",
label_style.render(".desktop:"),
value_style.render(&result.desktop_path.display().to_string())
);
if result.icon_downloaded {
println!(
" {} {}",
label_style.render("icon:"),
value_style.render(&result.icon_path.display().to_string())
);
} else {
println!(
" {}",
warn_style.render("icon: download skipped (no network or curl unavailable)")
);
}
println!();
}
// ============================================================================
/// Parse comma-separated list into Vec<String>, filtering empty items /// Parse comma-separated list into Vec<String>, filtering empty items
pub fn parse_comma_separated_list(input: &str) -> Vec<String> { pub fn parse_comma_separated_list(input: &str) -> Vec<String> {
input input
@@ -531,6 +786,50 @@ mod tests {
render_config_created(&vec!["~/Projects".to_string()], 5, true, 24, &windows); render_config_created(&vec!["~/Projects".to_string()], 5, true, 24, &windows);
} }
#[test]
fn should_return_none_for_empty_layout_input() {
assert_eq!(parse_layout_input(""), None);
assert_eq!(parse_layout_input(" "), None);
}
#[test]
fn should_parse_layout_by_number() {
assert_eq!(parse_layout_input("1"), Some("main-horizontal".to_string()));
assert_eq!(parse_layout_input("2"), Some("main-vertical".to_string()));
assert_eq!(parse_layout_input("3"), Some("tiled".to_string()));
assert_eq!(parse_layout_input("4"), Some("even-horizontal".to_string()));
assert_eq!(parse_layout_input("5"), Some("even-vertical".to_string()));
}
#[test]
fn should_parse_layout_by_name() {
assert_eq!(
parse_layout_input("main-horizontal"),
Some("main-horizontal".to_string())
);
assert_eq!(
parse_layout_input("main-vertical"),
Some("main-vertical".to_string())
);
assert_eq!(parse_layout_input("tiled"), Some("tiled".to_string()));
assert_eq!(
parse_layout_input("even-horizontal"),
Some("even-horizontal".to_string())
);
assert_eq!(
parse_layout_input("even-vertical"),
Some("even-vertical".to_string())
);
}
#[test]
fn should_return_none_for_invalid_layout_input() {
assert_eq!(parse_layout_input("6"), None);
assert_eq!(parse_layout_input("0"), None);
assert_eq!(parse_layout_input("unknown"), None);
assert_eq!(parse_layout_input("horizontal"), None);
}
#[test] #[test]
fn render_config_created_with_disabled_cache_should_not_panic() { fn render_config_created_with_disabled_cache_should_not_panic() {
let windows = vec![Window { let windows = vec![Window {
@@ -540,4 +839,47 @@ mod tests {
}]; }];
render_config_created(&vec!["~/work".to_string()], 3, false, 24, &windows); render_config_created(&vec!["~/work".to_string()], 3, false, 24, &windows);
} }
#[test]
fn render_shortcut_unknown_de_should_not_panic() {
render_shortcut_unknown_de();
}
#[test]
fn render_shortcut_success_should_not_panic() {
render_shortcut_success(
"Hyprland",
"Super+Shift+T",
"Added to ~/.config/hypr/bindings.conf",
"Reload Hyprland with Super+Shift+R to activate.",
);
}
#[test]
fn render_desktop_integration_success_should_not_panic() {
use crate::shortcut::DesktopInstallResult;
use std::path::PathBuf;
let result = DesktopInstallResult {
desktop_path: PathBuf::from("/home/user/.local/share/applications/tmuxido.desktop"),
icon_path: PathBuf::from(
"/home/user/.local/share/icons/hicolor/96x96/apps/tmuxido.png",
),
icon_downloaded: true,
};
render_desktop_integration_success(&result);
}
#[test]
fn render_desktop_integration_success_without_icon_should_not_panic() {
use crate::shortcut::DesktopInstallResult;
use std::path::PathBuf;
let result = DesktopInstallResult {
desktop_path: PathBuf::from("/home/user/.local/share/applications/tmuxido.desktop"),
icon_path: PathBuf::from(
"/home/user/.local/share/icons/hicolor/96x96/apps/tmuxido.png",
),
icon_downloaded: false,
};
render_desktop_integration_success(&result);
}
} }
+165
View File
@@ -0,0 +1,165 @@
use std::fs;
use tempfile::tempdir;
use tmuxido::shortcut::{
KeyCombo, check_kde_conflict, install_desktop_integration_to, write_hyprland_binding,
write_kde_shortcut,
};
#[test]
fn writes_hyprland_binding_to_new_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("bindings.conf");
let combo = KeyCombo::parse("Super+Shift+T").unwrap();
write_hyprland_binding(&path, &combo).unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(
content.contains("SUPER SHIFT, T"),
"should contain Hyprland combo"
);
assert!(content.contains("tmuxido"), "should mention tmuxido");
assert!(
content.starts_with("bindd"),
"should start with bindd directive"
);
}
#[test]
fn write_hyprland_binding_skips_when_tmuxido_already_present() {
let dir = tempdir().unwrap();
let path = dir.path().join("bindings.conf");
fs::write(&path, "bindd = SUPER SHIFT, T, Tmuxido, exec, tmuxido\n").unwrap();
let combo = KeyCombo::parse("Super+Shift+T").unwrap();
write_hyprland_binding(&path, &combo).unwrap();
let content = fs::read_to_string(&path).unwrap();
let count = content.lines().filter(|l| l.contains("tmuxido")).count();
assert_eq!(count, 1, "should not add a duplicate line");
}
#[test]
fn write_hyprland_binding_creates_parent_dirs() {
let dir = tempdir().unwrap();
let path = dir.path().join("nested").join("hypr").join("bindings.conf");
let combo = KeyCombo::parse("Super+Ctrl+T").unwrap();
write_hyprland_binding(&path, &combo).unwrap();
assert!(
path.exists(),
"file should be created even when parent dirs are missing"
);
}
#[test]
fn writes_kde_shortcut_to_new_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("kglobalshortcutsrc");
let combo = KeyCombo::parse("Super+Shift+T").unwrap();
write_kde_shortcut(&path, &combo).unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(
content.contains("[tmuxido]"),
"should contain [tmuxido] section"
);
assert!(
content.contains("Meta+Shift+T"),
"should use Meta notation for KDE"
);
assert!(
content.contains("Launch Tmuxido"),
"should include action description"
);
}
#[test]
fn write_kde_shortcut_skips_when_section_already_exists() {
let dir = tempdir().unwrap();
let path = dir.path().join("kglobalshortcutsrc");
fs::write(
&path,
"[tmuxido]\nLaunch Tmuxido=Meta+Shift+T,none,Launch Tmuxido\n",
)
.unwrap();
let combo = KeyCombo::parse("Super+Shift+P").unwrap();
write_kde_shortcut(&path, &combo).unwrap();
let content = fs::read_to_string(&path).unwrap();
let count = content.matches("[tmuxido]").count();
assert_eq!(count, 1, "should not add a duplicate section");
}
#[test]
fn check_kde_conflict_finds_existing_binding() {
let dir = tempdir().unwrap();
let path = dir.path().join("kglobalshortcutsrc");
fs::write(
&path,
"[myapp]\nLaunch Something=Meta+Shift+T,none,Launch Something\n",
)
.unwrap();
let combo = KeyCombo::parse("Super+Shift+T").unwrap();
let conflict = check_kde_conflict(&path, &combo);
assert_eq!(conflict, Some("myapp".to_string()));
}
#[test]
fn check_kde_conflict_returns_none_for_free_binding() {
let dir = tempdir().unwrap();
let path = dir.path().join("kglobalshortcutsrc");
fs::write(
&path,
"[myapp]\nLaunch Something=Meta+Ctrl+T,none,Launch Something\n",
)
.unwrap();
let combo = KeyCombo::parse("Super+Shift+T").unwrap();
assert!(check_kde_conflict(&path, &combo).is_none());
}
#[test]
fn check_kde_conflict_returns_none_when_file_missing() {
let combo = KeyCombo::parse("Super+Shift+T").unwrap();
assert!(check_kde_conflict(std::path::Path::new("/nonexistent/path"), &combo).is_none());
}
#[test]
fn installs_desktop_file_to_given_path() {
let dir = tempdir().unwrap();
let desktop_path = dir.path().join("applications").join("tmuxido.desktop");
let icon_path = dir
.path()
.join("icons")
.join("hicolor")
.join("96x96")
.join("apps")
.join("tmuxido.png");
let result = install_desktop_integration_to(&desktop_path, &icon_path).unwrap();
assert!(result.desktop_path.exists(), ".desktop file should exist");
let content = fs::read_to_string(&result.desktop_path).unwrap();
assert!(content.contains("[Desktop Entry]"));
assert!(content.contains("Exec=tmuxido"));
assert!(content.contains("Icon=tmuxido"));
assert!(content.contains("Terminal=true"));
assert!(content.contains("StartupWMClass=tmuxido"));
}
#[test]
fn desktop_install_creates_parent_dirs() {
let dir = tempdir().unwrap();
let desktop_path = dir.path().join("a").join("b").join("tmuxido.desktop");
let icon_path = dir.path().join("icons").join("tmuxido.png");
install_desktop_integration_to(&desktop_path, &icon_path).unwrap();
assert!(desktop_path.exists());
}
+10
View File
@@ -0,0 +1,10 @@
[Desktop Entry]
Name=Tmuxido
Comment=Quickly find and open projects in tmux
Exec=tmuxido
Icon=tmuxido
Type=Application
Categories=Development;Utility;
Terminal=true
Keywords=tmux;project;fzf;dev;
StartupWMClass=tmuxido