diff --git a/.gitignore b/.gitignore index ea8c4bf..cfa6940 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 3dca762..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,32 +0,0 @@ -# Owlry - Claude Code Instructions - -## Release Workflow - -Always use `just` for releases and AUR deployment: - -```bash -# Bump version (updates Cargo.toml + Cargo.lock, commits) -just bump 0.x.y - -# Push and create tag -git push && just tag - -# Update AUR package -just aur-update - -# Review changes, then publish -just aur-publish -``` - -Do NOT manually edit Cargo.toml for version bumps - use `just bump`. - -## Available just recipes - -- `just build` / `just release` - Build debug/release -- `just check` - Run cargo check + clippy -- `just test` - Run tests -- `just bump ` - Bump version -- `just tag` - Create and push git tag -- `just aur-update` - Update PKGBUILD checksums -- `just aur-publish` - Commit and push to AUR -- `just aur-test` - Test PKGBUILD locally diff --git a/Cargo.toml b/Cargo.toml index 82713bb..e6efa3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,11 @@ serde_json = "1" # Date/time for frecency calculations chrono = { version = "0.4", features = ["serde"] } +[features] +default = [] +# Enable verbose debug logging (for development/testing builds) +dev-logging = [] + [profile.release] lto = true codegen-units = 1 @@ -65,3 +70,9 @@ opt-level = "z" # Optimize for size [profile.dev] opt-level = 0 debug = true + +# For installing a testable build: cargo install --path . --profile dev-install --features dev-logging +[profile.dev-install] +inherits = "release" +strip = false +debug = 1 # Basic debug info for stack traces diff --git a/src/app.rs b/src/app.rs index 57a7218..202b3f7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,6 +2,7 @@ use crate::cli::CliArgs; use crate::config::Config; use crate::data::FrecencyStore; use crate::filter::ProviderFilter; +use crate::paths; use crate::providers::ProviderManager; use crate::theme; use crate::ui::MainWindow; @@ -98,10 +99,8 @@ impl OwlryApp { debug!("Loaded built-in owl theme"); } _ => { - // Check for custom theme in ~/.config/owlry/themes/{name}.css - if let Some(theme_path) = dirs::config_dir() - .map(|p| p.join("owlry").join("themes").join(format!("{}.css", theme_name))) - { + // Check for custom theme in $XDG_CONFIG_HOME/owlry/themes/{name}.css + if let Some(theme_path) = paths::theme_file(theme_name) { if theme_path.exists() { theme_provider.load_from_path(&theme_path); debug!("Loaded custom theme from {:?}", theme_path); @@ -119,7 +118,7 @@ impl OwlryApp { } // 3. Load user's custom stylesheet if exists - if let Some(custom_path) = dirs::config_dir().map(|p| p.join("owlry").join("style.css")) { + if let Some(custom_path) = paths::custom_style_file() { if custom_path.exists() { let custom_provider = CssProvider::new(); custom_provider.load_from_path(&custom_path); diff --git a/src/config/mod.rs b/src/config/mod.rs index 0fe5c21..1ae1c21 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,7 +1,9 @@ +use log::{debug, info, warn}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::process::Command; -use log::{info, warn, debug}; + +use crate::paths; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { @@ -250,7 +252,7 @@ impl Default for Config { impl Config { pub fn config_path() -> Option { - dirs::config_dir().map(|p| p.join("owlry").join("config.toml")) + paths::config_file() } pub fn load_or_default() -> Self { @@ -289,9 +291,7 @@ impl Config { pub fn save(&self) -> Result<(), Box> { let path = Self::config_path().ok_or("Could not determine config path")?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } + paths::ensure_parent_dir(&path)?; let content = toml::to_string_pretty(self)?; std::fs::write(&path, content)?; diff --git a/src/data/frecency.rs b/src/data/frecency.rs index 357cb70..af43413 100644 --- a/src/data/frecency.rs +++ b/src/data/frecency.rs @@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; +use crate::paths; + /// A single frecency entry tracking launch count and recency #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FrecencyEntry { @@ -56,10 +58,7 @@ impl FrecencyStore { /// Get the path to the frecency data file fn data_path() -> PathBuf { - dirs::data_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("owlry") - .join("frecency.json") + paths::frecency_file().unwrap_or_else(|| PathBuf::from("frecency.json")) } /// Load frecency data from a file @@ -85,10 +84,7 @@ impl FrecencyStore { return Ok(()); } - // Ensure directory exists - if let Some(parent) = self.path.parent() { - std::fs::create_dir_all(parent)?; - } + paths::ensure_parent_dir(&self.path)?; let content = serde_json::to_string_pretty(&self.data)?; std::fs::write(&self.path, content)?; diff --git a/src/filter.rs b/src/filter.rs index d5aab49..02dc59e 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -1,5 +1,8 @@ use std::collections::HashSet; +#[cfg(feature = "dev-logging")] +use log::debug; + use crate::config::ProvidersConfig; use crate::providers::ProviderType; @@ -69,10 +72,15 @@ impl ProviderFilter { set }; - Self { + let filter = Self { enabled, active_prefix: None, - } + }; + + #[cfg(feature = "dev-logging")] + debug!("[Filter] Created with enabled providers: {:?}", filter.enabled); + + filter } /// Default filter: apps only @@ -92,8 +100,12 @@ impl ProviderFilter { if self.enabled.is_empty() { self.enabled.insert(ProviderType::Application); } + #[cfg(feature = "dev-logging")] + debug!("[Filter] Toggled OFF {:?}, enabled: {:?}", provider, self.enabled); } else { self.enabled.insert(provider); + #[cfg(feature = "dev-logging")] + debug!("[Filter] Toggled ON {:?}, enabled: {:?}", provider, self.enabled); } } @@ -118,6 +130,10 @@ impl ProviderFilter { /// Set prefix mode (from :app, :cmd, etc.) pub fn set_prefix(&mut self, prefix: Option) { + #[cfg(feature = "dev-logging")] + if self.active_prefix != prefix { + debug!("[Filter] Prefix changed: {:?} -> {:?}", self.active_prefix, prefix); + } self.active_prefix = prefix; } @@ -176,6 +192,8 @@ impl ProviderFilter { for (prefix_str, provider) in prefixes { if let Some(rest) = trimmed.strip_prefix(prefix_str) { + #[cfg(feature = "dev-logging")] + debug!("[Filter] parse_query({:?}) -> prefix={:?}, query={:?}", query, provider, rest); return ParsedQuery { prefix: Some(provider), query: rest.to_string(), @@ -214,6 +232,8 @@ impl ProviderFilter { for (prefix_str, provider) in partial_prefixes { if trimmed == prefix_str { + #[cfg(feature = "dev-logging")] + debug!("[Filter] parse_query({:?}) -> partial prefix {:?}", query, provider); return ParsedQuery { prefix: Some(provider), query: String::new(), @@ -221,10 +241,15 @@ impl ProviderFilter { } } - ParsedQuery { + let result = ParsedQuery { prefix: None, query: query.to_string(), - } + }; + + #[cfg(feature = "dev-logging")] + debug!("[Filter] parse_query({:?}) -> prefix={:?}, query={:?}", query, result.prefix, result.query); + + result } /// Get enabled providers for UI display (sorted) diff --git a/src/main.rs b/src/main.rs index bd4107b..3f4934e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod cli; mod config; mod data; mod filter; +mod paths; mod providers; mod theme; mod ui; @@ -11,11 +12,26 @@ use app::OwlryApp; use cli::CliArgs; use log::{info, warn}; +#[cfg(feature = "dev-logging")] +use log::debug; + fn main() { - env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + let default_level = if cfg!(feature = "dev-logging") { "debug" } else { "info" }; + + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(default_level)) + .format_timestamp_millis() + .init(); let args = CliArgs::parse_args(); + #[cfg(feature = "dev-logging")] + { + debug!("┌─────────────────────────────────────────┐"); + debug!("│ DEV-LOGGING: Verbose output enabled │"); + debug!("└─────────────────────────────────────────┘"); + debug!("CLI args: {:?}", args); + } + info!("Starting Owlry launcher"); // Diagnostic: log critical environment variables diff --git a/src/paths.rs b/src/paths.rs new file mode 100644 index 0000000..d395c26 --- /dev/null +++ b/src/paths.rs @@ -0,0 +1,214 @@ +//! Centralized path handling following XDG Base Directory Specification. +//! +//! XDG directories used: +//! - `$XDG_CONFIG_HOME/owlry/` - User configuration (config.toml, themes/, style.css) +//! - `$XDG_DATA_HOME/owlry/` - User data (scripts/, frecency.json) +//! - `$XDG_CACHE_HOME/owlry/` - Cache files (future use) +//! +//! See: https://specifications.freedesktop.org/basedir-spec/latest/ + +use std::path::PathBuf; + +/// Application name used in XDG paths +const APP_NAME: &str = "owlry"; + +// ============================================================================= +// XDG Base Directories +// ============================================================================= + +/// Get XDG config home: `$XDG_CONFIG_HOME` or `~/.config` +pub fn config_home() -> Option { + dirs::config_dir() +} + +/// Get XDG data home: `$XDG_DATA_HOME` or `~/.local/share` +pub fn data_home() -> Option { + dirs::data_dir() +} + +/// Get XDG cache home: `$XDG_CACHE_HOME` or `~/.cache` +#[allow(dead_code)] +pub fn cache_home() -> Option { + dirs::cache_dir() +} + +/// Get user home directory +pub fn home() -> Option { + dirs::home_dir() +} + +// ============================================================================= +// Owlry-specific directories +// ============================================================================= + +/// Owlry config directory: `$XDG_CONFIG_HOME/owlry/` +pub fn owlry_config_dir() -> Option { + config_home().map(|p| p.join(APP_NAME)) +} + +/// Owlry data directory: `$XDG_DATA_HOME/owlry/` +pub fn owlry_data_dir() -> Option { + data_home().map(|p| p.join(APP_NAME)) +} + +/// Owlry cache directory: `$XDG_CACHE_HOME/owlry/` +#[allow(dead_code)] +pub fn owlry_cache_dir() -> Option { + cache_home().map(|p| p.join(APP_NAME)) +} + +// ============================================================================= +// Config files +// ============================================================================= + +/// Main config file: `$XDG_CONFIG_HOME/owlry/config.toml` +pub fn config_file() -> Option { + owlry_config_dir().map(|p| p.join("config.toml")) +} + +/// Custom user stylesheet: `$XDG_CONFIG_HOME/owlry/style.css` +pub fn custom_style_file() -> Option { + owlry_config_dir().map(|p| p.join("style.css")) +} + +/// User themes directory: `$XDG_CONFIG_HOME/owlry/themes/` +pub fn themes_dir() -> Option { + owlry_config_dir().map(|p| p.join("themes")) +} + +/// Get path for a specific theme: `$XDG_CONFIG_HOME/owlry/themes/{name}.css` +pub fn theme_file(name: &str) -> Option { + themes_dir().map(|p| p.join(format!("{}.css", name))) +} + +// ============================================================================= +// Data files +// ============================================================================= + +/// User scripts directory: `$XDG_DATA_HOME/owlry/scripts/` +pub fn scripts_dir() -> Option { + owlry_data_dir().map(|p| p.join("scripts")) +} + +/// Frecency data file: `$XDG_DATA_HOME/owlry/frecency.json` +pub fn frecency_file() -> Option { + owlry_data_dir().map(|p| p.join("frecency.json")) +} + +// ============================================================================= +// System directories +// ============================================================================= + +/// System data directories for applications (XDG_DATA_DIRS) +pub fn system_data_dirs() -> Vec { + let mut dirs = Vec::new(); + + // User data directory first + if let Some(data) = data_home() { + dirs.push(data.join("applications")); + } + + // System directories + dirs.push(PathBuf::from("/usr/share/applications")); + dirs.push(PathBuf::from("/usr/local/share/applications")); + + // Flatpak directories + if let Some(data) = data_home() { + dirs.push(data.join("flatpak/exports/share/applications")); + } + dirs.push(PathBuf::from("/var/lib/flatpak/exports/share/applications")); + + dirs +} + +// ============================================================================= +// External application paths +// ============================================================================= + +/// SSH config file: `~/.ssh/config` +pub fn ssh_config() -> Option { + home().map(|p| p.join(".ssh").join("config")) +} + +/// Firefox profile directory: `~/.mozilla/firefox/` +pub fn firefox_dir() -> Option { + home().map(|p| p.join(".mozilla").join("firefox")) +} + +/// Chromium-based browser bookmark paths (using XDG config where browsers support it) +pub fn chromium_bookmark_paths() -> Vec { + let config = match config_home() { + Some(c) => c, + None => return Vec::new(), + }; + + vec![ + // Google Chrome + config.join("google-chrome/Default/Bookmarks"), + // Chromium + config.join("chromium/Default/Bookmarks"), + // Brave + config.join("BraveSoftware/Brave-Browser/Default/Bookmarks"), + // Microsoft Edge + config.join("microsoft-edge/Default/Bookmarks"), + // Vivaldi + config.join("vivaldi/Default/Bookmarks"), + ] +} + +// ============================================================================= +// Helper functions +// ============================================================================= + +/// Ensure a directory exists, creating it if necessary +pub fn ensure_dir(path: &PathBuf) -> std::io::Result<()> { + if !path.exists() { + std::fs::create_dir_all(path)?; + } + Ok(()) +} + +/// Ensure parent directory of a file exists +pub fn ensure_parent_dir(path: &PathBuf) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + if !parent.exists() { + std::fs::create_dir_all(parent)?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_paths_are_consistent() { + // All owlry paths should be under XDG directories + if let (Some(config), Some(data)) = (owlry_config_dir(), owlry_data_dir()) { + assert!(config.ends_with("owlry")); + assert!(data.ends_with("owlry")); + } + } + + #[test] + fn test_config_file_path() { + if let Some(path) = config_file() { + assert!(path.ends_with("config.toml")); + assert!(path.to_string_lossy().contains("owlry")); + } + } + + #[test] + fn test_frecency_in_data_dir() { + if let Some(path) = frecency_file() { + assert!(path.ends_with("frecency.json")); + // Should be in data dir, not config dir + let path_str = path.to_string_lossy(); + assert!( + path_str.contains(".local/share") || path_str.contains("XDG_DATA_HOME"), + "frecency should be in data directory" + ); + } + } +} diff --git a/src/providers/application.rs b/src/providers/application.rs index 48f9ac5..3c7c1b8 100644 --- a/src/providers/application.rs +++ b/src/providers/application.rs @@ -1,7 +1,7 @@ use super::{LaunchItem, Provider, ProviderType}; +use crate::paths; use freedesktop_desktop_entry::{DesktopEntry, Iter}; use log::{debug, warn}; -use std::path::PathBuf; /// Clean desktop file field codes from command string. /// Removes %f, %F, %u, %U, %d, %D, %n, %N, %i, %c, %k, %v, %m field codes @@ -75,25 +75,8 @@ impl ApplicationProvider { Self { items: Vec::new() } } - fn get_application_dirs() -> Vec { - let mut dirs = Vec::new(); - - // User applications - if let Some(data_home) = dirs::data_dir() { - dirs.push(data_home.join("applications")); - } - - // System applications - dirs.push(PathBuf::from("/usr/share/applications")); - dirs.push(PathBuf::from("/usr/local/share/applications")); - - // Flatpak applications - if let Some(data_home) = dirs::data_dir() { - dirs.push(data_home.join("flatpak/exports/share/applications")); - } - dirs.push(PathBuf::from("/var/lib/flatpak/exports/share/applications")); - - dirs + fn get_application_dirs() -> Vec { + paths::system_data_dirs() } } diff --git a/src/providers/bookmarks.rs b/src/providers/bookmarks.rs index df98ae0..c064f6e 100644 --- a/src/providers/bookmarks.rs +++ b/src/providers/bookmarks.rs @@ -1,3 +1,4 @@ +use crate::paths; use crate::providers::{LaunchItem, Provider, ProviderType}; use log::{debug, warn}; use serde::Deserialize; @@ -27,8 +28,8 @@ impl BookmarksProvider { fn load_firefox_bookmarks(&mut self) { // Firefox stores bookmarks in places.sqlite // The file is locked when Firefox is running, so we read from backup - let firefox_dir = match dirs::home_dir() { - Some(h) => h.join(".mozilla").join("firefox"), + let firefox_dir = match paths::firefox_dir() { + Some(d) => d, None => return, }; @@ -99,29 +100,10 @@ impl BookmarksProvider { } fn load_chrome_bookmarks(&mut self) { - // Chrome/Chromium bookmarks are in JSON format - let home = match dirs::home_dir() { - Some(h) => h, - None => return, - }; - - // Try multiple browser paths - let bookmark_paths = [ - // Chrome - home.join(".config/google-chrome/Default/Bookmarks"), - // Chromium - home.join(".config/chromium/Default/Bookmarks"), - // Brave - home.join(".config/BraveSoftware/Brave-Browser/Default/Bookmarks"), - // Edge - home.join(".config/microsoft-edge/Default/Bookmarks"), - // Vivaldi - home.join(".config/vivaldi/Default/Bookmarks"), - ]; - - for path in &bookmark_paths { + // Chrome/Chromium bookmarks are in JSON format (XDG config paths) + for path in paths::chromium_bookmark_paths() { if path.exists() { - self.read_chrome_bookmarks(path); + self.read_chrome_bookmarks(&path); } } } diff --git a/src/providers/files.rs b/src/providers/files.rs index 3cad950..93a65ba 100644 --- a/src/providers/files.rs +++ b/src/providers/files.rs @@ -1,3 +1,4 @@ +use crate::paths; use crate::providers::{LaunchItem, ProviderType}; use log::{debug, warn}; use std::process::Command; @@ -106,7 +107,7 @@ impl FileSearchProvider { fn search_with_fd(&self, pattern: &str) -> Vec { // fd searches from home directory by default - let home = dirs::home_dir().unwrap_or_default(); + let home = paths::home().unwrap_or_default(); let output = match Command::new("fd") .args([ @@ -132,7 +133,7 @@ impl FileSearchProvider { } fn search_with_locate(&self, pattern: &str) -> Vec { - let home = dirs::home_dir().unwrap_or_default(); + let home = paths::home().unwrap_or_default(); let output = match Command::new("locate") .args([ diff --git a/src/providers/mod.rs b/src/providers/mod.rs index bd4698e..13526ab 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -30,6 +30,9 @@ use fuzzy_matcher::FuzzyMatcher; use fuzzy_matcher::skim::SkimMatcherV2; use log::info; +#[cfg(feature = "dev-logging")] +use log::debug; + use crate::data::FrecencyStore; /// Represents a single searchable/launchable item @@ -288,12 +291,16 @@ impl ProviderManager { frecency: &FrecencyStore, frecency_weight: f64, ) -> Vec<(LaunchItem, i64)> { + #[cfg(feature = "dev-logging")] + debug!("[Search] query={:?}, max={}, frecency_weight={}", query, max_results, frecency_weight); + let mut results: Vec<(LaunchItem, i64)> = Vec::new(); // Check for calculator query (= or calc prefix) if CalculatorProvider::is_calculator_query(query) { if let Some(calc_result) = self.calculator.evaluate(query) { - // Calculator results get a high score to appear first + #[cfg(feature = "dev-logging")] + debug!("[Search] Calculator result: {}", calc_result.name); results.push((calc_result, 10000)); } } @@ -323,6 +330,8 @@ impl ProviderManager { // Check for file search query if FileSearchProvider::is_file_query(query) { let file_results = self.filesearch.evaluate(query); + #[cfg(feature = "dev-logging")] + debug!("[Search] File search returned {} results", file_results.len()); for (idx, item) in file_results.into_iter().enumerate() { // Score decreases for each result to maintain order results.push((item, 8000 - idx as i64)); @@ -387,6 +396,18 @@ impl ProviderManager { results.extend(search_results); results.sort_by(|a, b| b.1.cmp(&a.1)); results.truncate(max_results); + + #[cfg(feature = "dev-logging")] + { + debug!("[Search] Returning {} results", results.len()); + for (i, (item, score)) in results.iter().take(5).enumerate() { + debug!("[Search] #{}: {} (score={}, provider={:?})", i + 1, item.name, score, item.provider); + } + if results.len() > 5 { + debug!("[Search] ... and {} more", results.len() - 5); + } + } + results } diff --git a/src/providers/scripts.rs b/src/providers/scripts.rs index 121eaf3..c30978e 100644 --- a/src/providers/scripts.rs +++ b/src/providers/scripts.rs @@ -1,10 +1,11 @@ +use crate::paths; use crate::providers::{LaunchItem, Provider, ProviderType}; use log::{debug, warn}; use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; -/// Custom scripts provider - runs user scripts from ~/.config/owlry/scripts/ +/// Custom scripts provider - runs user scripts from `$XDG_DATA_HOME/owlry/scripts/` pub struct ScriptsProvider { items: Vec, } @@ -14,14 +15,10 @@ impl ScriptsProvider { Self { items: Vec::new() } } - fn scripts_dir() -> Option { - dirs::config_dir().map(|p| p.join("owlry").join("scripts")) - } - fn load_scripts(&mut self) { self.items.clear(); - let scripts_dir = match Self::scripts_dir() { + let scripts_dir = match paths::scripts_dir() { Some(p) => p, None => { debug!("Could not determine scripts directory"); @@ -32,7 +29,7 @@ impl ScriptsProvider { if !scripts_dir.exists() { debug!("Scripts directory not found at {:?}", scripts_dir); // Create the directory for the user - if let Err(e) = fs::create_dir_all(&scripts_dir) { + if let Err(e) = paths::ensure_dir(&scripts_dir) { warn!("Failed to create scripts directory: {}", e); } return; diff --git a/src/providers/ssh.rs b/src/providers/ssh.rs index 25e09a6..54da2ed 100644 --- a/src/providers/ssh.rs +++ b/src/providers/ssh.rs @@ -1,7 +1,7 @@ +use crate::paths; use crate::providers::{LaunchItem, Provider, ProviderType}; use log::{debug, warn}; use std::fs; -use std::path::PathBuf; /// SSH connections provider - parses ~/.ssh/config pub struct SshProvider { @@ -27,8 +27,8 @@ impl SshProvider { self.terminal_command = terminal.to_string(); } - fn ssh_config_path() -> Option { - dirs::home_dir().map(|p| p.join(".ssh").join("config")) + fn ssh_config_path() -> Option { + paths::ssh_config() } fn parse_ssh_config(&mut self) { diff --git a/src/ui/main_window.rs b/src/ui/main_window.rs index 5c279be..50bcbd8 100644 --- a/src/ui/main_window.rs +++ b/src/ui/main_window.rs @@ -10,6 +10,10 @@ use gtk4::{ ListBoxRow, Orientation, ScrolledWindow, SelectionMode, ToggleButton, }; use log::info; + +#[cfg(feature = "dev-logging")] +use log::debug; + use std::cell::RefCell; use std::collections::HashMap; use std::process::Command; @@ -143,7 +147,7 @@ impl MainWindow { hints_box.add_css_class("owlry-hints"); let hints_label = Label::builder() - .label("Tab: cycle mode ↑↓: navigate Enter: launch Esc: close = calc ? web :app :cmd") + .label(&Self::build_hints(&cfg.providers)) .halign(gtk4::Align::Center) .hexpand(true) .build(); @@ -252,6 +256,52 @@ impl MainWindow { format!("Search {}...", active.join(", ")) } + /// Build dynamic hints based on enabled providers + fn build_hints(config: &crate::config::ProvidersConfig) -> String { + let mut parts: Vec = vec![ + "Tab: cycle".to_string(), + "↑↓: nav".to_string(), + "Enter: launch".to_string(), + "Esc: close".to_string(), + ]; + + // Add trigger hints for enabled dynamic providers + if config.calculator { + parts.push("= calc".to_string()); + } + if config.websearch { + parts.push("? web".to_string()); + } + if config.files { + parts.push("/ files".to_string()); + } + + // Add prefix hints for static providers + let mut prefixes = Vec::new(); + if config.system { + prefixes.push(":sys"); + } + if config.emoji { + prefixes.push(":emoji"); + } + if config.ssh { + prefixes.push(":ssh"); + } + if config.clipboard { + prefixes.push(":clip"); + } + if config.bookmarks { + prefixes.push(":bm"); + } + + // Only show first few prefixes to avoid overflow + if !prefixes.is_empty() { + parts.push(prefixes[..prefixes.len().min(4)].join(" ")); + } + + parts.join(" ") + } + /// Scroll the given row into view within the scrolled window fn scroll_to_row(scrolled: &ScrolledWindow, results_list: &ListBox, row: &ListBoxRow) { let vadj = scrolled.vadjustment(); @@ -298,6 +348,9 @@ impl MainWindow { display_name: &str, is_active: bool, ) { + #[cfg(feature = "dev-logging")] + debug!("[UI] Entering submenu for service: {} (active={})", unit_name, is_active); + let actions = UuctlProvider::actions_for_service(unit_name, display_name, is_active); // Save current state @@ -340,7 +393,11 @@ impl MainWindow { hints_label: &Label, search_entry: &Entry, filter: &Rc>, + config: &Rc>, ) { + #[cfg(feature = "dev-logging")] + debug!("[UI] Exiting submenu"); + let saved_search = { let mut state = submenu_state.borrow_mut(); state.active = false; @@ -350,7 +407,7 @@ impl MainWindow { // Restore UI mode_label.set_label(filter.borrow().mode_display_name()); - hints_label.set_label("Tab: cycle mode ↑↓: navigate Enter: launch Esc: close = calc ? web :app :cmd"); + hints_label.set_label(&Self::build_hints(&config.borrow().providers)); search_entry.set_placeholder_text(Some(&Self::build_placeholder(&filter.borrow()))); search_entry.set_text(&saved_search); @@ -553,7 +610,7 @@ impl MainWindow { let scrolled = self.scrolled.clone(); let search_entry = self.search_entry.clone(); let _current_results = self.current_results.clone(); - let _config = self.config.clone(); + let config = self.config.clone(); let filter = self.filter.clone(); let filter_buttons = self.filter_buttons.clone(); let mode_label = self.mode_label.clone(); @@ -564,6 +621,9 @@ impl MainWindow { let ctrl = modifiers.contains(gtk4::gdk::ModifierType::CONTROL_MASK); let shift = modifiers.contains(gtk4::gdk::ModifierType::SHIFT_MASK); + #[cfg(feature = "dev-logging")] + debug!("[UI] Key pressed: {:?} (ctrl={}, shift={})", key, ctrl, shift); + match key { Key::Escape => { // If in submenu, exit submenu first @@ -574,6 +634,7 @@ impl MainWindow { &hints_label, &search_entry, &filter, + &config, ); gtk4::glib::Propagation::Stop } else { @@ -590,6 +651,7 @@ impl MainWindow { &hints_label, &search_entry, &filter, + &config, ); gtk4::glib::Propagation::Stop } else { @@ -833,10 +895,15 @@ impl MainWindow { // Record this launch for frecency tracking if config.providers.frecency { frecency.borrow_mut().record_launch(&item.id); + #[cfg(feature = "dev-logging")] + debug!("[UI] Recorded frecency launch for: {}", item.id); } info!("Launching: {} ({})", item.name, item.command); + #[cfg(feature = "dev-logging")] + debug!("[UI] Launch details: terminal={}, provider={:?}", item.terminal, item.provider); + let cmd = if item.terminal { format!("{} -e {}", config.general.terminal_command, item.command) } else {