✨ 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
This commit is contained in:
parent
906eec994f
commit
3aacf30697
12
CHANGELOG.md
12
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/).
|
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
|
## [0.7.1] - 2026-03-01
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -864,7 +864,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tmuxido"
|
name = "tmuxido"
|
||||||
version = "0.7.1"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tmuxido"
|
name = "tmuxido"
|
||||||
version = "0.7.1"
|
version = "0.8.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@ -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
|
- 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
|
||||||
@ -106,6 +107,13 @@ Update tmuxido to the latest version:
|
|||||||
tmuxido --update
|
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:
|
View help:
|
||||||
```bash
|
```bash
|
||||||
tmuxido --help
|
tmuxido --help
|
||||||
|
|||||||
29
install.sh
29
install.sh
@ -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
|
||||||
@ -21,12 +24,36 @@ tag=$(curl -fsSL \
|
|||||||
[ -z "$tag" ] && { echo "Could not fetch latest release" >&2; exit 1; }
|
[ -z "$tag" ] && { echo "Could not fetch latest release" >&2; exit 1; }
|
||||||
|
|
||||||
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."
|
||||||
|
|||||||
@ -125,6 +125,11 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(config_path)
|
Ok(config_path)
|
||||||
|
|||||||
@ -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,10 @@ 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 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");
|
||||||
|
|||||||
11
src/main.rs
11
src/main.rs
@ -7,7 +7,7 @@ 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_shortcut_wizard, show_cache_status};
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(
|
#[command(
|
||||||
@ -30,6 +30,10 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
@ -40,6 +44,11 @@ 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();
|
||||||
|
}
|
||||||
|
|
||||||
// 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()?;
|
||||||
|
|
||||||
|
|||||||
824
src/shortcut.rs
Normal file
824
src/shortcut.rs
Normal file
@ -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<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(¤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::<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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
143
src/ui.rs
143
src/ui.rs
@ -425,6 +425,134 @@ 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!();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
/// 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
|
||||||
@ -651,4 +779,19 @@ 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.",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
128
tests/shortcut.rs
Normal file
128
tests/shortcut.rs
Normal file
@ -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());
|
||||||
|
}
|
||||||
10
tmuxido.desktop
Normal file
10
tmuxido.desktop
Normal 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
|
||||||
Loading…
x
Reference in New Issue
Block a user