mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 04:07:12 -06:00
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>
246 lines
11 KiB
Rust
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());
|
|
}
|
|
}
|