Fields: unit fields take % in arithmetic, as in 3cm*50% and 50pt+25%

Unit fields and length strings read a percentage operand as a share of
a value. After * or /, the percentage scales the term before it:
3cm*50% is 1.5 cm and 3cm/50% is 6 cm. After + or -, it adds or takes
away that share of everything before it: 50pt+25% is 62.5 pt and
100pt+20pt-50% is 60 pt. Before this change these expressions read as
nothing, and the field kept its old value.

The parser now splits a sum at its last + or -, so a percentage applies
to the whole sum before it. Text without % gives the same values as
before, apart from float rounding. A percentage on its own (50%) still
reads as nothing, and percent and degree fields still read % as their
suffix.

Command length strings use the same parser: file.new's width and
height, the distance params and the length preferences.

Docs: docs/mcp.md, docs/ui-parity.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-16 06:16:35 +07:00
committed by bflatastic
parent da43c662e0
commit 7e9d0be723
7 changed files with 102 additions and 12 deletions
+31 -7
View File
@@ -236,7 +236,10 @@ impl Unit {
/// Parse `12`, `12pt`, `1in`, `3 mm`, `2p6` (picas+points), simple `+ - * /` arithmetic.
///
/// A unit after a `*` or `/` operand measures the whole expression (`1080/2 px` is 540 px), so
/// math typed before a field's unit suffix works.
/// math typed before a field's unit suffix works. After `*` or `/`, a percentage scales the term
/// before it (`3cm*50%` is 1.5 cm, `3cm/50%` is 6 cm). After `+` or `-`, it adds or takes away
/// that share of everything before it (`50pt+25%` is 62.5 pt, `100pt+20pt-50%` is 60 pt). A
/// percentage on its own (`50%`) reads as nothing.
pub fn parse(self, s: &str) -> Option<f64> {
parse_measure(s, self).filter(|v| v.is_finite())
}
@@ -273,8 +276,9 @@ impl Unit {
fn parse_measure(s: &str, default: Unit) -> Option<f64> {
let s = s.trim();
// Simple arithmetic on the right (Illustrator fields accept "10+5", "100/2", "3in*2").
// `*` and `/` share one precedence level, so they split at the last of either (left to right).
for ops in [&['+'][..], &['-'][..], &['*', '/'][..]] {
// `+` and `-` share one precedence level, as do `*` and `/`, so each level splits at the last
// of its operators (left to right).
for ops in [&['+', '-'][..], &['*', '/'][..]] {
// Past the first character, so a leading sign isn't an operator.
let skip = s.chars().next().map_or(0, char::len_utf8);
// A `+` or `-` right after another operator is the sign of its operand (`10*-2`, `5--3`).
@@ -290,11 +294,23 @@ fn parse_measure(s: &str, default: Unit) -> Option<f64> {
}
let Some(op) = s[i..].chars().next() else { continue };
return match op {
'+' => Some(parse_measure(l, default)? + parse_measure(r, default)?),
'-' => Some(parse_measure(l, default)? - parse_measure(r, default)?),
'+' | '-' => {
let a = parse_measure(l, default)?;
// A percentage added or taken away is a share of everything before it:
// `50pt+25%` is 62.5 pt, `100pt+20%-10pt` is 110 pt.
let b = match percent(r) {
Some(k) => a * k,
None => parse_measure(r, default)?,
};
Some(if op == '+' { a + b } else { a - b })
}
_ => {
// A factor or divisor is a plain number; a unit after it is the expression's.
let (k, unit) = number_unit(r)?;
// A factor or divisor is a plain number or a percentage (`3cm*50%` is 1.5 cm);
// a unit after it is the expression's.
let (k, unit) = match percent(r) {
Some(k) => (k, None),
None => number_unit(r)?,
};
let a = parse_measure(l, unit.unwrap_or(default))?;
if op == '*' {
Some(a * k)
@@ -344,6 +360,14 @@ fn number_unit(s: &str) -> Option<(f64, Option<Unit>)> {
Some((v, unit))
}
/// A number followed by `%` (`50%`, `12.5 %`) → its fraction (`0.5`, `0.125`).
fn percent(s: &str) -> Option<f64> {
match number_unit(s.trim().strip_suffix('%')?)? {
(p, None) => Some(p / 100.0),
_ => None,
}
}
/// A unitless field value (percent, degrees, counts) with the same `+ - * /` arithmetic as
/// [`Unit::parse`] (`45*2`, `100/3`); text other than digits, `.` and operators reads as nothing.
pub fn parse_number(s: &str) -> Option<f64> {
+46 -3
View File
@@ -1,5 +1,7 @@
//! Units: format/parse round-trips in every unit, typed units winning over the field's unit, names
//! and canvas readouts.
//! Units: format/parse round-trips in every unit, typed units winning over the field's unit,
//! percentages in arithmetic, names and canvas readouts.
use proptest::prelude::*;
use super::Unit;
@@ -34,13 +36,54 @@ fn typed_units_win_over_the_fields_unit() {
assert_eq!(Unit::Pixels.parse("-4"), Some(-4.0));
}
#[test]
fn a_percentage_is_a_share_of_the_value_before_it() {
let (mm, cm) = (72.0 / 25.4, 72.0 / 2.54);
for (u, s, want) in [
(Unit::Centimeters, "3cm*50%", 1.5 * cm),
(Unit::Points, "50pt+25%", 62.5),
(Unit::Points, "50pt-25%", 37.5),
(Unit::Millimeters, "10*50%", 5.0 * mm),
(Unit::Centimeters, "3cm/50%", 6.0 * cm),
(Unit::Points, "50 pt + 25 %", 62.5),
(Unit::Points, "10+20+50%", 45.0),
// A percentage added or taken away is a share of everything before it. A percentage factor
// is a share of the term before it.
(Unit::Points, "100pt+20pt-50%", 60.0),
(Unit::Points, "100pt-20pt+50%", 120.0),
(Unit::Points, "100pt+20%-10pt", 110.0),
(Unit::Points, "10+20*50%", 20.0),
(Unit::Points, "10*2+50%", 30.0),
(Unit::Points, "10*2/50%", 40.0),
(Unit::Points, "10+-50%", 5.0),
(Unit::Points, "50pt--25%", 62.5),
(Unit::Centimeters, "3cm*-50%", -1.5 * cm),
] {
let got = u.parse(s).unwrap_or_else(|| panic!("{u:?} can't read `{s}`"));
assert!((got - want).abs() < 1e-9, "{u:?}: `{s}` → {got}, want {want}");
}
}
#[test]
fn odd_text_reads_as_nothing_without_panicking() {
for s in ["", "é", "é5", "°+1", "×2", "5 ×", "—3", "mm", "1/0", "--", "+"] {
for s in ["", "é", "é5", "°+1", "×2", "5 ×", "—3", "mm", "1/0", "--", "+", "50%", "%", "5%%", "%5", "1/0%", "10+%", "10*5pt%"] {
assert_eq!(Unit::Millimeters.parse(s), None, "{s}");
}
}
proptest! {
/// Field text made of digits, operators, `%`, spaces and unit letters reads as a finite value
/// or as nothing and does not panic.
#[test]
fn arithmetic_text_reads_as_a_finite_value_or_nothing(s in "[0-9.+*/% ptxinmcfyd-]{0,24}") {
for u in Unit::ALL {
if let Some(v) = u.parse(&s) {
prop_assert!(v.is_finite(), "{u:?}: `{s}` → {v}");
}
}
}
}
#[test]
fn units_are_named_by_label_key_or_suffix() {
for u in Unit::ALL {
+8
View File
@@ -76,6 +76,14 @@ fn every_setting_reaches_the_document() {
assert_eq!((back.raster_effects_ppi, back.setup.bleed, back.setup.background), (150.0, d.setup.bleed, Background::White));
}
#[test]
fn a_length_with_a_percentage_is_a_share_of_the_value_before_it() {
let s = new_doc(json!({"width": "200mm*50%", "height": "100mm+25%", "units": "Millimeters"}));
let r = boards(&s)[0];
assert!((r.width() - Unit::Millimeters.to_pt(100.0)).abs() < 1e-9, "{}", r.width());
assert!((r.height() - Unit::Millimeters.to_pt(125.0)).abs() < 1e-9, "{}", r.height());
}
#[test]
fn a_white_background_exports_opaque() {
let alpha = |bg: &str| {
+12
View File
@@ -275,6 +275,18 @@ fn focus_selects_the_whole_field_so_typing_replaces_it() {
assert_eq!(f.num(vec![enter()]), Some(540.0));
}
#[test]
fn a_percentage_typed_in_a_field_is_a_share_of_the_value_before_it() {
let mut f = Field::new();
let cm = |ui: &mut egui::Ui| widgets::num_field(ui, "f", Some(72.0), Unit::Centimeters, 80.0);
f.frame(vec![], &cm);
f.frame(f.click(1), &cm);
f.frame(vec![], &cm);
f.frame(vec![Event::Text("3cm*50%".into())], &cm);
let got = f.frame(vec![enter()], &cm).expect("the field reads `3cm*50%`");
assert!((got - Unit::Centimeters.to_pt(1.5)).abs() < 1e-9, "{got}");
}
#[test]
fn double_click_selects_the_whole_field() {
let mut f = Field::new();
+3 -1
View File
@@ -2097,7 +2097,9 @@ report warn about it).
## Units
Commands take and return lengths in points (the distances of `object.path.offsetPath`, `object.path.simplify`,
`object.path.splitIntoGrid` and `text.setStyle` `size`/`leading` also take a string with a unit, `"5 mm"`). The units
`object.path.splitIntoGrid` and `text.setStyle` `size`/`leading` also take a string with a unit, `"5 mm"`). A length
string takes the same arithmetic as the desktop app's fields, where `%` is a share of the value before it:
`"200mm*50%"` is 100 mm and `"50pt+25%"` is 62.5 pt. The units
only change what the UI shows and how it reads typed numbers. Preferences ▸ Units has
three: General (rulers, positions and sizes, the Info panel, dialog distances, canvas measurement labels), Stroke
(weights, dashes) and Type (font size, leading, baseline shift). General is the active document's units
+1
View File
@@ -421,6 +421,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs.
- Hint bar, contextual task bar, 19 dock panels with ≡ menus.
- Numeric fields keep the pointer's pressed character as the text-drag start when movement exceeds the click threshold (#1326, in part); a completed focus click selects the unit too. Pixel/point, plain and optional fields are regression-tested in both directions, before/after focus, with replacement text; keyboard focus, double-click, arrows and wheel keep their prior behavior. Native egui input confirms partial replacement gives 17 px instead of 700 px, one Undo/Redo, window captures and an exact saved-document read in a separate CLI process. Short movements still counted as clicks can select all; that case remains open.
- Unit fields and length strings take `%` in arithmetic: `3cm*50%` reads as 1.5 cm, and `50pt+25%` reads as 62.5 pt.
- The contextual task bar's handle drags it anywhere on the canvas (kept whole on it as the window resizes); unpinned it keeps that offset as it follows the selection, and its More Options menu hides it, pins its position across selection changes (`window.taskBar.pin`), resets it under the selection (`window.taskBar.reset`) and shows the Properties panel. As in Illustrator the position isn't kept across launches. Not done: the bar's tool-specific variants (only the selection tools show it).
- Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it.
- Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on the dock, which lights up under the pointer, its panels go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating groups stack into sets: dropped on the upper or lower half of another group (a line shows where), a group joins it right above or below it, and the set's groups stack top to bottom, each showing one panel, moving together by the top group's title bar, whose × docks them all; a group dragged by its tab strip leaves the set (`window.panel.float {below, above}`). A double-click on a tab collapses a floating group to its tabs (`collapsed`). Sets dropped on the left edge of the icon column or of another dock column dock as dock columns (a multi-column dock, `window.panel.dock {column}`), whose groups stack, tab and tear out as a floating set's do, and whose top strip floats the column again. Floating panels, their sets and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, docking a panel into the default tabbed group it doesn't belong to, reordering tabs by dragging, collapsing a set or dock column to icons, dock columns right of the default dock, resizing floating panels and the groups of a set.
+1 -1
View File
@@ -134,7 +134,7 @@ tools, never checked side by side) isn't captured row by row.
| Arrow keys nudge by Keyboard Increment (0.001–1296 pt); Shift ×10; Alt copies | done | |
| Fields commit what was typed on a click away or a move to another field, as Enter does; Escape discards it | done | #1787 |
| Number fields: Up/Down step, Shift ×10, Cmd/Ctrl ÷10, wheel over a focused field | done | Shift+arrow in the Stroke weight field doesn't step by 10 (#991) |
| Arithmetic in fields (`+ - * /`) and unit suffixes (`3 mm`, `1in`, `2p6`) | done | `Unit::parse`; mixed units in one expression not verified |
| Arithmetic in fields (`+ - * /`, and `%` of the value before it: `3cm*50%`, `50pt+25%`) and unit suffixes (`3 mm`, `1in`, `2p6`) | done | `Unit::parse`; mixed units in one expression not verified |
| Numeric fields: focus click selects the whole value; dragging selects from the pressed character | partial | #1326; select-all waits for click release; px/pt/plain/optional fields, both directions and replacement text tested; native API input, 17 px partial replacement, Undo/Redo, saved-file round trip and screenshots verified; Tab, double-click, arrows and wheel preserved; short movements still classified as clicks can select all and remain open |
| Units: pt, pc, in, mm, cm, px, ft-in, m, yd, ft; General/Stroke/Type units | done | units preference reverting to points on SVG output reported (#864) |
| Field precision: 3 decimals for steps; values kept as f64 | done | |