Merge main into type-3d-redraw

This commit is contained in:
Drew Dibble
2026-11-09 13:20:30 -07:00
62 changed files with 1188 additions and 141 deletions
+96 -45
View File
@@ -7,11 +7,13 @@
//! `removeWord`, `changeWord`), which every document's check takes as right; Ignore All
//! (`text.spelling.ignoreAll`) skips a word in the active document only.
use std::collections::BTreeSet;
use std::ops::Range;
use std::sync::atomic::{AtomicU64, Ordering};
use craft_proof::{IssueKind, Options, ProofLang};
use serde_json::{Value, json};
use vectorcraft_doc::{Document, NodeId, NodeKind};
use vectorcraft_doc::{Document, NodeId, NodeKind, TextObject};
use super::menucmds::whole_or;
use super::typemenu::{all_texts, text_ids};
@@ -106,56 +108,96 @@ fn check(s: &mut Session, p: &Value) -> Result<Value> {
None => all_texts(&s.doc()?.doc),
};
let st = s.doc()?;
let ignored = &st.spelling_ignored;
let opts = Options::default();
let mut words = vec![];
let mut more = false;
'texts: for id in ids {
for id in ids {
let Some(NodeKind::Text(t)) = st.doc.node(id).map(|n| &n.kind) else { continue };
let text = t.plain_text();
// Each run in its own language, at its bytes of the plain text.
let mut spans: Vec<(Range<usize>, ProofLang)> = Vec::with_capacity(t.runs.len());
let mut at = 0;
for r in &t.runs {
let end = at + r.text.len();
spans.push((at..end, ProofLang::from_tag(r.style.language.as_deref())));
at = end;
let (found, cut) = misspellings(t, &st.spelling_ignored, MAX_WORDS - words.len());
for m in found {
let Some(word) = text.get(m.range.clone()) else { continue };
words.push(json!({ "id": id.0, "start": m.range.start, "end": m.range.end, "word": word, "lang": m.lang.tag() }));
}
// craft-proof checks one paragraph at a time (its language detection reads the
// paragraph), and the check stops at the paragraph where the list fills up rather than
// proofing the rest of the text.
let mut para_start = 0;
for para in text.split('\n') {
let para_end = para_start + para.len();
let para_spans: Vec<(Range<usize>, ProofLang)> = spans
.iter()
.filter_map(|(r, lang)| {
let (a, b) = (r.start.max(para_start), r.end.min(para_end));
(a < b).then(|| (a - para_start..b - para_start, *lang))
})
.collect();
for issue in craft_proof::check_text_with(para, &para_spans, &opts, false, false) {
if issue.kind != IssueKind::Spelling {
continue;
}
let (start, end) = (para_start + issue.start, para_start + issue.end);
let Some(word) = text.get(start..end) else { continue };
if craft_proof::custom_word(word).is_some_and(|w| ignored.contains(&w)) {
continue;
}
if words.len() == MAX_WORDS {
more = true;
break 'texts;
}
words.push(json!({ "id": id.0, "start": start, "end": end, "word": word, "lang": issue.lang.tag() }));
}
// Past the paragraph and its `\n`.
para_start = para_end + 1;
if cut {
more = true;
break;
}
}
Ok(json!({ "words": words, "more": more }))
}
/// One word of a type object its dictionaries don't know ([`misspellings`]).
#[derive(Clone, Debug, PartialEq)]
pub struct Misspelling {
/// The word's bytes in the object's [`TextObject::plain_text`].
pub range: Range<usize>,
/// The language it was checked in.
pub lang: ProofLang,
}
/// The words of `t` its dictionaries don't know, in text order: each run checked in its
/// language, each paragraph on its own, leaving out the words in `ignored` (a document's Ignore
/// All words). At most `max` of them; the flag says the list was cut short there. What
/// `text.spelling.check` lists for one object, and what Auto Spell Check underlines.
pub fn misspellings(t: &TextObject, ignored: &BTreeSet<String>, max: usize) -> (Vec<Misspelling>, bool) {
let text = t.plain_text();
// Each run in its own language, at its bytes of the plain text.
let mut spans: Vec<(Range<usize>, ProofLang)> = Vec::with_capacity(t.runs.len());
let mut at = 0;
for r in &t.runs {
let end = at + r.text.len();
spans.push((at..end, ProofLang::from_tag(r.style.language.as_deref())));
at = end;
}
let opts = Options::default();
let mut found = vec![];
// craft-proof checks one paragraph at a time (its language detection reads the paragraph),
// and the check stops at the paragraph where the list fills up rather than proofing the rest
// of the text.
let mut para_start = 0;
for para in text.split('\n') {
let para_end = para_start + para.len();
let para_spans: Vec<(Range<usize>, ProofLang)> = spans
.iter()
.filter_map(|(r, lang)| {
let (a, b) = (r.start.max(para_start), r.end.min(para_end));
(a < b).then(|| (a - para_start..b - para_start, *lang))
})
.collect();
for issue in craft_proof::check_text_with(para, &para_spans, &opts, false, false) {
if issue.kind != IssueKind::Spelling {
continue;
}
let (start, end) = (para_start + issue.start, para_start + issue.end);
let Some(word) = text.get(start..end) else { continue };
if craft_proof::custom_word(word).is_some_and(|w| ignored.contains(&w)) {
continue;
}
if found.len() == max {
return (found, true);
}
found.push(Misspelling { range: start..end, lang: issue.lang });
}
// Past the paragraph and its `\n`.
para_start = para_end + 1;
}
(found, false)
}
/// Goes up each time the custom dictionary may have changed: an add, delete or change (saved or
/// not, since a save takes in the words another window saved), and a dictionary file attached.
static DICTIONARY_GENERATION: AtomicU64 = AtomicU64::new(0);
/// The custom dictionary's generation: what [`misspellings`] found before it last changed may be
/// out of date (Auto Spell Check checks the type again).
pub fn dictionary_generation() -> u64 {
DICTIONARY_GENERATION.load(Ordering::Relaxed)
}
fn dictionary_changed() {
DICTIONARY_GENERATION.fetch_add(1, Ordering::Relaxed);
}
/// `ids` sorted back to front, as the document paints them: by their index paths, which are in
/// paint order.
fn in_paint_order(d: &Document, mut ids: Vec<NodeId>) -> Vec<NodeId> {
@@ -246,7 +288,9 @@ fn add_word(s: &mut Session, p: &Value) -> Result<Value> {
let word = word_param(p, "word", C)?;
let added = !craft_proof::user_dictionary().contains(&word);
craft_proof::add_word(&word);
save_dictionary(s, &[], C).inspect_err(|_| {
let saved = save_dictionary(s, &[], C);
dictionary_changed();
saved.inspect_err(|_| {
// Only this command's word: words the save took in from the file stay.
if added {
craft_proof::remove_word(&word);
@@ -263,7 +307,9 @@ fn remove_word(s: &mut Session, p: &Value) -> Result<Value> {
if !craft_proof::remove_word(&word) {
return Err(bad(C, format!("`{word}` isn't in the custom dictionary")));
}
save_dictionary(s, std::slice::from_ref(&word), C).inspect_err(|_| craft_proof::add_word(&word))?;
let saved = save_dictionary(s, std::slice::from_ref(&word), C);
dictionary_changed();
saved.inspect_err(|_| craft_proof::add_word(&word))?;
Ok(json!({ "word": word }))
}
@@ -276,13 +322,17 @@ fn change_word(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, format!("`{word}` isn't in the custom dictionary")));
}
if word == to {
save_dictionary(s, &[], C)?;
let saved = save_dictionary(s, &[], C);
dictionary_changed();
saved?;
return Ok(json!({ "word": to }));
}
let to_is_new = !craft_proof::user_dictionary().contains(&to);
craft_proof::remove_word(&word);
craft_proof::add_word(&to);
save_dictionary(s, std::slice::from_ref(&word), C).inspect_err(|_| {
let saved = save_dictionary(s, std::slice::from_ref(&word), C);
dictionary_changed();
saved.inspect_err(|_| {
if to_is_new {
craft_proof::remove_word(&to);
retake_file_words(s);
@@ -315,5 +365,6 @@ impl Session {
Err(why) => log::warn!("not reading the custom dictionary {}: {why}; it is left as it is", path.display()),
}
self.dictionary_file = Some(path);
dictionary_changed();
}
}
+30 -3
View File
@@ -2,7 +2,7 @@
//! of symbols the library panel shows. A library is a document holding the symbols with the
//! resources they use (nested symbols, patterns, swatches, brushes, graphic styles, images) and
//! their Symbol Options; saved, it is a native `.vectorcraft` file, which File › Open also opens.
//! The built-in library is generated here ([`BUILTIN`]); User Defined ones are the `.vectorcraft`
//! The built-in libraries are generated here ([`BUILTIN`]); User Defined ones are the `.vectorcraft`
//! files in the user library folder ([`Libraries`]), and Other Library… loads the symbols of any
//! document VectorCraft opens. `symbol.addFromLibrary` copies symbols into the document;
//! `symbol.saveLibrary` writes the document's.
@@ -12,7 +12,7 @@ use std::sync::{Arc, LazyLock};
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Symbol};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Symbol, SymbolNineSlice};
use vectorcraft_geom::{Affine, Point, Rect, shapes};
use super::brushsym::{CURRENT_SYMBOL, natural_size, normalize, place_xf, set_natural_size};
@@ -154,7 +154,10 @@ pub struct BuiltinSymbolLibrary {
}
/// The built-in symbol libraries, in menu order: original art generated in code.
pub const BUILTIN: &[BuiltinSymbolLibrary] = &[BuiltinSymbolLibrary { id: "basic-shapes", name: "Basic Shapes", make: basic_shapes }];
pub const BUILTIN: &[BuiltinSymbolLibrary] = &[
BuiltinSymbolLibrary { id: "basic-shapes", name: "Basic Shapes", make: basic_shapes },
BuiltinSymbolLibrary { id: "ui-frames", name: "UI Frames", make: ui_frames },
];
/// Built-in library `id`, made once.
fn builtin_library(id: &str) -> Option<Arc<SymbolLibrary>> {
@@ -194,6 +197,30 @@ fn basic_shapes() -> SymbolLibrary {
SymbolLibrary { name: "Basic Shapes".into(), doc }
}
/// UI Frames: panel, button and bar HUD chrome with default 9-slice guides (original art).
fn ui_frames() -> SymbolLibrary {
// Heights keep default 9-slice (0.25) past each corner radius so a vertical resize
// stretches only the flat middle, not the curves.
let frames = [
("Panel", Rect::new(-48.0, -36.0, 48.0, 36.0), 10.0, "#2b3340", "#8aa0b8"),
("Button", Rect::new(-36.0, -18.0, 36.0, 18.0), 8.0, "#3a6ea5", "#d7e6f5"),
("Bar", Rect::new(-56.0, -24.0, 56.0, 24.0), 10.0, "#1f6b4a", "#b7e0c9"),
];
let mut doc = Document::new(100.0, 100.0);
for (name, rect, radius, fill_hex, edge_hex) in frames {
let fill = Color::from_hex(fill_hex).unwrap_or(Color::BLACK);
let edge = Color::from_hex(edge_hex).unwrap_or(Color::BLACK);
let id = doc.alloc_id();
let art = Node::path(id, shapes::rounded_rectangle(rect, radius), Appearance::basic(Paint::solid(fill), Paint::solid(edge), 1.5));
let Ok((art, size, _)) = normalize(art) else { continue };
doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) });
set_natural_size(&mut doc, name, Some(size));
doc.set_symbol_nine_slice(name, Some(SymbolNineSlice::default()));
}
doc.title = "UI Frames".into();
SymbolLibrary { name: "UI Frames".into(), doc }
}
/// An instance of symbol `name` (what the resource gathering follows).
fn instance(name: &str) -> Node {
Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::IDENTITY })