7 Commits

Author SHA1 Message Date
536c5c5012 chore: bump version to 0.4.1 2025-12-30 06:12:02 +01:00
abd4df6939 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>
2025-12-30 06:11:50 +01:00
43f7228be2 feat(justfile): add bump-meta and aur-publish-meta
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 03:54:13 +01:00
a1b47b8ba0 chore: bump all plugins and runtimes to 0.2.1 2025-12-30 03:49:36 +01:00
ccce9b8572 fix(justfile): handle _srcver for plugin AUR packages
Plugins use _srcver (core version) for source tarball, separate from
pkgver (plugin version). This allows independent plugin versioning
while still downloading from the core release tag.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 03:45:39 +01:00
ffb4c2f127 fix: prevent .SRCINFO creation in project root
- Use subshell for cd in aur-update-all recipe
- Add .SRCINFO to root .gitignore

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 03:40:08 +01:00
cde599db03 feat(justfile): add comprehensive release automation
- bump-all: bump plugins + runtimes together
- aur-update-all: update all 20 AUR packages
- aur-publish-all: publish all AUR packages
- release-all: complete release workflow (bump, tag, push, update AUR)
- release-core: core-only release workflow

Usage: just release-all 0.5.0 0.3.0
       (core version, plugin version)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 03:39:13 +01:00
37 changed files with 2132 additions and 265 deletions

2
.gitignore vendored
View File

