mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 04:07:12 -06:00
Selected text in a value field was drawn in the accent on a selection tinted with the same accent, hard to read in every brightness. The tests focus a panel number field and a dialog text field, select their text and read the colours of the glyphs drawn, in all four brightnesses: all must be the strong text colour. Without strong_selected_text the glyphs come out in the accent.
932 lines
42 KiB
Rust
932 lines
42 KiB
Rust
//! 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 vectorcraft_plugins::{Manifest, Param, ParamSpec};
|
||
|
||
use crate::state::Dialog;
|
||
use crate::theme::Tokens;
|
||
|
||
/// Width of a dialog's value fields ([`text`] and [`length`], frame included).
|
||
pub(super) 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
|
||
}
|
||
|
||
/// What [`length`] shows for `d.fields[key]`, as the screen-reader value of a rail over `range`
|
||
/// (in points) beside it: the number in `unit` and the field's text. `None` while the field is
|
||
/// blank.
|
||
pub(super) fn length_value(d: &Dialog, key: &str, unit: Unit, range: std::ops::RangeInclusive<f64>) -> Option<crate::widgets::SliderValue> {
|
||
let pt = d.fields.contains_key(key).then(|| d.f64(key, 0.0))?;
|
||
let number = unit.number(pt).parse().unwrap_or(unit.from_pt(pt));
|
||
Some(crate::widgets::SliderValue::text(number, unit.from_pt(*range.start())..=unit.from_pt(*range.end()), unit.format(pt)))
|
||
}
|
||
|
||
/// 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" | "spiral" => &["radius"],
|
||
"star" => &["radius1", "radius2"],
|
||
"lineSegment" => &["length"],
|
||
"arc" => &["lengthX", "lengthY"],
|
||
"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" | "lineSegment", "angle") => Some("°"),
|
||
("scale", "sx" | "sy") | ("spiral", "decay") => Some("%"),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// The fields of the form dialogs ([`grid`]) that the dialog's own body draws, by dialog kind.
|
||
fn own_rows(kind: &str) -> &'static [&'static str] {
|
||
match kind {
|
||
"artboardOptions" => &["color"],
|
||
_ => &[],
|
||
}
|
||
}
|
||
|
||
/// 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"],
|
||
"spiral" => &["radius", "decay", "segments"],
|
||
"artboardOptions" => &["name", "width", "height"],
|
||
"lineSegment" => &["length", "angle"],
|
||
_ => &[],
|
||
}
|
||
}
|
||
|
||
/// 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 {
|
||
text_edit_limited(ui, d, key, width, usize::MAX)
|
||
}
|
||
|
||
/// [`text_edit`] that takes at most `max_chars` characters: typing and pasting stop at the limit,
|
||
/// and a longer value already in the field is kept, with nothing added to it until it has fewer
|
||
/// than `max_chars` characters.
|
||
pub(super) fn text_edit_limited(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32, max_chars: usize) -> 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).char_limit(max_chars))
|
||
});
|
||
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, UI-only keys and the
|
||
/// kind's [`own_rows`] 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) {
|
||
grid_then(ui, d, unit, |_, _| {});
|
||
}
|
||
|
||
/// [`grid`], then the rows `more` draws in the same grid, so that their labels and fields line up
|
||
/// with the generic ones.
|
||
pub(super) fn grid_then(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit, more: impl FnOnce(&mut egui::Ui, &mut Dialog)) {
|
||
let rail = Tokens::get(ui.ctx()).input_border;
|
||
let (lengths, first, own) = (lengths(&d.kind), order(&d.kind), own_rows(&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())
|
||
.filter(|(k, _)| !own.contains(&k.as_str()))
|
||
.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_named(ui, d, &k, tl!("Skew:"), -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();
|
||
}
|
||
}
|
||
more(ui, d);
|
||
});
|
||
}
|
||
|
||
/// 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) {
|
||
let cur = v.as_str().unwrap_or_default();
|
||
let label = options.iter().find(|(_, value)| *value == cur).map_or(cur, |(l, _)| choice_label(l));
|
||
let labels: Vec<&str> = options.iter().map(|(l, _)| choice_label(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_box(ui, &humanized(&k), 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
|
||
}
|
||
|
||
/// The label of one of [`param_fields`]' choices in the UI language, in the "effect" context: some
|
||
/// languages read the plain Soft, Horizontal and Vertical (Grain's types) as type settings.
|
||
pub(super) fn choice_label(label: &'static str) -> &'static str {
|
||
crate::i18n::tr_ctx(crate::i18n::current(), "effect", label)
|
||
}
|
||
|
||
/// 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 manifest (the filter, live effect and exporter
|
||
/// dialogs): numbers within their range, whole numbers, checkboxes, dropdowns, text and lists of
|
||
/// numbers, a row each. A row is labelled as the manifest says (else from the parameter's name),
|
||
/// with its unit after the value and its help on the label and on a help button that opens its
|
||
/// page. Parameters outside any group come first, then each group as a section that folds
|
||
/// ([`Manifest::sections`]); a parameter whose `visibleWhen` the values don't meet is left out
|
||
/// ([`Manifest::visible`]). Returns true when a value changed. Labels, choices, groups and help are
|
||
/// the plug-in's text, shown as they are. A plug-in may declare more parameters
|
||
/// ([`MAX_PARAMS`](vectorcraft_plugins::manifest::MAX_PARAMS)) than the window has room for, and
|
||
/// the rows then scroll ([`super::modal::scrolled_rows`]).
|
||
pub(super) fn schema_fields(app: &mut crate::VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, m: &Manifest) -> bool {
|
||
let (changed, help) = schema_rows(ui, d, m);
|
||
if let Some(url) = help {
|
||
app.open_url(&url);
|
||
}
|
||
changed
|
||
}
|
||
|
||
/// [`schema_fields`] → whether a value changed, and the help page asked for, left to the caller to
|
||
/// open.
|
||
pub(super) fn schema_rows(ui: &mut egui::Ui, d: &mut Dialog, m: &Manifest) -> (bool, Option<String>) {
|
||
let mut changed = false;
|
||
let mut help = None;
|
||
let visible = m.visible(&d.fields);
|
||
let sections = m.sections();
|
||
// Every section's label column is as wide as the longest label shown, so the fields line up
|
||
// down the dialog; the rows outside groups make up for the indent of a group's rows.
|
||
let font = egui::TextStyle::Body.resolve(ui.style());
|
||
let label_width = (m.params.iter().filter(|(k, _)| visible.contains(k.as_str())))
|
||
.map(|(k, p)| ui.painter().layout_no_wrap(param_label(k, p), font.clone(), egui::Color32::WHITE).size().x)
|
||
.fold(0.0, f32::max);
|
||
let indent = if sections.iter().any(|(group, _)| group.is_some()) { ui.spacing().indent } else { 0.0 };
|
||
super::modal::scrolled_rows(ui, "plugin-fields", |ui| {
|
||
for (section, (group, params)) in sections.into_iter().enumerate() {
|
||
let shown: Vec<&(String, Param)> = params.into_iter().filter(|(k, _)| visible.contains(k.as_str())).collect();
|
||
if shown.is_empty() {
|
||
continue;
|
||
}
|
||
let rows = |ui: &mut egui::Ui, label_width: f32| {
|
||
let grid = egui::Grid::new(("plugin-grid", section)).num_columns(2).spacing([12.0, 8.0]).min_col_width(label_width);
|
||
grid.show(ui, |ui| {
|
||
for (k, p) in shown {
|
||
let (row_changed, opened) = param_row(ui, d, k, p);
|
||
changed |= row_changed;
|
||
help = help.take().or(opened);
|
||
ui.end_row();
|
||
}
|
||
});
|
||
};
|
||
match group {
|
||
None => rows(ui, label_width + indent),
|
||
Some(g) => {
|
||
egui::CollapsingHeader::new(g).id_salt(("plugin-group", g)).default_open(true).show(ui, |ui| rows(ui, label_width));
|
||
}
|
||
}
|
||
}
|
||
});
|
||
(changed, help)
|
||
}
|
||
|
||
/// The label of parameter `k`'s row: its manifest `label`, else its name spaced out.
|
||
fn param_label(k: &str, p: &Param) -> String {
|
||
p.ui.label.as_ref().map_or_else(|| humanize(k), |l| format!("{l}:"))
|
||
}
|
||
|
||
/// One row of [`schema_fields`]: parameter `k`'s label and value field → whether the value changed,
|
||
/// and the help page asked for.
|
||
fn param_row(ui: &mut egui::Ui, d: &mut Dialog, k: &str, p: &Param) -> (bool, Option<String>) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let label = param_label(k, p);
|
||
let shown_label = ui.label(egui::RichText::new(&label).color(t.text));
|
||
if let Some(help) = &p.ui.help {
|
||
shown_label.on_hover_text(help);
|
||
}
|
||
let cur = d.fields.get(k).cloned().unwrap_or_else(|| p.spec.default_value());
|
||
let unit = p.ui.unit.as_deref().unwrap_or_default();
|
||
let suffix = unit_suffix(unit);
|
||
let mut opened = None;
|
||
let new = ui
|
||
.horizontal(|ui| {
|
||
let (new, in_field) = value_field(ui, k, &label, &p.spec, &cur, &suffix);
|
||
if !in_field && !unit.is_empty() {
|
||
ui.label(egui::RichText::new(unit).color(t.text_dim));
|
||
}
|
||
if let Some(url) = &p.ui.help_url
|
||
&& crate::widgets::icon_button(ui, "external-link", tl!("Open this setting's help page"), false, 20.0).clicked()
|
||
{
|
||
opened = Some(url.clone());
|
||
}
|
||
new
|
||
})
|
||
.inner;
|
||
let changed = new.map(|v| d.fields.insert(k.to_string(), v)).is_some();
|
||
(changed, opened)
|
||
}
|
||
|
||
/// The field of parameter `k`'s value `cur` (a checkbox is called by the row's `label`) → the new
|
||
/// value when it changed, and whether the field shows `suffix` (numbers do; the other fields leave
|
||
/// the unit to a label after them).
|
||
fn value_field(ui: &mut egui::Ui, k: &str, label: &str, spec: &ParamSpec, cur: &Value, suffix: &str) -> (Option<Value>, bool) {
|
||
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, suffix, 2, 100.0).map(|x| json!(x)), true)
|
||
}
|
||
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 {
|
||
let field = crate::widgets::range_field(ui, ("plugin-int", k), x as f64, *min as f64..=*max as f64, suffix, 0, 100.0);
|
||
(field.map(|x| json!(x as i64)), true)
|
||
} else {
|
||
let shown = x.to_string();
|
||
crate::widgets::take_dialog_focus(ui, ui.id().with(("plugin-int", k)), &shown);
|
||
let typed = crate::widgets::text_field(ui, ("plugin-int", k), Some(&shown), 100.0, 1);
|
||
(typed.and_then(|text| text.parse::<i64>().ok()).map(|x| json!(x.clamp(*min, *max))), false)
|
||
}
|
||
}
|
||
ParamSpec::Bool { .. } => {
|
||
let b = cur.as_bool().unwrap_or(false);
|
||
(crate::widgets::check_box(ui, label, b, true).then(|| json!(!b)), false)
|
||
}
|
||
ParamSpec::Choice { options, .. } => {
|
||
let labels: Vec<&str> = options.iter().map(String::as_str).collect();
|
||
let chosen = crate::widgets::dropdown_names(ui, ("plugin-choice", k), cur.as_str().unwrap_or_default(), &labels, 140.0);
|
||
(chosen.and_then(|i| options.get(i)).map(|o| json!(o)), false)
|
||
}
|
||
ParamSpec::Text { max_length, .. } => {
|
||
let typed = crate::widgets::text_field(ui, ("plugin-text", k), cur.as_str(), 160.0, 1);
|
||
(typed.map(|text| json!(text.chars().take(*max_length).collect::<String>())), false)
|
||
}
|
||
ParamSpec::NumberList { .. } => {
|
||
let typed = crate::widgets::text_field(ui, ("plugin-list", k), Some(&number_list_text(cur)), 160.0, 1);
|
||
(typed.and_then(|text| read_number_list(&text, spec)), false)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A `numberList` value as its field shows it: "45, 135".
|
||
pub(crate) fn number_list_text(cur: &Value) -> String {
|
||
let numbers: Vec<String> = cur.as_array().into_iter().flatten().filter_map(Value::as_f64).map(number_text).collect();
|
||
numbers.join(", ")
|
||
}
|
||
|
||
/// The list typed in a `numberList` parameter's field: numbers separated by commas, semicolons or
|
||
/// spaces, each clamped into the parameter's range. `None` (the value stays as it was) when a
|
||
/// number doesn't read, there are too few or too many, or `spec` isn't a number list.
|
||
pub(crate) fn read_number_list(text: &str, spec: &ParamSpec) -> Option<Value> {
|
||
let ParamSpec::NumberList { min, max, min_items, max_items, .. } = spec else { return None };
|
||
let parts = text.split([',', ';', ' ']).filter(|p| !p.trim().is_empty());
|
||
let list: Vec<f64> = parts.map(|p| vectorcraft_doc::parse_number(p).filter(|x| x.is_finite())).collect::<Option<_>>()?;
|
||
(*min_items..=*max_items).contains(&list.len()).then(|| json!(list.into_iter().map(|x| x.clamp(*min, *max)).collect::<Vec<f64>>()))
|
||
}
|
||
|
||
/// What a number field shows after its value for a plug-in parameter's `unit` (none: nothing): a
|
||
/// sign such as ° or % joins the number ("45°"), and any other unit is spaced from it ("0.4 mm",
|
||
/// "5 1/min"), so a unit starting with a digit isn't read as more of the number.
|
||
pub(crate) fn unit_suffix(unit: &str) -> String {
|
||
if unit.is_empty() || unit.starts_with(['°', '%', '′', '″']) { unit.to_string() } else { format!(" {unit}") }
|
||
}
|
||
|
||
/// A number of a list as [`value_field`] shows it: at most 4 decimals, without trailing zeros.
|
||
fn number_text(x: f64) -> String {
|
||
let s = format!("{x:.4}");
|
||
if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s }
|
||
}
|
||
|
||
/// A field label from its camelCase key (`miterLimit` → "Miter Limit:").
|
||
pub(crate) 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(),
|
||
"Light Dark Balance" => "Light/Dark Balance:".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_named(ui, d, key, tl!(label), range, suffix, track);
|
||
});
|
||
}
|
||
|
||
/// The rail and value field of [`slider`] without its label (for a row that draws its own); screen
|
||
/// readers call the rail `name` (the row's label, already translated), with the number and text
|
||
/// the field shows as its value.
|
||
pub(super) fn slider_field_named(
|
||
ui: &mut egui::Ui,
|
||
d: &mut Dialog,
|
||
key: &str,
|
||
name: &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);
|
||
let shown = crate::widgets::SliderValue::field(v, range.clone(), suffix, 0);
|
||
slider_rail_valued(ui, d, key, name, range, Some(shown), track);
|
||
if let Some(n) = crate::widgets::range_field(ui, ("dlg-field", key), v, min..=max, suffix, 0, 52.0).filter(|n| *n != v) {
|
||
d.fields.insert(key.into(), json!(n));
|
||
}
|
||
}
|
||
|
||
/// The rail of [`slider_field_named`] alone: dragging it sets the number `d.fields[key]` to a
|
||
/// whole number in `range`, and its keys step the number while it has the keyboard focus
|
||
/// ([`crate::widgets::color_slider_stepped`]); a value past the range shows at its end until the
|
||
/// rail moves. Screen readers call it `name` (already translated), with the number
|
||
/// `d.fields[key]` as its value.
|
||
pub(super) fn slider_rail_named(
|
||
ui: &mut egui::Ui,
|
||
d: &mut Dialog,
|
||
key: &str,
|
||
name: &str,
|
||
range: std::ops::RangeInclusive<f64>,
|
||
track: &dyn Fn(f32) -> egui::Color32,
|
||
) {
|
||
let shown = crate::widgets::SliderValue::number(d.f64(key, 0.0), range.clone());
|
||
slider_rail_valued(ui, d, key, name, range, Some(shown), track);
|
||
}
|
||
|
||
/// [`slider_rail_named`] that screen readers get `shown` from: the number and text of the field
|
||
/// the row draws beside it (no value for `None`, as for a blank field). Its keys step `shown`'s
|
||
/// number, which sets `d.fields[key]` to the number at the same place on `range` (`shown`'s range
|
||
/// and `range` are the same span in two units, such as millimeters and points).
|
||
pub(super) fn slider_rail_valued(
|
||
ui: &mut egui::Ui,
|
||
d: &mut Dialog,
|
||
key: &str,
|
||
name: &str,
|
||
range: std::ops::RangeInclusive<f64>,
|
||
shown: Option<crate::widgets::SliderValue>,
|
||
track: &dyn Fn(f32) -> egui::Color32,
|
||
) {
|
||
let (min, max) = (*range.start(), *range.end());
|
||
let held = d.f64(key, 0.0);
|
||
let v = held.clamp(min, max);
|
||
let units = shown.as_ref().map(|s| s.range.clone());
|
||
let (dragged, _, keyed) =
|
||
crate::widgets::color_slider_stepped(ui, ("dlg-slider", key), name, shown, ((v - min) / (max - min)) as f32, SLIDER_WIDTH, track);
|
||
let keyed =
|
||
keyed.zip(units).map(|(k, u)| if u == range { k } else { (min + (k - u.start()) / (u.end() - u.start()) * (max - min)).clamp(min, max) });
|
||
if let Some(n) = dragged.map(|x| (min + x as f64 * (max - min)).round()).or(keyed).filter(|n| *n != held) {
|
||
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;
|
||
}
|
||
preview_now(app, d, label, cmd, params)
|
||
}
|
||
|
||
/// The preview of [`preview_result`] without its checkbox: `params` run again on the interaction's
|
||
/// snapshot when they differ from the last preview. Returns the command's result only when a new
|
||
/// preview succeeds.
|
||
pub(super) fn preview_now(app: &mut crate::VectorcraftApp, d: &mut Dialog, label: &str, cmd: &str, params: Value) -> Option<Value> {
|
||
let mut result = None;
|
||
if d.fields.get(PREVIEWED) != Some(¶ms) || !app.session.in_interaction() {
|
||
let _ = app.session.begin_interaction(label);
|
||
match app.session.preview(cmd, ¶ms) {
|
||
Ok(r) => result = Some(r),
|
||
Err(e) => app.status(e.to_string()),
|
||
}
|
||
d.fields.insert(PREVIEWED.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; the command's warning shows in the status bar either way. 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, ¶ms) {
|
||
Ok(v) => {
|
||
let r = app.session.commit_interaction().map(|_| Value::Null).map_err(|e| e.to_string());
|
||
if r.is_ok()
|
||
&& let Some(w) = v.get("warning").and_then(Value::as_str)
|
||
{
|
||
app.status(w.to_string());
|
||
}
|
||
r
|
||
}
|
||
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 plugin_field_tests {
|
||
use super::*;
|
||
|
||
/// A unit reads after its number as it is written: a sign joins it ("45°", "50%"), anything
|
||
/// else is spaced, so a unit starting with a digit isn't read as more of the number.
|
||
#[test]
|
||
fn units_follow_the_number() {
|
||
let shown = |unit| format!("5{}", unit_suffix(unit));
|
||
assert_eq!([shown("mm"), shown("°"), shown("%"), shown("1/min"), shown("µm")], ["5 mm", "5°", "5%", "5 1/min", "5 µm"]);
|
||
}
|
||
|
||
#[test]
|
||
fn number_lists_read_as_typed_within_their_limits() {
|
||
let spec = ParamSpec::NumberList { min: 0.0, max: 360.0, default: vec![45.0], min_items: 1, max_items: 3 };
|
||
assert_eq!(number_list_text(&json!([45.0, 135.5, 0.125])), "45, 135.5, 0.125");
|
||
assert_eq!(read_number_list("10; 400 20", &spec), Some(json!([10.0, 360.0, 20.0])), "separators, and clamped");
|
||
assert_eq!(read_number_list(" 90 , 2*45 ", &spec), Some(json!([90.0, 90.0])));
|
||
for bad in ["", "1, 2, 3, 4", "1, x", "1, inf"] {
|
||
assert_eq!(read_number_list(bad, &spec), None, "{bad:?}");
|
||
}
|
||
assert_eq!(read_number_list("1", &ParamSpec::Bool { default: false }), None, "not a list");
|
||
}
|
||
}
|
||
|
||
#[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());
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod selected_text_tests {
|
||
use super::*;
|
||
use crate::widgets::look_tests::{frame, glyph_colours, themed};
|
||
|
||
// Regression test for #1465: selected text in a dialog's text field was in the accent, on a
|
||
// selection tinted with the same accent. It is in the strong text colour.
|
||
#[test]
|
||
fn selected_text_in_a_dialog_text_field_is_in_the_strong_text_colour() {
|
||
for brightness in crate::theme::Brightness::ALL {
|
||
let ctx = themed(brightness);
|
||
let t = Tokens::get(&ctx);
|
||
let mut d = Dialog::new("test", json!({"name": "Artboard 1"}));
|
||
let id = std::cell::Cell::new(egui::Id::NULL);
|
||
let mut add = |ui: &mut egui::Ui| {
|
||
id.set(ui.id().with(("dlg-text", "name")));
|
||
text_edit(ui, &mut d, "name", 120.0);
|
||
};
|
||
frame(&ctx, 0.0, vec![], &mut add);
|
||
// Focus the field and select all of its text.
|
||
ctx.memory_mut(|m| m.request_focus(id.get()));
|
||
frame(&ctx, 0.1, vec![], &mut add);
|
||
let mut state = egui::TextEdit::load_state(&ctx, id.get()).unwrap_or_default();
|
||
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(10))));
|
||
state.store(&ctx, id.get());
|
||
let shapes = frame(&ctx, 0.2, vec![], &mut add);
|
||
let colours = glyph_colours(&shapes);
|
||
assert!(!colours.is_empty(), "{brightness:?}: the text is drawn");
|
||
let mut used = colours.clone();
|
||
used.dedup();
|
||
assert_eq!(used, [t.text_strong], "{brightness:?}: selected text in {used:?}");
|
||
}
|
||
}
|
||
}
|