fix(owlry-core,owlry): preserve config on save, fix tab filtering, clean provider fields

- Config::save() now merges into existing file via toml_edit to preserve
  user comments and unknown keys instead of overwriting
- Remove 16 dead ProvidersConfig plugin-toggle fields (uuctl, ssh, clipboard,
  bookmarks, emoji, scripts, files, media, weather_*, pomodoro_*) that became
  unreachable after accept_all=true was introduced
- Wire general.tabs to ProviderFilter::new() to drive UI tab display
- Fix set_single_mode() and toggle() not clearing accept_all, making tab
  filtering a no-op in default launch mode
- Add restore_all_mode() for cycle-back-to-All path in tab cycling
- Reduce PROVIDER_TOGGLES to built-in providers only
This commit is contained in:
2026-04-06 01:59:48 +02:00
parent 7863de9971
commit 178f81082a
5 changed files with 116 additions and 245 deletions

View File

@@ -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<String>,
/// Location for weather (city name or coordinates)
#[serde(default)]
pub weather_location: Option<String>,
/// 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<PathBuf> {
paths::config_file()
@@ -585,13 +534,34 @@ impl Config {
Ok(config)
}
#[allow(dead_code)]
pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
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::<toml_edit::DocumentMut>() {
Ok(mut doc) => {
if let Ok(new_doc) = new_content.parse::<toml_edit::DocumentMut>() {
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(())

View File

@@ -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<ProviderType>,
cli_providers: Option<Vec<ProviderType>>,
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<ProviderType> = 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.)

View File

@@ -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,
}
}

View File

@@ -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));

View File

@@ -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<String> = 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);