Skip to content

Commit c85cb95

Browse files
authored
Merge pull request #824 from multiplex55/codex/implement-auto-labeling-for-mouse-gestures
Auto-label gestures from recordings and generalize plugin list expansion in binding picker
2 parents 3462784 + c06b07c commit c85cb95

1 file changed

Lines changed: 226 additions & 71 deletions

File tree

src/gui/mouse_gestures_dialog.rs

Lines changed: 226 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use crate::mouse_gestures::db::{
66
};
77
use crate::mouse_gestures::engine::{DirMode, GestureTracker};
88
use crate::mouse_gestures::service::MouseGestureConfig;
9+
use crate::plugin::Plugin;
910
use eframe::egui;
1011

1112
#[derive(Debug, Clone, Copy)]
@@ -122,6 +123,125 @@ fn stroke_points_in_rect(stroke: &[[i16; 2]], rect: egui::Rect) -> Vec<egui::Pos
122123
.collect()
123124
}
124125

126+
const AUTO_LABEL_PREFIX: &str = "Gesture";
127+
const AUTO_LABEL_TOKEN_MAX: usize = 24;
128+
129+
fn normalize_recorded_tokens_for_label(tokens: &str) -> String {
130+
tokens
131+
.chars()
132+
.filter(|c| c.is_ascii_alphanumeric())
133+
.map(|c| c.to_ascii_uppercase())
134+
.take(AUTO_LABEL_TOKEN_MAX)
135+
.collect()
136+
}
137+
138+
fn label_from_recorded_tokens(tokens: &str) -> Option<String> {
139+
let normalized = normalize_recorded_tokens_for_label(tokens);
140+
if normalized.is_empty() {
141+
None
142+
} else {
143+
Some(format!("{AUTO_LABEL_PREFIX} {normalized}"))
144+
}
145+
}
146+
147+
fn is_default_generated_label(label: &str) -> bool {
148+
let trimmed = label.trim();
149+
if trimmed.is_empty() {
150+
return true;
151+
}
152+
153+
let Some(rest) = trimmed.strip_prefix(AUTO_LABEL_PREFIX) else {
154+
return false;
155+
};
156+
let rest = rest.trim();
157+
if rest.is_empty() {
158+
return true;
159+
}
160+
161+
rest.chars().all(|c| c.is_ascii_digit()) || rest == normalize_recorded_tokens_for_label(rest)
162+
}
163+
164+
fn list_query_prefix_for_plugin(plugin_name: &str) -> Option<&'static str> {
165+
match plugin_name {
166+
"folders" => Some("f list"),
167+
"bookmarks" => Some("bm list"),
168+
"clipboard" => Some("cb list"),
169+
"emoji" => Some("emoji list"),
170+
"notes" => Some("note list"),
171+
_ => None,
172+
}
173+
}
174+
175+
fn resolve_action_source(plugin: &dyn Plugin, filter: &str) -> Vec<crate::actions::Action> {
176+
if let Some(prefix) = list_query_prefix_for_plugin(plugin.name()) {
177+
let query = if filter.trim().is_empty() {
178+
prefix.to_string()
179+
} else {
180+
format!("{prefix} {}", filter.trim())
181+
};
182+
let actions = plugin.search(&query);
183+
if !actions.is_empty() {
184+
return actions;
185+
}
186+
}
187+
plugin.commands()
188+
}
189+
190+
fn append_query_args(query: &str, add_args: &str) -> String {
191+
let extra = add_args.trim();
192+
if extra.is_empty() {
193+
return query.to_string();
194+
}
195+
if query.ends_with(' ') {
196+
format!("{query}{extra}")
197+
} else {
198+
format!("{query} {extra}")
199+
}
200+
}
201+
202+
fn apply_recording_to_entry(
203+
entry: &mut GestureEntry,
204+
token_buffer: &mut String,
205+
recorded_tokens: &str,
206+
normalized_stroke: Vec<[i16; 2]>,
207+
) {
208+
entry.tokens = recorded_tokens.to_string();
209+
*token_buffer = entry.tokens.clone();
210+
entry.stroke = normalized_stroke;
211+
212+
if is_default_generated_label(&entry.label) {
213+
if let Some(auto_label) = label_from_recorded_tokens(recorded_tokens) {
214+
entry.label = auto_label;
215+
}
216+
}
217+
}
218+
219+
fn apply_action_pick(editor: &mut BindingEditor, act: &crate::actions::Action, add_args: &str) {
220+
if let Some(query) = act.action.strip_prefix("query:") {
221+
editor.kind = match editor.kind {
222+
BindingKind::SetQueryAndShow => BindingKind::SetQueryAndShow,
223+
BindingKind::SetQueryAndExecute => BindingKind::SetQueryAndExecute,
224+
_ => BindingKind::SetQuery,
225+
};
226+
editor.action = append_query_args(query, add_args);
227+
editor.args.clear();
228+
} else if act.action == "launcher:toggle" {
229+
editor.kind = BindingKind::ToggleLauncher;
230+
editor.action.clear();
231+
editor.args.clear();
232+
} else {
233+
editor.kind = BindingKind::Execute;
234+
editor.action = act.action.clone();
235+
editor.args = if add_args.trim().is_empty() {
236+
act.args.clone().unwrap_or_default()
237+
} else {
238+
add_args.trim().to_string()
239+
};
240+
}
241+
editor.label = act.label.clone();
242+
editor.add_args.clear();
243+
}
244+
125245
pub struct GestureRecorder {
126246
config: RecorderConfig,
127247
tracker: GestureTracker,
@@ -691,43 +811,16 @@ impl MgGesturesDialog {
691811
}
692812
if ui.button(format!("{} - {}", act.label, act.desc)).clicked()
693813
{
694-
editor.label = act.label.clone();
695-
if let Some(query) = act.action.strip_prefix("query:") {
696-
editor.kind = match editor.kind {
697-
BindingKind::SetQueryAndShow => {
698-
BindingKind::SetQueryAndShow
699-
}
700-
BindingKind::SetQueryAndExecute => {
701-
BindingKind::SetQueryAndExecute
702-
}
703-
_ => BindingKind::SetQuery,
704-
};
705-
editor.action = query.to_string();
706-
editor.args.clear();
707-
} else if act.action == "launcher:toggle" {
708-
editor.kind = BindingKind::ToggleLauncher;
709-
editor.action.clear();
710-
editor.args.clear();
711-
} else {
712-
editor.kind = BindingKind::Execute;
713-
editor.action = act.action.clone();
714-
editor.args = act.args.clone().unwrap_or_default();
715-
}
716-
editor.add_args.clear();
814+
apply_action_pick(editor, act, "");
717815
}
718816
}
719817
});
720818
} else if let Some(plugin) =
721819
app.plugins.iter().find(|p| p.name() == editor.add_plugin)
722820
{
723821
let filter = editor.add_filter.trim().to_lowercase();
724-
let mut actions = if plugin.name() == "folders" {
725-
plugin.search(&format!("f list {}", editor.add_filter))
726-
} else if plugin.name() == "bookmarks" {
727-
plugin.search(&format!("bm list {}", editor.add_filter))
728-
} else {
729-
plugin.commands()
730-
};
822+
let mut actions =
823+
resolve_action_source(plugin.as_ref(), &editor.add_filter);
731824
egui::ScrollArea::vertical()
732825
.id_source("mg_binding_action_list")
733826
.max_height(160.0)
@@ -742,41 +835,8 @@ impl MgGesturesDialog {
742835
}
743836
if ui.button(format!("{} - {}", act.label, act.desc)).clicked()
744837
{
745-
let args = if editor.add_args.trim().is_empty() {
746-
None
747-
} else {
748-
Some(editor.add_args.clone())
749-
};
750-
751-
if let Some(query) = act.action.strip_prefix("query:") {
752-
let mut query = query.to_string();
753-
if let Some(ref a) = args {
754-
query.push_str(a);
755-
}
756-
editor.kind = match editor.kind {
757-
BindingKind::SetQueryAndShow => {
758-
BindingKind::SetQueryAndShow
759-
}
760-
BindingKind::SetQueryAndExecute => {
761-
BindingKind::SetQueryAndExecute
762-
}
763-
_ => BindingKind::SetQuery,
764-
};
765-
editor.action = query;
766-
editor.args.clear();
767-
} else if act.action == "launcher:toggle" {
768-
editor.kind = BindingKind::ToggleLauncher;
769-
editor.action.clear();
770-
editor.args.clear();
771-
} else {
772-
editor.kind = BindingKind::Execute;
773-
editor.action = act.action.clone();
774-
editor.args = args.unwrap_or_else(|| {
775-
act.args.clone().unwrap_or_default()
776-
});
777-
}
778-
editor.label = act.label.clone();
779-
editor.add_args.clear();
838+
let add_args = editor.add_args.clone();
839+
apply_action_pick(editor, &act, &add_args);
780840
}
781841
}
782842
});
@@ -1022,9 +1082,14 @@ impl MgGesturesDialog {
10221082
));
10231083
ui.horizontal(|ui| {
10241084
if ui.button("Use Recording").clicked() {
1025-
entry.tokens = recorded_tokens.clone();
1026-
self.token_buffer = entry.tokens.clone();
1027-
entry.stroke = self.recorder.normalized_stroke();
1085+
let normalized_stroke =
1086+
self.recorder.normalized_stroke();
1087+
apply_recording_to_entry(
1088+
entry,
1089+
&mut self.token_buffer,
1090+
&recorded_tokens,
1091+
normalized_stroke,
1092+
);
10281093
self.recorder.reset();
10291094
save_now = true;
10301095
}
@@ -1067,9 +1132,14 @@ impl MgGesturesDialog {
10671132
if response.drag_stopped() {
10681133
let recorded_tokens = self.recorder.tokens_string();
10691134
if !recorded_tokens.is_empty() {
1070-
entry.tokens = recorded_tokens.clone();
1071-
self.token_buffer = entry.tokens.clone();
1072-
entry.stroke = self.recorder.normalized_stroke();
1135+
let normalized_stroke =
1136+
self.recorder.normalized_stroke();
1137+
apply_recording_to_entry(
1138+
entry,
1139+
&mut self.token_buffer,
1140+
&recorded_tokens,
1141+
normalized_stroke,
1142+
);
10731143
save_now = true;
10741144
}
10751145
//Clear the live drawing so the saved preview is shown
@@ -1207,4 +1277,89 @@ mod tests {
12071277
assert_eq!(dlg.selected_idx, Some(1));
12081278
assert_eq!(dlg.rename_idx, Some(1));
12091279
}
1280+
1281+
#[derive(Clone)]
1282+
struct MockPlugin {
1283+
name: String,
1284+
search_results: Vec<crate::actions::Action>,
1285+
command_results: Vec<crate::actions::Action>,
1286+
}
1287+
1288+
impl crate::plugin::Plugin for MockPlugin {
1289+
fn search(&self, _query: &str) -> Vec<crate::actions::Action> {
1290+
self.search_results.clone()
1291+
}
1292+
1293+
fn name(&self) -> &str {
1294+
&self.name
1295+
}
1296+
1297+
fn description(&self) -> &str {
1298+
"mock"
1299+
}
1300+
1301+
fn capabilities(&self) -> &[&str] {
1302+
&[]
1303+
}
1304+
1305+
fn commands(&self) -> Vec<crate::actions::Action> {
1306+
self.command_results.clone()
1307+
}
1308+
}
1309+
1310+
#[test]
1311+
fn auto_label_is_applied_only_for_default_generated_labels() {
1312+
let mut entry = gesture("Gesture 3", "");
1313+
1314+
let mut token_buffer = String::new();
1315+
apply_recording_to_entry(&mut entry, &mut token_buffer, "udlr", Vec::new());
1316+
1317+
assert_eq!(entry.tokens, "udlr");
1318+
assert_eq!(entry.label, "Gesture UDLR");
1319+
}
1320+
1321+
#[test]
1322+
fn customized_label_is_preserved_when_new_recording_is_applied() {
1323+
let mut entry = gesture("My Favorite Gesture", "UD");
1324+
1325+
let mut token_buffer = String::new();
1326+
apply_recording_to_entry(&mut entry, &mut token_buffer, "LR", Vec::new());
1327+
1328+
assert_eq!(entry.tokens, "LR");
1329+
assert_eq!(entry.label, "My Favorite Gesture");
1330+
}
1331+
1332+
#[test]
1333+
fn resolve_action_source_prefers_mapped_list_search_and_falls_back_to_commands() {
1334+
let list_action = crate::actions::Action {
1335+
label: "Clipboard Entry".into(),
1336+
desc: "Clipboard".into(),
1337+
action: "clipboard:copy:1".into(),
1338+
args: None,
1339+
};
1340+
let command_action = crate::actions::Action {
1341+
label: "cb list".into(),
1342+
desc: "Clipboard".into(),
1343+
action: "query:cb list".into(),
1344+
args: None,
1345+
};
1346+
1347+
let mapped_plugin = MockPlugin {
1348+
name: "clipboard".into(),
1349+
search_results: vec![list_action.clone()],
1350+
command_results: vec![command_action.clone()],
1351+
};
1352+
let mapped_actions = resolve_action_source(&mapped_plugin, "abc");
1353+
assert_eq!(mapped_actions.len(), 1);
1354+
assert_eq!(mapped_actions[0].action, "clipboard:copy:1");
1355+
1356+
let fallback_plugin = MockPlugin {
1357+
name: "custom".into(),
1358+
search_results: vec![list_action],
1359+
command_results: vec![command_action.clone()],
1360+
};
1361+
let fallback_actions = resolve_action_source(&fallback_plugin, "abc");
1362+
assert_eq!(fallback_actions.len(), 1);
1363+
assert_eq!(fallback_actions[0].action, command_action.action);
1364+
}
12101365
}

0 commit comments

Comments
 (0)