Page Layout view: drag the margins on the rulers (#792)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76 2026-10-12 20:57:02 +02:00
parent fc45b7f6d8
commit 339f8ed35b
15 changed files with 320 additions and 3 deletions

View File

@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed | | 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-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 | 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) | | 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 | | 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-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 | Password-encrypted workbooks open (#11); progress log row |
| 2026-10-11 | minor | Forecast Sheet (#322); progress log row | | 2026-10-11 | minor | Forecast Sheet (#322); progress log row |

View File

@ -100,6 +100,8 @@ pub struct GridState {
pub paper: Option<crate::paper_view::PaperCache>, pub paper: Option<crate::paper_view::PaperCache>,
/// Header or footer being edited in place (Page Layout view). /// Header or footer being edited in place (Page Layout view).
pub hf_edit: Option<crate::paper_view::HfEdit>, pub hf_edit: Option<crate::paper_view::HfEdit>,
/// A page margin being dragged on a ruler (Page Layout view); committed on release.
pub ruler_drag: Option<crate::paper_view::RulerDrag>,
/// Insert › Header & Footer: start editing the first page's header once Page Layout view is up. /// Insert › Header & Footer: start editing the first page's header once Page Layout view is up.
pub hf_start: bool, pub hf_start: bool,
/// The grid geometry of the last frame (hit-testing for agents and tests). /// 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 { if paper {
let probe = Geo::new(sh, grid_rect, scroll); 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) { 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); return Geo::paged(sh, grid_rect, scroll, g);
} }
} }

View File

