feat: initial owlry application launcher

Owl-themed Wayland application launcher with GTK4 and layer-shell.

Features:
- Provider-based architecture (apps, commands, systemd user services)
- Filter tabs and prefix shortcuts (:app, :cmd, :uuctl)
- Submenu actions for systemd services (start/stop/restart/status/journal)
- Smart terminal detection with fallback chain
- CLI options for mode selection (--mode, --providers)
- Fuzzy search with configurable max results
- Custom owl-inspired dark theme

🤖 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-28 14:09:24 +01:00
commit 2d3efcdd56
17 changed files with 4052 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

1516
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

58
Cargo.toml Normal file
View File

@@ -0,0 +1,58 @@
[package]
name = "owlry"
version = "0.1.0"
edition = "2024"
rust-version = "1.90"
description = "A lightweight, owl-themed application launcher for Wayland"
authors = ["Your Name <you@example.com>"]
license = "GPL-3.0-or-later"
repository = "https://github.com/yourusername/owlry"
keywords = ["launcher", "wayland", "gtk4", "linux"]
categories = ["gui"]
[dependencies]
# GTK4 for the UI
gtk4 = { version = "0.9", features = ["v4_12"] }
# Layer shell support for Wayland overlay behavior
gtk4-layer-shell = "0.4"
# Async runtime for non-blocking operations
tokio = { version = "1", features = ["rt", "sync", "process", "fs"] }
# Fuzzy matching for search
fuzzy-matcher = "0.3"
# XDG desktop entry parsing
freedesktop-desktop-entry = "0.7"
# Directory utilities
dirs = "5"
# Low-level syscalls for stdin detection
libc = "0.2"
# Logging
log = "0.4"
env_logger = "0.11"
# Error handling
thiserror = "2"
# Configuration
serde = { version = "1", features = ["derive"] }
toml = "0.8"
# CLI argument parsing
clap = { version = "4", features = ["derive"] }
[profile.release]
lto = true
codegen-units = 1
panic = "abort"
strip = true
opt-level = "z" # Optimize for size
[profile.dev]
opt-level = 0
debug = true

246
resources/style.css Normal file
View File

@@ -0,0 +1,246 @@
/*
* Owlry - Owl-themed Application Launcher
*
* Color Palette (Owl-inspired):
* - Deep night sky: #1a1b26 (background)
* - Twilight: #24283b (secondary bg)
* - Owl feathers: #414868 (borders/muted)
* - Moon glow: #c0caf5 (primary text)
* - Owl eyes (amber): #e0af68 (accent/highlight)
* - Forest shadows: #565f89 (secondary text)
* - Barn owl cream: #f5e0dc (bright accent)
*/
/* Main window */
.owlry-window {
background-color: transparent;
}
.owlry-main {
background-color: rgba(26, 27, 38, 0.95);
border-radius: 16px;
border: 1px solid rgba(65, 72, 104, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(224, 175, 104, 0.1);
}
/* Search entry */
.owlry-search {
background-color: rgba(36, 40, 59, 0.8);
border: 2px solid rgba(65, 72, 104, 0.5);
border-radius: 12px;
padding: 12px 16px;
font-size: 16px;
color: #c0caf5;
caret-color: #e0af68;
min-height: 24px;
}
.owlry-search:focus {
border-color: #e0af68;
box-shadow: 0 0 0 2px rgba(224, 175, 104, 0.2);
outline: none;
}
.owlry-search placeholder {
color: #565f89;
}
/* Results list */
.owlry-results {
background-color: transparent;
border-radius: 8px;
}
/* Individual result row */
.owlry-result-row {
background-color: transparent;
border-radius: 8px;
margin: 2px 0;
transition: background-color 150ms ease;
}
.owlry-result-row:hover {
background-color: rgba(36, 40, 59, 0.6);
}
.owlry-result-row:selected {
background-color: rgba(224, 175, 104, 0.15);
border-left: 3px solid #e0af68;
}
.owlry-result-row:selected:hover {
background-color: rgba(224, 175, 104, 0.2);
}
/* Result icon */
.owlry-result-icon {
color: #c0caf5;
opacity: 0.9;
}
.owlry-result-row:selected .owlry-result-icon {
color: #e0af68;
opacity: 1;
}
/* Result name */
.owlry-result-name {
font-size: 14px;
font-weight: 500;
color: #c0caf5;
}
.owlry-result-row:selected .owlry-result-name {
color: #f5e0dc;
}
/* Result description */
.owlry-result-description {
font-size: 12px;
color: #565f89;
margin-top: 2px;
}
.owlry-result-row:selected .owlry-result-description {
color: #7aa2f7;
}
/* Provider badges */
.owlry-result-badge {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 3px 8px;
border-radius: 6px;
background-color: rgba(65, 72, 104, 0.4);
color: #565f89;
}
.owlry-badge-app {
background-color: rgba(122, 162, 247, 0.2);
color: #7aa2f7;
}
.owlry-badge-cmd {
background-color: rgba(187, 154, 247, 0.2);
color: #bb9af7;
}
.owlry-badge-dmenu {
background-color: rgba(158, 206, 106, 0.2);
color: #9ece6a;
}
.owlry-badge-uuctl {
background-color: rgba(224, 175, 104, 0.2);
color: #e0af68;
}
/* Scrollbar styling */
scrollbar {
background-color: transparent;
}
scrollbar slider {
background-color: rgba(65, 72, 104, 0.5);
border-radius: 4px;
min-width: 6px;
min-height: 40px;
}
scrollbar slider:hover {
background-color: rgba(86, 95, 137, 0.7);
}
scrollbar slider:active {
background-color: #e0af68;
}
/* Selection highlighting */
selection {
background-color: rgba(224, 175, 104, 0.3);
color: #f5e0dc;
}
/* Header bar with mode indicator and filter tabs */
.owlry-header {
margin-bottom: 4px;
}
/* Mode indicator */
.owlry-mode-indicator {
font-size: 12px;
font-weight: 600;
color: #e0af68;
padding: 4px 12px;
background-color: rgba(224, 175, 104, 0.15);
border-radius: 6px;
}
/* Filter tabs container */
.owlry-filter-tabs {
/* Container spacing handled by GtkBox */
}
/* Filter toggle buttons */
.owlry-filter-button {
font-size: 11px;
font-weight: 500;
padding: 4px 10px;
border-radius: 6px;
background-color: rgba(65, 72, 104, 0.3);
color: #565f89;
border: 1px solid transparent;
min-height: 20px;
transition: all 150ms ease;
}
.owlry-filter-button:hover {
background-color: rgba(65, 72, 104, 0.5);
color: #c0caf5;
}
.owlry-filter-button:checked {
background-color: rgba(224, 175, 104, 0.2);
color: #e0af68;
border-color: rgba(224, 175, 104, 0.4);
}
/* Provider-specific filter button colors when active */
.owlry-filter-app:checked {
background-color: rgba(122, 162, 247, 0.2);
color: #7aa2f7;
border-color: rgba(122, 162, 247, 0.4);
}
.owlry-filter-cmd:checked {
background-color: rgba(187, 154, 247, 0.2);
color: #bb9af7;
border-color: rgba(187, 154, 247, 0.4);
}
.owlry-filter-uuctl:checked {
background-color: rgba(224, 175, 104, 0.2);
color: #e0af68;
border-color: rgba(224, 175, 104, 0.4);
}
.owlry-filter-dmenu:checked {
background-color: rgba(158, 206, 106, 0.2);
color: #9ece6a;
border-color: rgba(158, 206, 106, 0.4);
}
/* Hints bar at bottom */
.owlry-hints {
padding-top: 8px;
border-top: 1px solid rgba(65, 72, 104, 0.3);
}
.owlry-hints-label {
font-size: 10px;
color: #565f89;
letter-spacing: 0.5px;
}

96
src/app.rs Normal file
View File

@@ -0,0 +1,96 @@
use crate::cli::CliArgs;
use crate::config::Config;
use crate::filter::ProviderFilter;
use crate::providers::ProviderManager;
use crate::ui::MainWindow;
use gtk4::prelude::*;
use gtk4::{gio, Application};
use gtk4_layer_shell::{Edge, Layer, LayerShell};
use log::debug;
use std::cell::RefCell;
use std::rc::Rc;
const APP_ID: &str = "org.owlry.launcher";
pub struct OwlryApp {
app: Application,
}
impl OwlryApp {
pub fn new(args: CliArgs) -> Self {
let app = Application::builder()
.application_id(APP_ID)
.flags(gio::ApplicationFlags::FLAGS_NONE)
.build();
app.connect_activate(move |app| Self::on_activate(app, &args));
Self { app }
}
pub fn run(&self) -> i32 {
self.app.run().into()
}
fn on_activate(app: &Application, args: &CliArgs) {
debug!("Activating Owlry");
let config = Rc::new(RefCell::new(Config::load_or_default()));
let providers = Rc::new(RefCell::new(ProviderManager::new()));
// Create filter from CLI args and config
let filter = ProviderFilter::new(
args.mode,
args.providers.clone(),
&config.borrow().providers,
);
let filter = Rc::new(RefCell::new(filter));
let window = MainWindow::new(app, config.clone(), providers.clone(), filter.clone());
// Set up layer shell for Wayland overlay behavior
window.init_layer_shell();
window.set_layer(Layer::Overlay);
window.set_keyboard_mode(gtk4_layer_shell::KeyboardMode::Exclusive);
// Anchor to all edges for centered overlay effect
// We'll use margins to control the actual size
window.set_anchor(Edge::Top, true);
window.set_anchor(Edge::Bottom, false);
window.set_anchor(Edge::Left, false);
window.set_anchor(Edge::Right, false);
// Position from top
window.set_margin(Edge::Top, 200);
// Load CSS styling
Self::load_css();
window.present();
}
fn load_css() {
let provider = gtk4::CssProvider::new();
// Try to load from config directory first, then fall back to embedded
let config_css = dirs::config_dir().map(|p| p.join("owlry").join("style.css"));
if let Some(css_path) = config_css {
if css_path.exists() {
provider.load_from_path(&css_path);
debug!("Loaded CSS from {:?}", css_path);
} else {
provider.load_from_string(include_str!("../resources/style.css"));
debug!("Loaded embedded CSS");
}
} else {
provider.load_from_string(include_str!("../resources/style.css"));
}
gtk4::style_context_add_provider_for_display(
&gtk4::gdk::Display::default().expect("Could not get default display"),
&provider,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
}
}

29
src/cli.rs Normal file
View File

@@ -0,0 +1,29 @@
use clap::Parser;
use crate::providers::ProviderType;
#[derive(Parser, Debug, Clone)]
#[command(
name = "owlry",
about = "An owl-themed application launcher for Wayland",
version
)]
pub struct CliArgs {
/// Start in single-provider mode (app, cmd, uuctl)
#[arg(long, short = 'm', value_parser = parse_provider)]
pub mode: Option<ProviderType>,
/// Comma-separated list of enabled providers (app,cmd,uuctl)
#[arg(long, short = 'p', value_delimiter = ',', value_parser = parse_provider)]
pub providers: Option<Vec<ProviderType>>,
}
fn parse_provider(s: &str) -> Result<ProviderType, String> {
s.parse()
}
impl CliArgs {
pub fn parse_args() -> Self {
Self::parse()
}
}

171
src/config/mod.rs Normal file
View File

@@ -0,0 +1,171 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::process::Command;
use log::{info, warn, debug};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub general: GeneralConfig,
pub appearance: AppearanceConfig,
pub providers: ProvidersConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeneralConfig {
pub show_icons: bool,
pub max_results: usize,
pub terminal_command: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppearanceConfig {
pub width: i32,
pub height: i32,
pub font_size: u32,
pub border_radius: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProvidersConfig {
pub applications: bool,
pub commands: bool,
pub uuctl: bool,
}
/// Detect the best available terminal emulator
/// Fallback chain:
/// 1. $TERMINAL env var (user's explicit preference)
/// 2. xdg-terminal-exec (freedesktop standard)
/// 3. Common Wayland-native terminals (kitty, alacritty, wezterm, foot)
/// 4. Common X11/legacy terminals (gnome-terminal, konsole, xfce4-terminal)
/// 5. x-terminal-emulator (Debian alternatives)
/// 6. xterm (ultimate fallback)
fn detect_terminal() -> String {
// 1. Check $TERMINAL env var first
if let Ok(term) = std::env::var("TERMINAL") {
if !term.is_empty() && command_exists(&term) {
debug!("Using $TERMINAL: {}", term);
return term;
}
}
// 2. Try xdg-terminal-exec (freedesktop standard)
if command_exists("xdg-terminal-exec") {
debug!("Using xdg-terminal-exec");
return "xdg-terminal-exec".to_string();
}
// 3. Common Wayland-native terminals (preferred)
let wayland_terminals = ["kitty", "alacritty", "wezterm", "foot"];
for term in wayland_terminals {
if command_exists(term) {
debug!("Found Wayland terminal: {}", term);
return term.to_string();
}
}
// 4. Common X11/legacy terminals
let legacy_terminals = ["gnome-terminal", "konsole", "xfce4-terminal", "tilix", "terminator"];
for term in legacy_terminals {
if command_exists(term) {
debug!("Found legacy terminal: {}", term);
return term.to_string();
}
}
// 5. Try x-terminal-emulator (Debian alternatives system)
if command_exists("x-terminal-emulator") {
debug!("Using x-terminal-emulator");
return "x-terminal-emulator".to_string();
}
// 6. Ultimate fallback
debug!("Falling back to xterm");
"xterm".to_string()
}
/// Check if a command exists in PATH
fn command_exists(cmd: &str) -> bool {
Command::new("which")
.arg(cmd)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
impl Default for Config {
fn default() -> Self {
let terminal = detect_terminal();
info!("Detected terminal: {}", terminal);
Self {
general: GeneralConfig {
show_icons: true,
max_results: 10,
terminal_command: terminal,
},
appearance: AppearanceConfig {
width: 600,
height: 400,
font_size: 14,
border_radius: 12,
},
providers: ProvidersConfig {
applications: true,
commands: true,
uuctl: true,
},
}
}
}
impl Config {
pub fn config_path() -> Option<PathBuf> {
dirs::config_dir().map(|p| p.join("owlry").join("config.toml"))
}
pub fn load_or_default() -> Self {
Self::load().unwrap_or_else(|e| {
warn!("Failed to load config: {}, using defaults", e);
Self::default()
})
}
pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
let path = Self::config_path().ok_or("Could not determine config path")?;
if !path.exists() {
info!("Config file not found, using defaults");
return Ok(Self::default());
}
let content = std::fs::read_to_string(&path)?;
let mut config: Config = toml::from_str(&content)?;
info!("Loaded config from {:?}", path);
// Validate terminal - if configured terminal doesn't exist, auto-detect
if !command_exists(&config.general.terminal_command) {
warn!(
"Configured terminal '{}' not found, auto-detecting",
config.general.terminal_command
);
config.general.terminal_command = detect_terminal();
info!("Using detected terminal: {}", config.general.terminal_command);
}
Ok(config)
}
pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
let path = Self::config_path().ok_or("Could not determine config path")?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = toml::to_string_pretty(self)?;
std::fs::write(&path, content)?;
info!("Saved config to {:?}", path);
Ok(())
}
}

237
src/filter.rs Normal file
View File

@@ -0,0 +1,237 @@
use std::collections::HashSet;
use crate::config::ProvidersConfig;
use crate::providers::ProviderType;
/// Tracks which providers are enabled and handles prefix-based filtering
#[derive(Debug, Clone)]
pub struct ProviderFilter {
enabled: HashSet<ProviderType>,
active_prefix: Option<ProviderType>,
}
/// Result of parsing a query for prefix syntax
#[derive(Debug, Clone)]
pub struct ParsedQuery {
pub prefix: Option<ProviderType>,
pub query: String,
}
impl ProviderFilter {
/// Create filter from CLI args and config
pub fn new(
cli_mode: Option<ProviderType>,
cli_providers: Option<Vec<ProviderType>>,
config_providers: &ProvidersConfig,
) -> Self {
let enabled = if let Some(mode) = cli_mode {
// --mode overrides everything: single provider
HashSet::from([mode])
} else if let Some(providers) = cli_providers {
// --providers overrides config
providers.into_iter().collect()
} else {
// Use config file settings, default to apps only
let mut set = HashSet::new();
if config_providers.applications {
set.insert(ProviderType::Application);
}
if config_providers.commands {
set.insert(ProviderType::Command);
}
if config_providers.uuctl {
set.insert(ProviderType::Uuctl);
}
// Default to apps if nothing enabled
if set.is_empty() {
set.insert(ProviderType::Application);
}
set
};
Self {
enabled,
active_prefix: None,
}
}
/// Default filter: apps only
pub fn apps_only() -> Self {
Self {
enabled: HashSet::from([ProviderType::Application]),
active_prefix: None,
}
}
/// Toggle a provider on/off
pub fn toggle(&mut self, provider: ProviderType) {
if self.enabled.contains(&provider) {
self.enabled.remove(&provider);
// Ensure at least one provider is always enabled
if self.enabled.is_empty() {
self.enabled.insert(ProviderType::Application);
}
} else {
self.enabled.insert(provider);
}
}
/// Enable a specific provider
pub fn enable(&mut self, provider: ProviderType) {
self.enabled.insert(provider);
}
/// Disable a specific provider (ensures at least one remains)
pub fn disable(&mut self, provider: ProviderType) {
self.enabled.remove(&provider);
if self.enabled.is_empty() {
self.enabled.insert(ProviderType::Application);
}
}
/// Set to single provider mode
pub fn set_single_mode(&mut self, provider: ProviderType) {
self.enabled.clear();
self.enabled.insert(provider);
}
/// Set prefix mode (from :app, :cmd, etc.)
pub fn set_prefix(&mut self, prefix: Option<ProviderType>) {
self.active_prefix = prefix;
}
/// Check if a provider should be searched
pub fn is_active(&self, provider: ProviderType) -> bool {
if let Some(prefix) = self.active_prefix {
provider == prefix
} else {
self.enabled.contains(&provider)
}
}
/// Check if provider is in enabled set (ignoring prefix)
pub fn is_enabled(&self, provider: ProviderType) -> bool {
self.enabled.contains(&provider)
}
/// Get current active prefix if any
pub fn active_prefix(&self) -> Option<ProviderType> {
self.active_prefix
}
/// Parse query for prefix syntax
pub fn parse_query(query: &str) -> ParsedQuery {
let trimmed = query.trim_start();
// Check for prefix patterns (with trailing space)
let prefixes = [
(":app ", ProviderType::Application),
(":apps ", ProviderType::Application),
(":cmd ", ProviderType::Command),
(":command ", ProviderType::Command),
(":uuctl ", ProviderType::Uuctl),
];
for (prefix_str, provider) in prefixes {
if let Some(rest) = trimmed.strip_prefix(prefix_str) {
return ParsedQuery {
prefix: Some(provider),
query: rest.to_string(),
};
}
}
// Handle prefix without trailing space (still typing)
let partial_prefixes = [
(":app", ProviderType::Application),
(":apps", ProviderType::Application),
(":cmd", ProviderType::Command),
(":command", ProviderType::Command),
(":uuctl", ProviderType::Uuctl),
];
for (prefix_str, provider) in partial_prefixes {
if trimmed == prefix_str {
return ParsedQuery {
prefix: Some(provider),
query: String::new(),
};
}
}
ParsedQuery {
prefix: None,
query: query.to_string(),
}
}
/// Get enabled providers for UI display (sorted)
pub fn enabled_providers(&self) -> Vec<ProviderType> {
let mut providers: Vec<_> = self.enabled.iter().copied().collect();
providers.sort_by_key(|p| match p {
ProviderType::Application => 0,
ProviderType::Command => 1,
ProviderType::Uuctl => 2,
ProviderType::Dmenu => 3,
});
providers
}
/// Get display name for current mode
pub fn mode_display_name(&self) -> &'static str {
if let Some(prefix) = self.active_prefix {
return match prefix {
ProviderType::Application => "Apps",
ProviderType::Command => "Commands",
ProviderType::Uuctl => "uuctl",
ProviderType::Dmenu => "dmenu",
};
}
let enabled: Vec<_> = self.enabled_providers();
if enabled.len() == 1 {
match enabled[0] {
ProviderType::Application => "Apps",
ProviderType::Command => "Commands",
ProviderType::Uuctl => "uuctl",
ProviderType::Dmenu => "dmenu",
}
} else {
"All"
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_query_with_prefix() {
let result = ProviderFilter::parse_query(":app firefox");
assert_eq!(result.prefix, Some(ProviderType::Application));
assert_eq!(result.query, "firefox");
}
#[test]
fn test_parse_query_without_prefix() {
let result = ProviderFilter::parse_query("firefox");
assert_eq!(result.prefix, None);
assert_eq!(result.query, "firefox");
}
#[test]
fn test_parse_query_partial_prefix() {
let result = ProviderFilter::parse_query(":cmd");
assert_eq!(result.prefix, Some(ProviderType::Command));
assert_eq!(result.query, "");
}
#[test]
fn test_toggle_ensures_one_enabled() {
let mut filter = ProviderFilter::apps_only();
filter.toggle(ProviderType::Application);
// Should still have apps enabled as fallback
assert!(filter.is_enabled(ProviderType::Application));
}
}

21
src/main.rs Normal file
View File

@@ -0,0 +1,21 @@
mod app;
mod cli;
mod config;
mod filter;
mod providers;
mod ui;
use app::OwlryApp;
use cli::CliArgs;
use log::info;
fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let args = CliArgs::parse_args();
info!("Starting Owlry launcher");
let app = OwlryApp::new(args);
std::process::exit(app.run());
}

View File

@@ -0,0 +1,120 @@
use super::{LaunchItem, Provider, ProviderType};
use freedesktop_desktop_entry::{DesktopEntry, Iter};
use log::{debug, warn};
use std::path::PathBuf;
pub struct ApplicationProvider {
items: Vec<LaunchItem>,
}
impl ApplicationProvider {
pub fn new() -> Self {
Self { items: Vec::new() }
}
fn get_application_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
// User applications
if let Some(data_home) = dirs::data_dir() {
dirs.push(data_home.join("applications"));
}
// System applications
dirs.push(PathBuf::from("/usr/share/applications"));
dirs.push(PathBuf::from("/usr/local/share/applications"));
// Flatpak applications
if let Some(data_home) = dirs::data_dir() {
dirs.push(data_home.join("flatpak/exports/share/applications"));
}
dirs.push(PathBuf::from("/var/lib/flatpak/exports/share/applications"));
dirs
}
}
impl Provider for ApplicationProvider {
fn name(&self) -> &str {
"Applications"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Application
}
fn refresh(&mut self) {
self.items.clear();
let dirs = Self::get_application_dirs();
debug!("Scanning application directories: {:?}", dirs);
// Empty locale list for default locale
let locales: &[&str] = &[];
for path in Iter::new(dirs.into_iter()) {
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
warn!("Failed to read {:?}: {}", path, e);
continue;
}
};
let desktop_entry = match DesktopEntry::from_str(&path, &content, Some(locales)) {
Ok(e) => e,
Err(e) => {
warn!("Failed to parse {:?}: {}", path, e);
continue;
}
};
// Skip entries marked as hidden or no-display
if desktop_entry.no_display() || desktop_entry.hidden() {
continue;
}
// Only include Application type entries
if desktop_entry.type_() != Some("Application") {
continue;
}
let name = match desktop_entry.name(locales) {
Some(n) => n.to_string(),
None => continue,
};
let run_cmd = match desktop_entry.exec() {
Some(e) => {
// Clean up run command (remove %u, %U, %f, %F, etc.)
e.split_whitespace()
.filter(|s| !s.starts_with('%'))
.collect::<Vec<_>>()
.join(" ")
}
None => continue,
};
let item = LaunchItem {
id: path.to_string_lossy().to_string(),
name,
description: desktop_entry.comment(locales).map(|s| s.to_string()),
icon: desktop_entry.icon().map(|s| s.to_string()),
provider: ProviderType::Application,
command: run_cmd,
terminal: desktop_entry.terminal(),
};
self.items.push(item);
}
debug!("Found {} applications", self.items.len());
// Sort alphabetically by name
self.items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
}

