diff --git a/ROADMAP.md b/ROADMAP.md index 73b62ee6..bb28112e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -134,6 +134,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-24 | Preferences ▸ Measurement units: inches or centimetres. The Format pane's Size and Position fields, Shape Format ▸ Size on the ribbon, the Slide Size dialog and the ruler show and take the chosen unit; the deck still stores points (#1326) | | 2026-10-23 | Chart text colour: `chart.options {textColor}` and Font Color on a selected chart colour its title, axes, legend and data labels; the colour is written to the chart part (`c:txPr` on the axes, legend, labels and chart, the title's run defaults) so an imported chart keeps its `txPr` colours through an edit and a save; text without a colour of its own takes PowerPoint's default, the theme's text colour at 65 % plus 35 % (595959 on a light theme, light grey on a dark one) | | 2026-10-23 | Format Background ▸ Apply to All also sets the background of the slide masters the slides use (and clears their layouts' overrides), so new slides keep it (#891) | | 2026-10-22 | Table text takes the master's other-text style (`otherStyle`), shapes and text boxes the presentation's default text style, as PowerPoint for Mac draws them (synthetic probe: 14 pt cells under a 14 pt `otherStyle` and a 26 pt `defaultTextStyle`); a private deck's table had been drawn 20 pt instead of 18 | @@ -278,6 +279,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-24 | trivial | Progress log: measurement units in centimetres (#1326). Scores unchanged | | 2026-10-22 | trivial | Progress log: Animation Pane ▸ Play From. Scores unchanged | | 2026-10-23 | trivial | Progress log: scroll bars on the zoomed slide pane (#1157). Scores and milestone names unchanged | | 2026-10-20 | trivial | Explicit empty chart number-format sections hide their values; blank whole-code General fallback is retained. Synthetic formatter regressions; chart-element scores and readiness estimates unchanged | diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 54380bd1..583d5232 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -343,6 +343,9 @@ pub struct Prefs { pub undo_limit: usize, /// Minutes between AutoRecover saves of unsaved presentations. pub recovery_minutes: f64, + /// The unit the rulers, the Format pane and the size dialogs show (Preferences); the deck + /// itself always stores points. + pub units: deckcraft_geom::Unit, } impl Default for Prefs { @@ -365,6 +368,7 @@ impl Default for Prefs { author: "Presenter".into(), undo_limit: 500, recovery_minutes: 1.0, + units: deckcraft_geom::Unit::Inches, } } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c6320dff..29b1783a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -355,7 +355,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { draw_guides(app, &painter, xf, slide_rect, &t); } if app.ui.ruler { - ruler(&painter, avail, slide_rect, scale, &t); + ruler(&painter, avail, slide_rect, scale, app.session.prefs.units, &t); ruler_markers(app, ui, &painter, avail, xf, &t); } pointer(app, ui, &resp, xf); @@ -569,44 +569,52 @@ fn master_tex( Some((crate::textures::upload_editor(ctx, "master-canvas", crate::textures::to_color_image(&img, false)), region)) } -fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) { +/// The ruler's numbered step in points and the ticks per step: quarter inches, or half +/// centimetres. Any other unit (only a hand-edited `prefs.json` gives one) keeps inches, since a +/// tick per point or pixel would be a solid bar. +pub(crate) fn ruler_steps(unit: deckcraft_geom::Unit) -> (f64, u32) { + match unit { + deckcraft_geom::Unit::Centimeters => (unit.points_per_unit(), 2), + _ => (72.0, 4), + } +} + +fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, unit: deckcraft_geom::Unit, t: &Tokens) { let top = Rect::from_min_max(avail.min, pos2(avail.max.x, avail.min.y + 16.0)); let left = Rect::from_min_max(avail.min, pos2(avail.min.x + 16.0, avail.max.y)); p.rect_filled(top, CornerRadius::ZERO, t.card); p.rect_filled(left, CornerRadius::ZERO, t.card); - let inch = 72.0 * scale; - let mut k = 0; - loop { - let x = slide.min.x + k as f32 * inch / 4.0; - if x > slide.max.x { - break; - } - let h = if k % 4 == 0 { + let (step, sub) = ruler_steps(unit); + let tick = step as f32 * scale / sub as f32; + let len = |k: u32| { + if k.is_multiple_of(sub) { 8.0 - } else if k % 2 == 0 { + } else if k.is_multiple_of(2) { 5.0 } else { 3.0 - }; + } + }; + let mut k = 0; + loop { + let x = slide.min.x + k as f32 * tick; + if x > slide.max.x { + break; + } + let h = len(k); p.line_segment([pos2(x, top.max.y - h), pos2(x, top.max.y)], Stroke::new(1.0, t.text_faint)); - if k % 4 == 0 { - p.text(pos2(x + 2.0, top.min.y + 1.0), Align2::LEFT_TOP, format!("{}", k / 4), theme::font(8.0), t.text_dim); + if k.is_multiple_of(sub) { + p.text(pos2(x + 2.0, top.min.y + 1.0), Align2::LEFT_TOP, format!("{}", k / sub), theme::font(8.0), t.text_dim); } k += 1; } let mut k = 0; loop { - let y = slide.min.y + k as f32 * inch / 4.0; + let y = slide.min.y + k as f32 * tick; if y > slide.max.y { break; } - let w = if k % 4 == 0 { - 8.0 - } else if k % 2 == 0 { - 5.0 - } else { - 3.0 - }; + let w = len(k); p.line_segment([pos2(left.max.x - w, y), pos2(left.max.x, y)], Stroke::new(1.0, t.text_faint)); k += 1; } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e0fa02e9..66d08404 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -209,6 +209,16 @@ fn check(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, default: bool) -> v } +/// The unit's abbreviation in a field label, "Width (cm):". +fn unit_abbr(unit: deckcraft_geom::Unit) -> &'static str { + match unit { + deckcraft_geom::Unit::Inches => "in", + deckcraft_geom::Unit::Centimeters => "cm", + deckcraft_geom::Unit::Points => "pt", + deckcraft_geom::Unit::Pixels => "px", + } +} + fn num(d: &mut Dialog, key: &str, default: f64) -> f64 { d.get(key, "").trim().trim_end_matches(['"', '%', ' ']).parse::().ok().filter(|v| v.is_finite()).unwrap_or(default) } @@ -1055,20 +1065,24 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } "slideSize" => { let cur = app.session.active().map(|s| s.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE); + let len = app.session.prefs.units; + let ppu = len.points_per_unit(); + let shown = |pt: f64| format!("{:.2}", pt / ppu); ui.label("Slides sized for:"); let preset = d.get("preset", ""); egui::ComboBox::from_id_salt("sizes").selected_text(if preset.is_empty() { "Custom" } else { &preset }).show_ui(ui, |ui| { for (label, w, h) in deckcraft_model::defaults::SLIDE_SIZES { if ui.selectable_label(preset == *label, *label).clicked() { d.fields.insert("preset".into(), label.to_string()); - d.fields.insert("w".into(), format!("{:.2}", w / 72.0)); - d.fields.insert("h".into(), format!("{:.2}", h / 72.0)); + d.fields.insert("w".into(), shown(*w)); + d.fields.insert("h".into(), shown(*h)); } } }); - field(ui, d, "Width (in):", "w", &format!("{:.2}", cur.width / 72.0)); - field(ui, d, "Height (in):", "h", &format!("{:.2}", cur.height / 72.0)); - let (w, h) = (num(d, "w", cur.width / 72.0), num(d, "h", cur.height / 72.0)); + let abbr = unit_abbr(len); + field(ui, d, &format!("Width ({abbr}):"), "w", &shown(cur.width)); + field(ui, d, &format!("Height ({abbr}):"), "h", &shown(cur.height)); + let (w, h) = (num(d, "w", cur.width / ppu), num(d, "h", cur.height / ppu)); ui.horizontal(|ui| { ui.label("Orientation:"); let portrait = ui.radio(h > w, "Portrait").clicked(); @@ -1082,7 +1096,10 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let maximize = check(ui, d, "Maximize content (else ensure fit)", "max", false); let (ok, cancel) = buttons(ui, "OK"); if ok { - let (w, h) = (num(d, "w", cur.width / 72.0) * 72.0, num(d, "h", cur.height / 72.0) * 72.0); + // A field still showing the current size keeps it exactly: the two decimals shown + // (13.33", 33.87 cm) would otherwise nudge the slide off 960 pt. + let mut points = |key: &str, cur: f64| if d.get(key, "") == shown(cur) { cur } else { num(d, key, cur / ppu) * ppu }; + let (w, h) = (points("w", cur.width), points("h", cur.height)); run(app, "design.slideSize", json!({"w": w, "h": h, "scale": if maximize { "maximize" } else { "ensureFit" }})); } ok || cancel @@ -2274,6 +2291,13 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } d.fields.remove("uiScale"); } + // PowerPoint takes the unit from the system's measurement setting; DeckCraft asks here. + ui.horizontal(|ui| { + ui.label("Measurement units:"); + let units = &mut app.session.prefs.units; + ui.radio_value(units, deckcraft_geom::Unit::Inches, "Inches"); + ui.radio_value(units, deckcraft_geom::Unit::Centimeters, "Centimeters"); + }); ui.label(egui::RichText::new("Editing").font(theme::bold(13.0))); ui.checkbox(&mut app.session.prefs.smart_guides, "Show smart guides"); ui.checkbox(&mut app.session.prefs.snap_to_grid, "Snap objects to grid"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2c7fd8cc..6e51f708 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2133,6 +2133,8 @@ mod slide_size_tests; mod texture_limit_tests; #[cfg(test)] mod ui_soak_tests; +#[cfg(test)] +mod units_tests; #[cfg(test)] #[path = "datetime_tests.rs"] diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 14ac9344..20c8aaea 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -330,10 +330,12 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { let lock_id = egui::Id::new("fmt-lock-aspect"); let mut lock: bool = ui.data_mut(|d| d.get_temp(lock_id).unwrap_or(false)); let locked = lock; - let mut fields = |ui: &mut Ui, label: &str, id: &str, v: f64, key: &str, cmd: &str, unit: f64| { + let len = app.session.prefs.units; + let mut fields = |ui: &mut Ui, label: &str, id: &str, v: f64, key: &str, cmd: &str, angle: bool| { + let (unit, suffix) = if angle { (1.0, "°") } else { (len.points_per_unit(), len.suffix()) }; ui.horizontal(|ui| { ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); - if let Some(nv) = widgets::spinner(ui, id, v / unit, 0.1, if unit == 72.0 { "\"" } else { "°" }, 70.0) { + if let Some(nv) = widgets::spinner(ui, id, v / unit, 0.1, suffix, 70.0) { let mut p = json!({key: nv * unit}); if cmd == "shape.resize" && locked { p["lockAspect"] = json!(true); @@ -343,16 +345,16 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { }); }; ui.label(egui::RichText::new("Size").font(theme::bold(13.0))); - fields(ui, "Height", "fh", x.h, "h", "shape.resize", 72.0); - fields(ui, "Width", "fw", x.w, "w", "shape.resize", 72.0); - fields(ui, "Rotation", "fr", x.rot, "deg", "shape.rotate", 1.0); + fields(ui, "Height", "fh", x.h, "h", "shape.resize", false); + fields(ui, "Width", "fw", x.w, "w", "shape.resize", false); + fields(ui, "Rotation", "fr", x.rot, "deg", "shape.rotate", true); if ui.checkbox(&mut lock, "Lock aspect ratio").changed() { ui.data_mut(|d| d.insert_temp(lock_id, lock)); } ui.add_space(6.0); ui.label(egui::RichText::new("Position").font(theme::bold(13.0))); - fields(ui, "Horizontal", "fx", x.x, "x", "shape.move", 72.0); - fields(ui, "Vertical", "fy", x.y, "y", "shape.move", 72.0); + fields(ui, "Horizontal", "fx", x.x, "x", "shape.move", false); + fields(ui, "Vertical", "fy", x.y, "y", "shape.move", false); ui.add_space(6.0); ui.label(egui::RichText::new("Alt Text").font(theme::bold(13.0))); let id = ui.id().with(("alt", uid, sh.id.0)); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 340ca99f..4e5c47ef 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2589,16 +2589,20 @@ fn size_group(app: &mut SlideApp, ui: &mut Ui) { ui.label("Width:"); return; }; + let len = app.session.prefs.units; + let (ppu, suffix) = (len.points_per_unit(), len.suffix()); + // " cm" needs more room than the inch mark. + let width = if len == deckcraft_geom::Unit::Inches { 54.0 } else { 66.0 }; ui.horizontal(|ui| { ui.label("Height:"); - if let Some(v) = widgets::spinner(ui, "sh", x.h / 72.0, 0.1, "\"", 54.0) { - run(app, "shape.resize", json!({"h": v.max(0.0) * 72.0})); + if let Some(v) = widgets::spinner(ui, "sh", x.h / ppu, 0.1, suffix, width) { + run(app, "shape.resize", json!({"h": v.max(0.0) * ppu})); } }); ui.horizontal(|ui| { ui.label("Width: "); - if let Some(v) = widgets::spinner(ui, "sw", x.w / 72.0, 0.1, "\"", 54.0) { - run(app, "shape.resize", json!({"w": v.max(0.0) * 72.0})); + if let Some(v) = widgets::spinner(ui, "sw", x.w / ppu, 0.1, suffix, width) { + run(app, "shape.resize", json!({"w": v.max(0.0) * ppu})); } }); }); diff --git a/crates/ui-egui/src/settings.rs b/crates/ui-egui/src/settings.rs index 9a1b8b62..1fd5223b 100644 --- a/crates/ui-egui/src/settings.rs +++ b/crates/ui-egui/src/settings.rs @@ -127,11 +127,12 @@ mod tests { fn saving_round_trips_and_leaves_no_temporary_file() { let d = dir("save"); let ui = UiState { zoom: Some(1.5), recent: vec!["/a.pptx".into()], ..Default::default() }; - let prefs = Prefs { author: "Ann".into(), ..Default::default() }; + let prefs = Prefs { author: "Ann".into(), units: deckcraft_geom::Unit::Centimeters, ..Default::default() }; save(&d.join("nested"), &ui, &prefs); let (u, p) = load(&d.join("nested")); assert_eq!(u.unwrap().zoom, Some(1.5)); - assert_eq!(p.unwrap().author, "Ann"); + let p = p.unwrap(); + assert_eq!((p.author.as_str(), p.units), ("Ann", deckcraft_geom::Unit::Centimeters)); let names: Vec = std::fs::read_dir(d.join("nested")).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect(); assert!(names.iter().all(|n| !n.ends_with(".tmp")), "{names:?}"); diff --git a/crates/ui-egui/src/units_tests.rs b/crates/ui-egui/src/units_tests.rs new file mode 100644 index 00000000..9d39d22f --- /dev/null +++ b/crates/ui-egui/src/units_tests.rs @@ -0,0 +1,171 @@ +//! Measurement units (#1326): with centimetres chosen in Preferences, the Format pane, the ribbon +//! and the Slide Size dialog show and take centimetres, and the deck keeps its sizes in points. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use egui::accesskit::Role; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; +use deckcraft_engine::Session; +use deckcraft_geom::Unit; + +fn harness(app: SlideApp) -> Harness<'static, SlideApp> { + Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ) +} + +/// A blank slide with one 2" x 1.5" rectangle at (1", 0.5"), selected. +fn app_with_shape(units: Unit) -> SlideApp { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [72.0, 36.0, 144.0, 108.0]})).unwrap()["id"].as_u64().unwrap(); + s.execute("edit.select", &json!({"ids": [id]})).unwrap(); + s.prefs.units = units; + SlideApp::new(s, Services::default()) +} + +fn text_values(h: &Harness<'_, SlideApp>) -> Vec { + h.query_all_by_role(Role::TextInput).filter_map(|n| n.value()).collect() +} + +fn selected_xfrm(app: &SlideApp) -> deckcraft_geom::Xfrm { + let st = app.session.active().unwrap(); + let sh = st.shape(*st.selection.shapes.first().unwrap()).unwrap(); + deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh) +} + +#[test] +fn the_format_pane_shows_and_takes_centimetres() { + let mut app = app_with_shape(Unit::Centimeters); + app.ui.pane = Some("format".into()); + app.ui.format_tab = "size".into(); + app.ui.ruler = true; + app.ui.tab = "Shape Format".into(); + let mut h = harness(app); + h.run_steps(3); + if let (Ok(path), Ok(img)) = (std::env::var("DECKCRAFT_PNG_OUT_UNITS"), h.render()) { + let _ = img.save(path); + } + h.state_mut().ui.tab = "Home".into(); + h.run_steps(3); + let shown = text_values(&h); + // 108 pt = 1.5" = 3.81 cm; 144 pt = 5.08 cm; 72 pt = 2.54 cm; 36 pt = 1.27 cm. + for v in ["3.81 cm", "5.08 cm", "2.54 cm", "1.27 cm"] { + assert!(shown.iter().any(|s| s == v), "{v} in {shown:?}"); + } + assert!(shown.iter().all(|s| !s.ends_with('"')), "no inches left: {shown:?}"); + // Typing 2.54 into Width makes the shape one inch wide, not 2.54 inches. + let width = h.query_all_by_role(Role::TextInput).find(|n| n.value().as_deref() == Some("5.08 cm")).unwrap(); + width.focus(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.query_all_by_role(Role::TextInput).find(|n| n.is_focused()).unwrap().type_text("2.54"); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let x = selected_xfrm(h.state()); + assert!((x.w - 72.0).abs() < 1e-6, "{x:?}"); + assert!((x.h - 108.0).abs() < 1e-6, "the height is untouched: {x:?}"); +} + +#[test] +fn the_format_pane_keeps_inches_by_default() { + let mut app = app_with_shape(Unit::default()); + app.ui.pane = Some("format".into()); + app.ui.format_tab = "size".into(); + let mut h = harness(app); + h.run_steps(3); + let shown = text_values(&h); + assert!(shown.iter().any(|s| s == "2\""), "{shown:?}"); + assert!(shown.iter().all(|s| !s.ends_with("cm")), "{shown:?}"); +} + +/// Shape Format › Size on the ribbon: the same unit as the pane (the pane stays closed here). +#[test] +fn the_ribbon_size_boxes_show_and_take_centimetres() { + let mut app = app_with_shape(Unit::Centimeters); + app.ui.tab = "Shape Format".into(); + let mut h = harness(app); + h.run_steps(3); + let shown = text_values(&h); + assert!(shown.iter().any(|s| s == "3.81 cm") && shown.iter().any(|s| s == "5.08 cm"), "{shown:?}"); + let height = h.query_all_by_role(Role::TextInput).find(|n| n.value().as_deref() == Some("3.81 cm")).unwrap(); + height.focus(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.query_all_by_role(Role::TextInput).find(|n| n.is_focused()).unwrap().type_text("1.27"); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let x = selected_xfrm(h.state()); + assert!((x.h - 36.0).abs() < 1e-6 && (x.w - 144.0).abs() < 1e-6, "{x:?}"); +} + +#[test] +fn the_slide_size_dialog_shows_and_takes_centimetres() { + let mut app = app_with_shape(Unit::Centimeters); + app.dialog = Some(crate::dialogs::Dialog::new("slideSize")); + let mut h = harness(app); + h.run_steps(3); + assert!(h.query_by_label("Width (cm):").is_some() && h.query_by_label("Height (cm):").is_some()); + assert!(h.query_by_label("Width (in):").is_none()); + // 960 x 540 pt = 33.87 x 19.05 cm. + let shown = text_values(&h); + assert!(shown.iter().any(|s| s == "33.87") && shown.iter().any(|s| s == "19.05"), "{shown:?}"); + // A 4:3 size typed in centimetres: 25.4 x 19.05 cm = 10" x 7.5" = 720 x 540 pt. + let d = h.state_mut().dialog.as_mut().unwrap(); + d.fields.insert("w".into(), "25.4".into()); + h.run_steps(1); + h.get_by_label("OK").click(); + h.run_steps(3); + let size = h.state().session.active().unwrap().doc.slide_size; + assert!((size.width - 720.0).abs() < 1e-6 && (size.height - 540.0).abs() < 1e-6, "{size:?}"); +} + +/// OK with the shown values untouched keeps the exact size: 13.333" is shown rounded to 33.87 cm, +/// which read back would make the slide 960.09 pt wide. +#[test] +fn ok_in_the_slide_size_dialog_without_edits_keeps_the_size() { + for units in [Unit::Centimeters, Unit::Inches] { + let mut app = app_with_shape(units); + app.dialog = Some(crate::dialogs::Dialog::new("slideSize")); + let mut h = harness(app); + h.run_steps(3); + h.get_by_label("OK").click(); + h.run_steps(3); + let size = h.state().session.active().unwrap().doc.slide_size; + assert_eq!((size.width, size.height), (960.0, 540.0), "{units:?}"); + } +} + +#[test] +fn preferences_choose_the_units_and_cancel_puts_them_back() { + let mut app = app_with_shape(Unit::Inches); + app.dialog = Some(crate::dialogs::Dialog::new("preferences")); + let mut h = harness(app); + h.run_steps(3); + h.get_by_label("Inches").click(); + h.run_steps(2); + h.get_by_label("Centimeters").click(); + h.run_steps(2); + assert_eq!(h.state().session.prefs.units, Unit::Centimeters); + h.query_all_by_role_and_label(Role::Button, "Cancel").next().unwrap().click_accesskit(); + h.run_steps(2); + assert_eq!(h.state().session.prefs.units, Unit::Inches); +} + +#[test] +fn the_ruler_counts_centimetres_in_halves_and_inches_in_quarters() { + assert_eq!(crate::canvas::ruler_steps(Unit::Inches), (72.0, 4)); + let (cm, sub) = crate::canvas::ruler_steps(Unit::Centimeters); + assert!((cm - 72.0 / 2.54).abs() < 1e-9 && sub == 2, "{cm} {sub}"); + // A hand-edited prefs.json can say points: the ruler stays in inches rather than a tick per point. + assert_eq!(crate::canvas::ruler_steps(Unit::Points), (72.0, 4)); +}