From 1c8d0b8fe5c65425820ad272a9354cc1e4780a02 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 8 Oct 2026 12:06:39 +0200 Subject: [PATCH 01/45] 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 d7a4b8c41..813b85d3a 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 bdbc28d95..e9e05a2e6 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 6acaf5119..9abb15dc9 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/45] 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 9abb15dc9..f02248a8c 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 a3cbb9b9a7d01e1dbe4d7858d52bac26c9fc73be Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 8 Oct 2026 08:13:30 -0400 Subject: [PATCH 03/45] Bump version to 0.2.0 (from 0.1.0) for release Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 32 ++++++++++++++++---------------- Cargo.toml | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f3b1e6426..c6538f604 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1392,7 +1392,7 @@ dependencies = [ [[package]] name = "gridcraft" -version = "0.1.0" +version = "0.2.0" dependencies = [ "eframe", "egui", @@ -1408,7 +1408,7 @@ dependencies = [ [[package]] name = "gridcraft-calc" -version = "0.1.0" +version = "0.2.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1420,7 +1420,7 @@ dependencies = [ [[package]] name = "gridcraft-chart" -version = "0.1.0" +version = "0.2.0" dependencies = [ "gridcraft-calc", "gridcraft-core", @@ -1431,7 +1431,7 @@ dependencies = [ [[package]] name = "gridcraft-cli" -version = "0.1.0" +version = "0.2.0" dependencies = [ "gridcraft-engine", "gridcraft-mcp", @@ -1441,7 +1441,7 @@ dependencies = [ [[package]] name = "gridcraft-core" -version = "0.1.0" +version = "0.2.0" dependencies = [ "serde", "serde_json", @@ -1450,7 +1450,7 @@ dependencies = [ [[package]] name = "gridcraft-engine" -version = "0.1.0" +version = "0.2.0" dependencies = [ "gridcraft-calc", "gridcraft-chart", @@ -1470,7 +1470,7 @@ dependencies = [ [[package]] name = "gridcraft-formula" -version = "0.1.0" +version = "0.2.0" dependencies = [ "gridcraft-core", "serde", @@ -1479,7 +1479,7 @@ dependencies = [ [[package]] name = "gridcraft-functions" -version = "0.1.0" +version = "0.2.0" dependencies = [ "gridcraft-core", "regex", @@ -1487,7 +1487,7 @@ dependencies = [ [[package]] name = "gridcraft-mcp" -version = "0.1.0" +version = "0.2.0" dependencies = [ "gridcraft-engine", "log", @@ -1496,7 +1496,7 @@ dependencies = [ [[package]] name = "gridcraft-model" -version = "0.1.0" +version = "0.2.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1507,18 +1507,18 @@ dependencies = [ [[package]] name = "gridcraft-numfmt" -version = "0.1.0" +version = "0.2.0" dependencies = [ "gridcraft-core", ] [[package]] name = "gridcraft-pdf" -version = "0.1.0" +version = "0.2.0" [[package]] name = "gridcraft-ui-egui" -version = "0.1.0" +version = "0.2.0" dependencies = [ "egui", "egui_kittest", @@ -1533,7 +1533,7 @@ dependencies = [ [[package]] name = "gridcraft-web" -version = "0.1.0" +version = "0.2.0" dependencies = [ "eframe", "egui", @@ -1550,7 +1550,7 @@ dependencies = [ [[package]] name = "gridcraft-xlsx" -version = "0.1.0" +version = "0.2.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -4287,7 +4287,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.1.0" +version = "0.2.0" dependencies = [ "gridcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index a7f99e0cb..a73ac4a3e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"] [workspace.package] -version = "0.1.0" +version = "0.2.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From 64c223c5f84bbfaaf6fc7909c13bda9f6ebadcfc Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 8 Oct 2026 09:31:42 -0400 Subject: [PATCH 04/45] Release: Flatpak x86_64/aarch64, AppImage zsync updates, README Downloads (#14) - AppImages embed gh-releases-zsync update information and ship a .zsync beside them; the linux job installs zsync and checks both. - New flatpak job (x86_64 on ubuntu-24.04, aarch64 on ubuntu-24.04-arm) repackages the linux tarball with packaging/linux/flatpak-bundle.sh and the new bundle manifest; the from-source manifest gets the same commented finish-args, and packaging-lint checks that the two agree. - Jobs that sign nothing (version, linux, flatpak, web, FreeBSD packaging) no longer use the release environment, so a branch dispatch is a dry run; the draft-release job still needs the signed jobs and only runs on the release branch. - README: Downloads section listing every build. Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- .github/workflows/freebsd.yml | 7 +- .github/workflows/packaging-lint.yml | 11 ++- .github/workflows/release.yml | 67 ++++++++++++++--- README.md | 44 ++++++++++++ docs/releasing.md | 24 +++++-- packaging/linux/flatpak-bundle.sh | 72 +++++++++++++++++++ .../ai.storyteller.gridcraft.bundle.yml | 49 +++++++++++++ .../flatpak/ai.storyteller.gridcraft.yml | 10 ++- packaging/linux/package.sh | 30 ++++++-- 9 files changed, 287 insertions(+), 27 deletions(-) create mode 100755 packaging/linux/flatpak-bundle.sh create mode 100644 packaging/linux/flatpak/ai.storyteller.gridcraft.bundle.yml diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 3e2370996..b8efbdce7 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -1,7 +1,8 @@ # Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner # (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter. -# Builds run only from the `release` branch: release.yml calls this workflow, and a manual run -# from any other branch is skipped. +# Test builds run only from the `release` branch (a manual run from any other branch is skipped). +# Packaging runs (release.yml calls it that way) run from any branch, so a release.yml dry run +# from a branch builds the tarball too; they need no secrets. # # With `package: true` (release.yml calls it that way, or a manual run) it instead builds the # release binaries and uploads `gridcraft--freebsd-x86_64.tar.gz` as the @@ -41,7 +42,7 @@ permissions: jobs: freebsd: name: FreeBSD 14 (${{ inputs.package && 'package' || 'build + test' }}) - if: github.ref == 'refs/heads/release' + if: github.ref == 'refs/heads/release' || inputs.package runs-on: ubuntu-latest timeout-minutes: 300 env: diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index ab3ad17ef..ffd5fe76d 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -54,4 +54,13 @@ jobs: desktop-file-validate packaging/linux/ai.storyteller.gridcraft.desktop sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.gridcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.gridcraft.metainfo.xml" appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.gridcraft.metainfo.xml" - python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.gridcraft.yml')); assert m['id'] == 'ai.storyteller.gridcraft' and m['command'] == 'gridcraft'" + python3 - <<'EOF' + import yaml + src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.gridcraft.yml")) + bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.gridcraft.bundle.yml")) + for m in (src, bundle): + assert m["id"] == "ai.storyteller.gridcraft" and m["command"] == "gridcraft", m["id"] + for key in ("runtime", "runtime-version", "sdk", "finish-args"): + assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}" + print("Flatpak manifests ok") + EOF diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ee6058148..de15fd8a2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,9 +2,12 @@ # creates or updates the draft GitHub Release "GridCraft v". See docs/releasing.md. # Copied from DesignCraft (craftrules release/playbook.md); keep the two in step. # -# All jobs run in the `release` environment (restricted to the `release` branch), which holds -# the signing secrets. Every secret is optional: missing ones produce unsigned artifacts and a -# warning, never a failed build. +# The signing jobs (macOS, Windows) and the draft-release job run in the `release` environment +# (restricted to the `release` branch), which holds the signing secrets. Every secret is +# optional: missing ones produce unsigned artifacts and a warning, never a failed build. The +# other jobs sign nothing and get no secrets, so a manual run from another branch is a dry run: +# they build and upload their artifacts, the environment refuses the signing jobs, and the +# draft-release job (which needs them) is skipped. name: Release on: @@ -35,10 +38,8 @@ env: jobs: version: name: version - # Only the release branch builds; a manual run from any other branch skips every job. - if: github.ref == 'refs/heads/release' runs-on: ubuntu-latest - environment: release + # No `environment: release`: this job needs no secrets (see the header about dry runs). outputs: version: ${{ steps.v.outputs.version }} date: ${{ steps.v.outputs.date }} @@ -178,7 +179,7 @@ jobs: # The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+, # Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md. runs-on: ${{ matrix.runner }} - environment: release + # No `environment: release`: this job signs nothing, so it gets no release secrets. strategy: fail-fast: false matrix: @@ -203,7 +204,7 @@ jobs: sudo apt-get update sudo apt-get install -y --no-install-recommends \ libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \ - desktop-file-utils appstream python3-yaml + desktop-file-utils appstream python3-yaml zsync 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" @@ -228,6 +229,9 @@ jobs: for appimage in dist/release/*.AppImage; do chmod +x "$appimage" APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version + # Update information is embedded, and the .zsync for delta updates sits beside it. + "$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|' + test -s "$appimage.zsync" done - uses: actions/upload-artifact@v4 with: @@ -235,11 +239,49 @@ jobs: path: dist/release/* if-no-files-found: error + flatpak: + name: Flatpak ${{ matrix.arch }} + needs: [version, linux] + # Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust + # build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships. + runs-on: ${{ matrix.runner }} + # No `environment: release`: this job signs nothing, so it gets no release secrets. + strategy: + fail-fast: false + matrix: + include: + - { arch: x86_64, runner: ubuntu-24.04 } + - { arch: aarch64, runner: ubuntu-24.04-arm } + env: + GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }} + GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: release-linux-${{ matrix.arch }} + path: dist/linux + - name: Install flatpak-builder + run: | + sudo apt-get update + # librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's + # `appstreamcli compose`, which renders the scalable icon and fails without it. + sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common + # Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs. + 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 + with: + name: release-flatpak-${{ matrix.arch }} + path: dist/release/*.flatpak + if-no-files-found: error + web: name: Web (wasm) needs: version runs-on: ubuntu-latest - environment: release + # No `environment: release`: this job signs nothing, so it gets no release secrets. env: GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }} GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} @@ -280,7 +322,7 @@ jobs: # FreeBSD has no hosted runners: freebsd.yml builds in a FreeBSD VM and, with package: true, # uploads the release-freebsd artifact (gridcraft--freebsd-x86_64.tar.gz). It needs no - # secrets, so the called workflow runs without the release environment. + # secrets, so the called workflow runs without the release environment (and in dry runs). freebsd: name: FreeBSD needs: version @@ -292,7 +334,10 @@ jobs: release: name: Draft GitHub Release - needs: [version, macos, windows, linux, web, freebsd] + needs: [version, macos, windows, linux, flatpak, web, freebsd] + # Belt and braces for branch dry runs: only the release branch drafts a release (the + # environment's branch rule refuses this job and the signing jobs anywhere else, too). + if: github.ref == 'refs/heads/release' runs-on: ubuntu-latest environment: release permissions: diff --git a/README.md b/README.md index d7a4b8c41..5819f74a0 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,50 @@ alpha. Next up: signed release builds, XLSX fidelity on real-world files, perfor sheets, then slicers, Solver, chart trendlines and a true Page Layout view. The plan and estimates are in [ROADMAP.md](ROADMAP.md). +## Downloads + +Every [release](https://github.com/storytold/gridcraft/releases/latest) ships these builds. `` is the +version number; `SHA256SUMS.txt` lists a checksum for every file. + +### macOS + +| Build | File | Notes | +|---|---|---| +| App, universal (Apple silicon + Intel) | `gridcraft--macos-universal.dmg` | Signed and notarized | +| Command-line tool, universal | `gridcraft-cli--macos-universal.zip` | Signed and notarized | + +### Windows + +| Build | Installer | Portable | +|---|---|---| +| x64 (64-bit Intel/AMD) | `gridcraft--windows-x64.msi` | `gridcraft--windows-x64-portable.zip` | +| arm64 (Snapdragon and other ARM PCs) | `gridcraft--windows-arm64.msi` | `gridcraft--windows-arm64-portable.zip` | +| x86 (32-bit) | `gridcraft--windows-x86.msi` | `gridcraft--windows-x86-portable.zip` | + +Installers and executables are code-signed. + +### Linux + +| Format | x86_64 | aarch64 (ARM64) | Notes | +|---|---|---|---| +| AppImage | `gridcraft--linux-x86_64.AppImage` | `gridcraft--linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) | +| Flatpak | `gridcraft--linux-x86_64.flatpak` | `gridcraft--linux-aarch64.flatpak` | Sandboxed; `flatpak install --user ` | +| Debian/Ubuntu | `gridcraft--linux-x86_64.deb` | `gridcraft--linux-aarch64.deb` | | +| Fedora/RHEL/openSUSE | `gridcraft--linux-x86_64.rpm` | `gridcraft--linux-aarch64.rpm` | | +| Tarball | `gridcraft--linux-x86_64.tar.gz` | `gridcraft--linux-aarch64.tar.gz` | Unpack anywhere | + +### FreeBSD + +| Build | File | +|---|---| +| x86_64 | `gridcraft--freebsd-x86_64.tar.gz` | + +### Web (WebAssembly) + +| Build | File | Notes | +|---|---|---| +| Static site | `gridcraft-web-.zip` | Runs in a modern browser; host it on any static server | + ## The Crafting Apps GridCraft is one of the **Crafting Apps**: free, open-source creative tools from the diff --git a/docs/releasing.md b/docs/releasing.md index 81761c7ae..352409e85 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -67,8 +67,10 @@ After the draft is published, the workflow refuses to touch that version again, **Test runs:** *Actions → Release → Run workflow* runs the whole pipeline by hand. The optional `version` input (such as `0.2.0-rc.1`) overrides `Cargo.toml` for that run only; each job applies it with `cargo xtask version set` before building, so the binaries report it too. The -run still needs the `release` environment, which only the `release` branch can use, so pick -that branch in the dialog. +signing jobs and the draft release need the `release` environment, which only the `release` +branch can use. Run from any other branch, it's a dry run: Linux, Flatpak, FreeBSD and web build +and upload their artifacts, the environment refuses macOS and Windows, and no release is drafted +(`gh workflow run release.yml --ref `). ## What gets built @@ -78,8 +80,9 @@ that branch in the dialog. | Windows 10+ x64 | `gridcraft--windows-x64.msi`, `gridcraft--windows-x64-portable.zip` | `windows-latest` | | Windows 10+ x86 (32-bit) | `gridcraft--windows-x86.msi`, `gridcraft--windows-x86-portable.zip` | `windows-latest` | | Windows 11 ARM64 | `gridcraft--windows-arm64.msi`, `…-portable.zip` (cross-compiled, signed like x64) | `windows-latest`; installed and run on `windows-11-arm` by `windows-arm64.yml` | -| Linux x86_64 | `gridcraft--linux-x86_64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04` | -| Linux aarch64 | `gridcraft--linux-aarch64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04-arm` | +| Linux x86_64 | `gridcraft--linux-x86_64.{AppImage,AppImage.zsync,deb,rpm,tar.gz}` | `ubuntu-22.04` | +| Linux aarch64 | `gridcraft--linux-aarch64.{AppImage,AppImage.zsync,deb,rpm,tar.gz}` | `ubuntu-22.04-arm` | +| Flatpak x86_64, aarch64 | `gridcraft--linux-.flatpak` (repackages the Linux tarball) | `ubuntu-24.04`, `ubuntu-24.04-arm` | | FreeBSD 14 x86_64 | `gridcraft--freebsd-x86_64.tar.gz` | FreeBSD 14.3 VM (`freebsd.yml`, called by `release.yml`) | | Web | `gridcraft-web-.zip` (static site; see [`packaging/web/README.md`](../packaging/web/README.md)) | `ubuntu-latest` | @@ -137,10 +140,19 @@ Locally on Windows: `dotnet tool install -g wix --version 5.0.2`, then binaries need glibc ≥ 2.35. X11/Wayland/xkbcommon/Vulkan/EGL are loaded at runtime; the packages declare them (see `nfpm.yaml`). +Each AppImage embeds update information +(`gh-releases-zsync|storytold|gridcraft|latest|gridcraft-*-linux-.AppImage.zsync`), and the +matching `.zsync` is published beside it, so AppImageUpdate and AppImageLauncher fetch only the +changed blocks of the newest published release. appimagetool writes the `.zsync` when +`zsyncmake` (the `zsync` package) is installed; without it the script warns and skips it. + Flatpak: `packaging/linux/flatpak/ai.storyteller.gridcraft.yml` builds from source and is ready for a Flathub submission (freedesktop 25.08; Wayland + X11 fallback, `dri`, IPC; Documents and -Downloads; everything else through portals). CI validates it but doesn't build it; build by hand -with the commands in its header. +Downloads; everything else through portals); build it by hand with the commands in its header. +The single-file `.flatpak` on each release comes from `packaging/linux/flatpak-bundle.sh` and +`ai.storyteller.gridcraft.bundle.yml`, which repackage the release's Linux tarball (no Rust +build) and smoke-test the result in the sandbox. packaging-lint checks that both manifests +agree on runtime and finish-args. Locally (on Linux): install [nfpm](https://nfpm.goreleaser.com/install/), then `packaging/linux/package.sh` (or `--formats "deb tar"`). diff --git a/packaging/linux/flatpak-bundle.sh b/packaging/linux/flatpak-bundle.sh new file mode 100755 index 000000000..27b67b34d --- /dev/null +++ b/packaging/linux/flatpak-bundle.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Repackage a GridCraft Linux tarball as a single-file Flatpak bundle: +# +# $DIST/gridcraft--linux-.flatpak +# +# Usage: packaging/linux/flatpak-bundle.sh [--no-test] [TARBALL] +# +# TARBALL defaults to $DIST/gridcraft--linux-.tar.gz from package.sh. The bundle +# is built for the host architecture (x86_64 or aarch64), which must match the tarball's. +# Needs flatpak, flatpak-builder and the SVG pixbuf loader (librsvg2-common) for the host's +# `appstreamcli compose`; the freedesktop runtime and SDK named in the manifest are +# installed per-user from Flathub. Unless --no-test, the bundle is then installed per-user and +# `gridcraft-cli --version` is run inside the sandbox as a smoke test. +# Manifest: packaging/linux/flatpak/ai.storyteller.gridcraft.bundle.yml. +set -euo pipefail +# shellcheck source=../env.sh +. "$(dirname "${BASH_SOURCE[0]}")/../env.sh" +HERE="$ROOT/packaging/linux" +APP_ID=ai.storyteller.gridcraft +FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo + +TEST=1 +TARBALL="" +while [ $# -gt 0 ]; do + case "$1" in + --no-test) TEST=0; shift ;; + -h | --help) sed -n '2,14p' "$0"; exit 0 ;; + -*) echo "unknown argument: $1" >&2; exit 2 ;; + *) TARBALL="$1"; shift ;; + esac +done + +ARCH="$(uname -m)" +case "$ARCH" in + x86_64) ;; + aarch64 | arm64) ARCH=aarch64 ;; + *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; +esac +BASENAME="gridcraft-$VERSION-linux-$ARCH" +TARBALL="${TARBALL:-$DIST/$BASENAME.tar.gz}" +[ -f "$TARBALL" ] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; } +for tool in flatpak flatpak-builder; do + command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; } +done + +echo "==> GridCraft $VERSION Flatpak bundle for $ARCH from $(basename "$TARBALL")" + +WORK="$CARGO_TARGET_DIR/flatpak-bundle" +rm -rf "$WORK" +mkdir -p "$WORK/stage" +tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1 +[ -x "$WORK/stage/bin/gridcraft" ] || { echo "error: $TARBALL has no bin/gridcraft" >&2; exit 1; } +cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml" + +flatpak remote-add --user --if-not-exists flathub "$FLATHUB" +# --disable-rofiles-fuse: no FUSE in containers and CI runners. +flatpak-builder --user --install-deps-from=flathub --disable-rofiles-fuse --force-clean \ + --default-branch=stable --repo="$WORK/repo" --state-dir="$WORK/state" "$WORK/build" "$WORK/$APP_ID.yml" + +OUT="$DIST/$BASENAME.flatpak" +# --runtime-repo lets `flatpak install` fetch the freedesktop runtime from Flathub if missing. +flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable +echo "wrote $OUT" + +if [ "$TEST" = 1 ]; then + flatpak install --user -y --noninteractive --reinstall "$OUT" + flatpak info --user "$APP_ID" + flatpak run --command=gridcraft-cli "$APP_ID" --version + flatpak run --command=sh "$APP_ID" -c 'ls /app/share/applications /app/share/metainfo /app/share/mime/packages /app/share/icons/hicolor/scalable/apps' +fi +echo "==> done" +ls -lh "$OUT" diff --git a/packaging/linux/flatpak/ai.storyteller.gridcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.gridcraft.bundle.yml new file mode 100644 index 000000000..2903122ed --- /dev/null +++ b/packaging/linux/flatpak/ai.storyteller.gridcraft.bundle.yml @@ -0,0 +1,49 @@ +# Flatpak manifest for the single-file bundle attached to every GitHub release +# (gridcraft--linux-.flatpak). Build it with packaging/linux/flatpak-bundle.sh. +# +# It repackages the release's own Linux tarball (gridcraft--linux-.tar.gz, +# built on Ubuntu 22.04 by package.sh) instead of compiling Rust inside flatpak-builder: no +# network and no vendored crate sources during the build, a few seconds instead of 20+ minutes, +# and byte-for-byte the same binaries as the AppImage/deb/rpm. The script unpacks the tarball +# into `stage/` next to a copy of this file. The binaries need glibc >= 2.35 and load X11, +# Wayland, xkbcommon and Vulkan/GL at runtime; the freedesktop runtime has all of them, and the +# GPU driver comes from its org.freedesktop.Platform.GL extension. +# +# ai.storyteller.gridcraft.yml (same directory) is the from-source manifest for Flathub. Keep +# runtime, runtime-version, sdk and finish-args identical in both (packaging-lint checks this). +id: ai.storyteller.gridcraft +runtime: org.freedesktop.Platform +runtime-version: '25.08' +sdk: org.freedesktop.Sdk +command: gridcraft +finish-args: + # Display: Wayland first; X11 only when no Wayland compositor is running. + - --socket=wayland + - --socket=fallback-x11 + # X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails. + - --share=ipc + # GPU access for rendering. + - --device=dri + # Open/Save dialogs use the file-chooser portal (rfd), and drag-and-drop uses the document + # portal, so broad filesystem access isn't needed. Documents and Downloads are granted for + # opening workbooks by path from the command line. + - --filesystem=xdg-documents + - --filesystem=xdg-download + # Deliberately not granted: network (the optional --control channel listens on 127.0.0.1 in + # the sandbox; `flatpak override --user --share=network ai.storyteller.gridcraft` exposes it to + # host agents) and home/host filesystem access. +modules: + - name: gridcraft + buildsystem: simple + build-commands: + - install -Dm755 bin/gridcraft /app/bin/gridcraft + # Headless CLI and MCP server: `flatpak run --command=gridcraft-cli ai.storyteller.gridcraft`. + - install -Dm755 bin/gridcraft-cli /app/bin/gridcraft-cli + - install -Dm644 share/applications/ai.storyteller.gridcraft.desktop /app/share/applications/ai.storyteller.gridcraft.desktop + - install -Dm644 share/mime/packages/ai.storyteller.gridcraft.xml /app/share/mime/packages/ai.storyteller.gridcraft.xml + # Already has the release's version and date substituted by package.sh. + - install -Dm644 share/metainfo/ai.storyteller.gridcraft.metainfo.xml /app/share/metainfo/ai.storyteller.gridcraft.metainfo.xml + - mkdir -p /app/share/icons /app/share/doc && cp -R share/icons/hicolor /app/share/icons/ && cp -R share/doc/gridcraft /app/share/doc/ + sources: + - type: dir + path: stage diff --git a/packaging/linux/flatpak/ai.storyteller.gridcraft.yml b/packaging/linux/flatpak/ai.storyteller.gridcraft.yml index 3d7e8f8e6..8bde5db6d 100644 --- a/packaging/linux/flatpak/ai.storyteller.gridcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.gridcraft.yml @@ -8,6 +8,8 @@ # flatpak run ai.storyteller.gridcraft # # For Flathub, replace the `dir` source with a `git` source pinned to a release tag and commit. +# The single-file .flatpak attached to each release comes from ai.storyteller.gridcraft.bundle.yml, +# which repackages the prebuilt release binaries. Keep runtime-version and finish-args identical. id: ai.storyteller.gridcraft runtime: org.freedesktop.Platform runtime-version: '25.08' @@ -16,15 +18,21 @@ sdk-extensions: - org.freedesktop.Sdk.Extension.rust-stable command: gridcraft finish-args: - - --share=ipc + # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland - --socket=fallback-x11 + # X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails. + - --share=ipc + # GPU access for rendering. - --device=dri # Open/Save dialogs use the file-chooser portal (rfd), and drag-and-drop uses the document # portal, so broad filesystem access isn't needed. Documents and Downloads are granted for # opening workbooks by path from the command line. - --filesystem=xdg-documents - --filesystem=xdg-download + # Deliberately not granted: network (the optional --control channel listens on 127.0.0.1 in + # the sandbox; `flatpak override --user --share=network ai.storyteller.gridcraft` exposes it to + # host agents) and home/host filesystem access. build-options: append-path: /usr/lib/sdk/rust-stable/bin modules: diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 47b588199..20ad1b2b8 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -2,6 +2,7 @@ # Build and package GridCraft for Linux ( is x86_64 or aarch64): # # $DIST/gridcraft--linux-.AppImage any distro with glibc >= the build host's +# $DIST/gridcraft--linux-.AppImage.zsync delta updates (needs zsyncmake) # $DIST/gridcraft--linux-.deb Debian, Ubuntu, Mint, Pop!_OS, ... # $DIST/gridcraft--linux-.rpm Fedora, openSUSE, RHEL, ... # $DIST/gridcraft--linux-.tar.gz plain FHS-style tree (bin/, share/) @@ -10,7 +11,8 @@ # # Needs: cargo; nfpm for deb/rpm (https://nfpm.goreleaser.com); appimagetool for the AppImage # (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04, -# glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli. +# glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli, +# zsyncmake (the zsync package) for the AppImage's .zsync. set -euo pipefail # shellcheck source=../env.sh . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" @@ -23,7 +25,7 @@ while [ $# -gt 0 ]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --formats) FORMATS="$2"; shift 2 ;; - -h | --help) sed -n '2,15p' "$0"; exit 0 ;; + -h | --help) sed -n '2,17p' "$0"; exit 0 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esac done @@ -108,11 +110,29 @@ if has appimage; then chmod +x "$TOOL" fi fi - OUT="$DIST/$BASENAME.AppImage" + # Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR or APPIMAGETOOL may be + # relative, e.g. target/agent-). + OUT="$(cd "$DIST" && pwd)/$BASENAME.AppImage" + APPDIR="$(cd "$APPDIR" && pwd)" + TOOL="$(cd "$(dirname "$TOOL")" && pwd)/$(basename "$TOOL")" + # A .zsync left by an earlier run would hide a missing zsyncmake and describe another file. + rm -f "$OUT.zsync" + # Update information: AppImageUpdate, AppImageLauncher and the like read it from the file and + # fetch only the blocks that changed in a newer release, through the .zsync published next to + # each AppImage on GitHub Releases. `latest` is the newest published release that is not a + # pre-release. A fork's builds point at its own releases through GITHUB_REPOSITORY. + REPO="${GITHUB_REPOSITORY:-storytold/gridcraft}" + UPDATE_INFO="gh-releases-zsync|${REPO%%/*}|${REPO#*/}|latest|gridcraft-*-linux-$ARCH.AppImage.zsync" # Extract-and-run: works without FUSE (containers, CI). The output embeds the static runtime, - # so users don't need libfuse2 either. - ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream "$APPDIR" "$OUT" + # so users don't need libfuse2 either. With zsyncmake on the host (CI installs the zsync + # package) appimagetool also writes the .zsync, into its working directory, hence the cd. + (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream -u "$UPDATE_INFO" "$APPDIR" "$OUT") echo "wrote $OUT" + if [ -s "$OUT.zsync" ]; then + echo "wrote $OUT.zsync" + else + warn "zsyncmake not found, so $OUT.zsync was not written; AppImage delta updates need it" + fi fi "$STAGE/usr/bin/gridcraft-cli" --version From 26a6969626b4e57baecd6291165f0049ab56870a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 8 Oct 2026 09:50:17 -0400 Subject: [PATCH 05/45] README Downloads: where to get it first, then builds with Windows before macOS (#15) Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- README.md | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 5819f74a0..f5aa689cf 100644 --- a/README.md +++ b/README.md @@ -162,15 +162,7 @@ are in [ROADMAP.md](ROADMAP.md). ## Downloads -Every [release](https://github.com/storytold/gridcraft/releases/latest) ships these builds. `` is the -version number; `SHA256SUMS.txt` lists a checksum for every file. - -### macOS - -| Build | File | Notes | -|---|---|---| -| App, universal (Apple silicon + Intel) | `gridcraft--macos-universal.dmg` | Signed and notarized | -| Command-line tool, universal | `gridcraft-cli--macos-universal.zip` | Signed and notarized | +**Download GridCraft** from GitHub: the [latest release](https://github.com/storytold/gridcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/gridcraft/releases) has earlier versions and their notes. `` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file. ### Windows @@ -182,6 +174,13 @@ version number; `SHA256SUMS.txt` lists a checksum for every file. Installers and executables are code-signed. +### macOS + +| Build | File | Notes | +|---|---|---| +| App, universal (Apple silicon + Intel) | `gridcraft--macos-universal.dmg` | Signed and notarized | +| Command-line tool, universal | `gridcraft-cli--macos-universal.zip` | Signed and notarized | + ### Linux | Format | x86_64 | aarch64 (ARM64) | Notes | From fb823899c57b41703edcad2b6476cf4b8a01dfc4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 8 Oct 2026 10:02:29 -0400 Subject: [PATCH 06/45] Bump version to 0.3.0 (from 0.2.0) for release Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 32 ++++++++++++++++---------------- Cargo.toml | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c6538f604..29bd97ae6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1392,7 +1392,7 @@ dependencies = [ [[package]] name = "gridcraft" -version = "0.2.0" +version = "0.3.0" dependencies = [ "eframe", "egui", @@ -1408,7 +1408,7 @@ dependencies = [ [[package]] name = "gridcraft-calc" -version = "0.2.0" +version = "0.3.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1420,7 +1420,7 @@ dependencies = [ [[package]] name = "gridcraft-chart" -version = "0.2.0" +version = "0.3.0" dependencies = [ "gridcraft-calc", "gridcraft-core", @@ -1431,7 +1431,7 @@ dependencies = [ [[package]] name = "gridcraft-cli" -version = "0.2.0" +version = "0.3.0" dependencies = [ "gridcraft-engine", "gridcraft-mcp", @@ -1441,7 +1441,7 @@ dependencies = [ [[package]] name = "gridcraft-core" -version = "0.2.0" +version = "0.3.0" dependencies = [ "serde", "serde_json", @@ -1450,7 +1450,7 @@ dependencies = [ [[package]] name = "gridcraft-engine" -version = "0.2.0" +version = "0.3.0" dependencies = [ "gridcraft-calc", "gridcraft-chart", @@ -1470,7 +1470,7 @@ dependencies = [ [[package]] name = "gridcraft-formula" -version = "0.2.0" +version = "0.3.0" dependencies = [ "gridcraft-core", "serde", @@ -1479,7 +1479,7 @@ dependencies = [ [[package]] name = "gridcraft-functions" -version = "0.2.0" +version = "0.3.0" dependencies = [ "gridcraft-core", "regex", @@ -1487,7 +1487,7 @@ dependencies = [ [[package]] name = "gridcraft-mcp" -version = "0.2.0" +version = "0.3.0" dependencies = [ "gridcraft-engine", "log", @@ -1496,7 +1496,7 @@ dependencies = [ [[package]] name = "gridcraft-model" -version = "0.2.0" +version = "0.3.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1507,18 +1507,18 @@ dependencies = [ [[package]] name = "gridcraft-numfmt" -version = "0.2.0" +version = "0.3.0" dependencies = [ "gridcraft-core", ] [[package]] name = "gridcraft-pdf" -version = "0.2.0" +version = "0.3.0" [[package]] name = "gridcraft-ui-egui" -version = "0.2.0" +version = "0.3.0" dependencies = [ "egui", "egui_kittest", @@ -1533,7 +1533,7 @@ dependencies = [ [[package]] name = "gridcraft-web" -version = "0.2.0" +version = "0.3.0" dependencies = [ "eframe", "egui", @@ -1550,7 +1550,7 @@ dependencies = [ [[package]] name = "gridcraft-xlsx" -version = "0.2.0" +version = "0.3.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -4287,7 +4287,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.2.0" +version = "0.3.0" dependencies = [ "gridcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index a73ac4a3e..89f268ead 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"] [workspace.package] -version = "0.2.0" +version = "0.3.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From 881bab8b6ae44d872895835b0fc4dcae96b0fccc Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Thu, 8 Oct 2026 19:40:12 -0600 Subject: [PATCH 07/45] 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 f5aa689cf..97a2eadaa 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 000000000..ccd0c4596 --- /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 6acaf5119..c342ae41b 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 be33eba7d1101fe7427a08adb99994c7d76ec9de Mon Sep 17 00:00:00 2001 From: Xus Badia Date: Thu, 8 Oct 2026 15:12:32 +0200 Subject: [PATCH 08/45] macOS DMG: branded window background and icon layout The DMG opened as a plain Finder window. It now shows the app icon's figure on the GridCraft colour, with GridCraft.app and the Applications link side by side. Same approach as PhotoCraft's and VectorCraft's (storytold/photocraft#1201, storytold/vectorcraft#493). - packaging/macos/dmg/: background.svg (source; text outlined, no paper grain, the figure an of assets/app-icon), background.tiff (1x + 2x HiDPI, 16-colour palette, Deflate, sRGB; ~26 KB) and dmg-layout.DS_Store (Finder view settings: window, icon size 128, positions, background). - generate.py writes the TIFF and the layout on any OS, byte-for-byte reproducible, with nothing from the machine that ran it (no paths, users, disk names or volume UUIDs). - package.sh copies them into the staging folder; the image is still built with hdiutil makehybrid. Nothing extra runs in the signing job. - The volume name drops the version: the layout finds the background through an alias that includes the volume name. The DMG file name keeps the version. Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.md | 2 + docs/releasing.md | 3 + packaging/macos/dmg/README.md | 44 +++++++ packaging/macos/dmg/background.svg | 31 +++++ packaging/macos/dmg/background.tiff | Bin 0 -> 27312 bytes packaging/macos/dmg/dmg-layout.DS_Store | Bin 0 -> 16388 bytes packaging/macos/dmg/generate.py | 158 ++++++++++++++++++++++++ packaging/macos/package.sh | 7 +- 8 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 packaging/macos/dmg/README.md create mode 100644 packaging/macos/dmg/background.svg create mode 100644 packaging/macos/dmg/background.tiff create mode 100644 packaging/macos/dmg/dmg-layout.DS_Store create mode 100644 packaging/macos/dmg/generate.py diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 35a5d7d1b..36678d8a4 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -18,6 +18,8 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | | `docs/brand/LICENSE-brand.txt` | (licence text) | craftrules | — | | | `assets/app-icon/gridcraft.svg` | GridCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | PLACEHOLDER app icon (ledger page); replaced by the owner's creature art later | +| `packaging/macos/dmg/background.svg` | @XusBadia | original (hand-written SVG, derived from the GridCraft app icon `assets/app-icon/gridcraft.svg`, referenced via ``, not copied; text is outlined glyph paths; `packaging/macos/dmg/generate.py` renders it to `background.tiff`) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | GridCraft macOS DMG window background for the Finder window; added 2026-10-08 | +| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") | | `assets/app-icon/gridcraft-1024.png` | GridCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, rendered from gridcraft.svg | | `assets/app-icon/gridcraft-macos-512.png` | GridCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, macOS grid render | | `assets/app-icon/gridcraft.icns` | GridCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, macOS bundle icon | diff --git a/docs/releasing.md b/docs/releasing.md index 352409e85..f4a28eba7 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -101,6 +101,9 @@ and upload their artifacts, the environment refuses macOS and Windows, and no re - **Notarization:** the app is zipped and sent with `xcrun notarytool submit --wait`, then stapled. The DMG (with an `Applications` link) is signed, notarized and stapled too, and checked with `codesign --verify --strict`, `stapler validate` and `spctl -a -vvv`. + Its Finder window (background, icon size and positions) comes from + [`packaging/macos/dmg/`](../packaging/macos/dmg/README.md), and its volume is named `GridCraft` + without the version, which the window's background needs; the DMG file name keeps the version. - **CLI:** the universal `gridcraft-cli` is signed, zipped and notarized. Locally, without certificates, the script signs ad-hoc (`codesign -s -`) and skips notarization: diff --git a/packaging/macos/dmg/README.md b/packaging/macos/dmg/README.md new file mode 100644 index 000000000..0a6233d41 --- /dev/null +++ b/packaging/macos/dmg/README.md @@ -0,0 +1,44 @@ +# macOS DMG window + +What Finder shows when the DMG opens: a 660 × 400 pt background with the app icon and the +`Applications` link side by side. `package.sh` copies these files into the image; nothing here is +generated at build time, so the DMG still builds with `hdiutil makehybrid` (no mounted device, no +Finder scripting on CI, nothing extra installed in the signing job). + +| File | What | +|---|---| +| `background.svg` | Source of the background: the app icon (`assets/app-icon/gridcraft.svg`, linked, not copied) cropped as a cover on the GridCraft colour field (`#1f9d55`), Ink and Paper. Its text (Inter, JetBrains Mono) is outlined, so rendering it needs no fonts. No paper grain: noise doesn't compress, and this keeps `background.tiff` small. | +| `background.tiff` | The background at 1x (660 × 400 px, 72 dpi) and 2x (1320 × 800 px, 144 dpi) in one HiDPI TIFF (16-colour palette, Deflate, sRGB). Goes to `.background/background.tiff`. | +| `dmg-layout.DS_Store` | Finder's view settings for the volume: window size, icon size 128, GridCraft.app at (326, 205), `Applications` at (574, 205), and the background. Goes to `.DS_Store` in the image; named so it isn't mistaken for (or ignored like) a Finder-generated `.DS_Store`. | +| `generate.py` | Writes `background.tiff` and `dmg-layout.DS_Store` from the SVG and the layout above. | + +## The volume name has no version + +The mounted volume is called `GridCraft`, not `GridCraft `. The layout points at the background +through an alias that includes the volume name, and Finder resolves it by that name: with a +versioned name the window keeps its size and icon positions but shows no background (tested). +The DMG file name (`gridcraft--macos-.dmg`) still carries the version. + +## Rules + +- **Finder draws the icon labels in black in light and dark mode** when a window has a background, + so the area under both icons stays light (Paper). +- **Nothing goes inside the icon boxes:** artwork keeps 10 pt clear of each 128 pt icon box and of + the label strip under it. + +## Regenerate + +Edit `background.svg` (or the layout constants in `generate.py`), then run, on any OS: + +```sh +pip install 'pillow>=12' ds_store==1.3.3 mac_alias==2.2.3 +python3 packaging/macos/dmg/generate.py # needs resvg on PATH +``` + +Both outputs are byte-for-byte reproducible and carry nothing from the machine that ran it: the +SVG renders without any fonts, and `dmg-layout.DS_Store` is written from scratch, its background +alias holding only the volume name and `/.background/background.tiff`. If you add text to the SVG, +outline it (`usvg` from resvg converts text to paths). The window is 660 × 432 with Finder's 32 pt +title bar; the content area is 660 × 400. + +This follows VectorCraft's `packaging/macos/dmg/` (storytold/vectorcraft#493). diff --git a/packaging/macos/dmg/background.svg b/packaging/macos/dmg/background.svg new file mode 100644 index 000000000..b4c266101 --- /dev/null +++ b/packaging/macos/dmg/background.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/macos/dmg/background.tiff b/packaging/macos/dmg/background.tiff new file mode 100644 index 0000000000000000000000000000000000000000..f7e54fe18234e055a10661d18f1ebfb7a7e0bff5 GIT binary patch literal 27312 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 >uqmDD<7rIyRhqfl(@ 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 literal 0 HcmV?d00001 diff --git a/packaging/macos/dmg/generate.py b/packaging/macos/dmg/generate.py new file mode 100644 index 000000000..a16875962 --- /dev/null +++ b/packaging/macos/dmg/generate.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Regenerate background.tiff and dmg-layout.DS_Store for the macOS DMG window (see README.md here). + +Needs resvg on PATH and: + pip install 'pillow>=12' ds_store==1.3.3 mac_alias==2.2.3 + +Runs on any OS, no Mac needed. Both outputs come from this script alone, so they carry nothing +from the machine that made them: no local paths, user or disk names, dates or volume UUIDs. + + python3 packaging/macos/dmg/generate.py +""" + +import datetime +import os +import shutil +import subprocess +import sys +import tempfile + +from ds_store import DSStore +from mac_alias import Alias, TargetInfo, VolumeInfo +from PIL import Image, ImageCms + +HERE = os.path.dirname(os.path.abspath(__file__)) + +# Keep in sync with package.sh (volume name) and README.md (layout). +VOLUME = "GridCraft" +WIDTH, HEIGHT = 660, 400 # window content, pt; the background's 1x size +TITLE_BAR = 32 +ICON_SIZE = 128 +PALETTE = 16 # colours in background.tiff +ICONS = {"GridCraft.app": (326, 205), "Applications": (574, 205)} +# A fixed date for the alias and the colour profile. Finder never matches it against the image +# (each build is a new volume): it finds the background by volume name and path. +FIXED_DATE = datetime.datetime(2026, 10, 8, tzinfo=datetime.timezone.utc) + + +def render(svg, width, out): + # The SVG's text is already outlined and no fonts are loaded, so every machine draws the + # same pixels. + subprocess.run( + ["resvg", "--skip-system-fonts", "-w", str(width), svg, out], + check=True, + ) + with Image.open(out) as im: + return im.convert("RGB") + + +def quantize(im): + # No dithering: dither noise compresses badly and the artwork has flat fills. + return im.quantize(PALETTE, method=Image.Quantize.FASTOCTREE, dither=Image.Dither.NONE) + + +def srgb_profile(): + # LittleCMS's built-in sRGB ("No copyright, use freely"), with its creation date fixed. + icc = bytearray(ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes()) + d = FIXED_DATE + icc[24:36] = b"".join(v.to_bytes(2, "big") for v in (d.year, d.month, d.day, 0, 0, 0)) + if icc[84:100] != bytes(16): + sys.exit("error: the sRGB profile has an ID; recompute it after fixing the date") + return bytes(icc) + + +def write_tiff(svg, out): + """1x (72 dpi) and 2x (144 dpi) in one TIFF, as `tiffutil -cathidpicheck` writes them.""" + with tempfile.TemporaryDirectory() as tmp: + one, two = ( + quantize(render(svg, WIDTH * s, os.path.join(tmp, f"{s}x.png"))) for s in (1, 2) + ) + two.encoderinfo = {"dpi": (144, 144)} # the rest as the first page + # Opaque 16-colour palette, Deflate: the artwork is three inks plus antialiasing, and a palette + # keeps the file under ~100 KB (repository assets must stay small). As PhotoCraft's. + one.save( + out, + "TIFF", + save_all=True, + append_images=[two], + compression="tiff_adobe_deflate", # brand-ok: Pillow's name for TIFF compression 8 (Deflate) + dpi=(72, 72), + icc_profile=srgb_profile(), + ) + with Image.open(out) as im: + im.seek(1) + if im.info.get("dpi") != (144, 144): + sys.exit("error: the 2x page isn't 144 dpi (needs Pillow 12 or later)") + strips = [] + for page in (0, 1): + im.seek(page) + strips += zip(im.tag_v2[273], im.tag_v2[279]) # StripOffsets, StripByteCounts + # TIFF keeps directories and tag data on even offsets. When a strip ends on an odd one, libtiff + # leaves the padding byte after it uninitialised, so the file changed from run to run with + # identical pixels. Zero it: nothing refers to that byte. + starts = {o for o, _ in strips} + with open(out, "r+b") as f: + for o, n in strips: + if (o + n) % 2 and o + n not in starts: + f.seek(o + n) + f.write(bytes(1)) + + +def background_alias(): + # What Finder stores for /.background/background.tiff on the volume, minus anything about the + # machine (dmgbuild writes the same kind of alias from a mounted image). Disk type 5 (ejectable), + # the volume flags and the folder/file ids (25, 26) are what Finder wrote on a test image; the ids + # are only hints, as the volume's creation date. + volume = VolumeInfo(VOLUME, FIXED_DATE, b"H+", 5, 0x0D02, b"\0\0") + volume.posix_path = f"/Volumes/{VOLUME}" + target = TargetInfo(0, "background.tiff", 25, 26, FIXED_DATE, b"\0\0\0\0", b"\0\0\0\0") + target.folder_name = ".background" + target.cnid_path = [25] + target.carbon_path = f"{VOLUME}:.background:\0background.tiff" + target.posix_path = "/.background/background.tiff" + return Alias(volume=volume, target=target).to_bytes() + + +def write_ds_store(out): + if os.path.exists(out): + os.remove(out) + with DSStore.open(out, "w+") as d: + d["."]["bwsp"] = { + "ShowStatusBar": False, + "ShowToolbar": False, + "ShowTabView": False, + "ContainerShowSidebar": False, + "ShowSidebar": False, + "WindowBounds": f"{{{{200, 528}}, {{{WIDTH}, {HEIGHT + TITLE_BAR}}}}}", + } + d["."]["icvp"] = { + "viewOptionsVersion": 1, + "backgroundType": 2, + "backgroundImageAlias": background_alias(), + "backgroundColorRed": 1.0, + "backgroundColorGreen": 1.0, + "backgroundColorBlue": 1.0, + "gridOffsetX": 0.0, + "gridOffsetY": 0.0, + "gridSpacing": 100.0, + "arrangeBy": "none", + "showIconPreview": True, + "showItemInfo": False, + "labelOnBottom": True, + "textSize": 16.0, + "iconSize": float(ICON_SIZE), + } + d["."]["vSrn"] = ("long", 1) + for name, pos in ICONS.items(): + d[name]["Iloc"] = pos + + +def main(): + if not shutil.which("resvg"): + sys.exit("error: resvg not found (brew install resvg / cargo install resvg --locked)") + write_tiff(os.path.join(HERE, "background.svg"), os.path.join(HERE, "background.tiff")) + write_ds_store(os.path.join(HERE, "dmg-layout.DS_Store")) + + +if __name__ == "__main__": + main() diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index 976db943e..b128c3c7f 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -131,10 +131,15 @@ STAGE="$WORK/dmg" mkdir -p "$STAGE" ditto "$APP" "$STAGE/GridCraft.app" ln -s /Applications "$STAGE/Applications" +# Finder window layout: background, icon size and positions (packaging/macos/dmg/README.md). +mkdir -p "$STAGE/.background" +cp "$HERE/dmg/background.tiff" "$STAGE/.background/background.tiff" +cp "$HERE/dmg/dmg-layout.DS_Store" "$STAGE/.DS_Store" rm -f "$DMG" "$WORK/raw.dmg" # makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`, # which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions. -hdiutil makehybrid -hfs -hfs-volume-name "GridCraft $VERSION" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE" +# The volume name has no version: .DS_Store finds the background through an alias that includes it. +hdiutil makehybrid -hfs -hfs-volume-name "GridCraft" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE" hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG" rm -f "$WORK/raw.dmg" sign "$DMG" 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 09/45] 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 837f0806a..3377d52e9 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 f3e36cdf4..8b8c0d820 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 10/45] 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 139256ffc..f1b39fcf9 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 000000000..54c8f6ea4 --- /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 11/45] 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 753585f10..75d0020db 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 8eb1eca1a..d89165650 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 000000000..c28e0806e --- /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 12/45] 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 4b326cc36..834146cd3 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 000000000..8a6673855 --- /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 13/45] 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 4b326cc36..6e28ec7bb 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 000000000..391ccd6af --- /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 14/45] 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 a51a2f1a8..09ea606e0 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 4b326cc36..736262624 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 74bbeb495..15d0fff59 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 15/45] 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 4b326cc36..30e849333 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 74bbeb495..730cdae23 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 16/45] 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 fca5dc42a..5296c4815 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 17/45] 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 7fba23fe5..b82443470 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 b08cf2aab..5d0be7ace 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 f1f1becb3..258aea6c9 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 18/45] 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 abef54ffd..ecdb52b17 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 67db0830d..255bf7fe0 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 3a0bab385..8196f5a79 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 19/45] 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 eee516b24..b30cb6a3d 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 20/45] 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 8961d8769..1a7ec859c 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 cffe15430..3660de8a5 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 111225de0..c0a61df64 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 f3e36cdf4..443588b65 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 21/45] 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 467a9e0be..aaf3448f9 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 ff12ac81d..54e9d0303 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 22/45] 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 4b326cc36..e53ea02ab 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 74bbeb495..7016ae2a0 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 4d46b9170b6e4c599d9d4c625b32d831f732e8ec Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 9 Oct 2026 17:14:43 -0400 Subject: [PATCH 23/45] ci: publish Flatpak repository to Pages --- .github/workflows/flatpak-pages.yml | 162 ++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 .github/workflows/flatpak-pages.yml diff --git a/.github/workflows/flatpak-pages.yml b/.github/workflows/flatpak-pages.yml new file mode 100644 index 000000000..44f97cc5a --- /dev/null +++ b/.github/workflows/flatpak-pages.yml @@ -0,0 +1,162 @@ +name: Publish Flatpak repository to Pages + +on: + release: + types: [published] + workflow_dispatch: + inputs: + release_tag: + description: "Published GitHub release tag to publish (for example, v1.2.3)" + required: true + type: string + +permissions: + contents: read + +concurrency: + group: flatpak-pages-${{ github.repository }} + cancel-in-progress: false + +jobs: + build: + name: Build Flatpak repository + runs-on: ubuntu-24.04 + permissions: + contents: read + pages: write + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }} + APP_ID: ai.storyteller.gridcraft + steps: + - name: Configure Pages + id: pages + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + + - name: Install Flatpak repository tools + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends flatpak ostree + + - name: Download and validate release bundles + shell: bash + run: | + set -euo pipefail + tag="$RELEASE_TAG" + if ! printf '%s' "$tag" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then + echo "::error::Release tag '$tag' is not strict SemVer prefixed with v" + exit 1 + fi + published_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" --jq \ + 'select(.draft == false and .published_at != null) | .tag_name')" + if [[ "$published_tag" != "$tag" ]]; then + echo "::error::$tag is not a published release in $GITHUB_REPOSITORY" + exit 1 + fi + + version="${tag#v}" + mkdir -p bundles + gh release download "$tag" --repo "$GITHUB_REPOSITORY" \ + --pattern SHA256SUMS.txt --dir bundles + for arch in x86_64 aarch64; do + asset="gridcraft-$version-linux-$arch.flatpak" + gh release download "$tag" --repo "$GITHUB_REPOSITORY" \ + --pattern "$asset" --dir bundles + test -s "bundles/$asset" + ( + cd bundles + awk -v file="$asset" '$2 == file || $2 == "*" file { print; found=1 } END { exit !found }' \ + SHA256SUMS.txt | sha256sum --check - + ) + done + test "$(find bundles -maxdepth 1 -type f -name '*.flatpak' | wc -l)" -eq 2 + + for arch in x86_64 aarch64; do + asset="bundles/gridcraft-$version-linux-$arch.flatpak" + validation_repo="$(mktemp -d)" + ostree --repo="$validation_repo" init --mode=archive-z2 + flatpak build-import-bundle "$validation_repo" "$asset" + expected_ref="app/$APP_ID/$arch/stable" + ostree --repo="$validation_repo" refs | grep -Fxq "$expected_ref" || { + echo "::error::$(basename "$asset") does not contain $expected_ref" + exit 1 + } + rm -rf "$validation_repo" + done + + - name: Build repository and site + shell: bash + env: + PAGES_BASE_URL: ${{ steps.pages.outputs.base_url }} + run: | + set -euo pipefail + site="$RUNNER_TEMP/site" + repo_dir="$site/flatpak/repo" + mkdir -p "$repo_dir" + ostree --repo="$repo_dir" init --mode=archive-z2 + + previous_url="${PAGES_BASE_URL%/}/flatpak/repo" + if curl --fail --silent --show-error --location \ + --output /dev/null "$previous_url/summary"; then + ostree --repo="$repo_dir" remote add --no-gpg-verify previous "$previous_url" + ostree --repo="$repo_dir" pull --mirror previous + ostree --repo="$repo_dir" remote delete previous + fi + + version="${RELEASE_TAG#v}" + for arch in x86_64 aarch64; do + flatpak build-import-bundle "$repo_dir" \ + "bundles/gridcraft-$version-linux-$arch.flatpak" + done + flatpak build-update-repo \ + --title="GridCraft" \ + --comment='Official GridCraft releases' \ + --description='Official Flatpak repository for GridCraft' \ + --homepage="https://github.com/$GITHUB_REPOSITORY" \ + --default-branch=stable \ + "$repo_dir" + + for arch in x86_64 aarch64; do + ostree --repo="$repo_dir" rev-parse "app/$APP_ID/$arch/stable" >/dev/null + done + test -s "$repo_dir/summary" + + base_url="${PAGES_BASE_URL%/}" + repo_url="$base_url/flatpak/repo/" + cat >"$site/flatpak/gridcraft.flatpakrepo" <"$site/flatpak/index.html" <GridCraft Flatpak repository

GridCraft Flatpak repository

Latest published release: $RELEASE_TAG

Add the GridCraft repository

flatpak remote-add --if-not-exists gridcraft $base_url/flatpak/gridcraft.flatpakrepo
+          flatpak install gridcraft $APP_ID
+ HTML + cat >"$site/index.html" <GridCraft downloads

GridCraft downloads

Flatpak repository

Release assets

+ HTML + touch "$site/.nojekyll" + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: ${{ runner.temp }}/site + include-hidden-files: true + + deploy: + name: Deploy Pages + needs: build + runs-on: ubuntu-24.04 + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 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 24/45] 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 94d9b18d1..86999d141 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 bfc83b92e..c70942f6b 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 25/45] Contributors: add nihilist-narwhal --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 89c6db208..2e6b83bbb 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 26/45] 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 b8efbdce7..0a82a910f 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 ffd5fe76d..e9927c552 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 de15fd8a2..ca270a662 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 927ce1df8..92239fa56 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 13664fe66b952e7abcd68e2371c1d4f34f990dac Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 9 Oct 2026 21:02:14 -0400 Subject: [PATCH 27/45] Bump version to 0.4.0 (from 0.3.0) for release; refresh contributors Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 32 +++++++++++------------ Cargo.toml | 2 +- contributors/contributors.json | 48 ++++++++++++++++++++++++++-------- 3 files changed, 54 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 29bd97ae6..e3a414438 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1392,7 +1392,7 @@ dependencies = [ [[package]] name = "gridcraft" -version = "0.3.0" +version = "0.4.0" dependencies = [ "eframe", "egui", @@ -1408,7 +1408,7 @@ dependencies = [ [[package]] name = "gridcraft-calc" -version = "0.3.0" +version = "0.4.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1420,7 +1420,7 @@ dependencies = [ [[package]] name = "gridcraft-chart" -version = "0.3.0" +version = "0.4.0" dependencies = [ "gridcraft-calc", "gridcraft-core", @@ -1431,7 +1431,7 @@ dependencies = [ [[package]] name = "gridcraft-cli" -version = "0.3.0" +version = "0.4.0" dependencies = [ "gridcraft-engine", "gridcraft-mcp", @@ -1441,7 +1441,7 @@ dependencies = [ [[package]] name = "gridcraft-core" -version = "0.3.0" +version = "0.4.0" dependencies = [ "serde", "serde_json", @@ -1450,7 +1450,7 @@ dependencies = [ [[package]] name = "gridcraft-engine" -version = "0.3.0" +version = "0.4.0" dependencies = [ "gridcraft-calc", "gridcraft-chart", @@ -1470,7 +1470,7 @@ dependencies = [ [[package]] name = "gridcraft-formula" -version = "0.3.0" +version = "0.4.0" dependencies = [ "gridcraft-core", "serde", @@ -1479,7 +1479,7 @@ dependencies = [ [[package]] name = "gridcraft-functions" -version = "0.3.0" +version = "0.4.0" dependencies = [ "gridcraft-core", "regex", @@ -1487,7 +1487,7 @@ dependencies = [ [[package]] name = "gridcraft-mcp" -version = "0.3.0" +version = "0.4.0" dependencies = [ "gridcraft-engine", "log", @@ -1496,7 +1496,7 @@ dependencies = [ [[package]] name = "gridcraft-model" -version = "0.3.0" +version = "0.4.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1507,18 +1507,18 @@ dependencies = [ [[package]] name = "gridcraft-numfmt" -version = "0.3.0" +version = "0.4.0" dependencies = [ "gridcraft-core", ] [[package]] name = "gridcraft-pdf" -version = "0.3.0" +version = "0.4.0" [[package]] name = "gridcraft-ui-egui" -version = "0.3.0" +version = "0.4.0" dependencies = [ "egui", "egui_kittest", @@ -1533,7 +1533,7 @@ dependencies = [ [[package]] name = "gridcraft-web" -version = "0.3.0" +version = "0.4.0" dependencies = [ "eframe", "egui", @@ -1550,7 +1550,7 @@ dependencies = [ [[package]] name = "gridcraft-xlsx" -version = "0.3.0" +version = "0.4.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -4287,7 +4287,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.3.0" +version = "0.4.0" dependencies = [ "gridcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 89f268ead..a1ce6a552 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"] [workspace.package] -version = "0.3.0" +version = "0.4.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" diff --git a/contributors/contributors.json b/contributors/contributors.json index 10cba0513..d338d1656 100644 --- a/contributors/contributors.json +++ b/contributors/contributors.json @@ -2,23 +2,49 @@ "schema": 1, "repo": "storytold/gridcraft", "branch": "main", - "head": "a60bd2f40edae13197965ded9433dcf276caca45", - "head_date": "2026-10-07T18:45:37Z", - "commits": 16, + "head": "51b531be3e8b3f5a19d2b7714a4780a79569ca92", + "head_date": "2026-10-08T13:12:32Z", + "commits": 24, "unattributed_commits": 0, "contributors": [ { "login": "echelon", "people_entry": true, "github_name": null, - "prs": 1, - "commits": 16, - "lines_added": 78054, - "lines_deleted": 3954, + "prs": 4, + "commits": 21, + "lines_added": 79088, + "lines_deleted": 3997, "binary_added": 35, "binary_deleted": 12, "first_commit": "2026-10-06T02:09:42Z", - "last_commit": "2026-10-07T18:45:37Z" + "last_commit": "2026-10-08T13:50:17Z" + }, + { + "login": "h-a-n-a-s-h-i", + "people_entry": false, + "github_name": null, + "prs": 0, + "commits": 2, + "lines_added": 34, + "lines_deleted": 34, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T12:13:30Z", + "last_commit": "2026-10-08T14:02:29Z" + }, + { + "login": "XusBadia", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 244, + "lines_deleted": 1, + "binary_added": 2, + "binary_deleted": 0, + "first_commit": "2026-10-08T13:12:32Z", + "last_commit": "2026-10-08T13:12:32Z" } ], "models": [ @@ -27,9 +53,9 @@ "company": "Anthropic", "model": "Claude Opus", "version": "5.5", - "commits": 15, - "lines_added": 78047, - "lines_deleted": 3954 + "commits": 23, + "lines_added": 79359, + "lines_deleted": 4032 } ] } 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 28/45] 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 4b326cc36..5ae1beb43 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 74bbeb495..82f3d50d3 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 29/45] 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 a80448a52..7e3aca81a 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 30/45] 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 43e57a485..260d90054 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 a80448a52..09c046b5e 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 8eb1eca1a..a21c77954 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 47c5432b4..cdbb92784 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 2dbd8df7b..290e5b370 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 000000000..2a5365aa0 --- /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 638ec217b..9c6c31b65 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 31/45] 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 47c5432b4..828187112 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 000000000..5f6ca7c9c --- /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 32/45] 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 1b0cd3468..4af7fb601 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 d6bc2ba87..657fcfab8 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 f3e36cdf4..1ae84714f 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 000000000..11ff351e6 --- /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 b6c186d2d..d79254fa1 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 4b326cc36..5f861d839 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 8eb1eca1a..86442bb4e 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 47c5432b4..825a8e7dc 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 000000000..af8a1a9dc --- /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 33/45] 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 1b0cd3468..f9fa0103e 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 4b326cc36..e76e00656 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 000000000..faebebf00 --- /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 34/45] 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 2bea26cda..0c9eb5c3e 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 837f0806a..a6a3d5c39 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 f3e36cdf4..bd51545bd 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 b6c186d2d..dd2791bb9 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 35/45] 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 397a05cbd..a32401bd5 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 837f0806a..4b2425b03 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 f3e36cdf4..f59d6fcf0 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 165250199..d6a9c4561 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 36/45] 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 cffe15430..cc994f00f 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 24df503a4..e32422a4f 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 f59d6fcf0..bf85261dd 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 753585f10..d3d865009 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 37/45] 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 837f0806a..9ba367271 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 f3e36cdf4..0faa77326 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 734ff5c1e..ae082a61b 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 ff84a5bb7..e6c0b59be 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 38/45] 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 2bea26cda..e72a654ab 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 f3e36cdf4..eb8bcbd55 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 39/45] 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 | 43 ++++++++++++++++++++----------- crates/engine/src/tests.rs | 31 ++++++++++++++++++++++ 2 files changed, 59 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index e72a654ab..64d583225 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 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))))); + } } } - } - 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))))); + 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 eb8bcbd55..fe07b5f8c 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 40/45] 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 cffe15430..82a7195dc 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 fe07b5f8c..927c4cd45 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 8652b20b3..6ccef9e9c 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 915d8e4f9..bf45e91fa 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 41/45] 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 abef54ffd..39c725c17 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 13b47c41c..500dec2c5 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 67db0830d..535585f01 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 f3e36cdf4..61c21b4ea 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 51e5068c8..39f251154 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 b6143ba27..14b45d95d 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 f5014020c..b92e2e6b4 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 0aa223eba..12e785f95 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 ff84a5bb7..7ad826293 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 eee516b24..b4298c95d 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 42/45] 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 000000000..c35f85703 --- /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 8961d8769..2de8e8462 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) { From deecb18f5d75f8bd54f85a0f0a8337b211fd1b52 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 12:11:51 -0400 Subject: [PATCH 43/45] Review fix-up: skip pre-releases, prune the Pages repo, read-only pages in build - Skip the build when the published release is a pre-release, and refuse a manual tag with a pre-release suffix or a release GitHub marks as one, so rc builds never reach the stable branch. - flatpak build-update-repo --prune --prune-depth=3 so the re-mirrored Pages repository does not grow without bound. - The build job only needs pages: read for configure-pages. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/flatpak-pages.yml | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/workflows/flatpak-pages.yml b/.github/workflows/flatpak-pages.yml index 44f97cc5a..cd8f12ab3 100644 --- a/.github/workflows/flatpak-pages.yml +++ b/.github/workflows/flatpak-pages.yml @@ -20,10 +20,14 @@ concurrency: jobs: build: name: Build Flatpak repository + # Pre-releases (rc builds) never reach the stable branch: a published pre-release is skipped + # here, and the validation step below refuses a manual tag with a pre-release suffix. + if: github.event_name != 'release' || !github.event.release.prerelease runs-on: ubuntu-24.04 permissions: contents: read - pages: write + # configure-pages only reads the Pages configuration (base_url); deploy needs write. + pages: read env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }} @@ -47,10 +51,17 @@ jobs: echo "::error::Release tag '$tag' is not strict SemVer prefixed with v" exit 1 fi + # A pre-release has a "-" before any "+build" metadata (v1.2.3-rc.1). + case "${tag%%+*}" in + *-*) + echo "::error::$tag is a pre-release; only stable releases are published to the Flatpak repository" + exit 1 + ;; + esac published_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" --jq \ - 'select(.draft == false and .published_at != null) | .tag_name')" + 'select(.draft == false and .prerelease == false and .published_at != null) | .tag_name')" if [[ "$published_tag" != "$tag" ]]; then - echo "::error::$tag is not a published release in $GITHUB_REPOSITORY" + echo "::error::$tag is not a published stable (non-pre-release) release in $GITHUB_REPOSITORY" exit 1 fi @@ -108,7 +119,10 @@ jobs: flatpak build-import-bundle "$repo_dir" \ "bundles/gridcraft-$version-linux-$arch.flatpak" done + # Prune unreachable objects and keep at most 3 commits per ref, so the Pages repository + # (re-mirrored and re-deployed on every release) does not grow without bound. flatpak build-update-repo \ + --prune --prune-depth=3 \ --title="GridCraft" \ --comment='Official GridCraft releases' \ --description='Official Flatpak repository for GridCraft' \ From d3b8929fde9161e5c1e7d0066469f4009e765d1a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 9 Oct 2026 23:14:29 -0400 Subject: [PATCH 44/45] ci: add RISC-V Linux build --- .github/workflows/release.yml | 69 +++++++++++++++++++++++++++++++++++ docs/releasing.md | 1 + packaging/linux/package.sh | 19 +++++++--- 3 files changed, 84 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de15fd8a2..5ae44f579 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -323,6 +323,75 @@ jobs: # FreeBSD has no hosted runners: freebsd.yml builds in a FreeBSD VM and, with package: true, # uploads the release-freebsd artifact (gridcraft--freebsd-x86_64.tar.gz). It needs no # secrets, so the called workflow runs without the release environment (and in dry runs). + linux-riscv64: + name: Linux riscv64 + needs: version + runs-on: ubuntu-26.04 + env: + GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }} + GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} + steps: + - uses: actions/checkout@v4 + - name: Check out craft-fonts + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable + with: + toolchain: stable + targets: riscv64gc-unknown-linux-gnu + - name: Apply version override + if: inputs.version != '' + run: cargo xtask version set "$GRIDCRAFT_VERSION" + - name: Install cross-compilation dependencies + run: | + sudo dpkg --add-architecture riscv64 + if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then + sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources + fi + if [ -f /etc/apt/sources.list ]; then + sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list + sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list + fi + cat << 'EOF' | sudo tee /etc/apt/sources.list.d/riscv64.list + deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports resolute main universe restricted multiverse + deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports resolute-updates main universe restricted multiverse + deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports resolute-security main universe restricted multiverse + EOF + sudo apt-get update + sudo apt-get install -y --allow-downgrades --no-install-recommends \ + gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \ + libasound2-dev:riscv64 libxkbcommon-dev:riscv64 \ + libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 libxi-dev:riscv64 libgl1-mesa-dev:riscv64 + if command -v qemu-riscv64 >/dev/null 2>&1 && ! command -v qemu-riscv64-static >/dev/null 2>&1; then + sudo ln -s "$(command -v qemu-riscv64)" /usr/local/bin/qemu-riscv64-static + fi + - name: Build and package + env: + CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc + CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_RUSTFLAGS: "-C link-arg=-L/usr/riscv64-linux-gnu/lib -C link-arg=-L/usr/lib/riscv64-linux-gnu" + PKG_CONFIG_SYSROOT_DIR: / + PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig + PKG_CONFIG_ALLOW_CROSS: "1" + CROSS_ARCH: riscv64 + CROSS_TARGET: riscv64gc-unknown-linux-gnu + CROSS_COMPILE: riscv64-linux-gnu- + EMULATOR: qemu-riscv64-static + run: | + export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1 + packaging/linux/package.sh --formats "tar" + - name: Smoke test CLI under QEMU + run: | + tar -xzf dist/release/*.tar.gz + qemu-riscv64-static -L /usr/riscv64-linux-gnu gridcraft-*/bin/gridcraft-cli --version + - uses: actions/upload-artifact@v4 + with: + name: release-linux-riscv64 + path: dist/release/*.tar.gz + if-no-files-found: error + freebsd: name: FreeBSD needs: version diff --git a/docs/releasing.md b/docs/releasing.md index f4a28eba7..de94b212b 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -82,6 +82,7 @@ and upload their artifacts, the environment refuses macOS and Windows, and no re | Windows 11 ARM64 | `gridcraft--windows-arm64.msi`, `…-portable.zip` (cross-compiled, signed like x64) | `windows-latest`; installed and run on `windows-11-arm` by `windows-arm64.yml` | | Linux x86_64 | `gridcraft--linux-x86_64.{AppImage,AppImage.zsync,deb,rpm,tar.gz}` | `ubuntu-22.04` | | Linux aarch64 | `gridcraft--linux-aarch64.{AppImage,AppImage.zsync,deb,rpm,tar.gz}` | `ubuntu-22.04-arm` | +| Linux riscv64 | `gridcraft--linux-riscv64.tar.gz` | cross-compiled on `ubuntu-26.04` | | Flatpak x86_64, aarch64 | `gridcraft--linux-.flatpak` (repackages the Linux tarball) | `ubuntu-24.04`, `ubuntu-24.04-arm` | | FreeBSD 14 x86_64 | `gridcraft--freebsd-x86_64.tar.gz` | FreeBSD 14.3 VM (`freebsd.yml`, called by `release.yml`) | | Web | `gridcraft-web-.zip` (static site; see [`packaging/web/README.md`](../packaging/web/README.md)) | `ubuntu-latest` | diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 20ad1b2b8..e7123034a 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -30,8 +30,9 @@ while [ $# -gt 0 ]; do esac done -ARCH="$(uname -m)" +ARCH="${CROSS_ARCH:-$(uname -m)}" case "$ARCH" in + riscv64) DEB_ARCH=riscv64 ;; x86_64) DEB_ARCH=amd64 ;; aarch64 | arm64) ARCH=aarch64; DEB_ARCH=arm64 ;; *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; @@ -42,9 +43,13 @@ BASENAME="gridcraft-$VERSION-linux-$ARCH" echo "==> GridCraft $VERSION for Linux $ARCH ($FORMATS)" if [ "$SKIP_BUILD" = 0 ]; then - (cd "$ROOT" && cargo build --release --locked -p gridcraft -p gridcraft-cli) + if [ -n "${CROSS_TARGET:-}" ]; then + (cd "$ROOT" && cargo build --release --locked -p gridcraft -p gridcraft-cli --target "$CROSS_TARGET") + else + (cd "$ROOT" && cargo build --release --locked -p gridcraft -p gridcraft-cli) + fi fi -BIN="$CARGO_TARGET_DIR/release" +BIN="$CARGO_TARGET_DIR/${CROSS_TARGET:+$CROSS_TARGET/}release" WORK="$CARGO_TARGET_DIR/linux-package" STAGE="$WORK/root" rm -rf "$WORK" @@ -52,7 +57,7 @@ rm -rf "$WORK" # ---- stage an FHS tree (shared by every format) ------------------------------------------------- install -Dm755 "$BIN/gridcraft" "$STAGE/usr/bin/gridcraft" install -Dm755 "$BIN/gridcraft-cli" "$STAGE/usr/bin/gridcraft-cli" -strip "$STAGE/usr/bin/gridcraft" "$STAGE/usr/bin/gridcraft-cli" 2>/dev/null || true +"${CROSS_COMPILE:-}strip" "$STAGE/usr/bin/gridcraft" "$STAGE/usr/bin/gridcraft-cli" 2>/dev/null || true install -Dm644 "$HERE/$APP_ID.desktop" "$STAGE/usr/share/applications/$APP_ID.desktop" install -Dm644 "$HERE/$APP_ID.mime.xml" "$STAGE/usr/share/mime/packages/$APP_ID.xml" mkdir -p "$STAGE/usr/share/metainfo" @@ -135,6 +140,10 @@ if has appimage; then fi fi -"$STAGE/usr/bin/gridcraft-cli" --version +if [ -z "${CROSS_TARGET:-}" ]; then + "$STAGE/usr/bin/gridcraft-cli" --version +elif [ -n "${EMULATOR:-}" ]; then + $EMULATOR "$STAGE/usr/bin/gridcraft-cli" --version +fi echo "==> done" ls -lh "$DIST" From efcfd08822153ebcf5e5812fd56c7c458f391ca6 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 12:29:05 -0400 Subject: [PATCH 45/45] Review fix-up: align the riscv64 job with the other Linux jobs - Pin actions/checkout and upload-artifact to the SHAs the other jobs use (v7.0.1, v7.0.2); add rust-cache; use the same version-override command. - Run on ubuntu-24.04 (ubuntu-26.04 is not a known hosted label) and take the ports suite from /etc/os-release; 22.04's riscv64 ports lag its amd64 updates and the multiarch install fails there. - Add linux-riscv64 to the release job's needs so release-linux-riscv64 is downloaded before the draft release is built. - Drop the unused libasound2 dependency, the linker -L flags and the qemu-static symlink; package.sh's emulator check is the smoke test (QEMU_LD_PREFIX), and quote $EMULATOR there. - Move the job above the FreeBSD comment; list riscv64 in docs/releasing.md. Verified: the job's steps in an ubuntu:24.04 container build, package and run gridcraft-cli --version under qemu-riscv64. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 62 +++++++++++++++++------------------ docs/releasing.md | 2 +- packaging/linux/package.sh | 4 +-- 3 files changed, 34 insertions(+), 34 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f8db6b3b0..6c9050a9e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -316,34 +316,40 @@ jobs: path: dist/release/* if-no-files-found: error - # FreeBSD has no hosted runners: freebsd.yml builds in a FreeBSD VM and, with package: true, - # uploads the release-freebsd artifact (gridcraft--freebsd-x86_64.tar.gz). It needs no - # secrets, so the called workflow runs without the release environment (and in dry runs). linux-riscv64: name: Linux riscv64 needs: version - runs-on: ubuntu-26.04 + # Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release + # from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's + # riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the + # CLI is smoke-tested under QEMU. + runs-on: ubuntu-24.04 + # No `environment: release`: this job signs nothing, so it gets no release secrets. env: GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }} GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - - uses: actions/checkout@v4 + - 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@686976e191b89faba57d3206551f0f330d8cb249 # stable with: - toolchain: stable targets: riscv64gc-unknown-linux-gnu + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + key: release-linux-riscv64 - name: Apply version override if: inputs.version != '' - run: cargo xtask version set "$GRIDCRAFT_VERSION" + run: cargo run -q -p xtask --no-default-features -- version set "$GRIDCRAFT_VERSION" - name: Install cross-compilation dependencies run: | + . /etc/os-release sudo dpkg --add-architecture riscv64 + # Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports. if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources fi @@ -351,43 +357,37 @@ jobs: sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list fi - cat << 'EOF' | sudo tee /etc/apt/sources.list.d/riscv64.list - deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports resolute main universe restricted multiverse - deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports resolute-updates main universe restricted multiverse - deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports resolute-security main universe restricted multiverse - EOF + for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do + echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse" + done | sudo tee /etc/apt/sources.list.d/riscv64.list sudo apt-get update - sudo apt-get install -y --allow-downgrades --no-install-recommends \ + sudo apt-get install -y --no-install-recommends \ gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \ - libasound2-dev:riscv64 libxkbcommon-dev:riscv64 \ - libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 libxi-dev:riscv64 libgl1-mesa-dev:riscv64 - if command -v qemu-riscv64 >/dev/null 2>&1 && ! command -v qemu-riscv64-static >/dev/null 2>&1; then - sudo ln -s "$(command -v qemu-riscv64)" /usr/local/bin/qemu-riscv64-static - fi - - name: Build and package + libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \ + libxi-dev:riscv64 libgl1-mesa-dev:riscv64 + - name: Build and package (tar.gz) env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc - CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_RUSTFLAGS: "-C link-arg=-L/usr/riscv64-linux-gnu/lib -C link-arg=-L/usr/lib/riscv64-linux-gnu" PKG_CONFIG_SYSROOT_DIR: / PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig PKG_CONFIG_ALLOW_CROSS: "1" CROSS_ARCH: riscv64 CROSS_TARGET: riscv64gc-unknown-linux-gnu CROSS_COMPILE: riscv64-linux-gnu- - EMULATOR: qemu-riscv64-static - run: | - export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1 - packaging/linux/package.sh --formats "tar" - - name: Smoke test CLI under QEMU - run: | - tar -xzf dist/release/*.tar.gz - qemu-riscv64-static -L /usr/riscv64-linux-gnu gridcraft-*/bin/gridcraft-cli --version - - uses: actions/upload-artifact@v4 + EMULATOR: qemu-riscv64 + QEMU_LD_PREFIX: /usr/riscv64-linux-gnu + run: packaging/linux/package.sh --formats tar + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-linux-riscv64 path: dist/release/*.tar.gz if-no-files-found: error + # FreeBSD has no hosted runners: freebsd.yml builds in a FreeBSD VM and, with package: true, + # uploads the release-freebsd artifact (gridcraft--freebsd-x86_64.tar.gz). It needs no + # secrets, so the called workflow runs without the release environment (and in dry runs). freebsd: name: FreeBSD needs: version @@ -399,7 +399,7 @@ jobs: release: name: Draft GitHub Release - needs: [version, macos, windows, linux, flatpak, web, freebsd] + needs: [version, macos, windows, linux, linux-riscv64, flatpak, web, freebsd] # Belt and braces for branch dry runs: only the release branch drafts a release (the # environment's branch rule refuses this job and the signing jobs anywhere else, too). if: github.ref == 'refs/heads/release' diff --git a/docs/releasing.md b/docs/releasing.md index de94b212b..0e0abfa11 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -82,7 +82,7 @@ and upload their artifacts, the environment refuses macOS and Windows, and no re | Windows 11 ARM64 | `gridcraft--windows-arm64.msi`, `…-portable.zip` (cross-compiled, signed like x64) | `windows-latest`; installed and run on `windows-11-arm` by `windows-arm64.yml` | | Linux x86_64 | `gridcraft--linux-x86_64.{AppImage,AppImage.zsync,deb,rpm,tar.gz}` | `ubuntu-22.04` | | Linux aarch64 | `gridcraft--linux-aarch64.{AppImage,AppImage.zsync,deb,rpm,tar.gz}` | `ubuntu-22.04-arm` | -| Linux riscv64 | `gridcraft--linux-riscv64.tar.gz` | cross-compiled on `ubuntu-26.04` | +| Linux riscv64 | `gridcraft--linux-riscv64.tar.gz` (cross-compiled, glibc >= 2.39; CLI smoke-tested under QEMU) | `ubuntu-24.04` | | Flatpak x86_64, aarch64 | `gridcraft--linux-.flatpak` (repackages the Linux tarball) | `ubuntu-24.04`, `ubuntu-24.04-arm` | | FreeBSD 14 x86_64 | `gridcraft--freebsd-x86_64.tar.gz` | FreeBSD 14.3 VM (`freebsd.yml`, called by `release.yml`) | | Web | `gridcraft-web-.zip` (static site; see [`packaging/web/README.md`](../packaging/web/README.md)) | `ubuntu-latest` | diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index e7123034a..9fde2c591 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Build and package GridCraft for Linux ( is x86_64 or aarch64): +# Build and package GridCraft for Linux ( is x86_64 or aarch64, or riscv64 when cross-compiling): # # $DIST/gridcraft--linux-.AppImage any distro with glibc >= the build host's # $DIST/gridcraft--linux-.AppImage.zsync delta updates (needs zsyncmake) @@ -143,7 +143,7 @@ fi if [ -z "${CROSS_TARGET:-}" ]; then "$STAGE/usr/bin/gridcraft-cli" --version elif [ -n "${EMULATOR:-}" ]; then - $EMULATOR "$STAGE/usr/bin/gridcraft-cli" --version + "$EMULATOR" "$STAGE/usr/bin/gridcraft-cli" --version fi echo "==> done" ls -lh "$DIST"