105
src/providers/command.rs Normal file
View File

@@ -0,0 +1,105 @@
use super::{LaunchItem, Provider, ProviderType};
use log::debug;
use std::collections::HashSet;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
pub struct CommandProvider {
items: Vec<LaunchItem>,
}
impl CommandProvider {
pub fn new() -> Self {
Self { items: Vec::new() }
}
fn get_path_dirs() -> Vec<PathBuf> {
std::env::var("PATH")
.unwrap_or_default()
.split(':')
.map(PathBuf::from)
.filter(|p| p.exists())
.collect()
}
fn is_executable(path: &std::path::Path) -> bool {
if let Ok(metadata) = path.metadata() {
let permissions = metadata.permissions();
permissions.mode() & 0o111 != 0
} else {
false
}
}
}
impl Provider for CommandProvider {
fn name(&self) -> &str {
"Commands"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Command
}
fn refresh(&mut self) {
self.items.clear();
let dirs = Self::get_path_dirs();
let mut seen_names: HashSet<String> = HashSet::new();
debug!("Scanning PATH directories for commands");
for dir in dirs {
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.filter_map(Result::ok) {
let path = entry.path();
// Skip directories and non-executable files
if path.is_dir() || !Self::is_executable(&path) {
continue;
}
let name = match path.file_name() {
Some(n) => n.to_string_lossy().to_string(),
None => continue,
};
// Skip duplicates (first one in PATH wins)
if seen_names.contains(&name) {
continue;
}
seen_names.insert(name.clone());
// Skip hidden files
if name.starts_with('.') {
continue;
}
let item = LaunchItem {
id: path.to_string_lossy().to_string(),
name: name.clone(),
description: Some(format!("Run {}", path.display())),
icon: Some("utilities-terminal".to_string()),
provider: ProviderType::Command,
command: name,
terminal: false,
};
self.items.push(item);
}
}
debug!("Found {} commands in PATH", self.items.len());
// Sort alphabetically
self.items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
}

94
src/providers/dmenu.rs Normal file
View File

@@ -0,0 +1,94 @@
use super::{LaunchItem, Provider, ProviderType};
use log::debug;
use std::io::{self, BufRead};
/// Provider for dmenu-style input from stdin
pub struct DmenuProvider {
items: Vec<LaunchItem>,
enabled: bool,
}
impl DmenuProvider {
pub fn new() -> Self {
Self {
items: Vec::new(),
enabled: false,
}
}
/// Check if stdin has data (non-blocking check)
pub fn has_stdin_data() -> bool {
use std::os::unix::io::AsRawFd;
let stdin_fd = io::stdin().as_raw_fd();
let mut poll_fd = libc::pollfd {
fd: stdin_fd,
events: libc::POLLIN,
revents: 0,
};
// Non-blocking poll with 0 timeout
let result = unsafe { libc::poll(&mut poll_fd, 1, 0) };
result > 0 && (poll_fd.revents & libc::POLLIN) != 0
}
/// Enable dmenu mode (called when stdin has data)
pub fn enable(&mut self) {
self.enabled = true;
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
}
impl Provider for DmenuProvider {
fn name(&self) -> &str {
"dmenu"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Dmenu
}
fn refresh(&mut self) {
self.items.clear();
if !self.enabled {
return;
}
debug!("Reading dmenu items from stdin");
let stdin = io::stdin();
for (idx, line) in stdin.lock().lines().enumerate() {
let line = match line {
Ok(l) => l,
Err(_) => continue,
};
let line = line.trim();
if line.is_empty() {
continue;
}
let item = LaunchItem {
id: format!("dmenu:{}", idx),
name: line.to_string(),
description: None,
icon: None,
provider: ProviderType::Dmenu,
command: line.to_string(),
terminal: false,
};
self.items.push(item);
}
debug!("Read {} items from stdin", self.items.len());
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
}

203
src/providers/mod.rs Normal file
View File

@@ -0,0 +1,203 @@
mod application;
mod command;
mod dmenu;
mod uuctl;
pub use application::ApplicationProvider;
pub use command::CommandProvider;
pub use dmenu::DmenuProvider;
pub use uuctl::UuctlProvider;
use fuzzy_matcher::FuzzyMatcher;
use fuzzy_matcher::skim::SkimMatcherV2;
/// Represents a single searchable/launchable item
#[derive(Debug, Clone)]
pub struct LaunchItem {
pub id: String,
pub name: String,
pub description: Option<String>,
pub icon: Option<String>,
pub provider: ProviderType,
pub command: String,
pub terminal: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ProviderType {
Application,
Command,
Dmenu,
Uuctl,
}
impl std::str::FromStr for ProviderType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"app" | "apps" | "application" | "applications" => Ok(ProviderType::Application),
"cmd" | "command" | "commands" => Ok(ProviderType::Command),
"uuctl" => Ok(ProviderType::Uuctl),
"dmenu" => Ok(ProviderType::Dmenu),
_ => Err(format!("Unknown provider: '{}'. Valid: app, cmd, uuctl", s)),
}
}
}
impl std::fmt::Display for ProviderType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProviderType::Application => write!(f, "app"),
ProviderType::Command => write!(f, "cmd"),
ProviderType::Dmenu => write!(f, "dmenu"),
ProviderType::Uuctl => write!(f, "uuctl"),
}
}
}
/// Trait for all search providers
pub trait Provider: Send {
fn name(&self) -> &str;
fn provider_type(&self) -> ProviderType;
fn refresh(&mut self);
fn items(&self) -> &[LaunchItem];
}
/// Manages all providers and handles searching
pub struct ProviderManager {
providers: Vec<Box<dyn Provider>>,
matcher: SkimMatcherV2,
}
impl ProviderManager {
pub fn new() -> Self {
let mut manager = Self {
providers: Vec::new(),
matcher: SkimMatcherV2::default(),
};
// Check if running in dmenu mode (stdin has data)
let dmenu_mode = DmenuProvider::has_stdin_data();
if dmenu_mode {
// In dmenu mode, only use dmenu provider
let mut dmenu = DmenuProvider::new();
dmenu.enable();
manager.providers.push(Box::new(dmenu));
} else {
// Normal mode: use all standard providers
manager.providers.push(Box::new(ApplicationProvider::new()));
manager.providers.push(Box::new(CommandProvider::new()));
manager.providers.push(Box::new(UuctlProvider::new()));
}
// Initial refresh
manager.refresh_all();
manager
}
pub fn is_dmenu_mode(&self) -> bool {
self.providers
.iter()
.any(|p| p.provider_type() == ProviderType::Dmenu)
}
pub fn refresh_all(&mut self) {
for provider in &mut self.providers {
provider.refresh();
}
}
pub fn search(&self, query: &str, max_results: usize) -> Vec<(LaunchItem, i64)> {
if query.is_empty() {
// Return recent/popular items when query is empty
return self.providers
.iter()
.flat_map(|p| p.items().iter().cloned())
.take(max_results)
.map(|item| (item, 0))
.collect();
}
let mut results: Vec<(LaunchItem, i64)> = self.providers
.iter()
.flat_map(|provider| {
provider.items().iter().filter_map(|item| {
// Match against name and description
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 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), // Lower weight for description matches
(None, None) => None,
};
score.map(|s| (item.clone(), s))
})
})
.collect();
// Sort by score (descending)
results.sort_by(|a, b| b.1.cmp(&a.1));
results.truncate(max_results);
results
}
/// Search with provider filtering
pub fn search_filtered(
&self,
query: &str,
max_results: usize,
filter: &crate::filter::ProviderFilter,
) -> Vec<(LaunchItem, i64)> {
if query.is_empty() {
return self
.providers
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter().cloned())
.take(max_results)
.map(|item| (item, 0))
.collect();
}
let mut results: Vec<(LaunchItem, i64)> = self
.providers
.iter()
.filter(|provider| filter.is_active(provider.provider_type()))
.flat_map(|provider| {
provider.items().iter().filter_map(|item| {
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 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,
};
score.map(|s| (item.clone(), s))
})
})
.collect();
results.sort_by(|a, b| b.1.cmp(&a.1));
results.truncate(max_results);
results
}
/// Get all available provider types (for UI tabs)
pub fn available_providers(&self) -> Vec<ProviderType> {
self.providers.iter().map(|p| p.provider_type()).collect()
}
}

