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

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
}
}