10 Commits

Author SHA1 Message Date
617dbbce3e chore: bump version to 0.3.4 2025-12-28 19:28:17 +01:00
4ff054afe0 chore: bump version to 0.3.3 2025-12-28 19:21:51 +01:00
8547dfa951 chore: bump version to 0.3.2 2025-12-28 19:15:53 +01:00
e1a6650b7d fix: remove duplicate emoji from emoji picker display
Moved emoji character to description instead of name to avoid
showing it twice (once as icon, once in text).

Now displays:
- Name: "grinning face"
- Description: "😀 smile happy"

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 19:15:45 +01:00
6ae8c734d2 chore: bump version to 0.3.1 2025-12-28 19:05:43 +01:00
cf48d53c57 fix: enable new providers in filter by default
Added config options for all new providers (system, ssh, clipboard,
bookmarks, emoji, scripts, files) with default=true via serde.

Updated filter to add these providers to enabled set based on config.

Also updated README with comprehensive documentation for all providers.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 19:05:35 +01:00
41cd212264 chore: bump version to 0.3.0 2025-12-28 18:55:32 +01:00
7cdb97d743 feat: add 7 new providers (system, ssh, clipboard, files, bookmarks, emoji, scripts)
New providers:
- System: shutdown, reboot, suspend, hibernate, lock, logout, reboot into BIOS
- SSH: parse ~/.ssh/config for quick host connections
- Clipboard: integrate with cliphist for clipboard history
- Files: search files using fd or locate (/ or find prefix)
- Bookmarks: read Chrome/Chromium/Brave/Edge browser bookmarks
- Emoji: searchable emoji picker with wl-copy integration
- Scripts: run user scripts from ~/.config/owlry/scripts/

Filter prefixes: :sys, :ssh, :clip, :file, :bm, :emoji, :script

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 18:55:27 +01:00
98ac769b29 chore: bump version to 0.2.1 2025-12-28 18:33:42 +01:00
e73793dd6e fix: web search not working in :web filter mode
Added evaluate_raw() method to WebSearchProvider and handler in
search_with_frecency() to support raw queries when using :web prefix.

Same pattern as calculator fix - trigger prefixes (?, web) call
evaluate() while filter mode (:web) calls evaluate_raw().

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 18:33:34 +01:00
20 changed files with 2032 additions and 78 deletions

2
Cargo.lock generated
View File

