diff --git a/Cargo.lock b/Cargo.lock index 4883465..8501b66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.21" @@ -90,6 +99,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + [[package]] name = "bytes" version = "1.11.0" @@ -145,6 +160,20 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.5.53" @@ -191,6 +220,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "dirs" version = "5.0.1" @@ -267,6 +302,12 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "freedesktop-desktop-entry" version = "0.7.19" @@ -694,6 +735,30 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "indexmap" version = "2.12.1" @@ -710,6 +775,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + [[package]] name = "jiff" version = "0.2.17" @@ -734,6 +805,16 @@ dependencies = [ "syn", ] +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "khronos_api" version = "3.1.0" @@ -805,6 +886,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "meval" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f79496a5651c8d57cd033c5add8ca7ee4e3d5f7587a4777484640d9cb60392d9" +dependencies = [ + "fnv", + "nom", +] + [[package]] name = "mio" version = "1.1.1" @@ -816,6 +907,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nom" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5b8c256fd9471521bcb84c3cdba98921497f1a331cbc15b8030fc63b82050ce" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "objc" version = "0.2.7" @@ -845,6 +951,12 @@ dependencies = [ "objc", ] +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -859,8 +971,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "owlry" -version = "0.1.8" +version = "0.1.9" dependencies = [ + "chrono", "clap", "dirs", "env_logger", @@ -870,7 +983,9 @@ dependencies = [ "gtk4-layer-shell", "libc", "log", + "meval", "serde", + "serde_json", "thiserror 2.0.17", "tokio", "toml 0.8.23", @@ -1009,6 +1124,12 @@ dependencies = [ "semver", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "semver" version = "1.0.27" @@ -1045,6 +1166,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.148" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -1318,6 +1452,51 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1340,12 +1519,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -1514,3 +1746,9 @@ name = "xml-rs" version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "zmij" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d6085d62852e35540689d1f97ad663e3971fc19cf5eceab364d62c646ea167" diff --git a/Cargo.toml b/Cargo.toml index 1775845..79d0f27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "owlry" -version = "0.1.8" +version = "0.1.9" edition = "2024" rust-version = "1.90" description = "A lightweight, owl-themed application launcher for Wayland" @@ -46,6 +46,15 @@ toml = "0.8" # CLI argument parsing clap = { version = "4", features = ["derive"] } +# Math expression evaluation for calculator +meval = "0.2" + +# JSON serialization for data persistence +serde_json = "1" + +# Date/time for frecency calculations +chrono = { version = "0.4", features = ["serde"] } + [profile.release] lto = true codegen-units = 1 diff --git a/config.example.toml b/config.example.toml index 1be1e74..cf2d159 100644 --- a/config.example.toml +++ b/config.example.toml @@ -32,6 +32,7 @@ border_radius = 12 # accent = "#7aa2f7" # accent_bright = "#89b4fa" # badge_app = "#9ece6a" +# badge_calc = "#e0af68" # badge_cmd = "#7aa2f7" # badge_dmenu = "#bb9af7" # badge_uuctl = "#f7768e" @@ -40,3 +41,10 @@ border_radius = 12 applications = true commands = true uuctl = true + +# Calculator provider (type "= 5+3" or "calc 5+3") +calculator = true + +# Frecency: boost frequently/recently used items in search results +frecency = true +frecency_weight = 0.3 # 0.0 = disabled, 1.0 = strong boost diff --git a/resources/base.css b/resources/base.css index ab93575..0c57997 100644 --- a/resources/base.css +++ b/resources/base.css @@ -106,6 +106,11 @@ color: var(--owlry-badge-app, @blue_3); } +.owlry-badge-calc { + background-color: alpha(var(--owlry-badge-calc, @yellow_3), 0.2); + color: var(--owlry-badge-calc, @yellow_3); +} + .owlry-badge-cmd { background-color: alpha(var(--owlry-badge-cmd, @purple_3), 0.2); color: var(--owlry-badge-cmd, @purple_3); @@ -166,6 +171,12 @@ border-color: alpha(var(--owlry-badge-app, @blue_3), 0.4); } +.owlry-filter-calc:checked { + background-color: alpha(var(--owlry-badge-calc, @yellow_3), 0.2); + color: var(--owlry-badge-calc, @yellow_3); + border-color: alpha(var(--owlry-badge-calc, @yellow_3), 0.4); +} + .owlry-filter-cmd:checked { background-color: alpha(var(--owlry-badge-cmd, @purple_3), 0.2); color: var(--owlry-badge-cmd, @purple_3); diff --git a/src/app.rs b/src/app.rs index ddec144..0d2a51d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,5 +1,6 @@ use crate::cli::CliArgs; use crate::config::Config; +use crate::data::FrecencyStore; use crate::filter::ProviderFilter; use crate::providers::ProviderManager; use crate::theme; @@ -40,6 +41,7 @@ impl OwlryApp { let config = Rc::new(RefCell::new(Config::load_or_default())); let providers = Rc::new(RefCell::new(ProviderManager::new())); + let frecency = Rc::new(RefCell::new(FrecencyStore::load_or_default())); // Create filter from CLI args and config let filter = ProviderFilter::new( @@ -49,7 +51,7 @@ impl OwlryApp { ); let filter = Rc::new(RefCell::new(filter)); - let window = MainWindow::new(app, config.clone(), providers.clone(), filter.clone()); + let window = MainWindow::new(app, config.clone(), providers.clone(), frecency.clone(), filter.clone()); // Set up layer shell for Wayland overlay behavior window.init_layer_shell(); diff --git a/src/config/mod.rs b/src/config/mod.rs index 7aca0a4..5d985e4 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -35,6 +35,7 @@ pub struct ThemeColors { pub accent_bright: Option, // Provider badge colors pub badge_app: Option, + pub badge_calc: Option, pub badge_cmd: Option, pub badge_dmenu: Option, pub badge_uuctl: Option, @@ -59,6 +60,23 @@ pub struct ProvidersConfig { pub applications: bool, pub commands: bool, pub uuctl: bool, + /// Enable calculator provider (= expression or calc expression) + #[serde(default = "default_true")] + pub calculator: bool, + /// Enable frecency-based result ranking + #[serde(default = "default_true")] + pub frecency: bool, + /// Weight for frecency boost (0.0 = disabled, 1.0 = strong boost) + #[serde(default = "default_frecency_weight")] + pub frecency_weight: f64, +} + +fn default_true() -> bool { + true +} + +fn default_frecency_weight() -> f64 { + 0.3 } /// Detect the best launch wrapper for the current session @@ -172,6 +190,9 @@ impl Default for Config { applications: true, commands: true, uuctl: true, + calculator: true, + frecency: true, + frecency_weight: 0.3, }, } } diff --git a/src/data/frecency.rs b/src/data/frecency.rs new file mode 100644 index 0000000..357cb70 --- /dev/null +++ b/src/data/frecency.rs @@ -0,0 +1,223 @@ +use chrono::{DateTime, Utc}; +use log::{debug, info, warn}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; + +/// A single frecency entry tracking launch count and recency +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FrecencyEntry { + pub launch_count: u32, + pub last_launch: DateTime, +} + +/// Persistent frecency data store +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FrecencyData { + pub version: u32, + pub entries: HashMap, +} + +impl Default for FrecencyData { + fn default() -> Self { + Self { + version: 1, + entries: HashMap::new(), + } + } +} + +/// Frecency store for tracking and boosting recently/frequently used items +pub struct FrecencyStore { + data: FrecencyData, + path: PathBuf, + dirty: bool, +} + +impl FrecencyStore { + /// Create a new frecency store, loading existing data if available + pub fn new() -> Self { + let path = Self::data_path(); + let data = Self::load_from_path(&path).unwrap_or_default(); + + info!("Frecency store loaded with {} entries", data.entries.len()); + + Self { + data, + path, + dirty: false, + } + } + + /// Alias for new() - loads from disk or creates default + pub fn load_or_default() -> Self { + Self::new() + } + + /// Get the path to the frecency data file + fn data_path() -> PathBuf { + dirs::data_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("owlry") + .join("frecency.json") + } + + /// Load frecency data from a file + fn load_from_path(path: &PathBuf) -> Option { + if !path.exists() { + debug!("Frecency file not found at {:?}", path); + return None; + } + + let content = std::fs::read_to_string(path).ok()?; + match serde_json::from_str(&content) { + Ok(data) => Some(data), + Err(e) => { + warn!("Failed to parse frecency data: {}", e); + None + } + } + } + + /// Save frecency data to disk + pub fn save(&mut self) -> Result<(), Box> { + if !self.dirty { + return Ok(()); + } + + // Ensure directory exists + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent)?; + } + + let content = serde_json::to_string_pretty(&self.data)?; + std::fs::write(&self.path, content)?; + self.dirty = false; + + debug!("Frecency data saved to {:?}", self.path); + Ok(()) + } + + /// Record a launch event for an item + pub fn record_launch(&mut self, item_id: &str) { + let now = Utc::now(); + + let entry = self + .data + .entries + .entry(item_id.to_string()) + .or_insert(FrecencyEntry { + launch_count: 0, + last_launch: now, + }); + + entry.launch_count += 1; + entry.last_launch = now; + self.dirty = true; + + debug!( + "Recorded launch for '{}': count={}, last={}", + item_id, entry.launch_count, entry.last_launch + ); + + // Auto-save after recording + if let Err(e) = self.save() { + warn!("Failed to save frecency data: {}", e); + } + } + + /// Calculate frecency score for an item + /// Uses Firefox-style algorithm: score = launch_count * recency_weight + pub fn get_score(&self, item_id: &str) -> f64 { + match self.data.entries.get(item_id) { + Some(entry) => Self::calculate_frecency(entry.launch_count, entry.last_launch), + None => 0.0, + } + } + + /// Calculate frecency using Firefox-style algorithm + fn calculate_frecency(launch_count: u32, last_launch: DateTime) -> f64 { + let now = Utc::now(); + let age = now.signed_duration_since(last_launch); + let age_days = age.num_hours() as f64 / 24.0; + + // Recency weight based on how recently the item was used + let recency_weight = if age_days < 1.0 { + 100.0 // Today + } else if age_days < 7.0 { + 70.0 // This week + } else if age_days < 30.0 { + 50.0 // This month + } else if age_days < 90.0 { + 30.0 // This quarter + } else { + 10.0 // Older + }; + + launch_count as f64 * recency_weight + } + + /// Get all entries (for debugging/display) + #[allow(dead_code)] + pub fn entries(&self) -> &HashMap { + &self.data.entries + } + + /// Clear all frecency data + #[allow(dead_code)] + pub fn clear(&mut self) { + self.data.entries.clear(); + self.dirty = true; + } +} + +impl Default for FrecencyStore { + fn default() -> Self { + Self::new() + } +} + +impl Drop for FrecencyStore { + fn drop(&mut self) { + // Attempt to save on drop + if let Err(e) = self.save() { + warn!("Failed to save frecency data on drop: {}", e); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_frecency_calculation() { + let now = Utc::now(); + + // Recent launch should have high score + let score_today = FrecencyStore::calculate_frecency(10, now); + assert!(score_today > 900.0); // 10 * 100 + + // Older launch should have lower score + let week_ago = now - chrono::Duration::days(5); + let score_week = FrecencyStore::calculate_frecency(10, week_ago); + assert!(score_week < score_today); + assert!(score_week > 600.0); // 10 * 70 + + // Much older launch + let month_ago = now - chrono::Duration::days(45); + let score_month = FrecencyStore::calculate_frecency(10, month_ago); + assert!(score_month < score_week); + } + + #[test] + fn test_launch_count_matters() { + let now = Utc::now(); + + let score_few = FrecencyStore::calculate_frecency(2, now); + let score_many = FrecencyStore::calculate_frecency(20, now); + + assert!(score_many > score_few); + assert!((score_many / score_few - 10.0).abs() < 0.1); // Should be ~10x + } +} diff --git a/src/data/mod.rs b/src/data/mod.rs new file mode 100644 index 0000000..8fc1d1b --- /dev/null +++ b/src/data/mod.rs @@ -0,0 +1,3 @@ +mod frecency; + +pub use frecency::FrecencyStore; diff --git a/src/filter.rs b/src/filter.rs index 1a0090e..d9b0d6d 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -129,6 +129,8 @@ impl ProviderFilter { let prefixes = [ (":app ", ProviderType::Application), (":apps ", ProviderType::Application), + (":calc ", ProviderType::Calculator), + (":calculator ", ProviderType::Calculator), (":cmd ", ProviderType::Command), (":command ", ProviderType::Command), (":uuctl ", ProviderType::Uuctl), @@ -147,6 +149,8 @@ impl ProviderFilter { let partial_prefixes = [ (":app", ProviderType::Application), (":apps", ProviderType::Application), + (":calc", ProviderType::Calculator), + (":calculator", ProviderType::Calculator), (":cmd", ProviderType::Command), (":command", ProviderType::Command), (":uuctl", ProviderType::Uuctl), @@ -172,9 +176,10 @@ impl ProviderFilter { let mut providers: Vec<_> = self.enabled.iter().copied().collect(); providers.sort_by_key(|p| match p { ProviderType::Application => 0, - ProviderType::Command => 1, - ProviderType::Uuctl => 2, - ProviderType::Dmenu => 3, + ProviderType::Calculator => 1, + ProviderType::Command => 2, + ProviderType::Uuctl => 3, + ProviderType::Dmenu => 4, }); providers } @@ -184,6 +189,7 @@ impl ProviderFilter { if let Some(prefix) = self.active_prefix { return match prefix { ProviderType::Application => "Apps", + ProviderType::Calculator => "Calc", ProviderType::Command => "Commands", ProviderType::Uuctl => "uuctl", ProviderType::Dmenu => "dmenu", @@ -194,6 +200,7 @@ impl ProviderFilter { if enabled.len() == 1 { match enabled[0] { ProviderType::Application => "Apps", + ProviderType::Calculator => "Calc", ProviderType::Command => "Commands", ProviderType::Uuctl => "uuctl", ProviderType::Dmenu => "dmenu", diff --git a/src/main.rs b/src/main.rs index 7eca455..bd4107b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod app; mod cli; mod config; +mod data; mod filter; mod providers; mod theme; diff --git a/src/providers/calculator.rs b/src/providers/calculator.rs new file mode 100644 index 0000000..c3f09e0 --- /dev/null +++ b/src/providers/calculator.rs @@ -0,0 +1,191 @@ +use super::{LaunchItem, Provider, ProviderType}; +use log::debug; + +/// Calculator provider for evaluating math expressions +/// Syntax: `= expression` or `calc expression` +pub struct CalculatorProvider { + /// Cached result from last evaluation + cached_result: Option, +} + +impl CalculatorProvider { + pub fn new() -> Self { + Self { + cached_result: None, + } + } + + /// Check if a query is a calculator expression + pub fn is_calculator_query(query: &str) -> bool { + let trimmed = query.trim(); + trimmed.starts_with("= ") || trimmed.starts_with("calc ") + } + + /// Extract the expression from a calculator query + fn extract_expression(query: &str) -> Option<&str> { + let trimmed = query.trim(); + if let Some(expr) = trimmed.strip_prefix("= ") { + Some(expr.trim()) + } else if let Some(expr) = trimmed.strip_prefix("calc ") { + Some(expr.trim()) + } else { + None + } + } + + /// Evaluate an expression and return a LaunchItem result + pub fn evaluate(&mut self, query: &str) -> Option { + let expr = Self::extract_expression(query)?; + + if expr.is_empty() { + return None; + } + + debug!("Evaluating expression: {}", expr); + + match meval::eval_str(expr) { + Ok(result) => { + // Format result nicely + let result_str = if result.fract() == 0.0 && result.abs() < 1e15 { + // Integer result + format!("{}", result as i64) + } else { + // Float result with reasonable precision + let formatted = format!("{:.10}", result); + // Trim trailing zeros + formatted.trim_end_matches('0').trim_end_matches('.').to_string() + }; + + let item = LaunchItem { + id: format!("calc:{}", expr), + name: result_str.clone(), + description: Some(format!("= {}", expr)), + icon: Some("accessories-calculator".to_string()), + provider: ProviderType::Calculator, + // Copy result to clipboard using wl-copy + command: format!("sh -c 'echo -n \"{}\" | wl-copy'", result_str), + terminal: false, + }; + + debug!("Calculator result: {} = {}", expr, result_str); + self.cached_result = Some(item.clone()); + Some(item) + } + Err(e) => { + debug!("Calculator error for '{}': {}", expr, e); + None + } + } + } +} + +impl Provider for CalculatorProvider { + fn name(&self) -> &str { + "Calculator" + } + + fn provider_type(&self) -> ProviderType { + ProviderType::Calculator + } + + fn refresh(&mut self) { + // Calculator doesn't need refresh - it evaluates on-demand + self.cached_result = None; + } + + fn items(&self) -> &[LaunchItem] { + // Calculator is a dynamic provider - items are generated from query + // Return cached result if available (for UI display) + match &self.cached_result { + Some(item) => std::slice::from_ref(item), + None => &[], + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_calculator_query() { + assert!(CalculatorProvider::is_calculator_query("= 5+3")); + assert!(CalculatorProvider::is_calculator_query("calc 5+3")); + assert!(CalculatorProvider::is_calculator_query(" = 5+3")); + assert!(!CalculatorProvider::is_calculator_query("5+3")); + assert!(!CalculatorProvider::is_calculator_query("firefox")); + } + + #[test] + fn test_extract_expression() { + assert_eq!( + CalculatorProvider::extract_expression("= 5+3"), + Some("5+3") + ); + assert_eq!( + CalculatorProvider::extract_expression("calc 5+3"), + Some("5+3") + ); + assert_eq!( + CalculatorProvider::extract_expression("= 5 + 3 "), + Some("5 + 3") + ); + assert_eq!(CalculatorProvider::extract_expression("5+3"), None); + } + + #[test] + fn test_evaluate_basic() { + let mut calc = CalculatorProvider::new(); + + let result = calc.evaluate("= 5+3").unwrap(); + assert_eq!(result.name, "8"); + + let result = calc.evaluate("= 10 * 2").unwrap(); + assert_eq!(result.name, "20"); + + let result = calc.evaluate("= 15 / 3").unwrap(); + assert_eq!(result.name, "5"); + } + + #[test] + fn test_evaluate_float() { + let mut calc = CalculatorProvider::new(); + + let result = calc.evaluate("= 5/2").unwrap(); + assert_eq!(result.name, "2.5"); + + let result = calc.evaluate("= 1/3").unwrap(); + assert!(result.name.starts_with("0.333")); + } + + #[test] + fn test_evaluate_functions() { + let mut calc = CalculatorProvider::new(); + + let result = calc.evaluate("= sqrt(16)").unwrap(); + assert_eq!(result.name, "4"); + + let result = calc.evaluate("= abs(-5)").unwrap(); + assert_eq!(result.name, "5"); + } + + #[test] + fn test_evaluate_constants() { + let mut calc = CalculatorProvider::new(); + + let result = calc.evaluate("= pi").unwrap(); + assert!(result.name.starts_with("3.14159")); + + let result = calc.evaluate("= e").unwrap(); + assert!(result.name.starts_with("2.718")); + } + + #[test] + fn test_evaluate_invalid() { + let mut calc = CalculatorProvider::new(); + + assert!(calc.evaluate("= ").is_none()); + assert!(calc.evaluate("= invalid").is_none()); + assert!(calc.evaluate("= 5 +").is_none()); + } +} diff --git a/src/providers/mod.rs b/src/providers/mod.rs index b825c29..18866e7 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -1,9 +1,11 @@ mod application; +mod calculator; mod command; mod dmenu; mod uuctl; pub use application::ApplicationProvider; +pub use calculator::CalculatorProvider; pub use command::CommandProvider; pub use dmenu::DmenuProvider; pub use uuctl::UuctlProvider; @@ -12,6 +14,8 @@ use fuzzy_matcher::FuzzyMatcher; use fuzzy_matcher::skim::SkimMatcherV2; use log::info; +use crate::data::FrecencyStore; + /// Represents a single searchable/launchable item #[derive(Debug, Clone)] pub struct LaunchItem { @@ -28,6 +32,7 @@ pub struct LaunchItem { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ProviderType { Application, + Calculator, Command, Dmenu, Uuctl, @@ -39,10 +44,14 @@ impl std::str::FromStr for ProviderType { fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { "app" | "apps" | "application" | "applications" => Ok(ProviderType::Application), + "calc" | "calculator" => Ok(ProviderType::Calculator), "cmd" | "command" | "commands" => Ok(ProviderType::Command), "uuctl" => Ok(ProviderType::Uuctl), "dmenu" => Ok(ProviderType::Dmenu), - _ => Err(format!("Unknown provider: '{}'. Valid: app, cmd, uuctl", s)), + _ => Err(format!( + "Unknown provider: '{}'. Valid: app, calc, cmd, uuctl", + s + )), } } } @@ -51,6 +60,7 @@ impl std::fmt::Display for ProviderType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ProviderType::Application => write!(f, "app"), + ProviderType::Calculator => write!(f, "calc"), ProviderType::Command => write!(f, "cmd"), ProviderType::Dmenu => write!(f, "dmenu"), ProviderType::Uuctl => write!(f, "uuctl"), @@ -70,6 +80,7 @@ pub trait Provider: Send { /// Manages all providers and handles searching pub struct ProviderManager { providers: Vec>, + calculator: CalculatorProvider, matcher: SkimMatcherV2, } @@ -77,6 +88,7 @@ impl ProviderManager { pub fn new() -> Self { let mut manager = Self { providers: Vec::new(), + calculator: CalculatorProvider::new(), matcher: SkimMatcherV2::default(), }; @@ -206,6 +218,79 @@ impl ProviderManager { results } + /// Search with frecency boosting and calculator support + pub fn search_with_frecency( + &mut self, + query: &str, + max_results: usize, + filter: &crate::filter::ProviderFilter, + frecency: &FrecencyStore, + frecency_weight: f64, + ) -> Vec<(LaunchItem, i64)> { + let mut results: Vec<(LaunchItem, i64)> = Vec::new(); + + // Check for calculator query first + if CalculatorProvider::is_calculator_query(query) { + if let Some(calc_result) = self.calculator.evaluate(query) { + // Calculator results get a high score to appear first + results.push((calc_result, 10000)); + } + } + + // Empty query (after checking calculator) - return frecency-sorted items + if query.is_empty() { + let mut items: Vec<(LaunchItem, i64)> = self + .providers + .iter() + .filter(|p| filter.is_active(p.provider_type())) + .flat_map(|p| p.items().iter().cloned()) + .map(|item| { + let frecency_score = frecency.get_score(&item.id); + let boosted = (frecency_score * frecency_weight * 100.0) as i64; + (item, boosted) + }) + .collect(); + + items.sort_by(|a, b| b.1.cmp(&a.1)); + items.truncate(max_results); + return items; + } + + // Regular search with frecency boost + let search_results: Vec<(LaunchItem, i64)> = self + .providers + .iter() + .filter(|provider| filter.is_active(provider.provider_type())) + .flat_map(|provider| { + provider.items().iter().filter_map(|item| { + let name_score = self.matcher.fuzzy_match(&item.name, query); + let desc_score = item + .description + .as_ref() + .and_then(|d| self.matcher.fuzzy_match(d, query)); + + let base_score = match (name_score, desc_score) { + (Some(n), Some(d)) => Some(n.max(d)), + (Some(n), None) => Some(n), + (None, Some(d)) => Some(d / 2), + (None, None) => None, + }; + + base_score.map(|s| { + let frecency_score = frecency.get_score(&item.id); + let frecency_boost = (frecency_score * frecency_weight * 10.0) as i64; + (item.clone(), s + frecency_boost) + }) + }) + }) + .collect(); + + results.extend(search_results); + results.sort_by(|a, b| b.1.cmp(&a.1)); + results.truncate(max_results); + results + } + /// Get all available provider types (for UI tabs) #[allow(dead_code)] pub fn available_providers(&self) -> Vec { diff --git a/src/theme.rs b/src/theme.rs index b99c040..91da83e 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -35,6 +35,9 @@ pub fn generate_variables_css(config: &AppearanceConfig) -> String { if let Some(ref badge_app) = config.colors.badge_app { css.push_str(&format!(" --owlry-badge-app: {};\n", badge_app)); } + if let Some(ref badge_calc) = config.colors.badge_calc { + css.push_str(&format!(" --owlry-badge-calc: {};\n", badge_calc)); + } if let Some(ref badge_cmd) = config.colors.badge_cmd { css.push_str(&format!(" --owlry-badge-cmd: {};\n", badge_cmd)); } diff --git a/src/ui/main_window.rs b/src/ui/main_window.rs index 6cdc94c..7a44085 100644 --- a/src/ui/main_window.rs +++ b/src/ui/main_window.rs @@ -1,4 +1,5 @@ use crate::config::Config; +use crate::data::FrecencyStore; use crate::filter::ProviderFilter; use crate::providers::{LaunchItem, ProviderManager, ProviderType, UuctlProvider}; use crate::ui::ResultRow; @@ -36,6 +37,7 @@ pub struct MainWindow { scrolled: ScrolledWindow, config: Rc>, providers: Rc>, + frecency: Rc>, current_results: Rc>>, filter: Rc>, mode_label: Label, @@ -49,6 +51,7 @@ impl MainWindow { app: &Application, config: Rc>, providers: Rc>, + frecency: Rc>, filter: Rc>, ) -> Self { let cfg = config.borrow(); @@ -140,7 +143,7 @@ impl MainWindow { hints_box.add_css_class("owlry-hints"); let hints_label = Label::builder() - .label("Tab: cycle mode ↑↓: navigate Enter: launch Esc: close :app :cmd :uuctl") + .label("Tab: cycle mode ↑↓: navigate Enter: launch Esc: close = calc :app :cmd :uuctl") .halign(gtk4::Align::Center) .hexpand(true) .build(); @@ -163,6 +166,7 @@ impl MainWindow { scrolled, config, providers, + frecency, current_results: Rc::new(RefCell::new(Vec::new())), filter, mode_label, @@ -202,6 +206,7 @@ impl MainWindow { button.add_css_class("owlry-filter-button"); let css_class = match provider_type { ProviderType::Application => "owlry-filter-app", + ProviderType::Calculator => "owlry-filter-calc", ProviderType::Command => "owlry-filter-cmd", ProviderType::Uuctl => "owlry-filter-uuctl", ProviderType::Dmenu => "owlry-filter-dmenu", @@ -221,6 +226,7 @@ impl MainWindow { .iter() .map(|p| match p { ProviderType::Application => "applications", + ProviderType::Calculator => "calculator", ProviderType::Command => "commands", ProviderType::Uuctl => "uuctl units", ProviderType::Dmenu => "options", @@ -328,7 +334,7 @@ impl MainWindow { // Restore UI mode_label.set_label(filter.borrow().mode_display_name()); - hints_label.set_label("Tab: cycle mode ↑↓: navigate Enter: launch Esc: close :app :cmd :uuctl"); + hints_label.set_label("Tab: cycle mode ↑↓: navigate Enter: launch Esc: close = calc :app :cmd :uuctl"); search_entry.set_placeholder_text(Some(&Self::build_placeholder(&filter.borrow()))); search_entry.set_text(&saved_search); @@ -341,6 +347,7 @@ impl MainWindow { let providers = self.providers.clone(); let results_list = self.results_list.clone(); let config = self.config.clone(); + let frecency = self.frecency.clone(); let current_results = self.current_results.clone(); let filter = self.filter.clone(); let mode_label = self.mode_label.clone(); @@ -401,6 +408,7 @@ impl MainWindow { if parsed.prefix.is_some() { let prefix_name = match parsed.prefix.unwrap() { ProviderType::Application => "applications", + ProviderType::Calculator => "calculator", ProviderType::Command => "commands", ProviderType::Uuctl => "uuctl units", ProviderType::Dmenu => "options", @@ -409,13 +417,27 @@ impl MainWindow { .set_placeholder_text(Some(&format!("Search {}...", prefix_name))); } - let max_results = config.borrow().general.max_results; - let results: Vec = providers - .borrow() - .search_filtered(&parsed.query, max_results, &filter.borrow()) - .into_iter() - .map(|(item, _)| item) - .collect(); + let cfg = config.borrow(); + let max_results = cfg.general.max_results; + let frecency_weight = cfg.providers.frecency_weight; + let use_frecency = cfg.providers.frecency; + drop(cfg); + + let results: Vec = if use_frecency { + providers + .borrow_mut() + .search_with_frecency(&parsed.query, max_results, &filter.borrow(), &frecency.borrow(), frecency_weight) + .into_iter() + .map(|(item, _)| item) + .collect() + } else { + providers + .borrow() + .search_filtered(&parsed.query, max_results, &filter.borrow()) + .into_iter() + .map(|(item, _)| item) + .collect() + }; while let Some(child) = results_list.first_child() { results_list.remove(&child); @@ -437,6 +459,7 @@ impl MainWindow { let results_list_for_activate = self.results_list.clone(); let current_results_for_activate = self.current_results.clone(); let config_for_activate = self.config.clone(); + let frecency_for_activate = self.frecency.clone(); let window_for_activate = self.window.clone(); let submenu_state_for_activate = self.submenu_state.clone(); let mode_label_for_activate = self.mode_label.clone(); @@ -470,7 +493,7 @@ impl MainWindow { ); } else { // Execute the command - Self::launch_item(item, &config_for_activate.borrow()); + Self::launch_item(item, &config_for_activate.borrow(), &frecency_for_activate); window_for_activate.close(); } } @@ -647,6 +670,7 @@ impl MainWindow { // Double-click to launch let current_results = self.current_results.clone(); let config = self.config.clone(); + let frecency = self.frecency.clone(); let window = self.window.clone(); let submenu_state = self.submenu_state.clone(); let results_list_for_click = self.results_list.clone(); @@ -675,7 +699,7 @@ impl MainWindow { is_active, ); } else { - Self::launch_item(item, &config.borrow()); + Self::launch_item(item, &config.borrow(), &frecency); window.close(); } } @@ -743,14 +767,27 @@ impl MainWindow { } fn update_results(&self, query: &str) { - let max_results = self.config.borrow().general.max_results; - let results: Vec = self - .providers - .borrow() - .search_filtered(query, max_results, &self.filter.borrow()) - .into_iter() - .map(|(item, _)| item) - .collect(); + let cfg = self.config.borrow(); + let max_results = cfg.general.max_results; + let frecency_weight = cfg.providers.frecency_weight; + let use_frecency = cfg.providers.frecency; + drop(cfg); + + let results: Vec = if use_frecency { + self.providers + .borrow_mut() + .search_with_frecency(query, max_results, &self.filter.borrow(), &self.frecency.borrow(), frecency_weight) + .into_iter() + .map(|(item, _)| item) + .collect() + } else { + self.providers + .borrow() + .search_filtered(query, max_results, &self.filter.borrow()) + .into_iter() + .map(|(item, _)| item) + .collect() + }; while let Some(child) = self.results_list.first_child() { self.results_list.remove(&child); @@ -768,7 +805,12 @@ impl MainWindow { *self.current_results.borrow_mut() = results; } - fn launch_item(item: &LaunchItem, config: &Config) { + fn launch_item(item: &LaunchItem, config: &Config, frecency: &Rc>) { + // Record this launch for frecency tracking + if config.providers.frecency { + frecency.borrow_mut().record_launch(&item.id); + } + info!("Launching: {} ({})", item.name, item.command); let cmd = if item.terminal { diff --git a/src/ui/result_row.rs b/src/ui/result_row.rs index 11052c3..52787c5 100644 --- a/src/ui/result_row.rs +++ b/src/ui/result_row.rs @@ -32,6 +32,7 @@ impl ResultRow { // Default icon based on provider type let default_icon = match item.provider { crate::providers::ProviderType::Application => "application-x-executable", + crate::providers::ProviderType::Calculator => "accessories-calculator", crate::providers::ProviderType::Command => "utilities-terminal", crate::providers::ProviderType::Dmenu => "view-list-symbolic", crate::providers::ProviderType::Uuctl => "system-run",