diff --git a/ROADMAP.md b/ROADMAP.md index 1189fd7..062170e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-11 | Ctrl+drag on the fill handle swaps copying and extending a series (#264) | | 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 | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index bb54fe0..35d9030 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -79,7 +79,7 @@ pub fn specs() -> Vec { "AutoFill", [], None, - "{source: \"A1:A2\", target: \"A1:A10\", mode?: series|copy|formats|values}: the fill-handle drag", + "{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)", has_doc, auto_fill ), @@ -1139,6 +1139,7 @@ fn auto_fill(s: &mut Session, p: &Json) -> Result { let Some(target) = str_param(p, "target").and_then(RangeRef::parse) else { return Err(bad("edit.autoFill", "missing `target`")) }; let mode = match str_param(p, "mode").unwrap_or("series") { "copy" => crate::fill::FillMode::Copy, + "alternate" => crate::fill::FillMode::Alternate, "formats" => crate::fill::FillMode::Formats, "values" => crate::fill::FillMode::ValuesOnly, _ => crate::fill::FillMode::Series, diff --git a/crates/engine/src/fill.rs b/crates/engine/src/fill.rs index d1c0ca5..9e0c970 100644 --- a/crates/engine/src/fill.rs +++ b/crates/engine/src/fill.rs @@ -13,6 +13,9 @@ use crate::cmd::Ctx; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FillMode { Series, + /// The Ctrl+drag fill: the opposite of a plain drag. A lone number fills a + /// series stepping by 1; anything a plain drag would extend is copied. + Alternate, Copy, Formats, ValuesOnly, @@ -62,13 +65,14 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F if forward { (src.end.col + 1..=target.end.col).collect() } else { (target.start.col..src.start.col).rev().collect() }; cols.into_iter().map(|c| CellRef::new(lane, c)).collect() }; - let pattern = if mode == FillMode::Series { - Pattern::detect( + let pattern = if matches!(mode, FillMode::Series | FillMode::Alternate) { + let detected = Pattern::detect( &src_cells, &custom, cx.wb.date_system, cx.wb.styles.get(src_cells.first().and_then(|c| c.1.as_ref()).map(|c| c.style).unwrap_or_default()).num_fmt.as_str(), - ) + ); + if mode == FillMode::Alternate { detected.alternate(&src_cells) } else { detected } } else { Pattern::Copy }; @@ -84,7 +88,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::Copy => { + FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy => { 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() { @@ -231,6 +235,19 @@ impl Pattern { Pattern::Copy } + /// The pattern of a Ctrl+drag, which swaps copying and extending: a single + /// number (which a plain drag copies) counts up by 1, and every series a + /// plain drag would extend (numbers, dates, lists, `Item 1`) is copied. + fn alternate(self, cells: &[(CellRef, Option)]) -> Pattern { + match (self, cells) { + (Pattern::Copy, [(_, Some(cell))]) if cell.formula.is_none() => match cell.value.as_f64() { + Some(n) if cell.value.is_number() => Pattern::Linear { last: n, first: n, step: 1.0 }, + _ => Pattern::Copy, + }, + _ => Pattern::Copy, + } + } + /// The value `k` steps beyond the source (forward) or before it (backward). fn value(&self, k: i64, forward: bool, _len: usize) -> Option { let sign = if forward { 1 } else { -1 }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6bd03d5..4a87bd8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -452,6 +452,39 @@ fn fill_series() { assert_eq!(v(&s, "C2"), Value::from("Item 10")); } +#[test] +fn ctrl_drag_fill_swaps_copy_and_series() { + // Issue #264: Ctrl+drag fills a lone number as a series, and copies what a plain drag extends. + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "7"})).unwrap(); + s.execute("edit.autoFill", json!({"source": "A1", "target": "A1:A4"})).unwrap(); + assert_eq!(v(&s, "A4"), Value::Number(7.0), "a plain drag copies a lone number"); + s.execute("edit.autoFill", json!({"source": "A1", "target": "A1:A4", "mode": "alternate"})).unwrap(); + assert_eq!(v(&s, "A2"), Value::Number(8.0)); + assert_eq!(v(&s, "A4"), Value::Number(10.0)); + // Upward too. + s.execute("cell.set", json!({"cell": "B5", "input": "7"})).unwrap(); + s.execute("edit.autoFill", json!({"source": "B5", "target": "B3:B5", "mode": "alternate"})).unwrap(); + assert_eq!(v(&s, "B3"), Value::Number(5.0)); + // A two-number series, a list entry and text with a number are copied. + s.execute("range.setValues", json!({"range": "C1", "values": [[1], [3]]})).unwrap(); + s.execute("edit.autoFill", json!({"source": "C1:C2", "target": "C1:C4", "mode": "alternate"})).unwrap(); + assert_eq!(v(&s, "C3"), Value::Number(1.0)); + assert_eq!(v(&s, "C4"), Value::Number(3.0)); + s.execute("cell.set", json!({"cell": "D1", "input": "Mon"})).unwrap(); + s.execute("edit.autoFill", json!({"source": "D1", "target": "D1:D3", "mode": "alternate"})).unwrap(); + assert_eq!(v(&s, "D3"), Value::from("Mon")); + s.execute("cell.set", json!({"cell": "E1", "input": "Item 9"})).unwrap(); + s.execute("edit.autoFill", json!({"source": "E1", "target": "E1:E2", "mode": "alternate"})).unwrap(); + assert_eq!(v(&s, "E2"), Value::from("Item 9")); + // Plain text copies either way; an empty source clears. + s.execute("cell.set", json!({"cell": "F1", "input": "abc"})).unwrap(); + s.execute("edit.autoFill", json!({"source": "F1", "target": "F1:F2", "mode": "alternate"})).unwrap(); + assert_eq!(v(&s, "F2"), Value::from("abc")); + s.execute("edit.autoFill", json!({"source": "G1", "target": "G1:G3", "mode": "alternate"})).unwrap(); + assert_eq!(v(&s, "G3"), Value::Empty); +} + #[test] fn insert_delete_cells_adjust() { let formula = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["formula"].clone(); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 72d33e2..e3de154 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1421,7 +1421,8 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & Drag::Fill { target: Some(t) } => { let src = sel.current(); if t != src { - let mode = if mods.command { "copy" } else { "series" }; + // 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))); } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e85bca1..8dfe624 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -32,7 +32,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Behaviour | Status | |---|---| | Click/drag/Shift/Ctrl selection, multi-area selection | rectangular drag works in one direction only in some cases (#28) | -| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done; 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 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 | | Point mode (click/drag/arrow references), reference colouring, F4 | done |