From 800715b42fcd0de5bdf860f3b1be176b67bb56bf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 16:03:57 -0800 Subject: [PATCH] Actions: Select Object matches letter case per character and rejects mistyped dialog fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit select.byNote compares letter case one character at a time, so a final sigma matches σ, and Whole Word checks its boundaries against the note's own characters, so İ neither ends a word early nor matches half of itself. The search runs in time proportional to the note's length. Set Selection passes its fields to ui.action.insertSelectObject as they are, so a note or option of another type set through ui.dialog.set is an error and the dialog stays open. --- crates/engine/src/cmd/select.rs | 75 ++++++++++++++++--- crates/engine/src/tests/attributes.rs | 20 +++++ .../src/dialogs/action_select_object.rs | 5 +- crates/ui-egui/src/panels/actions.rs | 13 ++++ 4 files changed, 99 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 5f4a0b4d50..ea307c7575 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -784,29 +784,80 @@ fn by_note(s: &mut Session, p: &Value) -> Result { } /// Whether `note` holds `query`: anywhere, and in any letter case unless `case_sensitive`. With -/// `whole_word`, the text right before and right after it is not a letter, a digit or `_`. +/// `whole_word`, the match starts and ends on whole characters of `note`, and the characters of +/// `note` right before and right after it are not a letter, a digit or `_`. +/// +/// Letter case is compared one character at a time ([`fold_case`]), so a final Σ still matches σ, +/// and each folded character keeps the byte offset of the character of `note` it comes from. fn note_matches(note: &str, query: &str, case_sensitive: bool, whole_word: bool) -> bool { if note.is_empty() || query.is_empty() { return false; } - let (note, query) = if case_sensitive { (note.to_string(), query.to_string()) } else { (note.to_lowercase(), query.to_lowercase()) }; - if !whole_word { - return note.contains(&query); - } + let fold = |text: &str| -> Vec<(char, usize)> { + let mut out = Vec::with_capacity(text.len()); + for (at, c) in text.char_indices() { + if case_sensitive { + out.push((c, at)); + } else { + out.extend(fold_case(c).map(|f| (f, at))); + } + } + out + }; + let folded = fold(note); + let query: Vec = fold(query).into_iter().map(|(c, _)| c).collect(); let word = |c: char| c.is_alphanumeric() || c == '_'; - let mut from = 0; - while let Some(at) = note.get(from..).and_then(|rest| rest.find(&query)).map(|i| from + i) { - let before = note[..at].chars().next_back(); - let after = note[at + query.len()..].chars().next(); - if !before.is_some_and(word) && !after.is_some_and(word) { + // Whether the match of `query` that starts at `folded[i]` counts. + let accept = |i: usize| { + if !whole_word { return true; } - // Matches may overlap: look again from the next character. - from = at + note[at..].chars().next().map_or(1, char::len_utf8); + let (start, last) = (folded[i].1, folded[i + query.len() - 1].1); + let starts_whole = i == 0 || folded[i - 1].1 != start; + let ends_whole = folded.get(i + query.len()).is_none_or(|(_, at)| *at != last); + if !starts_whole || !ends_whole { + return false; + } + let end = last + note[last..].chars().next().map_or(0, char::len_utf8); + !note[..start].chars().next_back().is_some_and(word) && !note[end..].chars().next().is_some_and(word) + }; + // Knuth-Morris-Pratt, so a long note takes time in proportion to its length, and every place + // the query occurs is tried, also places that overlap. `fail[k]` is the length of the longest + // proper prefix of `query[..=k]` that is also its suffix. + let mut fail = vec![0; query.len()]; + let mut k = 0; + for i in 1..query.len() { + while k > 0 && query[i] != query[k] { + k = fail[k - 1]; + } + if query[i] == query[k] { + k += 1; + } + fail[i] = k; + } + let mut k = 0; + for (j, (c, _)) in folded.iter().enumerate() { + while k > 0 && *c != query[k] { + k = fail[k - 1]; + } + if *c == query[k] { + k += 1; + } + if k == query.len() { + if accept(j + 1 - k) { + return true; + } + k = fail[k - 1]; + } } false } +/// `c` in lowercase for a comparison that ignores letter case, with the final sigma ς read as σ. +fn fold_case(c: char) -> impl Iterator { + c.to_lowercase().map(|l| if l == 'ς' { 'σ' } else { l }) +} + /// Does `document.inspect` report a fill and stroke for `n`? A compound path's are its own, though /// it holds its members, and its members report none. fn paint_reported(d: &Document, n: &Node) -> bool { diff --git a/crates/engine/src/tests/attributes.rs b/crates/engine/src/tests/attributes.rs index 606200481e..d3edf2615b 100644 --- a/crates/engine/src/tests/attributes.rs +++ b/crates/engine/src/tests/attributes.rs @@ -269,6 +269,26 @@ fn select_by_note_whole_word_matches_overlapping_places_and_other_scripts() { assert!(!matches(&mut s, "", json!({"note": "a"}))); } +/// Letter case is compared one character at a time: a final Σ, whose lowercase is ς, matches Σ +/// and σ. Whole Word looks at the characters of the note itself, so a letter whose lowercase +/// takes two characters (İ) neither ends a word early nor matches half of itself. +#[test] +fn select_by_note_compares_letter_case_per_character() { + let mut s = session(); + let a = rect(&mut s, 0.0); + let matches = |s: &mut Session, text: &str, p: Value| { + note(s, a, text); + by_note(s, p).is_ok() + }; + assert!(matches(&mut s, "ΟΣ", json!({"note": "Σ"}))); + assert!(matches(&mut s, "ΟΣ", json!({"note": "σ"}))); + assert!(matches(&mut s, "ΟΣ", json!({"note": "ος", "wholeWord": true}))); + assert!(!matches(&mut s, "ΟΣ", json!({"note": "σ", "caseSensitive": true}))); + assert!(!matches(&mut s, "İx", json!({"note": "i", "wholeWord": true}))); + assert!(matches(&mut s, "İ x", json!({"note": "İ", "wholeWord": true}))); + assert!(!matches(&mut s, "İ x", json!({"note": "i", "wholeWord": true}))); +} + /// Select by Note leaves out hidden and locked objects, selects a group whose note matches without /// its members and a member whose note matches on its own, keeps to the isolated group, and leaves /// the selection as it was when it finds nothing. Bad params are errors that change nothing. diff --git a/crates/ui-egui/src/dialogs/action_select_object.rs b/crates/ui-egui/src/dialogs/action_select_object.rs index 80a72652c9..455ae61cac 100644 --- a/crates/ui-egui/src/dialogs/action_select_object.rs +++ b/crates/ui-egui/src/dialogs/action_select_object.rs @@ -50,8 +50,9 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { - let mut params = json!({"note": d.str("note"), "caseSensitive": d.bool("caseSensitive"), "wholeWord": d.bool("wholeWord")}); - for key in ["set", "action", "step"] { + // The fields go as they are, so a field of another type is an error rather than a default. + let mut params = json!({}); + for key in ["note", "caseSensitive", "wholeWord", "set", "action", "step"] { if let Some(value) = d.fields.get(key) { params[key] = value.clone(); } diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 3fa7ba5824..fc70693ce8 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -5863,9 +5863,22 @@ mod tests { assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 3, "Cancel inserts nothing"); open(&ctx, &mut app); + // A field of another type (set through ui.dialog.set) is an error, and the dialog stays. + for (key, value) in [("note", json!(5)), ("caseSensitive", json!("yes")), ("wholeWord", json!(1))] { + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("note".into(), json!("Badge")); + d.fields.insert("caseSensitive".into(), json!(false)); + d.fields.insert("wholeWord".into(), json!(false)); + d.fields.insert(key.into(), value.clone()); + } + assert!(crate::dialogs::confirm(&mut app).is_err(), "`{key}`: {value} is an error"); + assert_eq!(open_kind(&app), Some(crate::dialogs::action_select_object::KIND), "`{key}`: the dialog stays open"); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 3, "`{key}`: nothing is inserted"); + } if let Some(d) = app.ui.dialog.as_mut() { d.fields.insert("note".into(), json!("Badge")); d.fields.insert("caseSensitive".into(), json!(true)); + d.fields.insert("wholeWord".into(), json!(false)); } crate::dialogs::confirm(&mut app).unwrap(); assert_eq!(open_kind(&app), None, "OK closes Set Selection");