diff --git a/ROADMAP.md b/ROADMAP.md index 1189fd7..e805e2e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-11 | Freeze panes by dragging the freeze handles on the header edges (#195) | | 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed | | 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 | | 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows | diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 72d33e2..a106ff6 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -40,6 +40,11 @@ pub enum Drag { Fill { target: Option, }, + /// Dragging a freeze handle: `cols` for the column bar, `to` the line count it would freeze. + Freeze { + cols: bool, + to: Option, + }, /// Moving the selection by its border (cut & paste). Move { grab: CellRef, @@ -414,6 +419,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { // Headers. if sh.show_headings { paint_headers(&painter, &geo, sh, &sel, &t, &quads); + paint_freeze_handles(&painter, &geo, sh, &app.grid.drag, &t); } // Interaction. let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu); @@ -1066,9 +1072,23 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & let cur_rect = geo.range_rect(sh, sel.current()); let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0)); + let (freeze_cols, freeze_rows) = freeze_handles(geo); // Hover cursors. if resp.hovered() && let Some(p) = pos + && (freeze_cols.contains(p) || freeze_rows.contains(p) || matches!(app.grid.drag, Drag::Freeze { .. })) + { + let cols = match app.grid.drag { + Drag::Freeze { cols, .. } => cols, + _ => freeze_cols.contains(p), + }; + ctx.set_cursor_icon(if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }); + if app.grid.drag == Drag::None && !pointer.any_down() { + let tip = if cols { "Drag to freeze columns" } else { "Drag to freeze rows" }; + resp.clone().on_hover_text_at_pointer(crate::i18n::current(&ctx).tr(tip)); + } + } else if resp.hovered() + && let Some(p) = pos { let near_col_edge = in_col_header && col_edge(geo, sh, p.x).is_some(); let near_row_edge = in_row_header && row_edge(geo, sh, p.y).is_some(); @@ -1192,6 +1212,11 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & if resp.drag_started() || (primary_clicked && app.grid.drag == Drag::None) { // Where the press began (a drag is only recognised after the pointer has moved). let Some(p) = pointer.press_origin().or(pos) else { return }; + // Freeze handles sit on the header edges (and the corner's edges when nothing is frozen). + if freeze_cols.contains(p) || freeze_rows.contains(p) { + app.grid.drag = Drag::Freeze { cols: freeze_cols.contains(p), to: None }; + return; + } if in_corner { let _ = app.session.run("selection.set", json!({"range": RangeRef::all().a1()})); return; @@ -1378,6 +1403,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & let _ = app.session.run("home.rowHeight", json!({"rows": rows, "height": h.round()})); coalesce_last_undo(app); } + Drag::Freeze { cols, .. } => { + app.grid.drag = Drag::Freeze { cols, to: Some(freeze_boundary(geo, sh, p, cols)) }; + } Drag::Fill { .. } => { let src = sel.current(); let target = fill_target(src, pc); @@ -1427,6 +1455,14 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } } } + Drag::Freeze { cols, to: Some(to) } => { + let (rows_now, cols_now) = (geo.fr, geo.fc); + let (rows, cols) = if cols { (rows_now, to) } else { (to, cols_now) }; + if (rows, cols) != (rows_now, cols_now) { + // A1 (nothing left frozen) unfreezes. + app.run_or_alert("view.freezePanes", json!({"cell": CellRef::new(rows, cols).a1()})); + } + } Drag::Move { to: Some(to), .. } => { let src = sel.current(); if to != src.start { @@ -1568,6 +1604,60 @@ fn on_border(r: Rect, p: Pos2) -> bool { outer.contains(p) && !inner.contains(p) } +/// The freeze handles, as in Excel for the web: a bar in the upper part of the column-header strip +/// on the frozen columns' right edge, and one in the left part of the row-header strip on the +/// frozen rows' bottom edge. With nothing frozen they sit on the corner's edges. The rest of the +/// header edge keeps resizing the column or row. +pub fn freeze_handles(geo: &Geo) -> (Rect, Rect) { + let x = geo.cells.left() + geo.frozen_w; + let y = geo.cells.top() + geo.frozen_h; + let cols = Rect::from_min_max(pos2(x - 3.0, geo.rect.top()), pos2(x + 3.0, geo.rect.top() + geo.header_h * 0.4)); + let rows = Rect::from_min_max(pos2(geo.rect.left(), y - 3.0), pos2(geo.rect.left() + geo.header_w * 0.4, y + 3.0)); + (cols, rows) +} + +/// How many columns (or rows) a freeze handle dropped at `p` freezes: the line boundary nearest +/// the pointer, limited so the frozen pane leaves room for the scrolling one. +fn freeze_boundary(geo: &Geo, sh: &Sheet, p: Pos2, cols: bool) -> u32 { + if cols { + if p.x <= geo.cells.left() { + return 0; + } + let c = geo.col_at(sh, p.x); + let (l, r) = (geo.x(sh, c), geo.x(sh, c.saturating_add(1).min(MAX_COLS - 1))); + let b = if p.x - l > r - p.x { c.saturating_add(1) } else { c }; + let room = geo.ci.at((((geo.cells.width() - 40.0) / geo.z).max(0.0)) as f64); + b.min(room).min(MAX_COLS - 1) + } else { + if p.y <= geo.cells.top() { + return 0; + } + let rr = geo.row_at(sh, p.y); + let (t, b2) = (geo.y(sh, rr), geo.y(sh, rr.saturating_add(1).min(MAX_ROWS - 1))); + let b = if p.y - t > b2 - p.y { rr.saturating_add(1) } else { rr }; + let room = geo.ri.at((((geo.cells.height() - 40.0) / geo.z).max(0.0)) as f64); + b.min(room).min(MAX_ROWS - 1) + } +} + +/// Paints the freeze handles and, while one is dragged, where the freeze line will go. +fn paint_freeze_handles(p: &Painter, geo: &Geo, sh: &Sheet, drag: &Drag, t: &Tokens) { + let (cols, rows) = freeze_handles(geo); + let bar = Color32::from_gray(150); + p.rect_filled(Rect::from_center_size(cols.center(), vec2(3.0, cols.height())), 1.0, bar); + p.rect_filled(Rect::from_center_size(rows.center(), vec2(rows.width(), 3.0)), 1.0, bar); + if let Drag::Freeze { cols, to: Some(to) } = *drag { + let stroke = Stroke::new(2.0, t.accent); + if cols { + let x = geo.cells.left() + sh.col_left(to) as f32 * geo.z; + p.line_segment([pos2(x, geo.rect.top()), pos2(x, geo.cells.bottom())], stroke); + } else { + let y = geo.cells.top() + sh.row_top(to) as f32 * geo.z; + p.line_segment([pos2(geo.rect.left(), y), pos2(geo.cells.right(), y)], stroke); + } + } +} + fn col_edge(geo: &Geo, sh: &Sheet, x: f32) -> Option<(u32, f32)> { let c = geo.col_at(sh, x); for cc in [c, c.saturating_sub(1)] { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 1e7b212..fcfc970 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -345,6 +345,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("Light", "ライト"), ("Dark", "ダーク"), ("Interface language", "表示言語"), + ("Drag to freeze columns", "ドラッグして列を固定"), + ("Drag to freeze rows", "ドラッグして行を固定"), ("Command\nPalette", "コマンド パレット"), ("Agent\nControl", "エージェント制御"), ("Action\nJournal", "操作ジャーナル"), diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index c37ae3c..14a219d 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -192,6 +192,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Unfreeze Panes" => "틀 고정 취소", "Dark Mode" => "다크 모드", "Interface language" => "인터페이스 언어", + "Drag to freeze columns" => "끌어서 열 고정", + "Drag to freeze rows" => "끌어서 행 고정", "Command\nPalette" => "명령\n팔레트", "Agent\nControl" => "에이전트\n제어", "Action\nJournal" => "작업\n기록", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 1bdc2c3..c2bb8e7 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -193,6 +193,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Unfreeze Panes" => "Descongelar Painéis", "Dark Mode" => "Modo Escuro", "Interface language" => "Idioma da interface", + "Drag to freeze columns" => "Arraste para congelar colunas", + "Drag to freeze rows" => "Arraste para congelar linhas", "Command\nPalette" => "Paleta de\nComandos", "Agent\nControl" => "Controle\ndo Agente", "Action\nJournal" => "Registro\nde Ações", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 8d54856..ef24f8a 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -192,6 +192,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Unfreeze Panes" => "Снять закрепление областей", "Dark Mode" => "Тёмный режим", "Interface language" => "Язык интерфейса", + "Drag to freeze columns" => "Перетащите, чтобы закрепить столбцы", + "Drag to freeze rows" => "Перетащите, чтобы закрепить строки", "Command\nPalette" => "Палитра\nкоманд", "Agent\nControl" => "Управление\nагентом", "Action\nJournal" => "Журнал\nдействий", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 9f04ada..ff3ba59 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -192,6 +192,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Unfreeze Panes" => "取消冻结窗格", "Dark Mode" => "深色模式", "Interface language" => "界面语言", + "Drag to freeze columns" => "拖动以冻结列", + "Drag to freeze rows" => "拖动以冻结行", "Command\nPalette" => "命令\n面板", "Agent\nControl" => "智能体\n控制", "Action\nJournal" => "操作\n日志", diff --git a/crates/ui-egui/tests/freeze_handles.rs b/crates/ui-egui/tests/freeze_handles.rs new file mode 100644 index 0000000..877c4cf --- /dev/null +++ b/crates/ui-egui/tests/freeze_handles.rs @@ -0,0 +1,76 @@ +//! Freezing panes by dragging the freeze handles on the header edges (#195). + +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::SheetApp; +use gridcraft_ui_egui::grid::{Geo, freeze_handles}; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn geo(h: &Harness) -> Geo { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll) +} + +fn drag(h: &mut Harness, from: Pos2, to: Pos2) { + let press = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + h.input_mut().events.extend([Event::PointerMoved(from), press(from, true)]); + h.step(); + for i in 1..=6 { + let t = i as f32 / 6.0; + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * t)); + h.step(); + } + h.input_mut().events.push(press(to, false)); + h.run_steps(3); +} + +fn freeze(h: &Harness) -> Option<(u32, u32)> { + h.state().session.active().unwrap().wb.active().unwrap().freeze +} + +#[test] +fn dragging_the_handles_freezes_moves_and_unfreezes() { + let mut h = harness(); + assert_eq!(freeze(&h), None); + // Column handle from the corner to just past column C's left edge: two columns frozen. + let g = geo(&h); + let sh = h.state().session.active().unwrap().wb.active().unwrap().clone(); + let (cols, _) = freeze_handles(&g); + let target = pos2(g.cell_rect(&sh, CellRef::new(0, 2)).left() + 3.0, g.cells.top() + 40.0); + drag(&mut h, cols.center(), target); + assert_eq!(freeze(&h), Some((0, 2))); + + // Row handle down to row 4's top: three rows frozen, columns kept. + let g = geo(&h); + let (_, rows) = freeze_handles(&g); + let target = pos2(g.cells.left() + 40.0, g.cell_rect(&sh, CellRef::new(3, 4)).top() + 2.0); + drag(&mut h, rows.center(), target); + assert_eq!(freeze(&h), Some((3, 2))); + + // Both handles back to the corner unfreeze. + let g = geo(&h); + let (cols, _) = freeze_handles(&g); + drag(&mut h, cols.center(), pos2(g.cells.left() - 5.0, cols.center().y)); + assert_eq!(freeze(&h), Some((3, 0))); + let g = geo(&h); + let (_, rows) = freeze_handles(&g); + drag(&mut h, rows.center(), pos2(rows.center().x, g.cells.top() - 5.0)); + assert_eq!(freeze(&h), None); +}