mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 22:20:50 -06:00
ui.dialog.set stored any field name it was given, so a misspelled field (`discrad` for `discard`) was kept without effect and the dialog then ran with its old value. It now answers such a field with an error that lists the dialog's fields, and leaves the dialog unchanged. A dialog accepts the fields it holds, `__` fields, `discard` with a discard button, `preview` with a live preview, the fields it reads before a control adds them (each spec's `inputs`, for example Copy and the object options of the Transform dialogs, Move's `distance` and `angle`, the reference point, `path` of the export options dialogs, `searchFolder`, Brush Options' `tips`, the Justification and Hyphenation values that open blank, the Corners `kind` and `radius` of corners that differ, the Gradient Stop popover's `color`, `opacity`, `location` and `spread`, and a new artboard's `color` and display marks), and the fields it passes to the command it runs: every parameter of the generic `command` dialog's command, and the `textureImage` that Load Texture… sets in the Glass and Texturizer effects. Edit Selection now opens holding `deleted<i>` for each of its rows, so setting a row number it does not have returns an error, and Transparency Flattener Presets opens holding the selected preset's name and options instead of adding them on its first frame.
368 lines
16 KiB
Rust
368 lines
16 KiB
Rust
//! 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 (blank fields where paragraphs in scope differ) 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()),
|
||
// A value the paragraphs don't share opens blank, without its field.
|
||
inputs: |_| {
|
||
&[
|
||
"wordMin",
|
||
"wordDesired",
|
||
"wordMax",
|
||
"letterMin",
|
||
"letterDesired",
|
||
"letterMax",
|
||
"glyphMin",
|
||
"glyphDesired",
|
||
"glyphMax",
|
||
"autoLeading",
|
||
"singleWord",
|
||
]
|
||
},
|
||
..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> {
|
||
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 mut values = Map::new();
|
||
for (_, prefix, _) in ROWS {
|
||
for (i, (suffix, _)) in COLUMNS.iter().enumerate() {
|
||
let key = format!("{prefix}{suffix}");
|
||
if let Some(v) = crate::panels::paragraph::shared_justification_f64(app, |j| match prefix {
|
||
"word" => j.word_spacing[i],
|
||
"letter" => j.letter_spacing[i],
|
||
_ => j.glyph_scaling[i],
|
||
}) {
|
||
values.insert(key, json!(v));
|
||
}
|
||
}
|
||
}
|
||
if let Some(v) = crate::panels::paragraph::shared_justification_f64(app, |j| j.auto_leading) {
|
||
values.insert("autoLeading".into(), json!(v));
|
||
}
|
||
if let Some(s) = crate::panels::paragraph::shared_single_word_justify(app) {
|
||
values.insert("singleWord".into(), json!(single_word_name(s)));
|
||
}
|
||
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 keys = COLUMNS.map(|(suffix, _)| format!("{prefix}{suffix}"));
|
||
if !keys.iter().all(|k| d.fields.contains_key(k)) {
|
||
return false;
|
||
}
|
||
let [min, desired, max] = keys.map(|k| d.f64(&k, 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}");
|
||
let value = d.fields.get(&key).and_then(|v| v.as_f64());
|
||
if let Some(v) = widgets::range_field(ui, ("justification", key.as_str()), value, *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;
|
||
let leading = d.fields.get("autoLeading").and_then(|v| v.as_f64());
|
||
if let Some(v) = widgets::range_field(ui, ("justification", "autoLeading"), leading, 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.fields.get("singleWord").and_then(|v| v.as_str());
|
||
let shown = current.and_then(|current| choices.iter().find(|(v, _)| *v == current).map(|(_, l)| *l)).unwrap_or("");
|
||
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}");
|
||
if !d.fields.contains_key(&key) {
|
||
continue;
|
||
}
|
||
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));
|
||
}
|
||
}
|
||
if d.fields.contains_key("autoLeading") {
|
||
let leading = d.f64("autoLeading", 120.0);
|
||
if was("autoLeading") != Some(leading) {
|
||
p.insert("autoLeading".into(), json!(leading));
|
||
}
|
||
}
|
||
if let Some(single) = d.fields.get("singleWord").and_then(|v| v.as_str())
|
||
&& initial.and_then(|m| m.get("singleWord")).and_then(Value::as_str) != Some(single)
|
||
{
|
||
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);
|
||
}
|
||
|
||
/// When paragraphs in scope disagree, only shared justification values are filled in.
|
||
#[test]
|
||
fn justification_dialog_mixed_paragraph_fields_show_blank() {
|
||
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();
|
||
let d = app.ui.dialog.clone().unwrap();
|
||
assert!(!d.fields.contains_key("wordMin"));
|
||
assert!(!d.fields.contains_key("wordMax"));
|
||
assert_eq!(d.f64("wordDesired", 0.0), 100.0);
|
||
set(&mut app, "wordMin", json!(75.0));
|
||
super::super::confirm(&mut app).unwrap();
|
||
let (first, second) = paras(&app, id);
|
||
assert_eq!(first.justification.word_spacing, [75.0, 100.0, 133.0]);
|
||
assert_eq!(second.justification.word_spacing, [75.0, 100.0, 200.0]);
|
||
}
|
||
}
|