Files
vectorcraft/crates/ui-egui/src/dialogs/justification.rs
T
VictorandClaude Opus 5.5 a275496ef4 G4: Paragraph panel menu › Justification… (word and letter spacing, glyph scaling, auto leading, single word justification)
The Paragraph panel menu's Justification… was a disabled stub, and justified
lines stretched word spaces between fixed limits (80% to 150%).

Each paragraph now has Justification options: Word Spacing, Letter Spacing
and Glyph Scaling (Minimum, Desired, Maximum, in percent), Auto Leading and
Single Word Justification. Desired applies to every line; a justified line
fills its measure with word spacing first, then letter spacing, then glyph
scaling, each within its range, and past them its word spaces take the
rest. The Every-line Composer scores lines by that flexibility. Auto
Leading sets the leading of characters whose leading is Auto. A justified
line holding one word is spread between its letters (Full Justify) or
aligned left, centre or right.

The defaults are the documented ones (80/100/133, 0/0/0, 100/100/100, 120,
Full Justify), saved only when they differ. Because the default maximum
word spacing is 133%, justified paragraphs break differently than before;
the Every-line Composer's second pass is now unbounded for justified text
(it used to give up past four times the stretch and fall back to the
Single-line Composer, which the narrower default range would make common),
and lines that can't stretch at all are still refused.

text.setFormat takes wordSpacing, letterSpacing, glyphScaling ([min,
desired, max] or {min?, desired?, max?}), autoLeading and
singleWordJustification; Minimum ≤ Desired ≤ Maximum must hold or nothing
changes. Paragraph styles carry the options (New and Redefine capture
them), and Reset Panel resets them. Justification… opens a dialog
(ui.justification) with Preview; OK is disabled while a row is out of
order. Live SVG text places characters the options move, and a glyph that
Glyph Scaling scales is stretched to its width; PDF uses the glyphs'
transforms. Vertical type isn't glyph scaled; type on a path doesn't use
the options.

Docs: docs/mcp.md, docs/type-parity.md, docs/gaps.md.

Closes #2755.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:40:14 +07:00

