Units: Preferences chooses inches or centimetres for the Format pane, the ribbon's size boxes, the Slide Size dialog and the ruler (#1326)

This commit is contained in:
Marco Arnulfo
2026-10-24 23:02:45 +02:00
parent 38d2ade2b7
commit d384bbc496
9 changed files with 259 additions and 41 deletions
+2
View File
@@ -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 |
+4
View File
@@ -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,
}
}
}
+30 -22
View File
@@ -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;
}
+30 -6
View File
@@ -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::<f64>().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");
+2
View File
@@ -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"]
+9 -7
View File
@@ -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));
+8 -4
View File
@@ -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}));
}
});
});
+3 -2
View File
@@ -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<String> =
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:?}");
+171
View File
@@ -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<String> {
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));
}