mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
Merge PR #1652: Fill handle: right-drag opens the fill menu (Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…)
crates/ui-egui/src/grid.rs: kept both GridState fields (context_menu_frame, fill_menu); previous_popups tuple has four elements inside the touch_pan branch; release match has Drag::Rows|Cols, then the two Drag::Fill arms docs/ui-parity.md: kept HEAD header line, both change rows
This commit is contained in:
@@ -79,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"AutoFill",
|
||||
[],
|
||||
None,
|
||||
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies)",
|
||||
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values|extend}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies; values = Fill Without Formatting: the series, targets keep their formats; extend = Fill Series: the series, and a lone number counts up)",
|
||||
has_doc,
|
||||
auto_fill
|
||||
),
|
||||
@@ -1403,6 +1403,7 @@ fn auto_fill(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
"alternate" => crate::fill::FillMode::Alternate,
|
||||
"formats" => crate::fill::FillMode::Formats,
|
||||
"values" => crate::fill::FillMode::ValuesOnly,
|
||||
"extend" => crate::fill::FillMode::Extend,
|
||||
_ => crate::fill::FillMode::Series,
|
||||
};
|
||||
if target.count() > 5_000_000 || src.count() > 5_000_000 {
|
||||
|
||||
@@ -19,7 +19,17 @@ pub enum FillMode {
|
||||
Alternate,
|
||||
Copy,
|
||||
Formats,
|
||||
/// Fill Without Formatting: extends like a plain drag, the targets keep their formats.
|
||||
ValuesOnly,
|
||||
/// Fill Series from the right-drag menu: extends like a plain drag, and a lone number counts up.
|
||||
Extend,
|
||||
}
|
||||
|
||||
impl FillMode {
|
||||
/// The modes that continue the source's series rather than copy it.
|
||||
fn extends_series(self) -> bool {
|
||||
matches!(self, FillMode::Series | FillMode::Extend | FillMode::ValuesOnly)
|
||||
}
|
||||
}
|
||||
|
||||
/// Built-in custom lists.
|
||||
@@ -67,15 +77,18 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
|
||||
cols.into_iter().map(|c| CellRef::new(lane, c)).collect()
|
||||
};
|
||||
let fmt = |c: &(CellRef, Option<Cell>)| cx.wb.styles.get(c.1.as_ref().map(|c| c.style).unwrap_or_default()).num_fmt.as_str();
|
||||
let pattern = if matches!(mode, FillMode::Series | FillMode::Alternate) {
|
||||
let pattern = if mode.extends_series() || mode == FillMode::Alternate {
|
||||
let detected = Pattern::detect(&src_cells, &custom, cx.wb.date_system, src_cells.first().map(fmt).unwrap_or_default());
|
||||
if mode == FillMode::Alternate { detected.alternate(&src_cells) } else { detected }
|
||||
match (mode, detected) {
|
||||
(FillMode::Alternate, p) | (FillMode::Extend, p @ Pattern::Copy) => p.alternate(&src_cells),
|
||||
(_, p) => p,
|
||||
}
|
||||
} else {
|
||||
Pattern::Copy
|
||||
};
|
||||
// A source with no common pattern (`Product 1`, `On backorder`): each cell continues its
|
||||
// own series, one step per repeat of the source.
|
||||
let per_cell: Vec<Pattern> = if mode == FillMode::Series && matches!(pattern, Pattern::Copy) && src_cells.len() > 1 {
|
||||
let per_cell: Vec<Pattern> = if mode.extends_series() && matches!(pattern, Pattern::Copy) && src_cells.len() > 1 {
|
||||
src_cells.iter().map(|c| Pattern::detect(std::slice::from_ref(c), &custom, cx.wb.date_system, fmt(c))).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
@@ -93,7 +106,7 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
|
||||
writes.push((*t, Some(existing)));
|
||||
continue;
|
||||
}
|
||||
FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy => {
|
||||
FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy | FillMode::Extend => {
|
||||
let style = if mode == FillMode::ValuesOnly { sh.cell(*t).map(|c| c.style).unwrap_or_default() } else { cell.style };
|
||||
if let Some(f) = &cell.formula {
|
||||
if let Some(e) = f.expr() {
|
||||
|
||||
@@ -783,6 +783,43 @@ fn ctrl_drag_fill_swaps_copy_and_series() {
|
||||
assert_eq!(v(&s, "G3"), Value::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_series_from_the_right_drag_menu_counts_a_lone_number() {
|
||||
// Excel's right-drag Fill Series: a lone number counts up, everything else extends as a plain drag.
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "7"})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": "A1", "target": "A1:A3", "mode": "extend"})).unwrap();
|
||||
assert_eq!([v(&s, "A2"), v(&s, "A3")], [Value::Number(8.0), Value::Number(9.0)]);
|
||||
s.execute("range.setValues", json!({"range": "B1", "values": [[1], [3]]})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": "B1:B2", "target": "B1:B4", "mode": "extend"})).unwrap();
|
||||
assert_eq!([v(&s, "B3"), v(&s, "B4")], [Value::Number(5.0), Value::Number(7.0)]);
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": "Item 9"})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": "C1", "target": "C1:C2", "mode": "extend"})).unwrap();
|
||||
assert_eq!(v(&s, "C2"), Value::from("Item 10"));
|
||||
// A source with no common pattern continues each cell's own series, as a plain drag does.
|
||||
s.execute("range.setValues", json!({"range": "D1", "values": [["Product 1"], ["On backorder"]]})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": "D1:D2", "target": "D1:D4", "mode": "extend"})).unwrap();
|
||||
assert_eq!([v(&s, "D3"), v(&s, "D4")], [Value::from("Product 2"), Value::from("On backorder")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_without_formatting_extends_the_series_and_keeps_the_target_formats() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [null], [9]]})).unwrap();
|
||||
s.execute("home.bold", json!({"range": "A1:A2", "on": true})).unwrap();
|
||||
s.execute("home.italic", json!({"range": "A4", "on": true})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": "A1:A2", "target": "A1:A4", "mode": "values"})).unwrap();
|
||||
assert_eq!([v(&s, "A3"), v(&s, "A4")], [Value::Number(3.0), Value::Number(4.0)]);
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
let a4 = d.wb.styles.get(sh.style_id(CellRef::parse("A4").unwrap()));
|
||||
assert!(a4.font.italic && !a4.font.bold, "A4 keeps its own formats");
|
||||
assert!(!d.wb.styles.get(sh.style_id(CellRef::parse("A3").unwrap())).font.bold);
|
||||
s.execute("range.setValues", json!({"range": "B1", "values": [["Product 1"], ["On backorder"]]})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": "B1:B2", "target": "B1:B4", "mode": "values"})).unwrap();
|
||||
assert_eq!([v(&s, "B3"), v(&s, "B4")], [Value::from("Product 2"), Value::from("On backorder")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_delete_cells_adjust() {
|
||||
let formula = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["formula"].clone();
|
||||
|
||||
@@ -336,7 +336,7 @@ impl Dialog {
|
||||
Number { key: "step", label: "Step value:" },
|
||||
Text { key: "stop", label: "Stop value:" },
|
||||
],
|
||||
json!({"direction": "columns", "type": "linear", "dateUnit": "day", "step": 1}),
|
||||
json!({"direction": if p("direction") == "rows" { "rows" } else { "columns" }, "type": "linear", "dateUnit": "day", "step": 1}),
|
||||
),
|
||||
"comment" => {
|
||||
let threaded = p("threaded").as_bool().unwrap_or(true);
|
||||
|
||||
@@ -78,8 +78,10 @@ pub enum Drag {
|
||||
start: f32,
|
||||
orig: f32,
|
||||
},
|
||||
/// Dragging the fill handle; `menu` when the right button drags it (the fill menu opens on release).
|
||||
Fill {
|
||||
target: Option<RangeRef>,
|
||||
menu: bool,
|
||||
},
|
||||
/// Dragging a freeze handle: `cols` for the column bar, `to` the line count it would freeze.
|
||||
Freeze {
|
||||
@@ -116,6 +118,8 @@ pub struct GridState {
|
||||
pub context_menu: Option<Pos2>,
|
||||
/// The frame a right-click opened `context_menu` in: that click's own press doesn't close it.
|
||||
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)>,
|
||||
pub marching_phase: f32,
|
||||
/// The selection outline on its way to a new selection.
|
||||
pub glide: crate::selection_glide::SelectionGlide,
|
||||
@@ -760,7 +764,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
crate::paper_view::paint_rulers(app, &painter, rect, &geo, &t);
|
||||
}
|
||||
// Interaction.
|
||||
let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu);
|
||||
let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu, app.grid.fill_menu);
|
||||
// A pan the grid never saw end (it was hidden mid-drag) must not swallow the next press.
|
||||
if app.grid.touch_pan && !resp.dragged() && !resp.drag_stopped() {
|
||||
app.grid.touch_pan = false;
|
||||
@@ -782,6 +786,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
list_picker(app, ui, &geo, previous_popups.1 == app.grid.list_picker);
|
||||
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);
|
||||
let track_x = if rtl { (available.left(), rect.left()) } else { (rect.right(), available.right()) };
|
||||
vertical_scrollbar(app, ui, sh, &geo, track_x, max_y, &t);
|
||||
if app.session.clipboard.is_some() {
|
||||
@@ -1731,7 +1736,7 @@ pub(crate) fn paint_selection(
|
||||
p.rect_filled(h, 0.0, t.sel_border);
|
||||
}
|
||||
// Fill-handle drag preview.
|
||||
if let crate::grid::Drag::Fill { target: Some(tr) } = &app.grid.drag
|
||||
if let crate::grid::Drag::Fill { target: Some(tr), .. } = &app.grid.drag
|
||||
&& range_in_pane(*tr, rows, cols)
|
||||
{
|
||||
let rr = geo.range_rect(sh, *tr);
|
||||
@@ -2225,7 +2230,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||
return;
|
||||
}
|
||||
if on_handle(p) {
|
||||
app.grid.drag = Drag::Fill { target: None };
|
||||
app.grid.drag = Drag::Fill { target: None, menu: resp.drag_started_by(egui::PointerButton::Secondary) };
|
||||
return;
|
||||
}
|
||||
if on_cur_border(p) && resp.drag_started() {
|
||||
@@ -2360,10 +2365,10 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||
Drag::Freeze { cols, .. } => {
|
||||
app.grid.drag = Drag::Freeze { cols, to: Some(freeze_boundary(geo, sh, p, cols)) };
|
||||
}
|
||||
Drag::Fill { .. } => {
|
||||
Drag::Fill { menu, .. } => {
|
||||
let src = sel.current();
|
||||
let target = fill_target(src, pc);
|
||||
app.grid.drag = Drag::Fill { target: Some(target) };
|
||||
app.grid.drag = Drag::Fill { target: Some(target), menu };
|
||||
scroll_by(app, auto);
|
||||
}
|
||||
Drag::Move { grab, .. } => {
|
||||
@@ -2405,7 +2410,17 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||
}
|
||||
// Whole rows or columns clicked or dragged over by their headers.
|
||||
Drag::Rows(_) | Drag::Cols(_) => format_painter_apply(app),
|
||||
Drag::Fill { target: Some(t) } => {
|
||||
// Esc mid-drag lands here too (egui ends the drag on Esc): no menu, and the key is
|
||||
// spent on the drag so the grid's Esc doesn't also drop the copy.
|
||||
Drag::Fill { target: Some(t), menu: true } => {
|
||||
if let Some(p) = pos
|
||||
&& t != sel.current()
|
||||
&& !ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape))
|
||||
{
|
||||
app.grid.fill_menu = Some((sel.current(), t, geo.mirror_pos(p)));
|
||||
}
|
||||
}
|
||||
Drag::Fill { target: Some(t), menu: false } => {
|
||||
let src = sel.current();
|
||||
if t != src {
|
||||
// Ctrl (Cmd on macOS) swaps copying and extending, as in Excel.
|
||||
@@ -2760,7 +2775,16 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
|
||||
}
|
||||
let other_focus = ctx.memory(|m| m.focused()).is_some_and(|f| f != resp.id && (editing || !stale.contains(&f)));
|
||||
// An open filter menu owns the keyboard even when a click inside it left focus on nothing (#1516).
|
||||
if editing || app.dialog.is_some() || other_focus || app.message.is_some() || app.grid.filter_menu.is_some() {
|
||||
// So do the fill menu (its Esc closes it, not the copy) and the right-drag that opens it (an
|
||||
// arrow key would move the selection the menu fills from).
|
||||
if editing
|
||||
|| app.dialog.is_some()
|
||||
|| other_focus
|
||||
|| app.message.is_some()
|
||||
|| app.grid.filter_menu.is_some()
|
||||
|| app.grid.fill_menu.is_some()
|
||||
|| matches!(app.grid.drag, Drag::Fill { menu: true, .. })
|
||||
{
|
||||
return;
|
||||
}
|
||||
if ctx.memory(|m| m.focused()).is_none() {
|
||||
@@ -3130,6 +3154,45 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 };
|
||||
let mut close = false;
|
||||
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 lang = crate::i18n::current(ui.ctx());
|
||||
for (label, mode) in items {
|
||||
if label == "-" {
|
||||
ui.separator();
|
||||
continue;
|
||||
}
|
||||
if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(180.0, 20.0))).clicked() {
|
||||
close = true;
|
||||
if mode == "ui:series" {
|
||||
let _ = app.session.run("selection.set", json!({"range": target.union(&src).a1(), "active": src.start.a1()}));
|
||||
let direction = if target.width() != src.width() { "rows" } else { "columns" };
|
||||
app.open_dialog("series", json!({"direction": direction}));
|
||||
} else if let Err(e) = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1(), "mode": mode})) {
|
||||
app.message = Some(("GridCraft".into(), crate::clean_error(&e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if close || popup_dismissed(&response.response, was_open) {
|
||||
app.grid.fill_menu = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Used by `EditState` users to keep the active cell visible while pointing.
|
||||
pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, extend: bool) {
|
||||
let Some(d) = app.session.active() else { return };
|
||||
|
||||
@@ -1250,6 +1250,7 @@ impl SheetApp {
|
||||
&& !ctx.text_edit_focused()
|
||||
&& self.grid.context_menu.is_none()
|
||||
&& self.grid.header_menu.is_none()
|
||||
&& self.grid.fill_menu.is_none()
|
||||
&& self.grid.filter_menu.is_none()
|
||||
&& self.grid.list_picker.is_none()
|
||||
&& self.grid.renaming_tab.is_none()
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
//! Dragging the fill handle with the right button fills nothing on release: it opens Excel's fill
|
||||
//! menu (Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…).
|
||||
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2, vec2};
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::{CellRef, RangeRef, Value};
|
||||
use gridcraft_ui_egui::{SheetApp, grid::Drag, 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.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.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);
|
||||
}
|
||||
|
||||
fn escape(h: &mut Harness) {
|
||||
h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
}
|
||||
|
||||
/// 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 fills(h: &Harness) -> usize {
|
||||
h.state().session.journal.iter().filter(|(id, _)| id == "edit.autoFill").count()
|
||||
}
|
||||
|
||||
fn selection(h: &Harness) -> String {
|
||||
h.state().session.active().unwrap().selection.a1()
|
||||
}
|
||||
|
||||
fn n(x: f64) -> Value {
|
||||
Value::Number(x)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_drag_opens_the_fill_menu_and_fills_nothing() {
|
||||
let mut h = harness(one_two);
|
||||
drag_handle(&mut h, "B2", "B6", PointerButton::Secondary);
|
||||
|
||||
let menu = h.state().grid.fill_menu.map(|(src, target, _)| (src, target));
|
||||
assert_eq!(menu, Some((RangeRef::parse("B1:B2").unwrap(), RangeRef::parse("B1:B6").unwrap())));
|
||||
assert_eq!(values(&h, &["B3", "B6"]), [Value::Empty, Value::Empty]);
|
||||
assert_eq!(fills(&h), 0);
|
||||
assert!(h.state().grid.context_menu.is_none(), "not the cell context menu");
|
||||
assert_eq!(selection(&h), "B1:B2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_drag_still_fills_the_series() {
|
||||
let mut h = harness(one_two);
|
||||
drag_handle(&mut h, "B2", "B6", PointerButton::Primary);
|
||||
|
||||
assert_eq!(values(&h, &["B3", "B4", "B5", "B6"]), [n(3.0), n(4.0), n(5.0), n(6.0)]);
|
||||
assert!(h.state().grid.fill_menu.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_cells_repeats_the_source() {
|
||||
let mut h = harness(one_two);
|
||||
drag_handle(&mut h, "B2", "B6", PointerButton::Secondary);
|
||||
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!(h.state().grid.fill_menu.is_none());
|
||||
assert_eq!(selection(&h), "B1:B6");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_series_counts_up_from_a_lone_number() {
|
||||
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::Secondary);
|
||||
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)]);
|
||||
}
|
||||
|
||||
/// B1:B2 = 1, 2 in bold, B4 = 9 in italic.
|
||||
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_formatting_only_leaves_the_values() {
|
||||
let mut h = harness(formatted);
|
||||
drag_handle(&mut h, "B2", "B4", PointerButton::Secondary);
|
||||
h.get_by_label("Fill Formatting Only").click();
|
||||
h.run_steps(3);
|
||||
|
||||
assert_eq!(values(&h, &["B3", "B4"]), [Value::Empty, n(9.0)]);
|
||||
assert!(bold(&h, "B3") && bold(&h, "B4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_without_formatting_extends_the_series_under_the_targets_formats() {
|
||||
let mut h = harness(formatted);
|
||||
drag_handle(&mut h, "B2", "B4", PointerButton::Secondary);
|
||||
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"), "the source's bold stays behind");
|
||||
assert!(italic(&h, "B4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn series_opens_the_series_dialog_on_the_dragged_cells_in_their_direction() {
|
||||
let mut h = harness(|s| {
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "1"})).unwrap();
|
||||
s.execute("selection.set", json!({"cell": "B1"})).unwrap();
|
||||
});
|
||||
drag_handle(&mut h, "B1", "E1", PointerButton::Secondary);
|
||||
h.get_by_label("Series…").click();
|
||||
h.run_steps(3);
|
||||
|
||||
assert_eq!(selection(&h), "B1:E1");
|
||||
let dialog = h.state().dialog.as_ref().map(|d| (d.name().to_string(), d.values.get("direction").cloned()));
|
||||
assert_eq!(dialog, Some(("series".into(), Some(json!("rows")))));
|
||||
h.get_by_label(" OK ").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(values(&h, &["C1", "D1", "E1"]), [n(2.0), n(3.0), n(4.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_mid_right_drag_cancels_it() {
|
||||
let mut h = harness(|s| {
|
||||
one_two(s);
|
||||
s.execute("edit.copy", json!({"range": "D1"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "B1:B2"})).unwrap();
|
||||
});
|
||||
assert!(h.state().session.clipboard.is_some());
|
||||
let (start, end) = (corner(&h, "B2"), center(&h, "B6"));
|
||||
press(&mut h, start, PointerButton::Secondary);
|
||||
move_to(&mut h, start, end);
|
||||
escape(&mut h);
|
||||
assert_eq!(h.state().grid.drag, Drag::None, "the dashed outline goes");
|
||||
assert!(h.query_by_label("Copy Cells").is_none(), "the menu doesn't flash for the Esc frame");
|
||||
release(&mut h, end, PointerButton::Secondary);
|
||||
|
||||
assert!(h.state().grid.fill_menu.is_none());
|
||||
assert!(h.state().grid.context_menu.is_none());
|
||||
assert_eq!(values(&h, &["B3", "B6"]), [Value::Empty, Value::Empty]);
|
||||
assert!(h.state().session.clipboard.is_some(), "Esc cancels the drag, not the copy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_keys_mid_right_drag_leave_the_source_alone() {
|
||||
let mut h = harness(one_two);
|
||||
let (start, end) = (corner(&h, "B2"), center(&h, "B6"));
|
||||
press(&mut h, start, PointerButton::Secondary);
|
||||
move_to(&mut h, start, end);
|
||||
h.input_mut().events.push(Event::Key { key: Key::ArrowDown, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
release(&mut h, end, PointerButton::Secondary);
|
||||
|
||||
assert_eq!(selection(&h), "B1:B2");
|
||||
assert_eq!(h.state().grid.fill_menu.map(|(src, ..)| src), Some(RangeRef::parse("B1:B2").unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_closes_the_menu_without_filling() {
|
||||
let mut h = harness(|s| {
|
||||
one_two(s);
|
||||
s.execute("edit.copy", json!({"range": "D1"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "B1:B2"})).unwrap();
|
||||
});
|
||||
drag_handle(&mut h, "B2", "B6", PointerButton::Secondary);
|
||||
assert!(h.state().grid.fill_menu.is_some());
|
||||
escape(&mut h);
|
||||
h.run_steps(2);
|
||||
|
||||
assert!(h.state().grid.fill_menu.is_none());
|
||||
assert_eq!(values(&h, &["B3", "B6"]), [Value::Empty, Value::Empty]);
|
||||
assert!(h.state().session.clipboard.is_some(), "Esc closes the menu, not the copy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_click_on_the_handle_opens_the_context_menu() {
|
||||
let mut h = harness(one_two);
|
||||
let at = corner(&h, "B2");
|
||||
press(&mut h, at, PointerButton::Secondary);
|
||||
release(&mut h, at, PointerButton::Secondary);
|
||||
|
||||
assert!(h.state().grid.context_menu.is_some());
|
||||
assert!(h.state().grid.fill_menu.is_none());
|
||||
}
|
||||
+2
-1
@@ -45,7 +45,7 @@ this logic, with one undo step.
|
||||
|---|---|
|
||||
| Click/drag/Shift/Ctrl selection, multi-area selection | rectangular drag works in one direction only in some cases (#28) |
|
||||
| Selection outline glides to a new selection (Excel: "Provide feedback with animation") | done (10-11): 0.12 s, decelerating, with the fill and fill handle; View ▸ Animations (`view.animations`) turns it off. The timing is an estimate, not yet measured against Excel; the system's reduced-motion setting isn't followed yet |
|
||||
| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); right-drag menu missing |
|
||||
| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); right-drag menu partial: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…; Fill Days/Weekdays/Months/Years, Linear/Growth Trend and Flash Fill missing |
|
||||
| Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) |
|
||||
| In-cell editing, Enter/Tab movement, Alt/Option+Enter line breaks | done |
|
||||
| Number-format colors (`[Green]`, `[Color1]`–`[Color56]`) | Rendered in grid and PDF from shared RGB mappings (#1452) |
|
||||
@@ -165,6 +165,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
|
||||
|---|---|---|
|
||||
| 2026-10-19 | minor | Ctrl+Enter (`range.fill`) gives each cell what typing the input there gives: a General cell gets the automatic number format (`5%`, `$5`, `3/4/2026`, `=TODAY()`) even when it already held a value, and a cell with its own format, a formatted row or column, or Text keeps it. Cells that held a value used to keep General (`5%` showed 0.05), and empty cells took the active cell's format |
|
||||
| 2026-10-18 | minor | Keytip letters typed with left Alt held continue the sequence (Alt+H, O, A / Alt+H, O, I ran nothing before: Alt+letter cancelled keytips); other Alt combinations, AltGr and macOS Option are unchanged (#1846) |
|
||||
| 2026-10-16 | minor | Right-drag on the fill handle opens the fill menu on release: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…; Esc cancels the drag or closes the menu without filling |
|
||||
| 2026-10-16 | minor | With the active cell in a Table whose header row is scrolled out of view, the column headings show the header names and their filter buttons in place of the letters (#1423) |
|
||||
| 2026-10-15 | minor | Number-format named colors use their RGB values; indexed `[Color1]`–`[Color56]` resolve through the shared legacy palette in grid and PDF (#1452) |
|
||||
| 2026-10-14 | minor | Spill-child formula bar uses the anchor formula with disabled theme text and blocks formula-bar edit entry; selection and recalculation restore ordinary behavior |
|
||||
|
||||
Reference in New Issue
Block a user