265
src/providers/uuctl.rs Normal file
View File

@@ -0,0 +1,265 @@
use super::{LaunchItem, Provider, ProviderType};
use log::{debug, warn};
use std::process::Command;
/// Provider for systemd user services
/// Uses systemctl --user to list and control user-level services
pub struct UuctlProvider {
items: Vec<LaunchItem>,
}
/// Represents the state of a systemd service
#[derive(Debug, Clone)]
pub struct ServiceState {
pub unit_name: String,
pub display_name: String,
pub description: String,
pub active: bool,
pub sub_state: String,
}
impl UuctlProvider {
pub fn new() -> Self {
Self { items: Vec::new() }
}
fn systemctl_available() -> bool {
Command::new("systemctl")
.arg("--user")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Generate submenu actions for a given service
pub fn actions_for_service(unit_name: &str, display_name: &str, is_active: bool) -> Vec<LaunchItem> {
let mut actions = Vec::new();
if is_active {
actions.push(LaunchItem {
id: format!("systemd:restart:{}", unit_name),
name: "↻ Restart".to_string(),
description: Some(format!("Restart {}", display_name)),
icon: Some("view-refresh".to_string()),
provider: ProviderType::Uuctl,
command: format!("systemctl --user restart {}", unit_name),
terminal: false,
});
actions.push(LaunchItem {
id: format!("systemd:stop:{}", unit_name),
name: "■ Stop".to_string(),
description: Some(format!("Stop {}", display_name)),
icon: Some("process-stop".to_string()),
provider: ProviderType::Uuctl,
command: format!("systemctl --user stop {}", unit_name),
terminal: false,
});
actions.push(LaunchItem {
id: format!("systemd:reload:{}", unit_name),
name: "⟳ Reload".to_string(),
description: Some(format!("Reload {} configuration", display_name)),
icon: Some("view-refresh".to_string()),
provider: ProviderType::Uuctl,
command: format!("systemctl --user reload {}", unit_name),
terminal: false,
});
actions.push(LaunchItem {
id: format!("systemd:kill:{}", unit_name),
name: "✗ Kill".to_string(),
description: Some(format!("Force kill {}", display_name)),
icon: Some("edit-delete".to_string()),
provider: ProviderType::Uuctl,
command: format!("systemctl --user kill {}", unit_name),
terminal: false,
});
} else {
actions.push(LaunchItem {
id: format!("systemd:start:{}", unit_name),
name: "▶ Start".to_string(),
description: Some(format!("Start {}", display_name)),
icon: Some("media-playback-start".to_string()),
provider: ProviderType::Uuctl,
command: format!("systemctl --user start {}", unit_name),
terminal: false,
});
}
// Always available actions
actions.push(LaunchItem {
id: format!("systemd:status:{}", unit_name),
name: " Status".to_string(),
description: Some(format!("Show {} status", display_name)),
icon: Some("dialog-information".to_string()),
provider: ProviderType::Uuctl,
command: format!("systemctl --user status {}", unit_name),
terminal: true,
});
actions.push(LaunchItem {
id: format!("systemd:journal:{}", unit_name),
name: "📋 Journal".to_string(),
description: Some(format!("Show {} logs", display_name)),
icon: Some("utilities-system-monitor".to_string()),
provider: ProviderType::Uuctl,
command: format!("journalctl --user -u {} -f", unit_name),
terminal: true,
});
actions.push(LaunchItem {
id: format!("systemd:enable:{}", unit_name),
name: "⊕ Enable".to_string(),
description: Some(format!("Enable {} on startup", display_name)),
icon: Some("emblem-default".to_string()),
provider: ProviderType::Uuctl,
command: format!("systemctl --user enable {}", unit_name),
terminal: false,
});
actions.push(LaunchItem {
id: format!("systemd:disable:{}", unit_name),
name: "⊖ Disable".to_string(),
description: Some(format!("Disable {} on startup", display_name)),
icon: Some("emblem-unreadable".to_string()),
provider: ProviderType::Uuctl,
command: format!("systemctl --user disable {}", unit_name),
terminal: false,
});
actions
}
fn parse_systemctl_output(output: &str) -> Vec<LaunchItem> {
let mut items = Vec::new();
for line in output.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
// Parse systemctl output - handle variable whitespace
// Format: UNIT LOAD ACTIVE SUB DESCRIPTION...
let mut parts = line.split_whitespace();
let unit_name = match parts.next() {
Some(u) => u,
None => continue,
};
// Skip if not a proper service name
if !unit_name.ends_with(".service") {
continue;
}
let _load_state = parts.next().unwrap_or("");
let active_state = parts.next().unwrap_or("");
let sub_state = parts.next().unwrap_or("");
let description: String = parts.collect::<Vec<_>>().join(" ");
// Create a clean display name
let display_name = unit_name
.trim_end_matches(".service")
.replace("app-", "")
.replace("@autostart", "")
.replace("\\x2d", "-");
let is_active = active_state == "active";
let status_icon = if is_active { "" } else { "" };
let status_desc = if description.is_empty() {
format!("{} {} ({})", status_icon, sub_state, active_state)
} else {
format!("{} {} ({})", status_icon, description, sub_state)
};
// Store service info in the command field as encoded data
// Format: SUBMENU:unit_name:is_active
let submenu_data = format!("SUBMENU:{}:{}", unit_name, is_active);
items.push(LaunchItem {
id: format!("systemd:service:{}", unit_name),
name: display_name,
description: Some(status_desc),
icon: Some(if is_active { "emblem-ok-symbolic" } else { "emblem-pause-symbolic" }.to_string()),
provider: ProviderType::Uuctl,
command: submenu_data, // Special marker for submenu
terminal: false,
});
}
items
}
/// Check if an item is a submenu trigger (service, not action)
pub fn is_submenu_item(item: &LaunchItem) -> bool {
item.provider == ProviderType::Uuctl && item.command.starts_with("SUBMENU:")
}
/// Parse submenu data from item command
pub fn parse_submenu_data(item: &LaunchItem) -> Option<(String, String, bool)> {
if !Self::is_submenu_item(item) {
return None;
}
let parts: Vec<&str> = item.command.splitn(3, ':').collect();
if parts.len() >= 3 {
let unit_name = parts[1].to_string();
let is_active = parts[2] == "true";
Some((unit_name, item.name.clone(), is_active))
} else {
None
}
}
}
impl Provider for UuctlProvider {
fn name(&self) -> &str {
"systemd-user"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Uuctl
}
fn refresh(&mut self) {
self.items.clear();
if !Self::systemctl_available() {
debug!("systemctl --user not available, skipping");
return;
}
// List all user services (both running and available)
let output = match Command::new("systemctl")
.args(["--user", "list-units", "--type=service", "--all", "--no-legend", "--no-pager"])
.output()
{
Ok(o) => o,
Err(e) => {
warn!("Failed to run systemctl --user: {}", e);
return;
}
};
if !output.status.success() {
warn!("systemctl --user failed with status: {}", output.status);
return;
}
let stdout = String::from_utf8_lossy(&output.stdout);
self.items = Self::parse_systemctl_output(&stdout);
// Sort by name
self.items.sort_by(|a, b| a.name.cmp(&b.name));
debug!("Found {} systemd user services", self.items.len());
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
}

792
src/ui/main_window.rs Normal file
View File

@@ -0,0 +1,792 @@
use crate::config::Config;
use crate::filter::ProviderFilter;
use crate::providers::{LaunchItem, ProviderManager, ProviderType, UuctlProvider};
use crate::ui::ResultRow;
use gtk4::gdk::Key;
use gtk4::prelude::*;
use gtk4::{
Application, ApplicationWindow, Box as GtkBox, Entry, EventControllerKey, Label, ListBox,
ListBoxRow, Orientation, ScrolledWindow, SelectionMode, ToggleButton,
};
use log::info;
use std::cell::RefCell;
use std::collections::HashMap;
use std::process::Command;
use std::rc::Rc;
/// Tracks submenu state for services that have action submenus
#[derive(Debug, Clone, Default)]
struct SubmenuState {
/// Whether we're currently in a submenu
active: bool,
/// The service name being viewed
service_name: String,
/// Display name for the header
display_name: String,
/// The submenu items (actions)
items: Vec<LaunchItem>,
/// Saved search text to restore on exit
saved_search: String,
}
pub struct MainWindow {
window: ApplicationWindow,
search_entry: Entry,
results_list: ListBox,
scrolled: ScrolledWindow,
config: Rc<RefCell<Config>>,
providers: Rc<RefCell<ProviderManager>>,
current_results: Rc<RefCell<Vec<LaunchItem>>>,
filter: Rc<RefCell<ProviderFilter>>,
mode_label: Label,
hints_label: Label,
filter_buttons: Rc<RefCell<HashMap<ProviderType, ToggleButton>>>,
submenu_state: Rc<RefCell<SubmenuState>>,
}
impl MainWindow {
pub fn new(
app: &Application,
config: Rc<RefCell<Config>>,
providers: Rc<RefCell<ProviderManager>>,
filter: Rc<RefCell<ProviderFilter>>,
) -> Self {
let cfg = config.borrow();
let window = ApplicationWindow::builder()
.application(app)
.title("Owlry")
.default_width(cfg.appearance.width)
.default_height(cfg.appearance.height)
.resizable(false)
.decorated(false)
.build();
window.add_css_class("owlry-window");
// Main container
let main_box = GtkBox::builder()
.orientation(Orientation::Vertical)
.spacing(8)
.margin_top(16)
.margin_bottom(16)
.margin_start(16)
.margin_end(16)
.build();
main_box.add_css_class("owlry-main");
// Header with mode indicator and filter tabs
let header_box = GtkBox::builder()
.orientation(Orientation::Horizontal)
.spacing(12)
.margin_bottom(8)
.build();
header_box.add_css_class("owlry-header");
// Mode indicator label
let mode_label = Label::builder()
.label(filter.borrow().mode_display_name())
.halign(gtk4::Align::Start)
.hexpand(false)
.build();
mode_label.add_css_class("owlry-mode-indicator");
// Filter tabs container
let filter_tabs = GtkBox::builder()
.orientation(Orientation::Horizontal)
.spacing(4)
.halign(gtk4::Align::End)
.hexpand(true)
.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);
let filter_buttons = Rc::new(RefCell::new(filter_buttons));
header_box.append(&mode_label);
header_box.append(&filter_tabs);
// Search entry with dynamic placeholder
let placeholder = Self::build_placeholder(&filter.borrow());
let search_entry = Entry::builder()
.placeholder_text(&placeholder)
.hexpand(true)
.build();
search_entry.add_css_class("owlry-search");
// Results list in scrolled window
let results_list = ListBox::builder()
.selection_mode(SelectionMode::Single)
.vexpand(true)
.build();
results_list.add_css_class("owlry-results");
let scrolled = ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::Never)
.vscrollbar_policy(gtk4::PolicyType::Automatic)
.vexpand(true)
.child(&results_list)
.build();
// Hints bar at bottom
let hints_box = GtkBox::builder()
.orientation(Orientation::Horizontal)
.margin_top(8)
.build();
hints_box.add_css_class("owlry-hints");
let hints_label = Label::builder()
.label("Tab: cycle mode ↑↓: navigate Enter: launch Esc: close :app :cmd :uuctl")
.halign(gtk4::Align::Center)
.hexpand(true)
.build();
hints_label.add_css_class("owlry-hints-label");
hints_box.append(&hints_label);
// Assemble layout
main_box.append(&header_box);
main_box.append(&search_entry);
main_box.append(&scrolled.clone());
main_box.append(&hints_box);
window.set_child(Some(&main_box));
drop(cfg);
let main_window = Self {
window,
search_entry,
results_list,
scrolled,
config,
providers,
current_results: Rc::new(RefCell::new(Vec::new())),
filter,
mode_label,
hints_label,
filter_buttons,
submenu_state: Rc::new(RefCell::new(SubmenuState::default())),
};
main_window.setup_signals();
main_window.update_results("");
// Ensure search entry has focus when window is shown
main_window.search_entry.grab_focus();
main_window
}
fn create_filter_buttons(
container: &GtkBox,
filter: &Rc<RefCell<ProviderFilter>>,
) -> 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 {
let button = ToggleButton::builder()
.label(label)
.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::Command => "owlry-filter-cmd",
ProviderType::Uuctl => "owlry-filter-uuctl",
ProviderType::Dmenu => "owlry-filter-dmenu",
};
button.add_css_class(css_class);
container.append(&button);
buttons.insert(provider_type, button);
}
buttons
}
fn build_placeholder(filter: &ProviderFilter) -> String {
let active: Vec<&str> = filter
.enabled_providers()
.iter()
.map(|p| match p {
ProviderType::Application => "applications",
ProviderType::Command => "commands",
ProviderType::Uuctl => "uuctl units",
ProviderType::Dmenu => "options",
})
.collect();
format!("Search {}...", active.join(", "))
}
/// Scroll the given row into view within the scrolled window
fn scroll_to_row(scrolled: &ScrolledWindow, results_list: &ListBox, row: &ListBoxRow) {
let vadj = scrolled.vadjustment();
let row_index = row.index();
if row_index < 0 {
return;
}
let visible_height = vadj.page_size();
let current_scroll = vadj.value();
let list_height = results_list.height() as f64;
let row_count = {
let mut count = 0;
let mut child = results_list.first_child();
while child.is_some() {
count += 1;
child = child.and_then(|c| c.next_sibling());
}
count.max(1) as f64
};
let row_height = list_height / row_count;
let row_top = row_index as f64 * row_height;
let row_bottom = row_top + row_height;
if row_top < current_scroll {
vadj.set_value(row_top);
} else if row_bottom > current_scroll + visible_height {
vadj.set_value(row_bottom - visible_height);
}
}
/// Enter submenu mode for a service
fn enter_submenu(
submenu_state: &Rc<RefCell<SubmenuState>>,
results_list: &ListBox,
current_results: &Rc<RefCell<Vec<LaunchItem>>>,
mode_label: &Label,
hints_label: &Label,
search_entry: &Entry,
unit_name: &str,
display_name: &str,
is_active: bool,
) {
let actions = UuctlProvider::actions_for_service(unit_name, display_name, is_active);
// Save current state
{
let mut state = submenu_state.borrow_mut();
state.active = true;
state.service_name = unit_name.to_string();
state.display_name = display_name.to_string();
state.items = actions.clone();
state.saved_search = search_entry.text().to_string();
}
// Update UI
mode_label.set_label(&format!("{}", display_name));
hints_label.set_label("Enter: execute Esc/Backspace: back ↑↓: navigate");
search_entry.set_text("");
search_entry.set_placeholder_text(Some(&format!("Filter {} actions...", display_name)));
// Display actions
while let Some(child) = results_list.first_child() {
results_list.remove(&child);
}
for item in &actions {
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.borrow_mut() = actions;
}
/// Exit submenu mode
fn exit_submenu(
submenu_state: &Rc<RefCell<SubmenuState>>,
mode_label: &Label,
hints_label: &Label,
search_entry: &Entry,
filter: &Rc<RefCell<ProviderFilter>>,
) {
let saved_search = {
let mut state = submenu_state.borrow_mut();
state.active = false;
state.items.clear();
state.saved_search.clone()
};
// Restore UI
mode_label.set_label(filter.borrow().mode_display_name());
hints_label.set_label("Tab: cycle mode ↑↓: navigate Enter: launch Esc: close :app :cmd :uuctl");
search_entry.set_placeholder_text(Some(&Self::build_placeholder(&filter.borrow())));
search_entry.set_text(&saved_search);
// Trigger refresh by emitting changed signal
search_entry.emit_by_name::<()>("changed", &[]);
}
fn setup_signals(&self) {
// Search input handling with prefix detection
let providers = self.providers.clone();
let results_list = self.results_list.clone();
let config = self.config.clone();
let current_results = self.current_results.clone();
let filter = self.filter.clone();
let mode_label = self.mode_label.clone();
let search_entry_for_change = self.search_entry.clone();
let submenu_state = self.submenu_state.clone();
self.search_entry.connect_changed(move |entry| {
let raw_query = entry.text();
// If in submenu, filter the submenu items
if submenu_state.borrow().active {
let state = submenu_state.borrow();
let query = raw_query.to_lowercase();
let filtered: Vec<LaunchItem> = state
.items
.iter()
.filter(|item| {
query.is_empty()
|| item.name.to_lowercase().contains(&query)
|| item
.description
.as_ref()
.map(|d| d.to_lowercase().contains(&query))
.unwrap_or(false)
})
.cloned()
.collect();
// Clear and repopulate
while let Some(child) = results_list.first_child() {
results_list.remove(&child);
}
for item in &filtered {
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.borrow_mut() = filtered;
return;
}
// Normal mode: parse prefix and search
let parsed = ProviderFilter::parse_query(&raw_query);
{
let mut f = filter.borrow_mut();
f.set_prefix(parsed.prefix);
}
mode_label.set_label(filter.borrow().mode_display_name());
if parsed.prefix.is_some() {
let prefix_name = match parsed.prefix.unwrap() {
ProviderType::Application => "applications",
ProviderType::Command => "commands",
ProviderType::Uuctl => "uuctl units",
ProviderType::Dmenu => "options",
};
search_entry_for_change
.set_placeholder_text(Some(&format!("Search {}...", prefix_name)));
}
let max_results = config.borrow().general.max_results;
let results: Vec<LaunchItem> = providers
.borrow()
.search_filtered(&parsed.query, max_results, &filter.borrow())
.into_iter()
.map(|(item, _)| item)
.collect();
while let Some(child) = results_list.first_child() {
results_list.remove(&child);
}
for item in &results {
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.borrow_mut() = results;
});
// Entry activate signal (Enter key in search entry)
let results_list_for_activate = self.results_list.clone();
let current_results_for_activate = self.current_results.clone();
let config_for_activate = self.config.clone();
let window_for_activate = self.window.clone();
let submenu_state_for_activate = self.submenu_state.clone();
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();
self.search_entry.connect_activate(move |_| {
let selected = results_list_for_activate
.selected_row()
.or_else(|| results_list_for_activate.row_at_index(0));
if let Some(row) = selected {
let index = row.index() as usize;
let results = current_results_for_activate.borrow();
if let Some(item) = results.get(index) {
// Check if this is a submenu item
if let Some((unit_name, display_name, is_active)) =
UuctlProvider::parse_submenu_data(item)
{
drop(results); // Release borrow before calling enter_submenu
Self::enter_submenu(
&submenu_state_for_activate,
&results_list_for_activate,
&current_results_for_activate,
&mode_label_for_activate,
&hints_label_for_activate,
&search_entry_for_activate,
&unit_name,
&display_name,
is_active,
);
} else {
// Execute the command
Self::launch_item(item, &config_for_activate.borrow());
window_for_activate.close();
}
}
}
});
// Filter button signals
for (provider_type, button) in self.filter_buttons.borrow().iter() {
let filter = self.filter.clone();
let search_entry = self.search_entry.clone();
let mode_label = self.mode_label.clone();
let ptype = *provider_type;
button.connect_toggled(move |btn| {
{
let mut f = filter.borrow_mut();
if btn.is_active() {
f.enable(ptype);
} else {
f.disable(ptype);
}
}
mode_label.set_label(filter.borrow().mode_display_name());
search_entry.set_placeholder_text(Some(&Self::build_placeholder(&filter.borrow())));
search_entry.emit_by_name::<()>("changed", &[]);
});
}
// Keyboard navigation
let key_controller = EventControllerKey::new();
let window = self.window.clone();
let results_list = self.results_list.clone();
let scrolled = self.scrolled.clone();
let search_entry = self.search_entry.clone();
let current_results = self.current_results.clone();
let config = self.config.clone();
let filter = self.filter.clone();
let filter_buttons = self.filter_buttons.clone();
let mode_label = self.mode_label.clone();
let hints_label = self.hints_label.clone();
let submenu_state = self.submenu_state.clone();
key_controller.connect_key_pressed(move |_, key, _, modifiers| {
let ctrl = modifiers.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
let shift = modifiers.contains(gtk4::gdk::ModifierType::SHIFT_MASK);
match key {
Key::Escape => {
// If in submenu, exit submenu first
if submenu_state.borrow().active {
Self::exit_submenu(
&submenu_state,
&mode_label,
&hints_label,
&search_entry,
&filter,
);
gtk4::glib::Propagation::Stop
} else {
window.close();
gtk4::glib::Propagation::Stop
}
}
Key::BackSpace if search_entry.text().is_empty() => {
// If in submenu with empty search, exit submenu
if submenu_state.borrow().active {
Self::exit_submenu(
&submenu_state,
&mode_label,
&hints_label,
&search_entry,
&filter,
);
gtk4::glib::Propagation::Stop
} else {
window.close();
gtk4::glib::Propagation::Stop
}
}
Key::Down => {
let selected = results_list
.selected_row()
.or_else(|| results_list.row_at_index(0));
if let Some(current) = selected {
let next_index = current.index() + 1;
if let Some(next_row) = results_list.row_at_index(next_index) {
results_list.select_row(Some(&next_row));
Self::scroll_to_row(&scrolled, &results_list, &next_row);
}
}
gtk4::glib::Propagation::Stop
}
Key::Up => {
if let Some(selected) = results_list.selected_row() {
let prev_index = selected.index() - 1;
if prev_index >= 0 {
if let Some(prev_row) = results_list.row_at_index(prev_index) {
results_list.select_row(Some(&prev_row));
Self::scroll_to_row(&scrolled, &results_list, &prev_row);
}
}
}
gtk4::glib::Propagation::Stop
}
// Tab cycles through filter modes (only when not in submenu)
Key::Tab if !ctrl => {
if !submenu_state.borrow().active {
Self::cycle_filter_mode(
&filter,
&filter_buttons,
&search_entry,
&mode_label,
!shift,
);
}
gtk4::glib::Propagation::Stop
}
Key::ISO_Left_Tab => {
if !submenu_state.borrow().active {
Self::cycle_filter_mode(
&filter,
&filter_buttons,
&search_entry,
&mode_label,
false,
);
}
gtk4::glib::Propagation::Stop
}
// Ctrl+1/2/3 toggle specific providers (only when not in submenu)
Key::_1 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,
);
}
gtk4::glib::Propagation::Stop
}
_ => gtk4::glib::Propagation::Proceed,
}
});
self.window.add_controller(key_controller);
// Double-click to launch
let current_results = self.current_results.clone();
let config = self.config.clone();
let window = self.window.clone();
let submenu_state = self.submenu_state.clone();
let results_list_for_click = self.results_list.clone();
let mode_label = self.mode_label.clone();
let hints_label = self.hints_label.clone();
let search_entry = self.search_entry.clone();
self.results_list.connect_row_activated(move |_list, row| {
let index = row.index() as usize;
let results = current_results.borrow();
if let Some(item) = results.get(index) {
// Check if this is a submenu item
if let Some((unit_name, display_name, is_active)) =
UuctlProvider::parse_submenu_data(item)
{
drop(results);
Self::enter_submenu(
&submenu_state,
&results_list_for_click,
&current_results,
&mode_label,
&hints_label,
&search_entry,
&unit_name,
&display_name,
is_active,
);
} else {
Self::launch_item(item, &config.borrow());
window.close();
}
}
});
}
fn cycle_filter_mode(
filter: &Rc<RefCell<ProviderFilter>>,
buttons: &Rc<RefCell<HashMap<ProviderType, ToggleButton>>>,
entry: &Entry,
mode_label: &Label,
forward: bool,
) {
let order = [
ProviderType::Application,
ProviderType::Command,
ProviderType::Uuctl,
];
let current = filter.borrow().enabled_providers();
let next = if current.len() == 1 {
let idx = order.iter().position(|p| p == &current[0]).unwrap_or(0);
if forward {
order[(idx + 1) % order.len()]
} else {
order[(idx + order.len() - 1) % order.len()]
}
} else {
ProviderType::Application
};
{
let mut f = filter.borrow_mut();
f.set_single_mode(next);
}
for (ptype, button) in buttons.borrow().iter() {
button.set_active(*ptype == next);
}
mode_label.set_label(filter.borrow().mode_display_name());
entry.set_placeholder_text(Some(&Self::build_placeholder(&filter.borrow())));
entry.emit_by_name::<()>("changed", &[]);
}
fn toggle_provider_button(
provider: ProviderType,
filter: &Rc<RefCell<ProviderFilter>>,
buttons: &Rc<RefCell<HashMap<ProviderType, ToggleButton>>>,
entry: &Entry,
mode_label: &Label,
) {
{
let mut f = filter.borrow_mut();
f.toggle(provider);
}
if let Some(button) = buttons.borrow().get(&provider) {
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())));
entry.emit_by_name::<()>("changed", &[]);
}
fn update_results(&self, query: &str) {
let max_results = self.config.borrow().general.max_results;
let results: Vec<LaunchItem> = self
.providers
.borrow()
.search_filtered(query, max_results, &self.filter.borrow())
.into_iter()
.map(|(item, _)| item)
.collect();
while let Some(child) = self.results_list.first_child() {
self.results_list.remove(&child);
}
for item in &results {
let row = ResultRow::new(item);
self.results_list.append(&row);
}
if let Some(first_row) = self.results_list.row_at_index(0) {
self.results_list.select_row(Some(&first_row));
}
*self.current_results.borrow_mut() = results;
}
fn launch_item(item: &LaunchItem, config: &Config) {
info!("Launching: {} ({})", item.name, item.command);
let cmd = if item.terminal {
format!("{} -e {}", config.general.terminal_command, item.command)
} else {
item.command.clone()
};
if let Err(e) = Command::new("sh").arg("-c").arg(&cmd).spawn() {
log::error!("Failed to launch '{}': {}", item.name, e);
}
}
}
impl std::ops::Deref for MainWindow {
type Target = ApplicationWindow;
fn deref(&self) -> &Self::Target {
&self.window
}
}

