Merge main into fix/pl-missing-message-rows

This commit is contained in:
dotCooCoo
2026-11-13 22:42:16 -08:00
33 changed files with 2394 additions and 133 deletions
+17 -4
View File
@@ -11,7 +11,9 @@ use super::{
// ---------- Pencil / Paintbrush ----------
/// Fit a freehand polyline: drop jitter, build a spline through the samples (sharp turns stay
/// corners) and simplify it to `tol`.
/// corners) and simplify it to `tol`. A closed stroke is fitted as the open one is and closed by a
/// straight segment from its end back to its start: fitting round the gap would curve that segment
/// on from both ends, past them and back.
fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
let min = (tol * 0.3).max(vectorcraft_tools::draw2::MIN_FIT_TOLERANCE * 0.3);
let (Some(&p0), Some(&pn)) = (pts.first(), pts.last()) else { return SubPath::new(vec![], closed) };
@@ -41,15 +43,26 @@ fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
(kept[i], corner)
})
.collect();
let sp = vectorcraft_tools::draw2::catmull_rom(&marked, closed);
let sp = vectorcraft_tools::draw2::catmull_rom(&marked, false);
if sp.anchors.len() < 3 {
return sp;
// Two anchors closed would run back over the line itself.
return SubPath::new(sp.anchors, closed && n > 2);
}
let fitted = po::simplify_with(
&PathData::single(sp.clone()),
&SimplifyOptions { tolerance: tol, corner_angle_deg: 60.0, straight_lines: false, ..Default::default() },
);
fitted.subpaths.into_iter().next().filter(|s| s.anchors.len() >= 2).unwrap_or(sp)
let mut anchors = fitted.subpaths.into_iter().next().filter(|s| s.anchors.len() >= 2).unwrap_or(sp).anchors;
if closed {
// No handle on either side of the closing segment.
if let Some(l) = anchors.last_mut() {
l.h_out = l.p;
}
if let Some(f) = anchors.first_mut() {
f.h_in = f.p;
}
}
SubPath::new(anchors, closed)
}
/// The path of a freehand stroke: fitted at `tol` ([`fit_freehand`]), or with no `tol` the points
+3 -4
View File
@@ -1688,10 +1688,9 @@ fn warichu_is_a_character_attribute_set_saved_and_exported() {
assert!(["warichu", "warichuLines", "warichuGap", "warichuScale", "warichuAlign"].iter().all(|k| saved.get(*k).is_none()), "{saved}");
let plain_svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default());
let plain_pdf = vectorcraft_pdf::export(&s.doc().unwrap().doc, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap();
let text_ops = |pdf: &[u8]| {
let s = String::from_utf8_lossy(pdf);
s.contains("Tj") || s.contains("TJ")
};
// Only text objects count: the file ID in the trailer and the XMP is base64 that changes each
// second and now and then spells `Tj` or `TJ`.
let text_ops = |pdf: &[u8]| String::from_utf8_lossy(pdf).lines().any(|l| l.starts_with("BT ") && (l.contains("Tj") || l.contains("TJ")));
assert!(plain_svg.contains("<text") && text_ops(&plain_pdf));
// text.setFormat with the values the characters already have changes nothing and records no
+5 -4
View File
@@ -369,12 +369,13 @@ impl DocState {
/// menu asks on every frame it is open, and measuring builds the solids of 3D effects, so the
/// answer is kept for this version of the document and those artboards, and a change of the
/// selection or the view keeps it. It is measured again when what the measure reads outside the
/// document changes: the installed plug-ins, whose effects draw art, and the installed fonts,
/// which set the bounds of all type.
/// document changes: the installed plug-ins, whose effects draw art, and the installed fonts and
/// the hyphenation exceptions, which set the bounds of all type.
pub fn any_art_to_fit(&self, indices: &[usize]) -> bool {
self.any_art_to_fit_with(indices, (vectorcraft_plugins::registry::revision(), vectorcraft_text::FontDb::global().generation()))
self.any_art_to_fit_with(indices, (vectorcraft_plugins::registry::revision(), vectorcraft_text::layout_inputs()))
}
/// [`Self::any_art_to_fit`], with `outside` the generations of the plug-ins and the fonts.
/// [`Self::any_art_to_fit`], with `outside` the generations of the plug-ins and the layout inputs
/// ([`vectorcraft_text::layout_inputs`]).
pub(crate) fn any_art_to_fit_with(&self, indices: &[usize], outside: (u64, u64)) -> bool {
// Measuring runs renderer and plug-in code; should it ever ask again while measuring, it
// is measured without being kept rather than borrowed twice.
+4 -2
View File
@@ -25,9 +25,11 @@ impl Session {
// Clicks find an image's own fills and strokes where their effects draw them.
vectorcraft_doc::hit::set_image_paint(vectorcraft_render::effects::image_paint);
// Clicks find a blend's steps and an envelope's distorted art where the canvas draws them,
// evaluated again when the fonts or the plug-ins change.
// evaluated again when the fonts, the hyphenation exceptions or the plug-ins change.
vectorcraft_doc::hit::set_live_art(vectorcraft_render::effects::expand_live, || {
vectorcraft_text::FontDb::global().generation().rotate_left(32) ^ vectorcraft_render::effects::plugin_revision()
let mut h = std::hash::DefaultHasher::new();
std::hash::Hash::hash(&(vectorcraft_text::layout_inputs(), vectorcraft_render::effects::plugin_revision()), &mut h);
std::hash::Hasher::finish(&h)
});
// Envelopes map type from the bounds of its glyph outlines, underlines and strikethroughs.
vectorcraft_text::register_envelope_ink();
+19
View File
@@ -184,6 +184,25 @@ fn pencil_alt_closes_path() {
assert!(ps[0].1.is_closed());
}
/// A Pencil stroke drawn as a dome and closed with Alt at the release closes with a straight segment
/// across the bottom, from where the stroke ends to where it starts: the path stays within where the
/// pen went, rather than curving on past both ends and back like a hat's brim.
#[test]
fn pencil_alt_closes_a_stroke_straight_back_to_its_start() {
let mut s = session();
let pts: Vec<(f64, f64)> =
(0..=60).map(|i| f64::from(i) * std::f64::consts::PI / 60.0).map(|a| (300.0 - 150.0 * a.cos(), 400.0 - 200.0 * a.sin())).collect();
drag(&mut s, "pencil", &pts, Mods { alt: true, ..Default::default() });
let ps = paths(&s);
let sp = &ps[0].1.subpaths[0];
assert!(sp.closed);
let b = ps[0].1.bounds().unwrap();
assert!(b.x0 > 149.5 && b.x1 < 450.5 && b.y1 < 400.5, "within the stroke: {b:?}");
let (first, last) = (sp.anchors.first().unwrap(), sp.anchors.last().unwrap());
assert!(first.p.distance(Point::new(150.0, 400.0)) < 1e-6 && last.p.distance(Point::new(450.0, 400.0)) < 1e-6, "{first:?} {last:?}");
assert_eq!((last.h_out, first.h_in), (last.p, first.p), "a straight closing segment");
}
/// #3799: with Shift held, a wavy Pencil drag draws one straight segment at 45° steps from the
/// Constrain Angle (here 30°: a drag at about 60° comes out at 75°).
#[test]
+2 -2
View File
@@ -110,8 +110,8 @@ pub(crate) struct PlacedEntry {
pub(crate) art: std::sync::Weak<Node>,
/// The art placed, or `None` for art placed again in every frame ([`depends_on_document`]).
pub(crate) placed: Option<Arc<Node>>,
/// The font generation ([`vectorcraft_text::FontDb::generation`]) the art was placed in, when
/// it holds type: the art is placed again when the fonts change.
/// The layout inputs ([`vectorcraft_text::layout_inputs`]) the art was placed with, when it
/// holds type: the art is placed again when the fonts or the hyphenation exceptions change.
pub(crate) fonts: Option<u64>,
/// The 9-slice map the art was placed through ([`Renderer::sliced_art`]), or `None` for art
/// placed by the transform alone ([`Renderer::placed_art`]). An entry is used only by the
+2 -2
View File
@@ -412,7 +412,7 @@ impl Renderer {
/// brush library it reads; for images and placed documents their pixels and files, the
/// layer's Dim Images and the options they are drawn with (with
/// [`crate::RenderOptions::kept_images_only`], also which pixels and undecodable notes the
/// image store holds); for type the fonts, whether overprinting shows
/// image store holds); for type the fonts and the hyphenation exceptions, whether overprinting shows
/// ([`crate::proof::overprints`]) and Document Setup's substitution highlights; and the
/// plug-ins installed, with the names of the document's swatches and patterns and its pattern
/// definitions (a plug-in's painted pieces can name them), when an object in it has a plug-in
@@ -457,7 +457,7 @@ impl Renderer {
f.doc.setup.highlight_substituted_glyphs,
o.highlight_alternate_glyphs,
));
(vectorcraft_text::FontDb::global().generation(), crate::proof::overprints(f.opts), highlights).hash(&mut h);
(vectorcraft_text::layout_inputs(), crate::proof::overprints(f.opts), highlights).hash(&mut h);
}
if reads.plugins || (doc.plugins && (reads.symbols || reads.patterns)) {
(effects::plugin_revision(), doc.names, doc.patterns).hash(&mut h);
+2 -2
View File
@@ -65,8 +65,8 @@ pub struct Renderer {
/// The latest cached glyph geometry for each text object id. A move changes the node's Arc
/// but not its glyphs in text space, so the old geometry can still be used after comparison.
text_ids: PtrMap<NodeId, usize>,
/// The [`vectorcraft_text::FontDb::generation`] `texts` was laid out with: type lays out
/// again when fonts are added or rescanned.
/// The [`vectorcraft_text::layout_inputs`] `texts` was laid out with: type lays out again
/// when fonts are added or rescanned and when the hyphenation exceptions change.
text_fonts: u64,
/// Clip regions per clipping path (see [`Self::clip_of`]).
clips: FrameCache<usize, ClipEntry>,
+4 -3
View File
@@ -127,8 +127,9 @@ struct Walked {
}
impl Walked {
/// The [`resources_key`] of the node walked: the walk's hash, the font generation when it found
/// type or a placed file, the plug-in revision and the hash of the document's swatch and pattern
/// The [`resources_key`] of the node walked: the walk's hash, the layout inputs (fonts and
/// hyphenation exceptions, [`vectorcraft_text::layout_inputs`]) when it found type or a placed
/// file, the plug-in revision and the hash of the document's swatch and pattern
/// names (`names`) when it found a plug-in effect, and the color settings `cms`. Whether a
/// plug-in paints its pieces depends on the plug-ins installed, so the names count for every
/// plug-in effect.
@@ -136,7 +137,7 @@ impl Walked {
let mut h = DefaultHasher::new();
self.hash.hash(&mut h);
if self.text {
vectorcraft_text::FontDb::global().generation().hash(&mut h);
vectorcraft_text::layout_inputs().hash(&mut h);
}
if self.plugins {
crate::effects::plugin_revision().hash(&mut h);
+4 -3
View File
@@ -39,7 +39,8 @@ type TileKey = (String, u64, Ink, bool);
/// What a tile was drawn from besides its pattern's own art, for art that shows more than itself:
/// the art of every pattern and symbol and the pixels of every image in the document, the
/// document's other data (which holds its brushes), and the fonts. Editing a symbol leaves the art
/// document's other data (which holds its brushes), and the fonts and hyphenation exceptions
/// ([`vectorcraft_text::layout_inputs`]). Editing a symbol leaves the art
/// of a pattern that shows it as it was, so the art alone kept the tile from before (#1723). Held
/// weakly: a weak reference keeps the allocation, so no other value gets one of these addresses
/// while the tile is kept.
@@ -66,13 +67,13 @@ impl Deps {
art: Self::art(doc).map(Arc::downgrade).collect(),
pixels: Self::pixels(doc).map(Arc::downgrade).collect(),
unknown: vectorcraft_doc::Shared::downgrade(&doc.unknown),
fonts: vectorcraft_text::FontDb::global().generation(),
fonts: vectorcraft_text::layout_inputs(),
}
}
/// Does `doc` hold what the tile was drawn from?
fn current(&self, doc: &Document) -> bool {
self.fonts == vectorcraft_text::FontDb::global().generation()
self.fonts == vectorcraft_text::layout_inputs()
&& std::ptr::eq(self.unknown.as_ptr(), &*doc.unknown)
&& self.art.iter().map(Weak::as_ptr).eq(Self::art(doc).map(Arc::as_ptr))
&& self.pixels.iter().map(Weak::as_ptr).eq(Self::pixels(doc).map(Arc::as_ptr))
+5 -5
View File
@@ -55,7 +55,7 @@ impl Renderer {
/// objects) hit for them. Other art is placed again in every frame, and so is art that holds
/// symbol instances, type with inline graphics, images, placed documents or raster effects
/// ([`depends_on_document`]), and art that the cache has no room for. Art that holds type is
/// placed again when the fonts change.
/// placed again when the fonts or the hyphenation exceptions change.
pub(super) fn placed_art(&mut self, art: &Arc<Node>, xf: Affine, scale_strokes: bool, inst: Option<&Node>) -> (Arc<Node>, bool) {
let kept_kind = matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) || vectorcraft_doc::live::is_live(art);
// An instance with appearance changes of its own isn't kept: the key doesn't tell them apart.
@@ -67,7 +67,7 @@ impl Renderer {
let key = (Arc::as_ptr(art) as usize, xf.as_coeffs().map(f64::to_bits), scale_strokes, stain);
if let Some(e) = self.placed_arts.get(&key, self.stamp)
&& std::ptr::eq(e.art.as_ptr(), Arc::as_ptr(art))
&& e.fonts.is_none_or(|g| g == vectorcraft_text::FontDb::global().generation())
&& e.fonts.is_none_or(|g| g == vectorcraft_text::layout_inputs())
&& e.slice.is_none()
{
return match &e.placed {
@@ -106,7 +106,7 @@ impl Renderer {
if keeps
&& let Some(e) = self.placed_arts.get(&key, self.stamp)
&& std::ptr::eq(e.art.as_ptr(), Arc::as_ptr(art))
&& e.fonts.is_none_or(|g| g == vectorcraft_text::FontDb::global().generation())
&& e.fonts.is_none_or(|g| g == vectorcraft_text::layout_inputs())
&& e.slice.as_ref() == Some(map)
{
match e.placed.clone() {
@@ -134,7 +134,7 @@ impl Renderer {
fn keep_placed(&mut self, key: PlacedKey, art: &Arc<Node>, placed: &Arc<Node>, slice: Option<vectorcraft_doc::NineSliceMap>) -> bool {
let kept = (!depends_on_document(art)).then(|| placed.clone());
let keeps = kept.is_some();
let fonts = (keeps && holds_type(art)).then(|| vectorcraft_text::FontDb::global().generation());
let fonts = (keeps && holds_type(art)).then(vectorcraft_text::layout_inputs);
// The bytes count the key and the entry, the art's allocation that the weak reference
// keeps, and the kept art.
let bytes = size_of::<(PlacedKey, PlacedEntry, Node)>() + kept.as_deref().map_or(0, lru::node_bytes);
@@ -150,7 +150,7 @@ impl Renderer {
if !matches!(&a.kind, NodeKind::Text(own) if std::ptr::eq(&**own, t)) {
return (Arc::new(text_geom(t)), false);
}
let fonts = vectorcraft_text::FontDb::global().generation();
let fonts = vectorcraft_text::layout_inputs();
if fonts != self.text_fonts {
self.texts.clear();
self.text_ids.clear();
+78 -11
View File
@@ -3,16 +3,16 @@
use std::sync::{PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard};
use vectorcraft_doc::text::{CharStyle, TextObject};
use vectorcraft_doc::text::{CharStyle, TextKind, TextObject};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::Point;
use vectorcraft_geom::{PathData, Point};
use crate::*;
/// Taken to add fonts, or to look up an installed one by name (`FontDb::face` loads it, which
/// changes the fonts too). Renderers lay type out again when the fonts change, so tests of what
/// they keep between frames hold it shared ([`fonts_steady`]) and the tests that change the fonts
/// hold it alone ([`adding_fonts`]).
/// Taken to add fonts, to look up an installed one by name (`FontDb::face` loads it, which
/// changes the fonts too) or to change the hyphenation exceptions. Renderers lay type out again
/// when the fonts or the exceptions change, so tests of what they keep between frames hold it
/// shared ([`fonts_steady`]) and the tests that change them hold it alone ([`adding_fonts`]).
static FONTS: RwLock<()> = RwLock::new(());
/// No fonts are added by these tests until the guard is dropped.
@@ -29,14 +29,12 @@ pub(crate) fn adding_fonts() -> RwLockWriteGuard<'static, ()> {
/// What `step` returns from a run in which the fonts stayed the same. The fonts can change while
/// [`fonts_steady`] is held: the first lookup by family name catalogs the installed fonts, and
/// resolving a cataloged family, or a character no loaded font has, loads a face. `step` runs up
/// to 100 times, until [`vectorcraft_text::FontDb::generation`] is the same after a run as before
/// it.
/// to 100 times, until [`vectorcraft_text::layout_inputs`] is the same after a run as before it.
pub(crate) fn with_fonts_unchanged<T>(mut step: impl FnMut() -> T) -> T {
let db = vectorcraft_text::FontDb::global();
for _ in 0..100 {
let before = db.generation();
let before = vectorcraft_text::layout_inputs();
let out = step();
if db.generation() == before {
if vectorcraft_text::layout_inputs() == before {
return out;
}
}
@@ -466,6 +464,75 @@ fn enveloped_type_under_effects_redraws_when_its_font_becomes_available() {
}
}
/// Hyphenated area type redraws with its new line breaks when Preferences › Hyphenation ›
/// Exceptions change: its glyph geometry, the pixels of its raster effects, the art drawn for
/// geometry effects, the art kept for a symbol instance and an envelope's evaluation are made
/// again.
#[test]
fn hyphenated_type_redraws_when_the_exceptions_change() {
let _adding = adding_fonts();
struct Clear;
impl Drop for Clear {
fn drop(&mut self) {
vectorcraft_text::set_hyphenation_exceptions("");
}
}
let _clear = Clear;
use vectorcraft_doc::Effect;
let moved = || Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 0.0}), visible: true };
const WORDS: &str = "Extraordinarily comprehensive typographical considerations notwithstanding everything.";
const NEVER: &str = "extraordinarily, comprehensive, typographical, considerations, notwithstanding";
let cases = ["the area type", "the type with a drop shadow", "the group with a Transform effect", "the symbol instance", "the envelope"];
for (i, what) in cases.into_iter().enumerate() {
vectorcraft_text::set_hyphenation_exceptions("");
let mut d = Document::new(300.0, 150.0);
let mut t = TextObject::point(Point::ZERO, WORDS, CharStyle { size: 14.0, ..Default::default() });
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(10.0, 10.0, 130.0, 145.0).to_path(0.1)) };
t.xf = Affine::IDENTITY;
t.para.hyphenate = true;
let mut object = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
match i {
0 => {}
1 => {
let params = serde_json::json!({"x": 4, "y": 4, "blur": 1, "opacity": 100});
object.appearance.effects.push(Effect { id: "stylize.dropShadow".into(), params, visible: true });
}
2 => {
object = Node::group(d.alloc_id(), vec![Arc::new(object)]);
object.appearance.effects.push(moved());
}
3 => {
object.appearance.effects.push(moved());
let art = Node::group(d.alloc_id(), vec![Arc::new(object)]);
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Words".into(), art: Arc::new(art) });
object = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Words".into(), xf: Affine::IDENTITY });
}
_ => {
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let envelope = NodeKind::Envelope {
content: vec![Arc::new(object)],
kind: Box::new(kind),
fidelity: 50.0,
editing: false,
options: Default::default(),
frame: Box::new(Affine::IDENTITY),
};
object = Node::new(d.alloc_id(), envelope);
}
}
let l = d.layers[0].id;
d.insert(Some(l), 0, object).unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
let before = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
vectorcraft_text::set_hyphenation_exceptions(NEVER);
let after = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
let fresh = Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
assert_ne!(before, fresh, "{what}: the words in the list are not broken");
assert!(after == fresh, "{what}: drawn as a new renderer draws it");
}
}
/// Without an engine session, a renderer maps an envelope of type from its glyph outline bounds,
/// and measures the type again when its font becomes available.
#[test]
+4 -4
View File
@@ -320,13 +320,13 @@ fn kept_glyphs(r: &mut Renderer, d: &Document) -> Vec<Option<Arc<TextGeom>>> {
.collect()
}
/// `f`'s result from a run during which the fonts did not change: other tests add fonts, and the
/// renderer lays all type out again when the fonts change.
/// `f`'s result from a run during which the fonts and the hyphenation exceptions did not change:
/// other tests change them, and the renderer lays all type out again when they change.
fn fonts_unchanged<T>(mut f: impl FnMut() -> T) -> T {
loop {
let fonts = vectorcraft_text::FontDb::global().generation();
let inputs = vectorcraft_text::layout_inputs();
let t = f();
if vectorcraft_text::FontDb::global().generation() == fonts {
if vectorcraft_text::layout_inputs() == inputs {
return t;
}
}
+1 -1
View File
@@ -859,7 +859,7 @@ fn type_outside_its_frame_is_drawn() {
fn a_pan_draws_the_type_from_the_kept_pixels() {
let _fonts = crate::tests_fontchange::fonts_steady();
let opts = RenderOptions::default();
let fonts = || vectorcraft_text::FontDb::global().generation();
let fonts = vectorcraft_text::layout_inputs;
// How many type objects a renderer drew offscreen for `b` in `second` after drawing `a` in
// `first`. The second frame's pixels are a new renderer's, each channel within `near`. The
// frames are drawn again while the fonts change between them (other tests load fonts, and the
+5 -5
View File
@@ -502,16 +502,16 @@ mod tests {
}
/// Process-wide store used by `prefs.set` / restore. Each change makes envelopes measure type
/// again.
/// again, and caches of layouts lay type out again.
#[test]
fn set_hyphenation_exceptions_round_trips_the_source_string() {
let inputs = crate::layout::envelope_ink_inputs();
let inputs = crate::layout_inputs();
set_hyphenation_exceptions("typography, hap-pen");
assert_eq!(hyphenation_exceptions(), "typography, hap-pen");
let changed = crate::layout::envelope_ink_inputs();
assert_ne!(changed, inputs, "the envelope inputs change with the exceptions");
let changed = crate::layout_inputs();
assert_ne!(changed, inputs, "the layout inputs change with the exceptions");
set_hyphenation_exceptions("");
assert!(hyphenation_exceptions().is_empty());
assert_ne!(crate::layout::envelope_ink_inputs(), changed, "and again when they are cleared");
assert_ne!(crate::layout_inputs(), changed, "and again when they are cleared");
}
}
+1 -3
View File
@@ -46,9 +46,7 @@ pub(crate) use driver::{fit_passes, layout_faces_per_call};
pub(crate) use on_path::{layout_trying_each_start, path_arrangements};
use vertical::{TcyStyle, stands_upright, tate_chu_yoko, upright_cell, upright_centre, upright_extent, whole_character_widths};
#[cfg(test)]
pub(crate) use driver::envelope_ink_inputs;
pub use driver::{envelope_ink, register_envelope_ink};
pub use driver::{envelope_ink, layout_inputs, register_envelope_ink};
const EPS: f64 = 1e-6;
+5 -4
View File
@@ -191,12 +191,13 @@ pub fn envelope_ink(t: &TextObject, m: Affine, projection: Option<(vectorcraft_g
/// Make envelopes map type from [`envelope_ink`], measured again when the fonts or the hyphenation
/// exceptions change (the first registration stays, see `vectorcraft_doc::live::set_type_ink`).
pub fn register_envelope_ink() {
vectorcraft_doc::live::set_type_ink(envelope_ink, envelope_ink_inputs);
vectorcraft_doc::live::set_type_ink(envelope_ink, layout_inputs);
}
/// A number that changes whenever what [`envelope_ink`] reads besides the type changes: the
/// installed fonts and the hyphenation exceptions.
pub(crate) fn envelope_ink_inputs() -> u64 {
/// A number that changes when the fonts ([`FontDb::generation`]) or the hyphenation exceptions
/// (Preferences › Hyphenation › Exceptions) change. Caches of layouts made with the global font
/// database, and of art made from them, key on it.
pub fn layout_inputs() -> u64 {
FontDb::global().generation().rotate_left(32) ^ crate::hyphen::exceptions_generation()
}
+1 -1
View File
@@ -47,7 +47,7 @@ pub use fontdb::{
user_font_dirs,
};
pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions};
pub use layout::{envelope_ink, register_envelope_ink};
pub use layout::{envelope_ink, layout_inputs, register_envelope_ink};
use std::sync::Arc;
pub use svg_glyph::{SvgGlyphReader, set_svg_glyph_reader};
+3 -3
View File
@@ -134,7 +134,7 @@ fn text_hash(s: &str) -> u64 {
h.finish()
}
/// The edited text's layout, keyed by the font database's generation and the text.
/// The edited text's layout, keyed by [`vectorcraft_text::layout_inputs`] and the text.
type CachedLayout = ((u64, TextObject), Arc<TextLayout>);
/// Process-global Type-tool edit-session counter: the IME target's replacement epoch.
@@ -281,9 +281,9 @@ impl TypeTool {
}
/// Layout of `t`, cached by content (overlays run every frame) and by the fonts available (the
/// web build adds fonts after startup).
/// web build adds fonts after startup) and the hyphenation exceptions.
fn layout(&self, t: &TextObject) -> Arc<TextLayout> {
let fonts = FontDb::global().generation();
let fonts = vectorcraft_text::layout_inputs();
let mut c = self.cache.lock().unwrap_or_else(|e| e.into_inner());
if let Some(((g, k), l)) = c.as_ref()
&& *g == fonts
+3 -2
View File
@@ -167,7 +167,8 @@ pub(crate) fn port_near(cx: &ToolContext, p: Point) -> Option<(NodeId, Port, f64
}
/// Which selected text overflows, for [`thread_ports`]: each text object's layout verdict kept
/// with the object and the fonts it was made from, as overlays run every frame.
/// with the object and the layout inputs it was made with (fonts and hyphenation exceptions,
/// [`vectorcraft_text::layout_inputs`]), as overlays run every frame.
#[derive(Default)]
pub struct PortMarks {
seen: std::collections::HashMap<NodeId, ((u64, TextObject), bool)>,
@@ -178,7 +179,7 @@ pub struct PortMarks {
/// port whether text is hidden by the size of its frame, whatever tool is active. None when more
/// than [`PORTS_MAX`] of them are selected.
pub fn thread_ports(marks: &mut PortMarks, cx: &ToolContext) -> Vec<Overlay> {
let fonts = FontDb::global().generation();
let fonts = vectorcraft_text::layout_inputs();
let mut o = Vec::new();
let mut kept = std::collections::HashMap::new();
if !ports_shown(cx.doc, &cx.selection.objects) {
+30
View File
@@ -1538,6 +1538,36 @@ fn selected_type_shows_its_ports_in_each_state() {
assert!(thread_ports(&mut marks, &cx(&d, &sel, &p)).is_empty(), "only selected type shows them");
}
/// The out port shows whether the text fits as the hyphenation exceptions lay it out now: when
/// Preferences › Hyphenation › Exceptions change, the selected type is laid out again.
#[test]
fn the_out_port_follows_the_hyphenation_exceptions() {
struct Clear;
impl Drop for Clear {
fn drop(&mut self) {
vectorcraft_text::set_hyphenation_exceptions("");
}
}
let _clear = Clear;
const WORDS: &str = "Extraordinarily comprehensive typographical considerations notwithstanding everything.";
const NEVER: &str = "extraordinarily, comprehensive, typographical, considerations, notwithstanding";
let (mut d, id) = crate::testutil::doc_with_area_type();
// A 70 by 170 pt frame holds the hyphenated text, and the text without those breaks overflows
// it.
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("not text") };
t.runs = vec![TextRun::new(WORDS, CharStyle { size: 14.0, ..Default::default() })];
t.para.hyphenate = true;
t.kind = TextKind::Area { frame: vectorcraft_geom::PathData::from_bezpath(&Rect::new(0.0, 0.0, 70.0, 170.0).to_path(0.1)) };
let (mut sel, p) = (Selection::default(), paint());
sel.set([id]);
let mut marks = PortMarks::default();
let red = |ov: &[Overlay]| ov.iter().filter(|o| matches!(o, Overlay::Path { color: [230, 30, 30], .. })).count();
vectorcraft_text::set_hyphenation_exceptions("");
assert_eq!(red(&thread_ports(&mut marks, &cx(&d, &sel, &p))), 0, "the hyphenated text fits");
vectorcraft_text::set_hyphenation_exceptions(NEVER);
assert_eq!(red(&thread_ports(&mut marks, &cx(&d, &sel, &p))), 1, "the text without those breaks does not");
}
/// Past [`PORTS_MAX`] selected frames (select all of an opened PDF) no ports are drawn or clicked:
/// they would bury the canvas in boxes; up to it every frame has its two.
#[test]
+4 -3
View File
@@ -99,7 +99,7 @@ struct Drag {
began: bool,
}
/// The type object's layout, keyed by the font database's generation and the object.
/// The type object's layout, keyed by [`vectorcraft_text::layout_inputs`] and the object.
type CachedLayout = ((u64, TextObject), Arc<TextLayout>);
#[derive(Default)]
@@ -196,9 +196,10 @@ fn char_box(t: &TextObject, lay: &TextLayout, gi: usize) -> Option<CharBox> {
}
impl TouchTypeTool {
/// Layout of `t`, cached by content (overlays run every frame) and by the fonts available.
/// Layout of `t`, cached by content (overlays run every frame), by the fonts available and by
/// the hyphenation exceptions.
fn layout(&self, t: &TextObject) -> Arc<TextLayout> {
let fonts = FontDb::global().generation();
let fonts = vectorcraft_text::layout_inputs();
let mut c = self.cache.lock().unwrap_or_else(|e| e.into_inner());
if let Some(((g, k), l)) = c.as_ref()
&& *g == fonts
+6 -2
View File
@@ -1691,7 +1691,7 @@ pub(crate) fn end_stale_preview(app: &mut VectorcraftApp, ctx: &egui::Context) {
}
/// What the font menus preview on and apply to: the Type tool's selected range while it edits
/// text (the whole text when nothing is selected), else the selected text objects.
/// text (the whole text when nothing is selected), else every frame of each selected story.
pub(crate) fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, pick: FontPick) {
let previewing = crate::panels::pstate::<bool>(ctx, PREVIEWING);
match pick {
@@ -1791,7 +1791,11 @@ fn font_command(app: &VectorcraftApp, family: &str, style: Option<&str>) -> (&'s
}
("text.setRangeStyle", p)
}
None => ("text.setStyle", p),
None => {
// With the Selection tool: every frame of each selected story.
crate::panels::character::target_selected_stories(app, &mut p);
("text.setStyle", p)
}
}
}
File diff suppressed because it is too large Load Diff
+75 -21
View File
@@ -2,6 +2,7 @@
//! scale, baseline shift, character rotation, the anti-aliasing method and the All Caps, Small Caps,
//! Superscript, Subscript, Underline and Strikethrough toggles and the Language menu.
use std::collections::HashSet;
use std::sync::{Mutex, OnceLock};
use egui::{Key, Ui, vec2};
@@ -99,8 +100,8 @@ pub(crate) fn shared_text_flag(app: &VectorcraftApp, f: impl Fn(&vectorcraft_doc
}
/// `f` of the character styles the panels act on: the runs the Type tool's selection covers (the
/// caret's style at a caret), else every run of the selected type, groups' included. `None` (a
/// blank field) where they differ, or with no type.
/// caret's style at a caret), else every run of every frame of the selected stories
/// ([`selected_stories`]). `None` (a blank field) where they differ, or with no type.
pub(crate) fn shared<T: PartialEq>(app: &VectorcraftApp, f: impl Fn(&CharStyle) -> T) -> Option<T> {
shared_styles(app, |_, _, style, _| Some(f(style)))
}
@@ -162,12 +163,8 @@ fn shared_styles<T: PartialEq>(
None => each_paragraph_run(t, Some((a, b)), |para, style, inline| acc.add(f(t, para, style, inline))),
}
} else {
for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
n.walk(&mut |c| {
if let NodeKind::Text(t) = &c.kind {
each_paragraph_run(t, None, |para, style, inline| acc.add(f(t, para, style, inline)));
}
});
for t in selected_story_texts(app) {
each_paragraph_run(t, None, |para, style, inline| acc.add(f(t, para, style, inline)));
}
}
acc.value().flatten()
@@ -243,9 +240,12 @@ pub(crate) fn format(app: &mut VectorcraftApp, p: Value) {
/// [`format`] that returns the command's result: `text.setRangeStyle` on the range the Type tool
/// has selected (the whole text when nothing is selected) while it edits text, else
/// `text.setFormat` on the selected text objects.
/// `text.setFormat` on the selected stories ([`selected_stories`]).
pub(crate) fn run_format(app: &mut VectorcraftApp, mut p: Value) -> Result<Value, String> {
let Some((id, a, b)) = text_editing(app) else { return app.run("text.setFormat", p) };
let Some((id, a, b)) = text_editing(app) else {
target_selected_stories(app, &mut p);
return app.run("text.setFormat", p);
};
if let Some(first) = text_story(app).and_then(|story| story.first().copied()) {
return story_style(app, first, p);
}
@@ -283,9 +283,11 @@ pub(crate) fn shared_warichu(app: &VectorcraftApp) -> SharedWarichu {
}
/// Apply character attributes: to the selected range while the Type tool edits text (the whole
/// object when nothing is selected), else to the selected text objects.
fn char_cmd(app: &mut VectorcraftApp, cmd: &str, p: Value) {
/// object when nothing is selected), else to every frame of the selected stories
/// ([`selected_stories`]).
fn char_cmd(app: &mut VectorcraftApp, cmd: &str, mut p: Value) {
if text_editing(app).is_none() {
target_selected_stories(app, &mut p);
app.run(cmd, p).ok();
return;
}
@@ -546,6 +548,55 @@ pub(crate) fn text_story(app: &VectorcraftApp) -> Option<Vec<NodeId>> {
(ids.len() >= 2).then_some(ids)
}
/// The type objects the panels act on with the Selection tool: the selected type objects and those
/// in selected groups, each with every frame of its threaded story (locked and hidden frames
/// included), each once, the frames of a story in thread order.
pub(crate) fn selected_stories(app: &VectorcraftApp) -> Vec<NodeId> {
let Some(st) = app.session.active() else { return Vec::new() };
let mut frames = Vec::new();
let mut taken = HashSet::new();
for selected in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
selected.walk(&mut |child| {
if matches!(child.kind, NodeKind::Text(_)) && !taken.contains(&child.id) {
for id in st.doc.story_of(child.id) {
if taken.insert(id) {
frames.push(id);
}
}
}
});
}
frames
}
/// The type objects of [`selected_stories`].
pub(crate) fn selected_story_texts(app: &VectorcraftApp) -> Vec<&vectorcraft_doc::TextObject> {
let Some(st) = app.session.active() else { return Vec::new() };
selected_stories(app)
.into_iter()
.filter_map(|id| match st.doc.node(id).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => Some(t.as_ref()),
_ => None,
})
.collect()
}
/// Give the command parameters `p` the `ids` of [`selected_stories`], so a command run with the
/// Selection tool sets every frame of each selected story. `p` stays as it is when it names its
/// targets, with no type selected, and while an object of a symbol instance's art is selected,
/// which the commands refuse.
pub(crate) fn target_selected_stories(app: &VectorcraftApp, p: &mut Value) {
if p.get("ids").is_some() || p.get("id").is_some() || app.session.active().is_none_or(|st| st.selection.symbol_part.is_some()) {
return;
}
let ids = selected_stories(app);
if let Some(params) = p.as_object_mut()
&& !ids.is_empty()
{
params.insert("ids".into(), json!(ids.iter().map(|id| id.0).collect::<Vec<_>>()));
}
}
/// `text.setRangeStyle` with `p` on all the text of every frame of the story that `first` is a
/// frame of, as one undo step.
fn story_style(app: &mut VectorcraftApp, first: NodeId, mut p: Value) -> Result<Value, String> {
@@ -910,7 +961,8 @@ fn anti_alias_method(app: &mut VectorcraftApp, ui: &mut Ui, width: f32) {
}
/// The anti-aliasing method of the type objects the panel acts on: the one the Type tool edits,
/// else the selected type (groups' included). `None` where they differ, or with no type.
/// else every frame of the selected stories ([`selected_stories`]). `None` where they differ, or
/// with no type.
fn shared_anti_alias(app: &VectorcraftApp) -> Option<TypeAntiAlias> {
let st = app.session.active()?;
let mut acc = Shared::None;
@@ -921,12 +973,8 @@ fn shared_anti_alias(app: &VectorcraftApp) -> Option<TypeAntiAlias> {
}
}
None => {
for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
n.walk(&mut |c| {
if let NodeKind::Text(t) = &c.kind {
acc.add(t.anti_alias);
}
});
for t in selected_story_texts(app) {
acc.add(t.anti_alias);
}
}
}
@@ -934,12 +982,14 @@ fn shared_anti_alias(app: &VectorcraftApp) -> Option<TypeAntiAlias> {
}
/// Set the anti-aliasing method `m` of the frame the Type tool edits, whatever part of its text
/// is selected, else the selected type objects (`text.setFormat {antiAlias}`).
/// is selected, else every frame of the selected stories (`text.setFormat {antiAlias}`).
fn set_anti_alias(app: &mut VectorcraftApp, m: TypeAntiAlias) {
let mut p = json!({"antiAlias": m.id()});
if let Some((id, _, _)) = text_editing(app) {
end_typing(app);
p["ids"] = json!([id.0]);
} else {
target_selected_stories(app, &mut p);
}
app.run("text.setFormat", p).ok();
}
@@ -1160,7 +1210,11 @@ fn axes_cmd(app: &mut VectorcraftApp, axes: Value, phase: widgets::Live) {
}
super::live_run(app, "Character", "text.setRangeStyle", q, phase);
}
None => super::live_run(app, "Character", "text.setFormat", json!({ "fontAxes": axes }), phase),
None => {
let mut p = json!({ "fontAxes": axes });
target_selected_stories(app, &mut p);
super::live_run(app, "Character", "text.setFormat", p, phase);
}
}
}
@@ -1741,3 +1741,141 @@ fn story_changes_take_in_locked_and_hidden_frames() {
assert_eq!(story_sizes(&app, id).iter().filter(|s| (**s - 9.0).abs() > 1e-9).count(), 0, "frame {id} takes the size");
}
}
/// [`three_frame_story`] with its second frame locked and its third hidden, a type object of its own
/// below it, and the first frame selected with the Selection tool → the app, the frames and the
/// other type object.
fn story_selected_in_its_first_frame() -> (VectorcraftApp, [u64; 3], u64) {
let (mut app, a, b, c) = three_frame_story(60);
let other = app.run("text.create", json!({"x": 10, "y": 250, "text": "Other", "size": 12})).unwrap()["id"].as_u64().unwrap();
app.run("select.set", json!({"ids": [b]})).unwrap();
app.run("object.lock", json!({})).unwrap();
app.run("select.set", json!({"ids": [c]})).unwrap();
app.run("object.hide", json!({})).unwrap();
app.run("select.set", json!({"ids": [a]})).unwrap();
assert_eq!(text_editing(&app), None);
(app, [a, b, c], other)
}
fn undo_steps(app: &VectorcraftApp) -> usize {
app.session.active().unwrap().history.undo.len()
}
/// With the Selection tool, a selected frame of a threaded story stands for the whole story: the
/// panel's changes reach its locked and hidden frames, each in one undo step, and leave other type
/// alone.
#[test]
fn selection_tool_changes_reach_every_frame_of_the_story() {
let (mut app, frames, other) = story_selected_in_its_first_frame();
assert_eq!(selected_stories(&app), frames.map(NodeId).to_vec());
let steps = undo_steps(&app);
style(&mut app, json!({"size": 9}));
assert_eq!(undo_steps(&app), steps + 1, "one undo step");
for id in frames {
assert!(story_sizes(&app, id).iter().all(|s| (*s - 9.0).abs() < 1e-9), "frame {id} takes the size");
}
assert!(story_sizes(&app, other).iter().all(|s| (*s - 12.0).abs() < 1e-9), "other type keeps its size");
app.run("edit.undo", json!({})).unwrap();
for id in frames {
assert!(story_sizes(&app, id).iter().all(|s| (*s - 12.0).abs() < 1e-9), "undo gives frame {id} its size back");
}
format(&mut app, json!({"underline": true}));
for id in frames {
assert!(story_frame(&app, id).runs.iter().all(|r| r.style.underline), "frame {id} is underlined");
}
assert!(story_frame(&app, other).runs.iter().all(|r| !r.style.underline));
// Tate-chu-yoko Settings and Warichu Settings set their values through `run_format`.
run_format(&mut app, json!({"tcyUpDown": 2})).unwrap();
for id in frames {
assert!(story_frame(&app, id).runs.iter().all(|r| (r.style.tcy_up_down - 2.0).abs() < 1e-9), "frame {id} takes Up/Down");
}
assert!(story_frame(&app, other).runs.iter().all(|r| r.style.tcy_up_down.abs() < 1e-9));
}
/// With the Selection tool the anti-aliasing method goes to every frame of the selected story.
#[test]
fn selection_tool_anti_aliasing_reaches_every_frame_of_the_story() {
let (mut app, frames, other) = story_selected_in_its_first_frame();
set_anti_alias(&mut app, TypeAntiAlias::Strong);
for id in frames {
assert_eq!(story_frame(&app, id).anti_alias, TypeAntiAlias::Strong, "frame {id}");
}
assert_eq!(story_frame(&app, other).anti_alias, TypeAntiAlias::Sharp);
assert_eq!(shared_anti_alias(&app), Some(TypeAntiAlias::Strong));
}
/// With the Selection tool a Variable Font slider sets the axis on every frame of the selected
/// story, in one undo step.
#[test]
fn selection_tool_font_axes_reach_every_frame_of_the_story() {
use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font};
vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap());
let (mut app, frames, _) = story_selected_in_its_first_frame();
app.run("text.setStyle", json!({"ids": frames, "font": TWO_AXIS_FAMILY})).unwrap();
app.run("select.set", json!({"ids": [frames[0]]})).unwrap();
let steps = undo_steps(&app);
axes_cmd(&mut app, json!({"wght": 700}), widgets::Live::Released);
assert_eq!(undo_steps(&app), steps + 1, "one undo step");
for id in frames {
let t = story_frame(&app, id);
assert!(t.runs.iter().all(|r| r.style.font_axes.get("wght") == Some(&700.0)), "frame {id}: {:?}", t.runs[0].style.font_axes);
}
}
/// With the Selection tool a font family picked in the font menu, and its preview, go to every
/// frame of the selected story, in one undo step.
#[test]
fn selection_tool_font_family_reaches_every_frame_of_the_story() {
let (mut app, frames, other) = story_selected_in_its_first_frame();
let family = |app: &VectorcraftApp, id: u64| story_frame(app, id).runs.iter().map(|r| r.style.font_family.clone()).collect::<Vec<_>>();
let before = family(&app, other);
let ctx = egui::Context::default();
crate::font_menu::apply(&mut app, &ctx, crate::font_menu::FontPick::Preview("Source Serif 4".into(), None));
for id in frames {
assert!(family(&app, id).iter().all(|f| f == "Source Serif 4"), "the preview shows on frame {id}");
}
let steps = undo_steps(&app);
crate::font_menu::apply(&mut app, &ctx, crate::font_menu::FontPick::Chosen("Source Serif 4".into(), None));
assert_eq!(undo_steps(&app), steps + 1, "one undo step");
for id in frames {
assert!(family(&app, id).iter().all(|f| f == "Source Serif 4"), "frame {id} takes the family");
}
assert_eq!(family(&app, other), before, "other type keeps its family");
}
/// The fields show a value only when every frame of the selected story has it: a hidden frame that
/// differs leaves them blank.
#[test]
fn selection_tool_fields_read_every_frame_of_the_story() {
let (mut app, [a, b, c], _) = story_selected_in_its_first_frame();
assert_eq!(shared(&app, |s| s.size), Some(12.0));
assert_eq!(shared_anti_alias(&app), Some(TypeAntiAlias::Sharp));
app.run("text.setStyle", json!({"ids": [c], "size": 20})).unwrap();
app.run("text.setFormat", json!({"ids": [b], "antiAlias": TypeAntiAlias::Crisp.id()})).unwrap();
app.run("select.set", json!({"ids": [a]})).unwrap();
assert_eq!(shared(&app, |s| s.size), None, "the hidden frame's size differs");
assert_eq!(shared_anti_alias(&app), None, "the locked frame's anti-aliasing differs");
}
/// Two frames of one story, or a frame in a selected group, count once; an unthreaded type
/// object is a story of its own.
#[test]
fn selected_stories_list_each_frame_once() {
let (mut app, [a, b, c], other) = story_selected_in_its_first_frame();
app.run("object.unlockAll", json!({})).unwrap();
app.run("select.set", json!({"ids": [b, a]})).unwrap();
assert_eq!(selected_stories(&app), [a, b, c].map(NodeId).to_vec(), "thread order, each once");
app.run("select.set", json!({"ids": [other, a]})).unwrap();
let group = app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap();
app.run("select.set", json!({"ids": [group]})).unwrap();
let mut listed = selected_stories(&app);
listed.sort_by_key(|id| id.0);
let mut expected = [a, b, c, other].map(NodeId).to_vec();
expected.sort_by_key(|id| id.0);
assert_eq!(listed, expected);
app.run("select.set", json!({"ids": []})).unwrap();
assert!(selected_stories(&app).is_empty());
}
+24 -37
View File
@@ -28,8 +28,9 @@ pub const ALIGNMENTS: [(Justify, &str, &str, &str); 7] = [
(Justify::JustifyAll, "dc-para-justify-all", "Justify all lines", "justifyAll"),
];
/// Paragraph attributes apply to the selected text objects' paragraphs or, while the Type tool
/// edits text, to the paragraphs its selection (or caret) touches (ending its typing session).
/// Paragraph attributes apply to the paragraphs of every frame of the selected stories or, while
/// the Type tool edits text, to the paragraphs its selection (or caret) touches (ending its typing
/// session).
fn format(app: &mut VectorcraftApp, p: Value) {
para_cmd(app, "text.setFormat", p);
}
@@ -75,8 +76,16 @@ fn para_cmd(app: &mut VectorcraftApp, cmd: &str, p: Value) {
run_on_paragraphs(app, cmd, p).ok();
}
/// Run `cmd` on the paragraphs the Paragraph panel acts on ([`format`]) and return its result.
pub(crate) fn run_on_paragraphs(app: &mut VectorcraftApp, cmd: &str, mut p: Value) -> Result<Value, String> {
/// Run `cmd` on the paragraphs the Paragraph panel acts on ([`format`]) and return its result. With
/// the Selection tool these are the paragraphs of every frame of the selected stories
/// ([`super::character::selected_stories`]).
pub(crate) fn run_on_paragraphs(app: &mut VectorcraftApp, cmd: &str, p: Value) -> Result<Value, String> {
run_on(app, cmd, p, true)
}
/// [`run_on_paragraphs`], with the Selection tool on the selected type objects alone unless
/// `stories`.
fn run_on(app: &mut VectorcraftApp, cmd: &str, mut p: Value, stories: bool) -> Result<Value, String> {
if let Some((id, a, b)) = super::character::text_editing(app) {
super::character::end_typing(app);
match super::character::text_story(app) {
@@ -88,6 +97,8 @@ pub(crate) fn run_on_paragraphs(app: &mut VectorcraftApp, cmd: &str, mut p: Valu
p["end"] = json!(b);
}
}
} else if stories {
super::character::target_selected_stories(app, &mut p);
}
app.run(cmd, p)
}
@@ -208,14 +219,10 @@ fn visit_paragraphs(app: &VectorcraftApp, mut f: impl FnMut(&ParaStyle)) {
}
return;
}
for node in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
node.walk(&mut |c| {
if let NodeKind::Text(t) = &c.kind {
for i in 0..t.paragraph_count() {
f(t.para_at(i));
}
}
});
for t in super::character::selected_story_texts(app) {
for i in 0..t.paragraph_count() {
f(t.para_at(i));
}
}
}
@@ -328,18 +335,8 @@ fn selected_paragraph_direction(app: &VectorcraftApp, fallback: &ParaStyle) -> O
{
return if collect_text_direction(text, Some((a, b)), &mut shared) { shared.or(default) } else { None };
}
fn collect(node: &vectorcraft_doc::Node, shared: &mut Option<bool>) -> bool {
if let NodeKind::Text(text) = &node.kind {
return collect_text_direction(text, None, shared);
}
node.children().is_none_or(|children| children.iter().all(|child| collect(child, shared)))
}
for node in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
if !collect(node, &mut shared) {
return None;
}
}
shared.or(default)
let texts = super::character::selected_story_texts(app);
if texts.into_iter().all(|t| collect_text_direction(t, None, &mut shared)) { shared.or(default) } else { None }
}
/// A hyphenation option shared by every paragraph in scope, or `None` when they differ.
@@ -365,18 +362,8 @@ fn selected_alignment(app: &VectorcraftApp, fallback: &ParaStyle) -> Option<Just
{
return if collect_text_alignment(text, Some((a, b)), &mut shared) { shared.or(default) } else { None };
}
fn collect(node: &vectorcraft_doc::Node, shared: &mut Option<Justify>) -> bool {
if let NodeKind::Text(text) = &node.kind {
return collect_text_alignment(text, None, shared);
}
node.children().is_none_or(|children| children.iter().all(|child| collect(child, shared)))
}
for node in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
if !collect(node, &mut shared) {
return None;
}
}
shared.or(default)
let texts = super::character::selected_story_texts(app);
if texts.into_iter().all(|t| collect_text_alignment(t, None, &mut shared)) { shared.or(default) } else { None }
}
/// The seven alignment buttons shared by Paragraph and Properties; no button is selected
@@ -492,7 +479,7 @@ pub(crate) fn number_sample(style: vectorcraft_doc::text::NumberStyle, n: u32) -
/// the Type tool's anchor and caret past the characters inserted at or before them).
pub(crate) fn convert_list_to_text(app: &mut VectorcraftApp) -> Result<Value, String> {
text_style(app).ok_or("select some type first")?;
run_on_paragraphs(app, "text.convertListToText", json!({}))
run_on(app, "text.convertListToText", json!({}), false)
}
/// The Bullets button and the arrow beside it (Paragraph panel). The button makes the paragraphs
@@ -1035,3 +1035,58 @@ fn reset_panel_leaves_an_undo_group_open_in_another_document() {
app.run("edit.undoGroup.end", json!({})).unwrap();
assert_eq!(steps(app.session.active().unwrap()), a_steps, "the later edit is part of the Batch step");
}
/// With the Selection tool a selected frame of a threaded story stands for the whole story: the
/// Paragraph panel's changes and Reset Panel reach its locked and hidden frames, each in one undo
/// step, other type keeps its paragraphs, and the alignment buttons read every frame.
#[test]
fn selection_tool_paragraph_changes_reach_every_frame_of_the_story() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 900, "height": 300})).unwrap();
let story = "The quick brown fox jumps over the lazy dog.\n".repeat(12);
let area = json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 60}});
let a = app.run("text.create", area).unwrap()["id"].as_u64().unwrap();
let b = app.run("text.thread.add", json!({"from": a, "rect": [200, 10, 150, 60]})).unwrap()["id"].as_u64().unwrap();
let c = app.run("text.thread.add", json!({"from": b, "rect": [400, 10, 150, 200]})).unwrap()["id"].as_u64().unwrap();
let other = app.run("text.create", json!({"x": 10, "y": 250, "text": "Other", "size": 12})).unwrap()["id"].as_u64().unwrap();
app.run("select.set", json!({"ids": [b]})).unwrap();
app.run("object.lock", json!({})).unwrap();
app.run("select.set", json!({"ids": [c]})).unwrap();
app.run("object.hide", json!({})).unwrap();
app.run("select.set", json!({"ids": [a]})).unwrap();
let paragraphs = |app: &VectorcraftApp, id: u64| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => (0..t.paragraph_count()).map(|i| t.para_at(i).clone()).collect::<Vec<_>>(),
_ => panic!("not text"),
};
let undo_steps = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len();
for id in [a, b, c] {
assert!(!paragraphs(&app, id).is_empty(), "frame {id} holds text");
}
let steps = undo_steps(&app);
format(&mut app, json!({"spaceBefore": 4}));
assert_eq!(undo_steps(&app), steps + 1, "one undo step");
for id in [a, b, c] {
assert!(paragraphs(&app, id).iter().all(|p| (p.space_before - 4.0).abs() < 1e-9), "frame {id} takes the space");
}
assert!(paragraphs(&app, other).iter().all(|p| p.space_before.abs() < 1e-9), "other type keeps its paragraphs");
assert_eq!(shared_para_field(&app, |p| p.space_before), Some(4.0));
align(&mut app, "center").unwrap();
for id in [a, b, c] {
assert!(paragraphs(&app, id).iter().all(|p| p.justify == Justify::Center), "frame {id} is centered");
}
assert!(paragraphs(&app, other).iter().all(|p| p.justify != Justify::Center));
assert_eq!(selected_alignment(&app, &ParaStyle::default()), Some(Justify::Center));
// A hidden frame aligned otherwise leaves no alignment button on.
app.run("text.setStyle", json!({"ids": [c], "justify": "right"})).unwrap();
app.run("select.set", json!({"ids": [a]})).unwrap();
assert_eq!(selected_alignment(&app, &ParaStyle::default()), None);
let steps = undo_steps(&app);
reset_panel(&mut app);
assert_eq!(undo_steps(&app), steps + 1, "Reset Panel is one undo step");
for id in [a, b, c] {
assert!(paragraphs(&app, id).iter().all(|p| p.space_before.abs() < 1e-9 && p.justify == Justify::Auto), "frame {id} is reset");
}
}
+21 -3
View File
@@ -419,7 +419,9 @@ fn stops_json(stops: &[TabStop]) -> Value {
)
}
/// Run `text.tabs.set` with `stops` on the text the panel acts on. Returns false when it fails.
/// Run `text.tabs.set` with `stops` on the text the panel acts on: with the Selection tool, every
/// frame of the selected stories ([`super::character::selected_stories`]). Returns false when it
/// fails.
fn apply(app: &mut VectorcraftApp, stops: &[TabStop]) -> bool {
if let Some((id, a, b)) = super::character::text_editing(app) {
super::character::end_typing(app);
@@ -430,7 +432,9 @@ fn apply(app: &mut VectorcraftApp, stops: &[TabStop]) -> bool {
};
app.run("text.tabs.set", p).is_ok()
} else {
app.run("text.tabs.set", json!({"stops": stops_json(stops)})).is_ok()
let mut p = json!({"stops": stops_json(stops)});
super::character::target_selected_stories(app, &mut p);
app.run("text.tabs.set", p).is_ok()
}
}
@@ -727,6 +731,20 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
resp.on_hover_text(tl!("Click to add a tab stop, drag to move it, drag it off the ruler to delete it"));
}
/// Whether a paragraph of the whole story Clear All Tabs acts on has tab stops: after Select All in
/// threaded text, every frame of the edited story; with the Selection tool, every frame of the
/// selected stories ([`super::character::selected_stories`]).
fn story_has_tabs(app: &VectorcraftApp) -> bool {
let has_tabs = |t: &TextObject| (0..t.paragraph_count()).any(|i| !t.para_at(i).tabs.is_empty());
match (super::character::text_editing(app), super::character::text_story(app), app.session.active()) {
(Some(_), Some(story), Some(st)) => {
story.iter().any(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Text(t)) if has_tabs(t)))
}
(Some(_), _, _) => false,
(None, _, _) => super::character::selected_story_texts(app).into_iter().any(has_tabs),
}
}
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let Some((_, para, _)) = current_para(app) else {
ui.add_enabled(false, egui::Button::new(tl!("Select text")).frame(false));
@@ -742,7 +760,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
if sel >= stops.len() {
sel = stops.len().saturating_sub(1);
}
if menu_item(ui, tl!("Clear All Tabs"), !stops.is_empty(), false)
if menu_item(ui, tl!("Clear All Tabs"), !stops.is_empty() || story_has_tabs(app), false)
&& let Err(e) = super::paragraph::run_on_paragraphs(app, "text.tabs.clear", json!({}))
{
app.status(e);
+37
View File
@@ -642,6 +642,43 @@ fn text_of(app: &VectorcraftApp, id: u64) -> vectorcraft_doc::TextObject {
}
}
/// With the Selection tool, tab stops set in the panel go to every paragraph of every frame of a
/// selected threaded story, its locked and hidden frames included.
#[test]
fn selection_tool_tab_stops_reach_every_frame_of_the_story() {
let (mut app, frames) = split_story_app();
app.run("select.set", json!({"ids": [frames[1]]})).unwrap();
app.run("object.lock", json!({})).unwrap();
app.run("select.set", json!({"ids": [frames[2]]})).unwrap();
app.run("object.hide", json!({})).unwrap();
app.run("select.set", json!({"ids": [frames[0]]})).unwrap();
let stop = TabStop { position: 36.0, align: Default::default(), leader: String::new(), align_on: '.' };
assert!(apply(&mut app, std::slice::from_ref(&stop)));
for id in frames {
let t = text_of(&app, id);
assert!((0..t.paragraph_count()).all(|i| t.para_at(i).tabs == [stop.clone()]), "frame {id} takes the stop");
}
}
/// With the Selection tool, Clear All Tabs is on when only another frame of the selected story has
/// tab stops, and clears them there.
#[test]
fn clear_all_tabs_is_on_when_another_frame_of_the_story_has_stops() {
let (mut app, [a, b, c]) = split_story_app();
app.run("text.tabs.set", json!({"ids": [c], "stops": [{"position": 36}]})).unwrap();
app.run("select.set", json!({"ids": [c]})).unwrap();
app.run("object.hide", json!({})).unwrap();
app.run("select.set", json!({"ids": [a]})).unwrap();
assert!(current(&app).unwrap().1.is_empty(), "the selected frame has no stops");
assert!(story_has_tabs(&app));
super::super::paragraph::run_on_paragraphs(&mut app, "text.tabs.clear", json!({})).unwrap();
for id in [a, b, c] {
let t = text_of(&app, id);
assert!((0..t.paragraph_count()).all(|i| t.para_at(i).tabs.is_empty()), "frame {id} has no stops");
}
assert!(!story_has_tabs(&app));
}
/// Each paragraph of the story of `frames` by its first word, with its left indent and first-line
/// position, counted once; each piece of a paragraph split between frames has the same values.
fn story_paragraphs(app: &VectorcraftApp, frames: &[u64]) -> Vec<(String, f64, f64)> {
+16
View File
@@ -1040,6 +1040,22 @@ fn open_menu_with(id: &str, left: f32, width: f32, screen: egui::Vec2, look: Men
assert!(frames < 1000, "the menu's samples are still rendering after {frames} frames");
shapes = frame(vec![]);
}
// egui places the menu by the size it had the frame before. When a name measured late (a face
// added for its script, a font found in the background) widens it on that frame, the menu sits
// where the narrower one fit until the next: draw until it stays put.
let filter_at = |shapes: &[egui::epaint::ClippedShape]| {
shapes.iter().find_map(|s| match &s.shape {
egui::Shape::Text(t) if t.galley.text() == crate::i18n::t("Any Kind") => Some(t.pos),
_ => None,
})
};
for _ in 0..100 {
let before = filter_at(&shapes);
shapes = frame(vec![]);
if filter_at(&shapes) == before && !crate::font_menu::wait_for_samples(&ctx) {
break;
}
}
let mut texts = vec![];
let mut samples = vec![];
let mut tints = vec![];
+1 -1
View File
@@ -49,7 +49,7 @@ plus review. Native-speaker review is human work and isn't in the hours.
| Language | Code | Translated | Status |
|---|---|---|---|
| Traditional Chinese (Taiwan) | `zh-hant` | 4,892 rows, 790 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete when counted (80% overall) | partial |
| Traditional Chinese (Taiwan) | `zh-hant` | 8,820 rows, 3,438 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 40 missing messages for it; menus, `tl!` literals and messages were complete when counted on 2026-10-21 (80% overall) | partial |
| Italian | `it` | 8,757 rows, 3,422 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 40 missing messages for it | full |
| Dutch | `nl` | 7,299 rows, 1,609 of them `@msg` and 47 `@msgplural` (counted 2026-11-13 on `main` at `9a1a158a3`): in `COMPLETE_MESSAGES`, and the untranslated report lists the same missing messages for it as for German; one/other plurals | full |
| Danish | `da` | 5,279 rows, 750 of them `@msg` (counted 2026-10-21): menus and `tl!` literals complete when counted; its messages are partial; one/other plurals | full |
File diff suppressed because one or more lines are too long