diff --git a/src/dashboard/data_cache.rs b/src/dashboard/data_cache.rs index dedc28fd..6099082c 100644 --- a/src/dashboard/data_cache.rs +++ b/src/dashboard/data_cache.rs @@ -8,6 +8,8 @@ use crate::plugins::fav::{load_favs, FavEntry, FAV_FILE}; use crate::plugins::note::{load_notes, Note}; use crate::plugins::snippets::{load_snippets, SnippetEntry, SNIPPETS_FILE}; use crate::plugins::todo::{load_todos, TodoEntry, TODO_FILE}; +use crate::mouse_gestures::db::{load_gestures, GestureDb, GESTURES_FILE}; +use crate::mouse_gestures::usage::{load_usage, GestureUsageEntry, GESTURES_USAGE_FILE}; use crate::{launcher, launcher::RecycleBinInfo}; use chrono::Local; use std::sync::{Arc, Mutex}; @@ -40,6 +42,12 @@ impl From for RecycleBinSnapshot { } } +#[derive(Clone, Debug, Default)] +pub struct GestureSnapshot { + pub db: Arc, + pub usage: Arc>, +} + #[derive(Clone)] pub struct DashboardDataSnapshot { pub clipboard_history: Arc>, @@ -49,6 +57,7 @@ pub struct DashboardDataSnapshot { pub calendar: Arc, pub processes: Arc>, pub favorites: Arc>, + pub gestures: Arc, pub process_error: Option, pub system_status: Option, pub recycle_bin: Option, @@ -64,6 +73,7 @@ impl Default for DashboardDataSnapshot { calendar: Arc::new(CalendarSnapshot::default()), processes: Arc::new(Vec::new()), favorites: Arc::new(Vec::new()), + gestures: Arc::new(GestureSnapshot::default()), process_error: None, system_status: None, recycle_bin: None, @@ -81,6 +91,7 @@ impl DashboardDataSnapshot { calendar: Arc::clone(&self.calendar), processes: Arc::clone(&self.processes), favorites: Arc::clone(&self.favorites), + gestures: Arc::clone(&self.gestures), process_error: self.process_error.clone(), system_status: self.system_status.clone(), recycle_bin: self.recycle_bin.clone(), @@ -96,6 +107,7 @@ impl DashboardDataSnapshot { calendar: Arc::clone(&self.calendar), processes: Arc::clone(&self.processes), favorites: Arc::clone(&self.favorites), + gestures: Arc::clone(&self.gestures), process_error: self.process_error.clone(), system_status: self.system_status.clone(), recycle_bin: self.recycle_bin.clone(), @@ -111,6 +123,7 @@ impl DashboardDataSnapshot { calendar: Arc::clone(&self.calendar), processes: Arc::clone(&self.processes), favorites: Arc::clone(&self.favorites), + gestures: Arc::clone(&self.gestures), process_error: self.process_error.clone(), system_status: self.system_status.clone(), recycle_bin: self.recycle_bin.clone(), @@ -126,6 +139,7 @@ impl DashboardDataSnapshot { calendar: Arc::clone(&self.calendar), processes: Arc::clone(&self.processes), favorites: Arc::clone(&self.favorites), + gestures: Arc::clone(&self.gestures), process_error: self.process_error.clone(), system_status: self.system_status.clone(), recycle_bin: self.recycle_bin.clone(), @@ -141,6 +155,7 @@ impl DashboardDataSnapshot { calendar: Arc::clone(&self.calendar), processes: Arc::clone(&self.processes), favorites: Arc::new(favorites), + gestures: Arc::clone(&self.gestures), process_error: self.process_error.clone(), system_status: self.system_status.clone(), recycle_bin: self.recycle_bin.clone(), @@ -156,6 +171,7 @@ impl DashboardDataSnapshot { calendar: Arc::clone(&self.calendar), processes: Arc::new(processes), favorites: Arc::clone(&self.favorites), + gestures: Arc::clone(&self.gestures), process_error, system_status: self.system_status.clone(), recycle_bin: self.recycle_bin.clone(), @@ -171,6 +187,7 @@ impl DashboardDataSnapshot { calendar: Arc::clone(&self.calendar), processes: Arc::clone(&self.processes), favorites: Arc::clone(&self.favorites), + gestures: Arc::clone(&self.gestures), process_error: self.process_error.clone(), system_status, recycle_bin: self.recycle_bin.clone(), @@ -186,6 +203,7 @@ impl DashboardDataSnapshot { calendar: Arc::clone(&self.calendar), processes: Arc::clone(&self.processes), favorites: Arc::clone(&self.favorites), + gestures: Arc::clone(&self.gestures), process_error: self.process_error.clone(), system_status: self.system_status.clone(), recycle_bin, @@ -201,6 +219,26 @@ impl DashboardDataSnapshot { calendar: Arc::new(calendar), processes: Arc::clone(&self.processes), favorites: Arc::clone(&self.favorites), + gestures: Arc::clone(&self.gestures), + process_error: self.process_error.clone(), + system_status: self.system_status.clone(), + recycle_bin: self.recycle_bin.clone(), + } + } + + fn with_gestures(&self, db: GestureDb, usage: Vec) -> Self { + Self { + clipboard_history: Arc::clone(&self.clipboard_history), + snippets: Arc::clone(&self.snippets), + notes: Arc::clone(&self.notes), + todos: Arc::clone(&self.todos), + calendar: Arc::clone(&self.calendar), + processes: Arc::clone(&self.processes), + favorites: Arc::clone(&self.favorites), + gestures: Arc::new(GestureSnapshot { + db: Arc::new(db), + usage: Arc::new(usage), + }), process_error: self.process_error.clone(), system_status: self.system_status.clone(), recycle_bin: self.recycle_bin.clone(), @@ -259,6 +297,7 @@ impl DashboardDataCache { self.refresh_todos(); self.refresh_calendar(); self.refresh_favorites(); + self.refresh_gestures(); self.refresh_processes(plugins); self.refresh_system_status(); self.refresh_recycle_bin(); @@ -316,6 +355,14 @@ impl DashboardDataCache { } } + pub fn refresh_gestures(&self) { + let db = load_gestures(GESTURES_FILE).unwrap_or_default(); + let usage = load_usage(GESTURES_USAGE_FILE); + if let Ok(mut state) = self.state.lock() { + state.snapshot = Arc::new(state.snapshot.with_gestures(db, usage)); + } + } + pub fn maybe_refresh_processes(&self, plugins: &PluginManager, interval: Duration) { let should_refresh = self .state diff --git a/src/dashboard/widgets/gesture_cheat_sheet.rs b/src/dashboard/widgets/gesture_cheat_sheet.rs new file mode 100644 index 00000000..57d36d44 --- /dev/null +++ b/src/dashboard/widgets/gesture_cheat_sheet.rs @@ -0,0 +1,181 @@ +use super::{ + edit_typed_settings, gesture_focus_action, gesture_toggle_action, Widget, WidgetAction, + WidgetSettingsContext, WidgetSettingsUiResult, +}; +use crate::dashboard::dashboard::{DashboardContext, WidgetActivation}; +use crate::mouse_gestures::db::{format_tokens, BindingEntry, GestureEntry}; +use crate::mouse_gestures::engine::DirMode; +use eframe::egui; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +fn default_count() -> usize { + 5 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GestureCheatSheetConfig { + #[serde(default = "default_count")] + pub count: usize, + #[serde(default)] + pub show_disabled: bool, +} + +impl Default for GestureCheatSheetConfig { + fn default() -> Self { + Self { + count: default_count(), + show_disabled: false, + } + } +} + +pub struct GestureCheatSheetWidget { + cfg: GestureCheatSheetConfig, +} + +impl GestureCheatSheetWidget { + pub fn new(cfg: GestureCheatSheetConfig) -> Self { + Self { cfg } + } + + pub fn settings_ui( + ui: &mut egui::Ui, + value: &mut serde_json::Value, + ctx: &WidgetSettingsContext<'_>, + ) -> WidgetSettingsUiResult { + edit_typed_settings( + ui, + value, + ctx, + |ui, cfg: &mut GestureCheatSheetConfig, _ctx| { + let mut changed = false; + ui.horizontal(|ui| { + ui.label("Count"); + changed |= ui + .add(egui::DragValue::new(&mut cfg.count).clamp_range(1..=50)) + .changed(); + }); + changed |= ui.checkbox(&mut cfg.show_disabled, "Show disabled").changed(); + changed + }, + ) + } + + fn primary_binding_label(bindings: &[BindingEntry]) -> String { + bindings + .iter() + .find(|binding| binding.enabled) + .or_else(|| bindings.first()) + .map(|binding| binding.label.clone()) + .unwrap_or_else(|| "Unbound".into()) + } + + fn usage_counts( + usage: &[crate::mouse_gestures::usage::GestureUsageEntry], + ) -> HashMap<(String, String, DirMode), usize> { + let mut counts: HashMap<(String, String, DirMode), usize> = HashMap::new(); + for entry in usage { + *counts + .entry(( + entry.gesture_label.clone(), + entry.tokens.clone(), + entry.dir_mode, + )) + .or_insert(0) += 1; + } + counts + } +} + +impl Default for GestureCheatSheetWidget { + fn default() -> Self { + Self::new(GestureCheatSheetConfig::default()) + } +} + +impl Widget for GestureCheatSheetWidget { + fn render( + &mut self, + ui: &mut egui::Ui, + ctx: &DashboardContext<'_>, + _activation: WidgetActivation, + ) -> Option { + let snapshot = ctx.data_cache.snapshot(); + let gestures = &snapshot.gestures.db.gestures; + let usage = &snapshot.gestures.usage; + let counts = Self::usage_counts(usage); + let mut rows: Vec<(GestureEntry, usize)> = gestures + .iter() + .filter(|gesture| !gesture.bindings.is_empty()) + .cloned() + .map(|gesture| { + let count = counts + .get(&(gesture.label.clone(), gesture.tokens.clone(), gesture.dir_mode)) + .copied() + .unwrap_or(0); + (gesture, count) + }) + .collect(); + + if !self.cfg.show_disabled { + rows.retain(|(gesture, _)| gesture.enabled); + } + + rows.sort_by(|a, b| { + b.1.cmp(&a.1) + .then_with(|| a.0.label.cmp(&b.0.label)) + }); + + let mut clicked = None; + let count = self.cfg.count.max(1); + if rows.is_empty() { + ui.label("No bound gestures configured."); + return None; + } + + egui::Grid::new("gesture_cheat_sheet") + .striped(true) + .show(ui, |ui| { + ui.label("On"); + ui.label("Gesture"); + ui.label("Tokens"); + ui.label("Primary binding"); + ui.end_row(); + + for (gesture, _) in rows.into_iter().take(count) { + let mut enabled = gesture.enabled; + if ui.checkbox(&mut enabled, "").changed() { + clicked = Some(gesture_toggle_action( + &gesture.label, + &gesture.tokens, + gesture.dir_mode, + enabled, + )); + } + if ui + .selectable_label(false, gesture.label.clone()) + .clicked() + { + clicked = Some(gesture_focus_action( + &gesture.label, + &gesture.tokens, + gesture.dir_mode, + None, + )); + } + ui.label(format_tokens(&gesture.tokens)); + ui.label(Self::primary_binding_label(&gesture.bindings)); + ui.end_row(); + } + }); + + clicked + } + + fn on_config_updated(&mut self, settings: &serde_json::Value) { + if let Ok(cfg) = serde_json::from_value::(settings.clone()) { + self.cfg = cfg; + } + } +} diff --git a/src/dashboard/widgets/gesture_health.rs b/src/dashboard/widgets/gesture_health.rs new file mode 100644 index 00000000..eb14e0bc --- /dev/null +++ b/src/dashboard/widgets/gesture_health.rs @@ -0,0 +1,73 @@ +use crate::actions::Action; +use crate::dashboard::dashboard::{DashboardContext, WidgetActivation}; +use crate::dashboard::widgets::{Widget, WidgetAction}; +use crate::mouse_gestures::stats::gesture_stats; +use eframe::egui; + +#[derive(Default)] +pub struct GestureHealthWidget; + +impl GestureHealthWidget { + pub fn new(_cfg: ()) -> Self { + Self + } + + fn action(label: &str, action: &str) -> WidgetAction { + WidgetAction { + action: Action { + label: label.into(), + desc: "Mouse gestures".into(), + action: action.into(), + args: None, + }, + query_override: None, + } + } +} + +impl Widget for GestureHealthWidget { + fn render( + &mut self, + ui: &mut egui::Ui, + ctx: &DashboardContext<'_>, + _activation: WidgetActivation, + ) -> Option { + let snapshot = ctx.data_cache.snapshot(); + let stats = gesture_stats(&snapshot.gestures.db); + + ui.label("Gesture health"); + egui::Grid::new("gesture_health_stats") + .striped(true) + .show(ui, |ui| { + ui.label("Zero bindings"); + ui.label(stats.zero_bindings.to_string()); + ui.end_row(); + ui.label("Duplicate tokens"); + ui.label(stats.duplicate_tokens.to_string()); + ui.end_row(); + ui.label("Disabled gestures"); + ui.label(stats.disabled_gestures.to_string()); + ui.end_row(); + }); + + ui.separator(); + ui.label("Quick actions"); + let mut clicked = None; + ui.horizontal_wrapped(|ui| { + if ui.button("Edit gestures").clicked() { + clicked = Some(Self::action("Edit gestures", "mg:dialog")); + } + if ui.button("Add gesture").clicked() { + clicked = Some(Self::action("Add gesture", "mg:dialog:add")); + } + if ui.button("Add binding").clicked() { + clicked = Some(Self::action("Add binding", "mg:dialog:binding")); + } + if ui.button("Settings").clicked() { + clicked = Some(Self::action("Settings", "mg:dialog:settings")); + } + }); + + clicked + } +} diff --git a/src/dashboard/widgets/gesture_recent.rs b/src/dashboard/widgets/gesture_recent.rs new file mode 100644 index 00000000..edd8c512 --- /dev/null +++ b/src/dashboard/widgets/gesture_recent.rs @@ -0,0 +1,133 @@ +use super::{ + edit_typed_settings, gesture_focus_action, Widget, WidgetAction, WidgetSettingsContext, + WidgetSettingsUiResult, +}; +use crate::dashboard::dashboard::{DashboardContext, WidgetActivation}; +use crate::mouse_gestures::db::format_tokens; +use eframe::egui; +use serde::{Deserialize, Serialize}; + +fn default_count() -> usize { + 5 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GestureRecentConfig { + #[serde(default = "default_count")] + pub count: usize, +} + +impl Default for GestureRecentConfig { + fn default() -> Self { + Self { + count: default_count(), + } + } +} + +pub struct GestureRecentWidget { + cfg: GestureRecentConfig, +} + +impl GestureRecentWidget { + pub fn new(cfg: GestureRecentConfig) -> Self { + Self { cfg } + } + + pub fn settings_ui( + ui: &mut egui::Ui, + value: &mut serde_json::Value, + ctx: &WidgetSettingsContext<'_>, + ) -> WidgetSettingsUiResult { + edit_typed_settings(ui, value, ctx, |ui, cfg: &mut GestureRecentConfig, _ctx| { + let mut changed = false; + ui.horizontal(|ui| { + ui.label("Count"); + changed |= ui + .add(egui::DragValue::new(&mut cfg.count).clamp_range(1..=50)) + .changed(); + }); + changed + }) + } + + fn binding_label( + snapshot: &crate::dashboard::data_cache::DashboardDataSnapshot, + entry: &crate::mouse_gestures::usage::GestureUsageEntry, + ) -> String { + let gesture = snapshot.gestures.db.gestures.iter().find(|gesture| { + gesture.label == entry.gesture_label + && gesture.tokens == entry.tokens + && gesture.dir_mode == entry.dir_mode + }); + let Some(gesture) = gesture else { + return "Unknown".into(); + }; + let mut enabled_bindings = gesture.bindings.iter().filter(|binding| binding.enabled); + enabled_bindings + .nth(entry.binding_idx) + .map(|binding| binding.label.clone()) + .unwrap_or_else(|| "Unknown".into()) + } +} + +impl Default for GestureRecentWidget { + fn default() -> Self { + Self::new(GestureRecentConfig::default()) + } +} + +impl Widget for GestureRecentWidget { + fn render( + &mut self, + ui: &mut egui::Ui, + ctx: &DashboardContext<'_>, + _activation: WidgetActivation, + ) -> Option { + let snapshot = ctx.data_cache.snapshot(); + let usage = &snapshot.gestures.usage; + if usage.is_empty() { + ui.label("No recent gestures."); + return None; + } + + let mut clicked = None; + let count = self.cfg.count.max(1); + egui::Grid::new("gesture_recent") + .striped(true) + .show(ui, |ui| { + ui.label("Gesture"); + ui.label("Tokens"); + ui.label("Binding"); + ui.label("Action"); + ui.end_row(); + + for entry in usage.iter().rev().take(count) { + let binding_label = Self::binding_label(&snapshot, entry); + if ui + .selectable_label(false, entry.gesture_label.clone()) + .clicked() + { + clicked = Some(gesture_focus_action( + &entry.gesture_label, + &entry.tokens, + entry.dir_mode, + Some(entry.binding_idx), + )); + } + ui.label(format_tokens(&entry.tokens)); + ui.label(format!("#{}", entry.binding_idx + 1)); + ui.label(binding_label); + ui.end_row(); + } + }); + + clicked + } + + fn on_config_updated(&mut self, settings: &serde_json::Value) { + if let Ok(cfg) = serde_json::from_value::(settings.clone()) { + self.cfg = cfg; + } + } +} diff --git a/src/dashboard/widgets/mod.rs b/src/dashboard/widgets/mod.rs index 51215580..cb8acfea 100644 --- a/src/dashboard/widgets/mod.rs +++ b/src/dashboard/widgets/mod.rs @@ -1,5 +1,7 @@ use crate::actions::Action; use crate::dashboard::dashboard::{DashboardContext, WidgetActivation}; +use crate::mouse_gestures::engine::DirMode; +use crate::mouse_gestures::selection::{GestureFocusArgs, GestureToggleArgs}; use crate::plugin::PluginManager; use eframe::egui; use serde::{de::DeserializeOwned, Deserialize, Serialize}; @@ -15,6 +17,9 @@ mod clipboard_snippets; mod command_history; mod diagnostics; mod frequent_commands; +mod gesture_cheat_sheet; +mod gesture_health; +mod gesture_recent; mod layouts; mod notes_recent; mod notes_tags; @@ -50,6 +55,9 @@ pub use clipboard_snippets::ClipboardSnippetsWidget; pub use command_history::CommandHistoryWidget; pub use diagnostics::DiagnosticsWidget; pub use frequent_commands::FrequentCommandsWidget; +pub use gesture_cheat_sheet::GestureCheatSheetWidget; +pub use gesture_health::GestureHealthWidget; +pub use gesture_recent::GestureRecentWidget; pub use layouts::LayoutsWidget; pub use notes_recent::NotesRecentWidget; pub use notes_tags::NotesTagsWidget; @@ -239,6 +247,20 @@ impl WidgetRegistry { WidgetFactory::new(FrequentCommandsWidget::new) .with_settings_ui(FrequentCommandsWidget::settings_ui), ); + reg.register( + "gesture_cheat_sheet", + WidgetFactory::new(GestureCheatSheetWidget::new) + .with_settings_ui(GestureCheatSheetWidget::settings_ui), + ); + reg.register( + "gesture_recent", + WidgetFactory::new(GestureRecentWidget::new) + .with_settings_ui(GestureRecentWidget::settings_ui), + ); + reg.register( + "gesture_health", + WidgetFactory::new(GestureHealthWidget::new), + ); reg.register( "todo", WidgetFactory::new(TodoWidget::new).with_settings_ui(TodoWidget::settings_ui), @@ -527,6 +549,52 @@ pub(crate) fn query_suggestions( out } +pub(crate) fn gesture_focus_action( + label: &str, + tokens: &str, + dir_mode: DirMode, + binding_idx: Option, +) -> WidgetAction { + let args = GestureFocusArgs { + label: label.to_string(), + tokens: tokens.to_string(), + dir_mode, + binding_idx, + }; + WidgetAction { + action: Action { + label: label.to_string(), + desc: "Mouse gestures".into(), + action: "mg:dialog:focus".into(), + args: serde_json::to_string(&args).ok(), + }, + query_override: None, + } +} + +pub(crate) fn gesture_toggle_action( + label: &str, + tokens: &str, + dir_mode: DirMode, + enabled: bool, +) -> WidgetAction { + let args = GestureToggleArgs { + label: label.to_string(), + tokens: tokens.to_string(), + dir_mode, + enabled, + }; + WidgetAction { + action: Action { + label: format!("Toggle {label}"), + desc: "Mouse gestures".into(), + action: "mg:toggle".into(), + args: serde_json::to_string(&args).ok(), + }, + query_override: None, + } +} + pub(crate) fn edit_typed_settings( ui: &mut egui::Ui, value: &mut Value, diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 35e0ab4f..f74bec50 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -77,6 +77,8 @@ use crate::help_window::HelpWindow; use crate::history::{self, HistoryEntry, HistoryPin, HISTORY_PINS_FILE}; use crate::indexer; use crate::launcher::launch_action; +use crate::mouse_gestures::db::{load_gestures, save_gestures, GESTURES_FILE}; +use crate::mouse_gestures::selection::{GestureFocusArgs, GestureToggleArgs}; use crate::plugin::PluginManager; use crate::plugin_editor::PluginEditor; use crate::plugins::note::{NoteExternalOpen, NotePluginSettings}; @@ -142,6 +144,7 @@ pub enum WatchEvent { Notes, Todos, Favorites, + Gestures, Dashboard(DashboardEvent), Recycle(Result<(), String>), ExecuteAction(Action), @@ -191,6 +194,7 @@ impl From for TestWatchEvent { WatchEvent::Notes => TestWatchEvent::Actions, WatchEvent::Todos => TestWatchEvent::Actions, WatchEvent::Favorites => TestWatchEvent::Actions, + WatchEvent::Gestures => TestWatchEvent::Actions, WatchEvent::Dashboard(_) => TestWatchEvent::Actions, WatchEvent::Recycle(_) => unreachable!(), WatchEvent::ExecuteAction(_) => TestWatchEvent::Actions, @@ -604,6 +608,9 @@ impl LauncherApp { WatchEvent::Favorites => { self.dashboard_data_cache.refresh_favorites(); } + WatchEvent::Gestures => { + self.dashboard_data_cache.refresh_gestures(); + } WatchEvent::Dashboard(_) => { self.dashboard.reload(); for warn in &self.dashboard.warnings { @@ -1093,6 +1100,14 @@ impl LauncherApp { WatchEvent::Favorites, ), (notes_dir.as_path(), WatchEvent::Notes), + ( + Path::new(crate::mouse_gestures::db::GESTURES_FILE), + WatchEvent::Gestures, + ), + ( + Path::new(crate::mouse_gestures::usage::GESTURES_USAGE_FILE), + WatchEvent::Gestures, + ), ] { match watch_file(path, tx.clone(), event) { Ok(w) => watchers.push(w), @@ -1119,6 +1134,14 @@ impl LauncherApp { WatchEvent::Favorites, ), (notes_dir.as_path(), WatchEvent::Notes), + ( + Path::new(crate::mouse_gestures::db::GESTURES_FILE), + WatchEvent::Gestures, + ), + ( + Path::new(crate::mouse_gestures::usage::GESTURES_USAGE_FILE), + WatchEvent::Gestures, + ), ] { if path.exists() { if let Ok(w) = watch_file(path, tx.clone(), event) { @@ -2414,25 +2437,38 @@ impl LauncherApp { self.mouse_gestures_dialog.open_add(); } else if a.action == "mg:dialog:binding" { self.mouse_gestures_dialog.open_binding_editor(); + } else if a.action == "mg:dialog:focus" { + if let Some(args) = a + .args + .as_deref() + .and_then(|raw| serde_json::from_str::(raw).ok()) + { + self.mouse_gestures_dialog + .open_focus(&args.label, &args.tokens, args.dir_mode); + } else { + self.mouse_gestures_dialog.open(); + } } else if a.action == "mg:dialog:settings" { self.open_mouse_gesture_settings_dialog(); - } else if a.action == "mg:practice" { - let enabled = crate::plugins::mouse_gestures::toggle_practice_mode(); - if self.enable_toasts { - let label = if enabled { - "Mouse gesture practice mode enabled" - } else { - "Mouse gesture practice mode disabled" - }; - push_toast( - &mut self.toasts, - Toast { - text: label.into(), - kind: ToastKind::Info, - options: ToastOptions::default() - .duration_in_seconds(self.toast_duration as f64), - }, - ); + } else if a.action == "mg:toggle" { + if let Some(args) = a + .args + .as_deref() + .and_then(|raw| serde_json::from_str::(raw).ok()) + { + let mut db = load_gestures(GESTURES_FILE).unwrap_or_default(); + if let Some(gesture) = db.gestures.iter_mut().find(|gesture| { + gesture.label == args.label + && gesture.tokens == args.tokens + && gesture.dir_mode == args.dir_mode + }) { + gesture.enabled = args.enabled; + if let Err(err) = save_gestures(GESTURES_FILE, &db) { + self.set_error(format!("Failed to save mouse gestures: {err}")); + } else { + self.dashboard_data_cache.refresh_gestures(); + } + } } } else if let Some(label) = a.action.strip_prefix("fav:dialog:") { if label.is_empty() { @@ -4771,6 +4807,7 @@ pub fn recv_test_event(rx: &Receiver) -> Option { | WatchEvent::Notes | WatchEvent::Todos | WatchEvent::Favorites + | WatchEvent::Gestures | WatchEvent::ExecuteAction(_) => { continue; } diff --git a/src/gui/mouse_gestures_dialog.rs b/src/gui/mouse_gestures_dialog.rs index 7dbb4785..832b55be 100644 --- a/src/gui/mouse_gestures_dialog.rs +++ b/src/gui/mouse_gestures_dialog.rs @@ -321,6 +321,21 @@ impl MgGesturesDialog { self.ensure_selection(); } + pub fn open_focus(&mut self, label: &str, tokens: &str, dir_mode: DirMode) { + self.open(); + self.selected_idx = self + .db + .gestures + .iter() + .position(|gesture| { + gesture.label == label + && gesture.tokens == tokens + && gesture.dir_mode == dir_mode + }) + .or(self.selected_idx); + self.ensure_selection(); + } + pub fn open_add(&mut self) { self.open(); self.add_gesture(); diff --git a/src/mouse_gestures/mod.rs b/src/mouse_gestures/mod.rs index d1f42529..45d105b9 100644 --- a/src/mouse_gestures/mod.rs +++ b/src/mouse_gestures/mod.rs @@ -1,4 +1,7 @@ pub mod db; pub mod engine; pub mod overlay; +pub mod selection; pub mod service; +pub mod stats; +pub mod usage; diff --git a/src/mouse_gestures/selection.rs b/src/mouse_gestures/selection.rs new file mode 100644 index 00000000..24da8689 --- /dev/null +++ b/src/mouse_gestures/selection.rs @@ -0,0 +1,19 @@ +use crate::mouse_gestures::engine::DirMode; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GestureFocusArgs { + pub label: String, + pub tokens: String, + pub dir_mode: DirMode, + #[serde(default)] + pub binding_idx: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GestureToggleArgs { + pub label: String, + pub tokens: String, + pub dir_mode: DirMode, + pub enabled: bool, +} diff --git a/src/mouse_gestures/service.rs b/src/mouse_gestures/service.rs index e69e88d6..f286af58 100644 --- a/src/mouse_gestures/service.rs +++ b/src/mouse_gestures/service.rs @@ -6,7 +6,9 @@ use crate::mouse_gestures::engine::{DirMode, GestureTracker}; use crate::mouse_gestures::overlay::{ DefaultOverlayBackend, HintOverlay, OverlayBackend, TrailOverlay, }; +use crate::mouse_gestures::usage::{record_usage, GestureUsageEntry, GESTURES_USAGE_FILE}; use anyhow::anyhow; +use chrono::Local; use once_cell::sync::OnceCell; use std::collections::HashMap; #[cfg(windows)] @@ -458,6 +460,16 @@ fn worker_loop( save_selection_state(GESTURES_STATE_FILE, &selection_state); } if let Some(action) = actions.get(idx).cloned() { + record_usage( + GESTURES_USAGE_FILE, + GestureUsageEntry { + timestamp: Local::now().timestamp(), + gesture_label: gesture_label.clone(), + tokens: tokens.clone(), + dir_mode: config.dir_mode, + binding_idx: idx, + }, + ); if config.practice_mode { tracing::info!( tokens = %tokens, diff --git a/src/mouse_gestures/stats.rs b/src/mouse_gestures/stats.rs new file mode 100644 index 00000000..bb2c8b3e --- /dev/null +++ b/src/mouse_gestures/stats.rs @@ -0,0 +1,83 @@ +use crate::mouse_gestures::db::{GestureConflictKind, GestureDb}; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct GestureStats { + pub zero_bindings: usize, + pub duplicate_tokens: usize, + pub disabled_gestures: usize, +} + +pub fn gesture_stats(db: &GestureDb) -> GestureStats { + let mut stats = GestureStats::default(); + for gesture in &db.gestures { + if !gesture.enabled { + stats.disabled_gestures += 1; + } + let enabled_bindings = gesture.bindings.iter().filter(|b| b.enabled).count(); + if enabled_bindings == 0 { + stats.zero_bindings += 1; + } + } + stats.duplicate_tokens = db + .find_conflicts() + .iter() + .filter(|conflict| conflict.kind == GestureConflictKind::DuplicateTokens) + .count(); + stats +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mouse_gestures::db::{BindingEntry, BindingKind, GestureEntry}; + use crate::mouse_gestures::engine::DirMode; + + fn binding(label: &str) -> BindingEntry { + BindingEntry { + label: label.into(), + kind: BindingKind::Execute, + action: "action".into(), + args: None, + enabled: true, + } + } + + #[test] + fn gesture_stats_detects_unbound_and_duplicates() { + let db = GestureDb { + schema_version: 2, + gestures: vec![ + GestureEntry { + label: "One".into(), + tokens: "RD".into(), + dir_mode: DirMode::Four, + stroke: Vec::new(), + enabled: true, + bindings: vec![], + }, + GestureEntry { + label: "Two".into(), + tokens: "RD".into(), + dir_mode: DirMode::Four, + stroke: Vec::new(), + enabled: true, + bindings: vec![binding("Action")], + }, + GestureEntry { + label: "Three".into(), + tokens: "UL".into(), + dir_mode: DirMode::Four, + stroke: Vec::new(), + enabled: false, + bindings: vec![binding("Other")], + }, + ], + }; + + let stats = gesture_stats(&db); + + assert_eq!(stats.zero_bindings, 1); + assert_eq!(stats.duplicate_tokens, 1); + assert_eq!(stats.disabled_gestures, 1); + } +} diff --git a/src/mouse_gestures/usage.rs b/src/mouse_gestures/usage.rs new file mode 100644 index 00000000..26d7d9fa --- /dev/null +++ b/src/mouse_gestures/usage.rs @@ -0,0 +1,39 @@ +use crate::mouse_gestures::engine::DirMode; +use serde::{Deserialize, Serialize}; + +pub const GESTURES_USAGE_FILE: &str = "mouse_gestures_usage.json"; +const MAX_USAGE_ENTRIES: usize = 100; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GestureUsageEntry { + pub timestamp: i64, + pub gesture_label: String, + pub tokens: String, + pub dir_mode: DirMode, + pub binding_idx: usize, +} + +pub fn load_usage(path: &str) -> Vec { + let content = std::fs::read_to_string(path).unwrap_or_default(); + if content.trim().is_empty() { + return Vec::new(); + } + serde_json::from_str(&content).unwrap_or_default() +} + +pub fn record_usage(path: &str, entry: GestureUsageEntry) { + let mut usage = load_usage(path); + usage.push(entry); + if usage.len() > MAX_USAGE_ENTRIES { + let drain = usage.len().saturating_sub(MAX_USAGE_ENTRIES); + usage.drain(0..drain); + } + match serde_json::to_string_pretty(&usage) { + Ok(json) => { + if let Err(err) = std::fs::write(path, json) { + tracing::error!(?err, "failed to save mouse gesture usage log"); + } + } + Err(err) => tracing::error!(?err, "failed to serialize mouse gesture usage log"), + } +} diff --git a/src/plugins/mouse_gestures.rs b/src/plugins/mouse_gestures.rs index f1e15396..9547f328 100644 --- a/src/plugins/mouse_gestures.rs +++ b/src/plugins/mouse_gestures.rs @@ -179,16 +179,6 @@ impl MouseGestureRuntime { } } -pub fn toggle_practice_mode() -> bool { - let mut enabled = false; - with_service(|svc| { - svc.settings.practice_mode = !svc.settings.practice_mode; - enabled = svc.settings.practice_mode; - svc.apply(); - }); - enabled -} - static SERVICE: OnceCell> = OnceCell::new(); fn with_service(f: F) @@ -269,12 +259,6 @@ impl MouseGesturesPlugin { action: "query:mg conflicts".into(), args: None, }, - Action { - label: "mg practice".into(), - desc: "Toggle mouse gesture practice mode".into(), - action: "mg:practice".into(), - args: None, - }, ] } @@ -351,14 +335,6 @@ impl Plugin for MouseGesturesPlugin { args: None, }]; } - if strip_prefix_ci(trimmed, "mg practice").is_some() { - return vec![Action { - label: "Toggle mouse gesture practice mode".into(), - desc: "Mouse gestures".into(), - action: "mg:practice".into(), - args: None, - }]; - } if let Some(rest) = strip_prefix_ci(trimmed, "mg find") { let query = rest.trim(); let db = load_gestures(GESTURES_FILE).unwrap_or_default(); diff --git a/tests/dashboard_config.rs b/tests/dashboard_config.rs index 0a5afa15..9c8575ed 100644 --- a/tests/dashboard_config.rs +++ b/tests/dashboard_config.rs @@ -42,6 +42,14 @@ fn dashboard_enabled_by_default() { assert!(settings.dashboard.show_when_query_empty); } +#[test] +fn gesture_widgets_registered() { + let registry = WidgetRegistry::with_defaults(); + assert!(registry.contains("gesture_cheat_sheet")); + assert!(registry.contains("gesture_recent")); + assert!(registry.contains("gesture_health")); +} + #[test] fn unknown_widgets_removed_during_normalization() { let mut cfg = DashboardConfig { diff --git a/tests/mouse_gestures_plugin.rs b/tests/mouse_gestures_plugin.rs index 1f7adf66..19ed2493 100644 --- a/tests/mouse_gestures_plugin.rs +++ b/tests/mouse_gestures_plugin.rs @@ -16,8 +16,7 @@ fn mouse_gestures_commands_match_expected_labels() { "mg list", "mg find", "mg where", - "mg conflicts", - "mg practice" + "mg conflicts" ] ); let action_strings: Vec<_> = actions.iter().map(|a| a.action.as_str()).collect(); @@ -32,7 +31,6 @@ fn mouse_gestures_commands_match_expected_labels() { "query:mg find ", "query:mg where ", "query:mg conflicts", - "mg:practice", ] ); }