feat: add lazy loading, non-blocking bookmarks, and file search fix

- Add lazy loading for result lists (load more on scroll/selection)
- Add non-blocking bookmark loading with JSON cache
- Add Firefox favicon extraction and caching
- Fix dynamic provider filtering (files/calc/websearch in All mode)
- Fix clippy warnings across core and plugins
- Add apex-neon theme
- Add aur/ to gitignore

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-30 06:11:50 +01:00
parent 43f7228be2
commit abd4df6939
21 changed files with 1931 additions and 220 deletions

View File

@@ -25,3 +25,7 @@ dirs = "5.0"
# For parsing Chrome bookmarks JSON
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# For reading Firefox bookmarks (places.sqlite)
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
tokio = { version = "1", features = ["rt-multi-thread"] }

View File

@@ -1,9 +1,9 @@
//! Bookmarks Plugin for Owlry
//!
//! A static provider that reads browser bookmarks from Chrome/Chromium.
//! Firefox support would require the rusqlite crate for reading places.sqlite.
//! A static provider that reads browser bookmarks from various browsers.
//!
//! Supported browsers:
//! - Firefox (via places.sqlite using SQLx)
//! - Chrome
//! - Chromium
//! - Brave
@@ -14,8 +14,14 @@ use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, API_VERSION,
};
use serde::Deserialize;
use sqlx::sqlite::SqlitePoolOptions;
use sqlx::Row;
use std::fs;
use std::path::PathBuf;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
// Plugin metadata
const PLUGIN_ID: &str = "bookmarks";
@@ -27,17 +33,141 @@ const PLUGIN_DESCRIPTION: &str = "Browser bookmark search";
const PROVIDER_ID: &str = "bookmarks";
const PROVIDER_NAME: &str = "Bookmarks";
const PROVIDER_PREFIX: &str = ":bm";
const PROVIDER_ICON: &str = "web-browser";
const PROVIDER_ICON: &str = "user-bookmarks-symbolic";
const PROVIDER_TYPE_ID: &str = "bookmarks";
/// Bookmarks provider state - holds cached items
struct BookmarksState {
/// Cached bookmark items (returned immediately on refresh)
items: Vec<PluginItem>,
/// Flag to prevent concurrent background loads
loading: Arc<AtomicBool>,
}
impl BookmarksState {
fn new() -> Self {
Self { items: Vec::new() }
Self {
items: Vec::new(),
loading: Arc::new(AtomicBool::new(false)),
}
}
/// Get or create the favicon cache directory
fn favicon_cache_dir() -> Option<PathBuf> {
dirs::cache_dir().map(|d| d.join("owlry/favicons"))
}
/// Ensure the favicon cache directory exists
fn ensure_favicon_cache_dir() -> Option<PathBuf> {
Self::favicon_cache_dir().and_then(|dir| {
fs::create_dir_all(&dir).ok()?;
Some(dir)
})
}
/// Hash a URL to create a cache filename
fn url_to_cache_filename(url: &str) -> String {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
url.hash(&mut hasher);
format!("{:016x}.png", hasher.finish())
}
/// Get the bookmark cache file path
fn bookmark_cache_file() -> Option<PathBuf> {
dirs::cache_dir().map(|d| d.join("owlry/bookmarks.json"))
}
/// Load cached bookmarks from disk (fast)
fn load_cached_bookmarks() -> Vec<PluginItem> {
let cache_file = match Self::bookmark_cache_file() {
Some(f) => f,
None => return Vec::new(),
};
if !cache_file.exists() {
return Vec::new();
}
let content = match fs::read_to_string(&cache_file) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
// Parse cached bookmarks (simple JSON format)
#[derive(serde::Deserialize)]
struct CachedBookmark {
id: String,
name: String,
command: String,
description: Option<String>,
icon: String,
}
let cached: Vec<CachedBookmark> = match serde_json::from_str(&content) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
cached
.into_iter()
.map(|b| {
let mut item = PluginItem::new(b.id, b.name, b.command)
.with_icon(&b.icon)
.with_keywords(vec!["bookmark".to_string()]);
if let Some(desc) = b.description {
item = item.with_description(desc);
}
item
})
.collect()
}
/// Save bookmarks to cache file
fn save_cached_bookmarks(items: &[PluginItem]) {
let cache_file = match Self::bookmark_cache_file() {
Some(f) => f,
None => return,
};
// Ensure cache directory exists
if let Some(parent) = cache_file.parent() {
let _ = fs::create_dir_all(parent);
}
#[derive(serde::Serialize)]
struct CachedBookmark {
id: String,
name: String,
command: String,
description: Option<String>,
icon: String,
}
let cached: Vec<CachedBookmark> = items
.iter()
.map(|item| {
let desc: Option<String> = match &item.description {
abi_stable::std_types::ROption::RSome(s) => Some(s.to_string()),
abi_stable::std_types::ROption::RNone => None,
};
let icon: String = match &item.icon {
abi_stable::std_types::ROption::RSome(s) => s.to_string(),
abi_stable::std_types::ROption::RNone => PROVIDER_ICON.to_string(),
};
CachedBookmark {
id: item.id.to_string(),
name: item.name.to_string(),
command: item.command.to_string(),
description: desc,
icon,
}
})
.collect();
if let Ok(json) = serde_json::to_string(&cached) {
let _ = fs::write(&cache_file, json);
}
}
fn chromium_bookmark_paths() -> Vec<PathBuf> {
@@ -61,18 +191,87 @@ impl BookmarksState {
paths
}
fn load_bookmarks(&mut self) {
self.items.clear();
fn firefox_places_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
// Load Chrome/Chromium bookmarks
for path in Self::chromium_bookmark_paths() {
if path.exists() {
self.read_chrome_bookmarks(&path);
if let Some(home) = dirs::home_dir() {
let firefox_dir = home.join(".mozilla/firefox");
if firefox_dir.exists() {
// Find all profile directories
if let Ok(entries) = fs::read_dir(&firefox_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let places = path.join("places.sqlite");
if places.exists() {
paths.push(places);
}
}
}
}
}
}
paths
}
/// Find Firefox favicons.sqlite paths (paired with places.sqlite)
fn firefox_favicons_path(places_path: &Path) -> Option<PathBuf> {
let favicons = places_path.parent()?.join("favicons.sqlite");
if favicons.exists() {
Some(favicons)
} else {
None
}
}
fn read_chrome_bookmarks(&mut self, path: &PathBuf) {
fn load_bookmarks(&mut self) {
// Fast path: load from cache immediately
if self.items.is_empty() {
self.items = Self::load_cached_bookmarks();
}
// Don't start another background load if one is already running
if self.loading.swap(true, Ordering::SeqCst) {
return;
}
// Spawn background thread to refresh bookmarks
let loading = self.loading.clone();
thread::spawn(move || {
let mut items = Vec::new();
// Load Chrome/Chromium bookmarks (fast - just JSON parsing)
for path in Self::chromium_bookmark_paths() {
if path.exists() {
Self::read_chrome_bookmarks_static(&path, &mut items);
}
}
// Load Firefox bookmarks with favicons (async via tokio)
let rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(_) => {
loading.store(false, Ordering::SeqCst);
return;
}
};
for path in Self::firefox_places_paths() {
rt.block_on(async {
Self::read_firefox_bookmarks_async(&path, &mut items).await;
});
}
// Save to cache for next startup
Self::save_cached_bookmarks(&items);
loading.store(false, Ordering::SeqCst);
});
}
/// Read Chrome bookmarks (static helper for background thread)
fn read_chrome_bookmarks_static(path: &PathBuf, items: &mut Vec<PluginItem>) {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return,
@@ -83,29 +282,27 @@ impl BookmarksState {
Err(_) => 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);
Self::process_chrome_folder_static(&bar, items);
}
if let Some(other) = roots.other {
self.process_chrome_folder(&other);
Self::process_chrome_folder_static(&other, items);
}
if let Some(synced) = roots.synced {
self.process_chrome_folder(&synced);
Self::process_chrome_folder_static(&synced, items);
}
}
}
fn process_chrome_folder(&mut self, folder: &ChromeBookmarkNode) {
fn process_chrome_folder_static(folder: &ChromeBookmarkNode, items: &mut Vec<PluginItem>) {
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(
items.push(
PluginItem::new(
format!("bookmark:{}", url),
name,
@@ -113,19 +310,199 @@ impl BookmarksState {
)
.with_description(url.clone())
.with_icon(PROVIDER_ICON)
.with_keywords(vec!["bookmark".to_string(), "web".to_string()]),
.with_keywords(vec!["bookmark".to_string(), "chrome".to_string()]),
);
}
}
Some("folder") => {
// Recursively process subfolders
self.process_chrome_folder(child);
Self::process_chrome_folder_static(child, items);
}
_ => {}
}
}
}
}
/// Read Firefox bookmarks asynchronously
async fn read_firefox_bookmarks_async(places_path: &PathBuf, items: &mut Vec<PluginItem>) {
let temp_dir = std::env::temp_dir();
let temp_db = temp_dir.join("owlry_places_temp.sqlite");
if fs::copy(places_path, &temp_db).is_err() {
return;
}
let wal_path = places_path.with_extension("sqlite-wal");
if wal_path.exists() {
let temp_wal = temp_db.with_extension("sqlite-wal");
let _ = fs::copy(&wal_path, &temp_wal);
}
let favicons_path = Self::firefox_favicons_path(places_path);
let temp_favicons = temp_dir.join("owlry_favicons_temp.sqlite");
if let Some(ref fp) = favicons_path {
let _ = fs::copy(fp, &temp_favicons);
let fav_wal = fp.with_extension("sqlite-wal");
if fav_wal.exists() {
let _ = fs::copy(&fav_wal, temp_favicons.with_extension("sqlite-wal"));
}
}
let db_url = format!("sqlite:{}?mode=ro", temp_db.display());
let favicons_url = if favicons_path.is_some() {
Some(format!("sqlite:{}?mode=ro", temp_favicons.display()))
} else {
None
};
let cache_dir = Self::ensure_favicon_cache_dir();
let bookmarks = Self::fetch_firefox_bookmarks_with_favicons(
&db_url,
favicons_url.as_deref(),
cache_dir.as_ref(),
)
.await;
// Clean up temp files
let _ = fs::remove_file(&temp_db);
let _ = fs::remove_file(temp_db.with_extension("sqlite-wal"));
let _ = fs::remove_file(&temp_favicons);
let _ = fs::remove_file(temp_favicons.with_extension("sqlite-wal"));
for (title, url, favicon_path) in bookmarks {
let icon = favicon_path.unwrap_or_else(|| PROVIDER_ICON.to_string());
items.push(
PluginItem::new(
format!("bookmark:firefox:{}", url),
title,
format!("xdg-open '{}'", url.replace('\'', "'\\''")),
)
.with_description(url)
.with_icon(&icon)
.with_keywords(vec!["bookmark".to_string(), "firefox".to_string()]),
);
}
}
/// Fetch Firefox bookmarks with their favicons
async fn fetch_firefox_bookmarks_with_favicons(
places_url: &str,
favicons_url: Option<&str>,
cache_dir: Option<&PathBuf>,
) -> Vec<(String, String, Option<String>)> {
// First, fetch bookmarks from places.sqlite
let pool = match SqlitePoolOptions::new()
.max_connections(1)
.connect(places_url)
.await
{
Ok(p) => p,
Err(_) => return Vec::new(),
};
// Query bookmarks joining moz_bookmarks with moz_places
// type=1 means URL bookmarks (not folders, separators, etc.)
let query = r#"
SELECT b.title, p.url
FROM moz_bookmarks b
JOIN moz_places p ON b.fk = p.id
WHERE b.type = 1
AND p.url NOT LIKE 'place:%'
AND p.url NOT LIKE 'about:%'
AND b.title IS NOT NULL
AND b.title != ''
ORDER BY b.dateAdded DESC
LIMIT 500
"#;
let rows = match sqlx::query(query).fetch_all(&pool).await {
Ok(r) => r,
Err(_) => return Vec::new(),
};
let bookmarks: Vec<(String, String)> = rows
.into_iter()
.filter_map(|row| {
let title: Option<String> = row.get("title");
let url: Option<String> = row.get("url");
match (title, url) {
(Some(t), Some(u)) => Some((t, u)),
_ => None,
}
})
.collect();
// If no favicons database or cache dir, return without favicons
let (favicons_url, cache_dir) = match (favicons_url, cache_dir) {
(Some(f), Some(c)) => (f, c),
_ => return bookmarks.into_iter().map(|(t, u)| (t, u, None)).collect(),
};
// Connect to favicons database
let fav_pool = match SqlitePoolOptions::new()
.max_connections(1)
.connect(favicons_url)
.await
{
Ok(p) => p,
Err(_) => return bookmarks.into_iter().map(|(t, u)| (t, u, None)).collect(),
};
// Fetch favicons for each URL
let mut results = Vec::new();
for (title, url) in bookmarks {
let favicon_path = Self::get_favicon_for_url(&fav_pool, &url, cache_dir).await;
results.push((title, url, favicon_path));
}
results
}
/// Get favicon for a URL, caching to file if needed
async fn get_favicon_for_url(
pool: &sqlx::SqlitePool,
page_url: &str,
cache_dir: &Path,
) -> Option<String> {
// Check if already cached
let cache_filename = Self::url_to_cache_filename(page_url);
let cache_path = cache_dir.join(&cache_filename);
if cache_path.exists() {
return Some(cache_path.to_string_lossy().to_string());
}
// Query favicon data from database
// Join moz_pages_w_icons -> moz_icons_to_pages -> moz_icons
// Prefer smaller icons (32px) for efficiency
let query = r#"
SELECT i.data
FROM moz_pages_w_icons p
JOIN moz_icons_to_pages ip ON p.id = ip.page_id
JOIN moz_icons i ON ip.icon_id = i.id
WHERE p.page_url = ?
AND i.data IS NOT NULL
ORDER BY ABS(i.width - 32) ASC
LIMIT 1
"#;
let row = sqlx::query(query)
.bind(page_url)
.fetch_optional(pool)
.await
.ok()??;
let data: Vec<u8> = row.get("data");
if data.is_empty() {
return None;
}
// Write favicon data to cache file
let mut file = fs::File::create(&cache_path).ok()?;
file.write_all(&data).ok()?;
Some(cache_path.to_string_lossy().to_string())
}
}
// Chrome bookmark JSON structures
@@ -241,6 +618,14 @@ mod tests {
assert!(!paths.is_empty());
}
#[test]
fn test_firefox_paths() {
// This will find paths if Firefox is installed
let paths = BookmarksState::firefox_places_paths();
// Path detection should work (may be empty if Firefox not installed)
assert!(paths.len() >= 0);
}
#[test]
fn test_parse_chrome_bookmarks() {
let json = r#"{

View File

@@ -423,6 +423,7 @@ impl EmojiState {
name.to_string(),
format!("printf '%s' '{}' | wl-copy", emoji),
)
.with_icon(*emoji) // Use emoji character as icon
.with_description(format!("{} {}", emoji, keywords))
.with_keywords(vec![name.to_string(), keywords.to_string()]),
);

