Actions: Select Object matches letter case per character and rejects mistyped dialog fields

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.
This commit is contained in:
dotCooCoo
2026-11-12 16:03:57 -08:00
parent 68fe1dbaea
commit 800715b42f
4 changed files with 99 additions and 14 deletions
+63 -12
View File
@@ -784,29 +784,80 @@ fn by_note(s: &mut Session, p: &Value) -> Result<Value> {
}
/// 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<char> = 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<Item = char> {
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 {
+20
View File
@@ -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.
@@ -50,8 +50,9 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
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();
}
+13
View File
@@ -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");