feat: add tags, configurable tabs, and tag-based filtering

- Add `tags` field to LaunchItem for categorization
- Extract .desktop Categories as tags for applications
- Add semantic tags to providers (systemd, ssh, script, etc.)
- Display tag badges in result rows (max 3 tags)
- Add `tabs` config option for customizable header tabs
- Dynamic Ctrl+1-9 shortcuts based on tab config
- Add `:tag:XXX` prefix for tag-based filtering
- Include tags in fuzzy search with lower weight
- Update config.example.toml with tabs documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-29 17:30:47 +01:00
parent 2a2a22f72c
commit 7ca8a1f443
20 changed files with 253 additions and 77 deletions

View File

@@ -13,6 +13,10 @@ terminal_command = "kitty" # Auto-detected if not set
# "" # Direct execution
# launch_wrapper = "uwsm app --"
# Provider tabs shown in header bar (Ctrl+1, Ctrl+2, etc. to toggle)
# Valid values: app, cmd, uuctl, bookmark, calc, clip, dmenu, emoji, file, script, ssh, sys, web
tabs = ["app", "cmd", "uuctl"]
[appearance]
width = 600
height = 400

View File

@@ -22,6 +22,18 @@ pub struct GeneralConfig {
/// If None or empty, launches directly via sh -c
#[serde(default)]
pub launch_wrapper: Option<String>,
/// Provider tabs shown in the header bar.
/// Valid values: app, cmd, uuctl, bookmark, calc, clip, dmenu, emoji, file, script, ssh, sys, web
#[serde(default = "default_tabs")]
pub tabs: Vec<String>,
}
fn default_tabs() -> Vec<String> {
vec![
"app".to_string(),
"cmd".to_string(),
"uuctl".to_string(),
]
}
/// User-customizable theme colors
@@ -220,6 +232,7 @@ impl Default for Config {
max_results: 10,
terminal_command: terminal,
launch_wrapper: detect_launch_wrapper(),
tabs: default_tabs(),
},
appearance: AppearanceConfig {
width: 600,

View File

@@ -17,6 +17,7 @@ pub struct ProviderFilter {
#[derive(Debug, Clone)]
pub struct ParsedQuery {
pub prefix: Option<ProviderType>,
pub tag_filter: Option<String>,
pub query: String,
}
@@ -161,6 +162,30 @@ impl ProviderFilter {
pub fn parse_query(query: &str) -> ParsedQuery {
let trimmed = query.trim_start();
// Check for tag filter pattern: ":tag:XXX query" or ":tag:XXX"
if let Some(rest) = trimmed.strip_prefix(":tag:") {
// Find the end of the tag (space or end of string)
if let Some(space_idx) = rest.find(' ') {
let tag = rest[..space_idx].to_lowercase();
let query_part = rest[space_idx + 1..].to_string();
#[cfg(feature = "dev-logging")]
debug!("[Filter] parse_query({:?}) -> tag={:?}, query={:?}", query, tag, query_part);
return ParsedQuery {
prefix: None,
tag_filter: Some(tag),
query: query_part,
};
} else {
// Just the tag, no query yet
let tag = rest.to_lowercase();
return ParsedQuery {
prefix: None,
tag_filter: Some(tag),
query: String::new(),
};
}
}
// Check for prefix patterns (with trailing space)
let prefixes = [
(":app ", ProviderType::Application),
@@ -196,6 +221,7 @@ impl ProviderFilter {
debug!("[Filter] parse_query({:?}) -> prefix={:?}, query={:?}", query, provider, rest);
return ParsedQuery {
prefix: Some(provider),
tag_filter: None,
query: rest.to_string(),
};
}
@@ -236,6 +262,7 @@ impl ProviderFilter {
debug!("[Filter] parse_query({:?}) -> partial prefix {:?}", query, provider);
return ParsedQuery {
prefix: Some(provider),
tag_filter: None,
query: String::new(),
};
}
@@ -243,11 +270,12 @@ impl ProviderFilter {
let result = ParsedQuery {
prefix: None,
tag_filter: None,
query: query.to_string(),
};
#[cfg(feature = "dev-logging")]
debug!("[Filter] parse_query({:?}) -> prefix={:?}, query={:?}", query, result.prefix, result.query);
debug!("[Filter] parse_query({:?}) -> prefix={:?}, tag={:?}, query={:?}", query, result.prefix, result.tag_filter, result.query);
result
}

View File

@@ -135,6 +135,12 @@ impl Provider for ApplicationProvider {
None => continue,
};
// Extract categories as tags (lowercase for consistency)
let tags: Vec<String> = desktop_entry
.categories()
.map(|cats| cats.into_iter().map(|s| s.to_lowercase()).collect())
.unwrap_or_default();
let item = LaunchItem {
id: path.to_string_lossy().to_string(),
name,
@@ -143,6 +149,7 @@ impl Provider for ApplicationProvider {
provider: ProviderType::Application,
command: run_cmd,
terminal: desktop_entry.terminal(),
tags,
};
self.items.push(item);

View File

@@ -157,6 +157,7 @@ impl BookmarksProvider {
provider: ProviderType::Bookmarks,
command: format!("xdg-open '{}'", url.replace('\'', "'\\''")),
terminal: false,
tags: Vec::new(),
});
}
}

View File

@@ -73,6 +73,7 @@ impl CalculatorProvider {
provider: ProviderType::Calculator,
command: format!("echo -n '{}' | wl-copy", result_str),
terminal: false,
tags: vec!["math".to_string()],
})
}
Err(_) => None,
@@ -111,6 +112,7 @@ impl CalculatorProvider {
// Copy result to clipboard using wl-copy
command: format!("sh -c 'echo -n \"{}\" | wl-copy'", result_str),
terminal: false,
tags: vec!["math".to_string()],
};
debug!("Calculator result: {} = {}", expr, result_str);

View File

@@ -99,6 +99,7 @@ impl ClipboardProvider {
provider: ProviderType::Clipboard,
command,
terminal: false,
tags: Vec::new(),
});
}

