Files
vectorcraft/crates/ui-egui/src/dialogs/document_setup.rs
T
bflatasticandClaude Opus 5.5 2724263d00 M4.54: Placing a text file asks for Text Import Options and sets it as area type
file.place of a .txt file takes `text: {characterSet: unicode|ansi, platform: windows|mac,
removeLineReturns, removeParagraphReturns, replaceSpaces}`: Unicode reads UTF-8 or UTF-16 by its
byte-order mark (other bytes as the platform's 8-bit set), ANSI reads Windows-1252 or Mac Roman;
CR LF, CR and LF all break lines; joining lines makes each block one paragraph, blank lines can
go, and runs of spaces can become tabs. The text becomes area type filling `rect`, the replaced
object's bounds, or the artboard less a 36 pt margin (type reflows rather than scaling), as one
undo step. The place cursor and file.place.info take text files too, and File → Place lists them.

In the app, placing a text file (the Place dialog, a drop, file.place without options) opens the
Text Import Options dialog. Its dropdown rows share a form helper with Document Setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:07:23 +07:00

246 lines
11 KiB
Rust

//! File → Document Setup: General (units, bleed, Edit Artboards, outline images, substitution
//! highlights), Transparency (grid size and colours, simulated paper, flattener preset, white
//! overprint) and Type (language, quotes, superscript/subscript/small caps, text export) tabs.
//!
//! Fields: what `document.setup` reports (and takes back), plus `tab` and `bleedLinked`. OK runs
//! `document.setup` with them: one undo step. On an error the dialog stays open.
use serde_json::{Value, json};
use vectorcraft_doc::setup::{GRID_COLOR_PRESETS, LANGUAGES};
use vectorcraft_doc::{GridSize, Quotes, Unit};
use super::{DialogSpec, form};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
pub(super) const KIND: &str = "documentSetup";
const TABS: [&str; 3] = ["General", "Transparency", "Type"];
/// Dialog-only fields `document.setup` doesn't take.
const UI_ONLY: [&str; 2] = ["tab", "bleedLinked"];
pub(super) const LABEL: f32 = 130.0;
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Document Setup".into(), body, confirm, min_width: 540.0, max_width: Some(540.0), ..DialogSpec::FORM };
/// Open Document Setup on the active document's settings.
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
let mut fields = app.session.execute("document.setup", &json!({})).map_err(|e| e.to_string())?;
let linked = fields["bleed"].as_array().is_some_and(|b| b.windows(2).all(|w| w[0] == w[1]));
fields["bleedLinked"] = json!(linked);
fields["tab"] = json!(TABS[0]);
app.ui.dialog = Some(Dialog::new(KIND, fields));
Ok(Value::Null)
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let params = d.fields.iter().filter(|(k, _)| !UI_ONLY.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect();
let r = app.run("document.setup", Value::Object(params));
if r.is_ok() {
app.ui.dialog = None;
}
r
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let tab = TABS.iter().position(|t| *t == d.str("tab")).unwrap_or(0);
if let Some(i) = widgets::tab_bar(ui, &TABS, tab) {
d.fields.insert("tab".into(), json!(TABS[i]));
}
ui.add_space(12.0);
ui.vertical(|ui| {
ui.set_min_height(330.0);
match tab {
0 => general(app, ui, d),
1 => transparency(app, ui, d),
_ => typography(ui, d),
}
});
false
}
/// A dropdown row bound to the string `d.fields[key]`; `options` are (value, label).
pub(super) fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, options: &[(&str, &str)]) {
form::choice(ui, d, key, label, (LABEL, 220.0), options);
}
/// A checkbox bound to `d.fields[key]`.
pub(super) fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
let on = d.bool(key);
if widgets::check(ui, label, on, true) {
d.fields.insert(key.into(), json!(!on));
}
}
/// The units the dialog shows lengths in.
fn unit(d: &Dialog) -> Unit {
Unit::named(&d.str("units")).unwrap_or_default()
}
fn general(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
let units: Vec<(&str, &str)> = Unit::ALL.iter().map(|u| (u.label(), u.label())).collect();
choice(ui, d, "units", "Units:", &units);
ui.add_space(6.0);
let unit = unit(d);
widgets::label_row(ui, "Bleed:", LABEL, |ui| form::bleed(ui, d, unit, 64.0));
ui.add_space(6.0);
widgets::label_row(ui, "", LABEL, |ui| {
// Applies the settings, then edits the artboards with the Artboard tool.
if widgets::flat_button(ui, "Edit Artboards", 120.0).clicked() && confirm(app, d).is_ok() {
app.select_tool("artboard");
}
});
ui.add_space(12.0);
check(ui, d, "outlineImages", "Show Images in Outline Mode");
check(ui, d, "highlightSubstitutedFonts", "Highlight Substituted Fonts");
check(ui, d, "highlightSubstitutedGlyphs", "Highlight Substituted Glyphs");
}
fn transparency(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
let t = Tokens::get(ui.ctx());
widgets::subheader(ui, "Transparency Grid");
let sizes: Vec<(&str, &str)> = GridSize::ALL.iter().map(|g| (g.id(), g.label())).collect();
choice(ui, d, "gridSize", "Grid Size:", &sizes);
let colors: Vec<String> = d
.fields
.get("gridColors")
.and_then(Value::as_array)
.map(|a| a.iter().map(|c| c.as_str().unwrap_or("").to_string()).collect())
.unwrap_or_default();
let preset = GRID_COLOR_PRESETS
.iter()
.find(|(_, pair)| colors.len() == 2 && colors.iter().zip(pair).all(|(h, c)| h.eq_ignore_ascii_case(&c.to_hex())))
.map_or("Custom", |(n, _)| n);
let names: Vec<&str> = GRID_COLOR_PRESETS.iter().map(|(n, _)| *n).chain(["Custom"]).collect();
widgets::label_row(ui, "Grid Colors:", LABEL, |ui| {
if let Some((_, pair)) = widgets::dropdown(ui, "gridColors", preset, &names, 220.0).and_then(|i| GRID_COLOR_PRESETS.get(i)) {
d.fields.insert("gridColors".into(), json!(pair.map(|c| c.to_hex())));
}
});
// The two colours (the first is also the paper colour): editing either makes them Custom.
widgets::label_row(ui, "", LABEL, |ui| {
for i in 0..2 {
let hex = colors.get(i).map_or("", |h| h.trim_start_matches('#'));
let (chip, _) = ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::hover());
if let Some(c) = vectorcraft_color::Color::from_hex(hex) {
ui.painter().rect_filled(chip, 2.0, crate::panels::c32(&c));
}
ui.painter().rect_stroke(chip, 2.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
if let Some(c) = widgets::hex_field(ui, ("grid-color", i), hex).and_then(|h| vectorcraft_color::Color::from_hex(&h)) {
let mut v = colors.clone();
v.resize(2, "#ffffff".into());
v[i] = c.to_hex();
d.fields.insert("gridColors".into(), json!(v));
}
ui.add_space(10.0);
}
});
ui.add_space(4.0);
check(ui, d, "simulatePaper", "Simulate Colored Paper");
ui.add_space(14.0);
widgets::subheader(ui, "Export and Clipboard Transparency Flattener Settings");
let presets: Vec<String> = app.session.flattener_presets().into_iter().map(|p| p.name).collect();
let options: Vec<(&str, &str)> = presets.iter().map(|p| (p.as_str(), p.as_str())).collect();
choice(ui, d, "flattenerPreset", "Preset:", &options);
check(ui, d, "discardWhiteOverprint", "Discard White Overprint in Output");
}
fn typography(ui: &mut egui::Ui, d: &mut Dialog) {
let t = Tokens::get(ui.ctx());
let langs: Vec<(&str, &str)> = LANGUAGES.iter().map(|(l, _)| (*l, *l)).collect();
let before = d.str("language");
choice(ui, d, "language", "Language:", &langs);
// A language brings its quotes.
if d.str("language") != before
&& let Some(q) = vectorcraft_doc::setup::language_quotes(&d.str("language"))
{
d.fields.insert("quotes".into(), json!({"double": q.double.iter().collect::<String>(), "single": q.single.iter().collect::<String>()}));
}
for (key, label, choices) in [("double", "Double Quotes:", Quotes::DOUBLE_CHOICES), ("single", "Single Quotes:", Quotes::SINGLE_CHOICES)] {
let pairs: Vec<String> = choices.iter().map(|p| p.iter().collect()).collect();
let labels: Vec<&str> = pairs.iter().map(String::as_str).collect();
let cur = d.fields.get("quotes").and_then(|q| q.get(key)).and_then(Value::as_str).unwrap_or("").to_string();
widgets::label_row(ui, label, LABEL, |ui| {
if let Some(pair) = widgets::dropdown(ui, ("quotes", key), &cur, &labels, 90.0).and_then(|i| pairs.get(i)) {
let mut q = d.fields.get("quotes").cloned().unwrap_or_else(|| json!({}));
q[key] = json!(pair);
d.fields.insert("quotes".into(), q);
}
});
}
check(ui, d, "typographersQuotes", "Use Typographer's Quotes");
ui.add_space(14.0);
widgets::subheader(ui, "Options");
egui::Grid::new("docsetup-scripts").num_columns(3).spacing([16.0, 6.0]).show(ui, |ui| {
ui.label("");
for h in ["Size", "Position"] {
ui.label(egui::RichText::new(h).size(11.0).color(t.text_dim));
}
ui.end_row();
for (key, label) in [("superscript", "Superscript:"), ("subscript", "Subscript:")] {
ui.label(egui::RichText::new(label).color(t.text));
for part in ["size", "position"] {
let v = d.fields.get(key).and_then(|m| m.get(part)).and_then(Value::as_f64).unwrap_or(0.0);
if let Some(n) = widgets::plain_field(ui, (key, part), v, "%", 1, 70.0) {
let mut m = d.fields.get(key).cloned().unwrap_or_else(|| json!({}));
m[part] = json!(n);
d.fields.insert(key.into(), m);
}
}
ui.end_row();
}
ui.label(egui::RichText::new("Small Caps:").color(t.text));
if let Some(n) = widgets::plain_field(ui, "smallCaps", d.f64("smallCapsSize", 70.0), "%", 1, 70.0) {
d.fields.insert("smallCapsSize".into(), json!(n));
}
ui.end_row();
});
ui.add_space(6.0);
choice(ui, d, "exportText", "Export:", &[("editable", "Preserve Text Editability"), ("appearance", "Preserve Text Appearance")]);
}
#[cfg(test)]
mod tests {
use serde_json::json;
use vectorcraft_engine::Session;
use crate::VectorcraftApp;
fn frame(app: &mut VectorcraftApp) {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, Default::default());
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 900.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| crate::dialogs::show(app, ui.ctx()));
out.textures_delta.clear();
}
#[test]
fn every_tab_draws_and_ok_is_one_undo_step() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
app.run("file.documentSetup", json!({})).unwrap();
for tab in ["General", "Transparency", "Type"] {
app.ui.dialog.as_mut().unwrap().fields.insert("tab".into(), json!(tab));
frame(&mut app);
}
let d = app.ui.dialog.as_mut().unwrap();
assert_eq!((d.fields["flattenerPreset"].as_str(), d.fields["bleedLinked"].as_bool()), (Some("Medium Resolution"), Some(true)));
for (k, v) in [("bleed", json!([9, 9, 9, 9])), ("gridColors", json!("Blue")), ("language", json!("German")), ("units", json!("Millimeters"))]
{
d.fields.insert(k.into(), v);
}
crate::dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let st = app.session.active().unwrap();
assert_eq!((st.doc.setup.bleed, st.doc.setup.grid_colors_name()), ([9.0; 4], "Blue"));
assert_eq!((st.doc.setup.quotes.double, st.doc.units), (['„', '“'], vectorcraft_doc::Unit::Millimeters));
assert_eq!(st.history.undo.len(), 1);
// A bad value keeps the dialog open.
app.run("file.documentSetup", json!({})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("bleed".into(), json!(500));
assert!(crate::dialogs::confirm(&mut app).is_err());
assert!(app.ui.dialog.is_some());
}
}