Merge pull request #309 from vangel76/feat/drag-freeze-lines

Grid: freeze panes by dragging the freeze handles (#195)

# Conflicts:
#	ROADMAP.md
#	crates/ui-egui/src/i18n.rs
#	crates/ui-egui/src/i18n/ko.rs
#	crates/ui-egui/src/i18n/pt.rs
#	crates/ui-egui/src/i18n/ru.rs
#	crates/ui-egui/src/i18n/zh.rs
This commit is contained in:
Brandon Thomas
2026-10-11 12:04:51 -04:00
8 changed files with 177 additions and 0 deletions
+1
View File
@@ -142,6 +142,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-11 | Ribbon translated into German (266 labels; `de`, `de-DE`, `de-AT`, `de-CH` locale tags resolve to German) |
| 2026-10-11 | View › Interface size: a saved 75–200 % scale for the whole window (#230) |
| 2026-10-11 | View › Formula Bar and Zoom 100% reachable from the CLI and MCP (#289) |
| 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 |
+90
View File
@@ -40,6 +40,11 @@ pub enum Drag {
Fill {
target: Option<RangeRef>,
},
/// Dragging a freeze handle: `cols` for the column bar, `to` the line count it would freeze.
Freeze {
cols: bool,
to: Option<u32>,
},
/// Moving the selection by its border (cut & paste).
Move {
grab: CellRef,
@@ -441,6 +446,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);
@@ -1135,9 +1141,23 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
let on_handle = |p: Pos2| handle.contains(p) && handle_panes.iter().any(|r| r.contains(p));
let on_cur_border = |p: Pos2| on_border(cur_rect, p) && border_panes.iter().any(|r| r.contains(p));
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();
@@ -1268,6 +1288,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;
@@ -1461,6 +1486,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);
@@ -1511,6 +1539,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 {
@@ -1652,6 +1688,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)] {
+2
View File
@@ -355,6 +355,8 @@ const JAPANESE: &[(&str, &str)] = &[
("Interface language", "表示言語"),
("Interface size", "表示サイズ"),
("Auto", "自動"),
("Drag to freeze columns", "ドラッグして列を固定"),
("Drag to freeze rows", "ドラッグして行を固定"),
("Command\nPalette", "コマンド パレット"),
("Agent\nControl", "エージェント制御"),
("Action\nJournal", "操作ジャーナル"),
+2
View File
@@ -194,6 +194,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Interface language" => "인터페이스 언어",
"Interface size" => "인터페이스 크기",
"Auto" => "자동",
"Drag to freeze columns" => "끌어서 열 고정",
"Drag to freeze rows" => "끌어서 행 고정",
"Command\nPalette" => "명령\n팔레트",
"Agent\nControl" => "에이전트\n제어",
"Action\nJournal" => "작업\n기록",
+2
View File
@@ -195,6 +195,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Interface language" => "Idioma da interface",
"Interface size" => "Tamanho da interface",
"Auto" => "Automático",
"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",
+2
View File
@@ -194,6 +194,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Interface language" => "Язык интерфейса",
"Interface size" => "Размер интерфейса",
"Auto" => "Авто",
"Drag to freeze columns" => "Перетащите, чтобы закрепить столбцы",
"Drag to freeze rows" => "Перетащите, чтобы закрепить строки",
"Command\nPalette" => "Палитра\nкоманд",
"Agent\nControl" => "Управление\nагентом",
"Action\nJournal" => "Журнал\nдействий",
+2
View File
@@ -194,6 +194,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Interface language" => "界面语言",
"Interface size" => "界面大小",
"Auto" => "自动",
"Drag to freeze columns" => "拖动以冻结列",
"Drag to freeze rows" => "拖动以冻结行",
"Command\nPalette" => "命令\n面板",
"Agent\nControl" => "智能体\n控制",
"Action\nJournal" => "操作\n日志",
+76
View File
@@ -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);
}