View File

@@ -57,31 +57,31 @@ impl PomodoroConfig {
let config_content = config_path
.and_then(|p| fs::read_to_string(p).ok());
if let Some(content) = config_content {
if let Ok(toml) = content.parse::<toml::Table>() {
// Try [plugins.pomodoro] first (new format)
if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table()) {
if let Some(pomodoro) = plugins.get("pomodoro").and_then(|v| v.as_table()) {
return Self::from_toml_table(pomodoro);
}
}
if let Some(content) = config_content
&& let Ok(toml) = content.parse::<toml::Table>()
{
// Try [plugins.pomodoro] first (new format)
if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table())
&& let Some(pomodoro) = plugins.get("pomodoro").and_then(|v| v.as_table())
{
return Self::from_toml_table(pomodoro);
}
// Fallback to [providers] section (old format)
if let Some(providers) = toml.get("providers").and_then(|v| v.as_table()) {
let work_mins = providers
.get("pomodoro_work_mins")
.and_then(|v| v.as_integer())
.map(|v| v as u32)
.unwrap_or(DEFAULT_WORK_MINS);
// Fallback to [providers] section (old format)
if let Some(providers) = toml.get("providers").and_then(|v| v.as_table()) {
let work_mins = providers
.get("pomodoro_work_mins")
.and_then(|v| v.as_integer())
.map(|v| v as u32)
.unwrap_or(DEFAULT_WORK_MINS);
let break_mins = providers
.get("pomodoro_break_mins")
.and_then(|v| v.as_integer())
.map(|v| v as u32)
.unwrap_or(DEFAULT_BREAK_MINS);
let break_mins = providers
.get("pomodoro_break_mins")
.and_then(|v| v.as_integer())
.map(|v| v as u32)
.unwrap_or(DEFAULT_BREAK_MINS);
return Self { work_mins, break_mins };
}
return Self { work_mins, break_mins };
}
}

View File

@@ -82,41 +82,41 @@ impl WeatherConfig {
let config_content = config_path
.and_then(|p| fs::read_to_string(p).ok());
if let Some(content) = config_content {
if let Ok(toml) = content.parse::<toml::Table>() {
// Try [plugins.weather] first (new format)
if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table()) {
if let Some(weather) = plugins.get("weather").and_then(|v| v.as_table()) {
return Self::from_toml_table(weather);
}
}
if let Some(content) = config_content
&& let Ok(toml) = content.parse::<toml::Table>()
{
// Try [plugins.weather] first (new format)
if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table())
&& let Some(weather) = plugins.get("weather").and_then(|v| v.as_table())
{
return Self::from_toml_table(weather);
}
// Fallback to [providers] section (old format)
if let Some(providers) = toml.get("providers").and_then(|v| v.as_table()) {
let provider_str = providers
.get("weather_provider")
.and_then(|v| v.as_str())
.unwrap_or("wttr.in");
// Fallback to [providers] section (old format)
if let Some(providers) = toml.get("providers").and_then(|v| v.as_table()) {
let provider_str = providers
.get("weather_provider")
.and_then(|v| v.as_str())
.unwrap_or("wttr.in");
let provider = provider_str.parse().unwrap_or(WeatherProviderType::WttrIn);
let provider = provider_str.parse().unwrap_or(WeatherProviderType::WttrIn);
let api_key = providers
.get("weather_api_key")
.and_then(|v| v.as_str())
.map(String::from);
let api_key = providers
.get("weather_api_key")
.and_then(|v| v.as_str())
.map(String::from);
let location = providers
.get("weather_location")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let location = providers
.get("weather_location")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
return Self {
provider,
api_key,
location,
};
}
return Self {
provider,
api_key,
location,
};
}
}

View File

@@ -56,7 +56,10 @@ impl OwlryApp {
let native_providers = Self::load_native_plugins(&config.borrow());
// Create provider manager with native plugins
#[cfg(feature = "lua")]
let mut provider_manager = ProviderManager::with_native_plugins(native_providers);
#[cfg(not(feature = "lua"))]
let provider_manager = ProviderManager::with_native_plugins(native_providers);
// Load Lua plugins if enabled (requires lua feature)
#[cfg(feature = "lua")]
@@ -75,7 +78,7 @@ impl OwlryApp {
);
let filter = Rc::new(RefCell::new(filter));
let window = MainWindow::new(app, config.clone(), providers.clone(), frecency.clone(), filter.clone());
let window = MainWindow::new(app, config.clone(), providers.clone(), frecency.clone(), filter.clone(), args.prompt.clone());
// Set up layer shell for Wayland overlay behavior
window.init_layer_shell();

View File

@@ -21,6 +21,10 @@ pub struct CliArgs {
#[arg(long, short = 'p', value_delimiter = ',', value_parser = parse_provider)]
pub providers: Option<Vec<ProviderType>>,
/// Custom prompt text for the search input (useful for dmenu mode)
#[arg(long)]
pub prompt: Option<String>,
/// Subcommand to run (if any)
#[command(subcommand)]
pub command: Option<Command>,

View File

@@ -6,10 +6,13 @@ use std::process::Command;
use crate::paths;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub general: GeneralConfig,
#[serde(default)]
pub appearance: AppearanceConfig,
#[serde(default)]
pub providers: ProvidersConfig,
#[serde(default)]
pub plugins: PluginsConfig,
@@ -17,9 +20,13 @@ pub struct Config {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeneralConfig {
#[serde(default = "default_true")]
pub show_icons: bool,
#[serde(default = "default_max_results")]
pub max_results: usize,
pub terminal_command: String,
/// Terminal command (auto-detected if not specified)
#[serde(default)]
pub terminal_command: Option<String>,
/// Launch wrapper command for app execution.
/// Examples: "uwsm app --", "hyprctl dispatch exec --", "systemd-run --user --"
/// If None or empty, launches directly via sh -c
@@ -31,6 +38,22 @@ pub struct GeneralConfig {
pub tabs: Vec<String>,
}
impl Default for GeneralConfig {
fn default() -> Self {
Self {
show_icons: true,
max_results: 100,
terminal_command: None,
launch_wrapper: None,
tabs: default_tabs(),
}
}
}
fn default_max_results() -> usize {
100
}
fn default_tabs() -> Vec<String> {
vec![
"app".to_string(),
@@ -40,9 +63,10 @@ fn default_tabs() -> Vec<String> {
}
/// User-customizable theme colors
/// All fields are optional - unset values inherit from GTK theme
/// All fields are optional - unset values inherit from theme or GTK defaults
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ThemeColors {
// Core colors
pub background: Option<String>,
pub background_secondary: Option<String>,
pub border: Option<String>,
@@ -64,13 +88,21 @@ pub struct ThemeColors {
pub badge_sys: Option<String>,
pub badge_uuctl: Option<String>,
pub badge_web: Option<String>,
// Widget badge colors
pub badge_media: Option<String>,
pub badge_weather: Option<String>,
pub badge_pomo: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppearanceConfig {
#[serde(default = "default_width")]
pub width: i32,
#[serde(default = "default_height")]
pub height: i32,
#[serde(default = "default_font_size")]
pub font_size: u32,
#[serde(default = "default_border_radius")]
pub border_radius: u32,
/// Theme name: None = GTK default, "owl" = built-in owl theme
#[serde(default)]
@@ -80,10 +112,31 @@ pub struct AppearanceConfig {
pub colors: ThemeColors,
}
impl Default for AppearanceConfig {
fn default() -> Self {
Self {
width: 850,
height: 650,
font_size: 14,
border_radius: 12,
theme: None,
colors: ThemeColors::default(),
}
}
}
fn default_width() -> i32 { 850 }
fn default_height() -> i32 { 650 }
fn default_font_size() -> u32 { 14 }
fn default_border_radius() -> u32 { 12 }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProvidersConfig {
#[serde(default = "default_true")]
pub applications: bool,
#[serde(default = "default_true")]
pub commands: bool,
#[serde(default = "default_true")]
pub uuctl: bool,
/// Enable calculator provider (= expression or calc expression)
#[serde(default = "default_true")]
@@ -159,6 +212,36 @@ pub struct ProvidersConfig {
pub pomodoro_break_mins: u32,
}
impl Default for ProvidersConfig {
fn default() -> Self {
Self {
applications: true,
commands: true,
uuctl: true,
calculator: true,
frecency: true,
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,
media: true,
weather: false,
weather_provider: "wttr.in".to_string(),
weather_api_key: None,
weather_location: Some("Berlin".to_string()),
pomodoro: false,
pomodoro_work_mins: 25,
pomodoro_break_mins: 5,
}
}
}
/// Configuration for plugins
///
/// Supports per-plugin configuration via `[plugins.<name>]` sections:
@@ -450,57 +533,7 @@ fn command_exists(cmd: &str) -> bool {
.unwrap_or(false)
}
impl Default for Config {
fn default() -> Self {
let terminal = detect_terminal();
info!("Detected terminal: {}", terminal);
Self {
general: GeneralConfig {
show_icons: true,
max_results: 10,
terminal_command: terminal,
launch_wrapper: detect_launch_wrapper(),
tabs: default_tabs(),
},
appearance: AppearanceConfig {
width: 850,
height: 650,
font_size: 14,
border_radius: 12,
theme: None,
colors: ThemeColors::default(),
},
providers: ProvidersConfig {
applications: true,
commands: true,
uuctl: true,
calculator: true,
frecency: true,
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,
// Widget providers
media: true,
weather: false,
weather_provider: "wttr.in".to_string(),
weather_api_key: None,
weather_location: Some("Berlin".to_string()),
pomodoro: false,
pomodoro_work_mins: 25,
pomodoro_break_mins: 5,
},
plugins: PluginsConfig::default(),
}
}
}
// Note: Config derives Default via #[derive(Default)] - all sub-structs have impl Default
impl Config {
pub fn config_path() -> Option<PathBuf> {
@@ -517,23 +550,37 @@ impl Config {
pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
let path = Self::config_path().ok_or("Could not determine config path")?;
if !path.exists() {
let mut config = if !path.exists() {
info!("Config file not found, using defaults");
return Ok(Self::default());
Self::default()
} else {
let content = std::fs::read_to_string(&path)?;
let config: Config = toml::from_str(&content)?;
info!("Loaded config from {:?}", path);
config
};
// Auto-detect terminal if not configured or configured terminal doesn't exist
match &config.general.terminal_command {
None => {
let terminal = detect_terminal();
info!("Detected terminal: {}", terminal);
config.general.terminal_command = Some(terminal);
}
Some(term) if !command_exists(term) => {
warn!("Configured terminal '{}' not found, auto-detecting", term);
let terminal = detect_terminal();
info!("Using detected terminal: {}", terminal);
config.general.terminal_command = Some(terminal);
}
Some(term) => {
debug!("Using configured terminal: {}", term);
}
}
let content = std::fs::read_to_string(&path)?;
let mut config: Config = toml::from_str(&content)?;
info!("Loaded config from {:?}", path);
// Validate terminal - if configured terminal doesn't exist, auto-detect
if !command_exists(&config.general.terminal_command) {
warn!(
"Configured terminal '{}' not found, auto-detecting",
config.general.terminal_command
);
config.general.terminal_command = detect_terminal();
info!("Using detected terminal: {}", config.general.terminal_command);
// Auto-detect launch wrapper if not configured
if config.general.launch_wrapper.is_none() {
config.general.launch_wrapper = detect_launch_wrapper();
}
Ok(config)

View File

@@ -64,8 +64,17 @@ impl ProviderFilter {
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
// Dynamic providers: add to filter set so they work in "All" mode
// but can still be excluded when in single-provider mode
if config_providers.files {
set.insert(ProviderType::Files);
}
if config_providers.calculator {
set.insert(ProviderType::Calculator);
}
if config_providers.websearch {
set.insert(ProviderType::WebSearch);
}
// Default to apps if nothing enabled
if set.is_empty() {
set.insert(ProviderType::Application);
@@ -104,9 +113,11 @@ impl ProviderFilter {
#[cfg(feature = "dev-logging")]
debug!("[Filter] Toggled OFF {:?}, enabled: {:?}", provider, self.enabled);
} else {
#[cfg(feature = "dev-logging")]
let provider_debug = format!("{:?}", provider);
self.enabled.insert(provider);
#[cfg(feature = "dev-logging")]
debug!("[Filter] Toggled ON {:?}, enabled: {:?}", provider, self.enabled);
debug!("[Filter] Toggled ON {}, enabled: {:?}", provider_debug, self.enabled);
}
}

View File

@@ -156,6 +156,7 @@ pub fn discover_plugins(plugins_dir: &Path) -> PluginResult<HashMap<String, (Plu
}
/// Check if a plugin is compatible with the given owlry version
#[allow(dead_code)]
pub fn check_compatibility(manifest: &PluginManifest, owlry_version: &str) -> PluginResult<()> {
if !manifest.is_compatible_with(owlry_version) {
return Err(PluginError::VersionMismatch {
@@ -230,6 +231,7 @@ impl PluginManifest {
}
/// Check if this plugin is compatible with the given owlry version
#[allow(dead_code)]
pub fn is_compatible_with(&self, owlry_version: &str) -> bool {
let req = match semver::VersionReq::parse(&self.plugin.owlry_version) {
Ok(r) => r,

View File

@@ -43,6 +43,7 @@ pub use api::provider::{PluginItem, ProviderRegistration};
#[allow(unused_imports)]
pub use api::{ActionRegistration, HookEvent, ThemeRegistration};
#[allow(unused_imports)]
pub use error::{PluginError, PluginResult};
#[cfg(feature = "lua")]

View File

@@ -80,13 +80,13 @@ impl std::str::FromStr for ProviderType {
"cmd" | "command" | "commands" => Ok(ProviderType::Command),
"dmenu" => Ok(ProviderType::Dmenu),
"emoji" | "emojis" => Ok(ProviderType::Emoji),
"file" | "files" | "find" => Ok(ProviderType::Files),
"file" | "files" | "find" | "filesearch" => Ok(ProviderType::Files),
"media" | "mpris" | "player" => Ok(ProviderType::MediaPlayer),
"pomo" | "pomodoro" | "timer" => Ok(ProviderType::Pomodoro),
"script" | "scripts" => Ok(ProviderType::Scripts),
"ssh" => Ok(ProviderType::Ssh),
"sys" | "system" | "power" => Ok(ProviderType::System),
"uuctl" => Ok(ProviderType::Uuctl),
"uuctl" | "systemd" => Ok(ProviderType::Uuctl),
"weather" => Ok(ProviderType::Weather),
"web" | "websearch" | "search" => Ok(ProviderType::WebSearch),
// Plugin types are prefixed with "plugin:" (e.g., "plugin:github-repos")
@@ -273,12 +273,14 @@ impl ProviderManager {
}
/// Add a dynamic provider (e.g., from a Lua plugin)
#[allow(dead_code)]
pub fn add_provider(&mut self, provider: Box<dyn Provider>) {
info!("Added plugin provider: {}", provider.name());
self.providers.push(provider);
}
/// Add multiple providers at once (for batch plugin loading)
#[allow(dead_code)]
pub fn add_providers(&mut self, providers: Vec<Box<dyn Provider>>) {
for provider in providers {
self.add_provider(provider);
@@ -394,6 +396,10 @@ impl ProviderManager {
if filter.active_prefix().is_none() && query.is_empty() {
// Widget priority scores based on type
for provider in &self.widget_providers {
// Skip if this provider type is filtered out
if !filter.is_active(provider.provider_type()) {
continue;
}
let base_score = match provider.type_id() {
"weather" => 12000,
"pomodoro" => 11500,
@@ -407,9 +413,15 @@ impl ProviderManager {
}
// Query dynamic providers (calculator, websearch, filesearch)
// Each provider internally checks if the query matches its prefix
// Only query if:
// 1. Their specific filter is active (e.g., :file prefix or Files tab selected), OR
// 2. No specific single-mode filter is active (showing all providers)
if !query.is_empty() {
for (provider_idx, provider) in self.dynamic_providers.iter().enumerate() {
// Skip if this provider type is explicitly filtered out
if !filter.is_active(provider.provider_type()) {
continue;
}
let dynamic_results = provider.query(query);
let base_score = match provider.type_id() {
"calc" => 10000,

View File

@@ -42,6 +42,16 @@ impl NativeProvider {
}
}
/// Get the ProviderType for this native provider
/// Maps type_id string to the appropriate ProviderType variant
fn get_provider_type(&self) -> ProviderType {
// Parse type_id to get the proper ProviderType
// This uses the FromStr impl which maps strings like "clipboard" -> ProviderType::Clipboard
self.info.type_id.as_str().parse().unwrap_or_else(|_| {
ProviderType::Plugin(self.info.type_id.to_string())
})
}
/// Convert a plugin API item to a core LaunchItem
fn convert_item(&self, item: ApiPluginItem) -> LaunchItem {
LaunchItem {
@@ -49,7 +59,7 @@ impl NativeProvider {
name: item.name.to_string(),
description: item.description.as_ref().map(|s| s.to_string()).into(),
icon: item.icon.as_ref().map(|s| s.to_string()).into(),
provider: ProviderType::Plugin(self.info.type_id.to_string()),
provider: self.get_provider_type(),
command: item.command.to_string(),
terminal: item.terminal,
tags: item.keywords.iter().map(|s| s.to_string()).collect(),
@@ -113,7 +123,7 @@ impl Provider for NativeProvider {
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin(self.info.type_id.to_string())
self.get_provider_type()
}
fn refresh(&mut self) {

View File

@@ -67,6 +67,18 @@
opacity: 1;
}
/* Symbolic icons - inherit text color */
.owlry-symbolic-icon {
-gtk-icon-style: symbolic;
}
/* Emoji icon - displayed as large text */
.owlry-emoji-icon {
font-size: 24px;
min-width: 32px;
min-height: 32px;
}
/* Result name */
.owlry-result-name {
font-size: var(--owlry-font-size, 14px);

View File

@@ -72,6 +72,17 @@ pub fn generate_variables_css(config: &AppearanceConfig) -> String {
css.push_str(&format!(" --owlry-badge-web: {};\n", badge_web));
}
// Widget badge colors
if let Some(ref badge_media) = config.colors.badge_media {
css.push_str(&format!(" --owlry-badge-media: {};\n", badge_media));
}
if let Some(ref badge_weather) = config.colors.badge_weather {
css.push_str(&format!(" --owlry-badge-weather: {};\n", badge_weather));
}
if let Some(ref badge_pomo) = config.colors.badge_pomo {
css.push_str(&format!(" --owlry-badge-pomo: {};\n", badge_pomo));
}
css.push_str("}\n");
css
}

View File

@@ -35,6 +35,19 @@ struct SubmenuState {
saved_search: String,
}
/// State for lazy loading results
#[derive(Default)]
struct LazyLoadState {
/// All matching results (may be more than displayed)
all_results: Vec<LaunchItem>,
/// Number of items currently displayed
displayed_count: usize,
}
/// Number of items to display initially and per batch
const INITIAL_RESULTS: usize = 15;
const LOAD_MORE_BATCH: usize = 10;
pub struct MainWindow {
window: ApplicationWindow,
search_entry: Entry,
@@ -51,6 +64,11 @@ pub struct MainWindow {
submenu_state: Rc<RefCell<SubmenuState>>,
/// Parsed tab config (ProviderTypes for cycling)
tab_order: Rc<Vec<ProviderType>>,
/// Custom prompt text (overrides dynamic placeholder when set)
#[allow(dead_code)]
custom_prompt: Option<String>,
/// Lazy loading state
lazy_state: Rc<RefCell<LazyLoadState>>,
}
impl MainWindow {
@@ -60,6 +78,7 @@ impl MainWindow {
providers: Rc<RefCell<ProviderManager>>,
frecency: Rc<RefCell<FrecencyStore>>,
filter: Rc<RefCell<ProviderFilter>>,
custom_prompt: Option<String>,
) -> Self {
let cfg = config.borrow();
@@ -111,24 +130,21 @@ impl MainWindow {
.build();
filter_tabs.add_css_class("owlry-filter-tabs");
// 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);
// Get enabled providers from filter (which respects CLI --mode/--providers or config)
// This makes tabs dynamic based on what's actually enabled
let enabled = filter.borrow().enabled_providers();
let tab_strings: Vec<String> = enabled.iter().map(|p| p.to_string()).collect();
let tab_order = Rc::new(enabled);
// Create toggle buttons for each provider (from config)
let filter_buttons = Self::create_filter_buttons(&filter_tabs, &filter, &cfg.general.tabs);
// Create toggle buttons for each enabled provider
let filter_buttons = Self::create_filter_buttons(&filter_tabs, &filter, &tab_strings);
let filter_buttons = Rc::new(RefCell::new(filter_buttons));
header_box.append(&mode_label);
header_box.append(&filter_tabs);
// Search entry with dynamic placeholder
let placeholder = Self::build_placeholder(&filter.borrow());
// Search entry with dynamic placeholder (or custom prompt if provided)
let placeholder = custom_prompt.clone().unwrap_or_else(|| Self::build_placeholder(&filter.borrow()));
let search_entry = Entry::builder()
.placeholder_text(&placeholder)
.hexpand(true)
@@ -175,6 +191,8 @@ impl MainWindow {
drop(cfg);
let lazy_state = Rc::new(RefCell::new(LazyLoadState::default()));
let main_window = Self {
window,
search_entry,
@@ -190,9 +208,12 @@ impl MainWindow {
filter_buttons,
submenu_state: Rc::new(RefCell::new(SubmenuState::default())),
tab_order,
custom_prompt,
lazy_state,
};
main_window.setup_signals();
main_window.setup_lazy_loading();
main_window.update_results("");
// Ensure search entry has focus when window is shown
@@ -232,11 +253,11 @@ impl MainWindow {
let mut results = current_results_for_auto.borrow_mut();
for type_id in &widget_ids {
if let Some(new_item) = providers_for_auto.borrow().get_widget_item(type_id) {
if let Some(existing) = results.iter_mut().find(|i| i.id == new_item.id) {
existing.name = new_item.name;
existing.description = new_item.description;
}
if let Some(new_item) = providers_for_auto.borrow().get_widget_item(type_id)
&& let Some(existing) = results.iter_mut().find(|i| i.id == new_item.id)
{
existing.name = new_item.name;
existing.description = new_item.description;
}
}
}
@@ -263,11 +284,21 @@ impl MainWindow {
}
};
let label = Self::provider_tab_label(&provider_type);
let base_label = Self::provider_tab_label(&provider_type);
// Show number hint in the label for first 9 tabs (using superscript)
let label = if idx < 9 {
let superscript = match idx + 1 {
1 => "¹", 2 => "²", 3 => "³", 4 => "", 5 => "",
6 => "", 7 => "", 8 => "", 9 => "", _ => "",
};
format!("{}{}", base_label, superscript)
} else {
base_label.to_string()
};
let shortcut = format!("Ctrl+{}", idx + 1);
let button = ToggleButton::builder()
.label(label)
.label(&label)
.tooltip_text(&shortcut)
.active(filter.borrow().is_enabled(provider_type.clone()))
.build();
@@ -525,6 +556,7 @@ impl MainWindow {
let mode_label = self.mode_label.clone();
let search_entry_for_change = self.search_entry.clone();
let submenu_state = self.submenu_state.clone();
let lazy_state = self.lazy_state.clone();
self.search_entry.connect_changed(move |entry| {
let raw_query = entry.text();
@@ -623,11 +655,21 @@ impl MainWindow {
.collect()
};
// Clear existing results
while let Some(child) = results_list.first_child() {
results_list.remove(&child);
}
for item in &results {
// Lazy loading: store all results but only display initial batch
let initial_count = INITIAL_RESULTS.min(results.len());
{
let mut lazy = lazy_state.borrow_mut();
lazy.all_results = results.clone();
lazy.displayed_count = initial_count;
}
// Display only initial batch
for item in results.iter().take(initial_count) {
let row = ResultRow::new(item);
results_list.append(&row);
}
@@ -636,7 +678,8 @@ impl MainWindow {
results_list.select_row(Some(&first_row));
}
*current_results.borrow_mut() = results;
// current_results holds only what's displayed (for selection/activation)
*current_results.borrow_mut() = results.into_iter().take(initial_count).collect();
});
// Entry activate signal (Enter key in search entry)
@@ -847,6 +890,7 @@ impl MainWindow {
// 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 => {
info!("[UI] Ctrl+number detected: {:?}", key);
if !submenu_state.borrow().active {
let idx = match key {
Key::_1 => 0,
@@ -860,7 +904,9 @@ impl MainWindow {
Key::_9 => 8,
_ => return gtk4::glib::Propagation::Proceed,
};
info!("[UI] Toggling tab at index {}", idx);
if let Some(provider) = tab_order.get(idx) {
info!("[UI] Found provider: {:?}", provider);
Self::toggle_provider_button(
provider.clone(),
&filter,
@@ -868,6 +914,8 @@ impl MainWindow {
&search_entry,
&mode_label,
);
} else {
info!("[UI] No provider at index {}, tab_order len={}", idx, tab_order.len());
}
}
gtk4::glib::Propagation::Stop
@@ -955,25 +1003,65 @@ impl MainWindow {
}
let current = filter.borrow().enabled_providers();
let all_enabled = current.len() == tab_order.len();
let next = if current.len() == 1 {
let idx = tab_order.iter().position(|p| p == &current[0]).unwrap_or(0);
if forward {
tab_order[(idx + 1) % tab_order.len()].clone()
// Cycle: All -> Provider1 -> Provider2 -> ... -> ProviderN -> All
// In "All" mode (all providers enabled), we go to first provider (forward) or last (backward)
// In single-provider mode, we go to next provider or back to All at the boundary
if all_enabled {
// Currently showing all, go to first (forward) or last (backward) single provider
let next = if forward {
tab_order[0].clone()
} else {
tab_order[(idx + tab_order.len() - 1) % tab_order.len()].clone()
tab_order[tab_order.len() - 1].clone()
};
{
let mut f = filter.borrow_mut();
f.set_single_mode(next.clone());
}
for (ptype, button) in buttons.borrow().iter() {
button.set_active(ptype == &next);
}
} else if current.len() == 1 {
let idx = tab_order.iter().position(|p| p == &current[0]).unwrap_or(0);
let at_boundary = if forward { idx == tab_order.len() - 1 } else { idx == 0 };
if at_boundary {
// At boundary, go back to "All" mode
{
let mut f = filter.borrow_mut();
for provider in tab_order {
f.enable(provider.clone());
}
}
for (_, button) in buttons.borrow().iter() {
button.set_active(true);
}
} else {
// Move to next/previous provider
let next = if forward {
tab_order[idx + 1].clone()
} else {
tab_order[idx - 1].clone()
};
{
let mut f = filter.borrow_mut();
f.set_single_mode(next.clone());
}
for (ptype, button) in buttons.borrow().iter() {
button.set_active(ptype == &next);
}
}
} else {
tab_order[0].clone()
};
{
let mut f = filter.borrow_mut();
f.set_single_mode(next.clone());
}
for (ptype, button) in buttons.borrow().iter() {
button.set_active(ptype == &next);
// Some but not all providers enabled - go to first provider
let next = tab_order[0].clone();
{
let mut f = filter.borrow_mut();
f.set_single_mode(next.clone());
}
for (ptype, button) in buttons.borrow().iter() {
button.set_active(ptype == &next);
}
}
mode_label.set_label(filter.borrow().mode_display_name());
@@ -1009,6 +1097,7 @@ impl MainWindow {
let use_frecency = cfg.providers.frecency;
drop(cfg);
// Fetch all matching results (up to max_results)
let results: Vec<LaunchItem> = if use_frecency {
self.providers
.borrow_mut()
@@ -1025,11 +1114,21 @@ impl MainWindow {
.collect()
};
// Clear existing results
while let Some(child) = self.results_list.first_child() {
self.results_list.remove(&child);
}
for item in &results {
// Store all results for lazy loading
let initial_count = INITIAL_RESULTS.min(results.len());
{
let mut lazy = self.lazy_state.borrow_mut();
lazy.all_results = results.clone();
lazy.displayed_count = initial_count;
}
// Display initial batch only
for item in results.iter().take(initial_count) {
let row = ResultRow::new(item);
self.results_list.append(&row);
}
@@ -1038,7 +1137,74 @@ impl MainWindow {
self.results_list.select_row(Some(&first_row));
}
*self.current_results.borrow_mut() = results;
// current_results holds what's currently displayed
*self.current_results.borrow_mut() = results.into_iter().take(initial_count).collect();
}
/// Set up lazy loading scroll detection
fn setup_lazy_loading(&self) {
let vadj = self.scrolled.vadjustment();
let results_list = self.results_list.clone();
let lazy_state = self.lazy_state.clone();
let current_results = self.current_results.clone();
// Load more on scroll
vadj.connect_value_changed(move |adj| {
let value = adj.value();
let upper = adj.upper();
let page_size = adj.page_size();
// Load more when near bottom (within 50px)
let near_bottom = upper > page_size && (value + page_size >= upper - 50.0);
if near_bottom {
Self::load_more_items(&lazy_state, &results_list, &current_results);
}
});
// Also load more when selecting rows near the end (keyboard navigation)
let lazy_state2 = self.lazy_state.clone();
let results_list2 = self.results_list.clone();
let current_results2 = self.current_results.clone();
self.results_list.connect_row_selected(move |_, row| {
if let Some(row) = row {
let index = row.index();
let lazy = lazy_state2.borrow();
let displayed = lazy.displayed_count;
let all_count = lazy.all_results.len();
drop(lazy);
// Load more if within 3 items of the end
if displayed < all_count && (index as usize) >= displayed.saturating_sub(3) {
Self::load_more_items(&lazy_state2, &results_list2, &current_results2);
}
}
});
}
/// Load more items from lazy state
fn load_more_items(
lazy_state: &Rc<RefCell<LazyLoadState>>,
results_list: &ListBox,
current_results: &Rc<RefCell<Vec<LaunchItem>>>,
) {
let mut lazy = lazy_state.borrow_mut();
let all_count = lazy.all_results.len();
let displayed = lazy.displayed_count;
if displayed < all_count {
// Load next batch
let new_end = (displayed + LOAD_MORE_BATCH).min(all_count);
for item in lazy.all_results[displayed..new_end].iter() {
let row = ResultRow::new(item);
results_list.append(&row);
}
lazy.displayed_count = new_end;
// Update current_results
let mut current = current_results.borrow_mut();
current.extend(lazy.all_results[displayed..new_end].iter().cloned());
}
}
/// Handle item activation - returns true if window should close
@@ -1075,7 +1241,8 @@ impl MainWindow {
debug!("[UI] Launch details: terminal={}, provider={:?}", item.terminal, item.provider);
let cmd = if item.terminal {
format!("{} -e {}", config.general.terminal_command, item.command)
let terminal = config.general.terminal_command.as_deref().unwrap_or("xterm");
format!("{} -e {}", terminal, item.command)
} else {
item.command.clone()
};

View File

@@ -7,6 +7,17 @@ pub struct ResultRow {
row: ListBoxRow,
}
/// Check if a string looks like an emoji (starts with a non-ASCII character
/// and is very short - typically 1-4 chars for complex emojis with ZWJ)
fn is_emoji_icon(s: &str) -> bool {
if s.is_empty() {
return false;
}
// Emojis are non-ASCII and typically very short (1-8 chars for complex ZWJ sequences)
let first_char = s.chars().next().unwrap();
!first_char.is_ascii() && s.chars().count() <= 8
}
impl ResultRow {
#[allow(clippy::new_ret_no_self)]
pub fn new(item: &LaunchItem) -> ListBoxRow {
@@ -26,46 +37,64 @@ impl ResultRow {
.margin_end(12)
.build();
// Icon - handle GResource paths, file paths, icon names, and fallbacks
// Icon - handle GResource paths, file paths, icon names, emojis, and fallbacks
let icon_widget: Widget = if let Some(icon_path) = &item.icon {
let img = if icon_path.starts_with("/org/owlry/launcher/icons/") {
if is_emoji_icon(icon_path) {
// Emoji character - display as text label
let emoji_label = Label::builder()
.label(icon_path)
.width_request(32)
.height_request(32)
.valign(gtk4::Align::Center)
.halign(gtk4::Align::Center)
.build();
emoji_label.add_css_class("owlry-result-icon");
emoji_label.add_css_class("owlry-emoji-icon");
emoji_label.upcast()
} else if icon_path.starts_with("/org/owlry/launcher/icons/") {
// GResource path - load from bundled resources
Image::from_resource(icon_path)
let img = Image::from_resource(icon_path);
img.set_pixel_size(32);
img.add_css_class("owlry-result-icon");
img.upcast()
} else if icon_path.starts_with('/') {
// Absolute file path
Image::from_file(icon_path)
let img = Image::from_file(icon_path);
img.set_pixel_size(32);
img.add_css_class("owlry-result-icon");
img.upcast()
} else {
// Icon theme name
Image::from_icon_name(icon_path)
};
img.set_pixel_size(32);
img.add_css_class("owlry-result-icon");
img.upcast()
let img = Image::from_icon_name(icon_path);
img.set_pixel_size(32);
img.add_css_class("owlry-result-icon");
img.upcast()
}
} else {
// Default icon based on provider type
// Default icon based on provider type (using symbolic icons for theme color support)
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::Application => "application-x-executable-symbolic",
crate::providers::ProviderType::Bookmarks => "user-bookmarks-symbolic",
crate::providers::ProviderType::Calculator => "accessories-calculator-symbolic",
crate::providers::ProviderType::Clipboard => "edit-paste-symbolic",
crate::providers::ProviderType::Command => "utilities-terminal-symbolic",
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",
// Widget providers now have icons set, but keep fallbacks
crate::providers::ProviderType::Emoji => "face-smile-symbolic",
crate::providers::ProviderType::Files => "folder-symbolic",
crate::providers::ProviderType::Scripts => "application-x-executable-symbolic",
crate::providers::ProviderType::Ssh => "network-server-symbolic",
crate::providers::ProviderType::System => "system-shutdown-symbolic",
crate::providers::ProviderType::Uuctl => "system-run-symbolic",
crate::providers::ProviderType::WebSearch => "web-browser-symbolic",
crate::providers::ProviderType::Weather => "weather-clear-symbolic",
crate::providers::ProviderType::MediaPlayer => "media-playback-start-symbolic",
crate::providers::ProviderType::Pomodoro => "alarm-symbolic",
crate::providers::ProviderType::Plugin(_) => "application-x-addon",
crate::providers::ProviderType::Plugin(_) => "application-x-addon-symbolic",
};
let img = Image::from_icon_name(default_icon);
img.set_pixel_size(32);
img.add_css_class("owlry-result-icon");
img.add_css_class("owlry-symbolic-icon");
img.upcast()
};