Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions crates/engine/src/cmd/edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1557,6 +1557,63 @@ fn format_painter(s: &mut Session, p: &Json) -> Result<Json> {
let Some(src_sheet) = wb.sheet(sheet) else { return Ok(()) };
let (h, w) = (src.height(), src.width());
let dest = if dest.is_single() { RangeRef::new(dest.start, dest.start.offset_clamped(h as i64 - 1, w as i64 - 1)) } else { dest };
// Whole rows or columns take a line format, as the ribbon's formatting commands give
// them, instead of a style on each of their cells (a column alone has a million). The
// line format comes from the first cell of the source pattern across the line; cells
// already stored follow the pattern. Row heights and column widths stay as they are.
let rows = dest.is_full_rows() && !dest.is_full_cols();
let cols = dest.is_full_cols() && !dest.is_full_rows();
let src_full = (rows && src.is_full_rows()) || (cols && src.is_full_cols());
if rows || cols {
let (first, src_first, n) = if rows { (dest.start.row, src.start.row, h) } else { (dest.start.col, src.start.col, w) };
let lines = if rows { dest.start.row..=dest.end.row } else { dest.start.col..=dest.end.col };
for line in lines {
let sl = src_first + (line - first) % n;
// The source cell for position `k` along the line.
let at = |k: u32| if rows { CellRef::new(sl, src.start.col + k % w) } else { CellRef::new(src.start.row + k % h, sl) };
let line_style = if src_full {
if rows { src_sheet.rows.get(&sl) } else { src_sheet.cols.get(&sl) }.and_then(|i| i.style)
} else {
Some(src_sheet.style_id(at(0)))
};
let line_style = line_style.map(|st| if same { st } else { cx.wb.styles.intern(wb.styles.get(st).clone()) });
let (dest_line, src_line) = if rows {
(RangeRef::rows(line, line), RangeRef::rows(sl, sl))
} else {
(RangeRef::cols(line, line), RangeRef::cols(sl, sl))
};
let mut cells: Vec<CellRef> =
cx.wb.sheet(dest_sheet).map(|sh| sh.cells.iter_range(dest_line).map(|(c, _)| c).collect()).unwrap_or_default();
if src_full {
cells.extend(
src_sheet
.cells
.iter_range(src_line)
.map(|(c, _)| if rows { CellRef::new(line, c.col) } else { CellRef::new(c.row, line) }),
);
}
if cols {
// A row format outranks a column format: the column's cells in formatted
// rows need a style of their own.
cells.extend(
cx.wb
.sheet(dest_sheet)
.into_iter()
.flat_map(|sh| sh.rows.iter().filter(|(_, i)| i.style.is_some()).map(|(r, _)| CellRef::new(*r, line))),
);
}
let sh = cx.sheet_mut(dest_sheet)?;
(if rows { &mut sh.rows } else { &mut sh.cols }).entry(line).or_default().style = line_style;
for c in cells {
let st = src_sheet.style_id(at(if rows { c.col } else { c.row }));
let st = if same { st } else { cx.wb.styles.intern(wb.styles.get(st).clone()) };
cx.sheet_mut(dest_sheet)?.set_style(c, st);
cx.touch(dest_sheet, c);
}
}
*cx.sel = crate::Selection::range(dest);
return Ok(());
}
for c in dest.iter().take(2_000_000) {
let sc = CellRef::new(src.start.row + (c.row - dest.start.row) % h, src.start.col + (c.col - dest.start.col) % w);
let st = src_sheet.style_id(sc);
Expand Down
24 changes: 22 additions & 2 deletions crates/ui-egui/src/grid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2019,8 +2019,10 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
if point_header(app, wb, sh, RangeRef::cols(c, c)) {
return;
}
// An armed Format Painter paints the columns dragged over instead of moving them.
if resp.drag_started()
&& !mods.any()
&& app.session.format_painter.is_none()
&& sel.ranges.len() == 1
&& sel.current().is_full_cols()
&& sel.current().contains(CellRef::new(0, c))
Expand Down Expand Up @@ -2258,11 +2260,16 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
// Release.
if resp.drag_stopped() || (!pointer.any_down() && app.grid.drag != Drag::None && !resp.dragged()) {
match std::mem::take(&mut app.grid.drag) {
Drag::Select if resp.drag_stopped() => {
if let Some(p) = pos {
Drag::Select => {
if resp.drag_stopped()
&& let Some(p) = pos
{
select_to_pointer(app, geo, sh, wb, p);
}
format_painter_apply(app);
}
// Whole rows or columns clicked or dragged over by their headers.
Drag::Rows(_) | Drag::Cols(_) => format_painter_apply(app),
Drag::Fill { target: Some(t) } => {
let src = sel.current();
if t != src {
Expand Down Expand Up @@ -2330,6 +2337,19 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
keyboard(app, &ctx, resp, geo, sh);
}

/// An armed Format Painter pastes onto the cells just clicked or dragged over (not while
/// pointing a formula reference, which also drags a Select).
fn format_painter_apply(app: &mut SheetApp) {
if app.session.format_painter.is_some() && !app.editor.as_ref().is_some_and(|ed| ed.can_point()) {
let dest = app.session.active().map(|d| d.selection.current().a1());
if let Some(dest) = dest
&& let Err(e) = app.run("edit.formatPainter", json!({"apply": dest}))
{
app.message = Some(("GridCraft".into(), crate::clean_error(&e)));
}
}
}

/// Point mode on a header: inserts the whole-column or whole-row reference instead of selecting.
fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef) -> bool {
let Some(ed) = app.editor.as_mut().filter(|ed| ed.can_point()) else { return false };
Expand Down
1 change: 1 addition & 0 deletions crates/ui-egui/src/ribbon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) {
}
let fp_on = app.session.format_painter.is_some();
let fp = toggle_button(ui, Icon::Brush, fp_on, "Format Painter (double-click to keep it on)");
fp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), fp_on, "Format Painter"));
control_keytip(app, ui, &fp, "edit.formatPainter");
if fp.double_clicked() {
act(app, "edit.formatPainter", json!({"sticky": true}));
Expand Down
196 changes: 196 additions & 0 deletions crates/ui-egui/tests/format_painter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
//! Format Painter through the real ribbon button and a click on the grid.

use egui::accesskit::Toggled;
use egui::{Event, Modifiers, PointerButton, Pos2};
use egui_kittest::kittest::{NodeT, Queryable};
use gridcraft_engine::Session;
use gridcraft_engine::core::CellRef;
use gridcraft_engine::model::Style;
use gridcraft_ui_egui::{SheetApp, grid::Geo};
use serde_json::json;

type Harness = egui_kittest::Harness<'static, SheetApp>;

fn harness() -> Harness {
let mut session = Session::new();
session.new_workbook();
session.execute("range.setValues", json!({"range": "A1", "values": [["source"]]})).unwrap();
session.execute("home.bold", json!({"range": "A1", "on": true})).unwrap();
session.execute("home.fillColor", json!({"range": "A1", "color": "#FFFF00"})).unwrap();
session.execute("selection.set", json!({"cell": "A1"})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
SheetApp::new(session, Default::default()),
);
h.run_steps(4);
h
}

fn style(h: &Harness, a1: &str) -> Style {
let doc = h.state().session.active().unwrap();
let sheet = doc.wb.active().unwrap();
doc.wb.styles.get(sheet.style_id(CellRef::parse(a1).unwrap())).clone()
}

fn point(h: &Harness, a1: &str) -> Pos2 {
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll).cell_rect(sheet, CellRef::parse(a1).unwrap()).center()
}

fn pressed(h: &Harness) -> bool {
h.get_by_label("Format Painter").accesskit_node().toggled() == Some(Toggled::True)
}

fn click_cell(h: &mut Harness, a1: &str) {
let at = point(h, a1);
for event in [
Event::PointerMoved(at),
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE },
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE },
] {
h.input_mut().events.push(event);
h.run_steps(1);
}
h.run_steps(2);
}

#[test]
fn ribbon_button_picks_up_the_format_and_a_click_on_a_cell_pastes_it() {
let mut h = harness();
let source = style(&h, "A1");
assert!(source.font.bold);
assert_ne!(style(&h, "C3"), source);

h.get_by_label("Format Painter").click();
h.run_steps(2);
assert!(h.state().session.format_painter.is_some(), "the button must arm the painter");
assert!(pressed(&h), "the armed button must show as pressed");

click_cell(&mut h, "C3");
assert_eq!(style(&h, "C3"), source, "clicking C3 must paste A1's format");
assert!(h.state().session.format_painter.is_none(), "a single-click painter disarms after one use");
assert!(!pressed(&h));
}

fn header_point(h: &Harness, header: &str) -> Pos2 {
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
match header.parse::<u32>() {
Ok(row) => {
let cell = geo.cell_rect(sheet, CellRef::new(row - 1, 0)).center();
Pos2::new(geo.cells.left() - geo.header_w * 0.5, cell.y)
}
Err(_) => {
let cell = geo.cell_rect(sheet, CellRef::parse(&format!("{header}1")).unwrap()).center();
Pos2::new(cell.x, geo.cells.top() - geo.header_h * 0.5)
}
}
}

fn drag(h: &mut Harness, from: Pos2, to: Pos2) {
for event in [
Event::PointerMoved(from),
Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE },
Event::PointerMoved(to),
Event::PointerMoved(to + egui::vec2(1.0, 0.0)),
Event::PointerButton { pos: to + egui::vec2(1.0, 0.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE },
] {
h.input_mut().events.push(event);
h.run_steps(1);
}
h.run_steps(2);
}

fn stored_cells(h: &Harness) -> usize {
h.state().session.active().unwrap().wb.active().unwrap().cells.len()
}

#[test]
fn a_click_on_a_row_header_pastes_the_format_on_the_whole_row() {
let mut h = harness();
let source = style(&h, "A1");
h.get_by_label("Format Painter").click();
h.run_steps(2);

let at = header_point(&h, "5");
drag(&mut h, at, at);
assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "5:5");
for a1 in ["A5", "K5", "XFD5"] {
assert_eq!(style(&h, a1), source, "{a1} must take A1's format");
}
assert_ne!(style(&h, "A6"), source, "row 6 was not painted");
// The row takes a row format: its 16384 cells are not stored one by one.
assert_eq!(stored_cells(&h), 1);
assert!(h.state().session.format_painter.is_none(), "a single-click painter disarms after one use");
assert!(!pressed(&h));
}

