diff --git a/crates/owlry-core/src/config/mod.rs b/crates/owlry-core/src/config/mod.rs index 3581e87..43a5888 100644 --- a/crates/owlry-core/src/config/mod.rs +++ b/crates/owlry-core/src/config/mod.rs @@ -2,6 +2,7 @@ use log::{debug, info, warn}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; +use toml_edit::{DocumentMut, Item}; use crate::paths; @@ -157,82 +158,26 @@ pub struct ProvidersConfig { pub applications: bool, #[serde(default = "default_true")] pub commands: bool, - #[serde(default = "default_true")] - pub uuctl: bool, - /// Enable calculator provider (= expression or calc expression) + /// Enable built-in calculator provider (= or calc trigger) #[serde(default = "default_true")] pub calculator: bool, - /// Enable converter provider (> expression or auto-detect) + /// Enable built-in unit/currency converter (> trigger) #[serde(default = "default_true")] pub converter: bool, + /// Enable built-in system actions (shutdown, reboot, lock, etc.) + #[serde(default = "default_true")] + pub system: bool, /// Enable frecency-based result ranking #[serde(default = "default_true")] pub frecency: bool, /// Weight for frecency boost (0.0 = disabled, 1.0 = strong boost) #[serde(default = "default_frecency_weight")] pub frecency_weight: f64, - /// Enable web search provider (? query or web query) - #[serde(default = "default_true")] - pub websearch: bool, - /// Search engine for web search + /// Search engine for web search (used by owlry-plugin-websearch) /// Options: google, duckduckgo, bing, startpage, searxng, brave, ecosia - /// Or custom URL with {query} placeholder + /// Or a custom URL with a {query} placeholder #[serde(default = "default_search_engine")] pub search_engine: String, - /// Enable system commands (shutdown, reboot, etc.) - #[serde(default = "default_true")] - pub system: bool, - /// Enable SSH connections from ~/.ssh/config - #[serde(default = "default_true")] - pub ssh: bool, - /// Enable clipboard history (requires cliphist) - #[serde(default = "default_true")] - pub clipboard: bool, - /// Enable browser bookmarks - #[serde(default = "default_true")] - pub bookmarks: bool, - /// Enable emoji picker - #[serde(default = "default_true")] - pub emoji: bool, - /// Enable custom scripts from ~/.config/owlry/scripts/ - #[serde(default = "default_true")] - pub scripts: bool, - /// Enable file search (requires fd or locate) - #[serde(default = "default_true")] - pub files: bool, - - // ─── Widget Providers ─────────────────────────────────────────────── - /// Enable MPRIS media player widget - #[serde(default = "default_true")] - pub media: bool, - - /// Enable weather widget - #[serde(default)] - pub weather: bool, - - /// Weather provider: wttr.in (default), openweathermap, open-meteo - #[serde(default = "default_weather_provider")] - pub weather_provider: String, - - /// API key for weather services that require it (e.g., OpenWeatherMap) - #[serde(default)] - pub weather_api_key: Option, - - /// Location for weather (city name or coordinates) - #[serde(default)] - pub weather_location: Option, - - /// Enable pomodoro timer widget - #[serde(default)] - pub pomodoro: bool, - - /// Pomodoro work duration in minutes - #[serde(default = "default_pomodoro_work")] - pub pomodoro_work_mins: u32, - - /// Pomodoro break duration in minutes - #[serde(default = "default_pomodoro_break")] - pub pomodoro_break_mins: u32, } impl Default for ProvidersConfig { @@ -240,28 +185,12 @@ impl Default for ProvidersConfig { Self { applications: true, commands: true, - uuctl: true, calculator: true, converter: true, + system: true, frecency: true, frecency_weight: 0.3, - websearch: true, search_engine: "duckduckgo".to_string(), - system: true, - ssh: true, - clipboard: true, - bookmarks: true, - emoji: true, - scripts: true, - files: true, - media: true, - weather: false, - weather_provider: "wttr.in".to_string(), - weather_api_key: None, - weather_location: Some("Berlin".to_string()), - pomodoro: false, - pomodoro_work_mins: 25, - pomodoro_break_mins: 5, } } } @@ -399,18 +328,6 @@ fn default_frecency_weight() -> f64 { 0.3 } -fn default_weather_provider() -> String { - "wttr.in".to_string() -} - -fn default_pomodoro_work() -> u32 { - 25 -} - -fn default_pomodoro_break() -> u32 { - 5 -} - /// Detect the best available terminal emulator /// Fallback chain: /// 1. $TERMINAL env var (user's explicit preference) @@ -539,6 +456,38 @@ fn command_exists(cmd: &str) -> bool { // Note: Config derives Default via #[derive(Default)] - all sub-structs have impl Default +/// Merge `new` into `existing`, updating values while preserving comments and unknown keys. +/// +/// Tables are recursed into so that section-level comments survive. For leaf values +/// (scalars, arrays) the item is replaced but the surrounding table structure — and +/// any keys in `existing` that are absent from `new` — are left untouched. +fn merge_toml_doc(existing: &mut DocumentMut, new: &DocumentMut) { + for (key, new_item) in new.iter() { + match existing.get_mut(key) { + Some(existing_item) => merge_item(existing_item, new_item), + None => { + existing.insert(key, new_item.clone()); + } + } + } +} + +fn merge_item(existing: &mut Item, new: &Item) { + match (existing.as_table_mut(), new.as_table()) { + (Some(e), Some(n)) => { + for (key, new_child) in n.iter() { + match e.get_mut(key) { + Some(existing_child) => merge_item(existing_child, new_child), + None => { + e.insert(key, new_child.clone()); + } + } + } + } + _ => *existing = new.clone(), + } +} + impl Config { pub fn config_path() -> Option { paths::config_file() @@ -585,13 +534,34 @@ impl Config { Ok(config) } - #[allow(dead_code)] pub fn save(&self) -> Result<(), Box> { let path = Self::config_path().ok_or("Could not determine config path")?; paths::ensure_parent_dir(&path)?; - let content = toml::to_string_pretty(self)?; + let new_content = toml::to_string_pretty(self)?; + + // If a config file already exists, merge into it to preserve comments and + // any keys the user has added that are not part of the Config struct. + let content = if path.exists() { + let existing = std::fs::read_to_string(&path)?; + match existing.parse::() { + Ok(mut doc) => { + if let Ok(new_doc) = new_content.parse::() { + merge_toml_doc(&mut doc, &new_doc); + } + doc.to_string() + } + Err(_) => { + // Existing file is malformed — fall back to full rewrite. + warn!("Existing config is malformed; overwriting with current settings"); + new_content + } + } + } else { + new_content + }; + std::fs::write(&path, content)?; info!("Saved config to {:?}", path); Ok(()) diff --git a/crates/owlry-core/src/filter.rs b/crates/owlry-core/src/filter.rs index e5dffdc..1f4f0c4 100644 --- a/crates/owlry-core/src/filter.rs +++ b/crates/owlry-core/src/filter.rs @@ -26,11 +26,17 @@ pub struct ParsedQuery { } impl ProviderFilter { - /// Create filter from CLI args and config + /// Create filter from CLI args and config. + /// + /// `tabs` is `general.tabs` from config and drives which provider tabs are + /// shown in the UI when no explicit CLI mode is active. It has no effect on + /// query routing: when no CLI mode is set, `accept_all=true` causes + /// `is_active()` to return `true` for every provider regardless. pub fn new( cli_mode: Option, cli_providers: Option>, config_providers: &ProvidersConfig, + tabs: &[String], ) -> Self { let accept_all = cli_mode.is_none() && cli_providers.is_none(); @@ -41,50 +47,23 @@ impl ProviderFilter { // --providers overrides config providers.into_iter().collect() } else { - // Use config file settings, default to apps only - let mut set = HashSet::new(); - // Core providers - if config_providers.applications { - set.insert(ProviderType::Application); - } - if config_providers.commands { - set.insert(ProviderType::Command); - } - // Plugin providers - use Plugin(type_id) for all - if config_providers.uuctl { - set.insert(ProviderType::Plugin("uuctl".to_string())); - } - if config_providers.system { - set.insert(ProviderType::Plugin("system".to_string())); - } - if config_providers.ssh { - set.insert(ProviderType::Plugin("ssh".to_string())); - } - if config_providers.clipboard { - set.insert(ProviderType::Plugin("clipboard".to_string())); - } - if config_providers.bookmarks { - set.insert(ProviderType::Plugin("bookmarks".to_string())); - } - if config_providers.emoji { - set.insert(ProviderType::Plugin("emoji".to_string())); - } - if config_providers.scripts { - set.insert(ProviderType::Plugin("scripts".to_string())); - } - // Dynamic providers - if config_providers.files { - set.insert(ProviderType::Plugin("filesearch".to_string())); - } - if config_providers.calculator { - set.insert(ProviderType::Plugin("calc".to_string())); - } - if config_providers.websearch { - set.insert(ProviderType::Plugin("websearch".to_string())); - } - // Default to apps if nothing enabled + // No CLI restriction: accept_all=true, so is_active() returns true for + // everything. Build the enabled set only for UI tab display, driven by + // general.tabs. Falls back to Application + Command if tabs is empty. + let mut set: HashSet = tabs + .iter() + .map(|s| Self::mode_string_to_provider_type(s)) + .collect(); if set.is_empty() { - set.insert(ProviderType::Application); + if config_providers.applications { + set.insert(ProviderType::Application); + } + if config_providers.commands { + set.insert(ProviderType::Command); + } + if set.is_empty() { + set.insert(ProviderType::Application); + } } set }; @@ -114,7 +93,8 @@ impl ProviderFilter { } } - /// Toggle a provider on/off + /// Toggle a provider on/off. Clears accept_all so the enabled set is + /// actually used for routing — use restore_all_mode() to go back to All. pub fn toggle(&mut self, provider: ProviderType) { if self.enabled.contains(&provider) { self.enabled.remove(&provider); @@ -137,6 +117,7 @@ impl ProviderFilter { provider_debug, self.enabled ); } + self.accept_all = false; } /// Enable a specific provider @@ -156,6 +137,12 @@ impl ProviderFilter { pub fn set_single_mode(&mut self, provider: ProviderType) { self.enabled.clear(); self.enabled.insert(provider); + self.accept_all = false; + } + + /// Restore accept-all mode (used when cycling back to the "All" tab). + pub fn restore_all_mode(&mut self) { + self.accept_all = true; } /// Set prefix mode (from :app, :cmd, etc.) diff --git a/crates/owlry-core/src/providers/config_editor.rs b/crates/owlry-core/src/providers/config_editor.rs index 5738567..aa777e2 100644 --- a/crates/owlry-core/src/providers/config_editor.rs +++ b/crates/owlry-core/src/providers/config_editor.rs @@ -23,24 +23,15 @@ const SEARCH_ENGINES: &[&str] = &[ const BUILTIN_THEMES: &[&str] = &["owl"]; /// Boolean provider fields that can be toggled via CONFIG:toggle:providers.*. +/// Only built-in providers are listed here; plugins are enabled/disabled via +/// [plugins] disabled_plugins in config.toml or `owlry plugin enable/disable`. const PROVIDER_TOGGLES: &[(&str, &str)] = &[ ("applications", "Applications"), ("commands", "Commands"), - ("uuctl", "Systemd Units"), ("calculator", "Calculator"), ("converter", "Unit Converter"), - ("frecency", "Frecency Ranking"), - ("websearch", "Web Search"), ("system", "System Actions"), - ("ssh", "SSH Connections"), - ("clipboard", "Clipboard History"), - ("bookmarks", "Bookmarks"), - ("emoji", "Emoji Picker"), - ("scripts", "Scripts"), - ("files", "File Search"), - ("media", "Media Widget"), - ("weather", "Weather Widget"), - ("pomodoro", "Pomodoro Widget"), + ("frecency", "Frecency Ranking"), ]; /// Built-in config editor provider. Interprets query text as a navigation path @@ -70,12 +61,8 @@ impl ConfigProvider { false }; - if result { - if let Ok(cfg) = self.config.read() { - if let Err(e) = cfg.save() { - warn!("Failed to save config: {}", e); - } - } + if result && let Ok(cfg) = self.config.read() && let Err(e) = cfg.save() { + warn!("Failed to save config: {}", e); } result @@ -98,10 +85,6 @@ impl ConfigProvider { cfg.providers.commands = !cfg.providers.commands; true } - "providers.uuctl" => { - cfg.providers.uuctl = !cfg.providers.uuctl; - true - } "providers.calculator" => { cfg.providers.calculator = !cfg.providers.calculator; true @@ -110,52 +93,12 @@ impl ConfigProvider { cfg.providers.converter = !cfg.providers.converter; true } - "providers.frecency" => { - cfg.providers.frecency = !cfg.providers.frecency; - true - } - "providers.websearch" => { - cfg.providers.websearch = !cfg.providers.websearch; - true - } "providers.system" => { cfg.providers.system = !cfg.providers.system; true } - "providers.ssh" => { - cfg.providers.ssh = !cfg.providers.ssh; - true - } - "providers.clipboard" => { - cfg.providers.clipboard = !cfg.providers.clipboard; - true - } - "providers.bookmarks" => { - cfg.providers.bookmarks = !cfg.providers.bookmarks; - true - } - "providers.emoji" => { - cfg.providers.emoji = !cfg.providers.emoji; - true - } - "providers.scripts" => { - cfg.providers.scripts = !cfg.providers.scripts; - true - } - "providers.files" => { - cfg.providers.files = !cfg.providers.files; - true - } - "providers.media" => { - cfg.providers.media = !cfg.providers.media; - true - } - "providers.weather" => { - cfg.providers.weather = !cfg.providers.weather; - true - } - "providers.pomodoro" => { - cfg.providers.pomodoro = !cfg.providers.pomodoro; + "providers.frecency" => { + cfg.providers.frecency = !cfg.providers.frecency; true } "general.show_icons" => { @@ -762,21 +705,10 @@ fn get_provider_bool(cfg: &Config, field: &str) -> bool { match field { "applications" => cfg.providers.applications, "commands" => cfg.providers.commands, - "uuctl" => cfg.providers.uuctl, "calculator" => cfg.providers.calculator, "converter" => cfg.providers.converter, - "frecency" => cfg.providers.frecency, - "websearch" => cfg.providers.websearch, "system" => cfg.providers.system, - "ssh" => cfg.providers.ssh, - "clipboard" => cfg.providers.clipboard, - "bookmarks" => cfg.providers.bookmarks, - "emoji" => cfg.providers.emoji, - "scripts" => cfg.providers.scripts, - "files" => cfg.providers.files, - "media" => cfg.providers.media, - "weather" => cfg.providers.weather, - "pomodoro" => cfg.providers.pomodoro, + "frecency" => cfg.providers.frecency, _ => false, } } diff --git a/crates/owlry/src/app.rs b/crates/owlry/src/app.rs index fdcc1a1..dc629a2 100644 --- a/crates/owlry/src/app.rs +++ b/crates/owlry/src/app.rs @@ -91,17 +91,20 @@ impl OwlryApp { .iter() .map(|s| ProviderFilter::mode_string_to_provider_type(s)) .collect(); + let tabs = &config.borrow().general.tabs.clone(); if provider_types.len() == 1 { ProviderFilter::new( Some(provider_types[0].clone()), None, &config.borrow().providers, + tabs, ) } else { - ProviderFilter::new(None, Some(provider_types), &config.borrow().providers) + ProviderFilter::new(None, Some(provider_types), &config.borrow().providers, tabs) } } else { - ProviderFilter::new(None, None, &config.borrow().providers) + let tabs = config.borrow().general.tabs.clone(); + ProviderFilter::new(None, None, &config.borrow().providers, &tabs) }; let filter = Rc::new(RefCell::new(filter)); diff --git a/crates/owlry/src/ui/main_window.rs b/crates/owlry/src/ui/main_window.rs index 3557786..bdf48aa 100644 --- a/crates/owlry/src/ui/main_window.rs +++ b/crates/owlry/src/ui/main_window.rs @@ -394,7 +394,9 @@ impl MainWindow { format!("Search {}...", active.join(", ")) } - /// Build dynamic hints based on enabled providers + /// Build hints string for the status bar based on enabled built-in providers. + /// Plugin trigger hints (? web, / files, etc.) are not included here since + /// plugin availability is not tracked in ProvidersConfig. fn build_hints(config: &owlry_core::config::ProvidersConfig) -> String { let mut parts: Vec = vec![ "Tab: cycle".to_string(), @@ -403,38 +405,14 @@ impl MainWindow { "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.converter { + parts.push("> conv".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.push(":sys".to_string()); } parts.join(" ") @@ -1159,6 +1137,7 @@ impl MainWindow { for provider in tab_order { f.enable(provider.clone()); } + f.restore_all_mode(); } for (_, button) in buttons.borrow().iter() { button.set_active(true);