Fill handle: Fill Days, Fill Weekdays, Fill Months and Fill Years for a date

When a fill starts from a date, the right-drag fill menu and the Auto
Fill Options button now also offer Fill Days, Fill Weekdays, Fill Months
and Fill Years. edit.autoFill takes them as the modes days, weekdays,
months and years.

A lone date steps by one unit. Two or more dates step by the difference
between the first two in that unit, or by one unit when both fall in
the same one. Fill Weekdays skips Saturday and Sunday, and a start on a
weekend moves to the next weekday first. Fill Months and Fill Years keep
the day of the month, clamped to the month's last day. A source that is
not all dates fills as a plain drag does.

Fills by months or years now keep the time of day of a date with a
time. A plain drag of dates a month apart and Series by Month or Year
used to drop it.
This commit is contained in:
dotCooCoo
2026-10-24 06:56:26 -07:00
parent 3099df934e
commit 00edfd0690
8 changed files with 309 additions and 14 deletions
+1
View File
@@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-10-24 | Fill handle: from a date, the right-drag menu and the Auto Fill Options button offer Fill Days, Fill Weekdays (Saturday and Sunday skipped), Fill Months and Fill Years |
| 2026-10-23 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text |
| 2026-10-23 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row |
| 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) |
+5 -1
View File
@@ -79,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
"AutoFill",
[],
None,
"{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)",
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values|extend|days|weekdays|months|years}: 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; days, weekdays, months, years = Fill Days, Fill Weekdays, Fill Months, Fill Years: dates step by one unit, or by the difference between the first two dates in that unit, and weekdays skip Saturday and Sunday)",
has_doc,
auto_fill
),
@@ -1462,6 +1462,10 @@ fn auto_fill(s: &mut Session, p: &Json) -> Result<Json> {
"formats" => crate::fill::FillMode::Formats,
"values" => crate::fill::FillMode::ValuesOnly,
"extend" => crate::fill::FillMode::Extend,
"days" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Day),
"weekdays" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Weekday),
"months" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Month),
"years" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Year),
_ => crate::fill::FillMode::Series,
};
if target.count() > crate::fill::MAX_FILL_CELLS || src.count() > crate::fill::MAX_FILL_CELLS {
+145 -6
View File
@@ -5,7 +5,7 @@ use std::sync::Arc;
use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, days_in_month, serial_from_ymd};
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::{Cell, Formula};
use gridcraft_model::{Cell, Formula, Workbook};
use gridcraft_numfmt::{FormatKind, NumberFormat};
use crate::Result;
@@ -23,15 +23,43 @@ pub enum FillMode {
ValuesOnly,
/// Fill Series from the right-drag menu: extends like a plain drag, and a lone number counts up.
Extend,
/// Fill Days, Fill Weekdays, Fill Months or Fill Years: a source of dates steps by that unit.
/// A lone date steps by one unit; two or more step by the difference between the first two,
/// counted in that unit (one unit when they fall in the same one). A source that isn't dates
/// extends like a plain drag.
Dates(DateUnit),
}
/// The step of a [`FillMode::Dates`] fill.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DateUnit {
Day,
/// Monday to Friday: Saturday and Sunday are skipped.
Weekday,
Month,
Year,
}
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)
matches!(self, FillMode::Series | FillMode::Extend | FillMode::ValuesOnly | FillMode::Dates(_))
}
}
/// Whether a number format shows a calendar date (`YYYY-MM-DD` and `m/d/yyyy h:mm` do; `0 "days"`
/// and `h:mm` don't).
fn is_date_format(fmt: &str) -> bool {
NumberFormat::parse(fmt).kind() == FormatKind::Date
}
/// Whether the first cell of `src` holds a date, so a fill from it can step by days, weekdays,
/// months or years: a number (not a formula) shown in a date format.
pub fn starts_with_date(wb: &Workbook, sheet: usize, src: RangeRef) -> bool {
let Some(cell) = wb.sheet(sheet).and_then(|sh| sh.cell(src.start)) else { return false };
cell.formula.is_none() && cell.value.is_number() && is_date_format(wb.styles.get(cell.style).num_fmt.as_str())
}
/// Built-in custom lists.
fn lists() -> Vec<Vec<String>> {
let short_days: Vec<String> = WEEKDAYS.iter().map(|d| d.get(..3).unwrap_or(d).to_string()).collect();
@@ -102,7 +130,12 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
};
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 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());
let first_fmt = src_cells.first().map(fmt).unwrap_or_default();
let dates = match mode {
FillMode::Dates(unit) => Pattern::dates(&src_cells, unit, cx.wb.date_system, first_fmt),
_ => None,
};
let detected = dates.unwrap_or_else(|| Pattern::detect(&src_cells, &custom, cx.wb.date_system, first_fmt));
match (mode, detected) {
(FillMode::Alternate, p) | (FillMode::Extend, p @ Pattern::Copy) => p.alternate(&src_cells),
(_, p) => p,
@@ -130,7 +163,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::Extend => {
FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy | FillMode::Extend | FillMode::Dates(_) => {
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() {
@@ -204,9 +237,63 @@ enum Pattern {
months: i64,
sys: gridcraft_core::DateSystem,
},
/// Dates stepping by weekdays, skipping Saturday and Sunday.
Weekdays {
last: f64,
first: f64,
step: i64,
sys: gridcraft_core::DateSystem,
},
}
impl Pattern {
/// The pattern of Fill Days, Fill Weekdays, Fill Months or Fill Years, or `None` when the
/// source isn't all dates (numbers, not formulas, under a date format in its first cell).
fn dates(cells: &[(CellRef, Option<Cell>)], unit: DateUnit, sys: gridcraft_core::DateSystem, fmt: &str) -> Option<Pattern> {
if !is_date_format(fmt) {
return None;
}
let mut nums = Vec::with_capacity(cells.len());
for (_, cell) in cells {
let cell = cell.as_ref()?;
if cell.formula.is_some() || !cell.value.is_number() {
return None;
}
nums.push(cell.value.as_f64()?);
}
let first = *nums.first()?;
let last = *nums.last()?;
let second = nums.get(1).copied();
// The difference between the first two dates in the unit, and one unit when they fall in
// the same one.
let at_least_one = |n: i64| if n == 0 { 1 } else { n };
Some(match unit {
DateUnit::Day => {
let days = second.map_or(1.0, |b| b.floor() - first.floor());
Pattern::Linear { last, first, step: if days == 0.0 { 1.0 } else { days } }
}
DateUnit::Weekday => {
let step = match second {
Some(b) => at_least_one(weekdays_between(sys, first, b)?),
None => 1,
};
Pattern::Weekdays { last, first, step, sys }
}
DateUnit::Month | DateUnit::Year => {
let months = match second {
Some(b) => {
let (a, b) = (datetime_from_serial(sys, first)?, datetime_from_serial(sys, b)?);
let years = b.year as i64 - a.year as i64;
if unit == DateUnit::Year { at_least_one(years) * 12 } else { at_least_one(years * 12 + b.month as i64 - a.month as i64) }
}
None if unit == DateUnit::Year => 12,
None => 1,
};
Pattern::Months { last, first, months, sys }
}
})
}
fn detect(cells: &[(CellRef, Option<Cell>)], custom: &[Vec<String>], sys: gridcraft_core::DateSystem, fmt: &str) -> Pattern {
let vals: Vec<Value> = cells.iter().map(|(_, c)| c.as_ref().map(|c| c.value.clone()).unwrap_or_default()).collect();
if cells.iter().any(|(_, c)| c.as_ref().is_some_and(|c| c.formula.is_some())) {
@@ -343,20 +430,72 @@ impl Pattern {
let s = add_months_clamped(*sys, base, months.checked_mul(k * sign)?)?;
Some(Value::number(s))
}
Pattern::Weekdays { last, first, step, sys } => {
let base = if forward { *last } else { *first };
Some(Value::number(add_weekdays(*sys, base, step.checked_mul(k)?.checked_mul(sign)?)?))
}
}
}
}
/// Whether `serial` falls on Monday to Friday; `None` outside the supported date range.
fn is_weekday(sys: gridcraft_core::DateSystem, serial: f64) -> Option<bool> {
datetime_from_serial(sys, serial).map(|d| d.weekday != 0 && d.weekday != 6)
}
/// `serial` moved by `n` weekdays (back when `n` is negative), keeping its time of day. A start on
/// a Saturday or Sunday moves to the next (or previous) weekday for the first step.
fn add_weekdays(sys: gridcraft_core::DateSystem, serial: f64, n: i64) -> Option<f64> {
if n == 0 {
return Some(serial);
}
let dir = n.signum() as f64;
let mut d = serial;
let next_weekday = |d: &mut f64| -> Option<()> {
loop {
*d += dir;
if is_weekday(sys, *d)? {
return Some(());
}
}
};
next_weekday(&mut d)?;
// From a weekday, every seven days hold five weekdays.
let rest = n.unsigned_abs() - 1;
d += dir * (rest / 5 * 7) as f64;
for _ in 0..rest % 5 {
next_weekday(&mut d)?;
}
datetime_from_serial(sys, d)?;
Some(d)
}
/// How many weekdays lie after `a` up to and including `b` (negative when `b` comes first),
/// counting whole days; `None` outside the supported date range.
fn weekdays_between(sys: gridcraft_core::DateSystem, a: f64, b: f64) -> Option<i64> {
let (lo, hi, sign) = if b >= a { (a.floor(), b.floor(), 1) } else { (b.floor(), a.floor(), -1) };
let days = (hi - lo) as i64;
let mut count = days / 7 * 5;
let mut d = lo + (days / 7 * 7) as f64;
for _ in 0..days % 7 {
d += 1.0;
if is_weekday(sys, d)? {
count += 1;
}
}
Some(count * sign)
}
/// `serial` shifted by `months` calendar months, keeping the day of month but clamping it to the
/// target month's last day (Jan 31 + 1 month = Feb 28/29). Always measured from the original date,
/// so a series does not drift: Jan 31, Feb 28, Mar 31, Apr 30.
/// so a series does not drift: Jan 31, Feb 28, Mar 31, Apr 30. The time of day is kept.
fn add_months_clamped(sys: gridcraft_core::DateSystem, serial: f64, months: i64) -> Option<f64> {
let d = datetime_from_serial(sys, serial)?;
let total = (d.year as i64).checked_mul(12)?.checked_add(d.month as i64 - 1)?.checked_add(months)?;
let year = total.div_euclid(12);
let month = total.rem_euclid(12) + 1;
let last = days_in_month(i32::try_from(year).ok()?, month as u32);
serial_from_ymd(sys, year, month, (d.day as i64).min(last as i64))
serial_from_ymd(sys, year, month, (d.day as i64).min(last as i64)).map(|day| day + serial - serial.floor())
}
fn round15(x: f64) -> f64 {
+80
View File
@@ -941,6 +941,86 @@ fn fill_without_formatting_extends_the_series_and_keeps_the_target_formats() {
assert_eq!([v(&s, "B3"), v(&s, "B4")], [Value::from("Product 2"), Value::from("On backorder")]);
}
#[test]
fn fill_days_weekdays_months_and_years_step_a_date_by_that_unit() {
let mut s = s();
let serials = |s: &Session, cells: &[&str]| cells.iter().map(|c| v(s, c)).collect::<Vec<_>>();
let n = |xs: &[f64]| xs.iter().map(|x| Value::Number(*x)).collect::<Vec<_>>();
// Friday 1/30/2026 is serial 46052.
s.execute("cell.set", json!({"cell": "A1", "input": "1/30/2026"})).unwrap();
assert_eq!(v(&s, "A1"), Value::Number(46052.0), "setup: a date");
s.execute("edit.autoFill", json!({"source": "A1", "target": "A1:A4", "mode": "days"})).unwrap();
assert_eq!(serials(&s, &["A2", "A3", "A4"]), n(&[46053.0, 46054.0, 46055.0]));
// Fill Weekdays skips Saturday 1/31 and Sunday 2/1.
s.execute("edit.autoFill", json!({"source": "A1", "target": "A1:A4", "mode": "weekdays"})).unwrap();
assert_eq!(serials(&s, &["A2", "A3", "A4"]), n(&[46055.0, 46056.0, 46057.0]));
// From Saturday 1/31, the first weekday is Monday 2/2.
s.execute("cell.set", json!({"cell": "B1", "input": "1/31/2026"})).unwrap();
s.execute("edit.autoFill", json!({"source": "B1", "target": "B1:B3", "mode": "weekdays"})).unwrap();
assert_eq!(serials(&s, &["B2", "B3"]), n(&[46055.0, 46056.0]));
// Fill Months keeps the day, clamped to the month's last day: Feb 28, Mar 31, Apr 30.
s.execute("edit.autoFill", json!({"source": "B1", "target": "B1:B4", "mode": "months"})).unwrap();
assert_eq!(serials(&s, &["B2", "B3", "B4"]), n(&[46081.0, 46112.0, 46142.0]));
s.execute("edit.autoFill", json!({"source": "B1", "target": "B1:B3", "mode": "years"})).unwrap();
assert_eq!(serials(&s, &["B2", "B3"]), n(&[46418.0, 46783.0]));
// Two dates step by their difference in the unit: 1/15 and 3/15 by two months.
s.execute("cell.set", json!({"cell": "C1", "input": "1/15/2026"})).unwrap();
s.execute("cell.set", json!({"cell": "C2", "input": "3/15/2026"})).unwrap();
s.execute("edit.autoFill", json!({"source": "C1:C2", "target": "C1:C4", "mode": "months"})).unwrap();
assert_eq!(serials(&s, &["C3", "C4"]), n(&[46157.0, 46218.0]));
// Monday 2/2 and Wednesday 2/4 step by two weekdays: Friday 2/6, Tuesday 2/10.
s.execute("cell.set", json!({"cell": "D1", "input": "2/2/2026"})).unwrap();
s.execute("cell.set", json!({"cell": "D2", "input": "2/4/2026"})).unwrap();
s.execute("edit.autoFill", json!({"source": "D1:D2", "target": "D1:D4", "mode": "weekdays"})).unwrap();
assert_eq!(serials(&s, &["D3", "D4"]), n(&[46059.0, 46063.0]));
// Upward from Monday 2/2: Friday 1/30, Thursday 1/29.
s.execute("cell.set", json!({"cell": "E5", "input": "2/2/2026"})).unwrap();
s.execute("edit.autoFill", json!({"source": "E5", "target": "E3:E5", "mode": "weekdays"})).unwrap();
assert_eq!(serials(&s, &["E4", "E3"]), n(&[46052.0, 46051.0]));
// A number that isn't a date fills as a plain drag does: a lone number copies.
s.execute("cell.set", json!({"cell": "F1", "input": "7"})).unwrap();
s.execute("edit.autoFill", json!({"source": "F1", "target": "F1:F2", "mode": "days"})).unwrap();
assert_eq!(v(&s, "F2"), Value::Number(7.0));
// An upper-case date format is a date: 1/31/2026 as YYYY-MM-DD fills by months.
s.execute("cell.set", json!({"cell": "G1", "input": "1/31/2026"})).unwrap();
s.execute("home.numberFormat", json!({"range": "G1", "code": "YYYY-MM-DD"})).unwrap();
s.execute("edit.autoFill", json!({"source": "G1", "target": "G1:G2", "mode": "months"})).unwrap();
assert_eq!(v(&s, "G2"), Value::Number(46081.0));
// A date with a time keeps the time: Fill Months and Fill Weekdays, a plain drag of two dates
// a month apart, and the Series dialog by months.
s.execute("cell.set", json!({"cell": "H1", "input": "1/31/2026 12:00"})).unwrap();
assert_eq!(v(&s, "H1"), Value::Number(46053.5), "setup: a date and time");
s.execute("edit.autoFill", json!({"source": "H1", "target": "H1:H2", "mode": "months"})).unwrap();
assert_eq!(v(&s, "H2"), Value::Number(46081.5));
s.execute("edit.autoFill", json!({"source": "H1", "target": "H1:H2", "mode": "weekdays"})).unwrap();
assert_eq!(v(&s, "H2"), Value::Number(46055.5));
s.execute("cell.set", json!({"cell": "I1", "input": "1/15/2026 6:00"})).unwrap();
s.execute("cell.set", json!({"cell": "I2", "input": "2/15/2026 6:00"})).unwrap();
s.execute("edit.autoFill", json!({"source": "I1:I2", "target": "I1:I3"})).unwrap();
assert_eq!(v(&s, "I3"), Value::Number(46096.25));
s.execute("cell.set", json!({"cell": "J1", "input": "1/31/2026 12:00"})).unwrap();
s.execute("edit.fillSeries", json!({"range": "J1:J2", "type": "date", "dateUnit": "month"})).unwrap();
assert_eq!(v(&s, "J2"), Value::Number(46081.5));
}
#[test]
fn only_a_number_in_a_date_format_offers_the_date_fills() {
let mut s = s();
let dates = |s: &Session, a: &str| crate::fill::starts_with_date(&s.doc().unwrap().wb, 0, gridcraft_core::RangeRef::parse(a).unwrap());
s.execute("cell.set", json!({"cell": "A1", "input": "1/31/2026"})).unwrap();
s.execute("cell.set", json!({"cell": "A2", "input": "7"})).unwrap();
s.execute("home.numberFormat", json!({"range": "A2", "code": "0 \"days\""})).unwrap();
s.execute("cell.set", json!({"cell": "A3", "input": "46053"})).unwrap();
s.execute("home.numberFormat", json!({"range": "A3", "code": "YYYY-MM-DD"})).unwrap();
s.execute("cell.set", json!({"cell": "A4", "input": "=A1"})).unwrap();
s.execute("cell.set", json!({"cell": "A5", "input": "13:30"})).unwrap();
assert!(dates(&s, "A1") && dates(&s, "A3"), "m/d/yyyy and YYYY-MM-DD are dates");
assert!(!dates(&s, "A2"), "quoted text in a number format is not a date field");
assert!(!dates(&s, "A4"), "a formula is not offered");
assert!(!dates(&s, "A5"), "a time is not a date");
assert!(!dates(&s, "A6"), "an empty cell is not a date");
}
#[test]
fn insert_delete_cells_adjust() {
let formula = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["formula"].clone();
+16 -3
View File
@@ -3413,6 +3413,16 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
const FILL_MODES: [(&str, &str); 4] =
[("Copy Cells", "copy"), ("Fill Series", "extend"), ("Fill Formatting Only", "formats"), ("Fill Without Formatting", "values")];
/// The ways to fill from a date, which both menus add after [`FILL_MODES`] when the source starts
/// with one.
const DATE_FILL_MODES: [(&str, &str); 4] = [("Fill Days", "days"), ("Fill Weekdays", "weekdays"), ("Fill Months", "months"), ("Fill Years", "years")];
/// The fill modes the menus list for a fill from `src` on the active sheet.
fn fill_modes(app: &SheetApp, src: RangeRef) -> Vec<(&'static str, &'static str)> {
let dates = app.session.active().is_some_and(|d| gridcraft_engine::fill::starts_with_date(&d.wb, d.wb.active_sheet, src));
FILL_MODES.into_iter().chain(DATE_FILL_MODES.into_iter().filter(|_| dates)).collect()
}
/// 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 =
@@ -3421,7 +3431,8 @@ fn set_fill_tag(app: &mut SheetApp, src: RangeRef, target: RangeRef) {
/// 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.
/// or Fill Without Formatting, and from a date also as Fill Days, Fill Weekdays, Fill Months or
/// Fill Years: 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 };
@@ -3464,9 +3475,10 @@ fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet
Stroke::NONE,
));
let mut chosen = None;
let modes = fill_modes(app, tag.src);
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(190.0);
for (label, mode) in FILL_MODES {
for (label, mode) in 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();
@@ -3493,10 +3505,11 @@ fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet
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 mut items = fill_modes(app, src);
items.extend([("-", ""), ("Series…", "ui:series")]);
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 = [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 == "-" {
@@ -1,5 +1,6 @@
//! 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…).
//! menu (Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…, and from
//! a date Fill Days, Fill Weekdays, Fill Months and Fill Years).
use egui::{Event, Key, Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::kittest::Queryable;
@@ -203,6 +204,34 @@ fn series_opens_the_series_dialog_on_the_dragged_cells_in_their_direction() {
assert_eq!(values(&h, &["C1", "D1", "E1"]), [n(2.0), n(3.0), n(4.0)]);
}
#[test]
fn a_date_adds_fill_days_weekdays_months_and_years() {
let mut h = harness(|s| {
// Friday 1/30/2026 is serial 46052.
s.execute("cell.set", json!({"cell": "B1", "input": "1/30/2026"})).unwrap();
s.execute("selection.set", json!({"cell": "B1"})).unwrap();
});
drag_handle(&mut h, "B1", "B3", PointerButton::Secondary);
for label in ["Fill Days", "Fill Months", "Fill Years"] {
assert!(h.query_by_label(label).is_some(), "{label}");
}
h.get_by_label("Fill Weekdays").click();
h.run_steps(3);
assert_eq!(values(&h, &["B2", "B3"]), [n(46055.0), n(46056.0)], "Monday 2/2 and Tuesday 2/3");
assert!(h.state().grid.fill_menu.is_none());
}
#[test]
fn a_number_gets_no_date_fills() {
let mut h = harness(one_two);
drag_handle(&mut h, "B2", "B6", PointerButton::Secondary);
assert!(h.query_by_label("Fill Series").is_some());
for label in ["Fill Days", "Fill Weekdays", "Fill Months", "Fill Years"] {
assert!(h.query_by_label(label).is_none(), "{label}");
}
}
#[test]
fn escape_mid_right_drag_cancels_it() {
let mut h = harness(|s| {
+29 -1
View File
@@ -1,5 +1,6 @@
//! 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).
//! Fill Series, Fill Formatting Only or Fill Without Formatting (#1403), and from a date as Fill
//! Days, Fill Weekdays, Fill Months or Fill Years.
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::kittest::Queryable;
@@ -139,6 +140,33 @@ fn a_lone_number_copies_on_drag_and_counts_up_with_fill_series() {
assert_eq!(values(&h, &["B2", "B3", "B4"]), [n(6.0), n(7.0), n(8.0)]);
}
#[test]
fn fill_months_redoes_a_date_drag_by_months() {
let mut h = harness(|s| {
// 1/31/2026 is serial 46053.
s.execute("cell.set", json!({"cell": "B1", "input": "1/31/2026"})).unwrap();
s.execute("selection.set", json!({"cell": "B1"})).unwrap();
});
drag_handle(&mut h, "B1", "B3", PointerButton::Primary);
assert_eq!(values(&h, &["B2", "B3"]), [n(46054.0), n(46055.0)], "a plain drag steps by days");
h.get_by_label("Auto Fill Options").click();
h.run_steps(3);
h.get_by_label("Fill Months").click();
h.run_steps(3);
assert_eq!(values(&h, &["B2", "B3"]), [n(46081.0), n(46112.0)], "Feb 28 and Mar 31");
}
#[test]
fn a_number_drag_offers_no_date_fills() {
let mut h = harness(one_two);
drag_handle(&mut h, "B2", "B6", PointerButton::Primary);
h.get_by_label("Auto Fill Options").click();
h.run_steps(3);
assert!(h.query_by_label("Copy Cells").is_some());
assert!(h.query_by_label("Fill Days").is_none());
}
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();
+3 -2
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Fill Days, Fill Weekdays, Fill Months and Fill Years in the fill-handle menus for a date; New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md).
**UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured
@@ -50,7 +50,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) |
| Cursor while dragging cells, headers, freeze handles and objects | stable for the active drag (#936); Draw tools keep their crosshair; header arrows and a custom cell cursor remain unimplemented |
| 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; month patterns use the workbook's 1900 or 1904 date system); right-drag menu partial: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…; a left-drag leaves an Auto Fill Options button with the first four (gone once the workbook changes or the selection moves); Fill Days/Weekdays/Months/Years, Linear/Growth Trend and Flash Fill missing |
| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series; month patterns use the workbook's 1900 or 1904 date system); right-drag menu partial: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, and from a date Fill Days, Fill Weekdays, Fill Months and Fill Years, then Series…; a left-drag leaves an Auto Fill Options button with the same fill choices (gone once the workbook changes or the selection moves); 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 |
| Point mode (click/drag/arrow references), reference colouring, F4 | done; Command/Ctrl-click adds separate arguments with the edit's locale separator, and Shift-click extends the current reference from a fixed anchor on the same sheet. Pointer-event regressions cover repeated/reversed selections, manual separators, sheet switches and keyboard continuation. |
@@ -181,6 +181,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
| 2026-10-23 | minor | Conditional Formatting ▸ New Rule, formula: clicking a cell on the grid writes its absolute reference at the formula box's caret (`=$B$2`, an empty box starts with `=`), a drag writes a range (`$B$2:$C$4`) and a second click replaces the reference just written, as in Excel ("Selecting cells on the worksheet inserts absolute cell references"). The selection the rule applies to doesn't move, and the box keeps the keyboard. After an operand (`=A1>5`) the click does nothing (#2348) |
| 2026-10-23 | minor | Frozen panes: Page Up, Ctrl+Up and Shift variants that move from the scrolled rows into the frozen ones bring the scrolled rows back to the top, so the rows in between are seen again (Page Up from far down used to end on row 1 with the view still a page down); Ctrl+Left and Alt+Page Up do the same for frozen columns. Other ways of selecting a frozen cell (click, Find, Go To, Undo, formula pointing) leave the view where it is |
| 2026-10-23 | minor | After a left-drag of the fill handle an Auto Fill Options button sits below the filled cells; its menu redoes the fill as Copy Cells, Fill Series, Fill Formatting Only or Fill Without Formatting (undoes the drag's fill, then `edit.autoFill` in that mode). It goes away when the workbook changes or the selection moves (#1403) |
| 2026-10-24 | minor | When the fill starts from a date, the right-drag fill menu and the Auto Fill Options button also offer Fill Days, Fill Weekdays (Saturday and Sunday skipped), Fill Months and Fill Years (`edit.autoFill` `mode: days|weekdays|months|years`). A lone date steps by one unit; two or more dates step by the difference between the first two in that unit, or one unit when they fall in the same one. Fill Months and Fill Years keep the day of the month, clamped to the month's last day. Every fill by months or years (these two, a drag of dates a month apart, and Series by Month or Year) keeps the time of day of a date with a time, which used to be dropped |
| 2026-10-22 | minor | Show Outline Symbols (`view.showOutlineSymbols`, Ctrl+8): a sheet can hide the outline's level buttons and brackets while keeping its outline, as Excel saves with `showOutlineSymbols="0"` (read and written); the grid then gives the bands no room |
| 2026-10-22 | minor | A number in a repeat-fill format (`*`, as in the Accounting format) that is too wide for its column shows #### like any other number, instead of the fill rebuilding the value so its leading digits were cut off (2,509.00 CHF drawn as "509.00 CHF") |
| 2026-10-21 | minor | Background error checking, first part (#1994): a constant whose text would be a number if typed (signs, thousands, currency, percent, exponent, parenthesised negative) gets Excel's green corner triangle; the active cell then shows a warning button whose menu names the problem and offers Convert to Number for the selection (`formulas.convertToNumber`). Dates, times and fractions kept as text and formula results are not flagged, as in Excel |