From 774b2a47008dabc9a04b715031a7e43bea29bbfe Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 9 Apr 2026 21:16:36 +0200 Subject: [PATCH] feat: configurable tab labels and search nouns from plugin metadata Script plugins can now declare tab_label and search_noun in their plugin.toml [[providers]] section. These flow through the Provider trait, IPC ProviderDesc, and into the UI via provider_meta::resolve(). Unknown plugins auto-generate labels from type_id instead of showing a generic "Plugin" label. --- CLAUDE.md | 5 +- README.md | 26 +++ crates/owlry-core/src/ipc.rs | 4 + crates/owlry-core/src/plugins/manifest.rs | 6 + .../owlry-core/src/plugins/runtime_loader.rs | 10 + crates/owlry-core/src/providers/mod.rs | 18 ++ crates/owlry-core/src/server.rs | 2 + crates/owlry-core/tests/ipc_test.rs | 2 + crates/owlry-lua/src/api/provider.rs | 4 + crates/owlry-lua/src/lib.rs | 6 + crates/owlry-lua/src/loader.rs | 4 + crates/owlry-lua/src/manifest.rs | 4 + crates/owlry-rune/src/api.rs | 2 + crates/owlry-rune/src/lib.rs | 12 + crates/owlry-rune/src/loader.rs | 2 + crates/owlry-rune/src/manifest.rs | 4 + crates/owlry/src/backend.rs | 19 +- crates/owlry/src/client.rs | 2 + crates/owlry/src/plugin_commands.rs | 2 + crates/owlry/src/ui/main_window.rs | 108 ++++++--- crates/owlry/src/ui/provider_meta.rs | 206 +++++++++++++----- 21 files changed, 358 insertions(+), 90 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ac6298c..fb92845 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -276,7 +276,7 @@ Communication uses newline-delimited JSON over a Unix domain socket at `$XDG_RUN | Type | Purpose | |------|---------| | `Results` | Search results with `Vec` | -| `Providers` | Provider list with `Vec` | +| `Providers` | Provider list with `Vec` (includes optional `tab_label`, `search_noun`) | | `SubmenuItems` | Submenu actions for a plugin | | `Ack` | Success acknowledgement | | `Error` | Error with message | @@ -331,6 +331,8 @@ All other providers are native plugins in the separate `owlry-plugins` repo (`so **Submenu System**: Plugins can return items with `SUBMENU:plugin_id:data` commands. When selected, the plugin is queried with `?SUBMENU:data` to get action items (e.g., systemd service actions). +**Provider Display Metadata**: Script plugins can declare `tab_label` and `search_noun` in their `plugin.toml` `[[providers]]` section, or at runtime via `owlry.provider.register()` (Lua) / `register_provider()` (Rune). These flow through the `Provider` trait → `ProviderDescriptor` → IPC `ProviderDesc` → UI. The UI resolves metadata via `provider_meta::resolve()`, checking IPC metadata first, then falling back to the hardcoded match table. Unknown plugins auto-generate labels from `type_id` (e.g., "hs" → "Hs"). Native plugins use the hardcoded match table since `owlry-plugin-api::ProviderInfo` doesn't include these fields (deferred to API v5). + ### Plugin API Native plugins use the ABI-stable interface in `owlry-plugin-api`: @@ -428,6 +430,7 @@ Plugins live in a separate repository: `somegit.dev/Owlibou/owlry-plugins` - **ABI stability**: Plugin interface uses `abi_stable` crate for safe Rust dynamic linking - **Plugin API v3**: Adds `position` (Normal/Widget) and `priority` fields to ProviderInfo - **ProviderType simplification**: Core uses only `Application`, `Command`, `Dmenu`, `Plugin(String)` - all plugin-specific types removed from core +- **Provider display metadata**: Tab labels and search nouns flow from plugin.toml → `Provider` trait → IPC `ProviderDesc` → UI `provider_meta::resolve()`. Known native plugins use a hardcoded match table; script plugins declare metadata in their manifest or at runtime registration ## Dependencies (Rust 1.90+, GTK 4.12+) diff --git a/README.md b/README.md index 26dbae3..20a461d 100644 --- a/README.md +++ b/README.md @@ -459,6 +459,32 @@ owlry plugin create my-plugin -r rune # Rune owlry plugin run [args...] ``` +### Plugin Display Metadata + +Plugins can declare how they appear in the UI via their `plugin.toml`. Without these fields, the tab label and search placeholder default to a capitalized version of the `type_id`. + +```toml +[[providers]] +id = "my-plugin" +name = "My Plugin" +type_id = "myplugin" +tab_label = "My Plugin" # Tab button and page title +search_noun = "plugin items" # Search placeholder: "Search plugin items..." +``` + +Both fields are optional. If omitted, the UI auto-generates from `type_id` (e.g., `type_id = "hs"` shows "Hs" as the tab label). + +Lua plugins can also set these at runtime via `owlry.provider.register()`: + +```lua +owlry.provider.register({ + id = "my-plugin", + name = "My Plugin", + tab_label = "My Plugin", + search_noun = "plugin items", +}) +``` + ### Creating Custom Plugins See [docs/PLUGIN_DEVELOPMENT.md](docs/PLUGIN_DEVELOPMENT.md) for: diff --git a/crates/owlry-core/src/ipc.rs b/crates/owlry-core/src/ipc.rs index 340d2e6..628d895 100644 --- a/crates/owlry-core/src/ipc.rs +++ b/crates/owlry-core/src/ipc.rs @@ -90,4 +90,8 @@ pub struct ProviderDesc { pub prefix: Option, pub icon: String, pub position: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_label: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub search_noun: Option, } diff --git a/crates/owlry-core/src/plugins/manifest.rs b/crates/owlry-core/src/plugins/manifest.rs index ef47e6d..6972750 100644 --- a/crates/owlry-core/src/plugins/manifest.rs +++ b/crates/owlry-core/src/plugins/manifest.rs @@ -73,6 +73,12 @@ pub struct ProviderSpec { pub provider_type: String, #[serde(default)] pub type_id: Option, + /// Short label for UI tab button (e.g., "Shutdown"). Defaults to provider name. + #[serde(default)] + pub tab_label: Option, + /// Noun for search placeholder (e.g., "shutdown actions"). Defaults to provider name. + #[serde(default)] + pub search_noun: Option, } fn default_provider_type() -> String { diff --git a/crates/owlry-core/src/plugins/runtime_loader.rs b/crates/owlry-core/src/plugins/runtime_loader.rs index 84b7ded..50045b3 100644 --- a/crates/owlry-core/src/plugins/runtime_loader.rs +++ b/crates/owlry-core/src/plugins/runtime_loader.rs @@ -41,6 +41,8 @@ pub struct ScriptProviderInfo { pub default_icon: RString, pub is_static: bool, pub prefix: owlry_plugin_api::ROption, + pub tab_label: owlry_plugin_api::ROption, + pub search_noun: owlry_plugin_api::ROption, } // Type alias for backwards compatibility @@ -273,6 +275,14 @@ impl Provider for RuntimeProvider { fn items(&self) -> &[LaunchItem] { &self.items } + + fn tab_label(&self) -> Option<&str> { + self.info.tab_label.as_ref().map(|s| s.as_str()).into() + } + + fn search_noun(&self) -> Option<&str> { + self.info.search_noun.as_ref().map(|s| s.as_str()).into() + } } // RuntimeProvider is Send + Sync because: diff --git a/crates/owlry-core/src/providers/mod.rs b/crates/owlry-core/src/providers/mod.rs index 432e1d1..0b24f96 100644 --- a/crates/owlry-core/src/providers/mod.rs +++ b/crates/owlry-core/src/providers/mod.rs @@ -42,6 +42,8 @@ pub struct ProviderDescriptor { pub prefix: Option, pub icon: String, pub position: String, + pub tab_label: Option, + pub search_noun: Option, } /// Trust level of a [`LaunchItem`]'s command, used to gate `sh -c` execution. @@ -148,6 +150,14 @@ pub trait Provider: Send + Sync { fn provider_type(&self) -> ProviderType; fn refresh(&mut self); fn items(&self) -> &[LaunchItem]; + /// Short label for UI tab button (e.g., "Shutdown"). None = use default. + fn tab_label(&self) -> Option<&str> { + None + } + /// Noun for search placeholder (e.g., "shutdown actions"). None = use default. + fn search_noun(&self) -> Option<&str> { + None + } } /// Trait for built-in providers that produce results per-keystroke. @@ -1022,6 +1032,8 @@ impl ProviderManager { prefix, icon, position: "normal".to_string(), + tab_label: provider.tab_label().map(String::from), + search_noun: provider.search_noun().map(String::from), }); } @@ -1033,6 +1045,8 @@ impl ProviderManager { prefix: provider.prefix().map(String::from), icon: provider.icon().to_string(), position: provider.position_str().to_string(), + tab_label: None, + search_noun: None, }); } @@ -1044,6 +1058,8 @@ impl ProviderManager { prefix: provider.prefix().map(String::from), icon: provider.icon().to_string(), position: provider.position_str().to_string(), + tab_label: None, + search_noun: None, }); } @@ -1055,6 +1071,8 @@ impl ProviderManager { prefix: provider.prefix().map(String::from), icon: provider.icon().to_string(), position: provider.position_str().to_string(), + tab_label: None, + search_noun: None, }); } diff --git a/crates/owlry-core/src/server.rs b/crates/owlry-core/src/server.rs index 06bfb24..e861054 100644 --- a/crates/owlry-core/src/server.rs +++ b/crates/owlry-core/src/server.rs @@ -472,6 +472,8 @@ fn descriptor_to_desc(desc: crate::providers::ProviderDescriptor) -> ProviderDes prefix: desc.prefix, icon: desc.icon, position: desc.position, + tab_label: desc.tab_label, + search_noun: desc.search_noun, } } diff --git a/crates/owlry-core/tests/ipc_test.rs b/crates/owlry-core/tests/ipc_test.rs index 6498309..0e88b54 100644 --- a/crates/owlry-core/tests/ipc_test.rs +++ b/crates/owlry-core/tests/ipc_test.rs @@ -64,6 +64,8 @@ fn test_providers_response() { prefix: Some(":app".into()), icon: "application-x-executable".into(), position: "normal".into(), + tab_label: None, + search_noun: None, }], }; let json = serde_json::to_string(&resp).unwrap(); diff --git a/crates/owlry-lua/src/api/provider.rs b/crates/owlry-lua/src/api/provider.rs index 3096fb1..542e51f 100644 --- a/crates/owlry-lua/src/api/provider.rs +++ b/crates/owlry-lua/src/api/provider.rs @@ -34,6 +34,8 @@ fn register_provider(lua: &Lua, config: Table) -> LuaResult<()> { .get::>("default_icon")? .unwrap_or_else(|| "application-x-addon".to_string()); let prefix: Option = config.get("prefix")?; + let tab_label: Option = config.get("tab_label")?; + let search_noun: Option = config.get("search_noun")?; // Check if it's a dynamic provider (has query function) or static (has refresh) let has_query: bool = config.contains_key("query")?; @@ -70,6 +72,8 @@ fn register_provider(lua: &Lua, config: Table) -> LuaResult<()> { default_icon, prefix, is_dynamic, + tab_label, + search_noun, }); }); diff --git a/crates/owlry-lua/src/lib.rs b/crates/owlry-lua/src/lib.rs index f2ef492..e3da514 100644 --- a/crates/owlry-lua/src/lib.rs +++ b/crates/owlry-lua/src/lib.rs @@ -113,6 +113,10 @@ pub struct LuaProviderInfo { pub is_static: bool, /// Optional prefix trigger pub prefix: ROption, + /// Short label for UI tab button + pub tab_label: ROption, + /// Noun for search placeholder + pub search_noun: ROption, } /// Internal runtime state @@ -184,6 +188,8 @@ impl LuaRuntimeState { default_icon: RString::from(reg.default_icon.as_str()), is_static: !reg.is_dynamic, prefix: reg.prefix.map(RString::from).into(), + tab_label: reg.tab_label.map(RString::from).into(), + search_noun: reg.search_noun.map(RString::from).into(), }); } } diff --git a/crates/owlry-lua/src/loader.rs b/crates/owlry-lua/src/loader.rs index 51578d7..fc17ad2 100644 --- a/crates/owlry-lua/src/loader.rs +++ b/crates/owlry-lua/src/loader.rs @@ -19,6 +19,8 @@ pub struct ProviderRegistration { pub default_icon: String, pub prefix: Option, pub is_dynamic: bool, + pub tab_label: Option, + pub search_noun: Option, } /// A loaded plugin instance @@ -113,6 +115,8 @@ impl LoadedPlugin { .unwrap_or_else(|| "application-x-addon".to_string()), prefix: decl.prefix.clone(), is_dynamic: decl.provider_type == "dynamic", + tab_label: decl.tab_label.clone(), + search_noun: decl.search_noun.clone(), }); } } diff --git a/crates/owlry-lua/src/manifest.rs b/crates/owlry-lua/src/manifest.rs index 5fdcc3f..19c9b93 100644 --- a/crates/owlry-lua/src/manifest.rs +++ b/crates/owlry-lua/src/manifest.rs @@ -34,6 +34,10 @@ pub struct ProviderDecl { pub provider_type: String, #[serde(default)] pub type_id: Option, + #[serde(default)] + pub tab_label: Option, + #[serde(default)] + pub search_noun: Option, } fn default_provider_type() -> String { diff --git a/crates/owlry-rune/src/api.rs b/crates/owlry-rune/src/api.rs index 8fbac12..3fe3044 100644 --- a/crates/owlry-rune/src/api.rs +++ b/crates/owlry-rune/src/api.rs @@ -16,6 +16,8 @@ pub struct ProviderRegistration { pub default_icon: String, pub is_static: bool, pub prefix: Option, + pub tab_label: Option, + pub search_noun: Option, } /// An item returned by a provider diff --git a/crates/owlry-rune/src/lib.rs b/crates/owlry-rune/src/lib.rs index 4f6651b..ef7749b 100644 --- a/crates/owlry-rune/src/lib.rs +++ b/crates/owlry-rune/src/lib.rs @@ -55,6 +55,8 @@ pub struct RuneProviderInfo { pub default_icon: RString, pub is_static: bool, pub prefix: ROption, + pub tab_label: ROption, + pub search_noun: ROption, } /// Opaque handle to runtime state @@ -126,6 +128,16 @@ extern "C" fn runtime_init(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> Ru .as_ref() .map(|p| RString::from(p.as_str())) .into(), + tab_label: reg + .tab_label + .as_ref() + .map(|s| RString::from(s.as_str())) + .into(), + search_noun: reg + .search_noun + .as_ref() + .map(|s| RString::from(s.as_str())) + .into(), }); } state.plugins.insert(id, plugin); diff --git a/crates/owlry-rune/src/loader.rs b/crates/owlry-rune/src/loader.rs index e660f09..136dc35 100644 --- a/crates/owlry-rune/src/loader.rs +++ b/crates/owlry-rune/src/loader.rs @@ -70,6 +70,8 @@ impl LoadedPlugin { default_icon: decl.icon.clone().unwrap_or_else(|| "application-x-addon".to_string()), is_static: decl.provider_type != "dynamic", prefix: decl.prefix.clone(), + tab_label: decl.tab_label.clone(), + search_noun: decl.search_noun.clone(), }); } } diff --git a/crates/owlry-rune/src/manifest.rs b/crates/owlry-rune/src/manifest.rs index bfc3f1e..1929a90 100644 --- a/crates/owlry-rune/src/manifest.rs +++ b/crates/owlry-rune/src/manifest.rs @@ -29,6 +29,10 @@ pub struct ProviderDecl { pub provider_type: String, #[serde(default)] pub type_id: Option, + #[serde(default)] + pub tab_label: Option, + #[serde(default)] + pub search_noun: Option, } fn default_provider_type() -> String { diff --git a/crates/owlry/src/backend.rs b/crates/owlry/src/backend.rs index a5fad79..1e175f9 100644 --- a/crates/owlry/src/backend.rs +++ b/crates/owlry/src/backend.rs @@ -8,7 +8,7 @@ use log::warn; use owlry_core::config::Config; use owlry_core::data::FrecencyStore; use owlry_core::filter::ProviderFilter; -use owlry_core::ipc::ResultItem; +use owlry_core::ipc::{ProviderDesc, ResultItem}; use owlry_core::providers::{ItemSource, LaunchItem, ProviderManager, ProviderType}; use std::sync::{Arc, Mutex}; @@ -349,13 +349,12 @@ impl SearchBackend { } } - /// Get available provider type IDs from the daemon, or from local manager. - #[allow(dead_code)] - pub fn available_provider_ids(&mut self) -> Vec { + /// Get available provider descriptors from the daemon, or from local manager. + pub fn available_providers(&mut self) -> Vec { match self { SearchBackend::Daemon(handle) => match handle.client.lock() { Ok(mut client) => match client.providers() { - Ok(descs) => descs.into_iter().map(|d| d.id).collect(), + Ok(descs) => descs, Err(e) => { warn!("IPC providers query failed: {}", e); Vec::new() @@ -369,7 +368,15 @@ impl SearchBackend { SearchBackend::Local { providers, .. } => providers .available_providers() .into_iter() - .map(|d| d.id) + .map(|d| ProviderDesc { + id: d.id, + name: d.name, + prefix: d.prefix, + icon: d.icon, + position: d.position, + tab_label: d.tab_label, + search_noun: d.search_noun, + }) .collect(), } } diff --git a/crates/owlry/src/client.rs b/crates/owlry/src/client.rs index e646a37..38e88ef 100644 --- a/crates/owlry/src/client.rs +++ b/crates/owlry/src/client.rs @@ -355,6 +355,8 @@ mod tests { prefix: Some(":app".into()), icon: "application-x-executable".into(), position: "normal".into(), + tab_label: None, + search_noun: None, }], }; diff --git a/crates/owlry/src/plugin_commands.rs b/crates/owlry/src/plugin_commands.rs index e4972f4..bb244e2 100644 --- a/crates/owlry/src/plugin_commands.rs +++ b/crates/owlry/src/plugin_commands.rs @@ -841,6 +841,8 @@ type = "static" type_id = "{type_id}" icon = "application-x-addon" # prefix = ":{type_id}" +# tab_label = "{display}" +# search_noun = "{type_id} items" "#, name = name, display = display, diff --git a/crates/owlry/src/ui/main_window.rs b/crates/owlry/src/ui/main_window.rs index b7314cb..33a3ed4 100644 --- a/crates/owlry/src/ui/main_window.rs +++ b/crates/owlry/src/ui/main_window.rs @@ -11,6 +11,7 @@ use gtk4::{ use log::info; use owlry_core::config::Config; use owlry_core::filter::ProviderFilter; +use owlry_core::ipc::ProviderDesc; use owlry_core::providers::{ItemSource, LaunchItem, ProviderType}; #[cfg(feature = "dev-logging")] @@ -77,6 +78,8 @@ pub struct MainWindow { debounce_source: Rc>>, /// Whether we're in dmenu mode (stdin pipe input) is_dmenu_mode: bool, + /// Provider display metadata from daemon (keyed by provider id) + provider_descs: Rc>, } impl MainWindow { @@ -143,8 +146,24 @@ impl MainWindow { let tab_strings: Vec = enabled.iter().map(|p| p.to_string()).collect(); let tab_order = Rc::new(enabled); + // Fetch provider display metadata from daemon/local backend + let provider_descs: HashMap = backend + .borrow_mut() + .available_providers() + .into_iter() + .map(|d| (d.id.clone(), d)) + .collect(); + let provider_descs = Rc::new(provider_descs); + + // Update mode label with resolved plugin name (if applicable) + mode_label.set_label(&Self::mode_display_name( + &filter.borrow(), + &provider_descs, + )); + // Create toggle buttons for each enabled provider - let filter_buttons = Self::create_filter_buttons(&filter_tabs, &filter, &tab_strings); + let filter_buttons = + Self::create_filter_buttons(&filter_tabs, &filter, &tab_strings, &provider_descs); let filter_buttons = Rc::new(RefCell::new(filter_buttons)); header_box.append(&mode_label); @@ -153,7 +172,7 @@ impl MainWindow { // Search entry with dynamic placeholder (or custom prompt if provided) let placeholder = custom_prompt .clone() - .unwrap_or_else(|| Self::build_placeholder(&filter.borrow())); + .unwrap_or_else(|| Self::build_placeholder(&filter.borrow(), &provider_descs)); let search_entry = Entry::builder() .placeholder_text(&placeholder) .hexpand(true) @@ -223,6 +242,7 @@ impl MainWindow { lazy_state, debounce_source: Rc::new(RefCell::new(None)), is_dmenu_mode, + provider_descs, }; main_window.setup_signals(); @@ -267,6 +287,7 @@ impl MainWindow { container: &GtkBox, filter: &Rc>, tabs: &[String], + descs: &HashMap, ) -> HashMap { let mut buttons = HashMap::new(); @@ -280,7 +301,7 @@ impl MainWindow { } }; - let base_label = Self::provider_tab_label(&provider_type); + let meta = Self::provider_meta(&provider_type, descs); // Show number hint in the label for first 9 tabs (using superscript) let label = if idx < 9 { let superscript = match idx + 1 { @@ -295,9 +316,9 @@ impl MainWindow { 9 => "⁹", _ => "", }; - format!("{}{}", base_label, superscript) + format!("{}{}", meta.tab_label, superscript) } else { - base_label.to_string() + meta.tab_label.clone() }; let shortcut = format!("Ctrl+{}", idx + 1); @@ -308,8 +329,7 @@ impl MainWindow { .build(); button.add_css_class("owlry-filter-button"); - let css_class = Self::provider_css_class(&provider_type); - button.add_css_class(css_class); + button.add_css_class(&meta.css_class); container.append(&button); buttons.insert(provider_type, button); @@ -318,21 +338,42 @@ impl MainWindow { buttons } - /// Get display label for a provider tab - /// Core types have fixed labels; plugins derive labels from type_id - fn provider_tab_label(provider: &ProviderType) -> &'static str { - provider_meta::meta_for(provider).tab_label + /// Get the mode display name, resolving plugin names via IPC metadata. + fn mode_display_name( + filter: &ProviderFilter, + descs: &HashMap, + ) -> String { + let base = filter.mode_display_name(); + // "Plugin" is the generic fallback — resolve it to the actual name + if base == "Plugin" { + let enabled = filter.enabled_providers(); + if enabled.len() == 1 + && let ProviderType::Plugin(_) = &enabled[0] + { + return Self::provider_meta(&enabled[0], descs).tab_label; + } + } + base.to_string() } - fn provider_css_class(provider: &ProviderType) -> &'static str { - provider_meta::meta_for(provider).css_class + /// Resolve display metadata for a provider, checking IPC data first. + fn provider_meta( + provider: &ProviderType, + descs: &HashMap, + ) -> provider_meta::ProviderMeta { + let type_id = provider.to_string(); + let desc = descs.get(&type_id); + provider_meta::resolve(provider, desc) } - fn build_placeholder(filter: &ProviderFilter) -> String { - let active: Vec<&str> = filter + fn build_placeholder( + filter: &ProviderFilter, + descs: &HashMap, + ) -> String { + let active: Vec = filter .enabled_providers() .iter() - .map(|p| provider_meta::meta_for(p).search_noun) + .map(|p| Self::provider_meta(p, descs).search_noun) .collect(); format!("Search {}...", active.join(", ")) @@ -451,6 +492,7 @@ impl MainWindow { search_entry: &Entry, filter: &Rc>, config: &Rc>, + descs: &HashMap, ) { #[cfg(feature = "dev-logging")] debug!("[UI] Exiting submenu"); @@ -463,9 +505,12 @@ impl MainWindow { }; // Restore UI - mode_label.set_label(filter.borrow().mode_display_name()); + mode_label.set_label(&Self::mode_display_name(&filter.borrow(), descs)); hints_label.set_label(&Self::build_hints(&config.borrow().providers)); - search_entry.set_placeholder_text(Some(&Self::build_placeholder(&filter.borrow()))); + search_entry.set_placeholder_text(Some(&Self::build_placeholder( + &filter.borrow(), + descs, + ))); search_entry.set_text(&saved_search); // Trigger refresh by emitting changed signal @@ -484,6 +529,7 @@ impl MainWindow { let submenu_state = self.submenu_state.clone(); let lazy_state = self.lazy_state.clone(); let debounce_source = self.debounce_source.clone(); + let descs = self.provider_descs.clone(); self.search_entry.connect_changed(move |entry| { let raw_query = entry.text(); @@ -532,7 +578,7 @@ impl MainWindow { f.set_prefix(parsed.prefix.clone()); } - mode_label.set_label(filter.borrow().mode_display_name()); + mode_label.set_label(&Self::mode_display_name(&filter.borrow(), &descs)); if let Some(ref prefix) = parsed.prefix { let prefix_name = match prefix { @@ -779,6 +825,7 @@ impl MainWindow { let search_entry = self.search_entry.clone(); let mode_label = self.mode_label.clone(); let ptype = provider_type.clone(); + let descs = self.provider_descs.clone(); button.connect_toggled(move |btn| { { @@ -789,8 +836,11 @@ impl MainWindow { f.disable(ptype.clone()); } } - mode_label.set_label(filter.borrow().mode_display_name()); - search_entry.set_placeholder_text(Some(&Self::build_placeholder(&filter.borrow()))); + mode_label.set_label(&Self::mode_display_name(&filter.borrow(), &descs)); + search_entry.set_placeholder_text(Some(&Self::build_placeholder( + &filter.borrow(), + &descs, + ))); search_entry.emit_by_name::<()>("changed", &[]); }); } @@ -811,6 +861,7 @@ impl MainWindow { let tab_order = self.tab_order.clone(); let is_dmenu_mode = self.is_dmenu_mode; let lazy_state_for_keys = self.lazy_state.clone(); + let provider_descs = self.provider_descs.clone(); key_controller.connect_key_pressed(move |_, key, _, modifiers| { let ctrl = modifiers.contains(gtk4::gdk::ModifierType::CONTROL_MASK); @@ -833,6 +884,7 @@ impl MainWindow { &search_entry, &filter, &config, + &provider_descs, ); gtk4::glib::Propagation::Stop } else { @@ -854,6 +906,7 @@ impl MainWindow { &search_entry, &filter, &config, + &provider_descs, ); gtk4::glib::Propagation::Stop } else { @@ -901,6 +954,7 @@ impl MainWindow { &mode_label, &tab_order, !shift, + &provider_descs, ); } gtk4::glib::Propagation::Stop @@ -914,6 +968,7 @@ impl MainWindow { &mode_label, &tab_order, false, + &provider_descs, ); } gtk4::glib::Propagation::Stop @@ -953,6 +1008,7 @@ impl MainWindow { &filter_buttons, &search_entry, &mode_label, + &provider_descs, ); } else { info!( @@ -1041,6 +1097,7 @@ impl MainWindow { mode_label: &Label, tab_order: &[ProviderType], forward: bool, + descs: &HashMap, ) { if tab_order.is_empty() { return; @@ -1113,8 +1170,8 @@ impl MainWindow { } } - mode_label.set_label(filter.borrow().mode_display_name()); - entry.set_placeholder_text(Some(&Self::build_placeholder(&filter.borrow()))); + mode_label.set_label(&Self::mode_display_name(&filter.borrow(), descs)); + entry.set_placeholder_text(Some(&Self::build_placeholder(&filter.borrow(), descs))); entry.emit_by_name::<()>("changed", &[]); } @@ -1124,6 +1181,7 @@ impl MainWindow { buttons: &Rc>>, entry: &Entry, mode_label: &Label, + descs: &HashMap, ) { { let mut f = filter.borrow_mut(); @@ -1134,8 +1192,8 @@ impl MainWindow { button.set_active(filter.borrow().is_enabled(provider)); } - mode_label.set_label(filter.borrow().mode_display_name()); - entry.set_placeholder_text(Some(&Self::build_placeholder(&filter.borrow()))); + mode_label.set_label(&Self::mode_display_name(&filter.borrow(), descs)); + entry.set_placeholder_text(Some(&Self::build_placeholder(&filter.borrow(), descs))); entry.emit_by_name::<()>("changed", &[]); } diff --git a/crates/owlry/src/ui/provider_meta.rs b/crates/owlry/src/ui/provider_meta.rs index 0af2994..60ffee5 100644 --- a/crates/owlry/src/ui/provider_meta.rs +++ b/crates/owlry/src/ui/provider_meta.rs @@ -1,101 +1,191 @@ +use owlry_core::ipc::ProviderDesc; use owlry_core::providers::ProviderType; /// Display metadata for a provider. pub struct ProviderMeta { - pub tab_label: &'static str, - pub css_class: &'static str, - pub search_noun: &'static str, + pub tab_label: String, + pub css_class: String, + pub search_noun: String, } -/// Return display metadata for a provider type. -pub fn meta_for(provider: &ProviderType) -> ProviderMeta { +/// Resolve display metadata, checking IPC-provided metadata first, then hardcoded fallback. +pub fn resolve(provider: &ProviderType, desc: Option<&ProviderDesc>) -> ProviderMeta { + let fallback = hardcoded(provider); + ProviderMeta { + tab_label: desc + .and_then(|d| d.tab_label.clone()) + .unwrap_or(fallback.tab_label), + css_class: fallback.css_class, // always auto-derived from type_id + search_noun: desc + .and_then(|d| d.search_noun.clone()) + .unwrap_or(fallback.search_noun), + } +} + +/// Hardcoded metadata for known provider types. Unknown plugins get auto-generated defaults. +fn hardcoded(provider: &ProviderType) -> ProviderMeta { match provider { ProviderType::Application => ProviderMeta { - tab_label: "Apps", - css_class: "owlry-filter-app", - search_noun: "applications", + tab_label: "Apps".to_string(), + css_class: "owlry-filter-app".to_string(), + search_noun: "applications".to_string(), }, ProviderType::Command => ProviderMeta { - tab_label: "Cmds", - css_class: "owlry-filter-cmd", - search_noun: "commands", + tab_label: "Cmds".to_string(), + css_class: "owlry-filter-cmd".to_string(), + search_noun: "commands".to_string(), }, ProviderType::Dmenu => ProviderMeta { - tab_label: "Dmenu", - css_class: "owlry-filter-dmenu", - search_noun: "options", + tab_label: "Dmenu".to_string(), + css_class: "owlry-filter-dmenu".to_string(), + search_noun: "options".to_string(), }, ProviderType::Plugin(type_id) => match type_id.as_str() { "bookmarks" => ProviderMeta { - tab_label: "Bookmarks", - css_class: "owlry-filter-bookmark", - search_noun: "bookmarks", + tab_label: "Bookmarks".to_string(), + css_class: "owlry-filter-bookmark".to_string(), + search_noun: "bookmarks".to_string(), }, "calc" => ProviderMeta { - tab_label: "Calc", - css_class: "owlry-filter-calc", - search_noun: "calculator", + tab_label: "Calc".to_string(), + css_class: "owlry-filter-calc".to_string(), + search_noun: "calculator".to_string(), }, "clipboard" => ProviderMeta { - tab_label: "Clip", - css_class: "owlry-filter-clip", - search_noun: "clipboard", + tab_label: "Clip".to_string(), + css_class: "owlry-filter-clip".to_string(), + search_noun: "clipboard".to_string(), }, "emoji" => ProviderMeta { - tab_label: "Emoji", - css_class: "owlry-filter-emoji", - search_noun: "emoji", + tab_label: "Emoji".to_string(), + css_class: "owlry-filter-emoji".to_string(), + search_noun: "emoji".to_string(), }, "filesearch" => ProviderMeta { - tab_label: "Files", - css_class: "owlry-filter-file", - search_noun: "files", + tab_label: "Files".to_string(), + css_class: "owlry-filter-file".to_string(), + search_noun: "files".to_string(), }, "media" => ProviderMeta { - tab_label: "Media", - css_class: "owlry-filter-media", - search_noun: "media", + tab_label: "Media".to_string(), + css_class: "owlry-filter-media".to_string(), + search_noun: "media".to_string(), }, "pomodoro" => ProviderMeta { - tab_label: "Pomo", - css_class: "owlry-filter-pomodoro", - search_noun: "pomodoro", + tab_label: "Pomo".to_string(), + css_class: "owlry-filter-pomodoro".to_string(), + search_noun: "pomodoro".to_string(), }, "scripts" => ProviderMeta { - tab_label: "Scripts", - css_class: "owlry-filter-script", - search_noun: "scripts", + tab_label: "Scripts".to_string(), + css_class: "owlry-filter-script".to_string(), + search_noun: "scripts".to_string(), }, "ssh" => ProviderMeta { - tab_label: "SSH", - css_class: "owlry-filter-ssh", - search_noun: "SSH hosts", + tab_label: "SSH".to_string(), + css_class: "owlry-filter-ssh".to_string(), + search_noun: "SSH hosts".to_string(), }, "system" => ProviderMeta { - tab_label: "System", - css_class: "owlry-filter-sys", - search_noun: "system", + tab_label: "System".to_string(), + css_class: "owlry-filter-sys".to_string(), + search_noun: "system".to_string(), }, "uuctl" => ProviderMeta { - tab_label: "uuctl", - css_class: "owlry-filter-uuctl", - search_noun: "uuctl units", + tab_label: "uuctl".to_string(), + css_class: "owlry-filter-uuctl".to_string(), + search_noun: "uuctl units".to_string(), }, "weather" => ProviderMeta { - tab_label: "Weather", - css_class: "owlry-filter-weather", - search_noun: "weather", + tab_label: "Weather".to_string(), + css_class: "owlry-filter-weather".to_string(), + search_noun: "weather".to_string(), }, "websearch" => ProviderMeta { - tab_label: "Web", - css_class: "owlry-filter-web", - search_noun: "web", - }, - _ => ProviderMeta { - tab_label: "Plugin", - css_class: "owlry-filter-plugin", - search_noun: "plugins", + tab_label: "Web".to_string(), + css_class: "owlry-filter-web".to_string(), + search_noun: "web".to_string(), }, + // Unknown plugin: auto-generate from type_id + _ => { + let label = capitalize(type_id); + ProviderMeta { + css_class: format!("owlry-filter-{}", type_id), + search_noun: type_id.to_string(), + tab_label: label, + } + } }, } } + +/// Capitalize first character of a string. +fn capitalize(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + None => String::new(), + Some(c) => c.to_uppercase().collect::() + chars.as_str(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_plugin_returns_hardcoded() { + let meta = resolve(&ProviderType::Plugin("calc".to_string()), None); + assert_eq!(meta.tab_label, "Calc"); + assert_eq!(meta.css_class, "owlry-filter-calc"); + assert_eq!(meta.search_noun, "calculator"); + } + + #[test] + fn unknown_plugin_generates_from_type_id() { + let meta = resolve(&ProviderType::Plugin("hs".to_string()), None); + assert_eq!(meta.tab_label, "Hs"); + assert_eq!(meta.css_class, "owlry-filter-hs"); + assert_eq!(meta.search_noun, "hs"); + } + + #[test] + fn ipc_metadata_overrides_hardcoded() { + let desc = ProviderDesc { + id: "hs".to_string(), + name: "Hypr Shutdown".to_string(), + prefix: None, + icon: "system-shutdown".to_string(), + position: "normal".to_string(), + tab_label: Some("Shutdown".to_string()), + search_noun: Some("shutdown actions".to_string()), + }; + let meta = resolve(&ProviderType::Plugin("hs".to_string()), Some(&desc)); + assert_eq!(meta.tab_label, "Shutdown"); + assert_eq!(meta.search_noun, "shutdown actions"); + // css_class always from hardcoded + assert_eq!(meta.css_class, "owlry-filter-hs"); + } + + #[test] + fn ipc_partial_override_falls_back() { + let desc = ProviderDesc { + id: "calc".to_string(), + name: "Calculator".to_string(), + prefix: None, + icon: "calc".to_string(), + position: "normal".to_string(), + tab_label: Some("Calculator".to_string()), + search_noun: None, // not provided + }; + let meta = resolve(&ProviderType::Plugin("calc".to_string()), Some(&desc)); + assert_eq!(meta.tab_label, "Calculator"); // from IPC + assert_eq!(meta.search_noun, "calculator"); // from hardcoded fallback + } + + #[test] + fn core_types_unaffected() { + let meta = resolve(&ProviderType::Application, None); + assert_eq!(meta.tab_label, "Apps"); + assert_eq!(meta.css_class, "owlry-filter-app"); + } +}