From 01a9395d12e8524b11c4ac7ce55d03faf33f4d00 Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Sat, 3 Jan 2026 07:03:02 -0500 Subject: [PATCH 1/7] Add dashboard subsystem and settings integration --- dashboard.json | 52 + src/dashboard/config.rs | 146 ++ src/dashboard/dashboard.rs | 148 ++ src/dashboard/layout.rs | 166 ++ src/dashboard/mod.rs | 7 + src/dashboard/widgets/frequent_commands.rs | 83 + src/dashboard/widgets/mod.rs | 106 ++ src/dashboard/widgets/note_meta.rs | 54 + src/dashboard/widgets/notes_open.rs | 50 + src/dashboard/widgets/plugin_home.rs | 81 + src/dashboard/widgets/recent_commands.rs | 65 + src/dashboard/widgets/todo_summary.rs | 59 + src/dashboard/widgets/weather_site.rs | 66 + src/gui/dashboard_editor_dialog.rs | 157 ++ src/gui/mod.rs | 1852 +++++++++----------- src/lib.rs | 1 + src/settings.rs | 30 + src/settings_editor.rs | 68 + tests/dashboard_config.rs | 99 ++ 19 files changed, 2269 insertions(+), 1021 deletions(-) create mode 100644 dashboard.json create mode 100644 src/dashboard/config.rs create mode 100644 src/dashboard/dashboard.rs create mode 100644 src/dashboard/layout.rs create mode 100644 src/dashboard/mod.rs create mode 100644 src/dashboard/widgets/frequent_commands.rs create mode 100644 src/dashboard/widgets/mod.rs create mode 100644 src/dashboard/widgets/note_meta.rs create mode 100644 src/dashboard/widgets/notes_open.rs create mode 100644 src/dashboard/widgets/plugin_home.rs create mode 100644 src/dashboard/widgets/recent_commands.rs create mode 100644 src/dashboard/widgets/todo_summary.rs create mode 100644 src/dashboard/widgets/weather_site.rs create mode 100644 src/gui/dashboard_editor_dialog.rs create mode 100644 tests/dashboard_config.rs diff --git a/dashboard.json b/dashboard.json new file mode 100644 index 00000000..ad36aa71 --- /dev/null +++ b/dashboard.json @@ -0,0 +1,52 @@ +{ + "version": 1, + "grid": { + "rows": 3, + "cols": 3 + }, + "slots": [ + { + "id": "Weather", + "widget": "weather_site", + "row": 0, + "col": 0, + "row_span": 1, + "col_span": 2, + "settings": { + "location": "Seattle" + } + }, + { + "id": "Recents", + "widget": "recent_commands", + "row": 1, + "col": 0, + "row_span": 1, + "col_span": 1 + }, + { + "id": "Frequently used", + "widget": "frequent_commands", + "row": 1, + "col": 1, + "row_span": 1, + "col_span": 1 + }, + { + "id": "Todos", + "widget": "todo_summary", + "row": 2, + "col": 0, + "row_span": 1, + "col_span": 1 + }, + { + "id": "Notes", + "widget": "notes_open", + "row": 2, + "col": 1, + "row_span": 1, + "col_span": 1 + } + ] +} diff --git a/src/dashboard/config.rs b/src/dashboard/config.rs new file mode 100644 index 00000000..311a6ee6 --- /dev/null +++ b/src/dashboard/config.rs @@ -0,0 +1,146 @@ +use crate::dashboard::widgets::WidgetRegistry; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::path::{Path, PathBuf}; + +fn default_version() -> u32 { + 1 +} + +fn default_rows() -> u8 { + 3 +} + +fn default_cols() -> u8 { + 3 +} + +fn default_span() -> u8 { + 1 +} + +/// Grid definition for the dashboard layout. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GridConfig { + #[serde(default = "default_rows")] + pub rows: u8, + #[serde(default = "default_cols")] + pub cols: u8, +} + +impl Default for GridConfig { + fn default() -> Self { + Self { + rows: default_rows(), + cols: default_cols(), + } + } +} + +/// Widget slot configuration. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SlotConfig { + #[serde(default)] + pub id: Option, + #[serde(default)] + pub widget: String, + pub row: i32, + pub col: i32, + #[serde(default = "default_span")] + pub row_span: u8, + #[serde(default = "default_span")] + pub col_span: u8, + #[serde(default)] + pub settings: serde_json::Value, +} + +impl SlotConfig { + pub fn with_widget(widget: &str, row: i32, col: i32) -> Self { + Self { + id: None, + widget: widget.to_string(), + row, + col, + row_span: default_span(), + col_span: default_span(), + settings: serde_json::Value::Object(Default::default()), + } + } +} + +/// Primary dashboard configuration. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DashboardConfig { + #[serde(default = "default_version")] + pub version: u32, + #[serde(default)] + pub grid: GridConfig, + #[serde(default)] + pub slots: Vec, +} + +impl Default for DashboardConfig { + fn default() -> Self { + Self { + version: default_version(), + grid: GridConfig::default(), + slots: vec![ + SlotConfig::with_widget("weather_site", 0, 0), + SlotConfig::with_widget("recent_commands", 1, 0), + SlotConfig::with_widget("frequent_commands", 1, 1), + SlotConfig::with_widget("todo_summary", 2, 0), + SlotConfig::with_widget("notes_open", 2, 1), + ], + } + } +} + +impl DashboardConfig { + /// Load a configuration from disk. Unknown widget types or invalid slots are + /// filtered out using the provided registry. + pub fn load(path: impl AsRef, registry: &WidgetRegistry) -> anyhow::Result { + let path = path.as_ref(); + let content = std::fs::read_to_string(path).unwrap_or_default(); + if content.trim().is_empty() { + return Ok(Self::default()); + } + let mut cfg: DashboardConfig = serde_json::from_str(&content)?; + cfg.sanitize(registry); + Ok(cfg) + } + + /// Save the configuration to disk. + pub fn save(&self, path: impl AsRef) -> anyhow::Result<()> { + let json = serde_json::to_string_pretty(self)?; + std::fs::write(path, json)?; + Ok(()) + } + + /// Remove unsupported widgets and normalize empty settings. + pub fn sanitize(&mut self, registry: &WidgetRegistry) { + self.slots.retain(|slot| { + if slot.widget.is_empty() { + return false; + } + if !registry.contains(&slot.widget) { + tracing::warn!(widget = %slot.widget, "unknown dashboard widget dropped"); + return false; + } + true + }); + for slot in &mut self.slots { + if slot.settings.is_null() { + slot.settings = json!({}); + } + } + } + + pub fn path_for(base: &str) -> PathBuf { + let base = Path::new(base); + if base.is_dir() { + base.join("dashboard.json") + } else { + PathBuf::from(base) + } + } +} diff --git a/src/dashboard/dashboard.rs b/src/dashboard/dashboard.rs new file mode 100644 index 00000000..24938046 --- /dev/null +++ b/src/dashboard/dashboard.rs @@ -0,0 +1,148 @@ +use crate::dashboard::config::DashboardConfig; +use crate::dashboard::layout::{normalize_slots, NormalizedSlot}; +use crate::dashboard::widgets::{WidgetAction, WidgetRegistry}; +use crate::{actions::Action, common::json_watch::JsonWatcher}; +use eframe::egui; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DashboardEvent { + Reloaded, +} + +/// Source of a widget activation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WidgetActivation { + Click, + Keyboard, +} + +/// Context shared with widgets at render time. +pub struct DashboardContext<'a> { + pub actions: &'a [Action], + pub usage: &'a std::collections::HashMap, + pub plugins: &'a crate::plugin::PluginManager, + pub default_location: Option<&'a str>, +} + +pub struct Dashboard { + config_path: PathBuf, + pub config: DashboardConfig, + pub slots: Vec, + registry: WidgetRegistry, + watcher: Option, + pub warnings: Vec, + event_tx: Option>, +} + +impl Dashboard { + pub fn new( + config_path: impl AsRef, + registry: WidgetRegistry, + event_tx: Option>, + ) -> Self { + let path = config_path.as_ref().to_path_buf(); + let (config, slots, warnings) = Self::load_internal(&path, ®istry); + Self { + config_path: path, + config, + slots, + registry, + watcher: None, + warnings, + event_tx, + } + } + + fn load_internal( + path: &Path, + registry: &WidgetRegistry, + ) -> (DashboardConfig, Vec, Vec) { + let cfg = DashboardConfig::load(path, registry).unwrap_or_default(); + let (slots, mut warnings) = normalize_slots(&cfg, registry); + if slots.is_empty() { + warnings.push("dashboard has no valid slots".into()); + } + (cfg, slots, warnings) + } + + pub fn reload(&mut self) { + let (cfg, slots, warnings) = Self::load_internal(&self.config_path, &self.registry); + self.config = cfg; + self.slots = slots; + self.warnings = warnings; + } + + pub fn set_path(&mut self, path: impl AsRef) { + self.config_path = path.as_ref().to_path_buf(); + self.reload(); + self.attach_watcher(); + } + + pub fn attach_watcher(&mut self) { + let path = self.config_path.clone(); + let tx = self.event_tx.clone(); + self.watcher = crate::common::json_watch::watch_json(path.clone(), move || { + tracing::info!("dashboard config changed"); + if let Some(tx) = &tx { + let _ = tx.send(DashboardEvent::Reloaded); + } + }) + .ok(); + } + + pub fn ui( + &mut self, + ui: &mut egui::Ui, + ctx: &DashboardContext<'_>, + activation: WidgetActivation, + ) -> Option { + let mut clicked = None; + let grid_cols = self.config.grid.cols.max(1) as usize; + let col_width = ui.available_width() / grid_cols.max(1) as f32; + + for slot in &self.slots { + let rect = egui::Rect::from_min_size( + ui.min_rect().min + + egui::vec2( + col_width * slot.col as f32, + (slot.row as f32) * 100.0, // coarse row height + ), + egui::vec2( + col_width * slot.col_span as f32, + 90.0 * slot.row_span as f32, + ), + ); + let mut child = ui.child_ui(rect, egui::Layout::left_to_right(egui::Align::TOP)); + if let Some(action) = self.render_slot(slot, &mut child, ctx, activation) { + clicked = Some(action); + } + } + + clicked + } + + fn render_slot( + &self, + slot: &NormalizedSlot, + ui: &mut egui::Ui, + ctx: &DashboardContext<'_>, + activation: WidgetActivation, + ) -> Option { + ui.group(|ui| { + ui.vertical(|ui| { + let heading = slot.id.as_deref().unwrap_or(&slot.widget); + ui.heading(heading); + self.registry + .create(&slot.widget, &slot.settings) + .and_then(|mut w| w.render(ui, ctx, activation)) + }) + .inner + }) + .inner + } + + pub fn registry(&self) -> &WidgetRegistry { + &self.registry + } +} diff --git a/src/dashboard/layout.rs b/src/dashboard/layout.rs new file mode 100644 index 00000000..2a41d7d0 --- /dev/null +++ b/src/dashboard/layout.rs @@ -0,0 +1,166 @@ +use crate::dashboard::config::{DashboardConfig, SlotConfig}; +use crate::dashboard::widgets::WidgetRegistry; +use serde_json::Value; + +#[derive(Debug, Clone, PartialEq)] +pub struct NormalizedSlot { + pub id: Option, + pub widget: String, + pub row: usize, + pub col: usize, + pub row_span: usize, + pub col_span: usize, + pub settings: Value, +} + +/// Validate and normalize slot positions to the configured grid size. +pub fn normalize_slots( + cfg: &DashboardConfig, + registry: &WidgetRegistry, +) -> (Vec, Vec) { + let rows = cfg.grid.rows.max(1) as usize; + let cols = cfg.grid.cols.max(1) as usize; + let mut occupied = vec![vec![false; cols]; rows]; + let mut normalized = Vec::new(); + let mut warnings = Vec::new(); + + for slot in &cfg.slots { + if !registry.contains(&slot.widget) { + warnings.push(format!("dropping unknown widget '{}'", slot.widget)); + continue; + } + if let Some(ns) = normalize_slot(slot, rows, cols, &mut occupied) { + normalized.push(ns); + } else { + warnings.push(format!( + "slot for widget '{}' is outside the grid and was ignored", + slot.widget + )); + } + } + + (normalized, warnings) +} + +fn normalize_slot( + slot: &SlotConfig, + rows: usize, + cols: usize, + occupied: &mut [Vec], +) -> Option { + if slot.row < 0 || slot.col < 0 { + return None; + } + let row = slot.row as usize; + let col = slot.col as usize; + if row >= rows || col >= cols { + return None; + } + let row_span = slot.row_span.max(1).min((rows - row).max(1) as u8) as usize; + let col_span = slot.col_span.max(1).min((cols - col).max(1) as u8) as usize; + + for r in row..row + row_span { + for c in col..col + col_span { + if occupied[r][c] { + return None; + } + } + } + for r in row..row + row_span { + for c in col..col + col_span { + occupied[r][c] = true; + } + } + + Some(NormalizedSlot { + id: slot.id.clone(), + widget: slot.widget.clone(), + row, + col, + row_span, + col_span, + settings: slot.settings.clone(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[derive(Default)] + struct DummyWidget; + + #[derive(Default, serde::Deserialize)] + struct DummyConfig; + + impl crate::dashboard::widgets::Widget for DummyWidget { + fn render( + &mut self, + _ui: &mut eframe::egui::Ui, + _ctx: &crate::dashboard::dashboard::DashboardContext<'_>, + _activation: crate::dashboard::dashboard::WidgetActivation, + ) -> Option { + None + } + } + + fn test_registry() -> WidgetRegistry { + let mut reg = WidgetRegistry::default(); + reg.register( + "test", + crate::dashboard::widgets::WidgetFactory::new(|_: DummyConfig| DummyWidget), + ); + reg + } + + #[test] + fn clamps_out_of_bounds() { + let cfg = DashboardConfig { + version: 1, + grid: crate::dashboard::config::GridConfig { rows: 2, cols: 2 }, + slots: vec![SlotConfig { + id: None, + widget: "test".into(), + row: 0, + col: 0, + row_span: 5, + col_span: 5, + settings: json!({}), + }], + }; + let registry = test_registry(); + let (slots, _) = normalize_slots(&cfg, ®istry); + assert_eq!(slots[0].row_span, 2); + assert_eq!(slots[0].col_span, 2); + } + + #[test] + fn prevents_overlap() { + let cfg = DashboardConfig { + version: 1, + grid: crate::dashboard::config::GridConfig { rows: 2, cols: 2 }, + slots: vec![ + SlotConfig::with_widget("test", 0, 0), + SlotConfig::with_widget("test", 0, 0), + ], + }; + let registry = test_registry(); + let (slots, warnings) = normalize_slots(&cfg, ®istry); + assert_eq!(slots.len(), 1); + assert_eq!(warnings.len(), 1); + } + + #[test] + fn ignores_negative() { + let cfg = DashboardConfig { + version: 1, + grid: crate::dashboard::config::GridConfig { rows: 2, cols: 2 }, + slots: vec![SlotConfig::with_widget("test", -1, 0)], + }; + let registry = test_registry(); + let (slots, warnings) = normalize_slots(&cfg, ®istry); + assert!(slots.is_empty()); + assert_eq!(warnings.len(), 1); + } +} diff --git a/src/dashboard/mod.rs b/src/dashboard/mod.rs new file mode 100644 index 00000000..3a2a20d3 --- /dev/null +++ b/src/dashboard/mod.rs @@ -0,0 +1,7 @@ +pub mod config; +pub mod dashboard; +pub mod layout; +pub mod widgets; + +pub use dashboard::{Dashboard, DashboardContext, DashboardEvent, WidgetActivation}; +pub use widgets::{WidgetAction, WidgetFactory, WidgetRegistry}; diff --git a/src/dashboard/widgets/frequent_commands.rs b/src/dashboard/widgets/frequent_commands.rs new file mode 100644 index 00000000..feeee3c9 --- /dev/null +++ b/src/dashboard/widgets/frequent_commands.rs @@ -0,0 +1,83 @@ +use super::{Widget, WidgetAction}; +use crate::actions::Action; +use crate::dashboard::dashboard::{DashboardContext, WidgetActivation}; +use eframe::egui; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FrequentCommandsConfig { + #[serde(default = "default_count")] + pub count: usize, +} + +impl Default for FrequentCommandsConfig { + fn default() -> Self { + Self { + count: default_count(), + } + } +} + +fn default_count() -> usize { + 5 +} + +pub struct FrequentCommandsWidget { + cfg: FrequentCommandsConfig, +} + +impl FrequentCommandsWidget { + pub fn new(cfg: FrequentCommandsConfig) -> Self { + Self { cfg } + } + + fn resolve_action<'a>(&self, actions: &'a [Action], key: &str) -> Option { + actions + .iter() + .find(|a| a.action == key) + .cloned() + .or_else(|| { + Some(Action { + label: key.to_string(), + desc: "Command".into(), + action: key.to_string(), + args: None, + }) + }) + } +} + +impl Default for FrequentCommandsWidget { + fn default() -> Self { + Self { + cfg: FrequentCommandsConfig::default(), + } + } +} + +impl Widget for FrequentCommandsWidget { + fn render( + &mut self, + ui: &mut egui::Ui, + ctx: &DashboardContext<'_>, + _activation: WidgetActivation, + ) -> Option { + let mut usage: Vec<(&String, &u32)> = ctx.usage.iter().collect(); + usage.sort_by(|a, b| b.1.cmp(a.1)); + ui.label("Frequent commands"); + for (idx, (action_id, _)) in usage.into_iter().enumerate() { + if idx >= self.cfg.count { + break; + } + if let Some(action) = self.resolve_action(ctx.actions, action_id) { + if ui.button(&action.label).clicked() { + return Some(WidgetAction { + query_override: Some(action.label.clone()), + action, + }); + } + } + } + None + } +} diff --git a/src/dashboard/widgets/mod.rs b/src/dashboard/widgets/mod.rs new file mode 100644 index 00000000..75ede2d7 --- /dev/null +++ b/src/dashboard/widgets/mod.rs @@ -0,0 +1,106 @@ +use crate::actions::Action; +use crate::dashboard::dashboard::{DashboardContext, WidgetActivation}; +use eframe::egui; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; + +mod frequent_commands; +mod note_meta; +mod notes_open; +mod plugin_home; +mod recent_commands; +mod todo_summary; +mod weather_site; + +pub use frequent_commands::FrequentCommandsWidget; +pub use note_meta::NoteMetaWidget; +pub use notes_open::NotesOpenWidget; +pub use plugin_home::PluginHomeWidget; +pub use recent_commands::RecentCommandsWidget; +pub use todo_summary::TodoSummaryWidget; +pub use weather_site::WeatherSiteWidget; + +/// Result of a widget activation. +#[derive(Debug, Clone, PartialEq)] +pub struct WidgetAction { + pub action: Action, + pub query_override: Option, +} + +/// Widget trait implemented by all dashboard widgets. +pub trait Widget: Send { + fn render( + &mut self, + ui: &mut egui::Ui, + ctx: &DashboardContext<'_>, + activation: WidgetActivation, + ) -> Option; +} + +/// Factory for building widgets from JSON settings. +#[derive(Clone)] +pub struct WidgetFactory { + ctor: fn(&Value) -> Box, +} + +impl WidgetFactory { + pub fn new( + build: fn(C) -> T, + ) -> Self { + Self { + ctor: move |v| { + let cfg = serde_json::from_value::(v.clone()).unwrap_or_default(); + Box::new(build(cfg)) + }, + } + } + + pub fn create(&self, settings: &Value) -> Box { + (self.ctor)(settings) + } +} + +#[derive(Clone, Default)] +pub struct WidgetRegistry { + map: HashMap, +} + +impl WidgetRegistry { + pub fn with_defaults() -> Self { + let mut reg = Self::default(); + reg.register("weather_site", WidgetFactory::new(WeatherSiteWidget::new)); + reg.register("notes_open", WidgetFactory::new(NotesOpenWidget::new)); + reg.register("note_meta", WidgetFactory::new(NoteMetaWidget::new)); + reg.register( + "recent_commands", + WidgetFactory::new(RecentCommandsWidget::new), + ); + reg.register( + "frequent_commands", + WidgetFactory::new(FrequentCommandsWidget::new), + ); + reg.register("todo_summary", WidgetFactory::new(TodoSummaryWidget::new)); + reg.register("plugin_home", WidgetFactory::new(PluginHomeWidget::new)); + reg + } + + pub fn register(&mut self, name: &str, factory: WidgetFactory) { + self.map.insert(name.to_string(), factory); + } + + pub fn contains(&self, name: &str) -> bool { + self.map.contains_key(name) + } + + pub fn create(&self, name: &str, settings: &Value) -> Option> { + self.map.get(name).map(|f| f.create(settings)) + } + + pub fn names(&self) -> Vec { + let mut names: Vec = self.map.keys().cloned().collect(); + names.sort(); + names + } +} diff --git a/src/dashboard/widgets/note_meta.rs b/src/dashboard/widgets/note_meta.rs new file mode 100644 index 00000000..ccf39740 --- /dev/null +++ b/src/dashboard/widgets/note_meta.rs @@ -0,0 +1,54 @@ +use super::{Widget, WidgetAction}; +use crate::actions::Action; +use crate::dashboard::dashboard::{DashboardContext, WidgetActivation}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct NoteMetaConfig { + pub label: Option, +} + +pub struct NoteMetaWidget { + cfg: NoteMetaConfig, +} + +impl NoteMetaWidget { + pub fn new(cfg: NoteMetaConfig) -> Self { + Self { cfg } + } +} + +impl Default for NoteMetaWidget { + fn default() -> Self { + Self { + cfg: NoteMetaConfig::default(), + } + } +} + +impl Widget for NoteMetaWidget { + fn render( + &mut self, + ui: &mut eframe::egui::Ui, + _ctx: &DashboardContext<'_>, + _activation: WidgetActivation, + ) -> Option { + let label = self + .cfg + .label + .clone() + .unwrap_or_else(|| "Recent Note".into()); + if ui.button(&label).clicked() { + return Some(WidgetAction { + action: Action { + label: label.clone(), + desc: "Note".into(), + action: "note:dialog".into(), + args: None, + }, + query_override: Some("note list".into()), + }); + } + None + } +} diff --git a/src/dashboard/widgets/notes_open.rs b/src/dashboard/widgets/notes_open.rs new file mode 100644 index 00000000..d75564a8 --- /dev/null +++ b/src/dashboard/widgets/notes_open.rs @@ -0,0 +1,50 @@ +use super::{Widget, WidgetAction}; +use crate::actions::Action; +use crate::dashboard::dashboard::{DashboardContext, WidgetActivation}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct NotesOpenConfig { + pub query: Option, +} + +pub struct NotesOpenWidget { + cfg: NotesOpenConfig, +} + +impl NotesOpenWidget { + pub fn new(cfg: NotesOpenConfig) -> Self { + Self { cfg } + } +} + +impl Default for NotesOpenWidget { + fn default() -> Self { + Self { + cfg: NotesOpenConfig::default(), + } + } +} + +impl Widget for NotesOpenWidget { + fn render( + &mut self, + ui: &mut eframe::egui::Ui, + _ctx: &DashboardContext<'_>, + _activation: WidgetActivation, + ) -> Option { + let label = "Open Notes"; + if ui.button(label).clicked() { + return Some(WidgetAction { + action: Action { + label: label.into(), + desc: "Note".into(), + action: "note:dialog".into(), + args: None, + }, + query_override: self.cfg.query.clone().or_else(|| Some("note".into())), + }); + } + None + } +} diff --git a/src/dashboard/widgets/plugin_home.rs b/src/dashboard/widgets/plugin_home.rs new file mode 100644 index 00000000..87d6f36c --- /dev/null +++ b/src/dashboard/widgets/plugin_home.rs @@ -0,0 +1,81 @@ +use super::{Widget, WidgetAction}; +use crate::actions::Action; +use crate::dashboard::dashboard::{DashboardContext, WidgetActivation}; +use eframe::egui; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PluginHomeConfig { + pub plugin: Option, + #[serde(default)] + pub home_query: Option, + #[serde(default = "default_limit")] + pub limit: usize, +} + +fn default_limit() -> usize { + 5 +} + +pub struct PluginHomeWidget { + cfg: PluginHomeConfig, +} + +impl PluginHomeWidget { + pub fn new(cfg: PluginHomeConfig) -> Self { + Self { cfg } + } +} + +impl Default for PluginHomeWidget { + fn default() -> Self { + Self { + cfg: PluginHomeConfig::default(), + } + } +} + +impl Widget for PluginHomeWidget { + fn render( + &mut self, + ui: &mut egui::Ui, + ctx: &DashboardContext<'_>, + _activation: WidgetActivation, + ) -> Option { + let plugin_name = match &self.cfg.plugin { + Some(p) => p, + None => { + ui.label("Configure plugin name"); + return None; + } + }; + + let mut actions: Vec = Vec::new(); + for p in ctx.plugins.iter() { + if p.name().eq_ignore_ascii_case(plugin_name) { + actions = p + .search(self.cfg.home_query.as_deref().unwrap_or_default()) + .into_iter() + .take(self.cfg.limit) + .collect(); + break; + } + } + + ui.label(format!("{} home", plugin_name)); + for act in actions { + if ui.button(&act.label).clicked() { + return Some(WidgetAction { + query_override: self + .cfg + .home_query + .clone() + .or_else(|| Some(act.label.clone())), + action: act, + }); + } + } + + None + } +} diff --git a/src/dashboard/widgets/recent_commands.rs b/src/dashboard/widgets/recent_commands.rs new file mode 100644 index 00000000..fc07671c --- /dev/null +++ b/src/dashboard/widgets/recent_commands.rs @@ -0,0 +1,65 @@ +use super::{Widget, WidgetAction}; +use crate::dashboard::dashboard::{DashboardContext, WidgetActivation}; +use eframe::egui; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecentCommandsConfig { + #[serde(default = "default_count")] + pub count: usize, +} + +impl Default for RecentCommandsConfig { + fn default() -> Self { + Self { + count: default_count(), + } + } +} + +fn default_count() -> usize { + 5 +} + +pub struct RecentCommandsWidget { + cfg: RecentCommandsConfig, +} + +impl RecentCommandsWidget { + pub fn new(cfg: RecentCommandsConfig) -> Self { + Self { cfg } + } +} + +impl Default for RecentCommandsWidget { + fn default() -> Self { + Self { + cfg: RecentCommandsConfig::default(), + } + } +} + +impl Widget for RecentCommandsWidget { + fn render( + &mut self, + ui: &mut egui::Ui, + _ctx: &DashboardContext<'_>, + _activation: WidgetActivation, + ) -> Option { + let mut clicked = None; + ui.label("Recent commands"); + if let Some(history) = crate::history::with_history(|h| { + h.iter().take(self.cfg.count).cloned().collect::>() + }) { + for entry in history { + if ui.button(&entry.action.label).clicked() { + clicked = Some(WidgetAction { + action: entry.action.clone(), + query_override: Some(entry.query.clone()), + }); + } + } + } + clicked + } +} diff --git a/src/dashboard/widgets/todo_summary.rs b/src/dashboard/widgets/todo_summary.rs new file mode 100644 index 00000000..5217b8c0 --- /dev/null +++ b/src/dashboard/widgets/todo_summary.rs @@ -0,0 +1,59 @@ +use super::{Widget, WidgetAction}; +use crate::actions::Action; +use crate::dashboard::dashboard::{DashboardContext, WidgetActivation}; +use eframe::egui; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct TodoSummaryConfig { + #[serde(default)] + pub query: Option, +} + +pub struct TodoSummaryWidget { + cfg: TodoSummaryConfig, +} + +impl TodoSummaryWidget { + pub fn new(cfg: TodoSummaryConfig) -> Self { + Self { cfg } + } +} + +impl Default for TodoSummaryWidget { + fn default() -> Self { + Self { + cfg: TodoSummaryConfig::default(), + } + } +} + +impl Widget for TodoSummaryWidget { + fn render( + &mut self, + ui: &mut egui::Ui, + _ctx: &DashboardContext<'_>, + _activation: WidgetActivation, + ) -> Option { + let todos = crate::plugins::todo::TODO_DATA + .read() + .ok() + .map(|t| t.clone()) + .unwrap_or_default(); + let done = todos.iter().filter(|t| t.done).count(); + let total = todos.len(); + ui.label(format!("Todos: {done}/{total} done")); + if ui.button("Open todos").clicked() { + return Some(WidgetAction { + action: Action { + label: "Todos".into(), + desc: "Todo".into(), + action: "todo:dialog".into(), + args: None, + }, + query_override: self.cfg.query.clone().or_else(|| Some("todo".into())), + }); + } + None + } +} diff --git a/src/dashboard/widgets/weather_site.rs b/src/dashboard/widgets/weather_site.rs new file mode 100644 index 00000000..9757fa67 --- /dev/null +++ b/src/dashboard/widgets/weather_site.rs @@ -0,0 +1,66 @@ +use super::{Widget, WidgetAction}; +use crate::actions::Action; +use crate::dashboard::dashboard::{DashboardContext, WidgetActivation}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct WeatherSiteConfig { + pub location: Option, + pub url: Option, +} + +pub struct WeatherSiteWidget { + cfg: WeatherSiteConfig, +} + +impl WeatherSiteWidget { + pub fn new(cfg: WeatherSiteConfig) -> Self { + Self { cfg } + } + + fn effective_location<'a>(&'a self, ctx: &'a DashboardContext<'_>) -> Option<&'a str> { + self.cfg + .location + .as_deref() + .or_else(|| ctx.default_location) + } +} + +impl Default for WeatherSiteWidget { + fn default() -> Self { + Self { + cfg: WeatherSiteConfig::default(), + } + } +} + +impl Widget for WeatherSiteWidget { + fn render( + &mut self, + ui: &mut eframe::egui::Ui, + ctx: &DashboardContext<'_>, + _activation: WidgetActivation, + ) -> Option { + let loc = self.effective_location(ctx).unwrap_or("your city"); + let url = self + .cfg + .url + .clone() + .unwrap_or_else(|| format!("https://www.google.com/search?q=weather+{loc}")); + let label = format!("Weather: {loc}"); + let clicked = ui.button(&label).clicked(); + ui.label("Opens weather for the configured location."); + if clicked { + return Some(WidgetAction { + action: Action { + label, + desc: "Weather".into(), + action: url, + args: None, + }, + query_override: Some(format!("weather {loc}")), + }); + } + None + } +} diff --git a/src/gui/dashboard_editor_dialog.rs b/src/gui/dashboard_editor_dialog.rs new file mode 100644 index 00000000..3b83423e --- /dev/null +++ b/src/gui/dashboard_editor_dialog.rs @@ -0,0 +1,157 @@ +use crate::dashboard::config::{DashboardConfig, SlotConfig}; +use crate::dashboard::widgets::WidgetRegistry; +use eframe::egui; + +pub struct DashboardEditorDialog { + pub open: bool, + path: String, + config: DashboardConfig, + selected: Option, + error: Option, + pending_save: bool, +} + +impl Default for DashboardEditorDialog { + fn default() -> Self { + Self { + open: false, + path: "dashboard.json".into(), + config: DashboardConfig::default(), + selected: None, + error: None, + pending_save: false, + } + } +} + +impl DashboardEditorDialog { + pub fn open(&mut self, path: &str, registry: &WidgetRegistry) { + self.path = path.to_string(); + self.reload(registry); + self.open = true; + } + + fn reload(&mut self, registry: &WidgetRegistry) { + match DashboardConfig::load(&self.path, registry) { + Ok(cfg) => { + self.config = cfg; + self.error = None; + } + Err(e) => { + self.error = Some(format!("Failed to load dashboard: {e}")); + } + } + } + + fn save(&mut self) { + let tmp = format!("{}.tmp", self.path); + if let Err(e) = self.config.save(&tmp) { + self.error = Some(format!("Failed to save: {e}")); + return; + } + if let Err(e) = std::fs::rename(&tmp, &self.path) { + self.error = Some(format!("Failed to finalize save: {e}")); + return; + } + self.pending_save = true; + } + + pub fn ui(&mut self, ctx: &egui::Context, registry: &WidgetRegistry) -> bool { + if !self.open { + return false; + } + let mut should_reload = false; + let mut open = self.open; + egui::Window::new("Dashboard Editor") + .open(&mut open) + .resizable(true) + .show(ctx, |ui| { + if let Some(err) = &self.error { + ui.colored_label(egui::Color32::RED, err); + } + + ui.horizontal(|ui| { + ui.label("Rows"); + ui.add(egui::DragValue::new(&mut self.config.grid.rows).clamp_range(1..=12)); + ui.label("Cols"); + ui.add(egui::DragValue::new(&mut self.config.grid.cols).clamp_range(1..=12)); + }); + + ui.separator(); + ui.horizontal(|ui| { + if ui.button("Add slot").clicked() { + self.config + .slots + .push(SlotConfig::with_widget("weather_site", 0, 0)); + } + if ui.button("Reload from disk").clicked() { + self.reload(registry); + } + if ui.button("Save").clicked() { + self.save(); + } + }); + + ui.separator(); + let mut idx = 0; + while idx < self.config.slots.len() { + let slot = &mut self.config.slots[idx]; + let mut removed = false; + ui.group(|ui| { + ui.horizontal(|ui| { + ui.label(format!("Slot {idx}")); + if ui.button("Remove").clicked() { + removed = true; + } + }); + egui::ComboBox::from_label("Widget type") + .selected_text(slot.widget.clone()) + .show_ui(ui, |ui| { + for name in registry.names() { + ui.selectable_value(&mut slot.widget, name.clone(), name); + } + }); + ui.horizontal(|ui| { + ui.label("Row"); + ui.add(egui::DragValue::new(&mut slot.row)); + ui.label("Col"); + ui.add(egui::DragValue::new(&mut slot.col)); + }); + ui.horizontal(|ui| { + ui.label("Row span"); + ui.add(egui::DragValue::new(&mut slot.row_span).clamp_range(1..=12)); + ui.label("Col span"); + ui.add(egui::DragValue::new(&mut slot.col_span).clamp_range(1..=12)); + }); + ui.horizontal(|ui| { + let id = slot.id.get_or_insert_with(|| format!("slot-{idx}")); + ui.label("Label"); + ui.text_edit_singleline(id); + }); + }); + if removed { + self.config.slots.remove(idx); + } else { + idx += 1; + } + } + + ui.separator(); + ui.label("Preview"); + let (_, mut warnings) = + crate::dashboard::layout::normalize_slots(&self.config, registry); + if !warnings.is_empty() { + warnings.dedup(); + for warn in warnings { + ui.colored_label(egui::Color32::YELLOW, warn); + } + } + }); + self.open = open; + if self.pending_save { + self.pending_save = false; + should_reload = true; + } + should_reload + } +} diff --git a/src/gui/mod.rs b/src/gui/mod.rs index cc7de288..9e385685 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -6,6 +6,7 @@ mod brightness_dialog; mod clipboard_dialog; mod convert_panel; mod cpu_list_dialog; +mod dashboard_editor_dialog; mod fav_dialog; mod image_panel; mod macro_dialog; @@ -55,6 +56,9 @@ pub use volume_dialog::VolumeDialog; use crate::actions::folders; use crate::actions::{load_actions, Action}; use crate::actions_editor::ActionsEditor; +use crate::dashboard::config::DashboardConfig; +use crate::dashboard::widgets::WidgetRegistry; +use crate::dashboard::{Dashboard, DashboardContext, DashboardEvent, WidgetActivation}; use crate::help_window::HelpWindow; use crate::history::{self, HistoryEntry}; use crate::indexer; @@ -68,6 +72,7 @@ use crate::settings_editor::SettingsEditor; use crate::toast_log::{append_toast_log, TOAST_LOG_FILE}; use crate::usage::{self, USAGE_FILE}; use crate::visibility::apply_visibility; +use dashboard_editor_dialog::DashboardEditorDialog; use eframe::egui; use egui_toast::{Toast, ToastKind, ToastOptions, Toasts}; use fst::{IntoStreamer, Map, MapBuilder, Streamer}; @@ -118,9 +123,17 @@ pub enum WatchEvent { Actions, Folders, Bookmarks, + Dashboard(DashboardEvent), Recycle(Result<(), String>), } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ActivationSource { + Enter, + Click, + Dashboard, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TestWatchEvent { Actions, @@ -134,6 +147,7 @@ impl From for TestWatchEvent { WatchEvent::Actions => TestWatchEvent::Actions, WatchEvent::Folders => TestWatchEvent::Folders, WatchEvent::Bookmarks => TestWatchEvent::Bookmarks, + WatchEvent::Dashboard(_) => TestWatchEvent::Actions, WatchEvent::Recycle(_) => unreachable!(), } } @@ -306,6 +320,13 @@ pub struct LauncherApp { /// Hold watchers so the `RecommendedWatcher` instances remain active. #[allow(dead_code)] // required to keep watchers alive watchers: Vec, + dashboard: Dashboard, + dashboard_enabled: bool, + dashboard_show_when_empty: bool, + dashboard_path: String, + dashboard_default_location: Option, + dashboard_editor: DashboardEditorDialog, + show_dashboard_editor: bool, rx: Receiver, folder_aliases: HashMap>, bookmark_aliases: HashMap>, @@ -639,6 +660,21 @@ impl LauncherApp { let toast_duration = settings.toast_duration; use std::path::Path; + let dashboard_path = DashboardConfig::path_for( + settings + .dashboard + .config_path + .as_deref() + .unwrap_or("dashboard.json"), + ); + let dashboard_registry = WidgetRegistry::with_defaults(); + let mut dashboard = Dashboard::new( + &dashboard_path, + dashboard_registry.clone(), + Some(tx.clone()), + ); + dashboard.attach_watcher(); + let folder_aliases = crate::plugins::folders::load_folders(crate::plugins::folders::FOLDERS_FILE) .unwrap_or_else(|_| crate::plugins::folders::default_folders()) @@ -808,6 +844,13 @@ impl LauncherApp { plugin_editor, settings_path, watchers, + dashboard, + dashboard_enabled: settings.dashboard.enabled, + dashboard_show_when_empty: settings.dashboard.show_when_query_empty, + dashboard_path: dashboard_path.to_string_lossy().to_string(), + dashboard_default_location: settings.dashboard.default_location.clone(), + dashboard_editor: DashboardEditorDialog::default(), + show_dashboard_editor: false, rx, folder_aliases, bookmark_aliases, @@ -1328,6 +1371,430 @@ impl LauncherApp { self.last_stopwatch_query } + pub fn should_show_dashboard(&self, trimmed: &str) -> bool { + self.dashboard_enabled && self.dashboard_show_when_empty && trimmed.trim().is_empty() + } + + pub fn activate_action( + &mut self, + mut a: Action, + query_override: Option, + _source: ActivationSource, + ) { + if let Some(new_query) = query_override { + self.query = new_query; + self.last_timer_query = + self.query.starts_with("timer list") || self.query.starts_with("alarm list"); + self.search(); + } + let current = self.query.clone(); + let mut refresh = false; + let mut set_focus = false; + let mut command_changed_query = false; + if let Some(new_q) = a.action.strip_prefix("query:") { + tracing::debug!("query action via activation: {new_q}"); + self.query = new_q.to_string(); + self.last_timer_query = + new_q.starts_with("timer list") || new_q.starts_with("alarm list"); + self.search(); + set_focus = true; + self.move_cursor_end = true; + } else if a.action == "help:show" { + self.help_window.open = true; + } else if a.action == "timer:dialog:timer" { + self.timer_dialog.open_timer(); + } else if a.action == "timer:dialog:alarm" { + self.timer_dialog.open_alarm(); + } else if a.action == "shell:dialog" { + self.shell_cmd_dialog.open(); + } else if a.action == "note:dialog" { + self.notes_dialog.open(); + } else if a.action == "note:unused_assets" { + self.unused_assets_dialog.open(); + } else if a.action == "bookmark:dialog" { + self.add_bookmark_dialog.open(); + } else if a.action == "snippet:dialog" { + self.snippet_dialog.open(); + } else if let Some(alias) = a.action.strip_prefix("snippet:edit:") { + self.snippet_dialog.open_edit(alias); + } else if a.action == "macro:dialog" { + self.macro_dialog.open(); + } else if let Some(label) = a.action.strip_prefix("fav:dialog:") { + if label.is_empty() { + self.fav_dialog.open(); + } else { + self.fav_dialog.open_edit(label); + } + } else if a.action == "todo:dialog" { + self.todo_dialog.open(); + } else if a.action == "todo:view" { + self.todo_view_dialog.open(); + } else if let Some(idx) = a.action.strip_prefix("todo:edit:") { + if let Ok(i) = idx.parse::() { + self.todo_view_dialog.open_edit(i); + } + } else if a.action == "clipboard:dialog" { + self.clipboard_dialog.open(); + } else if let Some(slug) = a.action.strip_prefix("note:open:") { + let slug = slug.to_string(); + self.open_note_panel(&slug, None); + } else if let Some(rest) = a.action.strip_prefix("note:new:") { + let mut parts = rest.splitn(2, ':'); + let slug = parts.next().unwrap_or("").to_string(); + let template = parts.next().map(|s| s.to_string()); + self.open_note_panel(&slug, template.as_deref()); + } else if a.action == "note:tags" { + self.open_note_tags(); + set_focus = true; + } else if let Some(link) = a.action.strip_prefix("note:link:") { + self.open_note_link(link); + } else if let Some(slug) = a.action.strip_prefix("note:remove:") { + self.delete_note(slug); + } else if a.action == "convert:panel" { + self.convert_panel.open(); + } else if a.action == "tempfile:dialog" { + self.tempfile_dialog.open(); + } else if a.action == "settings:dialog" { + self.show_settings = true; + } else if a.action == "volume:dialog" { + self.volume_dialog.open(); + } else if a.action == "brightness:dialog" { + self.brightness_dialog.open(); + } else if let Some(n) = a.action.strip_prefix("sysinfo:cpu_list:") { + if let Ok(count) = n.parse::() { + self.cpu_list_dialog.open(count); + } + } else if a.action.starts_with("tab:switch:") { + if self.enable_toasts { + push_toast( + &mut self.toasts, + Toast { + text: format!("Switching to {}", a.label).into(), + kind: ToastKind::Info, + options: ToastOptions::default() + .duration_in_seconds(self.toast_duration as f64), + }, + ); + } + let act = a.clone(); + std::thread::spawn(move || { + if let Err(e) = launch_action(&act) { + tracing::error!(?e, "failed to switch tab"); + } + }); + if a.action != "help:show" { + let _ = history::append_history( + HistoryEntry { + query: current.clone(), + query_lc: String::new(), + action: a.clone(), + }, + self.history_limit, + ); + let count = self.usage.entry(a.action.clone()).or_insert(0); + *count += 1; + } + } else if let Some(mode) = a.action.strip_prefix("screenshot:") { + use crate::actions::screenshot::Mode as ScreenshotMode; + let (mode, clip) = match mode { + "window" => (ScreenshotMode::Window, false), + "region" => (ScreenshotMode::Region, false), + "desktop" => (ScreenshotMode::Desktop, false), + "window_clip" => (ScreenshotMode::Window, true), + "region_clip" => (ScreenshotMode::Region, true), + "desktop_clip" => (ScreenshotMode::Desktop, true), + _ => (ScreenshotMode::Desktop, false), + }; + if let Err(e) = crate::plugins::screenshot::launch_editor(self, mode, clip) { + self.set_error(format!("Failed: {e}")); + } else if a.action != "help:show" { + let _ = history::append_history( + HistoryEntry { + query: current.clone(), + query_lc: String::new(), + action: a.clone(), + }, + self.history_limit, + ); + let count = self.usage.entry(a.action.clone()).or_insert(0); + *count += 1; + } + } else if let Err(e) = launch_action(&a) { + if a.desc == "Fav" && !a.action.starts_with("fav:") { + tracing::error!(?e, fav=%a.label, "failed to run favorite"); + } + self.set_error(format!("Failed: {e}")); + if self.enable_toasts { + push_toast( + &mut self.toasts, + Toast { + text: format!("Failed: {e}").into(), + kind: ToastKind::Error, + options: ToastOptions::default() + .duration_in_seconds(self.toast_duration as f64), + }, + ); + } + } else { + if a.desc == "Fav" && !a.action.starts_with("fav:") { + tracing::info!(fav=%a.label, command=%a.action, "ran favorite"); + } + if self.enable_toasts && a.action != "recycle:clean" { + let msg = if a.action.starts_with("clipboard:") { + format!("Copied {}", a.label) + } else { + format!("Launched {}", a.label) + }; + push_toast( + &mut self.toasts, + Toast { + text: msg.into(), + kind: ToastKind::Success, + options: ToastOptions::default() + .duration_in_seconds(self.toast_duration as f64), + }, + ); + } + if a.action != "help:show" { + let _ = history::append_history( + HistoryEntry { + query: current.clone(), + query_lc: String::new(), + action: a.clone(), + }, + self.history_limit, + ); + let count = self.usage.entry(a.action.clone()).or_insert(0); + *count += 1; + } + if a.action == "note:reload" { + refresh = true; + set_focus = true; + if self.enable_toasts { + push_toast( + &mut self.toasts, + Toast { + text: "Reloaded notes".into(), + kind: ToastKind::Success, + options: ToastOptions::default() + .duration_in_seconds(self.toast_duration as f64), + }, + ); + } + } else if a.action.starts_with("bookmark:add:") { + if self.preserve_command { + self.query = "bm add ".into(); + } else { + self.query.clear(); + } + command_changed_query = true; + refresh = true; + set_focus = true; + } else if a.action.starts_with("bookmark:remove:") { + refresh = true; + set_focus = true; + } else if a.action.starts_with("folder:add:") { + if self.preserve_command { + self.query = "f add ".into(); + } else { + self.query.clear(); + } + command_changed_query = true; + refresh = true; + set_focus = true; + } else if a.action.starts_with("folder:remove:") { + refresh = true; + set_focus = true; + } else if a.action.starts_with("fav:add:") { + refresh = true; + set_focus = true; + } else if a.action.starts_with("fav:remove:") { + refresh = true; + set_focus = true; + } else if a.action.starts_with("todo:add:") { + if self.preserve_command { + self.query = "todo add ".into(); + } else { + self.query.clear(); + } + command_changed_query = true; + refresh = true; + set_focus = true; + if self.enable_toasts { + if let Some(text) = a + .action + .strip_prefix("todo:add:") + .and_then(|r| r.split('|').next()) + { + push_toast( + &mut self.toasts, + Toast { + text: format!("Added todo {text}").into(), + kind: ToastKind::Success, + options: ToastOptions::default() + .duration_in_seconds(self.toast_duration as f64), + }, + ); + } + } + } else if a.action.starts_with("todo:remove:") { + refresh = true; + set_focus = true; + if current.starts_with("note list") { + self.pending_query = Some(current.clone()); + command_changed_query = true; + } + if self.enable_toasts { + let label = a.label.strip_prefix("Remove todo ").unwrap_or(&a.label); + push_toast( + &mut self.toasts, + Toast { + text: format!("Removed todo {label}").into(), + kind: ToastKind::Success, + options: ToastOptions::default() + .duration_in_seconds(self.toast_duration as f64), + }, + ); + } + } else if a.action.starts_with("todo:done:") { + refresh = true; + set_focus = true; + self.pending_query = Some(current.clone()); + command_changed_query = true; + if self.enable_toasts { + let label = a + .label + .trim_start_matches("[x] ") + .trim_start_matches("[ ] "); + push_toast( + &mut self.toasts, + Toast { + text: format!("Toggled todo {label}").into(), + kind: ToastKind::Success, + options: ToastOptions::default() + .duration_in_seconds(self.toast_duration as f64), + }, + ); + } + } else if a.action.starts_with("todo:pset:") { + refresh = true; + set_focus = true; + if self.enable_toasts { + push_toast( + &mut self.toasts, + Toast { + text: "Updated todo priority".into(), + kind: ToastKind::Success, + options: ToastOptions::default() + .duration_in_seconds(self.toast_duration as f64), + }, + ); + } + } else if a.action.starts_with("todo:tag:") { + refresh = true; + set_focus = true; + if self.enable_toasts { + push_toast( + &mut self.toasts, + Toast { + text: "Updated todo tags".into(), + kind: ToastKind::Success, + options: ToastOptions::default() + .duration_in_seconds(self.toast_duration as f64), + }, + ); + } + } else if a.action == "todo:clear" { + refresh = true; + set_focus = true; + if self.enable_toasts { + push_toast( + &mut self.toasts, + Toast { + text: "Cleared completed todos".into(), + kind: ToastKind::Success, + options: ToastOptions::default() + .duration_in_seconds(self.toast_duration as f64), + }, + ); + } + } else if a.action.starts_with("snippet:remove:") { + refresh = true; + set_focus = true; + if self.enable_toasts { + push_toast( + &mut self.toasts, + Toast { + text: format!("Removed snippet {}", a.label).into(), + kind: ToastKind::Success, + options: ToastOptions::default() + .duration_in_seconds(self.toast_duration as f64), + }, + ); + } + } else if a.action.starts_with("tempfile:remove:") { + refresh = true; + set_focus = true; + } else if a.action.starts_with("tempfile:alias:") { + refresh = true; + set_focus = true; + } else if a.action == "tempfile:new" || a.action.starts_with("tempfile:new:") { + if self.preserve_command { + self.query = "tmp new ".into(); + } else { + self.query.clear(); + } + command_changed_query = true; + set_focus = true; + } else if a.action.starts_with("timer:cancel:") && current.starts_with("timer rm") { + refresh = true; + set_focus = true; + } else if a.action.starts_with("timer:pause:") && current.starts_with("timer pause") { + refresh = true; + set_focus = true; + } else if a.action.starts_with("timer:resume:") && current.starts_with("timer resume") { + refresh = true; + set_focus = true; + } else if a.action.starts_with("timer:start:") && current.starts_with("timer add") { + if self.preserve_command { + self.query = "timer add ".into(); + } else { + self.query.clear(); + } + command_changed_query = true; + set_focus = true; + } + if self.clear_query_after_run && !command_changed_query { + self.query.clear(); + refresh = true; + set_focus = true; + } + if self.hide_after_run + && !a.action.starts_with("bookmark:add:") + && !a.action.starts_with("bookmark:remove:") + && !a.action.starts_with("folder:add:") + && !a.action.starts_with("folder:remove:") + && !a.action.starts_with("snippet:remove:") + && !a.action.starts_with("fav:add:") + && !a.action.starts_with("fav:remove:") + && !a.action.starts_with("screenshot:") + && !a.action.starts_with("calc:") + && !a.action.starts_with("todo:done:") + { + self.visible_flag.store(false, Ordering::SeqCst); + } + } + if refresh { + self.last_results_valid = false; + self.search(); + } + if set_focus { + self.focus_input(); + } else if self.visible_flag.load(Ordering::SeqCst) && !self.any_panel_open() { + self.focus_input(); + } + } + fn any_panel_open(&self) -> bool { self.alias_dialog.open || self.bookmark_alias_dialog.open @@ -2040,6 +2507,23 @@ impl eframe::App for LauncherApp { .map(|b| (b.url, b.alias)) .collect(); } + WatchEvent::Dashboard(_) => { + self.dashboard.reload(); + for warn in &self.dashboard.warnings { + tracing::warn!("dashboard: {}", warn); + if self.enable_toasts { + push_toast( + &mut self.toasts, + Toast { + text: warn.clone().into(), + kind: ToastKind::Warning, + options: ToastOptions::default() + .duration_in_seconds(self.toast_duration as f64), + }, + ); + } + } + } WatchEvent::Recycle(res) => match res { Ok(()) => { if self.enable_toasts { @@ -2206,520 +2690,94 @@ impl eframe::App for LauncherApp { if let Some(i) = launch_idx { if let Some(a) = self.results.get(i) { let a = a.clone(); - let current = self.query.clone(); - let mut refresh = false; - let mut set_focus = false; - let mut command_changed_query = false; - if let Some(new_q) = a.action.strip_prefix("query:") { - tracing::debug!("query action via Enter: {new_q}"); - self.query = new_q.to_string(); - self.last_timer_query = new_q.starts_with("timer list") - || new_q.starts_with("alarm list"); - self.search(); - set_focus = true; - tracing::debug!("move_cursor_end set via Enter key"); - self.move_cursor_end = true; - } else if a.action == "help:show" { - self.help_window.open = true; - } else if a.action == "timer:dialog:timer" { - self.timer_dialog.open_timer(); - } else if a.action == "timer:dialog:alarm" { - self.timer_dialog.open_alarm(); - } else if a.action == "shell:dialog" { - self.shell_cmd_dialog.open(); - } else if a.action == "note:dialog" { - self.notes_dialog.open(); - } else if a.action == "note:unused_assets" { - self.unused_assets_dialog.open(); - } else if a.action == "bookmark:dialog" { - self.add_bookmark_dialog.open(); - } else if a.action == "snippet:dialog" { - self.snippet_dialog.open(); - } else if let Some(alias) = a.action.strip_prefix("snippet:edit:") { - self.snippet_dialog.open_edit(alias); - } else if a.action == "macro:dialog" { - self.macro_dialog.open(); - } else if let Some(label) = a.action.strip_prefix("fav:dialog:") { - if label.is_empty() { - self.fav_dialog.open(); - } else { - self.fav_dialog.open_edit(label); - } - } else if a.action == "todo:dialog" { - self.todo_dialog.open(); - } else if a.action == "todo:view" { - self.todo_view_dialog.open(); - } else if let Some(idx) = a.action.strip_prefix("todo:edit:") { - if let Ok(i) = idx.parse::() { - self.todo_view_dialog.open_edit(i); - } - } else if a.action == "clipboard:dialog" { - self.clipboard_dialog.open(); - } else if let Some(slug) = a.action.strip_prefix("note:open:") { - let slug = slug.to_string(); - self.open_note_panel(&slug, None); - } else if let Some(rest) = a.action.strip_prefix("note:new:") { - let mut parts = rest.splitn(2, ':'); - let slug = parts.next().unwrap_or("").to_string(); - let template = parts.next().map(|s| s.to_string()); - self.open_note_panel(&slug, template.as_deref()); - } else if a.action == "note:tags" { - self.open_note_tags(); - set_focus = true; - } else if let Some(link) = a.action.strip_prefix("note:link:") { - self.open_note_link(link); - } else if let Some(slug) = a.action.strip_prefix("note:remove:") { - self.delete_note(slug); - } else if a.action == "convert:panel" { - self.convert_panel.open(); - } else if a.action == "tempfile:dialog" { - self.tempfile_dialog.open(); - } else if a.action == "settings:dialog" { - self.show_settings = true; - } else if a.action == "volume:dialog" { - self.volume_dialog.open(); - } else if a.action == "brightness:dialog" { - self.brightness_dialog.open(); - } else if let Some(n) = a.action.strip_prefix("sysinfo:cpu_list:") { - if let Ok(count) = n.parse::() { - self.cpu_list_dialog.open(count); - } - } else if a.action.starts_with("tab:switch:") { - if self.enable_toasts { - push_toast( - &mut self.toasts, - Toast { - text: format!("Switching to {}", a.label).into(), - kind: ToastKind::Info, - options: ToastOptions::default() - .duration_in_seconds(self.toast_duration as f64), - }, - ); - } - let act = a.clone(); - std::thread::spawn(move || { - if let Err(e) = launch_action(&act) { - tracing::error!(?e, "failed to switch tab"); - } - }); - if a.action != "help:show" { - let _ = history::append_history( - HistoryEntry { - query: current.clone(), - query_lc: String::new(), - action: a.clone(), - }, - self.history_limit, - ); - let count = self.usage.entry(a.action.clone()).or_insert(0); - *count += 1; - } - } else if let Some(mode) = a.action.strip_prefix("screenshot:") { - use crate::actions::screenshot::Mode as ScreenshotMode; - let (mode, clip) = match mode { - "window" => (ScreenshotMode::Window, false), - "region" => (ScreenshotMode::Region, false), - "desktop" => (ScreenshotMode::Desktop, false), - "window_clip" => (ScreenshotMode::Window, true), - "region_clip" => (ScreenshotMode::Region, true), - "desktop_clip" => (ScreenshotMode::Desktop, true), - _ => (ScreenshotMode::Desktop, false), - }; - if let Err(e) = crate::plugins::screenshot::launch_editor(self, mode, clip) - { - self.set_error(format!("Failed: {e}")); - } else if a.action != "help:show" { - let _ = history::append_history( - HistoryEntry { - query: current.clone(), - query_lc: String::new(), - action: a.clone(), - }, - self.history_limit, - ); - let count = self.usage.entry(a.action.clone()).or_insert(0); - *count += 1; - } - } else if let Err(e) = launch_action(&a) { - if a.desc == "Fav" && !a.action.starts_with("fav:") { - tracing::error!(?e, fav=%a.label, "failed to run favorite"); - } - self.set_error(format!("Failed: {e}")); - if self.enable_toasts { - push_toast(&mut self.toasts, Toast { - text: format!("Failed: {e}").into(), - kind: ToastKind::Error, - options: ToastOptions::default().duration_in_seconds(self.toast_duration as f64), - }); - } - } else { - if a.desc == "Fav" && !a.action.starts_with("fav:") { - tracing::info!(fav=%a.label, command=%a.action, "ran favorite"); - } - if self.enable_toasts && a.action != "recycle:clean" { - let msg = if a.action.starts_with("clipboard:") { - format!("Copied {}", a.label) - } else { - format!("Launched {}", a.label) - }; - push_toast(&mut self.toasts, Toast { - text: msg.into(), - kind: ToastKind::Success, - options: ToastOptions::default().duration_in_seconds(self.toast_duration as f64), - }); - } - if a.action != "help:show" { - let _ = history::append_history( - HistoryEntry { - query: current.clone(), - query_lc: String::new(), - action: a.clone(), - }, - self.history_limit, - ); - let count = self.usage.entry(a.action.clone()).or_insert(0); - *count += 1; - } - if a.action == "note:reload" { - refresh = true; - set_focus = true; - if self.enable_toasts { - push_toast( - &mut self.toasts, - Toast { - text: "Reloaded notes".into(), - kind: ToastKind::Success, - options: ToastOptions::default() - .duration_in_seconds(self.toast_duration as f64), - }, - ); - } - } else if a.action.starts_with("bookmark:add:") { - if self.preserve_command { - self.query = "bm add ".into(); - } else { - self.query.clear(); - } - command_changed_query = true; - refresh = true; - set_focus = true; - } else if a.action.starts_with("bookmark:remove:") { - refresh = true; - set_focus = true; - } else if a.action.starts_with("folder:add:") { - if self.preserve_command { - self.query = "f add ".into(); - } else { - self.query.clear(); - } - command_changed_query = true; - refresh = true; - set_focus = true; - } else if a.action.starts_with("folder:remove:") { - refresh = true; - set_focus = true; - } else if a.action.starts_with("fav:add:") { - refresh = true; - set_focus = true; - } else if a.action.starts_with("fav:remove:") { - refresh = true; - set_focus = true; - } else if a.action.starts_with("todo:add:") { - if self.preserve_command { - self.query = "todo add ".into(); - } else { - self.query.clear(); - } - command_changed_query = true; - refresh = true; - set_focus = true; - if self.enable_toasts { - if let Some(text) = a - .action - .strip_prefix("todo:add:") - .and_then(|r| r.split('|').next()) - { - push_toast(&mut self.toasts, Toast { - text: format!("Added todo {text}").into(), - kind: ToastKind::Success, - options: ToastOptions::default().duration_in_seconds(self.toast_duration as f64), - }); - } - } - } else if a.action.starts_with("todo:remove:") { - refresh = true; - set_focus = true; - if current.starts_with("note list") { - self.pending_query = Some(current.clone()); - command_changed_query = true; - } - if self.enable_toasts { - let label = - a.label.strip_prefix("Remove todo ").unwrap_or(&a.label); - push_toast(&mut self.toasts, Toast { - text: format!("Removed todo {label}").into(), - kind: ToastKind::Success, - options: ToastOptions::default().duration_in_seconds(self.toast_duration as f64), - }); - } - } else if a.action.starts_with("todo:done:") { - refresh = true; - set_focus = true; - // Re-run the current query so the todo list reflects the - // updated completion state immediately. - self.pending_query = Some(current.clone()); - command_changed_query = true; - if self.enable_toasts { - let label = a - .label - .trim_start_matches("[x] ") - .trim_start_matches("[ ] "); - push_toast(&mut self.toasts, Toast { - text: format!("Toggled todo {label}").into(), - kind: ToastKind::Success, - options: ToastOptions::default().duration_in_seconds(self.toast_duration as f64), - }); - } - } else if a.action.starts_with("todo:pset:") { - refresh = true; - set_focus = true; - if self.enable_toasts { - push_toast(&mut self.toasts, Toast { - text: "Updated todo priority".into(), - kind: ToastKind::Success, - options: ToastOptions::default().duration_in_seconds(self.toast_duration as f64), - }); - } - } else if a.action.starts_with("todo:tag:") { - refresh = true; - set_focus = true; - if self.enable_toasts { - push_toast(&mut self.toasts, Toast { - text: "Updated todo tags".into(), - kind: ToastKind::Success, - options: ToastOptions::default().duration_in_seconds(self.toast_duration as f64), - }); - } - } else if a.action == "todo:clear" { - refresh = true; - set_focus = true; - if self.enable_toasts { - push_toast(&mut self.toasts, Toast { - text: "Cleared completed todos".into(), - kind: ToastKind::Success, - options: ToastOptions::default().duration_in_seconds(self.toast_duration as f64), - }); - } - } else if a.action.starts_with("snippet:remove:") { - refresh = true; - set_focus = true; - if self.enable_toasts { - push_toast(&mut self.toasts, Toast { - text: format!("Removed snippet {}", a.label).into(), - kind: ToastKind::Success, - options: ToastOptions::default().duration_in_seconds(self.toast_duration as f64), - }); - } - } else if a.action.starts_with("tempfile:remove:") { - refresh = true; - set_focus = true; - } else if a.action.starts_with("tempfile:alias:") { - refresh = true; - set_focus = true; - } else if a.action == "tempfile:new" - || a.action.starts_with("tempfile:new:") - { - if self.preserve_command { - self.query = "tmp new ".into(); - } else { - self.query.clear(); - } - command_changed_query = true; - set_focus = true; - } else if a.action.starts_with("timer:cancel:") - && current.starts_with("timer rm") - { - refresh = true; - set_focus = true; - } else if a.action.starts_with("timer:pause:") - && current.starts_with("timer pause") - { - refresh = true; - set_focus = true; - } else if a.action.starts_with("timer:resume:") - && current.starts_with("timer resume") - { - refresh = true; - set_focus = true; - } else if a.action.starts_with("timer:start:") - && current.starts_with("timer add") - { - if self.preserve_command { - self.query = "timer add ".into(); - } else { - self.query.clear(); - } - command_changed_query = true; - set_focus = true; - } - if self.clear_query_after_run && !command_changed_query { - self.query.clear(); - refresh = true; - set_focus = true; - } - if self.hide_after_run - && !a.action.starts_with("bookmark:add:") - && !a.action.starts_with("bookmark:remove:") - && !a.action.starts_with("folder:add:") - && !a.action.starts_with("folder:remove:") - && !a.action.starts_with("snippet:remove:") - && !a.action.starts_with("fav:add:") - && !a.action.starts_with("fav:remove:") - && !a.action.starts_with("screenshot:") - && !a.action.starts_with("calc:") - && !a.action.starts_with("todo:done:") - { - self.visible_flag.store(false, Ordering::SeqCst); - } - } - if refresh { - // Ensure file removals update the visible results list - self.last_results_valid = false; - self.search(); - } - if set_focus { - self.focus_input(); - } else if self.visible_flag.load(Ordering::SeqCst) && !self.any_panel_open() - { - self.focus_input(); - } + self.activate_action(a, None, ActivationSource::Enter); } } }); - let area_height = ui.available_height(); - ScrollArea::vertical() - .max_height(area_height) - .show(ui, |ui| { - scale_ui(ui, self.list_scale, |ui| { - let mut refresh = false; - let mut set_focus = false; - let mut clicked_query: Option = None; - let show_full = self - .enabled_capabilities - .as_ref() - .and_then(|m| m.get("folders")) - .map(|caps| caps.contains(&"show_full_path".to_string())) - .unwrap_or(false); - for idx in 0..self.results.len() { - let a = self.results[idx].clone(); - let aliased = self - .folder_aliases - .get(&a.action) - .and_then(|v| v.as_ref()); - let show_path = show_full || aliased.is_none(); - let text = if show_path { - format!("{} : {}", a.label, a.desc) - } else { - a.label.clone() - }; - let mut resp = ui.add_sized( - [ui.available_width(), 0.0], - egui::SelectableLabel::new(self.selected == Some(idx), text), - ); - let tooltip = if a.desc == "Timer" - && a.action.starts_with("timer:show:") - { - if let Ok(id) = a.action[11..].parse::() { - if let Some(ts) = crate::plugins::timer::timer_start_ts(id) { - format!("Started {}", crate::plugins::timer::format_ts(ts)) + let use_dashboard = self.should_show_dashboard(&trimmed); + if use_dashboard { + let ctx = DashboardContext { + actions: &self.actions, + usage: &self.usage, + plugins: &self.plugins, + default_location: self.dashboard_default_location.as_deref(), + }; + if let Some(action) = self.dashboard.ui(ui, &ctx, WidgetActivation::Click) { + self.activate_action(action.action, action.query_override, ActivationSource::Dashboard); + } + } else { + let area_height = ui.available_height(); + ScrollArea::vertical() + .max_height(area_height) + .show(ui, |ui| { + scale_ui(ui, self.list_scale, |ui| { + let mut refresh = false; + let mut set_focus = false; + let show_full = self + .enabled_capabilities + .as_ref() + .and_then(|m| m.get("folders")) + .map(|caps| caps.contains(&"show_full_path".to_string())) + .unwrap_or(false); + for idx in 0..self.results.len() { + let a = self.results[idx].clone(); + let aliased = self + .folder_aliases + .get(&a.action) + .and_then(|v| v.as_ref()); + let show_path = show_full || aliased.is_none(); + let text = if show_path { + format!("{} : {}", a.label, a.desc) + } else { + a.label.clone() + }; + let mut resp = ui.add_sized( + [ui.available_width(), 0.0], + egui::SelectableLabel::new(self.selected == Some(idx), text), + ); + let tooltip = if a.desc == "Timer" + && a.action.starts_with("timer:show:") + { + if let Ok(id) = a.action[11..].parse::() { + if let Some(ts) = crate::plugins::timer::timer_start_ts(id) { + format!("Started {}", crate::plugins::timer::format_ts(ts)) + } else { + a.action.clone() + } } else { a.action.clone() } } else { a.action.clone() - } - } else { - a.action.clone() - }; - let menu_resp = resp.on_hover_text(tooltip); - let custom_idx = self - .actions - .iter() - .take(self.custom_len) - .position(|act| act.action == a.action && act.label == a.label); - if self.folder_aliases.contains_key(&a.action) - && !a.action.starts_with("folder:") - { - menu_resp.clone().context_menu(|ui| { - if ui.button("Set Alias").clicked() { - self.alias_dialog.open(&a.action); - ui.close_menu(); - } - if ui.button("Remove Folder").clicked() { - if let Err(e) = crate::plugins::folders::remove_folder( - crate::plugins::folders::FOLDERS_FILE, - &a.action, - ) { - self.error = - Some(format!("Failed to remove folder: {e}")); - } else { - refresh = true; - set_focus = true; - if self.enable_toasts { - push_toast(&mut self.toasts, Toast { - text: format!("Removed folder {}", a.label) - .into(), - kind: ToastKind::Success, - options: ToastOptions::default() - .duration_in_seconds(self.toast_duration as f64), - }); - } - } - ui.close_menu(); - } - }); - } else if self.bookmark_aliases.contains_key(&a.action) { - menu_resp.clone().context_menu(|ui| { - if ui.button("Set Alias").clicked() { - self.bookmark_alias_dialog.open(&a.action); - ui.close_menu(); - } - if ui.button("Remove Bookmark").clicked() { - if let Err(e) = crate::plugins::bookmarks::remove_bookmark( - crate::plugins::bookmarks::BOOKMARKS_FILE, - &a.action, - ) { - self.error = - Some(format!("Failed to remove bookmark: {e}")); - } else { - refresh = true; - set_focus = true; - if self.enable_toasts { - push_toast(&mut self.toasts, Toast { - text: format!("Removed bookmark {}", a.label) - .into(), - kind: ToastKind::Success, - options: ToastOptions::default() - .duration_in_seconds(self.toast_duration as f64), - }); - } - } - ui.close_menu(); - } - }); - } else if a.desc == "Timer" && a.action.starts_with("timer:show:") { - if let Ok(id) = a.action[11..].parse::() { - let query = self.query.trim().to_string(); + }; + let menu_resp = resp.on_hover_text(tooltip); + let custom_idx = self + .actions + .iter() + .take(self.custom_len) + .position(|act| act.action == a.action && act.label == a.label); + if self.folder_aliases.contains_key(&a.action) + && !a.action.starts_with("folder:") + { menu_resp.clone().context_menu(|ui| { - if ui.button("Pause Timer").clicked() { - crate::plugins::timer::pause_timer(id); - if query.starts_with("timer list") { + if ui.button("Set Alias").clicked() { + self.alias_dialog.open(&a.action); + ui.close_menu(); + } + if ui.button("Remove Folder").clicked() { + if let Err(e) = crate::plugins::folders::remove_folder( + crate::plugins::folders::FOLDERS_FILE, + &a.action, + ) { + self.error = + Some(format!("Failed to remove folder: {e}")); + } else { refresh = true; set_focus = true; if self.enable_toasts { push_toast(&mut self.toasts, Toast { - text: format!("Paused timer {}", a.label) + text: format!("Removed folder {}", a.label) .into(), kind: ToastKind::Success, options: ToastOptions::default() @@ -2729,14 +2787,26 @@ impl eframe::App for LauncherApp { } ui.close_menu(); } - if ui.button("Remove Timer").clicked() { - crate::plugins::timer::cancel_timer(id); - if query.starts_with("timer list") { + }); + } else if self.bookmark_aliases.contains_key(&a.action) { + menu_resp.clone().context_menu(|ui| { + if ui.button("Set Alias").clicked() { + self.bookmark_alias_dialog.open(&a.action); + ui.close_menu(); + } + if ui.button("Remove Bookmark").clicked() { + if let Err(e) = crate::plugins::bookmarks::remove_bookmark( + crate::plugins::bookmarks::BOOKMARKS_FILE, + &a.action, + ) { + self.error = + Some(format!("Failed to remove bookmark: {e}")); + } else { refresh = true; set_focus = true; if self.enable_toasts { push_toast(&mut self.toasts, Toast { - text: format!("Removed timer {}", a.label) + text: format!("Removed bookmark {}", a.label) .into(), kind: ToastKind::Success, options: ToastOptions::default() @@ -2747,35 +2817,137 @@ impl eframe::App for LauncherApp { ui.close_menu(); } }); - } - } else if a.desc == "Stopwatch" && a.action.starts_with("stopwatch:show:") { - if let Ok(id) = a.action["stopwatch:show:".len()..].parse::() { - let query = self.query.trim().to_string(); - menu_resp.clone().context_menu(|ui| { - if ui.button("Pause Stopwatch").clicked() { - crate::plugins::stopwatch::pause_stopwatch(id); - if query.starts_with("sw list") { - refresh = true; - set_focus = true; - if self.enable_toasts { - push_toast(&mut self.toasts, Toast { - text: format!("Paused stopwatch {}", a.label).into(), - kind: ToastKind::Success, - options: ToastOptions::default() - .duration_in_seconds(self.toast_duration as f64), - }); + } else if a.desc == "Timer" && a.action.starts_with("timer:show:") { + if let Ok(id) = a.action[11..].parse::() { + let query = self.query.trim().to_string(); + menu_resp.clone().context_menu(|ui| { + if ui.button("Pause Timer").clicked() { + crate::plugins::timer::pause_timer(id); + if query.starts_with("timer list") { + refresh = true; + set_focus = true; + if self.enable_toasts { + push_toast(&mut self.toasts, Toast { + text: format!("Paused timer {}", a.label) + .into(), + kind: ToastKind::Success, + options: ToastOptions::default() + .duration_in_seconds(self.toast_duration as f64), + }); + } + } + ui.close_menu(); + } + if ui.button("Remove Timer").clicked() { + crate::plugins::timer::cancel_timer(id); + if query.starts_with("timer list") { + refresh = true; + set_focus = true; + if self.enable_toasts { + push_toast(&mut self.toasts, Toast { + text: format!("Removed timer {}", a.label) + .into(), + kind: ToastKind::Success, + options: ToastOptions::default() + .duration_in_seconds(self.toast_duration as f64), + }); + } + } + ui.close_menu(); + } + }); + } + } else if a.desc == "Stopwatch" && a.action.starts_with("stopwatch:show:") { + if let Ok(id) = a.action["stopwatch:show:".len()..].parse::() { + let query = self.query.trim().to_string(); + menu_resp.clone().context_menu(|ui| { + if ui.button("Pause Stopwatch").clicked() { + crate::plugins::stopwatch::pause_stopwatch(id); + if query.starts_with("sw list") { + refresh = true; + set_focus = true; + if self.enable_toasts { + push_toast(&mut self.toasts, Toast { + text: format!("Paused stopwatch {}", a.label).into(), + kind: ToastKind::Success, + options: ToastOptions::default() + .duration_in_seconds(self.toast_duration as f64), + }); + } + } + ui.close_menu(); + } + if ui.button("Resume Stopwatch").clicked() { + crate::plugins::stopwatch::resume_stopwatch(id); + if query.starts_with("sw list") { + refresh = true; + set_focus = true; + if self.enable_toasts { + push_toast(&mut self.toasts, Toast { + text: format!("Resumed stopwatch {}", a.label).into(), + kind: ToastKind::Success, + options: ToastOptions::default() + .duration_in_seconds(self.toast_duration as f64), + }); + } + } + ui.close_menu(); + } + if ui.button("Stop Stopwatch").clicked() { + crate::plugins::stopwatch::stop_stopwatch(id); + if query.starts_with("sw list") { + refresh = true; + set_focus = true; + if self.enable_toasts { + push_toast(&mut self.toasts, Toast { + text: format!("Stopped stopwatch {}", a.label).into(), + kind: ToastKind::Success, + options: ToastOptions::default() + .duration_in_seconds(self.toast_duration as f64), + }); + } + } + ui.close_menu(); + } + if ui.button("Copy Time").clicked() { + if let Some(time) = + crate::plugins::stopwatch::format_elapsed(id) + { + if let Err(e) = + crate::actions::clipboard::set_text(&time) + { + self.error = + Some(format!("Failed to copy time: {e}")); + } else if self.enable_toasts { + push_toast(&mut self.toasts, Toast { + text: format!("Copied {time}").into(), + kind: ToastKind::Success, + options: ToastOptions::default() + .duration_in_seconds(self.toast_duration as f64), + }); + } } + ui.close_menu(); } + }); + } + } else if a.desc == "Snippet" { + menu_resp.clone().context_menu(|ui| { + if ui.button("Edit Snippet").clicked() { + self.snippet_dialog.open_edit(&a.label); ui.close_menu(); } - if ui.button("Resume Stopwatch").clicked() { - crate::plugins::stopwatch::resume_stopwatch(id); - if query.starts_with("sw list") { + if ui.button("Remove Snippet").clicked() { + if let Err(e) = remove_snippet(SNIPPETS_FILE, &a.label) { + self.error = + Some(format!("Failed to remove snippet: {e}")); + } else { refresh = true; set_focus = true; if self.enable_toasts { push_toast(&mut self.toasts, Toast { - text: format!("Resumed stopwatch {}", a.label).into(), + text: format!("Removed snippet {}", a.label) + .into(), kind: ToastKind::Success, options: ToastOptions::default() .duration_in_seconds(self.toast_duration as f64), @@ -2784,14 +2956,27 @@ impl eframe::App for LauncherApp { } ui.close_menu(); } - if ui.button("Stop Stopwatch").clicked() { - crate::plugins::stopwatch::stop_stopwatch(id); - if query.starts_with("sw list") { + }); + } else if a.desc == "Tempfile" && !a.action.starts_with("tempfile:") { + let file_path = a.action.clone(); + menu_resp.clone().context_menu(|ui| { + if ui.button("Set Alias").clicked() { + self.tempfile_alias_dialog.open(&file_path); + ui.close_menu(); + } + if ui.button("Delete File").clicked() { + if let Err(e) = crate::plugins::tempfile::remove_file( + std::path::Path::new(&file_path), + ) { + self.error = + Some(format!("Failed to delete file: {e}")); + } else { refresh = true; set_focus = true; if self.enable_toasts { push_toast(&mut self.toasts, Toast { - text: format!("Stopped stopwatch {}", a.label).into(), + text: format!("Removed file {}", a.label) + .into(), kind: ToastKind::Success, options: ToastOptions::default() .duration_in_seconds(self.toast_duration as f64), @@ -2800,523 +2985,134 @@ impl eframe::App for LauncherApp { } ui.close_menu(); } - if ui.button("Copy Time").clicked() { - if let Some(time) = - crate::plugins::stopwatch::format_elapsed(id) - { - if let Err(e) = - crate::actions::clipboard::set_text(&time) - { - self.error = - Some(format!("Failed to copy time: {e}")); - } else if self.enable_toasts { - push_toast(&mut self.toasts, Toast { - text: format!("Copied {time}").into(), - kind: ToastKind::Success, - options: ToastOptions::default() - .duration_in_seconds(self.toast_duration as f64), - }); + }); + } else if a.desc == "Note" + && a.action.starts_with("note:open:") + { + let slug = a.action.rsplit(':').next().unwrap_or("").to_string(); + menu_resp.clone().context_menu(|ui| { + if ui.button("Edit Note").clicked() { + self.open_note_panel(&slug, None); + ui.close_menu(); + } + if ui.button("Open in Notepad").clicked() { + match crate::plugins::note::load_notes() { + Ok(notes) => { + if let Some(note) = + notes.iter().find(|n| n.slug == slug) + { + if let Err(e) = std::process::Command::new( + "notepad.exe", + ) + .arg(¬e.path) + .spawn() + { + self.error = Some(e.to_string()); + } + } else { + self.error = + Some("Note not found".to_string()); + } + } + Err(e) => { + self.error = Some(e.to_string()); } } ui.close_menu(); } - }); - } - } else if a.desc == "Snippet" { - menu_resp.clone().context_menu(|ui| { - if ui.button("Edit Snippet").clicked() { - self.snippet_dialog.open_edit(&a.label); - ui.close_menu(); - } - if ui.button("Remove Snippet").clicked() { - if let Err(e) = remove_snippet(SNIPPETS_FILE, &a.label) { - self.error = - Some(format!("Failed to remove snippet: {e}")); - } else { - refresh = true; - set_focus = true; - if self.enable_toasts { - push_toast(&mut self.toasts, Toast { - text: format!("Removed snippet {}", a.label) - .into(), - kind: ToastKind::Success, - options: ToastOptions::default() - .duration_in_seconds(self.toast_duration as f64), - }); + if ui.button("Open in Neovim").clicked() { + if self.open_note_in_neovim( + &slug, + crate::plugins::note::load_notes, + |path| spawn_external(path, NoteExternalOpen::Wezterm), + ) { + ui.close_menu(); } } - ui.close_menu(); - } - }); - } else if a.desc == "Tempfile" && !a.action.starts_with("tempfile:") { - let file_path = a.action.clone(); - menu_resp.clone().context_menu(|ui| { - if ui.button("Set Alias").clicked() { - self.tempfile_alias_dialog.open(&file_path); - ui.close_menu(); - } - if ui.button("Delete File").clicked() { - if let Err(e) = crate::plugins::tempfile::remove_file( - std::path::Path::new(&file_path), - ) { - self.error = - Some(format!("Failed to delete file: {e}")); - } else { + if ui.button("Remove Note").clicked() { + self.delete_note(&slug); refresh = true; set_focus = true; - if self.enable_toasts { - push_toast(&mut self.toasts, Toast { - text: format!("Removed file {}", a.label) - .into(), - kind: ToastKind::Success, - options: ToastOptions::default() - .duration_in_seconds(self.toast_duration as f64), - }); - } + ui.close_menu(); } - ui.close_menu(); - } - }); - } else if a.desc == "Note" - && a.action.starts_with("note:open:") - { - let slug = a.action.rsplit(':').next().unwrap_or("").to_string(); - menu_resp.clone().context_menu(|ui| { - if ui.button("Edit Note").clicked() { - self.open_note_panel(&slug, None); - ui.close_menu(); - } - if ui.button("Open in Notepad").clicked() { - match crate::plugins::note::load_notes() { - Ok(notes) => { - if let Some(note) = - notes.iter().find(|n| n.slug == slug) - { - if let Err(e) = std::process::Command::new( - "notepad.exe", - ) - .arg(¬e.path) - .spawn() - { - self.error = Some(e.to_string()); - } - } else { + }); + } else if a.desc == "Clipboard" + && a.action.starts_with("clipboard:copy:") + { + let idx_str = a.action.rsplit(':').next().unwrap_or(""); + if let Ok(cb_idx) = idx_str.parse::() { + let cb_label = a.label.clone(); + menu_resp.clone().context_menu(|ui| { + if ui.button("Edit Entry").clicked() { + self.clipboard_dialog.open_edit(cb_idx); + ui.close_menu(); + } + if ui.button("Remove Entry").clicked() { + if let Err(e) = crate::plugins::clipboard::remove_entry( + crate::plugins::clipboard::CLIPBOARD_FILE, + cb_idx, + ) { self.error = - Some("Note not found".to_string()); + Some(format!("Failed to remove entry: {e}")); + } else { + refresh = true; + set_focus = true; + if self.enable_toasts { + push_toast(&mut self.toasts, Toast { + text: format!("Removed entry {}", cb_label) + .into(), + kind: ToastKind::Success, + options: ToastOptions::default() + .duration_in_seconds(self.toast_duration as f64), + }); + } } + ui.close_menu(); } - Err(e) => { - self.error = Some(e.to_string()); - } - } - ui.close_menu(); - } - if ui.button("Open in Neovim").clicked() { - if self.open_note_in_neovim( - &slug, - crate::plugins::note::load_notes, - |path| spawn_external(path, NoteExternalOpen::Wezterm), - ) { - ui.close_menu(); - } - } - if ui.button("Remove Note").clicked() { - self.delete_note(&slug); - refresh = true; - set_focus = true; - ui.close_menu(); + }); } - }); - } else if a.desc == "Clipboard" - && a.action.starts_with("clipboard:copy:") - { - let idx_str = a.action.rsplit(':').next().unwrap_or(""); - if let Ok(cb_idx) = idx_str.parse::() { - let cb_label = a.label.clone(); - menu_resp.clone().context_menu(|ui| { - if ui.button("Edit Entry").clicked() { - self.clipboard_dialog.open_edit(cb_idx); - ui.close_menu(); - } - if ui.button("Remove Entry").clicked() { - if let Err(e) = crate::plugins::clipboard::remove_entry( - crate::plugins::clipboard::CLIPBOARD_FILE, - cb_idx, - ) { - self.error = - Some(format!("Failed to remove entry: {e}")); - } else { - refresh = true; - set_focus = true; - if self.enable_toasts { - push_toast(&mut self.toasts, Toast { - text: format!("Removed entry {}", cb_label) - .into(), - kind: ToastKind::Success, - options: ToastOptions::default() - .duration_in_seconds(self.toast_duration as f64), - }); - } + } else if a.desc == "Todo" && a.action.starts_with("todo:done:") { + let idx_str = a.action.rsplit(':').next().unwrap_or(""); + if let Ok(todo_idx) = idx_str.parse::() { + menu_resp.clone().context_menu(|ui| { + if ui.button("Edit Todo").clicked() { + self.todo_view_dialog.open_edit(todo_idx); + ui.close_menu(); } - ui.close_menu(); - } - }); + }); + } } - } else if a.desc == "Todo" && a.action.starts_with("todo:done:") { - let idx_str = a.action.rsplit(':').next().unwrap_or(""); - if let Ok(todo_idx) = idx_str.parse::() { + if let Some(idx_act) = custom_idx { menu_resp.clone().context_menu(|ui| { - if ui.button("Edit Todo").clicked() { - self.todo_view_dialog.open_edit(todo_idx); + if ui.button("Edit App").clicked() { + self.editor.open_edit(idx_act, &self.actions[idx_act]); + self.show_editor = true; ui.close_menu(); } }); } - } - if let Some(idx_act) = custom_idx { - menu_resp.clone().context_menu(|ui| { - if ui.button("Edit App").clicked() { - self.editor.open_edit(idx_act, &self.actions[idx_act]); - self.show_editor = true; - ui.close_menu(); - } - }); - } - resp = menu_resp; - if self.selected == Some(idx) { - resp.scroll_to_me(Some(egui::Align::Center)); - } - if resp.clicked() { - let current = self.query.clone(); - let mut command_changed_query = false; - if let Some(new_q) = a.action.strip_prefix("query:") { - tracing::debug!("query action via click: {new_q}"); - clicked_query = Some(new_q.to_string()); - command_changed_query = true; - set_focus = true; - tracing::debug!("move_cursor_end set via mouse click"); - self.move_cursor_end = true; - } else if a.action == "help:show" { - self.help_window.open = true; - } else if a.action == "timer:dialog:timer" { - self.timer_dialog.open_timer(); - } else if a.action == "timer:dialog:alarm" { - self.timer_dialog.open_alarm(); - } else if a.action == "shell:dialog" { - self.shell_cmd_dialog.open(); - } else if a.action == "note:dialog" { - self.notes_dialog.open(); - } else if a.action == "note:unused_assets" { - self.unused_assets_dialog.open(); - } else if a.action == "bookmark:dialog" { - self.add_bookmark_dialog.open(); - } else if a.action == "snippet:dialog" { - self.snippet_dialog.open(); - } else if let Some(alias) = a.action.strip_prefix("snippet:edit:") { - self.snippet_dialog.open_edit(alias); - } else if a.action == "macro:dialog" { - self.macro_dialog.open(); - } else if let Some(label) = a.action.strip_prefix("fav:dialog:") { - if label.is_empty() { - self.fav_dialog.open(); - } else { - self.fav_dialog.open_edit(label); - } - } else if a.action == "todo:dialog" { - self.todo_dialog.open(); - } else if a.action == "todo:view" { - self.todo_view_dialog.open(); - } else if let Some(idx) = a.action.strip_prefix("todo:edit:") { - if let Ok(i) = idx.parse::() { - self.todo_view_dialog.open_edit(i); - } - } else if a.action == "clipboard:dialog" { - self.clipboard_dialog.open(); - } else if let Some(slug) = a.action.strip_prefix("note:open:") { - let slug = slug.to_string(); - self.open_note_panel(&slug, None); - } else if let Some(rest) = a.action.strip_prefix("note:new:") { - let mut parts = rest.splitn(2, ':'); - let slug = parts.next().unwrap_or("").to_string(); - let template = parts.next().map(|s| s.to_string()); - self.open_note_panel(&slug, template.as_deref()); - } else if a.action == "note:tags" { - self.open_note_tags(); - set_focus = true; - } else if let Some(link) = a.action.strip_prefix("note:link:") { - self.open_note_link(link); - } else if let Some(slug) = a.action.strip_prefix("note:remove:") { - self.delete_note(slug); - } else if a.action == "convert:panel" { - self.convert_panel.open(); - } else if a.action == "tempfile:dialog" { - self.tempfile_dialog.open(); - } else if a.action == "settings:dialog" { - self.show_settings = true; - } else if a.action == "volume:dialog" { - self.volume_dialog.open(); - } else if a.action == "brightness:dialog" { - self.brightness_dialog.open(); - } else if let Some(n) = a.action.strip_prefix("sysinfo:cpu_list:") { - if let Ok(count) = n.parse::() { - self.cpu_list_dialog.open(count); + resp = menu_resp; + if self.selected == Some(idx) { + resp.scroll_to_me(Some(egui::Align::Center)); } - } else if let Some(mode) = a.action.strip_prefix("screenshot:") { - use crate::actions::screenshot::Mode as ScreenshotMode; - let (mode, clip) = match mode { - "window" => (ScreenshotMode::Window, false), - "region" => (ScreenshotMode::Region, false), - "desktop" => (ScreenshotMode::Desktop, false), - "window_clip" => (ScreenshotMode::Window, true), - "region_clip" => (ScreenshotMode::Region, true), - "desktop_clip" => (ScreenshotMode::Desktop, true), - _ => (ScreenshotMode::Desktop, false), - }; - if let Err(e) = crate::plugins::screenshot::launch_editor(self, mode, clip) - { - self.set_error(format!("Failed: {e}")); - } else if a.action != "help:show" { - let _ = history::append_history( - HistoryEntry { - query: current.clone(), - query_lc: String::new(), - action: a.clone(), - }, - self.history_limit, - ); - let count = self.usage.entry(a.action.clone()).or_insert(0); - *count += 1; + if resp.clicked() { + self.selected = Some(idx); + self.activate_action(a.clone(), None, ActivationSource::Click); } - } else if let Err(e) = launch_action(&a) { - if a.desc == "Fav" && !a.action.starts_with("fav:") { - tracing::error!(?e, fav=%a.label, "failed to run favorite"); - } - self.error = Some(format!("Failed: {e}")); - self.error_time = Some(Instant::now()); - if self.enable_toasts { - push_toast(&mut self.toasts, Toast { - text: format!("Failed: {e}").into(), - kind: ToastKind::Error, - options: ToastOptions::default() - .duration_in_seconds(self.toast_duration as f64), - }); - } - } else { - if a.desc == "Fav" && !a.action.starts_with("fav:") { - tracing::info!(fav=%a.label, command=%a.action, "ran favorite"); - } - if self.enable_toasts && a.action != "recycle:clean" { - let msg = if a.action.starts_with("clipboard:") { - format!("Copied {}", a.label) - } else { - format!("Launched {}", a.label) - }; - push_toast(&mut self.toasts, Toast { - text: msg.into(), - kind: ToastKind::Success, - options: ToastOptions::default() - .duration_in_seconds(self.toast_duration as f64), - }); } - if a.action != "help:show" { - let _ = history::append_history( - HistoryEntry { - query: current.clone(), - query_lc: String::new(), - action: a.clone(), - }, - self.history_limit, - ); - let count = self.usage.entry(a.action.clone()).or_insert(0); - *count += 1; - } - if a.action.starts_with("bookmark:add:") { - if self.preserve_command { - self.query = "bm add ".into(); - } else { - self.query.clear(); - } - command_changed_query = true; - refresh = true; - set_focus = true; - } else if a.action.starts_with("bookmark:remove:") { - refresh = true; - set_focus = true; - } else if a.action.starts_with("folder:add:") { - if self.preserve_command { - self.query = "f add ".into(); - } else { - self.query.clear(); - } - command_changed_query = true; - refresh = true; - set_focus = true; - } else if a.action.starts_with("folder:remove:") { - refresh = true; - set_focus = true; - } else if a.action.starts_with("fav:add:") { - refresh = true; - set_focus = true; - } else if a.action.starts_with("fav:remove:") { - refresh = true; - set_focus = true; - } else if a.action.starts_with("todo:add:") { - if self.preserve_command { - self.query = "todo add ".into(); - } else { - self.query.clear(); - } - command_changed_query = true; - refresh = true; - set_focus = true; - if self.enable_toasts { - if let Some(text) = a - .action - .strip_prefix("todo:add:") - .and_then(|r| r.split('|').next()) - { - push_toast(&mut self.toasts, Toast { - text: format!("Added todo {text}").into(), - kind: ToastKind::Success, - options: ToastOptions::default().duration_in_seconds(self.toast_duration as f64), - }); - } - } - } else if a.action.starts_with("todo:remove:") { - refresh = true; - set_focus = true; - if current.starts_with("note list") { - clicked_query = Some(current.clone()); - command_changed_query = true; - } - if self.enable_toasts { - let label = a - .label - .strip_prefix("Remove todo ") - .unwrap_or(&a.label); - push_toast(&mut self.toasts, Toast { - text: format!("Removed todo {label}").into(), - kind: ToastKind::Success, - options: ToastOptions::default() - .duration_in_seconds(self.toast_duration as f64), - }); - } - } else if a.action.starts_with("todo:done:") { - refresh = true; - set_focus = true; - // Re-run the current query so the visible list refreshes - // with the toggled completion state. - clicked_query = Some(current.clone()); - command_changed_query = true; - if self.enable_toasts { - let label = a - .label - .trim_start_matches("[x] ") - .trim_start_matches("[ ] "); - push_toast(&mut self.toasts, Toast { - text: format!("Toggled todo {label}").into(), - kind: ToastKind::Success, - options: ToastOptions::default() - .duration_in_seconds(self.toast_duration as f64), - }); - } - } else if a.action.starts_with("todo:pset:") { - refresh = true; - set_focus = true; - if self.enable_toasts { - push_toast(&mut self.toasts, Toast { - text: "Updated todo priority".into(), - kind: ToastKind::Success, - options: ToastOptions::default().duration_in_seconds(self.toast_duration as f64), - }); - } - } else if a.action.starts_with("todo:tag:") { - refresh = true; - set_focus = true; - if self.enable_toasts { - push_toast(&mut self.toasts, Toast { - text: "Updated todo tags".into(), - kind: ToastKind::Success, - options: ToastOptions::default().duration_in_seconds(self.toast_duration as f64), - }); - } - } else if a.action == "todo:clear" { - refresh = true; - set_focus = true; - if self.enable_toasts { - push_toast(&mut self.toasts, Toast { - text: "Cleared completed todos".into(), - kind: ToastKind::Success, - options: ToastOptions::default() - .duration_in_seconds(self.toast_duration as f64), - }); - } - } else if a.action.starts_with("snippet:remove:") { - refresh = true; - set_focus = true; - if self.enable_toasts { - push_toast(&mut self.toasts, Toast { - text: format!("Removed snippet {}", a.label).into(), - kind: ToastKind::Success, - options: ToastOptions::default() - .duration_in_seconds(self.toast_duration as f64), - }); - } - } else if a.action.starts_with("tempfile:remove:") { - refresh = true; - set_focus = true; - } else if a.action.starts_with("tempfile:alias:") { - refresh = true; - set_focus = true; - } else if a.action == "tempfile:new" - || a.action.starts_with("tempfile:new:") - { - if self.preserve_command { - self.query = "tmp new ".into(); - } else { - self.query.clear(); - } - command_changed_query = true; - set_focus = true; - } - if self.clear_query_after_run && !command_changed_query { - self.query.clear(); - refresh = true; - set_focus = true; - } - if self.hide_after_run - && !a.action.starts_with("bookmark:add:") - && !a.action.starts_with("bookmark:remove:") - && !a.action.starts_with("folder:add:") - && !a.action.starts_with("folder:remove:") - && !a.action.starts_with("snippet:remove:") - && !a.action.starts_with("fav:add:") - && !a.action.starts_with("fav:remove:") - && !a.action.starts_with("calc:") - && !a.action.starts_with("todo:done:") - { - self.visible_flag.store(false, Ordering::SeqCst); - } - } - self.selected = Some(idx); + if refresh { + self.last_results_valid = false; + self.search(); } - } - if let Some(new_q) = clicked_query { - self.pending_query = Some(new_q); - } - if refresh { - // Ensure file removals update the visible results list - self.last_results_valid = false; - self.search(); - } - if set_focus { - self.focus_input(); - } else if self.visible_flag.load(Ordering::SeqCst) && !self.any_panel_open() - { - self.focus_input(); - } + if set_focus { + self.focus_input(); + } else if self.visible_flag.load(Ordering::SeqCst) && !self.any_panel_open() + { + self.focus_input(); + } + }); }); - }); + } }); let show_editor = self.show_editor; if show_editor { @@ -3336,6 +3132,20 @@ impl eframe::App for LauncherApp { ed.ui(ctx, self); self.plugin_editor = ed; } + if self.show_dashboard_editor && !self.dashboard_editor.open { + let registry = self.dashboard.registry().clone(); + self.dashboard_editor.open(&self.dashboard_path, ®istry); + } + if self.show_dashboard_editor { + let registry = self.dashboard.registry().clone(); + let mut dlg = std::mem::take(&mut self.dashboard_editor); + let reload = dlg.ui(ctx, ®istry); + self.show_dashboard_editor = dlg.open; + self.dashboard_editor = dlg; + if reload { + self.dashboard.reload(); + } + } let mut dlg = std::mem::take(&mut self.alias_dialog); dlg.ui(ctx, self); self.alias_dialog = dlg; diff --git a/src/lib.rs b/src/lib.rs index da2dd430..f6ac8559 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod actions; pub mod actions_editor; pub mod common; +pub mod dashboard; pub mod help_window; pub mod history; pub mod hotkey; diff --git a/src/settings.rs b/src/settings.rs index d888bc1f..6a6777af 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -40,6 +40,29 @@ pub enum LogFile { Path(String), } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DashboardSettings { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub config_path: Option, + #[serde(default)] + pub default_location: Option, + #[serde(default = "default_show_dashboard_when_empty")] + pub show_when_query_empty: bool, +} + +impl Default for DashboardSettings { + fn default() -> Self { + Self { + enabled: false, + config_path: None, + default_location: None, + show_when_query_empty: true, + } + } +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Settings { pub hotkey: Option, @@ -172,6 +195,8 @@ pub struct Settings { pub plugin_settings: std::collections::HashMap, #[serde(default)] pub pinned_panels: Vec, + #[serde(default)] + pub dashboard: DashboardSettings, } fn default_toasts() -> bool { @@ -230,6 +255,10 @@ fn default_net_refresh() -> f32 { 1.0 } +fn default_show_dashboard_when_empty() -> bool { + true +} + fn default_note_panel_size() -> (f32, f32) { (420.0, 320.0) } @@ -311,6 +340,7 @@ impl Default for Settings { screenshot_use_editor: true, plugin_settings: std::collections::HashMap::new(), pinned_panels: Vec::new(), + dashboard: DashboardSettings::default(), } } } diff --git a/src/settings_editor.rs b/src/settings_editor.rs index e5e72088..3bd7440e 100644 --- a/src/settings_editor.rs +++ b/src/settings_editor.rs @@ -1,3 +1,4 @@ +use crate::dashboard::config::DashboardConfig; use crate::gui::LauncherApp; use crate::hotkey::parse_hotkey; use crate::plugins::note::{NoteExternalOpen, NotePluginSettings}; @@ -59,6 +60,10 @@ pub struct SettingsEditor { screenshot_save_file: bool, screenshot_auto_save: bool, screenshot_use_editor: bool, + dashboard_enabled: bool, + dashboard_path: String, + dashboard_default_location: String, + dashboard_show_when_empty: bool, plugin_settings: std::collections::HashMap, plugins_expanded: bool, expand_request: Option, @@ -149,6 +154,18 @@ impl SettingsEditor { screenshot_save_file: settings.screenshot_save_file, screenshot_auto_save: settings.screenshot_auto_save, screenshot_use_editor: settings.screenshot_use_editor, + dashboard_enabled: settings.dashboard.enabled, + dashboard_path: settings + .dashboard + .config_path + .clone() + .unwrap_or_else(|| "dashboard.json".into()), + dashboard_default_location: settings + .dashboard + .default_location + .clone() + .unwrap_or_default(), + dashboard_show_when_empty: settings.dashboard.show_when_query_empty, plugin_settings: settings.plugin_settings.clone(), plugins_expanded: false, expand_request: None, @@ -269,6 +286,20 @@ impl SettingsEditor { plugin_settings: self.plugin_settings.clone(), show_examples: current.show_examples, pinned_panels: current.pinned_panels.clone(), + dashboard: crate::settings::DashboardSettings { + enabled: self.dashboard_enabled, + config_path: if self.dashboard_path.trim().is_empty() { + None + } else { + Some(self.dashboard_path.clone()) + }, + default_location: if self.dashboard_default_location.trim().is_empty() { + None + } else { + Some(self.dashboard_default_location.clone()) + }, + show_when_query_empty: self.dashboard_show_when_empty, + }, } } @@ -448,6 +479,28 @@ impl SettingsEditor { }); } + ui.separator(); + ui.heading("Dashboard"); + ui.checkbox( + &mut self.dashboard_enabled, + "Enable dashboard when query is empty", + ); + ui.horizontal(|ui| { + ui.label("Dashboard config path"); + ui.text_edit_singleline(&mut self.dashboard_path); + }); + ui.horizontal(|ui| { + ui.label("Default location"); + ui.text_edit_singleline(&mut self.dashboard_default_location); + }); + ui.checkbox( + &mut self.dashboard_show_when_empty, + "Show dashboard when the search box is blank", + ); + if ui.button("Customize Dashboard...").clicked() { + app.show_dashboard_editor = true; + } + ui.separator(); if ui .button(if self.plugins_expanded { @@ -704,6 +757,21 @@ impl SettingsEditor { new_settings.screenshot_auto_save; app.screenshot_use_editor = new_settings.screenshot_use_editor; + app.dashboard_enabled = new_settings.dashboard.enabled; + app.dashboard_show_when_empty = + new_settings.dashboard.show_when_query_empty; + app.dashboard_default_location = + new_settings.dashboard.default_location.clone(); + app.dashboard_path = DashboardConfig::path_for( + new_settings + .dashboard + .config_path + .as_deref() + .unwrap_or("dashboard.json"), + ) + .to_string_lossy() + .to_string(); + app.dashboard.set_path(&app.dashboard_path); app.toast_duration = new_settings.toast_duration; app.note_more_limit = new_settings.note_more_limit; let dirs = new_settings diff --git a/tests/dashboard_config.rs b/tests/dashboard_config.rs new file mode 100644 index 00000000..3a57e7b0 --- /dev/null +++ b/tests/dashboard_config.rs @@ -0,0 +1,99 @@ +use multi_launcher::actions::Action; +use multi_launcher::dashboard::config::{DashboardConfig, GridConfig, SlotConfig}; +use multi_launcher::dashboard::layout::normalize_slots; +use multi_launcher::dashboard::widgets::WidgetRegistry; +use multi_launcher::dashboard::ActivationSource; +use multi_launcher::gui::LauncherApp; +use multi_launcher::plugin::PluginManager; +use multi_launcher::settings::Settings; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +fn new_app(ctx: &eframe::egui::Context) -> LauncherApp { + LauncherApp::new( + ctx, + Arc::new(Vec::new()), + 0, + PluginManager::new(), + "actions.json".into(), + "settings.json".into(), + Settings::default(), + None, + None, + None, + None, + Arc::new(AtomicBool::new(false)), + Arc::new(AtomicBool::new(false)), + Arc::new(AtomicBool::new(false)), + ) +} + +#[test] +fn dashboard_config_defaults_present() { + let cfg = DashboardConfig::default(); + assert_eq!(cfg.version, 1); + assert_eq!(cfg.grid.rows, GridConfig::default().rows); + assert!(!cfg.slots.is_empty()); +} + +#[test] +fn unknown_widgets_removed_during_normalization() { + let mut cfg = DashboardConfig { + version: 1, + grid: GridConfig { rows: 2, cols: 2 }, + slots: vec![SlotConfig::with_widget("does_not_exist", 0, 0)], + }; + let registry = WidgetRegistry::with_defaults(); + cfg.sanitize(®istry); + let (slots, warnings) = normalize_slots(&cfg, ®istry); + assert!(slots.is_empty()); + assert!(!warnings.is_empty()); +} + +#[test] +fn layout_clamps_to_grid_and_prevents_overlap() { + let cfg = DashboardConfig { + version: 1, + grid: GridConfig { rows: 1, cols: 1 }, + slots: vec![ + SlotConfig::with_widget("weather_site", 0, 0), + SlotConfig::with_widget("weather_site", 0, 0), + SlotConfig::with_widget("weather_site", 5, 5), + ], + }; + let registry = WidgetRegistry::with_defaults(); + let (slots, warnings) = normalize_slots(&cfg, ®istry); + assert_eq!(slots.len(), 1); + assert_eq!(slots[0].row_span, 1); + assert_eq!(slots[0].col_span, 1); + assert!(!warnings.is_empty()); +} + +#[test] +fn activation_applies_query_override_first() { + let ctx = eframe::egui::Context::default(); + let mut app = new_app(&ctx); + app.query = "start".into(); + app.clear_query_after_run = false; + app.hide_after_run = false; + let action = Action { + label: "Query".into(), + desc: "".into(), + action: "query:after".into(), + args: None, + }; + app.activate_action(action, Some("override".into()), ActivationSource::Dashboard); + assert_eq!(app.query, "after"); + assert!(app.move_cursor_end); +} + +#[test] +fn should_show_dashboard_when_empty_query() { + let ctx = eframe::egui::Context::default(); + let mut app = new_app(&ctx); + app.dashboard_enabled = true; + app.dashboard_show_when_empty = true; + app.query.clear(); + let trimmed = app.query.trim().to_string(); + assert!(app.should_show_dashboard(&trimmed)); +} From c5a88a0eb90a1571ecc588f3b09609f76b687f4f Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Sat, 3 Jan 2026 07:13:36 -0500 Subject: [PATCH 2/7] Fix dashboard wiring and widget registry --- src/dashboard/dashboard.rs | 10 +++++----- src/dashboard/widgets/mod.rs | 8 ++++---- src/gui/mod.rs | 22 ++++++++++++++-------- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/dashboard/dashboard.rs b/src/dashboard/dashboard.rs index 24938046..a36c1091 100644 --- a/src/dashboard/dashboard.rs +++ b/src/dashboard/dashboard.rs @@ -32,14 +32,14 @@ pub struct Dashboard { registry: WidgetRegistry, watcher: Option, pub warnings: Vec, - event_tx: Option>, + event_cb: Option>, } impl Dashboard { pub fn new( config_path: impl AsRef, registry: WidgetRegistry, - event_tx: Option>, + event_cb: Option>, ) -> Self { let path = config_path.as_ref().to_path_buf(); let (config, slots, warnings) = Self::load_internal(&path, ®istry); @@ -50,7 +50,7 @@ impl Dashboard { registry, watcher: None, warnings, - event_tx, + event_cb, } } @@ -81,11 +81,11 @@ impl Dashboard { pub fn attach_watcher(&mut self) { let path = self.config_path.clone(); - let tx = self.event_tx.clone(); + let tx = self.event_cb.clone(); self.watcher = crate::common::json_watch::watch_json(path.clone(), move || { tracing::info!("dashboard config changed"); if let Some(tx) = &tx { - let _ = tx.send(DashboardEvent::Reloaded); + (tx)(DashboardEvent::Reloaded); } }) .ok(); diff --git a/src/dashboard/widgets/mod.rs b/src/dashboard/widgets/mod.rs index 75ede2d7..633e4b40 100644 --- a/src/dashboard/widgets/mod.rs +++ b/src/dashboard/widgets/mod.rs @@ -23,7 +23,7 @@ pub use todo_summary::TodoSummaryWidget; pub use weather_site::WeatherSiteWidget; /// Result of a widget activation. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone)] pub struct WidgetAction { pub action: Action, pub query_override: Option, @@ -42,7 +42,7 @@ pub trait Widget: Send { /// Factory for building widgets from JSON settings. #[derive(Clone)] pub struct WidgetFactory { - ctor: fn(&Value) -> Box, + ctor: std::sync::Arc Box + Send + Sync>, } impl WidgetFactory { @@ -50,10 +50,10 @@ impl WidgetFactory { build: fn(C) -> T, ) -> Self { Self { - ctor: move |v| { + ctor: std::sync::Arc::new(move |v| { let cfg = serde_json::from_value::(v.clone()).unwrap_or_default(); Box::new(build(cfg)) - }, + }), } } diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 9e385685..f1de16b0 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -320,13 +320,13 @@ pub struct LauncherApp { /// Hold watchers so the `RecommendedWatcher` instances remain active. #[allow(dead_code)] // required to keep watchers alive watchers: Vec, - dashboard: Dashboard, - dashboard_enabled: bool, - dashboard_show_when_empty: bool, - dashboard_path: String, - dashboard_default_location: Option, - dashboard_editor: DashboardEditorDialog, - show_dashboard_editor: bool, + pub dashboard: Dashboard, + pub dashboard_enabled: bool, + pub dashboard_show_when_empty: bool, + pub dashboard_path: String, + pub dashboard_default_location: Option, + pub dashboard_editor: DashboardEditorDialog, + pub show_dashboard_editor: bool, rx: Receiver, folder_aliases: HashMap>, bookmark_aliases: HashMap>, @@ -668,10 +668,16 @@ impl LauncherApp { .unwrap_or("dashboard.json"), ); let dashboard_registry = WidgetRegistry::with_defaults(); + let dashboard_event_cb = std::sync::Arc::new({ + let tx = tx.clone(); + move |ev: DashboardEvent| { + let _ = tx.send(WatchEvent::Dashboard(ev)); + } + }); let mut dashboard = Dashboard::new( &dashboard_path, dashboard_registry.clone(), - Some(tx.clone()), + Some(dashboard_event_cb), ); dashboard.attach_watcher(); From efa4d8ce492a52d420d7b4393b6bdb65bbaed6f7 Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Sat, 3 Jan 2026 07:30:54 -0500 Subject: [PATCH 3/7] Reserve dashboard space and fix editor IDs --- src/dashboard/dashboard.rs | 10 +++-- src/gui/dashboard_editor_dialog.rs | 62 ++++++++++++++++-------------- 2 files changed, 41 insertions(+), 31 deletions(-) diff --git a/src/dashboard/dashboard.rs b/src/dashboard/dashboard.rs index a36c1091..f47b1ea7 100644 --- a/src/dashboard/dashboard.rs +++ b/src/dashboard/dashboard.rs @@ -101,9 +101,13 @@ impl Dashboard { let grid_cols = self.config.grid.cols.max(1) as usize; let col_width = ui.available_width() / grid_cols.max(1) as f32; + let size = egui::vec2(ui.available_width(), ui.available_height()); + let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover()); + let mut child = ui.child_ui(rect, egui::Layout::top_down(egui::Align::LEFT)); + for slot in &self.slots { let rect = egui::Rect::from_min_size( - ui.min_rect().min + rect.min + egui::vec2( col_width * slot.col as f32, (slot.row as f32) * 100.0, // coarse row height @@ -113,8 +117,8 @@ impl Dashboard { 90.0 * slot.row_span as f32, ), ); - let mut child = ui.child_ui(rect, egui::Layout::left_to_right(egui::Align::TOP)); - if let Some(action) = self.render_slot(slot, &mut child, ctx, activation) { + let mut slot_ui = child.child_ui(rect, egui::Layout::left_to_right(egui::Align::TOP)); + if let Some(action) = self.render_slot(slot, &mut slot_ui, ctx, activation) { clicked = Some(action); } } diff --git a/src/gui/dashboard_editor_dialog.rs b/src/gui/dashboard_editor_dialog.rs index 3b83423e..fa84c947 100644 --- a/src/gui/dashboard_editor_dialog.rs +++ b/src/gui/dashboard_editor_dialog.rs @@ -97,36 +97,42 @@ impl DashboardEditorDialog { while idx < self.config.slots.len() { let slot = &mut self.config.slots[idx]; let mut removed = false; - ui.group(|ui| { - ui.horizontal(|ui| { - ui.label(format!("Slot {idx}")); - if ui.button("Remove").clicked() { - removed = true; - } - }); - egui::ComboBox::from_label("Widget type") - .selected_text(slot.widget.clone()) - .show_ui(ui, |ui| { - for name in registry.names() { - ui.selectable_value(&mut slot.widget, name.clone(), name); + ui.push_id(idx, |ui| { + ui.group(|ui| { + ui.horizontal(|ui| { + ui.label(format!("Slot {idx}")); + if ui.button("Remove").clicked() { + removed = true; } }); - ui.horizontal(|ui| { - ui.label("Row"); - ui.add(egui::DragValue::new(&mut slot.row)); - ui.label("Col"); - ui.add(egui::DragValue::new(&mut slot.col)); - }); - ui.horizontal(|ui| { - ui.label("Row span"); - ui.add(egui::DragValue::new(&mut slot.row_span).clamp_range(1..=12)); - ui.label("Col span"); - ui.add(egui::DragValue::new(&mut slot.col_span).clamp_range(1..=12)); - }); - ui.horizontal(|ui| { - let id = slot.id.get_or_insert_with(|| format!("slot-{idx}")); - ui.label("Label"); - ui.text_edit_singleline(id); + egui::ComboBox::from_label("Widget type") + .selected_text(slot.widget.clone()) + .show_ui(ui, |ui| { + for name in registry.names() { + ui.selectable_value(&mut slot.widget, name.clone(), name); + } + }); + ui.horizontal(|ui| { + ui.label("Row"); + ui.add(egui::DragValue::new(&mut slot.row)); + ui.label("Col"); + ui.add(egui::DragValue::new(&mut slot.col)); + }); + ui.horizontal(|ui| { + ui.label("Row span"); + ui.add( + egui::DragValue::new(&mut slot.row_span).clamp_range(1..=12), + ); + ui.label("Col span"); + ui.add( + egui::DragValue::new(&mut slot.col_span).clamp_range(1..=12), + ); + }); + ui.horizontal(|ui| { + let id = slot.id.get_or_insert_with(|| format!("slot-{idx}")); + ui.label("Label"); + ui.text_edit_singleline(id); + }); }); }); if removed { From edfa370eaa718888a3c9730179b2482ec52bb56f Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Sun, 4 Jan 2026 07:11:26 -0500 Subject: [PATCH 4/7] Expose activation source in tests and cursor flag accessor --- src/gui/mod.rs | 5 +++++ tests/dashboard_config.rs | 5 ++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/gui/mod.rs b/src/gui/mod.rs index f1de16b0..d5fdfcf8 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -1381,6 +1381,11 @@ impl LauncherApp { self.dashboard_enabled && self.dashboard_show_when_empty && trimmed.trim().is_empty() } + #[cfg(test)] + pub fn move_cursor_end_flag(&self) -> bool { + self.move_cursor_end + } + pub fn activate_action( &mut self, mut a: Action, diff --git a/tests/dashboard_config.rs b/tests/dashboard_config.rs index 3a57e7b0..aa441be9 100644 --- a/tests/dashboard_config.rs +++ b/tests/dashboard_config.rs @@ -2,8 +2,7 @@ use multi_launcher::actions::Action; use multi_launcher::dashboard::config::{DashboardConfig, GridConfig, SlotConfig}; use multi_launcher::dashboard::layout::normalize_slots; use multi_launcher::dashboard::widgets::WidgetRegistry; -use multi_launcher::dashboard::ActivationSource; -use multi_launcher::gui::LauncherApp; +use multi_launcher::gui::{ActivationSource, LauncherApp}; use multi_launcher::plugin::PluginManager; use multi_launcher::settings::Settings; use std::sync::atomic::AtomicBool; @@ -84,7 +83,7 @@ fn activation_applies_query_override_first() { }; app.activate_action(action, Some("override".into()), ActivationSource::Dashboard); assert_eq!(app.query, "after"); - assert!(app.move_cursor_end); + assert!(app.move_cursor_end_flag()); } #[test] From 1e0ba0256b9c809bbd1cc22c9eb8f1596752bbcb Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Sun, 4 Jan 2026 07:15:53 -0500 Subject: [PATCH 5/7] Expose dashboard cursor flag and clean quit_hotkey warning --- src/gui/mod.rs | 1 - tests/quit_hotkey.rs | 12 +++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/gui/mod.rs b/src/gui/mod.rs index d5fdfcf8..2a50ce1c 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -1381,7 +1381,6 @@ impl LauncherApp { self.dashboard_enabled && self.dashboard_show_when_empty && trimmed.trim().is_empty() } - #[cfg(test)] pub fn move_cursor_end_flag(&self) -> bool { self.move_cursor_end } diff --git a/tests/quit_hotkey.rs b/tests/quit_hotkey.rs index 1dc11721..31c68c20 100644 --- a/tests/quit_hotkey.rs +++ b/tests/quit_hotkey.rs @@ -1,6 +1,9 @@ -use multi_launcher::hotkey::{Hotkey, HotkeyTrigger}; use eframe::egui; -use std::sync::{Arc, Mutex, atomic::{AtomicBool, Ordering}}; +use multi_launcher::hotkey::{Hotkey, HotkeyTrigger}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, +}; use std::thread; use std::time::Duration; @@ -22,7 +25,7 @@ fn run_quit_loop( } if quit_requested { - if let Ok(mut guard) = ctx_handle.lock() { + if let Ok(guard) = ctx_handle.lock() { if let Some(c) = &*guard { c.send_viewport_cmd(egui::ViewportCommand::Close); c.request_repaint(); @@ -65,8 +68,7 @@ fn quit_hotkey_exits_main_loop() { let cmds = ctx.commands.lock().unwrap(); assert_eq!(cmds.len(), 1); match cmds[0] { - egui::ViewportCommand::Close => {}, + egui::ViewportCommand::Close => {} _ => panic!("unexpected command"), } } - From 7d32ca53653074277013385d63f9219ffbe03d44 Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Sun, 4 Jan 2026 07:22:44 -0500 Subject: [PATCH 6/7] Return sanitize warnings and fix unknown widget test --- src/dashboard/config.rs | 11 +++++++++-- tests/dashboard_config.rs | 4 ++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/dashboard/config.rs b/src/dashboard/config.rs index 311a6ee6..35e40fb9 100644 --- a/src/dashboard/config.rs +++ b/src/dashboard/config.rs @@ -105,7 +105,10 @@ impl DashboardConfig { return Ok(Self::default()); } let mut cfg: DashboardConfig = serde_json::from_str(&content)?; - cfg.sanitize(registry); + let warnings = cfg.sanitize(registry); + for w in warnings { + tracing::warn!("{w}"); + } Ok(cfg) } @@ -117,13 +120,16 @@ impl DashboardConfig { } /// Remove unsupported widgets and normalize empty settings. - pub fn sanitize(&mut self, registry: &WidgetRegistry) { + pub fn sanitize(&mut self, registry: &WidgetRegistry) -> Vec { + let mut warnings = Vec::new(); self.slots.retain(|slot| { if slot.widget.is_empty() { return false; } if !registry.contains(&slot.widget) { + let msg = format!("unknown dashboard widget '{}' dropped", slot.widget); tracing::warn!(widget = %slot.widget, "unknown dashboard widget dropped"); + warnings.push(msg); return false; } true @@ -133,6 +139,7 @@ impl DashboardConfig { slot.settings = json!({}); } } + warnings } pub fn path_for(base: &str) -> PathBuf { diff --git a/tests/dashboard_config.rs b/tests/dashboard_config.rs index aa441be9..47360262 100644 --- a/tests/dashboard_config.rs +++ b/tests/dashboard_config.rs @@ -43,8 +43,8 @@ fn unknown_widgets_removed_during_normalization() { slots: vec![SlotConfig::with_widget("does_not_exist", 0, 0)], }; let registry = WidgetRegistry::with_defaults(); - cfg.sanitize(®istry); - let (slots, warnings) = normalize_slots(&cfg, ®istry); + let warnings = cfg.sanitize(®istry); + let (slots, _) = normalize_slots(&cfg, ®istry); assert!(slots.is_empty()); assert!(!warnings.is_empty()); } From bcb1ac3b92d5b55c62b3dc1dcceef1225221bfbd Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Sun, 4 Jan 2026 07:35:34 -0500 Subject: [PATCH 7/7] Ignore dashboard watch events in test receiver --- src/gui/mod.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 2a50ce1c..27b86e19 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -3541,7 +3541,13 @@ impl LauncherApp { } pub fn recv_test_event(rx: &Receiver) -> Option { - rx.try_recv().ok().map(Into::into) + while let Ok(ev) = rx.try_recv() { + if matches!(ev, WatchEvent::Dashboard(_)) { + continue; + } + return Some(ev.into()); + } + None } #[cfg(test)]