Browse / github.com/storytold/gridcraft / patch
Fill handle: Linear Trend and Growth Trend on the right-drag menu for numbers
Fill handle: Linear Trend and Growth Trend on the right-drag menu for numbers ## Summary When every source cell holds a number, the right-drag fill menu now also offers Linear Trend and Growth Trend. `docs/ui-parity.md` listed them as missing from the menu. Microsoft's "Project values in a series" page (https://web.archive.org/web/20250910221013/https://support.microsoft.com/en-us/office/project-values-in-a-series-5311f5cf-149e-4d06-81dd-5aaad87e5400) says: - "Hold down the right mouse button, drag the fill handle in the direction that you want to fill with increasing values or decreasing values, release the mouse button, and then click Growth Trend on the shortcut men
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- dotCooCoo (imported from pull request #2628, open)
- Base
f6b0cec67e328f2f6352768b5cb2feb7e562c5db- Size
- +297 −22 in 7 files
- Review
- pending
- Shared
- 2026-10-28
- Id
bbcf3b54b443d6017595ae4b
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/engine/src/cmd/edit.rs crates/engine/src/fill.rs crates/engine/src/tests.rs crates/ui-egui/src/grid.rs crates/ui-egui/tests/it/fill_handle_right_drag.rs docs/ui-parity.md
@@ -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-25 | Fill handle: from numbers, the right-drag menu offers Linear Trend and Growth Trend, which continue the best-fit line or exponential curve of the source (1, 2 gives 3, 4, 5 or 4, 8, 16) | | 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before | | 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) | | 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) |@@ -79,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> { "AutoFill", [], None,- "{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)",+ "{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values|extend|days|weekdays|months|years|linearTrend|growthTrend}: 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; linearTrend, growthTrend = Linear Trend, Growth Trend: the targets continue the least-squares line or the exponential curve fitted to each row or column of source numbers, and the source keeps its values; an error when a source cell is not a number or holds a formula, or for growthTrend when a row or column mixes signs or holds zero)", has_doc, auto_fill ),@@ -1589,6 +1589,8 @@ fn auto_fill(s: &mut Session, p: &Json) -> Result<Json> { "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),+ "linearTrend" => crate::fill::FillMode::LinearTrend,+ "growthTrend" => crate::fill::FillMode::GrowthTrend, _ => crate::fill::FillMode::Series, }; if target.count() > crate::fill::MAX_FILL_CELLS || src.count() > crate::fill::MAX_FILL_CELLS {@@ -28,6 +28,12 @@ pub enum FillMode { /// 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),+ /// Linear Trend: the targets continue the least-squares line (y = mx + b) through the source's+ /// numbers. The source keeps its values.+ LinearTrend,+ /// Growth Trend: the targets continue the exponential curve (y = b·m^x) fitted to the source's+ /// numbers. The source keeps its values.+ GrowthTrend, } /// The step of a [`FillMode::Dates`] fill.@@ -47,6 +53,59 @@ impl FillMode { } } +/// Whether a fill from `src` to `target` runs down or up the sheet rather than across it.+fn fills_vertically(src: RangeRef, target: RangeRef) -> bool {+ target.height() != src.height() || target.width() == src.width()+}++/// The numbers in each lane of `src` that a fill to `target` continues, or `None` when a source+/// cell is empty or holds text, a logical value, an error or a formula.+fn numeric_lanes(wb: &Workbook, sheet: usize, src: RangeRef, target: RangeRef) -> Option<Vec<Vec<f64>>> {+ let sh = wb.sheet(sheet)?;+ let vertical = fills_vertically(src, target);+ let lanes: Vec<u32> = if vertical { (src.start.col..=src.end.col).collect() } else { (src.start.row..=src.end.row).collect() };+ let len = if vertical { src.height() } else { src.width() };+ lanes+ .into_iter()+ .map(|lane| {+ (0..len)+ .map(|i| {+ let c = if vertical { CellRef::new(src.start.row + i, lane) } else { CellRef::new(lane, src.start.col + i) };+ let cell = sh.cell(c)?;+ if cell.formula.is_some() || !cell.value.is_number() {+ return None;+ }+ cell.value.as_f64()+ })+ .collect()+ })+ .collect()+}++/// Whether the fill menu offers Linear Trend (`growth` false) or Growth Trend (`growth` true) for a+/// fill from `src` to `target`: every source cell holds a number, and for Growth Trend the numbers+/// of each lane are all above zero or all below zero.+pub fn trend_applies(wb: &Workbook, sheet: usize, src: RangeRef, target: RangeRef, growth: bool) -> bool {+ let Some(sh) = wb.sheet(sheet) else { return false };+ let vertical = fills_vertically(src, target);+ let (lanes, len) = if vertical { (src.start.col..=src.end.col, src.height()) } else { (src.start.row..=src.end.row, src.width()) };+ for lane in lanes {+ let (mut above, mut below) = (false, false);+ for i in 0..len {+ let c = if vertical { CellRef::new(src.start.row + i, lane) } else { CellRef::new(lane, src.start.col + i) };+ let Some(n) = sh.cell(c).filter(|cell| cell.formula.is_none() && cell.value.is_number()).and_then(|cell| cell.value.as_f64()) else {+ return false;+ };+ above |= n > 0.0;+ below |= n < 0.0;+ if growth && (n == 0.0 || (above && below)) {+ return false;+ }+ }+ }+ true+}+ /// 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 {@@ -90,7 +149,7 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F let custom = cx.wb.custom_lists.clone(); let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) }; // Direction: down/up if target extends rows, else right/left.- let vertical = target.height() != src.height() || target.width() == src.width();+ let vertical = fills_vertically(src, target); let forward = if vertical { target.end.row > src.end.row || target.start.row == src.start.row } else {@@ -108,11 +167,25 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F if u64::from(lane_count).saturating_mul(u64::from(steps)) > MAX_FILL_CELLS { return Err(crate::EngineError::Other("The fill is too large.".into())); }+ // Linear Trend and Growth Trend fit each lane's numbers.+ let trends: Vec<Pattern> = match mode {+ FillMode::LinearTrend | FillMode::GrowthTrend => {+ let Some(lanes) = numeric_lanes(&cx.wb, sheet, src, target) else {+ return Err(crate::EngineError::Other("Linear Trend and Growth Trend need a number in every source cell.".into()));+ };+ let fit = |nums: &Vec<f64>| if mode == FillMode::GrowthTrend { Pattern::growth(nums) } else { Some(Pattern::linear(nums)) };+ let Some(trends) = lanes.iter().map(fit).collect() else {+ return Err(crate::EngineError::Other("Growth Trend needs numbers that are all above zero or all below zero.".into()));+ };+ trends+ }+ _ => Vec::new(),+ }; let lanes: Vec<u32> = if vertical { (src.start.col..=src.end.col).collect() } else { (src.start.row..=src.end.row).collect() }; let src_len = if vertical { src.height() } else { src.width() }; let mut writes: Vec<(CellRef, Option<Cell>)> = Vec::new(); let mut pictures = Vec::new();- for lane in lanes {+ for (lane_i, lane) in lanes.into_iter().enumerate() { let src_cells: Vec<(CellRef, Option<Cell>)> = (0..src_len) .map(|i| { let c = if vertical { CellRef::new(src.start.row + i, lane) } else { CellRef::new(lane, src.start.col + i) };@@ -129,7 +202,9 @@ 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 mode.extends_series() || mode == FillMode::Alternate {+ let pattern = if let Some(trend) = trends.get(lane_i) {+ trend.clone()+ } else if mode.extends_series() || mode == FillMode::Alternate { 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),@@ -163,7 +238,14 @@ 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::Dates(_) => {+ FillMode::ValuesOnly+ | FillMode::Series+ | FillMode::Alternate+ | FillMode::Copy+ | FillMode::Extend+ | FillMode::Dates(_)+ | FillMode::LinearTrend+ | FillMode::GrowthTrend => { 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() {@@ -244,9 +326,42 @@ enum Pattern { step: i64, sys: gridcraft_core::DateSystem, },+ /// Growth Trend, kept as logarithms so that a value the curve reaches is not lost to an+ /// overflow or underflow on the way: the logarithms of the fitted magnitudes at the source's+ /// last and first cells, the logarithm of the factor between neighboring cells, and the sign+ /// of every value (1 or -1).+ Growth {+ last: f64,+ first: f64,+ step: f64,+ sign: f64,+ }, } impl Pattern {+ /// Linear Trend: the least-squares line through `nums`. A lone number gives a step of zero.+ fn linear(nums: &[f64]) -> Pattern {+ let (first, last, step) = least_squares(nums);+ Pattern::Linear { last, first, step }+ }++ /// The exponential curve y = b·m^x fitted to `nums` at positions 0, 1, 2… by least squares on+ /// the logarithms, as the GROWTH function fits positive numbers. Numbers below zero are fitted+ /// by their magnitudes and keep their sign. `None` when the numbers are not all above zero or+ /// all below zero. A lone number gives a factor of one.+ fn growth(nums: &[f64]) -> Option<Pattern> {+ let sign = if nums.iter().all(|y| *y > 0.0) {+ 1.0+ } else if nums.iter().all(|y| *y < 0.0) {+ -1.0+ } else {+ return None;+ };+ let logs: Vec<f64> = nums.iter().map(|y| (y * sign).ln()).collect();+ let (first, last, step) = least_squares(&logs);+ Some(Pattern::Growth { last, first, step, sign })+ }+ /// 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> {@@ -328,19 +443,7 @@ impl Pattern { } } // Least-squares step for 2+ values (Excel uses a linear trend).- let n = nums.len() as f64;- let mean_x = (n - 1.0) / 2.0;- let mean_y = nums.iter().sum::<f64>() / n;- let mut num = 0.0;- let mut den = 0.0;- for (i, y) in nums.iter().enumerate() {- num += (i as f64 - mean_x) * (y - mean_y);- den += (i as f64 - mean_x).powi(2);- }- let step = if den == 0.0 { 0.0 } else { num / den };- let fitted_last = mean_y + step * (n - 1.0 - mean_x);- let fitted_first = mean_y - step * mean_x;- return Pattern::Linear { last: fitted_last, first: fitted_first, step };+ return Pattern::linear(&nums); } // Text. if !vals.is_empty() && vals.iter().all(|v| v.is_text()) {@@ -434,10 +537,30 @@ impl Pattern { let base = if forward { *last } else { *first }; Some(Value::number(add_weekdays(*sys, base, step.checked_mul(k)?.checked_mul(sign)?)?)) }+ Pattern::Growth { last, first, step, sign: growth_sign } => {+ let base = if forward { *last } else { *first };+ Some(Value::number(round15(growth_sign * (base + step * (k * sign) as f64).exp())))+ } } } } +/// The least-squares line through `nums` at positions 0, 1, 2…: its values at the first and last+/// positions, and its step. A lone number gives a step of zero.+fn least_squares(nums: &[f64]) -> (f64, f64, f64) {+ let n = nums.len() as f64;+ let mean_x = (n - 1.0) / 2.0;+ let mean_y = nums.iter().sum::<f64>() / n;+ let mut num = 0.0;+ let mut den = 0.0;+ for (i, y) in nums.iter().enumerate() {+ num += (i as f64 - mean_x) * (y - mean_y);+ den += (i as f64 - mean_x).powi(2);+ }+ let step = if den == 0.0 { 0.0 } else { num / den };+ (mean_y - step * mean_x, mean_y + step * (n - 1.0 - mean_x), step)+}+ /// 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)@@ -1003,6 +1003,94 @@ fn fill_days_weekdays_months_and_years_step_a_date_by_that_unit() { assert_eq!(v(&s, "J2"), Value::Number(46081.5)); } +#[test]+fn linear_and_growth_trend_continue_the_fitted_line_or_curve() {+ let mut s = s();+ let nums = |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<_>>();+ let fill =+ |s: &mut Session, src: &str, target: &str, mode: &str| s.execute("edit.autoFill", json!({"source": src, "target": target, "mode": mode}));+ // The examples on Excel's "Project values in a series" page: 1, 2 and 1, 3 for both trends, 2, 3+ // for Growth Trend and 100, 95 for Linear Trend. 2, 3 gives 4, 5, 6 by Linear Trend.+ for (col, start, linear, growth) in [+ ("A", [1.0, 2.0], [3.0, 4.0, 5.0], [4.0, 8.0, 16.0]),+ ("B", [1.0, 3.0], [5.0, 7.0, 9.0], [9.0, 27.0, 81.0]),+ ("C", [2.0, 3.0], [4.0, 5.0, 6.0], [4.5, 6.75, 10.125]),+ ] {+ s.execute("range.setValues", json!({"range": format!("{col}1"), "values": [[start[0]], [start[1]]]})).unwrap();+ let (src, target) = (format!("{col}1:{col}2"), format!("{col}1:{col}5"));+ let cells: Vec<String> = (3..=5).map(|r| format!("{col}{r}")).collect();+ let cells: Vec<&str> = cells.iter().map(String::as_str).collect();+ fill(&mut s, &src, &target, "linearTrend").unwrap();+ assert_eq!(nums(&s, &cells), n(&linear), "Linear Trend from {start:?}");+ fill(&mut s, &src, &target, "growthTrend").unwrap();+ assert_eq!(nums(&s, &cells), n(&growth), "Growth Trend from {start:?}");+ }+ s.execute("range.setValues", json!({"range": "F1", "values": [[100], [95]]})).unwrap();+ fill(&mut s, "F1:F2", "F1:F4", "linearTrend").unwrap();+ assert_eq!(nums(&s, &["F3", "F4"]), n(&[90.0, 85.0]));+ // Three numbers: the targets follow the best fit and the source keeps its values. 1, 3, 4 fit+ // y = 1.5x + 7/6; 1, 2, 8 fit y = 0.8909·2.8284^x.+ s.execute("range.setValues", json!({"range": "D1", "values": [[1, 1], [3, 2], [4, 8]]})).unwrap();+ fill(&mut s, "D1:D3", "D1:D5", "linearTrend").unwrap();+ assert_eq!(nums(&s, &["D1", "D2", "D3", "D4", "D5"]), n(&[1.0, 3.0, 4.0, 5.66666666666667, 7.16666666666667]));+ fill(&mut s, "E1:E3", "E1:E4", "growthTrend").unwrap();+ assert_eq!(nums(&s, &["E1", "E2", "E3", "E4"]), n(&[1.0, 2.0, 8.0, 20.158736798318]));+ // Upward and leftward the trend continues backward: 1, 2 gives 0 and -1, or 0.5 and 0.25.+ s.execute("range.setValues", json!({"range": "G3", "values": [[1], [2]]})).unwrap();+ fill(&mut s, "G3:G4", "G1:G4", "linearTrend").unwrap();+ assert_eq!(nums(&s, &["G2", "G1"]), n(&[0.0, -1.0]));+ s.execute("range.setValues", json!({"range": "J1", "values": [[1, 2]]})).unwrap();+ fill(&mut s, "J1:K1", "H1:K1", "growthTrend").unwrap();+ assert_eq!(nums(&s, &["I1", "H1"]), n(&[0.5, 0.25]));+ // Negative numbers grow away from zero: -2, -4 gives -8.+ s.execute("range.setValues", json!({"range": "L1", "values": [[-2], [-4]]})).unwrap();+ fill(&mut s, "L1:L2", "L1:L3", "growthTrend").unwrap();+ assert_eq!(v(&s, "L3"), Value::Number(-8.0));+ // A tiny start with a huge factor still reaches 1e300 without overflowing on the way.+ s.execute("range.setValues", json!({"range": "S1", "values": [[1e-200], [1e-100]]})).unwrap();+ fill(&mut s, "S1:S2", "S1:S6", "growthTrend").unwrap();+ let Value::Number(s6) = v(&s, "S6") else { panic!("S6 is {:?}", v(&s, "S6")) };+ assert!((s6 / 1e300 - 1.0).abs() < 1e-9, "S6 = {s6}");+ // A lone number repeats, and dates a month apart step by their days, not by months.+ s.execute("cell.set", json!({"cell": "M1", "input": "7"})).unwrap();+ fill(&mut s, "M1", "M1:M2", "linearTrend").unwrap();+ assert_eq!(v(&s, "M2"), Value::Number(7.0));+ s.execute("cell.set", json!({"cell": "N1", "input": "1/15/2026"})).unwrap();+ s.execute("cell.set", json!({"cell": "N2", "input": "2/15/2026"})).unwrap();+ fill(&mut s, "N1:N2", "N1:N3", "linearTrend").unwrap();+ assert_eq!(v(&s, "N3"), Value::Number(46099.0), "2/15/2026 plus 31 days");+ // Text or an empty cell in the source, and zero or mixed signs for Growth Trend, are refused+ // and fill nothing.+ s.execute("range.setValues", json!({"range": "O1", "values": [[1, 1, 0], ["x", -2, 2]]})).unwrap();+ assert!(fill(&mut s, "O1:O2", "O1:O3", "linearTrend").is_err());+ assert!(fill(&mut s, "P1:P2", "P1:P3", "growthTrend").is_err());+ assert!(fill(&mut s, "Q1:Q2", "Q1:Q3", "growthTrend").is_err());+ assert!(fill(&mut s, "R1:R2", "R1:R3", "linearTrend").is_err());+ assert_eq!(nums(&s, &["O3", "P3", "Q3", "R3"]), [Value::Empty, Value::Empty, Value::Empty, Value::Empty]);+ fill(&mut s, "P1:P2", "P1:P3", "linearTrend").unwrap();+ assert_eq!(v(&s, "P3"), Value::Number(-5.0));+}++#[test]+fn trend_fills_are_offered_for_numbers_only() {+ let mut s = s();+ let offered = |s: &Session, src: &str, target: &str, growth: bool| {+ let r = |a: &str| gridcraft_core::RangeRef::parse(a).unwrap();+ crate::fill::trend_applies(&s.doc().unwrap().wb, 0, r(src), r(target), growth)+ };+ s.execute("range.setValues", json!({"range": "A1", "values": [[1, -1, 1], [2, -3, "x"]]})).unwrap();+ s.execute("cell.set", json!({"cell": "D1", "input": "=A1"})).unwrap();+ s.execute("cell.set", json!({"cell": "D2", "input": "5"})).unwrap();+ assert!(offered(&s, "A1:A2", "A1:A5", false) && offered(&s, "A1:A2", "A1:A5", true));+ // Down A1:B2, each column keeps one sign; across it, row 1 holds 1 and -1.+ assert!(offered(&s, "A1:B2", "A1:B5", true));+ assert!(offered(&s, "A1:B2", "A1:E2", false) && !offered(&s, "A1:B2", "A1:E2", true));+ assert!(!offered(&s, "C1:C2", "C1:C4", false), "text");+ assert!(!offered(&s, "D1:D2", "D1:D4", false), "a formula");+ assert!(!offered(&s, "E1:E2", "E1:E4", false), "empty cells");+}+ #[test] fn only_a_number_in_a_date_format_offers_the_date_fills() { let mut s = s();@@ -157,6 +157,9 @@ 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)>,+ /// The trends the fill menu lists, cached per (doc uid, revision, sheet, source, dragged range):+ /// finding them reads every source cell.+ pub fill_menu_trends: Option<((u64, u64, usize, RangeRef, RangeRef), Vec<(&'static str, &'static str)>)>, /// 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.@@ -3553,6 +3556,25 @@ fn fill_modes(app: &SheetApp, src: RangeRef) -> Vec<(&'static str, &'static str) FILL_MODES.into_iter().chain(DATE_FILL_MODES.into_iter().filter(|_| dates)).collect() } +/// Linear Trend and Growth Trend, which the right-drag fill menu adds after the other fill modes+/// when they apply to a fill from `src` to `target` on the active sheet.+fn trend_fill_modes(app: &mut SheetApp, src: RangeRef, target: RangeRef) -> Vec<(&'static str, &'static str)> {+ let Some(d) = app.session.active() else { return Vec::new() };+ let key = (d.uid, d.revision, d.wb.active_sheet(), src, target);+ if let Some((k, modes)) = &app.grid.fill_menu_trends+ && *k == key+ {+ return modes.clone();+ }+ let modes: Vec<(&'static str, &'static str)> = [("Linear Trend", "linearTrend", false), ("Growth Trend", "growthTrend", true)]+ .into_iter()+ .filter(|(_, _, growth)| gridcraft_engine::fill::trend_applies(&d.wb, d.wb.active_sheet(), src, target, *growth))+ .map(|(label, mode, _)| (label, mode))+ .collect();+ app.grid.fill_menu_trends = Some((key, modes.clone()));+ modes+}+ /// 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 =@@ -3636,6 +3658,7 @@ 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(trend_fill_modes(app, src, target)); items.extend([("-", ""), ("Series…", "ui:series")]); let response = egui::Area::new(egui::Id::unique("fill_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| {@@ -1,6 +1,7 @@ //! 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…, and from-//! a date Fill Days, Fill Weekdays, Fill Months and Fill Years).+//! menu (Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…, from a+//! date Fill Days, Fill Weekdays, Fill Months and Fill Years, and from numbers Linear Trend and+//! Growth Trend). use egui::{Event, Key, Modifiers, PointerButton, Pos2, vec2}; use egui_kittest::kittest::Queryable;@@ -224,6 +225,42 @@ fn a_number_gets_no_date_fills() { } } +#[test]+fn growth_trend_multiplies_and_linear_trend_adds() {+ let mut h = harness(one_two);+ drag_handle(&mut h, "B2", "B5", PointerButton::Secondary);+ h.get_by_label("Growth Trend").click();+ h.run_steps(3);+ assert_eq!(values(&h, &["B1", "B2", "B3", "B4", "B5"]), [n(1.0), n(2.0), n(4.0), n(8.0), n(16.0)]);+ assert!(h.state().grid.fill_menu.is_none());+ assert_eq!(selection(&h), "B1:B5");++ let mut h = harness(one_two);+ drag_handle(&mut h, "B2", "B5", PointerButton::Secondary);+ h.get_by_label("Linear Trend").click();+ h.run_steps(3);+ assert_eq!(values(&h, &["B3", "B4", "B5"]), [n(3.0), n(4.0), n(5.0)]);+}++#[test]+fn text_and_mixed_signs_leave_out_the_trends() {+ let mut h = harness(|s| {+ s.execute("range.setValues", json!({"range": "B1", "values": [["Item 1"], ["Item 2"]]})).unwrap();+ s.execute("selection.set", json!({"range": "B1:B2"})).unwrap();+ });+ drag_handle(&mut h, "B2", "B4", PointerButton::Secondary);+ assert!(h.query_by_label("Fill Series").is_some());+ assert!(h.query_by_label("Linear Trend").is_none() && h.query_by_label("Growth Trend").is_none());++ let mut h = harness(|s| {+ s.execute("range.setValues", json!({"range": "B1", "values": [[-1], [2]]})).unwrap();+ s.execute("selection.set", json!({"range": "B1:B2"})).unwrap();+ });+ drag_handle(&mut h, "B2", "B4", PointerButton::Secondary);+ assert!(h.query_by_label("Linear Trend").is_some());+ assert!(h.query_by_label("Growth Trend").is_none());+}+ #[test] fn escape_mid_right_drag_cancels_it() { let mut h = harness(|s| {@@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (Linear Trend and Growth Trend in the right-drag fill menu; AutoSum over a selected row puts its total to the right) · **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@@ -52,7 +52,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, 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 |+| 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, from a date Fill Days, Fill Weekdays, Fill Months and Fill Years, and from numbers Linear Trend and Growth Trend, then Series…; a left-drag leaves an Auto Fill Options button with the same fill choices except the trends (gone once the workbook changes or the selection moves); 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. |@@ -187,6 +187,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---|+| 2026-10-25 | minor | The right-drag fill menu offers Linear Trend and Growth Trend when every source cell holds a number (`edit.autoFill` modes `linearTrend` and `growthTrend`). The targets continue the least-squares line (y = mx + b) or the exponential curve (y = b·m^x) fitted to each row or column of the source, so 1, 2 continues 3, 4, 5 or 4, 8, 16, and dragging up or left continues the trend backward. The source keeps its values, and a lone number repeats. Growth Trend is offered only when each row or column is all above zero or all below zero; numbers below zero grow away from zero | | 2026-10-25 | minor | AutoSum (Alt+=, Home › AutoSum) over a selected row puts its total to the right of the row, or in its last cell when that is empty, as in Excel; it wrote a one-cell total under each cell. A selection's empty last column takes each row's total and its empty last row each column's, with the block's total in the corner when both are empty | | 2026-10-25 | minor | Paste Name lays out only the names in view, so a workbook with thousands of names opens it at once; the arrow keys still scroll the chosen name into view. Go To matches a `Sheet2!Name` reference and hides a workbook name behind the active sheet's own name in any case and script (`Größe` = `GRÖSSE`), as formulas do. The two Page Layout view rows are one; the 0.7 in side margin used when Align with Page Margins is off is marked unverified | | 2026-10-24 | minor | Go To lists the named ranges on every sheet, as Excel's does ("The Go to popup window shows named ranges on every worksheet in your workbook"): a name scoped to another sheet appears as `Sheet2!Name`, and hidden names, constants and formulas are left out. It keeps the references entered (newest first, ten at most), and a double-click on an entry goes there. The Reference box and `edit.goTo` take several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30,H21:H29`) and select them all; `edit.goTo {list: true}` returns the list |