14 Commits

Author SHA1 Message Date
a55567b422 chore(owlry-rune): bump version to 0.4.10 2026-01-02 16:59:22 +01:00
707caefadf chore(owlry-lua): bump version to 0.4.10 2026-01-02 16:59:22 +01:00
78895d34b5 chore(plugins): bump all plugins to 0.4.10 2026-01-02 16:59:14 +01:00
e6f217f19c chore: bump version to 0.4.10 2026-01-02 16:59:06 +01:00
ff04675417 refactor(config): replace launch_wrapper with use_uwsm boolean
- Replace complex auto-detection with explicit use_uwsm config option
- Remove detect_launch_wrapper() function and hyprctl/uwsm auto-detection
- Use gio launch as default (always available via GTK4's glib2 dependency)
- When use_uwsm=true, launch via uwsm app -- for systemd session integration
- Add error handling for when uwsm is enabled but not installed
- Update documentation in README.md, CLAUDE.md, and config.example.toml
2026-01-02 16:57:40 +01:00
b85f85c4da feat(dmenu): add full dmenu compatibility
- Add free-form text input (output typed text when no item matches)
- Add proper exit codes (0=selection, 1=cancelled)
- Detect dmenu mode via ProviderManager::is_dmenu_mode()

This enables standard dmenu usage patterns like:
  echo -e "yes\nno" | owlry -m dmenu && echo "selected"
2026-01-02 16:36:40 +01:00
1aa92ee1e5 chore(owlry-rune): bump version to 0.4.9 2026-01-02 16:18:19 +01:00
9532b3cfde chore(owlry-lua): bump version to 0.4.9 2026-01-02 16:18:18 +01:00
551e5d74ae chore(plugins): bump all plugins to 0.4.9 2026-01-02 16:18:18 +01:00
60eaffb2ab chore: bump version to 0.4.9 2026-01-02 16:18:08 +01:00
6d8d4a9f89 fix(providers): improve app discovery and launch reliability
- Add Keywords field from desktop files to searchable tags
  (fixes apps like Nautilus not found when searching by legacy name)
- Respect XDG_DATA_DIRS with proper fallbacks for app directories
- Add Flatpak, Snap, and Nix application directory support
- Simplify desktop file launch to use gio directly (guaranteed by GTK4)
- Add desktop notifications for launch failures
- Check desktop file existence before launch attempt
2026-01-02 16:18:00 +01:00
3ef9398655 chore: bump all crates to 0.4.8 2026-01-01 23:30:45 +01:00
46bb4bfb38 chore: bump version to 0.4.8 2026-01-01 23:28:09 +01:00
c8aed5faf5 fix(dmenu): print selection to stdout instead of executing
dmenu mode was incorrectly trying to execute the selected item
as a command (via hyprctl/sh). Now it properly prints the
selection to stdout, enabling standard dmenu piping workflows
like: git branch | owlry -m dmenu | xargs git checkout
2026-01-01 23:28:03 +01:00
25 changed files with 364 additions and 157 deletions

34
Cargo.lock generated
View File

@@ -2373,7 +2373,7 @@ dependencies = [
[[package]]
name = "owlry"
version = "0.4.7"
version = "0.4.10"
dependencies = [
"chrono",
"clap",
@@ -2402,7 +2402,7 @@ dependencies = [
[[package]]
name = "owlry-lua"
version = "0.4.7"
version = "0.4.10"
dependencies = [
"abi_stable",
"chrono",
@@ -2420,7 +2420,7 @@ dependencies = [
[[package]]
name = "owlry-plugin-api"
version = "0.4.7"
version = "0.4.10"
dependencies = [
"abi_stable",
"serde",
@@ -2428,7 +2428,7 @@ dependencies = [
[[package]]
name = "owlry-plugin-bookmarks"
version = "0.4.7"
version = "0.4.10"
dependencies = [
"abi_stable",
"dirs",
@@ -2440,7 +2440,7 @@ dependencies = [
[[package]]
name = "owlry-plugin-calculator"
version = "0.4.7"
version = "0.4.10"
dependencies = [
"abi_stable",
"meval",
@@ -2449,7 +2449,7 @@ dependencies = [
[[package]]
name = "owlry-plugin-clipboard"
version = "0.4.7"
version = "0.4.10"
dependencies = [
"abi_stable",
"owlry-plugin-api",
@@ -2457,7 +2457,7 @@ dependencies = [
[[package]]
name = "owlry-plugin-emoji"
version = "0.4.7"
version = "0.4.10"
dependencies = [
"abi_stable",
"owlry-plugin-api",
@@ -2465,7 +2465,7 @@ dependencies = [
[[package]]
name = "owlry-plugin-filesearch"
version = "0.4.7"
version = "0.4.10"
dependencies = [
"abi_stable",
"dirs",
@@ -2474,7 +2474,7 @@ dependencies = [
[[package]]
name = "owlry-plugin-media"
version = "0.4.7"
version = "0.4.10"
dependencies = [
"abi_stable",
"owlry-plugin-api",
@@ -2482,7 +2482,7 @@ dependencies = [
[[package]]
name = "owlry-plugin-pomodoro"
version = "0.4.7"
version = "0.4.10"
dependencies = [
"abi_stable",
"dirs",
@@ -2494,7 +2494,7 @@ dependencies = [
[[package]]
name = "owlry-plugin-scripts"
version = "0.4.7"
version = "0.4.10"
dependencies = [
"abi_stable",
"dirs",
@@ -2503,7 +2503,7 @@ dependencies = [
[[package]]
name = "owlry-plugin-ssh"
version = "0.4.7"
version = "0.4.10"
dependencies = [
"abi_stable",
"dirs",
@@ -2512,7 +2512,7 @@ dependencies = [
[[package]]
name = "owlry-plugin-system"
version = "0.4.7"
version = "0.4.10"
dependencies = [
"abi_stable",
"owlry-plugin-api",
@@ -2520,7 +2520,7 @@ dependencies = [
[[package]]
name = "owlry-plugin-systemd"
version = "0.4.7"
version = "0.4.10"
dependencies = [
"abi_stable",
"owlry-plugin-api",
@@ -2528,7 +2528,7 @@ dependencies = [
[[package]]
name = "owlry-plugin-weather"
version = "0.4.7"
version = "0.4.10"
dependencies = [
"abi_stable",
"dirs",
@@ -2541,7 +2541,7 @@ dependencies = [
[[package]]
name = "owlry-plugin-websearch"
version = "0.4.7"
version = "0.4.10"
dependencies = [
"abi_stable",
"owlry-plugin-api",
@@ -2549,7 +2549,7 @@ dependencies = [
[[package]]
name = "owlry-rune"
version = "0.4.7"
version = "0.4.10"
dependencies = [
"chrono",
"dirs",

View File

@@ -208,8 +208,8 @@ cp /usr/share/doc/owlry/config.example.toml ~/.config/owlry/config.toml
show_icons = true
max_results = 10
tabs = ["app", "cmd", "uuctl"]
# terminal_command = "kitty" # Auto-detected
# launch_wrapper = "uwsm app --" # Auto-detected
# terminal_command = "kitty" # Auto-detected
# use_uwsm = false # Enable for systemd session integration
[appearance]
width = 850

View File

@@ -75,6 +75,24 @@ The script runtimes make this viable without recompiling.
## Technical Debt
### Split monorepo for user build efficiency
Currently, a small core fix requires all 16 AUR packages to rebuild (same source tarball). Split into 3 repos:
| Repo | Contents | Versioning |
|------|----------|------------|
| `owlry` | Core binary | Independent |
| `owlry-plugin-api` | ABI interface (crates.io) | Semver, conservative |
| `owlry-plugins` | 13 plugins + 2 runtimes | Independent per plugin |
**Execution order:**
1. Publish `owlry-plugin-api` to crates.io
2. Update monorepo to use crates.io dependency
3. Create `owlry-plugins` repo, move plugins + runtimes
4. Slim current repo to core-only
5. Update AUR PKGBUILDs with new source URLs
**Benefit:** Core bugfix = 1 rebuild. Plugin fix = 1 rebuild. Third-party plugins possible via crates.io.
### Replace meval with evalexpr
`meval` depends on `nom v1.2.4` which will be rejected by future Rust versions. Migrate calculator plugin and Lua runtime to `evalexpr` v13+.

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-lua"
version = "0.4.7"
version = "0.4.10"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-plugin-api"
version = "0.4.7"
version = "0.4.10"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-plugin-bookmarks"
version = "0.4.7"
version = "0.4.10"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-plugin-calculator"
version = "0.4.7"
version = "0.4.10"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-plugin-clipboard"
version = "0.4.7"
version = "0.4.10"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-plugin-emoji"
version = "0.4.7"
version = "0.4.10"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-plugin-filesearch"
version = "0.4.7"
version = "0.4.10"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-plugin-media"
version = "0.4.7"
version = "0.4.10"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-plugin-pomodoro"
version = "0.4.7"
version = "0.4.10"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-plugin-scripts"
version = "0.4.7"
version = "0.4.10"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-plugin-ssh"
version = "0.4.7"
version = "0.4.10"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-plugin-system"
version = "0.4.7"
version = "0.4.10"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-plugin-systemd"
version = "0.4.7"
version = "0.4.10"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-plugin-weather"
version = "0.4.7"
version = "0.4.10"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-plugin-websearch"
version = "0.4.7"
version = "0.4.10"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-rune"
version = "0.4.7"
version = "0.4.10"
edition = "2024"
rust-version = "1.90"
description = "Rune scripting runtime for owlry plugins"

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry"
version = "0.4.7"
version = "0.4.10"
edition = "2024"
rust-version = "1.90"
description = "A lightweight, owl-themed application launcher for Wayland"

View File

@@ -27,11 +27,12 @@ pub struct GeneralConfig {
/// Terminal command (auto-detected if not specified)
#[serde(default)]
pub terminal_command: Option<String>,
/// Launch wrapper command for app execution.
/// Examples: "uwsm app --", "hyprctl dispatch exec --", "systemd-run --user --"
/// If None or empty, launches directly via sh -c
/// Enable uwsm (Universal Wayland Session Manager) for launching apps.
/// When enabled, desktop files are launched via `uwsm app -- <file>`
/// which starts apps in a proper systemd user session.
/// When disabled (default), apps are launched via `gio launch`.
#[serde(default)]
pub launch_wrapper: Option<String>,
pub use_uwsm: bool,
/// 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")]
@@ -44,7 +45,7 @@ impl Default for GeneralConfig {
show_icons: true,
max_results: 100,
terminal_command: None,
launch_wrapper: None,
use_uwsm: false,
tabs: default_tabs(),
}
}
@@ -396,28 +397,6 @@ fn default_pomodoro_break() -> u32 {
5
}
/// Detect the best launch wrapper for the current session
/// Checks for uwsm (Universal Wayland Session Manager) and hyprland
fn detect_launch_wrapper() -> Option<String> {
// Check if running under uwsm (has UWSM_FINALIZE_VARNAMES or similar uwsm env vars)
if (std::env::var("UWSM_FINALIZE_VARNAMES").is_ok()
|| std::env::var("__UWSM_SELECT_TAG").is_ok())
&& command_exists("uwsm") {
debug!("Detected uwsm session, using 'uwsm app --' wrapper");
return Some("uwsm app --".to_string());
}
// Check if running under Hyprland
if std::env::var("HYPRLAND_INSTANCE_SIGNATURE").is_ok()
&& command_exists("hyprctl") {
debug!("Detected Hyprland session, using 'hyprctl dispatch exec --' wrapper");
return Some("hyprctl dispatch exec --".to_string());
}
// No wrapper needed for other environments
debug!("No launch wrapper detected, using direct execution");
None
}
/// Detect the best available terminal emulator
/// Fallback chain:
@@ -578,11 +557,6 @@ impl Config {
}
}
// Auto-detect launch wrapper if not configured
if config.general.launch_wrapper.is_none() {
config.general.launch_wrapper = detect_launch_wrapper();
}
Ok(config)
}

View File

@@ -99,23 +99,57 @@ pub fn frecency_file() -> Option<PathBuf> {
// =============================================================================
/// System data directories for applications (XDG_DATA_DIRS)
///
/// Follows the XDG Base Directory Specification:
/// - $XDG_DATA_HOME/applications (defaults to ~/.local/share/applications)
/// - $XDG_DATA_DIRS/*/applications (defaults to /usr/local/share:/usr/share)
/// - Additional Flatpak and Snap directories
pub fn system_data_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
let mut seen = std::collections::HashSet::new();
// User data directory first
// Helper to add unique directories
let mut add_dir = |path: PathBuf| {
if seen.insert(path.clone()) {
dirs.push(path);
}
};
// 1. User data directory first (highest priority)
if let Some(data) = data_home() {
dirs.push(data.join("applications"));
add_dir(data.join("applications"));
}
// System directories
dirs.push(PathBuf::from("/usr/share/applications"));
dirs.push(PathBuf::from("/usr/local/share/applications"));
// 2. XDG_DATA_DIRS - parse the environment variable
// Default per spec: /usr/local/share:/usr/share
let xdg_data_dirs = std::env::var("XDG_DATA_DIRS")
.unwrap_or_else(|_| "/usr/local/share:/usr/share".to_string());
// Flatpak directories
if let Some(data) = data_home() {
dirs.push(data.join("flatpak/exports/share/applications"));
for dir in xdg_data_dirs.split(':') {
if !dir.is_empty() {
add_dir(PathBuf::from(dir).join("applications"));
}
}
dirs.push(PathBuf::from("/var/lib/flatpak/exports/share/applications"));
// 3. Always include standard system directories as fallback
// Some environments set XDG_DATA_DIRS without including these
add_dir(PathBuf::from("/usr/share/applications"));
add_dir(PathBuf::from("/usr/local/share/applications"));
// 4. Flatpak directories (user and system)
if let Some(data) = data_home() {
add_dir(data.join("flatpak/exports/share/applications"));
}
add_dir(PathBuf::from("/var/lib/flatpak/exports/share/applications"));
// 5. Snap directories
add_dir(PathBuf::from("/var/lib/snapd/desktop/applications"));
// 6. Nix directories (common on NixOS)
if let Some(home) = dirs::home_dir() {
add_dir(home.join(".nix-profile/share/applications"));
}
add_dir(PathBuf::from("/run/current-system/sw/share/applications"));
dirs
}

View File

@@ -98,6 +98,15 @@ impl Provider for ApplicationProvider {
// Empty locale list for default locale
let locales: &[&str] = &[];
// Get current desktop environment(s) for OnlyShowIn/NotShowIn filtering
// XDG_CURRENT_DESKTOP can be colon-separated (e.g., "ubuntu:GNOME")
let current_desktops: Vec<String> = std::env::var("XDG_CURRENT_DESKTOP")
.unwrap_or_default()
.split(':')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
for path in Iter::new(dirs.into_iter()) {
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
@@ -125,6 +134,24 @@ impl Provider for ApplicationProvider {
continue;
}
// Apply OnlyShowIn/NotShowIn filters only if we know the current desktop
// If XDG_CURRENT_DESKTOP is not set, show all apps (don't filter)
if !current_desktops.is_empty() {
// OnlyShowIn: if set, current desktop must be in the list
if desktop_entry.only_show_in().is_some_and(|only| {
!current_desktops.iter().any(|de| only.contains(&de.as_str()))
}) {
continue;
}
// NotShowIn: if current desktop is in the list, skip
if desktop_entry.not_show_in().is_some_and(|not| {
current_desktops.iter().any(|de| not.contains(&de.as_str()))
}) {
continue;
}
}
let name = match desktop_entry.name(locales) {
Some(n) => n.to_string(),
None => continue,
@@ -135,12 +162,17 @@ impl Provider for ApplicationProvider {
None => continue,
};
// Extract categories as tags (lowercase for consistency)
let tags: Vec<String> = desktop_entry
// Extract categories and keywords as tags (lowercase for consistency)
let mut tags: Vec<String> = desktop_entry
.categories()
.map(|cats| cats.into_iter().map(|s| s.to_lowercase()).collect())
.unwrap_or_default();
// Add keywords for searchability (e.g., Nautilus has Name=Files but Keywords contains "nautilus")
if let Some(keywords) = desktop_entry.keywords(locales) {
tags.extend(keywords.into_iter().map(|s| s.to_lowercase()));
}
let item = LaunchItem {
id: path.to_string_lossy().to_string(),
name,
@@ -157,6 +189,13 @@ impl Provider for ApplicationProvider {
debug!("Found {} applications", self.items.len());
#[cfg(feature = "dev-logging")]
debug!(
"XDG_CURRENT_DESKTOP={:?}, scanned dirs count={}",
current_desktops,
Self::get_application_dirs().len()
);
// Sort alphabetically by name
self.items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
}
@@ -210,4 +249,18 @@ mod tests {
"bash -c 'echo %u'"
);
}
#[test]
fn test_clean_desktop_exec_preserves_env() {
// env VAR=value pattern should be preserved
assert_eq!(
clean_desktop_exec_field("env GDK_BACKEND=x11 UBUNTU_MENUPROXY=0 audacity %F"),
"env GDK_BACKEND=x11 UBUNTU_MENUPROXY=0 audacity"
);
// Multiple env vars
assert_eq!(
clean_desktop_exec_field("env FOO=bar BAZ=qux myapp %u"),
"env FOO=bar BAZ=qux myapp"
);
}
}

View File

@@ -47,6 +47,8 @@ struct LazyLoadState {
/// Number of items to display initially and per batch
const INITIAL_RESULTS: usize = 15;
const LOAD_MORE_BATCH: usize = 10;
/// Debounce delay for search input (milliseconds)
const SEARCH_DEBOUNCE_MS: u64 = 50;
pub struct MainWindow {
window: ApplicationWindow,
@@ -69,6 +71,10 @@ pub struct MainWindow {
custom_prompt: Option<String>,
/// Lazy loading state
lazy_state: Rc<RefCell<LazyLoadState>>,
/// Debounce source ID for cancelling pending searches
debounce_source: Rc<RefCell<Option<gtk4::glib::SourceId>>>,
/// Whether we're in dmenu mode (stdin pipe input)
is_dmenu_mode: bool,
}
impl MainWindow {
@@ -193,6 +199,9 @@ impl MainWindow {
let lazy_state = Rc::new(RefCell::new(LazyLoadState::default()));
// Check if we're in dmenu mode (stdin pipe input)
let is_dmenu_mode = providers.borrow().is_dmenu_mode();
let main_window = Self {
window,
search_entry,
@@ -210,6 +219,8 @@ impl MainWindow {
tab_order,
custom_prompt,
lazy_state,
debounce_source: Rc::new(RefCell::new(None)),
is_dmenu_mode,
};
main_window.setup_signals();
@@ -554,7 +565,7 @@ impl MainWindow {
}
fn setup_signals(&self) {
// Search input handling with prefix detection
// Search input handling with prefix detection and debouncing
let providers = self.providers.clone();
let results_list = self.results_list.clone();
let config = self.config.clone();
@@ -565,11 +576,12 @@ impl MainWindow {
let search_entry_for_change = self.search_entry.clone();
let submenu_state = self.submenu_state.clone();
let lazy_state = self.lazy_state.clone();
let debounce_source = self.debounce_source.clone();
self.search_entry.connect_changed(move |entry| {
let raw_query = entry.text();
// If in submenu, filter the submenu items
// If in submenu, filter immediately (no debounce needed for small local lists)
if submenu_state.borrow().active {
let state = submenu_state.borrow();
let query = raw_query.to_lowercase();
@@ -607,7 +619,7 @@ impl MainWindow {
return;
}
// Normal mode: parse prefix and search
// Normal mode: update prefix/UI immediately for responsiveness
let parsed = ProviderFilter::parse_query(&raw_query);
{
@@ -643,53 +655,79 @@ impl MainWindow {
.set_placeholder_text(Some(&format!("Search {}...", prefix_name)));
}
let cfg = config.borrow();
let max_results = cfg.general.max_results;
let frecency_weight = cfg.providers.frecency_weight;
let use_frecency = cfg.providers.frecency;
drop(cfg);
let results: Vec<LaunchItem> = if use_frecency {
providers
.borrow_mut()
.search_with_frecency(&parsed.query, max_results, &filter.borrow(), &frecency.borrow(), frecency_weight, parsed.tag_filter.as_deref())
.into_iter()
.map(|(item, _)| item)
.collect()
} else {
providers
.borrow()
.search_filtered(&parsed.query, max_results, &filter.borrow())
.into_iter()
.map(|(item, _)| item)
.collect()
};
// Clear existing results
while let Some(child) = results_list.first_child() {
results_list.remove(&child);
// Cancel any pending debounced search
if let Some(source_id) = debounce_source.borrow_mut().take() {
source_id.remove();
}
// Lazy loading: store all results but only display initial batch
let initial_count = INITIAL_RESULTS.min(results.len());
{
let mut lazy = lazy_state.borrow_mut();
lazy.all_results = results.clone();
lazy.displayed_count = initial_count;
}
// Clone references for the debounced closure
let providers = providers.clone();
let results_list = results_list.clone();
let config = config.clone();
let frecency = frecency.clone();
let current_results = current_results.clone();
let filter = filter.clone();
let lazy_state = lazy_state.clone();
let debounce_source_for_closure = debounce_source.clone();
// Display only initial batch
for item in results.iter().take(initial_count) {
let row = ResultRow::new(item);
results_list.append(&row);
}
// Schedule debounced search
let source_id = gtk4::glib::timeout_add_local_once(
std::time::Duration::from_millis(SEARCH_DEBOUNCE_MS),
move || {
// Clear the source ID since we're now executing
*debounce_source_for_closure.borrow_mut() = None;
if let Some(first_row) = results_list.row_at_index(0) {
results_list.select_row(Some(&first_row));
}
let cfg = config.borrow();
let max_results = cfg.general.max_results;
let frecency_weight = cfg.providers.frecency_weight;
let use_frecency = cfg.providers.frecency;
drop(cfg);
// current_results holds only what's displayed (for selection/activation)
*current_results.borrow_mut() = results.into_iter().take(initial_count).collect();
let results: Vec<LaunchItem> = if use_frecency {
providers
.borrow_mut()
.search_with_frecency(&parsed.query, max_results, &filter.borrow(), &frecency.borrow(), frecency_weight, parsed.tag_filter.as_deref())
.into_iter()
.map(|(item, _)| item)
.collect()
} else {
providers
.borrow()
.search_filtered(&parsed.query, max_results, &filter.borrow())
.into_iter()
.map(|(item, _)| item)
.collect()
};
// Clear existing results
while let Some(child) = results_list.first_child() {
results_list.remove(&child);
}
// Lazy loading: store all results but only display initial batch
let initial_count = INITIAL_RESULTS.min(results.len());
{
let mut lazy = lazy_state.borrow_mut();
lazy.all_results = results.clone();
lazy.displayed_count = initial_count;
}
// Display only initial batch
for item in results.iter().take(initial_count) {
let row = ResultRow::new(item);
results_list.append(&row);
}
if let Some(first_row) = results_list.row_at_index(0) {
results_list.select_row(Some(&first_row));
}
// current_results holds only what's displayed (for selection/activation)
*current_results.borrow_mut() = results.into_iter().take(initial_count).collect();
},
);
*debounce_source.borrow_mut() = Some(source_id);
});
// Entry activate signal (Enter key in search entry)
@@ -703,12 +741,14 @@ impl MainWindow {
let mode_label_for_activate = self.mode_label.clone();
let hints_label_for_activate = self.hints_label.clone();
let search_entry_for_activate = self.search_entry.clone();
let is_dmenu_mode_for_activate = self.is_dmenu_mode;
self.search_entry.connect_activate(move |entry| {
let selected = results_list_for_activate
.selected_row()
.or_else(|| results_list_for_activate.row_at_index(0));
// Handle the case where we have a selected item
if let Some(row) = selected {
let index = row.index() as usize;
let results = current_results_for_activate.borrow();
@@ -755,6 +795,10 @@ impl MainWindow {
&providers_for_activate,
);
if should_close {
// In dmenu mode, exit with success code
if is_dmenu_mode_for_activate {
std::process::exit(0);
}
window_for_activate.close();
} else {
// Trigger search refresh for updated widget state
@@ -762,6 +806,16 @@ impl MainWindow {
}
}
}
return;
}
}
// No item selected/matched - in dmenu mode, output the typed text
if is_dmenu_mode_for_activate {
let text = entry.text();
if !text.is_empty() {
println!("{}", text);
std::process::exit(0);
}
}
});
@@ -802,6 +856,7 @@ impl MainWindow {
let hints_label = self.hints_label.clone();
let submenu_state = self.submenu_state.clone();
let tab_order = self.tab_order.clone();
let is_dmenu_mode = self.is_dmenu_mode;
key_controller.connect_key_pressed(move |_, key, _, modifiers| {
let ctrl = modifiers.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
@@ -824,6 +879,10 @@ impl MainWindow {
);
gtk4::glib::Propagation::Stop
} else {
// In dmenu mode, exit with cancel code (1)
if is_dmenu_mode {
std::process::exit(1);
}
window.close();
gtk4::glib::Propagation::Stop
}
@@ -841,6 +900,10 @@ impl MainWindow {
);
gtk4::glib::Propagation::Stop
} else {
// In dmenu mode, exit with cancel code (1)
if is_dmenu_mode {
std::process::exit(1);
}
window.close();
gtk4::glib::Propagation::Stop
}
@@ -1238,6 +1301,12 @@ impl MainWindow {
}
fn launch_item(item: &LaunchItem, config: &Config, frecency: &Rc<RefCell<FrecencyStore>>) {
// dmenu mode: print selection to stdout instead of executing
if matches!(item.provider, ProviderType::Dmenu) {
println!("{}", item.name);
return;
}
// Record this launch for frecency tracking
if config.providers.frecency {
frecency.borrow_mut().record_launch(&item.id);
@@ -1248,18 +1317,89 @@ impl MainWindow {
info!("Launching: {} ({})", item.name, item.command);
#[cfg(feature = "dev-logging")]
debug!("[UI] Launch details: terminal={}, provider={:?}", item.terminal, item.provider);
debug!("[UI] Launch details: terminal={}, provider={:?}, id={}", item.terminal, item.provider, item.id);
let cmd = if item.terminal {
let terminal = config.general.terminal_command.as_deref().unwrap_or("xterm");
format!("{} -e {}", terminal, item.command)
// Check if this is a desktop application (has .desktop file as ID)
let is_desktop_app = matches!(item.provider, ProviderType::Application)
&& item.id.ends_with(".desktop");
// Desktop files should be launched via proper launchers that implement the
// freedesktop Desktop Entry spec (D-Bus activation, field codes, env vars, etc.)
// We delegate to: uwsm (if configured), gio launch, or gtk-launch as fallback.
//
// Non-desktop items (commands, plugins) use sh -c for shell execution.
let result = if is_desktop_app {
Self::launch_desktop_file(&item.id, config)
} else {
item.command.clone()
Self::launch_command(&item.command, item.terminal, config)
};
// Detect if this is a shell command vs an application launch
// Shell commands: playerctl, dbus-send, systemctl, journalctl, or anything with shell operators
let is_shell_command = cmd.starts_with("playerctl ")
if let Err(e) = result {
let msg = format!("Failed to launch '{}': {}", item.name, e);
log::error!("{}", msg);
crate::notify::notify("Launch failed", &msg);
}
}
/// Launch a .desktop file.
///
/// When `use_uwsm` is enabled in config, launches via `uwsm app -- <file>`
/// which starts the app in a proper systemd user session.
///
/// Otherwise, uses `gio launch` which is always available (part of glib2/GTK4)
/// and handles D-Bus activation, field codes, Terminal flag, etc.
fn launch_desktop_file(desktop_path: &str, config: &Config) -> std::io::Result<std::process::Child> {
use std::path::Path;
// Check if desktop file exists
if !Path::new(desktop_path).exists() {
let msg = format!("Desktop file not found: {}", desktop_path);
log::error!("{}", msg);
crate::notify::notify("Launch failed", &msg);
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, msg));
}
if config.general.use_uwsm {
// Check if uwsm is available
let uwsm_available = Command::new("which")
.arg("uwsm")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !uwsm_available {
let msg = "uwsm is enabled in config but not installed";
log::error!("{}", msg);
crate::notify::notify("Launch failed", msg);
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, msg));
}
info!("Launching via uwsm: {}", desktop_path);
Command::new("uwsm")
.args(["app", "--", desktop_path])
.spawn()
} else {
info!("Launching via gio: {}", desktop_path);
Command::new("gio")
.args(["launch", desktop_path])
.spawn()
}
}
/// Launch a shell command (for non-desktop items like PATH commands, plugins, etc.)
fn launch_command(command: &str, terminal: bool, config: &Config) -> std::io::Result<std::process::Child> {
let cmd = if terminal {
let terminal_cmd = config.general.terminal_command.as_deref().unwrap_or("xterm");
format!("{} -e {}", terminal_cmd, command)
} else {
command.to_string()
};
// Shell/system commands run directly without uwsm wrapper
// (they're typically short-lived or system utilities)
let is_system_command = cmd.starts_with("playerctl ")
|| cmd.starts_with("dbus-send ")
|| cmd.starts_with("systemctl ")
|| cmd.starts_with("journalctl ")
@@ -1269,28 +1409,14 @@ impl MainWindow {
|| cmd.contains(" > ")
|| cmd.contains(" < ");
// Use launch wrapper if configured (uwsm, hyprctl, etc.)
// But skip wrapper for shell commands - they need sh -c
let result = match &config.general.launch_wrapper {
Some(wrapper) if !wrapper.is_empty() && !is_shell_command => {
info!("Using launch wrapper: {}", wrapper);
// Split wrapper into command and args (e.g., "uwsm app --" -> ["uwsm", "app", "--"])
let mut wrapper_parts: Vec<&str> = wrapper.split_whitespace().collect();
if wrapper_parts.is_empty() {
Command::new("sh").arg("-c").arg(&cmd).spawn()
} else {
let wrapper_cmd = wrapper_parts.remove(0);
Command::new(wrapper_cmd)
.args(&wrapper_parts)
.arg(&cmd)
.spawn()
}
}
_ => Command::new("sh").arg("-c").arg(&cmd).spawn(),
};
if let Err(e) = result {
log::error!("Failed to launch '{}': {}", item.name, e);
// Use uwsm for regular commands if enabled (and not a system command)
if config.general.use_uwsm && !is_system_command {
info!("Launching command via uwsm: {}", cmd);
Command::new("uwsm")
.args(["app", "--", "sh", "-c", &cmd])
.spawn()
} else {
Command::new("sh").arg("-c").arg(&cmd).spawn()
}
}
}

View File

@@ -30,9 +30,11 @@ max_results = 10
# Uncomment to override:
# terminal_command = "kitty"
# Launch wrapper for app execution (auto-detected for uwsm/Hyprland)
# Examples: "uwsm app --", "hyprctl dispatch exec --", ""
# launch_wrapper = "uwsm app --"
# Enable uwsm (Universal Wayland Session Manager) for launching apps.
# When enabled, apps are launched via "uwsm app --" which starts them
# in a proper systemd user session for better process management.
# Requires: uwsm to be installed
# use_uwsm = true
# Header tabs - providers shown as toggle buttons (Ctrl+1, Ctrl+2, etc.)
# Values: app, cmd, uuctl, bookmark, calc, clip, dmenu, emoji, file, script, ssh, sys, web