Grid: freeze panes by dragging the freeze handles (#195)
Like Excel for the web, a bar in the upper part of the column-header strip and one in the left part of the row-header strip sit on the frozen panes' edges (the corner's edges when nothing is frozen). Dragging one previews the line and, on release, freezes up to the nearest column or row boundary through view.freezePanes; dragging back to the corner unfreezes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9f896c1249
commit
33f10d0e6e
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -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)] {
|
||||
|
|
|
|||
|
|
@ -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", "操作ジャーナル"),
|
||||
|
|
|
|||
|
|
@ -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기록",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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действий",
|
||||
|
|
|
|||
|
|
@ -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日志",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue