mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-26 11:46:49 -06:00
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:
@@ -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 |
|
||||
|
||||
@@ -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)] {
|
||||
|
||||
@@ -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", "操作ジャーナル"),
|
||||
|
||||
@@ -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기록",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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действий",
|
||||
|
||||
@@ -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日志",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user