16 Commits

Author SHA1 Message Date
cb12ffbeca chore: bump version to 0.3.9 2025-12-29 18:06:55 +01:00
892333dbca style: reduce vertical spacing on result rows
- Row padding: 6px (was 8px)
- Row margin: 1px (was 2px)
- Tag badge margin: 2px (was 4px)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 18:06:50 +01:00
6d3d69d103 chore: bump version to 0.3.8 2025-12-29 17:58:51 +01:00
bec8fc332b feat: increase default window size and reduce padding
- Default dimensions: 700x500 (was 600x400)
- Main container padding: 12px (was 16px)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 17:58:47 +01:00
a750ef8559 docs: add example files to README configuration section
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 17:47:21 +01:00
7cbebd324f chore: bump version to 0.3.7 2025-12-29 17:44:57 +01:00
5519381d8c chore: bump version to 0.3.6 2025-12-29 17:39:46 +01:00
38025279f9 docs: reorganize and update README
- Add new features: tags, configurable tabs, tag filtering
- Add File Locations section with XDG paths
- Fix scripts path (~/.local/share instead of ~/.config)
- Add Tags section explaining tag-based filtering
- Add Tab Configuration section
- Consolidate providers into a table
- Streamline theming section
- Remove redundant examples and verbose explanations
- More concise overall (~140 lines shorter)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 17:39:01 +01:00
405b598b9b docs: add example files and reorganize config
- Add data/style.example.css with CSS customization guide
- Add data/scripts/example.sh as script template
- Reorganize config.example.toml with clear sections:
  - File locations box at top
  - Separate sections: General, Appearance, Providers
  - Group trigger providers vs prefix providers
  - Add inline comments for all options
- List all built-in themes in appearance section

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 17:34:10 +01:00
d086995399 docs: update config.example.toml with accurate XDG paths
- Add file locations header documenting all XDG paths
- Fix scripts path: $XDG_DATA_HOME not $XDG_CONFIG_HOME
- Document theme location for custom themes
- Document style.css for custom CSS overrides
- Document frecency.json storage location

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 17:32:05 +01:00
7ca8a1f443 feat: add tags, configurable tabs, and tag-based filtering
- Add `tags` field to LaunchItem for categorization
- Extract .desktop Categories as tags for applications
- Add semantic tags to providers (systemd, ssh, script, etc.)
- Display tag badges in result rows (max 3 tags)
- Add `tabs` config option for customizable header tabs
- Dynamic Ctrl+1-9 shortcuts based on tab config
- Add `:tag:XXX` prefix for tag-based filtering
- Include tags in fuzzy search with lower weight
- Update config.example.toml with tabs documentation

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 17:30:47 +01:00
2a2a22f72c refactor: restructure project directories to follow FHS
- Move themes/ → data/themes/ (installable to /usr/share/owlry/themes/)
- Move resources/ → src/resources/ (embedded into binary)
- Move config.example.toml → data/ (installable to /usr/share/doc/owlry/)
- Update include_str! paths in app.rs

New structure follows Filesystem Hierarchy Standard:
- data/         → files installed to /usr/share/
- src/resources → embedded resources compiled into binary

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 16:56:49 +01:00
0eccdc5883 refactor: centralize path handling with XDG Base Directory compliance
- Add src/paths.rs module for all XDG path lookups
- Move scripts from ~/.config to ~/.local/share (XDG data)
- Use $XDG_CONFIG_HOME for browser bookmark paths
- Add dev-logging feature flag for verbose debug output
- Add dev-install profile for testable release builds
- Remove CLAUDE.md from version control

BREAKING: Scripts directory moved from
~/.config/owlry/scripts/ to ~/.local/share/owlry/scripts/

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 16:46:14 +01:00
3f7a8950eb chore: bump version to 0.3.5 2025-12-28 19:33:25 +01:00
b38bf082e1 fix: update emoji test to check description field 2025-12-28 19:33:20 +01:00
617dbbce3e chore: bump version to 0.3.4 2025-12-28 19:28:17 +01:00
42 changed files with 1023 additions and 472 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
/target
CLAUDE.md

View File

@@ -1,32 +0,0 @@
# Owlry - Claude Code Instructions
## Release Workflow
Always use `just` for releases and AUR deployment:
```bash
# Bump version (updates Cargo.toml + Cargo.lock, commits)
just bump 0.x.y
# Push and create tag
git push && just tag
# Update AUR package
just aur-update
# Review changes, then publish
just aur-publish
```
Do NOT manually edit Cargo.toml for version bumps - use `just bump`.
## Available just recipes
- `just build` / `just release` - Build debug/release
- `just check` - Run cargo check + clippy
- `just test` - Run tests
- `just bump <version>` - Bump version
- `just tag` - Create and push git tag
- `just aur-update` - Update PKGBUILD checksums
- `just aur-publish` - Commit and push to AUR
- `just aur-test` - Test PKGBUILD locally

2
Cargo.lock generated
View File

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

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry"
version = "0.3.3"
version = "0.3.9"
edition = "2024"
rust-version = "1.90"
description = "A lightweight, owl-themed application launcher for Wayland"
@@ -55,6 +55,11 @@ serde_json = "1"
# Date/time for frecency calculations
chrono = { version = "0.4", features = ["serde"] }
[features]
default = []
# Enable verbose debug logging (for development/testing builds)
dev-logging = []
[profile.release]
lto = true
codegen-units = 1
@@ -65,3 +70,9 @@ opt-level = "z" # Optimize for size
[profile.dev]
opt-level = 0
debug = true
# For installing a testable build: cargo install --path . --profile dev-install --features dev-logging
[profile.dev-install]
inherits = "release"
strip = false
debug = 1 # Basic debug info for stack traces

347
README.md
View File

