# Conflicts:
#	docs/ui-parity.md
This commit is contained in:
Brandon Thomas
2026-10-23 10:11:33 -04:00
3 changed files with 313 additions and 13 deletions
+99 -10
View File
@@ -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 == "-" {
+211
View File
@@ -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");
}