mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-27 20:07:19 -06:00
Read effective character formatting so unchanged OK preserves language and no-proof choices inherited from defaults and styles.
1098 lines
46 KiB
Rust
1098 lines
46 KiB
Rust
//! Layout, Review and Mailings dialogs (#407): Line Numbers (Layout › Line Numbers › Line
|
||
//! Numbering Options…), Hyphenation (Hyphenation Options… and Manual…), Language (Review ›
|
||
//! Language), Envelopes and Labels (Mailings › Create), Formula (Table Layout › Data, #1201)
|
||
//! and Sort (Table Layout › Data, #1200). Each ends by running a command (`layout.lineNumbers`,
|
||
//! `layout.hyphenation`, `layout.manualHyphenation` with `caret.set` and `text.optionalHyphen`,
|
||
//! `review.language`, `mailings.envelopes`, `mailings.labels`, `table.formula`, `table.sort`), so
|
||
//! scripts and agents get the same result without the dialog. Shown through
|
||
//! [`crate::dialogs::Dialog::Layout`].
|
||
|
||
use egui::{Ui, vec2};
|
||
use serde::Serialize;
|
||
use serde_json::{Value, json};
|
||
|
||
use wordcraft_doc::section::LineNumberRestart;
|
||
use wordcraft_engine::cmd::mailings::{ENVELOPE_SIZES, LABEL_PRODUCTS};
|
||
|
||
use crate::WordApp;
|
||
use crate::options::MeasureExt;
|
||
use crate::theme::semibold;
|
||
|
||
#[derive(Clone, Debug, Serialize)]
|
||
#[serde(tag = "form", rename_all = "camelCase")]
|
||
pub enum LayoutDialog {
|
||
LineNumbers(LineNumbersForm),
|
||
Hyphenation(HyphenationForm),
|
||
ManualHyphenation(ManualForm),
|
||
Language(LanguageForm),
|
||
Envelopes(EnvelopeForm),
|
||
Labels(LabelsForm),
|
||
Formula(FormulaForm),
|
||
Sort(SortForm),
|
||
}
|
||
|
||
impl LayoutDialog {
|
||
pub fn name(&self) -> &'static str {
|
||
match self {
|
||
LayoutDialog::LineNumbers(_) => "lineNumbers",
|
||
LayoutDialog::Hyphenation(_) => "hyphenation",
|
||
LayoutDialog::ManualHyphenation(_) => "manualHyphenation",
|
||
LayoutDialog::Language(_) => "language",
|
||
LayoutDialog::Envelopes(_) => "envelopes",
|
||
LayoutDialog::Labels(_) => "labels",
|
||
LayoutDialog::Formula(_) => "tableFormula",
|
||
LayoutDialog::Sort(_) => "tableSort",
|
||
}
|
||
}
|
||
|
||
/// The window title (English; the caller translates it).
|
||
pub fn title(&self) -> &'static str {
|
||
match self {
|
||
LayoutDialog::LineNumbers(_) => "Line Numbers",
|
||
LayoutDialog::Hyphenation(_) => "Hyphenation",
|
||
LayoutDialog::ManualHyphenation(_) => "Manual Hyphenation",
|
||
LayoutDialog::Language(_) => "Language",
|
||
LayoutDialog::Envelopes(_) => "Envelopes",
|
||
LayoutDialog::Labels(_) => "Labels",
|
||
LayoutDialog::Formula(_) => "Formula",
|
||
LayoutDialog::Sort(_) => "Sort",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The dialog `ui.dialog` opens by `name`.
|
||
pub fn open(name: &str, app: &mut WordApp) -> Option<LayoutDialog> {
|
||
Some(match name {
|
||
"lineNumbers" => LayoutDialog::LineNumbers(LineNumbersForm::read(app)),
|
||
"hyphenation" => LayoutDialog::Hyphenation(HyphenationForm::read(app)),
|
||
"manualHyphenation" => LayoutDialog::ManualHyphenation(ManualForm::start(app)),
|
||
"language" => LayoutDialog::Language(LanguageForm::read(app)),
|
||
"envelopes" => LayoutDialog::Envelopes(EnvelopeForm::read(app)),
|
||
"labels" => LayoutDialog::Labels(LabelsForm::read(app)),
|
||
"tableFormula" => LayoutDialog::Formula(FormulaForm::read(app)?),
|
||
"tableSort" => LayoutDialog::Sort(SortForm::read(app)?),
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
/// Draws the dialog; returns true to close it.
|
||
pub fn body(app: &mut WordApp, ui: &mut Ui, d: &mut LayoutDialog) -> bool {
|
||
match d {
|
||
LayoutDialog::LineNumbers(f) => line_numbers_ui(app, ui, f),
|
||
LayoutDialog::Hyphenation(f) => hyphenation_ui(app, ui, f),
|
||
LayoutDialog::ManualHyphenation(f) => manual_ui(app, ui, f),
|
||
LayoutDialog::Language(f) => language_ui(app, ui, f),
|
||
LayoutDialog::Envelopes(f) => envelopes_ui(app, ui, f),
|
||
LayoutDialog::Labels(f) => labels_ui(app, ui, f),
|
||
LayoutDialog::Formula(f) => formula_ui(app, ui, f),
|
||
LayoutDialog::Sort(f) => sort_ui(app, ui, f),
|
||
}
|
||
}
|
||
|
||
/// The accent button and Cancel; returns (ok, cancel).
|
||
fn buttons(ui: &mut Ui, ok: &str, ok_enabled: bool) -> (bool, bool) {
|
||
let mut r = (false, false);
|
||
ui.add_space(8.0);
|
||
ui.horizontal(|ui| {
|
||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||
if ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||
r.1 = true;
|
||
}
|
||
let okb = ui.add_enabled(ok_enabled, crate::widgets::primary_button(ui, tl!(ok)));
|
||
if okb.clicked() {
|
||
r.0 = true;
|
||
}
|
||
});
|
||
});
|
||
r
|
||
}
|
||
|
||
fn heading(ui: &mut Ui, s: &str) {
|
||
ui.add_space(4.0);
|
||
ui.label(egui::RichText::new(tl!(s)).font(semibold(12.5)));
|
||
}
|
||
|
||
/// Runs a dialog's command; a refusal shows in the status bar and keeps the dialog open.
|
||
fn run(app: &mut WordApp, id: &str, params: Value) -> bool {
|
||
match app.run(id, params) {
|
||
Ok(_) => true,
|
||
Err(e) => {
|
||
app.status(e);
|
||
false
|
||
}
|
||
}
|
||
}
|
||
|
||
fn unit() -> wordcraft_geom::Unit {
|
||
crate::options::unit()
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Line Numbers
|
||
|
||
/// The Line Numbers dialog's fields. `distance` is in the interface unit.
|
||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct LineNumbersForm {
|
||
pub on: bool,
|
||
pub start: u32,
|
||
/// From text: automatic, or `distance`.
|
||
pub auto_distance: bool,
|
||
pub distance: f32,
|
||
pub count_by: u32,
|
||
/// `restartPage`, `restartSection` or `continuous`.
|
||
pub restart: String,
|
||
}
|
||
|
||
impl LineNumbersForm {
|
||
pub fn read(app: &WordApp) -> LineNumbersForm {
|
||
let sp = wordcraft_engine::cmd::page::sect(&app.session);
|
||
let l = sp.line_numbers.clone();
|
||
let cur = l.clone().unwrap_or_default();
|
||
LineNumbersForm {
|
||
on: l.is_some(),
|
||
start: cur.start.max(1),
|
||
auto_distance: cur.distance <= 0.0,
|
||
distance: if cur.distance > 0.0 { cur.distance / unit().pt_per_unit() } else { 0.25 * 72.0 / unit().pt_per_unit() },
|
||
count_by: cur.count_by.max(1),
|
||
restart: match cur.restart {
|
||
LineNumberRestart::Page => "restartPage",
|
||
LineNumberRestart::Section => "restartSection",
|
||
LineNumberRestart::Continuous => "continuous",
|
||
}
|
||
.into(),
|
||
}
|
||
}
|
||
|
||
/// `layout.lineNumbers` parameters.
|
||
pub fn params(&self) -> Value {
|
||
if !self.on {
|
||
return json!({"value": "none"});
|
||
}
|
||
let distance = if self.auto_distance { 0.0 } else { (self.distance * unit().pt_per_unit()).max(0.0) };
|
||
json!({"value": self.restart, "start": self.start, "countBy": self.count_by, "distance": distance})
|
||
}
|
||
}
|
||
|
||
fn line_numbers_ui(app: &mut WordApp, ui: &mut Ui, f: &mut LineNumbersForm) -> bool {
|
||
ui.checkbox(&mut f.on, tl!("Add line numbering"));
|
||
ui.add_enabled_ui(f.on, |ui| {
|
||
egui::Grid::new("lnum").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||
ui.label(tl!("Start at:"));
|
||
ui.add(egui::DragValue::new(&mut f.start).range(1..=32_767));
|
||
ui.end_row();
|
||
ui.label(tl!("From text:"));
|
||
ui.horizontal(|ui| {
|
||
ui.checkbox(&mut f.auto_distance, tl!("Auto"));
|
||
ui.add_enabled(
|
||
!f.auto_distance,
|
||
egui::DragValue::new(&mut f.distance).speed(0.01).range(0.0..=22.0 * 72.0 / unit().pt_per_unit()).measure(unit()).max_decimals(2),
|
||
);
|
||
});
|
||
ui.end_row();
|
||
ui.label(tl!("Count by:"));
|
||
ui.add(egui::DragValue::new(&mut f.count_by).range(1..=100));
|
||
ui.end_row();
|
||
});
|
||
heading(ui, "Numbering:");
|
||
for (v, l) in [("restartPage", "Restart Each Page"), ("restartSection", "Restart Each Section"), ("continuous", "Continuous")] {
|
||
ui.radio_value(&mut f.restart, v.to_string(), tl!(l));
|
||
}
|
||
});
|
||
let (ok, cancel) = buttons(ui, "OK", true);
|
||
if ok {
|
||
return run(app, "layout.lineNumbers", f.params());
|
||
}
|
||
cancel
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Hyphenation
|
||
|
||
/// The Hyphenation dialog's fields. `zone` is in the interface unit; `limit` 0 = no limit.
|
||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct HyphenationForm {
|
||
pub auto: bool,
|
||
pub caps: bool,
|
||
pub zone: f32,
|
||
pub limit: u32,
|
||
}
|
||
|
||
impl HyphenationForm {
|
||
pub fn read(app: &WordApp) -> HyphenationForm {
|
||
let s = &app.session.doc.settings;
|
||
HyphenationForm {
|
||
auto: s.auto_hyphenation,
|
||
caps: s.hyphenate_caps,
|
||
zone: s.hyphenation_zone.max(0.0) / unit().pt_per_unit(),
|
||
limit: s.consecutive_hyphen_limit,
|
||
}
|
||
}
|
||
|
||
/// `layout.hyphenation` parameters.
|
||
pub fn params(&self) -> Value {
|
||
json!({"value": self.auto, "caps": self.caps, "zone": (self.zone * unit().pt_per_unit()).max(0.0), "limit": self.limit})
|
||
}
|
||
}
|
||
|
||
fn hyphenation_ui(app: &mut WordApp, ui: &mut Ui, f: &mut HyphenationForm) -> bool {
|
||
ui.checkbox(&mut f.auto, tl!("Automatically hyphenate document"));
|
||
ui.checkbox(&mut f.caps, tl!("Hyphenate words in CAPS"));
|
||
egui::Grid::new("hyph").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||
ui.label(tl!("Hyphenation zone:"));
|
||
ui.add(egui::DragValue::new(&mut f.zone).speed(0.01).range(0.0..=22.0 * 72.0 / unit().pt_per_unit()).measure(unit()).max_decimals(2));
|
||
ui.end_row();
|
||
ui.label(tl!("Limit consecutive hyphens to:"));
|
||
let no_limit = tl!("No limit").to_string();
|
||
ui.add(
|
||
egui::DragValue::new(&mut f.limit).range(0..=99).custom_formatter(move |n, _| if n < 0.5 { no_limit.clone() } else { format!("{n:.0}") }),
|
||
);
|
||
ui.end_row();
|
||
});
|
||
ui.add_space(6.0);
|
||
let manual = ui.button(tl!("Manual…")).clicked();
|
||
let (ok, cancel) = buttons(ui, "OK", true);
|
||
if (ok || manual) && !run(app, "layout.hyphenation", f.params()) {
|
||
return false;
|
||
}
|
||
// Manual… keeps what was set here and moves on to manual hyphenation.
|
||
if manual {
|
||
app.dialog = crate::dialogs::Dialog::open("manualHyphenation", app);
|
||
}
|
||
ok || cancel || manual
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Manual hyphenation
|
||
|
||
/// One word at a time: where it may be hyphenated and where the hyphen goes.
|
||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct ManualForm {
|
||
pub word: String,
|
||
/// Where the word starts.
|
||
pub pos: Value,
|
||
/// Char indices a hyphen may go before.
|
||
pub points: Vec<usize>,
|
||
pub choice: usize,
|
||
/// No more words.
|
||
pub done: bool,
|
||
}
|
||
|
||
impl ManualForm {
|
||
/// From the selection, or from the start of the document.
|
||
pub fn start(app: &mut WordApp) -> ManualForm {
|
||
let from = if app.session.sel.is_collapsed() { Value::Null } else { wordcraft_engine::cmd::pos_json(&app.session.sel.ordered().0) };
|
||
let from = if from.is_null() { json!({"block": 0, "off": 0}) } else { from };
|
||
Self::next(app, from)
|
||
}
|
||
|
||
/// The next word from `from`.
|
||
pub fn next(app: &mut WordApp, from: Value) -> ManualForm {
|
||
let c = app.session.run("layout.manualHyphenation", &json!({"from": from})).unwrap_or_else(|_| json!({"done": true}));
|
||
let points: Vec<usize> =
|
||
c.get("points").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|n| n as usize).collect()).unwrap_or_default();
|
||
// The word is selected, so the page shows which one is asked about.
|
||
if let (Some(pos), Some(word)) = (c.get("pos"), c.get("word").and_then(Value::as_str)) {
|
||
let end = with_off(pos, pos.get("off").and_then(Value::as_u64).unwrap_or(0).saturating_add(word.len() as u64));
|
||
let _ = app.session.run("select.range", &json!({"anchor": pos, "focus": end}));
|
||
}
|
||
ManualForm {
|
||
word: c.get("word").and_then(Value::as_str).unwrap_or("").to_string(),
|
||
pos: c.get("pos").cloned().unwrap_or(Value::Null),
|
||
choice: c.get("suggest").and_then(Value::as_u64).map(|n| n as usize).or_else(|| points.last().copied()).unwrap_or(0),
|
||
points,
|
||
done: c.get("done").and_then(Value::as_bool).unwrap_or(false) || c.get("word").is_none(),
|
||
}
|
||
}
|
||
|
||
/// The word's position plus `bytes`.
|
||
fn at(&self, bytes: usize) -> Value {
|
||
let off = self.pos.get("off").and_then(Value::as_u64).unwrap_or(0);
|
||
with_off(&self.pos, off.saturating_add(bytes as u64))
|
||
}
|
||
|
||
fn byte_of(&self, ch: usize) -> usize {
|
||
self.word.char_indices().nth(ch).map_or(self.word.len(), |(i, _)| i)
|
||
}
|
||
|
||
/// Yes: an optional hyphen at the chosen point, then the next word.
|
||
pub fn accept(&mut self, app: &mut WordApp) {
|
||
let at = self.at(self.byte_of(self.choice));
|
||
if run(app, "caret.set", json!({"pos": at})) && run(app, "text.optionalHyphen", json!({})) {
|
||
let after = self.at(self.word.len() + '\u{ad}'.len_utf8());
|
||
*self = Self::next(app, after);
|
||
}
|
||
}
|
||
|
||
/// No: leave the word alone and go to the next one.
|
||
pub fn skip(&mut self, app: &mut WordApp) {
|
||
let after = self.at(self.word.len());
|
||
*self = Self::next(app, after);
|
||
}
|
||
}
|
||
|
||
/// A position (`{"story", "path", "off"}`) at another offset in the same paragraph.
|
||
fn with_off(pos: &Value, off: u64) -> Value {
|
||
let mut p = pos.as_object().cloned().unwrap_or_default();
|
||
p.insert("off".into(), json!(off));
|
||
Value::Object(p)
|
||
}
|
||
|
||
fn manual_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ManualForm) -> bool {
|
||
if f.done {
|
||
ui.label(tl!("Manual hyphenation is complete."));
|
||
ui.add_space(8.0);
|
||
return ui.button(tl!("Close")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape) || i.key_pressed(egui::Key::Enter));
|
||
}
|
||
ui.label(tl!("Hyphenate at:"));
|
||
// The word with every possible break; the chosen one is the hyphen that will be inserted.
|
||
ui.horizontal(|ui| {
|
||
ui.spacing_mut().item_spacing.x = 0.0;
|
||
let mut from = 0;
|
||
for pt in f.points.clone() {
|
||
ui.label(egui::RichText::new(f.word.chars().skip(from).take(pt.saturating_sub(from)).collect::<String>()).size(16.0));
|
||
if ui.selectable_label(f.choice == pt, egui::RichText::new("-").size(16.0)).clicked() {
|
||
f.choice = pt;
|
||
}
|
||
from = pt;
|
||
}
|
||
ui.label(egui::RichText::new(f.word.chars().skip(from).collect::<String>()).size(16.0));
|
||
});
|
||
ui.add_space(8.0);
|
||
let mut close = false;
|
||
ui.horizontal(|ui| {
|
||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||
if ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||
close = true;
|
||
}
|
||
if ui.button(tl!("No")).clicked() {
|
||
f.skip(app);
|
||
}
|
||
if ui.add(crate::widgets::primary_button(ui, tl!("Yes"))).clicked() {
|
||
f.accept(app);
|
||
}
|
||
});
|
||
});
|
||
close
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Language
|
||
|
||
/// Proofing languages offered, by BCP 47 tag, with their English names (translated for display).
|
||
pub const LANGUAGES: [(&str, &str); 29] = [
|
||
("en-US", "English (United States)"),
|
||
("en-GB", "English (United Kingdom)"),
|
||
("de-DE", "German (Germany)"),
|
||
("fr-FR", "French (France)"),
|
||
("es-ES", "Spanish (Spain)"),
|
||
("es-MX", "Spanish (Mexico)"),
|
||
("it-IT", "Italian"),
|
||
("pt-BR", "Portuguese (Brazil)"),
|
||
("pt-PT", "Portuguese (Portugal)"),
|
||
("nl-NL", "Dutch"),
|
||
("sv-SE", "Swedish"),
|
||
("da-DK", "Danish"),
|
||
("nb-NO", "Norwegian (Bokmål)"),
|
||
("fi-FI", "Finnish"),
|
||
("et-EE", "Estonian"),
|
||
("pl-PL", "Polish"),
|
||
("cs-CZ", "Czech"),
|
||
("uk-UA", "Ukrainian"),
|
||
("ru-RU", "Russian"),
|
||
("sr-Cyrl-RS", "Serbian (Cyrillic)"),
|
||
("sr-Latn-RS", "Serbian (Latin)"),
|
||
("el-GR", "Greek"),
|
||
("tr-TR", "Turkish"),
|
||
("ja-JP", "Japanese"),
|
||
("zh-CN", "Chinese (Simplified)"),
|
||
("zh-TW", "Chinese (Traditional)"),
|
||
("ko-KR", "Korean"),
|
||
("ar-SA", "Arabic"),
|
||
("he-IL", "Hebrew"),
|
||
];
|
||
|
||
/// The Language dialog's fields.
|
||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct LanguageForm {
|
||
pub lang: String,
|
||
pub no_proof: bool,
|
||
pub detect: bool,
|
||
}
|
||
|
||
impl LanguageForm {
|
||
pub fn read(app: &WordApp) -> LanguageForm {
|
||
let s = &app.session;
|
||
let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.as_deref());
|
||
let here = s.doc.styles.resolve_char(style, &s.typing_props());
|
||
let lang = here.lang.unwrap_or_else(|| "en-US".into());
|
||
LanguageForm { lang, no_proof: here.no_proof, detect: s.prefs.detect_language }
|
||
}
|
||
|
||
/// `review.language` parameters for OK.
|
||
pub fn params(&self) -> Value {
|
||
json!({"lang": self.lang, "noProof": self.no_proof, "detect": self.detect})
|
||
}
|
||
}
|
||
|
||
/// A language's name in the interface language (the tag itself when we don't know it).
|
||
fn language_name(tag: &str) -> String {
|
||
LANGUAGES.iter().find(|(t, _)| t.eq_ignore_ascii_case(tag)).map(|(_, n)| tl!(n).to_string()).unwrap_or_else(|| tag.to_string())
|
||
}
|
||
|
||
fn language_ui(app: &mut WordApp, ui: &mut Ui, f: &mut LanguageForm) -> bool {
|
||
ui.label(tl!("Mark selected text as:"));
|
||
let mut list: Vec<(String, String)> = LANGUAGES.iter().map(|(t, _)| (t.to_string(), language_name(t))).collect();
|
||
if !LANGUAGES.iter().any(|(t, _)| t.eq_ignore_ascii_case(&f.lang)) {
|
||
list.push((f.lang.clone(), f.lang.clone()));
|
||
}
|
||
list.sort_by_key(|a| a.1.to_lowercase());
|
||
egui::Frame::NONE.stroke(egui::Stroke::new(1.0, crate::theme::Tokens::get(ui.ctx()).border_strong)).inner_margin(4.0).show(ui, |ui| {
|
||
egui::ScrollArea::vertical().max_height(220.0).min_scrolled_height(220.0).show(ui, |ui| {
|
||
ui.set_min_width(300.0);
|
||
for (tag, name) in &list {
|
||
let on = f.lang.eq_ignore_ascii_case(tag);
|
||
let r = ui.selectable_label(on, name.as_str());
|
||
if r.clicked() {
|
||
f.lang = tag.clone();
|
||
}
|
||
}
|
||
});
|
||
});
|
||
ui.add_space(4.0);
|
||
ui.checkbox(&mut f.no_proof, tl!("Do not check spelling or grammar"));
|
||
ui.checkbox(&mut f.detect, tl!("Detect language automatically"));
|
||
ui.add_space(4.0);
|
||
let set_default = ui.button(tl!("Set As Default")).clicked();
|
||
if set_default && run(app, "review.language", json!({"lang": f.lang, "default": true, "detect": f.detect})) {
|
||
app.status(crate::i18n::fmt(tl!("{language} is now the document's default language."), &[("language", &language_name(&f.lang))]));
|
||
}
|
||
let (ok, cancel) = buttons(ui, "OK", true);
|
||
if ok {
|
||
return run(app, "review.language", f.params());
|
||
}
|
||
cancel
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Envelopes
|
||
|
||
/// The Envelopes dialog's fields. `size` indexes [`ENVELOPE_SIZES`].
|
||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EnvelopeForm {
|
||
pub delivery: String,
|
||
pub ret: String,
|
||
pub omit_return: bool,
|
||
pub size: usize,
|
||
}
|
||
|
||
impl EnvelopeForm {
|
||
/// The delivery address starts as the selected text (an address in the letter).
|
||
pub fn read(app: &WordApp) -> EnvelopeForm {
|
||
let sel = if app.session.sel.is_collapsed() { String::new() } else { app.session.selected_text() };
|
||
EnvelopeForm { delivery: sel.trim().replace("\r\n", "\n").replace('\r', "\n"), ret: String::new(), omit_return: false, size: 0 }
|
||
}
|
||
|
||
/// `mailings.envelopes` parameters; `add` puts the envelope at the start of this document.
|
||
pub fn params(&self, add: bool) -> Value {
|
||
let size = ENVELOPE_SIZES.get(self.size).map_or("Envelope #10", |s| s.0);
|
||
let ret = if self.omit_return { "" } else { self.ret.as_str() };
|
||
json!({"delivery": self.delivery, "return": ret, "size": size, "add": add})
|
||
}
|
||
}
|
||
|
||
fn envelopes_ui(app: &mut WordApp, ui: &mut Ui, f: &mut EnvelopeForm) -> bool {
|
||
ui.label(tl!("Delivery address:"));
|
||
ui.add(egui::TextEdit::multiline(&mut f.delivery).desired_rows(4).desired_width(340.0));
|
||
ui.horizontal(|ui| {
|
||
ui.label(tl!("Return address:"));
|
||
ui.checkbox(&mut f.omit_return, tl!("Omit"));
|
||
});
|
||
ui.add_enabled(!f.omit_return, egui::TextEdit::multiline(&mut f.ret).desired_rows(3).desired_width(340.0));
|
||
ui.horizontal(|ui| {
|
||
ui.label(tl!("Envelope size:"));
|
||
let label = |i: usize| ENVELOPE_SIZES.get(i).map(|(n, w, h)| envelope_label(n, *w, *h)).unwrap_or_default();
|
||
egui::ComboBox::from_id_salt("env_size").selected_text(label(f.size)).width(220.0).show_ui(ui, |ui| {
|
||
for i in 0..ENVELOPE_SIZES.len() {
|
||
ui.selectable_value(&mut f.size, i, label(i));
|
||
}
|
||
});
|
||
});
|
||
let ready = !f.delivery.trim().is_empty();
|
||
ui.add_space(8.0);
|
||
let (mut add, mut new, mut cancel) = (false, false, false);
|
||
ui.horizontal(|ui| {
|
||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||
cancel = ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape));
|
||
new = ui.add_enabled(ready, egui::Button::new(tl!("New Document"))).clicked();
|
||
add = ui.add_enabled(ready, crate::widgets::primary_button(ui, tl!("Add to Document"))).clicked();
|
||
});
|
||
});
|
||
if add || new {
|
||
return run(app, "mailings.envelopes", f.params(add));
|
||
}
|
||
cancel
|
||
}
|
||
|
||
/// `DL 110 × 220 mm` / `#10 4.13 × 9.5 in`: metric sizes in millimetres, the others in
|
||
/// inches. The pattern is translated (the unit's name, the order) and the numbers use the
|
||
/// region's decimal separator.
|
||
pub(crate) fn envelope_label(name: &str, w: f32, h: f32) -> String {
|
||
let short = name.strip_prefix("Envelope ").unwrap_or(name);
|
||
let (template, w, h) = if short.starts_with('#') || short == "Monarch" {
|
||
let i = |pt: f32| ((pt / 72.0) * 100.0).round() / 100.0;
|
||
(tl!("{name} {height} × {width} in"), i(w), i(h))
|
||
} else {
|
||
let mm = |pt: f32| (pt * 25.4 / 72.0).round();
|
||
(tl!("{name} {height} × {width} mm"), mm(w), mm(h))
|
||
};
|
||
crate::i18n::fmt(template, &[("name", short), ("height", &with_decimal(h.to_string())), ("width", &with_decimal(w.to_string()))])
|
||
}
|
||
|
||
/// A number written with `.` rewritten with the region's decimal separator (`2,625`).
|
||
fn with_decimal(n: String) -> String {
|
||
let sep = crate::options::decimal();
|
||
if sep == '.' { n } else { n.replace('.', sep.encode_utf8(&mut [0; 4])) }
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Labels
|
||
|
||
/// The Labels dialog's fields. `product` indexes [`LABEL_PRODUCTS`]; `row`/`col` count from 1.
|
||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct LabelsForm {
|
||
pub text: String,
|
||
pub product: usize,
|
||
pub single: bool,
|
||
pub row: usize,
|
||
pub col: usize,
|
||
/// One label per recipient (when a recipient list is loaded).
|
||
pub recipients: bool,
|
||
}
|
||
|
||
impl LabelsForm {
|
||
pub fn read(app: &WordApp) -> LabelsForm {
|
||
let sel = if app.session.sel.is_collapsed() { String::new() } else { app.session.selected_text() };
|
||
LabelsForm {
|
||
text: sel.trim().replace("\r\n", "\n").replace('\r', "\n"),
|
||
product: 0,
|
||
single: false,
|
||
row: 1,
|
||
col: 1,
|
||
recipients: !app.session.merge.rows.is_empty(),
|
||
}
|
||
}
|
||
|
||
/// `mailings.labels` parameters.
|
||
pub fn params(&self) -> Value {
|
||
let id = LABEL_PRODUCTS.get(self.product).map_or("letter-3x10", |p| p.id);
|
||
let mut v = json!({"text": self.text, "product": id, "fromRecipients": self.recipients && !self.single});
|
||
if self.single {
|
||
v["single"] = json!({"row": self.row, "col": self.col});
|
||
}
|
||
v
|
||
}
|
||
}
|
||
|
||
/// `Address labels, 3 × 10, 2.625" × 1" (Letter)`.
|
||
fn product_label(i: usize) -> String {
|
||
let Some(p) = LABEL_PRODUCTS.get(i) else { return String::new() };
|
||
let kind = match p.kind {
|
||
"shipping" => tl!("Shipping labels"),
|
||
"return" => tl!("Return address labels"),
|
||
_ => tl!("Address labels"),
|
||
};
|
||
// Inches on Letter, millimetres on A4, as the sheets are sold.
|
||
let size = if p.paper == "A4" {
|
||
let mm = |pt: f32| with_decimal(format!("{:.1}", pt * 25.4 / 72.0));
|
||
crate::i18n::fmt(tl!("{width} × {height} mm"), &[("width", &mm(p.width)), ("height", &mm(p.height))])
|
||
} else {
|
||
let i = |pt: f32| with_decimal((((pt / 72.0) * 1000.0).round() / 1000.0).to_string());
|
||
format!("{}\" × {}\"", i(p.width), i(p.height))
|
||
};
|
||
let (columns, rows) = (p.cols.to_string(), p.rows.to_string());
|
||
crate::i18n::fmt(
|
||
tl!("{kind}, {columns} × {rows}, {size} ({paper})"),
|
||
&[("kind", kind), ("columns", &columns), ("rows", &rows), ("size", &size), ("paper", tl!(p.paper))],
|
||
)
|
||
}
|
||
|
||
fn labels_ui(app: &mut WordApp, ui: &mut Ui, f: &mut LabelsForm) -> bool {
|
||
let has_recipients = !app.session.merge.rows.is_empty();
|
||
if has_recipients {
|
||
ui.checkbox(&mut f.recipients, tl!("One label per recipient"));
|
||
}
|
||
ui.add_enabled_ui(!(has_recipients && f.recipients && !f.single), |ui| {
|
||
ui.label(tl!("Address:"));
|
||
ui.add(egui::TextEdit::multiline(&mut f.text).desired_rows(4).desired_width(340.0));
|
||
});
|
||
ui.horizontal(|ui| {
|
||
ui.label(tl!("Label:"));
|
||
egui::ComboBox::from_id_salt("label_product").selected_text(product_label(f.product)).width(300.0).show_ui(ui, |ui| {
|
||
for i in 0..LABEL_PRODUCTS.len() {
|
||
ui.selectable_value(&mut f.product, i, product_label(i));
|
||
}
|
||
});
|
||
});
|
||
let (rows, cols) = LABEL_PRODUCTS.get(f.product).map_or((1, 1), |p| (p.rows, p.cols));
|
||
heading(ui, "Print");
|
||
ui.radio_value(&mut f.single, false, tl!("Full page of the same label"));
|
||
ui.horizontal(|ui| {
|
||
ui.radio_value(&mut f.single, true, tl!("Single label"));
|
||
ui.add_enabled_ui(f.single, |ui| {
|
||
ui.label(tl!("Row:"));
|
||
ui.add(egui::DragValue::new(&mut f.row).range(1..=rows));
|
||
ui.label(tl!("Column:"));
|
||
ui.add(egui::DragValue::new(&mut f.col).range(1..=cols));
|
||
});
|
||
});
|
||
f.row = f.row.clamp(1, rows.max(1));
|
||
f.col = f.col.clamp(1, cols.max(1));
|
||
let ready = (has_recipients && f.recipients && !f.single) || !f.text.trim().is_empty();
|
||
let (ok, cancel) = buttons(ui, "New Document", ready);
|
||
if ok {
|
||
return run(app, "mailings.labels", f.params());
|
||
}
|
||
cancel
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Formula
|
||
|
||
/// The functions `table.formula` works out, offered by Paste function.
|
||
pub const FORMULA_FUNCTIONS: [&str; 6] = ["AVERAGE", "COUNT", "MAX", "MIN", "PRODUCT", "SUM"];
|
||
|
||
/// The table Formula dialog's field: the formula, suggested as Word does from the numbers next
|
||
/// to the caret's cell (`=SUM(ABOVE)`, else `=SUM(LEFT)`, else a bare `=`).
|
||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct FormulaForm {
|
||
pub formula: String,
|
||
}
|
||
|
||
impl FormulaForm {
|
||
/// The caret's cell; none outside a table.
|
||
pub fn read(app: &WordApp) -> Option<FormulaForm> {
|
||
let s = &app.session;
|
||
let (tp, r, c) = s.sel.focus.path.cell()?;
|
||
let t = s.doc.table(s.sel.focus.story, &tp)?;
|
||
let lang = wordcraft_engine::cmd::default_lang(s);
|
||
let number = |row: Option<usize>, col: Option<usize>| {
|
||
let cell = t.rows.get(row?)?.cells.get(col?)?;
|
||
let text = cell.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::<Vec<_>>().join(" ");
|
||
wordcraft_doc::locale_number::parse_number(text.trim(), lang.as_deref())
|
||
};
|
||
let formula = if number(r.checked_sub(1), Some(c)).is_some() {
|
||
"=SUM(ABOVE)"
|
||
} else if number(Some(r), c.checked_sub(1)).is_some() {
|
||
"=SUM(LEFT)"
|
||
} else {
|
||
"="
|
||
};
|
||
Some(FormulaForm { formula: formula.into() })
|
||
}
|
||
|
||
/// Paste function: `NAME()` at the end of the formula.
|
||
pub fn paste(&mut self, name: &str) {
|
||
self.formula.push_str(name);
|
||
self.formula.push_str("()");
|
||
}
|
||
|
||
/// `table.formula` parameters for OK.
|
||
pub fn params(&self) -> Value {
|
||
json!({"formula": self.formula.trim()})
|
||
}
|
||
}
|
||
|
||
fn formula_ui(app: &mut WordApp, ui: &mut Ui, f: &mut FormulaForm) -> bool {
|
||
ui.label(tl!("Formula:"));
|
||
ui.add(egui::TextEdit::singleline(&mut f.formula).desired_width(300.0));
|
||
ui.add_space(4.0);
|
||
ui.horizontal(|ui| {
|
||
ui.label(tl!("Paste function:"));
|
||
let mut picked = None;
|
||
egui::ComboBox::from_id_salt("formula_paste").selected_text("").width(140.0).show_ui(ui, |ui| {
|
||
for name in FORMULA_FUNCTIONS {
|
||
if ui.selectable_label(false, name).clicked() {
|
||
picked = Some(name);
|
||
}
|
||
}
|
||
});
|
||
if let Some(name) = picked {
|
||
f.paste(name);
|
||
}
|
||
});
|
||
let ready = f.formula.trim().starts_with('=') && f.formula.trim().len() > 1;
|
||
let (ok, cancel) = buttons(ui, "OK", ready);
|
||
if ok {
|
||
return run(app, "table.formula", f.params());
|
||
}
|
||
cancel
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Sort
|
||
|
||
/// The table Sort dialog's fields: the column to sort by, the order and whether the first row is
|
||
/// a header (kept in place). `headers` is the first row's text, which names the columns when it
|
||
/// is a header.
|
||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct SortForm {
|
||
pub column: usize,
|
||
pub descending: bool,
|
||
pub header: bool,
|
||
pub headers: Vec<String>,
|
||
}
|
||
|
||
impl SortForm {
|
||
/// The caret's table; none outside a table.
|
||
pub fn read(app: &WordApp) -> Option<SortForm> {
|
||
let s = &app.session;
|
||
let (tp, _, _) = s.sel.focus.path.cell()?;
|
||
let first = s.doc.table(s.sel.focus.story, &tp)?.rows.first()?;
|
||
let headers = first
|
||
.cells
|
||
.iter()
|
||
.map(|c| c.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::<Vec<_>>().join(" ").trim().to_string())
|
||
.collect();
|
||
Some(SortForm { column: 0, descending: false, header: first.props.header, headers })
|
||
}
|
||
|
||
/// A column's name in Sort by: its header text, or `Column n`.
|
||
pub fn column_label(&self, i: usize) -> String {
|
||
match self.headers.get(i).filter(|h| self.header && !h.is_empty()) {
|
||
Some(h) => h.clone(),
|
||
None => crate::i18n::fmt(tl!("Column {n}"), &[("n", &(i + 1).to_string())]),
|
||
}
|
||
}
|
||
|
||
/// `table.sort` parameters for OK.
|
||
pub fn params(&self) -> Value {
|
||
json!({"column": self.column, "descending": self.descending, "header": self.header})
|
||
}
|
||
}
|
||
|
||
fn sort_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SortForm) -> bool {
|
||
heading(ui, "Sort by");
|
||
ui.horizontal(|ui| {
|
||
egui::ComboBox::from_id_salt("sort_column").selected_text(f.column_label(f.column)).width(180.0).show_ui(ui, |ui| {
|
||
for i in 0..f.headers.len() {
|
||
let label = f.column_label(i);
|
||
ui.selectable_value(&mut f.column, i, label);
|
||
}
|
||
});
|
||
ui.vertical(|ui| {
|
||
ui.radio_value(&mut f.descending, false, tl!("Ascending"));
|
||
ui.radio_value(&mut f.descending, true, tl!("Descending"));
|
||
});
|
||
});
|
||
heading(ui, "My list has");
|
||
ui.horizontal(|ui| {
|
||
ui.radio_value(&mut f.header, true, tl!("Header row"));
|
||
ui.radio_value(&mut f.header, false, tl!("No header row"));
|
||
});
|
||
let (ok, cancel) = buttons(ui, "OK", true);
|
||
if ok {
|
||
return run(app, "table.sort", f.params());
|
||
}
|
||
cancel
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use serde_json::json;
|
||
use wordcraft_engine::Session;
|
||
|
||
use super::*;
|
||
use crate::dialogs::Dialog;
|
||
use crate::{Services, WordApp};
|
||
|
||
fn app() -> WordApp {
|
||
WordApp::new(Session::new(wordcraft_doc::Document::from_text("Dear Jo,\nThanks.")), Services::default())
|
||
}
|
||
|
||
fn form(a: &mut WordApp) -> LayoutDialog {
|
||
let Some(Dialog::Layout(d)) = a.dialog.take() else { panic!("no dialog: {:?}", a.dialog) };
|
||
*d
|
||
}
|
||
|
||
/// #407: Line Numbering Options… opens with the section's numbering and OK sets every
|
||
/// option; Hyphenation Options sets the document's hyphenation settings.
|
||
#[test]
|
||
fn line_numbers_and_hyphenation_options_apply() {
|
||
let mut a = app();
|
||
a.run("ui.dialog", json!({"name": "lineNumbers"})).unwrap();
|
||
let LayoutDialog::LineNumbers(mut f) = form(&mut a) else { panic!("line numbers") };
|
||
assert!(!f.on && f.auto_distance && f.restart == "restartPage");
|
||
f.on = true;
|
||
f.start = 10;
|
||
f.count_by = 5;
|
||
f.restart = "continuous".into();
|
||
a.run("layout.lineNumbers", f.params()).unwrap();
|
||
let l = a.session.doc.last_section.line_numbers.clone().unwrap();
|
||
assert_eq!((l.start, l.count_by, l.distance, l.restart), (10, 5, 0.0, LineNumberRestart::Continuous));
|
||
a.run("ui.dialog", json!({"name": "lineNumbers"})).unwrap();
|
||
assert!(matches!(form(&mut a), LayoutDialog::LineNumbers(f) if f.on && f.start == 10 && f.restart == "continuous"));
|
||
|
||
a.run("ui.dialog", json!({"name": "hyphenation"})).unwrap();
|
||
let LayoutDialog::Hyphenation(mut h) = form(&mut a) else { panic!("hyphenation") };
|
||
assert!(!h.auto && h.caps && h.limit == 0);
|
||
(h.auto, h.caps, h.limit) = (true, false, 2);
|
||
a.run("layout.hyphenation", h.params()).unwrap();
|
||
let s = &a.session.doc.settings;
|
||
assert!(s.auto_hyphenation && !s.hyphenate_caps && s.consecutive_hyphen_limit == 2);
|
||
assert!((s.hyphenation_zone - 18.0).abs() < 0.01, "zone untouched: {}", s.hyphenation_zone);
|
||
}
|
||
|
||
/// #755: the Line Numbers "From text" distance and the Hyphenation zone follow the
|
||
/// File › Options unit (centimeters here); `params` sends points whatever the unit.
|
||
#[test]
|
||
fn line_numbers_and_hyphenation_measure_in_the_options_unit() {
|
||
let mut a = app();
|
||
a.run("file.options", json!({"units": "centimeters"})).unwrap();
|
||
a.run("layout.lineNumbers", json!({"value": "restartPage", "start": 1, "countBy": 1, "distance": 18.0})).unwrap();
|
||
a.run("ui.dialog", json!({"name": "lineNumbers"})).unwrap();
|
||
let LayoutDialog::LineNumbers(f) = form(&mut a) else { panic!("line numbers") };
|
||
assert!(!f.auto_distance);
|
||
let cm = 18.0 / wordcraft_geom::PT_PER_CM;
|
||
assert!((f.distance - cm).abs() < 0.01, "distance was {} cm", f.distance);
|
||
let sent = f.params()["distance"].as_f64().unwrap_or_default();
|
||
assert!((sent - 18.0).abs() < 0.05, "params sent {sent} pt");
|
||
|
||
a.run("layout.hyphenation", json!({"value": true, "zone": 36.0})).unwrap();
|
||
a.run("ui.dialog", json!({"name": "hyphenation"})).unwrap();
|
||
let LayoutDialog::Hyphenation(h) = form(&mut a) else { panic!("hyphenation") };
|
||
assert!((h.zone - 36.0 / wordcraft_geom::PT_PER_CM).abs() < 0.01, "zone was {} cm", h.zone);
|
||
let sent = h.params()["zone"].as_f64().unwrap_or_default();
|
||
assert!((sent - 36.0).abs() < 0.05, "params sent {sent} pt");
|
||
}
|
||
|
||
/// #407: the Language button opens the dialog (scripts with a language never see it); OK
|
||
/// marks the selection and "do not check", Set As Default changes the document default.
|
||
#[test]
|
||
fn language_dialog_marks_the_selection() {
|
||
let mut a = app();
|
||
a.run("select.all", json!({})).unwrap();
|
||
assert_eq!(a.run("review.language", json!({})).unwrap(), json!({"pending": "language"}));
|
||
let LayoutDialog::Language(mut f) = form(&mut a) else { panic!("language") };
|
||
assert_eq!(f.lang, "en-US");
|
||
f.lang = "sr-Latn-RS".into();
|
||
f.no_proof = true;
|
||
a.run("review.language", f.params()).unwrap();
|
||
let c = a.session.typing_props();
|
||
assert_eq!((c.lang.as_deref(), c.no_proof), (Some("sr-Latn-RS"), Some(true)));
|
||
a.run("review.language", json!({"lang": "fr-FR", "default": true})).unwrap();
|
||
assert_eq!(a.session.doc.styles.default_chr.lang.as_deref(), Some("fr-FR"));
|
||
assert!(a.dialog.is_none());
|
||
assert_eq!(language_name("xx-YY"), "xx-YY", "an unknown tag shows as itself");
|
||
}
|
||
|
||
/// #2717: language and no-proof can come from defaults or either kind of style.
|
||
#[test]
|
||
fn language_dialog_reads_inherited_proofing() {
|
||
for source in ["default", "paragraph", "character"] {
|
||
let mut a = app();
|
||
a.run("select.all", json!({})).unwrap();
|
||
let proofing = wordcraft_doc::CharProps { lang: Some("en-GB".into()), no_proof: Some(true), ..Default::default() };
|
||
match source {
|
||
"default" => a.session.doc.styles.default_chr.overlay(&proofing),
|
||
"paragraph" => a.session.doc.styles.styles.iter_mut().find(|s| s.id == "Normal").unwrap().chr.overlay(&proofing),
|
||
_ => {
|
||
a.session.doc.styles.styles.push(wordcraft_doc::Style {
|
||
id: "ProofingCharacter".into(),
|
||
kind: wordcraft_doc::StyleKind::Character,
|
||
chr: proofing,
|
||
..Default::default()
|
||
});
|
||
let (from, to) = a.session.sel.ordered();
|
||
let (from, to) = (from.clone(), to.clone());
|
||
a.session.doc.format_range(&from, &to, &|c| c.style = Some("ProofingCharacter".into())).unwrap();
|
||
}
|
||
}
|
||
let f = LanguageForm::read(&a);
|
||
assert_eq!((f.lang.as_str(), f.no_proof), ("en-GB", true), "{source}");
|
||
a.run("review.language", json!({"lang": "fr-FR", "noProof": false})).unwrap();
|
||
let f = LanguageForm::read(&a);
|
||
assert_eq!((f.lang.as_str(), f.no_proof), ("fr-FR", false), "direct formatting overrides {source}");
|
||
}
|
||
}
|
||
|
||
/// #2717: opening and accepting the dialog must not reset inherited proofing.
|
||
#[test]
|
||
fn language_dialog_ok_keeps_inherited_proofing() {
|
||
use egui_kittest::kittest::Queryable;
|
||
let mut a = app();
|
||
a.run("ui.language", json!({"value": "en"})).unwrap();
|
||
let normal = a.session.doc.styles.styles.iter_mut().find(|s| s.id == "Normal").unwrap();
|
||
normal.chr.lang = Some("en-GB".into());
|
||
normal.chr.no_proof = Some(true);
|
||
a.run("select.all", json!({})).unwrap();
|
||
let before = a.session.doc.clone();
|
||
a.run("review.language", json!({})).unwrap();
|
||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||
|ui, app: &mut WordApp| {
|
||
let ctx = ui.ctx().clone();
|
||
app.logic(&ctx);
|
||
app.ui(ui);
|
||
},
|
||
a,
|
||
);
|
||
h.run_steps(3);
|
||
h.get_by_label("Cancel").click();
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().session.doc, before, "cancel must not format the selection");
|
||
h.state_mut().run("review.language", json!({})).unwrap();
|
||
h.run_steps(3);
|
||
h.get_by_label("OK").click();
|
||
h.run_steps(3);
|
||
assert!(h.state().dialog.is_none());
|
||
let state = h.state_mut().run("format.state", json!({})).unwrap();
|
||
assert_eq!(state["lang"], "en-GB");
|
||
assert_eq!(state["noProof"], true);
|
||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||
assert_eq!(h.state().session.doc, before, "one undo restores the inherited formatting");
|
||
}
|
||
|
||
/// #407: Envelopes starts from the selected address; Add to Document puts an envelope
|
||
/// section before the letter without asking to save (nothing is replaced).
|
||
#[test]
|
||
fn envelope_dialog_adds_an_envelope_section() {
|
||
let mut a = app();
|
||
a.run("select.paragraph", json!({})).unwrap();
|
||
a.session.dirty = true;
|
||
assert_eq!(a.run("mailings.envelopes", json!({})).unwrap(), json!({"pending": "envelopes"}));
|
||
let LayoutDialog::Envelopes(mut f) = form(&mut a) else { panic!("envelopes") };
|
||
assert_eq!(f.delivery, "Dear Jo,");
|
||
f.delivery = "Jo Doe\n1 Main St".into();
|
||
f.ret = "Me".into();
|
||
f.size = ENVELOPE_SIZES.iter().position(|s| s.0 == "Envelope DL").unwrap();
|
||
let document = a.session.document_id();
|
||
a.run("mailings.envelopes", f.params(true)).unwrap();
|
||
assert!(a.dialog.is_none(), "no save prompt");
|
||
assert_eq!(a.session.document_id(), document);
|
||
let secs = a.session.doc.sections();
|
||
assert_eq!(secs.len(), 2);
|
||
assert!((secs[0].1.page_w - 623.6).abs() < 0.1);
|
||
assert!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Thanks."));
|
||
}
|
||
|
||
/// A table `cols` wide, typed cell by cell (empty cells are skipped over); the caret ends in
|
||
/// the last cell.
|
||
fn table_app(cols: usize, cells: &[&str]) -> WordApp {
|
||
let mut a = app();
|
||
a.run("insert.table", json!({"rows": cells.len() / cols, "cols": cols})).unwrap();
|
||
for (k, c) in cells.iter().enumerate() {
|
||
if k > 0 {
|
||
a.run("text.tab", json!({})).unwrap();
|
||
}
|
||
if !c.is_empty() {
|
||
a.run("text.insert", json!({"text": c})).unwrap();
|
||
}
|
||
}
|
||
a
|
||
}
|
||
|
||
fn fields(a: &WordApp) -> Vec<(String, String)> {
|
||
let mut out = Vec::new();
|
||
a.session.doc.objects_in_reading_order(&mut |o| {
|
||
if let wordcraft_doc::InlineObject::Field { instr, result, .. } = o {
|
||
out.push((instr.clone(), result.clone()));
|
||
}
|
||
});
|
||
out
|
||
}
|
||
|
||
/// #1201: Table Layout › Formula opens its dialog instead of inserting `=SUM(ABOVE)` at once.
|
||
/// The formula is suggested from the numbers next to the cell (above, else to the left), Paste
|
||
/// function adds a function to it, and OK inserts the field of the formula as edited.
|
||
#[test]
|
||
fn table_formula_opens_its_dialog() {
|
||
let mut a = table_app(2, &["Item", "Cost", "Tea", "3", "Jam", "4", "Total", ""]);
|
||
assert_eq!(a.run("table.formula", json!({})).unwrap(), json!({"pending": "tableFormula"}));
|
||
assert!(fields(&a).is_empty(), "nothing inserted yet");
|
||
let LayoutDialog::Formula(mut f) = form(&mut a) else { panic!("formula") };
|
||
assert_eq!(f.formula, "=SUM(ABOVE)");
|
||
f.formula = "=".into();
|
||
f.paste("MAX");
|
||
assert_eq!(f.formula, "=MAX()");
|
||
f.formula = "=MAX(ABOVE)".into();
|
||
a.run("table.formula", f.params()).unwrap();
|
||
assert_eq!(fields(&a), [("=MAX(ABOVE)".to_string(), "4".to_string())]);
|
||
assert!(a.dialog.is_none());
|
||
|
||
let mut a = table_app(3, &["Tea", "3", "", "Jam", "4", ""]);
|
||
a.run("table.formula", json!({})).unwrap();
|
||
let LayoutDialog::Formula(f) = form(&mut a) else { panic!("formula") };
|
||
assert_eq!(f.formula, "=SUM(LEFT)", "nothing above, numbers to the left");
|
||
|
||
let mut a = app();
|
||
a.run("ui.dialog", json!({"name": "tableFormula"})).unwrap();
|
||
assert!(a.dialog.is_none(), "no dialog outside a table");
|
||
}
|
||
|
||
fn first_column(a: &WordApp) -> Vec<String> {
|
||
let t = a.session.doc.body.iter().find_map(|b| b.as_table()).unwrap();
|
||
t.rows.iter().map(|r| r.cells[0].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect()).collect()
|
||
}
|
||
|
||
/// #1200: Table Layout › Sort opens its dialog instead of sorting by the first column at once;
|
||
/// with a header row the columns are named by it, and OK sorts the rows under it by the
|
||
/// chosen column (as numbers here: 2, 7, 10).
|
||
#[test]
|
||
fn table_sort_opens_its_dialog() {
|
||
let mut a = table_app(2, &["Name", "Score", "Bea", "10", "Al", "2", "Cy", "7"]);
|
||
assert_eq!(a.run("table.sort", json!({})).unwrap(), json!({"pending": "tableSort"}));
|
||
assert_eq!(first_column(&a), ["Name", "Bea", "Al", "Cy"], "nothing sorted yet");
|
||
let LayoutDialog::Sort(mut f) = form(&mut a) else { panic!("sort") };
|
||
assert!(!f.header && f.column == 0 && !f.descending);
|
||
assert_eq!(f.column_label(1), "Column 2");
|
||
f.header = true;
|
||
assert_eq!(f.column_label(1), "Score");
|
||
f.column = 1;
|
||
a.run("table.sort", f.params()).unwrap();
|
||
assert_eq!(first_column(&a), ["Name", "Al", "Cy", "Bea"]);
|
||
assert!(a.dialog.is_none());
|
||
}
|
||
|
||
/// The envelope sizes are in the interface language with the region's decimal separator
|
||
/// (#1128; the untranslated-text audit found `#10 4.13 × 9.5 in` drawn in English).
|
||
#[test]
|
||
fn envelope_sizes_follow_the_interface_language_and_region() {
|
||
let size = |name: &str| ENVELOPE_SIZES.iter().find(|s| s.0 == name).map(|(n, w, h)| envelope_label(n, *w, *h)).unwrap();
|
||
assert_eq!(size("Envelope #10"), "#10 4.13 × 9.5 in");
|
||
assert_eq!(size("Envelope DL"), "DL 110 × 220 mm");
|
||
let mut a = app();
|
||
a.run("file.options", json!({"region": "it-IT"})).unwrap();
|
||
crate::options::sync(&a);
|
||
crate::i18n::set_current(crate::i18n::Lang::from_code("it").unwrap());
|
||
assert_eq!(size("Envelope #10"), "#10 4,13 × 9,5 pollici");
|
||
assert_eq!(size("Envelope DL"), "DL 110 × 220 mm");
|
||
crate::i18n::set_current(crate::i18n::Lang::EN);
|
||
a.run("file.options", json!({"region": "en-US"})).unwrap();
|
||
crate::options::sync(&a);
|
||
}
|
||
|
||
/// #407: Labels builds a new document of the chosen sheet, or a single label.
|
||
#[test]
|
||
fn labels_dialog_builds_a_sheet() {
|
||
let mut a = app();
|
||
assert_eq!(a.run("mailings.labels", json!({})).unwrap(), json!({"pending": "labels"}));
|
||
let LayoutDialog::Labels(mut f) = form(&mut a) else { panic!("labels") };
|
||
f.text = "Ada Lovelace\nLondon".into();
|
||
f.product = LABEL_PRODUCTS.iter().position(|p| p.id == "a4-3x8").unwrap();
|
||
let r = a.session.run("mailings.labels", &f.params()).unwrap();
|
||
assert_eq!(r["labels"], 24);
|
||
assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body).matches("Ada Lovelace").count(), 24);
|
||
f.single = true;
|
||
(f.row, f.col) = (3, 2);
|
||
assert_eq!(a.session.run("mailings.labels", &f.params()).unwrap()["filled"], 1);
|
||
assert!(product_label(f.product).contains("63.5 × 33.9 mm (A4)"));
|
||
}
|
||
}
|