@@ -10,32 +10,30 @@ A lightweight, owl-themed application launcher for Wayland, built with GTK4 and
## Features
- **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`, `: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
- **Wayland native** - Uses Layer Shell for proper overlay behavior
- **Provider-based architecture** Search applications, commands, system actions, SSH hosts, clipboard history, bookmarks, emoji, and more
- **Fuzzy search with tags** Fast matching across names, descriptions, and category tags
- **Configurable tabs** — Customize header tabs and keyboard shortcuts
- **Filter prefixes** — Scope searches with `:app`, `:cmd`, `:tag:development`, etc.
- **Calculator** — Quick math with `= 5+3` or `calc sin(pi/2)`
- **Web search** — Search the web with `? query`
- **File search** Find files with `/ filename` (requires `fd` or `locate`)
- **Frecency ranking** — Frequently/recently used items rank higher
- **GTK4 theming** — System theme by default, with 9 built-in themes
- **Wayland native** — Uses Layer Shell for proper overlay behavior
## Installation
### Arch Linux (AUR)
```bash
# Using yay
yay -S owlry
# Using paru
# or
paru -S owlry
```
### Build from source
#### Dependencies
### Build from Source
**Dependencies:**
```bash
# Arch Linux
sudo pacman -S gtk4 gtk4-layer-shell
@@ -47,42 +45,31 @@ sudo apt install libgtk-4-dev libgtk4-layer-shell-dev
sudo dnf install gtk4-devel gtk4-layer-shell-devel
```
#### Optional dependencies
**Optional dependencies:**
```bash
# For clipboard history
# Clipboard history
sudo pacman -S cliphist wl-clipboard
# For file search
sudo pacman -S fd # or: mlocate
# File search (choose one)
sudo pacman -S fd # recommended
sudo pacman -S mlocate # alternative
```
#### Build
Requires Rust 1.90 or later.
**Build (requires Rust 1.90+):**
```bash
git clone https://somegit.dev/Owlibou/owlry.git
cd owlry
cargo build --release
# Binary: target/release/owlry
```
The binary will be at `target/release/owlry`.
## Usage
```bash
# Launch with default settings (GTK theme, all providers)
owlry
# Launch with only applications
owlry --mode app
# Launch with specific providers
owlry --providers app,cmd
# Show help
owlry --help
owlry # Launch with defaults
owlry --mode app # Applications only
owlry --providers app,cmd # Specific providers
owlry --help # Show all options
```
### Keyboard Shortcuts
@@ -91,135 +78,84 @@ owlry --help
|-----|--------|
| `Enter` | Launch selected item |
| `Escape` | Close launcher / exit submenu |
| `Up` / `Down` | Navigate results |
| `Tab` | Cycle filter modes |
| `Shift+Tab` | Cycle filter modes (reverse) |
| `Ctrl+1` | Toggle Applications filter |
| `Ctrl+2` | Toggle Commands filter |
| `Ctrl+3` | Toggle systemd filter |
| `` / `` | Navigate results |
| `Tab` | Cycle filter tabs |
| `Shift+Tab` | Cycle filter tabs (reverse) |
| `Ctrl+1..9` | Toggle tab by position |
### Search Prefixes
Filter results by provider using prefixes:
| 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` |
| `:clip` | Clipboard | `:clip password` |
| `:bm` | Bookmarks | `:bm github` |
| `:emoji` | Emoji | `:emoji heart` |
| `:script` | Scripts | `:script backup` |
| `:file` | Files | `:file config` |
| `:calc` | Calculator | `:calc sqrt(16)` |
| `:web` | Web search | `:web rust docs` |
| `:uuctl` | systemd services | `:uuctl docker` |
| `:uuctl` | systemd | `:uuctl docker` |
| `:tag:X` | Filter by tag | `:tag:development` |
### Trigger Prefixes
Some providers can be triggered directly without filter mode:
| Trigger | Provider | Example |
|---------|----------|---------|
| `=` | Calculator | `= 5+3` or `=5*2` |
| `=` | Calculator | `= 5+3` |
| `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
## File Locations
### Applications
Searches `.desktop` files from standard XDG directories.
Owlry follows the [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/latest/):
### 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)
| Path | Purpose |
|------|---------|
| `~/.config/owlry/config.toml` | Main configuration |
| `~/.config/owlry/themes/*.css` | Custom themes |
| `~/.config/owlry/style.css` | CSS overrides |
| `~/.local/share/owlry/scripts/` | User scripts |
| `~/.local/share/owlry/frecency.json` | Usage history |
## Configuration
Configuration file: `~/.config/owlry/config.toml`
Copy the example files:
```bash
# Config
mkdir -p ~/.config/owlry
cp /usr/share/doc/owlry/config.example.toml ~/.config/owlry/config.toml
# Optional: CSS overrides
cp /usr/share/doc/owlry/style.example.css ~/.config/owlry/style.css
# Optional: Example script
mkdir -p ~/.local/share/owlry/scripts
cp /usr/share/doc/owlry/scripts/example.sh ~/.local/share/owlry/scripts/
```
### Example Configuration
```toml
[general]
show_icons = true
max_results = 10
# terminal_command = "kitty" # Auto-detected if not set
# launch_wrapper = "uwsm app --" # Auto-detected for uwsm/hyprland
tabs = ["app", "cmd", "uuctl"] # Header tabs (Ctrl+1, Ctrl+2, etc.)
# terminal_command = "kitty" # Auto-detected
# launch_wrapper = "uwsm app --" # Auto-detected
[appearance]
width = 600
height = 400
width = 700
height = 500
font_size = 14
border_radius = 12
# theme = "owl" # Optional: "owl" or custom theme name
# theme = "owl" # Or: catppuccin-mocha, nord, dracula, etc.
[providers]
applications = true
@@ -227,7 +163,7 @@ commands = true
uuctl = true
calculator = true
websearch = true
search_engine = "duckduckgo" # google, bing, brave, ecosia, startpage, searxng
search_engine = "duckduckgo"
system = true
ssh = true
clipboard = true
@@ -236,72 +172,83 @@ emoji = true
scripts = true
files = true
frecency = true
frecency_weight = 0.3 # 0.0 = disabled, 1.0 = strong boost
frecency_weight = 0.3
```
### Default Values
### Tab Configuration
| Setting | Default |
|---------|---------|
| `show_icons` | `true` |
| `max_results` | `10` |
| `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` |
| `border_radius` | `12` |
| `theme` | None (GTK default) |
Customize which providers appear as header tabs:
### Launch Wrapper
```toml
[general]
# Available: app, cmd, uuctl, bookmark, calc, clip, dmenu,
# emoji, file, script, ssh, sys, web
tabs = ["app", "cmd", "ssh", "sys"]
```
When running in uwsm-managed or Hyprland sessions, owlry auto-detects and uses the appropriate launch wrapper:
Keyboard shortcuts `Ctrl+1` through `Ctrl+9` map to tab positions.
| Session | Wrapper | Purpose |
|---------|---------|---------|
| uwsm | `uwsm app --` | Proper systemd scope and session management |
| Hyprland | `hyprctl dispatch exec --` | Native Hyprland window management |
| Other | None (direct `sh -c`) | Standard shell execution |
## Providers
| Provider | Description | Trigger |
|----------|-------------|---------|
| **Applications** | `.desktop` files from XDG directories | `:app` |
| **Commands** | Executables in `$PATH` | `:cmd` |
| **System** | Shutdown, reboot, suspend, lock, BIOS | `:sys` |
| **SSH** | Hosts from `~/.ssh/config` | `:ssh` |
| **Clipboard** | History via cliphist | `:clip` |
| **Bookmarks** | Chrome, Brave, Edge, Vivaldi | `:bm` |
| **Emoji** | 300+ searchable emoji | `:emoji` |
| **Scripts** | User scripts | `:script` |
| **Calculator** | Math expressions | `=` or `:calc` |
| **Web Search** | Configurable engine | `?` or `:web` |
| **Files** | fd/locate search | `/` or `:file` |
| **systemd** | User services with actions | `:uuctl` |
### Tags
Items are tagged for better search:
- **Applications**: Categories from `.desktop` files (development, utility, etc.)
- **System**: `power`, `system`
- **SSH**: `ssh`
- **Scripts**: `script`
- **systemd**: `systemd`, `service`
Filter by tag with `:tag:tagname`:
```
:tag:development # Show development apps
:tag:utility vim # Search utilities for "vim"
```
### Scripts
Create executable scripts in `~/.local/share/owlry/scripts/`:
```bash
mkdir -p ~/.local/share/owlry/scripts
cat > ~/.local/share/owlry/scripts/backup.sh << 'EOF'
#!/bin/bash
rsync -av ~/Documents /backup/
notify-send "Backup complete"
EOF
chmod +x ~/.local/share/owlry/scripts/backup.sh
```
## Theming
### GTK Theme (Default)
By default, Owlry inherits colors from your system GTK4 theme (Adwaita, Breeze, etc.).
### Built-in Themes
Owlry includes an owl-inspired dark theme:
```toml
[appearance]
theme = "owl"
```
### Included Example Themes
Example themes are installed to `/usr/share/owlry/themes/`:
| Theme | Description |
|-------|-------------|
| `owl` | Owl-inspired dark theme with amber accents |
| `catppuccin-mocha` | Soothing pastel theme |
| `nord` | Arctic, north-bluish palette |
| `rose-pine` | All natural pine, faux fur and soho vibes |
| `dracula` | Dark theme for vampires |
| `gruvbox-dark` | Retro groove color scheme |
| `tokyo-night` | Lights of Tokyo at night |
| `solarized-dark` | Precision colors for machines and people |
| `one-dark` | Atom's iconic One Dark theme |
To use an example theme:
```bash
mkdir -p ~/.config/owlry/themes
cp /usr/share/owlry/themes/catppuccin-mocha.css ~/.config/owlry/themes/
```
Then set in config:
| `owl` | Dark theme with amber accents |
| `catppuccin-mocha` | Soothing pastel |
| `nord` | Arctic blue palette |
| `rose-pine` | Natural pine vibes |
| `dracula` | Dark vampire theme |
| `gruvbox-dark` | Retro groove |
| `tokyo-night` | Tokyo city lights |
| `solarized-dark` | Precision colors |
| `one-dark` | Atom's One Dark |
```toml
[appearance]
@@ -310,7 +257,7 @@ theme = "catppuccin-mocha"
### Custom Theme
Create a custom theme file at `~/.config/owlry/themes/mytheme.css`:
Create `~/.config/owlry/themes/mytheme.css`:
```css
:root {
@@ -324,7 +271,24 @@ Create a custom theme file at `~/.config/owlry/themes/mytheme.css`:
}
```
### CSS Variables Reference
### CSS Overrides
For tweaks without a full theme, create `~/.config/owlry/style.css`:
```css
/* Larger search input */
.owlry-search {
font-size: 18px;
padding: 12px 16px;
}
/* Hide tag badges */
.owlry-tag-badge {
display: none;
}
```
### CSS Variables
| Variable | Description |
|----------|-------------|
@@ -333,22 +297,17 @@ Create a custom theme file at `~/.config/owlry/themes/mytheme.css`:
| `--owlry-border` | Border color |
| `--owlry-text` | Primary text |
| `--owlry-text-secondary` | Muted text |
| `--owlry-accent` | Accent/highlight color |
| `--owlry-accent` | Accent color |
| `--owlry-accent-bright` | Bright accent |
| `--owlry-font-size` | Base font size |
| `--owlry-border-radius` | Border radius |
| `--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.
| `--owlry-border-radius` | Corner radius |
## License
This project is licensed under the GNU General Public License v3.0 or later - see [LICENSE](LICENSE) for details.
GNU General Public License v3.0 see [LICENSE](LICENSE).
## Acknowledgments
- [GTK4](https://gtk.org/) - UI toolkit
- [gtk4-layer-shell](https://github.com/wmww/gtk4-layer-shell) - Wayland Layer Shell bindings
- [fuzzy-matcher](https://crates.io/crates/fuzzy-matcher) - Fuzzy search algorithm
- [GTK4](https://gtk.org/) UI toolkit
- [gtk4-layer-shell](https://github.com/wmww/gtk4-layer-shell) Wayland Layer Shell
- [fuzzy-matcher](https://crates.io/crates/fuzzy-matcher) Fuzzy search

View File

@@ -1,77 +0,0 @@
# Owlry Configuration
# Copy to ~/.config/owlry/config.toml
[general]
show_icons = true
max_results = 10
terminal_command = "kitty" # Auto-detected if not set
# Launch wrapper for app execution (auto-detected if not set)
# Examples:
# "uwsm app --" # For uwsm sessions
# "hyprctl dispatch exec --" # For Hyprland
# "" # Direct execution
# launch_wrapper = "uwsm app --"
[appearance]
width = 600
height = 400
font_size = 14
border_radius = 12
# Theme: "owl" for built-in dark theme, or leave unset for GTK default
# theme = "owl"
# Individual color overrides (CSS color values)
# [appearance.colors]
# background = "#1a1b26"
# background_secondary = "#24283b"
# border = "#414868"
# text = "#c0caf5"
# text_secondary = "#565f89"
# accent = "#7aa2f7"
# accent_bright = "#89b4fa"
# badge_app = "#9ece6a"
# badge_calc = "#e0af68"
# badge_cmd = "#7aa2f7"
# badge_dmenu = "#bb9af7"
# badge_uuctl = "#f7768e"
[providers]
applications = true
commands = true
uuctl = true
# Calculator provider (type "= 5+3" or "calc 5+3")
calculator = true
# Frecency: boost frequently/recently used items in search results
frecency = true
frecency_weight = 0.3 # 0.0 = disabled, 1.0 = strong boost
# Web search provider (type "? query" or "web query")
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

115
data/config.example.toml Normal file
View File

@@ -0,0 +1,115 @@
# Owlry Configuration
# Copy to: ~/.config/owlry/config.toml
#
# File Locations (XDG Base Directory compliant):
# ┌─────────────────────────────────────────────────────────────────────┐
# │ Config: ~/.config/owlry/config.toml Main configuration │
# │ Themes: ~/.config/owlry/themes/*.css Custom theme files │
# │ Style: ~/.config/owlry/style.css CSS overrides │
# │ Scripts: ~/.local/share/owlry/scripts/ Executable scripts │
# │ Data: ~/.local/share/owlry/frecency.json Usage history │
# └─────────────────────────────────────────────────────────────────────┘
# ═══════════════════════════════════════════════════════════════════════
# GENERAL
# ═══════════════════════════════════════════════════════════════════════
[general]
show_icons = true
max_results = 10
# Terminal emulator (auto-detected if not set)
terminal_command = "kitty"
# Launch wrapper for app execution (auto-detected for uwsm/Hyprland)
# Examples: "uwsm app --", "hyprctl dispatch exec --", ""
# launch_wrapper = "uwsm app --"
# Header tabs - providers shown as toggle buttons (Ctrl+1, Ctrl+2, etc.)
# Values: app, cmd, uuctl, bookmark, calc, clip, dmenu, emoji, file, script, ssh, sys, web
tabs = ["app", "cmd", "uuctl"]
# ═══════════════════════════════════════════════════════════════════════
# APPEARANCE
# ═══════════════════════════════════════════════════════════════════════
[appearance]
width = 700
height = 500
font_size = 14
border_radius = 12
# Theme name - loads ~/.config/owlry/themes/{name}.css
# Built-in: owl, catppuccin-mocha, dracula, gruvbox-dark, nord,
# one-dark, rose-pine, solarized-dark, tokyo-night
# Or leave unset for GTK default
# theme = "owl"
# Color overrides (applied on top of theme)
# [appearance.colors]
# background = "#1a1b26"
# background_secondary = "#24283b"
# border = "#414868"
# text = "#c0caf5"
# text_secondary = "#565f89"
# accent = "#7aa2f7"
# accent_bright = "#89b4fa"
# badge_app = "#9ece6a"
# badge_calc = "#e0af68"
# badge_cmd = "#7aa2f7"
# badge_dmenu = "#bb9af7"
# badge_uuctl = "#f7768e"
# ═══════════════════════════════════════════════════════════════════════
# PROVIDERS
# ═══════════════════════════════════════════════════════════════════════
[providers]
# Core providers (appear in main search)
applications = true # .desktop applications
commands = true # Executables from $PATH
uuctl = true # systemd --user units
# Frecency - boost frequently/recently used items
# Data: ~/.local/share/owlry/frecency.json
frecency = true
frecency_weight = 0.3 # 0.0 = disabled, 1.0 = strong boost
# ───────────────────────────────────────────────────────────────────────
# Trigger Providers (activated by prefix)
# ───────────────────────────────────────────────────────────────────────
# Calculator: "= 5+3" or "calc 5+3" or ":calc"
calculator = true
# Web search: "? query" or "web query" or ":web"
websearch = true
search_engine = "duckduckgo"
# Options: google, duckduckgo, bing, startpage, searxng, brave, ecosia
# Custom: "https://search.example.com/?q={query}"
# File search: "/ pattern" or "find pattern" or ":file"
# Requires: fd or locate
files = true
# ───────────────────────────────────────────────────────────────────────
# Prefix Providers (use :prefix to search)
# ───────────────────────────────────────────────────────────────────────
# System: :sys or :power - shutdown, reboot, lock, suspend, hibernate, logout
system = true
# SSH: :ssh - connections from ~/.ssh/config
ssh = true
# Clipboard: :clip - history (requires cliphist)
clipboard = true
# Bookmarks: :bm - browser bookmarks (Chrome, Chromium, Brave, Edge, Vivaldi)
bookmarks = true
# Emoji: :emoji - picker (copies to clipboard)
emoji = true
# Scripts: :script - executables from ~/.local/share/owlry/scripts/
scripts = true

24
data/scripts/example.sh Normal file
View File

@@ -0,0 +1,24 @@
#!/bin/bash
# Example Owlry Script
# Copy to: ~/.local/share/owlry/scripts/
#
# Scripts in the scripts directory appear in Owlry search results.
# They are executed when selected.
#
# Naming convention:
# The filename (without extension) becomes the display name.
# Example: "system-update.sh" shows as "Script: system-update"
#
# Tips:
# - Make scripts executable: chmod +x script.sh
# - Use descriptive names for easy searching
# - Scripts can launch GUI apps, run terminal commands, etc.
# Example: Show a notification
notify-send "Owlry" "Hello from example script!"
# Example: Open a URL
# xdg-open "https://example.com"
# Example: Run a terminal command (set terminal: true in owlry if needed)
# echo "Script executed at $(date)"

73
data/style.example.css Normal file
View File

@@ -0,0 +1,73 @@
/*
* Owlry Custom Style Overrides
* Copy to: ~/.config/owlry/style.css
*
* This file is loaded AFTER themes, allowing you to override
* specific styles without creating a full theme.
*
* Available CSS classes:
* .owlry-window - Main window container
* .owlry-main - Main content area
* .owlry-header - Header with mode and tabs
* .owlry-search - Search input field
* .owlry-results - Results list container
* .owlry-result-row - Individual result row
* .owlry-result-name - Result item name
* .owlry-result-description - Result description text
* .owlry-result-icon - Result icon
* .owlry-tag-badge - Tag badges on results
* .owlry-badge-* - Provider badges (app, cmd, uuctl, etc.)
* .owlry-filter-button - Tab filter buttons
* .owlry-filter-* - Provider-specific filter buttons
* .owlry-mode-indicator - Current mode label
* .owlry-hints - Bottom hints bar
*
* CSS Variables (set in themes or override here):
* --owlry-bg - Main background color
* --owlry-bg-secondary - Secondary background
* --owlry-border - Border color
* --owlry-text - Primary text color
* --owlry-text-secondary - Secondary text color
* --owlry-accent - Accent/highlight color
* --owlry-accent-bright - Bright accent color
* --owlry-font-size - Base font size (default: 14px)
* --owlry-border-radius - Border radius (default: 12px)
*/
/* Example: Make the window slightly larger */
/*
.owlry-main {
padding: 20px;
}
*/
/* Example: Custom search field styling */
/*
.owlry-search {
font-size: 18px;
padding: 12px 16px;
}
*/
/* Example: Highlight selected row differently */
/*
.owlry-result-row:selected {
background-color: rgba(255, 255, 255, 0.1);
border-left: 4px solid var(--owlry-accent);
}
*/
/* Example: Hide tag badges */
/*
.owlry-tag-badge {
display: none;
}
*/
/* Example: Custom scrollbar */
/*
scrollbar slider {
background-color: rgba(128, 128, 128, 0.3);
border-radius: 4px;
}
*/

View File

@@ -2,6 +2,7 @@ use crate::cli::CliArgs;
use crate::config::Config;
use crate::data::FrecencyStore;
use crate::filter::ProviderFilter;
use crate::paths;
use crate::providers::ProviderManager;
use crate::theme;
use crate::ui::MainWindow;
@@ -81,7 +82,7 @@ impl OwlryApp {
// 1. Load base structural CSS (always applied)
let base_provider = CssProvider::new();
base_provider.load_from_string(include_str!("../resources/base.css"));
base_provider.load_from_string(include_str!("resources/base.css"));
gtk4::style_context_add_provider_for_display(
&display,
&base_provider,
@@ -94,14 +95,12 @@ impl OwlryApp {
let theme_provider = CssProvider::new();
match theme_name.as_str() {
"owl" => {
theme_provider.load_from_string(include_str!("../resources/owl-theme.css"));
theme_provider.load_from_string(include_str!("resources/owl-theme.css"));
debug!("Loaded built-in owl theme");
}
_ => {
// Check for custom theme in ~/.config/owlry/themes/{name}.css
if let Some(theme_path) = dirs::config_dir()
.map(|p| p.join("owlry").join("themes").join(format!("{}.css", theme_name)))
{
// Check for custom theme in $XDG_CONFIG_HOME/owlry/themes/{name}.css
if let Some(theme_path) = paths::theme_file(theme_name) {
if theme_path.exists() {
theme_provider.load_from_path(&theme_path);
debug!("Loaded custom theme from {:?}", theme_path);
@@ -119,7 +118,7 @@ impl OwlryApp {
}
// 3. Load user's custom stylesheet if exists
if let Some(custom_path) = dirs::config_dir().map(|p| p.join("owlry").join("style.css")) {
if let Some(custom_path) = paths::custom_style_file() {
if custom_path.exists() {
let custom_provider = CssProvider::new();
custom_provider.load_from_path(&custom_path);

View File

@@ -1,7 +1,9 @@
use log::{debug, info, warn};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::process::Command;
use log::{info, warn, debug};
use crate::paths;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
@@ -20,6 +22,18 @@ pub struct GeneralConfig {
/// If None or empty, launches directly via sh -c
#[serde(default)]
pub launch_wrapper: Option<String>,
/// Provider tabs shown in the header bar.
/// Valid values: app, cmd, uuctl, bookmark, calc, clip, dmenu, emoji, file, script, ssh, sys, web
#[serde(default = "default_tabs")]
pub tabs: Vec<String>,
}
fn default_tabs() -> Vec<String> {
vec![
"app".to_string(),
"cmd".to_string(),
"uuctl".to_string(),
]
}
/// User-customizable theme colors
@@ -218,10 +232,11 @@ impl Default for Config {
max_results: 10,
terminal_command: terminal,
launch_wrapper: detect_launch_wrapper(),
tabs: default_tabs(),
},
appearance: AppearanceConfig {
width: 600,
height: 400,
width: 700,
height: 500,
font_size: 14,
border_radius: 12,
theme: None,
@@ -250,7 +265,7 @@ impl Default for Config {
impl Config {
pub fn config_path() -> Option<PathBuf> {
dirs::config_dir().map(|p| p.join("owlry").join("config.toml"))
paths::config_file()
}
pub fn load_or_default() -> Self {
@@ -289,9 +304,7 @@ impl 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)?;
}
paths::ensure_parent_dir(&path)?;
let content = toml::to_string_pretty(self)?;
std::fs::write(&path, content)?;

View File

@@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use crate::paths;
/// A single frecency entry tracking launch count and recency
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FrecencyEntry {
@@ -56,10 +58,7 @@ impl FrecencyStore {
/// Get the path to the frecency data file
fn data_path() -> PathBuf {
dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("owlry")
.join("frecency.json")
paths::frecency_file().unwrap_or_else(|| PathBuf::from("frecency.json"))
}
/// Load frecency data from a file
@@ -85,10 +84,7 @@ impl FrecencyStore {
return Ok(());
}
// Ensure directory exists
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
paths::ensure_parent_dir(&self.path)?;
let content = serde_json::to_string_pretty(&self.data)?;
std::fs::write(&self.path, content)?;

View File

@@ -1,5 +1,8 @@
use std::collections::HashSet;
#[cfg(feature = "dev-logging")]
use log::debug;
use crate::config::ProvidersConfig;
use crate::providers::ProviderType;
@@ -14,6 +17,7 @@ pub struct ProviderFilter {
#[derive(Debug, Clone)]
pub struct ParsedQuery {
pub prefix: Option<ProviderType>,
pub tag_filter: Option<String>,
pub query: String,
}
@@ -69,10 +73,15 @@ impl ProviderFilter {
set
};
Self {
let filter = Self {
enabled,
active_prefix: None,
}
};
#[cfg(feature = "dev-logging")]
debug!("[Filter] Created with enabled providers: {:?}", filter.enabled);
filter
}
/// Default filter: apps only
@@ -92,8 +101,12 @@ impl ProviderFilter {
if self.enabled.is_empty() {
self.enabled.insert(ProviderType::Application);
}
#[cfg(feature = "dev-logging")]
debug!("[Filter] Toggled OFF {:?}, enabled: {:?}", provider, self.enabled);
} else {
self.enabled.insert(provider);
#[cfg(feature = "dev-logging")]
debug!("[Filter] Toggled ON {:?}, enabled: {:?}", provider, self.enabled);
}
}
@@ -118,6 +131,10 @@ impl ProviderFilter {
/// Set prefix mode (from :app, :cmd, etc.)
pub fn set_prefix(&mut self, prefix: Option<ProviderType>) {
#[cfg(feature = "dev-logging")]
if self.active_prefix != prefix {
debug!("[Filter] Prefix changed: {:?} -> {:?}", self.active_prefix, prefix);
}
self.active_prefix = prefix;
}
@@ -145,6 +162,30 @@ impl ProviderFilter {
pub fn parse_query(query: &str) -> ParsedQuery {
let trimmed = query.trim_start();
// Check for tag filter pattern: ":tag:XXX query" or ":tag:XXX"
if let Some(rest) = trimmed.strip_prefix(":tag:") {
// Find the end of the tag (space or end of string)
if let Some(space_idx) = rest.find(' ') {
let tag = rest[..space_idx].to_lowercase();
let query_part = rest[space_idx + 1..].to_string();
#[cfg(feature = "dev-logging")]
debug!("[Filter] parse_query({:?}) -> tag={:?}, query={:?}", query, tag, query_part);
return ParsedQuery {
prefix: None,
tag_filter: Some(tag),
query: query_part,
};
} else {
// Just the tag, no query yet
let tag = rest.to_lowercase();
return ParsedQuery {
prefix: None,
tag_filter: Some(tag),
query: String::new(),
};
}
}
// Check for prefix patterns (with trailing space)
let prefixes = [
(":app ", ProviderType::Application),
@@ -176,8 +217,11 @@ impl ProviderFilter {
for (prefix_str, provider) in prefixes {
if let Some(rest) = trimmed.strip_prefix(prefix_str) {
#[cfg(feature = "dev-logging")]
debug!("[Filter] parse_query({:?}) -> prefix={:?}, query={:?}", query, provider, rest);
return ParsedQuery {
prefix: Some(provider),
tag_filter: None,
query: rest.to_string(),
};
}
@@ -214,17 +258,26 @@ impl ProviderFilter {
for (prefix_str, provider) in partial_prefixes {
if trimmed == prefix_str {
#[cfg(feature = "dev-logging")]
debug!("[Filter] parse_query({:?}) -> partial prefix {:?}", query, provider);
return ParsedQuery {
prefix: Some(provider),
tag_filter: None,
query: String::new(),
};
}
}
ParsedQuery {
let result = ParsedQuery {
prefix: None,
tag_filter: None,
query: query.to_string(),
}
};
#[cfg(feature = "dev-logging")]
debug!("[Filter] parse_query({:?}) -> prefix={:?}, tag={:?}, query={:?}", query, result.prefix, result.tag_filter, result.query);
result
}
/// Get enabled providers for UI display (sorted)

View File

@@ -3,6 +3,7 @@ mod cli;
mod config;
mod data;
mod filter;
mod paths;
mod providers;
mod theme;
mod ui;
@@ -11,11 +12,26 @@ use app::OwlryApp;
use cli::CliArgs;
use log::{info, warn};
#[cfg(feature = "dev-logging")]
use log::debug;
fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let default_level = if cfg!(feature = "dev-logging") { "debug" } else { "info" };
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(default_level))
.format_timestamp_millis()
.init();
let args = CliArgs::parse_args();
#[cfg(feature = "dev-logging")]
{
debug!("┌─────────────────────────────────────────┐");
debug!("│ DEV-LOGGING: Verbose output enabled │");
debug!("└─────────────────────────────────────────┘");
debug!("CLI args: {:?}", args);
}
info!("Starting Owlry launcher");
// Diagnostic: log critical environment variables

214
src/paths.rs Normal file
View File

@@ -0,0 +1,214 @@
//! Centralized path handling following XDG Base Directory Specification.
//!
//! XDG directories used:
//! - `$XDG_CONFIG_HOME/owlry/` - User configuration (config.toml, themes/, style.css)
//! - `$XDG_DATA_HOME/owlry/` - User data (scripts/, frecency.json)
//! - `$XDG_CACHE_HOME/owlry/` - Cache files (future use)
//!
//! See: https://specifications.freedesktop.org/basedir-spec/latest/
use std::path::PathBuf;
/// Application name used in XDG paths
const APP_NAME: &str = "owlry";
// =============================================================================
// XDG Base Directories
// =============================================================================
/// Get XDG config home: `$XDG_CONFIG_HOME` or `~/.config`
pub fn config_home() -> Option<PathBuf> {
dirs::config_dir()
}
/// Get XDG data home: `$XDG_DATA_HOME` or `~/.local/share`
pub fn data_home() -> Option<PathBuf> {
dirs::data_dir()
}
/// Get XDG cache home: `$XDG_CACHE_HOME` or `~/.cache`
#[allow(dead_code)]
pub fn cache_home() -> Option<PathBuf> {
dirs::cache_dir()
}
/// Get user home directory
pub fn home() -> Option<PathBuf> {
dirs::home_dir()
}
// =============================================================================
// Owlry-specific directories
// =============================================================================
/// Owlry config directory: `$XDG_CONFIG_HOME/owlry/`
pub fn owlry_config_dir() -> Option<PathBuf> {
config_home().map(|p| p.join(APP_NAME))
}
/// Owlry data directory: `$XDG_DATA_HOME/owlry/`
pub fn owlry_data_dir() -> Option<PathBuf> {
data_home().map(|p| p.join(APP_NAME))
}
/// Owlry cache directory: `$XDG_CACHE_HOME/owlry/`
#[allow(dead_code)]
pub fn owlry_cache_dir() -> Option<PathBuf> {
cache_home().map(|p| p.join(APP_NAME))
}
// =============================================================================
// Config files
// =============================================================================
/// Main config file: `$XDG_CONFIG_HOME/owlry/config.toml`
pub fn config_file() -> Option<PathBuf> {
owlry_config_dir().map(|p| p.join("config.toml"))
}
/// Custom user stylesheet: `$XDG_CONFIG_HOME/owlry/style.css`
pub fn custom_style_file() -> Option<PathBuf> {
owlry_config_dir().map(|p| p.join("style.css"))
}
/// User themes directory: `$XDG_CONFIG_HOME/owlry/themes/`
pub fn themes_dir() -> Option<PathBuf> {
owlry_config_dir().map(|p| p.join("themes"))
}
/// Get path for a specific theme: `$XDG_CONFIG_HOME/owlry/themes/{name}.css`
pub fn theme_file(name: &str) -> Option<PathBuf> {
themes_dir().map(|p| p.join(format!("{}.css", name)))
}
// =============================================================================
// Data files
// =============================================================================
/// User scripts directory: `$XDG_DATA_HOME/owlry/scripts/`
pub fn scripts_dir() -> Option<PathBuf> {
owlry_data_dir().map(|p| p.join("scripts"))
}
/// Frecency data file: `$XDG_DATA_HOME/owlry/frecency.json`
pub fn frecency_file() -> Option<PathBuf> {
owlry_data_dir().map(|p| p.join("frecency.json"))
}
// =============================================================================
// System directories
// =============================================================================
/// System data directories for applications (XDG_DATA_DIRS)
pub fn system_data_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
// User data directory first
if let Some(data) = data_home() {
dirs.push(data.join("applications"));
}
// System directories
dirs.push(PathBuf::from("/usr/share/applications"));
dirs.push(PathBuf::from("/usr/local/share/applications"));
// Flatpak directories
if let Some(data) = data_home() {
dirs.push(data.join("flatpak/exports/share/applications"));
}
dirs.push(PathBuf::from("/var/lib/flatpak/exports/share/applications"));
dirs
}
// =============================================================================
// External application paths
// =============================================================================
/// SSH config file: `~/.ssh/config`
pub fn ssh_config() -> Option<PathBuf> {
home().map(|p| p.join(".ssh").join("config"))
}
/// Firefox profile directory: `~/.mozilla/firefox/`
pub fn firefox_dir() -> Option<PathBuf> {
home().map(|p| p.join(".mozilla").join("firefox"))
}
/// Chromium-based browser bookmark paths (using XDG config where browsers support it)
pub fn chromium_bookmark_paths() -> Vec<PathBuf> {
let config = match config_home() {
Some(c) => c,
None => return Vec::new(),
};
vec![
// Google Chrome
config.join("google-chrome/Default/Bookmarks"),
// Chromium
config.join("chromium/Default/Bookmarks"),
// Brave
config.join("BraveSoftware/Brave-Browser/Default/Bookmarks"),
// Microsoft Edge
config.join("microsoft-edge/Default/Bookmarks"),
// Vivaldi
config.join("vivaldi/Default/Bookmarks"),
]
}
// =============================================================================
// Helper functions
// =============================================================================
/// Ensure a directory exists, creating it if necessary
pub fn ensure_dir(path: &PathBuf) -> std::io::Result<()> {
if !path.exists() {
std::fs::create_dir_all(path)?;
}
Ok(())
}
/// Ensure parent directory of a file exists
pub fn ensure_parent_dir(path: &PathBuf) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
if !parent.exists() {
std::fs::create_dir_all(parent)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_paths_are_consistent() {
// All owlry paths should be under XDG directories
if let (Some(config), Some(data)) = (owlry_config_dir(), owlry_data_dir()) {
assert!(config.ends_with("owlry"));
assert!(data.ends_with("owlry"));
}
}
#[test]
fn test_config_file_path() {
if let Some(path) = config_file() {
assert!(path.ends_with("config.toml"));
assert!(path.to_string_lossy().contains("owlry"));
}
}
#[test]
fn test_frecency_in_data_dir() {
if let Some(path) = frecency_file() {
assert!(path.ends_with("frecency.json"));
// Should be in data dir, not config dir
let path_str = path.to_string_lossy();
assert!(
path_str.contains(".local/share") || path_str.contains("XDG_DATA_HOME"),
"frecency should be in data directory"
);
}
}
}

View File

@@ -1,7 +1,7 @@
use super::{LaunchItem, Provider, ProviderType};
use crate::paths;
use freedesktop_desktop_entry::{DesktopEntry, Iter};
use log::{debug, warn};
use std::path::PathBuf;
/// Clean desktop file field codes from command string.
/// Removes %f, %F, %u, %U, %d, %D, %n, %N, %i, %c, %k, %v, %m field codes
@@ -75,25 +75,8 @@ impl ApplicationProvider {
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
fn get_application_dirs() -> Vec<std::path::PathBuf> {
paths::system_data_dirs()
}
}
@@ -152,6 +135,12 @@ impl Provider for ApplicationProvider {
None => continue,
};
// Extract categories as tags (lowercase for consistency)
let tags: Vec<String> = desktop_entry
.categories()
.map(|cats| cats.into_iter().map(|s| s.to_lowercase()).collect())
.unwrap_or_default();
let item = LaunchItem {
id: path.to_string_lossy().to_string(),
name,
@@ -160,6 +149,7 @@ impl Provider for ApplicationProvider {
provider: ProviderType::Application,
command: run_cmd,
terminal: desktop_entry.terminal(),
tags,
};
self.items.push(item);

View File

@@ -1,3 +1,4 @@
use crate::paths;
use crate::providers::{LaunchItem, Provider, ProviderType};
use log::{debug, warn};
use serde::Deserialize;
@@ -27,8 +28,8 @@ impl BookmarksProvider {
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"),
let firefox_dir = match paths::firefox_dir() {
Some(d) => d,
None => return,
};
@@ -99,29 +100,10 @@ impl BookmarksProvider {
}
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 {
// Chrome/Chromium bookmarks are in JSON format (XDG config paths)
for path in paths::chromium_bookmark_paths() {
if path.exists() {
self.read_chrome_bookmarks(path);
self.read_chrome_bookmarks(&path);
}
}
}
@@ -175,6 +157,7 @@ impl BookmarksProvider {
provider: ProviderType::Bookmarks,
command: format!("xdg-open '{}'", url.replace('\'', "'\\''")),
terminal: false,
tags: Vec::new(),
});
}
}

View File

@@ -73,6 +73,7 @@ impl CalculatorProvider {
provider: ProviderType::Calculator,
command: format!("echo -n '{}' | wl-copy", result_str),
terminal: false,
tags: vec!["math".to_string()],
})
}
Err(_) => None,
@@ -111,6 +112,7 @@ impl CalculatorProvider {
// Copy result to clipboard using wl-copy
command: format!("sh -c 'echo -n \"{}\" | wl-copy'", result_str),
terminal: false,
tags: vec!["math".to_string()],
};
debug!("Calculator result: {} = {}", expr, result_str);

View File

@@ -99,6 +99,7 @@ impl ClipboardProvider {
provider: ProviderType::Clipboard,
command,
terminal: false,
tags: Vec::new(),
});
}

View File

@@ -87,6 +87,7 @@ impl Provider for CommandProvider {
provider: ProviderType::Command,
command: name,
terminal: false,
tags: Vec::new(),
};
self.items.push(item);

View File

@@ -101,6 +101,7 @@ impl Provider for DmenuProvider {
provider: ProviderType::Dmenu,
command: line.to_string(),
terminal: false,
tags: Vec::new(),
};
self.items.push(item);

View File

@@ -406,6 +406,7 @@ impl EmojiProvider {
// Copy emoji to clipboard using wl-copy
command: format!("printf '%s' '{}' | wl-copy", emoji),
terminal: false,
tags: Vec::new(), // TODO: Extract category from emoji data
});
// Store the search text for matching (not used directly but could be)
@@ -441,6 +442,10 @@ mod tests {
let mut provider = EmojiProvider::new();
provider.refresh();
assert!(provider.items().len() > 100);
assert!(provider.items().iter().any(|i| i.name.contains("😀")));
// Emoji character is in description, name is the human-readable name
assert!(provider
.items()
.iter()
.any(|i| i.description.as_ref().is_some_and(|d| d.contains("😀"))));
}
}

View File

@@ -1,3 +1,4 @@
use crate::paths;
use crate::providers::{LaunchItem, ProviderType};
use log::{debug, warn};
use std::process::Command;
@@ -106,7 +107,7 @@ impl FileSearchProvider {
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 home = paths::home().unwrap_or_default();
let output = match Command::new("fd")
.args([
@@ -132,7 +133,7 @@ impl FileSearchProvider {
}
fn search_with_locate(&self, pattern: &str) -> Vec<LaunchItem> {
let home = dirs::home_dir().unwrap_or_default();
let home = paths::home().unwrap_or_default();
let output = match Command::new("locate")
.args([
@@ -190,6 +191,7 @@ impl FileSearchProvider {
provider: ProviderType::Files,
command,
terminal: false,
tags: Vec::new(),
}
})
.collect()

View File

@@ -30,6 +30,9 @@ use fuzzy_matcher::FuzzyMatcher;
use fuzzy_matcher::skim::SkimMatcherV2;
use log::info;
#[cfg(feature = "dev-logging")]
use log::debug;
use crate::data::FrecencyStore;
/// Represents a single searchable/launchable item
@@ -43,6 +46,8 @@ pub struct LaunchItem {
pub provider: ProviderType,
pub command: String,
pub terminal: bool,
/// Tags/categories for filtering (e.g., from .desktop Categories)
pub tags: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -279,7 +284,7 @@ impl ProviderManager {
results
}
/// Search with frecency boosting and calculator support
/// Search with frecency boosting, calculator support, and tag filtering
pub fn search_with_frecency(
&mut self,
query: &str,
@@ -287,13 +292,18 @@ impl ProviderManager {
filter: &crate::filter::ProviderFilter,
frecency: &FrecencyStore,
frecency_weight: f64,
tag_filter: Option<&str>,
) -> Vec<(LaunchItem, i64)> {
#[cfg(feature = "dev-logging")]
debug!("[Search] query={:?}, max={}, frecency_weight={}", query, max_results, frecency_weight);
let mut results: Vec<(LaunchItem, i64)> = Vec::new();
// Check for calculator query (= or calc prefix)
if CalculatorProvider::is_calculator_query(query) {
if let Some(calc_result) = self.calculator.evaluate(query) {
// Calculator results get a high score to appear first
#[cfg(feature = "dev-logging")]
debug!("[Search] Calculator result: {}", calc_result.name);
results.push((calc_result, 10000));
}
}
@@ -323,6 +333,8 @@ impl ProviderManager {
// Check for file search query
if FileSearchProvider::is_file_query(query) {
let file_results = self.filesearch.evaluate(query);
#[cfg(feature = "dev-logging")]
debug!("[Search] File search returned {} results", file_results.len());
for (idx, item) in file_results.into_iter().enumerate() {
// Score decreases for each result to maintain order
results.push((item, 8000 - idx as i64));
@@ -343,6 +355,14 @@ impl ProviderManager {
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter().cloned())
.filter(|item| {
// Apply tag filter if present
if let Some(tag) = tag_filter {
item.tags.iter().any(|t| t.to_lowercase().contains(tag))
} else {
true
}
})
.map(|item| {
let frecency_score = frecency.get_score(&item.id);
let boosted = (frecency_score * frecency_weight * 100.0) as i64;
@@ -355,24 +375,43 @@ impl ProviderManager {
return items;
}
// Regular search with frecency boost
// Regular search with frecency boost and tag matching
let search_results: Vec<(LaunchItem, i64)> = self
.providers
.iter()
.filter(|provider| filter.is_active(provider.provider_type()))
.flat_map(|provider| {
provider.items().iter().filter_map(|item| {
// Apply tag filter if present
if let Some(tag) = tag_filter {
if !item.tags.iter().any(|t| t.to_lowercase().contains(tag)) {
return None;
}
}
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 base_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,
// Also match against tags (lower weight)
let tag_score = item
.tags
.iter()
.filter_map(|t| self.matcher.fuzzy_match(t, query))
.max()
.map(|s| s / 3); // Lower weight for tag matches
let base_score = match (name_score, desc_score, tag_score) {
(Some(n), Some(d), Some(t)) => Some(n.max(d).max(t)),
(Some(n), Some(d), None) => Some(n.max(d)),
(Some(n), None, Some(t)) => Some(n.max(t)),
(Some(n), None, None) => Some(n),
(None, Some(d), Some(t)) => Some((d / 2).max(t)),
(None, Some(d), None) => Some(d / 2),
(None, None, Some(t)) => Some(t),
(None, None, None) => None,
};
base_score.map(|s| {
@@ -387,6 +426,18 @@ impl ProviderManager {
results.extend(search_results);
results.sort_by(|a, b| b.1.cmp(&a.1));
results.truncate(max_results);
#[cfg(feature = "dev-logging")]
{
debug!("[Search] Returning {} results", results.len());
for (i, (item, score)) in results.iter().take(5).enumerate() {
debug!("[Search] #{}: {} (score={}, provider={:?})", i + 1, item.name, score, item.provider);
}
if results.len() > 5 {
debug!("[Search] ... and {} more", results.len() - 5);
}
}
results
}

View File

@@ -1,10 +1,11 @@
use crate::paths;
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/
/// Custom scripts provider - runs user scripts from `$XDG_DATA_HOME/owlry/scripts/`
pub struct ScriptsProvider {
items: Vec<LaunchItem>,
}
@@ -14,14 +15,10 @@ impl ScriptsProvider {
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() {
let scripts_dir = match paths::scripts_dir() {
Some(p) => p,
None => {
debug!("Could not determine scripts directory");
@@ -32,7 +29,7 @@ impl ScriptsProvider {
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) {
if let Err(e) = paths::ensure_dir(&scripts_dir) {
warn!("Failed to create scripts directory: {}", e);
}
return;
@@ -90,6 +87,7 @@ impl ScriptsProvider {
provider: ProviderType::Scripts,
command: path.to_string_lossy().to_string(),
terminal: false,
tags: vec!["script".to_string()],
});
}

View File

@@ -1,7 +1,7 @@
use crate::paths;
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 {
@@ -27,8 +27,8 @@ impl SshProvider {
self.terminal_command = terminal.to_string();
}
fn ssh_config_path() -> Option<PathBuf> {
dirs::home_dir().map(|p| p.join(".ssh").join("config"))
fn ssh_config_path() -> Option<std::path::PathBuf> {
paths::ssh_config()
}
fn parse_ssh_config(&mut self) {
@@ -161,6 +161,7 @@ impl SshProvider {
provider: ProviderType::Ssh,
command,
terminal: false, // We're already wrapping in terminal
tags: vec!["ssh".to_string()],
});
}
}

View File

@@ -76,6 +76,7 @@ impl SystemProvider {
provider: ProviderType::System,
command: command.to_string(),
terminal: false,
tags: vec!["power".to_string(), "system".to_string()],
});
}
}

View File

@@ -46,6 +46,7 @@ impl UuctlProvider {
provider: ProviderType::Uuctl,
command: format!("systemctl --user restart {}", unit_name),
terminal: false,
tags: vec!["systemd".to_string(), "service".to_string()],
});
actions.push(LaunchItem {
@@ -56,6 +57,7 @@ impl UuctlProvider {
provider: ProviderType::Uuctl,
command: format!("systemctl --user stop {}", unit_name),
terminal: false,
tags: vec!["systemd".to_string(), "service".to_string()],
});
actions.push(LaunchItem {
@@ -66,6 +68,7 @@ impl UuctlProvider {
provider: ProviderType::Uuctl,
command: format!("systemctl --user reload {}", unit_name),
terminal: false,
tags: vec!["systemd".to_string(), "service".to_string()],
});
actions.push(LaunchItem {
@@ -76,6 +79,7 @@ impl UuctlProvider {
provider: ProviderType::Uuctl,
command: format!("systemctl --user kill {}", unit_name),
terminal: false,
tags: vec!["systemd".to_string(), "service".to_string()],
});
} else {
actions.push(LaunchItem {
@@ -86,6 +90,7 @@ impl UuctlProvider {
provider: ProviderType::Uuctl,
command: format!("systemctl --user start {}", unit_name),
terminal: false,
tags: vec!["systemd".to_string(), "service".to_string()],
});
}
@@ -98,6 +103,7 @@ impl UuctlProvider {
provider: ProviderType::Uuctl,
command: format!("systemctl --user status {}", unit_name),
terminal: true,
tags: vec!["systemd".to_string(), "service".to_string()],
});
actions.push(LaunchItem {
@@ -108,6 +114,7 @@ impl UuctlProvider {
provider: ProviderType::Uuctl,
command: format!("journalctl --user -u {} -f", unit_name),
terminal: true,
tags: vec!["systemd".to_string(), "service".to_string()],
});
actions.push(LaunchItem {
@@ -118,6 +125,7 @@ impl UuctlProvider {
provider: ProviderType::Uuctl,
command: format!("systemctl --user enable {}", unit_name),
terminal: false,
tags: vec!["systemd".to_string(), "service".to_string()],
});
actions.push(LaunchItem {
@@ -128,6 +136,7 @@ impl UuctlProvider {
provider: ProviderType::Uuctl,
command: format!("systemctl --user disable {}", unit_name),
terminal: false,
tags: vec!["systemd".to_string(), "service".to_string()],
});
actions
@@ -189,6 +198,7 @@ impl UuctlProvider {
provider: ProviderType::Uuctl,
command: submenu_data, // Special marker for submenu
terminal: false,
tags: vec!["systemd".to_string(), "service".to_string()],
});
}

View File

@@ -136,6 +136,7 @@ impl WebSearchProvider {
provider: ProviderType::WebSearch,
command,
terminal: false,
tags: vec!["web".to_string(), "search".to_string()],
})
}
}

View File

@@ -14,7 +14,7 @@
background-color: var(--owlry-bg, @theme_bg_color);
border-radius: var(--owlry-border-radius, 12px);
border: 1px solid var(--owlry-border, @borders);
padding: 16px;
padding: 12px;
}
/* Search entry */
@@ -43,8 +43,8 @@
.owlry-result-row {
background-color: transparent;
border-radius: calc(var(--owlry-border-radius, 12px) - 4px);
margin: 2px 0;
padding: 8px 12px;
margin: 1px 0;
padding: 6px 12px;
}
.owlry-result-row:hover {
@@ -315,6 +315,22 @@ scrollbar slider:active {
background-color: var(--owlry-accent, @theme_selected_bg_color);
}
/* Tag badges */
.owlry-tag-badge {
font-size: calc(var(--owlry-font-size, 14px) - 4px);
font-weight: 500;
padding: 1px 6px;
border-radius: 4px;
background-color: alpha(var(--owlry-border, @borders), 0.3);
color: var(--owlry-text-secondary, alpha(@theme_fg_color, 0.6));
margin-top: 2px;
}
.owlry-result-row:selected .owlry-tag-badge {
background-color: alpha(var(--owlry-accent-bright, @theme_selected_fg_color), 0.2);
color: var(--owlry-accent-bright, @theme_selected_fg_color);
}
/* Text selection */
selection {
background-color: alpha(var(--owlry-accent, @theme_selected_bg_color), 0.3);

View File

@@ -10,6 +10,10 @@ use gtk4::{
ListBoxRow, Orientation, ScrolledWindow, SelectionMode, ToggleButton,
};
use log::info;
#[cfg(feature = "dev-logging")]
use log::debug;
use std::cell::RefCell;
use std::collections::HashMap;
use std::process::Command;
@@ -44,6 +48,8 @@ pub struct MainWindow {
hints_label: Label,
filter_buttons: Rc<RefCell<HashMap<ProviderType, ToggleButton>>>,
submenu_state: Rc<RefCell<SubmenuState>>,
/// Parsed tab config (ProviderTypes for cycling)
tab_order: Rc<Vec<ProviderType>>,
}
impl MainWindow {
@@ -104,8 +110,17 @@ impl MainWindow {
.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);
// Parse tabs config to ProviderTypes
let tab_order: Vec<ProviderType> = cfg
.general
.tabs
.iter()
.filter_map(|s| s.parse().ok())
.collect();
let tab_order = Rc::new(tab_order);
// Create toggle buttons for each provider (from config)
let filter_buttons = Self::create_filter_buttons(&filter_tabs, &filter, &cfg.general.tabs);
let filter_buttons = Rc::new(RefCell::new(filter_buttons));
header_box.append(&mode_label);
@@ -143,7 +158,7 @@ impl MainWindow {
hints_box.add_css_class("owlry-hints");
let hints_label = Label::builder()
.label("Tab: cycle mode ↑↓: navigate Enter: launch Esc: close = calc ? web :app :cmd")
.label(&Self::build_hints(&cfg.providers))
.halign(gtk4::Align::Center)
.hexpand(true)
.build();
@@ -173,6 +188,7 @@ impl MainWindow {
hints_label,
filter_buttons,
submenu_state: Rc::new(RefCell::new(SubmenuState::default())),
tab_order,
};
main_window.setup_signals();
@@ -187,38 +203,31 @@ impl MainWindow {
fn create_filter_buttons(
container: &GtkBox,
filter: &Rc<RefCell<ProviderFilter>>,
tabs: &[String],
) -> 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 {
// Parse tab strings to ProviderType and create buttons
for (idx, tab_str) in tabs.iter().enumerate() {
let provider_type: ProviderType = match tab_str.parse() {
Ok(pt) => pt,
Err(e) => {
log::warn!("Invalid tab config '{}': {}", tab_str, e);
continue;
}
};
let label = Self::provider_tab_label(provider_type);
let shortcut = format!("Ctrl+{}", idx + 1);
let button = ToggleButton::builder()
.label(label)
.tooltip_text(shortcut)
.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::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",
};
let css_class = Self::provider_css_class(provider_type);
button.add_css_class(css_class);
container.append(&button);
@@ -228,6 +237,44 @@ impl MainWindow {
buttons
}
/// Get display label for a provider tab
fn provider_tab_label(provider: ProviderType) -> &'static str {
match provider {
ProviderType::Application => "Apps",
ProviderType::Bookmarks => "Bookmarks",
ProviderType::Calculator => "Calc",
ProviderType::Clipboard => "Clip",
ProviderType::Command => "Cmds",
ProviderType::Dmenu => "Dmenu",
ProviderType::Emoji => "Emoji",
ProviderType::Files => "Files",
ProviderType::Scripts => "Scripts",
ProviderType::Ssh => "SSH",
ProviderType::System => "System",
ProviderType::Uuctl => "uuctl",
ProviderType::WebSearch => "Web",
}
}
/// Get CSS class for a provider
fn provider_css_class(provider: ProviderType) -> &'static str {
match provider {
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",
}
}
fn build_placeholder(filter: &ProviderFilter) -> String {
let active: Vec<&str> = filter
.enabled_providers()
@@ -252,6 +299,52 @@ impl MainWindow {
format!("Search {}...", active.join(", "))
}
/// Build dynamic hints based on enabled providers
fn build_hints(config: &crate::config::ProvidersConfig) -> String {
let mut parts: Vec<String> = vec![
"Tab: cycle".to_string(),
"↑↓: nav".to_string(),
"Enter: launch".to_string(),
"Esc: close".to_string(),
];
// Add trigger hints for enabled dynamic providers
if config.calculator {
parts.push("= calc".to_string());
}
if config.websearch {
parts.push("? web".to_string());
}
if config.files {
parts.push("/ files".to_string());
}
// Add prefix hints for static providers
let mut prefixes = Vec::new();
if config.system {
prefixes.push(":sys");
}
if config.emoji {
prefixes.push(":emoji");
}
if config.ssh {
prefixes.push(":ssh");
}
if config.clipboard {
prefixes.push(":clip");
}
if config.bookmarks {
prefixes.push(":bm");
}
// Only show first few prefixes to avoid overflow
if !prefixes.is_empty() {
parts.push(prefixes[..prefixes.len().min(4)].join(" "));
}
parts.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();
@@ -298,6 +391,9 @@ impl MainWindow {
display_name: &str,
is_active: bool,
) {
#[cfg(feature = "dev-logging")]
debug!("[UI] Entering submenu for service: {} (active={})", unit_name, is_active);
let actions = UuctlProvider::actions_for_service(unit_name, display_name, is_active);
// Save current state
@@ -340,7 +436,11 @@ impl MainWindow {
hints_label: &Label,
search_entry: &Entry,
filter: &Rc<RefCell<ProviderFilter>>,
config: &Rc<RefCell<Config>>,
) {
#[cfg(feature = "dev-logging")]
debug!("[UI] Exiting submenu");
let saved_search = {
let mut state = submenu_state.borrow_mut();
state.active = false;
@@ -350,7 +450,7 @@ impl MainWindow {
// Restore UI
mode_label.set_label(filter.borrow().mode_display_name());
hints_label.set_label("Tab: cycle mode ↑↓: navigate Enter: launch Esc: close = calc ? web :app :cmd");
hints_label.set_label(&Self::build_hints(&config.borrow().providers));
search_entry.set_placeholder_text(Some(&Self::build_placeholder(&filter.borrow())));
search_entry.set_text(&saved_search);
@@ -450,7 +550,7 @@ impl MainWindow {
let results: Vec<LaunchItem> = if use_frecency {
providers
.borrow_mut()
.search_with_frecency(&parsed.query, max_results, &filter.borrow(), &frecency.borrow(), frecency_weight)
.search_with_frecency(&parsed.query, max_results, &filter.borrow(), &frecency.borrow(), frecency_weight, parsed.tag_filter.as_deref())
.into_iter()
.map(|(item, _)| item)
.collect()
@@ -553,17 +653,21 @@ impl MainWindow {
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 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();
let tab_order = self.tab_order.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);
#[cfg(feature = "dev-logging")]
debug!("[UI] Key pressed: {:?} (ctrl={}, shift={})", key, ctrl, shift);
match key {
Key::Escape => {
// If in submenu, exit submenu first
@@ -574,6 +678,7 @@ impl MainWindow {
&hints_label,
&search_entry,
&filter,
&config,
);
gtk4::glib::Propagation::Stop
} else {
@@ -590,6 +695,7 @@ impl MainWindow {
&hints_label,
&search_entry,
&filter,
&config,
);
gtk4::glib::Propagation::Stop
} else {
@@ -631,6 +737,7 @@ impl MainWindow {
&filter_buttons,
&search_entry,
&mode_label,
&tab_order,
!shift,
);
}
@@ -643,45 +750,37 @@ impl MainWindow {
&filter_buttons,
&search_entry,
&mode_label,
&tab_order,
false,
);
}
gtk4::glib::Propagation::Stop
}
// Ctrl+1/2/3 toggle specific providers (only when not in submenu)
Key::_1 if ctrl => {
// Ctrl+1-9 toggle specific providers based on tab order (only when not in submenu)
Key::_1 | Key::_2 | Key::_3 | Key::_4 | Key::_5 |
Key::_6 | Key::_7 | Key::_8 | Key::_9 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,
);
let idx = match key {
Key::_1 => 0,
Key::_2 => 1,
Key::_3 => 2,
Key::_4 => 3,
Key::_5 => 4,
Key::_6 => 5,
Key::_7 => 6,
Key::_8 => 7,
Key::_9 => 8,
_ => return gtk4::glib::Propagation::Proceed,
};
if let Some(&provider) = tab_order.get(idx) {
Self::toggle_provider_button(
provider,
&filter,
&filter_buttons,
&search_entry,
&mode_label,
);
}
}
gtk4::glib::Propagation::Stop
}
@@ -735,24 +834,24 @@ impl MainWindow {
buttons: &Rc<RefCell<HashMap<ProviderType, ToggleButton>>>,
entry: &Entry,
mode_label: &Label,
tab_order: &[ProviderType],
forward: bool,
) {
let order = [
ProviderType::Application,
ProviderType::Command,
ProviderType::Uuctl,
];
if tab_order.is_empty() {
return;
}
let current = filter.borrow().enabled_providers();
let next = if current.len() == 1 {
let idx = order.iter().position(|p| p == &current[0]).unwrap_or(0);
let idx = tab_order.iter().position(|p| p == &current[0]).unwrap_or(0);
if forward {
order[(idx + 1) % order.len()]
tab_order[(idx + 1) % tab_order.len()]
} else {
order[(idx + order.len() - 1) % order.len()]
tab_order[(idx + tab_order.len() - 1) % tab_order.len()]
}
} else {
ProviderType::Application
tab_order[0]
};
{
@@ -800,7 +899,7 @@ impl MainWindow {
let results: Vec<LaunchItem> = if use_frecency {
self.providers
.borrow_mut()
.search_with_frecency(query, max_results, &self.filter.borrow(), &self.frecency.borrow(), frecency_weight)
.search_with_frecency(query, max_results, &self.filter.borrow(), &self.frecency.borrow(), frecency_weight, None)
.into_iter()
.map(|(item, _)| item)
.collect()
@@ -833,10 +932,15 @@ impl MainWindow {
// Record this launch for frecency tracking
if config.providers.frecency {
frecency.borrow_mut().record_launch(&item.id);
#[cfg(feature = "dev-logging")]
debug!("[UI] Recorded frecency launch for: {}", item.id);
}
info!("Launching: {} ({})", item.name, item.command);
#[cfg(feature = "dev-logging")]
debug!("[UI] Launch details: terminal={}, provider={:?}", item.terminal, item.provider);
let cmd = if item.terminal {
format!("{} -e {}", config.general.terminal_command, item.command)
} else {

View File

@@ -82,6 +82,25 @@ impl ResultRow {
text_box.append(&name_label);
}
// Tag badges (show first 3 tags)
if !item.tags.is_empty() {
let tags_box = GtkBox::builder()
.orientation(Orientation::Horizontal)
.spacing(4)
.halign(gtk4::Align::Start)
.build();
for tag in item.tags.iter().take(3) {
let tag_label = Label::builder()
.label(tag)
.build();
tag_label.add_css_class("owlry-tag-badge");
tags_box.append(&tag_label);
}
text_box.append(&tags_box);
}
// Provider badge
let badge = Label::builder()
.label(&item.provider.to_string())