From 3aacf3069798c84323719c6eea1bf6043e5a6612 Mon Sep 17 00:00:00 2001 From: cinco euzebio Date: Sun, 1 Mar 2026 19:22:32 -0300 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20keyboard=20shortcut=20setup?= =?UTF-8?q?=20wizard=20(0.8.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 12 + Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 8 + install.sh | 29 +- src/config.rs | 5 + src/lib.rs | 5 + src/main.rs | 11 +- src/shortcut.rs | 824 ++++++++++++++++++++++++++++++++++++++++++++++ src/ui.rs | 143 ++++++++ tests/shortcut.rs | 128 +++++++ tmuxido.desktop | 10 + 12 files changed, 1175 insertions(+), 4 deletions(-) create mode 100644 src/shortcut.rs create mode 100644 tests/shortcut.rs create mode 100644 tmuxido.desktop diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a07554..f72754f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ 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/). +## [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 +- `shortcut` module (`src/shortcut.rs`) with full unit and integration test coverage +- Icon and `.desktop` file installed by `install.sh` for launcher and window-rule integration + ## [0.7.1] - 2026-03-01 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index ec020fe..a2b843f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -864,7 +864,7 @@ dependencies = [ [[package]] name = "tmuxido" -version = "0.7.1" +version = "0.8.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 3b1a29e..6656bb5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tmuxido" -version = "0.7.1" +version = "0.8.0" edition = "2024" [dev-dependencies] diff --git a/README.md b/README.md index d74698a..af5c597 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ A Rust-based tool to quickly find and open projects in tmux using fzf. No extern - Smart caching system for fast subsequent runs - Configurable cache TTL - Self-update capability (`tmuxido --update`) +- Keyboard shortcut setup for Hyprland, GNOME, and KDE (`tmuxido --setup-shortcut`) - Zero external dependencies (except tmux and fzf) ## Installation @@ -106,6 +107,13 @@ Update tmuxido to the latest version: tmuxido --update ``` +Set up a desktop keyboard shortcut (Hyprland, GNOME, KDE): +```bash +tmuxido --setup-shortcut +``` + +This is also offered automatically on first run. Re-run it any time to reconfigure the shortcut or after switching desktop environments. + View help: ```bash tmuxido --help diff --git a/install.sh b/install.sh index be7c1d9..b2cc8d1 100644 --- a/install.sh +++ b/install.sh @@ -3,8 +3,11 @@ set -e REPO="cinco/tmuxido" BASE_URL="https://github.com" +RAW_URL="https://raw.githubusercontent.com/$REPO/refs/heads/main" API_URL="https://api.github.com" INSTALL_DIR="$HOME/.local/bin" +ICON_DIR="$HOME/.local/share/icons/hicolor/96x96/apps" +DESKTOP_DIR="$HOME/.local/share/applications" arch=$(uname -m) case "$arch" in @@ -21,12 +24,36 @@ tag=$(curl -fsSL \ [ -z "$tag" ] && { echo "Could not fetch latest release" >&2; exit 1; } echo "Installing tmuxido $tag..." + +# Binary mkdir -p "$INSTALL_DIR" curl -fsSL "$BASE_URL/$REPO/releases/download/$tag/$file" -o "$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 *":$INSTALL_DIR:"*) ;; *) echo "Note: add $INSTALL_DIR to your PATH (e.g. export PATH=\"\$HOME/.local/bin:\$PATH\")" ;; esac + +echo "Done! Run 'tmuxido' to get started." diff --git a/src/config.rs b/src/config.rs index 2aa01ae..559ae7c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -125,6 +125,11 @@ impl Config { fs::write(&config_path, toml_string).with_context(|| { 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); + } } Ok(config_path) diff --git a/src/lib.rs b/src/lib.rs index 3a93ee8..b5ef4b7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub mod config; pub mod deps; pub mod self_update; pub mod session; +pub mod shortcut; pub mod ui; pub mod update_check; @@ -15,6 +16,10 @@ use std::path::{Path, PathBuf}; use std::time::UNIX_EPOCH; use walkdir::WalkDir; +pub fn setup_shortcut_wizard() -> Result<()> { + shortcut::setup_shortcut_wizard() +} + pub fn show_cache_status(config: &Config) -> Result<()> { if !config.cache_enabled { println!("Cache is disabled in configuration"); diff --git a/src/main.rs b/src/main.rs index 20eb2f0..c9c472e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,7 @@ use tmuxido::config::Config; use tmuxido::deps::ensure_dependencies; use tmuxido::self_update; use tmuxido::update_check; -use tmuxido::{get_projects, launch_tmux_session, show_cache_status}; +use tmuxido::{get_projects, launch_tmux_session, setup_shortcut_wizard, show_cache_status}; #[derive(Parser, Debug)] #[command( @@ -30,6 +30,10 @@ struct Args { /// Update tmuxido to the latest version #[arg(long)] update: bool, + + /// Set up a keyboard shortcut to launch tmuxido + #[arg(long)] + setup_shortcut: bool, } fn main() -> Result<()> { @@ -40,6 +44,11 @@ fn main() -> Result<()> { return self_update::self_update(); } + // Handle standalone shortcut setup + if args.setup_shortcut { + return setup_shortcut_wizard(); + } + // Check that fzf and tmux are installed; offer to install if missing ensure_dependencies()?; diff --git a/src/shortcut.rs b/src/shortcut.rs new file mode 100644 index 0000000..2cb08f3 --- /dev/null +++ b/src/shortcut.rs @@ -0,0 +1,824 @@ +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, + pub key: String, +} + +impl KeyCombo { + /// Parse input like "Super+Shift+T", "super+shift+t", "SUPER+SHIFT+T" + pub fn parse(input: &str) -> Option { + 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 = 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: "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 = 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 = 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 { + 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 { + 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::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 { + 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 { + 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 { + 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(¤t_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::>() + .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 { + 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 { + 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 { + 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(()) +} + +// ============================================================================ +// 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(), "t"); + } + + #[test] + fn should_format_ctrl_for_gnome() { + let c = KeyCombo::parse("Super+Ctrl+P").unwrap(); + assert_eq!(c.to_gnome(), "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 = 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"); + } +} diff --git a/src/ui.rs b/src/ui.rs index c4f1a48..c097892 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -425,6 +425,134 @@ pub fn parse_cache_ttl_input(input: &str) -> Option { trimmed.parse::().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 { + 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 { + 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 { + 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!(); +} + +// ============================================================================ + /// Parse comma-separated list into Vec, filtering empty items pub fn parse_comma_separated_list(input: &str) -> Vec { input @@ -651,4 +779,19 @@ mod tests { }]; 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.", + ); + } } diff --git a/tests/shortcut.rs b/tests/shortcut.rs new file mode 100644 index 0000000..cfd0391 --- /dev/null +++ b/tests/shortcut.rs @@ -0,0 +1,128 @@ +use std::fs; +use tempfile::tempdir; +use tmuxido::shortcut::{KeyCombo, check_kde_conflict, 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()); +} diff --git a/tmuxido.desktop b/tmuxido.desktop new file mode 100644 index 0000000..fec84c7 --- /dev/null +++ b/tmuxido.desktop @@ -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