5
src/ui/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
mod main_window;
mod result_row;
pub use main_window::MainWindow;
pub use result_row::ResultRow;

93
src/ui/result_row.rs Normal file
View File

@@ -0,0 +1,93 @@
use crate::providers::LaunchItem;
use gtk4::prelude::*;
use gtk4::{Box as GtkBox, Image, Label, ListBoxRow, Orientation};
pub struct ResultRow {
row: ListBoxRow,
}
impl ResultRow {
pub fn new(item: &LaunchItem) -> ListBoxRow {
let row = ListBoxRow::builder()
.selectable(true)
.activatable(true)
.build();
row.add_css_class("owlry-result-row");
let hbox = GtkBox::builder()
.orientation(Orientation::Horizontal)
.spacing(12)
.margin_top(8)
.margin_bottom(8)
.margin_start(12)
.margin_end(12)
.build();
// Icon
let icon = if let Some(icon_name) = &item.icon {
Image::from_icon_name(icon_name)
} else {
// Default icon based on provider type
let default_icon = match item.provider {
crate::providers::ProviderType::Application => "application-x-executable",
crate::providers::ProviderType::Command => "utilities-terminal",
crate::providers::ProviderType::Dmenu => "view-list-symbolic",
crate::providers::ProviderType::Uuctl => "system-run",
};
Image::from_icon_name(default_icon)
};
icon.set_pixel_size(32);
icon.add_css_class("owlry-result-icon");
// Text container
let text_box = GtkBox::builder()
.orientation(Orientation::Vertical)
.hexpand(true)
.valign(gtk4::Align::Center)
.build();
// Name label
let name_label = Label::builder()
.label(&item.name)
.halign(gtk4::Align::Start)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
name_label.add_css_class("owlry-result-name");
// Description label
if let Some(desc) = &item.description {
let desc_label = Label::builder()
.label(desc)
.halign(gtk4::Align::Start)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
desc_label.add_css_class("owlry-result-description");
text_box.append(&name_label);
text_box.append(&desc_label);
} else {
text_box.append(&name_label);
}
// Provider badge
let badge = Label::builder()
.label(&item.provider.to_string())
.halign(gtk4::Align::End)
.valign(gtk4::Align::Center)
.build();
badge.add_css_class("owlry-result-badge");
badge.add_css_class(&format!("owlry-badge-{}", item.provider));
hbox.append(&icon);
hbox.append(&text_box);
hbox.append(&badge);
row.set_child(Some(&hbox));
row
}
}