@@ -971,7 +971,7 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "owlry"
version = "0.2.0"
version = "0.3.4"
dependencies = [
"chrono",
"clap",

View File

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

185
README.md
View File

@@ -10,12 +10,14 @@ A lightweight, owl-themed application launcher for Wayland, built with GTK4 and
## Features
- **Provider-based architecture** - Search applications, PATH commands, and systemd user services
- **Provider-based architecture** - Search applications, commands, system actions, SSH hosts, clipboard history, bookmarks, emoji, and more
- **Fuzzy search** - Fast, typo-tolerant matching across all providers
- **Filter tabs & prefixes** - Scope searches with UI tabs or `:app`, `:cmd`, `:uuctl` prefixes
- **Systemd integration** - Manage user services with submenu actions (start/stop/restart/status/journal)
- **Filter tabs & prefixes** - Scope searches with UI tabs or `:app`, `:cmd`, `:sys` prefixes
- **Calculator** - Quick math with `= 5+3` or `calc sin(pi/2)`
- **Web search** - Search the web with `? query` or `web query`
- **File search** - Find files with `/ filename` or `find config` (requires `fd` or `locate`)
- **Frecency ranking** - Frequently/recently used items rank higher
- **GTK4 theming** - Respects system theme by default, with optional custom themes
- **CSS variables** - Full customization via config or custom stylesheets
- **Wayland native** - Uses Layer Shell for proper overlay behavior
## Installation
@@ -45,6 +47,16 @@ sudo apt install libgtk-4-dev libgtk4-layer-shell-dev
sudo dnf install gtk4-devel gtk4-layer-shell-devel
```
#### Optional dependencies
```bash
# For clipboard history
sudo pacman -S cliphist wl-clipboard
# For file search
sudo pacman -S fd # or: mlocate
```
#### Build
Requires Rust 1.90 or later.
@@ -79,7 +91,7 @@ owlry --help
|-----|--------|
| `Enter` | Launch selected item |
| `Escape` | Close launcher / exit submenu |
| `` / `` | Navigate results |
| `Up` / `Down` | Navigate results |
| `Tab` | Cycle filter modes |
| `Shift+Tab` | Cycle filter modes (reverse) |
| `Ctrl+1` | Toggle Applications filter |
@@ -88,20 +100,108 @@ owlry --help
### Search Prefixes
| Prefix | Scope |
|--------|-------|
| `:app ` | Applications only |
| `:cmd ` | PATH commands only |
| `:uuctl ` | systemd user services only |
Filter results by provider using prefixes:
Example: `:cmd git` searches only PATH commands for "git"
| Prefix | Provider | Example |
|--------|----------|---------|
| `:app` | Applications | `:app firefox` |
| `:cmd` | PATH commands | `:cmd git` |
| `:sys` | System actions | `:sys shutdown` |
| `:ssh` | SSH hosts | `:ssh server` |
| `:clip` | Clipboard history | `:clip password` |
| `:bm` | Browser bookmarks | `:bm github` |
| `:emoji` | Emoji picker | `:emoji heart` |
| `:script` | Custom scripts | `:script backup` |
| `:file` | File search | `:file config.toml` |
| `:calc` | Calculator | `:calc 5+3` |
| `:web` | Web search | `:web rust docs` |
| `:uuctl` | systemd services | `:uuctl docker` |
### Trigger Prefixes
Some providers can be triggered directly without filter mode:
| Trigger | Provider | Example |
|---------|----------|---------|
| `=` | Calculator | `= 5+3` or `=5*2` |
| `calc ` | Calculator | `calc sqrt(16)` |
| `?` | Web search | `? rust programming` |
| `web ` | Web search | `web linux tips` |
| `search ` | Web search | `search owlry` |
| `/` | File search | `/ .bashrc` |
| `find ` | File search | `find config` |
## Providers
### Applications
Searches `.desktop` files from standard XDG directories.
### Commands
Searches executable files in `$PATH`.
### System
Quick access to system actions:
- Shutdown, Reboot, Suspend, Hibernate
- Lock Screen, Log Out
- **Reboot into BIOS** - Restart directly into UEFI/BIOS setup
### SSH
Parses `~/.ssh/config` and offers quick connections to configured hosts. Opens in your configured terminal.
### Clipboard (requires cliphist)
Search and paste from clipboard history. Requires `cliphist` and `wl-clipboard`:
```bash
sudo pacman -S cliphist wl-clipboard
```
### Bookmarks
Reads bookmarks from Chromium-based browsers:
- Chrome, Chromium, Brave, Edge, Vivaldi
### Emoji
Search 300+ emojis by name or keywords. Selected emoji is copied to clipboard via `wl-copy`.
### Scripts
Runs executable scripts from `~/.config/owlry/scripts/`. Create the directory and add your scripts:
```bash
mkdir -p ~/.config/owlry/scripts
echo '#!/bin/bash
# My backup script
rsync -av ~/Documents /backup/' > ~/.config/owlry/scripts/backup
chmod +x ~/.config/owlry/scripts/backup
```
### Calculator
Evaluate math expressions with `= expr` or `calc expr`:
- Basic: `= 5+3`, `= 10/3`
- Functions: `= sqrt(16)`, `= sin(pi/2)`
- Constants: `= pi`, `= e`
### Web Search
Search the web with `? query` or `web query`. Configurable search engine:
- Google, DuckDuckGo, Bing, Brave, Ecosia, Startpage, SearXNG
- Or custom URL with `{query}` placeholder
### File Search (requires fd or locate)
Search files with `/ pattern` or `find pattern`:
```bash
sudo pacman -S fd # recommended, faster
# or
sudo pacman -S mlocate && sudo updatedb
```
### systemd User Services
Lists and controls user-level systemd services. Select a service to access actions:
- Start / Stop / Restart / Reload
- Kill (force stop)
- Status (opens in terminal)
- Journal (live logs in terminal)
- Enable / Disable (autostart)
## Configuration
Configuration file: `~/.config/owlry/config.toml`
An example config is installed at `/usr/share/doc/owlry/config.example.toml`:
```bash
mkdir -p ~/.config/owlry
cp /usr/share/doc/owlry/config.example.toml ~/.config/owlry/config.toml
@@ -112,7 +212,7 @@ cp /usr/share/doc/owlry/config.example.toml ~/.config/owlry/config.toml
show_icons = true
max_results = 10
# terminal_command = "kitty" # Auto-detected if not set
# launch_wrapper = "uwsm app --" # Auto-detected for uwsm/hyprland sessions
# launch_wrapper = "uwsm app --" # Auto-detected for uwsm/hyprland
[appearance]
width = 600
@@ -121,15 +221,22 @@ font_size = 14
border_radius = 12
# theme = "owl" # Optional: "owl" or custom theme name
# [appearance.colors]
# Override individual colors (optional)
# accent = "#e0af68"
# background = "#1a1b26"
[providers]
applications = true
commands = true
uuctl = true
calculator = true
websearch = true
search_engine = "duckduckgo" # google, bing, brave, ecosia, startpage, searxng
system = true
ssh = true
clipboard = true
bookmarks = true
emoji = true
scripts = true
files = true
frecency = true
frecency_weight = 0.3 # 0.0 = disabled, 1.0 = strong boost
```
### Default Values
@@ -138,8 +245,8 @@ uuctl = true
|---------|---------|
| `show_icons` | `true` |
| `max_results` | `10` |
| `terminal_command` | Auto-detected ($TERMINAL xdg-terminal-exec kitty/alacritty/etc) |
| `launch_wrapper` | Auto-detected (uwsm hyprctl none) |
| `terminal_command` | Auto-detected ($TERMINAL -> xdg-terminal-exec -> kitty/alacritty/etc) |
| `launch_wrapper` | Auto-detected (uwsm -> hyprctl -> none) |
| `width` | `600` |
| `height` | `400` |
| `font_size` | `14` |
@@ -148,7 +255,7 @@ uuctl = true
### Launch Wrapper
When running in uwsm-managed or Hyprland sessions, owlry auto-detects and uses the appropriate launch wrapper for proper session integration:
When running in uwsm-managed or Hyprland sessions, owlry auto-detects and uses the appropriate launch wrapper:
| Session | Wrapper | Purpose |
|---------|---------|---------|
@@ -156,8 +263,6 @@ When running in uwsm-managed or Hyprland sessions, owlry auto-detects and uses t
| Hyprland | `hyprctl dispatch exec --` | Native Hyprland window management |
| Other | None (direct `sh -c`) | Standard shell execution |
You can override this with `launch_wrapper` in config, or set to empty string `""` to disable.
## Theming
### GTK Theme (Default)
@@ -219,13 +324,6 @@ Create a custom theme file at `~/.config/owlry/themes/mytheme.css`:
}
```
Then reference it in config:
```toml
[appearance]
theme = "mytheme"
```
### CSS Variables Reference
| Variable | Description |
@@ -239,35 +337,12 @@ theme = "mytheme"
| `--owlry-accent-bright` | Bright accent |
| `--owlry-font-size` | Base font size |
| `--owlry-border-radius` | Border radius |
| `--owlry-badge-app` | Application badge color |
| `--owlry-badge-cmd` | Command badge color |
| `--owlry-badge-dmenu` | Dmenu badge color |
| `--owlry-badge-uuctl` | systemd badge color |
| `--owlry-badge-*` | Provider badge colors (app, cmd, sys, ssh, clip, emoji, etc.) |
### Custom Stylesheet
For full control, create `~/.config/owlry/style.css` with any GTK4 CSS.
## Providers
### Applications
Searches `.desktop` files from standard XDG directories.
### Commands
Searches executable files in `$PATH`.
### systemd User Services
Lists and controls user-level systemd services. Select a service to access actions:
- Start / Stop / Restart / Reload
- Kill (force stop)
- Status (opens in terminal)
- Journal (live logs in terminal)
- Enable / Disable (autostart)
## License
This project is licensed under the GNU General Public License v3.0 or later - see [LICENSE](LICENSE) for details.

View File

@@ -54,3 +54,24 @@ websearch = true
# Options: google, duckduckgo, bing, startpage, searxng, brave, ecosia
# Or custom URL with {query} placeholder, e.g. "https://search.example.com/?q={query}"
search_engine = "duckduckgo"
# System commands (shutdown, reboot, lock, suspend, hibernate, logout, BIOS)
system = true
# SSH connections from ~/.ssh/config
ssh = true
# Clipboard history (requires cliphist)
clipboard = true
# Browser bookmarks (Chrome, Chromium, Brave, Edge, Vivaldi)
bookmarks = true
# Emoji picker (copies to clipboard)
emoji = true
# Custom scripts from ~/.config/owlry/scripts/
scripts = true
# File search (requires fd or locate, trigger with "/ pattern" or "find pattern")
files = true

View File

@@ -106,11 +106,21 @@
color: var(--owlry-badge-app, @blue_3);
}
.owlry-badge-bookmark {
background-color: alpha(var(--owlry-badge-bookmark, #f5a623), 0.2);
color: var(--owlry-badge-bookmark, #f5a623);
}
.owlry-badge-calc {
background-color: alpha(var(--owlry-badge-calc, @yellow_3), 0.2);
color: var(--owlry-badge-calc, @yellow_3);
}
.owlry-badge-clip {
background-color: alpha(var(--owlry-badge-clip, #8b5cf6), 0.2);
color: var(--owlry-badge-clip, #8b5cf6);
}
.owlry-badge-cmd {
background-color: alpha(var(--owlry-badge-cmd, @purple_3), 0.2);
color: var(--owlry-badge-cmd, @purple_3);
@@ -121,6 +131,31 @@
color: var(--owlry-badge-dmenu, @green_3);
}
.owlry-badge-emoji {
background-color: alpha(var(--owlry-badge-emoji, #f472b6), 0.2);
color: var(--owlry-badge-emoji, #f472b6);
}
.owlry-badge-file {
background-color: alpha(var(--owlry-badge-file, #22d3ee), 0.2);
color: var(--owlry-badge-file, #22d3ee);
}
.owlry-badge-script {
background-color: alpha(var(--owlry-badge-script, #a3e635), 0.2);
color: var(--owlry-badge-script, #a3e635);
}
.owlry-badge-ssh {
background-color: alpha(var(--owlry-badge-ssh, #2dd4bf), 0.2);
color: var(--owlry-badge-ssh, #2dd4bf);
}
.owlry-badge-sys {
background-color: alpha(var(--owlry-badge-sys, #ef4444), 0.2);
color: var(--owlry-badge-sys, #ef4444);
}
.owlry-badge-uuctl {
background-color: alpha(var(--owlry-badge-uuctl, @orange_3), 0.2);
color: var(--owlry-badge-uuctl, @orange_3);
@@ -176,30 +211,72 @@
border-color: alpha(var(--owlry-badge-app, @blue_3), 0.4);
}
.owlry-filter-bookmark:checked {
background-color: alpha(var(--owlry-badge-bookmark, #f5a623), 0.2);
color: var(--owlry-badge-bookmark, #f5a623);
border-color: alpha(var(--owlry-badge-bookmark, #f5a623), 0.4);
}
.owlry-filter-calc:checked {
background-color: alpha(var(--owlry-badge-calc, @yellow_3), 0.2);
color: var(--owlry-badge-calc, @yellow_3);
border-color: alpha(var(--owlry-badge-calc, @yellow_3), 0.4);
}
.owlry-filter-clip:checked {
background-color: alpha(var(--owlry-badge-clip, #8b5cf6), 0.2);
color: var(--owlry-badge-clip, #8b5cf6);
border-color: alpha(var(--owlry-badge-clip, #8b5cf6), 0.4);
}
.owlry-filter-cmd:checked {
background-color: alpha(var(--owlry-badge-cmd, @purple_3), 0.2);
color: var(--owlry-badge-cmd, @purple_3);
border-color: alpha(var(--owlry-badge-cmd, @purple_3), 0.4);
}
.owlry-filter-uuctl:checked {
background-color: alpha(var(--owlry-badge-uuctl, @orange_3), 0.2);
color: var(--owlry-badge-uuctl, @orange_3);
border-color: alpha(var(--owlry-badge-uuctl, @orange_3), 0.4);
}
.owlry-filter-dmenu:checked {
background-color: alpha(var(--owlry-badge-dmenu, @green_3), 0.2);
color: var(--owlry-badge-dmenu, @green_3);
border-color: alpha(var(--owlry-badge-dmenu, @green_3), 0.4);
}
.owlry-filter-emoji:checked {
background-color: alpha(var(--owlry-badge-emoji, #f472b6), 0.2);
color: var(--owlry-badge-emoji, #f472b6);
border-color: alpha(var(--owlry-badge-emoji, #f472b6), 0.4);
}
.owlry-filter-file:checked {
background-color: alpha(var(--owlry-badge-file, #22d3ee), 0.2);
color: var(--owlry-badge-file, #22d3ee);
border-color: alpha(var(--owlry-badge-file, #22d3ee), 0.4);
}
.owlry-filter-script:checked {
background-color: alpha(var(--owlry-badge-script, #a3e635), 0.2);
color: var(--owlry-badge-script, #a3e635);
border-color: alpha(var(--owlry-badge-script, #a3e635), 0.4);
}
.owlry-filter-ssh:checked {
background-color: alpha(var(--owlry-badge-ssh, #2dd4bf), 0.2);
color: var(--owlry-badge-ssh, #2dd4bf);
border-color: alpha(var(--owlry-badge-ssh, #2dd4bf), 0.4);
}
.owlry-filter-sys:checked {
background-color: alpha(var(--owlry-badge-sys, #ef4444), 0.2);
color: var(--owlry-badge-sys, #ef4444);
border-color: alpha(var(--owlry-badge-sys, #ef4444), 0.4);
}
.owlry-filter-uuctl:checked {
background-color: alpha(var(--owlry-badge-uuctl, @orange_3), 0.2);
color: var(--owlry-badge-uuctl, @orange_3);
border-color: alpha(var(--owlry-badge-uuctl, @orange_3), 0.4);
}
.owlry-filter-web:checked {
background-color: alpha(var(--owlry-badge-web, @teal_3), 0.2);
color: var(--owlry-badge-web, @teal_3);

View File

@@ -41,7 +41,8 @@ impl OwlryApp {
let config = Rc::new(RefCell::new(Config::load_or_default()));
let search_engine = config.borrow().providers.search_engine.clone();
let providers = Rc::new(RefCell::new(ProviderManager::with_search_engine(&search_engine)));
let terminal = config.borrow().general.terminal_command.clone();
let providers = Rc::new(RefCell::new(ProviderManager::with_config(&search_engine, &terminal)));
let frecency = Rc::new(RefCell::new(FrecencyStore::load_or_default()));
// Create filter from CLI args and config

View File

@@ -35,9 +35,16 @@ pub struct ThemeColors {
pub accent_bright: Option<String>,
// Provider badge colors
pub badge_app: Option<String>,
pub badge_bookmark: Option<String>,
pub badge_calc: Option<String>,
pub badge_clip: Option<String>,
pub badge_cmd: Option<String>,
pub badge_dmenu: Option<String>,
pub badge_emoji: Option<String>,
pub badge_file: Option<String>,
pub badge_script: Option<String>,
pub badge_ssh: Option<String>,
pub badge_sys: Option<String>,
pub badge_uuctl: Option<String>,
pub badge_web: Option<String>,
}
@@ -78,6 +85,27 @@ pub struct ProvidersConfig {
/// Or custom URL with {query} placeholder
#[serde(default = "default_search_engine")]
pub search_engine: String,
/// Enable system commands (shutdown, reboot, etc.)
#[serde(default = "default_true")]
pub system: bool,
/// Enable SSH connections from ~/.ssh/config
#[serde(default = "default_true")]
pub ssh: bool,
/// Enable clipboard history (requires cliphist)
#[serde(default = "default_true")]
pub clipboard: bool,
/// Enable browser bookmarks
#[serde(default = "default_true")]
pub bookmarks: bool,
/// Enable emoji picker
#[serde(default = "default_true")]
pub emoji: bool,
/// Enable custom scripts from ~/.config/owlry/scripts/
#[serde(default = "default_true")]
pub scripts: bool,
/// Enable file search (requires fd or locate)
#[serde(default = "default_true")]
pub files: bool,
}
fn default_search_engine() -> String {
@@ -208,6 +236,13 @@ impl Default for Config {
frecency_weight: 0.3,
websearch: true,
search_engine: "duckduckgo".to_string(),
system: true,
ssh: true,
clipboard: true,
bookmarks: true,
emoji: true,
scripts: true,
files: true,
},
}
}

View File

@@ -42,6 +42,26 @@ impl ProviderFilter {
if config_providers.uuctl {
set.insert(ProviderType::Uuctl);
}
if config_providers.system {
set.insert(ProviderType::System);
}
if config_providers.ssh {
set.insert(ProviderType::Ssh);
}
if config_providers.clipboard {
set.insert(ProviderType::Clipboard);
}
if config_providers.bookmarks {
set.insert(ProviderType::Bookmarks);
}
if config_providers.emoji {
set.insert(ProviderType::Emoji);
}
if config_providers.scripts {
set.insert(ProviderType::Scripts);
}
// Note: Files, Calculator, WebSearch are dynamic providers
// that don't need to be in the filter set - they're triggered by prefix
// Default to apps if nothing enabled
if set.is_empty() {
set.insert(ProviderType::Application);
@@ -129,10 +149,26 @@ impl ProviderFilter {
let prefixes = [
(":app ", ProviderType::Application),
(":apps ", ProviderType::Application),
(":bm ", ProviderType::Bookmarks),
(":bookmark ", ProviderType::Bookmarks),
(":bookmarks ", ProviderType::Bookmarks),
(":calc ", ProviderType::Calculator),
(":calculator ", ProviderType::Calculator),
(":clip ", ProviderType::Clipboard),
(":clipboard ", ProviderType::Clipboard),
(":cmd ", ProviderType::Command),
(":command ", ProviderType::Command),
(":emoji ", ProviderType::Emoji),
(":emojis ", ProviderType::Emoji),
(":file ", ProviderType::Files),
(":files ", ProviderType::Files),
(":find ", ProviderType::Files),
(":script ", ProviderType::Scripts),
(":scripts ", ProviderType::Scripts),
(":ssh ", ProviderType::Ssh),
(":sys ", ProviderType::System),
(":system ", ProviderType::System),
(":power ", ProviderType::System),
(":uuctl ", ProviderType::Uuctl),
(":web ", ProviderType::WebSearch),
(":search ", ProviderType::WebSearch),
@@ -151,10 +187,26 @@ impl ProviderFilter {
let partial_prefixes = [
(":app", ProviderType::Application),
(":apps", ProviderType::Application),
(":bm", ProviderType::Bookmarks),
(":bookmark", ProviderType::Bookmarks),
(":bookmarks", ProviderType::Bookmarks),
(":calc", ProviderType::Calculator),
(":calculator", ProviderType::Calculator),
(":clip", ProviderType::Clipboard),
(":clipboard", ProviderType::Clipboard),
(":cmd", ProviderType::Command),
(":command", ProviderType::Command),
(":emoji", ProviderType::Emoji),
(":emojis", ProviderType::Emoji),
(":file", ProviderType::Files),
(":files", ProviderType::Files),
(":find", ProviderType::Files),
(":script", ProviderType::Scripts),
(":scripts", ProviderType::Scripts),
(":ssh", ProviderType::Ssh),
(":sys", ProviderType::System),
(":system", ProviderType::System),
(":power", ProviderType::System),
(":uuctl", ProviderType::Uuctl),
(":web", ProviderType::WebSearch),
(":search", ProviderType::WebSearch),
@@ -180,11 +232,18 @@ impl ProviderFilter {
let mut providers: Vec<_> = self.enabled.iter().copied().collect();
providers.sort_by_key(|p| match p {
ProviderType::Application => 0,
ProviderType::Calculator => 1,
ProviderType::Command => 2,
ProviderType::Uuctl => 3,
ProviderType::WebSearch => 4,
ProviderType::Bookmarks => 1,
ProviderType::Calculator => 2,
ProviderType::Clipboard => 3,
ProviderType::Command => 4,
ProviderType::Dmenu => 5,
ProviderType::Emoji => 6,
ProviderType::Files => 7,
ProviderType::Scripts => 8,
ProviderType::Ssh => 9,
ProviderType::System => 10,
ProviderType::Uuctl => 11,
ProviderType::WebSearch => 12,
});
providers
}
@@ -194,11 +253,18 @@ impl ProviderFilter {
if let Some(prefix) = self.active_prefix {
return match prefix {
ProviderType::Application => "Apps",
ProviderType::Bookmarks => "Bookmarks",
ProviderType::Calculator => "Calc",
ProviderType::Clipboard => "Clipboard",
ProviderType::Command => "Commands",
ProviderType::Dmenu => "dmenu",
ProviderType::Emoji => "Emoji",
ProviderType::Files => "Files",
ProviderType::Scripts => "Scripts",
ProviderType::Ssh => "SSH",
ProviderType::System => "System",
ProviderType::Uuctl => "uuctl",
ProviderType::WebSearch => "Web",
ProviderType::Dmenu => "dmenu",
};
}
@@ -206,11 +272,18 @@ impl ProviderFilter {
if enabled.len() == 1 {
match enabled[0] {
ProviderType::Application => "Apps",
ProviderType::Bookmarks => "Bookmarks",
ProviderType::Calculator => "Calc",
ProviderType::Clipboard => "Clipboard",
ProviderType::Command => "Commands",
ProviderType::Dmenu => "dmenu",
ProviderType::Emoji => "Emoji",
ProviderType::Files => "Files",
ProviderType::Scripts => "Scripts",
ProviderType::Ssh => "SSH",
ProviderType::System => "System",
ProviderType::Uuctl => "uuctl",
ProviderType::WebSearch => "Web",
ProviderType::Dmenu => "dmenu",
}
} else {
"All"

242
src/providers/bookmarks.rs Normal file
View File

@@ -0,0 +1,242 @@
use crate::providers::{LaunchItem, Provider, ProviderType};
use log::{debug, warn};
use serde::Deserialize;
use std::fs;
use std::path::PathBuf;
/// Browser bookmarks provider - reads Firefox and Chrome bookmarks
pub struct BookmarksProvider {
items: Vec<LaunchItem>,
}
impl BookmarksProvider {
pub fn new() -> Self {
Self { items: Vec::new() }
}
fn load_bookmarks(&mut self) {
self.items.clear();
// Try Firefox first, then Chrome/Chromium
self.load_firefox_bookmarks();
self.load_chrome_bookmarks();
debug!("Loaded {} bookmarks total", self.items.len());
}
fn load_firefox_bookmarks(&mut self) {
// Firefox stores bookmarks in places.sqlite
// The file is locked when Firefox is running, so we read from backup
let firefox_dir = match dirs::home_dir() {
Some(h) => h.join(".mozilla").join("firefox"),
None => return,
};
if !firefox_dir.exists() {
debug!("Firefox directory not found");
return;
}
// Find default profile (ends with .default-release or .default)
let profile_dir = match Self::find_firefox_profile(&firefox_dir) {
Some(p) => p,
None => {
debug!("No Firefox profile found");
return;
}
};
// Try to read bookmarkbackups (JSON format, not locked)
let backup_dir = profile_dir.join("bookmarkbackups");
if backup_dir.exists() {
if let Some(latest_backup) = Self::find_latest_file(&backup_dir, "jsonlz4") {
// jsonlz4 files need decompression - skip for now, try places.sqlite
debug!("Found Firefox backup at {:?}, but jsonlz4 not supported", latest_backup);
}
}
// Try places.sqlite directly (may fail if Firefox is running)
let places_db = profile_dir.join("places.sqlite");
if places_db.exists() {
self.read_firefox_places(&places_db);
}
}
fn find_firefox_profile(firefox_dir: &PathBuf) -> Option<PathBuf> {
let entries = fs::read_dir(firefox_dir).ok()?;
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.ends_with(".default-release") || name.ends_with(".default") {
return Some(entry.path());
}
}
None
}
fn find_latest_file(dir: &PathBuf, extension: &str) -> Option<PathBuf> {
let entries = fs::read_dir(dir).ok()?;
entries
.flatten()
.filter(|e| {
e.path()
.extension()
.map(|ext| ext == extension)
.unwrap_or(false)
})
.max_by_key(|e| e.metadata().ok().and_then(|m| m.modified().ok()))
.map(|e| e.path())
}
fn read_firefox_places(&mut self, db_path: &PathBuf) {
// Note: This requires the rusqlite crate which we don't have
// For now, skip Firefox SQLite reading
debug!(
"Firefox places.sqlite found at {:?}, but SQLite reading not implemented",
db_path
);
}
fn load_chrome_bookmarks(&mut self) {
// Chrome/Chromium bookmarks are in JSON format
let home = match dirs::home_dir() {
Some(h) => h,
None => return,
};
// Try multiple browser paths
let bookmark_paths = [
// Chrome
home.join(".config/google-chrome/Default/Bookmarks"),
// Chromium
home.join(".config/chromium/Default/Bookmarks"),
// Brave
home.join(".config/BraveSoftware/Brave-Browser/Default/Bookmarks"),
// Edge
home.join(".config/microsoft-edge/Default/Bookmarks"),
// Vivaldi
home.join(".config/vivaldi/Default/Bookmarks"),
];
for path in &bookmark_paths {
if path.exists() {
self.read_chrome_bookmarks(path);
}
}
}
fn read_chrome_bookmarks(&mut self, path: &PathBuf) {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
warn!("Failed to read Chrome bookmarks from {:?}: {}", path, e);
return;
}
};
let bookmarks: ChromeBookmarks = match serde_json::from_str(&content) {
Ok(b) => b,
Err(e) => {
warn!("Failed to parse Chrome bookmarks: {}", e);
return;
}
};
// Process bookmark bar and other folders
if let Some(roots) = bookmarks.roots {
if let Some(bar) = roots.bookmark_bar {
self.process_chrome_folder(&bar);
}
if let Some(other) = roots.other {
self.process_chrome_folder(&other);
}
if let Some(synced) = roots.synced {
self.process_chrome_folder(&synced);
}
}
debug!("Loaded Chrome bookmarks from {:?}", path);
}
fn process_chrome_folder(&mut self, folder: &ChromeBookmarkNode) {
if let Some(ref children) = folder.children {
for child in children {
match child.node_type.as_deref() {
Some("url") => {
if let Some(ref url) = child.url {
let name = child.name.clone().unwrap_or_else(|| url.clone());
self.items.push(LaunchItem {
id: format!("bookmark:{}", url),
name,
description: Some(url.clone()),
icon: Some("web-browser".to_string()),
provider: ProviderType::Bookmarks,
command: format!("xdg-open '{}'", url.replace('\'', "'\\''")),
terminal: false,
});
}
}
Some("folder") => {
// Recursively process subfolders
self.process_chrome_folder(child);
}
_ => {}
}
}
}
}
}
// Chrome bookmark JSON structures
#[derive(Debug, Deserialize)]
struct ChromeBookmarks {
roots: Option<ChromeBookmarkRoots>,
}
#[derive(Debug, Deserialize)]
struct ChromeBookmarkRoots {
bookmark_bar: Option<ChromeBookmarkNode>,
other: Option<ChromeBookmarkNode>,
synced: Option<ChromeBookmarkNode>,
}
#[derive(Debug, Deserialize)]
struct ChromeBookmarkNode {
name: Option<String>,
url: Option<String>,
#[serde(rename = "type")]
node_type: Option<String>,
children: Option<Vec<ChromeBookmarkNode>>,
}
impl Provider for BookmarksProvider {
fn name(&self) -> &str {
"Bookmarks"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Bookmarks
}
fn refresh(&mut self) {
self.load_bookmarks();
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bookmarks_provider() {
let mut provider = BookmarksProvider::new();
provider.refresh();
// Just ensure it doesn't panic
}
}

137
src/providers/clipboard.rs Normal file
View File

@@ -0,0 +1,137 @@
use crate::providers::{LaunchItem, Provider, ProviderType};
use log::{debug, warn};
use std::process::Command;
/// Clipboard history provider - integrates with cliphist
pub struct ClipboardProvider {
items: Vec<LaunchItem>,
max_entries: usize,
}
impl ClipboardProvider {
pub fn new() -> Self {
Self {
items: Vec::new(),
max_entries: 50,
}
}
#[allow(dead_code)]
pub fn with_max_entries(max_entries: usize) -> Self {
Self {
items: Vec::new(),
max_entries,
}
}
/// Check if cliphist is available
fn has_cliphist() -> bool {
Command::new("which")
.arg("cliphist")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn load_clipboard_history(&mut self) {
self.items.clear();
if !Self::has_cliphist() {
debug!("cliphist not found, clipboard provider disabled");
return;
}
// Get clipboard history from cliphist
let output = match Command::new("cliphist").arg("list").output() {
Ok(o) => o,
Err(e) => {
warn!("Failed to run cliphist: {}", e);
return;
}
};
if !output.status.success() {
debug!("cliphist list returned non-zero status");
return;
}
let content = String::from_utf8_lossy(&output.stdout);
for (idx, line) in content.lines().take(self.max_entries).enumerate() {
// cliphist format: "id\tpreview"
let parts: Vec<&str> = line.splitn(2, '\t').collect();
if parts.is_empty() {
continue;
}
let clip_id = parts[0];
let preview = if parts.len() > 1 {
// Truncate long previews
let p = parts[1];
if p.len() > 80 {
format!("{}...", &p[..77])
} else {
p.to_string()
}
} else {
"[binary data]".to_string()
};
// Clean up preview - replace newlines with spaces
let preview_clean = preview
.replace('\n', " ")
.replace('\r', "")
.replace('\t', " ");
// Command to paste this entry
// echo "id" | cliphist decode | wl-copy
let command = format!(
"echo '{}' | cliphist decode | wl-copy",
clip_id.replace('\'', "'\\''")
);
self.items.push(LaunchItem {
id: format!("clipboard:{}", idx),
name: preview_clean,
description: Some("Copy to clipboard".to_string()),
icon: Some("edit-paste".to_string()),
provider: ProviderType::Clipboard,
command,
terminal: false,
});
}
debug!("Loaded {} clipboard entries", self.items.len());
}
}
impl Provider for ClipboardProvider {
fn name(&self) -> &str {
"Clipboard"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Clipboard
}
fn refresh(&mut self) {
self.load_clipboard_history();
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_clipboard_provider() {
let mut provider = ClipboardProvider::new();
provider.refresh();
// Just ensure it doesn't panic - cliphist may not be installed
}
}

446
src/providers/emoji.rs Normal file
View File

@@ -0,0 +1,446 @@
use crate::providers::{LaunchItem, Provider, ProviderType};
/// Emoji picker provider - search and copy emojis
pub struct EmojiProvider {
items: Vec<LaunchItem>,
}
impl EmojiProvider {
pub fn new() -> Self {
Self { items: Vec::new() }
}
fn load_emojis(&mut self) {
self.items.clear();
// Common emojis with searchable names
// Format: (emoji, name, keywords)
let emojis: &[(&str, &str, &str)] = &[
// Smileys & Emotion
("😀", "grinning face", "smile happy"),
("😃", "grinning face with big eyes", "smile happy"),
("😄", "grinning face with smiling eyes", "smile happy laugh"),
("😁", "beaming face with smiling eyes", "smile happy grin"),
("😅", "grinning face with sweat", "smile nervous"),
("🤣", "rolling on the floor laughing", "lol rofl funny"),
("😂", "face with tears of joy", "laugh cry funny lol"),
("🙂", "slightly smiling face", "smile"),
("😊", "smiling face with smiling eyes", "blush happy"),
("😇", "smiling face with halo", "angel innocent"),
("🥰", "smiling face with hearts", "love adore"),
("😍", "smiling face with heart-eyes", "love crush"),
("🤩", "star-struck", "excited wow amazing"),
("😘", "face blowing a kiss", "kiss love"),
("😜", "winking face with tongue", "playful silly"),
("🤪", "zany face", "crazy silly wild"),
("😎", "smiling face with sunglasses", "cool"),
("🤓", "nerd face", "geek glasses"),
("🧐", "face with monocle", "thinking inspect"),
("😏", "smirking face", "smug"),
("😒", "unamused face", "meh annoyed"),
("🙄", "face with rolling eyes", "whatever annoyed"),
("😬", "grimacing face", "awkward nervous"),
("😮‍💨", "face exhaling", "sigh relief"),
("🤥", "lying face", "pinocchio lie"),
("😌", "relieved face", "relaxed peaceful"),
("😔", "pensive face", "sad thoughtful"),
("😪", "sleepy face", "tired"),
("🤤", "drooling face", "hungry yummy"),
("😴", "sleeping face", "zzz tired"),
("😷", "face with medical mask", "sick covid"),
("🤒", "face with thermometer", "sick fever"),
("🤕", "face with head-bandage", "hurt injured"),
("🤢", "nauseated face", "sick gross"),
("🤮", "face vomiting", "sick puke"),
("🤧", "sneezing face", "achoo sick"),
("🥵", "hot face", "sweating heat"),
("🥶", "cold face", "freezing"),
("😵", "face with crossed-out eyes", "dizzy dead"),
("🤯", "exploding head", "mind blown wow"),
("🤠", "cowboy hat face", "yeehaw western"),
("🥳", "partying face", "celebration party"),
("🥸", "disguised face", "incognito"),
("😎", "cool face", "sunglasses"),
("🤡", "clown face", "circus"),
("👻", "ghost", "halloween spooky"),
("💀", "skull", "dead death"),
("☠️", "skull and crossbones", "danger death"),
("👽", "alien", "ufo extraterrestrial"),
("🤖", "robot", "bot android"),
("💩", "pile of poo", "poop shit"),
("😈", "smiling face with horns", "devil evil"),
("👿", "angry face with horns", "devil evil"),
// Gestures & People
("👋", "waving hand", "hello hi bye wave"),
("🤚", "raised back of hand", "stop"),
("🖐️", "hand with fingers splayed", "five high"),
("", "raised hand", "stop high five"),
("🖖", "vulcan salute", "spock trek"),
("👌", "ok hand", "okay perfect"),
("🤌", "pinched fingers", "italian"),
("🤏", "pinching hand", "small tiny"),
("✌️", "victory hand", "peace two"),
("🤞", "crossed fingers", "luck hope"),
("🤟", "love-you gesture", "ily rock"),
("🤘", "sign of the horns", "rock metal"),
("🤙", "call me hand", "shaka hang loose"),
("👈", "backhand index pointing left", "left point"),
("👉", "backhand index pointing right", "right point"),
("👆", "backhand index pointing up", "up point"),
("👇", "backhand index pointing down", "down point"),
("☝️", "index pointing up", "one point"),
("👍", "thumbs up", "like yes good approve"),
("👎", "thumbs down", "dislike no bad"),
("", "raised fist", "power solidarity"),
("👊", "oncoming fist", "punch bump"),
("🤛", "left-facing fist", "fist bump"),
("🤜", "right-facing fist", "fist bump"),
("👏", "clapping hands", "applause bravo"),
("🙌", "raising hands", "hooray celebrate"),
("👐", "open hands", "hug"),
("🤲", "palms up together", "prayer"),
("🤝", "handshake", "agreement deal"),
("🙏", "folded hands", "prayer please thanks"),
("✍️", "writing hand", "write"),
("💪", "flexed biceps", "strong muscle"),
("🦾", "mechanical arm", "robot prosthetic"),
("🦵", "leg", "kick"),
("🦶", "foot", "kick"),
("👂", "ear", "listen hear"),
("👃", "nose", "smell"),
("🧠", "brain", "smart think"),
("👀", "eyes", "look see watch"),
("👁️", "eye", "see look"),
("👅", "tongue", "taste lick"),
("👄", "mouth", "lips kiss"),
// Hearts & Love
("❤️", "red heart", "love"),
("🧡", "orange heart", "love"),
("💛", "yellow heart", "love friendship"),
("💚", "green heart", "love"),
("💙", "blue heart", "love"),
("💜", "purple heart", "love"),
("🖤", "black heart", "love dark"),
("🤍", "white heart", "love pure"),
("🤎", "brown heart", "love"),
("💔", "broken heart", "heartbreak sad"),
("❤️‍🔥", "heart on fire", "passion love"),
("❤️‍🩹", "mending heart", "healing recovery"),
("💕", "two hearts", "love"),
("💞", "revolving hearts", "love"),
("💓", "beating heart", "love"),
("💗", "growing heart", "love"),
("💖", "sparkling heart", "love"),
("💘", "heart with arrow", "love cupid"),
("💝", "heart with ribbon", "love gift"),
("💟", "heart decoration", "love"),
// Animals
("🐶", "dog face", "puppy"),
("🐱", "cat face", "kitty"),
("🐭", "mouse face", ""),
("🐹", "hamster", ""),
("🐰", "rabbit face", "bunny"),
("🦊", "fox", ""),
("🐻", "bear", ""),
("🐼", "panda", ""),
("🐨", "koala", ""),
("🐯", "tiger face", ""),
("🦁", "lion", ""),
("🐮", "cow face", ""),
("🐷", "pig face", ""),
("🐸", "frog", ""),
("🐵", "monkey face", ""),
("🦄", "unicorn", "magic"),
("🐝", "bee", "honeybee"),
("🦋", "butterfly", ""),
("🐌", "snail", "slow"),
("🐛", "bug", "caterpillar"),
("🦀", "crab", ""),
("🐙", "octopus", ""),
("🐠", "tropical fish", ""),
("🐟", "fish", ""),
("🐬", "dolphin", ""),
("🐳", "whale", ""),
("🦈", "shark", ""),
("🐊", "crocodile", "alligator"),
("🐢", "turtle", ""),
("🦎", "lizard", ""),
("🐍", "snake", ""),
("🦖", "t-rex", "dinosaur"),
("🦕", "sauropod", "dinosaur"),
("🐔", "chicken", ""),
("🐧", "penguin", ""),
("🦅", "eagle", "bird"),
("🦆", "duck", ""),
("🦉", "owl", ""),
// Food & Drink
("🍎", "red apple", "fruit"),
("🍐", "pear", "fruit"),
("🍊", "orange", "tangerine fruit"),
("🍋", "lemon", "fruit"),
("🍌", "banana", "fruit"),
("🍉", "watermelon", "fruit"),
("🍇", "grapes", "fruit"),
("🍓", "strawberry", "fruit"),
("🍒", "cherries", "fruit"),
("🍑", "peach", "fruit"),
("🥭", "mango", "fruit"),
("🍍", "pineapple", "fruit"),
("🥥", "coconut", "fruit"),
("🥝", "kiwi", "fruit"),
("🍅", "tomato", "vegetable"),
("🥑", "avocado", ""),
("🥦", "broccoli", "vegetable"),
("🥬", "leafy green", "vegetable salad"),
("🥒", "cucumber", "vegetable"),
("🌶️", "hot pepper", "spicy chili"),
("🌽", "corn", ""),
("🥕", "carrot", "vegetable"),
("🧄", "garlic", ""),
("🧅", "onion", ""),
("🥔", "potato", ""),
("🍞", "bread", ""),
("🥐", "croissant", ""),
("🥖", "baguette", "bread french"),
("🥨", "pretzel", ""),
("🧀", "cheese", ""),
("🥚", "egg", ""),
("🍳", "cooking", "frying pan egg"),
("🥞", "pancakes", "breakfast"),
("🧇", "waffle", "breakfast"),
("🥓", "bacon", "breakfast"),
("🍔", "hamburger", "burger"),
("🍟", "french fries", ""),
("🍕", "pizza", ""),
("🌭", "hot dog", ""),
("🥪", "sandwich", ""),
("🌮", "taco", "mexican"),
("🌯", "burrito", "mexican"),
("🍜", "steaming bowl", "ramen noodles"),
("🍝", "spaghetti", "pasta"),
("🍣", "sushi", "japanese"),
("🍱", "bento box", "japanese"),
("🍩", "doughnut", "donut dessert"),
("🍪", "cookie", "dessert"),
("🎂", "birthday cake", "dessert"),
("🍰", "shortcake", "dessert"),
("🧁", "cupcake", "dessert"),
("🍫", "chocolate bar", "dessert"),
("🍬", "candy", "sweet"),
("🍭", "lollipop", "candy sweet"),
("🍦", "soft ice cream", "dessert"),
("🍨", "ice cream", "dessert"),
("", "hot beverage", "coffee tea"),
("🍵", "teacup", "tea"),
("🧃", "juice box", ""),
("🥤", "cup with straw", "soda drink"),
("🍺", "beer mug", "drink alcohol"),
("🍻", "clinking beer mugs", "cheers drink"),
("🥂", "clinking glasses", "champagne cheers"),
("🍷", "wine glass", "drink alcohol"),
("🥃", "tumbler glass", "whiskey drink"),
("🍸", "cocktail glass", "martini drink"),
// Objects & Symbols
("💻", "laptop", "computer"),
("🖥️", "desktop computer", "pc"),
("⌨️", "keyboard", ""),
("🖱️", "computer mouse", ""),
("💾", "floppy disk", "save"),
("💿", "optical disk", "cd"),
("📱", "mobile phone", "smartphone"),
("☎️", "telephone", "phone"),
("📧", "email", "mail"),
("📨", "incoming envelope", "email"),
("📩", "envelope with arrow", "email send"),
("📝", "memo", "note write"),
("📄", "page facing up", "document"),
("📃", "page with curl", "document"),
("📑", "bookmark tabs", ""),
("📚", "books", "library read"),
("📖", "open book", "read"),
("🔗", "link", "chain url"),
("📎", "paperclip", "attachment"),
("🔒", "locked", "security"),
("🔓", "unlocked", "security open"),
("🔑", "key", "password"),
("🔧", "wrench", "tool fix"),
("🔨", "hammer", "tool"),
("⚙️", "gear", "settings"),
("🧲", "magnet", ""),
("💡", "light bulb", "idea"),
("🔦", "flashlight", ""),
("🔋", "battery", "power"),
("🔌", "electric plug", "power"),
("💰", "money bag", ""),
("💵", "dollar", "money cash"),
("💳", "credit card", "payment"),
("", "alarm clock", "time"),
("⏱️", "stopwatch", "timer"),
("📅", "calendar", "date"),
("📆", "tear-off calendar", "date"),
("", "check mark", "done yes"),
("", "cross mark", "no wrong delete"),
("", "question mark", "help"),
("", "exclamation mark", "important warning"),
("⚠️", "warning", "caution alert"),
("🚫", "prohibited", "no ban forbidden"),
("", "hollow circle", ""),
("🔴", "red circle", ""),
("🟠", "orange circle", ""),
("🟡", "yellow circle", ""),
("🟢", "green circle", ""),
("🔵", "blue circle", ""),
("🟣", "purple circle", ""),
("", "black circle", ""),
("", "white circle", ""),
("🟤", "brown circle", ""),
("", "black square", ""),
("", "white square", ""),
("🔶", "large orange diamond", ""),
("🔷", "large blue diamond", ""),
("", "star", "favorite"),
("🌟", "glowing star", "sparkle"),
("", "sparkles", "magic shine"),
("💫", "dizzy", "star"),
("🔥", "fire", "hot lit"),
("💧", "droplet", "water"),
("🌊", "wave", "water ocean"),
("🎵", "musical note", "music"),
("🎶", "musical notes", "music"),
("🎤", "microphone", "sing karaoke"),
("🎧", "headphones", "music"),
("🎮", "video game", "gaming controller"),
("🕹️", "joystick", "gaming"),
("🎯", "direct hit", "target bullseye"),
("🏆", "trophy", "winner award"),
("🥇", "1st place medal", "gold winner"),
("🥈", "2nd place medal", "silver"),
("🥉", "3rd place medal", "bronze"),
("🎁", "wrapped gift", "present"),
("🎈", "balloon", "party"),
("🎉", "party popper", "celebration tada"),
("🎊", "confetti ball", "celebration"),
// Arrows & Misc
("➡️", "right arrow", ""),
("⬅️", "left arrow", ""),
("⬆️", "up arrow", ""),
("⬇️", "down arrow", ""),
("↗️", "up-right arrow", ""),
("↘️", "down-right arrow", ""),
("↙️", "down-left arrow", ""),
("↖️", "up-left arrow", ""),
("↕️", "up-down arrow", ""),
("↔️", "left-right arrow", ""),
("🔄", "counterclockwise arrows", "refresh reload"),
("🔃", "clockwise arrows", "refresh reload"),
("", "plus", "add"),
("", "minus", "subtract"),
("", "division", "divide"),
("✖️", "multiply", "times"),
("♾️", "infinity", "forever"),
("💯", "hundred points", "100 perfect"),
("🆗", "ok button", "okay"),
("🆕", "new button", ""),
("🆓", "free button", ""),
("", "information", "info"),
("🅿️", "parking", ""),
("🚀", "rocket", "launch startup"),
("✈️", "airplane", "travel flight"),
("🚗", "car", "automobile"),
("🚕", "taxi", "cab"),
("🚌", "bus", ""),
("🚂", "locomotive", "train"),
("🏠", "house", "home"),
("🏢", "office building", "work"),
("🏥", "hospital", ""),
("🏫", "school", ""),
("🏛️", "classical building", ""),
("", "church", ""),
("🕌", "mosque", ""),
("🕍", "synagogue", ""),
("🗽", "statue of liberty", "usa america"),
("🗼", "tokyo tower", "japan"),
("🗾", "map of japan", ""),
("🌍", "globe europe-africa", "earth world"),
("🌎", "globe americas", "earth world"),
("🌏", "globe asia-australia", "earth world"),
("🌑", "new moon", ""),
("🌕", "full moon", ""),
("☀️", "sun", "sunny"),
("🌙", "crescent moon", "night"),
("", "star", ""),
("☁️", "cloud", ""),
("🌧️", "cloud with rain", "rainy"),
("⛈️", "cloud with lightning", "storm thunder"),
("🌈", "rainbow", ""),
("❄️", "snowflake", "cold winter"),
("☃️", "snowman", "winter"),
("🎄", "christmas tree", "xmas holiday"),
("🎃", "jack-o-lantern", "halloween pumpkin"),
("🐚", "shell", "beach"),
("🌸", "cherry blossom", "flower spring"),
("🌺", "hibiscus", "flower"),
("🌻", "sunflower", "flower"),
("🌹", "rose", "flower love"),
("🌷", "tulip", "flower"),
("🌱", "seedling", "plant grow"),
("🌲", "evergreen tree", ""),
("🌳", "deciduous tree", ""),
("🌴", "palm tree", "tropical"),
("🌵", "cactus", "desert"),
("🍀", "four leaf clover", "luck irish"),
("🍁", "maple leaf", "fall autumn canada"),
("🍂", "fallen leaf", "fall autumn"),
];
for (emoji, name, keywords) in emojis {
// Combine name and keywords for better searching
let search_text = format!("{} {}", name, keywords);
self.items.push(LaunchItem {
id: format!("emoji:{}", emoji),
name: name.to_string(),
description: Some(format!("{} {}", emoji, keywords)),
icon: None,
provider: ProviderType::Emoji,
// Copy emoji to clipboard using wl-copy
command: format!("printf '%s' '{}' | wl-copy", emoji),
terminal: false,
});
// Store the search text for matching (not used directly but could be)
let _ = search_text;
}
}
}
impl Provider for EmojiProvider {
fn name(&self) -> &str {
"Emoji"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Emoji
}
fn refresh(&mut self) {
self.load_emojis();
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_emoji_provider() {
let mut provider = EmojiProvider::new();
provider.refresh();
assert!(provider.items().len() > 100);
assert!(provider.items().iter().any(|i| i.name.contains("😀")));
}
}

224
src/providers/files.rs Normal file
View File

@@ -0,0 +1,224 @@
use crate::providers::{LaunchItem, ProviderType};
use log::{debug, warn};
use std::process::Command;
/// File search provider - uses fd or locate for fast file finding
pub struct FileSearchProvider {
search_tool: SearchTool,
max_results: usize,
}
#[derive(Debug, Clone, Copy)]
enum SearchTool {
Fd,
Locate,
None,
}
impl FileSearchProvider {
pub fn new() -> Self {
let search_tool = Self::detect_search_tool();
debug!("File search using: {:?}", search_tool);
Self {
search_tool,
max_results: 20,
}
}
fn detect_search_tool() -> SearchTool {
// Prefer fd (faster, respects .gitignore)
if Self::command_exists("fd") {
return SearchTool::Fd;
}
// Fall back to locate (requires updatedb)
if Self::command_exists("locate") {
return SearchTool::Locate;
}
SearchTool::None
}
fn command_exists(cmd: &str) -> bool {
Command::new("which")
.arg(cmd)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Check if query is a file search query
/// Triggers on: `/ query`, `file query`, `find query`
pub fn is_file_query(query: &str) -> bool {
let trimmed = query.trim();
trimmed.starts_with("/ ")
|| trimmed.starts_with("/")
|| trimmed.to_lowercase().starts_with("file ")
|| trimmed.to_lowercase().starts_with("find ")
}
/// Extract the search term from the query
fn extract_search_term(query: &str) -> Option<&str> {
let trimmed = query.trim();
if let Some(rest) = trimmed.strip_prefix("/ ") {
Some(rest.trim())
} else if let Some(rest) = trimmed.strip_prefix("/") {
Some(rest.trim())
} else if trimmed.to_lowercase().starts_with("file ") {
Some(trimmed[5..].trim())
} else if trimmed.to_lowercase().starts_with("find ") {
Some(trimmed[5..].trim())
} else {
None
}
}
/// Evaluate a file search query
pub fn evaluate(&self, query: &str) -> Vec<LaunchItem> {
let search_term = match Self::extract_search_term(query) {
Some(t) if !t.is_empty() => t,
_ => return Vec::new(),
};
self.search_files(search_term)
}
/// Evaluate a raw search term (for :file filter mode)
pub fn evaluate_raw(&self, search_term: &str) -> Vec<LaunchItem> {
let trimmed = search_term.trim();
if trimmed.is_empty() {
return Vec::new();
}
self.search_files(trimmed)
}
fn search_files(&self, pattern: &str) -> Vec<LaunchItem> {
match self.search_tool {
SearchTool::Fd => self.search_with_fd(pattern),
SearchTool::Locate => self.search_with_locate(pattern),
SearchTool::None => {
debug!("No file search tool available");
Vec::new()
}
}
}
fn search_with_fd(&self, pattern: &str) -> Vec<LaunchItem> {
// fd searches from home directory by default
let home = dirs::home_dir().unwrap_or_default();
let output = match Command::new("fd")
.args([
"--max-results",
&self.max_results.to_string(),
"--type",
"f", // Files only
"--type",
"d", // And directories
pattern,
])
.current_dir(&home)
.output()
{
Ok(o) => o,
Err(e) => {
warn!("Failed to run fd: {}", e);
return Vec::new();
}
};
self.parse_file_results(&String::from_utf8_lossy(&output.stdout), &home)
}
fn search_with_locate(&self, pattern: &str) -> Vec<LaunchItem> {
let home = dirs::home_dir().unwrap_or_default();
let output = match Command::new("locate")
.args([
"--limit",
&self.max_results.to_string(),
"--ignore-case",
pattern,
])
.output()
{
Ok(o) => o,
Err(e) => {
warn!("Failed to run locate: {}", e);
return Vec::new();
}
};
self.parse_file_results(&String::from_utf8_lossy(&output.stdout), &home)
}
fn parse_file_results(&self, output: &str, home: &std::path::Path) -> Vec<LaunchItem> {
output
.lines()
.filter(|line| !line.is_empty())
.map(|path| {
let path = path.trim();
let full_path = if path.starts_with('/') {
path.to_string()
} else {
home.join(path).to_string_lossy().to_string()
};
// Get filename for display
let filename = std::path::Path::new(&full_path)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| full_path.clone());
// Determine icon based on whether it's a directory
let is_dir = std::path::Path::new(&full_path).is_dir();
let icon = if is_dir {
"folder"
} else {
"text-x-generic"
};
// Command to open with xdg-open
let command = format!("xdg-open '{}'", full_path.replace('\'', "'\\''"));
LaunchItem {
id: format!("file:{}", full_path),
name: filename,
description: Some(full_path.clone()),
icon: Some(icon.to_string()),
provider: ProviderType::Files,
command,
terminal: false,
}
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_file_query() {
assert!(FileSearchProvider::is_file_query("/ config"));
assert!(FileSearchProvider::is_file_query("/config"));
assert!(FileSearchProvider::is_file_query("file config"));
assert!(FileSearchProvider::is_file_query("find config"));
assert!(!FileSearchProvider::is_file_query("config"));
assert!(!FileSearchProvider::is_file_query("? search"));
}
#[test]
fn test_extract_search_term() {
assert_eq!(
FileSearchProvider::extract_search_term("/ config.toml"),
Some("config.toml")
);
assert_eq!(
FileSearchProvider::extract_search_term("file bashrc"),
Some("bashrc")
);
}
}

View File

@@ -1,14 +1,28 @@
mod application;
mod bookmarks;
mod calculator;
mod clipboard;
mod command;
mod dmenu;
mod emoji;
mod files;
mod scripts;
mod ssh;
mod system;
mod uuctl;
mod websearch;
pub use application::ApplicationProvider;
pub use bookmarks::BookmarksProvider;
pub use calculator::CalculatorProvider;
pub use clipboard::ClipboardProvider;
pub use command::CommandProvider;
pub use dmenu::DmenuProvider;
pub use emoji::EmojiProvider;
pub use files::FileSearchProvider;
pub use scripts::ScriptsProvider;
pub use ssh::SshProvider;
pub use system::SystemProvider;
pub use uuctl::UuctlProvider;
pub use websearch::WebSearchProvider;
@@ -34,9 +48,16 @@ pub struct LaunchItem {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ProviderType {
Application,
Bookmarks,
Calculator,
Clipboard,
Command,
Dmenu,
Emoji,
Files,
Scripts,
Ssh,
System,
Uuctl,
WebSearch,
}
@@ -47,13 +68,20 @@ impl std::str::FromStr for ProviderType {
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"app" | "apps" | "application" | "applications" => Ok(ProviderType::Application),
"bookmark" | "bookmarks" | "bm" => Ok(ProviderType::Bookmarks),
"calc" | "calculator" => Ok(ProviderType::Calculator),
"clip" | "clipboard" => Ok(ProviderType::Clipboard),
"cmd" | "command" | "commands" => Ok(ProviderType::Command),
"uuctl" => Ok(ProviderType::Uuctl),
"dmenu" => Ok(ProviderType::Dmenu),
"emoji" | "emojis" => Ok(ProviderType::Emoji),
"file" | "files" | "find" => Ok(ProviderType::Files),
"script" | "scripts" => Ok(ProviderType::Scripts),
"ssh" => Ok(ProviderType::Ssh),
"sys" | "system" | "power" => Ok(ProviderType::System),
"uuctl" => Ok(ProviderType::Uuctl),
"web" | "websearch" | "search" => Ok(ProviderType::WebSearch),
_ => Err(format!(
"Unknown provider: '{}'. Valid: app, calc, cmd, uuctl",
"Unknown provider: '{}'. Valid: app, bookmark, calc, clip, cmd, emoji, file, script, ssh, sys, web",
s
)),
}
@@ -64,9 +92,16 @@ 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::Bookmarks => write!(f, "bookmark"),
ProviderType::Calculator => write!(f, "calc"),
ProviderType::Clipboard => write!(f, "clip"),
ProviderType::Command => write!(f, "cmd"),
ProviderType::Dmenu => write!(f, "dmenu"),
ProviderType::Emoji => write!(f, "emoji"),
ProviderType::Files => write!(f, "file"),
ProviderType::Scripts => write!(f, "script"),
ProviderType::Ssh => write!(f, "ssh"),
ProviderType::System => write!(f, "sys"),
ProviderType::Uuctl => write!(f, "uuctl"),
ProviderType::WebSearch => write!(f, "web"),
}
@@ -87,6 +122,7 @@ pub struct ProviderManager {
providers: Vec<Box<dyn Provider>>,
calculator: CalculatorProvider,
websearch: WebSearchProvider,
filesearch: FileSearchProvider,
matcher: SkimMatcherV2,
}
@@ -97,10 +133,15 @@ impl ProviderManager {
}
pub fn with_search_engine(search_engine: &str) -> Self {
Self::with_config(search_engine, "kitty")
}
pub fn with_config(search_engine: &str, terminal: &str) -> Self {
let mut manager = Self {
providers: Vec::new(),
calculator: CalculatorProvider::new(),
websearch: WebSearchProvider::with_engine(search_engine),
filesearch: FileSearchProvider::new(),
matcher: SkimMatcherV2::default(),
};
@@ -117,6 +158,14 @@ impl ProviderManager {
manager.providers.push(Box::new(ApplicationProvider::new()));
manager.providers.push(Box::new(CommandProvider::new()));
manager.providers.push(Box::new(UuctlProvider::new()));
// New providers
manager.providers.push(Box::new(SystemProvider::new()));
manager.providers.push(Box::new(SshProvider::with_terminal(terminal)));
manager.providers.push(Box::new(ClipboardProvider::new()));
manager.providers.push(Box::new(BookmarksProvider::new()));
manager.providers.push(Box::new(EmojiProvider::new()));
manager.providers.push(Box::new(ScriptsProvider::new()));
}
// Initial refresh
@@ -264,6 +313,28 @@ impl ProviderManager {
results.push((web_result, 9000));
}
}
// Also check for raw query when in :web filter mode
else if filter.active_prefix() == Some(ProviderType::WebSearch) && !query.is_empty() {
if let Some(web_result) = self.websearch.evaluate_raw(query) {
results.push((web_result, 9000));
}
}
// Check for file search query
if FileSearchProvider::is_file_query(query) {
let file_results = self.filesearch.evaluate(query);
for (idx, item) in file_results.into_iter().enumerate() {
// Score decreases for each result to maintain order
results.push((item, 8000 - idx as i64));
}
}
// Also check for raw query when in :file filter mode
else if filter.active_prefix() == Some(ProviderType::Files) && !query.is_empty() {
let file_results = self.filesearch.evaluate_raw(query);
for (idx, item) in file_results.into_iter().enumerate() {
results.push((item, 8000 - idx as i64));
}
}
// Empty query (after checking special providers) - return frecency-sorted items
if query.is_empty() {

181
src/providers/scripts.rs Normal file
View File

@@ -0,0 +1,181 @@
use crate::providers::{LaunchItem, Provider, ProviderType};
use log::{debug, warn};
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
/// Custom scripts provider - runs user scripts from ~/.config/owlry/scripts/
pub struct ScriptsProvider {
items: Vec<LaunchItem>,
}
impl ScriptsProvider {
pub fn new() -> Self {
Self { items: Vec::new() }
}
fn scripts_dir() -> Option<PathBuf> {
dirs::config_dir().map(|p| p.join("owlry").join("scripts"))
}
fn load_scripts(&mut self) {
self.items.clear();
let scripts_dir = match Self::scripts_dir() {
Some(p) => p,
None => {
debug!("Could not determine scripts directory");
return;
}
};
if !scripts_dir.exists() {
debug!("Scripts directory not found at {:?}", scripts_dir);
// Create the directory for the user
if let Err(e) = fs::create_dir_all(&scripts_dir) {
warn!("Failed to create scripts directory: {}", e);
}
return;
}
let entries = match fs::read_dir(&scripts_dir) {
Ok(e) => e,
Err(e) => {
warn!("Failed to read scripts directory: {}", e);
return;
}
};
for entry in entries.flatten() {
let path = entry.path();
// Skip directories
if path.is_dir() {
continue;
}
// Check if executable
let metadata = match path.metadata() {
Ok(m) => m,
Err(_) => continue,
};
let is_executable = metadata.permissions().mode() & 0o111 != 0;
if !is_executable {
continue;
}
// Get script name without extension
let filename = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let name = path
.file_stem()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or(filename.clone());
// Try to read description from first line comment
let description = Self::read_script_description(&path);
// Determine icon based on extension or shebang
let icon = Self::determine_icon(&path);
self.items.push(LaunchItem {
id: format!("script:{}", filename),
name: format!("Script: {}", name),
description,
icon: Some(icon),
provider: ProviderType::Scripts,
command: path.to_string_lossy().to_string(),
terminal: false,
});
}
debug!("Loaded {} scripts from {:?}", self.items.len(), scripts_dir);
}
fn read_script_description(path: &PathBuf) -> Option<String> {
let content = fs::read_to_string(path).ok()?;
let mut lines = content.lines();
// Skip shebang if present
let first_line = lines.next()?;
let check_line = if first_line.starts_with("#!") {
lines.next()?
} else {
first_line
};
// Look for a comment description
if check_line.starts_with("# ") {
Some(check_line[2..].trim().to_string())
} else if check_line.starts_with("// ") {
Some(check_line[3..].trim().to_string())
} else {
None
}
}
fn determine_icon(path: &PathBuf) -> String {
// Check extension first
if let Some(ext) = path.extension() {
match ext.to_string_lossy().as_ref() {
"sh" | "bash" | "zsh" => return "utilities-terminal".to_string(),
"py" | "python" => return "text-x-python".to_string(),
"js" | "ts" => return "text-x-javascript".to_string(),
"rb" => return "text-x-ruby".to_string(),
"pl" => return "text-x-perl".to_string(),
_ => {}
}
}
// Check shebang
if let Ok(content) = fs::read_to_string(path) {
if let Some(first_line) = content.lines().next() {
if first_line.contains("bash") || first_line.contains("sh") {
return "utilities-terminal".to_string();
} else if first_line.contains("python") {
return "text-x-python".to_string();
} else if first_line.contains("node") {
return "text-x-javascript".to_string();
} else if first_line.contains("ruby") {
return "text-x-ruby".to_string();
}
}
}
"application-x-executable".to_string()
}
}
impl Provider for ScriptsProvider {
fn name(&self) -> &str {
"Scripts"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Scripts
}
fn refresh(&mut self) {
self.load_scripts();
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_scripts_provider() {
let mut provider = ScriptsProvider::new();
provider.refresh();
// Just ensure it doesn't panic
}
}

197
src/providers/ssh.rs Normal file
View File

@@ -0,0 +1,197 @@
use crate::providers::{LaunchItem, Provider, ProviderType};
use log::{debug, warn};
use std::fs;
use std::path::PathBuf;
/// SSH connections provider - parses ~/.ssh/config
pub struct SshProvider {
items: Vec<LaunchItem>,
terminal_command: String,
}
impl SshProvider {
#[allow(dead_code)]
pub fn new() -> Self {
Self::with_terminal("kitty")
}
pub fn with_terminal(terminal: &str) -> Self {
Self {
items: Vec::new(),
terminal_command: terminal.to_string(),
}
}
#[allow(dead_code)]
pub fn set_terminal(&mut self, terminal: &str) {
self.terminal_command = terminal.to_string();
}
fn ssh_config_path() -> Option<PathBuf> {
dirs::home_dir().map(|p| p.join(".ssh").join("config"))
}
fn parse_ssh_config(&mut self) {
self.items.clear();
let config_path = match Self::ssh_config_path() {
Some(p) => p,
None => {
debug!("Could not determine SSH config path");
return;
}
};
if !config_path.exists() {
debug!("SSH config not found at {:?}", config_path);
return;
}
let content = match fs::read_to_string(&config_path) {
Ok(c) => c,
Err(e) => {
warn!("Failed to read SSH config: {}", e);
return;
}
};
let mut current_host: Option<String> = None;
let mut current_hostname: Option<String> = None;
let mut current_user: Option<String> = None;
let mut current_port: Option<String> = None;
for line in content.lines() {
let line = line.trim();
// Skip comments and empty lines
if line.is_empty() || line.starts_with('#') {
continue;
}
// Split on whitespace or '='
let parts: Vec<&str> = line.splitn(2, |c: char| c.is_whitespace() || c == '=')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
if parts.len() < 2 {
continue;
}
let key = parts[0].to_lowercase();
let value = parts[1];
match key.as_str() {
"host" => {
// Save previous host if exists
if let Some(host) = current_host.take() {
self.add_host_item(
&host,
current_hostname.take(),
current_user.take(),
current_port.take(),
);
}
// Skip wildcards and patterns
if !value.contains('*') && !value.contains('?') && value != "*" {
current_host = Some(value.to_string());
}
current_hostname = None;
current_user = None;
current_port = None;
}
"hostname" => {
current_hostname = Some(value.to_string());
}
"user" => {
current_user = Some(value.to_string());
}
"port" => {
current_port = Some(value.to_string());
}
_ => {}
}
}
// Don't forget the last host
if let Some(host) = current_host.take() {
self.add_host_item(&host, current_hostname, current_user, current_port);
}
debug!("Loaded {} SSH hosts", self.items.len());
}
fn add_host_item(
&mut self,
host: &str,
hostname: Option<String>,
user: Option<String>,
port: Option<String>,
) {
// Build description
let mut desc_parts = Vec::new();
if let Some(ref h) = hostname {
desc_parts.push(h.clone());
}
if let Some(ref u) = user {
desc_parts.push(format!("user: {}", u));
}
if let Some(ref p) = port {
desc_parts.push(format!("port: {}", p));
}
let description = if desc_parts.is_empty() {
None
} else {
Some(desc_parts.join(", "))
};
// Build SSH command - just use the host alias, SSH will resolve the rest
let ssh_command = format!("ssh {}", host);
// Wrap in terminal
let command = format!("{} -e {}", self.terminal_command, ssh_command);
self.items.push(LaunchItem {
id: format!("ssh:{}", host),
name: format!("SSH: {}", host),
description,
icon: Some("utilities-terminal".to_string()),
provider: ProviderType::Ssh,
command,
terminal: false, // We're already wrapping in terminal
});
}
}
impl Provider for SshProvider {
fn name(&self) -> &str {
"SSH"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Ssh
}
fn refresh(&mut self) {
self.parse_ssh_config();
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_ssh_config() {
// This test will only work if the user has an SSH config
let mut provider = SshProvider::new();
provider.refresh();
// Just ensure it doesn't panic
}
}

115
src/providers/system.rs Normal file
View File

@@ -0,0 +1,115 @@
use crate::providers::{LaunchItem, Provider, ProviderType};
/// System commands provider - shutdown, reboot, lock, etc.
pub struct SystemProvider {
items: Vec<LaunchItem>,
}
impl SystemProvider {
pub fn new() -> Self {
Self { items: Vec::new() }
}
fn load_commands(&mut self) {
self.items.clear();
// Define system commands
// Format: (id, name, description, icon, command)
let commands: Vec<(&str, &str, &str, &str, &str)> = vec![
(
"system:shutdown",
"Shutdown",
"Power off the system",
"system-shutdown",
"systemctl poweroff",
),
(
"system:reboot",
"Reboot",
"Restart the system",
"system-reboot",
"systemctl reboot",
),
(
"system:reboot-bios",
"Reboot into BIOS",
"Restart into UEFI/BIOS setup",
"system-reboot",
"systemctl reboot --firmware-setup",
),
(
"system:suspend",
"Suspend",
"Suspend to RAM",
"system-suspend",
"systemctl suspend",
),
(
"system:hibernate",
"Hibernate",
"Suspend to disk",
"system-suspend-hibernate",
"systemctl hibernate",
),
(
"system:lock",
"Lock Screen",
"Lock the session",
"system-lock-screen",
"loginctl lock-session",
),
(
"system:logout",
"Log Out",
"End the current session",
"system-log-out",
"loginctl terminate-session self",
),
];
for (id, name, description, icon, command) in commands {
self.items.push(LaunchItem {
id: id.to_string(),
name: name.to_string(),
description: Some(description.to_string()),
icon: Some(icon.to_string()),
provider: ProviderType::System,
command: command.to_string(),
terminal: false,
});
}
}
}
impl Provider for SystemProvider {
fn name(&self) -> &str {
"System"
}
fn provider_type(&self) -> ProviderType {
ProviderType::System
}
fn refresh(&mut self) {
self.load_commands();
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_system_provider() {
let mut provider = SystemProvider::new();
provider.refresh();
assert!(provider.items().len() >= 6);
assert!(provider.items().iter().any(|i| i.name == "Shutdown"));
assert!(provider.items().iter().any(|i| i.name == "Reboot into BIOS"));
}
}

View File

@@ -113,14 +113,24 @@ impl WebSearchProvider {
return None;
}
let url = self.build_search_url(search_term);
self.evaluate_raw(search_term)
}
/// Evaluate a raw search term (for :web filter mode)
pub fn evaluate_raw(&self, search_term: &str) -> Option<LaunchItem> {
let trimmed = search_term.trim();
if trimmed.is_empty() {
return None;
}
let url = self.build_search_url(trimmed);
// Use xdg-open to open the browser
let command = format!("xdg-open '{}'", url);
Some(LaunchItem {
id: format!("websearch:{}", search_term),
name: format!("Search: {}", search_term),
id: format!("websearch:{}", trimmed),
name: format!("Search: {}", trimmed),
description: Some("Open in browser".to_string()),
icon: Some("web-browser".to_string()),
provider: ProviderType::WebSearch,

View File

@@ -35,15 +35,36 @@ pub fn generate_variables_css(config: &AppearanceConfig) -> String {
if let Some(ref badge_app) = config.colors.badge_app {
css.push_str(&format!(" --owlry-badge-app: {};\n", badge_app));
}
if let Some(ref badge_bookmark) = config.colors.badge_bookmark {
css.push_str(&format!(" --owlry-badge-bookmark: {};\n", badge_bookmark));
}
if let Some(ref badge_calc) = config.colors.badge_calc {
css.push_str(&format!(" --owlry-badge-calc: {};\n", badge_calc));
}
if let Some(ref badge_clip) = config.colors.badge_clip {
css.push_str(&format!(" --owlry-badge-clip: {};\n", badge_clip));
}
if let Some(ref badge_cmd) = config.colors.badge_cmd {
css.push_str(&format!(" --owlry-badge-cmd: {};\n", badge_cmd));
}
if let Some(ref badge_dmenu) = config.colors.badge_dmenu {
css.push_str(&format!(" --owlry-badge-dmenu: {};\n", badge_dmenu));
}
if let Some(ref badge_emoji) = config.colors.badge_emoji {
css.push_str(&format!(" --owlry-badge-emoji: {};\n", badge_emoji));
}
if let Some(ref badge_file) = config.colors.badge_file {
css.push_str(&format!(" --owlry-badge-file: {};\n", badge_file));
}
if let Some(ref badge_script) = config.colors.badge_script {
css.push_str(&format!(" --owlry-badge-script: {};\n", badge_script));
}
if let Some(ref badge_ssh) = config.colors.badge_ssh {
css.push_str(&format!(" --owlry-badge-ssh: {};\n", badge_ssh));
}
if let Some(ref badge_sys) = config.colors.badge_sys {
css.push_str(&format!(" --owlry-badge-sys: {};\n", badge_sys));
}
if let Some(ref badge_uuctl) = config.colors.badge_uuctl {
css.push_str(&format!(" --owlry-badge-uuctl: {};\n", badge_uuctl));
}

View File

@@ -206,11 +206,18 @@ impl MainWindow {
button.add_css_class("owlry-filter-button");
let css_class = match provider_type {
ProviderType::Application => "owlry-filter-app",
ProviderType::Bookmarks => "owlry-filter-bookmark",
ProviderType::Calculator => "owlry-filter-calc",
ProviderType::Clipboard => "owlry-filter-clip",
ProviderType::Command => "owlry-filter-cmd",
ProviderType::Dmenu => "owlry-filter-dmenu",
ProviderType::Emoji => "owlry-filter-emoji",
ProviderType::Files => "owlry-filter-file",
ProviderType::Scripts => "owlry-filter-script",
ProviderType::Ssh => "owlry-filter-ssh",
ProviderType::System => "owlry-filter-sys",
ProviderType::Uuctl => "owlry-filter-uuctl",
ProviderType::WebSearch => "owlry-filter-web",
ProviderType::Dmenu => "owlry-filter-dmenu",
};
button.add_css_class(css_class);
@@ -227,11 +234,18 @@ impl MainWindow {
.iter()
.map(|p| match p {
ProviderType::Application => "applications",
ProviderType::Bookmarks => "bookmarks",
ProviderType::Calculator => "calculator",
ProviderType::Clipboard => "clipboard",
ProviderType::Command => "commands",
ProviderType::Dmenu => "options",
ProviderType::Emoji => "emoji",
ProviderType::Files => "files",
ProviderType::Scripts => "scripts",
ProviderType::Ssh => "SSH hosts",
ProviderType::System => "system",
ProviderType::Uuctl => "uuctl units",
ProviderType::WebSearch => "web",
ProviderType::Dmenu => "options",
})
.collect();
@@ -410,11 +424,18 @@ impl MainWindow {
if parsed.prefix.is_some() {
let prefix_name = match parsed.prefix.unwrap() {
ProviderType::Application => "applications",
ProviderType::Bookmarks => "bookmarks",
ProviderType::Calculator => "calculator",
ProviderType::Clipboard => "clipboard",
ProviderType::Command => "commands",
ProviderType::Dmenu => "options",
ProviderType::Emoji => "emoji",
ProviderType::Files => "files",
ProviderType::Scripts => "scripts",
ProviderType::Ssh => "SSH hosts",
ProviderType::System => "system",
ProviderType::Uuctl => "uuctl units",
ProviderType::WebSearch => "web",
ProviderType::Dmenu => "options",
};
search_entry_for_change
.set_placeholder_text(Some(&format!("Search {}...", prefix_name)));

View File

@@ -32,9 +32,16 @@ impl ResultRow {
// Default icon based on provider type
let default_icon = match item.provider {
crate::providers::ProviderType::Application => "application-x-executable",
crate::providers::ProviderType::Bookmarks => "user-bookmarks",
crate::providers::ProviderType::Calculator => "accessories-calculator",
crate::providers::ProviderType::Clipboard => "edit-paste",
crate::providers::ProviderType::Command => "utilities-terminal",
crate::providers::ProviderType::Dmenu => "view-list-symbolic",
crate::providers::ProviderType::Emoji => "face-smile",
crate::providers::ProviderType::Files => "folder",
crate::providers::ProviderType::Scripts => "application-x-executable",
crate::providers::ProviderType::Ssh => "network-server",
crate::providers::ProviderType::System => "system-shutdown",
crate::providers::ProviderType::Uuctl => "system-run",
crate::providers::ProviderType::WebSearch => "web-browser",
};