From e50377169c65cf3ac39613e75a2a0d26801c3606 Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Sat, 31 Jan 2026 12:18:49 -0500 Subject: [PATCH] Add binding kinds to mouse gesture bindings --- src/gui/mod.rs | 16 +- src/gui/mouse_gesture_settings_dialog.rs | 8 +- src/gui/mouse_gestures_dialog.rs | 736 ++++++++++++----------- src/mouse_gestures/db.rs | 167 ++++- tests/mouse_gestures_db.rs | 102 +++- tests/mouse_gestures_service.rs | 5 +- tests/mouse_gestures_ui.rs | 8 +- tests/trigger_visibility.rs | 2 +- 8 files changed, 678 insertions(+), 366 deletions(-) diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 3a96d06f..bdc7ec2a 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -2041,7 +2041,21 @@ impl LauncherApp { self.query.starts_with("timer list") || self.query.starts_with("alarm list"); self.search(); } + let mut focus_after_launcher = false; + if a.action == "launcher:show" { + if let Some(query) = a.args.as_ref() { + self.query = query.to_string(); + self.last_timer_query = + query.starts_with("timer list") || query.starts_with("alarm list"); + self.search(); + self.move_cursor_end = true; + focus_after_launcher = true; + } + } if self.handle_launcher_action(&a.action) { + if focus_after_launcher { + self.focus_input(); + } return; } let current = self.query.clone(); @@ -2488,7 +2502,7 @@ impl LauncherApp { } else if a.action != "help:show" { self.record_history_usage(&a, ¤t, source); } - } else if let Err(e) = launch_action(&a) { + } else if let Err(e) = execute_action(&a) { if a.desc == "Fav" && !a.action.starts_with("fav:") { tracing::error!(?e, fav=%a.label, "failed to run favorite"); } diff --git a/src/gui/mouse_gesture_settings_dialog.rs b/src/gui/mouse_gesture_settings_dialog.rs index 1cd1c64c..a6300358 100644 --- a/src/gui/mouse_gesture_settings_dialog.rs +++ b/src/gui/mouse_gesture_settings_dialog.rs @@ -257,9 +257,7 @@ impl MouseGestureSettingsDialog { } } -fn cancel_behavior_label( - value: crate::mouse_gestures::service::CancelBehavior, -) -> &'static str { +fn cancel_behavior_label(value: crate::mouse_gestures::service::CancelBehavior) -> &'static str { match value { crate::mouse_gestures::service::CancelBehavior::DoNothing => "Do nothing", crate::mouse_gestures::service::CancelBehavior::PassThroughClick => { @@ -268,9 +266,7 @@ fn cancel_behavior_label( } } -fn no_match_behavior_label( - value: crate::mouse_gestures::service::NoMatchBehavior, -) -> &'static str { +fn no_match_behavior_label(value: crate::mouse_gestures::service::NoMatchBehavior) -> &'static str { match value { crate::mouse_gestures::service::NoMatchBehavior::DoNothing => "Do nothing", crate::mouse_gestures::service::NoMatchBehavior::PassThroughClick => { diff --git a/src/gui/mouse_gestures_dialog.rs b/src/gui/mouse_gestures_dialog.rs index 98960523..7dbb4785 100644 --- a/src/gui/mouse_gestures_dialog.rs +++ b/src/gui/mouse_gestures_dialog.rs @@ -1,7 +1,7 @@ use crate::gui::LauncherApp; use crate::mouse_gestures::db::{ - format_gesture_label, load_gestures, save_gestures, BindingEntry, GestureDb, GestureEntry, - GESTURES_FILE, + format_gesture_label, load_gestures, save_gestures, BindingEntry, BindingKind, GestureDb, + GestureEntry, GESTURES_FILE, }; use crate::mouse_gestures::engine::{DirMode, GestureTracker}; use crate::mouse_gestures::service::MouseGestureConfig; @@ -207,7 +207,7 @@ struct BindingEditor { action: String, args: String, enabled: bool, - use_query: bool, + kind: BindingKind, add_plugin: String, add_filter: String, add_args: String, @@ -221,7 +221,7 @@ impl BindingEditor { self.action.clear(); self.args.clear(); self.enabled = true; - self.use_query = false; + self.kind = BindingKind::Execute; self.add_plugin.clear(); self.add_filter.clear(); self.add_args.clear(); @@ -230,22 +230,21 @@ impl BindingEditor { fn start_edit(&mut self, binding: Option<&BindingEntry>, idx: usize) { if let Some(binding) = binding { - let (action, use_query) = if let Some(rest) = binding.action.strip_prefix("query:") { - (rest.to_string(), true) + self.label = binding.label.clone(); + self.kind = binding.kind; + self.action = binding.action.clone(); + self.args = if binding.kind == BindingKind::Execute { + binding.args.clone().unwrap_or_default() } else { - (binding.action.clone(), false) + String::new() }; - self.label = binding.label.clone(); - self.action = action; - self.args = binding.args.clone().unwrap_or_default(); self.enabled = binding.enabled; - self.use_query = use_query; } else { self.label.clear(); self.action.clear(); self.args.clear(); self.enabled = true; - self.use_query = false; + self.kind = BindingKind::Execute; } self.edit_idx = Some(idx); self.add_plugin.clear(); @@ -379,11 +378,7 @@ impl MgGesturesDialog { } fn binding_target_label(binding: &BindingEntry) -> String { - if let Some(args) = &binding.args { - format!("{} {}", binding.action, args) - } else { - binding.action.clone() - } + binding.display_target() } fn bindings_ui( @@ -425,10 +420,7 @@ impl MgGesturesDialog { ui.label(Self::binding_target_label(binding)); }); ui.add_space(4.0); - if ui - .add_enabled(idx > 0, egui::Button::new("↑")) - .clicked() - { + if ui.add_enabled(idx > 0, egui::Button::new("↑")).clicked() { reorder_request = Some((idx, idx - 1)); } if ui @@ -506,153 +498,211 @@ impl MgGesturesDialog { } }); ui.horizontal(|ui| { - ui.label("Action"); - ui.text_edit_singleline(&mut editor.action); - }); - ui.horizontal(|ui| { - ui.label("Args"); - ui.text_edit_singleline(&mut editor.args); + ui.label("Type"); + ui.radio_value(&mut editor.kind, BindingKind::Execute, "Execute"); + ui.radio_value(&mut editor.kind, BindingKind::SetQuery, "Set query"); + ui.radio_value( + &mut editor.kind, + BindingKind::SetQueryAndShow, + "Set query + show", + ); + ui.radio_value( + &mut editor.kind, + BindingKind::ToggleLauncher, + "Toggle launcher", + ); }); + match editor.kind { + BindingKind::Execute => { + ui.horizontal(|ui| { + ui.label("Action"); + ui.text_edit_singleline(&mut editor.action); + }); + ui.horizontal(|ui| { + ui.label("Args"); + ui.text_edit_singleline(&mut editor.args); + }); + } + BindingKind::SetQuery | BindingKind::SetQueryAndShow => { + ui.horizontal(|ui| { + ui.label("Query"); + ui.text_edit_singleline(&mut editor.action); + }); + } + BindingKind::ToggleLauncher => { + ui.label("No action details required for toggling the launcher."); + } + } ui.horizontal(|ui| { - ui.checkbox(&mut editor.use_query, "Use query action"); ui.checkbox(&mut editor.enabled, "Enabled"); }); ui.separator(); - ui.label("Pick an action"); - ui.horizontal(|ui| { - ui.label("Category"); - let mut plugin_names: Vec<_> = - app.plugins.iter().map(|p| p.name().to_string()).collect(); - plugin_names.push("app".to_string()); - plugin_names.sort_unstable(); - egui::ComboBox::from_id_source("mg_binding_category") - .selected_text(if editor.add_plugin.is_empty() { - "Select".to_string() - } else { - editor.add_plugin.clone() - }) - .show_ui(ui, |ui| { - for name in plugin_names.iter() { - ui.selectable_value(&mut editor.add_plugin, name.to_string(), name); - } - }); - }); - ui.horizontal(|ui| { - ui.label("Filter"); - ui.text_edit_singleline(&mut editor.add_filter); - }); - ui.horizontal(|ui| { - ui.label("Args"); - ui.text_edit_singleline(&mut editor.add_args); - }); - if editor.add_plugin == "app" { - let filter = editor.add_filter.trim().to_lowercase(); - egui::ScrollArea::vertical() - .id_source("mg_binding_app_list") - .max_height(160.0) - .show(ui, |ui| { - for act in app.actions.iter() { - if !filter.is_empty() - && !act.label.to_lowercase().contains(&filter) - && !act.desc.to_lowercase().contains(&filter) - && !act.action.to_lowercase().contains(&filter) - { - continue; - } - if ui - .button(format!("{} - {}", act.label, act.desc)) - .clicked() - { - editor.label = act.label.clone(); - editor.use_query = false; - editor.action = act.action.clone(); - editor.args = act.args.clone().unwrap_or_default(); - editor.add_args.clear(); - } - } - }); - } else if let Some(plugin) = - app.plugins.iter().find(|p| p.name() == editor.add_plugin) - { - let filter = editor.add_filter.trim().to_lowercase(); - let mut actions = if plugin.name() == "folders" { - plugin.search(&format!("f list {}", editor.add_filter)) - } else if plugin.name() == "bookmarks" { - plugin.search(&format!("bm list {}", editor.add_filter)) - } else { - plugin.commands() + if editor.kind != BindingKind::ToggleLauncher { + let picker_label = match editor.kind { + BindingKind::Execute => "Pick an action", + BindingKind::SetQuery | BindingKind::SetQueryAndShow => "Pick a query", + BindingKind::ToggleLauncher => "Pick an action", }; - egui::ScrollArea::vertical() - .id_source("mg_binding_action_list") - .max_height(160.0) - .show(ui, |ui| { - for act in actions.drain(..) { - if !filter.is_empty() - && !act.label.to_lowercase().contains(&filter) - && !act.desc.to_lowercase().contains(&filter) - && !act.action.to_lowercase().contains(&filter) - { - continue; + ui.label(picker_label); + ui.horizontal(|ui| { + ui.label("Category"); + let mut plugin_names: Vec<_> = + app.plugins.iter().map(|p| p.name().to_string()).collect(); + plugin_names.push("app".to_string()); + plugin_names.sort_unstable(); + egui::ComboBox::from_id_source("mg_binding_category") + .selected_text(if editor.add_plugin.is_empty() { + "Select".to_string() + } else { + editor.add_plugin.clone() + }) + .show_ui(ui, |ui| { + for name in plugin_names.iter() { + ui.selectable_value( + &mut editor.add_plugin, + name.to_string(), + name, + ); } - if ui - .button(format!("{} - {}", act.label, act.desc)) - .clicked() - { - let mut command = act.action.clone(); - let mut args = if editor.add_args.trim().is_empty() { - None - } else { - Some(editor.add_args.clone()) - }; - - if let Some(q) = command.strip_prefix("query:") { - let mut q = q.to_string(); - if let Some(ref a) = args { - q.push_str(a); - } - if let Some(res) = - plugin.search(&q).into_iter().next() - { - command = res.action; - args = res.args; + }); + }); + ui.horizontal(|ui| { + ui.label("Filter"); + ui.text_edit_singleline(&mut editor.add_filter); + }); + ui.horizontal(|ui| { + ui.label("Args"); + ui.text_edit_singleline(&mut editor.add_args); + }); + if editor.add_plugin == "app" { + let filter = editor.add_filter.trim().to_lowercase(); + egui::ScrollArea::vertical() + .id_source("mg_binding_app_list") + .max_height(160.0) + .show(ui, |ui| { + for act in app.actions.iter() { + if !filter.is_empty() + && !act.label.to_lowercase().contains(&filter) + && !act.desc.to_lowercase().contains(&filter) + && !act.action.to_lowercase().contains(&filter) + { + continue; + } + if ui.button(format!("{} - {}", act.label, act.desc)).clicked() + { + editor.label = act.label.clone(); + if let Some(query) = act.action.strip_prefix("query:") { + editor.kind = match editor.kind { + BindingKind::SetQueryAndShow => { + BindingKind::SetQueryAndShow + } + _ => BindingKind::SetQuery, + }; + editor.action = query.to_string(); + editor.args.clear(); + } else if act.action == "launcher:toggle" { + editor.kind = BindingKind::ToggleLauncher; + editor.action.clear(); + editor.args.clear(); } else { - command = q; - args = None; + editor.kind = BindingKind::Execute; + editor.action = act.action.clone(); + editor.args = act.args.clone().unwrap_or_default(); } + editor.add_args.clear(); } - - let (action, use_query) = - if let Some(rest) = command.strip_prefix("query:") { - (rest.to_string(), true) + } + }); + } else if let Some(plugin) = + app.plugins.iter().find(|p| p.name() == editor.add_plugin) + { + let filter = editor.add_filter.trim().to_lowercase(); + let mut actions = if plugin.name() == "folders" { + plugin.search(&format!("f list {}", editor.add_filter)) + } else if plugin.name() == "bookmarks" { + plugin.search(&format!("bm list {}", editor.add_filter)) + } else { + plugin.commands() + }; + egui::ScrollArea::vertical() + .id_source("mg_binding_action_list") + .max_height(160.0) + .show(ui, |ui| { + for act in actions.drain(..) { + if !filter.is_empty() + && !act.label.to_lowercase().contains(&filter) + && !act.desc.to_lowercase().contains(&filter) + && !act.action.to_lowercase().contains(&filter) + { + continue; + } + if ui.button(format!("{} - {}", act.label, act.desc)).clicked() + { + let args = if editor.add_args.trim().is_empty() { + None } else { - (command, false) + Some(editor.add_args.clone()) }; - editor.label = act.label.clone(); - editor.use_query = use_query; - editor.action = action; - editor.args = args.unwrap_or_default(); - editor.add_args.clear(); + + if let Some(query) = act.action.strip_prefix("query:") { + let mut query = query.to_string(); + if let Some(ref a) = args { + query.push_str(a); + } + editor.kind = match editor.kind { + BindingKind::SetQueryAndShow => { + BindingKind::SetQueryAndShow + } + _ => BindingKind::SetQuery, + }; + editor.action = query; + editor.args.clear(); + } else if act.action == "launcher:toggle" { + editor.kind = BindingKind::ToggleLauncher; + editor.action.clear(); + editor.args.clear(); + } else { + editor.kind = BindingKind::Execute; + editor.action = act.action.clone(); + editor.args = args.unwrap_or_else(|| { + act.args.clone().unwrap_or_default() + }); + } + editor.label = act.label.clone(); + editor.add_args.clear(); + } } - } - }); + }); + } } ui.horizontal(|ui| { if ui.button("Save").clicked() { - if editor.label.trim().is_empty() || editor.action.trim().is_empty() { + let action_required = matches!( + editor.kind, + BindingKind::Execute + | BindingKind::SetQuery + | BindingKind::SetQueryAndShow + ); + if editor.label.trim().is_empty() + || (action_required && editor.action.trim().is_empty()) + { app.set_error("Label and action required".into()); } else { - let action = if editor.use_query { - format!("query:{}", editor.action.trim()) + let action = if editor.kind == BindingKind::ToggleLauncher { + String::new() } else { editor.action.trim().to_string() }; - let args = if editor.args.trim().is_empty() { - None - } else { + let args = if editor.kind == BindingKind::Execute + && !editor.args.trim().is_empty() + { Some(editor.args.trim().to_string()) + } else { + None }; let entry = BindingEntry { label: editor.label.trim().to_string(), + kind: editor.kind, action, args, enabled: editor.enabled, @@ -739,89 +789,86 @@ impl MgGesturesDialog { .auto_shrink([false, false]) .max_height(left_ui.available_height()) .show(&mut left_ui, |ui| { - // ScrollArea creates its own child Ui; re-apply the left clip - // so horizontally-wide rows can't paint into the right panel. - ui.set_clip_rect(left_clip); - let mut remove_idx: Option = None; - let gesture_order = self.sorted_gesture_indices(); - for idx in gesture_order { - let selected = self.selected_idx == Some(idx); - let entry = &mut self.db.gestures[idx]; + // ScrollArea creates its own child Ui; re-apply the left clip + // so horizontally-wide rows can't paint into the right panel. + ui.set_clip_rect(left_clip); + let mut remove_idx: Option = None; + let gesture_order = self.sorted_gesture_indices(); + for idx in gesture_order { + let selected = self.selected_idx == Some(idx); + let entry = &mut self.db.gestures[idx]; + ui.horizontal(|ui| { + if ui.checkbox(&mut entry.enabled, "").changed() { + save_now = true; + } + if ui + .selectable_label(selected, format_gesture_label(entry)) + .clicked() + { + self.selected_idx = Some(idx); + self.recorder.set_dir_mode(entry.dir_mode); + self.token_buffer = entry.tokens.clone(); + self.binding_dialog.editor.reset(); + self.binding_dialog.open = false; + } + if ui.button("Rename").clicked() { + self.rename_idx = Some(idx); + self.rename_label = entry.label.clone(); + } + if ui.button("Delete").clicked() { + remove_idx = Some(idx); + } + }); + if self.rename_idx == Some(idx) { + // Use a vertical group for renaming so the text edit never + // pushes the Save/Cancel buttons past the left panel width. + ui.group(|ui| { + ui.label("Label"); + ui.add_sized( + [ui.available_width(), 0.0], + egui::TextEdit::singleline(&mut self.rename_label), + ); ui.horizontal(|ui| { - if ui.checkbox(&mut entry.enabled, "").changed() { - save_now = true; - } - if ui - .selectable_label( - selected, - format_gesture_label(entry), - ) - .clicked() - { - self.selected_idx = Some(idx); - self.recorder.set_dir_mode(entry.dir_mode); - self.token_buffer = entry.tokens.clone(); - self.binding_dialog.editor.reset(); - self.binding_dialog.open = false; + if ui.button("Save").clicked() { + if !self.rename_label.trim().is_empty() { + entry.label = + self.rename_label.trim().to_string(); + self.rename_idx = None; + save_now = true; + } } - if ui.button("Rename").clicked() { - self.rename_idx = Some(idx); - self.rename_label = entry.label.clone(); - } - if ui.button("Delete").clicked() { - remove_idx = Some(idx); + if ui.button("Cancel").clicked() { + self.rename_idx = None; } }); - if self.rename_idx == Some(idx) { - // Use a vertical group for renaming so the text edit never - // pushes the Save/Cancel buttons past the left panel width. - ui.group(|ui| { - ui.label("Label"); - ui.add_sized( - [ui.available_width(), 0.0], - egui::TextEdit::singleline(&mut self.rename_label), - ); - ui.horizontal(|ui| { - if ui.button("Save").clicked() { - if !self.rename_label.trim().is_empty() { - entry.label = - self.rename_label.trim().to_string(); - self.rename_idx = None; - save_now = true; - } - } - if ui.button("Cancel").clicked() { - self.rename_idx = None; - } - }); - }); - } - } - if let Some(idx) = remove_idx { - self.db.gestures.remove(idx); - - if let Some(selected) = self.selected_idx { - if selected == idx { - self.selected_idx = None; - } else if selected > idx { - self.selected_idx = Some(selected - 1); - } - } + }); + } + } + if let Some(idx) = remove_idx { + self.db.gestures.remove(idx); - if let Some(rename) = self.rename_idx { - if rename == idx { - self.rename_idx = None; - } else if rename > idx { - self.rename_idx = Some(rename - 1); - } - } + if let Some(selected) = self.selected_idx { + if selected == idx { + self.selected_idx = None; + } else if selected > idx { + self.selected_idx = Some(selected - 1); + } + } - self.ensure_selection(); - self.binding_dialog.editor.reset(); - self.binding_dialog.open = false; - save_now = true; + if let Some(rename) = self.rename_idx { + if rename == idx { + self.rename_idx = None; + } else if rename > idx { + self.rename_idx = Some(rename - 1); } - }); + } + + self.ensure_selection(); + self.binding_dialog.editor.reset(); + self.binding_dialog.open = false; + save_now = true; + } + }); ui.separator(); // Right panel: allocate an exact rect (full height) and render into a child Ui @@ -844,138 +891,141 @@ impl MgGesturesDialog { if let Some(entry) = self.db.gestures.get_mut(idx) { ui.label("Recorder"); ui.horizontal(|ui| { - ui.label("Direction mode"); - let mut dir_mode = entry.dir_mode; - egui::ComboBox::from_id_source("mg_dir_mode") - .selected_text(match dir_mode { - DirMode::Four => "4-way", - DirMode::Eight => "8-way", - }) - .show_ui(ui, |ui| { - ui.selectable_value( - &mut dir_mode, - DirMode::Four, - "4-way", - ); - ui.selectable_value( - &mut dir_mode, - DirMode::Eight, - "8-way", - ); - }); - if dir_mode != entry.dir_mode { - entry.dir_mode = dir_mode; - self.recorder.set_dir_mode(dir_mode); - save_now = true; - } - }); - ui.label(format!( - "Gesture tokens: {}", - if entry.tokens.trim().is_empty() { - "∅" - } else { - entry.tokens.as_str() - } - )); - let recorded_tokens = self.recorder.tokens_string(); - ui.label(format!( - "Recorded tokens: {}", - if recorded_tokens.is_empty() { - "∅" - } else { - recorded_tokens.as_str() - } - )); - ui.horizontal(|ui| { - if ui.button("Use Recording").clicked() { - entry.tokens = recorded_tokens.clone(); - self.token_buffer = entry.tokens.clone(); - entry.stroke = self.recorder.normalized_stroke(); - self.recorder.reset(); - save_now = true; - } - if ui.button("Clear Recording").clicked() { + ui.label("Direction mode"); + let mut dir_mode = entry.dir_mode; + egui::ComboBox::from_id_source("mg_dir_mode") + .selected_text(match dir_mode { + DirMode::Four => "4-way", + DirMode::Eight => "8-way", + }) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut dir_mode, + DirMode::Four, + "4-way", + ); + ui.selectable_value( + &mut dir_mode, + DirMode::Eight, + "8-way", + ); + }); + if dir_mode != entry.dir_mode { + entry.dir_mode = dir_mode; + self.recorder.set_dir_mode(dir_mode); + save_now = true; + } + }); + ui.label(format!( + "Gesture tokens: {}", + if entry.tokens.trim().is_empty() { + "∅" + } else { + entry.tokens.as_str() + } + )); + let recorded_tokens = self.recorder.tokens_string(); + ui.label(format!( + "Recorded tokens: {}", + if recorded_tokens.is_empty() { + "∅" + } else { + recorded_tokens.as_str() + } + )); + ui.horizontal(|ui| { + if ui.button("Use Recording").clicked() { + entry.tokens = recorded_tokens.clone(); + self.token_buffer = entry.tokens.clone(); + entry.stroke = self.recorder.normalized_stroke(); + self.recorder.reset(); + save_now = true; + } + if ui.button("Clear Recording").clicked() { + self.recorder.reset(); + } + if !entry.stroke.is_empty() + && ui.button("Clear Saved").clicked() + { + entry.stroke.clear(); + save_now = true; + } + }); + let available = ui.available_width(); + let size = egui::vec2(available.max(320.0), 260.0); + let (rect, response) = + ui.allocate_exact_size(size, egui::Sense::drag()); + let painter = ui.painter_at(rect); + painter.rect_stroke( + rect, + 0.0, + egui::Stroke::new(1.0, egui::Color32::GRAY), + ); + if response.drag_started() { + // Starting a new recording replaces any existing saved preview stroke. + if !entry.stroke.is_empty() { + entry.stroke.clear(); + save_now = true; + } self.recorder.reset(); + if let Some(pos) = response.interact_pointer_pos() { + self.recorder.push_point(pos); + } } - if !entry.stroke.is_empty() - && ui.button("Clear Saved").clicked() - { - entry.stroke.clear(); - save_now = true; - } - }); - let available = ui.available_width(); - let size = egui::vec2(available.max(320.0), 260.0); - let (rect, response) = - ui.allocate_exact_size(size, egui::Sense::drag()); - let painter = ui.painter_at(rect); - painter.rect_stroke( - rect, - 0.0, - egui::Stroke::new(1.0, egui::Color32::GRAY), - ); - if response.drag_started() { - // Starting a new recording replaces any existing saved preview stroke. - if !entry.stroke.is_empty() { - entry.stroke.clear(); - save_now = true; - } - self.recorder.reset(); - if let Some(pos) = response.interact_pointer_pos() { - self.recorder.push_point(pos); - } - } - if response.dragged() { - if let Some(pos) = response.interact_pointer_pos() { - self.recorder.push_point(pos); + if response.dragged() { + if let Some(pos) = response.interact_pointer_pos() { + self.recorder.push_point(pos); + } } - } - if response.drag_stopped() { - let recorded_tokens = self.recorder.tokens_string(); - if !recorded_tokens.is_empty() { - entry.tokens = recorded_tokens.clone(); - self.token_buffer = entry.tokens.clone(); - entry.stroke = self.recorder.normalized_stroke(); - save_now = true; + if response.drag_stopped() { + let recorded_tokens = self.recorder.tokens_string(); + if !recorded_tokens.is_empty() { + entry.tokens = recorded_tokens.clone(); + self.token_buffer = entry.tokens.clone(); + entry.stroke = self.recorder.normalized_stroke(); + save_now = true; + } + //Clear the live drawing so the saved preview is shown + //immediately + self.recorder.reset(); } - //Clear the live drawing so the saved preview is shown - //immediately - self.recorder.reset(); - } - // Render the saved preview stroke (if any) behind the active recording. - if entry.stroke.len() >= 2 { - let pts = stroke_points_in_rect(&entry.stroke, rect); - if pts.len() >= 2 { + // Render the saved preview stroke (if any) behind the active recording. + if entry.stroke.len() >= 2 { + let pts = stroke_points_in_rect(&entry.stroke, rect); + if pts.len() >= 2 { + painter.add(egui::Shape::line( + pts, + egui::Stroke::new( + 2.0, + egui::Color32::from_gray(140), + ), + )); + } + } + if self.recorder.points().len() >= 2 { painter.add(egui::Shape::line( - pts, - egui::Stroke::new(2.0, egui::Color32::from_gray(140)), + self.recorder.points().to_vec(), + egui::Stroke::new(2.0, egui::Color32::LIGHT_BLUE), )); } - } - if self.recorder.points().len() >= 2 { - painter.add(egui::Shape::line( - self.recorder.points().to_vec(), - egui::Stroke::new(2.0, egui::Color32::LIGHT_BLUE), - )); - } - ui.separator(); - ui.horizontal(|ui| { - ui.label("Tokens"); - ui.text_edit_singleline(&mut self.token_buffer); - }); - ui.horizontal(|ui| { - if ui.button("Import").clicked() { - entry.tokens = self.token_buffer.trim().to_string(); - save_now = true; - } - if ui.button("Export").clicked() { - self.token_buffer = entry.tokens.clone(); - ctx.output_mut(|o| { - o.copied_text = self.token_buffer.clone(); - }); - } - }); + ui.separator(); + ui.horizontal(|ui| { + ui.label("Tokens"); + ui.text_edit_singleline(&mut self.token_buffer); + }); + ui.horizontal(|ui| { + if ui.button("Import").clicked() { + entry.tokens = self.token_buffer.trim().to_string(); + save_now = true; + } + if ui.button("Export").clicked() { + self.token_buffer = entry.tokens.clone(); + ctx.output_mut(|o| { + o.copied_text = self.token_buffer.clone(); + }); + } + }); ui.separator(); let binding_dialog = &mut self.binding_dialog; Self::bindings_ui( diff --git a/src/mouse_gestures/db.rs b/src/mouse_gestures/db.rs index 8b5b5a64..83014922 100644 --- a/src/mouse_gestures/db.rs +++ b/src/mouse_gestures/db.rs @@ -5,11 +5,29 @@ use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; pub const GESTURES_FILE: &str = "mouse_gestures.json"; -pub const SCHEMA_VERSION: u32 = 1; +pub const SCHEMA_VERSION: u32 = 2; +const LEGACY_SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum BindingKind { + Execute, + SetQuery, + SetQueryAndShow, + ToggleLauncher, +} + +impl Default for BindingKind { + fn default() -> Self { + BindingKind::Execute + } +} #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct BindingEntry { pub label: String, + #[serde(default)] + pub kind: BindingKind, pub action: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub args: Option, @@ -134,7 +152,11 @@ impl GestureDb { if binding.label.to_lowercase().contains(&query_lower) { fields.push(BindingMatchField::BindingLabel); } - if binding.action.to_lowercase().contains(&query_lower) { + if binding + .display_target() + .to_lowercase() + .contains(&query_lower) + { fields.push(BindingMatchField::Action); } if binding @@ -178,7 +200,7 @@ impl GestureDb { for gesture in self.gestures.iter().filter(|gesture| gesture.enabled) { for binding in gesture.bindings.iter().filter(|binding| binding.enabled) { if binding - .action + .action_string() .to_lowercase() .starts_with(&action_prefix) { @@ -419,12 +441,41 @@ impl GestureDb { } impl BindingEntry { + pub fn action_string(&self) -> String { + match self.kind { + BindingKind::Execute => self.action.clone(), + BindingKind::SetQuery => format!("query:{}", self.action), + BindingKind::SetQueryAndShow => "launcher:show".to_string(), + BindingKind::ToggleLauncher => "launcher:toggle".to_string(), + } + } + + pub fn display_target(&self) -> String { + match self.kind { + BindingKind::Execute => match &self.args { + Some(args) => format!("{} {}", self.action, args), + None => self.action.clone(), + }, + BindingKind::SetQuery => format!("query:{}", self.action), + BindingKind::SetQueryAndShow => format!("launcher:show (query: {})", self.action), + BindingKind::ToggleLauncher => "launcher:toggle".to_string(), + } + } + pub fn to_action(&self, gesture_label: &str) -> Action { + let (action, args) = match self.kind { + BindingKind::Execute => (self.action.clone(), self.args.clone()), + BindingKind::SetQuery => (format!("query:{}", self.action), None), + BindingKind::SetQueryAndShow => { + ("launcher:show".to_string(), Some(self.action.clone())) + } + BindingKind::ToggleLauncher => ("launcher:toggle".to_string(), None), + }; Action { label: self.label.clone(), desc: format!("Mouse gesture: {gesture_label}"), - action: self.action.clone(), - args: self.args.clone(), + action, + args, } } } @@ -434,14 +485,43 @@ pub fn load_gestures(path: &str) -> anyhow::Result { if content.trim().is_empty() { return Ok(GestureDb::default()); } - let db: GestureDb = serde_json::from_str(&content)?; - if db.schema_version != SCHEMA_VERSION { + let raw: serde_json::Value = serde_json::from_str(&content)?; + let version = raw + .get("schema_version") + .and_then(|v| v.as_u64()) + .unwrap_or(LEGACY_SCHEMA_VERSION as u64) as u32; + if version == SCHEMA_VERSION { + let db: GestureDb = serde_json::from_value(raw)?; + return Ok(db); + } + if version != LEGACY_SCHEMA_VERSION { return Err(anyhow::anyhow!( "Unsupported gesture schema version {}", - db.schema_version + version )); } - Ok(db) + + let legacy: LegacyGestureDb = serde_json::from_value(raw)?; + let gestures = legacy + .gestures + .into_iter() + .map(|gesture| GestureEntry { + label: gesture.label, + tokens: gesture.tokens, + dir_mode: gesture.dir_mode, + stroke: gesture.stroke, + enabled: gesture.enabled, + bindings: gesture + .bindings + .into_iter() + .map(|binding| binding.into_binding()) + .collect(), + }) + .collect(); + Ok(GestureDb { + schema_version: SCHEMA_VERSION, + gestures, + }) } pub fn save_gestures(path: &str, db: &GestureDb) -> anyhow::Result<()> { @@ -516,6 +596,75 @@ fn default_schema_version() -> u32 { SCHEMA_VERSION } +fn default_legacy_schema_version() -> u32 { + LEGACY_SCHEMA_VERSION +} + +#[derive(Debug, Clone, Deserialize)] +struct LegacyGestureDb { + #[serde(default = "default_legacy_schema_version")] + schema_version: u32, + #[serde(default)] + gestures: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct LegacyGestureEntry { + label: String, + tokens: String, + dir_mode: DirMode, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + stroke: Vec<[i16; 2]>, + #[serde(default = "default_enabled")] + enabled: bool, + #[serde(default)] + bindings: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct LegacyBindingEntry { + label: String, + action: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + args: Option, + #[serde(default = "default_enabled")] + enabled: bool, + #[serde(default)] + use_query: bool, +} + +impl LegacyBindingEntry { + fn into_binding(self) -> BindingEntry { + let action = self.action.trim().to_string(); + if action == "launcher:toggle" { + return BindingEntry { + label: self.label, + kind: BindingKind::ToggleLauncher, + action: String::new(), + args: None, + enabled: self.enabled, + }; + } + let (kind, action) = if self.use_query || action.starts_with("query:") { + let action = action + .strip_prefix("query:") + .unwrap_or(&action) + .trim() + .to_string(); + (BindingKind::SetQuery, action) + } else { + (BindingKind::Execute, action) + }; + BindingEntry { + label: self.label, + kind, + action, + args: self.args, + enabled: self.enabled, + } + } +} + fn fuzzy_score(needle: &str, haystack: &str) -> Option { let mut matched = 0_usize; let mut start_index = 0_usize; diff --git a/tests/mouse_gestures_db.rs b/tests/mouse_gestures_db.rs index d11ac64f..69813747 100644 --- a/tests/mouse_gestures_db.rs +++ b/tests/mouse_gestures_db.rs @@ -1,11 +1,12 @@ use eframe::egui; use multi_launcher::actions::{save_actions, Action}; use multi_launcher::gui::{ - send_event, LauncherApp, WatchEvent, + send_event, set_execute_action_hook, ActivationSource, LauncherApp, WatchEvent, }; use multi_launcher::mouse_gestures::db::{ - load_gestures, save_gestures, BindingEntry, BindingMatchField, GestureCandidate, GestureConflict, - GestureConflictKind, GestureDb, GestureEntry, GestureMatchType, SCHEMA_VERSION, + load_gestures, save_gestures, BindingEntry, BindingKind, BindingMatchField, GestureCandidate, + GestureConflict, GestureConflictKind, GestureDb, GestureEntry, GestureMatchType, + SCHEMA_VERSION, }; use multi_launcher::mouse_gestures::engine::DirMode; use multi_launcher::plugin::PluginManager; @@ -52,6 +53,7 @@ fn gesture_db_round_trip_serialization() { enabled: true, bindings: vec![BindingEntry { label: "Launch".into(), + kind: BindingKind::Execute, action: "stopwatch:show:1".into(), args: None, enabled: true, @@ -65,6 +67,58 @@ fn gesture_db_round_trip_serialization() { assert_eq!(db, loaded); } +#[test] +fn gesture_db_migrates_legacy_schema() { + let dir = tempdir().unwrap(); + let path = dir.path().join("mouse_gestures.json"); + std::fs::write( + &path, + r#"{ + "schema_version": 1, + "gestures": [ + { + "label": "Legacy", + "tokens": "LR", + "dir_mode": "Four", + "stroke": [], + "enabled": true, + "bindings": [ + { + "label": "Query", + "action": "query:calc", + "args": null, + "enabled": true + }, + { + "label": "UseQuery", + "action": "note list", + "args": null, + "enabled": true, + "use_query": true + }, + { + "label": "Toggle", + "action": "launcher:toggle", + "args": null, + "enabled": true + } + ] + } + ] +}"#, + ) + .unwrap(); + + let loaded = load_gestures(path.to_str().unwrap()).unwrap(); + assert_eq!(loaded.schema_version, SCHEMA_VERSION); + let bindings = &loaded.gestures[0].bindings; + assert_eq!(bindings[0].kind, BindingKind::SetQuery); + assert_eq!(bindings[0].action, "calc"); + assert_eq!(bindings[1].kind, BindingKind::SetQuery); + assert_eq!(bindings[1].action, "note list"); + assert_eq!(bindings[2].kind, BindingKind::ToggleLauncher); +} + #[test] fn gesture_db_rejects_unknown_schema_version() { let dir = tempdir().unwrap(); @@ -95,6 +149,7 @@ fn matching_skips_disabled_gestures_and_bindings() { enabled: false, bindings: vec![BindingEntry { label: "Launch".into(), + kind: BindingKind::Execute, action: "stopwatch:show:1".into(), args: None, enabled: true, @@ -108,6 +163,7 @@ fn matching_skips_disabled_gestures_and_bindings() { enabled: true, bindings: vec![BindingEntry { label: "Launch".into(), + kind: BindingKind::Execute, action: "stopwatch:show:2".into(), args: None, enabled: false, @@ -134,12 +190,14 @@ fn binding_resolution_is_deterministic() { bindings: vec![ BindingEntry { label: "Primary".into(), + kind: BindingKind::Execute, action: "stopwatch:show:1".into(), args: None, enabled: true, }, BindingEntry { label: "Secondary".into(), + kind: BindingKind::Execute, action: "stopwatch:show:2".into(), args: None, enabled: true, @@ -154,6 +212,7 @@ fn binding_resolution_is_deterministic() { enabled: true, bindings: vec![BindingEntry { label: "Tertiary".into(), + kind: BindingKind::Execute, action: "stopwatch:show:3".into(), args: None, enabled: true, @@ -181,6 +240,7 @@ fn binding_enabled_state_persists_and_controls_matching() { enabled: true, bindings: vec![BindingEntry { label: "Launch".into(), + kind: BindingKind::Execute, action: "stopwatch:show:1".into(), args: None, enabled: false, @@ -212,6 +272,7 @@ fn candidate_matching_ranks_exact_over_prefix_over_fuzzy() { enabled: true, bindings: vec![BindingEntry { label: "Exact bind".into(), + kind: BindingKind::Execute, action: "stopwatch:show:1".into(), args: None, enabled: true, @@ -225,6 +286,7 @@ fn candidate_matching_ranks_exact_over_prefix_over_fuzzy() { enabled: true, bindings: vec![BindingEntry { label: "Prefix bind".into(), + kind: BindingKind::Execute, action: "stopwatch:show:2".into(), args: None, enabled: true, @@ -238,6 +300,7 @@ fn candidate_matching_ranks_exact_over_prefix_over_fuzzy() { enabled: true, bindings: vec![BindingEntry { label: "Fuzzy bind".into(), + kind: BindingKind::Execute, action: "stopwatch:show:3".into(), args: None, enabled: true, @@ -265,6 +328,7 @@ fn search_bindings_matches_across_fields() { enabled: true, bindings: vec![BindingEntry { label: "Primary".into(), + kind: BindingKind::Execute, action: "browser:open".into(), args: Some("profile=work".into()), enabled: true, @@ -304,6 +368,7 @@ fn find_by_action_matches_prefixes() { enabled: true, bindings: vec![BindingEntry { label: "Primary".into(), + kind: BindingKind::Execute, action: "browser:open".into(), args: None, enabled: true, @@ -330,6 +395,7 @@ fn find_conflicts_groups_duplicates_and_prefixes() { enabled: true, bindings: vec![BindingEntry { label: "Primary".into(), + kind: BindingKind::Execute, action: "browser:open".into(), args: None, enabled: true, @@ -343,6 +409,7 @@ fn find_conflicts_groups_duplicates_and_prefixes() { enabled: true, bindings: vec![BindingEntry { label: "Secondary".into(), + kind: BindingKind::Execute, action: "mail:open".into(), args: None, enabled: true, @@ -356,6 +423,7 @@ fn find_conflicts_groups_duplicates_and_prefixes() { enabled: true, bindings: vec![BindingEntry { label: "Tertiary".into(), + kind: BindingKind::Execute, action: "settings:open".into(), args: None, enabled: true, @@ -369,6 +437,7 @@ fn find_conflicts_groups_duplicates_and_prefixes() { enabled: true, bindings: vec![BindingEntry { label: "Alt".into(), + kind: BindingKind::Execute, action: "other:open".into(), args: None, enabled: true, @@ -437,3 +506,30 @@ fn watch_event_executes_action() { assert_eq!(app.query, "after"); assert!(app.move_cursor_end_flag()); } + +#[test] +fn set_query_binding_avoids_execute_action() { + let _lock = TEST_MUTEX.lock().unwrap(); + let executed = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let executed_hook = Arc::clone(&executed); + set_execute_action_hook(Some(Box::new(move |_| { + executed_hook.fetch_add(1, Ordering::SeqCst); + Ok(()) + }))); + + let ctx = egui::Context::default(); + let mut app = new_app(&ctx, Vec::new()); + let binding = BindingEntry { + label: "Query".into(), + kind: BindingKind::SetQuery, + action: "timer list".into(), + args: None, + enabled: true, + }; + let action = binding.to_action("Gesture"); + assert_eq!(action.action, "query:timer list"); + app.activate_action(action, None, ActivationSource::Gesture); + + assert_eq!(executed.load(Ordering::SeqCst), 0); + set_execute_action_hook(None); +} diff --git a/tests/mouse_gestures_service.rs b/tests/mouse_gestures_service.rs index 4ab3cd68..7b572f0a 100644 --- a/tests/mouse_gestures_service.rs +++ b/tests/mouse_gestures_service.rs @@ -1,4 +1,6 @@ -use multi_launcher::mouse_gestures::db::{BindingEntry, GestureDb, GestureEntry, SCHEMA_VERSION}; +use multi_launcher::mouse_gestures::db::{ + BindingEntry, BindingKind, GestureDb, GestureEntry, SCHEMA_VERSION, +}; use multi_launcher::mouse_gestures::engine::DirMode; use multi_launcher::mouse_gestures::overlay::OverlayBackend; use multi_launcher::mouse_gestures::service::{ @@ -324,6 +326,7 @@ fn hint_text_includes_best_guess_and_match_type() { enabled: true, bindings: vec![BindingEntry { label: "Open Browser".into(), + kind: BindingKind::Execute, action: "stopwatch:show:1".into(), args: None, enabled: true, diff --git a/tests/mouse_gestures_ui.rs b/tests/mouse_gestures_ui.rs index 433579aa..5555bddb 100644 --- a/tests/mouse_gestures_ui.rs +++ b/tests/mouse_gestures_ui.rs @@ -1,8 +1,8 @@ use eframe::egui::Pos2; use multi_launcher::gui::{GestureRecorder, RecorderConfig}; use multi_launcher::mouse_gestures::db::{ - format_gesture_label, load_gestures, save_gestures, BindingEntry, GestureEntry, GestureDb, - SCHEMA_VERSION, + format_gesture_label, load_gestures, save_gestures, BindingEntry, BindingKind, GestureDb, + GestureEntry, SCHEMA_VERSION, }; use multi_launcher::mouse_gestures::engine::DirMode; use tempfile::tempdir; @@ -18,12 +18,14 @@ fn gesture_label_formatting_includes_tokens_and_bindings() { bindings: vec![ BindingEntry { label: "Browser back".into(), + kind: BindingKind::Execute, action: "app:back".into(), args: None, enabled: true, }, BindingEntry { label: "Disabled action".into(), + kind: BindingKind::Execute, action: "app:noop".into(), args: None, enabled: false, @@ -63,12 +65,14 @@ fn binding_order_changes_persist_after_save_load() { bindings: vec![ BindingEntry { label: "First".into(), + kind: BindingKind::Execute, action: "app:first".into(), args: None, enabled: true, }, BindingEntry { label: "Second".into(), + kind: BindingKind::Execute, action: "app:second".into(), args: None, enabled: true, diff --git a/tests/trigger_visibility.rs b/tests/trigger_visibility.rs index 00c9850d..21d0343d 100644 --- a/tests/trigger_visibility.rs +++ b/tests/trigger_visibility.rs @@ -3,8 +3,8 @@ use multi_launcher::actions::Action; use multi_launcher::hotkey::{Hotkey, HotkeyTrigger}; use multi_launcher::plugin::PluginManager; use multi_launcher::settings::Settings; -use multi_launcher::{gui::ActivationSource, gui::LauncherApp}; use multi_launcher::visibility::handle_visibility_trigger; +use multi_launcher::{gui::ActivationSource, gui::LauncherApp}; use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, Mutex,