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

This commit is contained in:
dotCooCoo
2026-11-13 22:42:08 -08:00
16 changed files with 2251 additions and 91 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
+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]
+3 -9
View File
@@ -491,16 +491,10 @@ pub(crate) fn text_within<'a>(bytes: &'a [u8], unpacked: &std::sync::atomic::Ato
match craft_svg::svgz::inflate(bytes, most) {
Ok(raw) => {
unpacked.fetch_add(raw.len() as u64, Ordering::Relaxed);
craft_svg::svgz::decode(&raw)
.map(|t| std::borrow::Cow::Owned(t.into_owned()))
.map_err(|_| not_utf8())
}
Err(craft_svg::svgz::Error::TooLarge(_)) => {
Err(SvgError::TooBig(format!("the SVGZ file unpacks to more than {} MB", most >> 20)))
}
Err(craft_svg::svgz::Error::Corrupt(e)) => {
Err(SvgError::Parse(format!("not a valid SVGZ file: {e}")))
craft_svg::svgz::decode(&raw).map(|t| std::borrow::Cow::Owned(t.into_owned())).map_err(|_| not_utf8())
}
Err(craft_svg::svgz::Error::TooLarge(_)) => Err(SvgError::TooBig(format!("the SVGZ file unpacks to more than {} MB", most >> 20))),
Err(craft_svg::svgz::Error::Corrupt(e)) => Err(SvgError::Parse(format!("not a valid SVGZ file: {e}"))),
Err(craft_svg::svgz::Error::Encoding) => Err(not_utf8()),
Err(e) => Err(SvgError::Parse(e.to_string())),
}
+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
+17 -9
View File
@@ -204,18 +204,25 @@ fn cmd_layers() -> Result<(), String> {
}
/// Workspace packages that must build for wasm32: all L0–L5 crates, the egui
/// shell, and the web app (its wasm-only code is compiled by nothing else).
fn wasm_set() -> Result<Vec<String>, String> {
let crates = layers::from_metadata(&metadata()?)?;
Ok(crates
/// shell, and the web app (its wasm-only code is compiled by nothing else). Each comes with its
/// package id, which `-p` takes where the name would be ambiguous: craft-libs builds against this
/// repo's geom and pathops by git, so the graph can hold a second, older copy of a workspace crate.
fn wasm_set() -> Result<Vec<(String, String)>, String> {
let meta = metadata()?;
let ids: std::collections::HashMap<&str, &str> =
meta["packages"].as_array().into_iter().flatten().filter_map(|p| Some((p["name"].as_str()?, p["id"].as_str()?))).collect();
layers::from_metadata(&meta)?
.into_iter()
.filter(|c| match layers::classify(&c.name) {
Some(layers::Class::Layer(l)) => l <= 5 || layers::short_name(&c.name) == "ui-egui",
Some(layers::Class::Standalone) => true,
_ => layers::short_name(&c.name) == "web",
})
.map(|c| c.name)
.collect())
.map(|c| {
let id = ids.get(c.name.as_str()).ok_or_else(|| format!("cargo metadata: no package id for {}", c.name))?.to_string();
Ok((c.name, id))
})
.collect()
}
/// One `cargo check` over the whole wasm set: the crates share one build, and `--keep-going`
@@ -224,10 +231,11 @@ fn cmd_wasm() -> Result<(), String> {
let set = wasm_set()?;
let mut c = cargo();
c.args(["check", "--target", "wasm32-unknown-unknown", "--keep-going"]);
for pkg in &set {
c.args(["-p", pkg]);
for (_, id) in &set {
c.args(["-p", id]);
}
run(c, &format!("cargo check --target wasm32-unknown-unknown --keep-going ({} crates: {})", set.len(), set.join(", ")))
let names: Vec<&str> = set.iter().map(|(name, _)| name.as_str()).collect();
run(c, &format!("cargo check --target wasm32-unknown-unknown --keep-going ({} crates: {})", set.len(), names.join(", ")))
}
/// Whether `cargo nextest` is installed.