From 8ddd20d95dda14df233968eb0340f9513ccd514f Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 8 Oct 2026 10:10:41 -0400 Subject: [PATCH] UI: right-clicking a ruler picks that ruler's units, and the vertical ruler measures in the vertical units (#133) --- ROADMAP.md | 2 +- crates/ui-egui/src/canvas.rs | 116 +++++++++++++++++++++++++++++++++-- 2 files changed, 113 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index aa4c17396..56c9f5a65 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -161,7 +161,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML. - Document Setup (⌥⌘P): intent, number of pages, start page # (an even start begins with a left page), facing pages, size presets and orientation, bleed and slug per edge; `layout.documentSetup {}` reports the setup. - Stroke panel: cap, join, miter limit, alignment, type, Start/End arrowheads (12 kinds, drawn on screen and in PDF; the path is shortened under pointed heads) and gap colour under dashes and dots. -- Preferences (⌘K: General, Type, Units & Increments, Grids, Guides & Pasteboard, Display Performance) backed by `prefs.set` (application) and `document.preferences` (units, increments, grids — undoable, saved with the document); scaling applies to content and scales stroke weights (Include Stroke Weight), and X/Y/W/H measure the stroke's outer edge (Dimensions Include Stroke Weight). Quick Apply (⌘Return) finds paragraph, character and object styles as well as commands. +- Preferences (⌘K: General, Type, Units & Increments, Grids, Guides & Pasteboard, Display Performance) backed by `prefs.set` (application) and `document.preferences` (units, increments, grids — undoable, saved with the document; right-click a ruler to pick its units, the horizontal and vertical rulers each have their own); scaling applies to content and scales stroke weights (Include Stroke Weight), and X/Y/W/H measure the stroke's outer edge (Dimensions Include Stroke Weight). Quick Apply (⌘Return) finds paragraph, character and object styles as well as commands. - Parent item overrides: Cmd+Shift-click a parent item on a page (or Override All Parent Page Items, ⌥⇧⌘L) makes an editable local copy that hides the parent's; Remove All Local Overrides and Detach All Objects from Parent in the Pages panel menu. - Place PDF: placed PDF pages are sized by their crop box, drawn on screen through hayro (with mip levels) and embedded in exported PDFs as vector form XObjects (krilla), `` in IDML. - Menus expose the engine's features (TOC, numbering & sections, text variables, hyperlinks, cross-references, footnotes, step and repeat, spelling, fitting/content, effects, Data Merge, Preflight, EPUB); any menu command with parameters gets a dialog generated from its parameter documentation. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8288dd35c..b4b17d3ab 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -318,6 +318,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { draw_tool_overlays(app, &painter, &xf); if rulers { draw_rulers(app, ui, full, rect, &xf, &doc, &layout, &t); + ruler_units_menus(app, &resp, full, rect, &doc); } if let Some(r) = sel_rect && !preview @@ -1247,8 +1248,7 @@ fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, xf: &Xf) { #[allow(clippy::too_many_arguments)] fn draw_rulers(app: &DesignApp, ui: &egui::Ui, full: Rect, rect: Rect, xf: &Xf, doc: &Document, layout: &CanvasLayout, t: &Tokens) { let painter = ui.painter_at(full); - let top = Rect::from_min_max(pos2(rect.min.x, full.min.y), pos2(full.max.x, rect.min.y)); - let left = Rect::from_min_max(pos2(full.min.x, rect.min.y), pos2(rect.min.x, full.max.y)); + let (top, left) = ruler_rects(full, rect); painter.rect_filled(top, 0.0, t.ruler); painter.rect_filled(left, 0.0, t.ruler); painter.rect_filled(Rect::from_min_max(full.min, rect.min), 0.0, t.ruler); @@ -1262,11 +1262,11 @@ fn draw_rulers(app: &DesignApp, ui: &egui::Ui, full: Rect, rect: Rect, xf: &Xf, let Some(i) = current_slot(app, layout) else { return }; let slot = &layout.slots[i]; let origin = Point::new(slot.bounds.x0, slot.bounds.y0); - let unit = doc.settings.horizontal_units; - let (major, sub) = unit.ruler_ticks(xf.zoom); let font = egui::FontId::proportional(9.0); let tick = Stroke::new(1.0, t.ruler_tick); // Horizontal. + let unit = doc.settings.horizontal_units; + let (major, sub) = unit.ruler_ticks(xf.zoom); let c0 = xf.to_canvas(top.min).x - origin.x; let c1 = xf.to_canvas(pos2(top.max.x, 0.0)).x - origin.x; let mut k = (c0 / major).floor() as i64; @@ -1291,6 +1291,8 @@ fn draw_rulers(app: &DesignApp, ui: &egui::Ui, full: Rect, rect: Rect, xf: &Xf, k += 1; } // Vertical. + let unit = doc.settings.vertical_units; + let (major, sub) = unit.ruler_ticks(xf.zoom); let c0 = xf.to_canvas(pos2(0.0, left.min.y)).y - origin.y; let c1 = xf.to_canvas(pos2(0.0, left.max.y)).y - origin.y; let mut k = (c0 / major).floor() as i64; @@ -1325,6 +1327,43 @@ fn draw_rulers(app: &DesignApp, ui: &egui::Ui, full: Rect, rect: Rect, xf: &Xf, } } +/// The horizontal (top) and vertical (left) rulers around the view `rect` in `full`. +fn ruler_rects(full: Rect, rect: Rect) -> (Rect, Rect) { + ( + Rect::from_min_max(pos2(rect.min.x, full.min.y), pos2(full.max.x, rect.min.y)), + Rect::from_min_max(pos2(full.min.x, rect.min.y), pos2(rect.min.x, full.max.y)), + ) +} + +/// Right-clicking a ruler opens a menu of units for that ruler. +fn ruler_units_menus(app: &mut DesignApp, resp: &egui::Response, full: Rect, rect: Rect, doc: &Document) { + let (top, left) = ruler_rects(full, rect); + let at = resp.interact_pointer_pos(); + for (ruler, key, cur) in [(top, "horizontalUnits", doc.settings.horizontal_units), (left, "verticalUnits", doc.settings.vertical_units)] { + let open = if resp.secondary_clicked() { + Some(egui::SetOpenCommand::Bool(at.is_some_and(|p| ruler.contains(p)))) + } else if resp.clicked() { + Some(egui::SetOpenCommand::Bool(false)) + } else { + None + }; + egui::Popup::context_menu(resp).id(resp.id.with(("ruler_units", key))).open_memory(open).show(|ui| ruler_units_menu(app, ui, key, cur)); + } +} + +/// Every unit, the current one checked; picking one sets `key` (`horizontalUnits` or +/// `verticalUnits`) through `document.preferences`. +fn ruler_units_menu(app: &mut DesignApp, ui: &mut egui::Ui, key: &str, cur: designcraft_geom::Unit) { + for u in designcraft_geom::Unit::ALL { + let label = crate::i18n::tr(&app.ui.language, u.label()); + let text = if u == cur { format!("✓ {label}") } else { format!(" {label}") }; + if ui.button(crate::rtl::widget(ui, text)).clicked() { + let _ = app.run("document.preferences", json!({ key: u })); + ui.close(); + } + } +} + fn fmt_tick(v: f64) -> String { let r = v.round(); if (v - r).abs() < 1e-6 { format!("{}", r as i64) } else { format!("{v:.1}") } @@ -1726,3 +1765,72 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec } true } + +#[cfg(test)] +mod tests { + use designcraft_geom::Unit; + use egui_kittest::{Harness, kittest::Queryable}; + + use super::*; + + fn harness(app: DesignApp) -> Harness<'static, DesignApp> { + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h + } + + fn right_click(h: &mut Harness<'_, DesignApp>, p: Pos2) { + h.hover_at(p); + h.step(); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }); + } + h.run_steps(3); + } + + fn units(h: &Harness<'_, DesignApp>) -> (Unit, Unit) { + let s = &h.state().session.doc().unwrap().doc.settings; + (s.horizontal_units, s.vertical_units) + } + + #[test] + fn right_clicking_a_ruler_sets_that_rulers_units() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let mut h = harness(app); + let rect = h.state().canvas_rect.unwrap(); + assert_eq!(units(&h), (Unit::Picas, Unit::Picas)); + // Vertical ruler: the current unit is checked; picking one sets only the vertical units. + right_click(&mut h, pos2(rect.min.x - RULER / 2.0, rect.center().y)); + h.get_by_label("✓ Picas"); + h.get_by_label(" Millimeters").click(); + h.run_steps(3); + assert_eq!(units(&h), (Unit::Picas, Unit::Millimeters)); + // Horizontal ruler. + right_click(&mut h, pos2(rect.center().x, rect.min.y - RULER / 2.0)); + h.get_by_label("✓ Picas"); + h.get_by_label(" Inches").click(); + h.run_steps(3); + assert_eq!(units(&h), (Unit::Inches, Unit::Millimeters)); + // The page and the ruler corner open no units menu. + for p in [rect.center(), rect.min - vec2(RULER / 2.0, RULER / 2.0)] { + right_click(&mut h, p); + assert!(h.query_by_label(" Points").is_none()); + } + } + + #[test] + fn rulers_open_no_units_menu_without_a_document() { + let mut h = harness(DesignApp::new(designcraft_engine::Session::new(), crate::Services::default())); + let Some(rect) = h.state().canvas_rect else { return }; + right_click(&mut h, pos2(rect.center().x, rect.min.y - RULER / 2.0)); + assert!(h.query_by_label(" Points").is_none()); + } +}