mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 08:29:14 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user