mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-26 03:36:54 -06:00
Merge pull request #2618 from aiongg/g3
Window per workbook (3/4): a native window per workbook window
This commit is contained in:
Generated
+228
-21
@@ -220,7 +220,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -742,6 +742,15 @@ dependencies = [
|
||||
"x509-cert",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cobs"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codespan-reporting"
|
||||
version = "0.13.1"
|
||||
@@ -877,13 +886,41 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
"icu_locale_core",
|
||||
"icu_locale_fallback",
|
||||
"icu_normalizer",
|
||||
"icu_plurals",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"icu_provider_adapters",
|
||||
"icu_provider_blob",
|
||||
"log",
|
||||
"objc2-foundation 0.3.2",
|
||||
"sys-locale",
|
||||
"unicode-bidi",
|
||||
"windows-registry",
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
dependencies = [
|
||||
"craft-winprint-gdi",
|
||||
"miniz_oxide 0.8.9",
|
||||
"miniz_oxide 0.9.1",
|
||||
"roxmltree",
|
||||
"tempfile",
|
||||
"winprint",
|
||||
@@ -893,15 +930,27 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
"egui",
|
||||
"epaint",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"skrifa",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -923,6 +972,17 @@ dependencies = [
|
||||
"zip 9.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"wayland-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-winprint-gdi"
|
||||
version = "0.1.0"
|
||||
@@ -1395,7 +1455,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1476,6 +1536,17 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
||||
|
||||
[[package]]
|
||||
name = "fixed_decimal"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"smallvec",
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
@@ -2011,6 +2082,7 @@ version = "0.12.1"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
"craft-window",
|
||||
"egui",
|
||||
"egui_kittest",
|
||||
"epaint_default_fonts",
|
||||
@@ -2307,6 +2379,23 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_decimal"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"fixed_decimal",
|
||||
"icu_locale_core",
|
||||
"icu_pattern",
|
||||
"icu_plurals",
|
||||
"icu_provider",
|
||||
"serde",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.3.0"
|
||||
@@ -2315,11 +2404,32 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"serde",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_fallback"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9"
|
||||
dependencies = [
|
||||
"icu_locale_core",
|
||||
"icu_locale_fallback_data",
|
||||
"icu_provider",
|
||||
"potential_utf",
|
||||
"tinystr",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_fallback_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8"
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.3.0"
|
||||
@@ -2331,6 +2441,9 @@ dependencies = [
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"utf16_iter",
|
||||
"utf8_iter",
|
||||
"write16",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -2340,6 +2453,32 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
|
||||
|
||||
[[package]]
|
||||
name = "icu_pattern"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc43ce0e76ee8cb0482dc6e2dbb9b76278e8038f45daabf2a2877577e2ceb18f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"either",
|
||||
"serde",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_plurals"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"fixed_decimal",
|
||||
"icu_provider",
|
||||
"serde",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.3.0"
|
||||
@@ -2369,6 +2508,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"postcard",
|
||||
"serde",
|
||||
"stable_deref_trait",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
@@ -2376,6 +2518,30 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider_adapters"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820"
|
||||
dependencies = [
|
||||
"icu_locale_fallback",
|
||||
"icu_provider",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider_blob"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e"
|
||||
dependencies = [
|
||||
"icu_provider",
|
||||
"postcard",
|
||||
"serde",
|
||||
"writeable",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
@@ -2663,6 +2829,9 @@ name = "litemap"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
@@ -3288,7 +3457,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3537,12 +3706,24 @@ dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "postcard"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
|
||||
dependencies = [
|
||||
"cobs",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -3690,7 +3871,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3949,9 +4130,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.20.0"
|
||||
version = "0.21.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
||||
checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
@@ -3997,7 +4181,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4056,7 +4240,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4742,7 +4926,10 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4784,10 +4971,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4890,6 +5077,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"serde_core",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -5224,6 +5412,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf16_iter"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
|
||||
|
||||
[[package]]
|
||||
name = "utf8-zero"
|
||||
version = "0.8.1"
|
||||
@@ -5739,7 +5933,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6084,11 +6278,20 @@ dependencies = [
|
||||
"wayland-protocols-wlr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "write16"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11-dl"
|
||||
@@ -6204,9 +6407,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||
checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
@@ -6399,16 +6602,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"serde_core",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec"
|
||||
version = "0.11.8"
|
||||
version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
|
||||
checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
@@ -6416,9 +6623,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerovec-derive"
|
||||
version = "0.11.6"
|
||||
version = "0.11.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
|
||||
checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
+6
-1
@@ -24,7 +24,9 @@ gridcraft-engine = { path = "crates/engine" }
|
||||
gridcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
gridcraft-mcp = { path = "crates/mcp" }
|
||||
gridcraft-pdf = { path = "crates/pdf" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
|
||||
# Every craft-libs crate (craft-update and craft-window here, craft-ui in ui-egui, craft-print in
|
||||
# the app) comes from one storytold/craft-libs revision.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.1.0", features = ["egui"] }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
@@ -57,6 +59,9 @@ sys-locale = "0.3"
|
||||
skrifa = { version = "0.44", default-features = false, features = ["std"] }
|
||||
# Real font bytes for tests (Ubuntu Light, compiled into epaint).
|
||||
epaint_default_fonts = "0.37"
|
||||
# One native window per workbook: the window list, Window menu titles and the immediate-viewport
|
||||
# driver. Same craft-libs revision as craft-update above.
|
||||
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", default-features = false, features = ["egui"] }
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
@@ -147,6 +147,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-23 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text |
|
||||
| 2026-10-23 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row |
|
||||
| 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) |
|
||||
| 2026-10-22 | On the desktop every workbook window is a native window of its own: New, Open and New Window open one, each keeps its own cell draft, dialog and ribbon, and the macOS menu bar acts on the window focused last. Closing one of several windows on a workbook doesn't ask; closing a workbook's last window, or quitting, asks about unsaved changes in that window. The last window closed quits on Windows and Linux; macOS keeps running with the menu bar. First craft-libs `craft-window` use (window list and viewport driver) |
|
||||
| 2026-10-22 | View › New Window opens a second window on the same workbook (it used to open a copy whose Save overwrote the original): each window has its own selection, active sheet and scroll, titles read "Book1:1" / "Book1:2", and Switch Windows, Hide, Unhide and Close act per window. The control channel, CLI `send` and MCP tools take an optional `window` or `doc` id and default to the active window; `window.list` (MCP `list_windows`) lists the windows |
|
||||
| 2026-10-22 | Typing in a cell and then choosing File › New no longer strands the first workbook: the entry is committed into its own workbook before any File or window command (a refused entry stays open and stops the command), View › Switch Windows and the macOS Window menu list the open workbooks with unsaved markers, `view.switchWindows {doc}` switches by a stable workbook id, and the macOS Quit item asks about unsaved workbooks |
|
||||
| 2026-10-21 | Page Setup paper sizes match Japanese Excel: `B4`/`B5`/`B6` are the JIS sizes (ISO B4/B5/B6 stay reachable by name), so a Japanese B5 worksheet prints at 182×257 mm; Japanese Postcard (100×148 mm), Double Postcard (148×200 mm), Envelope #3/#4 (120×235/90×205 mm), Kaku #2/#3 (240×332/216×277 mm) and A6 are added, both sizes reach the PDF page (`file.exportPdf`/`file.print` MediaBox) and the size survives an XLSX round-trip; the Page Layout Size menu lists them. `B4`/`B5` used to be the ISO sizes, which is changed here |
|
||||
@@ -244,6 +245,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-23 | minor | Progress log: accounting negatives in number text |
|
||||
| 2026-10-23 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! |
|
||||
| 2026-10-22 | trivial | Progress log: formula hardening (#2163, #2162) |
|
||||
| 2026-10-22 | minor | Progress log: a native window per workbook window |
|
||||
| 2026-10-22 | minor | Progress log: New Window shares the workbook; window ids for scripts and MCP |
|
||||
| 2026-10-22 | minor | Progress log: drafts stay in their workbook, Switch Windows, macOS Quit asks to save |
|
||||
| 2026-10-21 | minor | Page Setup paper sizes: JIS B4/B5/B6 (ISO by name), Japanese postcards and envelopes, A6; PDF page size follows, XLSX round-trips |
|
||||
|
||||
@@ -28,7 +28,9 @@ winresource = "0.1"
|
||||
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" }
|
||||
# Same craft-libs revision as the workspace's craft-update and craft-window (see the root
|
||||
# Cargo.toml).
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" }
|
||||
craft-update = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
arboard = { workspace = true }
|
||||
|
||||
@@ -43,7 +43,8 @@ pub fn poll(inbox: &Inbox, app: &mut SheetApp, ctx: &egui::Context) {
|
||||
app.open_path(p);
|
||||
}
|
||||
}
|
||||
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
||||
// Quit from the Dock or the system: each unsaved workbook is asked about in its window.
|
||||
Event::Quit => app.request_quit(ctx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,24 +23,27 @@ use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::doc_windows::DocWindows;
|
||||
use gridcraft_ui_egui::{Services, SheetApp};
|
||||
use serde_json::json;
|
||||
|
||||
struct App(SheetApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>, #[cfg(target_os = "macos")] fmv_macos_events::Inbox);
|
||||
/// The app, its native window per workbook window, and (macOS) the menu bar and Finder events.
|
||||
struct App(SheetApp, DocWindows, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>, #[cfg(target_os = "macos")] fmv_macos_events::Inbox);
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if self.1.is_none() && std::env::var_os("GRIDCRAFT_NO_NATIVE_MENU").is_none() {
|
||||
self.1 = Some(native_menu::NativeMenu::install(ctx));
|
||||
if self.2.is_none() && std::env::var_os("GRIDCRAFT_NO_NATIVE_MENU").is_none() {
|
||||
self.2 = Some(native_menu::NativeMenu::install(ctx));
|
||||
}
|
||||
if let Some(m) = &mut self.1 {
|
||||
m.poll(&mut self.0, ctx);
|
||||
if let Some(m) = &mut self.2 {
|
||||
m.poll(&mut self.0, ctx, self.1.focused_viewport());
|
||||
}
|
||||
apple_events::poll(&self.2, &mut self.0, ctx);
|
||||
apple_events::poll(&self.3, &mut self.0, ctx);
|
||||
}
|
||||
self.0.logic(ctx);
|
||||
self.1.logic(&mut self.0, ctx);
|
||||
if ctx.input(|input| input.viewport().close_requested()) && self.0.printing.busy() {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
}
|
||||
@@ -56,7 +59,7 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.0.ui(ui);
|
||||
self.1.show(&mut self.0, ui);
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
print::finish_pending_jobs();
|
||||
@@ -335,6 +338,7 @@ fn main() -> eframe::Result<()> {
|
||||
if let Some(i) = icon() {
|
||||
viewport = viewport.with_icon(i);
|
||||
}
|
||||
let window_builder = viewport.clone();
|
||||
let options = eframe::NativeOptions { viewport, ..Default::default() };
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let mut options = options;
|
||||
@@ -384,8 +388,10 @@ fn main() -> eframe::Result<()> {
|
||||
Err(e) => log::error!("control channel on port {port}: {e}"),
|
||||
}
|
||||
}
|
||||
let windows = DocWindows::new(&mut app, window_builder.clone(), DocWindows::platform_quits_with_last_window());
|
||||
Ok(Box::new(App(
|
||||
app,
|
||||
windows,
|
||||
#[cfg(target_os = "macos")]
|
||||
None,
|
||||
#[cfg(target_os = "macos")]
|
||||
|
||||
@@ -204,8 +204,8 @@ pub struct NativeMenu {
|
||||
/// Menu id of the Window menu item that brings workbook `doc` forward.
|
||||
const WINDOW_ITEM: &str = "window:id:";
|
||||
|
||||
/// The Quit item: closing the window asks about unsaved workbooks first, which AppKit's own
|
||||
/// Quit (terminate:) would skip.
|
||||
/// The Quit item: asks about each unsaved workbook in its window first
|
||||
/// (`SheetApp::request_quit`), which AppKit's own Quit (terminate:) would skip.
|
||||
const QUIT: &str = "app.quit";
|
||||
|
||||
fn select_theme(app: &mut SheetApp, id: &str) -> bool {
|
||||
@@ -312,11 +312,13 @@ impl NativeMenu {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn poll(&mut self, app: &mut SheetApp, ctx: &egui::Context) {
|
||||
/// Runs the chosen menu items. Commands go to the active window, the most recently focused;
|
||||
/// `viewport` is its egui viewport, for the items that act on its input.
|
||||
pub fn poll(&mut self, app: &mut SheetApp, ctx: &egui::Context, viewport: egui::ViewportId) {
|
||||
while let Ok(ev) = self.events.try_recv() {
|
||||
let id = ev.id.as_ref().to_string();
|
||||
if id == QUIT {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
app.request_quit(ctx);
|
||||
continue;
|
||||
}
|
||||
if let Some(window) = id.strip_prefix(WINDOW_ITEM).and_then(|w| w.parse::<u64>().ok()) {
|
||||
@@ -332,11 +334,11 @@ impl NativeMenu {
|
||||
_ => None,
|
||||
};
|
||||
if let Some(command) = clipboard {
|
||||
ctx.send_viewport_cmd(command);
|
||||
ctx.send_viewport_cmd_to(viewport, command);
|
||||
continue;
|
||||
}
|
||||
if id == "edit.undo" || id == "edit.redo" {
|
||||
ctx.input_mut(|i| {
|
||||
ctx.input_mut_for(viewport, |i| {
|
||||
i.events.push(egui::Event::Key {
|
||||
key: egui::Key::Z,
|
||||
physical_key: None,
|
||||
|
||||
@@ -166,6 +166,16 @@ impl Session {
|
||||
|
||||
/// Makes a window active, swapping its view into its workbook. Unknown ids are ignored.
|
||||
pub fn activate_window(&mut self, id: u64) {
|
||||
self.enter_window(id, true);
|
||||
}
|
||||
|
||||
/// Makes a window the one commands act on while the interface draws it, without counting as
|
||||
/// an activation: the most recently active window of each workbook stays the same.
|
||||
pub fn show_window(&mut self, id: u64) {
|
||||
self.enter_window(id, false);
|
||||
}
|
||||
|
||||
fn enter_window(&mut self, id: u64, recent: bool) {
|
||||
let Some(wi) = self.windows.iter().position(|w| w.id == id) else { return };
|
||||
let Some(w) = self.windows.get(wi) else { return };
|
||||
let doc = w.doc_uid;
|
||||
@@ -185,10 +195,12 @@ impl Session {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.activation = self.activation.saturating_add(1);
|
||||
let stamp = self.activation;
|
||||
if let Some(w) = self.windows.get_mut(wi) {
|
||||
w.last_active = stamp;
|
||||
if recent {
|
||||
self.activation = self.activation.saturating_add(1);
|
||||
let stamp = self.activation;
|
||||
if let Some(w) = self.windows.get_mut(wi) {
|
||||
w.last_active = stamp;
|
||||
}
|
||||
}
|
||||
self.active_window = Some(id);
|
||||
if let Some(di) = self.document_index(doc) {
|
||||
|
||||
@@ -9,9 +9,9 @@ description = "GridCraft egui frontend (Excel-style UI); swappable — nothing b
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
# The shared capture slot (`craft_ui::capture`) comes from the same reviewed immutable
|
||||
# craft-libs revision as the desktop's craft-print dependency.
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" }
|
||||
# The shared capture slot (`craft_ui::capture`) comes from the one craft-libs revision every
|
||||
# craft-libs dependency uses (see the root Cargo.toml).
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" }
|
||||
gridcraft-engine = { workspace = true }
|
||||
gridcraft-chart = { workspace = true }
|
||||
# `monochrome_emoji_fonts` restores the Noto Emoji and emoji-icon faces that 0.37 moved behind
|
||||
@@ -39,6 +39,7 @@ gridcraft-xlsx = { workspace = true, features = ["test-support"] }
|
||||
craft-update = { workspace = true }
|
||||
# The desktop's preferred language for the first-run interface language.
|
||||
sys-locale = { workspace = true }
|
||||
craft-window = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# Read the browser's preferred language for the first-run web interface language.
|
||||
|
||||
@@ -1439,29 +1439,14 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
}
|
||||
"saveChanges" => {
|
||||
ui.label(format!("Do you want to save the changes you made to {}?", d.values.get("title").and_then(Json::as_str).unwrap_or("this workbook")));
|
||||
// Asked while quitting: once answered, the next unsaved workbook is asked about.
|
||||
let quitting = d.values.get("quit").and_then(Json::as_bool) == Some(true);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Don't Save").clicked() {
|
||||
app.run_or_alert("file.close", json!({"force": true, "document": true}));
|
||||
if quitting {
|
||||
app.continue_quit(ui.ctx());
|
||||
for (label, answer) in
|
||||
[("Don't Save", crate::SaveAnswer::DontSave), ("Cancel", crate::SaveAnswer::Cancel), ("Save", crate::SaveAnswer::Save)]
|
||||
{
|
||||
if ui.button(label).clicked() && app.answer_save_changes(answer, quitting, ui.ctx()) {
|
||||
open = false;
|
||||
}
|
||||
open = false;
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
open = false;
|
||||
}
|
||||
// File operations must include the cell the user is still editing (as the File > Save menu item already does).
|
||||
if ui.button("Save").clicked() && app.commit_edit(0, 0, false, false) {
|
||||
app.run_or_alert("file.save", json!({}));
|
||||
if app.session.active().is_some_and(|x| !x.is_dirty()) {
|
||||
app.run_or_alert("file.close", json!({"force": true, "document": true}));
|
||||
if quitting {
|
||||
app.continue_quit(ui.ctx());
|
||||
}
|
||||
}
|
||||
open = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Desktop: each workbook window is a native window of its own.
|
||||
//!
|
||||
//! The engine owns the windows ([`Session::windows`]); [`DocWindows`] mirrors the visible ones in
|
||||
//! a [`WindowList`] and draws each through [`Viewports`]: the root viewport shows one window and
|
||||
//! every other window is an immediate viewport. Before a window draws, it becomes the engine's
|
||||
//! window and its interface state is swapped in ([`SheetApp::show_window`]); after the frame the
|
||||
//! focused window is active again, so menu commands, scripts and shortcuts outside a window's own
|
||||
//! input go to the window the user worked in last.
|
||||
//!
|
||||
//! Close and quit:
|
||||
//! - A window's close button closes that window (`window.close`): one of several windows on a
|
||||
//! workbook closes without asking; a workbook's last window asks Save / Don't Save / Cancel when
|
||||
//! it has unsaved changes.
|
||||
//! - Quit ([`SheetApp::request_quit`]) asks about each unsaved workbook in its window; Cancel stops.
|
||||
//! - When the root's window closes while others are open, another window moves into the root.
|
||||
//! - With no window left, the app quits, or (macOS) keeps running with its root hidden and the
|
||||
//! menu bar showing until a workbook is created or opened.
|
||||
//!
|
||||
//! [`Session::windows`]: gridcraft_engine::Session::windows
|
||||
|
||||
use craft_window::{Frame, Viewports, WindowEvent, WindowId, WindowList};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::SheetApp;
|
||||
|
||||
/// The native windows (see the module docs).
|
||||
pub struct DocWindows {
|
||||
/// Engine window id per native window.
|
||||
list: WindowList<u64>,
|
||||
viewports: Viewports,
|
||||
/// The builder new windows start from: the root window's size, icon and frame settings.
|
||||
base: egui::ViewportBuilder,
|
||||
/// Quit when the last window closes (Windows, Linux); macOS keeps running without a window.
|
||||
quit_with_last_window: bool,
|
||||
/// The root viewport is hidden because no window is open.
|
||||
root_hidden: bool,
|
||||
}
|
||||
|
||||
impl DocWindows {
|
||||
/// Takes over `app`'s windows. `base` is the root window's builder, used for new windows.
|
||||
pub fn new(app: &mut SheetApp, base: egui::ViewportBuilder, quit_with_last_window: bool) -> DocWindows {
|
||||
app.native_windows = true;
|
||||
// The root window keeps the desktop's frame, so every window does.
|
||||
DocWindows { list: WindowList::new(), viewports: Viewports::new(Frame::Desktop), base, quit_with_last_window, root_hidden: false }
|
||||
}
|
||||
|
||||
/// The platform default for the last window closing: macOS keeps running, others quit.
|
||||
pub fn platform_quits_with_last_window() -> bool {
|
||||
!cfg!(target_os = "macos")
|
||||
}
|
||||
|
||||
/// The native window showing engine window `window`.
|
||||
fn find(&self, window: u64) -> Option<WindowId> {
|
||||
self.list.find(|w| *w == window)
|
||||
}
|
||||
|
||||
/// The engine window drawn in the root viewport.
|
||||
pub fn root_window(&self) -> Option<u64> {
|
||||
self.viewports.root().and_then(|w| self.list.get(w).copied())
|
||||
}
|
||||
|
||||
/// The engine windows that have a native window, in opening order.
|
||||
pub fn shown_windows(&self) -> Vec<u64> {
|
||||
self.list.iter().map(|(_, w)| *w).collect()
|
||||
}
|
||||
|
||||
/// The egui viewport showing engine window `window`.
|
||||
pub fn viewport_of(&self, window: u64) -> Option<egui::ViewportId> {
|
||||
self.find(window).map(|id| self.viewports.viewport_id(id))
|
||||
}
|
||||
|
||||
/// The egui viewport of the focused window, where menu commands that act on input (copy,
|
||||
/// paste, undo in a text box) go.
|
||||
pub fn focused_viewport(&self) -> egui::ViewportId {
|
||||
self.list.active().map(|w| self.viewports.viewport_id(w)).unwrap_or(egui::ViewportId::ROOT)
|
||||
}
|
||||
|
||||
/// Call each frame after [`SheetApp::logic`], also while the root is hidden (eframe runs only
|
||||
/// the logic then): shows windows created or opened by menu commands, files and scripts.
|
||||
pub fn logic(&mut self, app: &mut SheetApp, ctx: &egui::Context) {
|
||||
self.sync(app, ctx);
|
||||
}
|
||||
|
||||
/// Mirrors the engine's visible windows: closed or hidden windows lose their native window
|
||||
/// (the root adopting another), new ones get one, and a window a command activated comes to
|
||||
/// the front. With no window left, quits or hides the root.
|
||||
fn sync(&mut self, app: &mut SheetApp, ctx: &egui::Context) {
|
||||
let visible = visible_windows(app);
|
||||
let gone: Vec<WindowId> = self.list.iter().filter(|(_, w)| !visible.contains(w)).map(|(id, _)| id).collect();
|
||||
let mut root_closed = false;
|
||||
for id in gone {
|
||||
root_closed |= self.viewports.root() == Some(id);
|
||||
self.list.close(id);
|
||||
}
|
||||
if root_closed {
|
||||
// The window the engine brought forward, else the most recently focused.
|
||||
let next = app.session.active_window().and_then(|w| self.find(w)).or_else(|| self.list.active());
|
||||
match next {
|
||||
Some(id) => self.viewports.adopt(ctx, &self.list, id),
|
||||
None => self.viewports.set_root(None),
|
||||
}
|
||||
}
|
||||
for w in visible {
|
||||
if self.find(w).is_none() {
|
||||
self.list.open(w);
|
||||
}
|
||||
}
|
||||
if self.viewports.root().is_none() {
|
||||
let first = app.session.active_window().and_then(|w| self.find(w)).or_else(|| self.list.active());
|
||||
self.viewports.set_root(first);
|
||||
}
|
||||
if self.list.is_empty() {
|
||||
if self.quit_with_last_window {
|
||||
if !app.allow_close {
|
||||
app.allow_close = true;
|
||||
ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Close);
|
||||
}
|
||||
} else if !self.root_hidden {
|
||||
self.root_hidden = true;
|
||||
ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Visible(false));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.root_hidden {
|
||||
self.root_hidden = false;
|
||||
ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Visible(true));
|
||||
ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Focus);
|
||||
}
|
||||
// A command or script activated another window: bring its native window forward.
|
||||
if let Some(id) = app.session.active_window().and_then(|w| self.find(w))
|
||||
&& self.list.active() != Some(id)
|
||||
{
|
||||
self.list.focus(id);
|
||||
self.viewports.focus(ctx, id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws every window. Call from the root viewport's UI each frame, in place of
|
||||
/// [`SheetApp::ui`].
|
||||
pub fn show(&mut self, app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
self.sync(app, &ctx);
|
||||
let focused = self.list.active().and_then(|id| self.list.get(id).copied());
|
||||
let titles: std::collections::HashMap<u64, String> = self.list.iter().map(|(_, w)| (*w, window_title(app, *w))).collect();
|
||||
let base = &self.base;
|
||||
// The window a command run in some window's UI activated (File › New, Switch Windows, a close).
|
||||
let mut requested: Option<u64> = None;
|
||||
let events = self.viewports.show(
|
||||
ui,
|
||||
&mut self.list,
|
||||
|_, w| base.clone().with_title(titles.get(w).cloned().unwrap_or_default()),
|
||||
|ui, _, w| {
|
||||
app.show_window(*w);
|
||||
// Files dropped on a window open as workbooks (the root's arrive through the app's
|
||||
// raw input hook; a child window's input reaches only its own pass).
|
||||
let dropped: Vec<String> = ui
|
||||
.ctx()
|
||||
.input(|i| i.raw.dropped_files.iter().filter_map(|f| f.path().to_str().map(str::to_string)).filter(|p| !p.is_empty()).collect());
|
||||
for path in dropped {
|
||||
app.open_path(&path);
|
||||
}
|
||||
app.ui(ui);
|
||||
if app.session.active_window() != Some(*w) {
|
||||
requested = app.session.active_window();
|
||||
}
|
||||
},
|
||||
);
|
||||
for event in events {
|
||||
match event {
|
||||
WindowEvent::Focused(_) => {}
|
||||
WindowEvent::CloseRequested(id) => {
|
||||
if let Some(w) = self.close_requested(app, &ctx, id) {
|
||||
requested = Some(w);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let target = requested.or_else(|| self.list.active().and_then(|id| self.list.get(id).copied()));
|
||||
if let Some(target) = target.filter(|w| app.session.window(*w).is_some()) {
|
||||
if requested.is_none()
|
||||
&& let Some(previous) = focused.filter(|p| *p != target && app.session.window(*p).is_some())
|
||||
{
|
||||
// The user moved to another window: the previous window's draft is entered there,
|
||||
// as Enter would. One its workbook refuses stays open in that window.
|
||||
app.enter_window(previous);
|
||||
let _ = app.settle_drafts();
|
||||
}
|
||||
app.enter_window(target);
|
||||
}
|
||||
self.sync(app, &ctx);
|
||||
}
|
||||
|
||||
/// A window's close button. Returns the window to bring forward when the close needs an
|
||||
/// answer (or moved focus); `None` when nothing changed.
|
||||
fn close_requested(&mut self, app: &mut SheetApp, ctx: &egui::Context, id: WindowId) -> Option<u64> {
|
||||
let is_root = self.viewports.root() == Some(id);
|
||||
if app.allow_close {
|
||||
// Quitting: every workbook has been answered for; the root's close ends the app.
|
||||
return None;
|
||||
}
|
||||
if is_root {
|
||||
// eframe would end the app with the root window; closing it is the app's decision.
|
||||
ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::CancelClose);
|
||||
}
|
||||
let window = *self.list.get(id)?;
|
||||
app.enter_window(window);
|
||||
app.run_or_alert("window.close", json!({"window": window}));
|
||||
app.session.active_window()
|
||||
}
|
||||
}
|
||||
|
||||
/// The windows that get a native window: every window not hidden (View › Hide), or the active one
|
||||
/// when all are hidden, so the app still has a window to unhide them from.
|
||||
fn visible_windows(app: &SheetApp) -> Vec<u64> {
|
||||
let shown: Vec<u64> = app.session.windows().iter().filter(|w| !w.hidden).map(|w| w.id).collect();
|
||||
if shown.is_empty() { app.session.active_window().into_iter().collect() } else { shown }
|
||||
}
|
||||
|
||||
/// A native window's title: the window's ("Book1", "Book1:2"), marked while its workbook has
|
||||
/// unsaved changes.
|
||||
fn window_title(app: &SheetApp, window: u64) -> String {
|
||||
let title = app.session.window_title(window).unwrap_or_default();
|
||||
let dirty = app.session.window(window).and_then(|w| app.session.documents().iter().find(|d| d.uid == w.doc_uid)).is_some_and(|d| d.is_dirty());
|
||||
if dirty { format!("{title} — Edited") } else { title }
|
||||
}
|
||||
+137
-21
@@ -15,6 +15,8 @@ pub mod control;
|
||||
mod craft_fonts_policy;
|
||||
pub mod credits;
|
||||
pub mod dialogs;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod doc_windows;
|
||||
pub mod editor;
|
||||
mod floating_pictures;
|
||||
pub mod fonts;
|
||||
@@ -38,6 +40,9 @@ pub mod theme;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod updates_ui;
|
||||
pub mod widgets;
|
||||
mod window_ui;
|
||||
|
||||
pub use window_ui::WindowUi;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -178,6 +183,14 @@ pub struct Notice {
|
||||
pub action: Option<(String, String)>,
|
||||
}
|
||||
|
||||
/// An answer to the Save Changes question.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SaveAnswer {
|
||||
Save,
|
||||
DontSave,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// One open window, as View › Switch Windows and the macOS Window menu list it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct OpenWindow {
|
||||
@@ -304,8 +317,8 @@ pub struct SheetApp {
|
||||
prepared_fonts: bool,
|
||||
/// Workbook fonts bound into the current font set; a change rebinds (installed fonts only).
|
||||
fonts_bound: Vec<String>,
|
||||
/// The (doc uid, revision) `fonts_bound` was computed for, so the style table is walked only
|
||||
/// when the workbook changes.
|
||||
/// The open workbooks' (uid, revision), folded, that `fonts_bound` was computed for, so the
|
||||
/// style tables are walked only when a workbook changes.
|
||||
fonts_seen: (u64, u64),
|
||||
/// Set while a font-set change is pending activation: skip this frame's paint, like the
|
||||
/// startup `fonts_ready` gate (egui applies new definitions at the next pass).
|
||||
@@ -352,6 +365,15 @@ pub struct SheetApp {
|
||||
/// View › Switch Windows opens its list on the next frame the View tab draws (asked for by a
|
||||
/// key tip, the command search or a menu, possibly while another tab was showing).
|
||||
pub(crate) open_switch_windows: bool,
|
||||
/// The window whose interface state the per-window fields above hold (see [`WindowUi`]).
|
||||
ui_window: Option<u64>,
|
||||
/// The other windows' interface state, by window id.
|
||||
parked: HashMap<u64, WindowUi>,
|
||||
/// Each window is a native window of its own ([`doc_windows`] on the desktop): a window's
|
||||
/// close button closes that window, and quitting is asked for separately. Without it (web,
|
||||
/// tests, embedding hosts) one window shows the active workbook window, and its close button
|
||||
/// quits.
|
||||
pub native_windows: bool,
|
||||
/// The update check, the startup prompt and a running self-update (desktop only).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updates_rt: updates_ui::Runtime,
|
||||
@@ -409,6 +431,9 @@ impl SheetApp {
|
||||
started: now_ms(),
|
||||
allow_close: false,
|
||||
open_switch_windows: false,
|
||||
ui_window: None,
|
||||
parked: HashMap::new(),
|
||||
native_windows: false,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updates_rt: Default::default(),
|
||||
}
|
||||
@@ -441,14 +466,15 @@ impl SheetApp {
|
||||
self.fonts_rebind = false;
|
||||
self.fonts_han = Some(theme::HanOrder::of(self.ui.language));
|
||||
self.fonts_bound = self.used_fonts();
|
||||
self.fonts_seen = self.session.active().map(|d| (d.uid, d.revision)).unwrap_or((0, 0));
|
||||
self.fonts_seen = self.fonts_key();
|
||||
}
|
||||
|
||||
/// The font names the active workbook uses: every style's font plus the theme's heading/body
|
||||
/// fonts. These are the families [`fonts`] binds an installed file for.
|
||||
/// The font names the open workbooks use: every style's font plus the theme's heading/body
|
||||
/// fonts. These are the families [`fonts`] binds an installed file for. Every workbook's, as
|
||||
/// each may be showing in a window of its own.
|
||||
pub fn used_fonts(&self) -> Vec<String> {
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
if let Some(d) = self.session.active() {
|
||||
for d in self.session.documents() {
|
||||
names.extend(d.wb.styles.iter().map(|(_, s)| s.font.name.clone()));
|
||||
names.push(d.wb.theme.major_font.clone());
|
||||
names.push(d.wb.theme.minor_font.clone());
|
||||
@@ -458,6 +484,14 @@ impl SheetApp {
|
||||
names
|
||||
}
|
||||
|
||||
/// The open workbooks' (uid, revision), folded: [`Self::used_fonts`] changes only with it.
|
||||
fn fonts_key(&self) -> (u64, u64) {
|
||||
self.session
|
||||
.documents()
|
||||
.iter()
|
||||
.fold((0u64, 0u64), |(uid, rev), d| (uid.wrapping_mul(31).wrapping_add(d.uid), rev.wrapping_mul(31).wrapping_add(d.revision)))
|
||||
}
|
||||
|
||||
/// Runs an engine command (or a UI command) and handles UI requests it makes.
|
||||
pub fn run(&mut self, id: &str, params: Json) -> Result<Json, String> {
|
||||
let r = self.run_command(id, params);
|
||||
@@ -500,7 +534,14 @@ impl SheetApp {
|
||||
if let Some(r) = self.run_ui_command(id, ¶ms) {
|
||||
return r;
|
||||
}
|
||||
let before = self.session.active_window();
|
||||
let r = self.session.run(id, params);
|
||||
if id.starts_with("file.") && self.session.active_window() != before {
|
||||
// File › New or Open from the Backstage view: the window it was in goes back to its sheet.
|
||||
self.backstage = None;
|
||||
}
|
||||
// Requests the command made (a dialog, a draft) belong to the window it left active.
|
||||
self.follow_active_window();
|
||||
self.after_engine();
|
||||
// The new chart is selected, as after inserting any object, so Delete removes it, not its data.
|
||||
if id == "insert.chart"
|
||||
@@ -671,6 +712,8 @@ impl SheetApp {
|
||||
|
||||
/// Shows a command error: a toast for an unavailable command, else a message box.
|
||||
fn alert(&mut self, e: String) {
|
||||
// Shown in the window the user ends up in, also after a command that switched windows.
|
||||
self.follow_active_window();
|
||||
if e.contains("is not available right now") {
|
||||
self.toast = Some((e, now_ms()));
|
||||
} else {
|
||||
@@ -830,6 +873,8 @@ impl SheetApp {
|
||||
|
||||
/// Picks up requests commands made (dialogs, edit mode…).
|
||||
pub fn after_engine(&mut self) {
|
||||
// The requests belong to the window the command left active.
|
||||
self.follow_active_window();
|
||||
for req in self.session.take_ui_requests() {
|
||||
match req {
|
||||
UiRequest::Dialog(name, params) => self.open_dialog(&name, params),
|
||||
@@ -956,6 +1001,9 @@ impl SheetApp {
|
||||
/// most important one (a preserved VBA project outranks, say, a skipped optional part).
|
||||
/// Kept deliberately generic: any open-time warning can reach the user this way.
|
||||
pub fn show_open_warnings(&mut self, r: &Json) {
|
||||
// The opened workbook's window shows them, however it was opened (a script may have run
|
||||
// `file.open` on the session directly).
|
||||
self.follow_active_window();
|
||||
let Some(list) = r.get("warnings").and_then(Json::as_array) else {
|
||||
self.notice = None;
|
||||
return;
|
||||
@@ -1133,7 +1181,7 @@ impl SheetApp {
|
||||
/// Commits the cell and text-box drafts, as Enter would, before a command that can show
|
||||
/// another workbook or write this one to a file, so a draft never lands in another workbook.
|
||||
/// A draft its workbook refuses stays open with the reason shown, and the command doesn't run.
|
||||
fn settle_drafts(&mut self) -> Result<(), String> {
|
||||
pub(crate) fn settle_drafts(&mut self) -> Result<(), String> {
|
||||
if !self.commit_edit(0, 0, false, false) {
|
||||
return Err(self.message.as_ref().map(|(_, m)| m.clone()).unwrap_or_else(|| "The cell being edited can't be entered.".into()));
|
||||
}
|
||||
@@ -1286,7 +1334,7 @@ impl SheetApp {
|
||||
/// Holds back a window close (the close button, Alt+F4, the OS quitting the app) while a
|
||||
/// workbook has unsaved changes, and asks about them first.
|
||||
fn guard_window_close(&mut self, ctx: &egui::Context) {
|
||||
if self.allow_close || !ctx.input(|i| i.viewport().close_requested()) {
|
||||
if self.native_windows || self.allow_close || !ctx.input(|i| i.viewport().close_requested()) {
|
||||
return;
|
||||
}
|
||||
// A cell still being edited is part of the workbook, as for File › Close.
|
||||
@@ -1307,21 +1355,75 @@ impl SheetApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks about the next unsaved workbook before quitting, or closes the window once none is left.
|
||||
/// Asks about the next unsaved workbook before quitting, in its window, or closes the app
|
||||
/// once none is left.
|
||||
pub(crate) fn continue_quit(&mut self, ctx: &egui::Context) {
|
||||
match self.session.documents().iter().position(|d| d.is_dirty()) {
|
||||
Some(i) => {
|
||||
self.session.set_active(i);
|
||||
let dirty = self.session.documents().iter().find(|d| d.is_dirty()).map(|d| d.uid);
|
||||
match dirty.and_then(|uid| self.session.doc_window(uid)) {
|
||||
Some(window) => {
|
||||
// Its window comes forward (the native window too: see `doc_windows`).
|
||||
self.enter_window(window);
|
||||
let title = self.session.active().map(|d| d.display_title()).unwrap_or_default();
|
||||
self.open_dialog("saveChanges", json!({"title": title, "quit": true}));
|
||||
}
|
||||
None => {
|
||||
self.allow_close = true;
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Close);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers the Save Changes question about the active workbook (its buttons). While quitting,
|
||||
/// the next unsaved workbook is asked about once this one is answered; Cancel stops the quit.
|
||||
/// Returns whether the question is settled: Save keeps it open when the cell being edited
|
||||
/// can't be entered.
|
||||
pub fn answer_save_changes(&mut self, answer: SaveAnswer, quitting: bool, ctx: &egui::Context) -> bool {
|
||||
match answer {
|
||||
SaveAnswer::Cancel => {}
|
||||
SaveAnswer::DontSave => {
|
||||
self.run_or_alert("file.close", json!({"force": true, "document": true}));
|
||||
if quitting {
|
||||
self.continue_quit(ctx);
|
||||
}
|
||||
}
|
||||
SaveAnswer::Save => {
|
||||
// File operations include the cell still being edited, as File › Save does.
|
||||
if !self.commit_edit(0, 0, false, false) {
|
||||
return false;
|
||||
}
|
||||
self.run_or_alert("file.save", json!({}));
|
||||
if self.session.active().is_some_and(|x| !x.is_dirty()) {
|
||||
self.run_or_alert("file.close", json!({"force": true, "document": true}));
|
||||
if quitting {
|
||||
self.continue_quit(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Quit (the macOS app menu, ⌘Q): commits every window's cell draft as Enter would, then asks
|
||||
/// about each unsaved workbook in its window; Cancel on any of them stops the quit. A draft its
|
||||
/// workbook refuses stops the quit too, with its window in front and the reason shown.
|
||||
pub fn request_quit(&mut self, ctx: &egui::Context) {
|
||||
while let Some(window) = self.parked_window_with_draft() {
|
||||
self.enter_window(window);
|
||||
if self.settle_drafts().is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if self.settle_drafts().is_err() {
|
||||
return;
|
||||
}
|
||||
if let Some(d) = self.dialog.as_mut().filter(|d| d.name() == "saveChanges") {
|
||||
// Already asking about a workbook (File › Close): answering it goes on to quit.
|
||||
d.values.insert("quit".into(), json!(true));
|
||||
return;
|
||||
}
|
||||
self.continue_quit(ctx);
|
||||
}
|
||||
|
||||
/// Per-frame logic (control channel, screenshots). Call before `ui`.
|
||||
pub fn logic(&mut self, ctx: &egui::Context) {
|
||||
self.ensure_text_width(ctx);
|
||||
@@ -1360,9 +1462,12 @@ impl SheetApp {
|
||||
self.autosave(ctx);
|
||||
self.update_title(ctx);
|
||||
self.request_hidden_paint(ctx);
|
||||
if self.session.active().is_none() {
|
||||
// One window always shows a workbook, unless each workbook has a native window of its own
|
||||
// (`doc_windows` decides what having none left means).
|
||||
if self.session.active().is_none() && !self.native_windows {
|
||||
self.session.new_workbook();
|
||||
}
|
||||
self.follow_active_window();
|
||||
}
|
||||
|
||||
/// Installs the text-width closure once, on the first frame.
|
||||
@@ -1418,9 +1523,9 @@ impl SheetApp {
|
||||
|
||||
/// Binds the workbook's installed fonts once the background scan has finished. Adding fonts
|
||||
/// re-applies the font set, which egui activates at the next pass, so skip this frame's paint
|
||||
/// (same one-frame cost as startup). Only when the workbook changed.
|
||||
/// (same one-frame cost as startup). Only when an open workbook changed.
|
||||
fn rebind_workbook_fonts(&mut self, ctx: &egui::Context) {
|
||||
let seen = self.session.active().map(|d| (d.uid, d.revision)).unwrap_or((0, 0));
|
||||
let seen = self.fonts_key();
|
||||
if seen == self.fonts_seen {
|
||||
return;
|
||||
}
|
||||
@@ -1460,9 +1565,10 @@ impl SheetApp {
|
||||
ctx.options_mut(|o| o.zoom_with_keyboard = false);
|
||||
}
|
||||
// egui closes the window on Ctrl+Q (and with Shift or Alt held too) without asking to save
|
||||
// (#1443). Excel for Windows has no such key (Ctrl+Q is Quick Analysis); macOS keeps Cmd+Q,
|
||||
// the app's Quit there.
|
||||
if ctx.os() != egui::os::OperatingSystem::Mac && ctx.options(|o| !o.quit_shortcuts.is_empty()) {
|
||||
// (#1443). Excel for Windows has no such key (Ctrl+Q is Quick Analysis). With a native
|
||||
// window per workbook egui's Cmd+Q would close only the window it was typed in: the menu
|
||||
// bar's Quit item has the key instead (it asks about unsaved workbooks first).
|
||||
if (ctx.os() != egui::os::OperatingSystem::Mac || self.native_windows) && ctx.options(|o| !o.quit_shortcuts.is_empty()) {
|
||||
ctx.options_mut(|o| o.quit_shortcuts.clear());
|
||||
}
|
||||
let zoom = self.ui.zoom_factor();
|
||||
@@ -1536,6 +1642,10 @@ impl SheetApp {
|
||||
/// Window title, next to the close/autosave state: `ViewportCommand::Title` is valid outside a
|
||||
/// pass and a control-channel rename while hidden updates it on return.
|
||||
fn update_title(&mut self, ctx: &egui::Context) {
|
||||
// Native windows get theirs from `doc_windows`.
|
||||
if self.native_windows {
|
||||
return;
|
||||
}
|
||||
let Some(d) = self.session.active() else { return };
|
||||
let title = format!("{}{}", d.display_title(), if d.is_dirty() { " — Edited" } else { "" });
|
||||
if self.grid.last_title.as_deref() != Some(title.as_str()) {
|
||||
@@ -1608,6 +1718,8 @@ impl SheetApp {
|
||||
return;
|
||||
}
|
||||
let t0 = now_ms();
|
||||
// A script may have switched windows since the last frame.
|
||||
self.follow_active_window();
|
||||
text_box::before_ui(self, &ctx);
|
||||
// Another window came forward without going through a command (which settles drafts
|
||||
// first): the draft goes into its own window instead of floating over this one. If its
|
||||
@@ -1655,7 +1767,10 @@ impl SheetApp {
|
||||
dialogs::show(self, &ctx);
|
||||
widgets::message_box(self, &ctx);
|
||||
widgets::toast(self, &ctx);
|
||||
control::issue_screenshots(self, &ctx);
|
||||
// Requests are admitted in `logic`, for the root viewport, so only its pass issues them.
|
||||
if ctx.viewport_id() == egui::ViewportId::ROOT {
|
||||
control::issue_screenshots(self, &ctx);
|
||||
}
|
||||
if !ctx.input(|i| i.focused) {
|
||||
// Nothing else to do.
|
||||
}
|
||||
@@ -1672,7 +1787,8 @@ impl SheetApp {
|
||||
// same frame must not offer it again (grid sizing calls `ui` more than once per frame);
|
||||
// provenance is the injected pass number, so only a pass that missed input handling is
|
||||
// re-offered by `raw_input_hook`.
|
||||
if self.synthetic_pending {
|
||||
// Synthetic input reaches the root viewport only (`raw_input_hook`), so its pass consumes it.
|
||||
if self.synthetic_pending && ctx.viewport_id() == egui::ViewportId::ROOT {
|
||||
self.synthetic.pop_front();
|
||||
self.synthetic_pending = false;
|
||||
self.synthetic_injected_at_pass = None;
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! The interface state each window keeps for itself.
|
||||
//!
|
||||
//! [`SheetApp`]'s per-window fields (the cell draft, dialog, Backstage view, grid gestures…) hold
|
||||
//! the state of one window: the engine's active window. Every other window's state is parked in a
|
||||
//! [`WindowUi`] and swapped in when that window is activated or drawn, the way the engine swaps a
|
||||
//! window's selection and sheet into its workbook. So a draft, a dialog or a message stays in the
|
||||
//! window it belongs to.
|
||||
|
||||
use gridcraft_engine::core::CellRef;
|
||||
|
||||
use crate::{Notice, SheetApp, backstage, dialogs, editor, grid, keytips, text_box};
|
||||
|
||||
/// One window's parked interface state (see the module docs).
|
||||
#[derive(Default)]
|
||||
pub struct WindowUi {
|
||||
editor: Option<editor::EditState>,
|
||||
text_box_editor: Option<text_box::EditState>,
|
||||
dialog: Option<dialogs::Dialog>,
|
||||
backstage: Option<backstage::Backstage>,
|
||||
message: Option<(String, String)>,
|
||||
toast: Option<(String, f64)>,
|
||||
notice: Option<Notice>,
|
||||
grid: grid::GridState,
|
||||
name_box: Option<String>,
|
||||
keytips: keytips::KeyTips,
|
||||
selected_chart: Option<u32>,
|
||||
tab_run: Option<(u64, usize, u32, CellRef)>,
|
||||
open_switch_windows: bool,
|
||||
/// The status bar's Ready/Enter/Edit, which follows the window's cell draft.
|
||||
mode: gridcraft_engine::Mode,
|
||||
}
|
||||
|
||||
impl WindowUi {
|
||||
/// A new window's state. The last border, fill and font colour picked carry over, as they are
|
||||
/// the user's recent choices rather than the window's.
|
||||
fn fresh(like: &grid::GridState) -> WindowUi {
|
||||
WindowUi {
|
||||
grid: grid::GridState {
|
||||
last_border: like.last_border.clone(),
|
||||
last_fill: like.last_fill.clone(),
|
||||
last_font_color: like.last_font_color.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SheetApp {
|
||||
/// Exchanges the per-window fields with `w`. Destructured in full, so a field added to
|
||||
/// [`WindowUi`] can't be left out of the swap.
|
||||
fn swap_window_ui(&mut self, w: &mut WindowUi) {
|
||||
let WindowUi {
|
||||
editor,
|
||||
text_box_editor,
|
||||
dialog,
|
||||
backstage,
|
||||
message,
|
||||
toast,
|
||||
notice,
|
||||
grid,
|
||||
name_box,
|
||||
keytips,
|
||||
selected_chart,
|
||||
tab_run,
|
||||
open_switch_windows,
|
||||
mode,
|
||||
} = w;
|
||||
std::mem::swap(&mut self.editor, editor);
|
||||
std::mem::swap(&mut self.text_box_editor, text_box_editor);
|
||||
std::mem::swap(&mut self.dialog, dialog);
|
||||
std::mem::swap(&mut self.backstage, backstage);
|
||||
std::mem::swap(&mut self.message, message);
|
||||
std::mem::swap(&mut self.toast, toast);
|
||||
std::mem::swap(&mut self.notice, notice);
|
||||
std::mem::swap(&mut self.grid, grid);
|
||||
std::mem::swap(&mut self.name_box, name_box);
|
||||
std::mem::swap(&mut self.keytips, keytips);
|
||||
std::mem::swap(&mut self.selected_chart, selected_chart);
|
||||
std::mem::swap(&mut self.tab_run, tab_run);
|
||||
std::mem::swap(&mut self.open_switch_windows, open_switch_windows);
|
||||
std::mem::swap(&mut self.session.mode, mode);
|
||||
}
|
||||
|
||||
/// The window whose interface state is in the per-window fields.
|
||||
pub fn ui_window(&self) -> Option<u64> {
|
||||
self.ui_window
|
||||
}
|
||||
|
||||
/// Brings the per-window fields in line with the engine's active window, after a command (or
|
||||
/// a script) switched windows: the previous window's state is parked, the active one's swapped
|
||||
/// in (a window never seen before starts fresh). A closed window's state is dropped.
|
||||
pub fn follow_active_window(&mut self) {
|
||||
let active = self.session.active_window();
|
||||
if active == self.ui_window {
|
||||
return;
|
||||
}
|
||||
let Some(previous) = self.ui_window else {
|
||||
// The first window: the fields are already its state.
|
||||
self.ui_window = active;
|
||||
return;
|
||||
};
|
||||
let mut outgoing = WindowUi::fresh(&self.grid);
|
||||
self.swap_window_ui(&mut outgoing);
|
||||
if self.session.window(previous).is_some() {
|
||||
self.parked.insert(previous, outgoing);
|
||||
}
|
||||
if let Some(mut incoming) = active.and_then(|w| self.parked.remove(&w)) {
|
||||
self.swap_window_ui(&mut incoming);
|
||||
}
|
||||
self.ui_window = active;
|
||||
let session = &self.session;
|
||||
self.parked.retain(|w, _| session.window(*w).is_some());
|
||||
}
|
||||
|
||||
/// Activates window `id` (it becomes the most recently active) with its interface state.
|
||||
pub fn enter_window(&mut self, id: u64) {
|
||||
self.session.activate_window(id);
|
||||
self.follow_active_window();
|
||||
}
|
||||
|
||||
/// Makes window `id` and its interface state current while it is drawn, without changing
|
||||
/// which window counts as most recently active.
|
||||
pub fn show_window(&mut self, id: u64) {
|
||||
self.session.show_window(id);
|
||||
self.follow_active_window();
|
||||
}
|
||||
|
||||
/// Whether a window other than the current one has a cell or text-box draft open.
|
||||
pub fn parked_window_with_draft(&self) -> Option<u64> {
|
||||
self.parked.iter().filter(|(_, w)| w.editor.is_some() || w.text_box_editor.is_some()).map(|(id, _)| *id).min()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
//! One native window per workbook window: each window keeps its own interface state, and closing
|
||||
//! or quitting asks about unsaved workbooks only when a workbook would close.
|
||||
//!
|
||||
//! The windows are drawn through a test backend that, like eframe, runs each immediate viewport
|
||||
//! as a nested pass with its own input, so a close button can be pressed on one window.
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use egui::{Context, RawInput, ViewportCommand, ViewportEvent, ViewportId, ViewportInfo};
|
||||
use gridcraft_engine::{Session, core::CellRef, core::Value};
|
||||
use gridcraft_ui_egui::doc_windows::DocWindows;
|
||||
use gridcraft_ui_egui::{SaveAnswer, SheetApp};
|
||||
use serde_json::json;
|
||||
|
||||
thread_local! {
|
||||
/// Each viewport's input for the next frame (focus, close button).
|
||||
static INFOS: RefCell<egui::ViewportIdMap<ViewportInfo>> = RefCell::new(egui::ViewportIdMap::default());
|
||||
/// The viewport commands the app sent this frame.
|
||||
static SENT: RefCell<Vec<(ViewportId, ViewportCommand)>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
fn record(mut out: egui::FullOutput) {
|
||||
out.textures_delta.clear(); // not painted
|
||||
SENT.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
for (id, v) in &out.viewport_output {
|
||||
s.extend(v.commands.iter().map(|c| (*id, c.clone())));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
struct Desk {
|
||||
ctx: Context,
|
||||
app: SheetApp,
|
||||
windows: DocWindows,
|
||||
}
|
||||
|
||||
impl Desk {
|
||||
fn new(quit_with_last_window: bool) -> Desk {
|
||||
INFOS.with(|i| i.borrow_mut().clear());
|
||||
let ctx = Context::default();
|
||||
ctx.set_embed_viewports(false);
|
||||
Context::set_immediate_viewport_renderer(|ctx, viewport| {
|
||||
let id = viewport.ids.this;
|
||||
let mut input = RawInput { viewport_id: id, ..RawInput::default() };
|
||||
input.viewports = INFOS.with(|i| i.borrow().clone());
|
||||
input.viewports.entry(id).or_default().parent = Some(viewport.ids.parent);
|
||||
let mut cb = viewport.viewport_ui_cb;
|
||||
record(ctx.run_ui(input, |ui| cb(ui)));
|
||||
});
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut app = SheetApp::new(session, Default::default());
|
||||
let windows = DocWindows::new(&mut app, egui::ViewportBuilder::default(), quit_with_last_window);
|
||||
let mut desk = Desk { ctx, app, windows };
|
||||
desk.frames(3);
|
||||
desk
|
||||
}
|
||||
|
||||
/// One frame as eframe runs it; `act` runs first, inside the frame. Returns the viewport
|
||||
/// commands sent.
|
||||
fn frame_with(&mut self, act: impl FnOnce(&mut SheetApp, &Context)) -> Vec<(ViewportId, ViewportCommand)> {
|
||||
SENT.with(|s| s.borrow_mut().clear());
|
||||
let mut input = RawInput { viewport_id: ViewportId::ROOT, ..RawInput::default() };
|
||||
input.viewports = INFOS.with(|i| i.borrow().clone());
|
||||
input.viewports.entry(ViewportId::ROOT).or_default();
|
||||
let Desk { ctx, app, windows } = self;
|
||||
let mut act = Some(act);
|
||||
let out = ctx.clone().run_ui(input, |ui| {
|
||||
let ctx = ui.ctx().clone();
|
||||
if let Some(act) = act.take() {
|
||||
act(app, &ctx);
|
||||
}
|
||||
app.logic(&ctx);
|
||||
windows.logic(app, &ctx);
|
||||
windows.show(app, ui);
|
||||
});
|
||||
record(out);
|
||||
// Close buttons are one-shot events.
|
||||
INFOS.with(|i| i.borrow_mut().values_mut().for_each(|v| v.events.clear()));
|
||||
SENT.with(|s| s.borrow().clone())
|
||||
}
|
||||
|
||||
fn frames(&mut self, n: usize) -> Vec<(ViewportId, ViewportCommand)> {
|
||||
(0..n).flat_map(|_| self.frame_with(|_, _| {})).collect()
|
||||
}
|
||||
|
||||
fn viewport(&self, window: u64) -> ViewportId {
|
||||
self.windows.viewport_of(window).unwrap_or_else(|| panic!("window {window} has no native window"))
|
||||
}
|
||||
|
||||
/// The user presses window `window`'s close button.
|
||||
fn close(&mut self, window: u64) -> Vec<(ViewportId, ViewportCommand)> {
|
||||
let vp = self.viewport(window);
|
||||
INFOS.with(|i| i.borrow_mut().entry(vp).or_default().events.push(ViewportEvent::Close));
|
||||
let mut sent = self.frame_with(|_, _| {});
|
||||
sent.extend(self.frames(2));
|
||||
sent
|
||||
}
|
||||
|
||||
/// Answers the Save Changes question showing in the active window, as its button does.
|
||||
fn answer(&mut self, answer: SaveAnswer) -> Vec<(ViewportId, ViewportCommand)> {
|
||||
let mut sent = self.frame_with(|app, ctx| {
|
||||
let d = app.dialog.take().unwrap_or_else(|| panic!("no question is showing"));
|
||||
assert_eq!(d.name(), "saveChanges");
|
||||
let quitting = d.values.get("quit").and_then(serde_json::Value::as_bool) == Some(true);
|
||||
app.answer_save_changes(answer, quitting, ctx);
|
||||
});
|
||||
sent.extend(self.frames(2));
|
||||
sent
|
||||
}
|
||||
|
||||
fn window_of(&self, doc: usize) -> u64 {
|
||||
let uid = self.app.session.documents()[doc].uid;
|
||||
self.app.session.doc_window(uid).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
fn sent_to(sent: &[(ViewportId, ViewportCommand)], vp: ViewportId, cmd: &ViewportCommand) -> bool {
|
||||
sent.iter().any(|(id, c)| *id == vp && c == cmd)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_window_keeps_its_own_selection_and_draft() {
|
||||
let mut desk = Desk::new(true);
|
||||
desk.app.run("file.new", json!({})).unwrap();
|
||||
desk.frames(3);
|
||||
let (w1, w2) = (desk.window_of(0), desk.window_of(1));
|
||||
assert_eq!(desk.windows.shown_windows(), [w1, w2], "a native window per workbook");
|
||||
assert_ne!(desk.viewport(w1), desk.viewport(w2));
|
||||
|
||||
desk.app.enter_window(w1);
|
||||
desk.app.run("selection.set", json!({"range": "B2"})).unwrap();
|
||||
desk.app.begin_edit(Some("one".into()), false);
|
||||
desk.frames(2);
|
||||
desk.app.enter_window(w2);
|
||||
desk.app.run("selection.set", json!({"range": "D4"})).unwrap();
|
||||
desk.app.begin_edit(Some("two".into()), false);
|
||||
desk.frames(3);
|
||||
|
||||
for (w, cell, text) in [(w1, "B2", "one"), (w2, "D4", "two")] {
|
||||
desk.app.enter_window(w);
|
||||
assert_eq!(desk.app.editor.as_ref().map(|ed| ed.text.as_str()), Some(text), "window {w} keeps its draft");
|
||||
assert!(desk.app.grid.rect.is_some(), "window {w} drew its own grid");
|
||||
assert_eq!(desk.app.session.doc().unwrap().selection.active, CellRef::parse(cell).unwrap(), "window {w} keeps its selection");
|
||||
}
|
||||
assert!(desk.app.session.documents().iter().all(|d| !d.is_dirty()), "drawing the other window commits no draft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_one_of_two_windows_on_a_workbook_does_not_ask() {
|
||||
let mut desk = Desk::new(true);
|
||||
desk.app.run("cell.set", json!({"cell": "A1", "input": "42"})).unwrap();
|
||||
desk.app.run("view.newWindow", json!({})).unwrap();
|
||||
desk.frames(3);
|
||||
let windows = desk.windows.shown_windows();
|
||||
assert_eq!(windows.len(), 2, "New Window opens a native window");
|
||||
let child = windows.iter().copied().find(|w| Some(*w) != desk.windows.root_window()).unwrap();
|
||||
|
||||
let sent = desk.close(child);
|
||||
assert!(desk.app.dialog.is_none(), "the workbook stays open in its other window: nothing to ask");
|
||||
assert_eq!(desk.app.session.documents().len(), 1);
|
||||
assert_eq!(desk.app.session.windows().len(), 1);
|
||||
assert!(desk.app.session.documents()[0].is_dirty(), "its changes are kept");
|
||||
assert_eq!(desk.windows.shown_windows().len(), 1);
|
||||
assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "{sent:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_the_last_window_of_an_unsaved_workbook_asks_and_the_app_quits_after() {
|
||||
let mut desk = Desk::new(true);
|
||||
desk.app.run("cell.set", json!({"cell": "A1", "input": "42"})).unwrap();
|
||||
desk.frames(2);
|
||||
let w = desk.window_of(0);
|
||||
let sent = desk.close(w);
|
||||
assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::CancelClose), "the window stays open to ask: {sent:?}");
|
||||
assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("saveChanges"));
|
||||
assert_eq!(desk.app.session.documents().len(), 1);
|
||||
|
||||
let sent = desk.answer(SaveAnswer::Cancel);
|
||||
assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "Cancel keeps it open");
|
||||
assert_eq!(desk.app.session.documents().len(), 1);
|
||||
|
||||
desk.close(w);
|
||||
let sent = desk.answer(SaveAnswer::DontSave);
|
||||
assert!(desk.app.session.documents().is_empty());
|
||||
assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "the last window closed: the app quits: {sent:?}");
|
||||
assert!(desk.app.allow_close);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_quitting_on_the_last_window_the_app_hides_and_new_shows_it_again() {
|
||||
let mut desk = Desk::new(false);
|
||||
let w = desk.window_of(0);
|
||||
let sent = desk.close(w);
|
||||
assert!(desk.app.session.documents().is_empty(), "a clean workbook closes without asking");
|
||||
assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Visible(false)), "{sent:?}");
|
||||
assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "the app keeps running: {sent:?}");
|
||||
assert!(desk.app.session.documents().is_empty(), "and doesn't make a workbook of its own");
|
||||
|
||||
let sent = desk.frame_with(|app, _| app.run_or_alert("file.new", json!({})));
|
||||
assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Visible(true)), "{sent:?}");
|
||||
assert_eq!(desk.windows.root_window(), Some(desk.window_of(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quit_asks_about_each_unsaved_workbook_in_its_window_and_cancel_stops() {
|
||||
let mut desk = Desk::new(true);
|
||||
desk.app.run("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
|
||||
desk.app.run("file.new", json!({})).unwrap();
|
||||
desk.app.run("cell.set", json!({"cell": "A1", "input": "2"})).unwrap();
|
||||
desk.frames(3);
|
||||
let (w1, w2) = (desk.window_of(0), desk.window_of(1));
|
||||
assert_eq!(desk.app.session.active_window(), Some(w2));
|
||||
|
||||
let sent = desk.frame_with(|app, ctx| app.request_quit(ctx));
|
||||
assert_eq!(desk.app.session.active_window(), Some(w1), "Book1's window comes forward to ask");
|
||||
assert!(sent_to(&sent, desk.viewport(w1), &ViewportCommand::Focus), "{sent:?}");
|
||||
assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("saveChanges"));
|
||||
let sent = desk.answer(SaveAnswer::Cancel);
|
||||
assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "Cancel stops the quit: {sent:?}");
|
||||
assert_eq!(desk.app.session.documents().len(), 2);
|
||||
assert!(desk.app.dialog.is_none());
|
||||
|
||||
desk.frame_with(|app, ctx| app.request_quit(ctx));
|
||||
let sent = desk.answer(SaveAnswer::DontSave);
|
||||
assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "Book2 is still unsaved: {sent:?}");
|
||||
assert_eq!(desk.app.session.active_window(), Some(w2), "then Book2's window asks");
|
||||
assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("saveChanges"));
|
||||
let sent = desk.answer(SaveAnswer::DontSave);
|
||||
assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "both answered: the app quits: {sent:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_root_adopts_the_remaining_window_with_its_workbook_and_state() {
|
||||
let mut desk = Desk::new(true);
|
||||
desk.app.run("file.new", json!({})).unwrap();
|
||||
desk.frames(3);
|
||||
let (w1, w2) = (desk.window_of(0), desk.window_of(1));
|
||||
assert_eq!(desk.windows.root_window(), Some(w1));
|
||||
let book2 = desk.app.session.documents()[1].uid;
|
||||
desk.app.enter_window(w2);
|
||||
desk.app.run("selection.set", json!({"range": "C3"})).unwrap();
|
||||
desk.app.begin_edit(Some("kept".into()), false);
|
||||
desk.frames(2);
|
||||
|
||||
let sent = desk.close(w1);
|
||||
assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::CancelClose), "closing the root's window doesn't end the app: {sent:?}");
|
||||
assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "{sent:?}");
|
||||
assert_eq!(desk.windows.root_window(), Some(w2), "Book2's window moves into the root");
|
||||
assert_eq!(desk.windows.shown_windows(), [w2]);
|
||||
assert_eq!(desk.app.session.documents().len(), 1);
|
||||
assert_eq!(desk.app.session.doc().unwrap().uid, book2);
|
||||
assert_eq!(desk.app.editor.as_ref().map(|ed| ed.text.as_str()), Some("kept"), "its draft is still open");
|
||||
assert_eq!(desk.app.session.doc().unwrap().selection.active, CellRef::parse("C3").unwrap());
|
||||
assert_eq!(desk.app.session.doc().unwrap().wb.active().unwrap().value(CellRef::parse("C3").unwrap()), Value::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_commands_follow_the_focused_window_and_leaving_a_window_enters_its_draft() {
|
||||
let mut desk = Desk::new(true);
|
||||
desk.app.run("file.new", json!({})).unwrap();
|
||||
desk.frames(3);
|
||||
let (w1, w2) = (desk.window_of(0), desk.window_of(1));
|
||||
desk.app.begin_edit(Some("typed".into()), false);
|
||||
desk.frames(2);
|
||||
assert_eq!(desk.app.session.active_window(), Some(w2));
|
||||
|
||||
// The user clicks into Book1's window.
|
||||
let (v1, v2) = (desk.viewport(w1), desk.viewport(w2));
|
||||
INFOS.with(|i| {
|
||||
let mut i = i.borrow_mut();
|
||||
i.entry(v1).or_default().focused = Some(true);
|
||||
i.entry(v2).or_default().focused = Some(false);
|
||||
});
|
||||
desk.frames(2);
|
||||
assert_eq!(desk.app.session.active_window(), Some(w1), "commands from the menu bar go to the window focused last");
|
||||
assert!(desk.app.editor.is_none(), "Book1's window has no draft");
|
||||
let book2 = &desk.app.session.documents()[1];
|
||||
assert_eq!(book2.wb.active().unwrap().value(CellRef::new(0, 0)), Value::text("typed"), "the draft was entered in its own window");
|
||||
desk.app.run("cell.set", json!({"cell": "B1", "input": "menu"})).unwrap();
|
||||
assert_eq!(desk.app.session.documents()[0].wb.active().unwrap().value(CellRef::new(0, 1)), Value::text("menu"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feedback_from_opening_a_workbook_shows_in_its_new_window() {
|
||||
let mut desk = Desk::new(true);
|
||||
desk.app.run("cell.set", json!({"cell": "A1", "input": "kept"})).unwrap();
|
||||
desk.frames(2);
|
||||
let book1 = desk.window_of(0);
|
||||
let mut wb = gridcraft_engine::model::Workbook::new();
|
||||
wb.vba_project = Some(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1 fake OLE2 vbaProject".to_vec().into());
|
||||
let b64 = gridcraft_engine::io::base64_encode(&gridcraft_engine::io::save_bytes(&wb, "macros.xlsm").unwrap());
|
||||
desk.frame_with(|app, _| {
|
||||
// A script opening the file on the session, then the interface reporting on it.
|
||||
let r = app.session.run("file.open", json!({"name": "macros.xlsm", "base64": b64})).unwrap();
|
||||
app.show_open_warnings(&r);
|
||||
app.run_or_alert("no.such.command", json!({}));
|
||||
});
|
||||
desk.frames(2);
|
||||
let opened = desk.window_of(1);
|
||||
assert_eq!(desk.windows.shown_windows(), [book1, opened]);
|
||||
assert_eq!(desk.app.session.active_window(), Some(opened));
|
||||
assert_eq!(desk.app.notice.as_ref().map(|n| n.kind), Some(gridcraft_ui_egui::NoticeKind::Warning), "the macro notice shows in the opened window");
|
||||
assert!(desk.app.message.is_some(), "and so does the alert");
|
||||
desk.app.enter_window(book1);
|
||||
assert!(desk.app.notice.is_none() && desk.app.message.is_none(), "Book1's window shows neither");
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# Hardware parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (a native window per workbook window) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
The hardware Excel uses, per platform, against GridCraft. Part of
|
||||
[target-app-parity.md](target-app-parity.md). **Hardware: ~40% ready, 25–40 h** (estimated).
|
||||
@@ -11,7 +11,7 @@ The hardware Excel uses, per platform, against GridCraft. Part of
|
||||
| GPU rendering of the grid and charts | DirectX / Metal accelerated drawing | wgpu: Metal · DirectX 12 (default since 10-08; OpenGL when DirectX 12 has no GPU driver or fails to start; Vulkan only on request, so the Intel UHD `igvk64.dll` crash (#120) can't happen; shaders compiled with FXC, so no DXC runtime is needed, #339) · Vulkan/GL · Vulkan/GL · WebGPU or WebGL2 | At parity in kind. Driver problems: screen flashing on Windows 11 (#76): 2–4 h |
|
||||
| Pen and touch (Draw tab) | Pen pressure and tilt, eraser end, touch mode, Ink to Shape, ink replay | Pen, highlighter, eraser, Ink to Shape with mouse/pen input; **no pressure or tilt**, no touch mode | Pressure-sensitive strokes where winit reports force; touch-friendly ribbon spacing: 4–6 h |
|
||||
| Trackpad and mouse | Smooth scrolling, pinch zoom, horizontal scroll, Magic Mouse | Smooth scrolling and zoom via egui | Verify pinch zoom on each platform: 1–2 h |
|
||||
| Multiple windows and monitors | One window per workbook, New Window for the same workbook, per-monitor DPI | One window; multiple workbooks switch inside it (#162; PR #171) | Native window per workbook, New Window, Arrange All across monitors: 6–8 h |
|
||||
| Multiple windows and monitors | One window per workbook, New Window for the same workbook, per-monitor DPI | A native window per workbook window on the desktop, New Window for the same workbook; new windows cascade from the last one on its monitor (web: one window) | Arrange All across monitors; verify per-monitor DPI with windows on two monitors: 2–3 h |
|
||||
| High-DPI and display scaling | yes | yes (display scale matched to desktop density; View › Interface size 75–200 % on top, #230) | — |
|
||||
| Accessibility hardware (screen readers, braille displays, switch control) | Narrator, JAWS, NVDA, VoiceOver | AccessKit enabled in eframe; grid cells and ribbon not yet exposed as an accessible tree | Counted under UI ([ui-parity.md](ui-parity.md)) |
|
||||
| Printers | System print with driver options | Desktop submits through craft-print with printer choice, copies, duplex and grayscale; web browser printing | Counted under print |
|
||||
@@ -20,6 +20,7 @@ The hardware Excel uses, per platform, against GridCraft. Part of
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-22 | minor | Multiple windows: each workbook window is a native window of its own (desktop). |
|
||||
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. |
|
||||
| 2026-10-11 | trivial | Windows: DirectX 12 shaders compiled with FXC, no DXC runtime needed (#339, #354) |
|
||||
| 2026-10-11 | trivial | Windows: OpenGL fallback when DirectX 12 has no GPU or fails to start; Vulkan never by default (#120) |
|
||||
|
||||
+3
-2
@@ -34,8 +34,8 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes
|
||||
| Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return) with Home, New (blank + the three samples), Open (recent + Browse; the recent list tells when each workbook was last changed, 10-12), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation mode and iterative calculation with Maximum Iterations and Maximum Change, 10-15) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has |
|
||||
| Dark mode, system appearance | yes | yes | done (10-09) |
|
||||
| Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab, whose Different First Page and Different Odd & Even Pages give the first page and the even pages their own header and footer (#344), and whose Scale with Document and Align with Page Margins set whether header and footer text follows the print scale and the side margins, on paper and in the view | margins drag on the rulers (#792, with header and footer margins); header/footer pictures missing (1–3 h). Unverified: with Align with Page Margins off, header and footer text sits 0.7 in from the paper edge, a value not yet measured in the target app |
|
||||
| Multiple windows per workbook | yes | no (#162) | see [hardware-parity.md](hardware-parity.md) |
|
||||
| Switch Windows, New Window, unsaved workbooks on quit | yes | yes (10-22): View › New Window opens another window on the same workbook (no copy: saving from either saves it), each with its own selection, active sheet and scroll; a workbook with several windows titles them "Book1:1", "Book1:2"; File › Close closes one of them without asking and asks only for the last; View › Switch Windows and the macOS Window menu list the open windows (active one checked, unsaved ones marked •) and bring the chosen one forward (`view.switchWindows {window}`, or `{doc}` for a workbook's most recent window); a cell being typed is entered into its own window and workbook before New, Open, Close, Save, Print and window switches (also between two windows on one workbook), and a refused entry stays open and stops the command; quitting (window close, Cmd+Q, the macOS Quit item) asks Save / Don't Save / Cancel for each unsaved workbook | one OS window for all workbooks (#162) |
|
||||
| Multiple windows per workbook | yes | yes (10-22, desktop): each workbook window is a native window of its own (New, Open and New Window open one; titles "Book1", or "Book1:1" / "Book1:2" for a workbook's second window, with " — Edited" while unsaved). Each window keeps its own cell draft, dialog, Backstage view, message and grid state, and its ribbon acts on it; on macOS the menu bar acts on the window focused last. Leaving a window enters its draft as Enter would. A window's close button closes that window: one of several on a workbook closes without asking, a workbook's last asks Save / Don't Save / Cancel when unsaved. Quit asks about each unsaved workbook in its window, and Cancel stops it. The last window closed quits on Windows and Linux; macOS keeps running with the menu bar until New or Open. Web: one window | no Arrange All; a hidden window (View › Hide) has no native window; every window repaints when one does; see [hardware-parity.md](hardware-parity.md) |
|
||||
| Switch Windows, New Window, unsaved workbooks on quit | yes | yes (10-22): View › New Window opens another window on the same workbook (no copy: saving from either saves it), each with its own selection, active sheet and scroll; a workbook with several windows titles them "Book1:1", "Book1:2"; File › Close closes one of them without asking and asks only for the last; View › Switch Windows and the macOS Window menu list the open windows (active one checked, unsaved ones marked •) and bring the chosen one forward (`view.switchWindows {window}`, or `{doc}` for a workbook's most recent window); a cell being typed is entered into its own window and workbook before New, Open, Close, Save, Print and window switches (also between two windows on one workbook), and a refused entry stays open and stops the command; quitting (Cmd+Q, the macOS Quit item, the Dock) asks Save / Don't Save / Cancel for each unsaved workbook | |
|
||||
|
||||
## Grid interaction
|
||||
|
||||
@@ -202,6 +202,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
|
||||
| 2026-10-24 | minor | When the fill starts from a date, the right-drag fill menu and the Auto Fill Options button also offer Fill Days, Fill Weekdays (Saturday and Sunday skipped), Fill Months and Fill Years (`edit.autoFill` with `mode` set to `days`, `weekdays`, `months` or `years`). A lone date steps by one unit; two or more dates step by the difference between the first two in that unit, or one unit when they fall in the same one. Fill Months and Fill Years keep the day of the month, clamped to the month's last day. Every fill by months or years (these two, a drag of dates a month apart, and Series by Month or Year) keeps the time of day of a date with a time, which used to be dropped |
|
||||
| 2026-10-22 | minor | Show Outline Symbols (`view.showOutlineSymbols`, Ctrl+8): a sheet can hide the outline's level buttons and brackets while keeping its outline, as Excel saves with `showOutlineSymbols="0"` (read and written); the grid then gives the bands no room |
|
||||
| 2026-10-22 | minor | A number in a repeat-fill format (`*`, as in the Accounting format) that is too wide for its column shows #### like any other number, instead of the fill rebuilding the value so its leading digits were cut off (2,509.00 CHF drawn as "509.00 CHF") |
|
||||
| 2026-10-22 | minor | One native window per workbook window on the desktop: per-window drafts, dialogs and ribbons; menu bar commands go to the focused window; closing one of several windows on a workbook doesn't ask; Quit asks in each unsaved workbook's window; the last window closed quits (Windows, Linux) or leaves macOS running |
|
||||
| 2026-10-22 | minor | New Window opens a second window on the same workbook instead of a copy that saved over the original; windows keep their own selection, sheet and scroll; titles Name:1 / Name:2; Switch Windows, Hide and Unhide work by window; File › Close closes one window of several without asking |
|
||||
| 2026-10-22 | minor | New workbook no longer loses the first one: the cell being typed is entered into its own workbook before any File or window command (an entry the workbook refuses stays open and the command doesn't run; an untouched F2 draft changes nothing), the editor is tied to its workbook, View › Switch Windows and the macOS Window menu list the open workbooks, and the macOS Quit item asks about unsaved workbooks like the close button |
|
||||
| 2026-10-21 | minor | Background error checking, first part (#1994): a constant whose text would be a number if typed (signs, thousands, currency, percent, exponent, parenthesised negative) gets Excel's green corner triangle; the active cell then shows a warning button whose menu names the problem and offers Convert to Number for the selection (`formulas.convertToNumber`). Dates, times and fractions kept as text and formula results are not flagged, as in Excel |
|
||||
|
||||
Reference in New Issue
Block a user