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:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
1516
Cargo.lock
generated
Normal file
1516
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
58
Cargo.toml
Normal file
58
Cargo.toml
Normal 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
246
resources/style.css
Normal 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
96
src/app.rs
Normal 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(
|
||||
>k4::gdk::Display::default().expect("Could not get default display"),
|
||||
&provider,
|
||||
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
);
|
||||
}
|
||||
}
|
||||
29
src/cli.rs
Normal file
29
src/cli.rs
Normal 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
171
src/config/mod.rs
Normal 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
237
src/filter.rs
Normal 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
21
src/main.rs
Normal 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());
|
||||
}
|
||||
120
src/providers/application.rs
Normal file
120
src/providers/application.rs
Normal 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
105
src/providers/command.rs
Normal 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
94
src/providers/dmenu.rs
Normal 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
203
src/providers/mod.rs
Normal 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
265
src/providers/uuctl.rs
Normal 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
792
src/ui/main_window.rs
Normal 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,
|
||||
¤t_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,
|
||||
¤t_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 == ¤t[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
5
src/ui/mod.rs
Normal 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
93
src/ui/result_row.rs
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user