diff --git a/ROADMAP.md b/ROADMAP.md index dd57172..ed35121 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 | Forecast Sheet gets Forecast Start: begin the forecast inside the history to compare it with the actuals (#785) | | 2026-10-12 | Ink strokes save as unfilled DrawingML paths and reopen as editable ink with their points, colour and width; icons in #551 remain a follow-up | | 2026-10-12 | The selection outline stays visible when extended to the last row (Ctrl+Shift+Down): its sides are cut to the viewport instead of stroking a 20-million-point rectangle (#687) | @@ -181,6 +182,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 | Ink preservation across XLSX save/reopen (ink portion of #551) | | 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 | diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 316714c..434b548 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -103,6 +103,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). @@ -491,11 +493,17 @@ 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); 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, &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 54d3fd2..4cc6a3e 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -296,6 +296,12 @@ const JAPANESE: &[(&str, &str)] = &[ ("Select\nObjects", "オブジェクトの選択"), ("Themes", "テーマ"), ("Margins", "余白"), + ("Left Margin", "左余白"), + ("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 3e25f82..bf8c35f 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -116,6 +116,12 @@ 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", + "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 2ad37f6..75653e5 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -116,6 +116,12 @@ 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", + "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 da41bd7..0582110 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -117,6 +117,12 @@ 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", + "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 e335c41..c70b25c 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -124,6 +124,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "Sélectionner les\nobjets", "Themes" => "Thèmes", "Margins" => "Marges", + "Left Margin" => "Marge gauche", + "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\nd'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 b684c78..f3e1688 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -121,6 +121,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "개체\n선택", "Themes" => "테마", "Margins" => "여백", + "Left Margin" => "왼쪽 여백", + "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 a02fd07..9ab5b7b 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -122,6 +122,12 @@ 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", + "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 c8f0445..868f653 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -121,6 +121,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "Выбрать\nобъекты", "Themes" => "Темы", "Margins" => "Поля", + "Left Margin" => "Левое поле", + "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 d1cf704..a9989f2 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -121,6 +121,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "选择\n对象", "Themes" => "主题", "Margins" => "页边距", + "Left Margin" => "左边距", + "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 110193a..621a979 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 }); } @@ -510,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 }; @@ -523,17 +532,20 @@ 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 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, ..] = 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)) } @@ -550,6 +562,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 @@ -569,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)) }; @@ -586,14 +601,246 @@ 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, 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), ); } +/// A page edge that can be dragged on a ruler. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Edge { + Left, + 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 { + /// Index into `PrintSettings::margins` / `PageGrid::margins`. + fn index(self) -> usize { + match self { + Edge::Left => 0, + 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", "header", "footer"][self.index()] + } + fn label(self) -> &'static str { + ["Left Margin", "Right Margin", "Top Margin", "Bottom Margin", "Header Margin", "Footer 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, + /// 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. +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; 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 | Edge::Header => rel_pt, + Edge::Right | Edge::Bottom | Edge::Footer => page - rel_pt, + }; + 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) +} + +/// 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(); + } + 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). +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, 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) { + 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); + 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)) +} + +/// 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, vr) = ruler_rects(outer, geo); + // Right-to-left sheets mirror the pages and the horizontal ruler; hit-testing is logical. + 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); + [m(0), m(1), m(2), m(3), m(4), m(5)] + }; + 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()).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() { + 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 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, preview: None }); + 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 +914,47 @@ 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, 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)); + assert_eq!(margin_from_drag(Edge::Top, 108.0, A4, m), Some(1.5)); + // 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.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}"); + // 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, 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, 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)); + // 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 f8850b3..b2e80d0 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(); } @@ -162,3 +174,201 @@ 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); +} + +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}"); +} + +#[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]); +} diff --git a/docs/gaps.md b/docs/gaps.md index a089900..540d17a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -118,8 +118,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. @@ -170,6 +169,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 | §13: Forecast Sheet Forecast Start (hindcast) landed (#785) | | 2026-10-12 | minor | §17: ink geometry survives XLSX saves; the icon portion of #551 remains | | 2026-10-12 | minor | §8: values on rows (Σ Values in Rows, `dataOnRows`) laid out, moved and round-tripped | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 8413973..7e308cb 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -30,7 +30,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, 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 @@ -163,6 +163,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-12 | minor | Alt+Down opens the active filter header's existing popup, with validation priority and mirrored anchoring on right-to-left sheets (part of #441) | | 2026-10-12 | minor | Paste from the ribbon (button and options menu), the cell and header context menus and the macOS menu bar's Edit menu pastes what is on the system clipboard, as Cmd/Ctrl+V does, instead of only GridCraft's own copy (the web build, which can't read the clipboard on request, still pastes GridCraft's copy). Copy and Cut by keytip (H,C / H,X) publish to the system clipboard like the ribbon buttons. After a control-channel or MCP copy, which leaves the system clipboard alone, Paste pastes that copy until other text is copied. Once a cut has been pasted, Paste (ribbon, menus or Cmd/Ctrl+V) doesn't paste the cut's clipboard text again over the moved cells (Excel pastes a cut once), and the Paste keytip (H,V,P) pastes the system clipboard like the button. Regression test for the Paste button click freeze in 0.5.0 (#273, fixed by #272); the Paste button gets an accessible "Paste" name for screen readers | | 2026-10-11 | minor | AutoFit accounts for table and ordinary filter dropdowns, including low zoom; header text excludes the button area |