feat: add calculator provider and frecency tracking
Calculator: - Type "= expression" or "calc expression" for instant math evaluation - Supports standard math functions (sqrt, sin, cos, etc.) and constants (pi, e) - Copies result to clipboard on Enter via wl-copy Frecency: - Firefox-style algorithm: score = launch_count * recency_weight - Boosts frequently and recently launched items in search results - Persists to ~/.local/share/owlry/frecency.json - Configurable via providers.frecency and providers.frecency_weight New config options: - providers.calculator = true - providers.frecency = true - providers.frecency_weight = 0.3 UI updates: - Added :calc prefix support - Calculator badge with yellow styling - Updated hints to show "= calc" syntax 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -35,6 +35,7 @@ pub struct ThemeColors {
|
||||
pub accent_bright: Option<String>,
|
||||
// Provider badge colors
|
||||
pub badge_app: Option<String>,
|
||||
pub badge_calc: Option<String>,
|
||||
pub badge_cmd: Option<String>,
|
||||
pub badge_dmenu: Option<String>,
|
||||
pub badge_uuctl: Option<String>,
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
223
src/data/frecency.rs
Normal file
223
src/data/frecency.rs
Normal file
@@ -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<Utc>,
|
||||
}
|
||||
|
||||
/// Persistent frecency data store
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FrecencyData {
|
||||
pub version: u32,
|
||||
pub entries: HashMap<String, FrecencyEntry>,
|
||||
}
|
||||
|
||||
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<FrecencyData> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<Utc>) -> 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<String, FrecencyEntry> {
|
||||
&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
|
||||
}
|
||||
}
|
||||
3
src/data/mod.rs
Normal file
3
src/data/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod frecency;
|
||||
|
||||
pub use frecency::FrecencyStore;
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod app;
|
||||
mod cli;
|
||||
mod config;
|
||||
mod data;
|
||||
mod filter;
|
||||
mod providers;
|
||||
mod theme;
|
||||
|
||||
191
src/providers/calculator.rs
Normal file
191
src/providers/calculator.rs
Normal file
@@ -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<LaunchItem>,
|
||||
}
|
||||
|
||||
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<LaunchItem> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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<Self, Self::Err> {
|
||||
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<Box<dyn Provider>>,
|
||||
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<ProviderType> {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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<RefCell<Config>>,
|
||||
providers: Rc<RefCell<ProviderManager>>,
|
||||
frecency: Rc<RefCell<FrecencyStore>>,
|
||||
current_results: Rc<RefCell<Vec<LaunchItem>>>,
|
||||
filter: Rc<RefCell<ProviderFilter>>,
|
||||
mode_label: Label,
|
||||
@@ -49,6 +51,7 @@ impl MainWindow {
|
||||
app: &Application,
|
||||
config: Rc<RefCell<Config>>,
|
||||
providers: Rc<RefCell<ProviderManager>>,
|
||||
frecency: Rc<RefCell<FrecencyStore>>,
|
||||
filter: Rc<RefCell<ProviderFilter>>,
|
||||
) -> 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<LaunchItem> = 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<LaunchItem> = 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<LaunchItem> = 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<LaunchItem> = 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<RefCell<FrecencyStore>>) {
|
||||
// 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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user