Files
vectorcraft/crates/ui-egui/src/dialogs/form.rs
T
dotCooCoo 917dcdeeef Simplify: add Corner Point Angle Threshold, Convert to Straight Lines and Show Original Path
The Simplify dialog had Tolerance, Preview and the anchor point counts.
It now also has three of the options of the reference app's advanced
Simplify dialog:

- Corner Point Angle Threshold is a slider from 0° to 180° that opens
  at 150°, with its label above it so the dialog stays under the
  420-point width that menu_parameter_dialogs_are_compact allows. An
  anchor whose corner is sharper than the threshold stays a corner, so
  moving the slider right keeps more corners. The dialog sends
  object.path.simplify's cornerAngle as 180° less the threshold, so the
  opening value gives the same result as before.
- Convert to Straight Lines sends straightLines.
- While Preview is on, Show Original Path draws the paths as they were
  before the preview, in a faint band of their layer's color under the
  selection outlines.

The three labels have rows in the 19 catalogs that translate every
label.

Docs: docs/target-app-parity.md, docs/ui-parity.md.
2026-10-16 15:27:53 +07:00

664 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Field widgets shared by the dialogs: labelled text fields, checkboxes, the generic field grid
//! and the typed parameter editor of the command and effect dialogs.
use serde_json::{Value, json};
use vectorcraft_doc::Unit;
use crate::state::Dialog;
use crate::theme::Tokens;
/// Width of a dialog's value fields ([`text`] and [`length`], frame included).
const FIELD_W: f32 = 132.0;
/// A labelled text field bound to `d.fields[key]` (one grid row).
pub(super) fn field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
field_w(ui, d, key, label, FIELD_W - 12.0);
}
/// [`field`] `width` points wide.
pub(super) fn field_w(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, width: f32) {
row_label(ui, label);
text(ui, d, key, width);
ui.end_row();
}
/// The label cell of a grid row.
pub(super) fn row_label(ui: &mut egui::Ui, label: &str) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(tl!(label)).color(t.text_dim));
}
/// A labelled [`length`] field (one grid row).
pub(super) fn length_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, unit: Unit) {
row_label(ui, label);
length(ui, d, key, unit, FIELD_W);
ui.end_row();
}
/// A distance field `width` wide bound to `d.fields[key]`: shown and typed in `unit` (a typed unit,
/// "5 mm", wins), kept in points (a string with a unit, "12 pt", also reads). Returns true when it
/// changed.
pub(super) fn length(ui: &mut egui::Ui, d: &mut Dialog, key: &str, unit: Unit, width: f32) -> bool {
let value = d.fields.contains_key(key).then(|| d.f64(key, 0.0));
let Some(v) = crate::widgets::num_field(ui, ("dlg-len", key), value, unit, width) else { return false };
d.fields.insert(key.into(), json!(v));
true
}
/// The fields of the form dialogs ([`grid`]) that are distances, by dialog kind.
fn lengths(kind: &str) -> &'static [&'static str] {
match kind {
"move" => &["dx", "dy", "distance"],
"rectangle" | "ellipse" | "rectangularGrid" | "polarGrid" | "artboardOptions" => &["width", "height"],
"roundedRectangle" => &["width", "height", "radius"],
"polygon" => &["radius"],
"star" => &["radius1", "radius2"],
"lineSegment" => &["length"],
"offsetPath" => &["offset"],
_ => &[],
}
}
/// The fields of the form dialogs ([`grid`]) that are angles or percentages, with what follows the
/// number: fields that ↑/↓ step by one (Shift: ten) and that do math, as the distances do.
fn suffix(kind: &str, key: &str) -> Option<&'static str> {
match (kind, key) {
("rotate" | "shear" | "reflect" | "move", "angle") => Some("°"),
("scale", "sx" | "sy") => Some("%"),
_ => None,
}
}
/// The fields of the form dialogs ([`grid`]) that come first, in this order (the others follow by
/// name).
fn order(kind: &str) -> &'static [&'static str] {
match kind {
"move" => &["dx", "dy", "distance", "angle"],
"offsetPath" => &["offset", "joins", "miterLimit"],
"rectangularGrid" => &["width", "height", "rows", "rowSkew", "columns", "columnSkew"],
"polarGrid" => &["width", "height", "concentric", "concentricSkew", "radial", "radialSkew"],
"artboardOptions" => &["name", "width", "height"],
_ => &[],
}
}
/// The form dialog ([`grid`]) fields that are a grid tool's divider skew, a slider in percent: the
/// words at the two ends of its rail (where the dividers crowd to), in the UI language.
fn skew_ends(kind: &str, key: &str) -> Option<(&'static str, &'static str)> {
match (kind, key) {
("rectangularGrid", "rowSkew") => Some((tl!("Bottom"), tl!("Top"))),
// The radial dividers gather on that side of the top one.
("rectangularGrid", "columnSkew") | ("polarGrid", "radialSkew") => Some((tl!("Left"), tl!("Right"))),
("polarGrid", "concentricSkew") => Some((tl!("Inside"), tl!("Outside"))),
_ => None,
}
}
/// The choices of a form dialog's ([`grid`]) dropdown field, as (value, label in the UI
/// language); empty for a field typed as text.
fn choices(kind: &str, key: &str) -> Vec<(&'static str, &'static str)> {
// The axes in the "axis" context: some languages read the plain Horizontal and Vertical as
// type orientations.
let axis = |value, label| (value, crate::i18n::tr_ctx(crate::i18n::current(), "axis", label));
match (kind, key) {
("offsetPath", "joins") => vec![("miter", tl!("Miter")), ("round", tl!("Round")), ("bevel", tl!("Bevel"))],
("average", "axis") => vec![axis("horizontal", "Horizontal"), axis("vertical", "Vertical"), axis("both", "Both")],
("reflect", "axis") => vec![axis("horizontal", "Horizontal"), axis("vertical", "Vertical"), axis("angle", "Angle")],
("shear", "axis") => vec![axis("horizontal", "Horizontal"), axis("vertical", "Vertical")],
_ => vec![],
}
}
/// A text field `width` wide bound to `d.fields[key]`. Returns true when it changed.
pub(super) fn text(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> bool {
text_edit(ui, d, key, width).changed()
}
/// [`text`] returning the field's response (focus, Enter).
pub(super) fn text_edit(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> egui::Response {
let t = Tokens::get(ui.ctx());
let mut s = d.str(key);
let id = ui.id().with(("dlg-text", key));
crate::widgets::take_dialog_focus(ui, id, &s);
let r = egui::Frame::NONE
.fill(t.input)
.stroke(egui::Stroke::new(1.0, t.input_border))
.corner_radius(egui::CornerRadius::same(3))
.inner_margin(egui::Margin::symmetric(6, 3))
.show(ui, |ui| {
crate::theme::strong_selected_text(ui);
ui.add(egui::TextEdit::singleline(&mut s).id(id).frame(egui::Frame::NONE).desired_width(width))
});
if r.inner.changed() {
d.fields.insert(key.into(), Value::String(s));
}
r.inner
}
/// A text box `rows` lines tall and `width` wide bound to `d.fields[key]`. Enter adds a line (it
/// doesn't press OK while the box has focus).
pub(super) fn text_area(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32, rows: usize) -> egui::Response {
let t = Tokens::get(ui.ctx());
let mut s = d.str(key);
let r = egui::Frame::NONE
.fill(t.input)
.stroke(egui::Stroke::new(1.0, t.input_border))
.corner_radius(egui::CornerRadius::same(3))
.inner_margin(egui::Margin::symmetric(6, 3))
.show(ui, |ui| {
crate::theme::strong_selected_text(ui);
ui.add(egui::TextEdit::multiline(&mut s).frame(egui::Frame::NONE).desired_width(width).desired_rows(rows))
});
if r.inner.changed() {
d.fields.insert(key.into(), Value::String(s));
}
if r.inner.has_focus() {
ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
}
r.inner
}
/// A checkbox bound to `d.fields[key]` (its box shows while unchecked too).
pub(super) fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
let b = d.bool(key);
if crate::widgets::check(ui, label, b, true) {
d.fields.insert(key.into(), Value::Bool(!b));
}
}
/// A dropdown row bound to the string `d.fields[key]`: `label` in a column `widths.0` wide, the
/// dropdown `widths.1` wide; `options` are (value, label).
pub(super) fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, widths: (f32, f32), options: &[(&str, &str)]) {
crate::widgets::label_row(ui, label, widths.0, |ui| dropdown(ui, d, key, widths.1, options, false));
}
/// A dropdown `width` wide bound to the string `d.fields[key]`; `options` are (value, label), the
/// labels shown as they are when `translated` (else the dropdown translates them). A value that
/// isn't among them shows as it is.
fn dropdown(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32, options: &[(&str, &str)], translated: bool) {
let cur = d.str(key);
let shown = options.iter().find(|(v, _)| v.eq_ignore_ascii_case(&cur)).map_or(cur.as_str(), |(_, l)| l);
let labels: Vec<&str> = options.iter().map(|(_, l)| *l).collect();
let chosen = if translated {
crate::widgets::dropdown_names(ui, key, shown, &labels, width)
} else {
crate::widgets::dropdown(ui, key, shown, &labels, width)
};
if let Some((value, _)) = chosen.and_then(|i| options.get(i)) {
d.fields.insert(key.into(), json!(value));
}
}
/// The generic dialog body: a field per non-boolean value (positions, indices and UI-only keys
/// hidden) in the kind's [`order`], the distances in `unit`, the [`choices`] as dropdowns and the
/// [`skew_ends`] fields as sliders.
pub(super) fn grid(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) {
let rail = Tokens::get(ui.ctx()).input_border;
let (lengths, first) = (lengths(&d.kind), order(&d.kind));
let mut keys: Vec<String> = d
.fields
.iter()
.filter(|(k, v)| !matches!(k.as_str(), "x" | "y" | "origin" | "index") && !k.starts_with("__") && !v.is_boolean())
.map(|(k, _)| k.clone())
.collect();
// Stable: the fields `order` doesn't name keep their order after the ones it does.
keys.sort_by_key(|k| first.iter().position(|f| f == k).unwrap_or(first.len()));
egui::Grid::new("dlg").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
for k in keys {
let options = choices(&d.kind, &k);
if lengths.contains(&k.as_str()) {
length_field(ui, d, &k, &humanized(&k), unit);
} else if let Some(suffix) = suffix(&d.kind, &k) {
row_label(ui, &humanized(&k));
if let Some(v) = crate::widgets::plain_field(ui, ("dlg-num", &k), d.f64(&k, 0.0), suffix, 3, FIELD_W) {
d.fields.insert(k, json!(v));
}
ui.end_row();
} else if let Some(ends) = skew_ends(&d.kind, &k) {
let max = vectorcraft_geom::shapes::GRID_SKEW_MAX;
row_label(ui, tl!("Skew:"));
ui.vertical(|ui| {
ui.horizontal(|ui| slider_field(ui, d, &k, -max..=max, "%", &|_| rail));
rail_ends(ui, ends);
});
ui.end_row();
} else if options.is_empty() {
let label = match (d.kind.as_str(), k.as_str()) {
("rectangularGrid", "rows") => tl!("Horizontal Dividers:").into(),
("rectangularGrid", "columns") => tl!("Vertical Dividers:").into(),
("polarGrid", "concentric") => tl!("Concentric Dividers:").into(),
("polarGrid", "radial") => tl!("Radial Dividers:").into(),
_ => humanized(&k),
};
field(ui, d, &k, &label);
} else {
row_label(ui, &humanized(&k));
dropdown(ui, d, &k, FIELD_W, &options, true);
ui.end_row();
}
}
});
}
/// Command/effect parameters from the dialog fields (drop UI-only keys).
pub(super) fn params(d: &Dialog) -> Value {
Value::Object(d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect())
}
/// The (label, value) choices of a parameter that picks one of some values.
pub(super) type Choices = &'static [(&'static str, &'static str)];
/// Generic editor for command/effect parameters: numbers, booleans, strings and colours; the
/// parameters `is_length` names are distances shown and typed in `unit`, those `choices` gives
/// choices for are dropdowns. Fields come in `rank` order (by name among equals). Returns true when
/// a value changed.
pub(super) fn param_fields(
ui: &mut egui::Ui,
d: &mut Dialog,
is_length: &dyn Fn(&str) -> bool,
choices: &dyn Fn(&str) -> Option<Choices>,
rank: &dyn Fn(&str) -> usize,
unit: Unit,
) -> bool {
let t = Tokens::get(ui.ctx());
let mut changed = false;
egui::Grid::new("fxgrid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
let mut keys: Vec<(String, Value)> =
d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect();
keys.sort_by_key(|(k, _)| rank(k));
for (k, v) in keys {
// A size's Relative / Absolute pair (Roughen, Zig Zag, Tweak) is its own label.
let relative = k == "relative" && v.is_boolean();
ui.label(egui::RichText::new(if relative { String::new() } else { humanized(&k) }).color(t.text));
if let Some(cur) = crate::widgets::blend_param(&k, &v) {
if let Some(m) = crate::widgets::blend_param_dropdown(ui, ("fx-blend", &k), cur) {
d.fields.insert(k, m);
changed = true;
}
ui.end_row();
continue;
}
if is_length(&k) {
changed |= length(ui, d, &k, unit, 140.0);
ui.end_row();
continue;
}
if let Some(options) = choices(&k) {
// In the "effect" context: some languages read the plain Soft, Horizontal and
// Vertical (Grain's types) as type settings.
let shown = |label: &'static str| crate::i18n::tr_ctx(crate::i18n::current(), "effect", label);
let cur = v.as_str().unwrap_or_default();
let label = options.iter().find(|(_, value)| *value == cur).map_or(cur, |(l, _)| shown(l));
let labels: Vec<&str> = options.iter().map(|(l, _)| shown(l)).collect();
if let Some((_, value)) = crate::widgets::dropdown_names(ui, ("fx-choice", &k), label, &labels, 140.0).and_then(|i| options.get(i)) {
d.fields.insert(k, json!(value));
changed = true;
}
ui.end_row();
continue;
}
match v {
Value::Number(n) => {
if let Some(x) = crate::widgets::plain_field(ui, ("fx-num", &k), n.as_f64().unwrap_or(0.0), "", 3, 140.0) {
d.fields.insert(k, typed_number(x));
changed = true;
}
}
Value::Bool(b) if relative => {
ui.horizontal(|ui| {
for (label, on) in [(tl!("Relative"), true), (tl!("Absolute"), false)] {
if crate::widgets::radio(ui, label, b == on, true) && b != on {
d.fields.insert(k.clone(), json!(on));
changed = true;
}
}
});
}
Value::Bool(b) => {
if crate::widgets::check(ui, "", b, true) {
d.fields.insert(k, json!(!b));
changed = true;
}
}
Value::String(mut s) if s.contains('\n') => {
// Multi-line values (Graph Data CSV) get a text area.
if ui.add(egui::TextEdit::multiline(&mut s).desired_width(260.0).desired_rows(8).font(egui::TextStyle::Monospace)).changed() {
d.fields.insert(k, json!(s));
changed = true;
}
}
Value::String(mut s) => {
if ui.add(egui::TextEdit::singleline(&mut s).desired_width(140.0)).changed() {
d.fields.insert(k, json!(s));
changed = true;
}
}
other => {
ui.label(egui::RichText::new(other.to_string()).color(t.text_dim).size(11.0));
}
}
ui.end_row();
}
});
changed
}
/// A number typed in a command's dialog as JSON: a whole number as an integer, as counts are read
/// (`as_u64`: Object Mosaic's columns and rows, #1000), which `12.0` isn't; a fraction stays one.
/// Commands that take any number read either.
fn typed_number(x: f64) -> Value {
if x.fract() == 0.0 && x.abs() < 1e15 { json!(x as i64) } else { json!(x) }
}
/// Editor for a plug-in's parameters from its schema (plug-in filter and effect dialogs): numbers
/// within their range, whole numbers, checkboxes and dropdowns, in declaration order. Returns
/// true when a value changed. The labels (from the parameter keys) and choices are the plug-in's
/// text, shown as they are.
pub(super) fn schema_fields(ui: &mut egui::Ui, d: &mut Dialog, specs: &[(String, vectorcraft_plugins::ParamSpec)]) -> bool {
use vectorcraft_plugins::ParamSpec;
let t = Tokens::get(ui.ctx());
let mut changed = false;
egui::Grid::new("plugin-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
for (k, spec) in specs {
ui.label(egui::RichText::new(humanize(k)).color(t.text));
let cur = d.fields.get(k).cloned().unwrap_or_else(|| spec.default_value());
let new = match spec {
ParamSpec::Number { min, max, .. } => {
let x = cur.as_f64().unwrap_or(*min);
crate::widgets::range_field(ui, ("plugin-num", k), x, *min..=*max, "", 2, 100.0).map(|x| json!(x))
}
ParamSpec::Int { min, max, .. } => {
let x = cur.as_i64().unwrap_or(*min).clamp(*min, *max);
// An f64 holds every whole number up to 2^53, so the numeric field (arrows, wheel, arithmetic) is exact there.
// Past it, the number typed is read as an integer.
const EXACT: i64 = 1 << 53;
if -EXACT <= *min && *max <= EXACT {
crate::widgets::range_field(ui, ("plugin-int", k), x as f64, *min as f64..=*max as f64, "", 0, 100.0).map(|x| json!(x as i64))
} else {
let shown = x.to_string();
crate::widgets::take_dialog_focus(ui, ui.id().with(("plugin-int", k)), &shown);
crate::widgets::text_field(ui, ("plugin-int", k), Some(&shown), 100.0, 1)
.and_then(|text| text.parse::<i64>().ok())
.map(|x| json!(x.clamp(*min, *max)))
}
}
ParamSpec::Bool { .. } => {
let b = cur.as_bool().unwrap_or(false);
crate::widgets::check(ui, "", b, true).then(|| json!(!b))
}
ParamSpec::Choice { options, .. } => {
let labels: Vec<&str> = options.iter().map(String::as_str).collect();
crate::widgets::dropdown_names(ui, ("plugin-choice", k), cur.as_str().unwrap_or_default(), &labels, 140.0)
.and_then(|i| options.get(i))
.map(|o| json!(o))
}
};
if let Some(v) = new {
d.fields.insert(k.clone(), v);
changed = true;
}
ui.end_row();
}
});
changed
}
/// A field label from its camelCase key (`miterLimit` → "Miter Limit:").
pub(super) fn humanize(k: &str) -> String {
let mut s = String::new();
for (i, c) in k.chars().enumerate() {
if i == 0 {
s.extend(c.to_uppercase());
} else if c.is_uppercase() {
s.push(' ');
s.push(c);
} else {
s.push(c);
}
}
match s.as_str() {
"Dx" => "Horizontal".into(),
"Dy" => "Vertical".into(),
"Sx" => "Horizontal %".into(),
"Sy" => "Vertical %".into(),
"Radius1" => "Radius 1".into(),
"Radius2" => "Radius 2".into(),
"Max Radius" => "Max. Radius:".into(),
"Blur Mode" => "Mode:".into(),
"Channel1" => "Channel 1:".into(),
"Channel2" => "Channel 2:".into(),
"Channel3" => "Channel 3:".into(),
"Channel4" => "Channel 4:".into(),
"Include Cmy Blacks" => "Include Blacks with CMY:".into(),
"Align To Path" => "Align to Path:".into(),
"Overlap Variation" | "Curviness Variation" | "Spacing Variation" => "Variation:".into(),
"Create" => "Create New Fields by:".into(),
_ => format!("{s}:"),
}
}
/// [`humanize`] translated where it is painted: the ":" stays outside the translated text.
pub(super) fn humanized(k: &str) -> String {
let h = humanize(k);
match h.strip_suffix(':') {
Some(base) => format!("{}:", tl!(base)),
None => tl!(&h).to_string(),
}
}
/// Widths of [`slider`]'s label column and rail.
pub(super) const SLIDER_LABEL: f32 = 64.0;
pub(super) const SLIDER_WIDTH: f32 = 180.0;
/// A labelled slider with a value field for the number `d.fields[key]` in `range` (whole numbers,
/// `suffix` after the value); `track(t)` colours the rail at 0..1.
pub(super) fn slider(
ui: &mut egui::Ui,
d: &mut Dialog,
key: &str,
label: &str,
range: std::ops::RangeInclusive<f64>,
suffix: &str,
track: &dyn Fn(f32) -> egui::Color32,
) {
slider_w(ui, d, (key, label, SLIDER_LABEL), range, suffix, track);
}
/// [`slider`] with its label column `label_w` wide: (`key`, `label`, `label_w`).
pub(super) fn slider_w(
ui: &mut egui::Ui,
d: &mut Dialog,
(key, label, label_w): (&str, &str, f32),
range: std::ops::RangeInclusive<f64>,
suffix: &str,
track: &dyn Fn(f32) -> egui::Color32,
) {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
ui.add_sized([label_w, 22.0], egui::Label::new(egui::RichText::new(tl!(label)).color(t.text)));
slider_field(ui, d, key, range, suffix, track);
});
}
/// The rail and value field of [`slider`] without its label (for a row that draws its own).
pub(super) fn slider_field(
ui: &mut egui::Ui,
d: &mut Dialog,
key: &str,
range: std::ops::RangeInclusive<f64>,
suffix: &str,
track: &dyn Fn(f32) -> egui::Color32,
) {
let (min, max) = (*range.start(), *range.end());
let v = d.f64(key, 0.0).clamp(min, max);
slider_rail(ui, d, key, range, track);
if let Some(n) = crate::widgets::plain_field(ui, ("dlg-field", key), v, suffix, 0, 52.0).map(|x| x.round().clamp(min, max)).filter(|n| *n != v) {
d.fields.insert(key.into(), json!(n));
}
}
/// The rail of [`slider_field`] alone: dragging it sets the number `d.fields[key]` to a whole
/// number in `range` (a value past the range shows at its end until the rail moves).
pub(super) fn slider_rail(ui: &mut egui::Ui, d: &mut Dialog, key: &str, range: std::ops::RangeInclusive<f64>, track: &dyn Fn(f32) -> egui::Color32) {
let (min, max) = (*range.start(), *range.end());
let v = d.f64(key, 0.0).clamp(min, max);
let (dragged, _) = crate::widgets::color_slider(ui, ("dlg-slider", key), ((v - min) / (max - min)) as f32, SLIDER_WIDTH, track);
if let Some(n) = dragged.map(|x| (min + x as f64 * (max - min)).round()).filter(|n| *n != v) {
d.fields.insert(key.into(), json!(n));
}
}
/// The words at the two ends of a slider's rail (Less … More), under a slider whose label column
/// is `label_w` wide.
pub(super) fn slider_ends(ui: &mut egui::Ui, label_w: f32, ends: (&str, &str)) {
ui.horizontal(|ui| {
ui.add_space(label_w + ui.spacing().item_spacing.x);
rail_ends(ui, ends);
});
}
/// The words of [`slider_ends`] under a rail that starts where the next widget does.
fn rail_ends(ui: &mut egui::Ui, (left, right): (&str, &str)) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(egui::vec2(SLIDER_WIDTH, 14.0), egui::Sense::hover());
let font = egui::FontId::proportional(11.0);
ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, left, font.clone(), t.text_dim);
ui.painter().text(r.right_center(), egui::Align2::RIGHT_CENTER, right, font, t.text_dim);
}
/// The field where [`preview`] keeps the parameters it last previewed.
pub(super) const PREVIEWED: &str = "__previewed";
/// The Preview checkbox of a dialog that previews `cmd` on the canvas: while it is on, `params` run
/// again on the interaction's snapshot whenever they differ from the last preview (whether a widget
/// or `ui.dialog.set` changed them); turning it off rolls back.
pub(super) fn preview(app: &mut crate::VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, label: &str, cmd: &str, params: Value) {
let _ = preview_result(app, ui, d, label, cmd, params);
}
/// As [`preview`], returning the command's result only when a new preview succeeds.
/// A dialog can retain a small summary (such as point counts) in its private fields.
pub(super) fn preview_result(
app: &mut crate::VectorcraftApp,
ui: &mut egui::Ui,
d: &mut Dialog,
label: &str,
cmd: &str,
params: Value,
) -> Option<Value> {
const LAST: &str = PREVIEWED;
ui.add_space(8.0);
let mut on = d.bool("preview");
if crate::widgets::check(ui, tl!("Preview"), on, true) {
on = !on;
d.fields.insert("preview".into(), json!(on));
}
if !on {
if d.fields.remove(LAST).is_some() {
let _ = app.session.cancel_interaction();
}
return None;
}
let mut result = None;
if d.fields.get(LAST) != Some(&params) || !app.session.in_interaction() {
let _ = app.session.begin_interaction(label);
match app.session.preview(cmd, &params) {
Ok(r) => result = Some(r),
Err(e) => app.status(e.to_string()),
}
d.fields.insert(LAST.into(), params);
}
result
}
/// OK of a dialog using [`preview`]: keep the previewed `cmd` as one undo step (or run it when no
/// preview runs) and close. On an error the dialog stays open.
pub(super) fn commit_preview(app: &mut crate::VectorcraftApp, cmd: &str, params: Value) -> Result<Value, String> {
let r = if app.session.in_interaction() {
match app.session.preview(cmd, &params) {
Ok(_) => app.session.commit_interaction().map(|_| Value::Null).map_err(|e| e.to_string()),
Err(e) => Err(e.to_string()),
}
} else {
app.run(cmd, params)
};
if r.is_ok() {
app.ui.dialog = None;
}
r
}
/// The `[top, bottom, left, right]` bleed in `d.fields["bleed"]` (points).
pub(super) fn bleed_values(d: &Dialog) -> [f64; 4] {
let mut out = [0.0; 4];
if let Some(a) = d.fields.get("bleed").and_then(Value::as_array) {
for (o, v) in out.iter_mut().zip(a) {
*o = v.as_f64().unwrap_or(0.0);
}
}
out
}
/// Bleed fields (Document Setup, New Document): Top, Bottom, Left and Right shown in `unit`, each
/// `width` wide with a caption above, and a link toggle (`bleedLinked`, on by default) that keeps
/// them equal. Values are clamped to 0–72 pt.
pub(super) fn bleed(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit, width: f32) {
let t = Tokens::get(ui.ctx());
let mut b = bleed_values(d);
let linked = d.fields.get("bleedLinked").and_then(Value::as_bool).unwrap_or(true);
let mut changed = None;
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
for (i, side) in ["Top", "Bottom", "Left", "Right"].into_iter().enumerate() {
ui.vertical(|ui| {
ui.label(egui::RichText::new(tl!(side)).size(11.0).color(t.text_dim));
if let Some(v) = crate::widgets::num_field(ui, ("bleed", i), Some(b[i]), unit, width) {
changed = Some((i, v.clamp(0.0, vectorcraft_doc::setup::MAX_BLEED)));
}
});
}
ui.vertical(|ui| {
ui.add_space(15.0);
let tip = if linked { tl!("Make the bleed values differ") } else { tl!("Make all bleed settings the same") };
if crate::widgets::icon_button(ui, if linked { "link" } else { "link-2-off" }, tip, linked, 24.0).clicked() {
d.fields.insert("bleedLinked".into(), json!(!linked));
if !linked {
// Linking makes every side the top's.
changed = Some((0, b[0]));
}
}
});
});
if let Some((i, v)) = changed {
// Linked (also just now): every side takes the value.
if d.fields.get("bleedLinked").and_then(Value::as_bool).unwrap_or(true) {
b = [v; 4];
} else {
b[i] = v;
}
d.fields.insert("bleed".into(), json!(b));
}
}
/// A small grey caption above a field (New Document's details column).
pub(super) fn caption(ui: &mut egui::Ui, text: &str) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(tl!(text)).size(11.0).color(t.text_dim));
}
#[cfg(test)]
mod typed_number_tests {
use super::*;
#[test]
fn whole_numbers_are_integers_and_fractions_stay_fractions() {
// Whatever the field held before: a count typed after a fraction is a count again.
assert_eq!(typed_number(24.0).as_u64(), Some(24));
assert_eq!(typed_number(-4.0), json!(-4));
assert_eq!(typed_number(0.0), json!(0));
assert_eq!(typed_number(2.5), json!(2.5));
// Numbers past exact integers stay floats.
assert!(typed_number(1e20).is_f64());
}
}