mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 05:07:51 -06:00
The Paragraph panel menu's Hyphenation… was always disabled, and automatic hyphenation used fixed limits: words of 6 letters or more, 3 letters on each side of the hyphen, any number of hyphenated lines in a row, and capitalized words hyphenated. Each paragraph now has hyphenation options: Words Longer Than, After First, Before Last, Hyphen Limit, Hyphenation Zone and Hyphenate Capitalized Words. The defaults are the fixed limits, and documents keep their line breaks. The options are saved only when they differ from the defaults. text.setFormat takes wordsLongerThan, afterFirst, beforeLast, hyphenLimit, hyphenationZone and hyphenateCapitalized, and paragraph styles carry the options. Hyphen Limit applies with both composers. Hyphenation Zone applies with the Single-line Composer: a line whose text before the next word already ends within the zone breaks before that word instead of hyphenating it. Hyphenation… is enabled with type selected and opens a Hyphenation dialog (ui.hyphenation) on the values the Paragraph panel shows. OK runs text.setFormat with the values that changed, as one undo step. Docs: docs/mcp.md, docs/target-app-parity.md.
183 lines
8.2 KiB
Rust
183 lines
8.2 KiB
Rust
//! Paragraph panel menu › Hyphenation… (or `ui.hyphenation`): the Hyphenation checkbox and the
|
||
//! limits automatic hyphenation keeps to in the paragraphs the Paragraph panel acts on: Words
|
||
//! Longer Than, After First, Before Last, Hyphen Limit, Hyphenation Zone and Hyphenate Capitalized
|
||
//! Words. The dialog opens on the values the Paragraph panel shows. OK runs `text.setFormat` with
|
||
//! the values that changed, as one undo step, and paragraphs that differ keep the values left alone.
|
||
//!
|
||
//! Fields: `hyphenate`, `wordsLongerThan` (letters, 1..25), `afterFirst` and `beforeLast`
|
||
//! (letters, 1..15), `hyphenLimit` (hyphens, 0..25, 0: no limit), `hyphenationZone` (pt) and
|
||
//! `hyphenateCapitalized`, as `text.setFormat` takes them, and `__initial` (the values it opened
|
||
//! with).
|
||
|
||
use serde_json::{Map, Value, json};
|
||
|
||
use super::swatch_options::{grid, label};
|
||
use super::{DialogSpec, form};
|
||
use crate::state::Dialog;
|
||
use crate::{VectorcraftApp, widgets};
|
||
|
||
/// The dialog kind of Hyphenation.
|
||
pub const KIND: &str = "hyphenation";
|
||
|
||
/// The field holding the values the dialog opened with.
|
||
const INITIAL: &str = "__initial";
|
||
|
||
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Hyphenation").into(), body, confirm, min_width: 300.0, ..DialogSpec::FORM };
|
||
|
||
/// Open Hyphenation 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")?;
|
||
let h = ¶.hyphenation;
|
||
let values = json!({
|
||
"hyphenate": para.hyphenate,
|
||
"wordsLongerThan": h.words_longer_than,
|
||
"afterFirst": h.after_first,
|
||
"beforeLast": h.before_last,
|
||
"hyphenLimit": h.hyphen_limit,
|
||
"hyphenationZone": h.zone,
|
||
"hyphenateCapitalized": h.capitalized,
|
||
});
|
||
let mut fields = values.clone();
|
||
fields[INITIAL] = values;
|
||
app.ui.dialog = Some(Dialog::new(KIND, fields));
|
||
Ok(Value::Null)
|
||
}
|
||
|
||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||
let unit = app.session.general_unit();
|
||
form::check(ui, d, "hyphenate", tl!("Hyphenation"));
|
||
ui.add_space(8.0);
|
||
// The whole-number fields: (field, label, unit, range).
|
||
let counts = [
|
||
("wordsLongerThan", tl!("Words Longer Than:"), tl!("letters"), (1.0, 25.0)),
|
||
("afterFirst", tl!("After First:"), tl!("letters"), (1.0, 15.0)),
|
||
("beforeLast", tl!("Before Last:"), tl!("letters"), (1.0, 15.0)),
|
||
("hyphenLimit", tl!("Hyphen Limit:"), tl!("hyphens"), (0.0, 25.0)),
|
||
];
|
||
grid(ui, |ui| {
|
||
for (key, text, suffix, (lo, hi)) in counts {
|
||
label(ui, text);
|
||
ui.horizontal(|ui| {
|
||
if let Some(v) = widgets::range_field(ui, ("hyphenation", key), d.f64(key, lo), lo..=hi, "", 0, 60.0) {
|
||
d.fields.insert(key.into(), json!(v));
|
||
}
|
||
ui.label(suffix);
|
||
});
|
||
ui.end_row();
|
||
}
|
||
label(ui, tl!("Hyphenation Zone:"));
|
||
form::length(ui, d, "hyphenationZone", unit, 90.0);
|
||
ui.end_row();
|
||
});
|
||
ui.add_space(8.0);
|
||
form::check(ui, d, "hyphenateCapitalized", tl!("Hyphenate Capitalized Words"));
|
||
false
|
||
}
|
||
|
||
/// `text.setFormat` parameters: the fields that differ from the values the dialog opened with.
|
||
fn params(d: &Dialog) -> Map<String, Value> {
|
||
let initial = d.fields.get(INITIAL).and_then(Value::as_object);
|
||
let mut p = Map::new();
|
||
for key in ["hyphenate", "wordsLongerThan", "afterFirst", "beforeLast", "hyphenLimit", "hyphenationZone", "hyphenateCapitalized"] {
|
||
let Some(v) = d.fields.get(key) else { continue };
|
||
// Numbers are compared as numbers (a field stores 3 as 3.0), and a length typed with its
|
||
// unit ("6 mm") is sent in points.
|
||
let v = if v.is_boolean() { v.clone() } else { json!(d.f64(key, 0.0)) };
|
||
let same = match (initial.and_then(|m| m.get(key)), &v) {
|
||
(Some(Value::Bool(was)), Value::Bool(now)) => was == now,
|
||
(Some(was), now) => was.as_f64().zip(now.as_f64()).is_some_and(|(a, b)| a == b),
|
||
(None, _) => false,
|
||
};
|
||
if !same {
|
||
p.insert(key.into(), v);
|
||
}
|
||
}
|
||
p
|
||
}
|
||
|
||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||
let p = params(d);
|
||
app.ui.dialog = None;
|
||
if p.is_empty() {
|
||
return Ok(Value::Null);
|
||
}
|
||
crate::panels::paragraph::run_on_paragraphs(app, "text.setFormat", Value::Object(p))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use vectorcraft_engine::Session;
|
||
use vectorcraft_engine::doc::{HyphenationOptions, 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"),
|
||
}
|
||
}
|
||
|
||
/// `ui.hyphenation` opens the dialog on the selected type; OK sets the paragraphs' hyphenation
|
||
/// options in one undo step.
|
||
#[test]
|
||
fn hyphenation_dialog_sets_the_paragraphs_options() {
|
||
let (mut app, id) = app();
|
||
app.run("ui.hyphenation", json!({})).unwrap();
|
||
let d = app.ui.dialog.clone().expect("Hyphenation opened");
|
||
assert_eq!(d.kind, KIND);
|
||
assert_eq!((d.bool("hyphenate"), d.f64("wordsLongerThan", 0.0), d.f64("hyphenLimit", -1.0)), (false, 5.0, 0.0));
|
||
assert!(d.bool("hyphenateCapitalized"));
|
||
let d = app.ui.dialog.as_mut().unwrap();
|
||
d.fields.insert("hyphenate".into(), json!(true));
|
||
d.fields.insert("wordsLongerThan".into(), json!(8));
|
||
d.fields.insert("hyphenLimit".into(), json!(2));
|
||
d.fields.insert("hyphenationZone".into(), json!("1 in"));
|
||
d.fields.insert("hyphenateCapitalized".into(), json!(false));
|
||
let undo = app.session.active().unwrap().history.undo.len();
|
||
super::super::confirm(&mut app).unwrap();
|
||
assert!(app.ui.dialog.is_none());
|
||
let want = HyphenationOptions { words_longer_than: 8, hyphen_limit: 2, zone: 72.0, capitalized: false, ..HyphenationOptions::default() };
|
||
for p in [paras(&app, id).0, paras(&app, id).1] {
|
||
assert!(p.hyphenate);
|
||
assert_eq!(p.hyphenation, want);
|
||
}
|
||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1);
|
||
}
|
||
|
||
/// OK sends only the values that changed: paragraphs whose other options differ keep them.
|
||
#[test]
|
||
fn hyphenation_dialog_leaves_unchanged_values_alone() {
|
||
let (mut app, id) = app();
|
||
app.run("text.setFormat", json!({"ids": [id], "afterFirst": 2, "start": 5, "end": 5})).unwrap();
|
||
app.run("ui.hyphenation", json!({})).unwrap();
|
||
let d = app.ui.dialog.as_mut().unwrap();
|
||
d.fields.insert("beforeLast".into(), json!(4));
|
||
// Edited and set back to the value it opened with, as a field stores it.
|
||
d.fields.insert("afterFirst".into(), json!(3.0));
|
||
super::super::confirm(&mut app).unwrap();
|
||
let (first, second) = paras(&app, id);
|
||
assert_eq!((first.hyphenation.after_first, first.hyphenation.before_last), (3, 4));
|
||
assert_eq!((second.hyphenation.after_first, second.hyphenation.before_last), (2, 4));
|
||
// Nothing changed: no undo step.
|
||
let undo = app.session.active().unwrap().history.undo.len();
|
||
app.run("ui.hyphenation", 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.hyphenation", json!({})).is_err());
|
||
assert!(app.ui.dialog.is_none());
|
||
}
|
||
}
|