@@ -11,3 +11,5 @@ aur/*/*.tar.gz
aur/*/*.tar.xz
aur/*/*.pkg.tar.*
# Keep PKGBUILD and .SRCINFO tracked
.SRCINFO
aur/

709
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-lua"
version = "0.2.0"
version = "0.2.1"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-plugin-api"
version = "0.2.0"
version = "0.2.1"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-plugin-bookmarks"
version = "0.2.0"
version = "0.2.1"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
@@ -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

@@ -1,6 +1,6 @@
[package]
name = "owlry-plugin-calculator"
version = "0.2.0"
version = "0.2.1"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-plugin-clipboard"
version = "0.2.0"
version = "0.2.1"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-plugin-emoji"
version = "0.2.0"
version = "0.2.1"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

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

@@ -1,6 +1,6 @@
[package]
name = "owlry-plugin-filesearch"
version = "0.2.0"
version = "0.2.1"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-plugin-media"
version = "0.2.0"
version = "0.2.1"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-plugin-pomodoro"
version = "0.2.0"
version = "0.2.1"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

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

@@ -1,6 +1,6 @@
[package]
name = "owlry-plugin-scripts"
version = "0.2.0"
version = "0.2.1"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-plugin-ssh"
version = "0.2.0"
version = "0.2.1"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-plugin-system"
version = "0.2.0"
version = "0.2.1"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-plugin-systemd"
version = "0.2.0"
version = "0.2.1"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-plugin-weather"
version = "0.2.0"
version = "0.2.1"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

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

@@ -1,6 +1,6 @@
[package]
name = "owlry-plugin-websearch"
version = "0.2.0"
version = "0.2.1"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-rune"
version = "0.2.0"
version = "0.2.1"
edition = "2024"
rust-version = "1.90"
description = "Rune scripting runtime for owlry plugins"

View File

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

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()
};

344
data/themes/apex-neon.css Normal file
View File

@@ -0,0 +1,344 @@
/*
* Owlry - Apex Neon Theme
* "State over Decoration."
*
* A high-contrast dark theme built for focus and clinical clarity.
* Color exists to signal STATE, not to decorate space.
*
* Author: S0wlz (Owlibou)
*
* ─────────────────────────────────────────────────────────────────
* APEX DNA - Semantic Color Roles:
*
* RED is the Predator: Active intent, cursor, current location, critical errors
* CYAN is Informational: Technical data, links, neutral highlights
* PURPLE is Sacred: Root access, special modes, exceptional states
* GREEN is Success: Completion, OK states, positive feedback
* YELLOW is Warning: Caution, load states, attention needed
*
* Rule: If a UI element is not important, it does not glow.
* ─────────────────────────────────────────────────────────────────
*
* Core Palette:
* - Void Black: #050505 (absolute background)
* - Dark Surface: #141414 (inputs, inactive elements)
* - Light Surface: #262626 (separators, borders)
* - Stark White: #ededed (primary text)
* - Muted: #737373 (secondary text)
* - Razor Red: #ff0044 (THE accent - focus, cursor, selection)
* - Electric Cyan: #00eaff (info, links, technical)
* - Sacred Purple: #9d00ff (special, root, elevated)
* - Neon Green: #00ff99 (success, OK)
* - Warning Yellow: #ffb700 (warning, caution)
*
* Bright Escalations:
* - Alert Red: #ff8899 (distinguishable from cursor)
* - Active Cyan: #5af3ff (active info)
* - Active Green: #2bffb2 (active success)
* - Urgent Yellow: #ffd24d (urgent warning)
* - Elevated Purple:#c84dff (elevated special)
*
* Usage: Set theme = "apex-neon" in config.toml
*/
:root {
/* Core surfaces */
--owlry-bg: #050505;
--owlry-bg-secondary: #141414;
--owlry-border: #262626;
--owlry-text: #ededed;
--owlry-text-secondary: #737373;
/* The Predator - primary accent */
--owlry-accent: #ff0044;
--owlry-accent-bright: #ff8899;
/* Provider badges - mapped to Apex semantics */
--owlry-badge-app: #00eaff; /* Cyan: apps are informational */
--owlry-badge-bookmark: #ffb700; /* Yellow: bookmarks need attention */
--owlry-badge-calc: #ffd24d; /* Bright Yellow: calculator results */
--owlry-badge-clip: #9d00ff; /* Purple: clipboard is special */
--owlry-badge-cmd: #9d00ff; /* Purple: commands are elevated */
--owlry-badge-dmenu: #00ff99; /* Green: dmenu is success/pipe */
--owlry-badge-emoji: #c84dff; /* Bright Purple: emoji is special */
--owlry-badge-file: #5af3ff; /* Bright Cyan: file search is active info */
--owlry-badge-script: #2bffb2; /* Bright Green: scripts execute successfully */
--owlry-badge-ssh: #00eaff; /* Cyan: SSH is technical/info */
--owlry-badge-sys: #ff0044; /* Red: system actions are critical */
--owlry-badge-uuctl: #ffb700; /* Yellow: uuctl requires attention */
--owlry-badge-web: #00eaff; /* Cyan: web is informational */
/* Widget badges */
--owlry-badge-media: #c84dff; /* Bright Purple: media is special */
--owlry-badge-weather: #5af3ff; /* Bright Cyan: weather is active info */
--owlry-badge-pomo: #ff8899; /* Alert Red: pomodoro demands attention */
}
.owlry-main {
background-color: rgba(5, 5, 5, 0.98);
border: 1px solid rgba(38, 38, 38, 0.8);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8),
0 0 0 1px rgba(255, 0, 68, 0.1);
}
.owlry-search {
background-color: rgba(20, 20, 20, 0.9);
border: 2px solid rgba(38, 38, 38, 0.8);
color: var(--owlry-text);
caret-color: var(--owlry-accent);
}
.owlry-search:focus {
border-color: var(--owlry-accent);
box-shadow: 0 0 0 2px rgba(255, 0, 68, 0.3);
}
.owlry-result-row:hover {
background-color: rgba(20, 20, 20, 0.8);
}
.owlry-result-row:selected {
background-color: rgba(255, 0, 68, 0.15);
border-left: 3px solid var(--owlry-accent);
}
.owlry-result-row:selected .owlry-result-name {
color: var(--owlry-accent-bright);
}
.owlry-result-row:selected .owlry-result-icon {
color: var(--owlry-accent);
}
/* Provider badges - styled per Apex semantics */
.owlry-badge-app {
background-color: rgba(0, 234, 255, 0.15);
color: var(--owlry-badge-app);
}
.owlry-badge-bookmark {
background-color: rgba(255, 183, 0, 0.15);
color: var(--owlry-badge-bookmark);
}
.owlry-badge-calc {
background-color: rgba(255, 210, 77, 0.15);
color: var(--owlry-badge-calc);
}
.owlry-badge-clip {
background-color: rgba(157, 0, 255, 0.15);
color: var(--owlry-badge-clip);
}
.owlry-badge-cmd {
background-color: rgba(157, 0, 255, 0.15);
color: var(--owlry-badge-cmd);
}
.owlry-badge-dmenu {
background-color: rgba(0, 255, 153, 0.15);
color: var(--owlry-badge-dmenu);
}
.owlry-badge-emoji {
background-color: rgba(200, 77, 255, 0.15);
color: var(--owlry-badge-emoji);
}
.owlry-badge-file {
background-color: rgba(90, 243, 255, 0.15);
color: var(--owlry-badge-file);
}
.owlry-badge-script {
background-color: rgba(43, 255, 178, 0.15);
color: var(--owlry-badge-script);
}
.owlry-badge-ssh {
background-color: rgba(0, 234, 255, 0.15);
color: var(--owlry-badge-ssh);
}
.owlry-badge-sys {
background-color: rgba(255, 0, 68, 0.15);
color: var(--owlry-badge-sys);
}
.owlry-badge-uuctl {
background-color: rgba(255, 183, 0, 0.15);
color: var(--owlry-badge-uuctl);
}
.owlry-badge-web {
background-color: rgba(0, 234, 255, 0.15);
color: var(--owlry-badge-web);
}
/* Widget badges */
.owlry-badge-media {
background-color: rgba(200, 77, 255, 0.15);
color: var(--owlry-badge-media);
}
.owlry-badge-weather {
background-color: rgba(90, 243, 255, 0.15);
color: var(--owlry-badge-weather);
}
.owlry-badge-pomo {
background-color: rgba(255, 136, 153, 0.15);
color: var(--owlry-badge-pomo);
}
/* Filter button - default uses The Predator */
.owlry-filter-button:checked {
background-color: rgba(255, 0, 68, 0.2);
color: var(--owlry-accent);
border-color: rgba(255, 0, 68, 0.5);
}
/* Provider-specific filter buttons - follow Apex semantics */
.owlry-filter-app:checked {
background-color: rgba(0, 234, 255, 0.15);
color: var(--owlry-badge-app);
border-color: rgba(0, 234, 255, 0.5);
}
.owlry-filter-bookmark:checked {
background-color: rgba(255, 183, 0, 0.15);
color: var(--owlry-badge-bookmark);
border-color: rgba(255, 183, 0, 0.5);
}
.owlry-filter-calc:checked {
background-color: rgba(255, 210, 77, 0.15);
color: var(--owlry-badge-calc);
border-color: rgba(255, 210, 77, 0.5);
}
.owlry-filter-clip:checked {
background-color: rgba(157, 0, 255, 0.15);
color: var(--owlry-badge-clip);
border-color: rgba(157, 0, 255, 0.5);
}
.owlry-filter-cmd:checked {
background-color: rgba(157, 0, 255, 0.15);
color: var(--owlry-badge-cmd);
border-color: rgba(157, 0, 255, 0.5);
}
.owlry-filter-dmenu:checked {
background-color: rgba(0, 255, 153, 0.15);
color: var(--owlry-badge-dmenu);
border-color: rgba(0, 255, 153, 0.5);
}
.owlry-filter-emoji:checked {
background-color: rgba(200, 77, 255, 0.15);
color: var(--owlry-badge-emoji);
border-color: rgba(200, 77, 255, 0.5);
}
.owlry-filter-file:checked {
background-color: rgba(90, 243, 255, 0.15);
color: var(--owlry-badge-file);
border-color: rgba(90, 243, 255, 0.5);
}
.owlry-filter-script:checked {
background-color: rgba(43, 255, 178, 0.15);
color: var(--owlry-badge-script);
border-color: rgba(43, 255, 178, 0.5);
}
.owlry-filter-ssh:checked {
background-color: rgba(0, 234, 255, 0.15);
color: var(--owlry-badge-ssh);
border-color: rgba(0, 234, 255, 0.5);
}
.owlry-filter-sys:checked {
background-color: rgba(255, 0, 68, 0.15);
color: var(--owlry-badge-sys);
border-color: rgba(255, 0, 68, 0.5);
}
.owlry-filter-uuctl:checked {
background-color: rgba(255, 183, 0, 0.15);
color: var(--owlry-badge-uuctl);
border-color: rgba(255, 183, 0, 0.5);
}
.owlry-filter-web:checked {
background-color: rgba(0, 234, 255, 0.15);
color: var(--owlry-badge-web);
border-color: rgba(0, 234, 255, 0.5);
}
/* Widget filter buttons */
.owlry-filter-media:checked {
background-color: rgba(200, 77, 255, 0.15);
color: var(--owlry-badge-media);
border-color: rgba(200, 77, 255, 0.5);
}
.owlry-filter-weather:checked {
background-color: rgba(90, 243, 255, 0.15);
color: var(--owlry-badge-weather);
border-color: rgba(90, 243, 255, 0.5);
}
.owlry-filter-pomodoro:checked {
background-color: rgba(255, 136, 153, 0.15);
color: var(--owlry-badge-pomo);
border-color: rgba(255, 136, 153, 0.5);
}
/* Scrollbar - subtle in Void, The Predator on active */
scrollbar slider {
background-color: rgba(38, 38, 38, 0.8);
}
scrollbar slider:hover {
background-color: rgba(64, 64, 64, 0.9);
}
scrollbar slider:active {
background-color: var(--owlry-accent);
}
/* Text selection - Apex Hard Rule: black text on red (target locked) */
selection {
background-color: var(--owlry-accent);
color: #050505;
}
/* Mode indicator - The Predator marks current mode */
.owlry-mode-indicator {
background-color: rgba(255, 0, 68, 0.2);
color: var(--owlry-accent);
border: 1px solid rgba(255, 0, 68, 0.3);
}
/* Hints bar */
.owlry-hints {
border-top: 1px solid rgba(38, 38, 38, 0.8);
}
.owlry-hints-label {
color: var(--owlry-text-secondary);
}
/* Tag badges in results */
.owlry-tag-badge {
background-color: rgba(38, 38, 38, 0.6);
color: var(--owlry-text-secondary);
}
.owlry-result-row:selected .owlry-tag-badge {
background-color: rgba(255, 136, 153, 0.25);
color: var(--owlry-accent-bright);
}