@ -290,6 +290,10 @@ const JAPANESE: &[(&str, &str)] = &[
("Select\nObjects", "オブジェクトの選択"), ("Select\nObjects", "オブジェクトの選択"),
("Themes", "テーマ"), ("Themes", "テーマ"),
("Margins", "余白"), ("Margins", "余白"),
("Left Margin", "左余白"),
("Right Margin", "右余白"),
("Top Margin", "上余白"),
("Bottom Margin", "下余白"),
("Size", "サイズ"), ("Size", "サイズ"),
("Print\nArea", "印刷範囲"), ("Print\nArea", "印刷範囲"),
("Set Print Area", "印刷範囲の設定"), ("Set Print Area", "印刷範囲の設定"),

View File

@ -116,6 +116,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Select\nObjects" => "Vybrat\nobjekty", "Select\nObjects" => "Vybrat\nobjekty",
"Themes" => "Motivy", "Themes" => "Motivy",
"Margins" => "Okraje", "Margins" => "Okraje",
"Left Margin" => "Levý okraj",
"Right Margin" => "Pravý okraj",
"Top Margin" => "Horní okraj",
"Bottom Margin" => "Dolní okraj",
"Size" => "Velikost", "Size" => "Velikost",
"Print\nArea" => "Oblast\ntisku", "Print\nArea" => "Oblast\ntisku",
"Set Print Area" => "Nastavit oblast tisku", "Set Print Area" => "Nastavit oblast tisku",

View File

@ -116,6 +116,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Select\nObjects" => "Objekte\nauswählen", "Select\nObjects" => "Objekte\nauswählen",
"Themes" => "Designs", "Themes" => "Designs",
"Margins" => "Seitenränder", "Margins" => "Seitenränder",
"Left Margin" => "Linker Seitenrand",
"Right Margin" => "Rechter Seitenrand",
"Top Margin" => "Oberer Seitenrand",
"Bottom Margin" => "Unterer Seitenrand",
"Size" => "Größe", "Size" => "Größe",
"Print\nArea" => "Druck-\nbereich", "Print\nArea" => "Druck-\nbereich",
"Set Print Area" => "Druckbereich festlegen", "Set Print Area" => "Druckbereich festlegen",

View File

@ -117,6 +117,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Select\nObjects" => "Seleccionar\nobjetos", "Select\nObjects" => "Seleccionar\nobjetos",
"Themes" => "Temas", "Themes" => "Temas",
"Margins" => "Márgenes", "Margins" => "Márgenes",
"Left Margin" => "Margen izquierdo",
"Right Margin" => "Margen derecho",
"Top Margin" => "Margen superior",
"Bottom Margin" => "Margen inferior",
"Size" => "Tamaño", "Size" => "Tamaño",
"Print\nArea" => "Área de\nimpresión", "Print\nArea" => "Área de\nimpresión",
"Set Print Area" => "Establecer área de impresión", "Set Print Area" => "Establecer área de impresión",

View File

@ -124,6 +124,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Select\nObjects" => "Sélection d’objets", "Select\nObjects" => "Sélection d’objets",
"Themes" => "Thèmes", "Themes" => "Thèmes",
"Margins" => "Marges", "Margins" => "Marges",
"Left Margin" => "Marge gauche",
"Right Margin" => "Marge droite",
"Top Margin" => "Marge haute",
"Bottom Margin" => "Marge basse",
"Size" => "Taille", "Size" => "Taille",
"Print\nArea" => "Zone d’impression", "Print\nArea" => "Zone d’impression",
"Set Print Area" => "Définir la zone d’impression", "Set Print Area" => "Définir la zone d’impression",

View File

@ -121,6 +121,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Select\nObjects" => "개체\n선택", "Select\nObjects" => "개체\n선택",
"Themes" => "테마", "Themes" => "테마",
"Margins" => "여백", "Margins" => "여백",
"Left Margin" => "왼쪽 여백",
"Right Margin" => "오른쪽 여백",
"Top Margin" => "위쪽 여백",
"Bottom Margin" => "아래쪽 여백",
"Size" => "용지 크기", "Size" => "용지 크기",
"Print\nArea" => "인쇄\n영역", "Print\nArea" => "인쇄\n영역",
"Set Print Area" => "인쇄 영역 설정", "Set Print Area" => "인쇄 영역 설정",

View File

@ -122,6 +122,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Select\nObjects" => "Selecionar\nObjetos", "Select\nObjects" => "Selecionar\nObjetos",
"Themes" => "Temas", "Themes" => "Temas",
"Margins" => "Margens", "Margins" => "Margens",
"Left Margin" => "Margem esquerda",
"Right Margin" => "Margem direita",
"Top Margin" => "Margem superior",
"Bottom Margin" => "Margem inferior",
"Size" => "Tamanho", "Size" => "Tamanho",
"Print\nArea" => "Área de\nImpressão", "Print\nArea" => "Área de\nImpressão",
"Set Print Area" => "Definir Área de Impressão", "Set Print Area" => "Definir Área de Impressão",

View File

@ -121,6 +121,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Select\nObjects" => "Выбрать\nобъекты", "Select\nObjects" => "Выбрать\nобъекты",
"Themes" => "Темы", "Themes" => "Темы",
"Margins" => "Поля", "Margins" => "Поля",
"Left Margin" => "Левое поле",
"Right Margin" => "Правое поле",
"Top Margin" => "Верхнее поле",
"Bottom Margin" => "Нижнее поле",
"Size" => "Размер", "Size" => "Размер",
"Print\nArea" => "Область\nпечати", "Print\nArea" => "Область\nпечати",
"Set Print Area" => "Задать область печати", "Set Print Area" => "Задать область печати",

View File

@ -121,6 +121,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Select\nObjects" => "选择\n对象", "Select\nObjects" => "选择\n对象",
"Themes" => "主题", "Themes" => "主题",
"Margins" => "页边距", "Margins" => "页边距",
"Left Margin" => "左边距",
"Right Margin" => "右边距",
"Top Margin" => "上边距",
"Bottom Margin" => "下边距",
"Size" => "纸张大小", "Size" => "纸张大小",
"Print\nArea" => "打印\n区域", "Print\nArea" => "打印\n区域",
"Set Print Area" => "设置打印区域", "Set Print Area" => "设置打印区域",

View File

@ -284,6 +284,7 @@ pub(crate) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo
commit(app); commit(app);
} }
let Some(pg) = geo.paper.clone() else { return }; 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) { 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 }); 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<f32> {
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<PageGrid>) -> Arc<PageGrid> {
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<f64> {
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. /// The Header & Footer contextual ribbon tab.
pub fn ribbon_tab(app: &mut SheetApp, ui: &mut egui::Ui) { pub fn ribbon_tab(app: &mut SheetApp, ui: &mut egui::Ui) {
use crate::widgets::big_button; 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()); 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)); 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));
}
}

View File

@ -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"); 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")); 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);
}

View File

@ -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) ### 14. Page Layout view and print polish (U, F)
- Page Layout view landed (#329): paper view with rulers and in-place header/footer editing. - 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 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.
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.
### 15. Remaining functions and an oracle suite (F) ### 15. Remaining functions and an oracle suite (F)
- 18 missing, 9 stubs; no Excel-computed oracle corpus; #137, #138. **Estimate:** 16–26 h. - 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 | | 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-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 | §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 | | 2026-10-11 | minor | §15: trim-reference operators (`A1:.E10`, `A1.:E10`, `A1.:.E10`) parse and evaluate |

View File

@ -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) | | 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 | | 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) | | 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) | | Multiple windows per workbook | yes | no (#162) | see [hardware-parity.md](hardware-parity.md) |
## Grid interaction ## Grid interaction
@ -160,6 +160,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
| Date | Change | Summary | | 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 | 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 | 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) | | 2026-10-11 | minor | Shift+Space / Ctrl+Space no longer start an edit; Control+Space selects the column on Mac (#338) |