mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 02:36:48 -06:00
+99
-10
@@ -102,6 +102,19 @@ pub enum Drag {
|
||||
},
|
||||
}
|
||||
|
||||
/// The fill a left-drag of the handle just made, which the Auto Fill Options button can redo
|
||||
/// another way. It only applies while nothing else changed the workbook (`revision`) and the
|
||||
/// filled cells are still selected.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FillTag {
|
||||
pub src: RangeRef,
|
||||
pub target: RangeRef,
|
||||
pub uid: u64,
|
||||
pub sheet: usize,
|
||||
pub revision: u64,
|
||||
pub undo_len: usize,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct GridState {
|
||||
pub drag: Drag,
|
||||
@@ -120,6 +133,8 @@ pub struct GridState {
|
||||
pub context_menu_frame: u64,
|
||||
/// After a right-drag of the fill handle: the source, the dragged range and where to open the menu.
|
||||
pub fill_menu: Option<(RangeRef, RangeRef, Pos2)>,
|
||||
/// After a left-drag of the fill handle: the Auto Fill Options button beside the filled cells.
|
||||
pub fill_tag: Option<FillTag>,
|
||||
/// The right-clicked chart, picture, shape or slicer: where to open its menu, its kind and id.
|
||||
pub object_menu: Option<(Pos2, &'static str, u32)>,
|
||||
pub marching_phase: f32,
|
||||
@@ -792,6 +807,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
header_menu(app, ui, previous_popups.2 == app.grid.header_menu);
|
||||
context_menu(app, ui);
|
||||
fill_menu(app, ui, previous_popups.3 == app.grid.fill_menu);
|
||||
fill_options_tag(app, ui, &geo, sh);
|
||||
object_menu(app, ui);
|
||||
error_check_tag(app, ui, &geo, sh);
|
||||
let track_x = if rtl { (available.left(), rect.left()) } else { (rect.right(), available.right()) };
|
||||
@@ -2546,8 +2562,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||
if t != src {
|
||||
// Ctrl (Cmd on macOS) swaps copying and extending, as in Excel.
|
||||
let mode = if mods.command { "alternate" } else { "series" };
|
||||
if let Err(e) = app.run("edit.autoFill", json!({"source": src.a1(), "target": t.a1(), "mode": mode})) {
|
||||
app.message = Some(("GridCraft".into(), crate::clean_error(&e)));
|
||||
match app.run("edit.autoFill", json!({"source": src.a1(), "target": t.a1(), "mode": mode})) {
|
||||
Ok(_) => set_fill_tag(app, src, t),
|
||||
Err(e) => app.message = Some(("GridCraft".into(), crate::clean_error(&e))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3330,6 +3347,85 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The ways to fill a dragged range, as the fill menu and the Auto Fill Options button list them.
|
||||
const FILL_MODES: [(&str, &str); 4] =
|
||||
[("Copy Cells", "copy"), ("Fill Series", "extend"), ("Fill Formatting Only", "formats"), ("Fill Without Formatting", "values")];
|
||||
|
||||
/// Remembers the fill just made so the Auto Fill Options button can offer the other ways.
|
||||
fn set_fill_tag(app: &mut SheetApp, src: RangeRef, target: RangeRef) {
|
||||
app.grid.fill_tag =
|
||||
app.session.active().map(|d| FillTag { src, target, uid: d.uid, sheet: d.wb.active_sheet, revision: d.revision, undo_len: d.undo.len() });
|
||||
}
|
||||
|
||||
/// The Auto Fill Options button below the bottom-right corner of cells a left-drag of the fill
|
||||
/// handle just filled. Its menu redoes that fill as Copy Cells, Fill Series, Fill Formatting Only
|
||||
/// or Fill Without Formatting: it undoes the drag's fill, then runs `edit.autoFill` in that mode.
|
||||
/// The button goes away as soon as anything else changes the workbook or the selection moves.
|
||||
fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet) {
|
||||
let Some(tag) = app.grid.fill_tag.clone() else { return };
|
||||
let current = app.session.active().is_some_and(|d| {
|
||||
d.uid == tag.uid
|
||||
&& d.wb.active_sheet == tag.sheet
|
||||
&& d.revision == tag.revision
|
||||
&& d.undo.len() == tag.undo_len
|
||||
&& d.selection.ranges.len() == 1
|
||||
&& d.selection.ranges.first().is_some_and(|r| *r == tag.target)
|
||||
});
|
||||
if !current {
|
||||
app.grid.fill_tag = None;
|
||||
return;
|
||||
}
|
||||
if app.editor.is_some() || app.dialog.is_some() || app.grid.drag != Drag::None {
|
||||
return;
|
||||
}
|
||||
let size = vec2(18.0, 18.0);
|
||||
let corner = geo.range_rect(sh, tag.target).right_bottom();
|
||||
let button = geo.mirror_rect(Rect::from_min_size(corner + vec2(2.0, 2.0), size));
|
||||
if !geo.cells.contains_rect(button) {
|
||||
return;
|
||||
}
|
||||
let lang = crate::i18n::current(ui.ctx());
|
||||
let resp = ui.interact(button, egui::Id::new("gridcraft.fill_options_tag"), Sense::click()).on_hover_text(lang.tr("Auto Fill Options"));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, lang.tr("Auto Fill Options")));
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let p = ui.painter();
|
||||
p.rect_filled(button, 3.0, if resp.hovered() { t.hover } else { t.window });
|
||||
p.rect_stroke(button, 3.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside);
|
||||
// Two small cells and a drop-down arrow.
|
||||
let c = button.center();
|
||||
p.rect_stroke(Rect::from_min_size(pos2(c.x - 6.0, c.y - 6.0), vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside);
|
||||
p.rect_stroke(Rect::from_min_size(pos2(c.x - 3.0, c.y - 3.0), vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside);
|
||||
p.add(egui::Shape::convex_polygon(
|
||||
vec![pos2(c.x + 1.0, c.y + 5.0), pos2(c.x + 7.0, c.y + 5.0), pos2(c.x + 4.0, c.y + 8.0)],
|
||||
t.text,
|
||||
Stroke::NONE,
|
||||
));
|
||||
let mut chosen = None;
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
ui.set_min_width(190.0);
|
||||
for (label, mode) in FILL_MODES {
|
||||
if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(180.0, 20.0))).clicked() {
|
||||
chosen = Some(mode);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(mode) = chosen {
|
||||
if let Err(e) = app.run("edit.undo", json!({})) {
|
||||
app.grid.fill_tag = None;
|
||||
app.message = Some(("GridCraft".into(), crate::clean_error(&e)));
|
||||
return;
|
||||
}
|
||||
match app.run("edit.autoFill", json!({"source": tag.src.a1(), "target": tag.target.a1(), "mode": mode})) {
|
||||
Ok(_) => set_fill_tag(app, tag.src, tag.target),
|
||||
Err(e) => {
|
||||
app.grid.fill_tag = None;
|
||||
app.message = Some(("GridCraft".into(), crate::clean_error(&e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu a right-drag of the fill handle opens on release, as in Excel.
|
||||
fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) {
|
||||
let Some((src, target, at)) = app.grid.fill_menu else { return };
|
||||
@@ -3337,14 +3433,7 @@ fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) {
|
||||
let response = egui::Area::new(egui::Id::new("fill_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_min_width(190.0);
|
||||
let items = [
|
||||
("Copy Cells", "copy"),
|
||||
("Fill Series", "extend"),
|
||||
("Fill Formatting Only", "formats"),
|
||||
("Fill Without Formatting", "values"),
|
||||
("-", ""),
|
||||
("Series…", "ui:series"),
|
||||
];
|
||||
let items = [FILL_MODES[0], FILL_MODES[1], FILL_MODES[2], FILL_MODES[3], ("-", ""), ("Series…", "ui:series")];
|
||||
let lang = crate::i18n::current(ui.ctx());
|
||||
for (label, mode) in items {
|
||||
if label == "-" {
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
//! After a left-drag of the fill handle, the Auto Fill Options button redoes the fill as Copy Cells,
|
||||
//! Fill Series, Fill Formatting Only or Fill Without Formatting (#1403).
|
||||
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::{CellRef, Value};
|
||||
use gridcraft_ui_egui::{SheetApp, grid::Geo};
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn harness(setup: impl FnOnce(&mut Session)) -> Harness {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
setup(&mut session);
|
||||
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
|
||||
}
|
||||
|
||||
/// B1:B2 holds 1, 2 and is selected.
|
||||
fn one_two(s: &mut Session) {
|
||||
s.execute("range.setValues", json!({"range": "B1", "values": [[1], [2]]})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "B1:B2"})).unwrap();
|
||||
}
|
||||
|
||||
fn corner(h: &Harness, cell: &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);
|
||||
geo.mirror_pos(geo.cell_rect(sheet, CellRef::parse(cell).unwrap()).right_bottom() - vec2(1.0, 1.0))
|
||||
}
|
||||
|
||||
fn center(h: &Harness, cell: &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);
|
||||
geo.screen_cell_rect(sheet, CellRef::parse(cell).unwrap()).center()
|
||||
}
|
||||
|
||||
fn press(h: &mut Harness, pos: Pos2, button: PointerButton) {
|
||||
h.input_mut().events.extend([Event::PointerMoved(pos), Event::PointerButton { pos, button, pressed: true, modifiers: Modifiers::NONE }]);
|
||||
h.step();
|
||||
}
|
||||
|
||||
fn move_to(h: &mut Harness, from: Pos2, to: Pos2) {
|
||||
for i in 1..=10 {
|
||||
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 10.0)));
|
||||
h.step();
|
||||
}
|
||||
}
|
||||
|
||||
fn release(h: &mut Harness, pos: Pos2, button: PointerButton) {
|
||||
h.input_mut().events.push(Event::PointerButton { pos, button, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
/// Drags the handle of the current selection (whose bottom-right cell is `from`) to `to`.
|
||||
fn drag_handle(h: &mut Harness, from: &str, to: &str, button: PointerButton) {
|
||||
let (start, end) = (corner(h, from), center(h, to));
|
||||
press(h, start, button);
|
||||
move_to(h, start, end);
|
||||
release(h, end, button);
|
||||
}
|
||||
|
||||
fn value(h: &Harness, cell: &str) -> Value {
|
||||
h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap())
|
||||
}
|
||||
|
||||
fn values(h: &Harness, cells: &[&str]) -> Vec<Value> {
|
||||
cells.iter().map(|c| value(h, c)).collect()
|
||||
}
|
||||
|
||||
fn bold(h: &Harness, cell: &str) -> bool {
|
||||
let d = h.state().session.active().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
d.wb.styles.get(sh.style_id(CellRef::parse(cell).unwrap())).font.bold
|
||||
}
|
||||
|
||||
fn italic(h: &Harness, cell: &str) -> bool {
|
||||
let d = h.state().session.active().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
d.wb.styles.get(sh.style_id(CellRef::parse(cell).unwrap())).font.italic
|
||||
}
|
||||
|
||||
fn selection(h: &Harness) -> String {
|
||||
h.state().session.active().unwrap().selection.a1()
|
||||
}
|
||||
|
||||
fn n(x: f64) -> Value {
|
||||
Value::Number(x)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_drag_shows_the_auto_fill_options_button() {
|
||||
let mut h = harness(one_two);
|
||||
assert!(h.query_by_label("Auto Fill Options").is_none());
|
||||
drag_handle(&mut h, "B2", "B6", PointerButton::Primary);
|
||||
|
||||
assert_eq!(values(&h, &["B3", "B6"]), [n(3.0), n(6.0)]);
|
||||
assert!(h.query_by_label("Auto Fill Options").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_cells_replaces_the_series_the_drag_made() {
|
||||
let mut h = harness(one_two);
|
||||
let steps_before = h.state().session.active().unwrap().undo.len();
|
||||
drag_handle(&mut h, "B2", "B6", PointerButton::Primary);
|
||||
h.get_by_label("Auto Fill Options").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Copy Cells").click();
|
||||
h.run_steps(3);
|
||||
|
||||
assert_eq!(values(&h, &["B3", "B4", "B5", "B6"]), [n(1.0), n(2.0), n(1.0), n(2.0)]);
|
||||
assert_eq!(selection(&h), "B1:B6");
|
||||
assert_eq!(h.state().session.active().unwrap().undo.len(), steps_before + 1, "one fill step, not two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lone_number_copies_on_drag_and_counts_up_with_fill_series() {
|
||||
let mut h = harness(|s| {
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "5"})).unwrap();
|
||||
s.execute("selection.set", json!({"cell": "B1"})).unwrap();
|
||||
});
|
||||
drag_handle(&mut h, "B1", "B4", PointerButton::Primary);
|
||||
assert_eq!(values(&h, &["B2", "B3", "B4"]), [n(5.0), n(5.0), n(5.0)]);
|
||||
h.get_by_label("Auto Fill Options").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Fill Series").click();
|
||||
h.run_steps(3);
|
||||
|
||||
assert_eq!(values(&h, &["B2", "B3", "B4"]), [n(6.0), n(7.0), n(8.0)]);
|
||||
}
|
||||
|
||||
fn formatted(s: &mut Session) {
|
||||
s.execute("range.setValues", json!({"range": "B1", "values": [[1], [2], [null], [9]]})).unwrap();
|
||||
s.execute("home.bold", json!({"range": "B1:B2", "on": true})).unwrap();
|
||||
s.execute("home.italic", json!({"range": "B4", "on": true})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "B1:B2"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_without_formatting_keeps_the_targets_formats() {
|
||||
let mut h = harness(formatted);
|
||||
drag_handle(&mut h, "B2", "B4", PointerButton::Primary);
|
||||
h.get_by_label("Auto Fill Options").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Fill Without Formatting").click();
|
||||
h.run_steps(3);
|
||||
|
||||
assert_eq!(values(&h, &["B3", "B4"]), [n(3.0), n(4.0)]);
|
||||
assert!(!bold(&h, "B3") && !bold(&h, "B4"));
|
||||
assert!(italic(&h, "B4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_button_goes_away_once_the_selection_moves() {
|
||||
let mut h = harness(one_two);
|
||||
drag_handle(&mut h, "B2", "B6", PointerButton::Primary);
|
||||
assert!(h.query_by_label("Auto Fill Options").is_some());
|
||||
let away = center(&h, "E9");
|
||||
h.input_mut().events.push(Event::PointerMoved(away));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: away, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.input_mut().events.push(Event::PointerButton { pos: away, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
|
||||
assert!(h.query_by_label("Auto Fill Options").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_drag_makes_no_button() {
|
||||
let mut h = harness(one_two);
|
||||
drag_handle(&mut h, "B2", "B6", PointerButton::Secondary);
|
||||
assert!(h.query_by_label("Auto Fill Options").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_button_goes_away_once_the_workbook_changes() {
|
||||
let mut h = harness(one_two);
|
||||
drag_handle(&mut h, "B2", "B6", PointerButton::Primary);
|
||||
assert!(h.query_by_label("Auto Fill Options").is_some());
|
||||
h.state_mut().session.execute("cell.set", json!({"cell": "D1", "input": "x"})).unwrap();
|
||||
h.run_steps(3);
|
||||
|
||||
assert_eq!(selection(&h), "B1:B6", "the selection did not move");
|
||||
assert!(h.query_by_label("Auto Fill Options").is_none(), "its undo would take the unrelated edit with it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_button_stays_inside_the_grid_on_a_right_to_left_sheet() {
|
||||
let mut h = harness(|s| {
|
||||
one_two(s);
|
||||
s.execute("view.rightToLeft", json!({"on": true})).unwrap();
|
||||
});
|
||||
drag_handle(&mut h, "B2", "B6", PointerButton::Primary);
|
||||
|
||||
let button = h.get_by_label("Auto Fill Options").rect();
|
||||
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);
|
||||
assert!(geo.cells.contains_rect(button), "{button:?} outside {:?}", geo.cells);
|
||||
let filled = geo.screen_cell_rect(sheet, CellRef::parse("B6").unwrap());
|
||||
assert!(button.center().x < filled.left() && button.top() >= filled.bottom() - 1.0, "below-left of the mirrored range's corner");
|
||||
}
|
||||
Reference in New Issue
Block a user