View File

@@ -87,6 +87,7 @@ impl Provider for CommandProvider {
provider: ProviderType::Command,
command: name,
terminal: false,
tags: Vec::new(),
};
self.items.push(item);

View File

@@ -101,6 +101,7 @@ impl Provider for DmenuProvider {
provider: ProviderType::Dmenu,
command: line.to_string(),
terminal: false,
tags: Vec::new(),
};
self.items.push(item);

View File

@@ -406,6 +406,7 @@ impl EmojiProvider {
// Copy emoji to clipboard using wl-copy
command: format!("printf '%s' '{}' | wl-copy", emoji),
terminal: false,
tags: Vec::new(), // TODO: Extract category from emoji data
});
// Store the search text for matching (not used directly but could be)

View File

@@ -191,6 +191,7 @@ impl FileSearchProvider {
provider: ProviderType::Files,
command,
terminal: false,
tags: Vec::new(),
}
})
.collect()

View File

@@ -46,6 +46,8 @@ pub struct LaunchItem {
pub provider: ProviderType,
pub command: String,
pub terminal: bool,
/// Tags/categories for filtering (e.g., from .desktop Categories)
pub tags: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -282,7 +284,7 @@ impl ProviderManager {
results
}
/// Search with frecency boosting and calculator support
/// Search with frecency boosting, calculator support, and tag filtering
pub fn search_with_frecency(
&mut self,
query: &str,
@@ -290,6 +292,7 @@ impl ProviderManager {
filter: &crate::filter::ProviderFilter,
frecency: &FrecencyStore,
frecency_weight: f64,
tag_filter: Option<&str>,
) -> Vec<(LaunchItem, i64)> {
#[cfg(feature = "dev-logging")]
debug!("[Search] query={:?}, max={}, frecency_weight={}", query, max_results, frecency_weight);
@@ -352,6 +355,14 @@ impl ProviderManager {
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter().cloned())
.filter(|item| {
// Apply tag filter if present
if let Some(tag) = tag_filter {
item.tags.iter().any(|t| t.to_lowercase().contains(tag))
} else {
true
}
})
.map(|item| {
let frecency_score = frecency.get_score(&item.id);
let boosted = (frecency_score * frecency_weight * 100.0) as i64;
@@ -364,24 +375,43 @@ impl ProviderManager {
return items;
}
// Regular search with frecency boost
// Regular search with frecency boost and tag matching
let search_results: Vec<(LaunchItem, i64)> = self
.providers
.iter()
.filter(|provider| filter.is_active(provider.provider_type()))
.flat_map(|provider| {
provider.items().iter().filter_map(|item| {
// Apply tag filter if present
if let Some(tag) = tag_filter {
if !item.tags.iter().any(|t| t.to_lowercase().contains(tag)) {
return None;
}
}
let name_score = self.matcher.fuzzy_match(&item.name, query);
let desc_score = item
.description
.as_ref()
.and_then(|d| self.matcher.fuzzy_match(d, query));
let base_score = match (name_score, desc_score) {
(Some(n), Some(d)) => Some(n.max(d)),
(Some(n), None) => Some(n),
(None, Some(d)) => Some(d / 2),
(None, None) => None,
// Also match against tags (lower weight)
let tag_score = item
.tags
.iter()
.filter_map(|t| self.matcher.fuzzy_match(t, query))
.max()
.map(|s| s / 3); // Lower weight for tag matches
let base_score = match (name_score, desc_score, tag_score) {
(Some(n), Some(d), Some(t)) => Some(n.max(d).max(t)),
(Some(n), Some(d), None) => Some(n.max(d)),
(Some(n), None, Some(t)) => Some(n.max(t)),
(Some(n), None, None) => Some(n),
(None, Some(d), Some(t)) => Some((d / 2).max(t)),
(None, Some(d), None) => Some(d / 2),
(None, None, Some(t)) => Some(t),
(None, None, None) => None,
};
base_score.map(|s| {

View File

@@ -87,6 +87,7 @@ impl ScriptsProvider {
provider: ProviderType::Scripts,
command: path.to_string_lossy().to_string(),
terminal: false,
tags: vec!["script".to_string()],
});
}

View File

@@ -161,6 +161,7 @@ impl SshProvider {
provider: ProviderType::Ssh,
command,
terminal: false, // We're already wrapping in terminal
tags: vec!["ssh".to_string()],
});
}
}

View File

@@ -76,6 +76,7 @@ impl SystemProvider {
provider: ProviderType::System,
command: command.to_string(),
terminal: false,
tags: vec!["power".to_string(), "system".to_string()],
});
}
}

View File

@@ -46,6 +46,7 @@ impl UuctlProvider {
provider: ProviderType::Uuctl,
command: format!("systemctl --user restart {}", unit_name),
terminal: false,
tags: vec!["systemd".to_string(), "service".to_string()],
});
actions.push(LaunchItem {
@@ -56,6 +57,7 @@ impl UuctlProvider {
provider: ProviderType::Uuctl,
command: format!("systemctl --user stop {}", unit_name),
terminal: false,
tags: vec!["systemd".to_string(), "service".to_string()],
});
actions.push(LaunchItem {
@@ -66,6 +68,7 @@ impl UuctlProvider {
provider: ProviderType::Uuctl,
command: format!("systemctl --user reload {}", unit_name),
terminal: false,
tags: vec!["systemd".to_string(), "service".to_string()],
});
actions.push(LaunchItem {
@@ -76,6 +79,7 @@ impl UuctlProvider {
provider: ProviderType::Uuctl,
command: format!("systemctl --user kill {}", unit_name),
terminal: false,
tags: vec!["systemd".to_string(), "service".to_string()],
});
} else {
actions.push(LaunchItem {
@@ -86,6 +90,7 @@ impl UuctlProvider {
provider: ProviderType::Uuctl,
command: format!("systemctl --user start {}", unit_name),
terminal: false,
tags: vec!["systemd".to_string(), "service".to_string()],
});
}
@@ -98,6 +103,7 @@ impl UuctlProvider {
provider: ProviderType::Uuctl,
command: format!("systemctl --user status {}", unit_name),
terminal: true,
tags: vec!["systemd".to_string(), "service".to_string()],
});
actions.push(LaunchItem {
@@ -108,6 +114,7 @@ impl UuctlProvider {
provider: ProviderType::Uuctl,
command: format!("journalctl --user -u {} -f", unit_name),
terminal: true,
tags: vec!["systemd".to_string(), "service".to_string()],
});
actions.push(LaunchItem {
@@ -118,6 +125,7 @@ impl UuctlProvider {
provider: ProviderType::Uuctl,
command: format!("systemctl --user enable {}", unit_name),
terminal: false,
tags: vec!["systemd".to_string(), "service".to_string()],
});
actions.push(LaunchItem {
@@ -128,6 +136,7 @@ impl UuctlProvider {
provider: ProviderType::Uuctl,
command: format!("systemctl --user disable {}", unit_name),
terminal: false,
tags: vec!["systemd".to_string(), "service".to_string()],
});
actions
@@ -189,6 +198,7 @@ impl UuctlProvider {
provider: ProviderType::Uuctl,
command: submenu_data, // Special marker for submenu
terminal: false,
tags: vec!["systemd".to_string(), "service".to_string()],
});
}

View File

@@ -136,6 +136,7 @@ impl WebSearchProvider {
provider: ProviderType::WebSearch,
command,
terminal: false,
tags: vec!["web".to_string(), "search".to_string()],
})
}
}

View File

@@ -315,6 +315,22 @@ scrollbar slider:active {
background-color: var(--owlry-accent, @theme_selected_bg_color);
}
/* Tag badges */
.owlry-tag-badge {
font-size: calc(var(--owlry-font-size, 14px) - 4px);
font-weight: 500;
padding: 1px 6px;
border-radius: 4px;
background-color: alpha(var(--owlry-border, @borders), 0.3);
color: var(--owlry-text-secondary, alpha(@theme_fg_color, 0.6));
margin-top: 4px;
}
.owlry-result-row:selected .owlry-tag-badge {
background-color: alpha(var(--owlry-accent-bright, @theme_selected_fg_color), 0.2);
color: var(--owlry-accent-bright, @theme_selected_fg_color);
}
/* Text selection */
selection {
background-color: alpha(var(--owlry-accent, @theme_selected_bg_color), 0.3);

View File

@@ -48,6 +48,8 @@ pub struct MainWindow {
hints_label: Label,
filter_buttons: Rc<RefCell<HashMap<ProviderType, ToggleButton>>>,
submenu_state: Rc<RefCell<SubmenuState>>,
/// Parsed tab config (ProviderTypes for cycling)
tab_order: Rc<Vec<ProviderType>>,
}
impl MainWindow {
@@ -108,8 +110,17 @@ impl MainWindow {
.build();
filter_tabs.add_css_class("owlry-filter-tabs");
// Create toggle buttons for each provider
let filter_buttons = Self::create_filter_buttons(&filter_tabs, &filter);
// Parse tabs config to ProviderTypes
let tab_order: Vec<ProviderType> = cfg
.general
.tabs
.iter()
.filter_map(|s| s.parse().ok())
.collect();
let tab_order = Rc::new(tab_order);
// Create toggle buttons for each provider (from config)
let filter_buttons = Self::create_filter_buttons(&filter_tabs, &filter, &cfg.general.tabs);
let filter_buttons = Rc::new(RefCell::new(filter_buttons));
header_box.append(&mode_label);
@@ -177,6 +188,7 @@ impl MainWindow {
hints_label,
filter_buttons,
submenu_state: Rc::new(RefCell::new(SubmenuState::default())),
tab_order,
};
main_window.setup_signals();
@@ -191,38 +203,31 @@ impl MainWindow {
fn create_filter_buttons(
container: &GtkBox,
filter: &Rc<RefCell<ProviderFilter>>,
tabs: &[String],
) -> HashMap<ProviderType, ToggleButton> {
let providers = [
(ProviderType::Application, "Apps", "Ctrl+1"),
(ProviderType::Command, "Cmds", "Ctrl+2"),
(ProviderType::Uuctl, "uuctl", "Ctrl+3"),
];
let mut buttons = HashMap::new();
for (provider_type, label, shortcut) in providers {
// Parse tab strings to ProviderType and create buttons
for (idx, tab_str) in tabs.iter().enumerate() {
let provider_type: ProviderType = match tab_str.parse() {
Ok(pt) => pt,
Err(e) => {
log::warn!("Invalid tab config '{}': {}", tab_str, e);
continue;
}
};
let label = Self::provider_tab_label(provider_type);
let shortcut = format!("Ctrl+{}", idx + 1);
let button = ToggleButton::builder()
.label(label)
.tooltip_text(shortcut)
.tooltip_text(&shortcut)
.active(filter.borrow().is_enabled(provider_type))
.build();
button.add_css_class("owlry-filter-button");
let css_class = match provider_type {
ProviderType::Application => "owlry-filter-app",
ProviderType::Bookmarks => "owlry-filter-bookmark",
ProviderType::Calculator => "owlry-filter-calc",
ProviderType::Clipboard => "owlry-filter-clip",
ProviderType::Command => "owlry-filter-cmd",
ProviderType::Dmenu => "owlry-filter-dmenu",
ProviderType::Emoji => "owlry-filter-emoji",
ProviderType::Files => "owlry-filter-file",
ProviderType::Scripts => "owlry-filter-script",
ProviderType::Ssh => "owlry-filter-ssh",
ProviderType::System => "owlry-filter-sys",
ProviderType::Uuctl => "owlry-filter-uuctl",
ProviderType::WebSearch => "owlry-filter-web",
};
let css_class = Self::provider_css_class(provider_type);
button.add_css_class(css_class);
container.append(&button);
@@ -232,6 +237,44 @@ impl MainWindow {
buttons
}
/// Get display label for a provider tab
fn provider_tab_label(provider: ProviderType) -> &'static str {
match provider {
ProviderType::Application => "Apps",
ProviderType::Bookmarks => "Bookmarks",
ProviderType::Calculator => "Calc",
ProviderType::Clipboard => "Clip",
ProviderType::Command => "Cmds",
ProviderType::Dmenu => "Dmenu",
ProviderType::Emoji => "Emoji",
ProviderType::Files => "Files",
ProviderType::Scripts => "Scripts",
ProviderType::Ssh => "SSH",
ProviderType::System => "System",
ProviderType::Uuctl => "uuctl",
ProviderType::WebSearch => "Web",
}
}
/// Get CSS class for a provider
fn provider_css_class(provider: ProviderType) -> &'static str {
match provider {
ProviderType::Application => "owlry-filter-app",
ProviderType::Bookmarks => "owlry-filter-bookmark",
ProviderType::Calculator => "owlry-filter-calc",
ProviderType::Clipboard => "owlry-filter-clip",
ProviderType::Command => "owlry-filter-cmd",
ProviderType::Dmenu => "owlry-filter-dmenu",
ProviderType::Emoji => "owlry-filter-emoji",
ProviderType::Files => "owlry-filter-file",
ProviderType::Scripts => "owlry-filter-script",
ProviderType::Ssh => "owlry-filter-ssh",
ProviderType::System => "owlry-filter-sys",
ProviderType::Uuctl => "owlry-filter-uuctl",
ProviderType::WebSearch => "owlry-filter-web",
}
}
fn build_placeholder(filter: &ProviderFilter) -> String {
let active: Vec<&str> = filter
.enabled_providers()
@@ -507,7 +550,7 @@ impl MainWindow {
let results: Vec<LaunchItem> = if use_frecency {
providers
.borrow_mut()
.search_with_frecency(&parsed.query, max_results, &filter.borrow(), &frecency.borrow(), frecency_weight)
.search_with_frecency(&parsed.query, max_results, &filter.borrow(), &frecency.borrow(), frecency_weight, parsed.tag_filter.as_deref())
.into_iter()
.map(|(item, _)| item)
.collect()
@@ -616,6 +659,7 @@ impl MainWindow {
let mode_label = self.mode_label.clone();
let hints_label = self.hints_label.clone();
let submenu_state = self.submenu_state.clone();
let tab_order = self.tab_order.clone();
key_controller.connect_key_pressed(move |_, key, _, modifiers| {
let ctrl = modifiers.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
@@ -693,6 +737,7 @@ impl MainWindow {
&filter_buttons,
&search_entry,
&mode_label,
&tab_order,
!shift,
);
}
@@ -705,45 +750,37 @@ impl MainWindow {
&filter_buttons,
&search_entry,
&mode_label,
&tab_order,
false,
);
}
gtk4::glib::Propagation::Stop
}
// Ctrl+1/2/3 toggle specific providers (only when not in submenu)
Key::_1 if ctrl => {
// Ctrl+1-9 toggle specific providers based on tab order (only when not in submenu)
Key::_1 | Key::_2 | Key::_3 | Key::_4 | Key::_5 |
Key::_6 | Key::_7 | Key::_8 | Key::_9 if ctrl => {
if !submenu_state.borrow().active {
Self::toggle_provider_button(
ProviderType::Application,
&filter,
&filter_buttons,
&search_entry,
&mode_label,
);
}
gtk4::glib::Propagation::Stop
}
Key::_2 if ctrl => {
if !submenu_state.borrow().active {
Self::toggle_provider_button(
ProviderType::Command,
&filter,
&filter_buttons,
&search_entry,
&mode_label,
);
}
gtk4::glib::Propagation::Stop
}
Key::_3 if ctrl => {
if !submenu_state.borrow().active {
Self::toggle_provider_button(
ProviderType::Uuctl,
&filter,
&filter_buttons,
&search_entry,
&mode_label,
);
let idx = match key {
Key::_1 => 0,
Key::_2 => 1,
Key::_3 => 2,
Key::_4 => 3,
Key::_5 => 4,
Key::_6 => 5,
Key::_7 => 6,
Key::_8 => 7,
Key::_9 => 8,
_ => return gtk4::glib::Propagation::Proceed,
};
if let Some(&provider) = tab_order.get(idx) {
Self::toggle_provider_button(
provider,
&filter,
&filter_buttons,
&search_entry,
&mode_label,
);
}
}
gtk4::glib::Propagation::Stop
}
@@ -797,24 +834,24 @@ impl MainWindow {
buttons: &Rc<RefCell<HashMap<ProviderType, ToggleButton>>>,
entry: &Entry,
mode_label: &Label,
tab_order: &[ProviderType],
forward: bool,
) {
let order = [
ProviderType::Application,
ProviderType::Command,
ProviderType::Uuctl,
];
if tab_order.is_empty() {
return;
}
let current = filter.borrow().enabled_providers();
let next = if current.len() == 1 {
let idx = order.iter().position(|p| p == &current[0]).unwrap_or(0);
let idx = tab_order.iter().position(|p| p == &current[0]).unwrap_or(0);
if forward {
order[(idx + 1) % order.len()]
tab_order[(idx + 1) % tab_order.len()]
} else {
order[(idx + order.len() - 1) % order.len()]
tab_order[(idx + tab_order.len() - 1) % tab_order.len()]
}
} else {
ProviderType::Application
tab_order[0]
};
{
@@ -862,7 +899,7 @@ impl MainWindow {
let results: Vec<LaunchItem> = if use_frecency {
self.providers
.borrow_mut()
.search_with_frecency(query, max_results, &self.filter.borrow(), &self.frecency.borrow(), frecency_weight)
.search_with_frecency(query, max_results, &self.filter.borrow(), &self.frecency.borrow(), frecency_weight, None)
.into_iter()
.map(|(item, _)| item)
.collect()

View File

@@ -82,6 +82,25 @@ impl ResultRow {
text_box.append(&name_label);
}
// Tag badges (show first 3 tags)
if !item.tags.is_empty() {
let tags_box = GtkBox::builder()
.orientation(Orientation::Horizontal)
.spacing(4)
.halign(gtk4::Align::Start)
.build();
for tag in item.tags.iter().take(3) {
let tag_label = Label::builder()
.label(tag)
.build();
tag_label.add_css_class("owlry-tag-badge");
tags_box.append(&tag_label);
}
text_box.append(&tags_box);
}
// Provider badge
let badge = Label::builder()
.label(&item.provider.to_string())