diff --git a/Cargo.lock b/Cargo.lock index 8501b66..42ed8b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -971,7 +971,7 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "owlry" -version = "0.1.9" +version = "0.2.0" dependencies = [ "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index 79d0f27..bf13925 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "owlry" -version = "0.1.9" +version = "0.2.0" edition = "2024" rust-version = "1.90" description = "A lightweight, owl-themed application launcher for Wayland" diff --git a/config.example.toml b/config.example.toml index cf2d159..cdc246b 100644 --- a/config.example.toml +++ b/config.example.toml @@ -48,3 +48,9 @@ calculator = true # Frecency: boost frequently/recently used items in search results frecency = true frecency_weight = 0.3 # 0.0 = disabled, 1.0 = strong boost + +# Web search provider (type "? query" or "web query") +websearch = true +# Options: google, duckduckgo, bing, startpage, searxng, brave, ecosia +# Or custom URL with {query} placeholder, e.g. "https://search.example.com/?q={query}" +search_engine = "duckduckgo" diff --git a/resources/base.css b/resources/base.css index 0c57997..bb0f15f 100644 --- a/resources/base.css +++ b/resources/base.css @@ -126,6 +126,11 @@ color: var(--owlry-badge-uuctl, @orange_3); } +.owlry-badge-web { + background-color: alpha(var(--owlry-badge-web, @teal_3), 0.2); + color: var(--owlry-badge-web, @teal_3); +} + /* Header bar */ .owlry-header { margin-bottom: 4px; @@ -195,6 +200,12 @@ border-color: alpha(var(--owlry-badge-dmenu, @green_3), 0.4); } +.owlry-filter-web:checked { + background-color: alpha(var(--owlry-badge-web, @teal_3), 0.2); + color: var(--owlry-badge-web, @teal_3); + border-color: alpha(var(--owlry-badge-web, @teal_3), 0.4); +} + /* Hints bar at bottom */ .owlry-hints { padding-top: 8px; diff --git a/src/app.rs b/src/app.rs index 0d2a51d..d33dc54 100644 --- a/src/app.rs +++ b/src/app.rs @@ -40,7 +40,8 @@ impl OwlryApp { debug!("Activating Owlry"); let config = Rc::new(RefCell::new(Config::load_or_default())); - let providers = Rc::new(RefCell::new(ProviderManager::new())); + let search_engine = config.borrow().providers.search_engine.clone(); + let providers = Rc::new(RefCell::new(ProviderManager::with_search_engine(&search_engine))); let frecency = Rc::new(RefCell::new(FrecencyStore::load_or_default())); // Create filter from CLI args and config diff --git a/src/config/mod.rs b/src/config/mod.rs index 5d985e4..e182623 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -39,6 +39,7 @@ pub struct ThemeColors { pub badge_cmd: Option, pub badge_dmenu: Option, pub badge_uuctl: Option, + pub badge_web: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -69,6 +70,18 @@ pub struct ProvidersConfig { /// Weight for frecency boost (0.0 = disabled, 1.0 = strong boost) #[serde(default = "default_frecency_weight")] pub frecency_weight: f64, + /// Enable web search provider (? query or web query) + #[serde(default = "default_true")] + pub websearch: bool, + /// Search engine for web search + /// Options: google, duckduckgo, bing, startpage, searxng, brave, ecosia + /// Or custom URL with {query} placeholder + #[serde(default = "default_search_engine")] + pub search_engine: String, +} + +fn default_search_engine() -> String { + "duckduckgo".to_string() } fn default_true() -> bool { @@ -193,6 +206,8 @@ impl Default for Config { calculator: true, frecency: true, frecency_weight: 0.3, + websearch: true, + search_engine: "duckduckgo".to_string(), }, } } diff --git a/src/filter.rs b/src/filter.rs index d9b0d6d..bbab1cd 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -134,6 +134,8 @@ impl ProviderFilter { (":cmd ", ProviderType::Command), (":command ", ProviderType::Command), (":uuctl ", ProviderType::Uuctl), + (":web ", ProviderType::WebSearch), + (":search ", ProviderType::WebSearch), ]; for (prefix_str, provider) in prefixes { @@ -154,6 +156,8 @@ impl ProviderFilter { (":cmd", ProviderType::Command), (":command", ProviderType::Command), (":uuctl", ProviderType::Uuctl), + (":web", ProviderType::WebSearch), + (":search", ProviderType::WebSearch), ]; for (prefix_str, provider) in partial_prefixes { @@ -179,7 +183,8 @@ impl ProviderFilter { ProviderType::Calculator => 1, ProviderType::Command => 2, ProviderType::Uuctl => 3, - ProviderType::Dmenu => 4, + ProviderType::WebSearch => 4, + ProviderType::Dmenu => 5, }); providers } @@ -192,6 +197,7 @@ impl ProviderFilter { ProviderType::Calculator => "Calc", ProviderType::Command => "Commands", ProviderType::Uuctl => "uuctl", + ProviderType::WebSearch => "Web", ProviderType::Dmenu => "dmenu", }; } @@ -203,6 +209,7 @@ impl ProviderFilter { ProviderType::Calculator => "Calc", ProviderType::Command => "Commands", ProviderType::Uuctl => "uuctl", + ProviderType::WebSearch => "Web", ProviderType::Dmenu => "dmenu", } } else { diff --git a/src/providers/calculator.rs b/src/providers/calculator.rs index c3f09e0..79a4032 100644 --- a/src/providers/calculator.rs +++ b/src/providers/calculator.rs @@ -18,14 +18,17 @@ impl CalculatorProvider { /// 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 ") + 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(); + // Support both "= expr" and "=expr" (with or without space) if let Some(expr) = trimmed.strip_prefix("= ") { Some(expr.trim()) + } else if let Some(expr) = trimmed.strip_prefix("=") { + Some(expr.trim()) } else if let Some(expr) = trimmed.strip_prefix("calc ") { Some(expr.trim()) } else { @@ -33,6 +36,49 @@ impl CalculatorProvider { } } + /// Check if string looks like a math expression (for :calc mode) + pub fn looks_like_expression(query: &str) -> bool { + let trimmed = query.trim(); + if trimmed.is_empty() { + return false; + } + // Contains math operators or is a number + trimmed.chars().any(|c| "+-*/^()".contains(c)) + || trimmed.parse::().is_ok() + || ["pi", "e", "sqrt", "sin", "cos", "tan", "abs", "ln", "log"] + .iter() + .any(|f| trimmed.to_lowercase().contains(f)) + } + + /// Evaluate a raw expression (for :calc filter mode) + pub fn evaluate_raw(&mut self, expr: &str) -> Option { + let trimmed = expr.trim(); + if trimmed.is_empty() { + return None; + } + + match meval::eval_str(trimmed) { + Ok(result) => { + let result_str = if result.fract() == 0.0 && result.abs() < 1e15 { + format!("{}", result as i64) + } else { + format!("{:.10}", result).trim_end_matches('0').trim_end_matches('.').to_string() + }; + + Some(LaunchItem { + id: format!("calc:{}", trimmed), + name: format!("{} = {}", trimmed, result_str), + description: Some("Press Enter to copy result".to_string()), + icon: Some("accessories-calculator".to_string()), + provider: ProviderType::Calculator, + command: format!("echo -n '{}' | wl-copy", result_str), + terminal: false, + }) + } + Err(_) => None, + } + } + /// Evaluate an expression and return a LaunchItem result pub fn evaluate(&mut self, query: &str) -> Option { let expr = Self::extract_expression(query)?; diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 18866e7..4ac6743 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -3,12 +3,14 @@ mod calculator; mod command; mod dmenu; mod uuctl; +mod websearch; pub use application::ApplicationProvider; pub use calculator::CalculatorProvider; pub use command::CommandProvider; pub use dmenu::DmenuProvider; pub use uuctl::UuctlProvider; +pub use websearch::WebSearchProvider; use fuzzy_matcher::FuzzyMatcher; use fuzzy_matcher::skim::SkimMatcherV2; @@ -36,6 +38,7 @@ pub enum ProviderType { Command, Dmenu, Uuctl, + WebSearch, } impl std::str::FromStr for ProviderType { @@ -48,6 +51,7 @@ impl std::str::FromStr for ProviderType { "cmd" | "command" | "commands" => Ok(ProviderType::Command), "uuctl" => Ok(ProviderType::Uuctl), "dmenu" => Ok(ProviderType::Dmenu), + "web" | "websearch" | "search" => Ok(ProviderType::WebSearch), _ => Err(format!( "Unknown provider: '{}'. Valid: app, calc, cmd, uuctl", s @@ -64,6 +68,7 @@ impl std::fmt::Display for ProviderType { ProviderType::Command => write!(f, "cmd"), ProviderType::Dmenu => write!(f, "dmenu"), ProviderType::Uuctl => write!(f, "uuctl"), + ProviderType::WebSearch => write!(f, "web"), } } } @@ -81,14 +86,21 @@ pub trait Provider: Send { pub struct ProviderManager { providers: Vec>, calculator: CalculatorProvider, + websearch: WebSearchProvider, matcher: SkimMatcherV2, } impl ProviderManager { + #[allow(dead_code)] pub fn new() -> Self { + Self::with_search_engine("duckduckgo") + } + + pub fn with_search_engine(search_engine: &str) -> Self { let mut manager = Self { providers: Vec::new(), calculator: CalculatorProvider::new(), + websearch: WebSearchProvider::with_engine(search_engine), matcher: SkimMatcherV2::default(), }; @@ -229,15 +241,31 @@ impl ProviderManager { ) -> Vec<(LaunchItem, i64)> { let mut results: Vec<(LaunchItem, i64)> = Vec::new(); - // Check for calculator query first + // Check for calculator query (= or calc prefix) if CalculatorProvider::is_calculator_query(query) { if let Some(calc_result) = self.calculator.evaluate(query) { // Calculator results get a high score to appear first results.push((calc_result, 10000)); } } + // Also check for raw expression when in :calc filter mode + else if filter.active_prefix() == Some(ProviderType::Calculator) + && CalculatorProvider::looks_like_expression(query) + { + if let Some(calc_result) = self.calculator.evaluate_raw(query) { + results.push((calc_result, 10000)); + } + } - // Empty query (after checking calculator) - return frecency-sorted items + // Check for web search query + if WebSearchProvider::is_websearch_query(query) { + if let Some(web_result) = self.websearch.evaluate(query) { + // Web search results get a high score to appear first + results.push((web_result, 9000)); + } + } + + // Empty query (after checking special providers) - return frecency-sorted items if query.is_empty() { let mut items: Vec<(LaunchItem, i64)> = self .providers diff --git a/src/providers/websearch.rs b/src/providers/websearch.rs new file mode 100644 index 0000000..9050f6c --- /dev/null +++ b/src/providers/websearch.rs @@ -0,0 +1,185 @@ +use crate::providers::{LaunchItem, ProviderType}; + +/// Common search engine URL templates +/// {query} is replaced with the URL-encoded search term +pub const SEARCH_ENGINES: &[(&str, &str)] = &[ + ("google", "https://www.google.com/search?q={query}"), + ("duckduckgo", "https://duckduckgo.com/?q={query}"), + ("bing", "https://www.bing.com/search?q={query}"), + ("startpage", "https://www.startpage.com/search?q={query}"), + ("searxng", "https://searx.be/search?q={query}"), + ("brave", "https://search.brave.com/search?q={query}"), + ("ecosia", "https://www.ecosia.org/search?q={query}"), +]; + +/// Default search engine if not configured +pub const DEFAULT_ENGINE: &str = "duckduckgo"; + +/// Web search provider - opens browser with search query +pub struct WebSearchProvider { + /// URL template with {query} placeholder + url_template: String, +} + +impl WebSearchProvider { + #[allow(dead_code)] + pub fn new() -> Self { + Self::with_engine(DEFAULT_ENGINE) + } + + /// Create provider with specific search engine + pub fn with_engine(engine_name: &str) -> Self { + let url_template = SEARCH_ENGINES + .iter() + .find(|(name, _)| *name == engine_name.to_lowercase()) + .map(|(_, url)| url.to_string()) + .unwrap_or_else(|| { + // If not a known engine, treat it as a custom URL template + if engine_name.contains("{query}") { + engine_name.to_string() + } else { + // Fall back to default + SEARCH_ENGINES + .iter() + .find(|(name, _)| *name == DEFAULT_ENGINE) + .map(|(_, url)| url.to_string()) + .unwrap() + } + }); + + Self { url_template } + } + + /// Check if query is a web search query + /// Triggers on: `? query`, `web query`, `search query` + pub fn is_websearch_query(query: &str) -> bool { + let trimmed = query.trim(); + trimmed.starts_with("? ") + || trimmed.starts_with("?") + || trimmed.to_lowercase().starts_with("web ") + || trimmed.to_lowercase().starts_with("search ") + } + + /// Extract the search term from the query + fn extract_search_term(query: &str) -> Option<&str> { + let trimmed = query.trim(); + + if let Some(rest) = trimmed.strip_prefix("? ") { + Some(rest.trim()) + } else if let Some(rest) = trimmed.strip_prefix("?") { + Some(rest.trim()) + } else if trimmed.to_lowercase().starts_with("web ") { + // Need to get the original casing + Some(trimmed[4..].trim()) + } else if trimmed.to_lowercase().starts_with("search ") { + Some(trimmed[7..].trim()) + } else { + None + } + } + + /// URL-encode a search query + fn url_encode(query: &str) -> String { + // TODO: This is where you can implement the URL encoding logic! + // Consider: Should we use a crate like `urlencoding` or implement manually? + // Manual encoding needs to handle: spaces, &, =, ?, #, etc. + query + .chars() + .map(|c| match c { + ' ' => "+".to_string(), + '&' => "%26".to_string(), + '=' => "%3D".to_string(), + '?' => "%3F".to_string(), + '#' => "%23".to_string(), + '+' => "%2B".to_string(), + '%' => "%25".to_string(), + c if c.is_ascii_alphanumeric() || "-_.~".contains(c) => c.to_string(), + c => format!("%{:02X}", c as u32), + }) + .collect() + } + + /// Build the search URL from a query + fn build_search_url(&self, search_term: &str) -> String { + let encoded = Self::url_encode(search_term); + self.url_template.replace("{query}", &encoded) + } + + /// Evaluate a web search query and return a LaunchItem if valid + pub fn evaluate(&self, query: &str) -> Option { + let search_term = Self::extract_search_term(query)?; + + if search_term.is_empty() { + return None; + } + + let url = self.build_search_url(search_term); + + // Use xdg-open to open the browser + let command = format!("xdg-open '{}'", url); + + Some(LaunchItem { + id: format!("websearch:{}", search_term), + name: format!("Search: {}", search_term), + description: Some("Open in browser".to_string()), + icon: Some("web-browser".to_string()), + provider: ProviderType::WebSearch, + command, + terminal: false, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_websearch_query() { + assert!(WebSearchProvider::is_websearch_query("? rust programming")); + assert!(WebSearchProvider::is_websearch_query("?rust")); + assert!(WebSearchProvider::is_websearch_query("web rust")); + assert!(WebSearchProvider::is_websearch_query("search rust")); + assert!(!WebSearchProvider::is_websearch_query("rust")); + assert!(!WebSearchProvider::is_websearch_query("= 5+3")); + } + + #[test] + fn test_extract_search_term() { + assert_eq!( + WebSearchProvider::extract_search_term("? rust programming"), + Some("rust programming") + ); + assert_eq!( + WebSearchProvider::extract_search_term("?rust"), + Some("rust") + ); + assert_eq!( + WebSearchProvider::extract_search_term("web rust docs"), + Some("rust docs") + ); + } + + #[test] + fn test_url_encode() { + assert_eq!(WebSearchProvider::url_encode("hello world"), "hello+world"); + assert_eq!(WebSearchProvider::url_encode("foo&bar"), "foo%26bar"); + assert_eq!(WebSearchProvider::url_encode("a=b"), "a%3Db"); + } + + #[test] + fn test_build_search_url() { + let provider = WebSearchProvider::with_engine("duckduckgo"); + let url = provider.build_search_url("rust programming"); + assert_eq!(url, "https://duckduckgo.com/?q=rust+programming"); + } + + #[test] + fn test_evaluate() { + let provider = WebSearchProvider::new(); + let item = provider.evaluate("? rust docs").unwrap(); + assert_eq!(item.name, "Search: rust docs"); + assert!(item.command.contains("xdg-open")); + assert!(item.command.contains("duckduckgo")); + } +} diff --git a/src/theme.rs b/src/theme.rs index 91da83e..b738d97 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -47,6 +47,9 @@ pub fn generate_variables_css(config: &AppearanceConfig) -> String { if let Some(ref badge_uuctl) = config.colors.badge_uuctl { css.push_str(&format!(" --owlry-badge-uuctl: {};\n", badge_uuctl)); } + if let Some(ref badge_web) = config.colors.badge_web { + css.push_str(&format!(" --owlry-badge-web: {};\n", badge_web)); + } css.push_str("}\n"); css diff --git a/src/ui/main_window.rs b/src/ui/main_window.rs index 7a44085..5b79ce9 100644 --- a/src/ui/main_window.rs +++ b/src/ui/main_window.rs @@ -143,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 = calc :app :cmd :uuctl") + .label("Tab: cycle mode ↑↓: navigate Enter: launch Esc: close = calc ? web :app :cmd") .halign(gtk4::Align::Center) .hexpand(true) .build(); @@ -209,6 +209,7 @@ impl MainWindow { ProviderType::Calculator => "owlry-filter-calc", ProviderType::Command => "owlry-filter-cmd", ProviderType::Uuctl => "owlry-filter-uuctl", + ProviderType::WebSearch => "owlry-filter-web", ProviderType::Dmenu => "owlry-filter-dmenu", }; button.add_css_class(css_class); @@ -229,6 +230,7 @@ impl MainWindow { ProviderType::Calculator => "calculator", ProviderType::Command => "commands", ProviderType::Uuctl => "uuctl units", + ProviderType::WebSearch => "web", ProviderType::Dmenu => "options", }) .collect(); @@ -334,7 +336,7 @@ impl MainWindow { // Restore UI mode_label.set_label(filter.borrow().mode_display_name()); - hints_label.set_label("Tab: cycle mode ↑↓: navigate Enter: launch Esc: close = calc :app :cmd :uuctl"); + hints_label.set_label("Tab: cycle mode ↑↓: navigate Enter: launch Esc: close = calc ? web :app :cmd"); search_entry.set_placeholder_text(Some(&Self::build_placeholder(&filter.borrow()))); search_entry.set_text(&saved_search); @@ -411,6 +413,7 @@ impl MainWindow { ProviderType::Calculator => "calculator", ProviderType::Command => "commands", ProviderType::Uuctl => "uuctl units", + ProviderType::WebSearch => "web", ProviderType::Dmenu => "options", }; search_entry_for_change diff --git a/src/ui/result_row.rs b/src/ui/result_row.rs index 52787c5..4d4ff10 100644 --- a/src/ui/result_row.rs +++ b/src/ui/result_row.rs @@ -36,6 +36,7 @@ impl ResultRow { crate::providers::ProviderType::Command => "utilities-terminal", crate::providers::ProviderType::Dmenu => "view-list-symbolic", crate::providers::ProviderType::Uuctl => "system-run", + crate::providers::ProviderType::WebSearch => "web-browser", }; Image::from_icon_name(default_icon) };