diff --git a/crates/engine/src/fill.rs b/crates/engine/src/fill.rs
--- a/crates/engine/src/fill.rs
+++ b/crates/engine/src/fill.rs
@@ -85,26 +85,148 @@ pub fn holds_source_in_corner(src: RangeRef, target: RangeRef) -> bool {
         && (target.start.col == src.start.col || target.end.col == src.end.col)
 }
 
+/// The same axis, direction and write count for a committed fill and its preview.
+struct FillShape {
+    vertical: bool,
+    forward: bool,
+    lane_count: u32,
+    steps: u32,
+}
+
+impl FillShape {
+    fn new(src: RangeRef, target: RangeRef) -> Self {
+        // Direction: down/up if target extends rows, else right/left.
+        let vertical = target.height() != src.height() || target.width() == src.width();
+        let forward = if vertical {
+            target.end.row > src.end.row || target.start.row == src.start.row
+        } else {
+            target.end.col > src.end.col || target.start.col == src.start.col
+        };
+        // Every source lane writes one cell per step past the source: bound that product, not the
+        // source and target separately (whole rows into whole columns was ~17 billion writes).
+        let (lane_count, steps) = if vertical {
+            let steps = if forward { target.end.row.saturating_sub(src.end.row) } else { src.start.row.saturating_sub(target.start.row) };
+            (src.width(), steps)
+        } else {
+            let steps = if forward { target.end.col.saturating_sub(src.end.col) } else { src.start.col.saturating_sub(target.start.col) };
+            (src.height(), steps)
+        };
+        Self { vertical, forward, lane_count, steps }
+    }
+}
+
+/// One source lane and its detected patterns, shared by filling and read-only prediction.
+struct FillLane {
+    source: Vec<(CellRef, Option<Cell>)>,
+    pattern: Pattern,
+    per_cell: Vec<Pattern>,
+}
+
+impl FillLane {
+    fn new(wb: &Workbook, src_cells: Vec<(CellRef, Option<Cell>)>, mode: FillMode) -> Self {
+        let fmt = |c: &(CellRef, Option<Cell>)| 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 first_fmt = src_cells.first().map(fmt).unwrap_or_default();
+            let dates = match mode {
+                FillMode::Dates(unit) => Pattern::dates(&src_cells, unit, wb.date_system, first_fmt),
+                _ => None,
+            };
+            let detected = dates.unwrap_or_else(|| Pattern::detect(&src_cells, &wb.custom_lists, wb.date_system, first_fmt));
+            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.extends_series() && matches!(pattern, Pattern::Copy) && src_cells.len() > 1 {
+            src_cells.iter().map(|c| Pattern::detect(std::slice::from_ref(c), &wb.custom_lists, wb.date_system, fmt(c))).collect()
+        } else {
+            Vec::new()
+        };
+        Self { source: src_cells, pattern, per_cell }
+    }
+
+    fn cell(&self, sh: &gridcraft_model::Sheet, t: CellRef, k: usize, forward: bool, mode: FillMode) -> (CellRef, Cell) {
+        let step = k as i64 + 1;
+        let idx = k % self.source.len().max(1);
+        let src_i = if forward { idx } else { self.source.len() - 1 - idx };
+        let (sc, scell) = self.source.get(src_i).cloned().unwrap_or((t, None));
+        let mut cell = scell.clone().unwrap_or_default();
+        match mode {
+            FillMode::Formats => {
+                let mut existing = sh.cell(t).cloned().unwrap_or_default();
+                existing.style = cell.style;
+                return (sc, existing);
+            }
+            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() {
+                        let shifted = gridcraft_formula::adjust::shift_relative(e, t.row as i64 - sc.row as i64, t.col as i64 - sc.col as i64);
+                        cell.formula = Some(Arc::new(Formula::from_expr(shifted)));
+                        cell.value = Value::Empty;
+                    }
+                } else if let Some(v) = match self.per_cell.get(src_i) {
+                    Some(p) => p.value(k as i64 / self.source.len() as i64 + 1, forward, 1),
+                    None => self.pattern.value(step, forward, self.source.len()),
+                } {
+                    cell.value = v;
+                }
+                cell.style = style;
+            }
+        }
+        (sc, cell)
+    }
+}
+
+/// Predict one destination cell using the committed fill's patterns and formula adjustment.
+/// No workbook, calculation, revision or history state is changed. Work depends only on one
+/// source lane (at most 4,096 cells), never on the distance dragged or number of target cells.
+/// Oversized sources and ranges beyond the fill command's size limit have no preview.
+pub fn preview_cell(wb: &Workbook, sheet: usize, src: RangeRef, target: RangeRef, at: CellRef, mode: FillMode) -> Option<Cell> {
+    if src.count() > MAX_FILL_CELLS
+        || target.count() > MAX_FILL_CELLS
+        || !holds_source_in_corner(src, target)
+        || !target.contains(at)
+        || src.contains(at)
+    {
+        return None;
+    }
+    let FillShape { vertical, forward, lane_count, steps } = FillShape::new(src, target);
+    let src_len = if vertical { src.height() } else { src.width() };
+    if src_len > 4096 || u64::from(lane_count) * u64::from(steps) > MAX_FILL_CELLS {
+        return None;
+    }
+    // A diagonal target is filled on the engine's selected axis, in the source's lanes only.
+    let (lane, offset) = if vertical {
+        if at.col < src.start.col || at.col > src.end.col {
+            return None;
+        }
+        (at.col, if forward { at.row.checked_sub(src.end.row)? } else { src.start.row.checked_sub(at.row)? })
+    } else {
+        if at.row < src.start.row || at.row > src.end.row {
+            return None;
+        }
+        (at.row, if forward { at.col.checked_sub(src.end.col)? } else { src.start.col.checked_sub(at.col)? })
+    };
+    let k = offset.checked_sub(1)? as usize;
+    let sh = wb.sheet(sheet)?;
+    let source = (0..src_len)
+        .map(|i| {
+            let c = if vertical { CellRef::new(src.start.row + i, lane) } else { CellRef::new(lane, src.start.col + i) };
+            (c, sh.cell(c).cloned())
+        })
+        .collect();
+    Some(FillLane::new(wb, source, mode).cell(sh, at, k, forward, mode).1)
+}
+
 /// Fills `target` (which contains `src` at one edge) from the pattern in `src`.
 pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: FillMode) -> Result<()> {
-    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 forward = if vertical {
-        target.end.row > src.end.row || target.start.row == src.start.row
-    } else {
-        target.end.col > src.end.col || target.start.col == src.start.col
-    };
-    // Every source lane writes one cell per step past the source: bound that product, not the
-    // source and target separately (whole rows into whole columns was ~17 billion writes).
-    let (lane_count, steps) = if vertical {
-        let steps = if forward { target.end.row.saturating_sub(src.end.row) } else { src.start.row.saturating_sub(target.start.row) };
-        (src.width(), steps)
-    } else {
-        let steps = if forward { target.end.col.saturating_sub(src.end.col) } else { src.start.col.saturating_sub(target.start.col) };
-        (src.height(), steps)
-    };
+    let FillShape { vertical, forward, lane_count, steps } = FillShape::new(src, target);
     if u64::from(lane_count).saturating_mul(u64::from(steps)) > MAX_FILL_CELLS {
         return Err(crate::EngineError::Other("The fill is too large.".into()));
     }
@@ -128,57 +250,12 @@ 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 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 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,
-            }
-        } 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.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()
-        };
+        let lane = FillLane::new(&cx.wb, src_cells, mode);
         for (k, t) in targets.iter().enumerate() {
-            let step = k as i64 + 1;
-            let idx = k % src_cells.len().max(1);
-            let src_i = if forward { idx } else { src_cells.len() - 1 - idx };
-            let (sc, scell) = src_cells.get(src_i).cloned().unwrap_or((src.start, None));
-            let mut cell = scell.clone().unwrap_or_default();
-            match mode {
-                FillMode::Formats => {
-                    let mut existing = sh.cell(*t).cloned().unwrap_or_default();
-                    existing.style = cell.style;
-                    writes.push((*t, Some(existing)));
-                    continue;
-                }
-                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() {
-                            let shifted = gridcraft_formula::adjust::shift_relative(e, t.row as i64 - sc.row as i64, t.col as i64 - sc.col as i64);
-                            cell.formula = Some(Arc::new(Formula::from_expr(shifted)));
-                            cell.value = Value::Empty;
-                        }
-                    } else if let Some(v) = match per_cell.get(src_i) {
-                        Some(p) => p.value(k as i64 / src_cells.len() as i64 + 1, forward, 1),
-                        None => pattern.value(step, forward, src_cells.len()),
-                    } {
-                        cell.value = v;
-                    }
-                    cell.style = style;
-                }
+            let (sc, cell) = lane.cell(sh, *t, k, forward, mode);
+            if mode == FillMode::Formats {
+                writes.push((*t, Some(cell)));
+                continue;
             }
             if let Some(picture) = sh.cell_pictures.get(&sc) {
                 pictures.push((*t, picture.clone()));
diff --git a/crates/engine/tests/it/autofill_preview.rs b/crates/engine/tests/it/autofill_preview.rs
new file mode 100644
--- /dev/null
+++ b/crates/engine/tests/it/autofill_preview.rs
@@ -0,0 +1,83 @@
+//! Fill-handle prediction agrees with the committed engine fill without changing state.
+
+use gridcraft_engine::{
+    Session,
+    core::{CellRef, RangeRef, Value},
+    fill::{FillMode, preview_cell},
+};
+
+#[test]
+fn preview_matches_committed_fill_in_both_directions_and_modes() {
+    for (values, source, target, at, mode, expected) in [
+        (serde_json::json!([[1], [2], [3]]), "B1:B3", "B1:B6", "B6", "series", "6"),
+        (serde_json::json!([[1], [4], [4]]), "B1:B3", "B1:B6", "B6", "series", "9"),
+        (serde_json::json!([[1], [2], [3]]), "B4:B6", "B1:B6", "B1", "series", "-2"),
+        (serde_json::json!([[1, 2, 3]]), "B2:D2", "B2:G2", "G2", "series", "6"),
+        (serde_json::json!([[1, 2, 3]]), "E2:G2", "B2:G2", "B2", "series", "-2"),
+        (serde_json::json!([[1], [2], [3]]), "B1:B3", "B1:B5", "B5", "alternate", "2"),
+        (serde_json::json!([[5]]), "B1", "B1:B4", "B4", "series", "5"),
+        (serde_json::json!([[5]]), "B1", "B1:B4", "B4", "alternate", "8"),
+        (serde_json::json!([["Item 007"]]), "B1", "B1:B4", "B4", "series", "Item 010"),
+        (serde_json::json!([["Monday"]]), "B1", "B1:B4", "B4", "series", "Thursday"),
+        (serde_json::json!([["Product 1"], ["On backorder"]]), "B1:B2", "B1:B5", "B5", "series", "Product 3"),
+        (serde_json::json!([["1/31/2026"]]), "B1", "B1:B4", "B4", "series", "2/3/2026"),
+        (serde_json::json!([["1/31/2026"], ["3/31/2026"]]), "B1:B2", "B1:B4", "B4", "series", "7/31/2026"),
+        (serde_json::json!([["=A1+$A$1"]]), "B1", "B1:B4", "B4", "series", "=A4+$A$1"),
+    ] {
+        let mut s = Session::new();
+        s.new_workbook();
+        let src = RangeRef::parse(source).unwrap();
+        let target_range = RangeRef::parse(target).unwrap();
+        let at = CellRef::parse(at).unwrap();
+        s.execute("range.setValues", serde_json::json!({"range": src.start.a1(), "values": values})).unwrap();
+        let d = s.doc().unwrap();
+        let revision = d.revision;
+        let undo = d.undo.len();
+        let fill_mode = if mode == "alternate" { FillMode::Alternate } else { FillMode::Series };
+        let preview = preview_cell(&d.wb, 0, src, target_range, at, fill_mode).unwrap();
+        let text = if preview.formula.is_some() {
+            preview.input_text()
+        } else {
+            gridcraft_engine::display::value_text_as(&d.wb, &preview.value, d.wb.styles.get(preview.style).num_fmt.as_str())
+        };
+        assert_eq!(text, expected, "{source} -> {target} ({mode})");
+        assert_eq!((s.doc().unwrap().revision, s.doc().unwrap().undo.len()), (revision, undo));
+        s.execute("edit.autoFill", serde_json::json!({"source": source, "target": target, "mode": mode})).unwrap();
+        let committed = s.doc().unwrap().wb.active().unwrap().cell(at).unwrap();
+        assert_eq!(preview.formula, committed.formula);
+        if preview.formula.is_none() {
+            assert_eq!(preview.value, committed.value);
+        }
+        assert_eq!(preview.style, committed.style);
+    }
+}
+
+#[test]
+fn preview_bounds_work_by_source_lane_instead_of_drag_distance() {
+    let mut s = Session::new();
+    s.new_workbook();
+    s.execute("range.setValues", serde_json::json!({"range": "A1", "values": [[1, 10], [2, 20]]})).unwrap();
+    let wb = &s.doc().unwrap().wb;
+    let src = RangeRef::parse("A1:B2").unwrap();
+    let target = RangeRef::parse("A1:B1048576").unwrap();
+    let at = CellRef::parse("B1048576").unwrap();
+    assert_eq!(preview_cell(wb, 0, src, target, at, FillMode::Series).unwrap().value, Value::number(10_485_760.0));
+    let huge_source = RangeRef::parse("A1:A1048575").unwrap();
+    assert!(
+        preview_cell(wb, 0, huge_source, RangeRef::parse("A1:A1048576").unwrap(), CellRef::parse("A1048576").unwrap(), FillMode::Series).is_none()
+    );
+    assert!(preview_cell(wb, 0, src, src, src.start, FillMode::Series).is_none());
+    assert!(preview_cell(wb, 0, RangeRef::parse("A1:F2").unwrap(), RangeRef::parse("A1:F1048576").unwrap(), at, FillMode::Series).is_none());
+}
+
+#[test]
+fn preview_rejects_rectangles_that_the_fill_command_refuses() {
+    let mut s = Session::new();
+    s.new_workbook();
+    s.execute("cell.set", serde_json::json!({"cell": "A1", "input": "5"})).unwrap();
+    for (source, target, at) in [("A1:E1", "A1:E1000001", "A1000001"), ("A1:XFD400", "A1:XFD401", "A401")] {
+        let (src, target_range, at) = (RangeRef::parse(source).unwrap(), RangeRef::parse(target).unwrap(), CellRef::parse(at).unwrap());
+        assert!(preview_cell(&s.doc().unwrap().wb, 0, src, target_range, at, FillMode::Series).is_none(), "{source} -> {target}");
+        assert!(s.execute("edit.autoFill", serde_json::json!({"source": source, "target": target})).is_err());
+    }
+}
diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs
--- a/crates/engine/tests/it/main.rs
+++ b/crates/engine/tests/it/main.rs
@@ -1,5 +1,6 @@
 //! The crate's integration tests, one module per file, built as one test binary.
 
+mod autofill_preview;
 mod autofit_filter_buttons;
 mod cf_formula_validate;
 mod chart_area_stacked100;
diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs
--- a/crates/ui-egui/src/grid.rs
+++ b/crates/ui-egui/src/grid.rs
@@ -863,6 +863,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
     context_menu(app, ui);
     fill_menu(app, ui, previous_popups.3 == app.grid.fill_menu);
     fill_options_tag(app, ui, &geo, sh);
+    fill_value_preview(app, ui, &geo, sh);
     object_menu(app, ui);
     error_check_tag(app, ui, &geo, sh);
     let track_x = if rtl { (available.left(), rect.left()) } else { (rect.right(), available.right()) };
@@ -2670,7 +2671,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
             }
             Drag::Fill { target: Some(t), menu: false } => {
                 let src = sel.current();
-                if t != src {
+                if t != src && !ctx.input_mut(|i| i.consume_key(mods, egui::Key::Escape)) {
                     // Ctrl (Cmd on macOS) swaps copying and extending, as in Excel.
                     let mode = if mods.command { "alternate" } else { "series" };
                     match app.run("edit.autoFill", json!({"source": src.a1(), "target": t.a1(), "mode": mode})) {
@@ -2844,6 +2845,46 @@ fn scroll_by(app: &mut SheetApp, d: egui::Vec2) {
     }
 }
 
+/// The current drag endpoint, predicted by the engine without executing an edit.
+fn fill_value_preview(app: &SheetApp, ui: &egui::Ui, geo: &Geo, sh: &Sheet) {
+    let Drag::Fill { target: Some(target), menu: false } = app.grid.drag else { return };
+    let Some(doc) = app.session.active() else { return };
+    let Some(pointer) = ui.ctx().pointer_hover_pos() else { return };
+    let pc = geo.cell_at(sh, geo.mirror_pos(pointer));
+    let at = CellRef::new(pc.row.clamp(target.start.row, target.end.row), pc.col.clamp(target.start.col, target.end.col));
+    let mode = if ui.input(|i| i.modifiers.command) { gridcraft_engine::fill::FillMode::Alternate } else { gridcraft_engine::fill::FillMode::Series };
+    let Some(cell) = gridcraft_engine::fill::preview_cell(&doc.wb, doc.wb.active_sheet(), doc.selection.current(), target, at, mode) else { return };
+    let style = doc.wb.styles.get(cell.style);
+    // Recalculation is deferred until release; show the shifted formula itself.
+    let text = if cell.formula.is_some() {
+        if style.protection.hidden && sh.is_protected() {
+            return;
+        }
+        crate::formula_locale::display(&cell.input_text(), app.ui.language.formula_locale(), &doc.wb, doc.wb.active_sheet())
+    } else {
+        gridcraft_engine::display::value_text_as(&doc.wb, &cell.value, style.num_fmt.as_str())
+    };
+    if text.is_empty() {
+        return;
+    }
+    // Keep long cell contents from covering the grid while dragging.
+    let mut chars = text.chars();
+    let mut label: String = chars.by_ref().take(128).collect();
+    if chars.next().is_some() {
+        label.push('…');
+    }
+    egui::Area::new(egui::Id::unique("fill_value_preview"))
+        .order(egui::Order::Tooltip)
+        .interactable(false)
+        .fixed_pos(pointer + vec2(14.0, 18.0))
+        .show(ui.ctx(), |ui| {
+            ui.set_max_width(240.0);
+            egui::Frame::popup(ui.style()).show(ui, |ui| {
+                ui.add(egui::Label::new(label).wrap());
+            });
+        });
+}
+
 fn fill_target(src: RangeRef, pc: CellRef) -> RangeRef {
     let below = pc.row as i64 - src.end.row as i64;
     let above = src.start.row as i64 - pc.row as i64;
diff --git a/crates/ui-egui/tests/it/fill_options_button.rs b/crates/ui-egui/tests/it/fill_options_button.rs
--- a/crates/ui-egui/tests/it/fill_options_button.rs
+++ b/crates/ui-egui/tests/it/fill_options_button.rs
@@ -91,6 +91,167 @@ fn n(x: f64) -> Value {
     Value::Number(x)
 }
 
+#[test]
+fn fill_drag_previews_endpoint_without_changing_the_workbook() {
+    let mut h = harness(|s| {
+        s.execute("range.setValues", json!({"range": "B1", "values": [[1], [2], [3]]})).unwrap();
+        s.execute("selection.set", json!({"range": "B1:B3"})).unwrap();
+    });
+    let before = h.state().session.active().unwrap();
+    let (undo_before, revision_before, dirty_before) = (before.undo.len(), before.revision, before.is_dirty());
+    let workbook_before = before.wb.clone();
+    let (start, end) = (corner(&h, "B3"), center(&h, "B6"));
+    press(&mut h, start, PointerButton::Primary);
+    move_to(&mut h, start, end);
+    h.run_steps(3);
+
+    assert_eq!(values(&h, &["B4", "B5", "B6"]), [Value::Empty, Value::Empty, Value::Empty]);
+    assert_eq!(h.state().session.active().unwrap().undo.len(), undo_before);
+    let during = h.state().session.active().unwrap();
+    assert_eq!((during.revision, during.is_dirty()), (revision_before, dirty_before));
+    assert!(std::sync::Arc::ptr_eq(&workbook_before, &during.wb));
+    if let Ok(path) = std::env::var("GRIDCRAFT_FILL_PREVIEW_SCREENSHOT") {
+        h.render().unwrap().save(path).unwrap();
+    }
+    assert!(h.query_by_label("6").is_some(), "the tooltip predicts the value at the drag endpoint");
+
+    release(&mut h, end, PointerButton::Primary);
+    assert_eq!(value(&h, "B6"), n(6.0));
+    assert!(h.query_by_label("6").is_none(), "the tooltip disappears on release");
+    assert!(h.query_by_label("Auto Fill Options").is_some());
+    assert_eq!(h.state().session.active().unwrap().undo.len(), undo_before + 1);
+}
+
+#[test]
+fn escape_cancels_the_fill_preview_without_committing() {
+    let mut h = harness(one_two);
+    let before = h.state().session.active().unwrap().wb.clone();
+    let (start, end) = (corner(&h, "B2"), center(&h, "B6"));
+    press(&mut h, start, PointerButton::Primary);
+    move_to(&mut h, start, end);
+    h.run_steps(3);
+    assert!(h.query_by_label("6").is_some());
+    h.input_mut().events.push(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
+    h.run_steps(3);
+    release(&mut h, end, PointerButton::Primary);
+    assert!(h.query_by_label("6").is_none());
+    assert!(std::sync::Arc::ptr_eq(&before, &h.state().session.active().unwrap().wb));
+    assert!(h.query_by_label("Auto Fill Options").is_none());
+}
+
+#[test]
+fn escape_also_cancels_a_copy_modifier_drag() {
+    let mut h = harness(one_two);
+    let before = h.state().session.active().unwrap().wb.clone();
+    let (start, end) = (corner(&h, "B2"), center(&h, "B6"));
+    press(&mut h, start, PointerButton::Primary);
+    move_to(&mut h, start, end);
+    let modifiers = Modifiers::COMMAND;
+    h.input_mut().events.push(Event::ModifiersChanged(modifiers));
+    h.run_steps(3);
+    assert!(h.query_by_label("2").is_some());
+    h.input_mut().events.push(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers });
+    h.run_steps(3);
+    release(&mut h, end, PointerButton::Primary);
+    assert!(h.query_by_label("2").is_none());
+    assert!(std::sync::Arc::ptr_eq(&before, &h.state().session.active().unwrap().wb));
+}
+
+#[test]
+fn fill_preview_follows_direction_format_and_copy_modifier() {
+    for (source, input, from, to, expected, alternate, rtl) in [
+        ("B4:B6", json!([[1], [2], [3]]), "B6", "B1", "-2", false, false),
+        ("B2:D2", json!([[1, 2, 3]]), "D2", "G2", "6", false, false),
+        ("E2:G2", json!([[1, 2, 3]]), "G2", "B2", "-2", false, false),
+        ("B1:B3", json!([[1], [2], [3]]), "B3", "B5", "2", true, false),
+        ("B1", json!([[5]]), "B1", "B4", "8", true, false),
+        ("B1", json!([["1/31/2026"]]), "B1", "B4", "2/3/2026", false, false),
+        ("B1", json!([["Item 007"]]), "B1", "B4", "Item 010", false, false),
+        ("B1", json!([["=A1+1"]]), "B1", "B4", "=A4+1", false, false),
+        ("B1:B3", json!([[1], [2], [3]]), "B3", "B6", "6", false, true),
+    ] {
+        let mut h = harness(|s| {
+            let start = source.split(':').next().unwrap();
+            s.execute("range.setValues", json!({"range": start, "values": input})).unwrap();
+            s.execute("selection.set", json!({"range": source})).unwrap();
+            if rtl {
+                s.execute("view.rightToLeft", json!({"on": true})).unwrap();
+            }
+        });
+        let (start, end) = (corner(&h, from), center(&h, to));
+        press(&mut h, start, PointerButton::Primary);
+        move_to(&mut h, start, end);
+        // Change the modifier during the drag; the tooltip must use the release mode.
+        let modifiers = Modifiers { command: alternate, ctrl: alternate, ..Modifiers::NONE };
+        h.input_mut().events.push(Event::ModifiersChanged(modifiers));
+        h.run_steps(3);
+        assert!(h.query_by_label(expected).is_some(), "{source} -> {to}, alternate={alternate}, rtl={rtl}");
+        assert_eq!(value(&h, to), Value::Empty);
+        h.input_mut().events.push(Event::PointerButton { pos: end, button: PointerButton::Primary, pressed: false, modifiers });
+        h.run_steps(3);
+        assert!(h.query_by_label(expected).is_none(), "preview gone after release");
+        let d = h.state().session.active().unwrap();
+        let sh = d.wb.active().unwrap();
+        let cell = CellRef::parse(to).unwrap();
+        let actual = if sh.cell(cell).is_some_and(|c| c.formula.is_some()) {
+            sh.input_text(cell)
+        } else {
+            gridcraft_engine::display::cell_text(&d.wb, sh, cell)
+        };
+        assert_eq!(actual, expected);
+    }
+}
+
+#[test]
+fn fill_preview_keeps_protected_formulas_hidden() {
+    for (protected, hidden, target_unlocked) in [(true, true, true), (true, true, false), (false, true, true), (true, false, true)] {
+        let mut h = harness(|s| {
+            s.execute("cell.set", json!({"cell": "B1", "input": "=A1*1.17"})).unwrap();
+            s.execute("home.formatCells", json!({"range": "B1", "style": {"protection": {"hidden": hidden}}})).unwrap();
+            if target_unlocked {
+                s.execute("home.formatCells", json!({"range": "B2:B4", "style": {"protection": {"locked": false}}})).unwrap();
+            }
+            s.execute("selection.set", json!({"cell": "B1"})).unwrap();
+            if protected {
+                s.execute("review.protectSheet", json!({})).unwrap();
+            }
+        });
+        let (start, end) = (corner(&h, "B1"), center(&h, "B4"));
+        press(&mut h, start, PointerButton::Primary);
+        move_to(&mut h, start, end);
+        h.run_steps(3);
+        assert_eq!(
+            h.query_by_label("=A4*1.17").is_some(),
+            !(protected && hidden),
+            "protected={protected}, hidden={hidden}, target_unlocked={target_unlocked}"
+        );
+        if !protected || target_unlocked {
+            release(&mut h, end, PointerButton::Primary);
+            let d = h.state().session.active().unwrap();
+            let sh = d.wb.active().unwrap();
+            assert_eq!(sh.input_text(CellRef::parse("B4").unwrap()), "=A4*1.17");
+            assert_eq!(gridcraft_engine::display::formula_hidden(&d.wb, sh, CellRef::parse("B4").unwrap()), protected && hidden);
+        }
+    }
+}
+
+#[test]
+fn formula_fill_preview_uses_the_selected_formula_language() {
+    let mut h = harness(|s| {
+        s.execute("cell.set", json!({"cell": "B1", "input": "=SUM(A1,1.5)"})).unwrap();
+        s.execute("selection.set", json!({"cell": "B1"})).unwrap();
+    });
+    h.state_mut().run("app.language.set", json!({"language": "es"})).unwrap();
+    h.run_steps(3);
+    let (start, end) = (corner(&h, "B1"), center(&h, "B4"));
+    press(&mut h, start, PointerButton::Primary);
+    move_to(&mut h, start, end);
+    h.run_steps(3);
+    assert!(h.query_by_label("=SUMA(A4;1,5)").is_some());
+    release(&mut h, end, PointerButton::Primary);
+    assert_eq!(h.state().session.active().unwrap().wb.active().unwrap().input_text(CellRef::parse("B4").unwrap()), "=SUM(A4,1.5)");
+}
+
 #[test]
 fn left_drag_shows_the_auto_fill_options_button() {
     let mut h = harness(one_two);
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -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-26 · **Change:** minor (fill-handle value preview) · **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); left-drag previews the endpoint's formatted value beside the pointer, using the engine's fill patterns (shifted formula text for formulas), without editing the workbook; previews omit source lanes over 4,096 cells and fills over the command's size limit; 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. |
@@ -187,6 +187,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-26 | minor | A left-drag of the fill handle previews the endpoint value beside the pointer before release (#2598). Prediction shares the engine's fill patterns, formats dates and numbers, shows shifted formulas in the selected formula language (respecting Hidden on protected sheets), and follows Ctrl/Cmd copy mode. It disappears on release or Escape without changing workbook revision or history; the existing fill menus are unchanged. Preview work is limited to one source lane of at most 4,096 cells, independent of drag distance |
 | 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 |
