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]
|
||||
# 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
|
||||
gtk4-layer-shell = "0.4"
|
||||
gtk4-layer-shell = "0.7"
|
||||
|
||||
# Async runtime for non-blocking operations
|
||||
tokio = { version = "1", features = ["rt", "sync", "process", "fs"] }
|
||||
@@ -55,6 +55,12 @@ serde_json = "1"
|
||||
# Date/time for frecency calculations
|
||||
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]
|
||||
default = []
|
||||
# Enable verbose debug logging (for development/testing builds)
|
||||
|
||||
@@ -18,8 +18,10 @@
|
||||
show_icons = true
|
||||
max_results = 10
|
||||
|
||||
# Terminal emulator (auto-detected if not set)
|
||||
terminal_command = "kitty"
|
||||
# Terminal emulator for SSH, scripts, etc.
|
||||
# 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)
|
||||
# Examples: "uwsm app --", "hyprctl dispatch exec --", ""
|
||||
@@ -34,8 +36,8 @@ tabs = ["app", "cmd", "uuctl"]
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
[appearance]
|
||||
width = 700
|
||||
height = 500
|
||||
width = 850
|
||||
height = 650
|
||||
font_size = 14
|
||||
border_radius = 12
|
||||
|
||||
@@ -113,3 +115,21 @@ emoji = true
|
||||
|
||||
# Scripts: :script - executables from ~/.local/share/owlry/scripts/
|
||||
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::filter::ProviderFilter;
|
||||
use crate::paths;
|
||||
use crate::providers::ProviderManager;
|
||||
use crate::providers::{PomodoroConfig, ProviderManager, WeatherConfig, WeatherProviderType};
|
||||
use crate::theme;
|
||||
use crate::ui::MainWindow;
|
||||
use gtk4::prelude::*;
|
||||
@@ -43,7 +43,38 @@ impl OwlryApp {
|
||||
let config = Rc::new(RefCell::new(Config::load_or_default()));
|
||||
let search_engine = config.borrow().providers.search_engine.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()));
|
||||
|
||||
// Create filter from CLI args and config
|
||||
@@ -71,12 +102,29 @@ impl OwlryApp {
|
||||
// Position from top
|
||||
window.set_margin(Edge::Top, 200);
|
||||
|
||||
// Set up icon theme fallbacks
|
||||
Self::setup_icon_theme();
|
||||
|
||||
// Load CSS styling with config for theming
|
||||
Self::load_css(&config.borrow());
|
||||
|
||||
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) {
|
||||
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)
|
||||
#[serde(default = "default_true")]
|
||||
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 {
|
||||
@@ -134,6 +168,18 @@ fn default_frecency_weight() -> f64 {
|
||||
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
|
||||
/// Checks for uwsm (Universal Wayland Session Manager) and hyprland
|
||||
fn detect_launch_wrapper() -> Option<String> {
|
||||
@@ -163,13 +209,14 @@ fn detect_launch_wrapper() -> Option<String> {
|
||||
/// Detect the best available terminal emulator
|
||||
/// Fallback chain:
|
||||
/// 1. $TERMINAL env var (user's explicit preference)
|
||||
/// 2. xdg-terminal-exec (freedesktop standard)
|
||||
/// 3. Common Wayland-native terminals (kitty, alacritty, wezterm, foot)
|
||||
/// 4. Common X11/legacy terminals (gnome-terminal, konsole, xfce4-terminal)
|
||||
/// 5. x-terminal-emulator (Debian alternatives)
|
||||
/// 6. xterm (ultimate fallback)
|
||||
/// 2. xdg-terminal-exec (freedesktop standard - if available)
|
||||
/// 3. Desktop-environment native terminal (GNOME→gnome-terminal, KDE→konsole, etc.)
|
||||
/// 4. Common Wayland-native terminals (kitty, alacritty, wezterm, foot)
|
||||
/// 5. Common X11/legacy terminals
|
||||
/// 6. x-terminal-emulator (Debian alternatives)
|
||||
/// 7. xterm (ultimate fallback - the cockroach of terminals)
|
||||
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 !term.is_empty() && command_exists(&term) {
|
||||
debug!("Using $TERMINAL: {}", term);
|
||||
@@ -183,7 +230,13 @@ fn detect_terminal() -> 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"];
|
||||
for term in wayland_terminals {
|
||||
if command_exists(term) {
|
||||
@@ -192,8 +245,8 @@ fn detect_terminal() -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Common X11/legacy terminals
|
||||
let legacy_terminals = ["gnome-terminal", "konsole", "xfce4-terminal", "tilix", "terminator"];
|
||||
// 5. Common X11/legacy terminals
|
||||
let legacy_terminals = ["gnome-terminal", "konsole", "xfce4-terminal", "mate-terminal", "tilix", "terminator"];
|
||||
for term in legacy_terminals {
|
||||
if command_exists(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") {
|
||||
debug!("Using x-terminal-emulator");
|
||||
return "x-terminal-emulator".to_string();
|
||||
}
|
||||
|
||||
// 6. Ultimate fallback
|
||||
// 7. Ultimate fallback - xterm exists everywhere
|
||||
debug!("Falling back to xterm");
|
||||
"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
|
||||
fn command_exists(cmd: &str) -> bool {
|
||||
Command::new("which")
|
||||
@@ -235,8 +335,8 @@ impl Default for Config {
|
||||
tabs: default_tabs(),
|
||||
},
|
||||
appearance: AppearanceConfig {
|
||||
width: 700,
|
||||
height: 500,
|
||||
width: 850,
|
||||
height: 650,
|
||||
font_size: 14,
|
||||
border_radius: 12,
|
||||
theme: None,
|
||||
@@ -258,6 +358,15 @@ impl Default for Config {
|
||||
emoji: true,
|
||||
scripts: 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::Emoji => 6,
|
||||
ProviderType::Files => 7,
|
||||
ProviderType::Scripts => 8,
|
||||
ProviderType::Ssh => 9,
|
||||
ProviderType::System => 10,
|
||||
ProviderType::Uuctl => 11,
|
||||
ProviderType::WebSearch => 12,
|
||||
ProviderType::MediaPlayer => 8,
|
||||
ProviderType::Pomodoro => 9,
|
||||
ProviderType::Scripts => 10,
|
||||
ProviderType::Ssh => 11,
|
||||
ProviderType::System => 12,
|
||||
ProviderType::Uuctl => 13,
|
||||
ProviderType::Weather => 14,
|
||||
ProviderType::WebSearch => 15,
|
||||
});
|
||||
providers
|
||||
}
|
||||
@@ -313,10 +316,13 @@ impl ProviderFilter {
|
||||
ProviderType::Dmenu => "dmenu",
|
||||
ProviderType::Emoji => "Emoji",
|
||||
ProviderType::Files => "Files",
|
||||
ProviderType::MediaPlayer => "Media",
|
||||
ProviderType::Pomodoro => "Pomodoro",
|
||||
ProviderType::Scripts => "Scripts",
|
||||
ProviderType::Ssh => "SSH",
|
||||
ProviderType::System => "System",
|
||||
ProviderType::Uuctl => "uuctl",
|
||||
ProviderType::Weather => "Weather",
|
||||
ProviderType::WebSearch => "Web",
|
||||
};
|
||||
}
|
||||
@@ -332,10 +338,13 @@ impl ProviderFilter {
|
||||
ProviderType::Dmenu => "dmenu",
|
||||
ProviderType::Emoji => "Emoji",
|
||||
ProviderType::Files => "Files",
|
||||
ProviderType::MediaPlayer => "Media",
|
||||
ProviderType::Pomodoro => "Pomodoro",
|
||||
ProviderType::Scripts => "Scripts",
|
||||
ProviderType::Ssh => "SSH",
|
||||
ProviderType::System => "System",
|
||||
ProviderType::Uuctl => "uuctl",
|
||||
ProviderType::Weather => "Weather",
|
||||
ProviderType::WebSearch => "Web",
|
||||
}
|
||||
} 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 emoji;
|
||||
mod files;
|
||||
mod media;
|
||||
mod pomodoro;
|
||||
mod scripts;
|
||||
mod ssh;
|
||||
mod system;
|
||||
mod uuctl;
|
||||
mod weather;
|
||||
mod websearch;
|
||||
|
||||
pub use application::ApplicationProvider;
|
||||
@@ -20,10 +23,13 @@ pub use command::CommandProvider;
|
||||
pub use dmenu::DmenuProvider;
|
||||
pub use emoji::EmojiProvider;
|
||||
pub use files::FileSearchProvider;
|
||||
pub use media::MediaProvider;
|
||||
pub use pomodoro::{PomodoroConfig, PomodoroProvider};
|
||||
pub use scripts::ScriptsProvider;
|
||||
pub use ssh::SshProvider;
|
||||
pub use system::SystemProvider;
|
||||
pub use uuctl::UuctlProvider;
|
||||
pub use weather::{WeatherConfig, WeatherProvider, WeatherProviderType};
|
||||
pub use websearch::WebSearchProvider;
|
||||
|
||||
use fuzzy_matcher::FuzzyMatcher;
|
||||
@@ -60,10 +66,13 @@ pub enum ProviderType {
|
||||
Dmenu,
|
||||
Emoji,
|
||||
Files,
|
||||
MediaPlayer,
|
||||
Pomodoro,
|
||||
Scripts,
|
||||
Ssh,
|
||||
System,
|
||||
Uuctl,
|
||||
Weather,
|
||||
WebSearch,
|
||||
}
|
||||
|
||||
@@ -80,13 +89,16 @@ impl std::str::FromStr for ProviderType {
|
||||
"dmenu" => Ok(ProviderType::Dmenu),
|
||||
"emoji" | "emojis" => Ok(ProviderType::Emoji),
|
||||
"file" | "files" | "find" => Ok(ProviderType::Files),
|
||||
"media" | "mpris" | "player" => Ok(ProviderType::MediaPlayer),
|
||||
"pomo" | "pomodoro" | "timer" => Ok(ProviderType::Pomodoro),
|
||||
"script" | "scripts" => Ok(ProviderType::Scripts),
|
||||
"ssh" => Ok(ProviderType::Ssh),
|
||||
"sys" | "system" | "power" => Ok(ProviderType::System),
|
||||
"uuctl" => Ok(ProviderType::Uuctl),
|
||||
"weather" => Ok(ProviderType::Weather),
|
||||
"web" | "websearch" | "search" => Ok(ProviderType::WebSearch),
|
||||
_ => 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
|
||||
)),
|
||||
}
|
||||
@@ -104,10 +116,13 @@ impl std::fmt::Display for ProviderType {
|
||||
ProviderType::Dmenu => write!(f, "dmenu"),
|
||||
ProviderType::Emoji => write!(f, "emoji"),
|
||||
ProviderType::Files => write!(f, "file"),
|
||||
ProviderType::MediaPlayer => write!(f, "media"),
|
||||
ProviderType::Pomodoro => write!(f, "pomo"),
|
||||
ProviderType::Scripts => write!(f, "script"),
|
||||
ProviderType::Ssh => write!(f, "ssh"),
|
||||
ProviderType::System => write!(f, "sys"),
|
||||
ProviderType::Uuctl => write!(f, "uuctl"),
|
||||
ProviderType::Weather => write!(f, "weather"),
|
||||
ProviderType::WebSearch => write!(f, "web"),
|
||||
}
|
||||
}
|
||||
@@ -128,6 +143,10 @@ pub struct ProviderManager {
|
||||
calculator: CalculatorProvider,
|
||||
websearch: WebSearchProvider,
|
||||
filesearch: FileSearchProvider,
|
||||
// Widget providers (optional, controlled by config)
|
||||
media: Option<MediaProvider>,
|
||||
weather: Option<WeatherProvider>,
|
||||
pomodoro: Option<PomodoroProvider>,
|
||||
matcher: SkimMatcherV2,
|
||||
}
|
||||
|
||||
@@ -138,15 +157,26 @@ impl ProviderManager {
|
||||
}
|
||||
|
||||
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 {
|
||||
providers: Vec::new(),
|
||||
calculator: CalculatorProvider::new(),
|
||||
websearch: WebSearchProvider::with_engine(search_engine),
|
||||
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(),
|
||||
};
|
||||
|
||||
@@ -195,6 +225,25 @@ impl ProviderManager {
|
||||
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)]
|
||||
@@ -299,6 +348,30 @@ impl ProviderManager {
|
||||
|
||||
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)
|
||||
if CalculatorProvider::is_calculator_query(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
|
||||
if query.is_empty() {
|
||||
let mut items: Vec<(LaunchItem, i64)> = self
|
||||
let items: Vec<(LaunchItem, i64)> = self
|
||||
.providers
|
||||
.iter()
|
||||
.filter(|p| filter.is_active(p.provider_type()))
|
||||
@@ -370,9 +443,11 @@ impl ProviderManager {
|
||||
})
|
||||
.collect();
|
||||
|
||||
items.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
items.truncate(max_results);
|
||||
return items;
|
||||
// Combine widgets (already in results) with frecency items
|
||||
results.extend(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
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* Emoji icon for widgets (weather, media, pomodoro) */
|
||||
.owlry-emoji-icon {
|
||||
font-size: 24px;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
/* Result name */
|
||||
.owlry-result-name {
|
||||
font-size: var(--owlry-font-size, 14px);
|
||||
@@ -166,6 +173,22 @@
|
||||
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 */
|
||||
.owlry-header {
|
||||
margin-bottom: 4px;
|
||||
@@ -283,6 +306,25 @@
|
||||
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 */
|
||||
.owlry-hints {
|
||||
padding-top: 8px;
|
||||
|
||||
@@ -248,10 +248,13 @@ impl MainWindow {
|
||||
ProviderType::Dmenu => "Dmenu",
|
||||
ProviderType::Emoji => "Emoji",
|
||||
ProviderType::Files => "Files",
|
||||
ProviderType::MediaPlayer => "Media",
|
||||
ProviderType::Pomodoro => "Pomo",
|
||||
ProviderType::Scripts => "Scripts",
|
||||
ProviderType::Ssh => "SSH",
|
||||
ProviderType::System => "System",
|
||||
ProviderType::Uuctl => "uuctl",
|
||||
ProviderType::Weather => "Weather",
|
||||
ProviderType::WebSearch => "Web",
|
||||
}
|
||||
}
|
||||
@@ -267,10 +270,13 @@ impl MainWindow {
|
||||
ProviderType::Dmenu => "owlry-filter-dmenu",
|
||||
ProviderType::Emoji => "owlry-filter-emoji",
|
||||
ProviderType::Files => "owlry-filter-file",
|
||||
ProviderType::MediaPlayer => "owlry-filter-media",
|
||||
ProviderType::Pomodoro => "owlry-filter-pomodoro",
|
||||
ProviderType::Scripts => "owlry-filter-script",
|
||||
ProviderType::Ssh => "owlry-filter-ssh",
|
||||
ProviderType::System => "owlry-filter-sys",
|
||||
ProviderType::Uuctl => "owlry-filter-uuctl",
|
||||
ProviderType::Weather => "owlry-filter-weather",
|
||||
ProviderType::WebSearch => "owlry-filter-web",
|
||||
}
|
||||
}
|
||||
@@ -288,10 +294,13 @@ impl MainWindow {
|
||||
ProviderType::Dmenu => "options",
|
||||
ProviderType::Emoji => "emoji",
|
||||
ProviderType::Files => "files",
|
||||
ProviderType::MediaPlayer => "media",
|
||||
ProviderType::Pomodoro => "pomodoro",
|
||||
ProviderType::Scripts => "scripts",
|
||||
ProviderType::Ssh => "SSH hosts",
|
||||
ProviderType::System => "system",
|
||||
ProviderType::Uuctl => "uuctl units",
|
||||
ProviderType::Weather => "weather",
|
||||
ProviderType::WebSearch => "web",
|
||||
})
|
||||
.collect();
|
||||
@@ -531,10 +540,13 @@ impl MainWindow {
|
||||
ProviderType::Dmenu => "options",
|
||||
ProviderType::Emoji => "emoji",
|
||||
ProviderType::Files => "files",
|
||||
ProviderType::MediaPlayer => "media",
|
||||
ProviderType::Pomodoro => "pomodoro",
|
||||
ProviderType::Scripts => "scripts",
|
||||
ProviderType::Ssh => "SSH hosts",
|
||||
ProviderType::System => "system",
|
||||
ProviderType::Uuctl => "uuctl units",
|
||||
ProviderType::Weather => "weather",
|
||||
ProviderType::WebSearch => "web",
|
||||
};
|
||||
search_entry_for_change
|
||||
@@ -584,13 +596,14 @@ impl MainWindow {
|
||||
let current_results_for_activate = self.current_results.clone();
|
||||
let config_for_activate = self.config.clone();
|
||||
let frecency_for_activate = self.frecency.clone();
|
||||
let providers_for_activate = self.providers.clone();
|
||||
let window_for_activate = self.window.clone();
|
||||
let submenu_state_for_activate = self.submenu_state.clone();
|
||||
let mode_label_for_activate = self.mode_label.clone();
|
||||
let hints_label_for_activate = self.hints_label.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
|
||||
.selected_row()
|
||||
.or_else(|| results_list_for_activate.row_at_index(0));
|
||||
@@ -616,9 +629,21 @@ impl MainWindow {
|
||||
is_active,
|
||||
);
|
||||
} else {
|
||||
// Execute the command
|
||||
Self::launch_item(item, &config_for_activate.borrow(), &frecency_for_activate);
|
||||
window_for_activate.close();
|
||||
// Execute the command (or handle internal commands)
|
||||
let item = item.clone();
|
||||
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 config = self.config.clone();
|
||||
let frecency = self.frecency.clone();
|
||||
let providers = self.providers.clone();
|
||||
let window = self.window.clone();
|
||||
let submenu_state = self.submenu_state.clone();
|
||||
let results_list_for_click = self.results_list.clone();
|
||||
@@ -822,8 +848,15 @@ impl MainWindow {
|
||||
is_active,
|
||||
);
|
||||
} else {
|
||||
Self::launch_item(item, &config.borrow(), &frecency);
|
||||
window.close();
|
||||
let item = item.clone();
|
||||
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;
|
||||
}
|
||||
|
||||
/// 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>>) {
|
||||
// Record this launch for frecency tracking
|
||||
if config.providers.frecency {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::providers::LaunchItem;
|
||||
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)]
|
||||
pub struct ResultRow {
|
||||
@@ -25,32 +25,55 @@ impl ResultRow {
|
||||
.margin_end(12)
|
||||
.build();
|
||||
|
||||
// Icon
|
||||
let icon = if let Some(icon_name) = &item.icon {
|
||||
Image::from_icon_name(icon_name)
|
||||
// Icon - handle file paths, icon names, emoji widgets, and fallbacks
|
||||
let icon_widget: Widget = if let Some(icon_name) = &item.icon {
|
||||
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 {
|
||||
// Default icon based on provider type
|
||||
let default_icon = match item.provider {
|
||||
crate::providers::ProviderType::Application => "application-x-executable",
|
||||
crate::providers::ProviderType::Bookmarks => "user-bookmarks",
|
||||
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",
|
||||
};
|
||||
Image::from_icon_name(default_icon)
|
||||
// For widgets, use emoji labels (more reliable than icon themes)
|
||||
match item.provider {
|
||||
crate::providers::ProviderType::Weather => {
|
||||
Self::create_emoji_icon("🌤️")
|
||||
}
|
||||
crate::providers::ProviderType::MediaPlayer => {
|
||||
Self::create_emoji_icon("🎵")
|
||||
}
|
||||
crate::providers::ProviderType::Pomodoro => {
|
||||
Self::create_emoji_icon("🍅")
|
||||
}
|
||||
_ => {
|
||||
let default_icon = match item.provider {
|
||||
crate::providers::ProviderType::Application => "application-x-executable",
|
||||
crate::providers::ProviderType::Bookmarks => "user-bookmarks",
|
||||
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
|
||||
let text_box = GtkBox::builder()
|
||||
.orientation(Orientation::Vertical)
|
||||
@@ -111,7 +134,7 @@ impl ResultRow {
|
||||
badge.add_css_class("owlry-result-badge");
|
||||
badge.add_css_class(&format!("owlry-badge-{}", item.provider));
|
||||
|
||||
hbox.append(&icon);
|
||||
hbox.append(&icon_widget);
|
||||
hbox.append(&text_box);
|
||||
hbox.append(&badge);
|
||||
|
||||
@@ -119,4 +142,16 @@ impl ResultRow {
|
||||
|
||||
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