mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 01:15:25 +00:00
Merge main into fix/pl-missing-message-rows
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)> {
|
||||
|
||||
@@ -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![];
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user