312 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Paragraph panel menu › Justification… (or `ui.justification`): Word Spacing, Letter Spacing and
//! Glyph Scaling (each Minimum, Desired and Maximum, in percent), Auto Leading and Single Word
//! Justification of the paragraphs the Paragraph panel acts on. The dialog opens on the values the
//! Paragraph panel shows and previews them on the canvas. OK runs `text.setFormat` with the values
//! that changed, as one undo step; paragraphs that differ keep the values left alone. OK is
//! disabled while a row's Minimum exceeds its Desired value or Desired its Maximum.
//!
//! Fields: `wordMin`, `wordDesired`, `wordMax` (0..1000), `letterMin`, `letterDesired`,
//! `letterMax` (-100..500), `glyphMin`, `glyphDesired`, `glyphMax` (50..200), `autoLeading`
//! (0..500), `singleWord` (`fullJustify`, `alignLeft`, `alignCenter`, `alignRight`) and `preview`,
//! with `__initial` (the values it opened with) and `__targets` (the type and range it acts on).
//!
//! Behaviour reference (public documentation):
//! <https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/adjust-word-and-letterspacing-in-justified-text.html>.
use serde_json::{Map, Value, json};
use vectorcraft_doc::{JustificationOptions, SingleWordJustify};
use super::swatch_options::label;
use super::{DialogSpec, form};
use crate::state::Dialog;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of Justification.
pub const KIND: &str = "justification";
/// The field holding the values the dialog opened with.
const INITIAL: &str = "__initial";
/// The field holding the `ids`, `start` and `end` the dialog's `text.setFormat` acts on.
const TARGETS: &str = "__targets";
const CMD: &str = "text.setFormat";
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| tl!("Justification").into(),
body,
confirm,
preview: true,
min_width: 460.0,
ok_enabled: Some(|_, d| problem(d).is_none()),
..DialogSpec::FORM
};
/// The three spacing rows: (`text.setFormat` key, field prefix, range).
const ROWS: [(&str, &str, (f64, f64)); 3] = [
("wordSpacing", "word", JustificationOptions::WORD_SPACING),
("letterSpacing", "letter", JustificationOptions::LETTER_SPACING),
("glyphScaling", "glyph", JustificationOptions::GLYPH_SCALING),
];
/// The columns of a row: (field suffix, `text.setFormat` key).
const COLUMNS: [(&str, &str); 3] = [("Min", "min"), ("Desired", "desired"), ("Max", "max")];
/// Single Word Justification's choices: (`text.setFormat` value, label).
fn single_word_choices() -> [(&'static str, &'static str); 4] {
[("fullJustify", tl!("Full Justify")), ("alignLeft", tl!("Align left")), ("alignCenter", tl!("Align center")), ("alignRight", tl!("Align right"))]
}
fn single_word_name(s: SingleWordJustify) -> &'static str {
match s {
SingleWordJustify::FullJustify => "fullJustify",
SingleWordJustify::AlignLeft => "alignLeft",
SingleWordJustify::AlignCenter => "alignCenter",
SingleWordJustify::AlignRight => "alignRight",
}
}
/// Open Justification on the paragraph attributes the Paragraph panel shows.
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
let (_, para) = crate::panels::character::text_style(app).ok_or("select some type first")?;
// While typing, the dialog acts on the paragraphs of the text being edited.
let mut targets = json!({});
if let Some((id, a, b)) = crate::panels::character::text_editing(app) {
crate::panels::character::end_typing(app);
targets = json!({"ids": [id.0], "start": a, "end": b});
}
let j = &para.justification;
let mut values = Map::new();
for ((_, prefix, _), v) in ROWS.iter().zip([j.word_spacing, j.letter_spacing, j.glyph_scaling]) {
for ((suffix, _), x) in COLUMNS.iter().zip(v) {
values.insert(format!("{prefix}{suffix}"), json!(x));
}
}
values.insert("autoLeading".into(), json!(j.auto_leading));
values.insert("singleWord".into(), json!(single_word_name(j.single_word)));
let mut fields = Value::Object(values.clone());
fields[INITIAL] = Value::Object(values);
fields[TARGETS] = targets;
fields["preview"] = json!(false);
app.ui.dialog = Some(Dialog::new(KIND, fields));
Ok(Value::Null)
}
/// Why OK can't apply the dialog: a row whose values are out of order.
fn problem(d: &Dialog) -> Option<&'static str> {
let out_of_order = ROWS.iter().any(|(_, prefix, _)| {
let [min, desired, max] = COLUMNS.map(|(suffix, _)| d.f64(&format!("{prefix}{suffix}"), 0.0));
!(min <= desired && desired <= max)
});
out_of_order.then(|| tl!("Minimum must not exceed Desired, nor Desired Maximum."))
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
// Its own grid: a label column and three value columns.
egui::Grid::new("justification-spacing").num_columns(4).spacing([10.0, 8.0]).min_col_width(70.0).show(ui, |ui| {
ui.label("");
for heading in [tl!("Minimum"), tl!("Desired"), tl!("Maximum")] {
ui.label(heading);
}
ui.end_row();
for ((_, prefix, (lo, hi)), text) in ROWS.iter().zip([tl!("Word Spacing:"), tl!("Letter Spacing:"), tl!("Glyph Scaling:")]) {
label(ui, text);
for (suffix, _) in COLUMNS {
let key = format!("{prefix}{suffix}");
if let Some(v) = widgets::range_field(ui, ("justification", key.as_str()), d.f64(&key, 0.0), *lo..=*hi, "%", 0, 70.0) {
d.fields.insert(key, json!(v));
}
}
ui.end_row();
}
});
ui.add_space(8.0);
egui::Grid::new("justification-more").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
label(ui, tl!("Auto Leading:"));
let (lo, hi) = JustificationOptions::AUTO_LEADING;
if let Some(v) = widgets::range_field(ui, ("justification", "autoLeading"), d.f64("autoLeading", 120.0), lo..=hi, "%", 0, 64.0) {
d.fields.insert("autoLeading".into(), json!(v));
}
ui.end_row();
label(ui, tl!("Single Word Justification:"));
let choices = single_word_choices();
let current = d.str("singleWord");
let shown = choices.iter().find(|(v, _)| *v == current).map_or(choices[0].1, |(_, l)| *l);
let labels = choices.map(|(_, l)| l);
if let Some(i) = widgets::dropdown(ui, ("justification", "singleWord"), shown, &labels, 140.0)
&& let Some((v, _)) = choices.get(i)
{
d.fields.insert("singleWord".into(), json!(v));
}
ui.end_row();
});
match problem(d) {
Some(why) => {
ui.add_space(6.0);
ui.colored_label(crate::theme::Tokens::get(ui.ctx()).error, why);
}
None => form::preview(app, ui, d, tl!("Justification"), CMD, Value::Object(params(d))),
}
false
}
/// `text.setFormat` parameters: the fields that differ from the values the dialog opened with,
/// on the dialog's type and range.
fn params(d: &Dialog) -> Map<String, Value> {
let initial = d.fields.get(INITIAL).and_then(Value::as_object);
let was = |key: &str| initial.and_then(|m| m.get(key)).and_then(Value::as_f64);
let mut p = d.fields.get(TARGETS).and_then(Value::as_object).cloned().unwrap_or_default();
for (param, prefix, _) in ROWS {
let mut changed = Map::new();
for (suffix, name) in COLUMNS {
let key = format!("{prefix}{suffix}");
let v = d.f64(&key, 0.0);
if was(&key) != Some(v) {
changed.insert(name.into(), json!(v));
}
}
if !changed.is_empty() {
p.insert(param.into(), Value::Object(changed));
}
}
let leading = d.f64("autoLeading", 120.0);
if was("autoLeading") != Some(leading) {
p.insert("autoLeading".into(), json!(leading));
}
let single = d.str("singleWord");
if initial.and_then(|m| m.get("singleWord")).and_then(Value::as_str) != Some(single.as_str()) {
p.insert("singleWordJustification".into(), json!(single));
}
p
}
/// Does `p` change anything (more than the targets)?
fn changes(p: &Map<String, Value>) -> bool {
p.keys().any(|k| !matches!(k.as_str(), "ids" | "start" | "end"))
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
if let Some(why) = problem(d) {
return Err(why.into());
}
let p = params(d);
if !changes(&p) {
if app.session.in_interaction() {
let _ = app.session.cancel_interaction();
}
app.ui.dialog = None;
return Ok(Value::Null);
}
form::commit_preview(app, CMD, Value::Object(p))
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
use vectorcraft_engine::doc::{NodeId, NodeKind, ParaStyle};
/// A selected area type object with two paragraphs.
fn app() -> (VectorcraftApp, u64) {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 400})).unwrap();
let id = app.run("text.create", json!({"x": 10, "y": 10, "text": "One\nTwo", "area": {"width": 200, "height": 100}})).unwrap()["id"]
.as_u64()
.unwrap();
app.run("select.set", json!({"ids": [id]})).unwrap();
(app, id)
}
fn paras(app: &VectorcraftApp, id: u64) -> (ParaStyle, ParaStyle) {
match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => (t.para_at(0).clone(), t.para_at(1).clone()),
_ => panic!("not text"),
}
}
fn frame(app: &mut VectorcraftApp) {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(app, ui.ctx()));
out.textures_delta.clear();
}
fn set(app: &mut VectorcraftApp, key: &str, v: Value) {
app.ui.dialog.as_mut().unwrap().fields.insert(key.into(), v);
}
/// `ui.justification` opens the dialog on the selected type's values; OK sets the paragraphs'
/// justification options in one undo step.
#[test]
fn justification_dialog_sets_the_paragraphs_options() {
let (mut app, id) = app();
app.run("ui.justification", json!({})).unwrap();
let d = app.ui.dialog.clone().expect("Justification opened");
assert_eq!(d.kind, KIND);
assert_eq!((d.f64("wordMin", 0.0), d.f64("wordDesired", 0.0), d.f64("wordMax", 0.0)), (80.0, 100.0, 133.0));
assert_eq!((d.f64("glyphDesired", 0.0), d.f64("autoLeading", 0.0), d.str("singleWord")), (100.0, 120.0, "fullJustify".to_string()));
set(&mut app, "wordMax", json!(150));
set(&mut app, "letterMax", json!(5));
set(&mut app, "glyphMin", json!(97));
set(&mut app, "autoLeading", json!(140));
set(&mut app, "singleWord", json!("alignRight"));
let undo = app.session.active().unwrap().history.undo.len();
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let want = JustificationOptions {
word_spacing: [80.0, 100.0, 150.0],
letter_spacing: [0.0, 0.0, 5.0],
glyph_scaling: [97.0, 100.0, 100.0],
auto_leading: 140.0,
single_word: SingleWordJustify::AlignRight,
};
for p in [paras(&app, id).0, paras(&app, id).1] {
assert_eq!(p.justification, want);
}
assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1);
}
/// OK sends only the values that changed: paragraphs whose other values differ keep them. Values
/// out of order keep the dialog open and change nothing.
#[test]
fn justification_dialog_leaves_unchanged_values_alone_and_refuses_values_out_of_order() {
let (mut app, id) = app();
app.run("text.setFormat", json!({"ids": [id], "wordSpacing": [70, 100, 200], "start": 5, "end": 5})).unwrap();
app.run("ui.justification", json!({})).unwrap();
set(&mut app, "letterMax", json!(20));
// Desired above Maximum: refused.
set(&mut app, "glyphDesired", json!(150));
let d = app.ui.dialog.clone().unwrap();
assert!(problem(&d).is_some());
assert!(super::super::confirm(&mut app).is_err());
assert!(app.ui.dialog.is_some(), "the dialog stays open");
assert_eq!(paras(&app, id).0.justification, JustificationOptions::default());
set(&mut app, "glyphDesired", json!(100.0));
super::super::confirm(&mut app).unwrap();
let (first, second) = paras(&app, id);
assert_eq!((first.justification.word_spacing, first.justification.letter_spacing), ([80.0, 100.0, 133.0], [0.0, 0.0, 20.0]));
assert_eq!((second.justification.word_spacing, second.justification.letter_spacing), ([70.0, 100.0, 200.0], [0.0, 0.0, 20.0]));
// Nothing changed: no undo step.
let undo = app.session.active().unwrap().history.undo.len();
app.run("ui.justification", json!({})).unwrap();
super::super::confirm(&mut app).unwrap();
assert_eq!(app.session.active().unwrap().history.undo.len(), undo);
// With no type selected the dialog does not open.
app.run("select.set", json!({"ids": []})).unwrap();
assert!(app.run("ui.justification", json!({})).is_err());
assert!(app.ui.dialog.is_none());
}
/// Preview shows the values on the canvas; Cancel rolls them back.
#[test]
fn justification_dialog_previews_and_cancel_restores() {
let (mut app, id) = app();
app.run("ui.justification", json!({})).unwrap();
set(&mut app, "autoLeading", json!(200));
set(&mut app, "preview", json!(true));
frame(&mut app);
assert!(app.session.in_interaction(), "the preview runs");
assert_eq!(paras(&app, id).0.justification.auto_leading, 200.0);
super::super::cancel(&mut app);
assert!(app.ui.dialog.is_none());
assert_eq!(paras(&app, id).0.justification.auto_leading, 120.0);
}
}