#[test]
fn dragging_over_column_headers_with_a_kept_painter_formats_every_column() {
let mut h = harness();
let source = style(&h, "A1");
h.state_mut().session.execute("edit.formatPainter", json!({"sticky": true})).unwrap();
h.run_steps(2);

let (from, to) = (header_point(&h, "C"), header_point(&h, "E"));
drag(&mut h, from, to);
assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "C:E");
for a1 in ["C2", "D500", "E1048576"] {
assert_eq!(style(&h, a1), source, "{a1} must take A1's format");
}
for a1 in ["B2", "F2"] {
assert_ne!(style(&h, a1), source, "{a1} is outside the dragged headers");
}
assert_eq!(stored_cells(&h), 1, "whole columns take a column format, not a million cell styles");
assert!(h.state().session.format_painter.is_some(), "a double-click painter stays armed");

let at = header_point(&h, "8");
drag(&mut h, at, at);
assert_eq!(style(&h, "Z8"), source, "the kept painter paints the next header clicked too");
}

#[test]
fn dragging_from_the_header_of_the_picked_up_column_paints_instead_of_moving_it() {
let mut h = harness();
let session = &mut h.state_mut().session;
session.execute("range.setValues", json!({"range": "C1", "values": [["kept"]]})).unwrap();
session.execute("home.italic", json!({"range": "C:C", "on": true})).unwrap();
session.execute("selection.set", json!({"range": "C:C"})).unwrap();
session.execute("edit.formatPainter", json!({})).unwrap();
h.run_steps(2);
let source = style(&h, "C2");

let (from, to) = (header_point(&h, "C"), header_point(&h, "E"));
drag(&mut h, from, to);
let sheet = h.state().session.active().unwrap().wb.active().unwrap();
assert_eq!(sheet.value(CellRef::parse("C1").unwrap()), "kept".into(), "column C must stay where it is");
assert_eq!(style(&h, "E2"), source, "E must take C's format");
assert!(h.state().session.format_painter.is_none());
}

#[test]
fn a_two_cell_source_on_a_column_header_paints_a_column_format() {
let mut h = harness();
let source = style(&h, "A1");
let session = &mut h.state_mut().session;
session.execute("home.italic", json!({"range": "A2", "on": true})).unwrap();
session.execute("home.fillColor", json!({"range": "7:7", "color": "#00FF00"})).unwrap();
session.execute("selection.set", json!({"range": "A1:A2"})).unwrap();
session.execute("edit.formatPainter", json!({})).unwrap();
h.run_steps(2);
assert_eq!(stored_cells(&h), 2);

let at = header_point(&h, "C");
drag(&mut h, at, at);
assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "C:C");
assert_eq!(style(&h, "C1048575"), source, "the column takes the pattern's first cell as its format");
assert_eq!(style(&h, "C7"), source, "row 7's own format must not hide the painted column");
// Only C7 is stored on top of A1:A2: not a million cells, one per pattern repeat.
assert_eq!(stored_cells(&h), 3);
}