feat: add widget providers (weather, media, pomodoro)
- Weather widget with Open-Meteo/wttr.in/OpenWeatherMap API support - 15-minute weather caching with geocoding for city names - MPRIS media player widget with play/pause toggle via dbus-send - Pomodoro timer widget with configurable work/break cycles - Widgets display at top of results with emoji icons - Improved terminal detection for Hyprland/Sway environments - Updated gtk4 to 0.10, gtk4-layer-shell to 0.7 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1608
Cargo.lock
generated
1608
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
10
Cargo.toml
10
Cargo.toml
@@ -12,10 +12,10 @@ categories = ["gui"]
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# GTK4 for the UI
|
# GTK4 for the UI
|
||||||
gtk4 = { version = "0.9", features = ["v4_12"] }
|
gtk4 = { version = "0.10", features = ["v4_12"] }
|
||||||
|
|
||||||
# Layer shell support for Wayland overlay behavior
|
# Layer shell support for Wayland overlay behavior
|
||||||
gtk4-layer-shell = "0.4"
|
gtk4-layer-shell = "0.7"
|
||||||
|
|
||||||
# Async runtime for non-blocking operations
|
# Async runtime for non-blocking operations
|
||||||
tokio = { version = "1", features = ["rt", "sync", "process", "fs"] }
|
tokio = { version = "1", features = ["rt", "sync", "process", "fs"] }
|
||||||
@@ -55,6 +55,12 @@ serde_json = "1"
|
|||||||
# Date/time for frecency calculations
|
# Date/time for frecency calculations
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
|
||||||
|
# D-Bus for MPRIS media player integration
|
||||||
|
zbus = { version = "4", default-features = false, features = ["tokio"] }
|
||||||
|
|
||||||
|
# HTTP client for weather API
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "blocking"] }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
# Enable verbose debug logging (for development/testing builds)
|
# Enable verbose debug logging (for development/testing builds)
|
||||||
|
|||||||
@@ -18,8 +18,10 @@
|
|||||||
show_icons = true
|
show_icons = true
|
||||||
max_results = 10
|
max_results = 10
|
||||||
|
|
||||||
# Terminal emulator (auto-detected if not set)
|
# Terminal emulator for SSH, scripts, etc.
|
||||||
terminal_command = "kitty"
|
# Auto-detection order: $TERMINAL → xdg-terminal-exec → DE-native → Wayland → X11 → xterm
|
||||||
|
# Uncomment to override:
|
||||||
|
# terminal_command = "kitty"
|
||||||
|
|
||||||
# Launch wrapper for app execution (auto-detected for uwsm/Hyprland)
|
# Launch wrapper for app execution (auto-detected for uwsm/Hyprland)
|
||||||
# Examples: "uwsm app --", "hyprctl dispatch exec --", ""
|
# Examples: "uwsm app --", "hyprctl dispatch exec --", ""
|
||||||
@@ -34,8 +36,8 @@ tabs = ["app", "cmd", "uuctl"]
|
|||||||
# ═══════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
[appearance]
|
[appearance]
|
||||||
width = 700
|
width = 850
|
||||||
height = 500
|
height = 650
|
||||||
font_size = 14
|
font_size = 14
|
||||||
border_radius = 12
|
border_radius = 12
|
||||||
|
|
||||||
@@ -113,3 +115,21 @@ emoji = true
|
|||||||
|
|
||||||
# Scripts: :script - executables from ~/.local/share/owlry/scripts/
|
# Scripts: :script - executables from ~/.local/share/owlry/scripts/
|
||||||
scripts = true
|
scripts = true
|
||||||
|
|
||||||
|
# ───────────────────────────────────────────────────────────────────────
|
||||||
|
# Widget Providers (shown at top of results)
|
||||||
|
# ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# MPRIS media player controls - shows now playing with play/pause/skip
|
||||||
|
media = true
|
||||||
|
|
||||||
|
# Weather widget - shows current conditions
|
||||||
|
weather = false
|
||||||
|
weather_provider = "wttr.in" # wttr.in (default), openweathermap, open-meteo
|
||||||
|
# weather_api_key = "" # Required for OpenWeatherMap
|
||||||
|
weather_location = "Berlin" # City name, "lat,lon", or leave empty for auto
|
||||||
|
|
||||||
|
# Pomodoro timer - work/break timer with controls
|
||||||
|
pomodoro = false
|
||||||
|
pomodoro_work_mins = 25 # Work session duration
|
||||||
|
pomodoro_break_mins = 5 # Break duration
|
||||||
|
|||||||
52
src/app.rs
52
src/app.rs
@@ -3,7 +3,7 @@ use crate::config::Config;
|
|||||||
use crate::data::FrecencyStore;
|
use crate::data::FrecencyStore;
|
||||||
use crate::filter::ProviderFilter;
|
use crate::filter::ProviderFilter;
|
||||||
use crate::paths;
|
use crate::paths;
|
||||||
use crate::providers::ProviderManager;
|
use crate::providers::{PomodoroConfig, ProviderManager, WeatherConfig, WeatherProviderType};
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
use crate::ui::MainWindow;
|
use crate::ui::MainWindow;
|
||||||
use gtk4::prelude::*;
|
use gtk4::prelude::*;
|
||||||
@@ -43,7 +43,38 @@ impl OwlryApp {
|
|||||||
let config = Rc::new(RefCell::new(Config::load_or_default()));
|
let config = Rc::new(RefCell::new(Config::load_or_default()));
|
||||||
let search_engine = config.borrow().providers.search_engine.clone();
|
let search_engine = config.borrow().providers.search_engine.clone();
|
||||||
let terminal = config.borrow().general.terminal_command.clone();
|
let terminal = config.borrow().general.terminal_command.clone();
|
||||||
let providers = Rc::new(RefCell::new(ProviderManager::with_config(&search_engine, &terminal)));
|
let media_enabled = config.borrow().providers.media;
|
||||||
|
|
||||||
|
// Build weather config if enabled
|
||||||
|
let weather_config = if config.borrow().providers.weather {
|
||||||
|
let cfg = config.borrow();
|
||||||
|
Some(WeatherConfig {
|
||||||
|
provider: cfg.providers.weather_provider.parse().unwrap_or(WeatherProviderType::WttrIn),
|
||||||
|
api_key: cfg.providers.weather_api_key.clone(),
|
||||||
|
location: cfg.providers.weather_location.clone().unwrap_or_default(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build pomodoro config if enabled
|
||||||
|
let pomodoro_config = if config.borrow().providers.pomodoro {
|
||||||
|
let cfg = config.borrow();
|
||||||
|
Some(PomodoroConfig {
|
||||||
|
work_mins: cfg.providers.pomodoro_work_mins,
|
||||||
|
break_mins: cfg.providers.pomodoro_break_mins,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let providers = Rc::new(RefCell::new(ProviderManager::with_config(
|
||||||
|
&search_engine,
|
||||||
|
&terminal,
|
||||||
|
media_enabled,
|
||||||
|
weather_config,
|
||||||
|
pomodoro_config,
|
||||||
|
)));
|
||||||
let frecency = Rc::new(RefCell::new(FrecencyStore::load_or_default()));
|
let frecency = Rc::new(RefCell::new(FrecencyStore::load_or_default()));
|
||||||
|
|
||||||
// Create filter from CLI args and config
|
// Create filter from CLI args and config
|
||||||
@@ -71,12 +102,29 @@ impl OwlryApp {
|
|||||||
// Position from top
|
// Position from top
|
||||||
window.set_margin(Edge::Top, 200);
|
window.set_margin(Edge::Top, 200);
|
||||||
|
|
||||||
|
// Set up icon theme fallbacks
|
||||||
|
Self::setup_icon_theme();
|
||||||
|
|
||||||
// Load CSS styling with config for theming
|
// Load CSS styling with config for theming
|
||||||
Self::load_css(&config.borrow());
|
Self::load_css(&config.borrow());
|
||||||
|
|
||||||
window.present();
|
window.present();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn setup_icon_theme() {
|
||||||
|
// Ensure we have icon fallbacks for weather/media icons
|
||||||
|
// These may not exist in all icon themes
|
||||||
|
if let Some(display) = gtk4::gdk::Display::default() {
|
||||||
|
let icon_theme = gtk4::IconTheme::for_display(&display);
|
||||||
|
|
||||||
|
// Add Adwaita as fallback search path (has weather and media icons)
|
||||||
|
icon_theme.add_search_path("/usr/share/icons/Adwaita");
|
||||||
|
icon_theme.add_search_path("/usr/share/icons/breeze");
|
||||||
|
|
||||||
|
debug!("Icon theme search paths configured with Adwaita/breeze fallbacks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn load_css(config: &Config) {
|
fn load_css(config: &Config) {
|
||||||
let display = gtk4::gdk::Display::default().expect("Could not get default display");
|
let display = gtk4::gdk::Display::default().expect("Could not get default display");
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,40 @@ pub struct ProvidersConfig {
|
|||||||
/// Enable file search (requires fd or locate)
|
/// Enable file search (requires fd or locate)
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub files: bool,
|
pub files: bool,
|
||||||
|
|
||||||
|
// ─── Widget Providers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Enable MPRIS media player widget
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub media: bool,
|
||||||
|
|
||||||
|
/// Enable weather widget
|
||||||
|
#[serde(default)]
|
||||||
|
pub weather: bool,
|
||||||
|
|
||||||
|
/// Weather provider: wttr.in (default), openweathermap, open-meteo
|
||||||
|
#[serde(default = "default_weather_provider")]
|
||||||
|
pub weather_provider: String,
|
||||||
|
|
||||||
|
/// API key for weather services that require it (e.g., OpenWeatherMap)
|
||||||
|
#[serde(default)]
|
||||||
|
pub weather_api_key: Option<String>,
|
||||||
|
|
||||||
|
/// Location for weather (city name or coordinates)
|
||||||
|
#[serde(default)]
|
||||||
|
pub weather_location: Option<String>,
|
||||||
|
|
||||||
|
/// Enable pomodoro timer widget
|
||||||
|
#[serde(default)]
|
||||||
|
pub pomodoro: bool,
|
||||||
|
|
||||||
|
/// Pomodoro work duration in minutes
|
||||||
|
#[serde(default = "default_pomodoro_work")]
|
||||||
|
pub pomodoro_work_mins: u32,
|
||||||
|
|
||||||
|
/// Pomodoro break duration in minutes
|
||||||
|
#[serde(default = "default_pomodoro_break")]
|
||||||
|
pub pomodoro_break_mins: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_search_engine() -> String {
|
fn default_search_engine() -> String {
|
||||||
@@ -134,6 +168,18 @@ fn default_frecency_weight() -> f64 {
|
|||||||
0.3
|
0.3
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_weather_provider() -> String {
|
||||||
|
"wttr.in".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_pomodoro_work() -> u32 {
|
||||||
|
25
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_pomodoro_break() -> u32 {
|
||||||
|
5
|
||||||
|
}
|
||||||
|
|
||||||
/// Detect the best launch wrapper for the current session
|
/// Detect the best launch wrapper for the current session
|
||||||
/// Checks for uwsm (Universal Wayland Session Manager) and hyprland
|
/// Checks for uwsm (Universal Wayland Session Manager) and hyprland
|
||||||
fn detect_launch_wrapper() -> Option<String> {
|
fn detect_launch_wrapper() -> Option<String> {
|
||||||
@@ -163,13 +209,14 @@ fn detect_launch_wrapper() -> Option<String> {
|
|||||||
/// Detect the best available terminal emulator
|
/// Detect the best available terminal emulator
|
||||||
/// Fallback chain:
|
/// Fallback chain:
|
||||||
/// 1. $TERMINAL env var (user's explicit preference)
|
/// 1. $TERMINAL env var (user's explicit preference)
|
||||||
/// 2. xdg-terminal-exec (freedesktop standard)
|
/// 2. xdg-terminal-exec (freedesktop standard - if available)
|
||||||
/// 3. Common Wayland-native terminals (kitty, alacritty, wezterm, foot)
|
/// 3. Desktop-environment native terminal (GNOME→gnome-terminal, KDE→konsole, etc.)
|
||||||
/// 4. Common X11/legacy terminals (gnome-terminal, konsole, xfce4-terminal)
|
/// 4. Common Wayland-native terminals (kitty, alacritty, wezterm, foot)
|
||||||
/// 5. x-terminal-emulator (Debian alternatives)
|
/// 5. Common X11/legacy terminals
|
||||||
/// 6. xterm (ultimate fallback)
|
/// 6. x-terminal-emulator (Debian alternatives)
|
||||||
|
/// 7. xterm (ultimate fallback - the cockroach of terminals)
|
||||||
fn detect_terminal() -> String {
|
fn detect_terminal() -> String {
|
||||||
// 1. Check $TERMINAL env var first
|
// 1. Check $TERMINAL env var first (user's explicit preference)
|
||||||
if let Ok(term) = std::env::var("TERMINAL") {
|
if let Ok(term) = std::env::var("TERMINAL") {
|
||||||
if !term.is_empty() && command_exists(&term) {
|
if !term.is_empty() && command_exists(&term) {
|
||||||
debug!("Using $TERMINAL: {}", term);
|
debug!("Using $TERMINAL: {}", term);
|
||||||
@@ -183,7 +230,13 @@ fn detect_terminal() -> String {
|
|||||||
return "xdg-terminal-exec".to_string();
|
return "xdg-terminal-exec".to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Common Wayland-native terminals (preferred)
|
// 3. Desktop-environment aware detection
|
||||||
|
if let Some(term) = detect_de_terminal() {
|
||||||
|
debug!("Using DE-native terminal: {}", term);
|
||||||
|
return term;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Common Wayland-native terminals (preferred for modern setups)
|
||||||
let wayland_terminals = ["kitty", "alacritty", "wezterm", "foot"];
|
let wayland_terminals = ["kitty", "alacritty", "wezterm", "foot"];
|
||||||
for term in wayland_terminals {
|
for term in wayland_terminals {
|
||||||
if command_exists(term) {
|
if command_exists(term) {
|
||||||
@@ -192,8 +245,8 @@ fn detect_terminal() -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Common X11/legacy terminals
|
// 5. Common X11/legacy terminals
|
||||||
let legacy_terminals = ["gnome-terminal", "konsole", "xfce4-terminal", "tilix", "terminator"];
|
let legacy_terminals = ["gnome-terminal", "konsole", "xfce4-terminal", "mate-terminal", "tilix", "terminator"];
|
||||||
for term in legacy_terminals {
|
for term in legacy_terminals {
|
||||||
if command_exists(term) {
|
if command_exists(term) {
|
||||||
debug!("Found legacy terminal: {}", term);
|
debug!("Found legacy terminal: {}", term);
|
||||||
@@ -201,17 +254,64 @@ fn detect_terminal() -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Try x-terminal-emulator (Debian alternatives system)
|
// 6. Try x-terminal-emulator (Debian alternatives system)
|
||||||
if command_exists("x-terminal-emulator") {
|
if command_exists("x-terminal-emulator") {
|
||||||
debug!("Using x-terminal-emulator");
|
debug!("Using x-terminal-emulator");
|
||||||
return "x-terminal-emulator".to_string();
|
return "x-terminal-emulator".to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Ultimate fallback
|
// 7. Ultimate fallback - xterm exists everywhere
|
||||||
debug!("Falling back to xterm");
|
debug!("Falling back to xterm");
|
||||||
"xterm".to_string()
|
"xterm".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Detect desktop environment and return its native terminal
|
||||||
|
fn detect_de_terminal() -> Option<String> {
|
||||||
|
// Check XDG_CURRENT_DESKTOP first
|
||||||
|
let desktop = std::env::var("XDG_CURRENT_DESKTOP")
|
||||||
|
.ok()
|
||||||
|
.map(|s| s.to_lowercase());
|
||||||
|
|
||||||
|
// Also check for Wayland compositor-specific env vars
|
||||||
|
let is_hyprland = std::env::var("HYPRLAND_INSTANCE_SIGNATURE").is_ok();
|
||||||
|
let is_sway = std::env::var("SWAYSOCK").is_ok();
|
||||||
|
|
||||||
|
// Map desktop environments to their native/preferred terminals
|
||||||
|
let candidates: &[&str] = if is_hyprland {
|
||||||
|
// Hyprland: foot and kitty are most popular in the community
|
||||||
|
&["foot", "kitty", "alacritty", "wezterm"]
|
||||||
|
} else if is_sway {
|
||||||
|
// Sway: foot is the recommended terminal (lightweight, Wayland-native)
|
||||||
|
&["foot", "alacritty", "kitty", "wezterm"]
|
||||||
|
} else if let Some(ref de) = desktop {
|
||||||
|
match de.as_str() {
|
||||||
|
s if s.contains("gnome") => &["gnome-terminal", "gnome-console", "kgx"],
|
||||||
|
s if s.contains("kde") || s.contains("plasma") => &["konsole"],
|
||||||
|
s if s.contains("xfce") => &["xfce4-terminal"],
|
||||||
|
s if s.contains("mate") => &["mate-terminal"],
|
||||||
|
s if s.contains("lxqt") => &["qterminal"],
|
||||||
|
s if s.contains("lxde") => &["lxterminal"],
|
||||||
|
s if s.contains("cinnamon") => &["gnome-terminal"],
|
||||||
|
s if s.contains("budgie") => &["tilix", "gnome-terminal"],
|
||||||
|
s if s.contains("pantheon") => &["io.elementary.terminal", "pantheon-terminal"],
|
||||||
|
s if s.contains("deepin") => &["deepin-terminal"],
|
||||||
|
s if s.contains("hyprland") => &["foot", "kitty", "alacritty", "wezterm"],
|
||||||
|
s if s.contains("sway") => &["foot", "alacritty", "kitty", "wezterm"],
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
for term in candidates {
|
||||||
|
if command_exists(term) {
|
||||||
|
return Some(term.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if a command exists in PATH
|
/// Check if a command exists in PATH
|
||||||
fn command_exists(cmd: &str) -> bool {
|
fn command_exists(cmd: &str) -> bool {
|
||||||
Command::new("which")
|
Command::new("which")
|
||||||
@@ -235,8 +335,8 @@ impl Default for Config {
|
|||||||
tabs: default_tabs(),
|
tabs: default_tabs(),
|
||||||
},
|
},
|
||||||
appearance: AppearanceConfig {
|
appearance: AppearanceConfig {
|
||||||
width: 700,
|
width: 850,
|
||||||
height: 500,
|
height: 650,
|
||||||
font_size: 14,
|
font_size: 14,
|
||||||
border_radius: 12,
|
border_radius: 12,
|
||||||
theme: None,
|
theme: None,
|
||||||
@@ -258,6 +358,15 @@ impl Default for Config {
|
|||||||
emoji: true,
|
emoji: true,
|
||||||
scripts: true,
|
scripts: true,
|
||||||
files: true,
|
files: true,
|
||||||
|
// Widget providers
|
||||||
|
media: true,
|
||||||
|
weather: false,
|
||||||
|
weather_provider: "wttr.in".to_string(),
|
||||||
|
weather_api_key: None,
|
||||||
|
weather_location: Some("Berlin".to_string()),
|
||||||
|
pomodoro: false,
|
||||||
|
pomodoro_work_mins: 25,
|
||||||
|
pomodoro_break_mins: 5,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -292,11 +292,14 @@ impl ProviderFilter {
|
|||||||
ProviderType::Dmenu => 5,
|
ProviderType::Dmenu => 5,
|
||||||
ProviderType::Emoji => 6,
|
ProviderType::Emoji => 6,
|
||||||
ProviderType::Files => 7,
|
ProviderType::Files => 7,
|
||||||
ProviderType::Scripts => 8,
|
ProviderType::MediaPlayer => 8,
|
||||||
ProviderType::Ssh => 9,
|
ProviderType::Pomodoro => 9,
|
||||||
ProviderType::System => 10,
|
ProviderType::Scripts => 10,
|
||||||
ProviderType::Uuctl => 11,
|
ProviderType::Ssh => 11,
|
||||||
ProviderType::WebSearch => 12,
|
ProviderType::System => 12,
|
||||||
|
ProviderType::Uuctl => 13,
|
||||||
|
ProviderType::Weather => 14,
|
||||||
|
ProviderType::WebSearch => 15,
|
||||||
});
|
});
|
||||||
providers
|
providers
|
||||||
}
|
}
|
||||||
@@ -313,10 +316,13 @@ impl ProviderFilter {
|
|||||||
ProviderType::Dmenu => "dmenu",
|
ProviderType::Dmenu => "dmenu",
|
||||||
ProviderType::Emoji => "Emoji",
|
ProviderType::Emoji => "Emoji",
|
||||||
ProviderType::Files => "Files",
|
ProviderType::Files => "Files",
|
||||||
|
ProviderType::MediaPlayer => "Media",
|
||||||
|
ProviderType::Pomodoro => "Pomodoro",
|
||||||
ProviderType::Scripts => "Scripts",
|
ProviderType::Scripts => "Scripts",
|
||||||
ProviderType::Ssh => "SSH",
|
ProviderType::Ssh => "SSH",
|
||||||
ProviderType::System => "System",
|
ProviderType::System => "System",
|
||||||
ProviderType::Uuctl => "uuctl",
|
ProviderType::Uuctl => "uuctl",
|
||||||
|
ProviderType::Weather => "Weather",
|
||||||
ProviderType::WebSearch => "Web",
|
ProviderType::WebSearch => "Web",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -332,10 +338,13 @@ impl ProviderFilter {
|
|||||||
ProviderType::Dmenu => "dmenu",
|
ProviderType::Dmenu => "dmenu",
|
||||||
ProviderType::Emoji => "Emoji",
|
ProviderType::Emoji => "Emoji",
|
||||||
ProviderType::Files => "Files",
|
ProviderType::Files => "Files",
|
||||||
|
ProviderType::MediaPlayer => "Media",
|
||||||
|
ProviderType::Pomodoro => "Pomodoro",
|
||||||
ProviderType::Scripts => "Scripts",
|
ProviderType::Scripts => "Scripts",
|
||||||
ProviderType::Ssh => "SSH",
|
ProviderType::Ssh => "SSH",
|
||||||
ProviderType::System => "System",
|
ProviderType::System => "System",
|
||||||
ProviderType::Uuctl => "uuctl",
|
ProviderType::Uuctl => "uuctl",
|
||||||
|
ProviderType::Weather => "Weather",
|
||||||
ProviderType::WebSearch => "Web",
|
ProviderType::WebSearch => "Web",
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
264
src/providers/media.rs
Normal file
264
src/providers/media.rs
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
//! MPRIS D-Bus media player widget provider
|
||||||
|
//!
|
||||||
|
//! Shows currently playing track as a single row with play/pause action.
|
||||||
|
|
||||||
|
use super::{LaunchItem, Provider, ProviderType};
|
||||||
|
use log::debug;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
/// Media player provider using MPRIS D-Bus interface
|
||||||
|
pub struct MediaProvider {
|
||||||
|
items: Vec<LaunchItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
struct MediaState {
|
||||||
|
player_name: String,
|
||||||
|
title: String,
|
||||||
|
artist: String,
|
||||||
|
is_playing: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MediaProvider {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut provider = Self { items: Vec::new() };
|
||||||
|
provider.refresh();
|
||||||
|
provider
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find active MPRIS players via dbus-send
|
||||||
|
fn find_players() -> Vec<String> {
|
||||||
|
let output = Command::new("dbus-send")
|
||||||
|
.args([
|
||||||
|
"--session",
|
||||||
|
"--dest=org.freedesktop.DBus",
|
||||||
|
"--type=method_call",
|
||||||
|
"--print-reply",
|
||||||
|
"/org/freedesktop/DBus",
|
||||||
|
"org.freedesktop.DBus.ListNames",
|
||||||
|
])
|
||||||
|
.output();
|
||||||
|
|
||||||
|
match output {
|
||||||
|
Ok(out) => {
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
stdout
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.starts_with("string \"org.mpris.MediaPlayer2.") {
|
||||||
|
let start = "string \"org.mpris.MediaPlayer2.".len();
|
||||||
|
let end = trimmed.len() - 1;
|
||||||
|
Some(trimmed[start..end].to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
debug!("Failed to list D-Bus names: {}", e);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get metadata from an MPRIS player
|
||||||
|
fn get_player_state(player: &str) -> Option<MediaState> {
|
||||||
|
let dest = format!("org.mpris.MediaPlayer2.{}", player);
|
||||||
|
|
||||||
|
// Get playback status
|
||||||
|
let status_output = Command::new("dbus-send")
|
||||||
|
.args([
|
||||||
|
"--session",
|
||||||
|
&format!("--dest={}", dest),
|
||||||
|
"--type=method_call",
|
||||||
|
"--print-reply",
|
||||||
|
"/org/mpris/MediaPlayer2",
|
||||||
|
"org.freedesktop.DBus.Properties.Get",
|
||||||
|
"string:org.mpris.MediaPlayer2.Player",
|
||||||
|
"string:PlaybackStatus",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
let status_str = String::from_utf8_lossy(&status_output.stdout);
|
||||||
|
let is_playing = status_str.contains("\"Playing\"");
|
||||||
|
let is_paused = status_str.contains("\"Paused\"");
|
||||||
|
|
||||||
|
// Only show if playing or paused (not stopped)
|
||||||
|
if !is_playing && !is_paused {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get metadata
|
||||||
|
let metadata_output = Command::new("dbus-send")
|
||||||
|
.args([
|
||||||
|
"--session",
|
||||||
|
&format!("--dest={}", dest),
|
||||||
|
"--type=method_call",
|
||||||
|
"--print-reply",
|
||||||
|
"/org/mpris/MediaPlayer2",
|
||||||
|
"org.freedesktop.DBus.Properties.Get",
|
||||||
|
"string:org.mpris.MediaPlayer2.Player",
|
||||||
|
"string:Metadata",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
let metadata_str = String::from_utf8_lossy(&metadata_output.stdout);
|
||||||
|
|
||||||
|
let title = Self::extract_string(&metadata_str, "xesam:title")
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string());
|
||||||
|
let artist = Self::extract_array(&metadata_str, "xesam:artist")
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string());
|
||||||
|
|
||||||
|
Some(MediaState {
|
||||||
|
player_name: player.to_string(),
|
||||||
|
title,
|
||||||
|
artist,
|
||||||
|
is_playing,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract string value from D-Bus output
|
||||||
|
fn extract_string(output: &str, key: &str) -> Option<String> {
|
||||||
|
let key_pattern = format!("\"{}\"", key);
|
||||||
|
let mut found = false;
|
||||||
|
|
||||||
|
for line in output.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.contains(&key_pattern) {
|
||||||
|
found = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
if let Some(pos) = trimmed.find("string \"") {
|
||||||
|
let start = pos + "string \"".len();
|
||||||
|
if let Some(end) = trimmed[start..].find('"') {
|
||||||
|
let value = &trimmed[start..start + end];
|
||||||
|
if !value.is_empty() {
|
||||||
|
return Some(value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !trimmed.starts_with("variant") {
|
||||||
|
found = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract array value from D-Bus output
|
||||||
|
fn extract_array(output: &str, key: &str) -> Option<String> {
|
||||||
|
let key_pattern = format!("\"{}\"", key);
|
||||||
|
let mut found = false;
|
||||||
|
let mut in_array = false;
|
||||||
|
let mut values = Vec::new();
|
||||||
|
|
||||||
|
for line in output.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.contains(&key_pattern) {
|
||||||
|
found = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if found && trimmed.contains("array [") {
|
||||||
|
in_array = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if in_array {
|
||||||
|
if let Some(pos) = trimmed.find("string \"") {
|
||||||
|
let start = pos + "string \"".len();
|
||||||
|
if let Some(end) = trimmed[start..].find('"') {
|
||||||
|
values.push(trimmed[start..start + end].to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if trimmed.contains(']') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if values.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(values.join(", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate single LaunchItem for media state
|
||||||
|
fn generate_items(&mut self, state: &MediaState) {
|
||||||
|
self.items.clear();
|
||||||
|
|
||||||
|
let status_icon = if state.is_playing { "▶️" } else { "⏸️" };
|
||||||
|
let action = if state.is_playing { "Pause" } else { "Play" };
|
||||||
|
|
||||||
|
// Single row: "🎵 ▶️ Title — Artist"
|
||||||
|
let name = format!("🎵 {} {} — {}", status_icon, state.title, state.artist);
|
||||||
|
|
||||||
|
// Extract player display name (e.g., "firefox.instance_1_94" -> "Firefox")
|
||||||
|
let player_display = state.player_name
|
||||||
|
.split('.')
|
||||||
|
.next()
|
||||||
|
.unwrap_or(&state.player_name);
|
||||||
|
let player_display = player_display[0..1].to_uppercase() + &player_display[1..];
|
||||||
|
|
||||||
|
let command = format!(
|
||||||
|
"dbus-send --session --dest=org.mpris.MediaPlayer2.{} /org/mpris/MediaPlayer2 org.mpris.MediaPlayer2.Player.PlayPause",
|
||||||
|
state.player_name
|
||||||
|
);
|
||||||
|
|
||||||
|
self.items.push(LaunchItem {
|
||||||
|
id: "media-now-playing".to_string(),
|
||||||
|
name,
|
||||||
|
description: Some(format!("{} · Press Enter to {}", player_display, action)),
|
||||||
|
icon: None, // Using emoji in name instead (▶/⏸)
|
||||||
|
provider: ProviderType::MediaPlayer,
|
||||||
|
command,
|
||||||
|
terminal: false,
|
||||||
|
tags: vec!["media".to_string(), "widget".to_string()],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Provider for MediaProvider {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"Media Player"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_type(&self) -> ProviderType {
|
||||||
|
ProviderType::MediaPlayer
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh(&mut self) {
|
||||||
|
self.items.clear();
|
||||||
|
|
||||||
|
let players = Self::find_players();
|
||||||
|
if players.is_empty() {
|
||||||
|
debug!("No MPRIS players found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find first active player
|
||||||
|
for player in &players {
|
||||||
|
if let Some(state) = Self::get_player_state(player) {
|
||||||
|
debug!("Found active player: {} - {}", player, state.title);
|
||||||
|
self.generate_items(&state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("No active media found");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn items(&self) -> &[LaunchItem] {
|
||||||
|
&self.items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MediaProvider {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,10 +6,13 @@ mod command;
|
|||||||
mod dmenu;
|
mod dmenu;
|
||||||
mod emoji;
|
mod emoji;
|
||||||
mod files;
|
mod files;
|
||||||
|
mod media;
|
||||||
|
mod pomodoro;
|
||||||
mod scripts;
|
mod scripts;
|
||||||
mod ssh;
|
mod ssh;
|
||||||
mod system;
|
mod system;
|
||||||
mod uuctl;
|
mod uuctl;
|
||||||
|
mod weather;
|
||||||
mod websearch;
|
mod websearch;
|
||||||
|
|
||||||
pub use application::ApplicationProvider;
|
pub use application::ApplicationProvider;
|
||||||
@@ -20,10 +23,13 @@ pub use command::CommandProvider;
|
|||||||
pub use dmenu::DmenuProvider;
|
pub use dmenu::DmenuProvider;
|
||||||
pub use emoji::EmojiProvider;
|
pub use emoji::EmojiProvider;
|
||||||
pub use files::FileSearchProvider;
|
pub use files::FileSearchProvider;
|
||||||
|
pub use media::MediaProvider;
|
||||||
|
pub use pomodoro::{PomodoroConfig, PomodoroProvider};
|
||||||
pub use scripts::ScriptsProvider;
|
pub use scripts::ScriptsProvider;
|
||||||
pub use ssh::SshProvider;
|
pub use ssh::SshProvider;
|
||||||
pub use system::SystemProvider;
|
pub use system::SystemProvider;
|
||||||
pub use uuctl::UuctlProvider;
|
pub use uuctl::UuctlProvider;
|
||||||
|
pub use weather::{WeatherConfig, WeatherProvider, WeatherProviderType};
|
||||||
pub use websearch::WebSearchProvider;
|
pub use websearch::WebSearchProvider;
|
||||||
|
|
||||||
use fuzzy_matcher::FuzzyMatcher;
|
use fuzzy_matcher::FuzzyMatcher;
|
||||||
@@ -60,10 +66,13 @@ pub enum ProviderType {
|
|||||||
Dmenu,
|
Dmenu,
|
||||||
Emoji,
|
Emoji,
|
||||||
Files,
|
Files,
|
||||||
|
MediaPlayer,
|
||||||
|
Pomodoro,
|
||||||
Scripts,
|
Scripts,
|
||||||
Ssh,
|
Ssh,
|
||||||
System,
|
System,
|
||||||
Uuctl,
|
Uuctl,
|
||||||
|
Weather,
|
||||||
WebSearch,
|
WebSearch,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,13 +89,16 @@ impl std::str::FromStr for ProviderType {
|
|||||||
"dmenu" => Ok(ProviderType::Dmenu),
|
"dmenu" => Ok(ProviderType::Dmenu),
|
||||||
"emoji" | "emojis" => Ok(ProviderType::Emoji),
|
"emoji" | "emojis" => Ok(ProviderType::Emoji),
|
||||||
"file" | "files" | "find" => Ok(ProviderType::Files),
|
"file" | "files" | "find" => Ok(ProviderType::Files),
|
||||||
|
"media" | "mpris" | "player" => Ok(ProviderType::MediaPlayer),
|
||||||
|
"pomo" | "pomodoro" | "timer" => Ok(ProviderType::Pomodoro),
|
||||||
"script" | "scripts" => Ok(ProviderType::Scripts),
|
"script" | "scripts" => Ok(ProviderType::Scripts),
|
||||||
"ssh" => Ok(ProviderType::Ssh),
|
"ssh" => Ok(ProviderType::Ssh),
|
||||||
"sys" | "system" | "power" => Ok(ProviderType::System),
|
"sys" | "system" | "power" => Ok(ProviderType::System),
|
||||||
"uuctl" => Ok(ProviderType::Uuctl),
|
"uuctl" => Ok(ProviderType::Uuctl),
|
||||||
|
"weather" => Ok(ProviderType::Weather),
|
||||||
"web" | "websearch" | "search" => Ok(ProviderType::WebSearch),
|
"web" | "websearch" | "search" => Ok(ProviderType::WebSearch),
|
||||||
_ => Err(format!(
|
_ => Err(format!(
|
||||||
"Unknown provider: '{}'. Valid: app, bookmark, calc, clip, cmd, emoji, file, script, ssh, sys, web",
|
"Unknown provider: '{}'. Valid: app, bookmark, calc, clip, cmd, emoji, file, media, pomo, script, ssh, sys, weather, web",
|
||||||
s
|
s
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
@@ -104,10 +116,13 @@ impl std::fmt::Display for ProviderType {
|
|||||||
ProviderType::Dmenu => write!(f, "dmenu"),
|
ProviderType::Dmenu => write!(f, "dmenu"),
|
||||||
ProviderType::Emoji => write!(f, "emoji"),
|
ProviderType::Emoji => write!(f, "emoji"),
|
||||||
ProviderType::Files => write!(f, "file"),
|
ProviderType::Files => write!(f, "file"),
|
||||||
|
ProviderType::MediaPlayer => write!(f, "media"),
|
||||||
|
ProviderType::Pomodoro => write!(f, "pomo"),
|
||||||
ProviderType::Scripts => write!(f, "script"),
|
ProviderType::Scripts => write!(f, "script"),
|
||||||
ProviderType::Ssh => write!(f, "ssh"),
|
ProviderType::Ssh => write!(f, "ssh"),
|
||||||
ProviderType::System => write!(f, "sys"),
|
ProviderType::System => write!(f, "sys"),
|
||||||
ProviderType::Uuctl => write!(f, "uuctl"),
|
ProviderType::Uuctl => write!(f, "uuctl"),
|
||||||
|
ProviderType::Weather => write!(f, "weather"),
|
||||||
ProviderType::WebSearch => write!(f, "web"),
|
ProviderType::WebSearch => write!(f, "web"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,6 +143,10 @@ pub struct ProviderManager {
|
|||||||
calculator: CalculatorProvider,
|
calculator: CalculatorProvider,
|
||||||
websearch: WebSearchProvider,
|
websearch: WebSearchProvider,
|
||||||
filesearch: FileSearchProvider,
|
filesearch: FileSearchProvider,
|
||||||
|
// Widget providers (optional, controlled by config)
|
||||||
|
media: Option<MediaProvider>,
|
||||||
|
weather: Option<WeatherProvider>,
|
||||||
|
pomodoro: Option<PomodoroProvider>,
|
||||||
matcher: SkimMatcherV2,
|
matcher: SkimMatcherV2,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,15 +157,26 @@ impl ProviderManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_search_engine(search_engine: &str) -> Self {
|
pub fn with_search_engine(search_engine: &str) -> Self {
|
||||||
Self::with_config(search_engine, "kitty")
|
// Use xterm as fallback - it's the universal cockroach of terminals
|
||||||
|
// In practice, app.rs passes the detected/configured terminal
|
||||||
|
Self::with_config(search_engine, "xterm", true, None, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_config(search_engine: &str, terminal: &str) -> Self {
|
pub fn with_config(
|
||||||
|
search_engine: &str,
|
||||||
|
terminal: &str,
|
||||||
|
media_enabled: bool,
|
||||||
|
weather_config: Option<WeatherConfig>,
|
||||||
|
pomodoro_config: Option<PomodoroConfig>,
|
||||||
|
) -> Self {
|
||||||
let mut manager = Self {
|
let mut manager = Self {
|
||||||
providers: Vec::new(),
|
providers: Vec::new(),
|
||||||
calculator: CalculatorProvider::new(),
|
calculator: CalculatorProvider::new(),
|
||||||
websearch: WebSearchProvider::with_engine(search_engine),
|
websearch: WebSearchProvider::with_engine(search_engine),
|
||||||
filesearch: FileSearchProvider::new(),
|
filesearch: FileSearchProvider::new(),
|
||||||
|
media: if media_enabled { Some(MediaProvider::new()) } else { None },
|
||||||
|
weather: weather_config.map(WeatherProvider::new),
|
||||||
|
pomodoro: pomodoro_config.map(PomodoroProvider::new),
|
||||||
matcher: SkimMatcherV2::default(),
|
matcher: SkimMatcherV2::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -195,6 +225,25 @@ impl ProviderManager {
|
|||||||
provider.items().len()
|
provider.items().len()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refresh widget providers
|
||||||
|
if let Some(ref mut media) = self.media {
|
||||||
|
media.refresh();
|
||||||
|
info!("Media widget loaded {} items", media.items().len());
|
||||||
|
}
|
||||||
|
if let Some(ref mut weather) = self.weather {
|
||||||
|
weather.refresh();
|
||||||
|
info!("Weather widget loaded {} items", weather.items().len());
|
||||||
|
}
|
||||||
|
if let Some(ref mut pomodoro) = self.pomodoro {
|
||||||
|
pomodoro.refresh();
|
||||||
|
info!("Pomodoro widget loaded {} items", pomodoro.items().len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get mutable reference to pomodoro provider (for handling actions)
|
||||||
|
pub fn pomodoro_mut(&mut self) -> Option<&mut PomodoroProvider> {
|
||||||
|
self.pomodoro.as_mut()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -299,6 +348,30 @@ impl ProviderManager {
|
|||||||
|
|
||||||
let mut results: Vec<(LaunchItem, i64)> = Vec::new();
|
let mut results: Vec<(LaunchItem, i64)> = Vec::new();
|
||||||
|
|
||||||
|
// Add widget items first (highest priority) - only when no specific filter is active
|
||||||
|
if filter.active_prefix().is_none() {
|
||||||
|
// Weather widget (score 12000) - shown at very top
|
||||||
|
if let Some(ref weather) = self.weather {
|
||||||
|
for item in weather.items() {
|
||||||
|
results.push((item.clone(), 12000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pomodoro widget (scores 11500-11502)
|
||||||
|
if let Some(ref pomodoro) = self.pomodoro {
|
||||||
|
for (idx, item) in pomodoro.items().iter().enumerate() {
|
||||||
|
results.push((item.clone(), 11502 - idx as i64));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media widget (scores 11000-11003)
|
||||||
|
if let Some(ref media) = self.media {
|
||||||
|
for (idx, item) in media.items().iter().enumerate() {
|
||||||
|
results.push((item.clone(), 11003 - idx as i64));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check for calculator query (= or calc prefix)
|
// Check for calculator query (= or calc prefix)
|
||||||
if CalculatorProvider::is_calculator_query(query) {
|
if CalculatorProvider::is_calculator_query(query) {
|
||||||
if let Some(calc_result) = self.calculator.evaluate(query) {
|
if let Some(calc_result) = self.calculator.evaluate(query) {
|
||||||
@@ -350,7 +423,7 @@ impl ProviderManager {
|
|||||||
|
|
||||||
// Empty query (after checking special providers) - return frecency-sorted items
|
// Empty query (after checking special providers) - return frecency-sorted items
|
||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
let mut items: Vec<(LaunchItem, i64)> = self
|
let items: Vec<(LaunchItem, i64)> = self
|
||||||
.providers
|
.providers
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|p| filter.is_active(p.provider_type()))
|
.filter(|p| filter.is_active(p.provider_type()))
|
||||||
@@ -370,9 +443,11 @@ impl ProviderManager {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
items.sort_by(|a, b| b.1.cmp(&a.1));
|
// Combine widgets (already in results) with frecency items
|
||||||
items.truncate(max_results);
|
results.extend(items);
|
||||||
return items;
|
results.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
|
results.truncate(max_results);
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular search with frecency boost and tag matching
|
// Regular search with frecency boost and tag matching
|
||||||
|
|||||||
334
src/providers/pomodoro.rs
Normal file
334
src/providers/pomodoro.rs
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
//! Pomodoro timer widget provider
|
||||||
|
//!
|
||||||
|
//! Shows timer with work/break cycles and playback-style controls.
|
||||||
|
//! State persists across sessions via JSON file.
|
||||||
|
|
||||||
|
use super::{LaunchItem, Provider, ProviderType};
|
||||||
|
use crate::paths;
|
||||||
|
use log::{debug, warn};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
/// Pomodoro timer provider with persistent state
|
||||||
|
pub struct PomodoroProvider {
|
||||||
|
items: Vec<LaunchItem>,
|
||||||
|
state: PomodoroState,
|
||||||
|
config: PomodoroConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PomodoroConfig {
|
||||||
|
pub work_mins: u32,
|
||||||
|
pub break_mins: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PomodoroConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
work_mins: 25,
|
||||||
|
break_mins: 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Timer phase
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub enum PomodoroPhase {
|
||||||
|
Idle,
|
||||||
|
Working,
|
||||||
|
WorkPaused,
|
||||||
|
Break,
|
||||||
|
BreakPaused,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PomodoroPhase {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persistent state (saved to disk)
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
struct PomodoroState {
|
||||||
|
phase: PomodoroPhase,
|
||||||
|
/// Remaining seconds in current phase
|
||||||
|
remaining_secs: u32,
|
||||||
|
/// Completed work sessions count
|
||||||
|
sessions: u32,
|
||||||
|
/// Unix timestamp when timer was last updated (for calculating elapsed time)
|
||||||
|
last_update: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PomodoroProvider {
|
||||||
|
pub fn new(config: PomodoroConfig) -> Self {
|
||||||
|
let state = Self::load_state().unwrap_or_else(|| PomodoroState {
|
||||||
|
phase: PomodoroPhase::Idle,
|
||||||
|
remaining_secs: config.work_mins * 60,
|
||||||
|
sessions: 0,
|
||||||
|
last_update: Self::now_secs(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut provider = Self {
|
||||||
|
items: Vec::new(),
|
||||||
|
state,
|
||||||
|
config,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update timer based on elapsed time since last save
|
||||||
|
provider.update_elapsed_time();
|
||||||
|
provider.generate_items();
|
||||||
|
provider
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current unix timestamp
|
||||||
|
fn now_secs() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load state from disk
|
||||||
|
fn load_state() -> Option<PomodoroState> {
|
||||||
|
let path = paths::owlry_data_dir()?.join("pomodoro.json");
|
||||||
|
let content = fs::read_to_string(&path).ok()?;
|
||||||
|
serde_json::from_str(&content).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save state to disk
|
||||||
|
fn save_state(&self) {
|
||||||
|
if let Some(data_dir) = paths::owlry_data_dir() {
|
||||||
|
let path = data_dir.join("pomodoro.json");
|
||||||
|
if let Err(e) = fs::create_dir_all(&data_dir) {
|
||||||
|
warn!("Failed to create data dir: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut state = self.state.clone();
|
||||||
|
state.last_update = Self::now_secs();
|
||||||
|
if let Ok(json) = serde_json::to_string_pretty(&state) {
|
||||||
|
if let Err(e) = fs::write(&path, json) {
|
||||||
|
warn!("Failed to save pomodoro state: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update remaining time based on elapsed time since last update
|
||||||
|
fn update_elapsed_time(&mut self) {
|
||||||
|
let now = Self::now_secs();
|
||||||
|
let elapsed = now.saturating_sub(self.state.last_update);
|
||||||
|
|
||||||
|
match self.state.phase {
|
||||||
|
PomodoroPhase::Working | PomodoroPhase::Break => {
|
||||||
|
if elapsed >= self.state.remaining_secs as u64 {
|
||||||
|
// Timer completed while app was closed
|
||||||
|
self.complete_phase();
|
||||||
|
} else {
|
||||||
|
self.state.remaining_secs -= elapsed as u32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Paused or idle - no time passes
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
self.state.last_update = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete current phase and transition to next
|
||||||
|
fn complete_phase(&mut self) {
|
||||||
|
match self.state.phase {
|
||||||
|
PomodoroPhase::Working => {
|
||||||
|
self.state.sessions += 1;
|
||||||
|
self.state.phase = PomodoroPhase::Break;
|
||||||
|
self.state.remaining_secs = self.config.break_mins * 60;
|
||||||
|
debug!("Work session {} completed, starting break", self.state.sessions);
|
||||||
|
}
|
||||||
|
PomodoroPhase::Break => {
|
||||||
|
self.state.phase = PomodoroPhase::Idle;
|
||||||
|
self.state.remaining_secs = self.config.work_mins * 60;
|
||||||
|
debug!("Break completed, ready for next session");
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
self.save_state();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle action commands (called from MainWindow)
|
||||||
|
pub fn handle_action(&mut self, action: &str) {
|
||||||
|
debug!("Pomodoro action: {}", action);
|
||||||
|
match action {
|
||||||
|
"start" | "resume" => self.start_or_resume(),
|
||||||
|
"pause" => self.pause(),
|
||||||
|
"reset" => self.reset(),
|
||||||
|
"skip" => self.skip_phase(),
|
||||||
|
_ => warn!("Unknown pomodoro action: {}", action),
|
||||||
|
}
|
||||||
|
self.save_state();
|
||||||
|
self.generate_items();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_or_resume(&mut self) {
|
||||||
|
match self.state.phase {
|
||||||
|
PomodoroPhase::Idle => {
|
||||||
|
self.state.phase = PomodoroPhase::Working;
|
||||||
|
self.state.remaining_secs = self.config.work_mins * 60;
|
||||||
|
}
|
||||||
|
PomodoroPhase::WorkPaused => {
|
||||||
|
self.state.phase = PomodoroPhase::Working;
|
||||||
|
}
|
||||||
|
PomodoroPhase::BreakPaused => {
|
||||||
|
self.state.phase = PomodoroPhase::Break;
|
||||||
|
}
|
||||||
|
_ => {} // Already running
|
||||||
|
}
|
||||||
|
self.state.last_update = Self::now_secs();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pause(&mut self) {
|
||||||
|
match self.state.phase {
|
||||||
|
PomodoroPhase::Working => {
|
||||||
|
self.state.phase = PomodoroPhase::WorkPaused;
|
||||||
|
}
|
||||||
|
PomodoroPhase::Break => {
|
||||||
|
self.state.phase = PomodoroPhase::BreakPaused;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.state.phase = PomodoroPhase::Idle;
|
||||||
|
self.state.remaining_secs = self.config.work_mins * 60;
|
||||||
|
self.state.sessions = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skip_phase(&mut self) {
|
||||||
|
self.complete_phase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format seconds as MM:SS
|
||||||
|
fn format_time(secs: u32) -> String {
|
||||||
|
let mins = secs / 60;
|
||||||
|
let secs = secs % 60;
|
||||||
|
format!("{:02}:{:02}", mins, secs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate LaunchItems from current state
|
||||||
|
fn generate_items(&mut self) {
|
||||||
|
self.items.clear();
|
||||||
|
|
||||||
|
let (phase_name, phase_icon, is_running) = match self.state.phase {
|
||||||
|
PomodoroPhase::Idle => ("Ready", "media-playback-start", false),
|
||||||
|
PomodoroPhase::Working => ("Work", "media-playback-start", true),
|
||||||
|
PomodoroPhase::WorkPaused => ("Work (Paused)", "media-playback-pause", false),
|
||||||
|
PomodoroPhase::Break => ("Break", "face-cool", true),
|
||||||
|
PomodoroPhase::BreakPaused => ("Break (Paused)", "media-playback-pause", false),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Timer display row
|
||||||
|
let time_str = Self::format_time(self.state.remaining_secs);
|
||||||
|
let name = format!("{}: {}", phase_name, time_str);
|
||||||
|
let description = if self.state.sessions > 0 {
|
||||||
|
Some(format!("Sessions: {} | {}min work / {}min break",
|
||||||
|
self.state.sessions, self.config.work_mins, self.config.break_mins))
|
||||||
|
} else {
|
||||||
|
Some(format!("{}min work / {}min break",
|
||||||
|
self.config.work_mins, self.config.break_mins))
|
||||||
|
};
|
||||||
|
|
||||||
|
self.items.push(LaunchItem {
|
||||||
|
id: "pomo-timer".to_string(),
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
icon: Some(phase_icon.to_string()),
|
||||||
|
provider: ProviderType::Pomodoro,
|
||||||
|
command: String::new(), // Info only
|
||||||
|
terminal: false,
|
||||||
|
tags: vec!["pomodoro".to_string(), "widget".to_string(), "timer".to_string()],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Primary control: Start/Pause
|
||||||
|
let (control_name, control_icon, control_action) = if is_running {
|
||||||
|
("Pause", "media-playback-pause", "pause")
|
||||||
|
} else {
|
||||||
|
match self.state.phase {
|
||||||
|
PomodoroPhase::Idle => ("Start Work", "media-playback-start", "start"),
|
||||||
|
_ => ("Resume", "media-playback-start", "resume"),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.items.push(LaunchItem {
|
||||||
|
id: "pomo-control".to_string(),
|
||||||
|
name: control_name.to_string(),
|
||||||
|
description: Some(format!("{} timer", control_name)),
|
||||||
|
icon: Some(control_icon.to_string()),
|
||||||
|
provider: ProviderType::Pomodoro,
|
||||||
|
command: format!("POMODORO:{}", control_action),
|
||||||
|
terminal: false,
|
||||||
|
tags: vec!["pomodoro".to_string(), "control".to_string()],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Secondary controls: Reset and Skip
|
||||||
|
if self.state.phase != PomodoroPhase::Idle {
|
||||||
|
self.items.push(LaunchItem {
|
||||||
|
id: "pomo-skip".to_string(),
|
||||||
|
name: "Skip".to_string(),
|
||||||
|
description: Some("Skip to next phase".to_string()),
|
||||||
|
icon: Some("media-skip-forward".to_string()),
|
||||||
|
provider: ProviderType::Pomodoro,
|
||||||
|
command: "POMODORO:skip".to_string(),
|
||||||
|
terminal: false,
|
||||||
|
tags: vec!["pomodoro".to_string(), "control".to_string()],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
self.items.push(LaunchItem {
|
||||||
|
id: "pomo-reset".to_string(),
|
||||||
|
name: "Reset".to_string(),
|
||||||
|
description: Some("Reset timer and sessions".to_string()),
|
||||||
|
icon: Some("view-refresh".to_string()),
|
||||||
|
provider: ProviderType::Pomodoro,
|
||||||
|
command: "POMODORO:reset".to_string(),
|
||||||
|
terminal: false,
|
||||||
|
tags: vec!["pomodoro".to_string(), "control".to_string()],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if timer has completed (for external polling)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn check_completion(&mut self) -> bool {
|
||||||
|
if matches!(self.state.phase, PomodoroPhase::Working | PomodoroPhase::Break) {
|
||||||
|
self.update_elapsed_time();
|
||||||
|
self.generate_items();
|
||||||
|
self.save_state();
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Provider for PomodoroProvider {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"Pomodoro"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_type(&self) -> ProviderType {
|
||||||
|
ProviderType::Pomodoro
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh(&mut self) {
|
||||||
|
self.update_elapsed_time();
|
||||||
|
self.generate_items();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn items(&self) -> &[LaunchItem] {
|
||||||
|
&self.items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PomodoroProvider {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(PomodoroConfig::default())
|
||||||
|
}
|
||||||
|
}
|
||||||
527
src/providers/weather.rs
Normal file
527
src/providers/weather.rs
Normal file
@@ -0,0 +1,527 @@
|
|||||||
|
//! Weather widget provider with multiple API support
|
||||||
|
//!
|
||||||
|
//! Supports:
|
||||||
|
//! - wttr.in (default, no API key required)
|
||||||
|
//! - OpenWeatherMap (requires API key)
|
||||||
|
//! - Open-Meteo (no API key required)
|
||||||
|
|
||||||
|
use super::{LaunchItem, Provider, ProviderType};
|
||||||
|
use log::{debug, error, warn};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
const CACHE_DURATION: Duration = Duration::from_secs(900); // 15 minutes
|
||||||
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
|
||||||
|
const USER_AGENT: &str = "owlry-launcher/0.3";
|
||||||
|
|
||||||
|
/// Weather provider with caching and multiple API support
|
||||||
|
pub struct WeatherProvider {
|
||||||
|
items: Vec<LaunchItem>,
|
||||||
|
config: WeatherConfig,
|
||||||
|
last_fetch: Option<Instant>,
|
||||||
|
cached_data: Option<WeatherData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct WeatherConfig {
|
||||||
|
pub provider: WeatherProviderType,
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
pub location: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum WeatherProviderType {
|
||||||
|
WttrIn,
|
||||||
|
OpenWeatherMap,
|
||||||
|
OpenMeteo,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for WeatherProviderType {
|
||||||
|
type Err = String;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s.to_lowercase().as_str() {
|
||||||
|
"wttr.in" | "wttr" | "wttrin" => Ok(Self::WttrIn),
|
||||||
|
"openweathermap" | "owm" => Ok(Self::OpenWeatherMap),
|
||||||
|
"open-meteo" | "openmeteo" | "meteo" => Ok(Self::OpenMeteo),
|
||||||
|
_ => Err(format!("Unknown weather provider: {}", s)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct WeatherData {
|
||||||
|
temperature: f32,
|
||||||
|
feels_like: Option<f32>,
|
||||||
|
condition: String,
|
||||||
|
humidity: Option<u8>,
|
||||||
|
wind_speed: Option<f32>,
|
||||||
|
icon: String,
|
||||||
|
location: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WeatherProvider {
|
||||||
|
pub fn new(config: WeatherConfig) -> Self {
|
||||||
|
let mut provider = Self {
|
||||||
|
items: Vec::new(),
|
||||||
|
config,
|
||||||
|
last_fetch: None,
|
||||||
|
cached_data: None,
|
||||||
|
};
|
||||||
|
provider.refresh();
|
||||||
|
provider
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create with default config (wttr.in, auto-detect location)
|
||||||
|
pub fn with_defaults() -> Self {
|
||||||
|
Self::new(WeatherConfig {
|
||||||
|
provider: WeatherProviderType::WttrIn,
|
||||||
|
api_key: None,
|
||||||
|
location: String::new(), // Empty = auto-detect
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if cache is still valid
|
||||||
|
fn is_cache_valid(&self) -> bool {
|
||||||
|
if let Some(last_fetch) = self.last_fetch {
|
||||||
|
last_fetch.elapsed() < CACHE_DURATION
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch weather data from the configured provider
|
||||||
|
fn fetch_weather(&self) -> Option<WeatherData> {
|
||||||
|
match self.config.provider {
|
||||||
|
WeatherProviderType::WttrIn => self.fetch_wttr_in(),
|
||||||
|
WeatherProviderType::OpenWeatherMap => self.fetch_openweathermap(),
|
||||||
|
WeatherProviderType::OpenMeteo => self.fetch_open_meteo(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch from wttr.in
|
||||||
|
fn fetch_wttr_in(&self) -> Option<WeatherData> {
|
||||||
|
let location = if self.config.location.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
self.config.location.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = format!("https://wttr.in/{}?format=j1", location);
|
||||||
|
debug!("Fetching weather from: {}", url);
|
||||||
|
|
||||||
|
let client = match reqwest::blocking::Client::builder()
|
||||||
|
.timeout(REQUEST_TIMEOUT)
|
||||||
|
.user_agent(USER_AGENT)
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to build HTTP client: {}", e);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = match client.get(&url).send() {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Weather request failed: {}", e);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let json: WttrInResponse = match response.json() {
|
||||||
|
Ok(j) => j,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to parse weather JSON: {}", e);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let current = json.current_condition.first()?;
|
||||||
|
let nearest = json.nearest_area.first()?;
|
||||||
|
|
||||||
|
let location_name = nearest
|
||||||
|
.area_name
|
||||||
|
.first()
|
||||||
|
.map(|a| a.value.clone())
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string());
|
||||||
|
|
||||||
|
Some(WeatherData {
|
||||||
|
temperature: current.temp_c.parse().unwrap_or(0.0),
|
||||||
|
feels_like: current.feels_like_c.parse().ok(),
|
||||||
|
condition: current
|
||||||
|
.weather_desc
|
||||||
|
.first()
|
||||||
|
.map(|d| d.value.clone())
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string()),
|
||||||
|
humidity: current.humidity.parse().ok(),
|
||||||
|
wind_speed: current.windspeed_kmph.parse().ok(),
|
||||||
|
icon: Self::condition_to_icon(¤t.weather_code),
|
||||||
|
location: location_name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch from OpenWeatherMap
|
||||||
|
fn fetch_openweathermap(&self) -> Option<WeatherData> {
|
||||||
|
let api_key = self.config.api_key.as_ref()?;
|
||||||
|
let location = if self.config.location.is_empty() {
|
||||||
|
warn!("OpenWeatherMap requires a location to be configured");
|
||||||
|
return None;
|
||||||
|
} else {
|
||||||
|
&self.config.location
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"https://api.openweathermap.org/data/2.5/weather?q={}&appid={}&units=metric",
|
||||||
|
location, api_key
|
||||||
|
);
|
||||||
|
debug!("Fetching weather from OpenWeatherMap");
|
||||||
|
|
||||||
|
let client = reqwest::blocking::Client::builder()
|
||||||
|
.timeout(REQUEST_TIMEOUT)
|
||||||
|
.build()
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
let response = client.get(&url).send().ok()?;
|
||||||
|
let json: OpenWeatherMapResponse = response.json().ok()?;
|
||||||
|
|
||||||
|
let weather = json.weather.first()?;
|
||||||
|
|
||||||
|
Some(WeatherData {
|
||||||
|
temperature: json.main.temp,
|
||||||
|
feels_like: Some(json.main.feels_like),
|
||||||
|
condition: weather.description.clone(),
|
||||||
|
humidity: Some(json.main.humidity),
|
||||||
|
wind_speed: Some(json.wind.speed * 3.6), // m/s to km/h
|
||||||
|
icon: Self::owm_icon_to_freedesktop(&weather.icon),
|
||||||
|
location: json.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch from Open-Meteo
|
||||||
|
fn fetch_open_meteo(&self) -> Option<WeatherData> {
|
||||||
|
// Open-Meteo requires coordinates, so we need to geocode first
|
||||||
|
// For simplicity, we'll use a geocoding step if location is a city name
|
||||||
|
let (lat, lon, location_name) = self.get_coordinates()?;
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"https://api.open-meteo.com/v1/forecast?latitude={}&longitude={}¤t=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&timezone=auto",
|
||||||
|
lat, lon
|
||||||
|
);
|
||||||
|
debug!("Fetching weather from Open-Meteo for {}", location_name);
|
||||||
|
|
||||||
|
let client = reqwest::blocking::Client::builder()
|
||||||
|
.timeout(REQUEST_TIMEOUT)
|
||||||
|
.build()
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
let response = client.get(&url).send().ok()?;
|
||||||
|
let json: OpenMeteoResponse = response.json().ok()?;
|
||||||
|
|
||||||
|
let current = json.current;
|
||||||
|
|
||||||
|
Some(WeatherData {
|
||||||
|
temperature: current.temperature_2m,
|
||||||
|
feels_like: None,
|
||||||
|
condition: Self::wmo_code_to_description(current.weather_code),
|
||||||
|
humidity: Some(current.relative_humidity_2m as u8),
|
||||||
|
wind_speed: Some(current.wind_speed_10m),
|
||||||
|
icon: Self::wmo_code_to_icon(current.weather_code),
|
||||||
|
location: location_name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get coordinates and location name for Open-Meteo (simple parsing or geocoding)
|
||||||
|
fn get_coordinates(&self) -> Option<(f64, f64, String)> {
|
||||||
|
let location = &self.config.location;
|
||||||
|
|
||||||
|
// Check if location is already coordinates (lat,lon)
|
||||||
|
if location.contains(',') {
|
||||||
|
let parts: Vec<&str> = location.split(',').collect();
|
||||||
|
if parts.len() == 2 {
|
||||||
|
if let (Ok(lat), Ok(lon)) = (
|
||||||
|
parts[0].trim().parse::<f64>(),
|
||||||
|
parts[1].trim().parse::<f64>(),
|
||||||
|
) {
|
||||||
|
// Use coordinates as location name (will be overwritten if we had a name)
|
||||||
|
return Some((lat, lon, location.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Open-Meteo geocoding API
|
||||||
|
let url = format!(
|
||||||
|
"https://geocoding-api.open-meteo.com/v1/search?name={}&count=1",
|
||||||
|
location
|
||||||
|
);
|
||||||
|
|
||||||
|
let client = reqwest::blocking::Client::builder()
|
||||||
|
.timeout(REQUEST_TIMEOUT)
|
||||||
|
.build()
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
let response = client.get(&url).send().ok()?;
|
||||||
|
let json: GeocodingResponse = response.json().ok()?;
|
||||||
|
|
||||||
|
let result = json.results?.into_iter().next()?;
|
||||||
|
Some((result.latitude, result.longitude, result.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert wttr.in weather code to freedesktop icon name
|
||||||
|
fn condition_to_icon(code: &str) -> String {
|
||||||
|
let icon = match code {
|
||||||
|
"113" => "weather-clear", // Sunny
|
||||||
|
"116" => "weather-few-clouds", // Partly cloudy
|
||||||
|
"119" => "weather-overcast", // Cloudy
|
||||||
|
"122" => "weather-overcast", // Overcast
|
||||||
|
"143" | "248" | "260" => "weather-fog",
|
||||||
|
"176" | "263" | "266" | "293" | "296" | "299" | "302" | "305" | "308" => {
|
||||||
|
"weather-showers"
|
||||||
|
}
|
||||||
|
"179" | "182" | "185" | "227" | "230" | "323" | "326" | "329" | "332" | "335"
|
||||||
|
| "338" | "350" | "368" | "371" | "374" | "377" => "weather-snow",
|
||||||
|
"200" | "386" | "389" | "392" | "395" => "weather-storm",
|
||||||
|
_ => "weather-clear",
|
||||||
|
};
|
||||||
|
// Try symbolic version first (more likely to exist)
|
||||||
|
format!("{}-symbolic", icon)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert OpenWeatherMap icon code to freedesktop icon
|
||||||
|
fn owm_icon_to_freedesktop(icon: &str) -> String {
|
||||||
|
match icon {
|
||||||
|
"01d" | "01n" => "weather-clear",
|
||||||
|
"02d" | "02n" => "weather-few-clouds",
|
||||||
|
"03d" | "03n" | "04d" | "04n" => "weather-overcast",
|
||||||
|
"09d" | "09n" | "10d" | "10n" => "weather-showers",
|
||||||
|
"11d" | "11n" => "weather-storm",
|
||||||
|
"13d" | "13n" => "weather-snow",
|
||||||
|
"50d" | "50n" => "weather-fog",
|
||||||
|
_ => "weather-clear",
|
||||||
|
}
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert WMO weather code to description
|
||||||
|
fn wmo_code_to_description(code: i32) -> String {
|
||||||
|
match code {
|
||||||
|
0 => "Clear sky",
|
||||||
|
1 => "Mainly clear",
|
||||||
|
2 => "Partly cloudy",
|
||||||
|
3 => "Overcast",
|
||||||
|
45 | 48 => "Foggy",
|
||||||
|
51 | 53 | 55 => "Drizzle",
|
||||||
|
61 | 63 | 65 => "Rain",
|
||||||
|
66 | 67 => "Freezing rain",
|
||||||
|
71 | 73 | 75 | 77 => "Snow",
|
||||||
|
80 | 81 | 82 => "Rain showers",
|
||||||
|
85 | 86 => "Snow showers",
|
||||||
|
95 | 96 | 99 => "Thunderstorm",
|
||||||
|
_ => "Unknown",
|
||||||
|
}
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert WMO weather code to icon name (used for emoji conversion)
|
||||||
|
fn wmo_code_to_icon(code: i32) -> String {
|
||||||
|
match code {
|
||||||
|
0 | 1 => "weather-clear",
|
||||||
|
2 => "weather-few-clouds",
|
||||||
|
3 => "weather-overcast",
|
||||||
|
45 | 48 => "weather-fog",
|
||||||
|
51 | 53 | 55 | 61 | 63 | 65 | 80 | 81 | 82 => "weather-showers",
|
||||||
|
66 | 67 | 71 | 73 | 75 | 77 | 85 | 86 => "weather-snow",
|
||||||
|
95 | 96 | 99 => "weather-storm",
|
||||||
|
_ => "weather-clear",
|
||||||
|
}
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate LaunchItems from weather data
|
||||||
|
fn generate_items(&mut self, data: &WeatherData) {
|
||||||
|
self.items.clear();
|
||||||
|
|
||||||
|
// Use emoji in name since icon themes are unreliable
|
||||||
|
let emoji = Self::weather_icon_to_emoji(&data.icon);
|
||||||
|
let temp_str = format!("{}°C", data.temperature.round() as i32);
|
||||||
|
let name = format!("{} {} {}", emoji, temp_str, data.condition);
|
||||||
|
|
||||||
|
let mut details = vec![data.location.clone()];
|
||||||
|
if let Some(humidity) = data.humidity {
|
||||||
|
details.push(format!("Humidity {}%", humidity));
|
||||||
|
}
|
||||||
|
if let Some(wind) = data.wind_speed {
|
||||||
|
details.push(format!("Wind {} km/h", wind.round() as i32));
|
||||||
|
}
|
||||||
|
if let Some(feels) = data.feels_like {
|
||||||
|
if (feels - data.temperature).abs() > 2.0 {
|
||||||
|
details.push(format!("Feels like {}°C", feels.round() as i32));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple URL encoding for location
|
||||||
|
let encoded_location = data.location.replace(' ', "+");
|
||||||
|
|
||||||
|
self.items.push(LaunchItem {
|
||||||
|
id: "weather-current".to_string(),
|
||||||
|
name,
|
||||||
|
description: Some(details.join(" | ")),
|
||||||
|
icon: None, // Use emoji in name instead
|
||||||
|
provider: ProviderType::Weather,
|
||||||
|
command: format!("xdg-open 'https://wttr.in/{}'", encoded_location),
|
||||||
|
terminal: false,
|
||||||
|
tags: vec!["weather".to_string(), "widget".to_string()],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert icon name to emoji for display
|
||||||
|
fn weather_icon_to_emoji(icon: &str) -> &'static str {
|
||||||
|
if icon.contains("clear") {
|
||||||
|
"☀️"
|
||||||
|
} else if icon.contains("few-clouds") {
|
||||||
|
"⛅"
|
||||||
|
} else if icon.contains("overcast") || icon.contains("clouds") {
|
||||||
|
"☁️"
|
||||||
|
} else if icon.contains("fog") {
|
||||||
|
"🌫️"
|
||||||
|
} else if icon.contains("showers") || icon.contains("rain") {
|
||||||
|
"🌧️"
|
||||||
|
} else if icon.contains("snow") {
|
||||||
|
"❄️"
|
||||||
|
} else if icon.contains("storm") {
|
||||||
|
"⛈️"
|
||||||
|
} else {
|
||||||
|
"🌡️"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Provider for WeatherProvider {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"Weather"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_type(&self) -> ProviderType {
|
||||||
|
ProviderType::Weather
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh(&mut self) {
|
||||||
|
// Use cache if still valid
|
||||||
|
if self.is_cache_valid() {
|
||||||
|
if let Some(data) = self.cached_data.clone() {
|
||||||
|
self.generate_items(&data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch new data
|
||||||
|
match self.fetch_weather() {
|
||||||
|
Some(data) => {
|
||||||
|
debug!("Weather fetched: {}°C, {}", data.temperature, data.condition);
|
||||||
|
self.cached_data = Some(data.clone());
|
||||||
|
self.last_fetch = Some(Instant::now());
|
||||||
|
self.generate_items(&data);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
warn!("Failed to fetch weather data");
|
||||||
|
self.items.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn items(&self) -> &[LaunchItem] {
|
||||||
|
&self.items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WeatherProvider {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::with_defaults()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── API Response Types ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct WttrInResponse {
|
||||||
|
current_condition: Vec<WttrInCurrent>,
|
||||||
|
nearest_area: Vec<WttrInArea>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct WttrInCurrent {
|
||||||
|
#[serde(rename = "temp_C")]
|
||||||
|
temp_c: String,
|
||||||
|
#[serde(rename = "FeelsLikeC")]
|
||||||
|
feels_like_c: String,
|
||||||
|
humidity: String,
|
||||||
|
#[serde(rename = "weatherCode")]
|
||||||
|
weather_code: String,
|
||||||
|
#[serde(rename = "weatherDesc")]
|
||||||
|
weather_desc: Vec<WttrInValue>,
|
||||||
|
#[serde(rename = "windspeedKmph")]
|
||||||
|
windspeed_kmph: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct WttrInValue {
|
||||||
|
value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct WttrInArea {
|
||||||
|
#[serde(rename = "areaName")]
|
||||||
|
area_name: Vec<WttrInValue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OpenWeatherMapResponse {
|
||||||
|
main: OwmMain,
|
||||||
|
weather: Vec<OwmWeather>,
|
||||||
|
wind: OwmWind,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OwmMain {
|
||||||
|
temp: f32,
|
||||||
|
feels_like: f32,
|
||||||
|
humidity: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OwmWeather {
|
||||||
|
description: String,
|
||||||
|
icon: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OwmWind {
|
||||||
|
speed: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OpenMeteoResponse {
|
||||||
|
current: OpenMeteoCurrent,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OpenMeteoCurrent {
|
||||||
|
temperature_2m: f32,
|
||||||
|
relative_humidity_2m: f32,
|
||||||
|
weather_code: i32,
|
||||||
|
wind_speed_10m: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GeocodingResponse {
|
||||||
|
results: Option<Vec<GeocodingResult>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GeocodingResult {
|
||||||
|
name: String,
|
||||||
|
latitude: f64,
|
||||||
|
longitude: f64,
|
||||||
|
}
|
||||||
@@ -67,6 +67,13 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Emoji icon for widgets (weather, media, pomodoro) */
|
||||||
|
.owlry-emoji-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
min-width: 32px;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Result name */
|
/* Result name */
|
||||||
.owlry-result-name {
|
.owlry-result-name {
|
||||||
font-size: var(--owlry-font-size, 14px);
|
font-size: var(--owlry-font-size, 14px);
|
||||||
@@ -166,6 +173,22 @@
|
|||||||
color: var(--owlry-badge-web, @teal_3);
|
color: var(--owlry-badge-web, @teal_3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Widget provider badges */
|
||||||
|
.owlry-badge-media {
|
||||||
|
background-color: alpha(var(--owlry-badge-media, #ec4899), 0.2);
|
||||||
|
color: var(--owlry-badge-media, #ec4899);
|
||||||
|
}
|
||||||
|
|
||||||
|
.owlry-badge-weather {
|
||||||
|
background-color: alpha(var(--owlry-badge-weather, #06b6d4), 0.2);
|
||||||
|
color: var(--owlry-badge-weather, #06b6d4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.owlry-badge-pomo {
|
||||||
|
background-color: alpha(var(--owlry-badge-pomo, #f97316), 0.2);
|
||||||
|
color: var(--owlry-badge-pomo, #f97316);
|
||||||
|
}
|
||||||
|
|
||||||
/* Header bar */
|
/* Header bar */
|
||||||
.owlry-header {
|
.owlry-header {
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
@@ -283,6 +306,25 @@
|
|||||||
border-color: alpha(var(--owlry-badge-web, @teal_3), 0.4);
|
border-color: alpha(var(--owlry-badge-web, @teal_3), 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Widget filter buttons */
|
||||||
|
.owlry-filter-media:checked {
|
||||||
|
background-color: alpha(var(--owlry-badge-media, #ec4899), 0.2);
|
||||||
|
color: var(--owlry-badge-media, #ec4899);
|
||||||
|
border-color: alpha(var(--owlry-badge-media, #ec4899), 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.owlry-filter-weather:checked {
|
||||||
|
background-color: alpha(var(--owlry-badge-weather, #06b6d4), 0.2);
|
||||||
|
color: var(--owlry-badge-weather, #06b6d4);
|
||||||
|
border-color: alpha(var(--owlry-badge-weather, #06b6d4), 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.owlry-filter-pomodoro:checked {
|
||||||
|
background-color: alpha(var(--owlry-badge-pomo, #f97316), 0.2);
|
||||||
|
color: var(--owlry-badge-pomo, #f97316);
|
||||||
|
border-color: alpha(var(--owlry-badge-pomo, #f97316), 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
/* Hints bar at bottom */
|
/* Hints bar at bottom */
|
||||||
.owlry-hints {
|
.owlry-hints {
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
|
|||||||
@@ -248,10 +248,13 @@ impl MainWindow {
|
|||||||
ProviderType::Dmenu => "Dmenu",
|
ProviderType::Dmenu => "Dmenu",
|
||||||
ProviderType::Emoji => "Emoji",
|
ProviderType::Emoji => "Emoji",
|
||||||
ProviderType::Files => "Files",
|
ProviderType::Files => "Files",
|
||||||
|
ProviderType::MediaPlayer => "Media",
|
||||||
|
ProviderType::Pomodoro => "Pomo",
|
||||||
ProviderType::Scripts => "Scripts",
|
ProviderType::Scripts => "Scripts",
|
||||||
ProviderType::Ssh => "SSH",
|
ProviderType::Ssh => "SSH",
|
||||||
ProviderType::System => "System",
|
ProviderType::System => "System",
|
||||||
ProviderType::Uuctl => "uuctl",
|
ProviderType::Uuctl => "uuctl",
|
||||||
|
ProviderType::Weather => "Weather",
|
||||||
ProviderType::WebSearch => "Web",
|
ProviderType::WebSearch => "Web",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,10 +270,13 @@ impl MainWindow {
|
|||||||
ProviderType::Dmenu => "owlry-filter-dmenu",
|
ProviderType::Dmenu => "owlry-filter-dmenu",
|
||||||
ProviderType::Emoji => "owlry-filter-emoji",
|
ProviderType::Emoji => "owlry-filter-emoji",
|
||||||
ProviderType::Files => "owlry-filter-file",
|
ProviderType::Files => "owlry-filter-file",
|
||||||
|
ProviderType::MediaPlayer => "owlry-filter-media",
|
||||||
|
ProviderType::Pomodoro => "owlry-filter-pomodoro",
|
||||||
ProviderType::Scripts => "owlry-filter-script",
|
ProviderType::Scripts => "owlry-filter-script",
|
||||||
ProviderType::Ssh => "owlry-filter-ssh",
|
ProviderType::Ssh => "owlry-filter-ssh",
|
||||||
ProviderType::System => "owlry-filter-sys",
|
ProviderType::System => "owlry-filter-sys",
|
||||||
ProviderType::Uuctl => "owlry-filter-uuctl",
|
ProviderType::Uuctl => "owlry-filter-uuctl",
|
||||||
|
ProviderType::Weather => "owlry-filter-weather",
|
||||||
ProviderType::WebSearch => "owlry-filter-web",
|
ProviderType::WebSearch => "owlry-filter-web",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -288,10 +294,13 @@ impl MainWindow {
|
|||||||
ProviderType::Dmenu => "options",
|
ProviderType::Dmenu => "options",
|
||||||
ProviderType::Emoji => "emoji",
|
ProviderType::Emoji => "emoji",
|
||||||
ProviderType::Files => "files",
|
ProviderType::Files => "files",
|
||||||
|
ProviderType::MediaPlayer => "media",
|
||||||
|
ProviderType::Pomodoro => "pomodoro",
|
||||||
ProviderType::Scripts => "scripts",
|
ProviderType::Scripts => "scripts",
|
||||||
ProviderType::Ssh => "SSH hosts",
|
ProviderType::Ssh => "SSH hosts",
|
||||||
ProviderType::System => "system",
|
ProviderType::System => "system",
|
||||||
ProviderType::Uuctl => "uuctl units",
|
ProviderType::Uuctl => "uuctl units",
|
||||||
|
ProviderType::Weather => "weather",
|
||||||
ProviderType::WebSearch => "web",
|
ProviderType::WebSearch => "web",
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -531,10 +540,13 @@ impl MainWindow {
|
|||||||
ProviderType::Dmenu => "options",
|
ProviderType::Dmenu => "options",
|
||||||
ProviderType::Emoji => "emoji",
|
ProviderType::Emoji => "emoji",
|
||||||
ProviderType::Files => "files",
|
ProviderType::Files => "files",
|
||||||
|
ProviderType::MediaPlayer => "media",
|
||||||
|
ProviderType::Pomodoro => "pomodoro",
|
||||||
ProviderType::Scripts => "scripts",
|
ProviderType::Scripts => "scripts",
|
||||||
ProviderType::Ssh => "SSH hosts",
|
ProviderType::Ssh => "SSH hosts",
|
||||||
ProviderType::System => "system",
|
ProviderType::System => "system",
|
||||||
ProviderType::Uuctl => "uuctl units",
|
ProviderType::Uuctl => "uuctl units",
|
||||||
|
ProviderType::Weather => "weather",
|
||||||
ProviderType::WebSearch => "web",
|
ProviderType::WebSearch => "web",
|
||||||
};
|
};
|
||||||
search_entry_for_change
|
search_entry_for_change
|
||||||
@@ -584,13 +596,14 @@ impl MainWindow {
|
|||||||
let current_results_for_activate = self.current_results.clone();
|
let current_results_for_activate = self.current_results.clone();
|
||||||
let config_for_activate = self.config.clone();
|
let config_for_activate = self.config.clone();
|
||||||
let frecency_for_activate = self.frecency.clone();
|
let frecency_for_activate = self.frecency.clone();
|
||||||
|
let providers_for_activate = self.providers.clone();
|
||||||
let window_for_activate = self.window.clone();
|
let window_for_activate = self.window.clone();
|
||||||
let submenu_state_for_activate = self.submenu_state.clone();
|
let submenu_state_for_activate = self.submenu_state.clone();
|
||||||
let mode_label_for_activate = self.mode_label.clone();
|
let mode_label_for_activate = self.mode_label.clone();
|
||||||
let hints_label_for_activate = self.hints_label.clone();
|
let hints_label_for_activate = self.hints_label.clone();
|
||||||
let search_entry_for_activate = self.search_entry.clone();
|
let search_entry_for_activate = self.search_entry.clone();
|
||||||
|
|
||||||
self.search_entry.connect_activate(move |_| {
|
self.search_entry.connect_activate(move |entry| {
|
||||||
let selected = results_list_for_activate
|
let selected = results_list_for_activate
|
||||||
.selected_row()
|
.selected_row()
|
||||||
.or_else(|| results_list_for_activate.row_at_index(0));
|
.or_else(|| results_list_for_activate.row_at_index(0));
|
||||||
@@ -616,9 +629,21 @@ impl MainWindow {
|
|||||||
is_active,
|
is_active,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Execute the command
|
// Execute the command (or handle internal commands)
|
||||||
Self::launch_item(item, &config_for_activate.borrow(), &frecency_for_activate);
|
let item = item.clone();
|
||||||
window_for_activate.close();
|
drop(results);
|
||||||
|
let should_close = Self::handle_item_action(
|
||||||
|
&item,
|
||||||
|
&config_for_activate.borrow(),
|
||||||
|
&frecency_for_activate,
|
||||||
|
&providers_for_activate,
|
||||||
|
);
|
||||||
|
if should_close {
|
||||||
|
window_for_activate.close();
|
||||||
|
} else {
|
||||||
|
// Trigger search refresh for updated widget state
|
||||||
|
entry.emit_by_name::<()>("changed", &[]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -794,6 +819,7 @@ impl MainWindow {
|
|||||||
let current_results = self.current_results.clone();
|
let current_results = self.current_results.clone();
|
||||||
let config = self.config.clone();
|
let config = self.config.clone();
|
||||||
let frecency = self.frecency.clone();
|
let frecency = self.frecency.clone();
|
||||||
|
let providers = self.providers.clone();
|
||||||
let window = self.window.clone();
|
let window = self.window.clone();
|
||||||
let submenu_state = self.submenu_state.clone();
|
let submenu_state = self.submenu_state.clone();
|
||||||
let results_list_for_click = self.results_list.clone();
|
let results_list_for_click = self.results_list.clone();
|
||||||
@@ -822,8 +848,15 @@ impl MainWindow {
|
|||||||
is_active,
|
is_active,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
Self::launch_item(item, &config.borrow(), &frecency);
|
let item = item.clone();
|
||||||
window.close();
|
drop(results);
|
||||||
|
let should_close = Self::handle_item_action(&item, &config.borrow(), &frecency, &providers);
|
||||||
|
if should_close {
|
||||||
|
window.close();
|
||||||
|
} else {
|
||||||
|
// Trigger search refresh for updated widget state
|
||||||
|
search_entry.emit_by_name::<()>("changed", &[]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -928,6 +961,28 @@ impl MainWindow {
|
|||||||
*self.current_results.borrow_mut() = results;
|
*self.current_results.borrow_mut() = results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle item activation - returns true if window should close
|
||||||
|
fn handle_item_action(
|
||||||
|
item: &LaunchItem,
|
||||||
|
config: &Config,
|
||||||
|
frecency: &Rc<RefCell<FrecencyStore>>,
|
||||||
|
providers: &Rc<RefCell<ProviderManager>>,
|
||||||
|
) -> bool {
|
||||||
|
// Check for POMODORO: internal command
|
||||||
|
if item.command.starts_with("POMODORO:") {
|
||||||
|
let action = item.command.strip_prefix("POMODORO:").unwrap_or("");
|
||||||
|
if let Some(pomodoro) = providers.borrow_mut().pomodoro_mut() {
|
||||||
|
pomodoro.handle_action(action);
|
||||||
|
}
|
||||||
|
// Don't close window - user might want to see updated state
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular item launch
|
||||||
|
Self::launch_item(item, config, frecency);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn launch_item(item: &LaunchItem, config: &Config, frecency: &Rc<RefCell<FrecencyStore>>) {
|
fn launch_item(item: &LaunchItem, config: &Config, frecency: &Rc<RefCell<FrecencyStore>>) {
|
||||||
// Record this launch for frecency tracking
|
// Record this launch for frecency tracking
|
||||||
if config.providers.frecency {
|
if config.providers.frecency {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::providers::LaunchItem;
|
use crate::providers::LaunchItem;
|
||||||
use gtk4::prelude::*;
|
use gtk4::prelude::*;
|
||||||
use gtk4::{Box as GtkBox, Image, Label, ListBoxRow, Orientation};
|
use gtk4::{Box as GtkBox, Image, Label, ListBoxRow, Orientation, Widget};
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub struct ResultRow {
|
pub struct ResultRow {
|
||||||
@@ -25,32 +25,55 @@ impl ResultRow {
|
|||||||
.margin_end(12)
|
.margin_end(12)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// Icon
|
// Icon - handle file paths, icon names, emoji widgets, and fallbacks
|
||||||
let icon = if let Some(icon_name) = &item.icon {
|
let icon_widget: Widget = if let Some(icon_name) = &item.icon {
|
||||||
Image::from_icon_name(icon_name)
|
let img = if icon_name.starts_with('/') {
|
||||||
|
// Absolute file path
|
||||||
|
Image::from_file(icon_name)
|
||||||
|
} else {
|
||||||
|
Image::from_icon_name(icon_name)
|
||||||
|
};
|
||||||
|
img.set_pixel_size(32);
|
||||||
|
img.add_css_class("owlry-result-icon");
|
||||||
|
img.upcast()
|
||||||
} else {
|
} else {
|
||||||
// Default icon based on provider type
|
// Default icon based on provider type
|
||||||
let default_icon = match item.provider {
|
// For widgets, use emoji labels (more reliable than icon themes)
|
||||||
crate::providers::ProviderType::Application => "application-x-executable",
|
match item.provider {
|
||||||
crate::providers::ProviderType::Bookmarks => "user-bookmarks",
|
crate::providers::ProviderType::Weather => {
|
||||||
crate::providers::ProviderType::Calculator => "accessories-calculator",
|
Self::create_emoji_icon("🌤️")
|
||||||
crate::providers::ProviderType::Clipboard => "edit-paste",
|
}
|
||||||
crate::providers::ProviderType::Command => "utilities-terminal",
|
crate::providers::ProviderType::MediaPlayer => {
|
||||||
crate::providers::ProviderType::Dmenu => "view-list-symbolic",
|
Self::create_emoji_icon("🎵")
|
||||||
crate::providers::ProviderType::Emoji => "face-smile",
|
}
|
||||||
crate::providers::ProviderType::Files => "folder",
|
crate::providers::ProviderType::Pomodoro => {
|
||||||
crate::providers::ProviderType::Scripts => "application-x-executable",
|
Self::create_emoji_icon("🍅")
|
||||||
crate::providers::ProviderType::Ssh => "network-server",
|
}
|
||||||
crate::providers::ProviderType::System => "system-shutdown",
|
_ => {
|
||||||
crate::providers::ProviderType::Uuctl => "system-run",
|
let default_icon = match item.provider {
|
||||||
crate::providers::ProviderType::WebSearch => "web-browser",
|
crate::providers::ProviderType::Application => "application-x-executable",
|
||||||
};
|
crate::providers::ProviderType::Bookmarks => "user-bookmarks",
|
||||||
Image::from_icon_name(default_icon)
|
crate::providers::ProviderType::Calculator => "accessories-calculator",
|
||||||
|
crate::providers::ProviderType::Clipboard => "edit-paste",
|
||||||
|
crate::providers::ProviderType::Command => "utilities-terminal",
|
||||||
|
crate::providers::ProviderType::Dmenu => "view-list-symbolic",
|
||||||
|
crate::providers::ProviderType::Emoji => "face-smile",
|
||||||
|
crate::providers::ProviderType::Files => "folder",
|
||||||
|
crate::providers::ProviderType::Scripts => "application-x-executable",
|
||||||
|
crate::providers::ProviderType::Ssh => "network-server",
|
||||||
|
crate::providers::ProviderType::System => "system-shutdown",
|
||||||
|
crate::providers::ProviderType::Uuctl => "system-run",
|
||||||
|
crate::providers::ProviderType::WebSearch => "web-browser",
|
||||||
|
_ => "application-x-executable",
|
||||||
|
};
|
||||||
|
let img = Image::from_icon_name(default_icon);
|
||||||
|
img.set_pixel_size(32);
|
||||||
|
img.add_css_class("owlry-result-icon");
|
||||||
|
img.upcast()
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
icon.set_pixel_size(32);
|
|
||||||
icon.add_css_class("owlry-result-icon");
|
|
||||||
|
|
||||||
// Text container
|
// Text container
|
||||||
let text_box = GtkBox::builder()
|
let text_box = GtkBox::builder()
|
||||||
.orientation(Orientation::Vertical)
|
.orientation(Orientation::Vertical)
|
||||||
@@ -111,7 +134,7 @@ impl ResultRow {
|
|||||||
badge.add_css_class("owlry-result-badge");
|
badge.add_css_class("owlry-result-badge");
|
||||||
badge.add_css_class(&format!("owlry-badge-{}", item.provider));
|
badge.add_css_class(&format!("owlry-badge-{}", item.provider));
|
||||||
|
|
||||||
hbox.append(&icon);
|
hbox.append(&icon_widget);
|
||||||
hbox.append(&text_box);
|
hbox.append(&text_box);
|
||||||
hbox.append(&badge);
|
hbox.append(&badge);
|
||||||
|
|
||||||
@@ -119,4 +142,16 @@ impl ResultRow {
|
|||||||
|
|
||||||
row
|
row
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create an emoji-based icon widget for widgets (weather, media, pomodoro)
|
||||||
|
/// More reliable than icon themes which may not have all icons
|
||||||
|
fn create_emoji_icon(emoji: &str) -> Widget {
|
||||||
|
let label = Label::builder()
|
||||||
|
.label(emoji)
|
||||||
|
.width_request(32)
|
||||||
|
.height_request(32)
|
||||||
|
.build();
|
||||||
|
label.add_css_class("owlry-emoji-icon");
|
||||||
|
label.upcast()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user