From 1c8d0b8fe5c65425820ad272a9354cc1e4780a02 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 8 Oct 2026 12:06:39 +0200 Subject: [PATCH 01/35] Desktop: write log records to standard error and a rotating log file The desktop app installed no logger, so every log::warn!/log::error! in the workspace was discarded (a sample that failed to build, wgpu and naga warnings), and panics a command's guard recovered from only reached standard error. A launch from a desktop menu has no terminal either, so there was nothing to attach to a bug report. apps/gridcraft/src/logging.rs (no new dependency): - standard error plus logs/gridcraft.log next to ui.json and prefs.json (~/.config/gridcraft/logs, or under $XDG_CONFIG_HOME); each launch rotates the previous log to gridcraft.1.log and .2.log, so the log of a run that crashed survives the next start; the file is capped at 16 MiB - records logged before the settings directory is known are buffered (bounded) and written once the file is attached - default levels: info for gridcraft*, warn for everything else; RUST_LOG replaces them with env_logger-style directives - never panics: an unusable directory or a failed write leaves standard error as the only sink - runs with GRIDCRAFT_NO_PREFS (agents' test runs) log to standard error only, so they don't rotate away the user's own logs The logger is installed first in main, followed by a panic hook that reports every panic, caught or not, through log::error! (with a backtrace when RUST_BACKTRACE is set). The file is opened after argument parsing (--version leaves no file). The start-up messages in main and the control channel's go through the logger. Documented in README.md (Logs). --- README.md | 13 + apps/gridcraft/src/control_server.rs | 2 +- apps/gridcraft/src/logging.rs | 579 +++++++++++++++++++++++++++ apps/gridcraft/src/main.rs | 54 ++- 4 files changed, 642 insertions(+), 6 deletions(-) create mode 100644 apps/gridcraft/src/logging.rs diff --git a/README.md b/README.md index d7a4b8c..813b85d 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,19 @@ cd apps/gridcraft-web && trunk serve --release # http://127.0.0.1:8771 (?sa You need [trunk](https://trunkrs.dev) and the `wasm32-unknown-unknown` target. Open uses the browser's file picker (dropping files works too) and Save downloads the workbook. +### Logs + +The desktop app writes its log to standard error and to `gridcraft.log` in the `logs` folder +next to its settings: `~/.config/gridcraft/logs` on Linux and BSD (`$XDG_CONFIG_HOME/gridcraft/logs` +when set), `~/Library/Application Support/GridCraft/logs` on macOS and +`%APPDATA%\GridCraft\logs` on Windows. Each start moves the previous log to `gridcraft.1.log` and +that one to `gridcraft.2.log`, so the log of a run that crashed survives the next start; attach +them to a bug report. A log file stops growing at 16 MiB. By default GridCraft's own crates log +at `info` and everything else at `warn`; `RUST_LOG` replaces that with env_logger-style +directives, e.g. `RUST_LOG=debug` or `RUST_LOG=warn,gridcraft_engine=debug` (a trailing `*` +matches a prefix: `gridcraft*=debug`). Runs with `GRIDCRAFT_NO_PREFS` set (no saved settings) +log to standard error only. + ## For agents: MCP, CLI and the control channel ```sh diff --git a/apps/gridcraft/src/control_server.rs b/apps/gridcraft/src/control_server.rs index bdbc28d..e9e05a2 100644 --- a/apps/gridcraft/src/control_server.rs +++ b/apps/gridcraft/src/control_server.rs @@ -21,7 +21,7 @@ pub fn start(port: u16, ctx: egui::Context) -> std::io::Result/logs/gridcraft.log`, next to `ui.json` and `prefs.json` (`config_dir` in +//! `main.rs`). Each start moves the previous log to `gridcraft.1.log` (and that one to `.2`), so +//! the log of a run that crashed survives the next launch. Runs without preferences +//! (`GRIDCRAFT_NO_PREFS`) log to standard error only. +//! +//! Levels: `info` for GridCraft's own crates, `warn` for everything else (wgpu and naga are +//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`, +//! `warn,gridcraft_engine=trace`, `wgpu_core=info`. A directive ending in `*` matches every target +//! that starts with it (`gridcraft*=debug`). +//! +//! Records logged before the settings directory is known are kept (up to [`MAX_PENDING`]) and +//! written once the file is attached. Writing never panics: a file that can't be created or +//! written leaves standard error as the only sink. +//! +//! This is the desktop app's logger only; `gridcraft-cli` and `gridcraft-web` set up their own. + +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock, PoisonError}; +use std::time::SystemTime; + +use log::LevelFilter; + +/// The current log file's name inside the log directory. +pub const LOG_FILE: &str = "gridcraft.log"; +/// How many previous logs are kept (`gridcraft.1.log` … `gridcraft..log`). +pub const KEEP: usize = 2; +/// The log file stops growing past this size (a runaway warning can't fill the disk). +pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024; +/// Records kept in memory until the log file is attached. +pub const MAX_PENDING: usize = 512; +/// The built-in filter when `RUST_LOG` is unset or empty. +pub const DEFAULT_FILTER: &str = "warn,gridcraft*=info"; + +/// Per-target level filter parsed from env_logger-style directives. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Filter { + default: LevelFilter, + directives: Vec<(String, LevelFilter)>, +} + +impl Filter { + /// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no + /// directive names log errors only. + pub fn parse(spec: &str) -> Filter { + let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() }; + for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) { + match part.split_once('=') { + Some((name, level)) => { + let name = name.trim(); + if let (false, Some(level)) = (name.is_empty(), parse_level(level)) { + f.directives.push((name.to_owned(), level)); + } + } + None => match parse_level(part) { + Some(level) => f.default = level, + // A bare target name: everything from it (env_logger does the same). + None => f.directives.push((part.to_owned(), LevelFilter::Trace)), + }, + } + } + f + } + + /// The level that applies to `target` (the most specific matching directive wins). + pub fn level_for(&self, target: &str) -> LevelFilter { + self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level) + } + + /// The most verbose level any target can reach (for `log::set_max_level`). + pub fn max(&self) -> LevelFilter { + self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max) + } +} + +fn parse_level(s: &str) -> Option { + s.trim().parse().ok() +} + +/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`); +/// `name*` matches every target starting with `name`. +fn matches(name: &str, target: &str) -> bool { + match name.strip_suffix('*') { + Some(prefix) => target.starts_with(prefix), + None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")), + } +} + +/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +pub fn timestamp(t: SystemTime) -> String { + let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); + let secs = since.as_secs(); + let (days, rem) = (secs / 86_400, secs % 86_400); + let (y, m, d) = civil_from_days(days); + format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) +} + +/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's +/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock +/// value can overflow it. +fn civil_from_days(days: u64) -> (u64, u64, u64) { + let z = days.saturating_add(719_468); + let era = z / 146_097; + let doe = z % 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2)); + (y, m, d) +} + +fn numbered(dir: &Path, n: usize) -> PathBuf { + if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("gridcraft.{n}.log")) } +} + +/// Shift the previous logs up one (`.log` → `.1.log` → … → `..log`, the oldest dropped) +/// and create a fresh, empty log file in `dir` (created if missing). +pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> { + std::fs::create_dir_all(dir)?; + for n in (1..=KEEP).rev() { + match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e), + _ => {} + } + } + let path = numbered(dir, 0); + let file = File::create(&path)?; + Ok((path, file)) +} + +/// One formatted log line: ` [] : \n`. +pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String { + let message = message.strip_suffix('\n').unwrap_or(message); + format!("{ts} {level:<5} [{thread}] {target}: {message}\n") +} + +/// Where formatted lines go besides standard error: kept in memory until a file is attached, +/// then appended to it until it reaches its size cap. +pub struct Sink { + file: Option, + pending: Vec, + dropped: usize, + written: u64, + max_bytes: u64, + capped: bool, +} + +impl Sink { + pub fn new(max_bytes: u64) -> Sink { + Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false } + } + + /// Append `line` to the file, or keep it until [`Sink::attach`]. + pub fn write(&mut self, line: &str) { + if self.file.is_none() { + if self.capped { + // No file is coming ([`Sink::no_file`]): standard error had the line. + } else if self.pending.len() < MAX_PENDING { + self.pending.push(line.to_owned()); + } else { + self.dropped = self.dropped.saturating_add(1); + } + return; + } + self.append(line); + } + + /// Start writing to `file` (a fresh one: the size cap starts over), first the lines kept so far. + pub fn attach(&mut self, file: File) { + self.file = Some(file); + self.written = 0; + self.capped = false; + for line in std::mem::take(&mut self.pending) { + self.append(&line); + } + if self.dropped > 0 { + let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped); + self.dropped = 0; + self.append(¬e); + } + } + + /// There will be no log file: forget the lines kept for it and keep no more. + pub fn no_file(&mut self) { + self.pending = Vec::new(); + self.dropped = 0; + self.capped = true; + } + + fn append(&mut self, line: &str) { + if self.capped { + return; + } + let Some(file) = self.file.as_mut() else { return }; + let len = u64::try_from(line.len()).unwrap_or(u64::MAX); + let text = if self.written.saturating_add(len) > self.max_bytes { + self.capped = true; + format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes) + } else { + self.written = self.written.saturating_add(len); + line.to_owned() + }; + // A full disk or a vanished file must not take the app down: drop the file sink. + if file.write_all(text.as_bytes()).is_err() { + self.file = None; + self.capped = true; + } + } +} + +/// The installed logger: a [`Filter`], standard error and a [`Sink`]. +pub struct AppLogger { + filter: Filter, + stderr: bool, + sink: Mutex, +} + +impl AppLogger { + pub fn new(filter: Filter, stderr: bool) -> AppLogger { + AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) } + } + + /// Rotate the logs in `dir` and send records to the fresh file; returns its path. On an error + /// standard error stays the only sink. + pub fn attach_dir(&self, dir: &Path) -> Result { + // A thread that panicked while logging leaves the sink usable: take it back. + let mut sink = self.sink.lock().unwrap_or_else(PoisonError::into_inner); + match rotate(dir) { + Ok((path, file)) => { + sink.attach(file); + Ok(path) + } + Err(e) => { + sink.no_file(); + Err(format!("{}: {e}", dir.display())) + } + } + } + + /// No log file for this run (runs without preferences): log to standard error only. + pub fn no_file(&self) { + self.sink.lock().unwrap_or_else(PoisonError::into_inner).no_file(); + } +} + +impl log::Log for AppLogger { + fn enabled(&self, metadata: &log::Metadata<'_>) -> bool { + metadata.level() <= self.filter.level_for(metadata.target()) + } + + fn log(&self, record: &log::Record<'_>) { + if !self.enabled(record.metadata()) { + return; + } + // Formatted before the sink is locked, so a message that logs while it is formatted can't deadlock. + let thread = std::thread::current(); + let line = + format_line(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string()); + if self.stderr { + // No terminal (a Windows GUI build, a closed pipe) is not an error worth reporting. + let _ = std::io::stderr().write_all(line.as_bytes()); + } + self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line); + } + + fn flush(&self) {} +} + +/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another +/// logger was installed first. Call [`AppLogger::attach_dir`] once the settings directory is known. +pub fn install() -> Option<&'static AppLogger> { + static LOGGER: OnceLock = OnceLock::new(); + let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty()); + let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER)); + let max = filter.max(); + let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true)); + log::set_logger(logger).ok()?; + log::set_max_level(max); + Some(logger) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::{Duration, UNIX_EPOCH}; + + fn temp_dir(tag: &str) -> PathBuf { + static N: AtomicUsize = AtomicUsize::new(0); + let d = std::env::temp_dir().join(format!("gridcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&d); + d + } + + fn read(path: &Path) -> String { + std::fs::read_to_string(path).expect("read log") + } + + fn record(logger: &AppLogger, level: log::Level, target: &str, msg: &str) { + use log::Log; + logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build()); + } + + #[test] + fn the_default_filter_shows_gridcraft_info_and_other_crates_warnings() { + let f = Filter::parse(DEFAULT_FILTER); + assert_eq!(f.level_for("gridcraft"), LevelFilter::Info); + assert_eq!(f.level_for("gridcraft::control_server"), LevelFilter::Info); + assert_eq!(f.level_for("gridcraft_engine::sample"), LevelFilter::Info); + assert_eq!(f.level_for("gridcraft_ui_egui::control"), LevelFilter::Info); + assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn); + assert_eq!(f.level_for("naga"), LevelFilter::Warn); + assert_eq!(f.max(), LevelFilter::Info); + } + + #[test] + fn directives_follow_env_logger_and_the_most_specific_one_wins() { + let f = Filter::parse("info,wgpu_core=error,gridcraft_calc=trace,gridcraft_calc::recalc=off"); + assert_eq!(f.level_for("eframe"), LevelFilter::Info); + assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error); + assert_eq!(f.level_for("gridcraft_calc::graph"), LevelFilter::Trace); + assert_eq!(f.level_for("gridcraft_calc::recalc"), LevelFilter::Off); + assert_eq!(f.max(), LevelFilter::Trace); + // A module name is matched at `::` boundaries, not as a bare prefix. + assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info); + // A trailing `*` is a prefix. + assert_eq!(Filter::parse("gridcraft*=debug").level_for("gridcraft_xlsx::read"), LevelFilter::Debug); + assert_eq!(Filter::parse("gridcraft*=debug").level_for("eframe"), LevelFilter::Error); + // A bare target name sets that target to the most verbose level, as env_logger does. + assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace); + assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error); + // Levels are case-insensitive and surrounding blanks are ignored. + assert_eq!(Filter::parse(" WARN , gridcraft = Debug ").level_for("gridcraft"), LevelFilter::Debug); + assert_eq!(Filter::parse(" WARN , gridcraft = Debug ").level_for("eframe"), LevelFilter::Warn); + } + + #[test] + fn hostile_specs_never_panic_and_fall_back_sensibly() { + let long = "x".repeat(100_000); + let many = "a=info,".repeat(10_000); + for spec in [ + "", + ",,,", + "=", + "==", + "=debug", + "gridcraft=", + "nonsense=loud", + "🦀=info", + "*", + "*=", + "=*", + "a=b=c", + "warn,,gridcraft=DEBUG", + "\0", + "é::ü=trace", + &long, + &many, + ] { + let f = Filter::parse(spec); + for target in ["", "gridcraft", "🦀", "a::b", "*"] { + let _ = f.level_for(target); + } + let _ = f.max(); + } + assert_eq!(Filter::parse("warn,,gridcraft=DEBUG").level_for("gridcraft"), LevelFilter::Debug); + // An unknown level is skipped: that target keeps the default. + assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error); + assert_eq!(Filter::parse("gridcraft=").level_for("gridcraft"), LevelFilter::Error); + assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error); + assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info); + } + + #[test] + fn timestamps_are_utc_with_milliseconds() { + assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z"); + // Leap day, and the day after it. + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z"); + // 2000 is a leap year (divisible by 400), 2100 is not. + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); + // Before the epoch (a clock set wrong) clamps instead of panicking. + assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + } + + #[test] + fn a_line_carries_time_level_thread_target_and_message() { + assert_eq!( + format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "gridcraft", "book.xlsx: file not found"), + "2026-10-08T07:59:17.728Z WARN [main] gridcraft: book.xlsx: file not found\n" + ); + // A multi-line message keeps its lines; the record still ends in one newline. + assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n")); + } + + #[test] + fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { + let dir = temp_dir("rotate"); + for run in 1..=4 { + let (path, _file) = rotate(&dir).expect("rotate"); + assert_eq!(path, dir.join(LOG_FILE)); + assert_eq!(read(&path), ""); + std::fs::write(&path, format!("run {run}")).expect("write"); + } + assert_eq!(read(&dir.join(LOG_FILE)), "run 4"); + assert_eq!(read(&dir.join("gridcraft.1.log")), "run 3"); + assert_eq!(read(&dir.join("gridcraft.2.log")), "run 2"); + assert!(!dir.join("gridcraft.3.log").exists(), "only {KEEP} old logs are kept"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() { + let dir = temp_dir("blocked"); + std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); + std::fs::write(&dir, "a file where the directory should be").expect("block"); + assert!(rotate(&dir).is_err()); + let _ = std::fs::remove_file(&dir); + } + + #[test] + fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() { + let dir = temp_dir("pending"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + sink.write("early 1\n"); + sink.write("early 2\n"); + sink.attach(file); + sink.write("late\n"); + assert_eq!(read(&path), "early 1\nearly 2\nlate\n"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() { + let dir = temp_dir("bounded"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + for i in 0..MAX_PENDING + 5 { + sink.write(&format!("line {i}\n")); + } + sink.attach(file); + let text = read(&path); + assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING); + assert!(text.contains("5 earlier log lines were dropped"), "{text}"); + assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n"))); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn exactly_a_full_pending_buffer_drops_nothing() { + let dir = temp_dir("full"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + for i in 0..MAX_PENDING { + sink.write(&format!("line {i}\n")); + } + sink.attach(file); + let text = read(&path); + assert_eq!(text.lines().count(), MAX_PENDING); + assert!(!text.contains("dropped"), "{text}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn without_a_file_nothing_is_kept_in_memory() { + let mut sink = Sink::new(MAX_FILE_BYTES); + sink.write("early\n"); + sink.no_file(); + sink.write("late\n"); + assert!(sink.pending.is_empty() && sink.dropped == 0); + } + + #[test] + fn the_file_stops_at_its_size_cap_with_one_note() { + let dir = temp_dir("cap"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(100); + sink.attach(file); + for i in 0..50 { + sink.write(&format!("line {i:02} with some padding\n")); + } + let text = read(&path); + assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}"); + assert_eq!(text.matches("log file reached").count(), 1, "{text}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_logger_filters_records_and_writes_them_to_the_attached_file() { + use log::Log; + let dir = temp_dir("logger"); + let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false); + record(&logger, log::Level::Info, "gridcraft", "before the settings directory"); + record(&logger, log::Level::Info, "wgpu_core::device", "too chatty"); + let path = logger.attach_dir(&dir).expect("attach"); + assert_eq!(path, dir.join(LOG_FILE)); + record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning"); + record(&logger, log::Level::Error, "gridcraft", "internal error: boom"); + record(&logger, log::Level::Debug, "gridcraft", "below info"); + let text = read(&path); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 3, "{text}"); + assert!(lines[0].contains(" INFO [") && lines[0].ends_with("gridcraft: before the settings directory"), "{text}"); + assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}"); + assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("gridcraft: internal error: boom"), "{text}"); + assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("gridcraft_ui_egui").build())); + assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build())); + // Attaching again rotates: the first log becomes `.1`. + logger.attach_dir(&dir).expect("attach again"); + assert_eq!(read(&dir.join("gridcraft.1.log")), text); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn records_from_many_threads_are_whole_lines() { + let dir = temp_dir("threads"); + let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); + let path = logger.attach_dir(&dir).expect("attach"); + let workers: Vec<_> = (0..8) + .map(|t| { + let l = std::sync::Arc::clone(&logger); + std::thread::spawn(move || { + for i in 0..100 { + record(&l, log::Level::Info, "gridcraft", &format!("thread {t} record {i}")); + } + }) + }) + .collect(); + for w in workers { + w.join().expect("worker"); + } + let text = read(&path); + assert_eq!(text.lines().count(), 800); + assert!(text.lines().all(|l| l.contains(" gridcraft: thread ")), "{text}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() { + let dir = temp_dir("unusable"); + std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); + std::fs::write(&dir, "not a directory").expect("block"); + let logger = AppLogger::new(Filter::parse("info"), false); + assert!(logger.attach_dir(&dir).is_err()); + record(&logger, log::Level::Error, "x", "still fine"); + // Nothing waits in memory for a file that won't come. + assert!(logger.sink.lock().expect("sink").pending.is_empty()); + let _ = std::fs::remove_file(&dir); + } + + #[test] + fn a_poisoned_sink_still_logs() { + let dir = temp_dir("poison"); + let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); + let l = std::sync::Arc::clone(&logger); + let _ = std::thread::spawn(move || { + let _guard = l.sink.lock().expect("lock"); + panic!("poison the sink"); + }) + .join(); + assert!(logger.sink.is_poisoned()); + let path = logger.attach_dir(&dir).expect("attach"); + record(&logger, log::Level::Warn, "gridcraft", "after the poison"); + assert!(read(&path).contains("gridcraft: after the poison")); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 6acaf51..9abb15d 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -9,6 +9,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod control_server; +mod logging; #[cfg(target_os = "macos")] mod native_menu; @@ -61,8 +62,19 @@ fn config_dir() -> Option { } } +/// Runs without preferences (`GRIDCRAFT_NO_PREFS`, agents' test runs) neither read nor write +/// them, and keep no log file. +fn prefs_enabled() -> bool { + std::env::var_os("GRIDCRAFT_NO_PREFS").is_none() +} + +/// Where the log files live: `logs` in the settings folder, next to `ui.json` (see `logging`). +fn log_dir() -> Option { + Some(config_dir()?.join("logs")) +} + fn load_prefs(app: &mut SheetApp) { - if std::env::var_os("GRIDCRAFT_NO_PREFS").is_some() { + if !prefs_enabled() { return; } let Some(dir) = config_dir() else { return }; @@ -79,7 +91,7 @@ fn load_prefs(app: &mut SheetApp) { } fn save_prefs(app: &SheetApp) { - if std::env::var_os("GRIDCRAFT_NO_PREFS").is_some() { + if !prefs_enabled() { return; } let Some(dir) = config_dir() else { return }; @@ -133,6 +145,25 @@ fn icon() -> Option { } fn main() -> eframe::Result<()> { + // First, so the panic hook and every start-up warning are recorded (`logging`). + let logger = logging::install(); + // Commands catch their panics (`Session::execute`), but the default hook only prints to + // standard error; this one sends every panic, caught or not, to the log file too, with a + // backtrace when RUST_BACKTRACE is set. + std::panic::set_hook(Box::new(|info| { + let trace = std::backtrace::Backtrace::capture(); + let report = if trace.status() == std::backtrace::BacktraceStatus::Captured { + format!("internal error: {info}\n{trace}") + } else { + format!("internal error: {info}") + }; + // Standard error and the log file; standard error alone when RUST_LOG turned errors off. + if log::log_enabled!(log::Level::Error) { + log::error!("{report}"); + } else { + eprintln!("gridcraft: {report}"); + } + })); let args: Vec = std::env::args().skip(1).collect(); if args.iter().any(|a| a == "--version" || a == "-V") { println!("GridCraft {}", env!("CARGO_PKG_VERSION")); @@ -157,15 +188,28 @@ fn main() -> eframe::Result<()> { } i += 1; } + // The log file lives in the settings folder, next to the preferences; opened after the + // arguments, so `--version` leaves no file behind. Records logged until now are written to it + // first. Runs without preferences (agents' test runs) log to standard error only, so they + // don't rotate away the user's own logs. + if let Some(logger) = logger { + match log_dir().filter(|_| prefs_enabled()) { + Some(dir) => match logger.attach_dir(&dir) { + Ok(path) => log::info!("GridCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()), + Err(e) => eprintln!("gridcraft: no log file: {e}"), + }, + None => logger.no_file(), + } + } let mut session = Session::new(); if let Some(s) = &sample && let Err(e) = session.execute("file.new", json!({"sample": s})) { - eprintln!("sample: {e}"); + log::warn!("sample {s}: {e}"); } for f in &files { if let Err(e) = session.execute("file.open", json!({"path": f})) { - eprintln!("{f}: {e}"); + log::warn!("{f}: {e}"); } } if session.documents().is_empty() { @@ -195,7 +239,7 @@ fn main() -> eframe::Result<()> { if let Some(port) = control_port { match control_server::start(port, cc.egui_ctx.clone()) { Ok(rx) => app.control_rx = Some(rx), - Err(e) => eprintln!("control channel on port {port}: {e}"), + Err(e) => log::error!("control channel on port {port}: {e}"), } } Ok(Box::new(App( From 26f325ec15cbd4d824612a6c3c29f65d83f6657f Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 8 Oct 2026 13:01:16 +0200 Subject: [PATCH 02/35] Desktop: no eprintln! on the log and panic paths eprintln! panics when standard error is a broken pipe (a GUI launch on Windows), and a panic inside the panic hook aborts. The "no log file" message goes through log::warn! (standard error only by then); the panic hook's fallback writes to standard error with write_fmt and ignores the error. Same fix as storytold/vectorcraft#465. --- apps/gridcraft/src/main.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 9abb15d..f02248a 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -161,7 +161,8 @@ fn main() -> eframe::Result<()> { if log::log_enabled!(log::Level::Error) { log::error!("{report}"); } else { - eprintln!("gridcraft: {report}"); + // `eprintln!` panics on a broken stderr pipe, and a panic inside the panic hook aborts. + let _ = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("gridcraft: {report}\n")); } })); let args: Vec = std::env::args().skip(1).collect(); @@ -196,7 +197,8 @@ fn main() -> eframe::Result<()> { match log_dir().filter(|_| prefs_enabled()) { Some(dir) => match logger.attach_dir(&dir) { Ok(path) => log::info!("GridCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()), - Err(e) => eprintln!("gridcraft: no log file: {e}"), + // Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics. + Err(e) => log::warn!("no log file: {e}"), }, None => logger.no_file(), } From 881bab8b6ae44d872895835b0fc4dcae96b0fccc Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Thu, 8 Oct 2026 19:40:12 -0600 Subject: [PATCH 03/35] Default Windows startup to DirectX 12 eframe's default wgpu instance enables PRIMARY | GL on Windows. Creating the OpenGL backend can crash inside AMD's atio6axx.dll before the window appears, so the app exits silently at launch. When WGPU_BACKEND is not set, the Windows instance now enables only DirectX 12; an explicit WGPU_BACKEND is still honoured. macOS, Linux and web are unchanged. Same fix as storytold/designcraft#168 and storytold/filmcraft#179. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LyVbT6Vv4BwWZ4hY8973qD --- README.md | 15 +++++++++++ apps/gridcraft/src/graphics.rs | 47 ++++++++++++++++++++++++++++++++++ apps/gridcraft/src/main.rs | 9 +++++++ 3 files changed, 71 insertions(+) create mode 100644 apps/gridcraft/src/graphics.rs diff --git a/README.md b/README.md index f5aa689..97a2ead 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,21 @@ are in [ROADMAP.md](ROADMAP.md). Installers and executables are code-signed. +**If the app doesn't open on Windows:** the desktop app initializes only DirectX 12 by default. +Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's +`atio6axx.dll`) before the window appears, so the app would flash in Task Manager and quit. +`WGPU_BACKEND` overrides the default for troubleshooting (for example `dx12` or `vulkan`). In +PowerShell, from the folder containing the executable: + +```powershell +$env:WGPU_BACKEND = "vulkan" +& .\gridcraft.exe +Remove-Item Env:WGPU_BACKEND # restore the default for later launches +``` + +An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux +and web backend defaults are unchanged. + ### macOS | Build | File | Notes | diff --git a/apps/gridcraft/src/graphics.rs b/apps/gridcraft/src/graphics.rs new file mode 100644 index 0000000..ccd0c45 --- /dev/null +++ b/apps/gridcraft/src/graphics.rs @@ -0,0 +1,47 @@ +//! Windows graphics instance configuration, applied before wgpu can initialize any driver. + +use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends}; + +/// Restrict the wgpu instance to DirectX 12 unless `backend_override` (normally +/// `Backends::from_env()`, i.e. `WGPU_BACKEND`) names other backends. +pub fn configure(options: &mut NativeOptions, backend_override: Option) { + if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { + // eframe's default includes GL. Creating that backend can crash inside AMD's + // atio6axx.dll before adapter selection or any Rust error handling runs, so the + // window never appears. Choosing an adapter afterwards is too late: only leaving GL + // out of the instance avoids it. This matches the verified WGPU_BACKEND=dx12 + // workaround without mutating process environment variables. + create.instance_descriptor.backends = backend_override.unwrap_or(Backends::DX12); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn configured_backends(backend_override: Option) -> Backends { + let mut options = NativeOptions::default(); + configure(&mut options, backend_override); + let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else { + panic!("default native options must create a wgpu instance"); + }; + create.instance_descriptor.backends + } + + #[test] + fn windows_default_initializes_only_dx12() { + // eframe's own default (PRIMARY | GL) would initialize OpenGL as well. + let WgpuSetup::CreateNew(eframe_default) = NativeOptions::default().wgpu_options.wgpu_setup else { + panic!("default native options must create a wgpu instance"); + }; + assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL)); + assert_eq!(configured_backends(None), Backends::DX12); + } + + #[test] + fn explicit_backend_override_is_preserved() { + for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] { + assert_eq!(configured_backends(Some(backends)), backends); + } + } +} diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 6acaf51..c342ae4 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -9,6 +9,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod control_server; +#[cfg(any(target_os = "windows", test))] +mod graphics; #[cfg(target_os = "macos")] mod native_menu; @@ -187,6 +189,13 @@ fn main() -> eframe::Result<()> { viewport = viewport.with_icon(i); } let options = eframe::NativeOptions { viewport, ..Default::default() }; + // Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs). + #[cfg(target_os = "windows")] + let options = { + let mut options = options; + graphics::configure(&mut options, eframe::wgpu::Backends::from_env()); + options + }; eframe::run_native( "GridCraft", options, From 4dd49f7b955d9bfe385aea5c06f197fc8c05e73c Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:45:50 +0200 Subject: [PATCH 04/35] Fix: inserting or deleting rows, columns or cells cancels copy/cut mode (#21) --- crates/engine/src/cmd/sheet.rs | 33 +++++++++++++++++++++++++-------- crates/engine/src/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 837f080..3377d52 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -196,6 +196,15 @@ fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) { } } +/// Inserting or deleting cells moves the copied block away from the stored source range, so, like +/// Excel, it cancels copy/cut mode for this workbook (a later paste would take the wrong cells). +fn cancel_copy_mode(s: &mut Session) { + let uid = s.doc().map(|d| d.uid).ok(); + if s.clipboard.as_ref().is_some_and(|c| Some(c.doc_uid) == uid) { + s.clipboard = None; + } +} + fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result { let (at, count) = lines(s, p, axis)?; let sheet = target_sheet(s, p)?; @@ -207,7 +216,7 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result { return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into())); } } - edit(s, |cx| { + let out = edit(s, |cx| { let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default(); { let sh = cx.sheet_mut(sheet)?; @@ -230,13 +239,15 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result { rewrite(&mut cx.wb, &name, &Edit::Insert { axis, at, count }); cx.structural = true; Ok(Json::Null) - }) + })?; + cancel_copy_mode(s); + Ok(out) } fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result { let (at, count) = lines(s, p, axis)?; let sheet = target_sheet(s, p)?; - edit(s, |cx| { + let out = edit(s, |cx| { let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default(); { let sh = cx.sheet_mut(sheet)?; @@ -254,7 +265,9 @@ fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result { rewrite(&mut cx.wb, &name, &Edit::Delete { axis, at, count }); cx.structural = true; Ok(Json::Null) - }) + })?; + cancel_copy_mode(s); + Ok(out) } fn insert_cells(s: &mut Session, p: &Json) -> Result { @@ -267,7 +280,7 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result { } let sheet = target_sheet(s, p)?; let right = shift == "right"; - edit(s, |cx| { + let out = edit(s, |cx| { let sh = cx.sheet_mut(sheet)?; if right { sh.cells.shift_cols_in_rows(r.start.row, r.end.row, r.start.col, r.width() as i64); @@ -285,7 +298,9 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result { super::edit::rewrite_all_formulas(&mut cx.wb, &name, &Edit::Move { from: moved, to_row: to.0, to_col: to.1 }); cx.structural = true; Ok(Json::Null) - }) + })?; + cancel_copy_mode(s); + Ok(out) } fn delete_cells(s: &mut Session, p: &Json) -> Result { @@ -298,7 +313,7 @@ fn delete_cells(s: &mut Session, p: &Json) -> Result { } let sheet = target_sheet(s, p)?; let left = shift == "left"; - edit(s, |cx| { + let out = edit(s, |cx| { let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default(); { let sh = cx.sheet_mut(sheet)?; @@ -320,7 +335,9 @@ fn delete_cells(s: &mut Session, p: &Json) -> Result { super::edit::rewrite_all_formulas(&mut cx.wb, &name, &Edit::Move { from: moved_from, to_row: r.start.row, to_col: r.start.col }); cx.structural = true; Ok(Json::Null) - }) + })?; + cancel_copy_mode(s); + Ok(out) } fn insert_sheet(s: &mut Session, p: &Json) -> Result { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f3e36cd..8b8c0d8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -70,6 +70,27 @@ fn insert_delete_rows_adjust() { assert_eq!(v(&s, "A1"), Value::Number(10.0)); } +#[test] +fn inserting_or_deleting_cells_cancels_copy_mode() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3]]})).unwrap(); + s.execute("edit.cut", json!({"range": "3:3"})).unwrap(); + s.execute("home.insertRows", json!({"rows": "3:3"})).unwrap(); + assert!(s.clipboard.is_none()); + // The cut data is now in row 4; a paste must not move the new blank row 3 instead. + assert!(s.execute("edit.paste", json!({"at": "A10"})).is_err()); + assert_eq!(v(&s, "A4"), Value::Number(3.0)); + s.execute("edit.copy", json!({"range": "A:A"})).unwrap(); + s.execute("home.deleteColumns", json!({"cols": "B:B"})).unwrap(); + assert!(s.clipboard.is_none()); + s.execute("edit.copy", json!({"range": "A1"})).unwrap(); + s.execute("home.insertCells", json!({"range": "A1", "shift": "down"})).unwrap(); + assert!(s.clipboard.is_none()); + s.execute("edit.copy", json!({"range": "A2"})).unwrap(); + s.execute("home.deleteCells", json!({"range": "A1", "shift": "up"})).unwrap(); + assert!(s.clipboard.is_none()); +} + #[test] fn fill_series() { let mut s = s(); From 28113c6c75cd57837f9b7fbeef4a983093c9143e Mon Sep 17 00:00:00 2001 From: harrisonoconnorhover <130776285+harrisonoconnorhover@users.noreply.github.com> Date: Fri, 9 Oct 2026 05:28:48 -0400 Subject: [PATCH 05/35] Fix #26: paint large ribbon dropdown chevrons --- crates/ui-egui/src/widgets.rs | 24 +++++++++---- crates/ui-egui/tests/widgets.rs | 62 +++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/tests/widgets.rs diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 139256f..f1b39fc 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -38,8 +38,10 @@ pub fn toggle_button(ui: &mut Ui, icon: Icon, on: bool, tip: &str) -> Response { pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: bool) -> Response { let t = Tokens::get(ui.ctx()); let font = theme::ui_font(11.5); - let lines: Vec<&str> = label.split('\n').collect(); - let text_w = lines.iter().map(|l| ui.painter().layout_no_wrap(l.to_string(), font.clone(), t.text).size().x).fold(0.0, f32::max); + let lines: Vec<_> = label.split('\n').map(|line| ui.painter().layout_no_wrap(line.to_string(), font.clone(), t.text)).collect(); + let arrow_size = 10.0; + let arrow_space = if dropdown { 4.0 + arrow_size } else { 0.0 }; + let text_w = lines.iter().enumerate().map(|(i, line)| line.size().x + if i + 1 == lines.len() { arrow_space } else { 0.0 }).fold(0.0, f32::max); let w = (text_w + 12.0).max(44.0); let (rect, resp) = ui.allocate_exact_size(vec2(w, 72.0), Sense::click()); if resp.is_pointer_button_down_on() { @@ -48,12 +50,20 @@ pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: boo ui.painter().rect_filled(rect, 5.0, t.hover); } icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.center().x, rect.top() + 21.0), vec2(30.0, 30.0)), icon, t.text); - for (i, l) in lines.iter().enumerate() { - let mut s = l.to_string(); - if dropdown && i + 1 == lines.len() { - s.push_str(" ▾"); + for (i, line) in lines.iter().enumerate() { + let has_arrow = dropdown && i + 1 == lines.len(); + let line_w = line.size().x + if has_arrow { arrow_space } else { 0.0 }; + let left = rect.center().x - line_w / 2.0; + let y = rect.top() + 46.0 + i as f32 * 13.0; + ui.painter().galley(pos2(left, y - line.size().y / 2.0), line.clone(), t.text); + if has_arrow { + icons::paint( + ui.painter(), + Rect::from_center_size(pos2(left + line_w - arrow_size / 2.0, y + 1.0), vec2(arrow_size, arrow_size)), + Icon::Chevron, + t.text_dim, + ); } - ui.painter().text(pos2(rect.center().x, rect.top() + 46.0 + i as f32 * 13.0), Align2::CENTER_CENTER, s, font.clone(), t.text); } resp.on_hover_text(tip) } diff --git a/crates/ui-egui/tests/widgets.rs b/crates/ui-egui/tests/widgets.rs new file mode 100644 index 0000000..54c8f6e --- /dev/null +++ b/crates/ui-egui/tests/widgets.rs @@ -0,0 +1,62 @@ +//! Ribbon widget paint tests, using egui's bundled fonts on every platform. + +use egui::{FontDefinitions, FontFamily, Rect, Shape, pos2, vec2}; +use gridcraft_ui_egui::{icons::Icon, theme, widgets}; + +fn render_big_button(label: &str, dropdown: bool) -> (Rect, Vec) { + let ctx = egui::Context::default(); + let mut fonts = FontDefinitions::default(); + let proportional = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); + fonts.families.insert(FontFamily::Name(theme::UI.into()), proportional); + ctx.set_fonts(fonts); + let mut rect = Rect::NOTHING; + let mut output = ctx + .run_ui(egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0))), ..Default::default() }, |ui| { + rect = widgets::big_button(ui, Icon::More, label, "test button", dropdown).rect + }); + output.textures_delta.clear(); + (rect, output.shapes.into_iter().map(|shape| shape.shape).collect()) +} + +#[test] +fn big_dropdown_paints_chevron_beside_final_label_line() { + for label in ["Paste", "Conditional\nFormatting", "Longer first line\nGo"] { + let (rect, shapes) = render_big_button(label, true); + let texts: Vec<_> = shapes.iter().filter_map(|shape| if let Shape::Text(text) = shape { Some(text) } else { None }).collect(); + assert_eq!(texts.iter().map(|text| text.galley.job.text.as_str()).collect::>(), label.split('\n').collect::>()); + let paths: Vec<_> = shapes.iter().filter_map(|shape| if let Shape::Path(path) = shape { Some(path) } else { None }).collect(); + assert_eq!(paths.len(), 1, "dropdown indicator must be painted independently of font glyphs"); + let chevron = paths[0]; + assert_eq!(chevron.points.len(), 3); + assert!(chevron.points[0].x < chevron.points[1].x && chevron.points[1].x < chevron.points[2].x); + assert!(chevron.points[1].y > chevron.points[0].y && chevron.points[1].y > chevron.points[2].y); + let last = texts.last().unwrap(); + let last_rect = last.galley.rect.translate(last.pos.to_vec2()); + assert!(chevron.points[0].x > last_rect.right(), "arrow must not overlap the label"); + assert!((chevron.points[1].y - last_rect.center().y).abs() < 3.0, "arrow must align with the final line"); + assert!(rect.right() - chevron.points[2].x >= 6.0, "button must reserve space for the arrow and padding"); + assert!(last_rect.left() - rect.left() >= 6.0); + assert!(((last_rect.left() + chevron.points[2].x) / 2.0 - rect.center().x).abs() < 2.0, "label and arrow must be centered together"); + for text in texts.iter().take(texts.len() - 1) { + let text_rect = text.galley.rect.translate(text.pos.to_vec2()); + assert!((text_rect.center().x - rect.center().x).abs() < 0.01, "earlier lines remain centered"); + } + } +} + +#[test] +fn big_button_without_dropdown_keeps_centered_labels_and_width() { + for label in ["Paste", "Conditional\nFormatting"] { + let (rect, shapes) = render_big_button(label, false); + assert!(!shapes.iter().any(|shape| matches!(shape, Shape::Path(_))), "plain button must not paint an arrow"); + let mut widest: f32 = 0.0; + for shape in shapes { + if let Shape::Text(text) = shape { + let text_rect = text.galley.rect.translate(text.pos.to_vec2()); + assert!((text_rect.center().x - rect.center().x).abs() < 0.01); + widest = widest.max(text_rect.width()); + } + } + assert!((rect.width() - (widest + 12.0).max(44.0)).abs() < 0.01); + } +} From b36f91941065b836d7732585057442f176505c4f Mon Sep 17 00:00:00 2001 From: harrisonoconnorhover <130776285+harrisonoconnorhover@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:51:08 -0400 Subject: [PATCH 06/35] Fix editing and formula bar reads in merged cells (#40) --- crates/ui-egui/src/formula_bar.rs | 2 +- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/tests/merged_cells.rs | 118 +++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/tests/merged_cells.rs diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 753585f..75d0020 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -65,7 +65,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { fn active_input(app: &SheetApp) -> String { let Some(d) = app.session.active() else { return String::new() }; let Some(sh) = d.wb.active() else { return String::new() }; - let a = d.selection.active; + let a = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active); if let Some(c) = sh.cell(a) { if c.formula.is_some() && d.wb.styles.get(c.style).protection.hidden && sh.is_protected() { return String::new(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8eb1eca..d891656 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -276,7 +276,7 @@ impl SheetApp { pub fn begin_edit(&mut self, text: Option, from_formula_bar: bool) { let Some(d) = self.session.active() else { return }; let Some(sh) = d.wb.active() else { return }; - let at = d.selection.active; + let at = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active); let current = sh.cell(at).map(|c| c.input_text()).unwrap_or_default(); let (text, replace) = match text { Some(t) => (t, true), diff --git a/crates/ui-egui/tests/merged_cells.rs b/crates/ui-egui/tests/merged_cells.rs new file mode 100644 index 0000000..c28e080 --- /dev/null +++ b/crates/ui-egui/tests/merged_cells.rs @@ -0,0 +1,118 @@ +//! Merged-cell edits must read and write the visible, top-left cell. + +use egui::{Event, Key, Modifiers, PointerButton, Pos2}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(input: &str) -> Harness { + let mut session = Session::new(); + session.new_workbook(); + session.execute("home.mergeCells", json!({"range": "A1:B2"})).unwrap(); + session.execute("cell.set", json!({"cell": "A1", "input": input})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn cell_center(h: &Harness, cell: &str) -> Pos2 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + geo.cell_rect(sheet, CellRef::parse(cell).unwrap()).center() +} + +fn click_cell(h: &mut Harness, cell: &str) { + let pos = cell_center(h, cell); + h.input_mut().events.extend([ + Event::PointerMoved(pos), + Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + ]); + h.run_steps(2); +} + +fn text(h: &mut Harness, text: &str) { + h.input_mut().events.push(Event::Text(text.into())); + h.run_steps(2); +} + +fn key(h: &mut Harness, key: Key) { + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); + } + h.run_steps(2); +} + +fn value(h: &Harness, cell: &str) -> Value { + h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap()) +} + +#[test] +fn typing_into_a_covered_cell_remains_visible_after_clicking_away() { + let mut h = harness(""); + click_cell(&mut h, "B2"); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "B2"); + text(&mut h, "hello"); + click_cell(&mut h, "D4"); + + assert!(h.state().editor.is_none()); + assert_eq!(value(&h, "A1"), Value::from("hello"), "the cell painted for the merge must contain the committed text"); + assert_eq!(value(&h, "B2"), Value::Empty, "editing a merge must not hide text in a covered cell"); + click_cell(&mut h, "B2"); + assert!(h.query_by_label("hello").is_some(), "the formula bar must show the merged content when a covered cell is selected"); +} + +#[test] +fn f2_and_double_click_edit_the_existing_merged_content() { + let mut h = harness("before"); + click_cell(&mut h, "B2"); + key(&mut h, Key::F2); + let editor = h.state().editor.as_ref().unwrap(); + assert_eq!(editor.cell.a1(), "A1"); + assert_eq!(editor.text, "before"); + text(&mut h, "!"); + key(&mut h, Key::Enter); + assert_eq!(value(&h, "A1"), Value::from("before!")); + + click_cell(&mut h, "D4"); + click_cell(&mut h, "B2"); + click_cell(&mut h, "B2"); + let editor = h.state().editor.as_ref().unwrap(); + assert_eq!(editor.cell.a1(), "A1"); + assert_eq!(editor.text, "before!"); + text(&mut h, " again"); + click_cell(&mut h, "D4"); + assert_eq!(value(&h, "A1"), Value::from("before! again")); + assert_eq!(value(&h, "B2"), Value::Empty); +} + +#[test] +fn formula_bar_reads_and_edits_the_merge_anchor_from_a_covered_cell() { + let mut h = harness("formula bar text"); + click_cell(&mut h, "B2"); + h.get_by_label("formula bar text").click(); + h.run_steps(2); + + let editor = h.state().editor.as_ref().unwrap(); + assert!(editor.from_formula_bar); + assert_eq!(editor.cell.a1(), "A1"); + assert_eq!(editor.text, "formula bar text"); + text(&mut h, " edited"); + key(&mut h, Key::Enter); + assert_eq!(value(&h, "A1"), Value::from("formula bar text edited")); + assert_eq!(value(&h, "B2"), Value::Empty); + click_cell(&mut h, "D4"); + click_cell(&mut h, "B2"); + assert!(h.query_by_label("formula bar text edited").is_some()); +} From 38c11f6fb9c78a9e2da66cb526c9439da490dc40 Mon Sep 17 00:00:00 2001 From: harrisonoconnorhover <130776285+harrisonoconnorhover@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:23:09 -0400 Subject: [PATCH 07/35] Fix header double-click AutoFit for selected ranges (#42) --- crates/ui-egui/src/grid.rs | 59 ++++++++----- crates/ui-egui/tests/header_autofit.rs | 116 +++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 20 deletions(-) create mode 100644 crates/ui-egui/tests/header_autofit.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 4b326cc..834146c 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1080,6 +1080,32 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } } + // A double-click is also a click: handle header AutoFit before resize setup returns. + if resp.double_clicked() + && let Some(p) = pos + { + let autofit = if in_col_header { + col_edge(geo, sh, p.x).map(|(col, _)| { + let selected = sel.ranges.iter().any(|r| r.is_full_cols() && col >= r.start.col && col <= r.end.col); + ("home.autofitColumnWidth", if selected { json!({}) } else { json!({"cols": RangeRef::cols(col, col).a1()}) }) + }) + } else if in_row_header { + row_edge(geo, sh, p.y).map(|(row, _)| { + let selected = sel.ranges.iter().any(|r| r.is_full_rows() && row >= r.start.row && row <= r.end.row); + ("home.autofitRowHeight", if selected { json!({}) } else { json!({"rows": RangeRef::rows(row, row).a1()}) }) + }) + } else { + None + }; + if let Some((command, params)) = autofit { + app.grid.drag = Drag::None; + app.grid.drag_select = false; + app.run_or_alert(command, params); + resp.request_focus(); + return; + } + } + // Press: decide the drag mode. if resp.drag_started() || (resp.clicked() && app.grid.drag == Drag::None) { // Where the press began (a drag is only recognised after the pointer has moved). @@ -1334,29 +1360,22 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & app.grid.drag_select = false; } - // Double-click on header edges autofits. + // Double-click the fill handle. if resp.double_clicked() && let Some(p) = pos + && handle.contains(p) { - if in_col_header && let Some((c, _)) = col_edge(geo, sh, p.x) { - let _ = app.run("home.autofitColumnWidth", json!({"cols": RangeRef::cols(c, c).a1()})); - } else if in_row_header && let Some((r, _)) = row_edge(geo, sh, p.y) { - let _ = app.run("home.autofitRowHeight", json!({"rows": format!("{}:{}", r + 1, r + 1)})); - } else if handle.contains(p) { - // Double-click the fill handle: fill down as far as the neighbouring column goes. - let src = sel.current(); - let neighbour = if src.start.col > 0 { src.start.col - 1 } else { src.end.col + 1 }; - let mut last = src.end.row; - while last + 1 < MAX_ROWS - && sh.value_ref(CellRef::new(last + 1, neighbour)).is_some_and(|v| !v.is_empty()) - && last - src.end.row < 1_000_000 - { - last += 1; - } - if last > src.end.row { - let target = RangeRef::new(src.start, CellRef::new(last, src.end.col)); - let _ = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1()})); - } + // Fill down as far as the neighbouring column goes. + let src = sel.current(); + let neighbour = if src.start.col > 0 { src.start.col - 1 } else { src.end.col + 1 }; + let mut last = src.end.row; + while last + 1 < MAX_ROWS && sh.value_ref(CellRef::new(last + 1, neighbour)).is_some_and(|v| !v.is_empty()) && last - src.end.row < 1_000_000 + { + last += 1; + } + if last > src.end.row { + let target = RangeRef::new(src.start, CellRef::new(last, src.end.col)); + let _ = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1()})); } } diff --git a/crates/ui-egui/tests/header_autofit.rs b/crates/ui-egui/tests/header_autofit.rs new file mode 100644 index 0000000..8a66738 --- /dev/null +++ b/crates/ui-egui/tests/header_autofit.rs @@ -0,0 +1,116 @@ +//! Header AutoFit and resize gestures, driven through real egui pointer events. + +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +fn harness(selection: &str) -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + session + .execute( + "range.setValues", + json!({"range": "A1", "values": [ + ["Short", "This column has considerably longer content", "Third", "Unchanged"], + ["Two\nlines", "B", "C", "D"], + ["Three\ntext\nlines", "B", "C", "D"] + ]}), + ) + .unwrap(); + session.execute("home.columnWidth", json!({"cols": "A:D", "width": 100})).unwrap(); + session.execute("home.rowHeight", json!({"rows": "1:4", "height": 60})).unwrap(); + session.execute("selection.set", json!({"range": selection})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h.state_mut().session.active_mut().unwrap().undo.clear(); + h.state_mut().session.journal.clear(); + h +} + +fn edge(h: &egui_kittest::Harness<'_, SheetApp>, rows: bool, index: u32) -> (Pos2, f32) { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + let pos = if rows { + pos2(geo.rect.left() + geo.header_w / 2.0, geo.y(sheet, index + 1)) + } else { + pos2(geo.x(sheet, index + 1), geo.rect.top() + geo.header_h / 2.0) + }; + (pos, geo.z) +} + +fn button(h: &mut egui_kittest::Harness<'_, SheetApp>, pos: Pos2, pressed: bool) { + h.input_mut() + .events + .extend([Event::PointerMoved(pos), Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + h.step(); +} + +fn sizes(h: &egui_kittest::Harness<'_, SheetApp>, rows: bool) -> Vec { + let sheet = h.state().session.active().unwrap().wb.active().unwrap(); + (0..4).map(|index| if rows { sheet.row_height(index) } else { sheet.col_width(index) }).collect() +} + +#[test] +fn header_double_click_autofits_selected_areas_or_only_the_clicked_line() { + for (rows, selection, clicked, changed) in [ + (false, "B:B", 1, [false, true, false, false]), + (false, "A:C", 1, [true, true, true, false]), + (false, "A:A,C:C", 0, [true, false, true, false]), + (false, "A:B", 2, [false, false, true, false]), + (true, "1:3", 1, [true, true, true, false]), + (true, "1:1,3:3", 0, [true, false, true, false]), + (true, "1:2", 2, [false, false, true, false]), + ] { + let mut h = harness(selection); + let before = sizes(&h, rows); + let selection_before = h.state().session.active().unwrap().selection.clone(); + let (pos, _) = edge(&h, rows, clicked); + button(&mut h, pos, true); + button(&mut h, pos, false); + h.step(); // Let the first click's resize state settle before the second click. + button(&mut h, pos, true); + button(&mut h, pos, false); + h.run_steps(2); + + for (index, ((old, new), changed)) in before.iter().zip(sizes(&h, rows)).zip(changed).enumerate() { + assert_eq!(*old != new, changed, "selection {selection}, clicked {clicked}, line {index}: {old} → {new}"); + } + assert_eq!(h.state().session.active().unwrap().selection, selection_before); + let command = if rows { "home.autofitRowHeight" } else { "home.autofitColumnWidth" }; + assert_eq!(h.state().session.journal.iter().filter(|(id, _)| id == command).count(), 1); + assert_eq!(h.state().session.active().unwrap().undo.len(), 1); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(sizes(&h, rows), before, "AutoFit should undo as a single operation"); + } +} + +#[test] +fn dragging_a_header_boundary_still_resizes_normally() { + for rows in [false, true] { + let mut h = harness(if rows { "2:2" } else { "B:B" }); + let before = sizes(&h, rows); + let (start, zoom) = edge(&h, rows, 1); + let delta = if rows { vec2(0.0, 30.0) } else { vec2(30.0, 0.0) }; + button(&mut h, start, true); + h.input_mut().events.push(Event::PointerMoved(start + delta / 2.0)); + h.step(); + h.input_mut().events.push(Event::PointerMoved(start + delta)); + h.step(); + button(&mut h, start + delta, false); + h.run_steps(2); + let after = sizes(&h, rows); + assert_eq!(after[1], (before[1] + 30.0 / zoom).round()); + for index in [0, 2, 3] { + assert_eq!(before[index], after[index]); + } + assert!(!h.state().session.journal.iter().any(|(id, _)| id.starts_with("home.autofit"))); + } +} From 1d2df6126b8e86b06b3414c610c460187686b15c Mon Sep 17 00:00:00 2001 From: harrisonoconnorhover <130776285+harrisonoconnorhover@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:32:07 -0400 Subject: [PATCH 08/35] Dismiss worksheet popups on outside clicks and focus loss (#41) --- crates/ui-egui/src/grid.rs | 36 +++-- crates/ui-egui/tests/popup_dismissal.rs | 176 ++++++++++++++++++++++++ 2 files changed, 197 insertions(+), 15 deletions(-) create mode 100644 crates/ui-egui/tests/popup_dismissal.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 4b326cc..6e28ec7 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -416,13 +416,14 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { paint_headers(&painter, &geo, sh, &sel, &t, &quads); } // Interaction. + let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu); interact(app, ui, &resp, &geo, sh, &wb); if app.editor.as_ref().is_some_and(|e| !e.from_formula_bar) { in_cell_editor(app, ui, &geo, sh, &wb); } - filter_menu(app, ui, &geo); - list_picker(app, ui, &geo); - header_menu(app, ui); + filter_menu(app, ui, &geo, previous_popups.0 == app.grid.filter_menu); + list_picker(app, ui, &geo, previous_popups.1 == app.grid.list_picker); + header_menu(app, ui, previous_popups.2 == app.grid.header_menu); context_menu(app, ui); if app.session.clipboard.is_some() { app.grid.marching_phase = (app.grid.marching_phase + 0.5) % 8.0; @@ -1643,7 +1644,13 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, crate::formula_bar::editor_widget(app, &mut child, id, font, false, erect.width() - 4.0); } -fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) { +/// Test the popup itself, not whether the pointer is over any part of the app. +/// A trigger can sit outside its popup, so its opening click must be ignored. +fn popup_dismissed(response: &egui::Response, was_open: bool) -> bool { + response.ctx.input(|i| !i.focused || i.key_pressed(egui::Key::Escape)) || (was_open && response.clicked_elsewhere()) +} + +fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) { let Some((col, at)) = app.grid.filter_menu else { return }; let Some(d) = app.session.active() else { return }; let wb = d.wb.clone(); @@ -1676,9 +1683,10 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) { } let wb = app.session.active().map(|d| d.wb.clone()).unwrap_or(wb); let values = gridcraft_engine::cmd::data::filter_values(&wb, si, col); + let key = egui::Id::new(("filter_sel", col)); let mut close = false; let area = egui::Area::new(egui::Id::new("filter_menu")).fixed_pos(at + vec2(-180.0, 10.0)).order(egui::Order::Foreground); - area.show(ui.ctx(), |ui| { + let response = area.show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_width(230.0); if ui.button("↑ Sort Ascending").clicked() { @@ -1691,7 +1699,6 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) { } ui.separator(); ui.label(egui::RichText::new("Filter").strong()); - let key = egui::Id::new(("filter_sel", col)); let mut checks: Vec<(String, bool)> = ui.ctx().data_mut(|d| d.get_temp::>(key)).unwrap_or(values.clone()); let all = checks.iter().all(|(_, b)| *b); let mut all_new = all; @@ -1710,7 +1717,6 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) { ui.horizontal(|ui| { if ui.button("Clear Filter").clicked() { let _ = app.run("data.filterBy", json!({"column": col_to_letters(col), "clear": true})); - ui.ctx().data_mut(|d| d.remove::>(key)); close = true; } if ui.button("Apply").clicked() { @@ -1722,13 +1728,13 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) { json!({"column": col_to_letters(col), "values": vals, "blanks": blanks}) }; let _ = app.run("data.filterBy", p); - ui.ctx().data_mut(|d| d.remove::>(key)); close = true; } }); }); }); - if close || ui.input(|i| i.key_pressed(egui::Key::Escape)) || (ui.input(|i| i.pointer.any_pressed()) && !ui.ctx().is_pointer_over_egui()) { + if close || popup_dismissed(&response.response, was_open) { + ui.ctx().data_mut(|d| d.remove::>(key)); app.grid.filter_menu = None; } } @@ -1939,7 +1945,7 @@ fn paint_overlays( let _ = (wb, si); } -fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) { +fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) { let Some(c) = app.grid.list_picker else { return }; let Some(d) = app.session.active() else { return }; let wb = d.wb.clone(); @@ -1952,7 +1958,7 @@ fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) { let items = gridcraft_engine::cmd::data::list_items(&wb, si, &dv); let r = geo.cell_rect(sh, c); let mut close = false; - egui::Area::new(egui::Id::new("dv_list")).fixed_pos(r.left_bottom()).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + let response = egui::Area::new(egui::Id::new("dv_list")).fixed_pos(r.left_bottom()).order(egui::Order::Foreground).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).inner_margin(2.0).show(ui, |ui| { ui.set_min_width(r.width().max(100.0)); egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| { @@ -1965,15 +1971,15 @@ fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) { }); }); }); - if close || ui.input(|i| i.key_pressed(egui::Key::Escape)) || (ui.input(|i| i.pointer.any_pressed()) && !ui.ctx().is_pointer_over_egui()) { + if close || popup_dismissed(&response.response, was_open) { app.grid.list_picker = None; } } -fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui) { +fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { let Some((at, rows)) = app.grid.header_menu else { return }; let mut close = false; - egui::Area::new(egui::Id::new("header_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + let response = egui::Area::new(egui::Id::new("header_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: Vec<(&str, &str)> = if rows { @@ -2033,7 +2039,7 @@ fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui) { } }); }); - if close || ui.input(|i| i.key_pressed(egui::Key::Escape)) || (ui.input(|i| i.pointer.any_pressed()) && !ui.ctx().is_pointer_over_egui()) { + if close || popup_dismissed(&response.response, was_open) { app.grid.header_menu = None; } } diff --git a/crates/ui-egui/tests/popup_dismissal.rs b/crates/ui-egui/tests/popup_dismissal.rs new file mode 100644 index 0000000..391ccd6 --- /dev/null +++ b/crates/ui-egui/tests/popup_dismissal.rs @@ -0,0 +1,176 @@ +//! Custom worksheet popups must keep inside interactions and dismiss outside clicks. + +use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2}; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_ui_egui::{SheetApp, grid}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +#[derive(Clone, Copy, Debug)] +enum Menu { + Header, + Filter, + Validation, +} + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Qty"], ["Red", 1], ["Blue", 2]]})).unwrap(); + session.execute("data.filter", json!({"range": "A1:B3"})).unwrap(); + session.execute("insert.table", json!({"range": "A1:B3"})).unwrap(); + session.execute("range.setValues", json!({"range": "H1", "values": [["Other", "Qty"], ["Green", 3]]})).unwrap(); + session.execute("insert.table", json!({"range": "H1:I2"})).unwrap(); + session.execute("data.validation", json!({"range": "E3", "type": "list", "formula1": "\"First,Second\""})).unwrap(); + session.execute("home.insertSheet", json!({})).unwrap(); + session.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn geo(h: &Harness) -> grid::Geo { + let app = h.state(); + let sh = app.session.active().unwrap().wb.active().unwrap(); + grid::Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll) +} + +fn cell_pos(h: &Harness, cell: &str) -> Pos2 { + geo(h).cell_rect(h.state().session.active().unwrap().wb.active().unwrap(), CellRef::parse(cell).unwrap()).center() +} + +fn click(h: &mut Harness, pos: Pos2, button: PointerButton) { + h.input_mut().events.push(Event::PointerMoved(pos)); + h.input_mut().events.push(Event::PointerButton { pos, button, pressed: true, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::PointerButton { pos, button, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +fn open(h: &mut Harness, menu: Menu) { + let geometry = geo(h); + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + let (pos, button) = match menu { + Menu::Header => (pos2(cell_pos(h, "A1").x, (geometry.rect.top() + geometry.cells.top()) / 2.0), PointerButton::Secondary), + Menu::Filter => (grid::filter_button_rect(&geometry, sh, CellRef::parse("A1").unwrap()).center(), PointerButton::Primary), + Menu::Validation => { + let cell = CellRef::parse("E3").unwrap(); + let pos = grid::validation_arrow(sh, &geometry, cell).unwrap().center(); + h.state_mut().run("selection.set", json!({"cell": "E3"})).unwrap(); + h.run_steps(2); + (pos, PointerButton::Primary) + } + }; + click(h, pos, button); + assert!(is_open(h, menu), "{menu:?} must survive its opening click"); +} + +fn is_open(h: &Harness, menu: Menu) -> bool { + match menu { + Menu::Header => h.state().grid.header_menu.is_some(), + Menu::Filter => h.state().grid.filter_menu.is_some(), + Menu::Validation => h.state().grid.list_picker.is_some(), + } +} + +#[test] +fn worksheet_popups_close_on_grid_and_ribbon_clicks() { + for menu in [Menu::Header, Menu::Filter, Menu::Validation] { + for ribbon in [false, true] { + let mut h = harness(); + open(&mut h, menu); + let outside = if ribbon { pos2(1050.0, 52.0) } else { cell_pos(&h, "H7") }; + click(&mut h, outside, PointerButton::Primary); + assert!(!is_open(&h, menu), "{menu:?} stayed open after outside click (ribbon={ribbon})"); + if !ribbon { + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "H7"); + } + } + } +} + +#[test] +fn filter_closes_when_clicking_another_table_or_sheet() { + for sheet in [false, true] { + let mut h = harness(); + open(&mut h, Menu::Filter); + let outside = if sheet { pos2(44.0, h.state().grid.rect.unwrap().bottom() + 16.0) } else { cell_pos(&h, "H2") }; + click(&mut h, outside, PointerButton::Primary); + assert!(!is_open(&h, Menu::Filter), "filter stayed open after switching table/sheet"); + if sheet { + assert_eq!(h.state().session.active().unwrap().wb.active_sheet, 1); + } else { + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "H2"); + } + } +} + +#[test] +fn popup_actions_still_execute_and_filter_checkboxes_stay_open() { + let mut h = harness(); + open(&mut h, Menu::Header); + h.get_by_label("Copy").click(); + h.run_steps(2); + assert!(!is_open(&h, Menu::Header)); + assert!(h.state().session.clipboard.is_some()); + + open(&mut h, Menu::Filter); + h.get_by_label("(Select All)").click(); + h.run_steps(2); + assert!(is_open(&h, Menu::Filter), "clicking a checkbox must keep the filter open"); + let outside = cell_pos(&h, "H7"); + click(&mut h, outside, PointerButton::Primary); + open(&mut h, Menu::Filter); + h.get_by_label("Apply").click(); + h.run_steps(2); + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + assert!(!sh.is_row_hidden(1) && !sh.is_row_hidden(2), "cancelled checkbox edits must not be applied after reopening"); + + open(&mut h, Menu::Filter); + h.get_by_label("(Select All)").click(); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(2); + assert!(!is_open(&h, Menu::Filter)); + assert!(h.state().session.active().unwrap().wb.active().unwrap().is_row_hidden(1)); + h.state_mut().run("data.clearFilter", json!({})).unwrap(); + h.run_steps(2); + + open(&mut h, Menu::Validation); + h.get_by_label("Second").click(); + h.run_steps(2); + assert!(!is_open(&h, Menu::Validation)); + assert_eq!(h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse("E3").unwrap()), Value::from("Second")); +} + +#[test] +fn worksheet_popups_close_on_escape_or_window_focus_loss() { + for menu in [Menu::Header, Menu::Filter, Menu::Validation] { + for focus_loss in [false, true] { + let mut h = harness(); + open(&mut h, menu); + if focus_loss { + h.input_mut().focused = false; + h.input_mut().events.push(Event::WindowFocused(false)); + } else { + h.input_mut().events.push(Event::Key { + key: Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: Modifiers::NONE, + }); + } + h.run_steps(2); + assert!(!is_open(&h, menu), "{menu:?} stayed open (focus_loss={focus_loss})"); + } + } +} From ad6e77f6ef857832a65df69a22093c7423c42ca6 Mon Sep 17 00:00:00 2001 From: lloyd <34131158+LloydNicholson@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:13:40 +0200 Subject: [PATCH 09/35] Issue #48: rotated text with Wrap Text draws upright (grid and print) Narrow columns with tall, upright headers (mark sheets) set both a text rotation and Wrap Text. The wrap path drew the text flat and returned before the rotation was applied, so headers came out as 3-4 character fragments. - Rotated text no longer takes the flat wrap path. Upright (+/-90 deg) text wraps to the row height, its lines stacking across the column; other angles keep one line, as before. - Downward text was anchored at the bottom-left and ran out of the cell (over the row headers); it now starts at the top, one block-height in. - Upright text no longer overflows into empty neighbours: its width is its line height, and the overflow repainted their gridlines. - Print/PDF gets the same three fixes. Tests: ui-egui interaction test (shapes drawn at +/-90 deg, inside a one-column clip) and a print test (every text block on the page rotated, a few long lines each). Each failed before the fix. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/print.rs | 49 +++++++++++++++++++++++++---- crates/ui-egui/src/grid.rs | 26 ++++++++++++--- crates/ui-egui/tests/interaction.rs | 39 +++++++++++++++++++++++ 3 files changed, 103 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index a51a2f1..09ea606 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -923,7 +923,15 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3 HAlign::Justify | HAlign::Fill => HAlign::Left, other => other, }; - let lines: Vec = if wrap { wrap_lines(&text, font, size, avail.max(size)) } else { vec![text] }; + let rot = st.align.rotation; + let rotated = rot != 0 && rot != 255; + // Rotated text wraps along its own direction: upright (±90°) text to the row height. + let lines: Vec = match (wrap, rotated) { + (true, false) => wrap_lines(&text, font, size, avail.max(size)), + (true, true) if rot.abs() == 90 => wrap_lines(&text, font, size, (h - 2.0 * pad).max(size)), + (true, true) => vec![text.lines().next().unwrap_or_default().to_string()], + (false, _) => vec![text], + }; let line_h = size * 1.2; let n = lines.len().max(1) as f32; let first_baseline = match st.align.v { @@ -933,7 +941,7 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3 }; // Overflow into empty neighbours (single-line text, not merged). let (mut clip_l, mut clip_r) = (x, x + w); - if !wrap && !merged && !is_num && tw + 2.0 * pad + indent > w { + if !wrap && !merged && !is_num && rot.abs() != 90 && tw + 2.0 * pad + indent > w { let need = tw + 2.0 * pad + indent - w; let grow = |dir: i64, mut need: f32| -> f32 { let mut ext = 0.0; @@ -966,13 +974,20 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3 let clip_r = clip_r.min(limits.1); pg.save(); pg.clip_rect(clip_l, y, (clip_r - clip_l).max(0.0), h); - let rot = st.align.rotation; - if rot != 0 && rot != 255 && !wrap { + if rotated { // Rotated text: anchored at the bottom-left (upward) or top-left (downward) of the cell. + // Wrapped lines stack rightward for upward text and leftward for downward text, so + // downward text starts its first line far enough in for the last to end at the left pad. let deg = (rot as f32).clamp(-90.0, 90.0); let angle = -deg.to_radians(); - let (ox, oy) = if deg > 0.0 { (x + pad + size * 0.8 * deg.to_radians().sin(), y + h - pad) } else { (x + pad, y + pad + size * 0.8) }; - if let Some(t) = lines.first() { + let last = lines.len().saturating_sub(1) as f32; + for (i, t) in lines.iter().enumerate() { + let i = i as f32; + let (ox, oy) = if deg > 0.0 { + (x + pad + size * 0.8 * deg.to_radians().sin() + i * line_h, y + h - pad) + } else { + (x + pad + (last - i) * line_h, y + pad + size * 0.8) + }; pg.text_rotated(ox, oy, size, font, color, t, angle); } pg.restore(); @@ -1762,4 +1777,26 @@ mod tests { let w = wrap_lines("Supercalifragilistic", Font::Helvetica, 10.0, 20.0); assert!(w.len() > 2); } + + #[test] + fn rotated_wrapped_text_prints_rotated() { + // Upright headers over narrow columns (mark sheets) combine rotation with Wrap Text. + let mut s = s(); + let header = "Term 1 : Drama : Continuous Assessment"; + s.execute("range.setValues", json!({"range": "A1", "values": [[header, header]]})).unwrap(); + s.execute("home.wrapText", json!({"range": "A1:B1", "on": true})).unwrap(); + s.execute("home.orientation", json!({"range": "A1", "angle": "up"})).unwrap(); + s.execute("home.orientation", json!({"range": "B1", "angle": "down"})).unwrap(); + s.execute("home.columnWidth", json!({"cols": "A:B", "chars": 6})).unwrap(); + s.execute("home.rowHeight", json!({"rows": "1:1", "height": 267})).unwrap(); + let (pdf, _) = pdf_of(&mut s, json!({})); + let pdf = String::from_utf8_lossy(&pdf); + // The page holds only these two cells, so every text block on it is a piece of a header. + let blocks: Vec<&str> = pdf.split("BT\n").skip(1).filter(|b| b.contains(" Tj")).collect(); + let up = blocks.iter().filter(|b| b.contains("\n0 1 -1 0 ")).count(); + let down = blocks.iter().filter(|b| b.contains("\n0 -1 1 0 ")).count(); + // Every piece of both headers is rotated, each wrapped along the row height into a few long lines. + assert_eq!(up + down, blocks.len(), "a header line printed flat: {blocks:?}"); + assert!((1..=3).contains(&up) && (1..=3).contains(&down), "up {up}, down {down}: {blocks:?}"); + } } diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 4b326cc..7362626 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -577,7 +577,9 @@ fn paint_quadrant( }; let indent = st.align.indent as f32 * 9.0 * z; let pad = 2.5 * z; - if st.align.wrap || st.align.h == HAlign::Justify || text.contains('\n') && st.align.wrap { + let rotated = st.align.rotation != 0 && st.align.rotation != 255; + // Rotated text wraps along its own direction (below), not across the column width. + if !rotated && (st.align.wrap || st.align.h == HAlign::Justify) { let wrap_w = (rect.width() - 2.0 * pad - indent).max(4.0); let mut job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, wrap_w); job.halign = match halign { @@ -622,9 +624,9 @@ fn paint_quadrant( galley = p.layout_no_wrap(s, font.clone(), color); w = galley.size().x; } - // Overflow into empty neighbours for text. + // Overflow into empty neighbours for text (not upright text, whose width is its line height). let mut clip = *rect; - if !numeric && !matches!(v, Value::Number(_)) && w > rect.width() - 2.0 * pad && in_merge(c).is_none() { + if !numeric && !matches!(v, Value::Number(_)) && st.align.rotation.abs() != 90 && w > rect.width() - 2.0 * pad && in_merge(c).is_none() { let mut right = rect.right(); let mut left = rect.left(); if matches!(halign, HAlign::Left | HAlign::Center | HAlign::Justify) { @@ -667,9 +669,23 @@ fn paint_quadrant( VAlign::Bottom => rect.bottom() - gh - 0.5 * z, }; let cp = p.with_clip_rect(clip.intersect(p.clip_rect())); - if st.align.rotation != 0 && st.align.rotation != 255 { + if rotated { let angle = -(st.align.rotation as f32).to_radians(); - let mut shape = egui::epaint::TextShape::new(pos2(rect.left() + pad, rect.bottom() - pad), galley.clone(), color); + // Upright (±90°) wrapped text wraps to the row height: lines then stack across the column. + let galley = if st.align.wrap && st.align.rotation.abs() == 90 { + let job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, (rect.height() - 2.0 * pad).max(4.0)); + p.layout_job(job) + } else { + galley.clone() + }; + // Upward text grows up and right from the bottom-left corner; downward text grows down + // and left from its anchor, so that anchor sits one block-height in from the top-left. + let pos = if angle > 0.0 { + pos2(rect.left() + pad + galley.size().y * angle.sin(), rect.top() + pad) + } else { + pos2(rect.left() + pad, rect.bottom() - pad) + }; + let mut shape = egui::epaint::TextShape::new(pos, galley, color); shape.angle = angle; cp.add(shape); } else { diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 74bbeb4..15d0fff 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -146,3 +146,42 @@ fn column_autocomplete_completes_on_enter() { key(&mut h, Key::Enter, Modifiers::NONE); assert_eq!(value(&h, "A5"), Value::from("Ea")); } + +fn text_shapes(shape: &egui::Shape, clip: egui::Rect, out: &mut Vec<(egui::epaint::TextShape, egui::Rect)>) { + match shape { + egui::Shape::Text(t) => out.push((t.clone(), clip)), + egui::Shape::Vec(v) => v.iter().for_each(|s| text_shapes(s, clip, out)), + _ => {} + } +} + +#[test] +fn rotated_wrapped_text_is_drawn_rotated_inside_its_cell() { + // Narrow columns with tall, upright headers (mark sheets): rotation and Wrap Text together. + let header = "Term 1 : Drama : Continuous Assessment"; + let mut s = blank(); + s.execute("range.setValues", json!({"range": "A1", "values": [[header, header]]})).unwrap(); + s.execute("home.wrapText", json!({"range": "A1:B1", "on": true})).unwrap(); + s.execute("home.orientation", json!({"range": "A1", "angle": "up"})).unwrap(); + s.execute("home.orientation", json!({"range": "B1", "angle": "down"})).unwrap(); + s.execute("home.columnWidth", json!({"cols": "A:B", "chars": 6})).unwrap(); + s.execute("home.rowHeight", json!({"rows": "1:1", "height": 267})).unwrap(); + s.execute("selection.set", json!({"cell": "C3"})).unwrap(); + let h = harness(s); + let mut found = Vec::new(); + for c in &h.output().shapes { + text_shapes(&c.shape, c.clip_rect, &mut found); + } + let rotated: Vec<_> = found.iter().filter(|(t, _)| t.galley.job.text == header).collect(); + assert_eq!(rotated.len(), 2, "both headers drawn as one text block each"); + for (t, clip) in rotated { + assert!((t.angle.abs() - std::f32::consts::FRAC_PI_2).abs() < 1e-3, "drawn at ±90°, got {}", t.angle); + // Wrapped along the row height into a couple of long lines, not across the narrow column. + assert!(t.galley.rows.len() <= 3, "{} lines", t.galley.rows.len()); + let bb = t.visual_bounding_rect(); + assert!(bb.height() > bb.width(), "upright, not flat: {bb:?}"); + assert!(clip.expand(1.0).contains_rect(bb), "inside its cell: {bb:?} not in {clip:?}"); + // Upright text never spills into the neighbouring columns: the clip is the cell itself. + assert!(clip.width() < 70.0, "clip spans one 6-character column, got {clip:?}"); + } +} From 903bbb096d874a5bd0f27cb324129a2a92c8d42b Mon Sep 17 00:00:00 2001 From: lloyd <34131158+LloydNicholson@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:23:15 +0200 Subject: [PATCH 10/35] Issue #51: General numbers fit by the cell's own font, not 11pt grid.rs measured the digit width once, for an 11pt cell font, and display_text judged how many General digits fit from that whatever the cell's size. 9pt marks such as 100 in columns with a stored width of 4 (common in mark sheets) came out as ##. The width is now measured per cell with its own font and size, as print.rs already does; the shared 11pt measurement and its paint_quadrant parameter go away. Test: a 9pt 100 in a 28px column draws in full, while 123456789 next to it still shows ####. Fails before the fix. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/grid.rs | 18 ++++-------------- crates/ui-egui/tests/interaction.rs | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 4b326cc..30e8493 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -373,7 +373,6 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let painter = ui.painter_at(rect); painter.rect_filled(rect, 0.0, t.grid_bg); let mut cf = CfCache::new(); - let char_w = ui.fonts_mut(|f| f.glyph_width(&FontId::new(11.0 * 96.0 / 72.0 * geo.z, egui::FontFamily::Name(theme::CELL.into())), '0')).max(1.0); // Quadrants: (rows, cols, clip). let (sr0, sr1) = geo.scroll_rows(sh); let (sc0, sc1) = geo.scroll_cols(sh); @@ -395,7 +394,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { continue; } let p = painter.with_clip_rect(*clip); - paint_quadrant(&p, &geo, &wb, si, sh, *rows, *cols, char_w, &mut cf, &t); + paint_quadrant(&p, &geo, &wb, si, sh, *rows, *cols, &mut cf, &t); paint_selection(&p, &geo, sh, &sel, &t, app); } // Freeze lines. @@ -431,18 +430,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { } #[allow(clippy::too_many_arguments)] -fn paint_quadrant( - p: &Painter, - geo: &Geo, - wb: &Workbook, - si: usize, - sh: &Sheet, - rows: (u32, u32), - cols: (u32, u32), - char_w: f32, - cf: &mut CfCache, - t: &Tokens, -) { +fn paint_quadrant(p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet, rows: (u32, u32), cols: (u32, u32), cf: &mut CfCache, t: &Tokens) { let z = geo.z; let (r0, r1) = rows; let (c0, c1) = cols; @@ -560,6 +548,8 @@ fn paint_quadrant( let size = st.font.size * 96.0 / 72.0 * z; let fam = theme::cell_family(&st.font.name, st.font.bold, st.font.italic); let font = FontId::new(size, fam); + // Digits fit by the cell's own font and size, not the default 11pt (9pt marks in narrow columns). + let char_w = p.layout_no_wrap("0".to_string(), font.clone(), Color32::PLACEHOLDER).size().x.max(1.0); let avail_w = rect.right() - text_left; let (text, ncolor, numeric, fill_char) = display_text(wb, sh, c, &v, st, avail_w / z.max(0.1) * z, char_w); if text.is_empty() { diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 74bbeb4..730cdae 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -146,3 +146,26 @@ fn column_autocomplete_completes_on_enter() { key(&mut h, Key::Enter, Modifiers::NONE); assert_eq!(value(&h, "A5"), Value::from("Ea")); } + +#[test] +fn general_numbers_fit_by_the_cells_own_font() { + // 9pt marks in narrow columns (a stored width of 4, i.e. 28px; common in mark sheets): "100" fits. + let mut s = blank(); + s.execute("range.setValues", json!({"range": "A1", "values": [[100, 123456789]]})).unwrap(); + s.execute("home.fontSize", json!({"range": "A1:B1", "size": 9})).unwrap(); + s.execute("home.columnWidth", json!({"cols": "A:B", "width": 28})).unwrap(); + s.execute("selection.set", json!({"cell": "C3"})).unwrap(); + let h = harness(s); + let texts: Vec = h + .output() + .shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Text(t) => Some(t.galley.job.text.clone()), + _ => None, + }) + .collect(); + assert!(texts.iter().any(|t| t == "100"), "100 drawn in full: {texts:?}"); + // A number that really doesn't fit still shows #### (once: only B1). + assert_eq!(texts.iter().filter(|t| t.starts_with('#')).count(), 1, "{texts:?}"); +} From 2c2d92ef6b595aed9a03326474a0fcc7b008dd79 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:29:29 +0200 Subject: [PATCH 11/35] ROUND: keep 15 significant digits when asked for 15+ digits, so ROUND(0.1+0.2,15) is 0.3 (#58) --- crates/functions/src/util.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/functions/src/util.rs b/crates/functions/src/util.rs index fca5dc4..5296c48 100644 --- a/crates/functions/src/util.rs +++ b/crates/functions/src/util.rs @@ -235,7 +235,9 @@ pub(crate) fn round_half_away(x: f64, digits: i32) -> f64 { // value = 0.d1d2…d15 × 10^(e+1); keep the first `keep` digits. let keep = e + 1 + digits; if keep >= d.len() as i64 { - return x; + // Nothing to round off, but Excel still keeps only 15 significant digits, which drops binary noise + // such as 0.1 + 0.2 = 0.30000000000000004. + return to_sig_digits(x, d.len()); } if keep < 0 { return 0.0; @@ -416,6 +418,8 @@ mod tests { assert_eq!(round_half_away(1234.5678, -2), 1200.0); assert_eq!(round_half_away(0.285, 2), 0.29); assert_eq!(round_half_away(1.005, 2), 1.01); + assert_eq!(round_half_away(0.1 + 0.2, 15), 0.3); + assert_eq!(round_half_away(0.1 + 0.2, 20), 0.3); } #[test] From ecaead4c12c11b2cfa9489a929606c1c76e06bb1 Mon Sep 17 00:00:00 2001 From: lloyd Date: Fri, 9 Oct 2026 20:36:08 +0200 Subject: [PATCH 12/35] DATE(1900,2,29) is serial 60, as in Excel serial_from_ymd worked out the real calendar date and then moved serials up to 60 down by one. February 1900 has no 29th in the real calendar, so DATE(1900,2,29) and DATEVALUE("2/29/1900") rolled over to 1 March (61), and DATE(1900,3,0) gave 28 February (59). Excel's 1900 system keeps the Lotus leap day: serial 60 is 29 February 1900, and DAY(60) already said so. The serial of the first of the month is now taken in Excel's calendar (one lower up to February 1900), and the day, with any overflow, counts on from there. So day and month overflow pass through serial 60 both ways: DATE(1900,1,60), DATE(1900,4,-31) and DATE(1901,-10,29) are 60 too. Month and day arithmetic is checked, so hostile values return None. days_in_month_in gives February 1900 29 days in the 1900 system; EDATE and EOMONTH use it, so EOMONTH(60,0) is 60 rather than the day before, and the text parser uses it in place of its own special case. The 1904 system is unchanged (1900 is before its epoch). Tests: serials around the leap day (overflow both ways, every serial 0-366 round-trips through its year, month and day), hostile months and days, parsing 2/29/1900 and 1900-02-29, DATE/DATEVALUE/EDATE/EOMONTH in the 1900 system and the 1904 system. The DATE, DATEVALUE and serial cases fail before the fix. Co-Authored-By: Claude Opus 5.5 --- crates/core/src/date.rs | 82 ++++++++++++++++++++++++++++++------ crates/core/src/parse.rs | 17 +++++++- crates/functions/src/date.rs | 47 +++++++++++++++++++-- 3 files changed, 129 insertions(+), 17 deletions(-) diff --git a/crates/core/src/date.rs b/crates/core/src/date.rs index 7fba23f..b824434 100644 --- a/crates/core/src/date.rs +++ b/crates/core/src/date.rs @@ -69,26 +69,34 @@ pub fn days_in_month(y: i32, m: u32) -> u32 { } } +/// Days in a month of a date system's calendar: like [`days_in_month`], except that February +/// 1900 has 29 days in the 1900 system (serial 60 is the fictitious 1900-02-29). +pub fn days_in_month_in(sys: DateSystem, y: i32, m: u32) -> u32 { + if sys == DateSystem::D1900 && y == 1900 && m == 2 { 29 } else { days_in_month(y, m) } +} + /// Serial for a date. Month and day overflow roll over like `DATE()` (month 13 = January next -/// year, day 0 = last day of the previous month). `None` before the epoch or after 9999-12-31. +/// year, day 0 = last day of the previous month), in the date system's own calendar: in the 1900 +/// system February 1900 has 29 days, so `(1900, 2, 29)` and `(1900, 3, 0)` are serial 60. +/// `None` before the epoch or after 9999-12-31. pub fn serial_from_ymd(sys: DateSystem, year: i64, month: i64, day: i64) -> Option { // DATE(): years 0–1899 are added to 1900. - let mut y = if (0..1900).contains(&year) { year + 1900 } else { year }; - let m0 = month - 1; - y += m0.div_euclid(12); + let y = if (0..1900).contains(&year) { year + 1900 } else { year }; + let m0 = month.checked_sub(1)?; + let y = y.checked_add(m0.div_euclid(12))?; let m = m0.rem_euclid(12) + 1; if !(1900..=9999).contains(&y) && sys == DateSystem::D1900 || !(1904..=9999).contains(&y) && sys == DateSystem::D1904 { return None; } - let base = days_from_civil(y as i32, m as u32, 1) + day - 1; - let serial = match sys { - DateSystem::D1900 => { - let s = base - UNIX_1899_12_30; - // Before the fictitious 1900-02-29 serials are one lower than the day count from 1899-12-30. - if s <= 60 { s - 1 } else { s } - } - DateSystem::D1904 => base - UNIX_1904_01_01, + let first = days_from_civil(y as i32, m as u32, 1); + // Serial of the first of the month; the day (and any overflow) counts on from there. + let first = match sys { + // Up to February 1900 the serial is one lower than the day count from 1899-12-30: the + // fictitious 1900-02-29 (serial 60) comes before 1 March. + DateSystem::D1900 => first - UNIX_1899_12_30 - i64::from(y == 1900 && m <= 2), + DateSystem::D1904 => first - UNIX_1904_01_01, }; + let serial = first.checked_add(day)?.checked_sub(1)?; if !(0..=2_958_465).contains(&serial) { return None; } @@ -168,10 +176,58 @@ mod tests { assert_eq!(serial_from_ymd(DateSystem::D1904, 2000, 1, 1), Some(35064.0)); } + #[test] + fn fictitious_1900_02_29() { + // Excel's 1900 calendar has a 29 February 1900 (serial 60); day and month overflow count + // through it. + let s = DateSystem::D1900; + assert_eq!(serial_from_ymd(s, 1900, 2, 29), Some(60.0)); + assert_eq!(serial_from_ymd(s, 1900, 3, 0), Some(60.0)); + assert_eq!(serial_from_ymd(s, 1900, 2, 30), Some(61.0)); + assert_eq!(serial_from_ymd(s, 1900, 1, 60), Some(60.0)); + assert_eq!(serial_from_ymd(s, 1900, 1, 61), Some(61.0)); + assert_eq!(serial_from_ymd(s, 1900, 1, 400), Some(400.0)); + assert_eq!(serial_from_ymd(s, 1900, 4, -30), Some(61.0)); + assert_eq!(serial_from_ymd(s, 1900, 4, -31), Some(60.0)); + assert_eq!(serial_from_ymd(s, 1900, 3, -1), Some(59.0)); + assert_eq!(serial_from_ymd(s, 1901, -10, 29), Some(60.0)); + assert_eq!(serial_from_ymd(s, 1900, 1, 0), Some(0.0)); + assert_eq!(serial_from_ymd(s, 1900, 1, -1), None); + assert_eq!(serial_from_ymd(s, 1900, 0, 1), None); + // Later years keep the real calendar. + assert_eq!(serial_from_ymd(s, 1901, 2, 29), serial_from_ymd(s, 1901, 3, 1)); + assert_eq!(serial_from_ymd(s, 1904, 2, 29), Some(1521.0)); + // Every serial up to 1900-12-31 comes back from its own year, month and day. + for serial in 0..=366 { + let d = datetime_from_serial(s, serial as f64).unwrap(); + assert_eq!(serial_from_ymd(s, d.year as i64, d.month as i64, d.day as i64), Some(serial as f64), "{d:?}"); + } + assert_eq!(days_in_month_in(s, 1900, 2), 29); + assert_eq!(days_in_month_in(s, 1901, 2), 28); + assert_eq!(days_in_month_in(DateSystem::D1904, 1900, 2), 28); + // The 1904 system has no fictitious day: 1904 is a real leap year. + let s = DateSystem::D1904; + assert_eq!(serial_from_ymd(s, 1904, 2, 29), Some(59.0)); + assert_eq!(serial_from_ymd(s, 1904, 3, 0), Some(59.0)); + assert_eq!(serial_from_ymd(s, 1904, 3, 1), Some(60.0)); + assert_eq!(serial_from_ymd(s, 1904, 1, 0), None); + assert_eq!(serial_from_ymd(s, 1900, 2, 29), None); + } + + #[test] + fn hostile_month_and_day() { + let s = DateSystem::D1900; + assert_eq!(serial_from_ymd(s, 2000, i64::MIN, 1), None); + assert_eq!(serial_from_ymd(s, 2000, i64::MAX, 1), None); + assert_eq!(serial_from_ymd(s, 2000, 1, i64::MAX), None); + assert_eq!(serial_from_ymd(s, 2000, 1, i64::MIN), None); + assert_eq!(serial_from_ymd(s, i64::MAX, 1, 1), None); + } + #[test] fn roundtrip() { let s = DateSystem::D1900; - for serial in [1.0, 59.0, 61.0, 36526.0, 46302.0, 2958465.0] { + for serial in [1.0, 59.0, 60.0, 61.0, 36526.0, 46302.0, 2958465.0] { let d = datetime_from_serial(s, serial).unwrap(); assert_eq!(serial_from_ymd(s, d.year as i64, d.month as i64, d.day as i64), Some(serial)); } diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs index b08cf2a..5d0be7a 100644 --- a/crates/core/src/parse.rs +++ b/crates/core/src/parse.rs @@ -266,7 +266,7 @@ fn valid_ymd(sys: DateSystem, y: i64, m: i64, d: i64) -> Option { if !(1..=12).contains(&m) || d < 1 { return None; } - let max = if y == 1900 && m == 2 { 29 } else { crate::date::days_in_month(y as i32, m as u32) as i64 }; + let max = crate::date::days_in_month_in(sys, y as i32, m as u32) as i64; if d > max { return None; } @@ -357,4 +357,19 @@ mod tests { assert_eq!(p("10/7/2026 18:00").value, Value::Number(46302.75)); assert_eq!(p("25:00").value, Value::Number(25.0 / 24.0)); } + + #[test] + fn fictitious_1900_02_29() { + // Excel accepts 29 February 1900 (serial 60) and keeps 1 March 1900 at 61. + assert_eq!(p("2/29/1900").value, Value::Number(60.0)); + assert_eq!(p("1900-02-29").value, Value::Number(60.0)); + assert_eq!(p("29-Feb-1900").value, Value::Number(60.0)); + assert_eq!(p("2/28/1900").value, Value::Number(59.0)); + assert_eq!(p("3/1/1900").value, Value::Number(61.0)); + assert_eq!(p("2/29/1901").value, Value::text("2/29/1901")); + assert_eq!(p("2/30/1900").value, Value::text("2/30/1900")); + // The 1904 system starts after it. + assert_eq!(parse_input("2/29/1900", DateSystem::D1904).value, Value::text("2/29/1900")); + assert_eq!(parse_input("2/29/1904", DateSystem::D1904).value, Value::Number(59.0)); + } } diff --git a/crates/functions/src/date.rs b/crates/functions/src/date.rs index f1f1bec..258aea6 100644 --- a/crates/functions/src/date.rs +++ b/crates/functions/src/date.rs @@ -1,6 +1,6 @@ //! Date and time functions. -use gridcraft_core::date::{DateTime, datetime_from_serial, days_in_month, is_leap, serial_from_ymd}; +use gridcraft_core::date::{DateTime, datetime_from_serial, days_in_month, days_in_month_in, is_leap, serial_from_ymd}; use gridcraft_core::parse::parse_input; use gridcraft_core::{CellError, DateSystem, Value}; @@ -164,7 +164,7 @@ fn edate(a: &[Arg], c: &mut dyn Ctx) -> R { if !(1900..=9999).contains(&y) { return Err(CellError::Num); } - let dd = (d.day.max(1) as i64).min(days_in_month(y as i32, mo as u32) as i64); + let dd = (d.day.max(1) as i64).min(days_in_month_in(sys, y as i32, mo as u32) as i64); num_val(ymd_serial(sys, y, mo, dd)? as f64) } @@ -177,7 +177,7 @@ fn eomonth(a: &[Arg], c: &mut dyn Ctx) -> R { if !(1900..=9999).contains(&y) { return Err(CellError::Num); } - num_val(ymd_serial(sys, y, mo, days_in_month(y as i32, mo as u32) as i64)? as f64) + num_val(ymd_serial(sys, y, mo, days_in_month_in(sys, y as i32, mo as u32) as i64)? as f64) } fn datedif(a: &[Arg], c: &mut dyn Ctx) -> R { @@ -652,6 +652,36 @@ mod tests { is_err(ev("EDATE", vec![n(1.0), n(1e12)]), CellError::Num); } + #[test] + fn fictitious_1900_02_29() { + // Excel's 1900 calendar has 29 February 1900 = serial 60, and DATE counts day and month + // overflow through it. + assert_eq!(d(1900.0, 2.0, 29.0), 60.0); + assert_eq!(d(1900.0, 3.0, 0.0), 60.0); + assert_eq!(d(1900.0, 2.0, 28.0), 59.0); + assert_eq!(d(1900.0, 3.0, 1.0), 61.0); + assert_eq!(d(1900.0, 2.0, 30.0), 61.0); + assert_eq!(d(1900.0, 1.0, 60.0), 60.0); + assert_eq!(d(1900.0, 4.0, -31.0), 60.0); + assert_eq!(d(1901.0, -10.0, 29.0), 60.0); + assert_eq!(d(0.0, 2.0, 29.0), 60.0); + assert_eq!(d(1900.0, 1.0, 0.0), 0.0); + close(ev("DAY", vec![n(d(1900.0, 3.0, 0.0))]), 29.0); + close(ev("DATEVALUE", vec![t("1900-02-29")]), 60.0); + close(ev("DATEVALUE", vec![t("2/29/1900")]), 60.0); + close(ev("DATEVALUE", vec![t("3/1/1900")]), 61.0); + is_err(ev("DATEVALUE", vec![t("2/29/1901")]), CellError::Value); + // EDATE and EOMONTH land on it as the end of February 1900. + close(ev("EOMONTH", vec![n(d(1900.0, 1.0, 15.0)), n(1.0)]), 60.0); + close(ev("EOMONTH", vec![n(60.0), n(0.0)]), 60.0); + close(ev("EOMONTH", vec![n(d(1900.0, 3.0, 15.0)), n(-1.0)]), 60.0); + close(ev("EOMONTH", vec![n(60.0), n(12.0)]), d(1901.0, 2.0, 28.0)); + close(ev("EDATE", vec![n(d(1900.0, 1.0, 31.0)), n(1.0)]), 60.0); + close(ev("EDATE", vec![n(d(1900.0, 3.0, 31.0)), n(-1.0)]), 60.0); + close(ev("EDATE", vec![n(60.0), n(1.0)]), d(1900.0, 3.0, 29.0)); + close(ev("EDATE", vec![n(60.0), n(12.0)]), d(1901.0, 2.0, 28.0)); + } + #[test] fn datedif_units() { let s = n(d(2001.0, 6.0, 1.0)); @@ -755,5 +785,16 @@ mod tests { close(crate::call(spec, &[n(0.0)], &mut ctx), 6.0); let spec = crate::lookup("YEAR").unwrap(); close(crate::call(spec, &[n(0.0)], &mut ctx), 1904.0); + // No fictitious day here: 1904 is a real leap year and 1900 is before the epoch. + let spec = crate::lookup("DATE").unwrap(); + close(crate::call(spec, &[n(1904.0), n(2.0), n(29.0)], &mut ctx), 59.0); + close(crate::call(spec, &[n(1904.0), n(3.0), n(0.0)], &mut ctx), 59.0); + close(crate::call(spec, &[n(1904.0), n(3.0), n(1.0)], &mut ctx), 60.0); + is_err(crate::call(spec, &[n(1900.0), n(2.0), n(29.0)], &mut ctx), CellError::Num); + let spec = crate::lookup("EOMONTH").unwrap(); + close(crate::call(spec, &[n(31.0), n(0.0)], &mut ctx), 59.0); + let spec = crate::lookup("DATEVALUE").unwrap(); + close(crate::call(spec, &[t("2/29/1904")], &mut ctx), 59.0); + is_err(crate::call(spec, &[t("2/29/1900")], &mut ctx), CellError::Value); } } From 8fe3276cb8fe29b000cde9459507a74ce3c93ce1 Mon Sep 17 00:00:00 2001 From: lloyd Date: Fri, 9 Oct 2026 20:44:18 +0200 Subject: [PATCH 13/35] SUMIF/AVERAGEIF size the sum range like the criteria range Excel adds up the range that starts at sum_range's top-left cell and has the criteria range's height and width, whatever size sum_range has. With A1:B3 = a,1 / b,2 / a,3, =SUMIF(A1:A3,"a",B1) is 4; GridCraft gave 1, because if_numbers only saw sum_range's values (one cell) and read them at the criteria positions. Its comment promised the resizing, but by then the argument was a plain value. The evaluator has the references, so it now does the resizing, as it does for OFFSET and INDEX: for SUMIF and AVERAGEIF with three arguments, a single-area sum range reference becomes resized_sum_range(sum, criteria height, criteria width), cut off at the sheet's edge. It works across sheets and through names. The dependency graph records the resized range, so editing a cell inside it recalculates; when the criteria range isn't a plain reference the formula is recalculated every time instead. SUMIFS and AVERAGEIFS keep requiring same-size ranges (#VALUE! otherwise). Tests: a single-cell, shorter, wider, longer and horizontal sum range, whole-column criteria, editing a cell of the resized range, the sheet edge, another sheet and a defined name, and SUMIFS/AVERAGEIFS size mismatches. They fail before the fix (1 instead of 4). Co-Authored-By: Claude Opus 5.5 --- crates/calc/src/eval.rs | 82 +++++++++++++++++++++++++------- crates/calc/src/tests.rs | 91 ++++++++++++++++++++++++++++++++++++ crates/functions/src/math.rs | 5 +- 3 files changed, 158 insertions(+), 20 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index abef54f..ecdb52b 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -696,8 +696,20 @@ impl<'h> Evaluator<'h> { return err(CellError::Value); } let mut evaluated = Vec::with_capacity(args.len()); - for a in args { - let ev = self.eval(a); + // SUMIF/AVERAGEIF: the size of the criteria range (argument 1), which the sum range takes. + let mut criteria_shape = None; + for (i, a) in args.iter().enumerate() { + let mut ev = self.eval(a); + if resizes_sum_range(name, args.len()) { + if i == 0 { + criteria_shape = ev_shape(&ev); + } else if i == 2 + && let (Some((h, w)), Ev::R(areas)) = (criteria_shape, &ev) + && let [area] = areas.as_slice() + { + ev = Ev::R(vec![Area { sheet: area.sheet, range: resized_sum_range(area.range, h, w) }]); + } + } let from_ref = matches!(ev, Ev::R(_)); let value = match ev { Ev::R(areas) if areas.len() > 1 => { @@ -1306,30 +1318,64 @@ fn binary_scalar(op: BinOp, a: &Value, b: &Value) -> Value { } } +/// SUMIF and AVERAGEIF with a sum range: Excel sizes the sum range like the criteria range. +fn resizes_sum_range(name: &str, args: usize) -> bool { + args == 3 && matches!(name, "SUMIF" | "AVERAGEIF") +} + +/// Height and width of a single-area reference or an array argument. +fn ev_shape(ev: &Ev) -> Option<(u32, u32)> { + match ev { + Ev::R(areas) => match areas.as_slice() { + [a] => Some((a.range.height(), a.range.width())), + _ => None, + }, + Ev::V(Value::Array(a)) => Some((u32::try_from(a.rows).unwrap_or(u32::MAX), u32::try_from(a.cols).unwrap_or(u32::MAX))), + Ev::V(_) => Some((1, 1)), + Ev::L(_) => None, + } +} + +/// The range SUMIF/AVERAGEIF add up: from the sum range's top-left cell, as many rows and columns +/// as the criteria range (`height` × `width`), cut off at the sheet's edge. +pub fn resized_sum_range(sum: RangeRef, height: u32, width: u32) -> RangeRef { + let row = sum.start.row.saturating_add(height.saturating_sub(1)).min(MAX_ROWS - 1); + let col = sum.start.col.saturating_add(width.saturating_sub(1)).min(MAX_COLS - 1); + RangeRef::new(sum.start, CellRef::new(row, col)) +} + /// Collects the static references of an expression with their sheets resolved; `dynamic` is /// set when the formula uses INDIRECT/OFFSET/names/tables whose targets can change. pub fn precedents(wb: &Workbook, sheet: usize, e: &Expr) -> (Vec, bool) { let mut out = Vec::new(); let mut dynamic = false; - e.walk(&mut |x| match x { - Expr::Ref(r) => { - let range = r.range(); - match &r.sheet { - SheetSel::Current => out.push(Area { sheet, range }), - SheetSel::Named(n) => { - if let Some(i) = wb.sheet_index(n) { - out.push(Area { sheet: i, range }); - } - } - SheetSel::Span(a, b) => { - if let (Some(i), Some(j)) = (wb.sheet_index(a), wb.sheet_index(b)) { - for s in i.min(j)..=i.max(j) { - out.push(Area { sheet: s, range }); - } - } + let push = |out: &mut Vec, r: &Reference, range: RangeRef| match &r.sheet { + SheetSel::Current => out.push(Area { sheet, range }), + SheetSel::Named(n) => { + if let Some(i) = wb.sheet_index(n) { + out.push(Area { sheet: i, range }); + } + } + SheetSel::Span(a, b) => { + if let (Some(i), Some(j)) = (wb.sheet_index(a), wb.sheet_index(b)) { + for s in i.min(j)..=i.max(j) { + out.push(Area { sheet: s, range }); } } } + }; + e.walk(&mut |x| match x { + Expr::Ref(r) => push(&mut out, r, r.range()), + // The cells SUMIF/AVERAGEIF really read from the sum range (see `resized_sum_range`); when + // the criteria range's size isn't known statically, recalculate the formula every time. + Expr::Call(n, args) if resizes_sum_range(n, args.len()) => match (args.first(), args.get(2)) { + (Some(Expr::Ref(c)), Some(Expr::Ref(s))) => { + let (crit, sum) = (c.range(), s.range()); + push(&mut out, s, resized_sum_range(sum, crit.height(), crit.width())); + } + (_, Some(Expr::Number(_) | Expr::Text(_) | Expr::Bool(_) | Expr::Error(_) | Expr::Array(_) | Expr::Missing)) => {} + _ => dynamic = true, + }, Expr::Call(n, _) if matches!(n.as_str(), "INDIRECT" | "OFFSET" | "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" | "CELL" | "INFO") => { diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 67db083..255bf7f 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -215,3 +215,94 @@ fn recalc_all_after_load() { t.calc.recalc_all(&mut t.wb); assert_eq!(t.num("A3"), 12.0); } + +#[test] +fn sumif_sum_range_takes_the_criteria_size() { + // Excel adds up the range that starts at sum_range's top-left cell with the criteria range's + // height and width, whatever size sum_range has. + let mut t = T::new(); + for (r, (k, v)) in [("a", 1), ("b", 2), ("a", 3)].iter().enumerate() { + t.set(&format!("A{}", r + 1), k); + t.set(&format!("B{}", r + 1), &v.to_string()); + } + t.set("D1", "=SUMIF(A1:A3,\"a\",B1)"); + assert_eq!(t.num("D1"), 4.0); + t.set("D2", "=AVERAGEIF(A1:A3,\"a\",B1)"); + assert_eq!(t.num("D2"), 2.0); + t.set("D3", "=SUMIF(A1:A3,\"a\",B1:B2)"); + assert_eq!(t.num("D3"), 4.0); + t.set("D4", "=SUMIF(A1:A3,\"a\",B1:F1)"); + assert_eq!(t.num("D4"), 4.0); + t.set("D5", "=SUMIF(A1:A3,\"a\",B1:B99)"); + assert_eq!(t.num("D5"), 4.0); + t.set("D6", "=SUMIF(A:A,\"a\",B1)"); + assert_eq!(t.num("D6"), 4.0); + t.set("D7", "=SUMIF(A1:A3,\"a\",$B$2)"); + assert_eq!(t.num("D7"), 2.0); // B2:B4: rows 1 and 3 match → B2 + B4 (empty) + t.set("D8", "=SUMIF(A1:A3,\"a\")"); + assert_eq!(t.num("D8"), 0.0); + // A horizontal criteria range resizes across. + t.set("H1", "1"); + t.set("I1", "0"); + t.set("J1", "1"); + t.set("H2", "10"); + t.set("I2", "20"); + t.set("J2", "30"); + t.set("D9", "=SUMIF(H1:J1,1,H2)"); + assert_eq!(t.num("D9"), 40.0); + // The resized cells are precedents: editing one recalculates. + t.set("B3", "5"); + assert_eq!(t.num("D1"), 6.0); + assert_eq!(t.num("D2"), 3.0); + assert_eq!(t.num("D3"), 6.0); + assert_eq!(t.num("D4"), 6.0); + assert_eq!(t.num("D6"), 6.0); + // SUMIFS and AVERAGEIFS don't resize: different sizes are #VALUE!. + t.set("D11", "=SUMIFS(B1,A1:A3,\"a\")"); + assert_eq!(t.get("D11"), Value::Error(CellError::Value)); + t.set("D12", "=AVERAGEIFS(B1:B2,A1:A3,\"a\")"); + assert_eq!(t.get("D12"), Value::Error(CellError::Value)); + t.set("D13", "=SUMIFS(B1:B3,A1:A3,\"a\")"); + assert_eq!(t.num("D13"), 6.0); +} + +#[test] +fn sumif_resized_sum_range_stops_at_the_sheet_edge() { + let mut t = T::new(); + t.set("A1", "a"); + t.set("A2", "a"); + t.set("B1048575", "5"); + t.set("B1048576", "7"); + t.set("C1", "=SUMIF(A1:A2,\"a\",B1048576)"); + assert_eq!(t.num("C1"), 7.0); + t.set("C2", "=SUMIF(A1:A2,\"a\",B1048575)"); + assert_eq!(t.num("C2"), 12.0); + t.set("C3", "=SUMIF(A1:B1,\"a\",XFD1)"); + assert_eq!(t.num("C3"), 0.0); +} + +#[test] +fn sumif_resized_sum_range_on_another_sheet_and_through_a_name() { + let mut t = T::new(); + t.wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("Data"))); + t.wb.names.push(gridcraft_model::DefinedName { + name: "Amounts".into(), + scope: None, + formula: "Data!$B$1".into(), + comment: String::new(), + hidden: false, + }); + for (r, k) in ["a", "b", "a"].iter().enumerate() { + t.set(&format!("A{}", r + 1), k); + t.wb.sheet_mut(1).unwrap().set_value(CellRef::new(r as u32, 1), Value::Number((r + 1) as f64)); + } + t.calc.rebuild(&t.wb); + t.set("C1", "=SUMIF(A1:A3,\"a\",Data!B1)"); + assert_eq!(t.num("C1"), 4.0); + t.set("C2", "=AVERAGEIF(A1:A3,\"a\",Amounts)"); + assert_eq!(t.num("C2"), 2.0); + t.wb.sheet_mut(1).unwrap().set_value(c("B3"), Value::Number(9.0)); + t.calc.cells_changed(&mut t.wb, &[(1, c("B3"))]); + assert_eq!(t.num("C1"), 10.0); + assert_eq!(t.num("C2"), 5.0); +} diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 3a0bab3..8196f5a 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -1006,8 +1006,9 @@ fn countif(a: &[Arg], _c: &mut dyn Ctx) -> R { num_val(range.iter().filter(|v| c.matches(v)).count() as f64) } -/// SUMIF / AVERAGEIF: matched numbers from the sum range, which takes the criteria range's -/// shape anchored at its own top-left. +/// SUMIF / AVERAGEIF: matched numbers from the sum range, read at the criteria range's positions. +/// Excel sizes a sum range reference like the criteria range, anchored at its own top-left cell; +/// the evaluator does that resizing (it has the references), so cells line up one to one here. fn if_numbers(a: &[Arg]) -> R> { let range = as_array(&crate::util::arg(a, 0)?.value); let c = Criterion::parse(&crate::util::arg(a, 1)?.value.scalar()); From b8b3ba0b0ef7041a49b7ead65e372be7338e026b Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:56:27 +0200 Subject: [PATCH 14/35] XLSX: write _xlpm. prefix on LET/LAMBDA parameter names, strip it on read (#63) --- crates/xlsx/src/fmla.rs | 77 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/crates/xlsx/src/fmla.rs b/crates/xlsx/src/fmla.rs index eee516b..b30cb6a 100644 --- a/crates/xlsx/src/fmla.rs +++ b/crates/xlsx/src/fmla.rs @@ -21,7 +21,56 @@ pub fn text_to_file(text: &str) -> String { } } +const PARAM_PREFIX: &str = "_xlpm."; + +/// Adds Excel's `_xlpm.` prefix to LET/LAMBDA parameter names, at the declaration and at every use in scope. +fn prefix_params(e: Expr, scope: &[String]) -> Expr { + let sub = |x: Box, sc: &[String]| Box::new(prefix_params(*x, sc)); + match e { + Expr::Name(n) if scope.iter().any(|s| s.eq_ignore_ascii_case(&n)) => Expr::Name(format!("{PARAM_PREFIX}{n}")), + Expr::Unary(op, x) => Expr::Unary(op, sub(x, scope)), + Expr::Paren(x) => Expr::Paren(sub(x, scope)), + Expr::Binary(op, a, b) => Expr::Binary(op, sub(a, scope), sub(b, scope)), + Expr::Invoke(c, args) => Expr::Invoke(sub(c, scope), args.into_iter().map(|a| prefix_params(a, scope)).collect()), + Expr::Call(name, args) if name == "LET" || name == "LAMBDA" => { + let last = args.len().saturating_sub(1); + let is_let = name == "LET"; + let mut sc = scope.to_vec(); + let mut pending: Option = None; + let mut out = Vec::with_capacity(args.len()); + for (i, a) in args.into_iter().enumerate() { + let declares = i < last && (!is_let || i % 2 == 0); + match a { + Expr::Name(n) if declares => { + out.push(Expr::Name(format!("{PARAM_PREFIX}{n}"))); + if is_let { + pending = Some(n); + } else { + sc.push(n); + } + } + other => { + out.push(prefix_params(other, &sc)); + // A LET name is in scope only after its value expression. + if let Some(n) = pending.take() { + sc.push(n); + } + } + } + } + Expr::Call(name, out) + } + // A LET-bound LAMBDA called by name, e.g. `f(3)`. + Expr::Call(name, args) if scope.iter().any(|s| s.eq_ignore_ascii_case(&name)) => { + Expr::Call(format!("{PARAM_PREFIX}{name}"), args.into_iter().map(|a| prefix_params(a, scope)).collect()) + } + Expr::Call(name, args) => Expr::Call(name, args.into_iter().map(|a| prefix_params(a, scope)).collect()), + other => other, + } +} + fn expr_to_file(e: Expr) -> String { + let e = prefix_params(e, &[]); let e = e.map(&mut |x| match x { Expr::Call(name, args) => match crate::tables::file_function_name(&name) { Some(n) => Expr::Call(n, args), @@ -32,11 +81,23 @@ fn expr_to_file(e: Expr) -> String { gridcraft_formula::print(&e) } +/// `_xlpm.x` → `x` (any case, as the parser upper-cases function names). +fn strip_param_prefix(n: String) -> String { + match (n.get(..PARAM_PREFIX.len()), n.get(PARAM_PREFIX.len()..)) { + (Some(p), Some(rest)) if p.eq_ignore_ascii_case(PARAM_PREFIX) => rest.to_string(), + _ => n, + } +} + /// Formula text read from a file → model text (strips prefixes when it parses). pub fn from_file(text: &str) -> String { let body = text.strip_prefix('=').unwrap_or(text); match gridcraft_formula::parse(body) { - Ok(e) => gridcraft_formula::print(&e), + Ok(e) => gridcraft_formula::print(&e.map(&mut |x| match x { + Expr::Name(n) => Expr::Name(strip_param_prefix(n)), + Expr::Call(n, args) => Expr::Call(strip_param_prefix(n), args), + other => other, + })), Err(_) => body.to_string(), } } @@ -52,4 +113,18 @@ mod tests { assert_eq!(from_file("_xlfn.XLOOKUP(1,A:A,B:B)"), "XLOOKUP(1,A:A,B:B)"); assert_eq!(text_to_file("=IFS(A1>0,1)"), "_xlfn.IFS(A1>0,1)"); } + + #[test] + fn let_lambda_params() { + assert_eq!(to_file(&Formula::new("LET(x,1,x+1)")), "_xlfn.LET(_xlpm.x,1,_xlpm.x+1)"); + assert_eq!(to_file(&Formula::new("LET(x,A1*2,y,x+1,x*y)")), "_xlfn.LET(_xlpm.x,A1*2,_xlpm.y,_xlpm.x+1,_xlpm.x*_xlpm.y)"); + assert_eq!(to_file(&Formula::new("LAMBDA(a,b,a^2+b)(3,4)")), "_xlfn.LAMBDA(_xlpm.a,_xlpm.b,_xlpm.a^2+_xlpm.b)(3,4)"); + // A name outside the scope stays a plain defined name. + assert_eq!(to_file(&Formula::new("LET(x,1,x)+x")), "_xlfn.LET(_xlpm.x,1,_xlpm.x)+x"); + assert_eq!(from_file("_xlfn.LET(_xlpm.x,1,_xlpm.x+1)"), "LET(x,1,x+1)"); + assert_eq!(from_file("_xlfn.LAMBDA(_xlpm.a,_xlpm.a*2)(3)"), "LAMBDA(a,a*2)(3)"); + // A LET-bound LAMBDA called by name. + assert_eq!(to_file(&Formula::new("LET(f,LAMBDA(x,x*2),f(3))")), "_xlfn.LET(_xlpm.f,_xlfn.LAMBDA(_xlpm.x,_xlpm.x*2),_xlpm.F(3))"); + assert_eq!(from_file("_xlfn.LET(_xlpm.f,_xlfn.LAMBDA(_xlpm.x,_xlpm.x*2),_xlpm.f(3))"), "LET(f,LAMBDA(x,x*2),F(3))"); + } } From d7174f829790e3eac2accc3aa3c7bb96961d14cf Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Fri, 9 Oct 2026 14:33:43 -0600 Subject: [PATCH 15/35] M2: preserve inherited formatting when entering empty cells (#70) Resolve input styles from the destination cell, row or column before parsing. Preserve inherited alignment, borders and number formats for single-cell and bulk entry, including text paste and array formulas. Co-authored-by: Codex --- crates/engine/src/cmd/data.rs | 3 +- crates/engine/src/cmd/edit.rs | 31 +++++++------- crates/engine/src/cmd/whatif.rs | 3 +- crates/engine/src/tests.rs | 71 +++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 8961d87..1a7ec85 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -662,8 +662,7 @@ fn text_to_columns(s: &mut Session, p: &Json) -> Result { } } for (c, t) in out { - let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(c)).cloned(); - let cell = super::edit::input_to_cell(&t, old.as_ref(), &mut cx.wb).unwrap_or(None); + let cell = super::edit::input_to_cell(&t, sheet, c, &mut cx.wb).unwrap_or(None); let shm = cx.sheet_mut(sheet)?; match cell { Some(x) => shm.cells.set(c, x), diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index cffe154..3660de8 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -119,9 +119,10 @@ pub fn specs() -> Vec { // ---------------------------------------------------------------- entry /// What typing `input` into a cell produces (constant or formula, plus an automatic number -/// format). Errors when the formula can't be parsed. -pub(crate) fn input_to_cell(input: &str, old: Option<&Cell>, wb: &mut gridcraft_model::Workbook) -> Result> { - let style = old.map(|c| c.style).unwrap_or_default(); +/// format). Inherits the destination's cell, row or column style, including its number +/// format, before parsing. Errors when the formula can't be parsed. +pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gridcraft_model::Workbook) -> Result> { + let style = wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(); if input.is_empty() { let c = Cell { value: Value::Empty, formula: None, style }; return Ok(if c.is_blank() { None } else { Some(c) }); @@ -206,8 +207,7 @@ fn cell_set(s: &mut Session, p: &Json) -> Result { if array { // Legacy Ctrl+Shift+Enter array formula over the selection. let range = cx.sel.current(); - let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(range.start)).cloned(); - let mut cell = input_to_cell(&input, old.as_ref(), &mut cx.wb)?.unwrap_or_default(); + let mut cell = input_to_cell(&input, sheet, range.start, &mut cx.wb)?.unwrap_or_default(); if let Some(f) = cell.formula.as_mut() { std::sync::Arc::make_mut(f).array = Some(range); } @@ -216,8 +216,7 @@ fn cell_set(s: &mut Session, p: &Json) -> Result { cx.touch(sheet, range.start); return Ok(Json::Null); } - let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(at)).cloned(); - let cell = input_to_cell(&input, old.as_ref(), &mut cx.wb)?; + let cell = input_to_cell(&input, sheet, at, &mut cx.wb)?; let sh = cx.sheet_mut(sheet)?; match cell { Some(c) => sh.cells.set(at, c), @@ -265,15 +264,16 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result { for (ci, v) in cols.iter().enumerate() { let Some(at) = start.offset(ri as i64, ci as i64) else { continue }; let input = json_to_input(v); - let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(at)).cloned(); let cell = match v { Json::Number(n) => Some(Cell { value: Value::number(n.as_f64().unwrap_or(0.0)), formula: None, - style: old.as_ref().map(|c| c.style).unwrap_or_default(), + style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(), }), - Json::Bool(b) => Some(Cell { value: Value::Bool(*b), formula: None, style: old.as_ref().map(|c| c.style).unwrap_or_default() }), - _ => input_to_cell(&input, old.as_ref(), &mut cx.wb)?, + Json::Bool(b) => { + Some(Cell { value: Value::Bool(*b), formula: None, style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default() }) + } + _ => input_to_cell(&input, sheet, at, &mut cx.wb)?, }; let sh = cx.sheet_mut(sheet)?; match cell { @@ -297,8 +297,7 @@ fn range_fill(s: &mut Session, p: &Json) -> Result { let input = str_param(p, "input").unwrap_or("").to_string(); let origin = s.doc()?.selection.active; edit(s, |cx| { - let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(origin)).cloned(); - let base = input_to_cell(&input, old.as_ref(), &mut cx.wb)?; + let base = input_to_cell(&input, sheet, origin, &mut cx.wb)?; for r in &ranges { if r.count() > 2_000_000 { return Err(bad("range.fill", "range too large")); @@ -665,8 +664,7 @@ fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result { for (ri, row) in rows.iter().enumerate() { for (ci, v) in row.iter().enumerate() { let Some(c) = at.offset(ri as i64, ci as i64) else { continue }; - let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(c)).cloned(); - let cell = input_to_cell(v, old.as_ref(), &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str())))); + let cell = input_to_cell(v, sheet, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str())))); let sh = cx.sheet_mut(sheet)?; match cell { Some(cell) => sh.cells.set(c, cell), @@ -1234,8 +1232,7 @@ fn replace(s: &mut Session, p: &Json) -> Result { } else { replace_ci(&text, &what, &with) }; - let old = cx.wb.sheet(si).and_then(|sh| sh.cell(c)).cloned(); - let cell = input_to_cell(&new, old.as_ref(), &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(new.as_str())))); + let cell = input_to_cell(&new, si, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(new.as_str())))); let sh = cx.sheet_mut(si)?; match cell { Some(cell) => sh.cells.set(c, cell), diff --git a/crates/engine/src/cmd/whatif.rs b/crates/engine/src/cmd/whatif.rs index 111225d..c0a61df 100644 --- a/crates/engine/src/cmd/whatif.rs +++ b/crates/engine/src/cmd/whatif.rs @@ -489,8 +489,7 @@ fn scenario_show(s: &mut Session, p: &Json) -> Result { edit(s, |cx| { for (c, v) in cells.iter().zip(sc.values.iter()) { let input = super::edit::json_to_input(v); - let old = cx.wb.sheet(si).and_then(|sh| sh.cell(*c)).cloned(); - let cell = super::edit::input_to_cell(&input, old.as_ref(), &mut cx.wb)?; + let cell = super::edit::input_to_cell(&input, si, *c, &mut cx.wb)?; let sh = cx.sheet_mut(si)?; match cell { Some(cell) => sh.cells.set(*c, cell), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f3e36cd..443588b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -13,6 +13,77 @@ fn v(s: &Session, a: &str) -> Value { s.doc().unwrap().wb.active().unwrap().value(CellRef::parse(a).unwrap()) } +#[test] +fn entering_empty_cells_preserves_whole_sheet_formatting() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "existing"})).unwrap(); + s.execute("selection.set", json!({"range": "A1:XFD1048576"})).unwrap(); + s.execute("home.alignCenter", json!({})).unwrap(); + let border = gridcraft_model::BorderLine { style: gridcraft_model::BorderStyle::Thin, color: gridcraft_model::Color::Auto }; + s.execute("home.formatCells", json!({"style": {"borders": {"top": border, "bottom": border, "left": border, "right": border}}})).unwrap(); + let expected = { + let d = s.doc().unwrap(); + d.wb.styles.get(d.wb.active().unwrap().style_id(CellRef::parse("B1").unwrap())).clone() + }; + assert_eq!(expected.align.h, gridcraft_model::HAlign::Center); + for (cell, input) in [("A1", "edited"), ("B1", "new"), ("C1", "=1+2"), ("XFD1048576", "edge")] { + s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); + let d = s.doc().unwrap(); + assert_eq!(d.wb.styles.get(d.wb.active().unwrap().style_id(CellRef::parse(cell).unwrap())), &expected, "{cell}"); + } + s.execute("range.setValues", json!({"range": "D1", "values": [["text", 7, true]]})).unwrap(); + for cell in ["D1", "E1", "F1"] { + let d = s.doc().unwrap(); + assert_eq!(d.wb.styles.get(d.wb.active().unwrap().style_id(CellRef::parse(cell).unwrap())), &expected, "{cell}"); + } + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "D1").is_empty()); + s.execute("edit.redo", json!({})).unwrap(); + let d = s.doc().unwrap(); + assert_eq!(d.wb.styles.get(d.wb.active().unwrap().style_id(CellRef::parse("D1").unwrap())), &expected); + assert_eq!(d.wb.active().unwrap().cells.len(), 7); + let (reopened, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&d.wb).unwrap()).unwrap(); + for cell in ["A1", "B1", "C1", "D1", "E1", "F1", "G1", "XFD1048576"] { + assert_eq!( + reopened.styles.get(reopened.active().unwrap().style_id(CellRef::parse(cell).unwrap())), + &expected, + "{cell} after XLSX round trip" + ); + } +} + +#[test] +fn entry_and_text_paste_use_inherited_formats_and_cell_overrides() { + let mut s = s(); + s.execute("home.bold", json!({"range": "B:B", "on": true})).unwrap(); + s.execute("home.formatCells", json!({"range": "3:3", "style": {"num_fmt": "@", "font": {"italic": true}}})).unwrap(); + s.execute("home.fontColor", json!({"range": "B3", "color": "#FF0000"})).unwrap(); + let expected = { + let d = s.doc().unwrap(); + d.wb.styles.get(d.wb.active().unwrap().style_id(CellRef::parse("B3").unwrap())).clone() + }; + s.execute("cell.set", json!({"cell": "B3", "input": "=1+2"})).unwrap(); + s.execute("cell.set", json!({"cell": "C3", "input": "00123"})).unwrap(); + s.execute("edit.paste", json!({"at": "D3", "text": "=2+3\t00456"})).unwrap(); + assert_eq!(v(&s, "B3"), Value::text("=1+2")); + assert_eq!(v(&s, "C3"), Value::text("00123")); + assert_eq!(v(&s, "D3"), Value::text("=2+3")); + assert_eq!(v(&s, "E3"), Value::text("00456")); + s.execute("cell.set", json!({"cell": "B4", "input": "12%"})).unwrap(); + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("B3").unwrap())), &expected); + for cell in ["C3", "D3", "E3"] { + let st = d.wb.styles.get(sh.style_id(CellRef::parse(cell).unwrap())); + assert!(st.font.italic); + assert!(!st.font.bold, "row style takes precedence over column style"); + assert_eq!(st.num_fmt.as_str(), "@"); + } + let percent = d.wb.styles.get(sh.style_id(CellRef::parse("B4").unwrap())); + assert!(percent.font.bold); + assert_ne!(percent.num_fmt.as_str(), "General"); +} + #[test] fn enter_values_and_formulas() { let mut s = s(); From 96b3ab324471e4dd2bb5d31f48cbd5f406796547 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Fri, 9 Oct 2026 14:37:52 -0600 Subject: [PATCH 16/35] M2: document the supported offscreen snapshot example (#71) Point both contributor instruction files to the UI crate's snapshot example and identify it as a command to run from a source checkout. Verified the documented command produces a readable sales dashboard PNG. Co-authored-by: Codex --- AGENTS.md | 2 +- CLAUDE.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 467a9e0..aaf3448 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr ## Running and looking at the app - `cargo run --release -p gridcraft -- --sample sales --control 7979` (sample workbook + control channel). - Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`. -- **Offscreen UI render** (no window): `cargo run --release -p gridcraft-cli -- snapshot --sample sales out.png`. +- **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`. - Headless: `gridcraft-cli eval '=SUM(1,2,3)'`, `gridcraft-cli run --in book.xlsx --cmd 'home.bold={"range":"A1:C1"}' --out book.xlsx`, `gridcraft-cli mcp`. - **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`. - Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`. macOS has no `timeout`; use `perl -e 'alarm 60; exec @ARGV' cmd`. diff --git a/CLAUDE.md b/CLAUDE.md index ff12ac8..54e9d03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr ## Running and looking at the app - `cargo run --release -p gridcraft -- --sample sales --control 7979` (sample workbook + control channel). - Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`. -- **Offscreen UI render** (no window): `cargo run --release -p gridcraft-cli -- snapshot --sample sales out.png`. +- **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`. - Headless: `gridcraft-cli eval '=SUM(1,2,3)'`, `gridcraft-cli run --in book.xlsx --cmd 'home.bold={"range":"A1:C1"}' --out book.xlsx`, `gridcraft-cli mcp`. - **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`. - Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`. macOS has no `timeout`; use `perl -e 'alarm 60; exec @ARGV' cmd`. From 165e6c8a3bfb8888af9432bb2e1b642c1fc37609 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Fri, 9 Oct 2026 14:46:29 -0600 Subject: [PATCH 17/35] M2: add a vertical worksheet scrollbar (#73) Reserve a strip beside the virtual grid for a theme-aware vertical scrollbar. Support paging, dragging and wheel scrolling without changing the active cell. Keep the scroll extent fixed during thumb dragging. Co-authored-by: Codex --- crates/ui-egui/src/grid.rs | 53 ++++++++++++++++++++++++++++- crates/ui-egui/tests/interaction.rs | 32 +++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 4b326cc..e53ea02 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -312,7 +312,8 @@ fn display_text( pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let rect = ui.available_rect_before_wrap(); + let available = ui.available_rect_before_wrap(); + let rect = Rect::from_min_max(available.min, pos2((available.right() - 14.0).max(available.left()), available.bottom())); let resp = ui.allocate_rect(rect, Sense::click_and_drag()); app.grid.rect = Some(rect); let Some(d) = app.session.active() else { return }; @@ -424,12 +425,62 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { list_picker(app, ui, &geo); header_menu(app, ui); context_menu(app, ui); + vertical_scrollbar(app, ui, sh, &geo, available.right(), max_y, &t); if app.session.clipboard.is_some() { app.grid.marching_phase = (app.grid.marching_phase + 0.5) % 8.0; ui.ctx().request_repaint_after(std::time::Duration::from_millis(60)); } } +/// The worksheet is painted virtually, so it needs its own scrollbar rather than a +/// ScrollArea containing all million rows. Keep the scroll extent stable during a drag. +fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &Geo, right: f32, limit: f32, t: &Tokens) { + let track = Rect::from_min_max(pos2(geo.rect.right(), geo.cells.top() + geo.frozen_h), pos2(right, geo.cells.bottom())); + if track.height() <= 0.0 || track.width() <= 0.0 { + return; + } + let visible = track.height() / geo.z; + let scroll = app.view().scroll.y; + let last = sh.used_range().map(|r| r.end.row.saturating_add(10)).unwrap_or(100).clamp(100, MAX_ROWS - 1); + let content = (sh.row_top(last) - sh.row_top(geo.fr)) as f32; + let extent = (content - visible).max(scroll).max(0.0).min(limit); + let id = ui.id().with("vscroll"); + let resp = ui.interact(track, id, Sense::click_and_drag()); + let drag_id = id.with("extent"); + if resp.drag_started() { + ui.ctx().data_mut(|d| d.insert_temp(drag_id, extent)); + } + let extent = if resp.dragged() { ui.ctx().data(|d| d.get_temp::(drag_id)).unwrap_or(extent) } else { extent }; + let thumb_h = (track.height() * visible / (extent + visible)).clamp(24.0_f32.min(track.height()), track.height()); + let travel = track.height() - thumb_h; + let thumb_top = track.top() + if extent > 0.0 { travel * (scroll / extent).clamp(0.0, 1.0) } else { 0.0 }; + let thumb = Rect::from_min_size(pos2(track.left() + 3.0, thumb_top), vec2((track.width() - 6.0).max(0.0), thumb_h)); + ui.painter().rect_filled(track, 0.0, t.tab_bar); + ui.painter().rect_filled(thumb, 4.0, if resp.hovered() || resp.dragged() { t.text_dim } else { t.text_disabled }); + let mut next = scroll; + if resp.dragged() && travel > 0.0 { + next += resp.drag_delta().y / travel * extent; + } else if resp.clicked() + && let Some(p) = resp.interact_pointer_pos() + { + if p.y < thumb.top() { + next -= visible; + } else if p.y > thumb.bottom() { + next += visible; + } + } + if resp.hovered() { + next -= ui.input(|i| i.smooth_scroll_delta.y) / geo.z; + } + next = next.clamp(0.0, limit); + if next != scroll { + if let Some(v) = app.view_mut() { + v.scroll.y = next; + } + ui.ctx().request_repaint(); + } +} + #[allow(clippy::too_many_arguments)] fn paint_quadrant( p: &Painter, diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 74bbeb4..7016ae2 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -25,6 +25,38 @@ fn blank() -> Session { s } +#[test] +fn vertical_scrollbar_pages_and_drags_without_changing_selection() { + let mut h = harness(blank()); + let grid = h.state().grid.rect.unwrap(); + let cells = h.state().grid.cells_rect.unwrap(); + let p = egui::pos2(grid.right() + 7.0, cells.bottom() - 20.0); + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(2); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert!(h.state().view().scroll.y > 0.0, "clicking the scrollbar track scrolls down"); + assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::new(0, 0)); + + h.state_mut().view_mut().unwrap().scroll.y = 0.0; + h.run_steps(2); + let start = egui::pos2(grid.right() + 7.0, cells.top() + 8.0); + h.input_mut().events.push(Event::PointerMoved(start)); + h.input_mut().events.push(Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(2); + let end = start + egui::vec2(0.0, 80.0); + h.input_mut().events.push(Event::PointerMoved(end)); + h.run_steps(2); + let dragged = h.state().view().scroll.y; + assert!(dragged > 0.0, "dragging the thumb scrolls down"); + h.run_steps(3); + assert_eq!(h.state().view().scroll.y, dragged, "holding the thumb still must not keep scrolling"); + h.input_mut().events.push(Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::new(0, 0)); +} + fn key(h: &mut egui_kittest::Harness<'static, SheetApp>, k: Key, m: Modifiers) { h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m }); h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m }); From cd083cfc5086d01dc7bf718b8a1baa47dd17e688 Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:14:59 -0500 Subject: [PATCH 18/35] XLSX: cache #SPILL! and #CALC! as #VALUE! They are not error codes of the file format, and Excel refuses to open a file whose cached values contain them ("Parameter error"). Any workbook with a blocked spill could not be opened in Excel. Excel caches these errors as #VALUE! itself. --- crates/xlsx/src/sheet_write.rs | 6 ++++++ crates/xlsx/src/tests/roundtrip.rs | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index 94d9b18..86999d1 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -555,7 +555,13 @@ fn write_cell( }; let value = match value { Value::Array(a) => a.get(0, 0).cloned().unwrap_or(Value::Empty), + v => v, + }; + let value = match value { Value::Error(CellError::Circ) => Value::Number(0.0), + // #SPILL! and #CALC! are not error codes of the file format (Excel refuses a file that + // caches them); Excel itself caches them as #VALUE!. + Value::Error(CellError::Spill | CellError::Calc) => Value::Error(CellError::Value), v => v, }; if formula.is_some() && dynamic.is_some() { diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index bfc83b9..c70942f 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -563,3 +563,27 @@ fn writing_repairs_invalid_models() { assert_eq!(s.tables[0].name, "Table1"); assert_eq!(s.cell(at("B2")).unwrap().style, StyleId::DEFAULT); } + +fn sheet1_xml(bytes: &[u8]) -> (String, bool) { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut s = String::new(); + z.by_name("xl/worksheets/sheet1.xml").unwrap().read_to_string(&mut s).unwrap(); + let metadata = z.by_name("xl/metadata.xml").is_ok(); + (s, metadata) +} + +/// Excel refuses to open a file whose cached values include `#SPILL!` or `#CALC!`. +#[test] +fn spill_and_calc_errors_are_cached_as_value_errors() { + let mut wb = Workbook::new(); + let s = wb.sheet_mut(0).unwrap(); + s.set_value(at("A1"), Value::Number(1.0)); + s.cells.set(at("C1"), Cell { value: Value::Error(CellError::Spill), ..Cell::formula(Formula::new("A1:A3*2")) }); + s.cells.set(at("C2"), Cell { value: Value::Error(CellError::Calc), ..Cell::formula(Formula::new("FILTER(A1:A3,A1:A3>5)")) }); + s.cells.set(at("C3"), Cell { value: Value::Error(CellError::NA), ..Cell::formula(Formula::new("NA()")) }); + let (xml, _) = sheet1_xml(&write_xlsx(&wb).unwrap()); + assert!(!xml.contains("#SPILL!") && !xml.contains("#CALC!"), "{xml}"); + assert!(xml.contains(r#"A1:A3*2#VALUE!"#), "{xml}"); + assert!(xml.contains(r#"_xlfn._xlws.FILTER(A1:A3,A1:A3>5)#VALUE!"#), "{xml}"); + assert!(xml.contains(r#"NA()#N/A"#), "{xml}"); +} From 311b1a593fdfc447f05235882a09ae64faf69f60 Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:14:59 -0500 Subject: [PATCH 19/35] Contributors: add nihilist-narwhal --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 89c6db2..2e6b83b 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -21,3 +21,7 @@ real_name = "Brandon Thomas" display_name = "Brandon Thomas" sync_github_name = false + +[people.nihilist-narwhal] +display_name = "nihilist-narwhal" +sync_github_name = false From 2b1da77e4686200db9a661d4b2cd1aa035672e70 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 9 Oct 2026 20:04:21 -0400 Subject: [PATCH 20/35] chore: bump github actions to fix Node 20 deprecation warnings --- .github/workflows/freebsd.yml | 9 +++-- .github/workflows/packaging-lint.yml | 2 +- .github/workflows/release.yml | 54 +++++++++++++--------------- .github/workflows/windows-arm64.yml | 10 +++--- 4 files changed, 35 insertions(+), 40 deletions(-) diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index b8efbdc..0a82a91 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -51,16 +51,15 @@ jobs: GRIDCRAFT_PACKAGE: ${{ inputs.package && '1' || '' }} GRIDCRAFT_BUILD_SHA: ${{ github.sha }} steps: - - uses: actions/checkout@v4 - # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases embed it. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check out craft-fonts - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: abb83316d96aa59c1cf64784289e378fe9fa5695 path: craft-fonts - name: Build in a FreeBSD VM - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1 + uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9 with: release: "14.3" usesh: true @@ -88,7 +87,7 @@ jobs: cargo build --workspace --all-targets --locked CRAFT_FONTS_DIR="$PWD/craft-fonts" cargo test --workspace --locked fi - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: inputs.package with: name: release-freebsd diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index ffd5fe7..e9927c5 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -23,7 +23,7 @@ jobs: env: ACTIONLINT_VERSION: 1.7.12 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install tools run: | sudo apt-get update diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de15fd8..ca270a6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,7 +45,7 @@ jobs: date: ${{ steps.v.outputs.date }} prerelease: ${{ steps.v.outputs.prerelease }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - id: v name: Resolve version run: | @@ -73,18 +73,17 @@ jobs: GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }} GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - - uses: actions/checkout@v4 - # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check out craft-fonts - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: abb83316d96aa59c1cf64784289e378fe9fa5695 path: craft-fonts - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: targets: aarch64-apple-darwin, x86_64-apple-darwin - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: release-macos-universal - name: Apply version override @@ -104,7 +103,7 @@ jobs: APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: packaging/macos/package.sh --arch universal - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-macos path: dist/release/* @@ -132,18 +131,17 @@ jobs: GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }} GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - - uses: actions/checkout@v4 - # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check out craft-fonts - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: abb83316d96aa59c1cf64784289e378fe9fa5695 path: craft-fonts - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: targets: ${{ matrix.target }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: release-windows-${{ matrix.arch }} - name: Apply version override @@ -167,7 +165,7 @@ jobs: AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-windows-${{ matrix.arch }} path: dist/release/* @@ -191,10 +189,9 @@ jobs: GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} NFPM_VERSION: 2.47.0 steps: - - uses: actions/checkout@v4 - # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check out craft-fonts - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: abb83316d96aa59c1cf64784289e378fe9fa5695 @@ -208,8 +205,8 @@ jobs: curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \ "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb" sudo dpkg -i "$RUNNER_TEMP/nfpm.deb" - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: release-linux-${{ matrix.arch }} - name: Apply version override @@ -233,7 +230,7 @@ jobs: "$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|' test -s "$appimage.zsync" done - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-linux-${{ matrix.arch }} path: dist/release/* @@ -256,8 +253,8 @@ jobs: GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }} GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: release-linux-${{ matrix.arch }} path: dist/linux @@ -271,7 +268,7 @@ jobs: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - name: Build the Flatpak bundle run: packaging/linux/flatpak-bundle.sh "dist/linux/gridcraft-$GRIDCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-flatpak-${{ matrix.arch }} path: dist/release/*.flatpak @@ -287,18 +284,17 @@ jobs: GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} TRUNK_VERSION: 0.21.14 steps: - - uses: actions/checkout@v4 - # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check out craft-fonts - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: abb83316d96aa59c1cf64784289e378fe9fa5695 path: craft-fonts - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: release-web - name: Install trunk @@ -314,7 +310,7 @@ jobs: CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts CRAFT_FONTS_REQUIRED: "1" run: packaging/web/package.sh - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-web path: dist/release/* @@ -348,7 +344,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: release-* path: artifacts diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 927ce1d..92239fa 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -28,11 +28,11 @@ jobs: runs-on: windows-latest timeout-minutes: 90 steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: targets: aarch64-pc-windows-msvc - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: windows-arm64-package - name: Install WiX @@ -43,7 +43,7 @@ jobs: env: DIST: ${{ github.workspace }}\dist\arm64 run: ./packaging/windows/package.ps1 -Arch arm64 - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: gridcraft-windows-arm64 path: dist/arm64/* @@ -56,7 +56,7 @@ jobs: runs-on: windows-11-arm timeout-minutes: 30 steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: gridcraft-windows-arm64 path: dist From 698a2a8009b5fa9dcb23f539b50d0bbee7ed54fe Mon Sep 17 00:00:00 2001 From: harrisonoconnorhover <130776285+harrisonoconnorhover@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:26:06 -0400 Subject: [PATCH 21/35] Keep keyboard navigation separate from pointer clicks (#87) --- crates/ui-egui/src/grid.rs | 16 ++++---- crates/ui-egui/tests/interaction.rs | 57 ++++++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 4b326cc..5ae1beb 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -983,6 +983,8 @@ fn paint_headers(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Sel fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) { let ctx = ui.ctx().clone(); + // Enter/Space synthesize egui clicks; only real pointer clicks use sheet coordinates. + let primary_clicked = resp.clicked_by(egui::PointerButton::Primary); let pointer = ctx.input(|i| i.pointer.clone()); let mods = ctx.input(|i| i.modifiers); let pos = pointer.interact_pos().or(pointer.hover_pos()); @@ -1048,7 +1050,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } return; } - if app.session.draw_tool == "eraser" && (resp.clicked() || resp.dragged()) { + if app.session.draw_tool == "eraser" && (primary_clicked || resp.dragged()) { if let Some(p) = pos && let Some(("shape", id, _)) = crate::chartview::hit(app, geo, sh, p) && sh.shapes.iter().any(|s| s.id == id && s.kind == gridcraft_engine::model::ShapeKind::Ink) @@ -1059,7 +1061,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } } // Objects (charts, pictures, shapes) take clicks first. - if resp.drag_started() || resp.clicked() { + if resp.drag_started() || primary_clicked { if let Some(p) = pos && let Some((kind, id, rect)) = crate::chartview::hit(app, geo, sh, p) { @@ -1075,13 +1077,13 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } } return; - } else if resp.clicked() || resp.drag_started() { + } else if primary_clicked || resp.drag_started() { app.selected_chart = None; } } // Press: decide the drag mode. - if resp.drag_started() || (resp.clicked() && app.grid.drag == Drag::None) { + if resp.drag_started() || (primary_clicked && app.grid.drag == Drag::None) { // Where the press began (a drag is only recognised after the pointer has moved). let Some(p) = pointer.press_origin().or(pos) else { return }; if in_corner { @@ -1177,12 +1179,12 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & let _ = app.session.run("selection.set", json!({"range": ranges.join(","), "active": c.a1()})); } else { let _ = app.session.run("selection.set", json!({"cell": c.a1()})); - if resp.clicked() && wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" { + if primary_clicked && wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" { let _ = app.run("cell.toggleCheckbox", json!({"cell": c.a1()})); } // A click on a hyperlink's text follows it (like Excel); elsewhere in the cell selects. if let Some(h) = sh.hyperlinks.get(&c).cloned() - && resp.clicked() + && primary_clicked && !mods.any() { let r = geo.cell_rect(sh, c); @@ -1386,7 +1388,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } } - if resp.clicked() || resp.drag_started() { + if primary_clicked || resp.drag_started() { resp.request_focus(); } keyboard(app, &ctx, resp, geo, sh); diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 74bbeb4..82f3d50 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -1,9 +1,9 @@ //! UI interaction tests: real egui input through the whole app (no GPU needed). -use egui::{Event, Key, Modifiers}; +use egui::{Event, Key, Modifiers, PointerButton}; use gridcraft_engine::Session; use gridcraft_engine::core::{CellRef, Value}; -use gridcraft_ui_egui::SheetApp; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; use serde_json::json; fn harness(session: Session) -> egui_kittest::Harness<'static, SheetApp> { @@ -40,6 +40,59 @@ fn value(h: &egui_kittest::Harness<'static, SheetApp>, a: &str) -> Value { h.state().session.active().and_then(|d| d.wb.active().map(|s| s.value(CellRef::parse(a).unwrap_or_default()))).unwrap_or_default() } +fn click_cell(h: &mut egui_kittest::Harness<'static, SheetApp>, cell: &str) { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + let pos = geo.cell_rect(sheet, CellRef::parse(cell).unwrap()).center(); + h.input_mut().events.extend([ + Event::PointerMoved(pos), + Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + ]); + h.run_steps(2); +} + +#[test] +fn keyboard_navigation_does_not_replay_the_last_pointer_click() { + let mut h = harness(blank()); + click_cell(&mut h, "B2"); + for (key_code, modifiers, expected) in [ + (Key::Enter, Modifiers::NONE, "B3"), + (Key::Enter, Modifiers::NONE, "B4"), + (Key::Enter, Modifiers::NONE, "B5"), + (Key::Enter, Modifiers::SHIFT, "B4"), + (Key::Tab, Modifiers::NONE, "C4"), + (Key::Enter, Modifiers::NONE, "C5"), + ] { + key(&mut h, key_code, modifiers); + let selection = &h.state().session.active().unwrap().selection; + assert_eq!(selection.active.a1(), expected); + assert_eq!(selection.anchor, selection.active); + assert!(h.state().editor.is_none(), "navigation must not start editing at the old pointer location"); + } + // A subsequent real pointer click must still select the clicked cell. + click_cell(&mut h, "D6"); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "D6"); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "D7"); +} + +#[test] +fn space_toggles_the_active_checkbox_without_clicking_the_hovered_cell() { + let mut session = blank(); + session.execute("insert.checkbox", json!({"range": "A1:B1"})).unwrap(); + let mut h = harness(session); + click_cell(&mut h, "A1"); + assert_eq!(value(&h, "A1"), Value::Bool(true)); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + key(&mut h, Key::Space, Modifiers::NONE); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "B1"); + assert_eq!(value(&h, "A1"), Value::Bool(true), "the old pointer location must remain unchanged"); + assert_eq!(value(&h, "B1"), Value::Bool(true)); + assert!(h.state().editor.is_none()); +} + #[test] fn typing_enters_values_and_moves_down() { let mut h = harness(blank()); From 91d44508b177d16c682ce14ef05fa43124d932f7 Mon Sep 17 00:00:00 2001 From: harrisonoconnorhover <130776285+harrisonoconnorhover@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:27:18 -0400 Subject: [PATCH 22/35] Correct screenshot timeout recovery advice (#78) --- crates/ui-egui/src/control.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index a80448a..7e3aca8 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -403,7 +403,7 @@ pub fn collect_screenshots(app: &mut SheetApp, ctx: &egui::Context) { if now < *deadline { return true; } - let _ = reply.send(json!({"ok": false, "error": "no frame was presented (screen locked or window hidden); use `gridcraft-cli snapshot`"})); + let _ = reply.send(json!({"ok": false, "error": "no frame was presented before the screenshot timeout; make the window visible, use `ui.focus`, then retry `ui.screenshot`"})); false }); } From 922847a1ed3adb854c4718653b39d435d40dc4b0 Mon Sep 17 00:00:00 2001 From: harrisonoconnorhover <130776285+harrisonoconnorhover@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:35:21 -0400 Subject: [PATCH 23/35] Add system-following interface appearance (#72) --- apps/gridcraft/src/native_menu.rs | 55 ++++++++- crates/ui-egui/src/control.rs | 2 + crates/ui-egui/src/lib.rs | 49 +++++++- crates/ui-egui/src/ribbon.rs | 18 ++- crates/ui-egui/src/theme.rs | 44 ++++---- crates/ui-egui/tests/system_theme.rs | 163 +++++++++++++++++++++++++++ docs/control-protocol.md | 6 + 7 files changed, 308 insertions(+), 29 deletions(-) create mode 100644 crates/ui-egui/tests/system_theme.rs diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs index 43e57a4..260d900 100644 --- a/apps/gridcraft/src/native_menu.rs +++ b/apps/gridcraft/src/native_menu.rs @@ -6,7 +6,7 @@ use std::sync::mpsc::{Receiver, channel}; use gridcraft_ui_egui::SheetApp; use muda::accelerator::Accelerator; -use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; +use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; use serde_json::{Value, json}; type Entry = (&'static str, &'static str, Option<&'static str>); @@ -78,7 +78,7 @@ fn tree() -> Vec<(&'static str, Vec)> { ("Freeze Top Row", "view.freezeTopRow", None), ("Unfreeze Panes", "view.unfreezePanes", None), ("-", "", None), - ("Dark Mode", "view.darkMode", None), + ("Display Theme", "view.theme", None), ("Collapse Ribbon", "view.collapseRibbon", Some("CMD+ALT+R")), ], ), @@ -181,6 +181,18 @@ fn tree() -> Vec<(&'static str, Vec)> { pub struct NativeMenu { _menu: Menu, events: Receiver, + theme_items: Vec<(&'static str, CheckMenuItem)>, +} + +fn select_theme(app: &mut SheetApp, id: &str) -> bool { + let mode = match id { + "theme:system" => "system", + "theme:light" => "light", + "theme:dark" => "dark", + _ => return false, + }; + app.run_or_alert("view.theme", json!({"mode": mode})); + true } impl NativeMenu { @@ -200,6 +212,7 @@ impl NativeMenu { &PredefinedMenuItem::quit(None), ]); let _ = menu.append(&app_menu); + let mut theme_items = Vec::new(); for (title, entries) in tree() { let sub = Submenu::new(title, true); for (label, id, acc) in entries { @@ -207,6 +220,16 @@ impl NativeMenu { let _ = sub.append(&PredefinedMenuItem::separator()); continue; } + if id == "view.theme" { + let themes = Submenu::new(label, true); + for (mode, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] { + let item = CheckMenuItem::with_id(format!("theme:{mode}"), label, true, false, None); + let _ = themes.append(&item); + theme_items.push((mode, item)); + } + let _ = sub.append(&themes); + continue; + } let accel = acc.and_then(|a| Accelerator::from_str(a).ok()); let item = MenuItem::with_id(id, label, true, accel); let _ = sub.append(&item); @@ -220,12 +243,15 @@ impl NativeMenu { let _ = tx.send(e); ctx.request_repaint(); })); - NativeMenu { _menu: menu, events: rx } + NativeMenu { _menu: menu, events: rx, theme_items } } pub fn poll(&self, app: &mut SheetApp, ctx: &egui::Context) { while let Ok(ev) = self.events.try_recv() { let id = ev.id.as_ref().to_string(); + if select_theme(app, &id) { + continue; + } if let Some(url) = id.strip_prefix("url:") { if let Some(open) = &app.services.open_url { open(url); @@ -252,5 +278,28 @@ impl NativeMenu { app.run_or_alert(&id, json!({})); } } + for (mode, item) in &self.theme_items { + let checked = app.ui.theme_mode() == *mode; + if item.is_checked() != checked { + item.set_checked(checked); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn theme_choices_use_the_shared_preference_command() { + let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default()); + for mode in ["dark", "system", "light"] { + assert!(select_theme(&mut app, &format!("theme:{mode}"))); + assert_eq!(app.ui.theme_mode(), mode); + assert!(app.message.is_none()); + } + assert!(!select_theme(&mut app, "view.formulaBar")); + assert_eq!(app.ui.theme_mode(), "light"); } } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index a80448a..09c046b 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -160,6 +160,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - ("view.formulaBar", "Formula Bar"), ("view.collapseRibbon", "Collapse Ribbon"), ("view.darkMode", "Dark Mode"), + ("view.theme", "Display Theme"), ("view.zoom100", "100%"), ("ui.dialog", "Open Dialog"), ] { @@ -178,6 +179,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - "ui.set" => { if let Some(v) = p.get("dark").and_then(Json::as_bool) { app.ui.dark = v; + app.ui.system_theme = false; } if let Some(v) = p.get("formulaBar").and_then(Json::as_bool) { app.ui.formula_bar = v; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8eb1eca..a21c779 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -34,7 +34,9 @@ pub use control::{ControlRequest, ControlResponse}; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct UiState { + /// The manual preference, retained when following the system appearance. pub dark: bool, + pub system_theme: bool, pub ribbon_tab: String, pub ribbon_collapsed: bool, pub formula_bar: bool, @@ -47,6 +49,7 @@ impl Default for UiState { fn default() -> Self { UiState { dark: false, + system_theme: false, ribbon_tab: "Home".into(), ribbon_collapsed: false, formula_bar: true, @@ -57,6 +60,28 @@ impl Default for UiState { } } +impl UiState { + pub fn theme_preference(&self) -> egui::ThemePreference { + if self.system_theme { + egui::ThemePreference::System + } else if self.dark { + egui::ThemePreference::Dark + } else { + egui::ThemePreference::Light + } + } + + pub fn theme_mode(&self) -> &'static str { + if self.system_theme { + "system" + } else if self.dark { + "dark" + } else { + "light" + } + } +} + /// Per-sheet view state (scroll position in sheet points). #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct SheetView { @@ -104,6 +129,7 @@ pub struct SheetApp { pub perf: Perf, pub fonts_ready: bool, fonts_set: bool, + effective_dark: bool, pub name_box: Option, pub(crate) shots: control::Shots, /// Chart selected on the sheet (id). @@ -133,6 +159,7 @@ impl SheetApp { perf: Perf::default(), fonts_ready: false, fonts_set: false, + effective_dark: false, name_box: None, shots: control::Shots::default(), selected_chart: None, @@ -183,9 +210,22 @@ impl SheetApp { Ok(Json::Null) } "view.darkMode" => { - self.ui.dark = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.dark); + let current = if self.ui.system_theme { self.effective_dark } else { self.ui.dark }; + self.ui.dark = p.get("on").and_then(Json::as_bool).unwrap_or(!current); + self.ui.system_theme = false; Ok(json!({"on": self.ui.dark})) } + "view.theme" => { + match p.get("mode").and_then(Json::as_str) { + Some("system") => self.ui.system_theme = true, + Some(mode @ ("light" | "dark")) => { + self.ui.dark = mode == "dark"; + self.ui.system_theme = false; + } + _ => return Some(Err("theme mode must be system, light, or dark".into())), + } + Ok(json!({"mode": self.ui.theme_mode()})) + } "view.zoom100" => return Some(self.session.run("view.zoom", json!({"percent": 100})).inspect(|_| self.after_engine())), "ui.dialog" => { let name = p.get("name").and_then(Json::as_str).unwrap_or(""); @@ -362,9 +402,12 @@ impl SheetApp { ctx.request_repaint(); } } - if ctx.global_style().visuals.dark_mode != self.ui.dark { - theme::apply(ctx, self.ui.dark); + let preference = self.ui.theme_preference(); + if ctx.options(|o| o.theme_preference) != preference { + ctx.set_theme(preference); + ctx.request_repaint(); } + self.effective_dark = ctx.theme() == egui::Theme::Dark; control::poll(self, ctx); // Files read asynchronously (web file picker, dropped files). let arrived: Vec<(String, Vec)> = self diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 47c5432..cdbb927 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1565,11 +1565,21 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { ], ); }); - let mut dark = app.ui.dark; ui.vertical(|ui| { - if ui.checkbox(&mut dark, "Dark Mode").changed() { - app.ui.dark = dark; - } + ui.label("Display theme"); + let mode = app.ui.theme_mode(); + let label = match mode { + "system" => "System", + "dark" => "Dark", + _ => "Light", + }; + egui::ComboBox::from_id_salt("display_theme").selected_text(label).width(88.0).show_ui(ui, |ui| { + for (value, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] { + if ui.selectable_label(mode == value, label).clicked() { + act(app, "view.theme", json!({"mode": value})); + } + } + }); }); } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 2dbd8df..290e5b3 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -317,25 +317,31 @@ pub fn font_definitions() -> FontDefinitions { } pub fn apply(ctx: &egui::Context, dark: bool) { - let t = if dark { Tokens::dark() } else { Tokens::light() }; - let mut v = if dark { Visuals::dark() } else { Visuals::light() }; - v.panel_fill = t.window; - v.window_fill = t.menu_bg; - v.extreme_bg_color = t.input_bg; - v.selection.bg_fill = t.accent_soft; - v.selection.stroke = Stroke::new(1.0, t.accent); - v.hyperlink_color = t.accent; - v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, t.text); - v.widgets.inactive.fg_stroke = Stroke::new(1.0, t.text); - v.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT; - v.widgets.hovered.weak_bg_fill = t.hover; - v.widgets.active.weak_bg_fill = t.pressed; - v.widgets.inactive.bg_stroke = Stroke::new(1.0, t.input_border); - v.window_corner_radius = egui::CornerRadius::same(10); - v.menu_corner_radius = egui::CornerRadius::same(8); - v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 16, spread: 0, color: t.shadow }; - ctx.set_visuals(v); - ctx.global_style_mut(|s| { + // Install both palettes before System can switch between the style slots. + for theme in [egui::Theme::Light, egui::Theme::Dark] { + let dark = theme == egui::Theme::Dark; + let t = if dark { Tokens::dark() } else { Tokens::light() }; + let mut v = if dark { Visuals::dark() } else { Visuals::light() }; + v.panel_fill = t.window; + v.window_fill = t.menu_bg; + v.extreme_bg_color = t.input_bg; + v.selection.bg_fill = t.accent_soft; + v.selection.stroke = Stroke::new(1.0, t.accent); + v.hyperlink_color = t.accent; + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, t.text); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, t.text); + v.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT; + v.widgets.hovered.weak_bg_fill = t.hover; + v.widgets.active.weak_bg_fill = t.pressed; + v.widgets.inactive.bg_stroke = Stroke::new(1.0, t.input_border); + v.window_corner_radius = egui::CornerRadius::same(10); + v.menu_corner_radius = egui::CornerRadius::same(8); + v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 16, spread: 0, color: t.shadow }; + ctx.set_visuals_of(theme, v); + } + ctx.options_mut(|o| o.fallback_theme = egui::Theme::Light); + ctx.set_theme(if dark { egui::Theme::Dark } else { egui::Theme::Light }); + ctx.all_styles_mut(|s| { s.spacing.item_spacing = egui::vec2(6.0, 4.0); s.spacing.button_padding = egui::vec2(6.0, 3.0); s.spacing.interact_size.y = 22.0; diff --git a/crates/ui-egui/tests/system_theme.rs b/crates/ui-egui/tests/system_theme.rs new file mode 100644 index 0000000..2a5365a --- /dev/null +++ b/crates/ui-egui/tests/system_theme.rs @@ -0,0 +1,163 @@ +//! Saved appearance preferences and live OS changes through the whole UI. + +use egui::{Theme, ThemePreference}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_ui_egui::{SheetApp, UiState, control, theme::Tokens}; +use serde_json::{Value, json}; + +fn harness(ui: UiState) -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + app.ui = ui; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h +} + +fn preferences(h: &egui_kittest::Harness<'static, SheetApp>) -> Value { + serde_json::to_value(&h.state().ui).unwrap() +} + +fn system_theme(h: &mut egui_kittest::Harness<'static, SheetApp>, theme: Option) { + h.input_mut().system_theme = theme; + h.run_steps(2); +} + +fn set_mode(h: &mut egui_kittest::Harness<'static, SheetApp>, mode: &str) { + let ctx = h.ctx.clone(); + let control::Outcome::Done(response) = + control::handle(h.state_mut(), &ctx, "engine.execute", &json!({"command": "view.theme", "params": {"mode": mode}})) + else { + panic!("engine.execute should return a response"); + }; + assert_eq!(response["ok"], true, "{response}"); + h.run_steps(2); +} + +fn select_ribbon_theme(h: &mut egui_kittest::Harness<'static, SheetApp>, label: &str) { + h.get_by_role(egui::accesskit::Role::ComboBox).click(); + h.run_steps(2); + h.get_by_role_and_label(egui::accesskit::Role::Button, label).click(); + h.run_steps(2); +} + +fn assert_appearance(h: &egui_kittest::Harness<'static, SheetApp>, dark: bool) { + let visuals = &h.ctx.global_style().visuals; + let tokens = if dark { Tokens::dark() } else { Tokens::light() }; + assert_eq!(visuals.dark_mode, dark); + assert_eq!(visuals.panel_fill, tokens.window); + assert_eq!(visuals.window_fill, tokens.menu_bg); + assert_eq!(visuals.selection.bg_fill, tokens.accent_soft); +} + +#[test] +fn old_preferences_stay_manual_and_system_preference_survives_reload() { + for dark in [false, true] { + let ui: UiState = serde_json::from_value(json!({"dark": dark})).unwrap(); + assert_eq!(ui.dark, dark); + let mut h = harness(ui); + assert_eq!(preferences(&h)["systemTheme"], false, "old saved settings remain manual"); + system_theme(&mut h, Some(if dark { Theme::Light } else { Theme::Dark })); + assert_appearance(&h, dark); + } + + let mut h = harness(UiState::default()); + set_mode(&mut h, "system"); + system_theme(&mut h, Some(Theme::Dark)); + let saved = serde_json::to_string(&h.state().ui).unwrap(); + let mut restored = harness(serde_json::from_str(&saved).unwrap()); + assert_eq!(preferences(&restored)["systemTheme"], true); + system_theme(&mut restored, Some(Theme::Light)); + assert_appearance(&restored, false); + system_theme(&mut restored, Some(Theme::Dark)); + assert_appearance(&restored, true); +} + +#[test] +fn system_tracks_live_os_changes_with_both_gridcraft_palettes_and_light_fallback() { + let mut h = harness(UiState::default()); + set_mode(&mut h, "system"); + assert_eq!(h.ctx.options(|o| o.theme_preference), ThemePreference::System); + // egui switches between these two style slots when the OS appearance changes. + for (theme, tokens) in [(Theme::Light, Tokens::light()), (Theme::Dark, Tokens::dark())] { + let style = h.ctx.style_of(theme); + assert_eq!(style.visuals.panel_fill, tokens.window); + assert_eq!(style.visuals.window_fill, tokens.menu_bg); + assert_eq!(style.visuals.selection.bg_fill, tokens.accent_soft); + } + for (reported, dark) in [(Some(Theme::Dark), true), (Some(Theme::Light), false), (Some(Theme::Dark), true), (None, false)] { + system_theme(&mut h, reported); + assert_appearance(&h, dark); + assert_eq!(preferences(&h)["systemTheme"], true); + assert_eq!(h.ctx.options(|o| o.theme_preference), ThemePreference::System); + } +} + +#[test] +fn ribbon_selects_system_and_fixed_modes_ignore_subsequent_os_changes() { + let mut h = harness(UiState::default()); + system_theme(&mut h, Some(Theme::Dark)); + assert_appearance(&h, false); + h.state_mut().run("ui.ribbonTab", json!({"tab": "View"})).unwrap(); + h.run_steps(2); + select_ribbon_theme(&mut h, "System"); + assert_eq!(preferences(&h)["systemTheme"], true); + assert_eq!(h.ctx.options(|o| o.theme_preference), ThemePreference::System); + assert_appearance(&h, true); + for (label, dark, preference) in [("Dark", true, ThemePreference::Dark), ("Light", false, ThemePreference::Light)] { + select_ribbon_theme(&mut h, label); + assert_eq!(preferences(&h)["systemTheme"], false); + assert_eq!(h.ctx.options(|o| o.theme_preference), preference); + for os in [Some(Theme::Dark), Some(Theme::Light), None] { + system_theme(&mut h, os); + assert_appearance(&h, dark); + } + } +} + +#[test] +fn invalid_mode_preserves_preferences_and_legacy_overrides_leave_system_mode() { + let mut h = harness(UiState::default()); + set_mode(&mut h, "system"); + system_theme(&mut h, Some(Theme::Dark)); + let before = preferences(&h); + assert!(h.state_mut().run("view.theme", json!({"mode": "sepia"})).is_err()); + h.run_steps(2); + assert_eq!(preferences(&h), before); + assert_appearance(&h, true); + + // The old toggle flips the displayed appearance, even when System chose it. + h.state_mut().run("view.darkMode", json!({})).unwrap(); + h.run_steps(2); + assert_eq!(preferences(&h)["systemTheme"], false); + assert_appearance(&h, false); + + set_mode(&mut h, "system"); + system_theme(&mut h, Some(Theme::Light)); + h.state_mut().run("view.darkMode", json!({"on": true})).unwrap(); + h.run_steps(2); + assert_eq!(preferences(&h)["systemTheme"], false); + assert_appearance(&h, true); + + set_mode(&mut h, "system"); + system_theme(&mut h, Some(Theme::Dark)); + let ctx = h.ctx.clone(); + let control::Outcome::Done(response) = control::handle(h.state_mut(), &ctx, "ui.set", &json!({"dark": false})) else { + panic!("ui.set should return a response"); + }; + assert_eq!(response["ok"], true); + h.run_steps(2); + assert_eq!(preferences(&h)["systemTheme"], false); + assert_appearance(&h, false); + system_theme(&mut h, Some(Theme::Dark)); + assert_appearance(&h, false); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 638ec21..9c6c31b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -39,5 +39,11 @@ Errors come back as `{"id": …, "ok": false, "error": "…"}`. Any other method name that is a command id (e.g. `home.bold`) runs that command. +Use `engine.execute` with `{command: "view.theme", params: {mode: "system"}}` +to choose the display theme; `mode` accepts `"system"`, `"light"`, or `"dark"`. +System follows live OS appearance changes and falls back to Light when the platform supplies no preference. +The saved choice remains System; new users still start in Light. The legacy +`view.darkMode` command and `ui.set {dark}` select a fixed Light or Dark theme. + For a headless equivalent (no window), use `gridcraft-cli mcp` or `gridcraft-cli run`; see [`mcp.md`](mcp.md) and [`cli.md`](cli.md). From daf4591da088d34bac86186851a7601f41c54036 Mon Sep 17 00:00:00 2001 From: harrisonoconnorhover <130776285+harrisonoconnorhover@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:35:53 -0400 Subject: [PATCH 24/35] Expose basic file operations in the shared ribbon (#93) --- crates/ui-egui/src/ribbon.rs | 27 +++++++++++ crates/ui-egui/tests/file_menu.rs | 76 +++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 crates/ui-egui/tests/file_menu.rs diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 47c5432..8281871 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -132,6 +132,7 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; ui.add_space(8.0); + file_menu(app, ui); let mut tabs: Vec<&str> = TABS.to_vec(); let ctx_tabs = contextual_tabs(app); tabs.extend(ctx_tabs.iter()); @@ -202,6 +203,32 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { }); } +fn file_menu(app: &mut SheetApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let label = egui::RichText::new("File").font(theme::ui_font(13.5)).color(t.accent); + let file = ui.add_sized(vec2(46.0, 30.0), egui::Button::new(label).frame(false)); + egui::Popup::menu(&file).show(|ui| { + for (label, id, needs_document) in [ + ("New Workbook", "file.new", false), + ("Open…", "file.open", false), + ("Save", "file.save", true), + ("Save As…", "file.saveAs", true), + ("Close", "file.close", true), + ] { + if id == "file.save" || id == "file.close" { + ui.separator(); + } + if ui.add_enabled(!needs_document || app.session.active().is_some(), egui::Button::new(label)).clicked() { + // File operations must include the cell the user is still editing. + if app.commit_edit(0, 0, false, false) { + app.run_or_alert(id, json!({})); + } + ui.close(); + } + } + }); +} + fn contextual_tabs(app: &SheetApp) -> Vec<&'static str> { let mut v = Vec::new(); if let Some(d) = app.session.active() diff --git a/crates/ui-egui/tests/file_menu.rs b/crates/ui-egui/tests/file_menu.rs new file mode 100644 index 0000000..5f6ca7c --- /dev/null +++ b/crates/ui-egui/tests/file_menu.rs @@ -0,0 +1,76 @@ +//! The shared ribbon exposes basic file operations without requiring a native menu. + +use std::sync::{Arc, Mutex}; + +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::{Session, core::CellRef, core::Value}; +use gridcraft_ui_egui::{Services, SheetApp}; + +fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(session, services), + ); + h.run_steps(4); + h +} + +fn choose(h: &mut egui_kittest::Harness<'static, SheetApp>, label: &str) { + h.get_by_label("File").click(); + h.run_steps(3); + h.get_by_label(label).click(); + h.run_steps(3); +} + +#[test] +fn new_and_open_are_visible_even_when_the_ribbon_is_collapsed() { + let opened = Arc::new(Mutex::new(0)); + let capture = opened.clone(); + let mut h = harness(Services { + pick_open: Some(Box::new(move || { + *capture.lock().unwrap() += 1; + None + })), + ..Default::default() + }); + h.state_mut().ui.ribbon_tab = "Data".into(); + h.state_mut().ui.ribbon_collapsed = true; + h.run_steps(2); + choose(&mut h, "New Workbook"); + assert_eq!(h.state().session.documents().len(), 2); + choose(&mut h, "Open…"); + assert_eq!(*opened.lock().unwrap(), 1, "Open invokes the platform file picker"); + assert_eq!(h.state().ui.ribbon_tab, "Data"); + assert!(h.state().ui.ribbon_collapsed); + assert!(h.query_by_label("Open…").is_none(), "choosing an action dismisses File"); +} + +#[test] +fn save_actions_include_the_cell_draft_and_close_keeps_the_unsaved_prompt() { + let downloads = Arc::new(Mutex::new(Vec::>::new())); + let capture = downloads.clone(); + let mut h = harness(Services { download: Some(Box::new(move |_, bytes| capture.lock().unwrap().push(bytes.to_vec()))), ..Default::default() }); + h.input_mut().events.push(egui::Event::Text("123".into())); + h.run_steps(2); + assert!(h.state().editor.is_some()); + choose(&mut h, "Save"); + choose(&mut h, "Save As…"); + let saved = downloads.lock().unwrap(); + assert_eq!(saved.len(), 2); + for bytes in saved.iter() { + let (wb, _) = gridcraft_engine::io::open_bytes("saved.xlsx", bytes).unwrap(); + assert_eq!(wb.active().unwrap().value(CellRef::new(0, 0)), Value::Number(123.0)); + } + drop(saved); + assert!(h.state().editor.is_none()); + choose(&mut h, "Close"); + assert_eq!(h.state().session.documents().len(), 1); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges")); + assert_eq!(h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::new(0, 0)), Value::Number(123.0)); +} From b2386e1c2380bc4f8d40605d630cee91146cef66 Mon Sep 17 00:00:00 2001 From: Harrison O'Connor-Hoover <130776285+harrisonoconnorhover@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:45:41 -0400 Subject: [PATCH 25/35] Issue 30: preview border presets and clear shared edges --- ROADMAP.md | 1 + crates/engine/src/cmd/format.rs | 27 +++++- crates/engine/src/tests.rs | 75 ++++++++++++++++ crates/ui-egui/src/border_preview.rs | 82 +++++++++++++++++ crates/ui-egui/src/dialogs.rs | 7 +- crates/ui-egui/src/grid.rs | 2 +- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/ribbon.rs | 3 +- crates/ui-egui/tests/borders.rs | 129 +++++++++++++++++++++++++++ 9 files changed, 323 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/src/border_preview.rs create mode 100644 crates/ui-egui/tests/borders.rs diff --git a/ROADMAP.md b/ROADMAP.md index 1b0cd34..4af7fb6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -86,6 +86,7 @@ by open equivalents or left out, not cloned. formats, 47 cell styles, 60 table styles, conditional formatting (cell rules, text, dates, duplicates, top/bottom, averages, data bars, colour scales, icon sets, formulas), automatic row heights, autofit, hide/unhide, freeze panes. + Border presets include visual diagrams; Format Cells previews draft borders before applying. - Data: sort (multi-level, by colour, custom lists), AutoFilter (values, custom, top 10, average, colour), tables with totals rows, remove duplicates, text to columns, data validation with error alerts, grouping/outline, subtotals. diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index d6bc2ba..657fcfa 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -365,6 +365,9 @@ fn borders(s: &mut Session, p: &Json) -> Result { let base = border_style(str_param(p, "style").unwrap_or("thin")); let sheet = target_sheet(s, p)?; let ranges = target_ranges(s, p)?; + if s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.as_ref().is_some_and(|pr| !pr.format_cells)) { + return Err(EngineError::Other("The cell or chart you're trying to change is on a protected sheet.".into())); + } edit(s, |cx| { for r in &ranges { if r.count() > 2_000_000 { @@ -453,7 +456,29 @@ fn borders(s: &mut Session, p: &Json) -> Result { } }); cx.sheet_mut(sheet)?.set_style(c, new); - // Clear the neighbour's facing edge when removing borders, so it really goes. + if preset == "none" { + // A shared line can be stored on either cell. Selected cells are handled + // by this loop; remove only facing edges outside this range. + for (boundary, dr, dc) in [(top, -1, 0), (bottom, 1, 0), (left, 0, -1), (right, 0, 1)] { + if !boundary { + continue; + } + let Some(neighbor) = c.offset(dr, dc) else { continue }; + let st = cx.wb.sheet(sheet).map(|sh| sh.style_id(neighbor)).unwrap_or_default(); + let new = cx.wb.styles.derive(st, |s| { + let edge = match (dr, dc) { + (-1, _) => &mut s.borders.bottom, + (1, _) => &mut s.borders.top, + (_, -1) => &mut s.borders.right, + _ => &mut s.borders.left, + }; + *edge = BorderLine::default(); + }); + if new != st { + cx.sheet_mut(sheet)?.set_style(neighbor, new); + } + } + } } } Ok(Json::Null) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f3e36cd..1ae8471 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -39,6 +39,81 @@ fn undo_redo() { assert!(s.doc().unwrap().is_dirty()); } +#[test] +fn no_border_clears_only_shared_edges_and_undo_restores_them() { + use gridcraft_core::RangeRef; + use gridcraft_model::{BorderLine, Borders}; + + for (range, neighbors) in [ + ("B2", &[("B1", "bottom"), ("B3", "top"), ("A2", "right"), ("C2", "left")][..]), + ( + "B2:C3", + &[("B1", "bottom"), ("C1", "bottom"), ("B4", "top"), ("C4", "top"), ("A2", "right"), ("A3", "right"), ("D2", "left"), ("D3", "left")][..], + ), + ] { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12], [13, 14, 15, 16]]})).unwrap(); + s.execute("cell.set", json!({"cell": "D4", "input": "=SUM(A1:C1)"})).unwrap(); + s.execute("home.bold", json!({"range": "A1:D4", "on": true})).unwrap(); + for preset in ["all", "diagonalDown"] { + s.execute("home.borders", json!({"range": "A1:D4", "preset": preset, "style": "double", "color": "#CC3344"})).unwrap(); + } + let before: Vec<_> = RangeRef::parse("A1:D4").unwrap().iter().map(|c| s.execute("cell.get", json!({"cell": c.a1()})).unwrap()).collect(); + s.execute("home.borders", json!({"range": range, "preset": "none"})).unwrap(); + let selected = RangeRef::parse(range).unwrap(); + for original in &before { + let address = original["cell"].as_str().unwrap(); + let mut expected = original.clone(); + if selected.contains(CellRef::parse(address).unwrap()) { + expected["style"]["borders"] = json!(Borders::default()); + } else if let Some((_, edge)) = neighbors.iter().find(|(cell, _)| *cell == address) { + expected["style"]["borders"][*edge] = json!(BorderLine::default()); + } + assert_eq!(s.execute("cell.get", json!({"cell": address})).unwrap(), expected, "clearing {range}, cell {address}"); + } + s.execute("edit.undo", json!({})).unwrap(); + for original in before { + assert_eq!(s.execute("cell.get", json!({"cell": original["cell"]})).unwrap(), original); + } + } +} + +#[test] +fn no_border_handles_sheet_corners_without_creating_out_of_bounds_cells() { + for (range, corner, neighbors) in [ + ("A1:B2", "A1", [("B1", "left"), ("A2", "top")]), + ("XFC1048575:XFD1048576", "XFD1048576", [("XFC1048576", "right"), ("XFD1048575", "bottom")]), + ] { + let mut s = s(); + s.execute("home.borders", json!({"range": range, "preset": "all"})).unwrap(); + s.execute("home.borders", json!({"range": corner, "preset": "none"})).unwrap(); + for (neighbor, edge) in neighbors { + assert_eq!(s.execute("cell.get", json!({"cell": neighbor})).unwrap()["style"]["borders"][edge]["style"], "None"); + } + let sh = s.doc().unwrap().wb.active().unwrap(); + assert!(sh.cells.iter().all(|(c, _)| c.is_valid())); + assert!(sh.cells.iter().count() <= 4); + } +} + +#[test] +fn borders_respect_sheet_formatting_protection() { + let mut s = s(); + s.execute("home.borders", json!({"range": "A1:B2", "preset": "all"})).unwrap(); + s.execute("review.protectSheet", json!({})).unwrap(); + let before = s.doc().unwrap().wb.clone(); + let undo_count = s.doc().unwrap().undo.len(); + for preset in ["none", "left"] { + assert!(s.execute("home.borders", json!({"range": "A1", "preset": preset})).is_err()); + assert_eq!(s.doc().unwrap().wb.styles, before.styles); + assert_eq!(s.doc().unwrap().wb.active().unwrap().cells, before.active().unwrap().cells); + assert_eq!(s.doc().unwrap().undo.len(), undo_count); + } + s.execute("review.protectSheet", json!({"formatCells": true})).unwrap(); + s.execute("home.borders", json!({"range": "A1", "preset": "none"})).unwrap(); + assert_eq!(s.execute("cell.get", json!({"cell": "B1"})).unwrap()["style"]["borders"]["left"]["style"], "None"); +} + #[test] fn copy_paste_shifts_formulas() { let mut s = s(); diff --git a/crates/ui-egui/src/border_preview.rs b/crates/ui-egui/src/border_preview.rs new file mode 100644 index 0000000..11ff351 --- /dev/null +++ b/crates/ui-egui/src/border_preview.rs @@ -0,0 +1,82 @@ +//! Original border diagrams, painted with the same strokes as worksheet cells. + +use egui::{Align2, Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use gridcraft_engine::model::{BorderStyle, Borders, Theme}; + +use crate::grid::paint_border; +use crate::theme::{self, Tokens}; + +/// A labelled menu button with a two-by-two diagram to distinguish inside edges +/// from the selection outline. Labels stay available to accessibility tools. +pub(crate) fn preset_button(ui: &mut Ui, label: &str, preset: &str) -> Response { + let response = ui.add(egui::Button::new("").frame(false).min_size(vec2(254.0, 26.0))); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label)); + let t = Tokens::get(ui.ctx()); + let rect = Rect::from_center_size(pos2(response.rect.left() + 16.0, response.rect.center().y), vec2(20.0, 16.0)); + let edges = [ + [rect.left_top(), rect.right_top()], + [rect.left_bottom(), rect.right_bottom()], + [rect.left_top(), rect.left_bottom()], + [rect.right_top(), rect.right_bottom()], + [rect.left_center(), rect.right_center()], + [rect.center_top(), rect.center_bottom()], + ]; + use BorderStyle::{Double, None, Thick, Thin}; + let styles = match preset { + "bottom" => [None, Thin, None, None, None, None], + "top" => [Thin, None, None, None, None, None], + "left" => [None, None, Thin, None, None, None], + "right" => [None, None, None, Thin, None, None], + "all" => [Thin; 6], + "outside" => [Thin, Thin, Thin, Thin, None, None], + "thickOutside" => [Thick, Thick, Thick, Thick, None, None], + "doubleBottom" => [None, Double, None, None, None, None], + "thickBottom" => [None, Thick, None, None, None, None], + "topBottom" => [Thin, Thin, None, None, None, None], + "topThickBottom" => [Thin, Thick, None, None, None, None], + "topDoubleBottom" => [Thin, Double, None, None, None, None], + "inside" => [None, None, None, None, Thin, Thin], + _ => [None; 6], + }; + for (points, style) in edges.into_iter().zip(styles) { + if style == None { + ui.painter().extend(egui::Shape::dotted_line(&points, t.text_disabled, 3.0, 0.45)); + } else { + paint_border(ui.painter(), points, style, t.text); + } + } + ui.painter().text(pos2(response.rect.left() + 36.0, response.rect.center().y), Align2::LEFT_CENTER, label, theme::ui_font(12.5), t.text); + response +} + +/// Preview only reads the dialog draft. A white worksheet surface keeps automatic +/// and explicit border colors faithful in both light and dark application themes. +pub(crate) fn sample(ui: &mut Ui, borders: &Borders, theme: &Theme) { + ui.add_space(8.0); + ui.label("Preview"); + let (area, response) = ui.allocate_exact_size(vec2(248.0, 104.0), Sense::hover()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, "Border preview")); + let t = Tokens::get(ui.ctx()); + ui.painter().rect_filled(area, 4.0, t.grid_bg); + ui.painter().rect_stroke(area, 4.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside); + let rect = area.shrink2(vec2(30.0, 20.0)); + let edges = [ + (borders.top, [rect.left_top(), rect.right_top()]), + (borders.bottom, [rect.left_bottom(), rect.right_bottom()]), + (borders.left, [rect.left_top(), rect.left_bottom()]), + (borders.right, [rect.right_top(), rect.right_bottom()]), + (borders.diag_down, [rect.left_top(), rect.right_bottom()]), + (borders.diag_up, [rect.left_bottom(), rect.right_top()]), + ]; + for (line, points) in edges.iter().take(4) { + if line.is_none() { + ui.painter().extend(egui::Shape::dotted_line(points, Color32::from_gray(190), 4.0, 0.5)); + } + } + for (line, points) in edges { + if !line.is_none() { + let color = line.color.resolve(theme).map(theme::color32).unwrap_or(Color32::BLACK); + paint_border(ui.painter(), points, line.style, color); + } + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b6c186d..d79254f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1087,12 +1087,17 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: (BorderStyle::Dotted, "dotted"), (BorderStyle::Double, "double"), ] { - if ui.selectable_label(slot.style == s, label).clicked() { + let response = ui.selectable_label(slot.style == s, label); + response + .widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::SelectableLabel, ui.is_enabled(), format!("{n} {label}"))); + if response.clicked() { slot.style = s; } } }); } + let theme = app.session.active().map(|doc| &doc.wb.theme).cloned().unwrap_or_default(); + crate::border_preview::sample(ui, &st.borders, &theme); } "Fill" => { let colors = app.session.active().map(|doc| doc.wb.theme.colors).unwrap_or_default(); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 4b326cc..5f861d8 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -769,7 +769,7 @@ pub fn filter_button_rect(geo: &Geo, sh: &Sheet, c: CellRef) -> Rect { Rect::from_min_size(pos2(rect.right() - s - 2.0, rect.bottom() - s - 2.0), vec2(s, s)) } -fn paint_border(p: &Painter, pts: [Pos2; 2], style: BorderStyle, col: Color32) { +pub(crate) fn paint_border(p: &Painter, pts: [Pos2; 2], style: BorderStyle, col: Color32) { let w = style.width(); let (a, b) = (pts[0], pts[1]); match style { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8eb1eca..86442bb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6,6 +6,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +mod border_preview; pub mod chartview; pub mod control; pub mod credits; diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 47c5432..825a8e7 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -352,6 +352,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { if b { act(app, "home.borders", json!({"preset": app.grid.last_border.clone()})); } + ba.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Border presets")); egui::Popup::menu(&ba).show(|ui| { for (label, preset) in [ ("Bottom Border", "bottom"), @@ -369,7 +370,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { ("Top and Double Bottom Border", "topDoubleBottom"), ("Inside Borders", "inside"), ] { - if ui.add(egui::Button::new(label).frame(false).min_size(vec2(220.0, 20.0))).clicked() { + if crate::border_preview::preset_button(ui, label, preset).clicked() { app.grid.last_border = preset.to_string(); act(app, "home.borders", json!({"preset": preset})); } diff --git a/crates/ui-egui/tests/borders.rs b/crates/ui-egui/tests/borders.rs new file mode 100644 index 0000000..af8a1a9 --- /dev/null +++ b/crates/ui-egui/tests/borders.rs @@ -0,0 +1,129 @@ +//! Border menu and dialog behavior through real egui controls. + +use egui::{Color32, Shape, vec2}; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_engine::model::{BorderLine, BorderStyle, Borders, Color}; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +fn harness() -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + session.run("range.setValues", json!({"range": "A1", "values": [[1, 2], [3, 4]]})).unwrap(); + session.run("selection.set", json!({"range": "A1:B2"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(6); + h +} + +fn borders(h: &egui_kittest::Harness<'static, SheetApp>, cell: &str) -> Borders { + let doc = h.state().session.active().unwrap(); + let sheet = doc.wb.active().unwrap(); + doc.wb.styles.get(sheet.style_id(CellRef::parse(cell).unwrap())).borders +} + +#[test] +fn diagram_menu_applies_left_right_inside_and_outside_then_undoes() { + for label in ["Left Border", "Right Border", "Inside Borders", "Outside Borders"] { + let mut h = harness(); + h.get_by_label("Border presets").click(); + h.run_steps(6); + h.get_by_label(label).click(); + h.run_steps(6); + let a = borders(&h, "A1"); + let b = borders(&h, "B2"); + match label { + "Left Border" => { + assert_eq!(a.left.style, BorderStyle::Thin); + assert!(a.right.is_none() && b.left.is_none() && b.right.is_none()); + } + "Right Border" => { + assert_eq!(b.right.style, BorderStyle::Thin); + assert!(a.left.is_none() && a.right.is_none() && b.left.is_none()); + } + "Inside Borders" => { + assert_eq!(a.right.style, BorderStyle::Thin); + assert_eq!(a.bottom.style, BorderStyle::Thin); + assert_eq!(b.left.style, BorderStyle::Thin); + assert_eq!(b.top.style, BorderStyle::Thin); + assert!(a.left.is_none() && a.top.is_none() && b.right.is_none() && b.bottom.is_none()); + } + _ => { + assert_eq!(a.left.style, BorderStyle::Thin); + assert_eq!(a.top.style, BorderStyle::Thin); + assert_eq!(b.right.style, BorderStyle::Thin); + assert_eq!(b.bottom.style, BorderStyle::Thin); + assert!(a.right.is_none() && a.bottom.is_none() && b.left.is_none() && b.top.is_none()); + } + } + let sheet = h.state().session.active().unwrap().wb.active().unwrap(); + assert_eq!(sheet.value(CellRef::new(0, 0)), Value::Number(1.0)); + assert_eq!(sheet.value(CellRef::new(1, 1)), Value::Number(4.0)); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(borders(&h, "A1"), Borders::default()); + assert_eq!(borders(&h, "B2"), Borders::default()); + } +} + +fn colored_borders() -> Borders { + let line = |color| BorderLine { style: BorderStyle::Thin, color: Color::Rgb(color) }; + Borders { + top: line(0xab192d), + bottom: line(0x174faa), + left: line(0x16864d), + right: line(0x985213), + diag_down: line(0x7c2ba2), + diag_up: line(0x248088), + } +} + +fn preview_lines(h: &egui_kittest::Harness<'static, SheetApp>, color: Color32) -> usize { + let preview = h.get_by_label("Border preview").rect(); + h.output() + .shapes + .iter() + .filter(|shape| match &shape.shape { + Shape::LineSegment { points, stroke } => stroke.color == color && points.iter().all(|p| preview.contains(*p)), + _ => false, + }) + .count() +} + +#[test] +fn border_preview_uses_the_colored_draft_until_ok_and_cancel_discards_it() { + let mut h = harness(); + let original = colored_borders(); + h.state_mut().run("home.formatCells", json!({"style": {"borders": original}})).unwrap(); + h.state_mut().open_dialog("formatCells", json!({"tab": "Border"})); + h.run_steps(6); + for line in [original.top, original.bottom, original.left, original.right, original.diag_down, original.diag_up] { + let [r, g, b] = line.color.resolve(&Default::default()).unwrap(); + assert_eq!(preview_lines(&h, Color32::from_rgb(r, g, b)), 1); + } + h.get_by_label("Top double").click(); + h.run_steps(6); + assert_eq!(borders(&h, "A1"), original); + assert_eq!(preview_lines(&h, Color32::from_rgb(0xab, 0x19, 0x2d)), 2); + h.get_by_label(" Cancel ").click(); + h.run_steps(6); + assert!(h.state().dialog.is_none()); + assert_eq!(borders(&h, "A1"), original); + h.state_mut().open_dialog("formatCells", json!({"tab": "Border"})); + h.run_steps(6); + h.get_by_label("Top double").click(); + h.run_steps(6); + h.get_by_label(" OK ").click(); + h.run_steps(6); + assert!(h.state().dialog.is_none()); + assert_eq!(borders(&h, "A1").top.style, BorderStyle::Double); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(borders(&h, "A1"), original); +} From 198e8ecb054cd968c8257192eed2702df659ba23 Mon Sep 17 00:00:00 2001 From: Harrison O'Connor-Hoover <130776285+harrisonoconnorhover@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:05:46 -0400 Subject: [PATCH 26/35] Issue #28: retain quick selection drag endpoints and clarify fill handle --- ROADMAP.md | 3 +- crates/ui-egui/src/grid.rs | 69 +++++++++---- crates/ui-egui/tests/selection.rs | 161 ++++++++++++++++++++++++++++++ 3 files changed, 212 insertions(+), 21 deletions(-) create mode 100644 crates/ui-egui/tests/selection.rs diff --git a/ROADMAP.md b/ROADMAP.md index 1b0cd34..f9fa010 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -81,7 +81,8 @@ by open equivalents or left out, not cloned. autocomplete and argument hints, copy/cut/paste and Paste Special (values, formats, transpose, operations, link, skip blanks), AutoFill series (numbers, dates, months, weekdays, custom lists, text+number), Fill Series, Flash Fill, find/replace with wildcards, Go To / Go To Special, - undo/redo with history, format painter. + undo/redo with history, format painter. Quick selection drags retain their final endpoint; + the fill handle explains how to fill versus select cells. - Formatting: fonts, fills, all border styles, alignment (wrap, indent, rotation, merge), number formats, 47 cell styles, 60 table styles, conditional formatting (cell rules, text, dates, duplicates, top/bottom, averages, data bars, colour scales, icon sets, formulas), automatic diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 4b326cc..e76e006 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1006,6 +1006,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & ctx.set_cursor_icon(CursorIcon::ResizeRow); } else if handle.contains(p) && app.editor.is_none() { ctx.set_cursor_icon(CursorIcon::Crosshair); + if app.grid.drag == Drag::None && !pointer.any_down() { + resp.clone().on_hover_text_at_pointer("Drag to fill cells. Drag from inside a cell to select a range."); + } } else if in_cells && app.editor.is_none() && on_border(cur_rect, p) { ctx.set_cursor_icon(CursorIcon::Move); } else if in_cells { @@ -1140,6 +1143,11 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & ed.point_cell = Some(c); app.grid.drag = Drag::Select; app.grid.drag_select = true; + if resp.drag_started() + && let Some(p) = pos + { + select_to_pointer(app, geo, sh, wb, p); + } return; } if app.editor.is_some() && !app.commit_edit(0, 0, false, false) { @@ -1193,6 +1201,12 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } app.grid.drag = Drag::Select; app.grid.drag_select = true; + if resp.drag_started() + && let Some(p) = pos + { + // The first recognised drag already includes movement away from the press. + select_to_pointer(app, geo, sh, wb, p); + } return; } @@ -1221,26 +1235,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & ); match app.grid.drag.clone() { Drag::Select => { - if let Some(ed) = app.editor.as_mut() - && ed.point.is_some() - { - if let Some(start) = ed.point_cell { - let r = RangeRef::new(start, pc); - let text = if r.is_single() { r.start.a1() } else { r.a1() }; - let keep = ed.point_cell; - ed.insert_ref(&text); - ed.point_cell = keep; - } - } else { - let anchor = sel.anchor; - let mut ranges: Vec = sel.ranges.iter().map(|r| r.a1()).collect(); - ranges.pop(); - ranges.push(RangeRef::new(anchor, pc).a1()); - let _ = app.session.run("selection.set", json!({"range": ranges.join(","), "active": sel.active.a1()})); - if let Some(d) = app.session.active_mut() { - d.selection.anchor = anchor; - } - } + select_to_pointer(app, geo, sh, wb, p); scroll_by(app, auto); } Drag::Rows(anchor) => { @@ -1313,6 +1308,11 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & // Release. if resp.drag_stopped() || (!pointer.any_down() && app.grid.drag != Drag::None && !resp.dragged()) { match std::mem::take(&mut app.grid.drag) { + Drag::Select if resp.drag_stopped() => { + if let Some(p) = pos { + select_to_pointer(app, geo, sh, wb, p); + } + } Drag::Fill { target: Some(t) } => { let src = sel.current(); if t != src { @@ -1392,6 +1392,35 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & keyboard(app, &ctx, resp, geo, sh); } +fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p: Pos2) { + let cell = CellRef::new( + geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)), + geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)), + ); + if let Some(ed) = app.editor.as_mut() + && ed.point.is_some() + { + if let Some(start) = ed.point_cell { + let range = RangeRef::new(start, cell); + let text = if range.is_single() { range.start.a1() } else { range.a1() }; + let text = if ed.sheet != wb.active_sheet { format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { text }; + ed.insert_ref(&text); + ed.point_cell = Some(start); + } + } else if let Some(d) = app.session.active() { + // Read the current anchor: starting a drag may have just replaced the selection. + let anchor = d.selection.anchor; + let active = d.selection.active; + let mut ranges: Vec = d.selection.ranges.iter().map(|r| r.a1()).collect(); + ranges.pop(); + ranges.push(RangeRef::new(anchor, cell).a1()); + let _ = app.session.run("selection.set", json!({"range": ranges.join(","), "active": active.a1()})); + if let Some(d) = app.session.active_mut() { + d.selection.anchor = anchor; + } + } +} + fn coalesce_last_undo(app: &mut SheetApp) { // Merge consecutive drag steps into one undo entry. if let Some(d) = app.session.active_mut() diff --git a/crates/ui-egui/tests/selection.rs b/crates/ui-egui/tests/selection.rs new file mode 100644 index 0000000..faebebf --- /dev/null +++ b/crates/ui-egui/tests/selection.rs @@ -0,0 +1,161 @@ +//! Cell selection consumes every pointer position without editing cell contents. + +use egui::{Event, Modifiers, PointerButton, Pos2}; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, RangeRef}; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::{Value, json}; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(initial: &str) -> Harness { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [["sentinel", 2], [3, "untouched"]]})).unwrap(); + session.execute("cell.set", json!({"cell": "C3", "input": "123"})).unwrap(); + session.execute("cell.set", json!({"cell": "F7", "input": "=C3*2"})).unwrap(); + session.execute("selection.set", json!({"cell": initial})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn point(h: &Harness, a1: &str) -> Pos2 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll).cell_rect(sheet, CellRef::parse(a1).unwrap()).center() +} + +fn cells(h: &Harness) -> Value { + let sheet = h.state().session.active().unwrap().wb.active().unwrap(); + json!(sheet.cells.iter().map(|(pos, cell)| (pos.a1(), cell)).collect::>()) +} + +fn frame(h: &mut Harness, event: Event) { + h.input_mut().events.push(event); + h.run_steps(1); +} + +fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +fn press(h: &mut Harness, cell: &str) { + let from = point(h, cell); + frame(h, Event::PointerMoved(from)); + frame(h, button(from, true)); +} + +fn assert_selection(h: &Harness, start: &str, end: &str) { + let selection = &h.state().session.active().unwrap().selection; + let start = CellRef::parse(start).unwrap(); + assert_eq!(selection.current(), RangeRef::new(start, CellRef::parse(end).unwrap())); + assert_eq!(selection.anchor, start); + assert_eq!(selection.active, start); +} + +#[test] +fn single_movement_selects_from_the_pressed_cell_in_both_directions() { + for (initial, start, end) in [("A1", "C3", "F7"), ("C3", "C3", "F7"), ("A1", "F7", "C3"), ("F7", "F7", "C3")] { + let mut h = harness(initial); + let before = cells(&h); + press(&mut h, start); + let to = point(&h, end); + frame(&mut h, Event::PointerMoved(to)); + assert_selection(&h, start, end); + frame(&mut h, button(to, false)); + h.run_steps(1); + assert_selection(&h, start, end); + assert_eq!(cells(&h), before); + } +} + +#[test] +fn release_consumes_a_new_endpoint_without_an_intermediate_movement_frame() { + let mut h = harness("A1"); + let before = cells(&h); + press(&mut h, "C3"); + let mid = point(&h, "D4"); + frame(&mut h, Event::PointerMoved(mid)); + let to = point(&h, "F7"); + frame(&mut h, button(to, false)); + assert_selection(&h, "C3", "F7"); + assert_eq!(cells(&h), before); +} + +#[test] +fn multiple_movement_frames_keep_the_anchor_and_cell_contents() { + let mut h = harness("A1"); + let before = cells(&h); + press(&mut h, "C3"); + for end in ["D4", "F7", "B2"] { + let to = point(&h, end); + frame(&mut h, Event::PointerMoved(to)); + assert_selection(&h, "C3", end); + } + let to = point(&h, "B2"); + frame(&mut h, button(to, false)); + assert_selection(&h, "C3", "B2"); + assert_eq!(cells(&h), before); +} + +#[test] +fn clicking_then_hovering_does_not_extend_selection() { + let mut h = harness("A1"); + let before = cells(&h); + press(&mut h, "C3"); + let from = point(&h, "C3"); + frame(&mut h, button(from, false)); + let to = point(&h, "F7"); + frame(&mut h, Event::PointerMoved(to)); + assert_selection(&h, "C3", "C3"); + assert_eq!(cells(&h), before); +} + +#[test] +fn formula_point_mode_consumes_first_movement_and_release() { + let mut h = harness("A1"); + let before = cells(&h); + h.state_mut().begin_edit(Some("=".into()), false); + h.run_steps(2); + press(&mut h, "C3"); + let mid = point(&h, "D4"); + frame(&mut h, Event::PointerMoved(mid)); + assert_eq!(h.state().editor.as_ref().unwrap().text, "=C3:D4"); + let to = point(&h, "F7"); + frame(&mut h, button(to, false)); + h.run_steps(1); + let editor = h.state().editor.as_ref().unwrap(); + assert_eq!(editor.text, "=C3:F7"); + assert_eq!(editor.point_cell, CellRef::parse("C3")); + assert_eq!(cells(&h), before); +} + +#[test] +fn formula_point_drag_keeps_the_sheet_qualifier() { + let mut h = harness("A1"); + let before = cells(&h); + h.state_mut().session.execute("home.insertSheet", json!({"name": "Other sheet"})).unwrap(); + h.state_mut().session.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + h.state_mut().begin_edit(Some("=".into()), false); + h.state_mut().session.execute("sheet.activate", json!({"sheet": "Other sheet"})).unwrap(); + h.run_steps(2); + let other_before = cells(&h); + press(&mut h, "C3"); + let mid = point(&h, "D4"); + frame(&mut h, Event::PointerMoved(mid)); + assert_eq!(h.state().editor.as_ref().unwrap().text, "='Other sheet'!C3:D4"); + let to = point(&h, "F7"); + frame(&mut h, button(to, false)); + assert_eq!(h.state().editor.as_ref().unwrap().text, "='Other sheet'!C3:F7"); + assert_eq!(cells(&h), other_before); + h.state_mut().session.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + assert_eq!(cells(&h), before); +} From b53a190ea1afeaf0bacd9348be3096aebf2ce981 Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:52:27 -0500 Subject: [PATCH 27/35] Names: keep sheet-level names with their sheet Moving a sheet left its sheet-level names on whatever sheet took its index, and copying it shifted nothing and copied no names. Moving now re-indexes name scopes, and a copy gets copies of the sheet's names, referring to the copy, as in Excel. Deleting a sheet left names that referred to it pointing at nothing; they now become #REF!. formulas.deleteName honours scope, and the Name Manager's Delete passes it. --- crates/engine/src/cmd/formulas.rs | 9 +++++-- crates/engine/src/cmd/sheet.rs | 41 +++++++++++++++++++++++++++- crates/engine/src/tests.rs | 45 +++++++++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 2 +- 4 files changed, 93 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index 2bea26c..0c9eb5c 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -36,7 +36,7 @@ pub fn specs() -> Vec { has_doc, define_name ), - cmd!("formulas.deleteName", "Delete Name", [], None, "{name, scope?}", has_doc, delete_name), + cmd!("formulas.deleteName", "Delete Name", [], None, "{name, scope?: \"Workbook\"|sheet name (default: every scope)}", has_doc, delete_name), cmd!(query "formulas.nameManager", "Name Manager", ["Formulas", "Defined Names"], Some("Cmd+F3"), "{} → names with values", has_doc, name_manager), cmd!( "formulas.createFromSelection", @@ -264,9 +264,14 @@ fn define_name(s: &mut Session, p: &Json) -> Result { fn delete_name(s: &mut Session, p: &Json) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("formulas.deleteName", "missing `name`"))?.to_string(); + let scope = match str_param(p, "scope") { + None => None, + Some("Workbook") => Some(None), + Some(sh) => Some(Some(s.doc()?.wb.sheet_index(sh).ok_or_else(|| bad("formulas.deleteName", "no such sheet"))?)), + }; edit(s, |cx| { let before = cx.wb.names.len(); - cx.wb.names.retain(|n| !n.name.eq_ignore_ascii_case(&name)); + cx.wb.names.retain(|n| !(n.name.eq_ignore_ascii_case(&name) && scope.is_none_or(|sc| n.scope == sc))); if cx.wb.names.len() == before { return Err(bad("formulas.deleteName", "no such name")); } diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 837f080..a6a3d5c 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -392,6 +392,13 @@ fn delete_sheet(s: &mut Session, p: &Json) -> Result { { *sc -= 1; } + // Names referring to the sheet become #REF!. + if let Ok(e) = gridcraft_formula::parse(&n.formula) { + let ne = gridcraft_formula::adjust::delete_sheet(e.clone(), &name); + if ne != e { + n.formula = gridcraft_formula::print(&ne); + } + } } if cx.wb.active_sheet >= cx.wb.sheets.len() || cx.wb.active_sheet > i { cx.wb.active_sheet = cx.wb.active_sheet.saturating_sub(1).min(cx.wb.sheets.len().saturating_sub(1)); @@ -461,10 +468,31 @@ fn move_sheet(s: &mut Session, p: &Json) -> Result { if copy { let mut sh = (*src).clone(); let base = sh.name.clone(); - let name = (2..).map(|k| format!("{base} ({k})")).find(|nm| cx.wb.sheet_index(nm).is_none()).unwrap_or(base); + let name = (2..).map(|k| format!("{base} ({k})")).find(|nm| cx.wb.sheet_index(nm).is_none()).unwrap_or(base.clone()); sh.name = name.chars().take(31).collect(); sh.tables.clear(); // table names must be unique let at = to.min(n); + // The copy gets its own copies of the sheet's names, referring to the copy. + let copies: Vec = cx + .wb + .names + .iter() + .filter(|nm| nm.scope == Some(i)) + .map(|nm| { + let formula = gridcraft_formula::parse(&nm.formula) + .map(|e| gridcraft_formula::print(&gridcraft_formula::adjust::rename_sheet(e, &base, &sh.name))) + .unwrap_or_else(|_| nm.formula.clone()); + gridcraft_model::DefinedName { scope: Some(at), formula, ..nm.clone() } + }) + .collect(); + for nm in cx.wb.names.iter_mut() { + if let Some(sc) = nm.scope.as_mut() + && *sc >= at + { + *sc += 1; + } + } + cx.wb.names.extend(copies); cx.wb.sheets.insert(at, Arc::new(sh)); cx.wb.active_sheet = at; } else { @@ -472,6 +500,17 @@ fn move_sheet(s: &mut Session, p: &Json) -> Result { let at = to.min(n - 1); cx.wb.sheets.insert(at, sh); cx.wb.active_sheet = at; + // Sheet-level names follow their sheet. + for nm in cx.wb.names.iter_mut() { + if let Some(sc) = nm.scope.as_mut() { + *sc = match *sc { + x if x == i => at, + x if i < x && x <= at => x - 1, + x if at <= x && x < i => x + 1, + x => x, + }; + } + } } cx.structural = true; Ok(Json::Null) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f3e36cd..bd51545 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -134,6 +134,51 @@ fn sheets() { assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["formula"], "='My Data'!A1*2"); } +#[test] +fn sheet_operations_keep_names_on_their_sheets() { + let names = |s: &Session| -> Vec<(String, Option, String)> { + let wb = &s.doc().unwrap().wb; + let mut v: Vec<_> = + wb.names.iter().map(|n| (n.name.clone(), n.scope.and_then(|i| wb.sheet(i)).map(|s| s.name.clone()), n.formula.clone())).collect(); + v.sort(); + v + }; + let named = |n: &str, scope: Option<&str>, f: &str| (n.to_string(), scope.map(str::to_string), f.to_string()); + let mut s = s(); + s.execute("home.insertSheet", json!({"name": "Data"})).unwrap(); + s.execute("home.insertSheet", json!({"name": "Notes"})).unwrap(); + s.execute("formulas.defineName", json!({"name": "Rate", "refersTo": "=Data!$A$1", "scope": "Data"})).unwrap(); + s.execute("formulas.defineName", json!({"name": "Rate", "refersTo": "=Notes!$B$1", "scope": "Notes"})).unwrap(); + s.execute("formulas.defineName", json!({"name": "Total", "refersTo": "=Data!$C$1"})).unwrap(); + // Moving Data (index 1) to the end: its names go with it. + s.execute("sheet.move", json!({"sheet": "Data", "to": 2})).unwrap(); + assert_eq!(names(&s), [named("Rate", Some("Data"), "Data!$A$1"), named("Rate", Some("Notes"), "Notes!$B$1"), named("Total", None, "Data!$C$1")]); + // Copying Data to the front copies its sheet-level names, referring to the copy. + s.execute("sheet.move", json!({"sheet": "Data", "to": 0, "copy": true})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheets[0].name, "Data (2)"); + assert_eq!( + names(&s), + [ + named("Rate", Some("Data"), "Data!$A$1"), + named("Rate", Some("Data (2)"), "'Data (2)'!$A$1"), + named("Rate", Some("Notes"), "Notes!$B$1"), + named("Total", None, "Data!$C$1") + ] + ); + // Deleting Data removes its names; names referring to it become #REF!. + s.execute("home.deleteSheet", json!({"sheet": "Data"})).unwrap(); + assert_eq!( + names(&s), + [named("Rate", Some("Data (2)"), "'Data (2)'!$A$1"), named("Rate", Some("Notes"), "Notes!$B$1"), named("Total", None, "#REF!")] + ); + // Deleting a name in one scope keeps the others. + s.execute("formulas.deleteName", json!({"name": "rate", "scope": "Notes"})).unwrap(); + assert_eq!(names(&s), [named("Rate", Some("Data (2)"), "'Data (2)'!$A$1"), named("Total", None, "#REF!")]); + assert!(s.execute("formulas.deleteName", json!({"name": "Total", "scope": "Notes"})).is_err()); + s.execute("formulas.deleteName", json!({"name": "Total", "scope": "Workbook"})).unwrap(); + assert_eq!(names(&s).len(), 1); +} + #[test] fn xlsx_roundtrip_through_engine() { let mut s = s(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b6c186d..dd2791b 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1368,7 +1368,7 @@ fn name_manager(app: &mut SheetApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { ui.label(n["scope"].as_str().unwrap_or("")); if ui.small_button("Delete").clicked() { - app.run_or_alert("formulas.deleteName", json!({"name": n["name"]})); + app.run_or_alert("formulas.deleteName", json!({"name": n["name"], "scope": n["scope"]})); } }); ui.end_row(); From 2a3d1ffd7f14c4d01365101549b4c7b9f682fb75 Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:25:58 -0500 Subject: [PATCH 28/35] Protection: refuse every edit of a locked cell on a protected sheet Only cell.set checked protection. The check now runs where every edit is committed: an edit that changes the value or formula of a locked cell on a protected sheet is refused with Excel's message, whichever command made it (clear, paste, fills, setValues, replace, goal seek, sort, insert/delete cells...). Inserting and deleting rows and columns follow the protection's options, and deleted lines must not hold locked cells. --- crates/engine/src/cmd/mod.rs | 47 ++++++++++++++++++++++- crates/engine/src/cmd/sheet.rs | 37 ++++++++++++++++++ crates/engine/src/tests.rs | 68 ++++++++++++++++++++++++++++++++++ crates/model/src/store.rs | 48 ++++++++++++++++++++++++ 4 files changed, 198 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 397a05c..a32401b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -209,6 +209,9 @@ pub struct Ctx<'a> { pub sel: &'a mut Selection, /// Rows whose automatic height should be recomputed (sheet, row). pub fit_rows: Vec<(usize, u32)>, + /// The command applied sheet protection itself (row/column inserts and deletes the + /// protection allows), so cells it shifts aren't refused as edits of locked cells. + pub protection_checked: bool, } pub(crate) fn edit(s: &mut Session, f: impl FnOnce(&mut Ctx) -> Result) -> Result { @@ -220,9 +223,12 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) let prof = std::env::var_os("GRIDCRAFT_PROFILE").is_some(); let t0 = std::time::Instant::now(); let mut sel = d.selection.clone(); - let mut ctx = Ctx { wb: (*d.wb).clone(), changed: Vec::new(), structural: false, sel: &mut sel, fit_rows: Vec::new() }; + let mut ctx = Ctx { wb: (*d.wb).clone(), changed: Vec::new(), structural: false, sel: &mut sel, fit_rows: Vec::new(), protection_checked: false }; let r = f(&mut ctx)?; - let Ctx { mut wb, changed, structural, mut fit_rows, .. } = ctx; + let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, .. } = ctx; + if !protection_checked { + check_protection(&d.wb, &wb)?; + } if changed.len() <= 200_000 { fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row))); } @@ -250,6 +256,43 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) Ok(r) } +/// Excel's message for an edit of a locked cell on a protected sheet. +pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet."; + +/// Refuses an edit that changed what a locked cell on a protected sheet holds (its value or +/// formula), whichever command made it. Sheets are matched by name, so moving, renaming or +/// deleting a sheet isn't an edit of its cells. +fn check_protection(old: &Workbook, new: &Workbook) -> Result<()> { + for sh in &old.sheets { + if !sh.is_protected() { + continue; + } + let Some(after) = new.sheet_index(&sh.name).and_then(|i| new.sheets.get(i)) else { continue }; + if std::sync::Arc::ptr_eq(sh, after) || !after.is_protected() { + continue; + } + for c in sh.cells.diff(&after.cells) { + if !same_content(sh.cell(c), after.cell(c)) && old.styles.get(sh.style_id(c)).protection.locked { + return Err(EngineError::Other(PROTECTED.into())); + } + } + } + Ok(()) +} + +/// Two cells hold the same value or formula (formats aside). +fn same_content(a: Option<&gridcraft_model::Cell>, b: Option<&gridcraft_model::Cell>) -> bool { + let empty = |c: Option<&gridcraft_model::Cell>| c.is_none_or(|c| c.formula.is_none() && c.value.is_empty()); + match (a, b) { + (Some(a), Some(b)) => match (&a.formula, &b.formula) { + (Some(x), Some(y)) => x.text == y.text && x.array == y.array, + (None, None) => a.value == b.value, + _ => false, + }, + _ => empty(a) && empty(b), + } +} + /// Recomputes an automatic row height from its content: the largest font, and wrapped or /// multi-line text. Rows with a user-set height keep it. pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) { diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 837f080..4b2425b 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -198,6 +198,7 @@ fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) { fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result { let (at, count) = lines(s, p, axis)?; + check_lines_protection(s, p, axis, at, count, true)?; let sheet = target_sheet(s, p)?; // Refuse to push data off the sheet. let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS }; @@ -209,6 +210,7 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result { } edit(s, |cx| { let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default(); + cx.protection_checked = true; { let sh = cx.sheet_mut(sheet)?; match axis { @@ -235,9 +237,11 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result { fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result { let (at, count) = lines(s, p, axis)?; + check_lines_protection(s, p, axis, at, count, false)?; let sheet = target_sheet(s, p)?; edit(s, |cx| { let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default(); + cx.protection_checked = true; { let sh = cx.sheet_mut(sheet)?; let block = match axis { @@ -359,6 +363,39 @@ fn edit_doc(d: &mut crate::DocState, f: impl FnOnce(&mut Ctx) -> Result) - super::commit(d, f) } +/// Sheet protection for inserting or deleting rows/columns: the protection must allow it, and +/// deleted lines must not hold locked cells. +fn check_lines_protection(s: &Session, p: &Json, axis: Axis, at: u32, count: u32, insert: bool) -> Result<()> { + let sheet = target_sheet(s, p)?; + let wb = &s.doc()?.wb; + let Some(sh) = wb.sheet(sheet) else { return Ok(()) }; + let Some(pr) = &sh.protection else { return Ok(()) }; + let allowed = match (axis, insert) { + (Axis::Rows, true) => pr.insert_rows, + (Axis::Rows, false) => pr.delete_rows, + (Axis::Cols, true) => pr.insert_columns, + (Axis::Cols, false) => pr.delete_columns, + }; + let locked = |st: gridcraft_model::StyleId| wb.styles.get(st).protection.locked; + let refuse = || EngineError::Other(PROTECTED.into()); + if !allowed { + return Err(refuse()); + } + if insert { + return Ok(()); + } + let end = at.saturating_add(count - 1).min(if axis == Axis::Rows { MAX_ROWS - 1 } else { MAX_COLS - 1 }); + let infos = if axis == Axis::Rows { &sh.rows } else { &sh.cols }; + if (at..=end).any(|i| locked(infos.get(&i).and_then(|x| x.style).unwrap_or_default())) { + return Err(refuse()); + } + let block = if axis == Axis::Rows { RangeRef::rows(at, end) } else { RangeRef::cols(at, end) }; + if sh.cells.iter_range(block).any(|(_, cell)| locked(cell.style)) { + return Err(refuse()); + } + Ok(()) +} + fn delete_sheet(s: &mut Session, p: &Json) -> Result { let i = target_sheet(s, p)?; let d = s.doc()?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f3e36cd..f59d6fc 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -97,6 +97,74 @@ fn sort_and_filter() { assert_eq!(r["hiddenRows"], 1); } +/// A sheet with locked data in A1:B3, unlocked cells in D1:D3 and row 10, then protected. +fn protected(extra: serde_json::Value) -> Session { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3]]})).unwrap(); + s.execute("cell.set", json!({"cell": "B1", "input": "=A1+D1"})).unwrap(); + s.execute("range.setValues", json!({"range": "D1", "values": [[10], [20], [30]]})).unwrap(); + s.execute("home.lockCell", json!({"range": "D1:D3", "on": false})).unwrap(); + s.execute("home.lockCell", json!({"range": "10:10", "on": false})).unwrap(); + s.execute("review.protectSheet", extra).unwrap(); + s +} + +#[test] +fn protection_refuses_edits_of_locked_cells() { + let refused = |s: &mut Session, id: &str, p: serde_json::Value| { + let before = s.doc().unwrap().wb.clone(); + let e = s.execute(id, p).expect_err(id).to_string(); + assert!(e.contains("protected sheet"), "{id}: {e}"); + assert_eq!(*s.doc().unwrap().wb, *before, "{id} changed the workbook"); + }; + let mut s = protected(json!({})); + refused(&mut s, "cell.set", json!({"cell": "A1", "input": "9"})); + refused(&mut s, "edit.clearContents", json!({"range": "A1:A3"})); + refused(&mut s, "edit.clearAll", json!({"range": "A1"})); + refused(&mut s, "range.setValues", json!({"range": "A2", "values": [[9]]})); + refused(&mut s, "range.fill", json!({"range": "A5:A6", "input": "x"})); + refused(&mut s, "edit.fillDown", json!({"range": "A1:A3"})); + refused(&mut s, "edit.fillRight", json!({"range": "A1:B1"})); + refused(&mut s, "edit.fillUp", json!({"range": "A1:A3"})); + refused(&mut s, "edit.fillLeft", json!({"range": "A1:B1"})); + refused(&mut s, "edit.autoFill", json!({"source": "A1", "target": "A1:A5"})); + s.execute("edit.copy", json!({"range": "D1"})).unwrap(); + refused(&mut s, "edit.paste", json!({"at": "A2"})); + refused(&mut s, "edit.paste", json!({"at": "A2", "text": "x\ty"})); + refused(&mut s, "home.deleteRows", json!({"rows": "2:2"})); + refused(&mut s, "home.deleteColumns", json!({"cols": "A:A"})); + refused(&mut s, "home.insertRows", json!({"rows": "1:1"})); + refused(&mut s, "home.deleteCells", json!({"range": "A1", "shift": "up"})); + refused(&mut s, "home.insertCells", json!({"range": "A1", "shift": "down"})); + refused(&mut s, "edit.replace", json!({"what": "2", "with": "5"})); + refused(&mut s, "data.goalSeek", json!({"set": "B1", "to": 50, "changing": "A1"})); + // Unlocked cells stay editable. + s.execute("edit.paste", json!({"at": "D2"})).unwrap(); + assert_eq!(v(&s, "D2"), Value::Number(10.0)); + s.execute("range.setValues", json!({"range": "D2", "values": [[5]]})).unwrap(); + s.execute("edit.fillDown", json!({"range": "D1:D3"})).unwrap(); + assert_eq!(v(&s, "D3"), Value::Number(10.0)); + s.execute("edit.clearContents", json!({"range": "D3"})).unwrap(); + assert_eq!(v(&s, "D3"), Value::Empty); + s.execute("data.goalSeek", json!({"set": "B1", "to": 50, "changing": "D1"})).unwrap(); + assert!((v(&s, "B1").as_f64().unwrap() - 50.0).abs() < 0.001); + s.execute("edit.replace", json!({"what": "49", "with": "48", "wholeCell": true})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(1.0)); +} + +#[test] +fn protection_allows_permitted_row_and_column_edits() { + let mut s = protected(json!({"insertRows": true, "deleteRows": true, "insertColumns": true})); + // Inserting shifts locked cells, which the protection allows. + s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap(); + assert_eq!(v(&s, "A2"), Value::Number(1.0)); + s.execute("home.insertColumns", json!({"cols": "A:A"})).unwrap(); + // Deleting a row with locked cells isn't; deleting an unlocked row is. + assert!(s.execute("home.deleteRows", json!({"rows": "2:2"})).is_err()); + s.execute("home.deleteRows", json!({"rows": "11:11"})).unwrap(); + assert!(s.execute("home.deleteColumns", json!({"cols": "Z:Z"})).is_err()); +} + #[test] fn formatting_and_styles() { let mut s = s(); diff --git a/crates/model/src/store.rs b/crates/model/src/store.rs index 1652501..d6a9c45 100644 --- a/crates/model/src/store.rs +++ b/crates/model/src/store.rs @@ -120,6 +120,40 @@ impl CellStore { pub fn has(&self, c: CellRef) -> bool { self.get(c).is_some_and(|cell| !cell.value.is_empty() || cell.formula.is_some()) } + /// Positions whose cells differ between `self` and `other` (stored on one side only, or not + /// equal). Bands both stores share are skipped, so comparing an edited copy with the store + /// it was cloned from only visits the bands the edit touched. + pub fn diff(&self, other: &CellStore) -> Vec { + let empty = Band::default(); + let mut keys: Vec = self.bands.keys().chain(other.bands.keys()).copied().collect(); + keys.sort_unstable(); + keys.dedup(); + let mut out = Vec::new(); + for k in keys { + let (a, b) = (self.bands.get(&k), other.bands.get(&k)); + if let (Some(a), Some(b)) = (a, b) + && Arc::ptr_eq(a, b) + { + continue; + } + let (a, b) = (a.map_or(&empty, |x| x), b.map_or(&empty, |x| x)); + for (row, cols) in &a.rows { + for (col, cell) in cols { + if b.rows.get(row).and_then(|r| r.get(col)) != Some(cell) { + out.push(CellRef::new(*row, *col)); + } + } + } + for (row, cols) in &b.rows { + for col in cols.keys() { + if !a.rows.get(row).is_some_and(|r| r.contains_key(col)) { + out.push(CellRef::new(*row, *col)); + } + } + } + } + out + } /// Removes every cell in `r` and returns them. pub fn take_range(&mut self, r: RangeRef) -> Vec<(CellRef, Cell)> { let keys: Vec = self.iter_range(r).map(|(c, _)| c).collect(); @@ -210,6 +244,20 @@ mod tests { assert_eq!(s.get(c(5, 0)).unwrap().value, Value::Number(99.0)); } + #[test] + fn diff_visits_changed_bands() { + let mut s = CellStore::new(); + for r in 0..1000 { + s.set(c(r, 0), Cell::value((r as f64).into())); + } + let snap = s.clone(); + assert!(s.diff(&snap).is_empty()); + s.set(c(5, 0), Cell::value(99.0.into())); + s.set(c(700, 3), Cell::value("new".into())); + s.remove(c(900, 0)); + assert_eq!(snap.diff(&s), [c(5, 0), c(700, 3), c(900, 0)]); + } + #[test] fn range_iteration_order() { let mut s = CellStore::new(); From e2943455997bb7605bb0e0f61a7b86254e93b094 Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:25:58 -0500 Subject: [PATCH 29/35] Protection: keep hidden formulas hidden On a protected sheet a formula formatted Hidden still showed with Show Formulas on, matched Find in formulas, and showed in the formula bar when a cell of its spill range was selected. --- crates/engine/src/cmd/edit.rs | 6 +++++- crates/engine/src/display.rs | 17 +++++++++++++++++ crates/engine/src/tests.rs | 26 ++++++++++++++++++++++++++ crates/ui-egui/src/formula_bar.rs | 3 +++ 4 files changed, 51 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index cffe154..cc994f0 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1175,7 +1175,11 @@ fn find(s: &mut Session, p: &Json) -> Result { .cells .iter() .filter_map(|(c, cell)| { - let text = if values || cell.formula.is_none() { crate::display::cell_text(&d.wb, sh, c) } else { cell.input_text() }; + let text = if values || cell.formula.is_none() || crate::display::formula_hidden(&d.wb, sh, c) { + crate::display::cell_text(&d.wb, sh, c) + } else { + cell.input_text() + }; matches(&text, &what, case, whole).then_some((c, text)) }) .collect(); diff --git a/crates/engine/src/display.rs b/crates/engine/src/display.rs index 24df503..e32422a 100644 --- a/crates/engine/src/display.rs +++ b/crates/engine/src/display.rs @@ -31,11 +31,28 @@ pub fn format(v: &Value, code: &str, wb: &Workbook) -> Formatted { format_value(v, &parsed(code), wb.date_system) } +/// Whether `c` has a formula that must not be shown: its cell is formatted Hidden on a +/// protected sheet. A spilled cell follows its anchor. +pub fn formula_hidden(wb: &Workbook, sheet: &Sheet, c: CellRef) -> bool { + if !sheet.is_protected() { + return false; + } + let at = match sheet.cell(c) { + Some(x) if x.formula.is_some() => c, + _ => match sheet.spill_ranges.iter().find(|(_, r)| r.contains(c)) { + Some((anchor, _)) => *anchor, + None => return false, + }, + }; + wb.styles.get(sheet.style_id(at)).protection.hidden +} + /// The text a cell shows (full precision General; the grid narrows General to fit the column). pub fn cell_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String { let v = sheet.value(c); if sheet.show_formulas && let Some(f) = sheet.cell(c).and_then(|x| x.formula.as_ref()) + && !formula_hidden(wb, sheet, c) { return format!("={}", f.text); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f59d6fc..bf85261 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -165,6 +165,32 @@ fn protection_allows_permitted_row_and_column_edits() { assert!(s.execute("home.deleteColumns", json!({"cols": "Z:Z"})).is_err()); } +#[test] +fn hidden_formulas_stay_hidden_on_protected_sheets() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)*7"})).unwrap(); + s.execute("cell.set", json!({"cell": "B1", "input": "=1+1"})).unwrap(); + s.execute("home.formatCells", json!({"range": "A1", "style": {"protection": {"locked": true, "hidden": true}}})).unwrap(); + s.execute("formulas.showFormulas", json!({"on": true})).unwrap(); + let text = |s: &Session, a: &str| { + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + crate::display::cell_text(&d.wb, sh, CellRef::parse(a).unwrap()) + }; + assert_eq!(text(&s, "A1"), "=SEQUENCE(3)*7"); + assert_eq!(s.execute("edit.find", json!({"what": "SEQUENCE", "all": true})).unwrap()["count"], 1); + s.execute("review.protectSheet", json!({})).unwrap(); + assert_eq!(text(&s, "A1"), "7"); + assert_eq!(text(&s, "B1"), "=1+1"); + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert!(crate::display::formula_hidden(&d.wb, sh, CellRef::parse("A2").unwrap())); + assert!(!crate::display::formula_hidden(&d.wb, sh, CellRef::parse("B1").unwrap())); + assert!(s.execute("edit.find", json!({"what": "SEQUENCE", "all": true})).is_err()); + let r = s.execute("edit.find", json!({"what": "7", "all": true})).unwrap(); + assert_eq!(r["results"][0]["value"], "7"); +} + #[test] fn formatting_and_styles() { let mut s = s(); diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 753585f..d3d8650 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -77,6 +77,9 @@ fn active_input(app: &SheetApp) -> String { if r.contains(a) && let Some(f) = sh.cell(*anchor).and_then(|c| c.formula.as_ref()) { + if gridcraft_engine::display::formula_hidden(&d.wb, sh, *anchor) { + return String::new(); + } return format!("={}", f.text); } } From 68fac6d861a8fec3c58f13cfa39f02d6ae711b86 Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:49:24 -0500 Subject: [PATCH 30/35] Insert/Delete Cells: adjust references like Excel Delete Cells built the references' edit and then discarded it, so references into the deleted cells didn't become #REF!, and Insert Cells treated the shift as a move of everything below, so =SUM(A1:A5) didn't grow to A1:A6 when A3 was inserted. Two new structural edits, InsertCells and DeleteCells, adjust a reference like inserting/deleting rows (columns) when it lies within the shifted columns (rows), and leave it as it is otherwise, as Excel does. Formulas, defined names, chart series, conditional-format, validation, table and print-area ranges, notes, links and sparklines follow. --- crates/engine/src/cmd/sheet.rs | 90 ++++++++++++++++++++++------------ crates/engine/src/tests.rs | 47 ++++++++++++++++++ crates/formula/src/adjust.rs | 21 ++++++++ crates/formula/src/tests.rs | 18 +++++++ 4 files changed, 145 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 837f080..9ba3672 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -74,20 +74,8 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert: None } }; - let map_range = |r: RangeRef| -> Option { - let e = if insert { Edit::Insert { axis, at, count } } else { Edit::Delete { axis, at, count } }; - let expr = gridcraft_formula::Expr::Ref(gridcraft_formula::Reference { - sheet: gridcraft_formula::SheetSel::Current, - kind: gridcraft_formula::RefKind::Range( - gridcraft_formula::Anchor { row: r.start.row, col: r.start.col, row_abs: false, col_abs: false }, - gridcraft_formula::Anchor { row: r.end.row, col: r.end.col, row_abs: false, col_abs: false }, - ), - }); - match gridcraft_formula::adjust::adjust(expr, "S", "S", &e) { - gridcraft_formula::Expr::Ref(r) => Some(r.range()), - _ => None, - } - }; + let e = if insert { Edit::Insert { axis, at, count } } else { Edit::Delete { axis, at, count } }; + let map_range = |r: RangeRef| map_range(r, &e); let map_cell = |c: CellRef| -> Option { match axis { Axis::Rows => map_line(c.row).map(|r| CellRef::new(r, c.col)), @@ -265,6 +253,7 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result { "column" | "entireColumn" => return insert_lines(s, &json!({"cols": RangeRef::cols(r.start.col, r.end.col).a1()}), Axis::Cols), _ => {} } + let e = Edit::InsertCells { axis: if shift == "right" { Axis::Cols } else { Axis::Rows }, range: r }; let sheet = target_sheet(s, p)?; let right = shift == "right"; edit(s, |cx| { @@ -274,15 +263,9 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result { } else { sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.start.row, r.height() as i64); } - // Formulas referring to cells that moved: approximate with a whole-sheet move edit. - let moved = if right { - RangeRef::new(r.start, CellRef::new(r.end.row, MAX_COLS - 1 - r.width())) - } else { - RangeRef::new(r.start, CellRef::new(MAX_ROWS - 1 - r.height(), r.end.col)) - }; + shift_cell_features(sh, &e); let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default(); - let to = if right { (r.start.row, r.start.col + r.width()) } else { (r.start.row + r.height(), r.start.col) }; - super::edit::rewrite_all_formulas(&mut cx.wb, &name, &Edit::Move { from: moved, to_row: to.0, to_col: to.1 }); + rewrite(&mut cx.wb, &name, &e); cx.structural = true; Ok(Json::Null) }) @@ -296,6 +279,7 @@ fn delete_cells(s: &mut Session, p: &Json) -> Result { "column" | "entireColumn" => return delete_lines(s, &json!({"cols": RangeRef::cols(r.start.col, r.end.col).a1()}), Axis::Cols), _ => {} } + let e = Edit::DeleteCells { axis: if shift == "left" { Axis::Cols } else { Axis::Rows }, range: r }; let sheet = target_sheet(s, p)?; let left = shift == "left"; edit(s, |cx| { @@ -308,16 +292,10 @@ fn delete_cells(s: &mut Session, p: &Json) -> Result { } else { sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.end.row + 1, -(r.height() as i64)); } + shift_cell_features(sh, &e); } - // References into the deleted block become #REF!; the rest move. - let deleted = Edit::Move { from: r, to_row: r.start.row, to_col: r.start.col }; - let _ = deleted; - let moved_from = if left { - RangeRef::new(CellRef::new(r.start.row, r.end.col + 1), CellRef::new(r.end.row, MAX_COLS - 1)) - } else { - RangeRef::new(CellRef::new(r.end.row + 1, r.start.col), CellRef::new(MAX_ROWS - 1, r.end.col)) - }; - super::edit::rewrite_all_formulas(&mut cx.wb, &name, &Edit::Move { from: moved_from, to_row: r.start.row, to_col: r.start.col }); + // References into the deleted cells become #REF!; the rest move. + rewrite(&mut cx.wb, &name, &e); cx.structural = true; Ok(Json::Null) }) @@ -538,3 +516,53 @@ fn step_sheet(s: &mut Session, dir: i64) -> Result { s.doc_mut()?.wb_switch_sheet(i as usize); ok() } + +/// Where a range of the edited sheet goes after a structural edit (`None` = deleted). +fn map_range(r: RangeRef, e: &Edit) -> Option { + let expr = gridcraft_formula::Expr::Ref(gridcraft_formula::Reference { + sheet: gridcraft_formula::SheetSel::Current, + kind: gridcraft_formula::RefKind::Range( + gridcraft_formula::Anchor { row: r.start.row, col: r.start.col, row_abs: false, col_abs: false }, + gridcraft_formula::Anchor { row: r.end.row, col: r.end.col, row_abs: false, col_abs: false }, + ), + }); + match gridcraft_formula::adjust::adjust(expr, "S", "S", e) { + gridcraft_formula::Expr::Ref(r) => Some(r.range()), + _ => None, + } +} + +/// Shifts what Insert/Delete Cells moves along with the cells: merged areas, conditional +/// format, validation, table, filter and print-area ranges, notes, links and sparklines. +fn shift_cell_features(sh: &mut Sheet, e: &Edit) { + let map_cell = |c: CellRef| map_range(RangeRef::cell(c), e).map(|r| r.start); + sh.merges = sh.merges.iter().filter_map(|m| map_range(*m, e)).filter(|m| !m.is_single()).collect(); + for cf in sh.cond_formats.iter_mut() { + cf.ranges = cf.ranges.iter().filter_map(|r| map_range(*r, e)).collect(); + } + sh.cond_formats.retain(|cf| !cf.ranges.is_empty()); + for dv in sh.validations.iter_mut() { + dv.ranges = dv.ranges.iter().filter_map(|r| map_range(*r, e)).collect(); + } + sh.validations.retain(|dv| !dv.ranges.is_empty()); + for t in sh.tables.iter_mut() { + t.range = map_range(t.range, e).unwrap_or_default(); + } + sh.tables.retain(|t| t.range != RangeRef::default()); + if let Some(af) = sh.autofilter.as_mut() { + match map_range(af.range, e) { + Some(r) => af.range = r, + None => sh.autofilter = None, + } + } + sh.comments = std::mem::take(&mut sh.comments).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect(); + sh.hyperlinks = std::mem::take(&mut sh.hyperlinks).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect(); + for sp in sh.sparklines.iter_mut() { + if let Some(c) = map_cell(sp.cell) { + sp.cell = c; + } + } + if let Some(pa) = sh.print.print_area { + sh.print.print_area = map_range(pa, e); + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f3e36cd..0faa773 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -84,6 +84,53 @@ fn fill_series() { assert_eq!(v(&s, "C2"), Value::from("Item 10")); } +#[test] +fn insert_delete_cells_adjust() { + let formula = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["formula"].clone(); + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4], [5]]})).unwrap(); + s.execute("cell.set", json!({"cell": "C1", "input": "=SUM(A1:A5)"})).unwrap(); + s.execute("cell.set", json!({"cell": "C2", "input": "=A4*10"})).unwrap(); + s.execute("cell.set", json!({"cell": "C3", "input": "=SUM(A1:B5)"})).unwrap(); + s.execute("formulas.defineName", json!({"name": "Fourth", "refersTo": "=Sheet1!$A$4"})).unwrap(); + s.execute( + "home.conditionalFormat", + json!({"range": "A4:A5", "rule": {"type": "cellIs", "operator": "greater", "value": "3", "preset": "redText"}}), + ) + .unwrap(); + s.execute("data.validation", json!({"range": "A5", "type": "whole", "operator": "greater", "formula1": "0"})).unwrap(); + s.execute("insert.chart", json!({"range": "A1:A5", "type": "line"})).unwrap(); + // Insert A3 shifting down: ranges across the insertion grow, references below move. + s.execute("home.insertCells", json!({"range": "A3", "shift": "down"})).unwrap(); + assert_eq!(formula(&mut s, "C1"), "=SUM(A1:A6)"); + assert_eq!(formula(&mut s, "C2"), "=A5*10"); + assert_eq!(formula(&mut s, "C3"), "=SUM(A1:B5)"); + assert_eq!(v(&s, "C2"), Value::Number(40.0)); + let sh = s.doc().unwrap().wb.active().unwrap().clone(); + assert_eq!(s.doc().unwrap().wb.names[0].formula, "Sheet1!$A$5"); + assert_eq!(sh.cond_formats[0].ranges[0].a1(), "A5:A6"); + assert_eq!(sh.validations[0].ranges[0].a1(), "A6"); + assert_eq!(sh.charts[0].series[0].values, "Sheet1!$A$1:$A$6"); + // Delete A5 (the 4) shifting up: references to it become #REF!, those below move up. + s.execute("home.deleteCells", json!({"range": "A5", "shift": "up"})).unwrap(); + assert_eq!(formula(&mut s, "C1"), "=SUM(A1:A5)"); + assert_eq!(formula(&mut s, "C2"), "=#REF!*10"); + assert_eq!(s.doc().unwrap().wb.names[0].formula, "#REF!"); + assert_eq!(v(&s, "C1"), Value::Number(11.0)); + let sh = s.doc().unwrap().wb.active().unwrap().clone(); + assert_eq!(sh.cond_formats[0].ranges[0].a1(), "A5"); + assert_eq!(sh.validations[0].ranges[0].a1(), "A5"); + assert_eq!(sh.charts[0].series[0].values, "Sheet1!$A$1:$A$5"); + // Shifting right and left. + s.execute("range.setValues", json!({"range": "E1", "values": [[1, 2, 3]]})).unwrap(); + s.execute("cell.set", json!({"cell": "E3", "input": "=G1+SUM(E1:G1)"})).unwrap(); + s.execute("home.insertCells", json!({"range": "F1", "shift": "right"})).unwrap(); + assert_eq!(formula(&mut s, "E3"), "=H1+SUM(E1:H1)"); + s.execute("home.deleteCells", json!({"range": "E1:F1", "shift": "left"})).unwrap(); + assert_eq!(formula(&mut s, "E3"), "=F1+SUM(E1:F1)"); + assert_eq!(v(&s, "E3"), Value::Number(8.0)); +} + #[test] fn sort_and_filter() { let mut s = s(); diff --git a/crates/formula/src/adjust.rs b/crates/formula/src/adjust.rs index 734ff5c..ae082a6 100644 --- a/crates/formula/src/adjust.rs +++ b/crates/formula/src/adjust.rs @@ -56,6 +56,12 @@ pub enum Edit { Delete { axis: Axis, at: u32, count: u32 }, /// Cut-paste of a block: references into `from` now point into the block at `to` (top-left). Move { from: RangeRef, to_row: u32, to_col: u32 }, + /// Insert Cells shifting down (`Rows`) or right (`Cols`): the cells of `range` and those + /// below (right of) it move by its height (width), within its columns (rows) only. + InsertCells { axis: Axis, range: RangeRef }, + /// Delete Cells shifting up (`Rows`) or left (`Cols`): `range` goes and the cells below + /// (right of) it move back by its height (width), within its columns (rows) only. + DeleteCells { axis: Axis, range: RangeRef }, } /// Adjusts a formula living on sheet `host` after `edit` on sheet `target`. @@ -158,6 +164,21 @@ fn adjust_ref(r: &Reference, edit: &Edit) -> Option { }; Some(Reference { sheet: r.sheet.clone(), kind }) } + Edit::InsertCells { axis, range } | Edit::DeleteCells { axis, range } => { + // Like inserting/deleting lines, for references that lie within the shifted + // columns (rows); Excel leaves references reaching outside them as they are. + let span = r.range(); + let (inside, at, count) = match axis { + Axis::Rows => (range.start.col <= span.start.col && span.end.col <= range.end.col, range.start.row, range.height()), + Axis::Cols => (range.start.row <= span.start.row && span.end.row <= range.end.row, range.start.col, range.width()), + }; + if !inside { + return Some(r.clone()); + } + let axis = *axis; + let lines = if matches!(edit, Edit::InsertCells { .. }) { Edit::Insert { axis, at, count } } else { Edit::Delete { axis, at, count } }; + adjust_ref(r, &lines) + } Edit::Move { from, to_row, to_col } => { let dr = *to_row as i64 - from.start.row as i64; let dc = *to_col as i64 - from.start.col as i64; diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index ff84a5b..e6c0b59 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -120,6 +120,24 @@ fn insert_delete() { assert_eq!(print(&adjust(e, "S", "S", &del)), "SUM(#REF!)"); } +#[test] +fn insert_delete_cells() { + let range = |a: &str| gridcraft_core::RangeRef::parse(a).unwrap(); + let adj = |f: &str, e: &Edit| print(&adjust(parse(f).unwrap(), "S", "S", e)); + // Insert A3:A4, shifting down: column A moves, other columns and wider ranges don't. + let ins = Edit::InsertCells { axis: Axis::Rows, range: range("A3:A4") }; + assert_eq!(adj("SUM(A1:A5)+A2+A3+$A$9+B3+SUM(A1:B5)+SUM(A:A)", &ins), "SUM(A1:A7)+A2+A5+$A$11+B3+SUM(A1:B5)+SUM(A:A)"); + // Insert B2 shifting right. + let ins = Edit::InsertCells { axis: Axis::Cols, range: range("B2") }; + assert_eq!(adj("A2+B2+C2+B3+SUM(A2:C2)", &ins), "A2+C2+D2+B3+SUM(A2:D2)"); + // Delete A3:A4 shifting up: references into it become #REF!, those below move up. + let del = Edit::DeleteCells { axis: Axis::Rows, range: range("A3:A4") }; + assert_eq!(adj("A2+A3+A4+A5+B5+SUM(A1:A10)+SUM(A3:A4)+SUM(A1:B10)", &del), "A2+#REF!+#REF!+A3+B5+SUM(A1:A8)+SUM(#REF!)+SUM(A1:B10)"); + // Delete B1:C2 shifting left. + let del = Edit::DeleteCells { axis: Axis::Cols, range: range("B1:C2") }; + assert_eq!(adj("A1+B1+D1+D2+D3+SUM(A1:E1)", &del), "A1+#REF!+B1+B2+D3+SUM(A1:C1)"); +} + #[test] fn moves_and_renames() { let mv = Edit::Move { from: gridcraft_core::RangeRef::parse("A1:B2").unwrap(), to_row: 9, to_col: 3 }; From 8029433b1c9f7293b5485d1ffdfa1a9182174dba Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:27:38 -0500 Subject: [PATCH 31/35] Names: refuse R1C1-style names valid_name refused R and C but took R1C1, R2, C3 and RC, which Excel refuses because they read as R1C1 references. --- crates/engine/src/cmd/formulas.rs | 19 +++++++++++++++---- crates/engine/src/tests.rs | 11 +++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index 2bea26c..e72a654 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -218,15 +218,26 @@ fn valid_name(n: &str) -> bool { if !(first.is_alphabetic() || first == '_' || first == '\\') { return false; } - if n.len() > 255 || n.eq_ignore_ascii_case("R") || n.eq_ignore_ascii_case("C") { - return false; - } - if CellRef::parse(n).is_some() { + if n.len() > 255 || CellRef::parse(n).is_some() || is_r1c1(n) { return false; } n.chars().all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | '\\' | '?')) } +/// Looks like an R1C1 reference (`R`, `C`, `RC`, `R2`, `C3`, `R1C1`), which Excel doesn't +/// accept as a name. +fn is_r1c1(n: &str) -> bool { + let digits = |s: &str| s.bytes().take_while(u8::is_ascii_digit).count(); + let mut rest = n; + if let Some(r) = rest.strip_prefix(['R', 'r']) { + rest = r.get(digits(r)..).unwrap_or(""); + } + if let Some(c) = rest.strip_prefix(['C', 'c']) { + rest = c.get(digits(c)..).unwrap_or(""); + } + rest.is_empty() && !n.is_empty() +} + fn define_name(s: &mut Session, p: &Json) -> Result { let Some(name) = str_param(p, "name").map(str::to_string) else { s.ui_requests.push(crate::UiRequest::Dialog("defineName".into(), json!({}))); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f3e36cd..eb8bcbd 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -121,6 +121,17 @@ fn tables_and_structured_refs() { assert_eq!(v(&s, "B4"), Value::Number(7.0)); } +#[test] +fn names_that_look_like_references_are_refused() { + let mut s = s(); + for bad in ["R1C1", "r2", "C3", "RC", "R", "c", "rc12", "A1", "XFD1048576", "1st"] { + assert!(s.execute("formulas.defineName", json!({"name": bad, "refersTo": "=1"})).is_err(), "{bad}"); + } + for good in ["R1C1X", "Rate", "RCx", "Rx", "_R1", "Cost"] { + s.execute("formulas.defineName", json!({"name": good, "refersTo": "=1"})).unwrap(); + } +} + #[test] fn sheets() { let mut s = s(); From b28b4b729f0e3c9a7bb4c37b0eb0f2955d5a2570 Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:27:38 -0500 Subject: [PATCH 32/35] Names: Create from Selection uses bottom and right, keeps existing names formulas.createFromSelection ignored bottom and right, and replaced any workbook name with the same label without asking. Bottom-row and right-column labels now name the cells above and to their left. An existing name is kept and listed in the result's skipped, unless replace is set (Excel asks; a command doesn't open dialogs). --- crates/engine/src/cmd/formulas.rs | 47 ++++++++++++++++++++----------- crates/engine/src/tests.rs | 31 ++++++++++++++++++++ 2 files changed, 61 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index e72a654..64d5832 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Create from Selection", ["Formulas", "Defined Names"], Some("Cmd+Shift+F3"), - "{range?, top?: true, left?: false, bottom?, right?}", + "{range?, top?: true, left?: false, bottom?: false, right?: false, replace?: false (existing names are kept and listed in `skipped`)}", has_doc, create_from_selection ), @@ -306,6 +306,9 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result { let r = target_range(s, p)?; let top = bool_param(p, "top").unwrap_or(true); let left = bool_param(p, "left").unwrap_or(false); + let bottom = bool_param(p, "bottom").unwrap_or(false); + let right = bool_param(p, "right").unwrap_or(false); + let replace = bool_param(p, "replace").unwrap_or(false); let d = s.doc()?; let sh = d.wb.active().ok_or(EngineError::NoDocument)?; let q = gridcraft_formula::quote_sheet(&sh.name); @@ -320,28 +323,38 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result { }; let clean = |t: String| -> String { let mut n: String = t.trim().chars().map(|c| if c.is_alphanumeric() || c == '.' { c } else { '_' }).collect(); - if n.chars().next().is_some_and(|c| c.is_ascii_digit()) || CellRef::parse(&n).is_some() { + if !n.is_empty() && !valid_name(&n) { n.insert(0, '_'); } - n + if valid_name(&n) { n } else { String::new() } }; + // The labels in the chosen edge rows/columns name the cells between them. + let (r0, r1) = (r.start.row + top as u32, r.end.row.saturating_sub(bottom as u32)); + let (c0, c1) = (r.start.col + left as u32, r.end.col.saturating_sub(right as u32)); let mut made = Vec::new(); - if top && r.height() > 1 { - for c in r.start.col..=r.end.col { - let name = clean(sh.value(CellRef::new(r.start.row, c)).display()); - if !name.is_empty() { - made.push((name, abs(RangeRef::new(CellRef::new(r.start.row + 1, c), CellRef::new(r.end.row, c))))); - } - } - } - if left && r.width() > 1 { - for row in r.start.row + top as u32..=r.end.row { - let name = clean(sh.value(CellRef::new(row, r.start.col)).display()); - if !name.is_empty() { - made.push((name, abs(RangeRef::new(CellRef::new(row, r.start.col + 1), CellRef::new(row, r.end.col))))); + if r0 <= r1 && c0 <= c1 { + for (on, row) in [(top, r.start.row), (bottom, r.end.row)] { + for c in (c0..=c1).filter(|_| on) { + let name = clean(sh.value(CellRef::new(row, c)).display()); + if !name.is_empty() { + made.push((name, abs(RangeRef::new(CellRef::new(r0, c), CellRef::new(r1, c))))); + } + } + } + for (on, col) in [(left, r.start.col), (right, r.end.col)] { + for row in (r0..=r1).filter(|_| on) { + let name = clean(sh.value(CellRef::new(row, col)).display()); + if !name.is_empty() { + made.push((name, abs(RangeRef::new(CellRef::new(row, c0), CellRef::new(row, c1))))); + } } } } + // Excel asks before replacing an existing name; here it's kept unless `replace` is set, and + // reported. + let exists = |n: &str| d.wb.names.iter().any(|x| x.name.eq_ignore_ascii_case(n) && x.scope.is_none()); + let (made, skipped): (Vec<_>, Vec<_>) = made.into_iter().partition(|(n, _)| replace || !exists(n)); + let skipped: Vec = skipped.into_iter().map(|(n, _)| n).collect(); let n = made.len(); edit(s, |cx| { for (name, f) in made { @@ -349,7 +362,7 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result { cx.wb.names.push(DefinedName { name, scope: None, formula: f, comment: String::new(), hidden: false }); } cx.structural = true; - Ok(json!({"created": n})) + Ok(json!({"created": n, "skipped": skipped})) }) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index eb8bcbd..fe07b5f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -121,6 +121,37 @@ fn tables_and_structured_refs() { assert_eq!(v(&s, "B4"), Value::Number(7.0)); } +#[test] +fn create_names_from_selection() { + let names = |s: &Session| -> Vec<(String, String)> { + let mut v: Vec<_> = s.doc().unwrap().wb.names.iter().map(|n| (n.name.clone(), n.formula.clone())).collect(); + v.sort(); + v + }; + let named = |n: &str, f: &str| (n.to_string(), f.to_string()); + let mut s = s(); + s.execute( + "range.setValues", + json!({"range": "A1", "values": [["", "Jan", "Feb", ""], ["North", 1, 2, "N"], ["South", 3, 4, "S"], ["", "First", "Second", ""]]}), + ) + .unwrap(); + s.execute("formulas.createFromSelection", json!({"range": "A1:D4", "top": false, "bottom": true, "right": true})).unwrap(); + assert_eq!( + names(&s), + [named("First", "Sheet1!$B$1:$B$3"), named("N", "Sheet1!$A$2:$C$2"), named("S", "Sheet1!$A$3:$C$3"), named("Second", "Sheet1!$C$1:$C$3")] + ); + // Existing names are kept unless replacing is asked for. + let r = s.execute("formulas.createFromSelection", json!({"range": "A1:C3", "top": true, "left": true})).unwrap(); + assert_eq!(r["created"], 4); + let r = s.execute("formulas.createFromSelection", json!({"range": "B1:C3", "top": true})).unwrap(); + assert_eq!((r["created"].clone(), r["skipped"].clone()), (json!(0), json!(["Jan", "Feb"]))); + s.execute("cell.set", json!({"cell": "B1", "input": "N"})).unwrap(); + let r = s.execute("formulas.createFromSelection", json!({"range": "B1:B3", "replace": true})).unwrap(); + assert_eq!(r["created"], 1); + assert!(names(&s).contains(&named("N", "Sheet1!$B$2:$B$3"))); + assert!(names(&s).contains(&named("North", "Sheet1!$B$2:$C$2"))); +} + #[test] fn names_that_look_like_references_are_refused() { let mut s = s(); From d4d38fd224814904da1320463c6d571402eb3730 Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:27:39 -0500 Subject: [PATCH 33/35] Names: calls of LAMBDA names keep the name's case The parser upper-cases function names, so =Double(4) showed as =DOUBLE(4). Calls of LAMBDA names are now spelled like the name when a formula is entered and when a file is read. --- crates/engine/src/cmd/edit.rs | 1 + crates/engine/src/tests.rs | 17 ++++++++++ crates/model/src/workbook.rs | 63 +++++++++++++++++++++++++++++++++++ crates/xlsx/src/read.rs | 2 ++ 4 files changed, 83 insertions(+) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index cffe154..82a7195 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -148,6 +148,7 @@ pub(crate) fn input_to_cell(input: &str, old: Option<&Cell>, wb: &mut gridcraft_ parsed = gridcraft_formula::parse(&text); } let expr = parsed.map_err(|e| EngineError::Other(format!("There's a problem with this formula: {e}")))?; + let expr = wb.name_call_case(expr); let mut cell = Cell::formula(Formula::from_expr(expr)); cell.style = style; // Formulas whose result is a date/time get a format from functions like TODAY(). diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index fe07b5f..927c4cd 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -163,6 +163,23 @@ fn names_that_look_like_references_are_refused() { } } +#[test] +fn lambda_names_keep_their_case_in_calls() { + let mut s = s(); + s.execute("formulas.defineName", json!({"name": "Double", "refersTo": "=LAMBDA(x,x*2)"})).unwrap(); + s.execute("formulas.defineName", json!({"name": "Quad", "refersTo": "=LAMBDA(x,Double(Double(x)))"})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": "=double(4)+Quad(1)+sum(1)"})).unwrap(); + assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["formula"], "=Double(4)+Quad(1)+SUM(1)"); + assert_eq!(v(&s, "A1"), Value::Number(13.0)); + let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + let b64 = r["base64"].as_str().unwrap().to_string(); + s.execute("file.open", json!({"name": "x.xlsx", "base64": b64})).unwrap(); + assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["formula"], "=Double(4)+Quad(1)+SUM(1)"); + let quad = s.doc().unwrap().wb.names.iter().find(|n| n.name == "Quad").unwrap().formula.clone(); + assert_eq!(quad, "LAMBDA(x,Double(Double(x)))"); + assert_eq!(v(&s, "A1"), Value::Number(13.0)); +} + #[test] fn sheets() { let mut s = s(); diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 8652b20..6ccef9e 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -3,8 +3,10 @@ use std::sync::Arc; use gridcraft_core::DateSystem; +use gridcraft_formula::Expr; use serde::{Deserialize, Serialize}; +use crate::cell::Formula; use crate::sheet::Sheet; use crate::style::{StyleTable, Theme}; @@ -143,6 +145,53 @@ impl Workbook { .find(|n| n.scope == Some(sheet) && n.name.eq_ignore_ascii_case(name)) .or_else(|| self.names.iter().find(|n| n.scope.is_none() && n.name.eq_ignore_ascii_case(name))) } + /// Spells calls of LAMBDA names the way the names are defined (`Double(4)`, where the + /// parser gives `DOUBLE(4)`: it upper-cases function names, as built-in ones are shown). + pub fn name_call_case(&self, e: Expr) -> Expr { + spell_calls(e, &self.lambda_names()) + } + /// [`Workbook::name_call_case`] for every cell formula and defined name (after reading a + /// file). + pub fn apply_name_call_case(&mut self) { + let lambdas = self.lambda_names(); + if lambdas.is_empty() { + return; + } + for sheet in self.sheets.iter_mut() { + let fixed: Vec<_> = sheet + .cells + .iter() + .filter_map(|(c, cell)| { + let f = cell.formula.as_ref()?; + let e = f.expr()?; + let ne = spell_calls(e.clone(), &lambdas); + (ne != e).then(|| (c, Formula { array: f.array, ..Formula::from_expr(ne) })) + }) + .collect(); + if fixed.is_empty() { + continue; + } + let sheet = Arc::make_mut(sheet); + for (c, f) in fixed { + if let Some(cell) = sheet.cells.get_mut(c) { + cell.formula = Some(Arc::new(f)); + } + } + } + for n in self.names.iter_mut() { + if let Ok(e) = gridcraft_formula::parse(&n.formula) { + let ne = spell_calls(e.clone(), &lambdas); + if ne != e { + n.formula = gridcraft_formula::print(&ne); + } + } + } + } + /// Names of the defined names holding a LAMBDA (callable like functions). + fn lambda_names(&self) -> Vec { + let is_lambda = |f: &str| f.trim_start_matches('=').trim_start().get(..7).is_some_and(|p| p.eq_ignore_ascii_case("LAMBDA(")); + self.names.iter().filter(|n| is_lambda(&n.formula)).map(|n| n.name.clone()).collect() + } /// Table by name across sheets: (sheet index, table index). pub fn table(&self, name: &str) -> Option<(usize, usize)> { for (si, s) in self.sheets.iter().enumerate() { @@ -172,6 +221,20 @@ impl Workbook { } } +/// Calls of the names in `lambdas` spelled as the names are. +fn spell_calls(e: Expr, lambdas: &[String]) -> Expr { + if lambdas.is_empty() { + return e; + } + e.map(&mut |x| match x { + Expr::Call(n, args) => match lambdas.iter().find(|l| l.eq_ignore_ascii_case(&n)) { + Some(l) => Expr::Call(l.clone(), args), + None => Expr::Call(n, args), + }, + other => other, + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 915d8e4..bf45e91 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -279,6 +279,8 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { if wb_rels.iter().any(|r| r.kind == "externalLink") { cx.warn("links to external workbooks are not supported"); } + // Calls of LAMBDA names are spelled like the names (the parser upper-cases them). + wb.apply_name_call_case(); wb.styles = cx.styles; Ok((wb, cx.report)) } From 9c29ef3fffc0834bf30d22d3ecdff5c44df9015e Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:04:10 -0500 Subject: [PATCH 34/35] Formulas: the spill range operator A1# formula::parse("Sheet1!$D$1#") failed with "unknown error literal". A # right after a cell reference is now the spill range operator: it evaluates to the anchor's current spill range, or #REF! when the cell doesn't spill (or its spill is blocked). Its users depend on the anchor, so they recalculate when the spill grows or shrinks. Names can use it. Files have no # operator: like Excel, the writer stores A1# as _xlfn.ANCHORARRAY(A1), and the parser reads that back as A1#. --- crates/calc/src/eval.rs | 19 ++++++++++++++++++- crates/calc/src/recalc.rs | 20 ++++++++++++++++++++ crates/calc/src/tests.rs | 34 ++++++++++++++++++++++++++++++++++ crates/engine/src/tests.rs | 17 +++++++++++++++++ crates/formula/src/ast.rs | 3 +++ crates/formula/src/lexer.rs | 5 +++++ crates/formula/src/parser.rs | 16 ++++++++++++++-- crates/formula/src/printer.rs | 7 +++++++ crates/formula/src/tests.rs | 16 ++++++++++++++++ crates/xlsx/src/fmla.rs | 9 +++++++++ 10 files changed, 143 insertions(+), 3 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index abef54f..39c725c 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -54,6 +54,10 @@ pub trait Host { fn random(&mut self) -> f64; /// Called when a volatile or dynamic-reference function is used. fn note_volatile(&mut self) {} + /// The spill range of the dynamic array anchored at `anchor` (`None`: it doesn't spill). + fn spill_range(&mut self, sheet: usize, anchor: CellRef) -> Option { + self.workbook().sheet(sheet)?.spill_ranges.get(&anchor).copied() + } /// Values of a block, row-major. Hosts can override with a sparse fast path. fn range_values(&mut self, sheet: usize, range: RangeRef) -> Vec { range.iter().map(|c| self.cell_value(sheet, c)).collect() @@ -322,6 +326,19 @@ impl<'h> Evaluator<'h> { } fn unary(&mut self, op: UnOp, x: &Expr) -> Ev { + if op == UnOp::Spill { + return match self.eval(x) { + Ev::R(areas) => match areas.as_slice() { + [a] if a.range.is_single() => match self.host.spill_range(a.sheet, a.range.start) { + Some(range) => Ev::R(vec![Area { sheet: a.sheet, range }]), + None => err(CellError::Ref), + }, + _ => err(CellError::Ref), + }, + Ev::V(Value::Error(e)) => err(e), + _ => err(CellError::Ref), + }; + } if op == UnOp::At { let ev = self.eval(x); return match ev { @@ -343,7 +360,7 @@ impl<'h> Evaluator<'h> { UnOp::Neg => Value::number(-n), UnOp::Plus => Value::number(n), UnOp::Percent => Value::number(n / 100.0), - UnOp::At => Value::number(n), + UnOp::At | UnOp::Spill => Value::number(n), }, op == UnOp::Plus, )) diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 13b47c4..500dec2 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -225,6 +225,26 @@ impl Host for PassHost<'_> { fn workbook(&self) -> &Workbook { self.wb } + fn spill_range(&mut self, sheet: usize, anchor: CellRef) -> Option { + let k = (sheet, anchor); + if !(self.pending.contains(&k) || self.results.contains_key(&k)) { + return self.wb.sheet(sheet)?.spill_ranges.get(&anchor).copied(); + } + // Recalculated in this pass: its new array, unless something blocks it (#SPILL!). + self.compute(k); + let arr = self.spills.get(&k)?; + let end = CellRef::new( + anchor.row.saturating_add((arr.rows as u32).saturating_sub(1)), + anchor.col.saturating_add((arr.cols as u32).saturating_sub(1)), + ); + let range = RangeRef::new(anchor, end); + let sh = self.wb.sheet(sheet)?; + let blocked = end.row >= gridcraft_core::MAX_ROWS + || end.col >= gridcraft_core::MAX_COLS + || sh.cells.iter_range(range).any(|(c, cell)| c != anchor && (cell.formula.is_some() || !cell.value.is_empty())) + || sh.merges.iter().any(|m| m.intersects(&range)); + (!blocked).then_some(range) + } fn cell_value(&mut self, sheet: usize, c: CellRef) -> Value { let k = (sheet, c); if self.pending.contains(&k) || self.results.contains_key(&k) { diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 67db083..535585f 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -33,6 +33,40 @@ impl T { } } +#[test] +fn spill_range_operator() { + let mut t = T::new(); + t.set("A1", "3"); + t.set("B1", "=SEQUENCE(A1)"); + t.set("C1", "=SUM(B1#)"); + t.set("C2", "=ROWS(B1#)"); + assert_eq!((t.num("C1"), t.num("C2")), (6.0, 3.0)); + // Growing and shrinking the spill recalculates its users. + t.set("A1", "5"); + assert_eq!((t.num("C1"), t.num("C2")), (15.0, 5.0)); + t.set("A1", "2"); + assert_eq!((t.num("C1"), t.num("C2")), (3.0, 2.0)); + // A blocked spill, or a cell that doesn't spill, gives #REF!. + t.set("B2", "x"); + assert_eq!(t.get("C1"), Value::Error(CellError::Ref)); + t.set("B2", ""); + assert_eq!(t.num("C1"), 3.0); + t.set("D1", "=SUM(A1#)"); + assert_eq!(t.get("D1"), Value::Error(CellError::Ref)); + // Through a defined name. + t.wb.names.push(gridcraft_model::DefinedName { + name: "Seq".into(), + scope: None, + formula: "Sheet1!$B$1#".into(), + comment: String::new(), + hidden: false, + }); + t.set("E1", "=SUM(Seq)*10"); + assert_eq!(t.num("E1"), 30.0); + t.set("A1", "4"); + assert_eq!(t.num("E1"), 100.0); +} + #[test] fn arithmetic_and_dependencies() { let mut t = T::new(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f3e36cd..61c21b4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -110,6 +110,23 @@ fn formatting_and_styles() { s.execute("home.cellStyle", json!({"range": "D1", "name": "Good"})).unwrap(); } +#[test] +fn spill_references_roundtrip_through_xlsx() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "B1", "input": "=SEQUENCE(4)"})).unwrap(); + s.execute("cell.set", json!({"cell": "C1", "input": "=sum(b1#)"})).unwrap(); + s.execute("formulas.defineName", json!({"name": "Seq", "refersTo": "=Sheet1!$B$1#"})).unwrap(); + s.execute("cell.set", json!({"cell": "C2", "input": "=MAX(Seq)"})).unwrap(); + assert_eq!(s.execute("cell.get", json!({"cell": "C1"})).unwrap()["formula"], "=SUM(B1#)"); + assert_eq!((v(&s, "C1"), v(&s, "C2")), (Value::Number(10.0), Value::Number(4.0))); + let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + let b64 = r["base64"].as_str().unwrap().to_string(); + s.execute("file.open", json!({"name": "x.xlsx", "base64": b64})).unwrap(); + assert_eq!(s.execute("cell.get", json!({"cell": "C1"})).unwrap()["formula"], "=SUM(B1#)"); + assert_eq!(s.doc().unwrap().wb.names[0].formula, "Sheet1!$B$1#"); + assert_eq!((v(&s, "C1"), v(&s, "C2")), (Value::Number(10.0), Value::Number(4.0))); +} + #[test] fn tables_and_structured_refs() { let mut s = s(); diff --git a/crates/formula/src/ast.rs b/crates/formula/src/ast.rs index 51e5068..39f2511 100644 --- a/crates/formula/src/ast.rs +++ b/crates/formula/src/ast.rs @@ -88,6 +88,9 @@ pub enum UnOp { Percent, /// Implicit intersection `@`. At, + /// Spill range operator `A1#`: the whole spill range of the dynamic array anchored at the + /// cell (`ANCHORARRAY` in files). + Spill, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] diff --git a/crates/formula/src/lexer.rs b/crates/formula/src/lexer.rs index b6143ba..14b45d9 100644 --- a/crates/formula/src/lexer.rs +++ b/crates/formula/src/lexer.rs @@ -124,6 +124,11 @@ pub fn tokenize(src: &str) -> Result, LexError> { push(&mut out, Tok::Sheet(a, b), j + 1); i = j + 1; } + '#' if out.last().is_some_and(|t: &Token| t.end == start && matches!(t.tok, Tok::Word(_))) => { + // Spill range operator right after a reference: `A1#`. + push(&mut out, Tok::Op("#"), i + 1); + i += 1; + } '#' => { // Error literal, or #-prefixed struct specifier (only inside [] which we lex as Struct). let rest = src.get(start..).unwrap_or(""); diff --git a/crates/formula/src/parser.rs b/crates/formula/src/parser.rs index f501402..b92e2e6 100644 --- a/crates/formula/src/parser.rs +++ b/crates/formula/src/parser.rs @@ -223,7 +223,14 @@ impl Parser { Tok::LBrace => self.array(), Tok::Func(name) => { let name = normalize_function_name(&name); - let args = self.args()?; + let mut args = self.args()?; + // Files write `A1#` as `_xlfn.ANCHORARRAY(A1)`. + if name == "ANCHORARRAY" + && let [Expr::Ref(Reference { kind: RefKind::Cell(_), .. })] = args.as_slice() + && let Some(r) = args.pop() + { + return Ok(Expr::Unary(UnOp::Spill, Box::new(r))); + } Ok(Expr::Call(name, args)) } Tok::Sheet(a, b) => { @@ -263,7 +270,12 @@ impl Parser { self.next(); return Ok(Expr::Ref(Reference { sheet, kind: RefKind::Range(a, b) })); } - return Ok(Expr::Ref(Reference { sheet, kind: RefKind::Cell(a) })); + let cell = Expr::Ref(Reference { sheet, kind: RefKind::Cell(a) }); + if *self.peek() == Tok::Op("#") { + self.next(); + return Ok(Expr::Unary(UnOp::Spill, Box::new(cell))); + } + return Ok(cell); } let second = match self.peek_at(1) { Tok::Word(w2) => Some(w2.clone()), diff --git a/crates/formula/src/printer.rs b/crates/formula/src/printer.rs index 0aa223e..12e785f 100644 --- a/crates/formula/src/printer.rs +++ b/crates/formula/src/printer.rs @@ -186,6 +186,13 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option) { write_expr(s, x, r1c1); s.push('%'); } + UnOp::Spill => { + write_expr(s, x, r1c1); + // A reference that became #REF! (its sheet or cell was deleted) stays `#REF!`. + if !matches!(**x, Expr::Error(_)) { + s.push('#'); + } + } }, Expr::Binary(op, a, b) => { write_expr(s, a, r1c1); diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index ff84a5b..7ad8262 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -52,6 +52,22 @@ fn normalises() { assert_eq!(rt("'Sheet1'!A1"), "Sheet1!A1"); } +#[test] +fn spill_operator() { + for f in ["A1#", "Sheet1!$D$1#", "SUM(B2#)", "'My Sheet'!A1#*2", "ROWS(A1#)+1", "IF(A1,#N/A,#REF!)"] { + assert_eq!(rt(f), f); + } + assert!(matches!(parse("A1#").unwrap(), Expr::Unary(UnOp::Spill, _))); + // Files spell it ANCHORARRAY. + assert_eq!(rt("_xlfn.ANCHORARRAY(Sheet1!$D$1)"), "Sheet1!$D$1#"); + assert_eq!(rt("SUM(ANCHORARRAY(B2))"), "SUM(B2#)"); + // Only a cell can spill. + assert!(parse("A1:B2#").is_err()); + assert!(parse("Name#").is_err()); + // Deleting the anchor's sheet leaves #REF!. + assert_eq!(print(&crate::adjust::delete_sheet(parse("SUM(Data!A1#)").unwrap(), "Data")), "SUM(#REF!)"); +} + #[test] fn precedence() { let e = parse("1+2*3").unwrap(); diff --git a/crates/xlsx/src/fmla.rs b/crates/xlsx/src/fmla.rs index eee516b..b4298c9 100644 --- a/crates/xlsx/src/fmla.rs +++ b/crates/xlsx/src/fmla.rs @@ -27,6 +27,8 @@ fn expr_to_file(e: Expr) -> String { Some(n) => Expr::Call(n, args), None => Expr::Call(name, args), }, + // Files have no `#` operator: Excel writes `A1#` as `_xlfn.ANCHORARRAY(A1)`. + Expr::Unary(gridcraft_formula::UnOp::Spill, r) => Expr::Call("_xlfn.ANCHORARRAY".into(), vec![*r]), other => other, }); gridcraft_formula::print(&e) @@ -45,6 +47,13 @@ pub fn from_file(text: &str) -> String { mod tests { use super::*; + #[test] + fn spill_references() { + assert_eq!(to_file(&Formula::new("SUM(Sheet1!$D$1#)+B2#")), "SUM(_xlfn.ANCHORARRAY(Sheet1!$D$1))+_xlfn.ANCHORARRAY(B2)"); + assert_eq!(text_to_file("=Sheet1!$D$1#"), "_xlfn.ANCHORARRAY(Sheet1!$D$1)"); + assert_eq!(from_file("SUM(_xlfn.ANCHORARRAY(Sheet1!$D$1))+_xlfn.ANCHORARRAY(B2)"), "SUM(Sheet1!$D$1#)+B2#"); + } + #[test] fn prefixes() { assert_eq!(to_file(&Formula::new("XLOOKUP(1,A:A,B:B)+SUM(A1)")), "_xlfn.XLOOKUP(1,A:A,B:B)+SUM(A1)"); From 06cb377c5445feec23e6d952ddff2be2227d9204 Mon Sep 17 00:00:00 2001 From: agammann <161159040+agammann@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:47:38 -0700 Subject: [PATCH 35/35] Fix escaped quotes in Text to Columns --- .../tests/text_to_columns_quotes.rs | 26 +++++++++++++++++++ crates/engine/src/cmd/data.rs | 10 +++++-- 2 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 apps/gridcraft-cli/tests/text_to_columns_quotes.rs diff --git a/apps/gridcraft-cli/tests/text_to_columns_quotes.rs b/apps/gridcraft-cli/tests/text_to_columns_quotes.rs new file mode 100644 index 0000000..c35f857 --- /dev/null +++ b/apps/gridcraft-cli/tests/text_to_columns_quotes.rs @@ -0,0 +1,26 @@ +use std::process::Command; + +#[test] +fn text_to_columns_preserves_doubled_quotes() { + let dir = std::env::temp_dir().join(format!("gridcraft-t2c-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("make temp directory"); + let script = dir.join("steps.jsonl"); + std::fs::write( + &script, + concat!( + "{\"command\":\"cell.set\",\"params\":{\"cell\":\"A1\",\"input\":\"\\\"a\\\"\\\"b\\\",c\"}}\n", + "{\"command\":\"data.textToColumns\",\"params\":{\"range\":\"A1\",\"delimiters\":[\",\"]}}\n", + ), + ) + .expect("write commands"); + let output = Command::new(env!("CARGO_BIN_EXE_gridcraft-cli")) + .args(["run", "--script"]) + .arg(&script) + .args(["--print", "A1:B1", "--csv", "--quiet"]) + .output() + .expect("run Gridcraft CLI"); + std::fs::remove_file(script).expect("remove script"); + std::fs::remove_dir(dir).expect("remove temp directory"); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "\"a\"\"b\",c"); +} diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 8961d87..2de8e84 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -683,9 +683,15 @@ fn split_delimited(text: &str, delims: &[char], consecutive: bool, quote: char) let mut cur = String::new(); let mut in_q = false; let mut last_delim = false; - for c in text.chars() { + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { if c == quote { - in_q = !in_q; + if in_q && chars.peek() == Some("e) { + cur.push(quote); + chars.next(); + } else { + in_q = !in_q; + } continue; } if !in_q && delims.contains(&c) {