181
justfile
View File

@@ -60,6 +60,10 @@ install-local:
sudo mkdir -p /usr/lib/owlry/plugins
sudo mkdir -p /usr/lib/owlry/runtimes
echo "Cleaning up stale files..."
# Remove runtime files that may have ended up in plugins dir (from old installs)
sudo rm -f /usr/lib/owlry/plugins/libowlry_lua.so /usr/lib/owlry/plugins/libowlry_rune.so
echo "Installing core binary..."
sudo install -Dm755 target/release/owlry /usr/bin/owlry
@@ -152,6 +156,50 @@ bump-plugins new_version:
git commit -m "chore(plugins): bump all plugins to {{new_version}}"
echo "All plugins bumped to {{new_version}}"
# Bump meta-packages (no crate, just AUR version)
bump-meta new_version:
#!/usr/bin/env bash
set -euo pipefail
for pkg in owlry-essentials owlry-tools owlry-widgets owlry-full; do
file="aur/$pkg/PKGBUILD"
old=$(grep '^pkgver=' "$file" | sed 's/pkgver=//')
if [ "$old" != "{{new_version}}" ]; then
echo "Bumping $pkg from $old to {{new_version}}"
sed -i 's/^pkgver=.*/pkgver={{new_version}}/' "$file"
(cd "aur/$pkg" && makepkg --printsrcinfo > .SRCINFO)
fi
done
echo "Meta-packages bumped to {{new_version}}"
# Bump all non-core crates (plugins + runtimes) to same version
bump-all new_version:
#!/usr/bin/env bash
set -euo pipefail
# Bump plugins
for toml in crates/owlry-plugin-*/Cargo.toml; do
crate=$(basename $(dirname "$toml"))
old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
if [ "$old" != "{{new_version}}" ]; then
echo "Bumping $crate from $old to {{new_version}}"
sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml"
fi
done
# Bump runtimes
for toml in crates/owlry-lua/Cargo.toml crates/owlry-rune/Cargo.toml; do
if [ -f "$toml" ]; then
crate=$(basename $(dirname "$toml"))
old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
if [ "$old" != "{{new_version}}" ]; then
echo "Bumping $crate from $old to {{new_version}}"
sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml"
fi
fi
done
cargo check --workspace
git add crates/owlry-plugin-*/Cargo.toml crates/owlry-lua/Cargo.toml crates/owlry-rune/Cargo.toml Cargo.lock
git commit -m "chore: bump all plugins and runtimes to {{new_version}}"
echo "All plugins and runtimes bumped to {{new_version}}"
# Bump core version (usage: just bump 0.2.0)
bump new_version:
#!/usr/bin/env bash
@@ -246,10 +294,13 @@ aur-update-pkg pkg:
exit 1
fi
# Determine crate name (strip owlry- prefix for meta-packages)
url="https://somegit.dev/Owlibou/owlry"
core_ver="{{version}}"
# Determine crate version
case "{{pkg}}" in
owlry-essentials|owlry-tools|owlry-widgets|owlry-full)
# Meta-packages have no crate, use PKGBUILD version
# Meta-packages have no crate, keep current version
crate_ver=$(grep '^pkgver=' "$aur_dir/PKGBUILD" | sed 's/pkgver=//')
;;
*)
@@ -264,14 +315,23 @@ aur-update-pkg pkg:
esac
cd "$aur_dir"
url="https://somegit.dev/Owlibou/owlry"
echo "Updating {{pkg}} PKGBUILD to version $crate_ver"
echo "Updating {{pkg}} PKGBUILD:"
echo " pkgver=$crate_ver"
sed -i "s/^pkgver=.*/pkgver=$crate_ver/" PKGBUILD
sed -i 's/^pkgrel=.*/pkgrel=1/' PKGBUILD
# Update checksums for packages that download source
if grep -q "^source=" PKGBUILD; then
# Update _srcver for plugins/runtimes (they download from core version tag)
if grep -q "^_srcver=" PKGBUILD; then
echo " _srcver=$core_ver"
sed -i "s/^_srcver=.*/_srcver=$core_ver/" PKGBUILD
# Update checksum using core version
echo "Updating checksums (from v$core_ver)..."
b2sum=$(curl -sL "$url/archive/v$core_ver.tar.gz" | b2sum | cut -d' ' -f1)
sed -i "s/^b2sums=.*/b2sums=('$b2sum')/" PKGBUILD
elif grep -q "^source=" PKGBUILD; then
# Core package uses pkgver for source
echo "Updating checksums..."
b2sum=$(curl -sL "$url/archive/v$crate_ver.tar.gz" | b2sum | cut -d' ' -f1)
sed -i "s/^b2sums=.*/b2sums=('$b2sum')/" PKGBUILD
@@ -281,9 +341,9 @@ aur-update-pkg pkg:
echo "Generating .SRCINFO..."
makepkg --printsrcinfo > .SRCINFO
git diff
git diff --stat
echo ""
echo "{{pkg}} updated to $crate_ver. Run 'just aur-publish-pkg {{pkg}}' to publish."
echo "{{pkg}} updated. Run 'just aur-publish-pkg {{pkg}}' to publish."
# Publish a specific AUR package
aur-publish-pkg pkg:
@@ -340,6 +400,16 @@ aur-publish-plugins:
echo ""
done
# Publish all meta-packages
aur-publish-meta:
#!/usr/bin/env bash
set -euo pipefail
for pkg in owlry-essentials owlry-tools owlry-widgets owlry-full; do
echo "=== Publishing $pkg ==="
just aur-publish-pkg "$pkg"
done
echo "All meta-packages published!"
# List all AUR packages with their versions
aur-status:
#!/usr/bin/env bash
@@ -357,8 +427,61 @@ aur-status:
fi
done
# Full release workflow (bump + tag + aur)
release-full new_version: (bump new_version)
# Update ALL AUR packages (core + plugins + runtimes + meta)
aur-update-all:
#!/usr/bin/env bash
set -euo pipefail
echo "=== Updating core ==="
just aur-update
echo ""
echo "=== Updating plugins ==="
for dir in aur/owlry-plugin-*/; do
pkg=$(basename "$dir")
echo "--- $pkg ---"
just aur-update-pkg "$pkg"
done
echo ""
echo "=== Updating runtimes ==="
just aur-update-pkg owlry-lua
just aur-update-pkg owlry-rune
echo ""
echo "=== Updating meta-packages ==="
for pkg in owlry-essentials owlry-tools owlry-widgets owlry-full; do
echo "--- $pkg ---"
# Use subshell to avoid cd issues
(cd "aur/$pkg" && makepkg --printsrcinfo > .SRCINFO)
done
echo ""
echo "All AUR packages updated. Run 'just aur-publish-all' to publish."
# Publish ALL AUR packages
aur-publish-all:
#!/usr/bin/env bash
set -euo pipefail
echo "=== Publishing core ==="
just aur-publish
echo ""
echo "=== Publishing plugins ==="
for dir in aur/owlry-plugin-*/; do
pkg=$(basename "$dir")
echo "--- $pkg ---"
just aur-publish-pkg "$pkg"
done
echo ""
echo "=== Publishing runtimes ==="
just aur-publish-pkg owlry-lua
just aur-publish-pkg owlry-rune
echo ""
echo "=== Publishing meta-packages ==="
for pkg in owlry-essentials owlry-tools owlry-widgets owlry-full; do
echo "--- $pkg ---"
just aur-publish-pkg "$pkg"
done
echo ""
echo "All AUR packages published!"
# Full release workflow for core only (bump + tag + aur)
release-core new_version: (bump new_version)
#!/usr/bin/env bash
set -euo pipefail
@@ -376,5 +499,41 @@ release-full new_version: (bump new_version)
just aur-update
echo ""
echo "Release v{{new_version}} prepared!"
echo "Core release v{{new_version}} prepared!"
echo "Review AUR changes, then run 'just aur-publish'"
# Full release workflow for everything (core + plugins + runtimes)
# Usage: just release-all 0.5.0 0.3.0
# First arg is core version, second is plugins/runtimes version
release-all core_version plugin_version:
#!/usr/bin/env bash
set -euo pipefail
echo "=== Bumping versions ==="
just bump {{core_version}}
just bump-all {{plugin_version}}
echo ""
echo "=== Pushing to origin ==="
git push
echo ""
echo "=== Creating tag ==="
just tag
echo "Waiting for tag to propagate..."
sleep 2
echo ""
echo "=== Updating all AUR packages ==="
just aur-update-all
echo ""
echo "=========================================="
echo "Release prepared!"
echo " Core: v{{core_version}}"
echo " Plugins/Runtimes: v{{plugin_version}}"
echo ""
echo "Review changes with 'just aur-status'"
echo "Then publish with 'just aur-publish-all'"
echo "=========================================="