Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 66ed962ad1 Merge branch 'main' into fix/sonar-complexity-batch-2b 2026-10-25 22:47:25 -04:00
Dylan M. Taylor 42bbf6baa2 refactor: split complex functions for Sonar S3776 (#2612)
Extract group_bracket from outline layout and move_marks from data
outline_settings. No behaviour change.
2026-10-25 21:39:17 -04:00
Dylan M. Taylor 52cf825395 refactor: split complex functions for Sonar S3776 (#2609)
Extract helpers from auto_sum (selection and single-cell guess), update_cursor
(drag, hover, freeze, and header-edge cursors) and read_styles (dxf reader).
No behaviour change.
2026-10-25 21:37:09 -04:00
Dylan M. Taylor bc881f6c4f refactor: split complex functions for Sonar S3776
Extract helpers from backstage options (general, calculation and iteration
sections), code_editor python (string scanning), xtask i18n literals
(escape and string readers) and calc recalc loops_among (SCC search).
No behaviour change.
2026-10-25 21:31:04 -04:00
Dylan M. Taylor 6f0e43c21c Linux: default to XWayland for file drag-and-drop on Wayland desktops (#165)
* Linux: default to XWayland for file drag-and-drop on Wayland desktops

* Ignore an empty DISPLAY before forcing X11
2026-10-25 19:37:08 -04:00
Dylan M. Taylor 0870b1ad1c Merge pull request #2595 from storytold/chore/deps-safe-refresh
chore(deps): safe dependency refresh
2026-10-25 19:00:54 -04:00
skylar 14dd23db6c chore(deps): refresh serde_json, arboard, muda, winresource, sha2 2026-10-25 16:36:12 -06:00
Dylan M. Taylor ad10881cc3 build: pin craft-update to craft-libs main (495e597) (#2591)
Picks up the startup prompt without the glyphs a font may lack (storytold/craft-libs#343, #344): the close mark is painted and the version pill is ASCII, so apps that panic on a missing glyph no longer take the prompt down.

Part of tracking issue storytold/craft-libs#319
2026-10-25 17:50:44 -04:00
Dylan M. TaylorandCodebuff ee7cbf32ab build: pin craft-update to craft-libs main (495e597) (#2586)
Picks up the update prompt fix that draws its close mark and version pill
without glyphs a font may lack, so the prompt no longer takes the app down
when a bundled face has no ✕ or →.

Part of tracking issue storytold/craft-libs#319

🤖 Generated with Codebuff

Co-authored-by: Codebuff <noreply@codebuff.com>
2026-10-25 17:37:15 -04:00
12 changed files with 477 additions and 446 deletions
Generated
+35 -108
View File
@@ -206,9 +206,9 @@ dependencies = [
[[package]]
name = "arboard"
version = "3.6.1"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917"
dependencies = [
"clipboard-win",
"image",
@@ -220,7 +220,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"windows-sys 0.52.0",
"wl-clipboard-rs",
"x11rb",
]
@@ -1395,7 +1395,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -1885,6 +1885,7 @@ dependencies = [
"pollster 1.0.1",
"rfd",
"serde_json",
"winit",
"winresource",
]
@@ -1989,7 +1990,7 @@ dependencies = [
"gridcraft-formula",
"serde",
"serde_json",
"sha2 0.11.0",
"sha2 0.11.1",
"thiserror 2.0.21",
]
@@ -2054,7 +2055,7 @@ dependencies = [
"gridcraft-model",
"quick-xml 0.41.0",
"sha1 0.11.0",
"sha2 0.11.0",
"sha2 0.11.1",
"thiserror 2.0.21",
"zip 3.0.0",
]
@@ -2763,9 +2764,9 @@ dependencies = [
[[package]]
name = "muda"
version = "0.21.1"
version = "0.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a7b000cca0783af26837fe2ebd9ddf1c42279463c085b97622ee1d2f444b89e"
checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03"
dependencies = [
"crossbeam-channel",
"dpi",
@@ -2776,7 +2777,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"png",
"thiserror 2.0.21",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -3287,7 +3288,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -3689,7 +3690,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -3996,7 +3997,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.60.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -4055,7 +4056,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.60.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -4201,9 +4202,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.151"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
@@ -4290,9 +4291,9 @@ dependencies = [
[[package]]
name = "sha2"
version = "0.11.0"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
@@ -4622,7 +4623,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -4783,10 +4784,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.60.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -5131,7 +5132,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -5738,7 +5739,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -5859,7 +5860,7 @@ version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
"windows-targets",
]
[[package]]
@@ -5868,16 +5869,7 @@ version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
"windows-targets",
]
[[package]]
@@ -5895,31 +5887,14 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
@@ -5937,96 +5912,48 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winit"
version = "0.30.13"
@@ -6119,9 +6046,9 @@ dependencies = [
[[package]]
name = "winresource"
version = "0.1.31"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087"
checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b"
dependencies = [
"toml",
"version_check",
+3
View File
@@ -24,6 +24,9 @@ chrono = { workspace = true }
# Icon + VERSIONINFO for gridcraft.exe; build.rs only uses it when the target is Windows.
winresource = "0.1"
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" }
craft-update = { workspace = true }
+12
View File
@@ -336,6 +336,18 @@ fn main() -> eframe::Result<()> {
viewport = viewport.with_icon(i);
}
let options = eframe::NativeOptions { viewport, ..Default::default() };
#[cfg(all(unix, not(target_os = "macos")))]
let mut options = options;
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
// manager showed a "no" cursor. Run through XWayland when it's there; GRIDCRAFT_WAYLAND=1
// keeps the native Wayland backend.
#[cfg(all(unix, not(target_os = "macos")))]
if std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty()) && std::env::var_os("GRIDCRAFT_WAYLAND").is_none() {
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
}));
}
// Before eframe creates the wgpu instance: Windows starts on DirectX 12, or OpenGL when
// DirectX 12 has no GPU driver, and never touches Vulkan unless asked (see graphics.rs).
#[cfg(target_os = "windows")]
+60 -53
View File
@@ -927,6 +927,64 @@ impl Host for PassHost<'_> {
}
}
/// Strongly connected components of a graph given as adjacency lists (Tarjan, iterative): each
/// node's component index, and the number of nodes in each component.
fn components(edges: &[Vec<usize>]) -> (Vec<usize>, Vec<usize>) {
let n = edges.len();
let (mut low, mut num) = (vec![0usize; n], vec![usize::MAX; n]);
let (mut on_stack, mut stack, mut comp) = (vec![false; n], Vec::new(), vec![usize::MAX; n]);
let (mut next, mut comps) = (0usize, 0usize);
for root in 0..n {
if num[root] != usize::MAX {
continue;
}
let mut call: Vec<(usize, usize)> = vec![(root, 0)];
num[root] = next;
low[root] = next;
next += 1;
stack.push(root);
on_stack[root] = true;
while let Some(top) = call.last_mut() {
let v = top.0;
if let Some(&w) = edges.get(v).and_then(|e| e.get(top.1)) {
top.1 += 1;
if num[w] == usize::MAX {
num[w] = next;
low[w] = next;
next += 1;
stack.push(w);
on_stack[w] = true;
call.push((w, 0));
} else if on_stack[w] {
low[v] = low[v].min(num[w]);
}
continue;
}
call.pop();
if let Some(&(u, _)) = call.last() {
low[u] = low[u].min(low[v]);
}
if low[v] == num[v] {
while let Some(w) = stack.pop() {
on_stack[w] = false;
comp[w] = comps;
if w == v {
break;
}
}
comps += 1;
}
}
}
let mut size = vec![0usize; comps];
for &c in &comp {
if let Some(s) = size.get_mut(c) {
*s += 1;
}
}
(comp, size)
}
impl Calc {
pub fn new() -> Self {
Self::default()
@@ -981,59 +1039,8 @@ impl Calc {
e
})
.collect();
// Tarjan's strongly connected components, iterative.
let n = circ.len();
let (mut low, mut num) = (vec![0usize; n], vec![usize::MAX; n]);
let (mut on_stack, mut stack, mut comp) = (vec![false; n], Vec::new(), vec![usize::MAX; n]);
let (mut next, mut comps) = (0usize, 0usize);
for root in 0..n {
if num[root] != usize::MAX {
continue;
}
let mut call: Vec<(usize, usize)> = vec![(root, 0)];
num[root] = next;
low[root] = next;
next += 1;
stack.push(root);
on_stack[root] = true;
while let Some(top) = call.last_mut() {
let v = top.0;
if let Some(&w) = edges.get(v).and_then(|e| e.get(top.1)) {
top.1 += 1;
if num[w] == usize::MAX {
num[w] = next;
low[w] = next;
next += 1;
stack.push(w);
on_stack[w] = true;
call.push((w, 0));
} else if on_stack[w] {
low[v] = low[v].min(num[w]);
}
continue;
}
call.pop();
if let Some(&(u, _)) = call.last() {
low[u] = low[u].min(low[v]);
}
if low[v] == num[v] {
while let Some(w) = stack.pop() {
on_stack[w] = false;
comp[w] = comps;
if w == v {
break;
}
}
comps += 1;
}
}
}
let mut size = vec![0usize; comps];
for &c in &comp {
if let Some(s) = size.get_mut(c) {
*s += 1;
}
}
// Loop members: nodes in a component of more than one, or with an edge to themselves.
let (comp, size) = components(&edges);
let on_loop = |i: usize| comp.get(i).and_then(|c| size.get(*c)).is_some_and(|s| *s > 1) || edges.get(i).is_some_and(|e| e.contains(&i));
// A formula with dynamic references (INDIRECT, OFFSET) may close a loop the graph can't see.
let mut out: Vec<Key> =
+29 -25
View File
@@ -1454,6 +1454,34 @@ fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
/// Data ▸ Outline ▸ Settings: whether summary rows sit below their detail and summary columns
/// right of it. A changed direction moves each collapsed group's mark (+) to its new summary line.
/// Moves the collapsed marks of a line axis to the summary line on its new side (`on` is the new
/// setting, `was_before` the old one). A line that nested groups share keeps its mark on the outermost
/// group whose detail is all hidden, or the outermost when none is.
fn move_marks(map: &mut std::collections::BTreeMap<u32, LineInfo>, was_before: bool, on: bool) {
let mut marked: Vec<outline::OutlineGroup> =
outline::groups_with(map, was_before).into_iter().filter(|g| g.has_summary() && map.get(&g.summary()).is_some_and(|e| e.collapsed)).collect();
// A mark on a line that nested groups share moves with the outermost of them whose
// detail is all hidden, or with the outermost when none is.
let mut owner: std::collections::BTreeMap<u32, (bool, u8)> = std::collections::BTreeMap::new();
for g in &marked {
let key = (!g.collapsed, g.level);
let best = owner.entry(g.summary()).or_insert(key);
*best = (*best).min(key);
}
marked.retain(|g| owner.get(&g.summary()) == Some(&(!g.collapsed, g.level)));
for g in &marked {
if let Some(e) = map.get_mut(&g.summary()) {
e.collapsed = false;
}
}
for g in marked {
let g = outline::OutlineGroup { summary_before: !on, ..g };
if g.has_summary() {
map.entry(g.summary()).or_default().collapsed = true;
}
}
}
fn outline_settings(s: &mut Session, p: &Json) -> Result<Json> {
let si = target_sheet(s, p)?;
edit(s, |cx| {
@@ -1469,31 +1497,7 @@ fn outline_settings(s: &mut Session, p: &Json) -> Result<Json> {
if was_before != on {
continue;
}
let map = if rows { &mut sh.rows } else { &mut sh.cols };
let mut marked: Vec<outline::OutlineGroup> = outline::groups_with(map, was_before)
.into_iter()
.filter(|g| g.has_summary() && map.get(&g.summary()).is_some_and(|e| e.collapsed))
.collect();
// A mark on a line that nested groups share moves with the outermost of them whose
// detail is all hidden, or with the outermost when none is.
let mut owner: std::collections::BTreeMap<u32, (bool, u8)> = std::collections::BTreeMap::new();
for g in &marked {
let key = (!g.collapsed, g.level);
let best = owner.entry(g.summary()).or_insert(key);
*best = (*best).min(key);
}
marked.retain(|g| owner.get(&g.summary()) == Some(&(!g.collapsed, g.level)));
for g in &marked {
if let Some(e) = map.get_mut(&g.summary()) {
e.collapsed = false;
}
}
for g in marked {
let g = outline::OutlineGroup { summary_before: !on, ..g };
if g.has_summary() {
map.entry(g.summary()).or_default().collapsed = true;
}
}
move_marks(if rows { &mut sh.rows } else { &mut sh.cols }, was_before, on);
}
Ok(json!({"summaryBelow": sh.summary_below, "summaryRight": sh.summary_right}))
})
+93 -69
View File
@@ -96,75 +96,73 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
fn auto_sum(s: &mut Session, p: &Json) -> Result<Json> {
let func = str_param(p, "function").unwrap_or("SUM").to_ascii_uppercase();
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let sel = match str_param(p, "range") {
Some(_) => target_range(s, p)?,
None => d.selection.current(),
};
if !sel.is_single() {
// A selection: totals go in its empty last row or column, as Excel's do, and otherwise
// past it: to the right of a single row, below each column of anything taller. Columns
// past the sheet's data get no total (a whole-row selection doesn't write 16,384
// formulas), nor do rows past it, and all totals are one edit (one undo step).
let Some(used) = sh.cells.used_range() else { return ok() };
let empty = |row: u32, col: u32| sh.value(CellRef::new(row, col)).is_empty();
let sum = |from: CellRef, to: CellRef| format!("={func}({})", RangeRef::new(from, to).a1());
let last_col = used.end.col.min(sel.end.col);
let mut writes: Vec<(CellRef, String)> = Vec::new();
if sel.start.row == sel.end.row {
// One row: its total goes in its last cell when that is empty, else just after it.
let row = sel.start.row;
let end_col = if sel.is_full_rows() { last_col } else { sel.end.col };
let (data_end, out) = if end_col > sel.start.col && empty(row, end_col) { (end_col - 1, end_col) } else { (end_col, end_col + 1) };
if out >= gridcraft_core::MAX_COLS {
return Err(EngineError::Other("There's no room to the right of the selection for the total.".into()));
}
writes.push((CellRef::new(row, out), sum(CellRef::new(row, sel.start.col), CellRef::new(row, data_end))));
} else {
// An empty last column takes each row's total; rows past the data get none.
let rows_end = sel.end.row.min(used.end.row);
let right_blank = !sel.is_full_rows() && sel.end.col > sel.start.col && (sel.start.row..=rows_end).all(|r| empty(r, sel.end.col));
let data_end_col = if right_blank { sel.end.col - 1 } else { last_col };
let last_row_blank = (sel.start.col..=last_col).all(|c| empty(sel.end.row, c));
let (data_end_row, out_row) = if last_row_blank { (sel.end.row.saturating_sub(1), sel.end.row) } else { (sel.end.row, sel.end.row + 1) };
// Each column's total, below it: unless only the last column is there to take totals.
if last_row_blank || !right_blank {
if out_row >= gridcraft_core::MAX_ROWS {
return Err(EngineError::Other("There's no room below the selection for the totals.".into()));
}
for c in sel.start.col..=data_end_col.min(last_col) {
writes.push((CellRef::new(out_row, c), sum(CellRef::new(sel.start.row, c), CellRef::new(data_end_row, c))));
}
}
if right_blank {
for r in sel.start.row..=data_end_row.min(rows_end) {
writes.push((CellRef::new(r, sel.end.col), sum(CellRef::new(r, sel.start.col), CellRef::new(r, data_end_col))));
}
// Both empty: the corner takes the total of the whole block.
if last_row_blank {
writes.push((
CellRef::new(sel.end.row, sel.end.col),
sum(CellRef::new(sel.start.row, sel.start.col), CellRef::new(data_end_row, data_end_col)),
));
}
}
}
let sheet = d.wb.active_sheet;
edit(s, |cx| {
for (c, f) in &writes {
let cell = super::edit::input_to_cell(f, sheet, *c, &mut cx.wb)?.unwrap_or_default();
cx.sheet_mut(sheet)?.set_cell(*c, cell);
cx.touch(sheet, *c);
}
Ok(())
})?;
return ok();
/// Whether a cell on the active sheet is empty.
fn blank(sh: &gridcraft_model::sheet::Sheet, row: u32, col: u32) -> bool {
sh.value(CellRef::new(row, col)).is_empty()
}
fn sum_formula(func: &str, from: CellRef, to: CellRef) -> String {
format!("={func}({})", RangeRef::new(from, to).a1())
}
/// Totals for a multi-cell selection as `(cell, formula)` writes. `None` when the sheet has no data.
fn selection_totals(sh: &gridcraft_model::sheet::Sheet, sel: RangeRef, func: &str) -> Result<Option<Vec<(CellRef, String)>>> {
// A selection: totals go in its empty last row or column, as Excel's do, and otherwise
// past it: to the right of a single row, below each column of anything taller. Columns
// past the sheet's data get no total (a whole-row selection doesn't write 16,384
// formulas), nor do rows past it, and all totals are one edit (one undo step).
let Some(used) = sh.cells.used_range() else { return Ok(None) };
if sel.start.row == sel.end.row { single_row_totals(sh, sel, used, func).map(Some) } else { block_totals(sh, sel, used, func).map(Some) }
}
/// One row: its total goes in its last cell when that is empty, else just after it.
fn single_row_totals(sh: &gridcraft_model::sheet::Sheet, sel: RangeRef, used: RangeRef, func: &str) -> Result<Vec<(CellRef, String)>> {
let row = sel.start.row;
let last_col = used.end.col.min(sel.end.col);
let end_col = if sel.is_full_rows() { last_col } else { sel.end.col };
let (data_end, out) = if end_col > sel.start.col && blank(sh, row, end_col) { (end_col - 1, end_col) } else { (end_col, end_col + 1) };
if out >= gridcraft_core::MAX_COLS {
return Err(EngineError::Other("There's no room to the right of the selection for the total.".into()));
}
// Single cell: guess the range above, else to the left.
let at = sel.start;
Ok(vec![(CellRef::new(row, out), sum_formula(func, CellRef::new(row, sel.start.col), CellRef::new(row, data_end)))])
}
/// A block of rows: each column's total below it, and each row's total in an empty last column.
fn block_totals(sh: &gridcraft_model::sheet::Sheet, sel: RangeRef, used: RangeRef, func: &str) -> Result<Vec<(CellRef, String)>> {
let mut writes: Vec<(CellRef, String)> = Vec::new();
let last_col = used.end.col.min(sel.end.col);
// An empty last column takes each row's total; rows past the data get none.
let rows_end = sel.end.row.min(used.end.row);
let right_blank = !sel.is_full_rows() && sel.end.col > sel.start.col && (sel.start.row..=rows_end).all(|r| blank(sh, r, sel.end.col));
let data_end_col = if right_blank { sel.end.col - 1 } else { last_col };
let last_row_blank = (sel.start.col..=last_col).all(|c| blank(sh, sel.end.row, c));
let (data_end_row, out_row) = if last_row_blank { (sel.end.row.saturating_sub(1), sel.end.row) } else { (sel.end.row, sel.end.row + 1) };
// Each column's total, below it: unless only the last column is there to take totals.
if last_row_blank || !right_blank {
if out_row >= gridcraft_core::MAX_ROWS {
return Err(EngineError::Other("There's no room below the selection for the totals.".into()));
}
for c in sel.start.col..=data_end_col.min(last_col) {
writes.push((CellRef::new(out_row, c), sum_formula(func, CellRef::new(sel.start.row, c), CellRef::new(data_end_row, c))));
}
}
if right_blank {
for r in sel.start.row..=data_end_row.min(rows_end) {
writes.push((CellRef::new(r, sel.end.col), sum_formula(func, CellRef::new(r, sel.start.col), CellRef::new(r, data_end_col))));
}
// Both empty: the corner takes the total of the whole block.
if last_row_blank {
writes.push((
CellRef::new(sel.end.row, sel.end.col),
sum_formula(func, CellRef::new(sel.start.row, sel.start.col), CellRef::new(data_end_row, data_end_col)),
));
}
}
Ok(writes)
}
/// The range a single cell's AutoSum guesses: the numbers above it, else the numbers to its left.
fn guessed_range(sh: &gridcraft_model::sheet::Sheet, at: CellRef) -> Option<RangeRef> {
let numeric = |c: CellRef| sh.value(c).is_number();
let mut range = None;
if at.row > 0 && (numeric(CellRef::new(at.row - 1, at.col)) || at.col == 0 || !numeric(CellRef::new(at.row, at.col - 1))) {
@@ -186,7 +184,33 @@ fn auto_sum(s: &mut Session, p: &Json) -> Result<Json> {
}
range = Some(RangeRef::new(CellRef::new(at.row, left), CellRef::new(at.row, at.col - 1)));
}
let text = match range {
range
}
fn auto_sum(s: &mut Session, p: &Json) -> Result<Json> {
let func = str_param(p, "function").unwrap_or("SUM").to_ascii_uppercase();
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let sel = match str_param(p, "range") {
Some(_) => target_range(s, p)?,
None => d.selection.current(),
};
if !sel.is_single() {
let Some(writes) = selection_totals(sh, sel, &func)? else { return ok() };
let sheet = d.wb.active_sheet;
edit(s, |cx| {
for (c, f) in &writes {
let cell = super::edit::input_to_cell(f, sheet, *c, &mut cx.wb)?.unwrap_or_default();
cx.sheet_mut(sheet)?.set_cell(*c, cell);
cx.touch(sheet, *c);
}
Ok(())
})?;
return ok();
}
// Single cell: guess the range above, else to the left.
let at = sel.start;
let text = match guessed_range(sh, at) {
Some(r) => format!("={func}({})", r.a1()),
None => {
s.ui_requests.push(crate::UiRequest::EditCell(Some(format!("={func}()"))));
+40 -25
View File
@@ -957,6 +957,35 @@ pub fn export_as(app: &mut SheetApp, fmt: &str) -> bool {
fn options(app: &mut SheetApp, ui: &mut Ui) {
let lang = app.ui.language;
section(ui, lang, "Options|General");
general_options(app, ui, lang);
ui.add_space(6.0);
let mut formula_bar = app.session.prefs.formula_bar;
if ui.checkbox(&mut formula_bar, lang.tr("Show formula bar")).changed() {
app.run_or_alert("view.formulaBar", json!({"on": formula_bar}));
}
ui.add_space(18.0);
section(ui, lang, "Options|Editing");
let mut autocomplete = app.ui.autocomplete;
if ui.checkbox(&mut autocomplete, lang.tr("Enable AutoComplete for cell values")).changed() {
app.run_or_alert("options.autoComplete", json!({"on": autocomplete}));
}
ui.add_space(18.0);
section(ui, lang, "Options|Save");
let mut autosave = app.ui.autosave;
if ui.checkbox(&mut autosave, lang.tr("Save .xlsx workbooks automatically after every change")).changed() {
app.toggle_autosave();
}
let Some(calc) = app.session.active().map(|d| d.wb.calc.clone()) else { return };
ui.add_space(18.0);
section(ui, lang, "Formulas");
ui.label(lang.tr("Workbook calculation"));
calc_options(app, ui, lang, &calc);
ui.add_space(6.0);
dim(ui, lang.tr("Calculation settings are saved with the workbook."));
}
/// The display theme and interface language controls of the General section.
fn general_options(app: &mut SheetApp, ui: &mut Ui, lang: Language) {
egui::Grid::new("backstage_options_general").num_columns(2).spacing(vec2(18.0, 10.0)).show(ui, |ui| {
let label = ui.label(lang.tr("Display theme"));
let mode = app.ui.theme_mode();
@@ -993,27 +1022,10 @@ fn options(app: &mut SheetApp, ui: &mut Ui) {
.labelled_by(label.id);
ui.end_row();
});
ui.add_space(6.0);
let mut formula_bar = app.session.prefs.formula_bar;
if ui.checkbox(&mut formula_bar, lang.tr("Show formula bar")).changed() {
app.run_or_alert("view.formulaBar", json!({"on": formula_bar}));
}
ui.add_space(18.0);
section(ui, lang, "Options|Editing");
let mut autocomplete = app.ui.autocomplete;
if ui.checkbox(&mut autocomplete, lang.tr("Enable AutoComplete for cell values")).changed() {
app.run_or_alert("options.autoComplete", json!({"on": autocomplete}));
}
ui.add_space(18.0);
section(ui, lang, "Options|Save");
let mut autosave = app.ui.autosave;
if ui.checkbox(&mut autosave, lang.tr("Save .xlsx workbooks automatically after every change")).changed() {
app.toggle_autosave();
}
let Some(calc) = app.session.active().map(|d| d.wb.calc.clone()) else { return };
ui.add_space(18.0);
section(ui, lang, "Formulas");
ui.label(lang.tr("Workbook calculation"));
}
/// The calculation mode, and iterative calculation, of the active workbook.
fn calc_options(app: &mut SheetApp, ui: &mut Ui, lang: Language, calc: &gridcraft_engine::model::CalcSettings) {
use gridcraft_engine::model::CalcMode;
for (mode, id, label) in [
(CalcMode::Automatic, "automatic", "Automatic"),
@@ -1030,6 +1042,13 @@ fn options(app: &mut SheetApp, ui: &mut Ui) {
app.run_or_alert("formulas.calculationOptions", json!({"iterative": iterative}));
}
// Maximum Iterations and Maximum Change apply only while iteration is on, as in Excel.
iteration_options(app, ui, lang, calc, iterative);
// TODO: add "Set precision as displayed" here once `formulas.calculationOptions` takes
// `precisionAsDisplayed` (PR #1366); the model already stores it.
}
/// Maximum Iterations and Maximum Change, live only while iterative calculation is on.
fn iteration_options(app: &mut SheetApp, ui: &mut Ui, lang: Language, calc: &gridcraft_engine::model::CalcSettings, iterative: bool) {
ui.add_enabled_ui(iterative, |ui| {
ui.horizontal(|ui| {
ui.add_space(24.0);
@@ -1070,10 +1089,6 @@ fn options(app: &mut SheetApp, ui: &mut Ui) {
});
});
});
// TODO: add "Set precision as displayed" here once `formulas.calculationOptions` takes
// `precisionAsDisplayed` (PR #1366); the model already stores it.
ui.add_space(6.0);
dim(ui, lang.tr("Calculation settings are saved with the workbook."));
}
/// Refuses a typed setting with a message, leaving the workbook as it was. The Enter that
+27 -22
View File
@@ -78,6 +78,32 @@ pub fn highlight(text: &str, lang: Lang) -> Vec<(Range<usize>, Token)> {
}
}
/// End (exclusive, in chars) of the Python string literal opening with quote `c` at char `i`.
fn python_string_end(chars: &[(usize, char)], i: usize, c: char) -> usize {
let triple = chars.get(i + 1).is_some_and(|(_, d)| *d == c) && chars.get(i + 2).is_some_and(|(_, d)| *d == c);
let mut j = if triple { i + 3 } else { i + 1 };
loop {
match chars.get(j) {
None => break,
Some((_, '\\')) => j += 2,
Some((_, '\n')) if !triple => break,
Some((_, d)) if *d == c => {
if !triple {
j += 1;
break;
}
if chars.get(j + 1).is_some_and(|(_, d)| *d == c) && chars.get(j + 2).is_some_and(|(_, d)| *d == c) {
j += 3;
break;
}
j += 1;
}
Some(_) => j += 1,
}
}
j.min(chars.len())
}
fn python(text: &str) -> Vec<(Range<usize>, Token)> {
let chars: Vec<(usize, char)> = text.char_indices().collect();
let end_of = |i: usize| chars.get(i).map(|(b, _)| *b).unwrap_or(text.len());
@@ -92,28 +118,7 @@ fn python(text: &str) -> Vec<(Range<usize>, Token)> {
out.push((start..end_of(j), Token::Comment));
i = j;
} else if c == '"' || c == '\'' {
let triple = chars.get(i + 1).is_some_and(|(_, d)| *d == c) && chars.get(i + 2).is_some_and(|(_, d)| *d == c);
let mut j = if triple { i + 3 } else { i + 1 };
loop {
match chars.get(j) {
None => break,
Some((_, '\\')) => j += 2,
Some((_, '\n')) if !triple => break,
Some((_, d)) if *d == c => {
if !triple {
j += 1;
break;
}
if chars.get(j + 1).is_some_and(|(_, d)| *d == c) && chars.get(j + 2).is_some_and(|(_, d)| *d == c) {
j += 3;
break;
}
j += 1;
}
Some(_) => j += 1,
}
}
let j = j.min(chars.len());
let j = python_string_end(&chars, i, c);
out.push((start..end_of(j), Token::String));
i = j;
} else if c.is_ascii_digit() || (c == '.' && chars.get(i + 1).is_some_and(|(_, d)| d.is_ascii_digit())) {
+52 -32
View File
@@ -2046,38 +2046,46 @@ fn update_cursor(app: &mut SheetApp, ui: &egui::Ui, resp: &egui::Response, geo:
}
// A held drag owns the cursor even outside the grid; a press below egui's threshold still
// has Drag::None and uses hover. A click can briefly leave a drag state after release.
if resp.dragged() && pointer.primary_down() {
let cursor = match app.grid.drag {
Drag::None => None,
Drag::Select => Some(CursorIcon::Cell),
Drag::Rows(_) | Drag::Cols(_) => Some(CursorIcon::Default),
Drag::Fill { .. } => Some(CursorIcon::Crosshair),
Drag::Move { .. } | Drag::MoveCols { .. } | Drag::Object { resize: false, .. } => Some(CursorIcon::Move),
Drag::ResizeCol { .. } => Some(CursorIcon::ResizeColumn),
Drag::ResizeRow { .. } => Some(CursorIcon::ResizeRow),
Drag::Freeze { cols, .. } => Some(if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }),
Drag::Object { resize: true, .. } => Some(CursorIcon::ResizeNwSe),
};
if let Some(cursor) = cursor {
ctx.set_cursor_icon(cursor);
return;
}
if resp.dragged()
&& pointer.primary_down()
&& let Some(cursor) = drag_cursor(&app.grid.drag)
{
ctx.set_cursor_icon(cursor);
return;
}
if !resp.hovered() {
return;
}
let Some(p) = pos else { return };
hover_cursor(app, ctx, resp, &pointer, geo, sh, p);
}
/// The cursor a held drag shows, or `None` when the drag leaves it to hover.
fn drag_cursor(drag: &Drag) -> Option<CursorIcon> {
match drag {
Drag::None => None,
Drag::Select => Some(CursorIcon::Cell),
Drag::Rows(_) | Drag::Cols(_) => Some(CursorIcon::Default),
Drag::Fill { .. } => Some(CursorIcon::Crosshair),
Drag::Move { .. } | Drag::MoveCols { .. } | Drag::Object { resize: false, .. } => Some(CursorIcon::Move),
Drag::ResizeCol { .. } => Some(CursorIcon::ResizeColumn),
Drag::ResizeRow { .. } => Some(CursorIcon::ResizeRow),
Drag::Freeze { cols, .. } => Some(if *cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }),
Drag::Object { resize: true, .. } => Some(CursorIcon::ResizeNwSe),
}
}
/// The cursor over the grid when no drag owns it: freeze bars, header edges, the fill handle, cells.
fn hover_cursor(app: &SheetApp, ctx: &egui::Context, resp: &egui::Response, pointer: &egui::PointerState, geo: &Geo, sh: &Sheet, p: Pos2) {
let (freeze_cols, freeze_rows) = freeze_handles(geo);
if freeze_cols.contains(p) || freeze_rows.contains(p) {
let cols = freeze_cols.contains(p);
ctx.set_cursor_icon(if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical });
if app.grid.drag == Drag::None && !pointer.any_down() {
let tip = if cols { "Drag to freeze columns" } else { "Drag to freeze rows" };
resp.clone().on_hover_text_at_pointer(crate::i18n::current(ctx).tr(tip));
}
freeze_cursor(app, ctx, resp, pointer, freeze_cols.contains(p));
return;
}
let in_cells = geo.in_cells(p);
let in_col_header = p.y < geo.cells.top() && p.y >= geo.head_top() && p.x >= geo.cells.left();
let in_row_header = p.x < geo.cells.left() && p.x >= geo.head_left() && p.y >= geo.cells.top();
let near_col_edge = in_col_header && col_edge(geo, sh, p.x).is_some();
let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default();
let cur_rect = geo.range_rect(sh, sel.current());
let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0));
@@ -2089,16 +2097,8 @@ fn update_cursor(app: &mut SheetApp, ui: &egui::Ui, resp: &egui::Response, geo:
let near_corner = selected_object.is_some_and(|(_, _, rect)| (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS);
if app.grid.drag == Drag::None && near_corner {
ctx.set_cursor_icon(CursorIcon::ResizeNwSe);
} else if (in_col_header && col_edge(geo, sh, p.x).is_some()) || (in_row_header && row_edge(geo, sh, p.y).is_some()) {
let near_col_edge = in_col_header && col_edge(geo, sh, p.x).is_some();
// Just past a hidden column or row, Excel shows a split cursor: dragging or
// double-clicking there unhides it.
let hidden = app.grid.drag == Drag::None
&& if near_col_edge { hidden_col_edge(geo, sh, p.x).is_some() } else { hidden_row_edge(geo, sh, p.y).is_some() };
match pointer.hover_pos() {
Some(at) if hidden => paint_split_cursor(ctx, at, near_col_edge),
_ => ctx.set_cursor_icon(if near_col_edge { CursorIcon::ResizeColumn } else { CursorIcon::ResizeRow }),
}
} else if near_col_edge || (in_row_header && row_edge(geo, sh, p.y).is_some()) {
header_edge_cursor(app, ctx, pointer, geo, sh, p, near_col_edge);
} else if on_handle && app.editor.is_none() {
ctx.set_cursor_icon(CursorIcon::Crosshair);
if app.grid.drag == Drag::None && !pointer.any_down() {
@@ -2115,6 +2115,26 @@ fn update_cursor(app: &mut SheetApp, ui: &egui::Ui, resp: &egui::Response, geo:
}
}
/// The resize cursor for dragging a freeze bar.
fn freeze_cursor(app: &SheetApp, ctx: &egui::Context, resp: &egui::Response, pointer: &egui::PointerState, cols: bool) {
ctx.set_cursor_icon(if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical });
if app.grid.drag == Drag::None && !pointer.any_down() {
let tip = if cols { "Drag to freeze columns" } else { "Drag to freeze rows" };
resp.clone().on_hover_text_at_pointer(crate::i18n::current(ctx).tr(tip));
}
}
/// The resize cursor over a column or row header edge. Just past a hidden column or row, Excel
/// shows a split cursor: dragging or double-clicking there unhides it.
fn header_edge_cursor(app: &SheetApp, ctx: &egui::Context, pointer: &egui::PointerState, geo: &Geo, sh: &Sheet, p: Pos2, near_col_edge: bool) {
let hidden =
app.grid.drag == Drag::None && if near_col_edge { hidden_col_edge(geo, sh, p.x).is_some() } else { hidden_row_edge(geo, sh, p.y).is_some() };
match pointer.hover_pos() {
Some(at) if hidden => paint_split_cursor(ctx, at, near_col_edge),
_ => ctx.set_cursor_icon(if near_col_edge { CursorIcon::ResizeColumn } else { CursorIcon::ResizeRow }),
}
}
fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) {
// Text selection and editing must not also move the object or edit worksheet cells.
if app.text_box_editor.is_some() || app.grid.hf_edit.is_some() {
+30 -24
View File
@@ -165,6 +165,35 @@ impl Dir<'_> {
}
/// The band's buttons and brackets for this frame.
/// The bracket for one group's detail, and its tick, on the lane `across`; `button` is the group's
/// summary button when it is on screen.
fn group_bracket(out: &mut Layout, dir: &Dir, grp: &OutlineGroup, button: Option<f32>, across: f32, side: f32) {
let first = dir.first_visible(grp.start, grp.end);
let Some((first_line, (a0, _))) = first else { return };
if grp.summary_before {
// From the button down (or across) to the last detail line, with the tick there.
let a0 = button.map_or(a0, |mid| mid + side / 2.0);
let end = dir.start(grp.end) + dir.size(grp.end);
let a1 = end.min(dir.hi);
if a1 <= a0 {
return;
}
let tick = (end <= dir.hi).then(|| [dir.point(a1 - 0.5, across), dir.point(a1 - 0.5, across + side / 2.0)]);
out.brackets.push(Bracket { line: [dir.point(a0, across), dir.point(a1 - 0.5, across)], tick });
return;
}
let a1 = match button {
Some(mid) => mid - side / 2.0,
None => dir.start(grp.summary()).min(dir.hi),
};
if a1 <= a0 {
return;
}
let start_shown = first_line == grp.start && (a0 - dir.start(grp.start)).abs() < 0.5;
let tick = start_shown.then(|| [dir.point(a0 + 0.5, across), dir.point(a0 + 0.5, across + side / 2.0)]);
out.brackets.push(Bracket { line: [dir.point(a0 + 0.5, across), dir.point(a1, across)], tick });
}
pub fn layout(geo: &Geo, sh: &Sheet) -> Layout {
let mut out = Layout::default();
let zoom = (geo.z / DISPLAY_SCALE).max(0.1);
@@ -212,30 +241,7 @@ pub fn layout(geo: &Geo, sh: &Sheet) -> Layout {
continue;
}
// The bracket, over the part of the detail that is on screen.
let first = dir.first_visible(grp.start, grp.end);
let Some((first_line, (a0, _))) = first else { continue };
if grp.summary_before {
// From the button down (or across) to the last detail line, with the tick there.
let a0 = button.map_or(a0, |mid| mid + side / 2.0);
let end = dir.start(grp.end) + dir.size(grp.end);
let a1 = end.min(dir.hi);
if a1 <= a0 {
continue;
}
let tick = (end <= dir.hi).then(|| [dir.point(a1 - 0.5, across), dir.point(a1 - 0.5, across + side / 2.0)]);
out.brackets.push(Bracket { line: [dir.point(a0, across), dir.point(a1 - 0.5, across)], tick });
continue;
}
let a1 = match button {
Some(mid) => mid - side / 2.0,
None => dir.start(grp.summary()).min(dir.hi),
};
if a1 <= a0 {
continue;
}
let start_shown = first_line == grp.start && (a0 - dir.start(grp.start)).abs() < 0.5;
let tick = start_shown.then(|| [dir.point(a0 + 0.5, across), dir.point(a0 + 0.5, across + side / 2.0)]);
out.brackets.push(Bracket { line: [dir.point(a0 + 0.5, across), dir.point(a1, across)], tick });
group_bracket(&mut out, dir, grp, button, across, side);
}
}
out
+46 -50
View File
@@ -359,59 +359,55 @@ pub fn read_styles(root: &El, checkboxes: &[bool], minor_font: &str) -> StylesIn
}
}
}
let dxfs = root
.child("dxfs")
.map(|d| {
d.kids("dxf")
.map(|x| {
let mut s = Style::default();
if let Some(e) = x.child("font") {
let f = read_font(e, &palette);
// Differential fonts rarely name a face; unless stated, keep the default face and size.
let has_size = e.child("sz").and_then(|n| n.attr_f64("val")).is_some();
let has_name = e.child("name").and_then(|n| n.attr("val")).is_some();
let name = if has_name { f.name.clone() } else { s.font.name.clone() };
let size = if has_size { f.size } else { s.font.size };
let def = Font::default();
let st = &mut s.dxf_stated;
st.bold = e.child("b").is_some() && !f.bold;
st.italic = e.child("i").is_some() && !f.italic;
st.strike = e.child("strike").is_some() && !f.strike;
st.underline = e.child("u").is_some() && f.underline == Underline::None;
st.size = has_size && size == def.size;
st.name = has_name && name == def.name;
st.scheme = e.child_val("scheme").map(str::to_string);
// A differential font's scheme is in `dxf_stated`.
s.font = Font { name, size, minor: s.font.minor, ..f };
}
if let Some(n) = x.child("numFmt") {
if let Some(code) = n.attr("formatCode") {
s.num_fmt = NumFmt::new(code);
} else if let Some(id) = n.attr_u32("numFmtId") {
s.num_fmt = num_fmt_code(id, &fmts);
}
}
if let Some(f) = x.child("fill") {
s.fill = read_fill(f, &palette, true);
}
if let Some(b) = x.child("border") {
s.borders = read_border(b, &palette);
}
if let Some(a) = x.child("alignment") {
s.align = read_alignment(a);
}
if let Some(p) = x.child("protection") {
s.protection = read_protection(p);
s.dxf_stated.protection = s.protection == Protection::default();
}
s
})
.collect()
})
.unwrap_or_default();
let dxfs = root.child("dxfs").map(|d| d.kids("dxf").map(|x| read_dxf(x, &palette, &fmts)).collect()).unwrap_or_default();
StylesIn { xfs, dxfs, palette, named, num_fmts: fmts }
}
/// One differential format (`dxf`): the style it sets over a cell's own.
fn read_dxf(x: &El, palette: &[u32], fmts: &HashMap<u32, String>) -> Style {
let mut s = Style::default();
if let Some(e) = x.child("font") {
let f = read_font(e, palette);
// Differential fonts rarely name a face; unless stated, keep the default face and size.
let has_size = e.child("sz").and_then(|n| n.attr_f64("val")).is_some();
let has_name = e.child("name").and_then(|n| n.attr("val")).is_some();
let name = if has_name { f.name.clone() } else { s.font.name.clone() };
let size = if has_size { f.size } else { s.font.size };
let def = Font::default();
let st = &mut s.dxf_stated;
st.bold = e.child("b").is_some() && !f.bold;
st.italic = e.child("i").is_some() && !f.italic;
st.strike = e.child("strike").is_some() && !f.strike;
st.underline = e.child("u").is_some() && f.underline == Underline::None;
st.size = has_size && size == def.size;
st.name = has_name && name == def.name;
st.scheme = e.child_val("scheme").map(str::to_string);
// A differential font's scheme is in `dxf_stated`.
s.font = Font { name, size, minor: s.font.minor, ..f };
}
if let Some(n) = x.child("numFmt") {
if let Some(code) = n.attr("formatCode") {
s.num_fmt = NumFmt::new(code);
} else if let Some(id) = n.attr_u32("numFmtId") {
s.num_fmt = num_fmt_code(id, fmts);
}
}
if let Some(f) = x.child("fill") {
s.fill = read_fill(f, palette, true);
}
if let Some(b) = x.child("border") {
s.borders = read_border(b, palette);
}
if let Some(a) = x.child("alignment") {
s.align = read_alignment(a);
}
if let Some(p) = x.child("protection") {
s.protection = read_protection(p);
s.dxf_stated.protection = s.protection == Protection::default();
}
s
}
// ---------------------------------------------------------------- writing
pub fn color_attrs(c: &Color) -> String {
+50 -38
View File
@@ -116,6 +116,55 @@ pub fn catalog_keys(source: &str) -> BTreeSet<String> {
keys
}
/// The character a `\u{...}` escape stands for, if it names a valid code point.
fn unicode_escape(chars: &mut std::iter::Peekable<std::str::CharIndices<'_>>) -> Option<char> {
let mut hex = String::new();
for (_, c) in chars.by_ref() {
match c {
'}' => break,
'{' => {}
c => hex.push(c),
}
}
u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32)
}
/// The value of a string literal whose opening quote was just read, and the byte offset past
/// its closing quote (`len`, the source's length, when it is never closed).
fn string_literal(chars: &mut std::iter::Peekable<std::str::CharIndices<'_>>, len: usize) -> (String, usize) {
let mut value = String::new();
let mut end = len;
while let Some((j, c)) = chars.next() {
match c {
'"' => {
end = j + 1;
break;
}
'\\' => match chars.next().map(|(_, c)| c) {
Some('n') => value.push('\n'),
Some('t') => value.push('\t'),
Some('r') => value.push('\r'),
Some('0') => value.push('\0'),
Some(c @ ('\\' | '"' | '\'')) => value.push(c),
Some('u') => {
if let Some(c) = unicode_escape(chars) {
value.push(c);
}
}
// A line continuation skips the newline and the next line's indentation.
Some('\n') => {
while chars.peek().is_some_and(|(_, c)| c.is_whitespace()) {
chars.next();
}
}
_ => {}
},
c => value.push(c),
}
}
(value, end)
}
/// Every string literal outside `//` comments: its decoded value, and the byte range from its
/// opening quote to just past its closing one.
fn literals(source: &str) -> Vec<(String, usize, usize)> {
@@ -133,44 +182,7 @@ fn literals(source: &str) -> Vec<(String, usize, usize)> {
if c != '"' {
continue;
}
let mut value = String::new();
let mut end = source.len();
while let Some((j, c)) = chars.next() {
match c {
'"' => {
end = j + 1;
break;
}
'\\' => match chars.next().map(|(_, c)| c) {
Some('n') => value.push('\n'),
Some('t') => value.push('\t'),
Some('r') => value.push('\r'),
Some('0') => value.push('\0'),
Some(c @ ('\\' | '"' | '\'')) => value.push(c),
Some('u') => {
let mut hex = String::new();
for (_, c) in chars.by_ref() {
match c {
'}' => break,
'{' => {}
c => hex.push(c),
}
}
if let Some(c) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
value.push(c);
}
}
// A line continuation skips the newline and the next line's indentation.
Some('\n') => {
while chars.peek().is_some_and(|(_, c)| c.is_whitespace()) {
chars.next();
}
}
_ => {}
},
c => value.push(c),
}
}
let (value, end) = string_literal(&mut chars, source.len());
out.push((value, i, end));
}
out