From 339f8ed35b0eab58068ffaa6be31c37ef9a54a83 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 20:57:02 +0200 Subject: [PATCH 1/4] Page Layout view: drag the margins on the rulers (#792) Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 2 + crates/ui-egui/src/grid.rs | 3 + crates/ui-egui/src/i18n.rs | 4 + crates/ui-egui/src/i18n/cs.rs | 4 + crates/ui-egui/src/i18n/de.rs | 4 + crates/ui-egui/src/i18n/es.rs | 4 + crates/ui-egui/src/i18n/fr.rs | 4 + crates/ui-egui/src/i18n/ko.rs | 4 + crates/ui-egui/src/i18n/pt.rs | 4 + crates/ui-egui/src/i18n/ru.rs | 4 + crates/ui-egui/src/i18n/zh.rs | 4 + crates/ui-egui/src/paper_view.rs | 221 +++++++++++++++++++++++ crates/ui-egui/tests/page_layout_view.rs | 54 ++++++ docs/gaps.md | 4 +- docs/ui-parity.md | 3 +- 15 files changed, 320 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06b3..6907953f7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | Page Layout view: drag the margin edges on the rulers to set the page margins (resize cursor, live preview, value tooltip, one undo step, Esc cancels) (#792) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | @@ -178,6 +179,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Progress log: margins dragged on the Page Layout rulers (#792) | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row | | 2026-10-11 | minor | Forecast Sheet (#322); progress log row | diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index d0eef8566..04c924d8a 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -100,6 +100,8 @@ pub struct GridState { pub paper: Option, /// Header or footer being edited in place (Page Layout view). pub hf_edit: Option, + /// A page margin being dragged on a ruler (Page Layout view); committed on release. + pub ruler_drag: Option, /// Insert › Header & Footer: start editing the first page's header once Page Layout view is up. pub hf_start: bool, /// The grid geometry of the last frame (hit-testing for agents and tests). @@ -493,6 +495,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { if paper { let probe = Geo::new(sh, grid_rect, scroll); if let Some(g) = crate::paper_view::grid_for(app, &wb, si, sh, probe.cells.size(), scroll) { + let g = crate::paper_view::with_dragged_margin(app, si, g); return Geo::paged(sh, grid_rect, scroll, g); } } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 406cc3dd5..27230b139 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -290,6 +290,10 @@ const JAPANESE: &[(&str, &str)] = &[ ("Select\nObjects", "オブジェクトの選択"), ("Themes", "テーマ"), ("Margins", "余白"), + ("Left Margin", "左余白"), + ("Right Margin", "右余白"), + ("Top Margin", "上余白"), + ("Bottom Margin", "下余白"), ("Size", "サイズ"), ("Print\nArea", "印刷範囲"), ("Set Print Area", "印刷範囲の設定"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index ca2e55942..96a5a1114 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -116,6 +116,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "Vybrat\nobjekty", "Themes" => "Motivy", "Margins" => "Okraje", + "Left Margin" => "Levý okraj", + "Right Margin" => "Pravý okraj", + "Top Margin" => "Horní okraj", + "Bottom Margin" => "Dolní okraj", "Size" => "Velikost", "Print\nArea" => "Oblast\ntisku", "Set Print Area" => "Nastavit oblast tisku", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 2ad37f675..30bff11c8 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -116,6 +116,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "Objekte\nauswählen", "Themes" => "Designs", "Margins" => "Seitenränder", + "Left Margin" => "Linker Seitenrand", + "Right Margin" => "Rechter Seitenrand", + "Top Margin" => "Oberer Seitenrand", + "Bottom Margin" => "Unterer Seitenrand", "Size" => "Größe", "Print\nArea" => "Druck-\nbereich", "Set Print Area" => "Druckbereich festlegen", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index da41bd711..b97cce843 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -117,6 +117,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "Seleccionar\nobjetos", "Themes" => "Temas", "Margins" => "Márgenes", + "Left Margin" => "Margen izquierdo", + "Right Margin" => "Margen derecho", + "Top Margin" => "Margen superior", + "Bottom Margin" => "Margen inferior", "Size" => "Tamaño", "Print\nArea" => "Área de\nimpresión", "Set Print Area" => "Establecer área de impresión", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 7bf9fd100..1ecddfa80 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -124,6 +124,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "Sélection d’objets", "Themes" => "Thèmes", "Margins" => "Marges", + "Left Margin" => "Marge gauche", + "Right Margin" => "Marge droite", + "Top Margin" => "Marge haute", + "Bottom Margin" => "Marge basse", "Size" => "Taille", "Print\nArea" => "Zone d’impression", "Set Print Area" => "Définir la zone d’impression", diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index b684c7886..164273988 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -121,6 +121,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "개체\n선택", "Themes" => "테마", "Margins" => "여백", + "Left Margin" => "왼쪽 여백", + "Right Margin" => "오른쪽 여백", + "Top Margin" => "위쪽 여백", + "Bottom Margin" => "아래쪽 여백", "Size" => "용지 크기", "Print\nArea" => "인쇄\n영역", "Set Print Area" => "인쇄 영역 설정", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index b554ef977..9d6a12890 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -122,6 +122,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "Selecionar\nObjetos", "Themes" => "Temas", "Margins" => "Margens", + "Left Margin" => "Margem esquerda", + "Right Margin" => "Margem direita", + "Top Margin" => "Margem superior", + "Bottom Margin" => "Margem inferior", "Size" => "Tamanho", "Print\nArea" => "Área de\nImpressão", "Set Print Area" => "Definir Área de Impressão", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index c8f0445d8..221b8709e 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -121,6 +121,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "Выбрать\nобъекты", "Themes" => "Темы", "Margins" => "Поля", + "Left Margin" => "Левое поле", + "Right Margin" => "Правое поле", + "Top Margin" => "Верхнее поле", + "Bottom Margin" => "Нижнее поле", "Size" => "Размер", "Print\nArea" => "Область\nпечати", "Set Print Area" => "Задать область печати", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index d1cf704cb..477ab82aa 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -121,6 +121,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "选择\n对象", "Themes" => "主题", "Margins" => "页边距", + "Left Margin" => "左边距", + "Right Margin" => "右边距", + "Top Margin" => "上边距", + "Bottom Margin" => "下边距", "Size" => "纸张大小", "Print\nArea" => "打印\n区域", "Set Print Area" => "设置打印区域", diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs index 110193a64..69784de3b 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -284,6 +284,7 @@ pub(crate) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo commit(app); } let Some(pg) = geo.paper.clone() else { return }; + ruler_margins(app, ui, geo, &pg, sh, wb.active_sheet); if std::mem::take(&mut app.grid.hf_start) { begin(app, wb.active_sheet, sh, (pg.data_rows.0, pg.data_cols.0), HfZone { footer: false, section: 1 }); } @@ -594,6 +595,197 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, ); } +/// A page edge that can be dragged on a ruler. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Edge { + Left, + Right, + Top, + Bottom, +} + +impl Edge { + /// Index into `PrintSettings::margins` / `PageGrid::margins`. + fn index(self) -> usize { + match self { + Edge::Left => 0, + Edge::Right => 1, + Edge::Top => 2, + Edge::Bottom => 3, + } + } + /// The `pageLayout.pageSetup` margin key. + fn key(self) -> &'static str { + ["left", "right", "top", "bottom"][self.index()] + } + fn label(self) -> &'static str { + ["Left Margin", "Right Margin", "Top Margin", "Bottom Margin"][self.index()] + } + fn horizontal(self) -> bool { + matches!(self, Edge::Left | Edge::Right) + } +} + +/// A margin being dragged on a ruler: the page it started on and the previewed value (inches). +#[derive(Clone, Debug)] +pub struct RulerDrag { + pub sheet: usize, + pub edge: Edge, + /// Page column (horizontal ruler) or row (vertical ruler) the drag started on. + pub page: usize, + pub start_inches: f32, + pub inches: f32, +} + +/// Content that must stay between the margins, in points. +const MIN_CONTENT_PT: f64 = 36.0; +/// Screen distance within which a ruler margin edge is grabbed. +const EDGE_GRAB: f32 = 4.0; + +/// Margin (inches, rounded to 0.01) for an edge dragged to `rel_pt` points from the page's +/// left/top edge. The margin stays on the page and leaves [`MIN_CONTENT_PT`] between the two +/// margins of its axis. `None` for NaN/infinite input or a page without a size. +pub fn margin_from_drag(edge: Edge, rel_pt: f64, page_pt: (f32, f32), margins_in: [f32; 4]) -> Option { + let page = f64::from(if edge.horizontal() { page_pt.0 } else { page_pt.1 }); + if !rel_pt.is_finite() || !page.is_finite() || page <= 0.0 { + return None; + } + let other = f64::from(margins_in.get(edge.index() ^ 1).copied().filter(|v| v.is_finite())?).max(0.0) * 72.0; + let raw = match edge { + Edge::Left | Edge::Top => rel_pt, + Edge::Right | Edge::Bottom => page - rel_pt, + }; + let max = (page - other - MIN_CONTENT_PT).max(0.0); + let pt = raw.clamp(0.0, max); + Some(((pt / 72.0 * 100.0).round() / 100.0) as f32) +} + +/// The grid with a margin being dragged applied, so the page content follows the pointer. +pub(crate) fn with_dragged_margin(app: &SheetApp, si: usize, pg: Arc) -> Arc { + let Some(d) = app.grid.ruler_drag.as_ref().filter(|d| d.sheet == si) else { return pg }; + // Layout units per point (the print scale and pixel/point ratio). + let per_pt = pg.page_w / f64::from(pg.paper_pt.0); + let mut g = (*pg).clone(); + let v = f64::from(d.inches) * 72.0 * per_pt; + if let Some(m) = g.margins.get_mut(d.edge.index()).filter(|_| v.is_finite() && per_pt.is_finite()) { + *m = v; + } + Arc::new(g) +} + +/// Points per layout unit of the grid (paper size over the layout page size). +fn pt_per_unit(pg: &PageGrid) -> Option { + let k = f64::from(pg.paper_pt.0) / pg.page_w; + (k.is_finite() && k > 0.0).then_some(k) +} + +/// The margin edge of a visible page under `pos` on the horizontal (`h`) or vertical ruler. +fn edge_at(geo: &Geo, pg: &PageGrid, pos: Pos2, h: bool) -> Option<(Edge, usize)> { + let ((i0, i1), (j0, j1)) = visible_pages(geo, pg); + let [ml, mr, mt, mb, ..] = pg.margins; + let mut best: Option<(f32, Edge, usize)> = None; + let mut consider = |d: f32, e: Edge, page: usize| { + if d <= EDGE_GRAB && best.is_none_or(|b| d < b.0) { + best = Some((d, e, page)); + } + }; + if h { + for j in j0..=j1.min(j0.saturating_add(200)) { + let (x, _, w, _) = pg.page_rect(0, j); + consider((screen(geo, x + ml, 0.0).x - pos.x).abs(), Edge::Left, j); + consider((screen(geo, x + w - mr, 0.0).x - pos.x).abs(), Edge::Right, j); + } + } else { + for i in i0..=i1.min(i0.saturating_add(200)) { + let (_, y, _, hh) = pg.page_rect(i, 0); + consider((screen(geo, 0.0, y + mt).y - pos.y).abs(), Edge::Top, i); + consider((screen(geo, 0.0, y + hh - mb).y - pos.y).abs(), Edge::Bottom, i); + } + } + best.map(|(_, e, p)| (e, p)) +} + +/// Dragging the margin edges on the rulers sets the page margins: a resize cursor and a tooltip +/// on hover, a live preview while dragging, one `pageLayout.pageSetup` (one undo step) on +/// release, Esc cancels. +fn ruler_margins(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid, sh: &Sheet, si: usize) { + let Some(outer) = app.grid.rect else { return }; + if geo.rect.top() - outer.top() < RULER - 0.5 { + app.grid.ruler_drag = None; + return; + } + // Leaving the sheet or the view drops the drag. + if app.grid.ruler_drag.as_ref().is_some_and(|d| d.sheet != si) { + app.grid.ruler_drag = None; + } + let hr = Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER)); + let vr = Rect::from_min_max(pos2(outer.left(), geo.cells.top()), pos2(outer.left() + RULER, geo.cells.bottom())); + let hresp = ui.interact(hr, egui::Id::new("ruler_margin_h"), egui::Sense::drag()); + let vresp = ui.interact(vr, egui::Id::new("ruler_margin_v"), egui::Sense::drag()); + let margins_in = |sh: &Sheet| -> [f32; 4] { + let m = |i: usize| sh.print.margins.get(i).copied().unwrap_or(0.5); + [m(0), m(1), m(2), m(3)] + }; + let current = margins_in(sh); + let metric = metric(app); + let tip = |ui: &egui::Ui, app: &SheetApp, edge: Edge, inches: f32| { + let value = if metric { format!("{:.2} cm", inches * 2.54) } else { format!("{inches:.2} in") }; + let text = format!("{}: {value}", app.ui.language.tr(edge.label())); + egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("ruler_margin_tip"), egui::PopupAnchor::Pointer).show(|ui| { + ui.label(text); + }); + }; + let cursor = |ui: &egui::Ui, edge: Edge| { + ui.ctx().set_cursor_icon(if edge.horizontal() { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }); + }; + if let Some(d) = app.grid.ruler_drag.clone() { + if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)) { + app.grid.ruler_drag = None; + return; + } + cursor(ui, d.edge); + let down = ui.input(|i| i.pointer.primary_down()); + let pos = ui.input(|i| i.pointer.latest_pos()); + let mut cur = d.clone(); + if let (Some(pos), Some(k)) = (pos, pt_per_unit(pg)) { + let rel = if d.edge.horizontal() { + let page_x = pg.page_x(d.page); + f64::from(geo.scroll.x) + f64::from((pos.x - geo.cells.left()) / geo.z) - page_x + } else { + let page_y = pg.page_y(d.page); + f64::from(geo.scroll.y) + f64::from((pos.y - geo.cells.top()) / geo.z) - page_y + }; + if let Some(v) = margin_from_drag(d.edge, rel * k, pg.paper_pt, current) { + cur.inches = v; + } + } + if down { + tip(ui, app, cur.edge, cur.inches); + app.grid.ruler_drag = Some(cur); + ui.ctx().request_repaint(); + } else { + app.grid.ruler_drag = None; + if (cur.inches - cur.start_inches).abs() > f32::EPSILON { + app.run_or_alert("pageLayout.pageSetup", json!({"sheet": si, "margins": {cur.edge.key(): cur.inches}})); + } + } + return; + } + for (resp, h) in [(&hresp, true), (&vresp, false)] { + let Some(pos) = resp.hover_pos().or_else(|| resp.interact_pointer_pos()) else { continue }; + let origin = if resp.drag_started() { ui.input(|i| i.pointer.press_origin()).unwrap_or(pos) } else { pos }; + let Some((edge, page)) = edge_at(geo, pg, origin, h) else { continue }; + let value = current.get(edge.index()).copied().unwrap_or(0.5); + if resp.drag_started() { + app.grid.ruler_drag = Some(RulerDrag { sheet: si, edge, page, start_inches: value, inches: value }); + cursor(ui, edge); + } else if resp.hovered() { + cursor(ui, edge); + tip(ui, app, edge, value); + } + } +} + /// The Header & Footer contextual ribbon tab. pub fn ribbon_tab(app: &mut SheetApp, ui: &mut egui::Ui) { use crate::widgets::big_button; @@ -667,3 +859,32 @@ fn sep(ui: &mut egui::Ui) { let (r, _) = ui.allocate_exact_size(vec2(13.0, 72.0), egui::Sense::hover()); ui.painter().line_segment([pos2(r.center().x, r.top() + 6.0), pos2(r.center().x, r.bottom() - 6.0)], Stroke::new(1.0, t.separator)); } + +#[cfg(test)] +mod tests { + use super::*; + + const A4: (f32, f32) = (595.0, 842.0); + + #[test] + fn drag_maps_to_margins_and_clamps_inside_the_page() { + let m = [0.7, 0.7, 0.75, 0.75]; + // 72 pt from the left edge is one inch; from the right edge the margin is measured inward. + assert_eq!(margin_from_drag(Edge::Left, 72.0, A4, m), Some(1.0)); + assert_eq!(margin_from_drag(Edge::Right, f64::from(A4.0) - 36.0, A4, m), Some(0.5)); + assert_eq!(margin_from_drag(Edge::Top, 108.0, A4, m), Some(1.5)); + // Dragged off the page: never negative. + assert_eq!(margin_from_drag(Edge::Left, -50.0, A4, m), Some(0.0)); + assert_eq!(margin_from_drag(Edge::Bottom, f64::from(A4.1) + 99.0, A4, m), Some(0.0)); + // Dragged across the opposite margin: stops a minimum content width short of it. + let max = margin_from_drag(Edge::Left, 10_000.0, A4, m).unwrap(); + assert!((f64::from(max) * 72.0 - (f64::from(A4.0) - 0.7 * 72.0 - MIN_CONTENT_PT)).abs() < 1.0, "{max}"); + // Hostile input is ignored, not clamped to something arbitrary. + assert_eq!(margin_from_drag(Edge::Left, f64::NAN, A4, m), None); + assert_eq!(margin_from_drag(Edge::Left, f64::INFINITY, A4, m), None); + assert_eq!(margin_from_drag(Edge::Left, 10.0, (0.0, 0.0), m), None); + assert_eq!(margin_from_drag(Edge::Left, 10.0, A4, [0.7, f32::NAN, 0.7, 0.7]), None); + // Oversized opposite margin leaves nothing to drag into. + assert_eq!(margin_from_drag(Edge::Left, 300.0, A4, [0.0, 40.0, 0.0, 0.0]), Some(0.0)); + } +} diff --git a/crates/ui-egui/tests/page_layout_view.rs b/crates/ui-egui/tests/page_layout_view.rs index f8850b3e2..632b9e222 100644 --- a/crates/ui-egui/tests/page_layout_view.rs +++ b/crates/ui-egui/tests/page_layout_view.rs @@ -162,3 +162,57 @@ fn insert_header_and_footer_switches_to_page_layout_view() { let e = h.state().grid.hf_edit.as_ref().expect("the header is being edited"); assert_eq!((e.footer, e.section, e.texts[0].as_str()), (false, 1, "Left")); } + +fn margins(h: &Harness) -> [f32; 6] { + h.state().session.doc().unwrap().wb.active().unwrap().print.margins +} + +fn mouse(h: &mut Harness, ev: Event) { + h.input_mut().events.push(ev); + h.step(); +} + +#[test] +fn dragging_a_ruler_edge_previews_commits_on_release_as_one_undo_step_and_esc_cancels() { + let mut h = fixture(); + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let start_margins = margins(&h); + let ruler_y = h.state().grid.rect.unwrap().top() + 9.0; + let edge = pos2(screen(&g, pg.page_x(0) + pg.margins[0], 0.0).x, ruler_y); + // One inch further in = 96 layout pixels at 100% print scale; use the screen scale to be exact. + let per_inch = (72.0 / (pg.paper_pt.0 as f64 / pg.page_w)) as f32 * g.z; + let target = pos2(edge.x + per_inch, ruler_y); + h.run_steps(40); + mouse(&mut h, Event::PointerMoved(edge)); + mouse(&mut h, Event::PointerButton { pos: edge, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + mouse(&mut h, Event::PointerMoved(pos2(edge.x + 5.0, ruler_y))); + mouse(&mut h, Event::PointerMoved(target)); + h.run_steps(2); + // Live preview: the page content moved, the document did not change yet. + let live = geo(&h).paper.unwrap().margins[0]; + assert!(live > pg.margins[0] + 1.0, "{live} vs {}", pg.margins[0]); + assert_eq!(margins(&h), start_margins); + let want = (start_margins[0] + 1.0 * 1.0).min(10.0); + mouse(&mut h, Event::PointerButton { pos: target, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + let after = margins(&h); + assert!((after[0] - want).abs() < 0.03, "{after:?} want left {want}"); + assert_eq!(after[1..], start_margins[1..]); + // One undo step restores it. + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(margins(&h), start_margins); + // Esc during a drag cancels: nothing is committed. + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let edge = pos2(screen(&g, pg.page_x(0) + pg.margins[0], 0.0).x, ruler_y); + h.run_steps(40); + mouse(&mut h, Event::PointerMoved(edge)); + mouse(&mut h, Event::PointerButton { pos: edge, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + mouse(&mut h, Event::PointerMoved(pos2(edge.x + per_inch, ruler_y))); + mouse(&mut h, Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + mouse(&mut h, Event::PointerButton { pos: edge, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + assert_eq!(margins(&h), start_margins); +} diff --git a/docs/gaps.md b/docs/gaps.md index 17a56b899..c595c1c8e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -110,8 +110,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- ### 14. Page Layout view and print polish (U, F) - Page Layout view landed (#329): paper view with rulers and in-place header/footer editing. - Remaining: pictures in headers/footers, Different First Page / Odd & Even pages, dragging - margins on the ruler. Desktop now submits directly through craft-print with printer choice, copies, duplex and grayscale; browser printing stays available. Print ranges and driver tray/options UI remain. **Estimate:** 5–10 h. + Margins are dragged on the rulers (#792). Remaining: pictures in headers/footers, Different First Page / Odd & Even pages. Desktop now submits directly through craft-print with printer choice, copies, duplex and grayscale; browser printing stays available. Print ranges and driver tray/options UI remain. **Estimate:** 5–10 h. ### 15. Remaining functions and an oracle suite (F) - 18 missing, 9 stubs; no Excel-computed oracle corpus; #137, #138. **Estimate:** 16–26 h. @@ -160,6 +159,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Page Layout view: dragging margins on the rulers (#792) removed from the §14 remainder | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | §6: iterative calculation for circular references landed (Maximum Iterations, Maximum Change) | | 2026-10-11 | minor | §15: trim-reference operators (`A1:.E10`, `A1.:E10`, `A1.:.E10`) parse and evaluate | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 10abcbccc..52dd3a344 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -28,7 +28,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Dark mode, system appearance | yes | yes | done (10-09); follows the system on first run, title-bar switch and File › Display theme (#194, #239) | | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return) with Home, New (blank + the three samples), Open (recent + Browse), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has | | Dark mode, system appearance | yes | yes | done (10-09) | -| Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab | header/footer pictures, Different First Page / Odd & Even, ruler margin dragging missing (1–3 h) | +| Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab | margins drag on the rulers (#792); header/footer pictures, Different First Page / Odd & Even missing (1–3 h) | | Multiple windows per workbook | yes | no (#162) | see [hardware-parity.md](hardware-parity.md) | ## Grid interaction @@ -160,6 +160,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Page Layout view: left/right/top/bottom margins are dragged on the rulers (#792) | | 2026-10-11 | minor | AutoFit accounts for table and ordinary filter dropdowns, including low zoom; header text excludes the button area | | 2026-10-11 | minor | Outline band for grouped rows and columns, Show / Hide Detail on the Data tab, numpad navigation keys in shortcuts (#373) | | 2026-10-11 | minor | Shift+Space / Ctrl+Space no longer start an edit; Control+Space selects the column on Mac (#338) | From c4ad8e3a9bfea58156bb92df10b68c12a653dfa9 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 21:01:09 +0200 Subject: [PATCH 2/4] Page Layout view: header/footer margins on the ruler, live re-pagination, right-to-left rulers (#792) Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/grid.rs | 2 +- crates/ui-egui/src/i18n.rs | 2 + crates/ui-egui/src/i18n/cs.rs | 2 + crates/ui-egui/src/i18n/de.rs | 2 + crates/ui-egui/src/i18n/es.rs | 2 + crates/ui-egui/src/i18n/fr.rs | 2 + crates/ui-egui/src/i18n/ko.rs | 2 + crates/ui-egui/src/i18n/pt.rs | 2 + crates/ui-egui/src/i18n/ru.rs | 2 + crates/ui-egui/src/i18n/zh.rs | 2 + crates/ui-egui/src/paper_view.rs | 90 +++++++++++++------ crates/ui-egui/tests/page_layout_view.rs | 108 +++++++++++++++++++++++ docs/ui-parity.md | 2 +- 13 files changed, 191 insertions(+), 29 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 04c924d8a..80af151ab 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -495,7 +495,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { if paper { let probe = Geo::new(sh, grid_rect, scroll); if let Some(g) = crate::paper_view::grid_for(app, &wb, si, sh, probe.cells.size(), scroll) { - let g = crate::paper_view::with_dragged_margin(app, si, g); + let g = crate::paper_view::with_dragged_margin(app, &wb, si, g); return Geo::paged(sh, grid_rect, scroll, g); } } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 27230b139..e2566a6d8 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -294,6 +294,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("Right Margin", "右余白"), ("Top Margin", "上余白"), ("Bottom Margin", "下余白"), + ("Header Margin", "ヘッダーの余白"), + ("Footer Margin", "フッターの余白"), ("Size", "サイズ"), ("Print\nArea", "印刷範囲"), ("Set Print Area", "印刷範囲の設定"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 96a5a1114..bdcedbf4e 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -120,6 +120,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Right Margin" => "Pravý okraj", "Top Margin" => "Horní okraj", "Bottom Margin" => "Dolní okraj", + "Header Margin" => "Okraj záhlaví", + "Footer Margin" => "Okraj zápatí", "Size" => "Velikost", "Print\nArea" => "Oblast\ntisku", "Set Print Area" => "Nastavit oblast tisku", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 30bff11c8..75653e583 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -120,6 +120,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Right Margin" => "Rechter Seitenrand", "Top Margin" => "Oberer Seitenrand", "Bottom Margin" => "Unterer Seitenrand", + "Header Margin" => "Kopfzeilenabstand", + "Footer Margin" => "Fußzeilenabstand", "Size" => "Größe", "Print\nArea" => "Druck-\nbereich", "Set Print Area" => "Druckbereich festlegen", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index b97cce843..0582110c7 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -121,6 +121,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Right Margin" => "Margen derecho", "Top Margin" => "Margen superior", "Bottom Margin" => "Margen inferior", + "Header Margin" => "Margen del encabezado", + "Footer Margin" => "Margen del pie de página", "Size" => "Tamaño", "Print\nArea" => "Área de\nimpresión", "Set Print Area" => "Establecer área de impresión", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 1ecddfa80..0db9df4c5 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -128,6 +128,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Right Margin" => "Marge droite", "Top Margin" => "Marge haute", "Bottom Margin" => "Marge basse", + "Header Margin" => "Marge de l'en-tête", + "Footer Margin" => "Marge du pied de page", "Size" => "Taille", "Print\nArea" => "Zone d’impression", "Set Print Area" => "Définir la zone d’impression", diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index 164273988..f3e1688d0 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -125,6 +125,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Right Margin" => "오른쪽 여백", "Top Margin" => "위쪽 여백", "Bottom Margin" => "아래쪽 여백", + "Header Margin" => "머리글 여백", + "Footer Margin" => "바닥글 여백", "Size" => "용지 크기", "Print\nArea" => "인쇄\n영역", "Set Print Area" => "인쇄 영역 설정", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 9d6a12890..d6185a515 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -126,6 +126,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Right Margin" => "Margem direita", "Top Margin" => "Margem superior", "Bottom Margin" => "Margem inferior", + "Header Margin" => "Margem do cabeçalho", + "Footer Margin" => "Margem do rodapé", "Size" => "Tamanho", "Print\nArea" => "Área de\nImpressão", "Set Print Area" => "Definir Área de Impressão", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 221b8709e..868f653b8 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -125,6 +125,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Right Margin" => "Правое поле", "Top Margin" => "Верхнее поле", "Bottom Margin" => "Нижнее поле", + "Header Margin" => "Поле верхнего колонтитула", + "Footer Margin" => "Поле нижнего колонтитула", "Size" => "Размер", "Print\nArea" => "Область\nпечати", "Set Print Area" => "Задать область печати", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 477ab82aa..a9989f2e4 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -125,6 +125,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Right Margin" => "右边距", "Top Margin" => "上边距", "Bottom Margin" => "下边距", + "Header Margin" => "页眉边距", + "Footer Margin" => "页脚边距", "Size" => "纸张大小", "Print\nArea" => "打印\n区域", "Set Print Area" => "设置打印区域", diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs index 69784de3b..f560f133c 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -524,17 +524,21 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, let minor = if metric { 2.0 } else { 4.0 }; let label_every = ((22.0 / unit).ceil() as usize).max(1); let font = theme::ui_font(9.5); - let hr = Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER)); + let hr = geo.mirror_rect(Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER))); let vr = Rect::from_min_max(pos2(outer.left(), geo.cells.top()), pos2(outer.left() + RULER, geo.cells.bottom())); + // Right-to-left sheets: the horizontal ruler is mirrored with the pages (ticks run from the + // right margin leftwards). + let mx = |x: f32| geo.mirror_x(x); p.rect_filled(Rect::from_min_max(outer.min, pos2(outer.right(), outer.top() + RULER)), 0.0, t.window); p.rect_filled(Rect::from_min_max(outer.min, pos2(outer.left() + RULER, outer.bottom())), 0.0, t.window); let ((i0, i1), (j0, j1)) = visible_pages(geo, &pg); - let [ml, mr, mt, mb, ..] = pg.margins; + let [ml, mr, mt, mb, mh, mf] = pg.margins; // One ruler span per page: margins shaded, the printable part light, ticks from the margin. let span = |p: &Painter, from: f32, to: f32, m0: f32, m1: f32, horizontal: bool, area: Rect| { let band = |a: f32, b: f32| { if horizontal { - Rect::from_min_max(pos2(a, area.top() + 2.0), pos2(b, area.bottom() - 2.0)) + let (a, b) = (mx(a), mx(b)); + Rect::from_min_max(pos2(a.min(b), area.top() + 2.0), pos2(a.max(b), area.bottom() - 2.0)) } else { Rect::from_min_max(pos2(area.left() + 2.0, a), pos2(area.right() - 2.0, b)) } @@ -551,6 +555,7 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, if pos > m1 + 0.5 { break; } + let pos = if horizontal { mx(pos) } else { pos }; let major = s.is_multiple_of(minor as usize); let len = if major { 6.0 @@ -587,6 +592,12 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, let a = screen(geo, 0.0, y).y; let b = screen(geo, 0.0, y + h).y; span(p, a, b, screen(geo, 0.0, y + mt).y, screen(geo, 0.0, y + h - mb).y, false, vr); + // Header and footer margins: a small marker at each distance, grabbable like the margins. + let pc = p.with_clip_rect(vr); + for at in [screen(geo, 0.0, y + mh).y, screen(geo, 0.0, y + h - mf).y] { + let x = vr.left() + 2.0; + pc.add(egui::Shape::convex_polygon(vec![pos2(x, at - 3.5), pos2(x, at + 3.5), pos2(x + 6.0, at)], t.header_text, Stroke::NONE)); + } } p.line_segment([pos2(outer.left(), outer.top() + RULER - 0.5), pos2(outer.right(), outer.top() + RULER - 0.5)], Stroke::new(1.0, t.header_line)); p.line_segment( @@ -602,6 +613,10 @@ pub enum Edge { Right, Top, Bottom, + /// Distance of the header from the top of the page. + Header, + /// Distance of the footer from the bottom of the page. + Footer, } impl Edge { @@ -612,14 +627,16 @@ impl Edge { Edge::Right => 1, Edge::Top => 2, Edge::Bottom => 3, + Edge::Header => 4, + Edge::Footer => 5, } } /// The `pageLayout.pageSetup` margin key. fn key(self) -> &'static str { - ["left", "right", "top", "bottom"][self.index()] + ["left", "right", "top", "bottom", "header", "footer"][self.index()] } fn label(self) -> &'static str { - ["Left Margin", "Right Margin", "Top Margin", "Bottom Margin"][self.index()] + ["Left Margin", "Right Margin", "Top Margin", "Bottom Margin", "Header Margin", "Footer Margin"][self.index()] } fn horizontal(self) -> bool { matches!(self, Edge::Left | Edge::Right) @@ -635,6 +652,9 @@ pub struct RulerDrag { pub page: usize, pub start_inches: f32, pub inches: f32, + /// The sheet re-paginated with `inches` (page breaks and fit-to-page scale as a commit would + /// give them), rebuilt when the value changes. + preview: Option<(f32, Arc)>, } /// Content that must stay between the margins, in points. @@ -645,32 +665,40 @@ const EDGE_GRAB: f32 = 4.0; /// Margin (inches, rounded to 0.01) for an edge dragged to `rel_pt` points from the page's /// left/top edge. The margin stays on the page and leaves [`MIN_CONTENT_PT`] between the two /// margins of its axis. `None` for NaN/infinite input or a page without a size. -pub fn margin_from_drag(edge: Edge, rel_pt: f64, page_pt: (f32, f32), margins_in: [f32; 4]) -> Option { +pub fn margin_from_drag(edge: Edge, rel_pt: f64, page_pt: (f32, f32), margins_in: [f32; 6]) -> Option { let page = f64::from(if edge.horizontal() { page_pt.0 } else { page_pt.1 }); if !rel_pt.is_finite() || !page.is_finite() || page <= 0.0 { return None; } let other = f64::from(margins_in.get(edge.index() ^ 1).copied().filter(|v| v.is_finite())?).max(0.0) * 72.0; let raw = match edge { - Edge::Left | Edge::Top => rel_pt, - Edge::Right | Edge::Bottom => page - rel_pt, + Edge::Left | Edge::Top | Edge::Header => rel_pt, + Edge::Right | Edge::Bottom | Edge::Footer => page - rel_pt, }; let max = (page - other - MIN_CONTENT_PT).max(0.0); let pt = raw.clamp(0.0, max); Some(((pt / 72.0 * 100.0).round() / 100.0) as f32) } -/// The grid with a margin being dragged applied, so the page content follows the pointer. -pub(crate) fn with_dragged_margin(app: &SheetApp, si: usize, pg: Arc) -> Arc { - let Some(d) = app.grid.ruler_drag.as_ref().filter(|d| d.sheet == si) else { return pg }; - // Layout units per point (the print scale and pixel/point ratio). - let per_pt = pg.page_w / f64::from(pg.paper_pt.0); - let mut g = (*pg).clone(); - let v = f64::from(d.inches) * 72.0 * per_pt; - if let Some(m) = g.margins.get_mut(d.edge.index()).filter(|_| v.is_finite() && per_pt.is_finite()) { - *m = v; +/// The grid while a margin is dragged: the sheet is re-paginated with the previewed margin, so +/// the content area, page breaks and the fit-to-page scale are those a commit would give. +pub(crate) fn with_dragged_margin(app: &mut SheetApp, wb: &Workbook, si: usize, pg: Arc) -> Arc { + let Some(d) = app.grid.ruler_drag.as_mut().filter(|d| d.sheet == si) else { return pg }; + if let Some((v, g)) = &d.preview + && (*v - d.inches).abs() < f32::EPSILON + { + return g.clone(); } - Arc::new(g) + let mut w = wb.clone(); + let Some(sheet) = w.sheets.get_mut(si) else { return pg }; + let Some(slot) = Arc::make_mut(sheet).print.margins.get_mut(d.edge.index()) else { return pg }; + *slot = d.inches; + let rows = pg.row_bands.last().map_or(0, |b| b.1); + let cols = pg.col_bands.last().map_or(0, |b| b.1); + let Some(g) = PageGrid::build(&w, si, rows, cols) else { return pg }; + let g = Arc::new(g); + d.preview = Some((d.inches, g.clone())); + g } /// Points per layout unit of the grid (paper size over the layout page size). @@ -682,7 +710,7 @@ fn pt_per_unit(pg: &PageGrid) -> Option { /// The margin edge of a visible page under `pos` on the horizontal (`h`) or vertical ruler. fn edge_at(geo: &Geo, pg: &PageGrid, pos: Pos2, h: bool) -> Option<(Edge, usize)> { let ((i0, i1), (j0, j1)) = visible_pages(geo, pg); - let [ml, mr, mt, mb, ..] = pg.margins; + let [ml, mr, mt, mb, mh, mf] = pg.margins; let mut best: Option<(f32, Edge, usize)> = None; let mut consider = |d: f32, e: Edge, page: usize| { if d <= EDGE_GRAB && best.is_none_or(|b| d < b.0) { @@ -700,6 +728,8 @@ fn edge_at(geo: &Geo, pg: &PageGrid, pos: Pos2, h: bool) -> Option<(Edge, usize) let (_, y, _, hh) = pg.page_rect(i, 0); consider((screen(geo, 0.0, y + mt).y - pos.y).abs(), Edge::Top, i); consider((screen(geo, 0.0, y + hh - mb).y - pos.y).abs(), Edge::Bottom, i); + consider((screen(geo, 0.0, y + mh).y - pos.y).abs(), Edge::Header, i); + consider((screen(geo, 0.0, y + hh - mf).y - pos.y).abs(), Edge::Footer, i); } } best.map(|(_, e, p)| (e, p)) @@ -720,11 +750,12 @@ fn ruler_margins(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid } let hr = Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER)); let vr = Rect::from_min_max(pos2(outer.left(), geo.cells.top()), pos2(outer.left() + RULER, geo.cells.bottom())); - let hresp = ui.interact(hr, egui::Id::new("ruler_margin_h"), egui::Sense::drag()); + // Right-to-left sheets mirror the pages and the horizontal ruler; hit-testing is logical. + let hresp = ui.interact(geo.mirror_rect(hr), egui::Id::new("ruler_margin_h"), egui::Sense::drag()); let vresp = ui.interact(vr, egui::Id::new("ruler_margin_v"), egui::Sense::drag()); - let margins_in = |sh: &Sheet| -> [f32; 4] { + let margins_in = |sh: &Sheet| -> [f32; 6] { let m = |i: usize| sh.print.margins.get(i).copied().unwrap_or(0.5); - [m(0), m(1), m(2), m(3)] + [m(0), m(1), m(2), m(3), m(4), m(5)] }; let current = margins_in(sh); let metric = metric(app); @@ -745,7 +776,7 @@ fn ruler_margins(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid } cursor(ui, d.edge); let down = ui.input(|i| i.pointer.primary_down()); - let pos = ui.input(|i| i.pointer.latest_pos()); + let pos = ui.input(|i| i.pointer.latest_pos()).map(|p| geo.mirror_pos(p)); let mut cur = d.clone(); if let (Some(pos), Some(k)) = (pos, pt_per_unit(pg)) { let rel = if d.edge.horizontal() { @@ -774,10 +805,11 @@ fn ruler_margins(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid for (resp, h) in [(&hresp, true), (&vresp, false)] { let Some(pos) = resp.hover_pos().or_else(|| resp.interact_pointer_pos()) else { continue }; let origin = if resp.drag_started() { ui.input(|i| i.pointer.press_origin()).unwrap_or(pos) } else { pos }; + let origin = geo.mirror_pos(origin); let Some((edge, page)) = edge_at(geo, pg, origin, h) else { continue }; let value = current.get(edge.index()).copied().unwrap_or(0.5); if resp.drag_started() { - app.grid.ruler_drag = Some(RulerDrag { sheet: si, edge, page, start_inches: value, inches: value }); + app.grid.ruler_drag = Some(RulerDrag { sheet: si, edge, page, start_inches: value, inches: value, preview: None }); cursor(ui, edge); } else if resp.hovered() { cursor(ui, edge); @@ -868,7 +900,7 @@ mod tests { #[test] fn drag_maps_to_margins_and_clamps_inside_the_page() { - let m = [0.7, 0.7, 0.75, 0.75]; + let m = [0.7, 0.7, 0.75, 0.75, 0.3, 0.3]; // 72 pt from the left edge is one inch; from the right edge the margin is measured inward. assert_eq!(margin_from_drag(Edge::Left, 72.0, A4, m), Some(1.0)); assert_eq!(margin_from_drag(Edge::Right, f64::from(A4.0) - 36.0, A4, m), Some(0.5)); @@ -883,8 +915,12 @@ mod tests { assert_eq!(margin_from_drag(Edge::Left, f64::NAN, A4, m), None); assert_eq!(margin_from_drag(Edge::Left, f64::INFINITY, A4, m), None); assert_eq!(margin_from_drag(Edge::Left, 10.0, (0.0, 0.0), m), None); - assert_eq!(margin_from_drag(Edge::Left, 10.0, A4, [0.7, f32::NAN, 0.7, 0.7]), None); + assert_eq!(margin_from_drag(Edge::Left, 10.0, A4, [0.7, f32::NAN, 0.7, 0.7, 0.3, 0.3]), None); // Oversized opposite margin leaves nothing to drag into. - assert_eq!(margin_from_drag(Edge::Left, 300.0, A4, [0.0, 40.0, 0.0, 0.0]), Some(0.0)); + assert_eq!(margin_from_drag(Edge::Left, 300.0, A4, [0.0, 40.0, 0.0, 0.0, 0.3, 0.3]), Some(0.0)); + // Header/footer distances are measured from the top / bottom edge. + assert_eq!(margin_from_drag(Edge::Header, 36.0, A4, m), Some(0.5)); + assert_eq!(margin_from_drag(Edge::Footer, f64::from(A4.1) - 18.0, A4, m), Some(0.25)); + assert_eq!(margin_from_drag(Edge::Footer, -1e9, A4, m), Some(0.0)); } } diff --git a/crates/ui-egui/tests/page_layout_view.rs b/crates/ui-egui/tests/page_layout_view.rs index 632b9e222..35028a732 100644 --- a/crates/ui-egui/tests/page_layout_view.rs +++ b/crates/ui-egui/tests/page_layout_view.rs @@ -12,8 +12,20 @@ use serde_json::json; type Harness = egui_kittest::Harness<'static, SheetApp>; fn fixture() -> Harness { + fixture_with(false, None) +} + +/// `rtl` mirrors the sheet; `setup` is extra `pageLayout.pageSetup` params. +fn fixture_with(rtl: bool, setup: Option) -> Harness { let mut session = Session::new(); session.new_workbook(); + if rtl { + session.execute("view.rightToLeft", json!({"on": true})).unwrap(); + } + if let Some(p) = setup { + session.execute("cell.set", json!({"cell": "Z1", "input": "wide"})).unwrap(); + session.execute("pageLayout.pageSetup", p).unwrap(); + } for r in 1..=60 { session.execute("cell.set", json!({"cell": format!("A{r}"), "input": r.to_string()})).unwrap(); } @@ -216,3 +228,99 @@ fn dragging_a_ruler_edge_previews_commits_on_release_as_one_undo_step_and_esc_ca h.run_steps(3); assert_eq!(margins(&h), start_margins); } + +fn press(h: &mut Harness, p: Pos2) { + h.run_steps(40); + mouse(h, Event::PointerMoved(p)); + mouse(h, Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); +} + +fn release(h: &mut Harness, p: Pos2) { + mouse(h, Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +/// Screen pixels per inch of the page. +fn per_inch(g: &Geo) -> f32 { + let pg = g.paper.clone().unwrap(); + (72.0 / (pg.paper_pt.0 as f64 / pg.page_w)) as f32 * g.z +} + +#[test] +fn header_and_footer_margins_are_dragged_on_the_vertical_ruler() { + let mut h = fixture(); + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let start = margins(&h); + let ruler_x = h.state().grid.rect.unwrap().left() + 9.0; + // Header: distance from the top of the page, 0.2 in further down. + let at = pos2(ruler_x, screen(&g, 0.0, pg.page_y(0) + pg.margins[4]).y); + let to = pos2(ruler_x, at.y + 0.2 * per_inch(&g)); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(ruler_x, at.y + 4.0))); + mouse(&mut h, Event::PointerMoved(to)); + release(&mut h, to); + let after = margins(&h); + assert!((after[4] - (start[4] + 0.2)).abs() < 0.03, "{after:?} vs {start:?}"); + assert_eq!((after[..4].to_vec(), after[5]), (start[..4].to_vec(), start[5])); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(margins(&h), start); + // Footer: distance from the bottom, dragged up by 0.2 in grows it (zoom out so the page + // bottom is on screen). + h.state_mut().run("view.zoom", json!({"percent": 50})).unwrap(); + h.run_steps(4); + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let (_, py, _, ph) = pg.page_rect(0, 0); + let at = pos2(ruler_x, screen(&g, 0.0, py + ph - pg.margins[5]).y); + let to = pos2(ruler_x, at.y - 0.2 * per_inch(&g)); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(ruler_x, at.y - 4.0))); + mouse(&mut h, Event::PointerMoved(to)); + release(&mut h, to); + assert!((margins(&h)[5] - (start[5] + 0.2)).abs() < 0.03, "{:?}", margins(&h)); +} + +#[test] +fn right_to_left_sheets_mirror_the_horizontal_ruler_drag() { + let mut h = fixture_with(true, None); + let g = geo(&h); + assert!(g.rtl); + let pg = g.paper.clone().unwrap(); + let start = margins(&h); + let y = h.state().grid.rect.unwrap().top() + 9.0; + // The left margin edge is drawn on the right of the mirrored page; dragging it towards the + // page centre (screen-left) grows the left margin. + let at = pos2(g.mirror_x(screen(&g, pg.page_x(0) + pg.margins[0], 0.0).x), y); + let to = pos2(at.x - per_inch(&g), y); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(at.x - 4.0, y))); + mouse(&mut h, Event::PointerMoved(to)); + release(&mut h, to); + let after = margins(&h); + assert!((after[0] - (start[0] + 1.0)).abs() < 0.03, "{after:?} vs {start:?}"); + assert_eq!(after[1], start[1]); +} + +#[test] +fn preview_repaginates_so_fit_to_width_matches_the_commit() { + let mut h = fixture_with(false, Some(json!({"fitWidth": 1, "fitHeight": 0}))); + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let start = margins(&h); + let y = h.state().grid.rect.unwrap().top() + 9.0; + let at = pos2(screen(&g, pg.page_x(0) + pg.margins[0], 0.0).x, y); + let to = pos2(at.x + 2.0 * per_inch(&g), y); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(at.x + 4.0, y))); + mouse(&mut h, Event::PointerMoved(to)); + h.run_steps(2); + let live_scale = geo(&h).paper.unwrap().scale; + assert_eq!(margins(&h), start); + assert!(live_scale < pg.scale, "live scale {live_scale} should shrink below {}", pg.scale); + release(&mut h, to); + h.run_steps(2); + let committed = geo(&h).paper.unwrap().scale; + assert!((committed - live_scale).abs() < 0.01, "committed {committed} vs preview {live_scale}"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 52dd3a344..04a7750e6 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -28,7 +28,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Dark mode, system appearance | yes | yes | done (10-09); follows the system on first run, title-bar switch and File › Display theme (#194, #239) | | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return) with Home, New (blank + the three samples), Open (recent + Browse), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has | | Dark mode, system appearance | yes | yes | done (10-09) | -| Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab | margins drag on the rulers (#792); header/footer pictures, Different First Page / Odd & Even missing (1–3 h) | +| Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab | margins drag on the rulers (#792, with header and footer margins); header/footer pictures, Different First Page / Odd & Even missing (1–3 h) | | Multiple windows per workbook | yes | no (#162) | see [hardware-parity.md](hardware-parity.md) | ## Grid interaction From 600c25098f4396a641b20a17f0bf02f664e96493 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 21:01:22 +0200 Subject: [PATCH 3/4] Fix footer clamp expectation in the drag test (#792) Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/paper_view.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs index f560f133c..89a567e17 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -921,6 +921,6 @@ mod tests { // Header/footer distances are measured from the top / bottom edge. assert_eq!(margin_from_drag(Edge::Header, 36.0, A4, m), Some(0.5)); assert_eq!(margin_from_drag(Edge::Footer, f64::from(A4.1) - 18.0, A4, m), Some(0.25)); - assert_eq!(margin_from_drag(Edge::Footer, -1e9, A4, m), Some(0.0)); + assert_eq!(margin_from_drag(Edge::Footer, 1e9, A4, m), Some(0.0)); } } From bd105a569bbee8a11b5fca9ab3c0203c66e2589f Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 21:03:37 +0200 Subject: [PATCH 4/4] Page Layout view: vertical ruler on the right for RTL sheets, header/footer distance clamped to the margins (#792) Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/grid.rs | 7 ++- crates/ui-egui/src/paper_view.rs | 60 +++++++++++++++++++----- crates/ui-egui/tests/page_layout_view.rs | 48 +++++++++++++++++++ 3 files changed, 101 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 80af151ab..10321d114 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -490,7 +490,12 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { // Page Layout view: pages on a desk, rulers above and left of the headings. let paper = sh.view == gridcraft_engine::model::SheetViewMode::PageLayout; let ruler = if paper && !app.session.hide_ruler { crate::paper_view::RULER } else { 0.0 }; - let grid_rect = Rect::from_min_max(pos2((rect.left() + ruler).min(rect.right()), (rect.top() + ruler).min(rect.bottom())), rect.max); + // Right-to-left sheets carry the vertical ruler on the right, beside the row headings. + let grid_rect = if rtl { + Rect::from_min_max(pos2(rect.left(), (rect.top() + ruler).min(rect.bottom())), pos2((rect.right() - ruler).max(rect.left()), rect.bottom())) + } else { + Rect::from_min_max(pos2((rect.left() + ruler).min(rect.right()), (rect.top() + ruler).min(rect.bottom())), rect.max) + }; let make_geo = |app: &mut SheetApp, scroll: egui::Vec2| -> Geo { if paper { let probe = Geo::new(sh, grid_rect, scroll); diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs index 89a567e17..621a97903 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -511,6 +511,14 @@ fn editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid) { app.grid.hf_edit = Some(e); } +/// Screen rectangles of the horizontal and vertical rulers. Right-to-left sheets mirror both: +/// the vertical ruler sits on the right, beside the row headings. +fn ruler_rects(outer: Rect, geo: &Geo) -> (Rect, Rect) { + let hr = geo.mirror_rect(Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER))); + let (x0, x1) = if geo.rtl { (outer.right() - RULER, outer.right()) } else { (outer.left(), outer.left() + RULER) }; + (hr, Rect::from_min_max(pos2(x0, geo.cells.top()), pos2(x1, geo.cells.bottom()))) +} + /// Horizontal and vertical rulers (`outer` is the grid area including the rulers). pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, t: &Tokens) { let Some(pg) = geo.paper.clone() else { return }; @@ -524,13 +532,12 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, let minor = if metric { 2.0 } else { 4.0 }; let label_every = ((22.0 / unit).ceil() as usize).max(1); let font = theme::ui_font(9.5); - let hr = geo.mirror_rect(Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER))); - let vr = Rect::from_min_max(pos2(outer.left(), geo.cells.top()), pos2(outer.left() + RULER, geo.cells.bottom())); + let (hr, vr) = ruler_rects(outer, geo); // Right-to-left sheets: the horizontal ruler is mirrored with the pages (ticks run from the // right margin leftwards). let mx = |x: f32| geo.mirror_x(x); p.rect_filled(Rect::from_min_max(outer.min, pos2(outer.right(), outer.top() + RULER)), 0.0, t.window); - p.rect_filled(Rect::from_min_max(outer.min, pos2(outer.left() + RULER, outer.bottom())), 0.0, t.window); + p.rect_filled(Rect::from_min_max(pos2(vr.left(), outer.top()), pos2(vr.right(), outer.bottom())), 0.0, t.window); let ((i0, i1), (j0, j1)) = visible_pages(geo, &pg); let [ml, mr, mt, mb, mh, mf] = pg.margins; // One ruler span per page: margins shaded, the printable part light, ticks from the margin. @@ -575,6 +582,8 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, } let (a, b) = if horizontal { (pos2(pos, area.bottom() - 2.0 - len), pos2(pos, area.bottom() - 2.0)) + } else if geo.rtl { + (pos2(area.left() + 2.0, pos), pos2(area.left() + 2.0 + len, pos)) } else { (pos2(area.right() - 2.0 - len, pos), pos2(area.right() - 2.0, pos)) }; @@ -595,13 +604,17 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, // Header and footer margins: a small marker at each distance, grabbable like the margins. let pc = p.with_clip_rect(vr); for at in [screen(geo, 0.0, y + mh).y, screen(geo, 0.0, y + h - mf).y] { - let x = vr.left() + 2.0; - pc.add(egui::Shape::convex_polygon(vec![pos2(x, at - 3.5), pos2(x, at + 3.5), pos2(x + 6.0, at)], t.header_text, Stroke::NONE)); + let (x, d) = if geo.rtl { (vr.right() - 2.0, -6.0) } else { (vr.left() + 2.0, 6.0) }; + pc.add(egui::Shape::convex_polygon(vec![pos2(x, at - 3.5), pos2(x, at + 3.5), pos2(x + d, at)], t.header_text, Stroke::NONE)); } } p.line_segment([pos2(outer.left(), outer.top() + RULER - 0.5), pos2(outer.right(), outer.top() + RULER - 0.5)], Stroke::new(1.0, t.header_line)); p.line_segment( - [pos2(outer.left() + RULER - 0.5, outer.top()), pos2(outer.left() + RULER - 0.5, outer.bottom())], + if geo.rtl { + [pos2(vr.left() + 0.5, outer.top()), pos2(vr.left() + 0.5, outer.bottom())] + } else { + [pos2(vr.right() - 0.5, outer.top()), pos2(vr.right() - 0.5, outer.bottom())] + }, Stroke::new(1.0, t.header_line), ); } @@ -675,8 +688,19 @@ pub fn margin_from_drag(edge: Edge, rel_pt: f64, page_pt: (f32, f32), margins_in Edge::Left | Edge::Top | Edge::Header => rel_pt, Edge::Right | Edge::Bottom | Edge::Footer => page - rel_pt, }; - let max = (page - other - MIN_CONTENT_PT).max(0.0); - let pt = raw.clamp(0.0, max); + let mut max = (page - other - MIN_CONTENT_PT).max(0.0); + let mut min = 0.0_f64; + let pt_of = |i: usize| f64::from(margins_in.get(i).copied().filter(|v| v.is_finite()).unwrap_or(0.0)).max(0.0) * 72.0; + match edge { + // The header/footer sits within its page margin (conservative: never past the margin edge). + Edge::Header => max = max.min(pt_of(2)), + Edge::Footer => max = max.min(pt_of(3)), + // ... so the top/bottom margin can't be dragged inside it. + Edge::Top => min = pt_of(4), + Edge::Bottom => min = pt_of(5), + _ => {} + } + let pt = raw.clamp(min.min(max), max); Some(((pt / 72.0 * 100.0).round() / 100.0) as f32) } @@ -748,10 +772,9 @@ fn ruler_margins(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid if app.grid.ruler_drag.as_ref().is_some_and(|d| d.sheet != si) { app.grid.ruler_drag = None; } - let hr = Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER)); - let vr = Rect::from_min_max(pos2(outer.left(), geo.cells.top()), pos2(outer.left() + RULER, geo.cells.bottom())); + let (hr, vr) = ruler_rects(outer, geo); // Right-to-left sheets mirror the pages and the horizontal ruler; hit-testing is logical. - let hresp = ui.interact(geo.mirror_rect(hr), egui::Id::new("ruler_margin_h"), egui::Sense::drag()); + let hresp = ui.interact(hr, egui::Id::new("ruler_margin_h"), egui::Sense::drag()); let vresp = ui.interact(vr, egui::Id::new("ruler_margin_v"), egui::Sense::drag()); let margins_in = |sh: &Sheet| -> [f32; 6] { let m = |i: usize| sh.print.margins.get(i).copied().unwrap_or(0.5); @@ -905,9 +928,9 @@ mod tests { assert_eq!(margin_from_drag(Edge::Left, 72.0, A4, m), Some(1.0)); assert_eq!(margin_from_drag(Edge::Right, f64::from(A4.0) - 36.0, A4, m), Some(0.5)); assert_eq!(margin_from_drag(Edge::Top, 108.0, A4, m), Some(1.5)); - // Dragged off the page: never negative. + // Dragged off the page: never negative (top/bottom keep the header/footer distance). assert_eq!(margin_from_drag(Edge::Left, -50.0, A4, m), Some(0.0)); - assert_eq!(margin_from_drag(Edge::Bottom, f64::from(A4.1) + 99.0, A4, m), Some(0.0)); + assert_eq!(margin_from_drag(Edge::Bottom, f64::from(A4.1) + 99.0, A4, m), Some(0.3)); // Dragged across the opposite margin: stops a minimum content width short of it. let max = margin_from_drag(Edge::Left, 10_000.0, A4, m).unwrap(); assert!((f64::from(max) * 72.0 - (f64::from(A4.0) - 0.7 * 72.0 - MIN_CONTENT_PT)).abs() < 1.0, "{max}"); @@ -922,5 +945,16 @@ mod tests { assert_eq!(margin_from_drag(Edge::Header, 36.0, A4, m), Some(0.5)); assert_eq!(margin_from_drag(Edge::Footer, f64::from(A4.1) - 18.0, A4, m), Some(0.25)); assert_eq!(margin_from_drag(Edge::Footer, 1e9, A4, m), Some(0.0)); + // Header stays within the top margin (0.75 in), footer within the bottom margin. + assert_eq!(margin_from_drag(Edge::Header, 500.0, A4, m), Some(0.75)); + assert_eq!(margin_from_drag(Edge::Footer, f64::from(A4.1) - 500.0, A4, m), Some(0.75)); + // The top/bottom margin can't be dragged inside the header/footer distance (0.3 in). + assert_eq!(margin_from_drag(Edge::Top, 2.0, A4, m), Some(0.3)); + assert_eq!(margin_from_drag(Edge::Bottom, f64::from(A4.1) - 2.0, A4, m), Some(0.3)); + // Hostile header/footer values (NaN, negative, huge) never panic or escape the page. + assert_eq!(margin_from_drag(Edge::Header, 10.0, A4, [0.7, 0.7, 0.75, 0.75, f32::NAN, 0.3]), Some(0.14)); + assert_eq!(margin_from_drag(Edge::Top, 300.0, A4, [0.7, 0.7, 0.75, 0.75, -5.0, f32::INFINITY]), Some(4.17)); + assert_eq!(margin_from_drag(Edge::Header, f64::NAN, A4, m), None); + assert!(margin_from_drag(Edge::Footer, 10.0, A4, [0.0, 0.0, 1e9, 1e9, 0.3, 1e9]).is_some_and(|v| (0.0..=11.7).contains(&v))); } } diff --git a/crates/ui-egui/tests/page_layout_view.rs b/crates/ui-egui/tests/page_layout_view.rs index 35028a732..b2e80d0d9 100644 --- a/crates/ui-egui/tests/page_layout_view.rs +++ b/crates/ui-egui/tests/page_layout_view.rs @@ -324,3 +324,51 @@ fn preview_repaginates_so_fit_to_width_matches_the_commit() { let committed = geo(&h).paper.unwrap().scale; assert!((committed - live_scale).abs() < 0.01, "committed {committed} vs preview {live_scale}"); } + +#[test] +fn right_to_left_sheets_carry_the_vertical_ruler_on_the_right() { + let mut h = fixture_with(true, None); + let g = geo(&h); + assert!(g.rtl); + let pg = g.paper.clone().unwrap(); + let start = margins(&h); + let outer = h.state().grid.rect.unwrap(); + // The grid leaves the ruler's width free on the right, not on the left. + assert!((g.rect.right() - (outer.right() - gridcraft_ui_egui::paper_view::RULER)).abs() < 0.5, "{:?} {outer:?}", g.rect); + assert!((g.rect.left() - outer.left()).abs() < 0.5); + let x = outer.right() - 9.0; + let at = pos2(x, screen(&g, 0.0, pg.page_y(0) + pg.margins[2]).y); + let to = pos2(x, at.y + 0.25 * per_inch(&g)); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(x, at.y + 4.0))); + mouse(&mut h, Event::PointerMoved(to)); + release(&mut h, to); + let after = margins(&h); + assert!((after[2] - (start[2] + 0.25)).abs() < 0.03, "{after:?} vs {start:?}"); + // The left ruler strip is not a handle any more: nothing happens there. + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let at = pos2(outer.left() + 9.0, screen(&g, 0.0, pg.page_y(0) + pg.margins[3]).y); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(at.x, at.y - 30.0))); + release(&mut h, pos2(at.x, at.y - 30.0)); + assert_eq!(margins(&h)[3], start[3]); +} + +#[test] +fn header_distance_cannot_be_dragged_past_the_top_margin() { + let mut h = fixture(); + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let start = margins(&h); + let ruler_x = h.state().grid.rect.unwrap().left() + 9.0; + let at = pos2(ruler_x, screen(&g, 0.0, pg.page_y(0) + pg.margins[4]).y); + let to = pos2(ruler_x, at.y + 3.0 * per_inch(&g)); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(ruler_x, at.y + 4.0))); + mouse(&mut h, Event::PointerMoved(to)); + release(&mut h, to); + let after = margins(&h); + assert!((after[4] - start[2]).abs() < 0.011, "header {} should stop at the top margin {}", after[4], start[2]); + assert_eq!(after[2], start[2]); +}