3 Commits

Author SHA1 Message Date
98ac769b29 chore: bump version to 0.2.1 2025-12-28 18:33:42 +01:00
e73793dd6e fix: web search not working in :web filter mode
Added evaluate_raw() method to WebSearchProvider and handler in
search_with_frecency() to support raw queries when using :web prefix.

Same pattern as calculator fix - trigger prefixes (?, web) call
evaluate() while filter mode (:web) calls evaluate_raw().

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 18:33:34 +01:00
e680032d0e feat: add web search provider and fix calculator
Web Search Provider:
- Type "? query" or "web query" to search the web
- Configurable search engine (duckduckgo, google, bing, startpage, etc.)
- Custom URL templates with {query} placeholder supported
- Opens browser via xdg-open

Calculator Fixes:
- Support "=5+3" without space (previously required "= 5+3")
- :calc mode now evaluates raw expressions directly
- Added looks_like_expression() for better detection

New config options:
- providers.websearch = true
- providers.search_engine = "duckduckgo"

UI updates:
- Added :web and :search prefixes
- Web badge with teal styling
- Updated hints bar to show "? web" syntax

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 18:22:57 +01:00
13 changed files with 331 additions and 9 deletions

2
Cargo.lock generated
View File

@@ -971,7 +971,7 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "owlry"
version = "0.1.9"
version = "0.2.1"
dependencies = [
"chrono",
"clap",

View File

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

View File

@@ -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"

View File

@@ -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;

View File

@@ -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

View File

@@ -39,6 +39,7 @@ pub struct ThemeColors {
pub badge_cmd: Option<String>,
pub badge_dmenu: Option<String>,
pub badge_uuctl: Option<String>,
pub badge_web: Option<String>,
}
#[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(),
},
}
}

View File

@@ -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 {

View File

@@ -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::<f64>().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<LaunchItem> {
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<LaunchItem> {
let expr = Self::extract_expression(query)?;

View File

@@ -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<Box<dyn Provider>>,
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,37 @@ 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));
}
}
// Also check for raw query when in :web filter mode
else if filter.active_prefix() == Some(ProviderType::WebSearch) && !query.is_empty() {
if let Some(web_result) = self.websearch.evaluate_raw(query) {
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

195
src/providers/websearch.rs Normal file
View File

@@ -0,0 +1,195 @@
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<LaunchItem> {
let search_term = Self::extract_search_term(query)?;
if search_term.is_empty() {
return None;
}
self.evaluate_raw(search_term)
}
/// Evaluate a raw search term (for :web filter mode)
pub fn evaluate_raw(&self, search_term: &str) -> Option<LaunchItem> {
let trimmed = search_term.trim();
if trimmed.is_empty() {
return None;
}
let url = self.build_search_url(trimmed);
// Use xdg-open to open the browser
let command = format!("xdg-open '{}'", url);
Some(LaunchItem {
id: format!("websearch:{}", trimmed),
name: format!("Search: {}", trimmed),
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"));
}
}

View File

@@ -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

View File

@@ -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

View File

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