mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-25 03:06:53 -06:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c217639cb5 | ||
|
|
e9764f6446 | ||
|
|
7a65d373c1 | ||
|
|
ada96c896d | ||
|
|
f86e93ad91 | ||
|
|
33393aa3d8 |
@@ -22,7 +22,7 @@ jobs:
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
# The same craft-fonts commit as release.yml and freebsd.yml, so the gate tests the fonts that ship.
|
||||
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
packages: >-
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev
|
||||
libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
|
||||
|
||||
@@ -29,4 +29,4 @@ jobs:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
with:
|
||||
app-name: gridcraft
|
||||
app-title: GridCraft
|
||||
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
|
||||
@@ -8,6 +8,10 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
|
||||
| Asset | Author | Source | Licence | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full monochrome fallback. Registered by the production default font definitions; dependency assets unmodified here, no font file copied into this repository. |
|
||||
| `epaint_default_fonts::HACK_REGULAR` (dependency font input) | Source Foundry Authors (Hack; DejaVu and Bitstream Vera foundations) | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Hack](https://github.com/source-foundry/Hack) | MIT + Bitstream Vera License (full upstream texts in `NOTICE`) | Hack-Regular.ttf is egui's bundled monospace face and GridCraft's last-resort symbol fallback. Dependency asset unmodified here, no font file copied into this repository. |
|
||||
| `epaint_default_fonts::NOTO_EMOJI_REGULAR` (dependency font input) | The Noto Project Authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Noto Emoji](https://fonts.google.com/noto/specimen/Noto+Emoji) | SIL Open Font License 1.1 (full upstream text in `NOTICE`) | NotoEmoji-Regular.ttf is enabled by egui's `monochrome_emoji_fonts` feature and used as the monochrome emoji fallback. Dependency asset unmodified here, no font file copied into this repository. |
|
||||
| `epaint_default_fonts::UBUNTU_LIGHT` (dependency font input) | Canonical Ltd. and the Ubuntu Font Family authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Ubuntu font](https://design.ubuntu.com/font) | Ubuntu Font Licence 1.0 (full upstream text in `NOTICE`) | Ubuntu-Light.ttf is egui's default proportional face. Dependency asset unmodified here, no font file copied into this repository. |
|
||||
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
|
||||
|
||||
Generated
+1770
-99
File diff suppressed because it is too large
Load Diff
+24
-6
@@ -7,7 +7,7 @@ default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
|
||||
version = "0.11.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/gridcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -43,10 +43,10 @@ sha1 = { version = "0.11", default-features = false }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
egui_extras = { version = "0.36", default-features = false }
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
egui = "0.37"
|
||||
egui_extras = { version = "0.37", default-features = false }
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
egui_kittest = "0.37"
|
||||
rfd = "0.17"
|
||||
arboard = { version = "3.6", default-features = false }
|
||||
# The desktop's preferred language, for the first-run interface language (native only).
|
||||
@@ -55,7 +55,7 @@ sys-locale = "0.3"
|
||||
# to egui, which panics on a file it can't parse. Same version as epaint's, so no second copy.
|
||||
skrifa = { version = "0.44", default-features = false, features = ["std"] }
|
||||
# Real font bytes for tests (Ubuntu Light, compiled into epaint).
|
||||
epaint_default_fonts = "0.36"
|
||||
epaint_default_fonts = "0.37"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
@@ -79,3 +79,21 @@ opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 16
|
||||
debug = 0
|
||||
|
||||
# Keep the complete egui family and native event bridge on reviewed fork commits.
|
||||
[patch.crates-io]
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
|
||||
|
||||
@@ -12,5 +12,265 @@ Third-party material
|
||||
This product bundles third-party material under its own licenses, listed below. The per-file
|
||||
list (author, source, license) is in ATTRIBUTION.md.
|
||||
|
||||
None: every bundled asset is original to GridCraft. Builds made with craft-fonts
|
||||
(CRAFT_FONTS_DIR) embed the fonts listed in ATTRIBUTION.md under the SIL Open Font License 1.1.
|
||||
GridCraft's original assets and the UI's bundled egui font inputs are listed in ATTRIBUTION.md.
|
||||
Builds made with craft-fonts (CRAFT_FONTS_DIR) also embed the fonts listed there under the SIL Open
|
||||
Font License 1.1.
|
||||
|
||||
|
||||
The UI's egui-icons.ttf subset and emoji-icon-font.ttf fallback are provided by epaint_default_fonts
|
||||
and registered through egui's default font definitions. They derive from emoji-icon-font by
|
||||
John Slegers, under the MIT License. The subset and full face share the following upstream notice.
|
||||
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
|
||||
Original: https://github.com/jslegers/emoji-icon-font
|
||||
The following text is reproduced verbatim from emoji-icon-font-mit-license.txt:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2014 John Slegers
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
Hack-Regular.ttf is provided by epaint_default_fonts and is egui's bundled monospace face. The
|
||||
work is Copyright 2018 Source Foundry Authors and is distributed under the MIT License and the
|
||||
Bitstream Vera License. The DejaVu work it derives from was committed to the public domain.
|
||||
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
|
||||
Upstream: https://github.com/source-foundry/Hack
|
||||
The following text is reproduced verbatim from Hack-Regular.txt:
|
||||
|
||||
The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License
|
||||
|
||||
The work in the DejaVu project was committed to the public domain.
|
||||
|
||||
Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera"
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018 Source Foundry Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
BITSTREAM VERA LICENSE
|
||||
|
||||
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces.
|
||||
|
||||
The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera".
|
||||
|
||||
This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names.
|
||||
|
||||
The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself.
|
||||
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org.
|
||||
|
||||
NotoEmoji-Regular.ttf is provided by epaint_default_fonts and is enabled by egui's
|
||||
monochrome_emoji_fonts feature. It is distributed under the SIL Open Font License, Version 1.1.
|
||||
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
|
||||
Upstream: https://fonts.google.com/noto/specimen/Noto+Emoji
|
||||
The following text is reproduced verbatim from OFL.txt:
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License,
|
||||
Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font
|
||||
creation efforts of academic and linguistic communities, and to
|
||||
provide a free and open framework in which fonts may be shared and
|
||||
improved in partnership with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply to
|
||||
any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software
|
||||
components as distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to,
|
||||
deleting, or substituting -- in part or in whole -- any of the
|
||||
components of the Original Version, by changing formats or by porting
|
||||
the Font Software to a new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed,
|
||||
modify, redistribute, and sell modified and unmodified copies of the
|
||||
Font Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components, in
|
||||
Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the
|
||||
corresponding Copyright Holder. This restriction only applies to the
|
||||
primary font name as presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created using
|
||||
the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
Ubuntu-Light.ttf is provided by epaint_default_fonts and is egui's default proportional face. It
|
||||
is distributed under the Ubuntu Font Licence, Version 1.0.
|
||||
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
|
||||
Upstream: https://design.ubuntu.com/font
|
||||
The following text is reproduced verbatim from UFL.txt:
|
||||
|
||||
-------------------------------
|
||||
UBUNTU FONT LICENCE Version 1.0
|
||||
-------------------------------
|
||||
|
||||
PREAMBLE
|
||||
This licence allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely. The fonts, including any derivative works, can be
|
||||
bundled, embedded, and redistributed provided the terms of this licence
|
||||
are met. The fonts and derivatives, however, cannot be released under
|
||||
any other licence. The requirement for fonts to remain under this
|
||||
licence does not require any document created using the fonts or their
|
||||
derivatives to be published under this licence, as long as the primary
|
||||
purpose of the document is not to be a vehicle for the distribution of
|
||||
the fonts.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this licence and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Original Version" refers to the collection of Font Software components
|
||||
as received under this licence.
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to
|
||||
a new environment.
|
||||
|
||||
"Copyright Holder(s)" refers to all individuals and companies who have a
|
||||
copyright ownership of the Font Software.
|
||||
|
||||
"Substantially Changed" refers to Modified Versions which can be easily
|
||||
identified as dissimilar to the Font Software by users of the Font
|
||||
Software comparing the Original Version with the Modified Version.
|
||||
|
||||
To "Propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification and with or without charging
|
||||
a redistribution fee), making available to the public, and in some
|
||||
countries other activities as well.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
This licence does not grant any rights under trademark law and all such
|
||||
rights are reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of the Font Software, to propagate the Font Software, subject to
|
||||
the below conditions:
|
||||
|
||||
1) Each copy of the Font Software must contain the above copyright
|
||||
notice and this licence. These can be included either as stand-alone
|
||||
text files, human-readable headers or in the appropriate machine-
|
||||
readable metadata fields within text or binary files as long as those
|
||||
fields can be easily viewed by the user.
|
||||
|
||||
2) The font name complies with the following:
|
||||
(a) The Original Version must retain its name, unmodified.
|
||||
(b) Modified Versions which are Substantially Changed must be renamed to
|
||||
avoid use of the name of the Original Version or similar names entirely.
|
||||
(c) Modified Versions which are not Substantially Changed must be
|
||||
renamed to both (i) retain the name of the Original Version and (ii) add
|
||||
additional naming elements to distinguish the Modified Version from the
|
||||
Original Version. The name of such Modified Versions must be the name of
|
||||
the Original Version, with "derivative X" where X represents the name of
|
||||
the new work, appended to that name.
|
||||
|
||||
3) The name(s) of the Copyright Holder(s) and any contributor to the
|
||||
Font Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except (i) as required by this licence, (ii) to
|
||||
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
|
||||
their explicit written permission.
|
||||
|
||||
4) The Font Software, modified or unmodified, in part or in whole, must
|
||||
be distributed entirely under this licence, and must not be distributed
|
||||
under any other licence. The requirement for fonts to remain under this
|
||||
licence does not affect any document created using the Font Software,
|
||||
except any version of the Font Software extracted from a document
|
||||
created using the Font Software may only be distributed under this
|
||||
licence.
|
||||
|
||||
TERMINATION
|
||||
This licence becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
|
||||
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
||||
DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
@@ -60,8 +60,10 @@ pub fn start() {
|
||||
}
|
||||
let mut app = SheetApp::new(session, services);
|
||||
app.ui.system_theme = true;
|
||||
// Follow the browser's language, except a CJK one: the web build has no system
|
||||
// CJK fonts (none are bundled), so that interface would render as tofu.
|
||||
// Follow the browser's language, except a CJK one. CJK coverage comes from
|
||||
// optional craft-fonts input, not a guaranteed web font set, so a CJK browser
|
||||
// cannot be assumed to render localized labels without tofu. Users can still
|
||||
// choose a language explicitly.
|
||||
let browser = gridcraft_ui_egui::i18n::Language::system();
|
||||
if !matches!(
|
||||
browser,
|
||||
@@ -156,8 +158,8 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(raw);
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(ctx, raw);
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
|
||||
@@ -13,7 +13,7 @@ workspace = true
|
||||
[dependencies]
|
||||
gridcraft-engine = { workspace = true }
|
||||
gridcraft-ui-egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "clipboard"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -25,7 +25,7 @@ chrono = { workspace = true }
|
||||
winresource = "0.1"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" }
|
||||
rfd = { workspace = true }
|
||||
arboard = { workspace = true }
|
||||
|
||||
|
||||
@@ -40,13 +40,12 @@ impl eframe::App for App {
|
||||
apple_events::poll(&self.2, &mut self.0, ctx);
|
||||
}
|
||||
self.0.logic(ctx);
|
||||
self.0.poll_printing(ctx);
|
||||
if ctx.input(|input| input.viewport().close_requested()) && self.0.printing.busy() {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
}
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(raw);
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(ctx, raw);
|
||||
// Files dropped on the window open as workbooks.
|
||||
for f in std::mem::take(&mut raw.dropped_files) {
|
||||
if let Some(p) = f.path().to_str().map(str::to_string).filter(|p| !p.is_empty()) {
|
||||
|
||||
@@ -5,7 +5,10 @@ pub fn discover() -> Discover {
|
||||
Box::new(|| {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let _ = std::thread::Builder::new().name("print-discovery".into()).spawn(move || {
|
||||
let printers = craft_print::printers().into_iter().map(|printer| Printer { name: printer.name, default: printer.default }).collect();
|
||||
let printers = craft_print::printers()
|
||||
.into_iter()
|
||||
.map(|printer| Printer { name: printer.name, label: printer.description, default: printer.default })
|
||||
.collect();
|
||||
let _ = sender.send(printers);
|
||||
});
|
||||
receiver
|
||||
|
||||
@@ -9,10 +9,15 @@ description = "GridCraft egui frontend (Excel-style UI); swappable — nothing b
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
|
||||
# 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" }
|
||||
gridcraft-engine = { workspace = true }
|
||||
gridcraft-chart = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
# `monochrome_emoji_fonts` restores the Noto Emoji and emoji-icon faces that 0.37 moved behind
|
||||
# this feature. The UI draws ✕, ◀, ↘ and 📋 as text; without these faces the strict kittest
|
||||
# harness (and a host with no symbol font) renders them as tofu.
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -36,7 +41,7 @@ sys-locale = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# Read the browser's preferred language for the first-run web interface language.
|
||||
web-sys = { version = "0.3", features = ["Window", "Navigator"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Navigator", "Performance"] }
|
||||
|
||||
[build-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -12,6 +12,9 @@ use std::fmt::Write as _;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
#[path = "src/craft_fonts_policy.rs"]
|
||||
mod craft_fonts_policy;
|
||||
|
||||
fn main() {
|
||||
// Read when the script runs: `env!` would keep the path from when the script was compiled,
|
||||
// which goes stale if the checkout moves (and a missing file reruns the script every build).
|
||||
@@ -27,6 +30,107 @@ fn main() {
|
||||
if let Err(e) = std::fs::write(&out, generate(&json, &people)) {
|
||||
println!("cargo::warning=writing {}: {e}", out.display());
|
||||
}
|
||||
emit_craft_fonts();
|
||||
}
|
||||
|
||||
/// The optional craft-fonts build input (<https://github.com/storytold/craft-fonts>, recipe from
|
||||
/// its `docs/integration.md`). With `CRAFT_FONTS_DIR=<absolute checkout>` the permitted fonts in
|
||||
/// its `fonts/manifest.txt` are embedded as `CRAFT_FONTS` for `theme`'s fallback chains; unset,
|
||||
/// the table is empty. Release and acceptance builds also set `CRAFT_FONTS_REQUIRED=1` (exactly
|
||||
/// `1`; any other value leaves required mode off), which turns a missing checkout, a missing
|
||||
/// required face or a malformed row into a build error.
|
||||
///
|
||||
/// GridCraft never copies font bytes into this repository and never reads a fixture from Cargo
|
||||
/// configuration: the only input is the environment variable. Barred Adobe/Source/Noto CJK faces
|
||||
/// stay in the shared repository for other consumers but are filtered here. A web build embeds
|
||||
/// only the explicit `WEB_ALLOWED` faces (no CJK collection). Native builds additionally opt into
|
||||
/// craft-fonts' `fonts/simplified-chinese-manifest.txt` (the OFL LXGW face DeckCraft uses): it is
|
||||
/// optional for ordinary builds and required for `CRAFT_FONTS_REQUIRED=1` native builds.
|
||||
fn emit_craft_fonts() {
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR");
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED");
|
||||
let required = craft_fonts_policy::required_mode(std::env::var("CRAFT_FONTS_REQUIRED").ok().as_deref());
|
||||
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
|
||||
let mut src = String::new();
|
||||
let _ = writeln!(src, "pub static CRAFT_FONTS_REQUIRED: bool = {required};");
|
||||
src.push_str("pub static CRAFT_FONTS: &[CraftFont] = &[\n");
|
||||
match std::env::var_os("CRAFT_FONTS_DIR").map(std::path::PathBuf::from) {
|
||||
Some(dir) => match craft_fonts(&dir, wasm, required) {
|
||||
Ok(entries) => src.push_str(&entries),
|
||||
Err(e) if required => {
|
||||
println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display());
|
||||
}
|
||||
Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()),
|
||||
},
|
||||
None if required => {
|
||||
println!("cargo::error=CRAFT_FONTS_REQUIRED=1 requires CRAFT_FONTS_DIR (an absolute craft-fonts checkout)");
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
src.push_str("];\n");
|
||||
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs");
|
||||
if let Err(e) = std::fs::write(&out, src) {
|
||||
println!("cargo::error=writing {}: {e}", out.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// One `CraftFont { .. }` initialiser per manifest row GridCraft may embed.
|
||||
fn craft_fonts(dir: &std::path::Path, wasm: bool, required: bool) -> Result<String, String> {
|
||||
let manifest = dir.join("fonts/manifest.txt");
|
||||
println!("cargo::rerun-if-changed={}", manifest.display());
|
||||
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
|
||||
let rows = craft_fonts_policy::parse_manifest(&text)?;
|
||||
craft_fonts_policy::check_declared_scripts(&rows)?;
|
||||
if required {
|
||||
craft_fonts_policy::check_required(&rows)?;
|
||||
}
|
||||
let chinese = if wasm { Vec::new() } else { chinese_font_rows(dir, required)? };
|
||||
for row in rows.iter().chain(&chinese) {
|
||||
if craft_fonts_policy::barred(&row.family) {
|
||||
println!("cargo::warning=craft-fonts: not embedding {} {} (repository asset policy)", row.family, row.style);
|
||||
}
|
||||
}
|
||||
let mut out = String::new();
|
||||
for row in craft_fonts_policy::embeddable(&rows, wasm).into_iter().chain(craft_fonts_policy::embeddable(&chinese, wasm)) {
|
||||
let path = dir.join(&row.file).canonicalize().map_err(|e| format!("{}: {e}", row.file))?;
|
||||
println!("cargo::rerun-if-changed={}", path.display());
|
||||
let scripts: Vec<String> = row.scripts.iter().map(|s| format!("{s:?}")).collect();
|
||||
let family = &row.family;
|
||||
let style = &row.style;
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" CraftFont {{ family: {family:?}, style: {style:?}, scripts: &[{}], bytes: include_bytes!({path:?}) }},",
|
||||
scripts.join(", "),
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The opt-in native Simplified Chinese rows. Older craft-fonts checkouts may lack the manifest, so
|
||||
/// an ordinary build warns and carries on; a required acceptance build fails rather than passing
|
||||
/// Chinese coverage only because the face was silently absent.
|
||||
fn chinese_font_rows(dir: &std::path::Path, required: bool) -> Result<Vec<craft_fonts_policy::ManifestRow>, String> {
|
||||
let manifest = dir.join("fonts/simplified-chinese-manifest.txt");
|
||||
println!("cargo::rerun-if-changed={}", manifest.display());
|
||||
let optional = |e: String| -> Result<Vec<craft_fonts_policy::ManifestRow>, String> {
|
||||
if required {
|
||||
Err(format!("{}: {e}", manifest.display()))
|
||||
} else {
|
||||
println!("cargo::warning=skipping optional Chinese fonts: {}: {e}", manifest.display());
|
||||
Ok(Vec::new())
|
||||
}
|
||||
};
|
||||
match std::fs::read_to_string(&manifest) {
|
||||
Ok(text) => match craft_fonts_policy::parse_manifest(&text).and_then(|rows| {
|
||||
craft_fonts_policy::check_declared_scripts(&rows)?;
|
||||
craft_fonts_policy::check_chinese_required(&rows)?;
|
||||
Ok(rows)
|
||||
}) {
|
||||
Ok(rows) => Ok(rows),
|
||||
Err(e) => optional(e),
|
||||
},
|
||||
Err(e) => optional(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn read<T>(path: &std::path::Path, fallback: &str, parse: impl Fn(&str) -> Result<T, String>) -> Option<T> {
|
||||
|
||||
@@ -260,7 +260,7 @@ fn save(app: &mut SheetApp, out: &mut Outcome) {
|
||||
|
||||
fn nav_item(ui: &mut Ui, icon: Icon, label: &str, selected: bool, text: Color32, sel: Color32) -> egui::Response {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(NAV_W, 38.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, label));
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, label));
|
||||
let p = ui.painter();
|
||||
if selected {
|
||||
p.rect_filled(rect, 0.0, sel);
|
||||
@@ -297,7 +297,7 @@ fn dim(ui: &mut Ui, text: &str) {
|
||||
fn tile(ui: &mut Ui, label: &str, sample: Option<usize>) -> egui::Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(150.0, 140.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label));
|
||||
let p = ui.painter();
|
||||
let thumb = Rect::from_min_size(rect.min, vec2(rect.width(), 104.0));
|
||||
p.rect_filled(thumb, 4.0, t.grid_bg);
|
||||
@@ -388,7 +388,7 @@ fn recent_list(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Ou
|
||||
for path in app.ui.recent.iter().take(limit).cloned().collect::<Vec<_>>() {
|
||||
let (name, folder) = split_path(&path);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &name));
|
||||
let p = ui.painter();
|
||||
if resp.hovered() {
|
||||
p.rect_filled(rect, 4.0, t.hover);
|
||||
@@ -514,7 +514,7 @@ fn action_row(ui: &mut Ui, icon: Icon, title: &str, detail: &str) -> egui::Respo
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let width = ui.available_width().min(CONTENT_W);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, 56.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, title));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, title));
|
||||
let p = ui.painter();
|
||||
if resp.hovered() {
|
||||
p.rect_filled(rect, 4.0, t.hover);
|
||||
@@ -529,7 +529,7 @@ fn action_row(ui: &mut Ui, icon: Icon, title: &str, detail: &str) -> egui::Respo
|
||||
fn square_button(ui: &mut Ui, icon: Icon, label: &str) -> egui::Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(96.0, 88.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label));
|
||||
let p = ui.painter();
|
||||
p.rect_filled(rect, 6.0, if resp.hovered() { t.hover } else { t.ribbon });
|
||||
p.rect_stroke(rect, 6.0, Stroke::new(1.0, if resp.hovered() { t.accent } else { t.ribbon_border }), StrokeKind::Inside);
|
||||
@@ -643,13 +643,13 @@ fn info(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage) {
|
||||
ui.end_row();
|
||||
for (i, (key, label)) in PROPERTIES.iter().enumerate() {
|
||||
let (Some(draft), Some(saved)) = (bs.props.get_mut(i), current.get(i)) else { continue };
|
||||
ui.label(RichText::new(lang.tr(label)).color(t.text_dim));
|
||||
let label = ui.label(RichText::new(lang.tr(label)).color(t.text_dim));
|
||||
let id = ui.make_persistent_id(("backstage_prop", *key));
|
||||
// Show the workbook's value unless the field is being edited (undo, another window).
|
||||
if !ui.ctx().memory(|m| m.has_focus(id)) {
|
||||
draft.clone_from(saved);
|
||||
}
|
||||
let resp = ui.add(egui::TextEdit::singleline(draft).id(id).desired_width(360.0));
|
||||
let resp = ui.add(egui::TextEdit::singleline(draft).id(id).desired_width(360.0)).labelled_by(label.id);
|
||||
if resp.lost_focus() && draft != saved {
|
||||
let mut params = serde_json::Map::new();
|
||||
params.insert((*key).to_string(), json!(draft.clone()));
|
||||
@@ -667,14 +667,19 @@ fn save_as(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcom
|
||||
let fmt = bs.save_format;
|
||||
let current = SAVE_TYPES.iter().find(|(ext, _)| *ext == fmt).map_or("Excel Workbook (*.xlsx)", |(_, label)| *label);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(lang.tr("Save as type"));
|
||||
egui::ComboBox::from_id_salt("backstage_save_type").width(280.0).selected_text(lang.tr(current)).show_ui(ui, |ui| {
|
||||
for (ext, label) in SAVE_TYPES {
|
||||
if ui.selectable_label(fmt == ext, lang.tr(label)).clicked() {
|
||||
bs.save_format = ext;
|
||||
let label = ui.label(lang.tr("Save as type"));
|
||||
egui::ComboBox::from_id_salt("backstage_save_type")
|
||||
.width(280.0)
|
||||
.selected_text(lang.tr(current))
|
||||
.show_ui(ui, |ui| {
|
||||
for (ext, label) in SAVE_TYPES {
|
||||
if ui.selectable_label(fmt == ext, lang.tr(label)).clicked() {
|
||||
bs.save_format = ext;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.response
|
||||
.labelled_by(label.id);
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
// Desktop: the system Save dialog. Web: the file downloads.
|
||||
@@ -728,14 +733,19 @@ fn print(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome)
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
let scope = if bs.print_all { "Print Entire Workbook" } else { "Print Active Sheets" };
|
||||
egui::ComboBox::from_id_salt("backstage_print_scope").width(230.0).selected_text(lang.tr(scope)).show_ui(ui, |ui| {
|
||||
for (all, label) in [(false, "Print Active Sheets"), (true, "Print Entire Workbook")] {
|
||||
if ui.selectable_label(bs.print_all == all, lang.tr(label)).clicked() {
|
||||
bs.print_all = all;
|
||||
bs.preview_page = 0;
|
||||
egui::ComboBox::from_id_salt("backstage_print_scope")
|
||||
.width(230.0)
|
||||
.selected_text(lang.tr(scope))
|
||||
.show_ui(ui, |ui| {
|
||||
for (all, label) in [(false, "Print Active Sheets"), (true, "Print Entire Workbook")] {
|
||||
if ui.selectable_label(bs.print_all == all, lang.tr(label)).clicked() {
|
||||
bs.print_all = all;
|
||||
bs.preview_page = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.response
|
||||
.accessible_name(lang.tr("Print What"));
|
||||
ui.add_space(8.0);
|
||||
if ui.checkbox(&mut bs.fit_to_page, lang.tr("Fit Sheet on One Page")).changed() {
|
||||
bs.preview_page = 0;
|
||||
@@ -939,29 +949,39 @@ fn options(app: &mut SheetApp, ui: &mut Ui) {
|
||||
let lang = app.ui.language;
|
||||
section(ui, lang, "Options|General");
|
||||
egui::Grid::new("backstage_options_general").num_columns(2).spacing(vec2(18.0, 10.0)).show(ui, |ui| {
|
||||
ui.label(lang.tr("Display theme"));
|
||||
let label = ui.label(lang.tr("Display theme"));
|
||||
let mode = app.ui.theme_mode();
|
||||
let current = match mode {
|
||||
"system" => "System",
|
||||
"dark" => "Dark",
|
||||
_ => "Light",
|
||||
};
|
||||
egui::ComboBox::from_id_salt("backstage_theme").width(160.0).selected_text(lang.tr(current)).show_ui(ui, |ui| {
|
||||
for (value, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] {
|
||||
if ui.selectable_label(mode == value, lang.tr(label)).clicked() {
|
||||
app.run_or_alert("view.theme", json!({"mode": value}));
|
||||
egui::ComboBox::from_id_salt("backstage_theme")
|
||||
.width(160.0)
|
||||
.selected_text(lang.tr(current))
|
||||
.show_ui(ui, |ui| {
|
||||
for (value, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] {
|
||||
if ui.selectable_label(mode == value, lang.tr(label)).clicked() {
|
||||
app.run_or_alert("view.theme", json!({"mode": value}));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.response
|
||||
.labelled_by(label.id);
|
||||
ui.end_row();
|
||||
ui.label(lang.tr("Interface language"));
|
||||
egui::ComboBox::from_id_salt("backstage_language").width(160.0).selected_text(lang.name()).show_ui(ui, |ui| {
|
||||
for l in i18n::Language::for_picker() {
|
||||
if ui.selectable_label(l == lang, l.name()).clicked() {
|
||||
app.run_or_alert("app.language.set", json!({"language": l.code()}));
|
||||
let label = ui.label(lang.tr("Interface language"));
|
||||
egui::ComboBox::from_id_salt("backstage_language")
|
||||
.width(160.0)
|
||||
.selected_text(lang.name())
|
||||
.show_ui(ui, |ui| {
|
||||
for l in i18n::Language::for_picker() {
|
||||
if ui.selectable_label(l == lang, l.name()).clicked() {
|
||||
app.run_or_alert("app.language.set", json!({"language": l.code()}));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.response
|
||||
.labelled_by(label.id);
|
||||
ui.end_row();
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::theme::{self, Tokens};
|
||||
/// from the selection outline. Labels stay available to accessibility tools.
|
||||
pub(crate) fn preset_button(ui: &mut Ui, label: &str, preset: &str) -> Response {
|
||||
let response = ui.add(egui::Button::new("").frame(false).min_size(vec2(254.0, 26.0)));
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label));
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label));
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let rect = Rect::from_center_size(pos2(response.rect.left() + 16.0, response.rect.center().y), vec2(20.0, 16.0));
|
||||
let edges = [
|
||||
@@ -55,7 +55,7 @@ pub(crate) fn sample(ui: &mut Ui, borders: &Borders, theme: &Theme) {
|
||||
ui.add_space(8.0);
|
||||
ui.label("Preview");
|
||||
let (area, response) = ui.allocate_exact_size(vec2(248.0, 104.0), Sense::hover());
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, "Border preview"));
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, "Border preview"));
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.painter().rect_filled(area, 4.0, t.grid_bg);
|
||||
ui.painter().rect_stroke(area, 4.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
|
||||
|
||||
@@ -122,7 +122,7 @@ pub(crate) fn paint(painter: &Painter, cell: Rect, picture: &Arc<CellPicture>) {
|
||||
}
|
||||
// Context-local handles must never leak between offscreen, native or browser
|
||||
// egui contexts, even when a workbook shares its Arc payload with another view.
|
||||
let cache = painter.ctx().data_mut(|data| data.get_temp_mut_or_default::<Arc<Mutex<Cache>>>(egui::Id::new("gridcraft.cell_pictures")).clone());
|
||||
let cache = painter.ctx().data_mut(|data| data.get_temp_mut_or_default::<Arc<Mutex<Cache>>>(egui::Id::unique("gridcraft.cell_pictures")).clone());
|
||||
let texture = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner).texture(painter.ctx(), picture);
|
||||
let painter = painter.with_clip_rect(cell.intersect(painter.clip_rect()));
|
||||
if let Some(texture) = texture {
|
||||
|
||||
@@ -551,7 +551,10 @@ pub fn code_editor(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max
|
||||
let caret = out.galley.pos_from_cursor(CCursor::new(e.caret)).translate(out.galley_pos.to_vec2());
|
||||
ui.scroll_to_rect(caret, None);
|
||||
}
|
||||
let mut r = out.response.response;
|
||||
// The field has no visible label; name it for screen readers and the strict
|
||||
// kittest accessibility check. The editor is a generic code field, so the name is
|
||||
// not part of the UI's translated strings.
|
||||
let mut r = out.response.response.accessible_name("Code Editor");
|
||||
if edited.is_some() {
|
||||
r.mark_changed();
|
||||
}
|
||||
@@ -671,13 +674,10 @@ mod tests {
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state(
|
||||
|ui, text: &mut String| {
|
||||
if ui.ctx().cumulative_pass_nr() == 0 {
|
||||
let mut fonts = egui::FontDefinitions::default();
|
||||
let mono = fonts.families.get(&egui::FontFamily::Monospace).cloned().unwrap_or_default();
|
||||
fonts.families.insert(egui::FontFamily::Name(theme::MONO.into()), mono);
|
||||
ui.ctx().set_fonts(fonts);
|
||||
ui.ctx().set_fonts(theme::pinned_font_definitions());
|
||||
return;
|
||||
}
|
||||
let id = egui::Id::new("code");
|
||||
let id = egui::Id::unique("code");
|
||||
ui.memory_mut(|m| m.request_focus(id));
|
||||
code_editor(ui, id, text, Lang::Python, 5);
|
||||
},
|
||||
@@ -694,12 +694,9 @@ mod tests {
|
||||
assert_eq!(h.state(), " x ");
|
||||
}
|
||||
|
||||
/// Gives the context the editor's monospace family (the test has no craft fonts).
|
||||
/// Gives the context the deterministic font set, including the editor's monospace family.
|
||||
fn install_mono(ctx: &egui::Context) {
|
||||
let mut fonts = egui::FontDefinitions::default();
|
||||
let mono = fonts.families.get(&egui::FontFamily::Monospace).cloned().unwrap_or_default();
|
||||
fonts.families.insert(egui::FontFamily::Name(theme::MONO.into()), mono);
|
||||
ctx.set_fonts(fonts);
|
||||
ctx.set_fonts(theme::pinned_font_definitions());
|
||||
}
|
||||
|
||||
fn editor_harness(text: &str) -> egui_kittest::Harness<'static, String> {
|
||||
@@ -709,13 +706,14 @@ mod tests {
|
||||
install_mono(ui.ctx());
|
||||
return;
|
||||
}
|
||||
let id = egui::Id::new("code");
|
||||
let id = egui::Id::unique("code");
|
||||
ui.memory_mut(|m| m.request_focus(id));
|
||||
code_editor(ui, id, text, Lang::Python, 5);
|
||||
},
|
||||
text.to_string(),
|
||||
);
|
||||
h.run_steps(2);
|
||||
h.check_accessibility();
|
||||
h
|
||||
}
|
||||
|
||||
@@ -871,7 +869,7 @@ mod tests {
|
||||
#[test]
|
||||
fn held_back_events_land_in_the_next_pass_only() {
|
||||
let mut h = editor_harness("");
|
||||
let id = egui::Id::new("code");
|
||||
let id = egui::Id::unique("code");
|
||||
let held = |pass, c: &str| Held { pass, events: vec![egui::Event::Text(c.into())] };
|
||||
// What the pass just completed held back comes in with the next one.
|
||||
let last = h.ctx.cumulative_pass_nr() - 1;
|
||||
@@ -894,10 +892,10 @@ mod tests {
|
||||
install_mono(ui.ctx());
|
||||
return;
|
||||
}
|
||||
let (code, other) = (egui::Id::new("code"), egui::Id::new("other"));
|
||||
let (code, other) = (egui::Id::unique("code"), egui::Id::unique("other"));
|
||||
ui.memory_mut(|m| m.request_focus(if s.2 { code } else { other }));
|
||||
code_editor(ui, code, &mut s.0, Lang::Python, 5);
|
||||
ui.add(TextEdit::singleline(&mut s.1).id(other));
|
||||
ui.add(TextEdit::singleline(&mut s.1).id(other)).accessible_name("Other field");
|
||||
},
|
||||
(String::new(), String::new(), true),
|
||||
);
|
||||
@@ -920,7 +918,7 @@ mod tests {
|
||||
install_mono(ui.ctx());
|
||||
return;
|
||||
}
|
||||
let id = egui::Id::new("code");
|
||||
let id = egui::Id::unique("code");
|
||||
ui.memory_mut(|m| m.request_focus(id));
|
||||
code_editor(ui, id, &mut s.0, Lang::Python, 5);
|
||||
if std::mem::take(&mut s.1) {
|
||||
|
||||
+1750
-79
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,259 @@
|
||||
//! craft-fonts build policy shared by `build.rs` and the UI crate.
|
||||
//!
|
||||
//! GridCraft stays a clean-room application: the shared repository may keep Adobe, Source Han and
|
||||
//! Noto CJK faces for other consumers, but GridCraft never embeds them. The permitted faces are
|
||||
//! matched by exact family and style, and their manifest script tags are asserted instead of
|
||||
//! trusting the free-form parser in `docs/integration.md`.
|
||||
//!
|
||||
//! The file is compiled twice: by `build.rs` for the full parser/comparison/embedding path (via
|
||||
//! `#[path]`), and by this crate, where `theme` only needs the barred-family filter and the web
|
||||
//! allowlist. The rest of the policy is exercised by this file's own tests, so it is not dead
|
||||
//! code to remove.
|
||||
#![allow(dead_code)]
|
||||
|
||||
/// Families GridCraft must never embed: the repository asset policy excludes Adobe/Source faces
|
||||
/// and the Noto CJK collection. There is deliberately no broad `Noto Sans` prefix: the permitted
|
||||
/// `Noto Sans Symbols` and `Noto Sans Symbols 2` faces start with it.
|
||||
pub const BARRED_PREFIXES: &[&str] = &["Adobe", "Source Han", "Source Sans", "Source Serif", "Noto Sans CJK", "Noto Serif CJK"];
|
||||
|
||||
/// The faces GridCraft's production font set relies on, with their exact manifest script tags.
|
||||
/// Every release/acceptance build that sets `CRAFT_FONTS_REQUIRED=1` must contain all of them.
|
||||
pub const REQUIRED_FACES: &[(&str, &str, &[&str])] = &[
|
||||
("BIZ UDPGothic", "Regular", &["Jpan", "Latn"]),
|
||||
("BIZ UDPGothic", "Bold", &["Jpan", "Latn"]),
|
||||
("NanumGothic", "Regular", &["Hang", "Kore"]),
|
||||
("Noto Sans Symbols", "Regular", &["Zsym"]),
|
||||
("Noto Sans Symbols 2", "Regular", &["Zsym"]),
|
||||
];
|
||||
|
||||
/// The native-only Simplified Chinese face from craft-fonts' separate
|
||||
/// `fonts/simplified-chinese-manifest.txt` (the same opt-in DeckCraft uses). It is OFL and not
|
||||
/// barred by the asset policy, but it never enters a web bundle; older native checkouts may omit
|
||||
/// the manifest, so only `CRAFT_FONTS_REQUIRED=1` builds demand it.
|
||||
pub const CHINESE_FACES: &[(&str, &str, &[&str])] = &[("LXGW WenKai Lite", "Regular", &["Hani", "Hans"])];
|
||||
|
||||
/// The explicit web allowlist. Browser builds embed only these faces; the Noto CJK collection and
|
||||
/// the document-only faces stay off the wasm bundle. `Noto Sans Symbols` supplies `⌃`, and
|
||||
/// `Noto Sans Symbols 2` supplies `✕`, `◀` and `✓`; `📋` still comes from egui's bundled emoji.
|
||||
pub const WEB_ALLOWED: &[(&str, &str)] = &[
|
||||
("BIZ UDPGothic", "Regular"),
|
||||
("BIZ UDPGothic", "Bold"),
|
||||
("NanumGothic", "Regular"),
|
||||
("Noto Sans Symbols", "Regular"),
|
||||
("Noto Sans Symbols 2", "Regular"),
|
||||
];
|
||||
|
||||
/// One validated `fonts/manifest.txt` row. Only the fields GridCraft consumes are kept.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ManifestRow {
|
||||
pub family: String,
|
||||
pub style: String,
|
||||
pub file: String,
|
||||
pub scripts: Vec<String>,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
/// Whether the `CRAFT_FONTS_REQUIRED` value enables required mode. Only exactly `1` does:
|
||||
/// `0`, an empty value and other words are not accepted as an implicit yes.
|
||||
pub fn required_mode(value: Option<&str>) -> bool {
|
||||
value == Some("1")
|
||||
}
|
||||
|
||||
/// Whether `family` is barred by GridCraft's asset policy.
|
||||
pub fn barred(family: &str) -> bool {
|
||||
BARRED_PREFIXES.iter().any(|prefix| family.starts_with(prefix))
|
||||
}
|
||||
|
||||
/// Whether a web build may embed this exact family and style.
|
||||
pub fn web_allowed(family: &str, style: &str) -> bool {
|
||||
WEB_ALLOWED.iter().any(|(f, s)| *f == family && *s == style)
|
||||
}
|
||||
|
||||
/// The exact script tags a required face must declare.
|
||||
pub fn required_scripts(family: &str, style: &str) -> Option<&'static [&'static str]> {
|
||||
REQUIRED_FACES.iter().chain(CHINESE_FACES.iter()).find(|(f, s, _)| *f == family && *s == style).map(|(_, _, scripts)| *scripts)
|
||||
}
|
||||
|
||||
/// Parse the craft-fonts eight-field manifest. Comments and blank lines are skipped; every other
|
||||
/// line must be complete and contain a valid SHA-256. A malformed row is an error, never a
|
||||
/// silently ignored font.
|
||||
pub fn parse_manifest(text: &str) -> Result<Vec<ManifestRow>, String> {
|
||||
let mut rows = Vec::new();
|
||||
for (number, line) in text.lines().enumerate() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let fields: Vec<&str> = line.split(" | ").map(str::trim).collect();
|
||||
let [family, style, file, scripts, licence, licence_file, sha256, source] = fields.as_slice() else {
|
||||
return Err(format!("manifest line {}: expected 8 fields separated by \" | \": {line}", number + 1));
|
||||
};
|
||||
if [family, style, file, scripts, licence, licence_file, sha256, source].iter().any(|f| f.is_empty()) {
|
||||
return Err(format!("manifest line {}: an empty field: {line}", number + 1));
|
||||
}
|
||||
if sha256.len() != 64 || !sha256.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(format!("manifest line {}: sha256 must be 64 hex digits: {sha256}", number + 1));
|
||||
}
|
||||
let scripts: Vec<String> = scripts.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect();
|
||||
if scripts.is_empty() || scripts.iter().any(|tag| tag.len() != 4 || !tag.chars().all(|c| c.is_ascii_alphabetic())) {
|
||||
return Err(format!("manifest line {}: script tags must be ISO 15924 four-letter tags: {line}", number + 1));
|
||||
}
|
||||
rows.push(ManifestRow {
|
||||
family: (*family).to_string(),
|
||||
style: (*style).to_string(),
|
||||
file: (*file).to_string(),
|
||||
scripts,
|
||||
sha256: (*sha256).to_string(),
|
||||
});
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Check the script tags of every face GridCraft knows. The craft-fonts parser accepts arbitrary
|
||||
/// strings, so a face relabelled without the scripts GridCraft relies on is rejected here.
|
||||
pub fn check_declared_scripts(rows: &[ManifestRow]) -> Result<(), String> {
|
||||
for row in rows {
|
||||
if let Some(expected) = required_scripts(&row.family, &row.style) {
|
||||
if row.scripts.iter().map(String::as_str).ne(expected.iter().copied()) {
|
||||
return Err(format!(
|
||||
"{} {} must declare scripts {} but the manifest says {}",
|
||||
row.family,
|
||||
row.style,
|
||||
expected.join(","),
|
||||
row.scripts.join(",")
|
||||
));
|
||||
}
|
||||
} else if REQUIRED_FACES.iter().chain(CHINESE_FACES.iter()).any(|(family, _, _)| *family == row.family) {
|
||||
return Err(format!("{} has an unexpected style {:?}; GridCraft only permits the exact required faces", row.family, row.style));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every required face must be present. Used only for `CRAFT_FONTS_REQUIRED=1` builds.
|
||||
pub fn check_required(rows: &[ManifestRow]) -> Result<(), String> {
|
||||
let present = |family: &str, style: &str| rows.iter().any(|row| row.family == family && row.style == style);
|
||||
let missing: Vec<String> = REQUIRED_FACES.iter().filter(|(f, s, _)| !present(f, s)).map(|(f, s, _)| format!("{f} {s}")).collect();
|
||||
if missing.is_empty() { Ok(()) } else { Err(format!("the craft-fonts manifest is missing required faces: {}", missing.join(", "))) }
|
||||
}
|
||||
|
||||
/// Every native-only Chinese face must be present. Used only for `CRAFT_FONTS_REQUIRED=1` native
|
||||
/// builds; ordinary builds may use an older craft-fonts checkout without the opt-in manifest.
|
||||
pub fn check_chinese_required(rows: &[ManifestRow]) -> Result<(), String> {
|
||||
let present = |family: &str, style: &str| rows.iter().any(|row| row.family == family && row.style == style);
|
||||
let missing: Vec<String> = CHINESE_FACES.iter().filter(|(f, s, _)| !present(f, s)).map(|(f, s, _)| format!("{f} {s}")).collect();
|
||||
if missing.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("the simplified-Chinese craft-fonts manifest is missing required faces: {}", missing.join(", ")))
|
||||
}
|
||||
}
|
||||
|
||||
/// The rows this build should embed, in manifest order. Barred families are always excluded; a
|
||||
/// web build additionally keeps only [`WEB_ALLOWED`].
|
||||
pub fn embeddable(rows: &[ManifestRow], wasm: bool) -> Vec<&ManifestRow> {
|
||||
rows.iter().filter(|row| !barred(&row.family) && (!wasm || web_allowed(&row.family, &row.style))).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const GOOD: &str = "NanumGothic | Regular | fonts/nanum-gothic/NanumGothic-Regular.ttf | Hang,Kore | OFL-1.1 | fonts/nanum-gothic/OFL.txt | 76f45ef4a6bcff344c837c95a7dcc26e017e38b5846d5ae0cdcb5b86be2e2d31 | https://example.invalid/nanum";
|
||||
|
||||
#[test]
|
||||
fn the_web_allowlist_is_exact_and_never_barred() {
|
||||
assert_eq!(
|
||||
WEB_ALLOWED,
|
||||
&[
|
||||
("BIZ UDPGothic", "Regular"),
|
||||
("BIZ UDPGothic", "Bold"),
|
||||
("NanumGothic", "Regular"),
|
||||
("Noto Sans Symbols", "Regular"),
|
||||
("Noto Sans Symbols 2", "Regular"),
|
||||
]
|
||||
);
|
||||
for (family, _) in WEB_ALLOWED {
|
||||
assert!(!barred(family), "{family} must not be barred");
|
||||
assert!(REQUIRED_FACES.iter().any(|(f, _, _)| f == family), "{family} must be a required face");
|
||||
}
|
||||
assert!(!WEB_ALLOWED.iter().any(|(family, _)| family.contains("CJK")), "the web allowlist must contain no CJK collection face");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_barred_prefixes_keep_the_permitted_noto_symbol_faces() {
|
||||
for family in ["Adobe Source Sans", "Source Han Sans", "Source Sans 3", "Source Serif 4", "Noto Sans CJK SC", "Noto Serif CJK JP"] {
|
||||
assert!(barred(family), "{family} must be barred");
|
||||
}
|
||||
for family in ["Noto Sans Symbols", "Noto Sans Symbols 2", "BIZ UDPGothic", "NanumGothic"] {
|
||||
assert!(!barred(family), "{family} must be permitted");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_value_one_enables_required_mode() {
|
||||
assert!(!required_mode(None));
|
||||
for value in ["", "0", "00", "01", "false", "true", "yes"] {
|
||||
assert!(!required_mode(Some(value)), "{value:?} must not enable required mode");
|
||||
}
|
||||
assert!(required_mode(Some("1")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_complete_manifest_row_parses() {
|
||||
let rows = parse_manifest(GOOD).unwrap_or_default();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows.first().map(|r| (r.family.as_str(), r.style.as_str())), Some(("NanumGothic", "Regular")));
|
||||
assert_eq!(rows.first().map(|r| r.scripts.clone()), Some(vec!["Hang".to_string(), "Kore".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_rows_are_errors() {
|
||||
for bad in [
|
||||
"NanumGothic | Regular | fonts/x.ttf | Hang,Kore",
|
||||
"NanumGothic | Regular | | Hang,Kore | OFL-1.1 | fonts/OFL.txt | 76f45ef4a6bcff344c837c95a7dcc26e017e38b5846d5ae0cdcb5b86be2e2d31 | https://example.invalid",
|
||||
"NanumGothic | Regular | fonts/x.ttf | Hang,Kore | OFL-1.1 | fonts/OFL.txt | notahash | https://example.invalid",
|
||||
"NanumGothic | Regular | fonts/x.ttf | Korean | OFL-1.1 | fonts/OFL.txt | 76f45ef4a6bcff344c837c95a7dcc26e017e38b5846d5ae0cdcb5b86be2e2d31 | https://example.invalid",
|
||||
] {
|
||||
assert!(parse_manifest(bad).is_err(), "must reject {bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_faces_and_script_tags_are_checked() {
|
||||
let rows = parse_manifest(GOOD).unwrap_or_default();
|
||||
assert!(check_required(&rows).is_err(), "one row cannot satisfy every required face");
|
||||
assert!(check_declared_scripts(&rows).is_ok());
|
||||
let relabelled = parse_manifest(&GOOD.replace("Hang,Kore", "Kore,Hang")).unwrap_or_default();
|
||||
assert!(check_declared_scripts(&relabelled).is_err(), "script order and exact tags are asserted");
|
||||
let unexpected_style = parse_manifest(&GOOD.replace("Regular", "Bold")).unwrap_or_default();
|
||||
assert!(check_declared_scripts(&unexpected_style).is_err(), "a required family in an unlisted style is rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_native_chinese_face_is_permitted_and_checked() {
|
||||
let chinese = "LXGW WenKai Lite | Regular | fonts/lxgw-wenkai-lite/LXGWWenKaiLite-Regular.ttf | Hani,Hans | OFL-1.1 | fonts/lxgw-wenkai-lite/OFL.txt | f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea | https://example.invalid/lxgw";
|
||||
let rows = parse_manifest(chinese).unwrap_or_default();
|
||||
assert!(check_declared_scripts(&rows).is_ok());
|
||||
assert!(check_chinese_required(&rows).is_ok());
|
||||
assert!(!barred("LXGW WenKai Lite"), "the OFL Chinese face is permitted");
|
||||
assert!(!web_allowed("LXGW WenKai Lite", "Regular"), "the Chinese face is native-only");
|
||||
let relabelled = parse_manifest(&chinese.replace("Hani,Hans", "Hans,Hani")).unwrap_or_default();
|
||||
assert!(check_declared_scripts(&relabelled).is_err(), "the Chinese script tags are asserted");
|
||||
let unexpected_style = parse_manifest(&chinese.replace("Regular", "Bold")).unwrap_or_default();
|
||||
assert!(check_declared_scripts(&unexpected_style).is_err(), "an unlisted Chinese style is rejected");
|
||||
assert!(check_chinese_required(&[]).is_err(), "a required native build needs the Chinese face");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_embedding_keeps_only_the_allowlist() {
|
||||
let manifest = format!(
|
||||
"{GOOD}\nNoto Sans CJK SC | Regular | fonts/cjk.otf | Hans,Latn | OFL-1.1 | fonts/OFL.txt | 76f45ef4a6bcff344c837c95a7dcc26e017e38b5846d5ae0cdcb5b86be2e2d31 | https://example.invalid/cjk"
|
||||
);
|
||||
let rows = parse_manifest(&manifest).unwrap_or_default();
|
||||
let native: Vec<&str> = embeddable(&rows, false).iter().map(|r| r.family.as_str()).collect();
|
||||
let web: Vec<&str> = embeddable(&rows, true).iter().map(|r| r.family.as_str()).collect();
|
||||
assert_eq!(native, ["NanumGothic"], "the barred CJK face is never embedded natively");
|
||||
assert_eq!(web, ["NanumGothic"], "the web build keeps only allowlisted faces");
|
||||
}
|
||||
}
|
||||
@@ -212,7 +212,7 @@ impl Default for View {
|
||||
|
||||
/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table.
|
||||
pub fn contributors_ui(ui: &mut egui::Ui) {
|
||||
let id = egui::Id::new("credits_view");
|
||||
let id = egui::Id::unique("credits_view");
|
||||
let mut v = ui.data_mut(|d| d.get_temp::<View>(id)).unwrap_or_default();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label("Show");
|
||||
|
||||
@@ -1271,7 +1271,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
_ => 380.0,
|
||||
};
|
||||
let window = egui::Window::new(app.ui.language.tr(&d.title))
|
||||
.id(egui::Id::new("dialog").with(&d.name))
|
||||
.id(egui::Id::unique("dialog").with(&d.name))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.default_width(width)
|
||||
@@ -1775,7 +1775,7 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
match f {
|
||||
Field::Text { key, label } => {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(lang.tr(label));
|
||||
let label = ui.label(lang.tr(label));
|
||||
let mut s = d
|
||||
.values
|
||||
.get(key)
|
||||
@@ -1783,9 +1783,9 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
.unwrap_or_default();
|
||||
let multiline = key == "text";
|
||||
let r = if multiline {
|
||||
ui.add(egui::TextEdit::multiline(&mut s).desired_rows(4).desired_width(320.0))
|
||||
ui.add(egui::TextEdit::multiline(&mut s).desired_rows(4).desired_width(320.0)).labelled_by(label.id)
|
||||
} else {
|
||||
ui.add(egui::TextEdit::singleline(&mut s).desired_width(320.0))
|
||||
ui.add(egui::TextEdit::singleline(&mut s).desired_width(320.0)).labelled_by(label.id)
|
||||
};
|
||||
take_first_focus(d, &r);
|
||||
if r.changed() {
|
||||
@@ -1799,9 +1799,10 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
Field::Password { key, label } => {
|
||||
// Masked as typed (Excel shows dots); "Show password" reveals it on purpose.
|
||||
ui.vertical(|ui| {
|
||||
ui.label(lang.tr(label));
|
||||
let label = ui.label(lang.tr(label));
|
||||
let mut s = d.values.get(key).and_then(Json::as_str).unwrap_or_default().to_string();
|
||||
if ui.add(egui::TextEdit::singleline(&mut s).password(!d.show_password).desired_width(320.0)).changed() {
|
||||
if ui.add(egui::TextEdit::singleline(&mut s).password(!d.show_password).desired_width(320.0)).labelled_by(label.id).changed()
|
||||
{
|
||||
d.values.insert(key.into(), json!(s));
|
||||
}
|
||||
ui.checkbox(&mut d.show_password, lang.tr("Show password"));
|
||||
@@ -1809,11 +1810,11 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
}
|
||||
Field::Number { key, label } => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(lang.tr(label));
|
||||
let label = ui.label(lang.tr(label));
|
||||
let mut n = d.values.get(key).and_then(Json::as_f64).unwrap_or(0.0);
|
||||
// Typed values count straight away: otherwise Enter or OK in the same frame
|
||||
// confirms the old value, because a number field only commits when it loses focus.
|
||||
let r = ui.add(egui::DragValue::new(&mut n).speed(0.5).update_while_editing(true));
|
||||
let r = ui.add(egui::DragValue::new(&mut n).speed(0.5).update_while_editing(true)).labelled_by(label.id);
|
||||
take_first_focus(d, &r);
|
||||
if r.changed() {
|
||||
d.values.insert(key.into(), json!(n));
|
||||
@@ -1822,17 +1823,22 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
}
|
||||
Field::Choice { key, label, options } => {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(lang.tr(label)).strong());
|
||||
let field_label = ui.label(egui::RichText::new(lang.tr(label)).strong());
|
||||
let cur = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string();
|
||||
if d.name == "dataValidation" {
|
||||
let selected = options.iter().find(|(val, _)| *val == cur).map(|(_, text)| lang.tr(text)).unwrap_or("");
|
||||
egui::ComboBox::from_id_salt(("dataValidation", key)).selected_text(selected).width(300.0).show_ui(ui, |ui| {
|
||||
for (val, text) in options {
|
||||
if ui.selectable_label(cur == val, lang.tr(text)).clicked() {
|
||||
d.set_value(key, json!(val));
|
||||
egui::ComboBox::from_id_salt(("dataValidation", key))
|
||||
.selected_text(selected)
|
||||
.width(300.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for (val, text) in options {
|
||||
if ui.selectable_label(cur == val, lang.tr(text)).clicked() {
|
||||
d.set_value(key, json!(val));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.response
|
||||
.labelled_by(field_label.id);
|
||||
} else {
|
||||
for (val, text) in options {
|
||||
if ui.radio(cur == val, lang.tr(text)).clicked() {
|
||||
@@ -1963,8 +1969,8 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
|
||||
let preview = app.session.active().map(|doc| gridcraft_engine::display::format(&sample, &code, &doc.wb).text).unwrap_or_default();
|
||||
cols[1].label(egui::RichText::new(preview).font(theme::ui_font(15.0)));
|
||||
cols[1].add_space(8.0);
|
||||
cols[1].label(lang.tr("Type (custom format code):"));
|
||||
cols[1].text_edit_singleline(&mut code);
|
||||
let label = cols[1].label(lang.tr("Type (custom format code):"));
|
||||
cols[1].text_edit_singleline(&mut code).labelled_by(label.id);
|
||||
for c in [
|
||||
"0",
|
||||
"0.00",
|
||||
@@ -2100,8 +2106,8 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
|
||||
] {
|
||||
let label = lang.tr(label);
|
||||
let response = ui.selectable_label(slot.style == s, label);
|
||||
response
|
||||
.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::SelectableLabel, ui.is_enabled(), format!("{n} {label}")));
|
||||
let selected = slot.style == s;
|
||||
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), selected, format!("{n} {label}")));
|
||||
if response.clicked() {
|
||||
slot.style = s;
|
||||
}
|
||||
@@ -2229,8 +2235,8 @@ fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let mut what = d.values.get("what").and_then(Json::as_str).unwrap_or("").to_string();
|
||||
let mut with = d.values.get("with").and_then(Json::as_str).unwrap_or("").to_string();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Find what:");
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut what).desired_width(260.0));
|
||||
let label = ui.label("Find what:");
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut what).desired_width(260.0)).labelled_by(label.id);
|
||||
// Like Excel, the cursor starts in "Find what" - asked for once, so clicking another field still works.
|
||||
if !d.values.contains_key("focused") {
|
||||
r.request_focus();
|
||||
@@ -2239,8 +2245,8 @@ fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
});
|
||||
if d.tab == "Replace" {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Replace with:");
|
||||
ui.add(egui::TextEdit::singleline(&mut with).desired_width(244.0));
|
||||
let label = ui.label("Replace with:");
|
||||
ui.add(egui::TextEdit::singleline(&mut with).desired_width(244.0)).labelled_by(label.id);
|
||||
});
|
||||
}
|
||||
let mut case = d.values.get("matchCase").and_then(Json::as_bool).unwrap_or(false);
|
||||
@@ -2364,6 +2370,7 @@ fn go_to_entries(app: &SheetApp) -> Vec<String> {
|
||||
}
|
||||
|
||||
fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) {
|
||||
let lang = app.ui.language;
|
||||
let entries: Vec<String> =
|
||||
d.result.as_ref().and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_str).map(str::to_owned).collect()).unwrap_or_default();
|
||||
let current = d.values.get("reference").and_then(Json::as_str).unwrap_or("").to_string();
|
||||
@@ -2382,8 +2389,8 @@ fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bo
|
||||
});
|
||||
let mut r = d.values.get("reference").and_then(Json::as_str).unwrap_or("").to_string();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Reference:");
|
||||
ui.text_edit_singleline(&mut r).request_focus();
|
||||
let label = ui.label(lang.tr("Reference:"));
|
||||
ui.text_edit_singleline(&mut r).labelled_by(label.id).request_focus();
|
||||
});
|
||||
d.values.insert("reference".into(), json!(r.clone()));
|
||||
if ui.button("Special…").clicked() {
|
||||
@@ -2402,6 +2409,7 @@ fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bo
|
||||
}
|
||||
|
||||
fn sort(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) {
|
||||
let lang = app.ui.language;
|
||||
let mut header = d.values.get("header").and_then(Json::as_bool).unwrap_or(true);
|
||||
ui.checkbox(&mut header, "My list has headers");
|
||||
d.values.insert("header".into(), json!(header));
|
||||
@@ -2432,16 +2440,20 @@ fn sort(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut boo
|
||||
let mut remove = None;
|
||||
for (i, lv) in levels.iter_mut().enumerate() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(if i == 0 { "Sort by" } else { "Then by" });
|
||||
let label = ui.label(lang.tr(if i == 0 { "Sort by" } else { "Then by" }));
|
||||
let mut col = lv["column"].as_str().unwrap_or("A").to_string();
|
||||
if columns.is_empty() {
|
||||
ui.add(egui::TextEdit::singleline(&mut col).desired_width(50.0));
|
||||
ui.add(egui::TextEdit::singleline(&mut col).desired_width(50.0)).labelled_by(label.id);
|
||||
} else {
|
||||
egui::ComboBox::from_id_salt(("sort_col", i)).selected_text(col.clone()).show_ui(ui, |ui| {
|
||||
for (letter, label) in &columns {
|
||||
ui.selectable_value(&mut col, letter.clone(), label.as_str());
|
||||
}
|
||||
});
|
||||
egui::ComboBox::from_id_salt(("sort_col", i))
|
||||
.selected_text(col.clone())
|
||||
.show_ui(ui, |ui| {
|
||||
for (letter, label) in &columns {
|
||||
ui.selectable_value(&mut col, letter.clone(), label.as_str());
|
||||
}
|
||||
})
|
||||
.response
|
||||
.labelled_by(label.id);
|
||||
}
|
||||
lv["column"] = json!(col.to_ascii_uppercase());
|
||||
let mut by = lv["by"].as_str().unwrap_or("values").to_string();
|
||||
@@ -2455,13 +2467,16 @@ fn sort(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut boo
|
||||
ui.selectable_value(&mut by, "values".into(), "Cell Values");
|
||||
ui.selectable_value(&mut by, "cellColor".into(), "Cell Color");
|
||||
ui.selectable_value(&mut by, "fontColor".into(), "Font Color");
|
||||
});
|
||||
})
|
||||
.response
|
||||
.accessible_name(lang.tr("Sort On"));
|
||||
lv["by"] = json!(by);
|
||||
let mut desc = lv["order"].as_str() == Some("desc");
|
||||
ui.radio_value(&mut desc, false, "A to Z");
|
||||
ui.radio_value(&mut desc, true, "Z to A");
|
||||
lv["order"] = json!(if desc { "desc" } else { "asc" });
|
||||
if ui.small_button("✕").clicked() {
|
||||
if crate::widgets::icon_button(ui, Icon::Close, crate::theme::Tokens::get(ui.ctx()).text_dim, "Remove level", vec2(18.0, 18.0)).clicked()
|
||||
{
|
||||
remove = Some(i);
|
||||
}
|
||||
});
|
||||
@@ -2742,9 +2757,9 @@ fn recommended_charts(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, con
|
||||
fn select_data(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||||
let text = |d: &Dialog, k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string();
|
||||
let edit = |ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str| {
|
||||
ui.label(label);
|
||||
let label = ui.label(label);
|
||||
let mut v = text(d, key);
|
||||
if ui.add(egui::TextEdit::singleline(&mut v).desired_width(420.0)).changed() {
|
||||
if ui.add(egui::TextEdit::singleline(&mut v).desired_width(420.0)).labelled_by(label.id).changed() {
|
||||
d.values.insert(key.into(), json!(v));
|
||||
}
|
||||
};
|
||||
@@ -3087,16 +3102,17 @@ fn analysis_toolpak(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confi
|
||||
/// Data › Solver: the Solver Parameters form. Solve runs `data.solver` through the dialog's
|
||||
/// command; the result opens Solver Results.
|
||||
fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||||
let lang = crate::i18n::current(ui.ctx());
|
||||
let text = |d: &Dialog, k: &str| d.values.get(k).map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())).unwrap_or_default();
|
||||
let edit = |ui: &mut egui::Ui, d: &mut Dialog, k: &str, width: f32| {
|
||||
let edit = |ui: &mut egui::Ui, d: &mut Dialog, k: &str, width: f32, label: &str| {
|
||||
let mut t = text(d, k);
|
||||
if ui.add(egui::TextEdit::singleline(&mut t).desired_width(width)).changed() {
|
||||
if ui.add(egui::TextEdit::singleline(&mut t).desired_width(width)).accessible_name(lang.tr(label)).changed() {
|
||||
d.values.insert(k.into(), json!(t));
|
||||
}
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Set Objective:");
|
||||
edit(ui, d, "objective", 200.0);
|
||||
edit(ui, d, "objective", 200.0, "Set Objective:");
|
||||
});
|
||||
let goal = text(d, "goal");
|
||||
ui.horizontal(|ui| {
|
||||
@@ -3106,10 +3122,10 @@ fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool
|
||||
d.values.insert("goal".into(), json!(g));
|
||||
}
|
||||
}
|
||||
edit(ui, d, "value", 80.0);
|
||||
edit(ui, d, "value", 80.0, "Value Of:");
|
||||
});
|
||||
ui.label("By Changing Variable Cells:");
|
||||
edit(ui, d, "variables", 420.0);
|
||||
edit(ui, d, "variables", 420.0, "By Changing Variable Cells:");
|
||||
ui.label("Subject to the Constraints:");
|
||||
let list: Vec<String> = d
|
||||
.values
|
||||
@@ -3128,17 +3144,22 @@ fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool
|
||||
});
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
edit(ui, d, "newLhs", 110.0);
|
||||
edit(ui, d, "newLhs", 110.0, "Constraint Cell Reference");
|
||||
let op = text(d, "newOp");
|
||||
egui::ComboBox::from_id_salt("solverOp").selected_text(op.clone()).width(60.0).show_ui(ui, |ui| {
|
||||
for o in ["<=", "=", ">=", "int", "bin"] {
|
||||
if ui.selectable_label(op == o, o).clicked() {
|
||||
d.values.insert("newOp".into(), json!(o));
|
||||
egui::ComboBox::from_id_salt("solverOp")
|
||||
.selected_text(op.clone())
|
||||
.width(60.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for o in ["<=", "=", ">=", "int", "bin"] {
|
||||
if ui.selectable_label(op == o, o).clicked() {
|
||||
d.values.insert("newOp".into(), json!(o));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.response
|
||||
.accessible_name(lang.tr("Constraint Operator"));
|
||||
if !matches!(op.as_str(), "int" | "bin") {
|
||||
edit(ui, d, "newRhs", 110.0);
|
||||
edit(ui, d, "newRhs", 110.0, "Constraint Value");
|
||||
}
|
||||
if ui.button("Add").clicked() {
|
||||
let lhs = text(d, "newLhs");
|
||||
@@ -3171,13 +3192,17 @@ fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool
|
||||
"evolutionary" => "Evolutionary",
|
||||
_ => "GRG Nonlinear",
|
||||
};
|
||||
egui::ComboBox::from_id_salt("solverMethod").selected_text(label(&method)).show_ui(ui, |ui| {
|
||||
for m in ["grg", "simplex", "evolutionary"] {
|
||||
if ui.selectable_label(method == m, label(m)).clicked() {
|
||||
d.values.insert("method".into(), json!(m));
|
||||
egui::ComboBox::from_id_salt("solverMethod")
|
||||
.selected_text(label(&method))
|
||||
.show_ui(ui, |ui| {
|
||||
for m in ["grg", "simplex", "evolutionary"] {
|
||||
if ui.selectable_label(method == m, label(m)).clicked() {
|
||||
d.values.insert("method".into(), json!(m));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.response
|
||||
.accessible_name(lang.tr("Select a Solving Method:"));
|
||||
});
|
||||
ui.label(
|
||||
egui::RichText::new(match method.as_str() {
|
||||
|
||||
@@ -145,7 +145,7 @@ fn decode(data: &[u8], side: u32) -> Option<(egui::ColorImage, bool)> {
|
||||
fn cache(ctx: &egui::Context) -> Arc<Mutex<Cache>> {
|
||||
// Context-local, like cell pictures: texture handles never leak between offscreen, native
|
||||
// and browser contexts.
|
||||
ctx.data_mut(|data| data.get_temp_mut_or_default::<Arc<Mutex<Cache>>>(egui::Id::new("gridcraft.floating_pictures")).clone())
|
||||
ctx.data_mut(|data| data.get_temp_mut_or_default::<Arc<Mutex<Cache>>>(egui::Id::unique("gridcraft.floating_pictures")).clone())
|
||||
}
|
||||
|
||||
/// Drops the textures of workbooks that are no longer open. Called every frame the grid paints.
|
||||
|
||||
@@ -48,7 +48,7 @@ impl Default for BarLayout {
|
||||
|
||||
impl BarLayout {
|
||||
pub fn id() -> egui::Id {
|
||||
egui::Id::new("formula_bar.layout")
|
||||
egui::Id::unique("formula_bar.layout")
|
||||
}
|
||||
|
||||
/// Last frame's layout.
|
||||
@@ -73,7 +73,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
// at MAX_LINES. It eases only when that target changes.
|
||||
let lines = if app.ui.formula_bar_expanded { MAX_LINES } else { last.lines.clamp(1, MAX_LINES) };
|
||||
let target = one_line(row_h) + (lines - 1) as f32 * row_h;
|
||||
let h = ui.ctx().animate_value_with_time(egui::Id::new("formula_bar.height"), target, EASE_SECONDS);
|
||||
let h = ui.ctx().animate_value_with_time(egui::Id::unique("formula_bar.height"), target, EASE_SECONDS);
|
||||
let mut layout = BarLayout { height: h, ..Default::default() };
|
||||
egui::Panel::top("formula_bar")
|
||||
.exact_size(h)
|
||||
@@ -106,7 +106,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
ui.painter().rect_stroke(field, 4.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
|
||||
let inner = field.shrink2(vec2(6.0, 3.0));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner));
|
||||
let id = egui::Id::new("gridcraft.formula_bar");
|
||||
let id = egui::Id::unique("gridcraft.formula_bar");
|
||||
// A spill child shows its anchor's formula muted and read-only.
|
||||
let read_only = active_spill_anchor(app).is_some();
|
||||
let info = if app.editor.is_some() {
|
||||
@@ -186,14 +186,19 @@ fn active_input(app: &SheetApp) -> String {
|
||||
}
|
||||
|
||||
fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) -> Rect {
|
||||
let id = egui::Id::new("gridcraft.name_box");
|
||||
let id = egui::Id::unique("gridcraft.name_box");
|
||||
let shown = app.name_box_text();
|
||||
let mut text = app.name_box.clone().unwrap_or(shown.clone());
|
||||
let rect = Rect::from_min_size(ui.cursor().min, vec2(118.0, 24.0));
|
||||
ui.painter().rect_filled(rect, 4.0, t.input_bg);
|
||||
ui.painter().rect_stroke(rect, 4.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_size(rect.min + vec2(6.0, 3.0), vec2(96.0, 18.0))));
|
||||
let resp = child.add(TextEdit::singleline(&mut text).id(id).frame(egui::Frame::NONE).font(theme::ui_font(13.0)).desired_width(96.0));
|
||||
// The box has no visible label of its own: screen readers and the strict accessibility check
|
||||
// need a name (egui 0.37 makes unnamed text inputs a test failure).
|
||||
let accessible_name = app.ui.language.tr("Name Box").to_string();
|
||||
let resp = child
|
||||
.add(TextEdit::singleline(&mut text).id(id).frame(egui::Frame::NONE).font(theme::ui_font(13.0)).desired_width(96.0))
|
||||
.accessible_name(accessible_name);
|
||||
if resp.gained_focus() {
|
||||
app.name_box = Some(shown.clone());
|
||||
}
|
||||
@@ -284,7 +289,7 @@ fn show_edit<'t>(
|
||||
/// `max_height` of text and scrolls beyond that, keeping the caret in view.
|
||||
pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: FontId, multiline: bool, width: f32, max_height: f32) -> EditorInfo {
|
||||
let Some(mut ed) = app.editor.take() else { return EditorInfo::default() };
|
||||
let mine = ed.from_formula_bar == (id == egui::Id::new("gridcraft.formula_bar"));
|
||||
let mine = ed.from_formula_bar == (id == egui::Id::unique("gridcraft.formula_bar"));
|
||||
// Keys the editor handles itself (before the TextEdit sees them), only where it has focus.
|
||||
let focused = ui.ctx().memory(|m| m.focused());
|
||||
let has_focus = focused == Some(id) || (mine && (ed.request_focus || focused.is_none()));
|
||||
@@ -412,7 +417,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
||||
// The text widget.
|
||||
let refs = formula_refs(&ed.text);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let on_sheet = id == egui::Id::new("gridcraft.cell_editor");
|
||||
let on_sheet = id == egui::Id::unique("gridcraft.cell_editor");
|
||||
let cell_text_color = if on_sheet { t.cell_text } else { t.text };
|
||||
let font2 = font.clone();
|
||||
let mut layouter = move |ui: &egui::Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| {
|
||||
@@ -467,7 +472,9 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
||||
})
|
||||
.inner
|
||||
};
|
||||
let resp = output.response.clone();
|
||||
// Both editors are text inputs without a visible label next to them: name the one being shown.
|
||||
let accessible_name = (if on_sheet { app.ui.language.tr("Cell Editor") } else { app.ui.language.tr("Formula Bar") }).to_string();
|
||||
let resp = output.response.response.clone().accessible_name(accessible_name);
|
||||
if synced {
|
||||
if ed.request_focus {
|
||||
resp.request_focus();
|
||||
@@ -486,7 +493,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
||||
output.state.store(ui.ctx(), id);
|
||||
} else if resp.clicked() || resp.gained_focus() {
|
||||
// Clicking into the other editor moves editing there.
|
||||
ed.from_formula_bar = id == egui::Id::new("gridcraft.formula_bar");
|
||||
ed.from_formula_bar = id == egui::Id::unique("gridcraft.formula_bar");
|
||||
ed.enter_mode = false;
|
||||
app.session.mode = gridcraft_engine::Mode::Edit;
|
||||
}
|
||||
@@ -540,8 +547,11 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
||||
ui.painter().rect_filled(r, 0.0, t.accent);
|
||||
ui.painter().galley(pos, g, t.on_accent);
|
||||
}
|
||||
// Autocomplete list and argument hint under the editor.
|
||||
if mine && ui.is_rect_visible(resp.rect) && (!ed.autocomplete.is_empty() || ed.current_function().is_some()) {
|
||||
// Autocomplete list and argument hint under the editor. Decide visibility from the area the
|
||||
// editor was allotted, not from its response: egui 0.37 gives every text field a
|
||||
// `spacing.interact_size.y` floor, so a one-line cell editor's response can reach below a short
|
||||
// cell and touch the visible quadrant even while the cell itself is hidden under a frozen row.
|
||||
if mine && ui.max_rect().intersect(ui.clip_rect()).is_positive() && (!ed.autocomplete.is_empty() || ed.current_function().is_some()) {
|
||||
let below = resp.rect.left_bottom() + vec2(0.0, 4.0);
|
||||
let language = app.ui.language;
|
||||
// Areas have their own clip rect: keep cell helpers inside the worksheet.
|
||||
@@ -788,6 +798,9 @@ mod spill_tests {
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
// `run_steps` does not enforce the 0.37 strict policy; run it explicitly so the
|
||||
// formula bar's accessible names are actually tested.
|
||||
h.check_accessibility();
|
||||
let disabled = Tokens::get(&h.ctx).text_disabled;
|
||||
assert!(h.output().shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Text(t)
|
||||
if t.galley.job.text == "=SEQUENCE(3)" && t.galley.job.sections.iter().all(|section| section.format.color == disabled))));
|
||||
@@ -798,6 +811,7 @@ mod spill_tests {
|
||||
h.run_steps(3);
|
||||
h.get_by_label("=SEQUENCE(3)").click();
|
||||
h.run_steps(3);
|
||||
h.check_accessibility();
|
||||
assert!(h.state().editor.as_ref().is_some_and(|e| e.from_formula_bar));
|
||||
}
|
||||
}
|
||||
|
||||
+53
-31
@@ -904,7 +904,7 @@ fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &G
|
||||
let last = sh.used_range().map(|r| r.end.row.saturating_add(10)).unwrap_or(100).clamp(100, MAX_ROWS - 1);
|
||||
let content = geo.scroll_top_of(sh, last);
|
||||
let extent = (content - visible).max(scroll).max(0.0).min(limit);
|
||||
let id = ui.id().with("vscroll");
|
||||
let id = ui.scope_id().with("vscroll");
|
||||
let resp = ui.interact(track, id, Sense::click_and_drag());
|
||||
let drag_id = id.with("extent");
|
||||
if resp.drag_started() {
|
||||
@@ -957,7 +957,7 @@ pub(crate) fn horizontal_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, track:
|
||||
let last = sh.used_range().map(|r| r.end.col.saturating_add(10)).unwrap_or(26).clamp(26, MAX_COLS - 1);
|
||||
let content = geo.scroll_left_of(sh, last);
|
||||
let extent = (content - visible).max(scroll).max(0.0);
|
||||
let id = ui.id().with("hscroll");
|
||||
let id = ui.scope_id().with("hscroll");
|
||||
let resp = ui.interact(track, id, Sense::click_and_drag());
|
||||
let drag_id = id.with("extent");
|
||||
if resp.drag_started() {
|
||||
@@ -2909,7 +2909,7 @@ fn hidden_row_edge(geo: &Geo, sh: &Sheet, y: f32) -> Option<u32> {
|
||||
/// has no such shape. Black on a white outline, like the system cursors.
|
||||
fn paint_split_cursor(ctx: &egui::Context, at: Pos2, cols: bool) {
|
||||
ctx.set_cursor_icon(CursorIcon::None);
|
||||
let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("gridcraft.split_cursor")));
|
||||
let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("gridcraft.split_cursor")));
|
||||
// Drawn for columns (bars vertical); rows swap the axes.
|
||||
let pt = |x: f32, y: f32| if cols { pos2(at.x + x, at.y + y) } else { pos2(at.x + y, at.y + x) };
|
||||
let arrow = |dir: f32| vec![pt(dir * 10.0, 0.0), pt(dir * 6.0, -4.0), pt(dir * 6.0, 4.0)];
|
||||
@@ -2998,7 +2998,7 @@ fn select_ranges(app: &mut SheetApp, r: RangeRef, add: bool, active: CellRef) {
|
||||
/// Grid keyboard handling when no text field has focus.
|
||||
fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: &Geo, sh: &Sheet) {
|
||||
// Focus left on an editor that no longer exists goes back to the grid.
|
||||
let stale = [egui::Id::new("gridcraft.cell_editor"), egui::Id::new("gridcraft.formula_bar"), crate::text_box::editor_id()];
|
||||
let stale = [egui::Id::unique("gridcraft.cell_editor"), egui::Id::unique("gridcraft.formula_bar"), crate::text_box::editor_id()];
|
||||
let editing = app.editor.is_some() || app.text_box_editor.is_some();
|
||||
if !editing && ctx.memory(|m| m.focused()).is_some_and(|f| stale.contains(&f)) {
|
||||
resp.request_focus();
|
||||
@@ -3043,6 +3043,14 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
|
||||
app.copy_to_clipboard(ctx, "edit.cut");
|
||||
}
|
||||
egui::Event::Paste(text) => app.paste_external(text),
|
||||
// GridCraft has no image-clipboard pipeline and no cell-picture insert-from-clipboard
|
||||
// command. Choosing to ignore `PasteImage` is explicit: inserting the bytes as a
|
||||
// floating picture without a chosen anchor/size would be a new feature, not parity.
|
||||
// A text representation, when the clipboard has one, still arrives as `Paste` above.
|
||||
egui::Event::PasteImage(_) => {}
|
||||
// PRIMARY middle-click paste belongs to egui's TextEdit; the grid has no caret to
|
||||
// insert text at, so a middle click on a cell is deliberately not a paste.
|
||||
egui::Event::MiddleClickPaste { .. } => {}
|
||||
egui::Event::Text(_) if shortcut_pressed || shortcut_ran => {}
|
||||
egui::Event::Text(text) if text.chars().all(|c| !c.is_control()) && !text.is_empty() => {
|
||||
app.begin_edit(Some(text), false);
|
||||
@@ -3193,18 +3201,29 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet,
|
||||
pos2(if cell.col >= geo.fc { split.x } else { geo.cells.left() }, if cell.row >= geo.fr { split.y } else { geo.cells.top() }),
|
||||
pos2(if cell.col >= geo.fc { geo.cells.right() } else { split.x }, if cell.row >= geo.fr { geo.cells.bottom() } else { split.y }),
|
||||
);
|
||||
let clip = ui.clip_rect().intersect(quadrant);
|
||||
// A cell scrolled entirely under a frozen row/column keeps its edit (Escape still works),
|
||||
// but neither the cell nor its helper may paint. Test the cell's own visible quadrant and
|
||||
// give the shared editor an empty clip when the cell is hidden: a one-line cell editor's
|
||||
// field can be larger than the cell (egui 0.37 gives text fields a minimum height), so its
|
||||
// own max_rect is not a reliable "is the cell on screen" test at the frozen boundary. The
|
||||
// pre-wrap cell rect must also be positive: a hidden row/column has zero height/width, but
|
||||
// Wrap Text can expand the editor below/right of it, which must not paint over the next line.
|
||||
let cell_visible = rect.width() > 0.0 && rect.height() > 0.0;
|
||||
// `erect` and `ui.clip_rect()` are in screen coordinates; `quadrant` is logical until mirrored.
|
||||
let screen_quadrant = geo.mirror_rect(quadrant);
|
||||
let on_screen = cell_visible && erect.intersect(screen_quadrant).is_positive() && !geo.hidden_under_frozen(sh, cell);
|
||||
let clip = if on_screen { ui.clip_rect().intersect(screen_quadrant) } else { Rect::NOTHING };
|
||||
let painter = ui.painter().with_clip_rect(clip);
|
||||
painter.rect_filled(erect, 0.0, fill);
|
||||
painter.rect_stroke(erect.expand(1.0), 0.0, Stroke::new(2.0, Tokens::get(ui.ctx()).sel_border), StrokeKind::Middle);
|
||||
let id = egui::Id::new("gridcraft.cell_editor");
|
||||
let id = egui::Id::unique("gridcraft.cell_editor");
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(erect.shrink2(vec2(2.0, 1.0))));
|
||||
child.set_clip_rect(clip);
|
||||
// The theme's caret is meant for the UI chrome and vanishes on some fills (and, in the dark
|
||||
// theme, on the white paper): pick black or white against the fill, like Excel.
|
||||
child.visuals_mut().text_cursor.stroke.color = ink_on(fill);
|
||||
crate::formula_bar::editor_widget(app, &mut child, id, font, wrap, erect.width() - 4.0, erect.height() - 2.0);
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("gridcraft.cell_editor.rect"), erect));
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("gridcraft.cell_editor.rect"), erect));
|
||||
}
|
||||
|
||||
/// Test the popup itself, not whether the pointer is over any part of the app.
|
||||
@@ -3252,16 +3271,16 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
|
||||
}
|
||||
let wb = app.session.active().map(|d| d.wb.clone()).unwrap_or(wb);
|
||||
let values = gridcraft_engine::cmd::data::filter_values(&wb, si, col);
|
||||
let key = egui::Id::new(("filter_sel", col));
|
||||
let search_key = egui::Id::new(("filter_search", col));
|
||||
let buttons_key = egui::Id::new(("filter_buttons", col));
|
||||
let key = egui::Id::unique(("filter_sel", col));
|
||||
let search_key = egui::Id::unique(("filter_search", col));
|
||||
let buttons_key = egui::Id::unique(("filter_buttons", col));
|
||||
// Enter is the menu's OK, as in Excel: taken before the search box (which would only drop its
|
||||
// focus) and the value checkboxes (which would toggle) see it. A focused button keeps it as its click.
|
||||
let buttons: Vec<egui::Id> = ui.ctx().data_mut(|d| d.get_temp(buttons_key)).unwrap_or_default();
|
||||
let on_button = ui.ctx().memory(|m| m.focused()).is_some_and(|f| buttons.contains(&f));
|
||||
let enter = !on_button && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
|
||||
let mut close = false;
|
||||
let area = egui::Area::new(egui::Id::new("filter_menu")).fixed_pos(at + vec2(-180.0, 10.0)).order(egui::Order::Foreground);
|
||||
let area = egui::Area::new(egui::Id::unique("filter_menu")).fixed_pos(at + vec2(-180.0, 10.0)).order(egui::Order::Foreground);
|
||||
let response = area.show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_width(230.0);
|
||||
@@ -3344,7 +3363,7 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
|
||||
fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
let Some(at) = app.grid.context_menu else { return };
|
||||
let mut close = false;
|
||||
let resp = egui::Area::new(egui::Id::new("cell_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
let resp = egui::Area::new(egui::Id::unique("cell_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_min_width(200.0);
|
||||
let insert_copied = insert_copied_item(app);
|
||||
@@ -3513,8 +3532,8 @@ fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet
|
||||
return;
|
||||
}
|
||||
let lang = crate::i18n::current(ui.ctx());
|
||||
let resp = ui.interact(button, egui::Id::new("gridcraft.fill_options_tag"), Sense::click()).on_hover_text(lang.tr("Auto Fill Options"));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, lang.tr("Auto Fill Options")));
|
||||
let resp = ui.interact(button, egui::Id::unique("gridcraft.fill_options_tag"), Sense::click()).on_hover_text(lang.tr("Auto Fill Options"));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, lang.tr("Auto Fill Options")));
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let p = ui.painter();
|
||||
p.rect_filled(button, 3.0, if resp.hovered() { t.hover } else { t.window });
|
||||
@@ -3561,7 +3580,7 @@ fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) {
|
||||
let mut close = false;
|
||||
let mut items = fill_modes(app, src);
|
||||
items.extend([("-", ""), ("Series…", "ui:series")]);
|
||||
let response = egui::Area::new(egui::Id::new("fill_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
let response = egui::Area::new(egui::Id::unique("fill_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_min_width(190.0);
|
||||
let lang = crate::i18n::current(ui.ctx());
|
||||
@@ -3592,7 +3611,7 @@ fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) {
|
||||
fn object_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
let Some((at, kind, id)) = app.grid.object_menu else { return };
|
||||
let mut close = false;
|
||||
let resp = egui::Area::new(egui::Id::new("object_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
let resp = egui::Area::new(egui::Id::unique("object_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_min_width(200.0);
|
||||
let mut items = vec![("Delete", "object.delete")];
|
||||
@@ -3649,8 +3668,8 @@ fn error_check_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet)
|
||||
return;
|
||||
}
|
||||
let lang = crate::i18n::current(ui.ctx());
|
||||
let resp = ui.interact(tag, egui::Id::new("gridcraft.error_check_tag"), Sense::click()).on_hover_text(lang.tr("Number Stored as Text"));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, lang.tr("Error checking options")));
|
||||
let resp = ui.interact(tag, egui::Id::unique("gridcraft.error_check_tag"), Sense::click()).on_hover_text(lang.tr("Number Stored as Text"));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, lang.tr("Error checking options")));
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let p = ui.painter();
|
||||
p.rect_filled(tag, 3.0, if resp.hovered() { t.hover } else { t.window });
|
||||
@@ -3744,15 +3763,18 @@ pub(crate) fn paint_overlays(
|
||||
{
|
||||
let r = geo.cell_rect(sh, sel.active);
|
||||
let (at, pivot) = (geo.mirror_pos(pos2(r.left() + 8.0, r.bottom() + 6.0)), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP });
|
||||
egui::Area::new(egui::Id::new("dv_input")).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).fill(Color32::from_rgb(0xFF, 0xFF, 0xE1)).show(ui, |ui| {
|
||||
ui.set_max_width(220.0);
|
||||
if !dv.input_title.is_empty() {
|
||||
ui.label(egui::RichText::new(&dv.input_title).strong().color(Color32::BLACK));
|
||||
}
|
||||
ui.label(egui::RichText::new(&dv.input_message).color(Color32::BLACK));
|
||||
});
|
||||
});
|
||||
egui::Area::new(egui::Id::unique("dv_input")).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show(
|
||||
ui.ctx(),
|
||||
|ui| {
|
||||
egui::Frame::popup(ui.style()).fill(Color32::from_rgb(0xFF, 0xFF, 0xE1)).show(ui, |ui| {
|
||||
ui.set_max_width(220.0);
|
||||
if !dv.input_title.is_empty() {
|
||||
ui.label(egui::RichText::new(&dv.input_title).strong().color(Color32::BLACK));
|
||||
}
|
||||
ui.label(egui::RichText::new(&dv.input_message).color(Color32::BLACK));
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
// Notes: shown on hover (or always when marked visible).
|
||||
let hover = app.grid.hover_cell;
|
||||
@@ -3769,7 +3791,7 @@ pub(crate) fn paint_overlays(
|
||||
let at = pos2(r.right() + 10.0, r.top());
|
||||
p.line_segment([r.right_top(), at + vec2(0.0, 6.0)], Stroke::new(1.0, Color32::from_gray(90)));
|
||||
let (at, pivot) = (geo.mirror_pos(at), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP });
|
||||
egui::Area::new(egui::Id::new(("note", c.row, c.col))).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show(
|
||||
egui::Area::new(egui::Id::unique(("note", c.row, c.col))).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show(
|
||||
ui.ctx(),
|
||||
|ui| {
|
||||
egui::Frame::popup(ui.style()).fill(if cm.threaded { Color32::WHITE } else { Color32::from_rgb(0xFF, 0xFF, 0xE1) }).show(ui, |ui| {
|
||||
@@ -3789,7 +3811,7 @@ pub(crate) fn paint_overlays(
|
||||
&& app.editor.is_none()
|
||||
{
|
||||
let tip = h.tooltip.clone().unwrap_or_else(|| format!("{}\nClick once to follow. Click and hold to select this cell.", h.target));
|
||||
egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("link_tip"), egui::PopupAnchor::Pointer).show(|ui| {
|
||||
egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::unique("link_tip"), egui::PopupAnchor::Pointer).show(|ui| {
|
||||
ui.label(tip);
|
||||
});
|
||||
}
|
||||
@@ -3858,7 +3880,7 @@ fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
|
||||
(true, true) => (geo.mirror_pos(r.left_top()), Align2::RIGHT_BOTTOM),
|
||||
};
|
||||
let mut close = false;
|
||||
let response = egui::Area::new(egui::Id::new("dv_list")).fixed_pos(at).pivot(pivot).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
let response = egui::Area::new(egui::Id::unique("dv_list")).fixed_pos(at).pivot(pivot).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).inner_margin(2.0).show(ui, |ui| {
|
||||
ui.set_min_width(r.width().max(100.0));
|
||||
egui::ScrollArea::vertical().max_height(height).min_scrolled_height(height).show(ui, |ui| {
|
||||
@@ -3882,7 +3904,7 @@ fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
|
||||
fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) {
|
||||
let Some((at, rows)) = app.grid.header_menu else { return };
|
||||
let mut close = false;
|
||||
let response = egui::Area::new(egui::Id::new("header_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
let response = egui::Area::new(egui::Id::unique("header_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_min_width(190.0);
|
||||
let insert_copied = insert_copied_item(app);
|
||||
|
||||
@@ -283,7 +283,7 @@ pub fn saved_language(ui_json: &serde_json::Value) -> Option<Language> {
|
||||
/// egui context-data key holding the language of the frame being drawn. Widgets that only get a
|
||||
/// `Ui` (not the app) read it to translate their labels; [`set_current`] writes it once per frame.
|
||||
fn lang_key() -> egui::Id {
|
||||
egui::Id::new("gridcraft.language")
|
||||
egui::Id::unique("gridcraft.language")
|
||||
}
|
||||
|
||||
/// The language the current frame is drawing in. `En` when unset.
|
||||
@@ -374,6 +374,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The screen-reader names for the formula-bar controls must be translated in every catalog
|
||||
/// (no silent English fallback) and stay single-line labels.
|
||||
#[test]
|
||||
fn accessible_control_names_are_translated_in_every_catalog() {
|
||||
for key in ["Name Box", "Cell Editor", "Font Size", "Sheet Name", "Text Box"] {
|
||||
for language in Language::ALL {
|
||||
let shown = language.tr(key);
|
||||
assert!(!shown.contains('\n'), "{}: {key:?} is not a single-line label: {shown:?}", language.code());
|
||||
if language != Language::En {
|
||||
assert_ne!(shown, key, "{} falls back to English for {key:?}", language.code());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translations_never_show_the_context_prefix() {
|
||||
// `tr` returns a found translation as it is, so a catalog that keeps `context|` in its
|
||||
|
||||
@@ -187,6 +187,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "Náhled\nkonců stránek",
|
||||
"Page\nLayout" => "Rozložení\nstránky",
|
||||
"Formula Bar" => "Řádek vzorců",
|
||||
"Name Box" => "Pole názvu",
|
||||
"Cell Editor" => "Editor buňky",
|
||||
"Font Size" => "Velikost písma",
|
||||
"Gridlines" => "Mřížka",
|
||||
"Headings" => "Záhlaví",
|
||||
"Zoom" => "Lupa",
|
||||
@@ -416,6 +419,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"More\nFunctions" => "Další\nfunkce",
|
||||
"Sheet Options|View" => "Zobrazit",
|
||||
"Print" => "Tisknout",
|
||||
"Print What" => "Co tisknout",
|
||||
|
||||
// Additional runtime labels and tooltips, grouped by source file.
|
||||
// formula_bar.rs (alphabetical keys).
|
||||
|
||||
@@ -191,6 +191,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "Seitenumbruch-\nvorschau",
|
||||
"Page\nLayout" => "Seiten-\nlayout",
|
||||
"Formula Bar" => "Formelleiste",
|
||||
"Name Box" => "Namensfeld",
|
||||
"Cell Editor" => "Zelleneditor",
|
||||
"Font Size" => "Schriftgröße",
|
||||
"Gridlines" => "Gitternetzlinien",
|
||||
"Headings" => "Überschriften",
|
||||
"Zoom" => "Zoom",
|
||||
@@ -424,6 +427,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Dark" => "Dunkel",
|
||||
"Sheet Options|View" => "Anzeige",
|
||||
"Print" => "Drucken",
|
||||
"Print What" => "Was drucken",
|
||||
// grid.rs cell and row/column header context menus.
|
||||
"Cut" => "Ausschneiden",
|
||||
"Copy" => "Kopieren",
|
||||
@@ -452,6 +456,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Next sheet" => "Nächstes Blatt",
|
||||
"All sheets" => "Alle Blätter",
|
||||
"New sheet (⇧F11)" => "Neues Blatt (⇧F11)",
|
||||
"Text Box" => "Textfeld",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -185,6 +185,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "Προεπισκόπηση\nαλλαγών σελίδας",
|
||||
"Page\nLayout" => "Διάταξη\nσελίδας",
|
||||
"Formula Bar" => "Γραμμή τύπων",
|
||||
"Name Box" => "Πλαίσιο ονόματος",
|
||||
"Cell Editor" => "Επεξεργαστής κελιού",
|
||||
"Font Size" => "Μέγεθος γραμματοσειράς",
|
||||
"Gridlines" => "Γραμμές πλέγματος",
|
||||
"Headings" => "Επικεφαλίδες",
|
||||
"Zoom" => "Μεγέθυνση",
|
||||
@@ -396,6 +399,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Dark" => "Σκοτεινό",
|
||||
"Sheet Options|View" => "Προβολή",
|
||||
"Print" => "Εκτύπωση",
|
||||
"Print What" => "Τι θα εκτυπωθεί",
|
||||
"PivotTable Fields" => "Πεδία συγκεντρωτικού πίνακα",
|
||||
"Choose fields to add to report:" => "Επιλέξτε πεδία για προσθήκη στην αναφορά:",
|
||||
"Use a field's menu to move it between areas:" => "Χρησιμοποιήστε το μενού ενός πεδίου για να το μετακινήσετε μεταξύ περιοχών:",
|
||||
|
||||
@@ -190,6 +190,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "Vista previa de\nsalto de página",
|
||||
"Page\nLayout" => "Diseño\nde página",
|
||||
"Formula Bar" => "Barra de fórmulas",
|
||||
"Name Box" => "Cuadro de nombre",
|
||||
"Cell Editor" => "Editor de celda",
|
||||
"Font Size" => "Tamaño de fuente",
|
||||
"Gridlines" => "Líneas de cuadrícula",
|
||||
"Headings" => "Encabezados",
|
||||
"Zoom" => "Zoom",
|
||||
@@ -421,6 +424,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Dark" => "Oscuro",
|
||||
"Sheet Options|View" => "Ver",
|
||||
"Print" => "Imprimir",
|
||||
"Print What" => "Qué imprimir",
|
||||
"PivotTable Fields" => "Campos de tabla dinámica",
|
||||
"Choose fields to add to report:" => "Seleccione los campos para agregar al informe:",
|
||||
"Use a field's menu to move it between areas:" => "Use el menú de un campo para moverlo entre áreas:",
|
||||
@@ -468,6 +472,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Next sheet" => "Hoja siguiente",
|
||||
"All sheets" => "Todas las hojas",
|
||||
"New sheet (⇧F11)" => "Nueva hoja (⇧F11)",
|
||||
"Text Box" => "Cuadro de texto",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -213,6 +213,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "Aperçu des sauts\nde page",
|
||||
"Page\nLayout" => "Mise en\npage",
|
||||
"Formula Bar" => "Barre de formule",
|
||||
"Name Box" => "Zone de nom",
|
||||
"Cell Editor" => "Éditeur de cellule",
|
||||
"Font Size" => "Taille de police",
|
||||
"Gridlines" => "Quadrillage",
|
||||
"Headings" => "En-têtes",
|
||||
"Zoom" => "Zoom",
|
||||
@@ -311,6 +314,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"More\nFunctions" => "Autres\nfonctions",
|
||||
"Sheet Options|View" => "Affichage",
|
||||
"Print" => "Impression",
|
||||
"Print What" => "Quoi imprimer",
|
||||
"Click to add data" => "Cliquez pour ajouter des données",
|
||||
"Add header" => "Ajouter un en-tête",
|
||||
"Add footer" => "Ajouter un pied de page",
|
||||
@@ -438,6 +442,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Next sheet" => "Feuille suivante",
|
||||
"All sheets" => "Toutes les feuilles",
|
||||
"New sheet (⇧F11)" => "Nouvelle feuille (⇧F11)",
|
||||
"Text Box" => "Zone de texte",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -205,6 +205,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "Anteprima interruzioni\ndi pagina",
|
||||
"Page\nLayout" => "Layout\ndi pagina",
|
||||
"Formula Bar" => "Barra della formula",
|
||||
"Name Box" => "Casella nome",
|
||||
"Cell Editor" => "Editor di cella",
|
||||
"Font Size" => "Dimensione carattere",
|
||||
"Gridlines" => "Griglia",
|
||||
"Headings" => "Intestazioni",
|
||||
"Zoom" => "Zoom",
|
||||
@@ -302,6 +305,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Dark" => "Scuro",
|
||||
"Sheet Options|View" => "Visualizza",
|
||||
"Print" => "Stampa",
|
||||
"Print What" => "Cosa stampare",
|
||||
// Page Layout view and the Header & Footer tab.
|
||||
"Click to add data" => "Fai clic per aggiungere dati",
|
||||
"Add header" => "Aggiungi intestazione",
|
||||
|
||||
@@ -210,6 +210,9 @@ pub(super) const CORE: &[(&str, &str)] = &[
|
||||
("Page Break\nPreview", "改ページ プレビュー"),
|
||||
("Page\nLayout", "ページ レイアウト"),
|
||||
("Formula Bar", "数式バー"),
|
||||
("Name Box", "名前ボックス"),
|
||||
("Cell Editor", "セル エディター"),
|
||||
("Font Size", "フォント サイズ"),
|
||||
("Gridlines", "目盛線"),
|
||||
("Headings", "見出し"),
|
||||
("Zoom", "ズーム"),
|
||||
@@ -307,6 +310,7 @@ pub(super) const CORE: &[(&str, &str)] = &[
|
||||
("More\nFunctions", "その他の関数"),
|
||||
("Sheet Options|View", "画面"),
|
||||
("Print", "印刷"),
|
||||
("Print What", "印刷する内容"),
|
||||
("Click to add data", "クリックしてデータを追加"),
|
||||
("Add header", "ヘッダーの追加"),
|
||||
("Add footer", "フッターの追加"),
|
||||
|
||||
@@ -208,6 +208,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "페이지 나누기\n미리 보기",
|
||||
"Page\nLayout" => "페이지\n레이아웃",
|
||||
"Formula Bar" => "수식 입력줄",
|
||||
"Name Box" => "이름 상자",
|
||||
"Cell Editor" => "셀 편집기",
|
||||
"Font Size" => "글꼴 크기",
|
||||
"Gridlines" => "눈금선",
|
||||
"Headings" => "머리글",
|
||||
"Zoom" => "확대/축소",
|
||||
@@ -307,6 +310,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Dark" => "어둡게",
|
||||
"Sheet Options|View" => "화면",
|
||||
"Print" => "인쇄",
|
||||
"Print What" => "인쇄할 내용",
|
||||
// Page Layout view and the Header & Footer tab.
|
||||
"Click to add data" => "클릭하여 데이터 추가",
|
||||
"Add header" => "머리글 추가",
|
||||
@@ -436,6 +440,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Next sheet" => "다음 시트",
|
||||
"All sheets" => "모든 시트",
|
||||
"New sheet (⇧F11)" => "새 시트 (⇧F11)",
|
||||
"Text Box" => "텍스트 상자",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -206,6 +206,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "Pagina-einde-\nweergave",
|
||||
"Page\nLayout" => "Pagina-\nindeling",
|
||||
"Formula Bar" => "Formulebalk",
|
||||
"Name Box" => "Naamvak",
|
||||
"Cell Editor" => "Celeditor",
|
||||
"Font Size" => "Tekengrootte",
|
||||
"Gridlines" => "Celrasterlijnen",
|
||||
"Headings" => "Kopteksten",
|
||||
"Zoom" => "Zoomen",
|
||||
@@ -302,6 +305,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"More\nFunctions" => "Meer\nfuncties",
|
||||
"Sheet Options|View" => "Weergeven",
|
||||
"Print" => "Afdrukken",
|
||||
"Print What" => "Wat afdrukken",
|
||||
"Click to add data" => "Klik om gegevens toe te voegen",
|
||||
"Add header" => "Koptekst toevoegen",
|
||||
"Add footer" => "Voettekst toevoegen",
|
||||
|
||||
@@ -201,6 +201,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "Podgląd\npodziału stron",
|
||||
"Page\nLayout" => "Układ\nstrony",
|
||||
"Formula Bar" => "Pasek formuły",
|
||||
"Name Box" => "Pole nazwy",
|
||||
"Cell Editor" => "Edytor komórki",
|
||||
"Font Size" => "Rozmiar czcionki",
|
||||
"Gridlines" => "Linie siatki",
|
||||
"Headings" => "Nagłówki",
|
||||
"Zoom" => "Powiększenie",
|
||||
@@ -298,6 +301,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Dark" => "Ciemny",
|
||||
"Sheet Options|View" => "Wyświetl",
|
||||
"Print" => "Drukuj",
|
||||
"Print What" => "Co drukować",
|
||||
"Click to add data" => "Kliknij, aby dodać dane",
|
||||
"Add header" => "Dodaj nagłówek",
|
||||
"Add footer" => "Dodaj stopkę",
|
||||
|
||||
@@ -205,6 +205,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "Visualização\nde Quebras de Página",
|
||||
"Page\nLayout" => "Layout\nde Página",
|
||||
"Formula Bar" => "Barra de Fórmulas",
|
||||
"Name Box" => "Caixa de nome",
|
||||
"Cell Editor" => "Editor de célula",
|
||||
"Font Size" => "Tamanho da fonte",
|
||||
"Gridlines" => "Linhas de Grade",
|
||||
"Headings" => "Títulos",
|
||||
"Zoom" => "Zoom",
|
||||
@@ -303,6 +306,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Dark" => "Escuro",
|
||||
"Sheet Options|View" => "Exibir",
|
||||
"Print" => "Imprimir",
|
||||
"Print What" => "O que imprimir",
|
||||
// Page Layout view and the Header & Footer tab.
|
||||
"Click to add data" => "Clique para adicionar dados",
|
||||
"Add header" => "Adicionar cabeçalho",
|
||||
|
||||
@@ -208,6 +208,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "Examinare\nsfârșituri de pagină",
|
||||
"Page\nLayout" => "Aspect\npagină",
|
||||
"Formula Bar" => "Bară de formule",
|
||||
"Name Box" => "Casetă de nume",
|
||||
"Cell Editor" => "Editor de celule",
|
||||
"Font Size" => "Dimensiune font",
|
||||
"Gridlines" => "Linii de grilă",
|
||||
"Headings" => "Titluri",
|
||||
"Zoom" => "Zoom",
|
||||
@@ -305,6 +308,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Dark" => "Întunecată",
|
||||
"Sheet Options|View" => "Vizualizare",
|
||||
"Print" => "Imprimare",
|
||||
"Print What" => "Ce imprimați",
|
||||
"Click to add data" => "Faceți clic pentru a adăuga date",
|
||||
"Add header" => "Adăugare antet",
|
||||
"Add footer" => "Adăugare subsol",
|
||||
|
||||
@@ -208,6 +208,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "Предварительный просмотр\nразрывов страниц",
|
||||
"Page\nLayout" => "Разметка\nстраницы",
|
||||
"Formula Bar" => "Строка формул",
|
||||
"Name Box" => "Поле имени",
|
||||
"Cell Editor" => "Редактор ячейки",
|
||||
"Font Size" => "Размер шрифта",
|
||||
"Gridlines" => "Сетка",
|
||||
"Headings" => "Заголовки",
|
||||
"Zoom" => "Масштаб",
|
||||
@@ -307,6 +310,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Dark" => "Тёмная",
|
||||
"Sheet Options|View" => "На экране",
|
||||
"Print" => "Печать",
|
||||
"Print What" => "Что печатать",
|
||||
// Page Layout view and the Header & Footer tab.
|
||||
"Click to add data" => "Щелкните, чтобы добавить данные",
|
||||
"Add header" => "Добавить верхний колонтитул",
|
||||
@@ -438,6 +442,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Next sheet" => "Следующий лист",
|
||||
"All sheets" => "Все листы",
|
||||
"New sheet (⇧F11)" => "Новый лист (⇧F11)",
|
||||
"Text Box" => "Надпись",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -203,6 +203,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "Pregled preloma\nstranica",
|
||||
"Page\nLayout" => "Izgled\nstranice",
|
||||
"Formula Bar" => "Traka formule",
|
||||
"Name Box" => "Polje imena",
|
||||
"Cell Editor" => "Uređivač ćelije",
|
||||
"Font Size" => "Veličina fonta",
|
||||
"Gridlines" => "Linije mreže",
|
||||
"Headings" => "Zaglavlja",
|
||||
"Zoom" => "Uvećanje",
|
||||
@@ -301,6 +304,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"More\nFunctions" => "Još\nfunkcija",
|
||||
"Sheet Options|View" => "Prikaz",
|
||||
"Print" => "Štampaj",
|
||||
"Print What" => "Šta štampati",
|
||||
"Click to add data" => "Kliknite da dodate podatke",
|
||||
"Add header" => "Dodaj zaglavlje",
|
||||
"Add footer" => "Dodaj podnožje",
|
||||
@@ -436,6 +440,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Next sheet" => "Следећи лист",
|
||||
"All sheets" => "Сви листови",
|
||||
"New sheet (⇧F11)" => "Нови лист (⇧F11)",
|
||||
"Text Box" => "Текстуално поље",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -188,6 +188,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "Sayfa Sonu\nÖnizleme",
|
||||
"Page\nLayout" => "Sayfa\nDüzeni",
|
||||
"Formula Bar" => "Formül Çubuğu",
|
||||
"Name Box" => "Ad kutusu",
|
||||
"Cell Editor" => "Hücre düzenleyici",
|
||||
"Font Size" => "Yazı tipi boyutu",
|
||||
"Gridlines" => "Kılavuz Çizgileri",
|
||||
"Headings" => "Başlıklar",
|
||||
"Zoom" => "Yakınlaştır",
|
||||
@@ -410,6 +413,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Dark" => "Koyu",
|
||||
"Sheet Options|View" => "Görünüm",
|
||||
"Print" => "Yazdır",
|
||||
"Print What" => "Yazdırılacaklar",
|
||||
"Text Box" => "Metin Kutusu",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -203,6 +203,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "Попередній перегляд\nрозривів сторінок",
|
||||
"Page\nLayout" => "Розмітка\nсторінки",
|
||||
"Formula Bar" => "Рядок формул",
|
||||
"Name Box" => "Поле імені",
|
||||
"Cell Editor" => "Редактор клітинки",
|
||||
"Font Size" => "Розмір шрифту",
|
||||
"Gridlines" => "Сітка",
|
||||
"Headings" => "Заголовки",
|
||||
"Zoom" => "Масштаб",
|
||||
@@ -300,6 +303,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Dark" => "Темна",
|
||||
"Sheet Options|View" => "На екрані",
|
||||
"Print" => "Друк",
|
||||
"Print What" => "Що друкувати",
|
||||
// Page Layout view and the Header & Footer tab.
|
||||
"Click to add data" => "Клацніть, щоб додати дані",
|
||||
"Add header" => "Додати верхній колонтитул",
|
||||
@@ -421,7 +425,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Do you want to save the changes you made to {title}?" => "Do you want to save the changes you made to {title}?",
|
||||
"Save a copy as .{format}" => "Save a copy as .{format}",
|
||||
"Select a chart first." => "Select a chart first.",
|
||||
"Text Box" => "Text Box",
|
||||
"Text Box" => "Напис",
|
||||
"Select Data" => "Select Data",
|
||||
"Row Height" => "Row Height",
|
||||
"Column Width" => "Column Width",
|
||||
|
||||
@@ -209,6 +209,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "Xem trước\nngắt trang",
|
||||
"Page\nLayout" => "Bố trí\ntrang",
|
||||
"Formula Bar" => "Thanh công thức",
|
||||
"Name Box" => "Hộp tên",
|
||||
"Cell Editor" => "Trình soạn thảo ô",
|
||||
"Font Size" => "Cỡ chữ",
|
||||
"Gridlines" => "Đường lưới",
|
||||
"Headings" => "Tiêu đề hàng/cột",
|
||||
"Zoom" => "Thu phóng",
|
||||
@@ -305,6 +308,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"More\nFunctions" => "Hàm\nkhác",
|
||||
"Sheet Options|View" => "Xem",
|
||||
"Print" => "In",
|
||||
"Print What" => "Nội dung in",
|
||||
// Page Layout view and the Header & Footer tab.
|
||||
"Click to add data" => "Bấm để thêm dữ liệu",
|
||||
"Add header" => "Thêm đầu trang",
|
||||
@@ -412,6 +416,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Workbook calculation" => "Tính toán sổ làm việc",
|
||||
"Enable iterative calculation" => "Bật tính toán lặp",
|
||||
"Calculation settings are saved with the workbook." => "Thiết lập tính toán được lưu cùng sổ làm việc.",
|
||||
"Text Box" => "Hộp văn bản",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -208,6 +208,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "分页符\n预览",
|
||||
"Page\nLayout" => "页面\n布局",
|
||||
"Formula Bar" => "编辑栏",
|
||||
"Name Box" => "名称框",
|
||||
"Cell Editor" => "单元格编辑器",
|
||||
"Font Size" => "字号",
|
||||
"Gridlines" => "网格线",
|
||||
"Headings" => "标题",
|
||||
"Zoom" => "缩放",
|
||||
@@ -305,6 +308,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Dark" => "深色",
|
||||
"Sheet Options|View" => "屏幕",
|
||||
"Print" => "打印",
|
||||
"Print What" => "打印内容",
|
||||
// Page Layout view and the Header & Footer tab.
|
||||
"Click to add data" => "单击可添加数据",
|
||||
"Add header" => "添加页眉",
|
||||
@@ -428,6 +432,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Next sheet" => "下一个工作表",
|
||||
"All sheets" => "所有工作表",
|
||||
"New sheet (⇧F11)" => "新建工作表 (⇧F11)",
|
||||
"Text Box" => "文本框",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -211,6 +211,17 @@ impl Palette {
|
||||
}
|
||||
}
|
||||
|
||||
/// The code-drawn icon that stands in for a character the current fonts cannot draw (`✕` and
|
||||
/// `✓` have no bundled egui face). Interface chrome uses this instead of painting a tofu box; a
|
||||
/// workbook cell still needs a real glyph because its characters are user data.
|
||||
pub fn fallback_for_symbol(c: char) -> Option<Icon> {
|
||||
match c {
|
||||
'✕' => Some(Icon::Close),
|
||||
'✓' => Some(Icon::Check),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws `icon` into `rect` (square-ish), in colour `c`, with the current theme's palette.
|
||||
pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
paint_with(p, rect, icon, c, &Palette::of(&Tokens::get(p.ctx())));
|
||||
|
||||
+473
-63
@@ -12,6 +12,7 @@ mod cell_pictures;
|
||||
pub mod chartview;
|
||||
pub mod code_editor;
|
||||
pub mod control;
|
||||
mod craft_fonts_policy;
|
||||
pub mod credits;
|
||||
pub mod dialogs;
|
||||
pub mod editor;
|
||||
@@ -261,13 +262,23 @@ pub struct SheetApp {
|
||||
pub services: Services,
|
||||
pub printing: printing::State,
|
||||
print_feedback_seen: Option<printing::Feedback>,
|
||||
/// Synthetic input from agents, one batch per frame (drags need several frames).
|
||||
/// Synthetic input from agents, one batch per step (drags need several steps).
|
||||
pub synthetic: std::collections::VecDeque<Vec<egui::Event>>,
|
||||
/// The front synthetic batch was injected into `RawInput` but no `ui` pass has applied it
|
||||
/// yet. eframe retains that unconsumed input across hidden logic-only ticks; the flag makes
|
||||
/// the hook offer it once and asks for a hidden paint until a UI pass applies it.
|
||||
pub(crate) synthetic_pending: bool,
|
||||
/// `cumulative_pass_nr` when the front batch was last injected. A completed egui pass that
|
||||
/// did not apply it (the font gate's early return) consumed the events with `begin_pass`
|
||||
/// without reaching input handling, so the hook must offer the same step again.
|
||||
pub(crate) synthetic_injected_at_pass: Option<u64>,
|
||||
pub control_rx: Option<std::sync::mpsc::Receiver<ControlRequest>>,
|
||||
pub grid: grid::GridState,
|
||||
pub perf: Perf,
|
||||
pub fonts_ready: bool,
|
||||
fonts_set: bool,
|
||||
/// Test harness set: rebuilds use the deterministic pinned font set instead of the host scan.
|
||||
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
|
||||
@@ -288,6 +299,16 @@ pub struct SheetApp {
|
||||
/// Transient ribbon keyboard navigation; never saved with UI preferences.
|
||||
pub keytips: keytips::KeyTips,
|
||||
pub(crate) shots: control::Shots,
|
||||
/// Monotonic epoch of [`control::CaptureGeometry`]: any resize/DPI/zoom/surface change bumps
|
||||
/// it, including a change back to an earlier value, so a capture taken in another layout is
|
||||
/// never saved against the current geometry.
|
||||
pub(crate) capture_epoch: u64,
|
||||
/// The geometry `capture_epoch` describes; `None` before the first frame.
|
||||
pub(crate) capture_geometry: Option<control::CaptureGeometry>,
|
||||
/// The recorded geometry came from a native `RawInput` frame (see `raw_input_hook`). When
|
||||
/// false (a bare test/embedder that never runs the hook) capture code falls back to egui's
|
||||
/// current input instead of reusing a stale recorded value.
|
||||
pub(crate) capture_geometry_from_raw: bool,
|
||||
/// Chart selected on the sheet (id).
|
||||
pub selected_chart: Option<u32>,
|
||||
/// The system clipboard's text when GridCraft last copied without publishing to it (a
|
||||
@@ -325,6 +346,8 @@ impl SheetApp {
|
||||
printing: Default::default(),
|
||||
print_feedback_seen: None,
|
||||
synthetic: Default::default(),
|
||||
synthetic_pending: false,
|
||||
synthetic_injected_at_pass: None,
|
||||
control_rx: None,
|
||||
grid: grid::GridState {
|
||||
last_border: "bottom".into(),
|
||||
@@ -335,6 +358,7 @@ impl SheetApp {
|
||||
perf: Perf::default(),
|
||||
fonts_ready: false,
|
||||
fonts_set: false,
|
||||
prepared_fonts: false,
|
||||
fonts_bound: Vec::new(),
|
||||
fonts_seen: (0, 0),
|
||||
fonts_rebind: false,
|
||||
@@ -345,6 +369,9 @@ impl SheetApp {
|
||||
name_box: None,
|
||||
keytips: keytips::KeyTips::default(),
|
||||
shots: control::Shots::default(),
|
||||
capture_epoch: 0,
|
||||
capture_geometry: None,
|
||||
capture_geometry_from_raw: false,
|
||||
selected_chart: None,
|
||||
clipboard_text_at_copy: None,
|
||||
cut_text: None,
|
||||
@@ -369,6 +396,21 @@ impl SheetApp {
|
||||
theme::apply(ctx, dark);
|
||||
}
|
||||
|
||||
/// Test-harness setup: mark a deterministic font set (see
|
||||
/// [`theme::pinned_font_definitions`]) as already installed, so the first `logic` pass neither
|
||||
/// starts the host font scan nor replaces the harness's pinned set. Production uses
|
||||
/// [`Self::setup_context`] instead.
|
||||
#[doc(hidden)]
|
||||
pub fn use_prepared_fonts(&mut self) {
|
||||
self.prepared_fonts = true;
|
||||
self.fonts_ready = true;
|
||||
self.fonts_set = true;
|
||||
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));
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn used_fonts(&self) -> Vec<String> {
|
||||
@@ -1100,6 +1142,8 @@ impl SheetApp {
|
||||
}
|
||||
|
||||
/// Terminal feedback also reaches the normal window when printing starts through control.
|
||||
/// Polls printing feedback. Called once per frame from [`Self::logic`]; the direct calls in
|
||||
/// `tests/native_print.rs` deliberately bypass the lifecycle to test this helper alone.
|
||||
pub fn poll_printing(&mut self, ctx: &egui::Context) {
|
||||
self.printing.poll(ctx);
|
||||
if self.printing.message == self.print_feedback_seen {
|
||||
@@ -1160,55 +1204,117 @@ impl SheetApp {
|
||||
|
||||
/// Per-frame logic (control channel, screenshots). Call before `ui`.
|
||||
pub fn logic(&mut self, ctx: &egui::Context) {
|
||||
if self.session.text_width.is_none() {
|
||||
let ctx = ctx.clone();
|
||||
self.session.text_width = Some(std::sync::Arc::new(move |text, font, exact| theme::cell_text_width(&ctx, text, font, exact)));
|
||||
}
|
||||
self.ensure_text_width(ctx);
|
||||
// A keytip control request lives one frame: the ribbon consumes it during `ui`, and
|
||||
// anything that never reached a control (collapsed ribbon, tab switch) is dropped.
|
||||
self.ui.keytip_invoke = None;
|
||||
// Floating pictures' textures go with their workbook, even when no sheet is painted.
|
||||
let open: Vec<u64> = self.session.documents().iter().map(|d| d.uid).collect();
|
||||
floating_pictures::forget_closed(ctx, &open);
|
||||
if self.fonts_han.is_some_and(|han| han != theme::HanOrder::of(self.ui.language)) {
|
||||
self.apply_font_state(ctx);
|
||||
if let Some(answer) = self.desktop_theme.take_change() {
|
||||
self.desktop_dark = Some(answer);
|
||||
}
|
||||
self.sync_theme_preference(ctx);
|
||||
self.sync_input_options(ctx);
|
||||
// The geometry captures are bound to. Native frames recorded it from their fresh
|
||||
// `RawInput` in `raw_input_hook` (including hidden logic-only ticks); the epoch is
|
||||
// monotonic, so a resize back to the original values is still a new epoch and old pixels
|
||||
// are never saved with new geometry.
|
||||
let capture_geometry = control::current_capture_geometry(self, ctx);
|
||||
control::note_capture_geometry(self, capture_geometry);
|
||||
// Drain before admission: completed or timed-out captures release their slots and
|
||||
// reservation first, so a new request is never refused for budget that is about to free.
|
||||
// Screenshot results, deadlines and geometry validation belong to `logic`; `ui` only
|
||||
// issues the capture command (after every widget, so a command cannot be missed).
|
||||
control::drain_screenshots(self, ctx);
|
||||
control::poll(self, ctx);
|
||||
// Printing feedback is polled once per frame, here, so hidden windows and logic-only
|
||||
// frames still see worker completion. `ui` and the desktop app no longer poll it.
|
||||
self.poll_printing(ctx);
|
||||
self.guard_window_close(ctx);
|
||||
// Files read asynchronously (web file picker, dropped files).
|
||||
self.poll_async_opens();
|
||||
self.autosave(ctx);
|
||||
self.update_title(ctx);
|
||||
self.request_hidden_paint(ctx);
|
||||
if self.session.active().is_none() {
|
||||
self.session.new_workbook();
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs the text-width closure once, on the first frame.
|
||||
fn ensure_text_width(&mut self, ctx: &egui::Context) {
|
||||
if self.session.text_width.is_some() {
|
||||
return;
|
||||
}
|
||||
let ctx = ctx.clone();
|
||||
self.session.text_width = Some(std::sync::Arc::new(move |text, font, exact| theme::cell_text_width(&ctx, text, font, exact)));
|
||||
}
|
||||
|
||||
/// Applies the font gates: a language Han-order change, startup/prepared fonts and workbook
|
||||
/// font binding. Every branch keeps its one-frame paint gate.
|
||||
fn apply_font_state(&mut self, ctx: &egui::Context) {
|
||||
// A pending synthetic step owns the next real `ui` pass: `ui` returns at the font gate
|
||||
// before it handles input, so a font change requested now would consume the step without
|
||||
// applying it. Delay the change one frame; the hidden-paint request keeps it coming.
|
||||
let defer_font_change = self.synthetic_pending || !self.synthetic.is_empty();
|
||||
if !defer_font_change && self.fonts_han.is_some_and(|han| han != theme::HanOrder::of(self.ui.language)) {
|
||||
self.fonts_ready = false;
|
||||
self.fonts_set = false;
|
||||
}
|
||||
if !self.fonts_ready {
|
||||
// New fonts apply from the next frame on: paint nothing until then.
|
||||
if self.fonts_set {
|
||||
self.fonts_ready = true;
|
||||
} else {
|
||||
SheetApp::setup_context_for_language(ctx, self.ui.dark, self.ui.language);
|
||||
self.fonts_set = true;
|
||||
// The rebuilt set has no workbook fonts yet: bind them again once it is active.
|
||||
self.fonts_bound.clear();
|
||||
self.fonts_seen = (0, 0);
|
||||
self.fonts_han = Some(theme::HanOrder::of(self.ui.language));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
self.start_font_setup(ctx);
|
||||
} else if self.fonts_rebind {
|
||||
// A font-set change made last frame is active now.
|
||||
self.fonts_rebind = false;
|
||||
} else if fonts::scan_done() {
|
||||
// Bind 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.
|
||||
let seen = self.session.active().map(|d| (d.uid, d.revision)).unwrap_or((0, 0));
|
||||
if seen != self.fonts_seen {
|
||||
let used = self.used_fonts();
|
||||
self.fonts_seen = seen;
|
||||
if used != self.fonts_bound {
|
||||
ctx.set_fonts(theme::font_definitions_with(self.ui.language, &used));
|
||||
self.fonts_bound = used;
|
||||
self.fonts_rebind = true;
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
} else if !defer_font_change && !self.prepared_fonts && fonts::scan_done() {
|
||||
self.rebind_workbook_fonts(ctx);
|
||||
}
|
||||
if let Some(answer) = self.desktop_theme.take_change() {
|
||||
self.desktop_dark = Some(answer);
|
||||
}
|
||||
|
||||
/// Applies the pending font set. New fonts apply from the next frame on: paint nothing until
|
||||
/// then. Startup always proceeds: without it a queued step would be re-offered forever.
|
||||
fn start_font_setup(&mut self, ctx: &egui::Context) {
|
||||
if self.fonts_set {
|
||||
self.fonts_ready = true;
|
||||
return;
|
||||
}
|
||||
if self.prepared_fonts {
|
||||
ctx.set_fonts(theme::pinned_font_definitions());
|
||||
theme::apply(ctx, self.ui.dark);
|
||||
} else {
|
||||
SheetApp::setup_context_for_language(ctx, self.ui.dark, self.ui.language);
|
||||
}
|
||||
self.fonts_set = true;
|
||||
// The rebuilt set has no workbook fonts yet: bind them again once it is active.
|
||||
self.fonts_bound.clear();
|
||||
self.fonts_seen = (0, 0);
|
||||
self.fonts_han = Some(theme::HanOrder::of(self.ui.language));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn rebind_workbook_fonts(&mut self, ctx: &egui::Context) {
|
||||
let seen = self.session.active().map(|d| (d.uid, d.revision)).unwrap_or((0, 0));
|
||||
if seen == self.fonts_seen {
|
||||
return;
|
||||
}
|
||||
let used = self.used_fonts();
|
||||
self.fonts_seen = seen;
|
||||
if used == self.fonts_bound {
|
||||
return;
|
||||
}
|
||||
ctx.set_fonts(theme::font_definitions_with(self.ui.language, &used));
|
||||
self.fonts_bound = used;
|
||||
self.fonts_rebind = true;
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// Follows the desktop/system theme preference and records the effective theme.
|
||||
fn sync_theme_preference(&mut self, ctx: &egui::Context) {
|
||||
let preference = match (
|
||||
self.ui.system_theme,
|
||||
ctx.system_theme().or_else(|| self.desktop_dark.map(|d| if d { egui::Theme::Dark } else { egui::Theme::Light })),
|
||||
@@ -1222,6 +1328,10 @@ impl SheetApp {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
self.effective_dark = ctx.theme() == egui::Theme::Dark;
|
||||
}
|
||||
|
||||
/// Keeps egui's own keyboard zoom and quit shortcuts off and applies GridCraft's zoom factor.
|
||||
fn sync_input_options(&mut self, ctx: &egui::Context) {
|
||||
// Interface size: the preference owns the zoom factor, so egui's own Ctrl/Cmd +/-/0 zoom
|
||||
// keys stay off (they would also swallow GridCraft's shortcuts).
|
||||
if ctx.options(|o| o.zoom_with_keyboard) {
|
||||
@@ -1238,9 +1348,10 @@ impl SheetApp {
|
||||
ctx.set_zoom_factor(zoom);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
control::poll(self, ctx);
|
||||
self.guard_window_close(ctx);
|
||||
// Files read asynchronously (web file picker, dropped files).
|
||||
}
|
||||
|
||||
/// Opens files that arrived asynchronously (web file picker, dropped files).
|
||||
fn poll_async_opens(&mut self) {
|
||||
let arrived: Vec<(String, Vec<u8>)> = self
|
||||
.services
|
||||
.inbox
|
||||
@@ -1263,14 +1374,83 @@ impl SheetApp {
|
||||
}
|
||||
self.after_engine();
|
||||
}
|
||||
if self.session.active().is_none() {
|
||||
self.session.new_workbook();
|
||||
}
|
||||
|
||||
/// AutoSave: save workbooks that have a file shortly after they change. This decision lives in
|
||||
/// `logic` so a hidden or logic-only frame still saves; the monotonic timestamp check keeps a
|
||||
/// forced hidden UI pass from saving the same change twice.
|
||||
fn autosave(&mut self, ctx: &egui::Context) {
|
||||
let dirty_xlsx = self.session.active().is_some_and(|d| d.is_dirty() && d.path.as_deref().is_some_and(|p| p.ends_with(".xlsx")));
|
||||
let now = crate::monotonic_ms();
|
||||
let autosave_due =
|
||||
self.ui.autosave && self.editor.is_none() && self.text_box_editor.is_none() && now - self.grid.last_autosave > 2000.0 && dirty_xlsx;
|
||||
if autosave_due {
|
||||
self.grid.last_autosave = now;
|
||||
self.store_view_state();
|
||||
let _ = self.session.run("file.save", json!({}));
|
||||
} else if self.ui.autosave && dirty_xlsx {
|
||||
// Keep checking even when no input arrives while the window is hidden.
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs(2));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
|
||||
if let Some(mut batch) = self.synthetic.pop_front() {
|
||||
raw.events.append(&mut batch);
|
||||
/// 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) {
|
||||
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()) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
|
||||
self.grid.last_title = Some(title);
|
||||
}
|
||||
}
|
||||
|
||||
/// A queued/settling capture with a capturable size and an unapplied synthetic step both need
|
||||
/// a real `ui` pass. Once a capture is issued, its `Screenshot` command's own `wants_paint`
|
||||
/// paints the hidden window; after that a full hidden pass every frame only wastes CPU. A
|
||||
/// minimized request has no size, so no hidden pass can reach issuance: the restore pass issues
|
||||
/// it instead. Native eframe honours `RequestPaintWhileHidden`; the web runner does not (hidden
|
||||
/// tabs only run `run_logic`), so a hidden web tab queues the work until it is shown again.
|
||||
/// This is a shared eframe web follow-up, not a GridCraft-local workaround, and the control
|
||||
/// protocol documents the limitation.
|
||||
fn request_hidden_paint(&self, ctx: &egui::Context) {
|
||||
if !(self.shots.needs_hidden_paint() || self.synthetic_pending || !self.synthetic.is_empty()) {
|
||||
return;
|
||||
}
|
||||
// The web runner has no `RequestPaintWhileHidden`; sending it there only logs an
|
||||
// unhandled command. A visible tab still gets the repaint below; a hidden tab keeps the
|
||||
// work queued (see the shared eframe follow-up in docs/egui-037-local-followups.md).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
// Capture geometry from this frame's real input before any synthetic event is added. This
|
||||
// runs on every native frame, including hidden/minimized logic-only ticks, so a resize,
|
||||
// DPI/zoom change, Wayland frame without `inner_rect`, or minimize/restore is recorded
|
||||
// even though egui's last UI pass still describes the old layout.
|
||||
let geometry = control::CaptureGeometry::from_raw_input(raw, ctx);
|
||||
control::note_capture_geometry(self, geometry);
|
||||
self.capture_geometry_from_raw = true;
|
||||
// One unapplied gesture step at a time. eframe retains an unconsumed `RawInput` for the
|
||||
// next pass and for a hidden logic-only tick, so the batch is offered once and survives
|
||||
// until a `ui` pass applies it (which pops it). Press and release stay distinct batches.
|
||||
//
|
||||
// A completed egui pass may have consumed the injected events without applying them: the
|
||||
// font gate returns before input handling, but `begin_pass` has already taken the events.
|
||||
// `cumulative_pass_nr` only advances when a pass ends, so a newer value with the batch
|
||||
// still pending means exactly that, and the step is offered again for the next real pass.
|
||||
if self.synthetic_pending {
|
||||
let consumed_by_an_unapplied_pass = self.synthetic_injected_at_pass.is_some_and(|pass| ctx.cumulative_pass_nr() > pass);
|
||||
if !consumed_by_an_unapplied_pass {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Some(batch) = self.synthetic.front() {
|
||||
raw.events.extend(batch.iter().cloned());
|
||||
self.synthetic_pending = true;
|
||||
self.synthetic_injected_at_pass = Some(ctx.cumulative_pass_nr());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1283,7 +1463,6 @@ impl SheetApp {
|
||||
/// Lays out the whole window.
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
self.poll_printing(&ctx);
|
||||
if !self.fonts_ready || self.fonts_rebind {
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
@@ -1329,7 +1508,6 @@ impl SheetApp {
|
||||
widgets::message_box(self, &ctx);
|
||||
widgets::toast(self, &ctx);
|
||||
control::issue_screenshots(self, &ctx);
|
||||
control::collect_screenshots(self, &ctx);
|
||||
if !ctx.input(|i| i.focused) {
|
||||
// Nothing else to do.
|
||||
}
|
||||
@@ -1342,24 +1520,14 @@ impl SheetApp {
|
||||
}
|
||||
}
|
||||
self.perf.last = now;
|
||||
// AutoSave: save workbooks that have a file shortly after they change.
|
||||
if self.ui.autosave
|
||||
&& self.editor.is_none()
|
||||
&& self.text_box_editor.is_none()
|
||||
&& now - self.grid.last_autosave > 2000.0
|
||||
&& self.session.active().is_some_and(|d| d.is_dirty() && d.path.as_deref().is_some_and(|p| p.ends_with(".xlsx")))
|
||||
{
|
||||
self.grid.last_autosave = now;
|
||||
self.store_view_state();
|
||||
let _ = self.session.run("file.save", json!({}));
|
||||
}
|
||||
// Window title.
|
||||
if let Some(d) = self.session.active() {
|
||||
let title = format!("{}{}", d.display_title(), if d.is_dirty() { " — Edited" } else { "" });
|
||||
if self.grid.last_title.as_deref() != Some(title.as_str()) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
|
||||
self.grid.last_title = Some(title);
|
||||
}
|
||||
// The step is applied only after a completed UI pass. A discarded or repeated pass of the
|
||||
// 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 {
|
||||
self.synthetic.pop_front();
|
||||
self.synthetic_pending = false;
|
||||
self.synthetic_injected_at_pass = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1460,10 +1628,252 @@ pub fn clean_error(e: &str) -> String {
|
||||
e.to_string()
|
||||
}
|
||||
|
||||
/// Wall-clock milliseconds since the Unix epoch, for user-visible timestamps (Backstage's "last
|
||||
/// changed", toasts). It can jump when the system clock is adjusted, so it must not drive
|
||||
/// deadlines or pacing; use [`monotonic_ms`] for those.
|
||||
pub fn now_ms() -> f64 {
|
||||
gridcraft_engine::core::date::unix_now_ms().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Monotonic milliseconds since the first call, for screenshot deadlines and AutoSave pacing.
|
||||
///
|
||||
/// Deliberately separate from [`now_ms`]: a wall-clock adjustment must not extend (or expire) a
|
||||
/// screenshot deadline or save a workbook twice. Native uses [`std::time::Instant`]; wasm uses the
|
||||
/// browser's `performance.now()`, which is monotonic too.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[allow(clippy::disallowed_methods)] // the std clock is read only off wasm
|
||||
pub fn monotonic_ms() -> f64 {
|
||||
use std::sync::OnceLock;
|
||||
static START: OnceLock<std::time::Instant> = OnceLock::new();
|
||||
START.get_or_init(std::time::Instant::now).elapsed().as_secs_f64() * 1000.0
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn monotonic_ms() -> f64 {
|
||||
web_sys::window().and_then(|window| window.performance()).map_or(0.0, |performance| performance.now())
|
||||
}
|
||||
|
||||
pub fn cell(r: u32, c: u32) -> CellRef {
|
||||
CellRef::new(r, c)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod lifecycle_tests {
|
||||
use super::*;
|
||||
|
||||
fn key_event(pressed: bool) -> egui::Event {
|
||||
egui::Event::Key { key: egui::Key::A, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE }
|
||||
}
|
||||
|
||||
fn app() -> SheetApp {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
SheetApp::new(session, Services::default())
|
||||
}
|
||||
|
||||
/// One headless pass; a test has no renderer for the texture atlas delta (0.37 panics when it
|
||||
/// is dropped unapplied).
|
||||
fn ui_pass(ctx: &egui::Context, raw: egui::RawInput, f: impl FnMut(&mut egui::Ui)) -> egui::FullOutput {
|
||||
let mut out = ctx.run_ui(raw, f);
|
||||
out.textures_delta.clear();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_logic_tick_keeps_one_unapplied_synthetic_step() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
app.synthetic.push_back(vec![key_event(true)]);
|
||||
app.synthetic.push_back(vec![key_event(false)]);
|
||||
|
||||
let mut first = egui::RawInput::default();
|
||||
app.raw_input_hook(&ctx, &mut first);
|
||||
assert_eq!(first.events.len(), 1, "only one step is offered");
|
||||
assert!(app.synthetic_pending);
|
||||
assert_eq!(app.synthetic.len(), 2, "a logic-only frame does not consume the step");
|
||||
|
||||
// eframe carries the unconsumed `RawInput` into the next pass, so the hook offers the step
|
||||
// only once; a hidden logic-only tick does not duplicate it or offer the release.
|
||||
let mut second = egui::RawInput::default();
|
||||
app.raw_input_hook(&ctx, &mut second);
|
||||
assert!(second.events.is_empty(), "an unapplied step is not injected a second time");
|
||||
assert_eq!(app.synthetic.len(), 2, "press and release stay distinct batches");
|
||||
}
|
||||
|
||||
fn raw_with_size(size: egui::Vec2) -> egui::RawInput {
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_font_gated_pass_offers_the_synthetic_step_again() {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_fonts(theme::font_definitions());
|
||||
let mut app = app();
|
||||
app.fonts_ready = true;
|
||||
app.fonts_set = true;
|
||||
// Binds `session.text_width`, as eframe's first frame does.
|
||||
let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx));
|
||||
app.synthetic.push_back(vec![key_event(true)]);
|
||||
|
||||
// The font gate returns before input handling; `begin_pass` has still consumed the events.
|
||||
app.fonts_rebind = true;
|
||||
let mut raw = raw_with_size(egui::vec2(900.0, 700.0));
|
||||
app.raw_input_hook(&ctx, &mut raw);
|
||||
assert_eq!(raw.events.len(), 1);
|
||||
let _ = ui_pass(&ctx, raw, |ui| app.ui(ui));
|
||||
assert!(app.synthetic_pending, "the early-returning pass did not apply the step");
|
||||
assert_eq!(app.synthetic.len(), 1, "the step is still queued");
|
||||
|
||||
// A completed pass that did not apply it made the hook offer it again for a real pass.
|
||||
app.fonts_rebind = false;
|
||||
let mut raw = raw_with_size(egui::vec2(900.0, 700.0));
|
||||
app.raw_input_hook(&ctx, &mut raw);
|
||||
assert_eq!(raw.events.len(), 1, "the step is not lost across the font gate");
|
||||
let _ = ui_pass(&ctx, raw, |ui| app.ui(ui));
|
||||
assert!(!app.synthetic_pending);
|
||||
assert!(app.synthetic.is_empty(), "the real UI pass applied the step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pending_step_delays_a_font_change() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
app.fonts_ready = true;
|
||||
app.fonts_han = Some(theme::HanOrder::Chinese);
|
||||
app.ui.language = i18n::Language::Ja;
|
||||
app.synthetic.push_back(vec![key_event(true)]);
|
||||
app.synthetic_pending = true;
|
||||
let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx));
|
||||
assert!(app.fonts_ready, "the font rebuild waits for the pass that applies the step");
|
||||
app.synthetic.clear();
|
||||
app.synthetic_pending = false;
|
||||
let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx));
|
||||
assert!(!app.fonts_ready, "without pending input the language rebuild proceeds");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_logic_tick_sees_fresh_viewport_info_and_keeps_the_epoch_monotonic() {
|
||||
fn tick(ctx: &egui::Context, app: &mut SheetApp, width: f32, native_ppp: f32) {
|
||||
let mut raw =
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 400.0))), ..Default::default() };
|
||||
if let Some(viewport) = raw.viewports.get_mut(&egui::ViewportId::ROOT) {
|
||||
viewport.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 400.0)));
|
||||
viewport.native_pixels_per_point = Some(native_ppp);
|
||||
}
|
||||
let _ = ctx.run_logic(&raw, |ctx| app.logic(ctx));
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
tick(&ctx, &mut app, 800.0, 2.0);
|
||||
let first = app.capture_geometry.expect("the first logic tick records the geometry");
|
||||
assert_eq!(first.viewport, Some(egui::vec2(800.0, 400.0)));
|
||||
assert_eq!(first.pixels_per_point, 2.0, "native scale times the interface zoom");
|
||||
tick(&ctx, &mut app, 900.0, 2.0);
|
||||
let resized = app.capture_geometry.expect("geometry");
|
||||
assert_eq!(resized.viewport, Some(egui::vec2(900.0, 400.0)), "run_logic's ViewportInfo is the fresh size");
|
||||
let epoch = app.capture_epoch;
|
||||
tick(&ctx, &mut app, 800.0, 2.0);
|
||||
assert_eq!(app.capture_geometry.map(|g| g.viewport), Some(Some(egui::vec2(800.0, 400.0))));
|
||||
assert!(app.capture_epoch > epoch, "resizing back is still a new epoch: old pixels must not be saved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_capture_epoch_changes_even_when_the_geometry_returns() {
|
||||
fn tick(ctx: &egui::Context, app: &mut SheetApp, width: f32) {
|
||||
let raw =
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 400.0))), ..Default::default() };
|
||||
let _ = ui_pass(ctx, raw, |ui| app.logic(ui.ctx()));
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
tick(&ctx, &mut app, 800.0);
|
||||
let base = app.capture_epoch;
|
||||
tick(&ctx, &mut app, 900.0);
|
||||
let changed = app.capture_epoch;
|
||||
assert!(changed > base, "a resize increments the capture epoch");
|
||||
tick(&ctx, &mut app, 800.0);
|
||||
assert!(app.capture_epoch > changed, "resizing back is still a new epoch: old pixels must not be saved");
|
||||
}
|
||||
|
||||
fn titles(out: &egui::LogicOutput) -> Vec<String> {
|
||||
out.viewport_commands
|
||||
.values()
|
||||
.flatten()
|
||||
.filter_map(|c| match c {
|
||||
egui::ViewportCommand::Title(title) => Some(title.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_logic_only_tick_autosaves_a_dirty_workbook_once() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-logic-autosave-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("autosave.xlsx");
|
||||
app.run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap();
|
||||
app.ui.autosave = true;
|
||||
app.session.active_mut().unwrap().path = Some(path.to_string_lossy().into_owned());
|
||||
app.grid.last_autosave = monotonic_ms() - 3000.0;
|
||||
|
||||
let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx));
|
||||
assert!(path.exists(), "a hidden logic-only tick saves a dirty workbook with a file");
|
||||
assert!(!app.session.active().unwrap().is_dirty(), "the save clears the dirty state");
|
||||
let saved_at = app.grid.last_autosave;
|
||||
let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx));
|
||||
assert_eq!(app.grid.last_autosave, saved_at, "the same change is not saved a second time");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_logic_only_tick_updates_the_title_once_per_change() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
let first = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx));
|
||||
let title = app.session.active().unwrap().display_title().to_string();
|
||||
let expected = format!("{title}{}", if app.session.active().unwrap().is_dirty() { " — Edited" } else { "" });
|
||||
assert_eq!(titles(&first), [expected], "the first logic tick titles the window");
|
||||
let second = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx));
|
||||
assert!(titles(&second).is_empty(), "an unchanged title is not sent every frame");
|
||||
|
||||
app.session.active_mut().unwrap().title = "Renamed".into();
|
||||
let third = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx));
|
||||
let expected = format!("Renamed{}", if app.session.active().unwrap().is_dirty() { " — Edited" } else { "" });
|
||||
assert_eq!(titles(&third), [expected], "a rename while hidden updates the title");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_logic_only_tick_polls_printing_feedback() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
app.printing.printers.push(crate::printing::Printer { name: "Queue".into(), label: String::new(), default: true });
|
||||
app.run("ui.printSettings", serde_json::json!({"printer": "Queue"})).unwrap();
|
||||
app.services.print_native = Some(Box::new(|_, _| {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
sender.send(Err("printer is offline".into())).unwrap();
|
||||
receiver
|
||||
}));
|
||||
app.run("ui.printSubmit", serde_json::json!({})).unwrap();
|
||||
let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx));
|
||||
assert_eq!(app.printing.message, Some(crate::printing::Feedback::Failed("printer is offline".into())));
|
||||
assert!(app.message.as_ref().is_some_and(|(_, text)| text.contains("printer is offline")), "logic surfaces the failure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pending_synthetic_step_requests_a_hidden_paint() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
app.synthetic.push_back(vec![key_event(true)]);
|
||||
app.synthetic_pending = true;
|
||||
let out = ui_pass(&ctx, egui::RawInput::default(), |ui| app.logic(ui.ctx()));
|
||||
let painted = out
|
||||
.viewport_output
|
||||
.get(&ctx.viewport_id())
|
||||
.is_some_and(|output| output.commands.iter().any(|c| matches!(c, egui::ViewportCommand::RequestPaintWhileHidden)));
|
||||
assert!(painted, "logic must ask for a real UI pass while a synthetic step is unapplied");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ fn comments(app: &mut SheetApp, ui: &mut Ui) {
|
||||
for (a, txt) in &c.replies {
|
||||
ui.label(egui::RichText::new(format!("↳ {a}: {txt}")).color(t.text));
|
||||
}
|
||||
let key = egui::Id::new(("reply", &cell));
|
||||
let key = egui::Id::unique(("reply", &cell));
|
||||
let mut draft: String = ui.ctx().data_mut(|m| m.get_temp(key)).unwrap_or_default();
|
||||
// Buttons first, right to left, so the reply field gets exactly the width they leave:
|
||||
// a field sized by a guess wider than the row pushes the resizable pane wider every frame.
|
||||
@@ -126,7 +126,7 @@ fn watch(app: &mut SheetApp, ui: &mut Ui) {
|
||||
ui.label(&cell);
|
||||
ui.label(w["value"].as_str().unwrap_or(""));
|
||||
ui.label(w["formula"].as_str().unwrap_or(""));
|
||||
if ui.small_button("✕").clicked() {
|
||||
if crate::widgets::icon_button(ui, crate::icons::Icon::Close, Tokens::get(ui.ctx()).text_dim, "Delete", vec2(18.0, 18.0)).clicked() {
|
||||
let _ = app.session.run("formulas.watchWindow", json!({"remove": cell}));
|
||||
}
|
||||
ui.end_row();
|
||||
@@ -154,7 +154,7 @@ fn selection(app: &mut SheetApp, ui: &mut Ui) {
|
||||
if ui.small_button("▼").on_hover_text("Send Backward").clicked() {
|
||||
app.run_or_alert("arrange.sendBackward", json!({"kind": kind, "id": id}));
|
||||
}
|
||||
if ui.small_button("✕").on_hover_text("Delete").clicked() {
|
||||
if crate::widgets::icon_button(ui, crate::icons::Icon::Close, Tokens::get(ui.ctx()).text_dim, "Delete", vec2(18.0, 18.0)).clicked() {
|
||||
app.run_or_alert("object.delete", json!({"kind": kind, "id": id}));
|
||||
}
|
||||
});
|
||||
@@ -172,7 +172,7 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) {
|
||||
};
|
||||
let theme = app.session.active().map(|d| d.wb.theme.clone()).unwrap_or_default();
|
||||
ui.collapsing("Chart Title", |ui| {
|
||||
let field = egui::Id::new(("gridcraft.chart_title", id));
|
||||
let field = egui::Id::unique(("gridcraft.chart_title", id));
|
||||
if let Some(title) = crate::widgets::committed_text(ui, field, chart.title.as_deref().unwrap_or(""), None) {
|
||||
app.run_or_alert("chart.set", json!({"chart": id, "title": title}));
|
||||
}
|
||||
@@ -217,14 +217,14 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) {
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Horizontal axis title:");
|
||||
let field = egui::Id::new(("gridcraft.chart_x_title", id));
|
||||
let field = egui::Id::unique(("gridcraft.chart_x_title", id));
|
||||
if let Some(xt) = crate::widgets::committed_text(ui, field, chart.x_title.as_deref().unwrap_or(""), None) {
|
||||
app.run_or_alert("chart.set", json!({"chart": id, "xTitle": if xt.is_empty() { Json::Null } else { json!(xt) }}));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Vertical axis title:");
|
||||
let field = egui::Id::new(("gridcraft.chart_y_title", id));
|
||||
let field = egui::Id::unique(("gridcraft.chart_y_title", id));
|
||||
if let Some(yt) = crate::widgets::committed_text(ui, field, chart.y_title.as_deref().unwrap_or(""), None) {
|
||||
app.run_or_alert("chart.set", json!({"chart": id, "yTitle": if yt.is_empty() { Json::Null } else { json!(yt) }}));
|
||||
}
|
||||
@@ -271,7 +271,7 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) {
|
||||
});
|
||||
ui.collapsing("Size", |ui| {
|
||||
// Keep the value being typed or dragged between frames; reloading it from the chart every frame threw the edit away.
|
||||
let draft = egui::Id::new(("gridcraft.chart_size_draft", id));
|
||||
let draft = egui::Id::unique(("gridcraft.chart_size_draft", id));
|
||||
let (mut w, mut h) = ui.data_mut(|d| d.get_temp::<(f32, f32)>(draft)).unwrap_or((chart.anchor.width, chart.anchor.height));
|
||||
let a = ui.add(egui::DragValue::new(&mut w).prefix("Width ").range(40.0..=4000.0));
|
||||
let b = ui.add(egui::DragValue::new(&mut h).prefix("Height ").range(40.0..=4000.0));
|
||||
|
||||
@@ -469,7 +469,7 @@ fn editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid) {
|
||||
let anchor = [Align2::LEFT_TOP, Align2::CENTER_TOP, Align2::RIGHT_TOP][k];
|
||||
let x = [r.left() + 3.0, r.center().x, r.right() - 3.0][k];
|
||||
p.text(pos2(x, r.top() + 2.0), anchor, text, font.clone(), Color32::BLACK);
|
||||
if ui.interact(*r, ui.id().with(("hf_section", k)), egui::Sense::click()).clicked() {
|
||||
if ui.interact(*r, ui.scope_id().with(("hf_section", k)), egui::Sense::click()).clicked() {
|
||||
switch_to = Some(k);
|
||||
}
|
||||
}
|
||||
@@ -478,9 +478,9 @@ fn editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid) {
|
||||
app.grid.hf_edit = Some(e);
|
||||
return;
|
||||
};
|
||||
let id = egui::Id::new("gridcraft.header_footer_editor");
|
||||
let id = egui::Id::unique("gridcraft.header_footer_editor");
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(3.0, 2.0)).intersect(geo.cells)));
|
||||
let align = [egui::Align::LEFT, egui::Align::Center, egui::Align::RIGHT][e.section.min(2)];
|
||||
let align = [egui::Align2::LEFT_TOP, egui::Align2::CENTER_TOP, egui::Align2::RIGHT_TOP][e.section.min(2)];
|
||||
if let Some(text) = e.texts.get_mut(e.section) {
|
||||
let mut output = TextEdit::multiline(text)
|
||||
.id(id)
|
||||
@@ -488,7 +488,7 @@ fn editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid) {
|
||||
.font(font)
|
||||
.text_color(Color32::BLACK)
|
||||
.background_color(Color32::WHITE)
|
||||
.horizontal_align(align)
|
||||
.align(align)
|
||||
.desired_width(r.width() - 6.0)
|
||||
.desired_rows(1)
|
||||
.lock_focus(true)
|
||||
@@ -776,8 +776,8 @@ fn ruler_margins(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid
|
||||
}
|
||||
let (hr, vr) = ruler_rects(outer, geo);
|
||||
// Right-to-left sheets mirror the pages and the horizontal ruler; hit-testing is logical.
|
||||
let hresp = ui.interact(hr, egui::Id::new("ruler_margin_h"), egui::Sense::drag());
|
||||
let vresp = ui.interact(vr, egui::Id::new("ruler_margin_v"), egui::Sense::drag());
|
||||
let hresp = ui.interact(hr, egui::Id::unique("ruler_margin_h"), egui::Sense::drag());
|
||||
let vresp = ui.interact(vr, egui::Id::unique("ruler_margin_v"), egui::Sense::drag());
|
||||
let margins_in = |sh: &Sheet| -> [f32; 6] {
|
||||
let m = |i: usize| sh.print.margins.get(i).copied().unwrap_or(0.5);
|
||||
[m(0), m(1), m(2), m(3), m(4), m(5)]
|
||||
@@ -787,7 +787,7 @@ fn ruler_margins(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid
|
||||
let tip = |ui: &egui::Ui, app: &SheetApp, edge: Edge, inches: f32| {
|
||||
let value = if metric { format!("{:.2} cm", inches * 2.54) } else { format!("{inches:.2} in") };
|
||||
let text = format!("{}: {value}", app.ui.language.tr(edge.label()));
|
||||
egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("ruler_margin_tip"), egui::PopupAnchor::Pointer).show(|ui| {
|
||||
egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::unique("ruler_margin_tip"), egui::PopupAnchor::Pointer).show(|ui| {
|
||||
ui.label(text);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -168,13 +168,17 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
|
||||
if ui.button(lang.tr("Calculated Field…")).clicked() {
|
||||
app.run_or_alert("pivot.calculatedField", json!({"pivot": name}));
|
||||
}
|
||||
egui::ComboBox::from_id_salt("pv_layout").selected_text(lang.tr("Report Layout")).show_ui(ui, |ui| {
|
||||
for (l, v) in [("Compact", "compact"), ("Outline", "outline"), ("Tabular", "tabular")] {
|
||||
if ui.button(lang.tr(l)).clicked() {
|
||||
app.run_or_alert("pivot.layout", json!({"pivot": name, "layout": v}));
|
||||
egui::ComboBox::from_id_salt("pv_layout")
|
||||
.selected_text(lang.tr("Report Layout"))
|
||||
.show_ui(ui, |ui| {
|
||||
for (l, v) in [("Compact", "compact"), ("Outline", "outline"), ("Tabular", "tabular")] {
|
||||
if ui.button(lang.tr(l)).clicked() {
|
||||
app.run_or_alert("pivot.layout", json!({"pivot": name, "layout": v}));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.response
|
||||
.accessible_name(lang.tr("Report Layout"));
|
||||
});
|
||||
let _: Option<Json> = None;
|
||||
});
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
use craft_ui::printing::Labels;
|
||||
pub use craft_ui::printing::{Discover, Duplex, Feedback, Printer, Settings, State, Submit};
|
||||
|
||||
/// Draws the shared print controls.
|
||||
///
|
||||
/// `craft_ui::printing::State::show` also calls `poll`, so this path polls a second time on top of
|
||||
/// [`crate::SheetApp::poll_printing`] in `logic`. That poll is idempotent (it only drains worker
|
||||
/// messages into the same state), and the logic-side poll is what lets a hidden window and a
|
||||
/// logic-only frame see completion, so it stays. Splitting poll out of the shared `show` API is a
|
||||
/// craft-ui follow-up, not a GridCraft-local change.
|
||||
pub fn show(state: &mut State, ui: &mut egui::Ui, discover: &Discover, translate: impl Fn(&str) -> String) {
|
||||
let labels = Labels {
|
||||
printer: &translate("Printer"),
|
||||
@@ -15,7 +22,7 @@ pub fn show(state: &mut State, ui: &mut egui::Ui, discover: &Discover, translate
|
||||
short_edge: &translate("Two-sided: short edge"),
|
||||
grayscale: &translate("Grayscale"),
|
||||
};
|
||||
state.show(ui, egui::Id::new("gridcraft_print_settings"), discover, &labels);
|
||||
state.show(ui, egui::Id::unique("gridcraft_print_settings"), discover, &labels);
|
||||
if let Some(message) = &state.message {
|
||||
ui.label(feedback(message, crate::i18n::current(ui.ctx()), translate));
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) {
|
||||
egui::Panel::top("title_bar").exact_size(TITLE_BAR_H).frame(egui::Frame::NONE.fill(t.window)).show(ui, |ui| {
|
||||
let rect = ui.max_rect();
|
||||
// Dragging the empty title area moves the window.
|
||||
let bg = ui.interact(rect, ui.id().with("drag_title"), Sense::click_and_drag());
|
||||
let bg = ui.interact(rect, ui.scope_id().with("drag_title"), Sense::click_and_drag());
|
||||
if bg.drag_started() {
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
|
||||
}
|
||||
@@ -80,7 +80,7 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) {
|
||||
let can_redo = gridcraft_engine::cmd::can_redo(&app.session).is_ok();
|
||||
// Named and marked enabled or not for assistive technology (and the UI tests).
|
||||
let undo = icon_button(ui, Icon::Undo, if can_undo { t.text_dim } else { t.text_disabled }, "Undo", vec2(26.0, ch));
|
||||
undo.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, can_undo, "Undo"));
|
||||
undo.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, can_undo, "Undo"));
|
||||
if undo.clicked() && can_undo {
|
||||
app.run_or_alert("edit.undo", json!({}));
|
||||
}
|
||||
@@ -101,7 +101,7 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) {
|
||||
}
|
||||
});
|
||||
let redo = icon_button(ui, Icon::Redo, if can_redo { t.text_dim } else { t.text_disabled }, "Redo", vec2(26.0, ch));
|
||||
redo.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, can_redo, "Redo"));
|
||||
redo.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, can_redo, "Redo"));
|
||||
if redo.clicked() && can_redo {
|
||||
app.run_or_alert("edit.redo", json!({}));
|
||||
}
|
||||
@@ -153,7 +153,7 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) {
|
||||
let lang = app.ui.language;
|
||||
let theme = icon_button(ui, Icon::Appearance, t.text_dim, "Dark Mode", vec2(28.0, 26.0));
|
||||
let dark = ui.ctx().theme() == egui::Theme::Dark;
|
||||
theme.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, dark, lang.tr("Dark Mode")));
|
||||
theme.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, dark, lang.tr("Dark Mode")));
|
||||
if theme.clicked() {
|
||||
act(app, "view.darkMode", json!({}));
|
||||
}
|
||||
@@ -292,30 +292,39 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(inner), |ui| {
|
||||
// One scroll state per tab: a tab opened after scrolling another starts at its
|
||||
// left edge, and a tab keeps its own offset while others are used.
|
||||
let scroll = egui::ScrollArea::horizontal()
|
||||
.id_salt(("ribbon_scroll", app.ui.ribbon_tab.as_str()))
|
||||
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(2.0, 2.0);
|
||||
match app.ui.ribbon_tab.as_str() {
|
||||
"Home" => home(app, ui),
|
||||
"Insert" => insert(app, ui),
|
||||
"Draw" => draw(app, ui),
|
||||
"Page Layout" => page_layout(app, ui),
|
||||
"Formulas" => formulas(app, ui),
|
||||
"Data" => data(app, ui),
|
||||
"Review" => review(app, ui),
|
||||
"View" => view(app, ui),
|
||||
"Automate" => automate(app, ui),
|
||||
"Table Design" => table_design(app, ui),
|
||||
"Chart Design" => chart_design(app, ui),
|
||||
crate::paper_view::TAB => crate::paper_view::ribbon_tab(app, ui),
|
||||
_ => home(app, ui),
|
||||
}
|
||||
});
|
||||
let tab = app.ui.ribbon_tab.clone();
|
||||
let mut area = egui::ScrollArea::horizontal()
|
||||
.id_salt(("ribbon_scroll", tab.as_str()))
|
||||
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden);
|
||||
// egui 0.37 keeps `ScrollArea`'s offset private; `scroll_offset` is the supported
|
||||
// way to move it. An arrow click records the requested offset for the next frame.
|
||||
// Expire it if this tab stops rendering, rather than replaying it much later.
|
||||
if let Some((frame, x)) = ui.ctx().data_mut(|d| d.remove_temp::<(u64, f32)>(RibbonOverflow::scroll_target_id(tab.as_str())))
|
||||
&& ui.ctx().cumulative_frame_nr().saturating_sub(frame) <= 1
|
||||
{
|
||||
area = area.horizontal_scroll_offset(x);
|
||||
}
|
||||
let scroll = area.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(2.0, 2.0);
|
||||
match app.ui.ribbon_tab.as_str() {
|
||||
"Home" => home(app, ui),
|
||||
"Insert" => insert(app, ui),
|
||||
"Draw" => draw(app, ui),
|
||||
"Page Layout" => page_layout(app, ui),
|
||||
"Formulas" => formulas(app, ui),
|
||||
"Data" => data(app, ui),
|
||||
"Review" => review(app, ui),
|
||||
"View" => view(app, ui),
|
||||
"Automate" => automate(app, ui),
|
||||
"Table Design" => table_design(app, ui),
|
||||
"Chart Design" => chart_design(app, ui),
|
||||
crate::paper_view::TAB => crate::paper_view::ribbon_tab(app, ui),
|
||||
_ => home(app, ui),
|
||||
}
|
||||
});
|
||||
scroll_arrows(ui, &t, scroll);
|
||||
});
|
||||
scroll_arrows(ui, &t, tab.as_str(), scroll);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -337,7 +346,12 @@ pub struct RibbonOverflow {
|
||||
|
||||
impl RibbonOverflow {
|
||||
fn id() -> egui::Id {
|
||||
egui::Id::new("ribbon_overflow")
|
||||
egui::Id::unique("ribbon_overflow")
|
||||
}
|
||||
|
||||
/// Where an arrow click asks the next frame's `ScrollArea` to scroll to.
|
||||
fn scroll_target_id(tab: &str) -> egui::Id {
|
||||
egui::Id::unique(("ribbon_scroll_target", tab))
|
||||
}
|
||||
|
||||
/// Last frame's overflow.
|
||||
@@ -351,15 +365,15 @@ impl RibbonOverflow {
|
||||
}
|
||||
}
|
||||
|
||||
/// When the tab's groups are wider than the card, arrows at its edges scroll them (the mouse wheel
|
||||
/// over the card still does too).
|
||||
fn scroll_arrows(ui: &mut Ui, t: &Tokens, mut scroll: egui::scroll_area::ScrollAreaOutput<()>) {
|
||||
/// When the tab's groups are wider than the card, arrows at its edges scroll them (horizontal wheel or
|
||||
/// trackpad input over the card still does too).
|
||||
fn scroll_arrows(ui: &mut Ui, t: &Tokens, tab: &str, scroll: egui::scroll_area::ScrollAreaOutput<()>) {
|
||||
let inner = scroll.inner_rect;
|
||||
let max = (scroll.content_size.x - inner.width()).max(0.0);
|
||||
let overflow = RibbonOverflow {
|
||||
content: scroll.content_size.x,
|
||||
visible: inner.width(),
|
||||
offset: scroll.state.offset.x,
|
||||
offset: scroll.state.clamped_offset().x,
|
||||
content_height: scroll.content_size.y,
|
||||
visible_height: inner.height(),
|
||||
};
|
||||
@@ -378,19 +392,19 @@ fn scroll_arrows(ui: &mut Ui, t: &Tokens, mut scroll: egui::scroll_area::ScrollA
|
||||
} else {
|
||||
Rect::from_min_size(pos2(inner.right() - 18.0, inner.top()), vec2(18.0, inner.height()))
|
||||
};
|
||||
let resp = ui.interact(r, ui.id().with(("ribbon_scroll_arrow", left)), Sense::click());
|
||||
let resp = ui.interact(r, ui.scope_id().with(("ribbon_scroll_arrow", left)), Sense::click());
|
||||
ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.hover } else { t.ribbon });
|
||||
ui.painter().rect_stroke(r, 4.0, Stroke::new(1.0, t.ribbon_border), StrokeKind::Inside);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), if left { Icon::Left } else { Icon::Right }, t.text);
|
||||
let name = if left { "Scroll ribbon left" } else { "Scroll ribbon right" };
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, name));
|
||||
if resp.on_hover_text(crate::i18n::current(ui.ctx()).tr(name)).clicked() {
|
||||
target = Some(if left { overflow.offset - step } else { overflow.offset + step });
|
||||
}
|
||||
}
|
||||
if let Some(x) = target {
|
||||
scroll.state.offset.x = x.clamp(0.0, max);
|
||||
scroll.state.store(ui.ctx(), scroll.id);
|
||||
let frame = ui.ctx().cumulative_frame_nr();
|
||||
ui.ctx().data_mut(|d| d.insert_temp(RibbonOverflow::scroll_target_id(tab), (frame, x.clamp(0.0, max))));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
@@ -489,7 +503,7 @@ fn circular_references(app: &SheetApp) -> Vec<(usize, String, String)> {
|
||||
fn keytip(app: &SheetApp, ui: &Ui, sequence: &str, at: egui::Pos2) {
|
||||
let Some(rest) = app.keytips.prefix().and_then(|prefix| sequence.strip_prefix(prefix)).filter(|s| !s.is_empty()) else { return };
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("ribbon_keytips"))).with_clip_rect(ui.clip_rect());
|
||||
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("ribbon_keytips"))).with_clip_rect(ui.clip_rect());
|
||||
let text = painter.layout_no_wrap(rest.into(), theme::ui_bold(10.5), t.text);
|
||||
let rect = Rect::from_center_size(at, text.size() + vec2(7.0, 3.0));
|
||||
painter.rect_filled(rect, 2.0, t.input_bg);
|
||||
@@ -727,7 +741,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) {
|
||||
let st = active_style(app);
|
||||
// Clipboard
|
||||
let paste = big_button(ui, Icon::Paste, "Paste", "Paste", true);
|
||||
paste.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Paste"));
|
||||
paste.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Paste"));
|
||||
for sequence in
|
||||
keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["edit.paste", "edit.pasteSpecial", "dialog:pasteSpecial"])
|
||||
{
|
||||
@@ -790,7 +804,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) {
|
||||
}
|
||||
let fp_on = app.session.format_painter.is_some();
|
||||
let fp = toggle_button(ui, Icon::Brush, fp_on, "Format Painter (double-click to keep it on)");
|
||||
fp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), fp_on, "Format Painter"));
|
||||
fp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), fp_on, "Format Painter"));
|
||||
control_keytip(app, ui, &fp, "edit.formatPainter");
|
||||
if fp.double_clicked() {
|
||||
act(app, "edit.formatPainter", json!({"sticky": true}));
|
||||
@@ -855,7 +869,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) {
|
||||
if b {
|
||||
act(app, "home.borders", json!({"preset": app.grid.last_border.clone()}));
|
||||
}
|
||||
ba.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Border presets"));
|
||||
ba.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Border presets"));
|
||||
egui::Popup::menu(&ba).show(|ui| {
|
||||
for (label, preset) in [
|
||||
("Bottom Border", "bottom"),
|
||||
@@ -1311,7 +1325,7 @@ fn size_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let size = st.font.size;
|
||||
let current = if size.fract() == 0.0 { format!("{}", size as i32) } else { format!("{size}") };
|
||||
let field = egui::Id::new("gridcraft.font_size_box");
|
||||
let field = egui::Id::unique("gridcraft.font_size_box");
|
||||
let draft = field.with("draft");
|
||||
let mut text = ui.data_mut(|d| d.get_temp::<String>(draft)).unwrap_or_else(|| current.clone());
|
||||
// One frame like the Font name box beside it (same fill, border, height and corners), with the
|
||||
@@ -1330,6 +1344,7 @@ fn size_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) {
|
||||
.desired_width(text_rect.width() - 12.0)
|
||||
.show(&mut field_ui);
|
||||
let resp = out.response;
|
||||
ui.ctx().accesskit_node_builder(resp.id, |node| node.set_label(app.ui.language.tr("Font Size")));
|
||||
control_keytip(app, ui, &resp, "home.fontSize");
|
||||
if invoke(app, "home.fontSize") {
|
||||
ui.memory_mut(|m| m.request_focus(field));
|
||||
@@ -1359,7 +1374,7 @@ fn size_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) {
|
||||
let looks = if resp.has_focus() || ui.rect_contains_pointer(frame) { &visuals.widgets.hovered } else { &visuals.widgets.inactive };
|
||||
let stroke = if resp.has_focus() { visuals.selection.stroke } else { looks.bg_stroke };
|
||||
ui.painter().set(bg, egui::epaint::RectShape::new(frame, looks.corner_radius, looks.weak_bg_fill, stroke, egui::StrokeKind::Inside));
|
||||
arrow.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Font Size"));
|
||||
arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Font Size"));
|
||||
let sizes = [8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 22.0, 24.0, 26.0, 28.0, 36.0, 48.0, 72.0];
|
||||
let labels: Vec<String> = sizes.iter().map(|s| format!("{}", *s as i32)).collect();
|
||||
let items: Vec<(&str, String)> = labels.iter().map(|s| (s.as_str(), s.clone())).collect();
|
||||
@@ -1641,7 +1656,7 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) {
|
||||
sep(ui);
|
||||
let pic = big_button(ui, Icon::Picture, "Pictures", "Insert a picture", true);
|
||||
anchor_keytip(app, ui, &pic, &["insert.picture"]);
|
||||
pic.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Pictures"));
|
||||
pic.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Pictures"));
|
||||
egui::Popup::menu(&pic).show(|ui| {
|
||||
for (label, placement) in [("Place in Cell", "cell"), ("Place over Cells", "overCells")] {
|
||||
if ui.button(label).clicked() {
|
||||
@@ -1826,7 +1841,8 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) {
|
||||
"€", "£", "¥", "©", "®", "™", "±", "≠", "≤", "≥", "÷", "×", "∞", "µ", "α", "β", "π", "Σ", "Ω", "°", "•", "…", "→", "←", "↑", "↓",
|
||||
"✓", "★", "½", "¼",
|
||||
] {
|
||||
if ui.button(ch).clicked() {
|
||||
// No bundled face has ✓: the button paints a code-drawn check instead of a tofu box.
|
||||
if crate::widgets::glyph_button(ui, ch.chars().next().unwrap_or('?'), &format!("Insert {ch}")).clicked() {
|
||||
act(app, "insert.symbol", json!({"char": ch}));
|
||||
}
|
||||
}
|
||||
@@ -2087,7 +2103,7 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) {
|
||||
egui::Popup::menu(&use_in).show(|ui| {
|
||||
// The menu lists the names Paste Names lists; clicking one runs `formulas.useInFormula`, which puts it at the caret.
|
||||
// Listed when the menu opens, and again only after the workbook changes.
|
||||
let cache = egui::Id::new("gridcraft.use_in_formula_names");
|
||||
let cache = egui::Id::unique("gridcraft.use_in_formula_names");
|
||||
let key = app.session.active().map(|d| (d.uid, d.revision, d.wb.active_sheet));
|
||||
let cached = ui.ctx().data(|m| m.get_temp::<(Option<(u64, u64, usize)>, std::sync::Arc<Vec<String>>)>(cache)).filter(|(k, _)| *k == key);
|
||||
let names = match cached {
|
||||
@@ -2138,7 +2154,7 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) {
|
||||
}
|
||||
});
|
||||
ui.vertical(|ui| {
|
||||
let __k = small_button(ui, Icon::Fx, "Show Formulas", "Show Formulas (⌃`)", false);
|
||||
let __k = small_button(ui, Icon::Fx, "Show Formulas", "Show Formulas", false);
|
||||
control_keytip(app, ui, &__k, "formulas.showFormulas");
|
||||
if __k.clicked() {
|
||||
act(app, "formulas.showFormulas", json!({}));
|
||||
@@ -2510,20 +2526,25 @@ fn view(app: &mut SheetApp, ui: &mut Ui) {
|
||||
);
|
||||
});
|
||||
ui.vertical(|ui| {
|
||||
ui.label(lang.tr("Display theme"));
|
||||
let label_response = ui.label(lang.tr("Display theme"));
|
||||
let mode = app.ui.theme_mode();
|
||||
let label = match mode {
|
||||
"system" => "System",
|
||||
"dark" => "Dark",
|
||||
_ => "Light",
|
||||
};
|
||||
egui::ComboBox::from_id_salt("display_theme").selected_text(lang.tr(label)).width(88.0).show_ui(ui, |ui| {
|
||||
for (value, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] {
|
||||
if ui.selectable_label(mode == value, lang.tr(label)).clicked() {
|
||||
act(app, "view.theme", json!({"mode": value}));
|
||||
egui::ComboBox::from_id_salt("display_theme")
|
||||
.selected_text(lang.tr(label))
|
||||
.width(88.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for (value, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] {
|
||||
if ui.selectable_label(mode == value, lang.tr(label)).clicked() {
|
||||
act(app, "view.theme", json!({"mode": value}));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.response
|
||||
.labelled_by(label_response.id);
|
||||
let mut animations = app.ui.animations;
|
||||
let hint = lang.tr("Glide the selection outline to the cell you move to instead of jumping there");
|
||||
if ui.checkbox(&mut animations, lang.tr("Animations")).on_hover_text(hint).changed() {
|
||||
@@ -2533,7 +2554,7 @@ fn view(app: &mut SheetApp, ui: &mut Ui) {
|
||||
sep(ui);
|
||||
// Interface size (issue #230): scales the whole window on top of the desktop's scale.
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(lang.tr("Interface size")).font(theme::ui_font(11.5)).color(t.text_dim));
|
||||
let label = ui.label(egui::RichText::new(lang.tr("Interface size")).font(theme::ui_font(11.5)).color(t.text_dim));
|
||||
let current = app.ui.interface_scale;
|
||||
let text = |v: u16| if v == 100 { lang.tr("Auto").to_string() } else { format!("{v}%") };
|
||||
egui::ComboBox::from_id_salt("ui_interface_size")
|
||||
@@ -2545,22 +2566,26 @@ fn view(app: &mut SheetApp, ui: &mut Ui) {
|
||||
act(app, "view.interfaceScale", json!({"percent": v}));
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.response
|
||||
.labelled_by(label.id);
|
||||
});
|
||||
// Interface language: settings live in the View tab, like Excel's Options. The label follows
|
||||
// the current language so it stays readable; the choices are always shown in their own script.
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(lang.tr("Interface language")).font(theme::ui_font(11.5)).color(t.text_dim));
|
||||
egui::ComboBox::from_id_salt("ui_language").width(120.0).selected_text(egui::RichText::new(lang.name()).font(theme::ui_font(12.5))).show_ui(
|
||||
ui,
|
||||
|ui| {
|
||||
let label = ui.label(egui::RichText::new(lang.tr("Interface language")).font(theme::ui_font(11.5)).color(t.text_dim));
|
||||
egui::ComboBox::from_id_salt("ui_language")
|
||||
.width(120.0)
|
||||
.selected_text(egui::RichText::new(lang.name()).font(theme::ui_font(12.5)))
|
||||
.show_ui(ui, |ui| {
|
||||
for l in crate::i18n::Language::ALL {
|
||||
if ui.selectable_label(l == lang, l.name()).clicked() {
|
||||
act(app, "app.language.set", json!({"language": l.code()}));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
})
|
||||
.response
|
||||
.labelled_by(label.id);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2600,7 +2625,7 @@ fn table_design(app: &mut SheetApp, ui: &mut Ui) {
|
||||
};
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new("Table Name:").small());
|
||||
let field = egui::Id::new(("gridcraft.table_name", &tname));
|
||||
let field = egui::Id::unique(("gridcraft.table_name", &tname));
|
||||
if let Some(name) = crate::widgets::committed_text(ui, field, &tname, Some(120.0))
|
||||
&& name != tname
|
||||
{
|
||||
|
||||
@@ -79,7 +79,9 @@ fn dragged_share(x: f32, bar: (f32, f32), right: f32) -> f32 {
|
||||
/// or less of the bar, as in Excel, for the workbook `uid`.
|
||||
fn tab_split_handle(app: &mut SheetApp, ui: &mut Ui, split: Rect, bar: (f32, f32), right: f32, uid: u64, t: &Tokens) {
|
||||
app.grid.tab_split = Some(split);
|
||||
let resp = ui.interact(split, ui.id().with("tab_split"), Sense::drag());
|
||||
let resp = ui.interact(split, ui.scope_id().with("tab_split"), Sense::drag());
|
||||
let name = app.ui.language.tr("Split tabs");
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, name));
|
||||
if resp.hovered() || resp.dragged() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
|
||||
}
|
||||
@@ -110,7 +112,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
|
||||
app.run_or_alert("sheet.next", json!({}));
|
||||
}
|
||||
let all = crate::widgets::icon_button(ui, Icon::SheetList, t.text_dim, app.ui.language.tr("All sheets"), vec2(24.0, 24.0));
|
||||
all.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, app.ui.language.tr("All sheets")));
|
||||
all.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, app.ui.language.tr("All sheets")));
|
||||
let items = app.session.active().map(|d| navigator_items(&d.wb.sheets)).unwrap_or_default();
|
||||
let active = app.session.active().map(|d| d.wb.active_sheet.to_string()).unwrap_or_default();
|
||||
let rows: Vec<(&str, String)> = items.iter().map(|(i, name)| (i.as_str(), name.clone())).collect();
|
||||
@@ -145,8 +147,8 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
|
||||
};
|
||||
// Reveal the selected sheet after navigation or layout changes, but
|
||||
// allow browsing other tabs without snapping back on every frame.
|
||||
let reveal_key = egui::Id::new((d.uid, d.wb.active_sheet, &sheets, tabs_width.to_bits()));
|
||||
let reveal_id = ui.id().with("worksheet_tabs_reveal");
|
||||
let reveal_key = egui::Id::unique((d.uid, d.wb.active_sheet, &sheets, tabs_width.to_bits()));
|
||||
let reveal_id = ui.scope_id().with("worksheet_tabs_reveal");
|
||||
let reveal_active = ui.ctx().data_mut(|data| {
|
||||
let changed = data.get_temp::<egui::Id>(reveal_id) != Some(reveal_key);
|
||||
data.insert_temp(reveal_id, reveal_key);
|
||||
@@ -206,8 +208,9 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
|
||||
if renaming {
|
||||
let mut text = app.grid.rename_text.clone();
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(6.0, 5.0))));
|
||||
let ed =
|
||||
child.add(egui::TextEdit::singleline(&mut text).font(theme::ui_font(13.0)).desired_width(r.width() - 12.0));
|
||||
let ed = child
|
||||
.add(egui::TextEdit::singleline(&mut text).font(theme::ui_font(13.0)).desired_width(r.width() - 12.0))
|
||||
.accessible_name(app.ui.language.tr("Sheet Name"));
|
||||
app.grid.rename_text = text.clone();
|
||||
if rename_needs_focus(ed.has_focus(), ed.lost_focus()) {
|
||||
ed.request_focus();
|
||||
@@ -322,7 +325,7 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) {
|
||||
|ui| {
|
||||
// A right-click anywhere on the status bar opens the statistics menu, the text included, so its labels are not
|
||||
// selectable. The zoom and view controls sense clicks and handle their own.
|
||||
let background = ui.interact(ui.max_rect(), egui::Id::new("gridcraft.status_bar"), Sense::click());
|
||||
let background = ui.interact(ui.max_rect(), egui::Id::unique("gridcraft.status_bar"), Sense::click());
|
||||
background.context_menu(|ui| status_bar_menu(app, ui));
|
||||
let text = |ui: &mut Ui, rich: egui::RichText| {
|
||||
ui.add(egui::Label::new(rich).selectable(false));
|
||||
@@ -379,6 +382,7 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) {
|
||||
}
|
||||
let mut z = zoom as f32;
|
||||
let slider = ui.add(egui::Slider::new(&mut z, 10.0..=400.0).show_value(false).logarithmic(true));
|
||||
ui.ctx().accesskit_node_builder(slider.id, |node| node.set_label(app.ui.language.tr("Zoom")));
|
||||
if slider.changed() {
|
||||
let _ = app.session.run("view.zoom", json!({"percent": z.round()}));
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ pub struct EditState {
|
||||
}
|
||||
|
||||
pub(crate) fn editor_id() -> egui::Id {
|
||||
egui::Id::new("gridcraft.text_box_editor")
|
||||
egui::Id::unique("gridcraft.text_box_editor")
|
||||
}
|
||||
|
||||
impl SheetApp {
|
||||
@@ -147,20 +147,22 @@ pub(crate) fn show(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &gridcr
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().id_salt(id).max_rect(rect));
|
||||
child.set_clip_rect(rect);
|
||||
egui::ScrollArea::vertical().id_salt(id).max_height(rect.height()).auto_shrink([false, false]).show(&mut child, |ui| {
|
||||
let response = ui.add(
|
||||
TextEdit::multiline(&mut ed.text)
|
||||
.id(id)
|
||||
.font(FontId::new(14.0 * geo.z, egui::FontFamily::Name(theme::CELL.into())))
|
||||
.text_color(Color32::BLACK)
|
||||
.frame(egui::Frame::NONE)
|
||||
.margin(egui::Margin::ZERO)
|
||||
.desired_width(rect.width())
|
||||
.desired_rows(1)
|
||||
// shape.setText's limit (a cell's).
|
||||
.char_limit(32_767)
|
||||
.min_size(rect.size())
|
||||
.lock_focus(true),
|
||||
);
|
||||
let response = ui
|
||||
.add(
|
||||
TextEdit::multiline(&mut ed.text)
|
||||
.id(id)
|
||||
.font(FontId::new(14.0 * geo.z, egui::FontFamily::Name(theme::CELL.into())))
|
||||
.text_color(Color32::BLACK)
|
||||
.frame(egui::Frame::NONE)
|
||||
.margin(egui::Margin::ZERO)
|
||||
.desired_width(rect.width())
|
||||
.desired_rows(1)
|
||||
// shape.setText's limit (a cell's).
|
||||
.char_limit(32_767)
|
||||
.min_size(rect.size())
|
||||
.lock_focus(true),
|
||||
)
|
||||
.accessible_name(app.ui.language.tr("Text Box"));
|
||||
if ed.request_focus {
|
||||
response.request_focus();
|
||||
ed.request_focus = false;
|
||||
|
||||
+340
-90
@@ -1,9 +1,13 @@
|
||||
//! Design tokens and fonts.
|
||||
//!
|
||||
//! Colours are our own, tuned to read like a modern desktop spreadsheet. Fonts come from the
|
||||
//! operating system at runtime (nothing bundled): the UI uses the platform UI font, cells use the
|
||||
//! best available match for the workbook font (Calibri/Carlito/Aptos/Arial…) with egui's built-in
|
||||
//! fonts as the last resort.
|
||||
//! Colours are our own, tuned to read like a modern desktop spreadsheet. Fonts come from egui's
|
||||
//! bundled faces and the operating system at runtime: the UI uses the platform UI font, cells use
|
||||
//! the best available match for the workbook font (Calibri/Carlito/Aptos/Arial…), and egui's
|
||||
//! bundled Ubuntu/Hack/egui-icons and monochrome emoji faces are the last resort. A build with
|
||||
//! `CRAFT_FONTS_DIR` also embeds the permitted OFL craft-fonts faces (barred Adobe/Source/Noto CJK
|
||||
//! families never enter it, and a web build keeps only its explicit allowlist). Native builds opt
|
||||
//! into craft-fonts' OFL LXGW Chinese face, so a host without a Han system face still draws Chinese
|
||||
//! interface text; no font file is stored in this repo.
|
||||
//!
|
||||
//! Chrome colours belong in [`Tokens`], each with a light and a dark value; colours
|
||||
//! drawn on the sheet keep their light value in both themes, because the sheet stays white. The
|
||||
@@ -12,8 +16,27 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use egui::epaint::text::Blob;
|
||||
use egui::{Color32, FontData, FontDefinitions, FontFamily, FontId, Stroke, Visuals};
|
||||
|
||||
/// A font from the optional craft-fonts build input (`CRAFT_FONTS_DIR` at build time; see
|
||||
/// <https://github.com/storytold/craft-fonts> and its `docs/integration.md`). The slice is empty
|
||||
/// when the app is built without the checkout; nothing is downloaded and no font is in this repo.
|
||||
#[derive(Debug)]
|
||||
pub struct CraftFont {
|
||||
pub family: &'static str,
|
||||
pub style: &'static str,
|
||||
/// ISO 15924 scripts the family is intended for, from the craft-fonts manifest.
|
||||
pub scripts: &'static [&'static str],
|
||||
pub bytes: &'static [u8],
|
||||
}
|
||||
|
||||
mod craft_fonts {
|
||||
use super::CraftFont;
|
||||
include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs"));
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Tokens {
|
||||
pub dark: bool,
|
||||
@@ -364,43 +387,40 @@ pub(crate) fn font_parses(bytes: &[u8], index: u32) -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn try_load(paths: &[(&str, u32)]) -> Option<FontData> {
|
||||
for (p, index) in paths {
|
||||
if let Some(bytes) = font_file(p, *index) {
|
||||
let mut fd = FontData::from_static(bytes);
|
||||
fd.index = *index;
|
||||
return Some(fd);
|
||||
if let Some(blob) = font_file(p, *index) {
|
||||
return Some(FontData::from_blob(blob, *index));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The interface and script fallback font files, each read once per run and kept for the rest of
|
||||
/// it. egui copies an owned font's bytes into its text engine and keeps the original too
|
||||
/// (`epaint`'s `blob_from_font_data`), so every one of these files was in memory twice, about
|
||||
/// 100 MB on a Mac; bytes it borrows for `'static` it shares instead. These faces are loaded by
|
||||
/// every font rebuild for as long as the app runs, so keeping them costs nothing, and rebuilds no
|
||||
/// longer read them from disk again. Only a file that parses is kept: a missing or broken one is
|
||||
/// looked for again next time.
|
||||
/// The interface and script fallback font files, each read once per run and kept as a shared
|
||||
/// `Blob` for the rest of it. egui shares the blob instead of copying the file into its text
|
||||
/// engine, because every one of these files is loaded by every font rebuild for as long as the
|
||||
/// app runs. Only a file that parses is kept: a missing or broken one is looked for again next
|
||||
/// time.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn font_file(path: &str, index: u32) -> Option<&'static [u8]> {
|
||||
static FILES: std::sync::Mutex<std::collections::BTreeMap<String, &'static [u8]>> = std::sync::Mutex::new(std::collections::BTreeMap::new());
|
||||
fn font_file(path: &str, index: u32) -> Option<Blob> {
|
||||
static FILES: std::sync::Mutex<std::collections::BTreeMap<String, Blob>> = std::sync::Mutex::new(std::collections::BTreeMap::new());
|
||||
let mut files = FILES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(&bytes) = files.get(path) {
|
||||
return font_parses(bytes, index).then_some(bytes);
|
||||
if let Some(blob) = files.get(path) {
|
||||
let bytes: &[u8] = (**blob).as_ref();
|
||||
return font_parses(bytes, index).then(|| std::sync::Arc::clone(blob));
|
||||
}
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
if !font_parses(&bytes, index) {
|
||||
log::warn!("skipping font {path} (face {index}): not a font file egui can read");
|
||||
return None;
|
||||
}
|
||||
let bytes: &'static [u8] = Box::leak(bytes.into_boxed_slice());
|
||||
files.insert(path.to_string(), bytes);
|
||||
Some(bytes)
|
||||
let blob: Blob = std::sync::Arc::new(bytes);
|
||||
files.insert(path.to_string(), blob.clone());
|
||||
Some(blob)
|
||||
}
|
||||
|
||||
/// Last line of defence before [`FontDefinitions`] reach egui: drops every face epaint could not
|
||||
/// parse (and its key from every family), so a bad font costs a fallback, never a crash.
|
||||
pub(crate) fn drop_unparseable_fonts(fonts: &mut FontDefinitions) {
|
||||
let bad: Vec<String> = fonts.font_data.iter().filter(|(_, fd)| !font_parses(&fd.font, fd.index)).map(|(k, _)| k.clone()).collect();
|
||||
let bad: Vec<String> = fonts.font_data.iter().filter(|(_, fd)| !font_parses(fd.bytes(), fd.index)).map(|(k, _)| k.clone()).collect();
|
||||
for key in &bad {
|
||||
log::warn!("dropping font {key:?}: egui can't parse it");
|
||||
fonts.font_data.remove(key);
|
||||
@@ -412,6 +432,90 @@ pub(crate) fn drop_unparseable_fonts(fonts: &mut FontDefinitions) {
|
||||
}
|
||||
}
|
||||
|
||||
/// egui's bundled monospace face, used as the last-resort symbol fallback (see `Hack-Regular.ttf`
|
||||
/// in `epaint_default_fonts`). It is present whenever egui is built with `default_fonts`.
|
||||
const HACK: &str = "Hack";
|
||||
|
||||
/// The permitted craft-fonts faces with their `FontDefinitions` keys, in manifest order. Empty
|
||||
/// without a `CRAFT_FONTS_DIR` checkout at build time. The filter is repeated here so a stale
|
||||
/// generated table can never smuggle in a barred face or a web-build face outside the allowlist.
|
||||
fn craft_font_faces() -> Vec<(String, &'static CraftFont)> {
|
||||
let wasm = cfg!(target_arch = "wasm32");
|
||||
craft_fonts::CRAFT_FONTS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, font)| !crate::craft_fonts_policy::barred(font.family))
|
||||
.filter(|(_, font)| !wasm || crate::craft_fonts_policy::web_allowed(font.family, font.style))
|
||||
.map(|(i, font)| (format!("craft-{i}-{}-{}", font.family, font.style), font))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Adds the optional craft-fonts faces to `fonts` and returns their keys.
|
||||
fn add_craft_fonts(fonts: &mut FontDefinitions) -> Vec<String> {
|
||||
let mut keys = Vec::new();
|
||||
for (key, font) in craft_font_faces() {
|
||||
fonts.font_data.insert(key.clone(), Arc::new(FontData::from_static(font.bytes)));
|
||||
keys.push(key);
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
/// Whether the optional craft-fonts input was embedded in this build.
|
||||
pub fn craft_fonts_loaded() -> bool {
|
||||
!craft_fonts::CRAFT_FONTS.is_empty()
|
||||
}
|
||||
|
||||
/// Whether the native-only Simplified Chinese craft face (LXGW WenKai Lite) was embedded. A web
|
||||
/// build or an older craft-fonts checkout without `fonts/simplified-chinese-manifest.txt` has none.
|
||||
pub fn chinese_font_loaded() -> bool {
|
||||
craft_font_faces().iter().any(|(_, f)| f.family == "LXGW WenKai Lite" && f.style == "Regular")
|
||||
}
|
||||
|
||||
/// Whether this build was made with `CRAFT_FONTS_REQUIRED=1`. Acceptance tests must fail rather
|
||||
/// than skip when this is true and the faces are absent.
|
||||
pub fn craft_fonts_required() -> bool {
|
||||
craft_fonts::CRAFT_FONTS_REQUIRED
|
||||
}
|
||||
|
||||
/// A bundled-face character with the same meaning as `c`, for interface chrome whose exact symbol
|
||||
/// no bundled face has (`✕`, `✓`, `⌃`). Document text still needs a real glyph; this is only a
|
||||
/// display fallback and never changes the characters a command inserts or stores.
|
||||
pub fn symbol_fallback(c: char) -> Option<char> {
|
||||
match c {
|
||||
'✕' => Some('×'),
|
||||
'✓' => Some('✔'),
|
||||
'⌃' => Some('^'),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` with every character the current font set cannot draw replaced by its semantic fallback
|
||||
/// (see [`symbol_fallback`]). Used by chrome tooltips so a host without symbol faces never paints
|
||||
/// a tofu box; it is not applied to workbook text.
|
||||
pub fn available_text(ui: &egui::Ui, text: &str) -> String {
|
||||
let font = ui_font(13.0);
|
||||
text.chars().map(|c| if ui.ctx().fonts_mut(|f| f.has_glyph(&font, c)) { c } else { symbol_fallback(c).unwrap_or(c) }).collect()
|
||||
}
|
||||
|
||||
/// The family, style and scripts of every embedded craft-fonts face (empty without the input).
|
||||
pub fn craft_font_scripts() -> impl Iterator<Item = (&'static str, &'static str, &'static [&'static str])> {
|
||||
craft_font_faces().into_iter().map(|(_, f)| (f.family, f.style, f.scripts))
|
||||
}
|
||||
|
||||
/// Whether one embedded craft-fonts face maps every non-whitespace character in `text`. Tests use
|
||||
/// this to tell "the pinned fixture covers this script" from "this host has no face for it",
|
||||
/// without trusting a manifest script tag alone.
|
||||
pub fn craft_fonts_cover(text: &str) -> bool {
|
||||
craft_font_faces().iter().any(|(_, f)| {
|
||||
use skrifa::MetadataProvider;
|
||||
let Ok(font) = skrifa::FontRef::from_index(f.bytes, 0) else {
|
||||
return false;
|
||||
};
|
||||
let map = font.charmap();
|
||||
text.chars().filter(|c| !c.is_whitespace()).all(|c| map.map(c).is_some())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn try_load(_paths: &[(&str, u32)]) -> Option<FontData> {
|
||||
None
|
||||
@@ -572,63 +676,57 @@ pub fn font_definitions_for_language(language: crate::i18n::Language) -> FontDef
|
||||
font_definitions_with(language, &[])
|
||||
}
|
||||
|
||||
/// The bundled faces plus the pinned craft-fonts faces, with no system-font probing or installed
|
||||
/// workbook-font binding.
|
||||
///
|
||||
/// Integration tests use this through their shared harness, so a minimal CI image and a developer
|
||||
/// machine see the same font set. Craft-fonts remains a pinned build input rather than a repository
|
||||
/// asset; without it only egui's bundled faces are available, which is the documented no-font mode.
|
||||
pub fn pinned_font_definitions() -> FontDefinitions {
|
||||
font_definitions_impl(crate::i18n::Language::En, &[], false)
|
||||
}
|
||||
|
||||
/// [`font_definitions_for_language`] that also binds the workbook fonts in `used` that are
|
||||
/// installed, so cells set in them render with them; the rest fall back to the roles. The bound
|
||||
/// families are recorded in [`crate::fonts`] so [`cell_family`] only names a family egui has.
|
||||
pub fn font_definitions_with(language: crate::i18n::Language, used: &[String]) -> FontDefinitions {
|
||||
font_definitions_impl(language, used, true)
|
||||
}
|
||||
|
||||
fn font_definitions_impl(language: crate::i18n::Language, used: &[String], system: bool) -> FontDefinitions {
|
||||
let mut fonts = FontDefinitions::default();
|
||||
let base_prop: Vec<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
|
||||
let base_mono: Vec<String> = fonts.families.get(&FontFamily::Monospace).cloned().unwrap_or_default();
|
||||
let mut bound: Vec<String> = Vec::new();
|
||||
// The non-Latin fallback faces have to sit at the end of each chain: a Latin font owns Latin,
|
||||
// and epaint picks the first face that has the glyph, so they only catch their own scripts.
|
||||
let fallback = load_fallback(&mut fonts, HanOrder::of(language));
|
||||
let fallback = if system { load_fallback(&mut fonts, HanOrder::of(language)) } else { Vec::new() };
|
||||
let hack = fonts.font_data.contains_key(HACK).then_some(HACK);
|
||||
for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] {
|
||||
let paths = candidates(role);
|
||||
let refs: Vec<(&str, u32)> = paths.iter().map(|(p, i)| (p.as_str(), *i)).collect();
|
||||
let mut chain: Vec<String> = Vec::new();
|
||||
if let Some(mut fd) = try_load(&refs) {
|
||||
// Helvetica Neue Bold's own TrueType hints close Cyrillic е into ө at 13 pt on a Retina
|
||||
// screen (#1184). Core Text never runs them, so this draws it the way macOS does.
|
||||
if cfg!(target_os = "macos") && role == UI_BOLD {
|
||||
fd.tweak.hinting = Some(false);
|
||||
}
|
||||
let key = format!("sys-{role}");
|
||||
fonts.font_data.insert(key.clone(), Arc::new(fd));
|
||||
chain.push(key);
|
||||
} else if role == UI_BOLD {
|
||||
chain.push(format!("sys-{UI}"));
|
||||
} else if role.starts_with("cell-") {
|
||||
chain.push(format!("sys-{CELL}"));
|
||||
}
|
||||
chain.extend(if role == MONO { base_mono.clone() } else { base_prop.clone() });
|
||||
chain.extend(fallback.iter().cloned());
|
||||
chain.retain(|k| fonts.font_data.contains_key(k));
|
||||
let chain = build_role_chain(&mut fonts, role, &base_prop, &base_mono, &fallback, hack, system);
|
||||
fonts.families.insert(FontFamily::Name(role.into()), chain);
|
||||
bound.push(role.to_string());
|
||||
}
|
||||
// Installed workbook fonts, so a cell's chosen font actually renders in it.
|
||||
for family in crate::fonts::installed_among(used) {
|
||||
bound.extend(crate::fonts::bind_family(&mut fonts, &family));
|
||||
if system {
|
||||
for family in crate::fonts::installed_among(used) {
|
||||
bound.extend(crate::fonts::bind_family(&mut fonts, &family));
|
||||
}
|
||||
}
|
||||
// Interface text (formula bar, name box, dialogs) falls back to the cell font before the
|
||||
// script faces: whatever a cell can draw, the formula bar can draw too, even where no script
|
||||
// fallback face was found (#349: macOS UI font without Hebrew, cells in Arial with it).
|
||||
let cell_key = format!("sys-{CELL}");
|
||||
if fonts.font_data.contains_key(&cell_key) {
|
||||
for role in [UI, UI_BOLD, SERIF, MONO] {
|
||||
if let Some(chain) = fonts.families.get_mut(&FontFamily::Name(role.into()))
|
||||
&& !chain.contains(&cell_key)
|
||||
{
|
||||
let at = chain.iter().position(|k| fallback.contains(k)).unwrap_or(chain.len());
|
||||
chain.insert(at, cell_key.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
bind_cell_fallback(&mut fonts, &fallback);
|
||||
// Default proportional text in widgets uses the UI font.
|
||||
if let Some(ui) = fonts.families.get(&FontFamily::Name(UI.into())).cloned() {
|
||||
fonts.families.insert(FontFamily::Proportional, ui);
|
||||
}
|
||||
// The optional craft-fonts faces go at the very end of every family: a system or bundled face
|
||||
// that has the script keeps its glyph shapes, while the permitted OFL faces cover scripts and
|
||||
// symbols the host image lacks (Japanese, Korean, Thai, Arabic, Georgian and the UI symbols).
|
||||
// Chinese comes from the native-only OFL LXGW face when embedded; a system Han face still
|
||||
// leads, and the barred Noto CJK collection never enters.
|
||||
append_craft_faces(&mut fonts);
|
||||
drop_unparseable_fonts(&mut fonts);
|
||||
// Only families egui still has data for count as bound (a dropped face can empty a chain).
|
||||
bound.retain(|f| fonts.families.get(&FontFamily::Name(f.as_str().into())).is_some_and(|c| !c.is_empty()));
|
||||
@@ -636,6 +734,84 @@ pub fn font_definitions_with(language: crate::i18n::Language, used: &[String]) -
|
||||
fonts
|
||||
}
|
||||
|
||||
/// Builds one role family's chain: the loaded system face (or its role fallback), egui's bundled
|
||||
/// proportional/monospace base, Hack's symbols, then the script fallbacks. Keys without font data
|
||||
/// are dropped, as before.
|
||||
fn build_role_chain(
|
||||
fonts: &mut FontDefinitions,
|
||||
role: &str,
|
||||
base_prop: &[String],
|
||||
base_mono: &[String],
|
||||
fallback: &[String],
|
||||
hack: Option<&str>,
|
||||
system: bool,
|
||||
) -> Vec<String> {
|
||||
let paths = candidates(role);
|
||||
let refs: Vec<(&str, u32)> = paths.iter().map(|(p, i)| (p.as_str(), *i)).collect();
|
||||
let mut chain: Vec<String> = Vec::new();
|
||||
let loaded = if system { try_load(&refs) } else { None };
|
||||
if let Some(mut fd) = loaded {
|
||||
// Helvetica Neue Bold's own TrueType hints close Cyrillic е into ө at 13 pt on a Retina
|
||||
// screen (#1184). Core Text never runs them, so this draws it the way macOS does.
|
||||
if cfg!(target_os = "macos") && role == UI_BOLD {
|
||||
fd.tweak.hinting = Some(false);
|
||||
}
|
||||
let key = format!("sys-{role}");
|
||||
fonts.font_data.insert(key.clone(), Arc::new(fd));
|
||||
chain.push(key);
|
||||
} else if role == UI_BOLD {
|
||||
chain.push(format!("sys-{UI}"));
|
||||
} else if role.starts_with("cell-") {
|
||||
chain.push(format!("sys-{CELL}"));
|
||||
}
|
||||
chain.extend(if role == MONO { base_mono.to_vec() } else { base_prop.to_vec() });
|
||||
// egui bundles Hack, a monospace face rich in arrows, triangles and keyboard symbols, but
|
||||
// 0.37 only puts it in the Monospace family. The interface draws ↑, ▲, ▼, ▸, ▾ and ↳ as
|
||||
// text (filter menus, ribbon, keytips), so add it behind the UI/cell and emoji faces; it
|
||||
// only catches characters those do not have.
|
||||
if let Some(hack) = hack
|
||||
&& !chain.iter().any(|k| k.as_str() == hack)
|
||||
{
|
||||
chain.push(hack.to_string());
|
||||
}
|
||||
chain.extend(fallback.iter().cloned());
|
||||
chain.retain(|k| fonts.font_data.contains_key(k));
|
||||
chain
|
||||
}
|
||||
|
||||
/// Inserts the loaded cell face before the script fallbacks of the interface families, so the
|
||||
/// formula bar and dialogs can draw whatever a cell can (#349).
|
||||
fn bind_cell_fallback(fonts: &mut FontDefinitions, fallback: &[String]) {
|
||||
let cell_key = format!("sys-{CELL}");
|
||||
if !fonts.font_data.contains_key(&cell_key) {
|
||||
return;
|
||||
}
|
||||
for role in [UI, UI_BOLD, SERIF, MONO] {
|
||||
if let Some(chain) = fonts.families.get_mut(&FontFamily::Name(role.into()))
|
||||
&& !chain.contains(&cell_key)
|
||||
{
|
||||
let at = chain.iter().position(|k| fallback.contains(k)).unwrap_or(chain.len());
|
||||
chain.insert(at, cell_key.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends the permitted craft-fonts faces to every family, preserving the leading system or
|
||||
/// bundled face's glyph shapes.
|
||||
fn append_craft_faces(fonts: &mut FontDefinitions) {
|
||||
let craft = add_craft_fonts(fonts);
|
||||
if craft.is_empty() {
|
||||
return;
|
||||
}
|
||||
for chain in fonts.families.values_mut() {
|
||||
for key in &craft {
|
||||
if !chain.contains(key) {
|
||||
chain.push(key.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the system non-Latin fallback faces (one file per script family) and returns their keys so
|
||||
/// every family can carry them as fallback. egui's bundled fonts have no coverage beyond Latin, so
|
||||
/// without this a Thai, Hebrew, Japanese or Chinese workbook shows tofu boxes even though the text
|
||||
@@ -940,6 +1116,12 @@ pub fn apply(ctx: &egui::Context, dark: bool) {
|
||||
v.widgets.hovered.weak_bg_fill = t.hover;
|
||||
v.widgets.active.weak_bg_fill = t.pressed;
|
||||
v.widgets.inactive.bg_stroke = Stroke::new(1.0, t.input_border);
|
||||
// 0.37 raises the default widget corner radii; GridCraft keeps its 0.36 shape (#8617).
|
||||
v.widgets.noninteractive.corner_radius = egui::CornerRadius::same(2);
|
||||
v.widgets.inactive.corner_radius = egui::CornerRadius::same(2);
|
||||
v.widgets.hovered.corner_radius = egui::CornerRadius::same(3);
|
||||
v.widgets.active.corner_radius = egui::CornerRadius::same(2);
|
||||
v.widgets.open.corner_radius = egui::CornerRadius::same(2);
|
||||
v.window_corner_radius = egui::CornerRadius::same(10);
|
||||
v.menu_corner_radius = egui::CornerRadius::same(8);
|
||||
v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 16, spread: 0, color: t.shadow };
|
||||
@@ -1129,13 +1311,83 @@ mod tests {
|
||||
}
|
||||
for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] {
|
||||
let chain = fonts.families.get(&FontFamily::Name(role.into())).map(Vec::as_slice).unwrap_or_default();
|
||||
assert!(chain.len() >= fb_keys.len(), "{role} chain too short for its fallback");
|
||||
let tail = &chain[chain.len() - fb_keys.len()..];
|
||||
assert_eq!(tail, fb_keys.as_slice(), "{role} must end its chain with the fallback faces");
|
||||
// The optional craft-fonts faces follow the system fallbacks at the very end.
|
||||
let end = chain.iter().rposition(|k| !k.starts_with("craft-")).map_or(0, |i| i + 1);
|
||||
assert!(end >= fb_keys.len(), "{role} chain too short for its fallback");
|
||||
assert_eq!(&chain[end - fb_keys.len()..end], fb_keys.as_slice(), "{role} must end its non-craft chain with the fallback faces");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn craft_fonts_are_the_last_fallback_in_every_family() {
|
||||
// Ordering: app/bundled faces, Hack/emoji, system script fallbacks, permitted craft faces.
|
||||
// A craft face must never displace a system face that already covers a glyph.
|
||||
let fonts = font_definitions_for_language(Language::En);
|
||||
let expected: Vec<String> = craft_font_faces().into_iter().map(|(key, _)| key).collect();
|
||||
if expected.is_empty() {
|
||||
return; // optional input absent
|
||||
}
|
||||
for (family, chain) in &fonts.families {
|
||||
let first = chain.iter().position(|k| k.starts_with("craft-")).unwrap_or(chain.len());
|
||||
assert_eq!(&chain[first..], expected.as_slice(), "{family:?} must end with exactly the permitted craft faces in manifest order");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_required_native_build_embeds_the_chinese_face() {
|
||||
if cfg!(target_arch = "wasm32") || !craft_fonts_required() {
|
||||
return;
|
||||
}
|
||||
assert!(
|
||||
chinese_font_loaded(),
|
||||
"CRAFT_FONTS_REQUIRED=1 native builds must embed the LXGW Chinese face from fonts/simplified-chinese-manifest.txt"
|
||||
);
|
||||
let face = craft_font_scripts().find(|(f, s, _)| *f == "LXGW WenKai Lite" && *s == "Regular");
|
||||
assert_eq!(face.map(|(_, _, scripts)| scripts), Some(["Hani", "Hans"].as_slice()), "the Chinese manifest's script tags are asserted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn barred_faces_never_enter_the_font_set() {
|
||||
for (family, _, _) in craft_font_scripts() {
|
||||
assert!(!family.contains("CJK"), "the barred Noto CJK collection must never be embedded: {family}");
|
||||
assert!(!family.starts_with("Source ") && !family.starts_with("Adobe "), "a barred Adobe/Source face was embedded: {family}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_required_craft_faces_keep_their_exact_scripts() {
|
||||
if !craft_fonts_loaded() {
|
||||
assert!(!craft_fonts_required(), "CRAFT_FONTS_REQUIRED=1 but no craft-fonts faces were embedded");
|
||||
return;
|
||||
}
|
||||
let faces: Vec<_> = craft_font_scripts().collect();
|
||||
for (family, style, scripts) in [
|
||||
("BIZ UDPGothic", "Regular", ["Jpan", "Latn"].as_slice()),
|
||||
("BIZ UDPGothic", "Bold", ["Jpan", "Latn"].as_slice()),
|
||||
("NanumGothic", "Regular", ["Hang", "Kore"].as_slice()),
|
||||
("Noto Sans Symbols", "Regular", ["Zsym"].as_slice()),
|
||||
("Noto Sans Symbols 2", "Regular", ["Zsym"].as_slice()),
|
||||
] {
|
||||
assert!(
|
||||
faces.iter().any(|(f, s, declared)| *f == family && *s == style && *declared == scripts),
|
||||
"{family} {style} with {scripts:?} must be embedded; got {faces:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn available_text_maps_unbundled_chrome_symbols() {
|
||||
// No bundled face has ⌃ ✓ ✕; chrome text must show ^ ✔ × instead of tofu. Document text is
|
||||
// never rewritten: this helper is only used for interface tooltips.
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_fonts(FontDefinitions::default());
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
assert_eq!(available_text(ui, "Show Formulas (⌃`) ✓ ✕"), "Show Formulas (^`) ✔ ×");
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_families_fall_back_to_the_cell_font() {
|
||||
// Structural (#349): the formula bar, name box and dialogs draw every script the cells can,
|
||||
@@ -1209,7 +1461,7 @@ mod tests {
|
||||
fallback_faces(HanOrder::of(language)).into_iter().filter_map(|(k, _)| fonts.font_data.get(k)).collect();
|
||||
for (i, a) in present.iter().enumerate() {
|
||||
for b in present.iter().skip(i + 1) {
|
||||
assert!(a.font != b.font, "the same font file is loaded for two fallback scripts");
|
||||
assert!(!std::sync::Arc::ptr_eq(&a.font, &b.font), "the same font file is loaded for two fallback scripts");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1217,16 +1469,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn fallback_covers_its_script() {
|
||||
// Real render check, per script: the proportional family (what cells and the UI use) must
|
||||
// resolve a sample string to a real glyph, not the tofu replacement char. Skips a script
|
||||
// whose face is absent (a minimal machine) and fails if a face is present but not wired in.
|
||||
let cases: [(&str, Vec<(String, u32)>, &str); 8] = [
|
||||
// Real glyph check, per script: the proportional family (what cells and the UI use) must
|
||||
// have a glyph for every non-space character in the sample. A script runs when a system
|
||||
// face or the optional craft-fonts input provides one; the printed skip is a recorded
|
||||
// gate, and a present face that is not wired in fails.
|
||||
let cases: [(&str, Vec<(String, u32)>, &str); 10] = [
|
||||
("sys-cjk-han-ja", cjk_han_japanese(), "カテゴリ"),
|
||||
// Chinese uses a system Han face or the native-only permitted LXGW craft face; the
|
||||
// barred Noto CJK collection never supplies it.
|
||||
("sys-cjk-han-zh", cjk_han_simplified(), "页面布局 客户端配置"),
|
||||
("sys-cjk-hangul", cjk_hangul(), "한글"),
|
||||
("sys-thai", thai(), "ทดสอบ"),
|
||||
("sys-lao", lao(), "ພາສາລາວ"),
|
||||
("sys-hebrew", hebrew(), "עברית"),
|
||||
("craft-noto-sans-arabic", Vec::new(), "مرحبا"),
|
||||
("craft-noto-sans-georgian", Vec::new(), "ქართული"),
|
||||
("ui", candidates(UI), "Русский"),
|
||||
// Czech interface labels: every Czech accented letter, in both cases.
|
||||
("ui", candidates(UI), "Příliš žluťoučký kůň úpěl ďábelské ódy ŘŠČŽÝÁÍÉÚŮĚŤĎŇÓ"),
|
||||
@@ -1236,26 +1493,19 @@ mod tests {
|
||||
ctx.set_fonts(font_definitions_for_language(language));
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |_ui| {});
|
||||
out.textures_delta.clear(); // nothing consumes the atlas in a headless test
|
||||
// `has_glyphs` is a false negative when the face that owns the script is also the face
|
||||
// that draws the replacement glyph (DejaVu Sans as the UI font owns Hebrew), so compare
|
||||
// the laid out glyphs with the tofu glyph itself instead.
|
||||
for family in [FontFamily::Proportional, FontFamily::Name(CELL.into())] {
|
||||
let font = FontId::new(14.0, family);
|
||||
let glyphs = |text: &str| {
|
||||
let galley = ctx.fonts_mut(|f| f.layout_no_wrap(text.to_owned(), font.clone(), Color32::BLACK));
|
||||
galley.rows.iter().flat_map(|r| r.glyphs.iter().filter(|g| g.chr != ' ').map(|g| g.uv_rect)).collect::<Vec<_>>()
|
||||
};
|
||||
let tofu = glyphs("\u{25FB}");
|
||||
let font = FontId::new(14.0, family.clone());
|
||||
for (key, cands, sample) in &cases {
|
||||
if !cands.iter().any(|(p, _)| std::path::Path::new(p).exists()) {
|
||||
continue; // no face for this script on this machine; nothing to assert
|
||||
// A script runs when a system face or the optional craft-fonts input provides
|
||||
// one. The skip is printed: a missing fixture is a recorded gate, not a pass.
|
||||
let system = cands.iter().any(|(p, _)| std::path::Path::new(p).exists());
|
||||
if !system && !craft_fonts_cover(sample) {
|
||||
eprintln!("skipped {key}: no system face and no craft-fonts face for {sample:?}");
|
||||
continue;
|
||||
}
|
||||
let drawn = glyphs(sample);
|
||||
assert!(
|
||||
!drawn.is_empty() && drawn.iter().all(|g| !tofu.contains(g)),
|
||||
"{key} is present but {sample:?} still renders as tofu ({language:?}, {:?})",
|
||||
font.family
|
||||
);
|
||||
let missing: String =
|
||||
sample.chars().filter(|c| !c.is_whitespace()).filter(|&c| !ctx.fonts_mut(|f| f.has_glyph(&font, c))).collect();
|
||||
assert!(missing.is_empty(), "{key} is present but {sample:?} has no glyph for {missing:?} ({language:?}, {family:?})");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1281,7 +1531,7 @@ mod tests {
|
||||
fn unparseable_font_files_are_rejected_not_fatal() {
|
||||
// #226: epaint panics in `set_fonts` on a face it can't parse ("An offset was out of
|
||||
// bounds"). Garbage, a truncated real font and a bad collection index must all be refused.
|
||||
let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.font.to_vec()).unwrap_or_default();
|
||||
let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.bytes().to_vec()).unwrap_or_default();
|
||||
assert!(font_parses(&real, 0), "egui's own font must parse");
|
||||
assert!(!font_parses(b"", 0));
|
||||
assert!(!font_parses(&[0xAB; 64], 0));
|
||||
@@ -1314,13 +1564,13 @@ mod tests {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let bad = dir.join("broken.ttf");
|
||||
let good = dir.join("good.ttf");
|
||||
let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.font.to_vec()).unwrap_or_default();
|
||||
let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.bytes().to_vec()).unwrap_or_default();
|
||||
let _ = std::fs::write(&bad, real.get(..40).unwrap_or_default());
|
||||
let _ = std::fs::write(&good, &real);
|
||||
let (bad_s, good_s) = (bad.to_string_lossy().into_owned(), good.to_string_lossy().into_owned());
|
||||
assert!(try_load(&[(bad_s.as_str(), 0)]).is_none());
|
||||
let loaded = try_load(&[(bad_s.as_str(), 0), (good_s.as_str(), 0)]);
|
||||
assert_eq!(loaded.map(|fd| fd.font.len()), Some(real.len()));
|
||||
assert_eq!(loaded.map(|fd| fd.bytes().len()), Some(real.len()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -1335,11 +1585,11 @@ mod tests {
|
||||
let first = try_load(&[(p.as_str(), 0)]).unwrap();
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
let second = try_load(&[(p.as_str(), 0)]).expect("kept from the first read");
|
||||
let (std::borrow::Cow::Borrowed(a), std::borrow::Cow::Borrowed(b)) = (&first.font, &second.font) else {
|
||||
panic!("interface font bytes must be borrowed, so egui shares them instead of copying");
|
||||
};
|
||||
assert!(std::ptr::eq(*a, *b));
|
||||
assert_eq!(*a, epaint_default_fonts::UBUNTU_LIGHT);
|
||||
assert!(
|
||||
std::sync::Arc::ptr_eq(&first.font, &second.font),
|
||||
"interface font bytes must be one shared blob, so egui shares them instead of copying"
|
||||
);
|
||||
assert_eq!(first.bytes(), epaint_default_fonts::UBUNTU_LIGHT);
|
||||
assert!(try_load(&[(p.as_str(), 3)]).is_none(), "a face the kept file doesn't have is still refused");
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@ const TOOLTIP_SHORTCUTS: &[(&str, &str, &str)] = &[
|
||||
("Insert Link", "⌘K", "Ctrl+K"),
|
||||
("Filter", "⌘⇧F", "Ctrl+Shift+L"),
|
||||
("Flash Fill", "⌘E", "Ctrl+E"),
|
||||
// The Control key, written the way each platform names it. macOS's ⌃ is supplied by its
|
||||
// system fonts; elsewhere the Windows spelling avoids a glyph no bundled face has.
|
||||
("Show Formulas", "⌃`", "Ctrl+`"),
|
||||
];
|
||||
|
||||
/// The shortcut a ribbon tooltip shows after `tip`, for this platform (`mac`).
|
||||
@@ -44,10 +47,12 @@ pub fn tooltip_shortcut(tip: &str, mac: bool) -> Option<&'static str> {
|
||||
fn tip_text(ui: &Ui, tip: &str) -> String {
|
||||
// A name shared with a two-line ribbon label (Czech "Automatický\nsoučet") reads as one line.
|
||||
let name = tr(ui, tip).replace('\n', " ");
|
||||
match tooltip_shortcut(tip, cfg!(target_os = "macos")) {
|
||||
let text = match tooltip_shortcut(tip, cfg!(target_os = "macos")) {
|
||||
Some(keys) => format!("{name} ({keys})"),
|
||||
None => name.to_string(),
|
||||
}
|
||||
None => name,
|
||||
};
|
||||
// A tooltip must not paint a tofu box on a host whose faces lack one of its symbols.
|
||||
theme::available_text(ui, &text)
|
||||
}
|
||||
|
||||
/// A flat square icon button with hover highlight and tooltip.
|
||||
@@ -63,6 +68,20 @@ pub fn icon_button(ui: &mut Ui, icon: Icon, color: Color32, tip: &str, size: egu
|
||||
resp.on_hover_text(tip_text(ui, tip))
|
||||
}
|
||||
|
||||
/// A button for a character the interface inserts (the Symbol gallery). When the current font set
|
||||
/// has no glyph for it, the code-drawn icon with the same meaning is painted instead of a tofu
|
||||
/// box. Only the button's appearance changes: clicking still inserts the exact requested character.
|
||||
pub fn glyph_button(ui: &mut Ui, ch: char, tip: &str) -> Response {
|
||||
let font = theme::ui_font(13.0);
|
||||
let has_glyph = ui.ctx().fonts_mut(|f| f.has_glyph(&font, ch));
|
||||
if !has_glyph && let Some(icon) = icons::fallback_for_symbol(ch) {
|
||||
return icon_button(ui, icon, Tokens::get(ui.ctx()).text, tip, vec2(24.0, 22.0));
|
||||
}
|
||||
// An unknown future character without a fallback icon still gets a readable label.
|
||||
let label = if has_glyph { ch.to_string() } else { format!("U+{:04X}", ch as u32) };
|
||||
ui.button(label).on_hover_text(tip_text(ui, tip))
|
||||
}
|
||||
|
||||
/// A toggle-able small button (e.g. Bold) showing a checked state.
|
||||
pub fn toggle_button(ui: &mut Ui, icon: Icon, on: bool, tip: &str) -> Response {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(26.0, 24.0), Sense::click());
|
||||
@@ -104,8 +123,8 @@ pub fn big_split_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str) -> (Res
|
||||
let split = rect.top() + 38.0;
|
||||
let main_rect = Rect::from_min_max(rect.min, pos2(rect.right(), split));
|
||||
let arrow_rect = Rect::from_min_max(pos2(rect.left(), split), rect.max);
|
||||
let main = ui.interact(main_rect, ui.id().with((label, "big-split-main")), Sense::click());
|
||||
let arrow = ui.interact(arrow_rect, ui.id().with((label, "big-split-arrow")), Sense::click());
|
||||
let main = ui.interact(main_rect, ui.scope_id().with((label, "big-split-main")), Sense::click());
|
||||
let arrow = ui.interact(arrow_rect, ui.scope_id().with((label, "big-split-arrow")), Sense::click());
|
||||
if main.hovered() || arrow.hovered() {
|
||||
ui.painter().rect_filled(rect, 5.0, t.hover);
|
||||
ui.painter().line_segment([pos2(rect.left() + 4.0, split), pos2(rect.right() - 4.0, split)], Stroke::new(1.0, t.separator));
|
||||
@@ -119,8 +138,8 @@ pub fn big_split_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str) -> (Res
|
||||
paint_big_label(ui, rect, &lines, true);
|
||||
let name = label.replace('\n', " ");
|
||||
let enabled = ui.is_enabled();
|
||||
main.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, &name));
|
||||
arrow.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, format!("{name} options")));
|
||||
main.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, &name));
|
||||
arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, format!("{name} options")));
|
||||
let tip = tip_text(ui, tip);
|
||||
(main.on_hover_text(tip.clone()), arrow.on_hover_text(tip))
|
||||
}
|
||||
@@ -194,8 +213,8 @@ pub fn split_button(ui: &mut Ui, icon: Icon, accent: Option<Color32>, tip: &str)
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(38.0, 24.0), Sense::hover());
|
||||
let main = Rect::from_min_size(rect.min, vec2(25.0, 24.0));
|
||||
let arrow = Rect::from_min_max(pos2(main.right(), rect.top()), rect.max);
|
||||
let m = ui.interact(main, ui.id().with((tip, "main")), Sense::click());
|
||||
let a = ui.interact(arrow, ui.id().with((tip, "arrow")), Sense::click());
|
||||
let m = ui.interact(main, ui.scope_id().with((tip, "main")), Sense::click());
|
||||
let a = ui.interact(arrow, ui.scope_id().with((tip, "arrow")), Sense::click());
|
||||
if m.hovered() || a.hovered() {
|
||||
ui.painter().rect_filled(rect, 4.0, t.hover);
|
||||
ui.painter().line_segment([pos2(main.right(), rect.top() + 3.0), pos2(main.right(), rect.bottom() - 3.0)], Stroke::new(1.0, t.separator));
|
||||
@@ -218,8 +237,12 @@ pub fn small_split_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str) -> (R
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(24.0 + tw + 14.0, 23.0), Sense::hover());
|
||||
let main = Rect::from_min_size(rect.min, vec2(24.0 + tw, rect.height()));
|
||||
let arrow = Rect::from_min_max(pos2(main.right(), rect.top()), rect.max);
|
||||
let m = ui.interact(main, ui.id().with((tip, "main")), Sense::click());
|
||||
let a = ui.interact(arrow, ui.id().with((tip, "arrow")), Sense::click());
|
||||
let m = ui.interact(main, ui.scope_id().with((tip, "main")), Sense::click());
|
||||
let a = ui.interact(arrow, ui.scope_id().with((tip, "arrow")), Sense::click());
|
||||
let name = label.replace('\n', " ");
|
||||
let enabled = ui.is_enabled();
|
||||
m.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, &name));
|
||||
a.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, format!("{name} options")));
|
||||
if m.hovered() || a.hovered() {
|
||||
ui.painter().rect_filled(rect, 4.0, t.hover);
|
||||
for (part, r) in [(&m, main), (&a, arrow)] {
|
||||
@@ -285,7 +308,7 @@ pub fn remember_color(recent: &mut Vec<String>, hex: &str) {
|
||||
}
|
||||
|
||||
fn recent_colors_id() -> egui::Id {
|
||||
egui::Id::new("gridcraft.recent_colors")
|
||||
egui::Id::unique("gridcraft.recent_colors")
|
||||
}
|
||||
|
||||
/// The More Colors… picker's state while it is open in a palette.
|
||||
@@ -320,7 +343,7 @@ fn palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str, more_colors:
|
||||
let mut picked = None;
|
||||
const ROWS: usize = 7; // 6 tint rows + 1 standard row
|
||||
const COLS: usize = 10;
|
||||
let pal_id = ui.id().with("__color_palette");
|
||||
let pal_id = ui.scope_id().with("__color_palette");
|
||||
let mut cur: Option<(usize, usize)> = ui.data_mut(|d| d.get_temp(pal_id)).unwrap_or(None);
|
||||
let more_id = pal_id.with("more");
|
||||
let mut more: Option<MoreColors> = ui.data_mut(|d| d.get_temp(more_id)).unwrap_or(None);
|
||||
@@ -401,7 +424,7 @@ fn palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str, more_colors:
|
||||
ui.label(egui::RichText::new("Theme Colors").small());
|
||||
let order = [0usize, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let tints = [0.0f64, 0.8, 0.6, 0.4, -0.25, -0.5];
|
||||
egui::Grid::new(ui.id().with("theme_grid")).spacing(vec2(2.0, 2.0)).show(ui, |ui| {
|
||||
egui::Grid::new(ui.scope_id().with("theme_grid")).spacing(vec2(2.0, 2.0)).show(ui, |ui| {
|
||||
for (row, tint) in tints.iter().enumerate() {
|
||||
for (col, &i) in order.iter().enumerate() {
|
||||
let base = theme_colors.get(i).copied().unwrap_or(0);
|
||||
@@ -515,7 +538,7 @@ pub fn message_box(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
let title = app.ui.language.tr(&title).to_owned();
|
||||
let msg = app.ui.language.tr_error(&msg);
|
||||
let mut close = false;
|
||||
egui::Modal::new(egui::Id::new("message_box")).show(ctx, |ui| {
|
||||
egui::Modal::new(egui::Id::unique("message_box")).show(ctx, |ui| {
|
||||
ui.set_width(380.0);
|
||||
ui.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(36.0, 36.0), Sense::hover());
|
||||
@@ -552,7 +575,7 @@ pub fn toast(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
}
|
||||
let msg = app.ui.language.tr_error(&msg);
|
||||
let Some(rect) = app.grid.rect else { return };
|
||||
egui::Area::new(egui::Id::new("toast")).fixed_pos(pos2(rect.center().x - 160.0, rect.bottom() - 48.0)).order(egui::Order::Tooltip).show(
|
||||
egui::Area::new(egui::Id::unique("toast")).fixed_pos(pos2(rect.center().x - 160.0, rect.bottom() - 48.0)).order(egui::Order::Tooltip).show(
|
||||
ctx,
|
||||
|ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
@@ -643,6 +666,26 @@ mod color_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod glyph_button_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_symbol_with_no_bundled_glyph_paints_a_code_icon() {
|
||||
// ✓ has no bundled egui face. The gallery button must paint Icon::Check (vector paths),
|
||||
// not the missing character, without weakening the font or accessibility policy.
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_fonts(egui::FontDefinitions::default());
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let response = glyph_button(ui, '✓', "Insert check mark");
|
||||
assert!(response.rect.width() > 0.0);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
assert!(!out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Text(t) if t.galley.job.text.contains('✓'))));
|
||||
assert!(out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Path(_))), "the code-drawn check must paint vector paths");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tooltip_shortcut_tests {
|
||||
use super::tooltip_shortcut;
|
||||
@@ -659,7 +702,7 @@ mod tooltip_shortcut_tests {
|
||||
assert_eq!(tooltip_shortcut("Search commands", false), None);
|
||||
for (name, mac, other) in super::TOOLTIP_SHORTCUTS {
|
||||
assert!(!other.contains(['⌘', '⇧', '⌥']), "{name}: {other}");
|
||||
assert!(mac.starts_with('⌘'), "{name}: {mac}");
|
||||
assert!(mac.starts_with('⌘') || mac.starts_with('⌃'), "{name}: {mac}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//! The shared strict test harness: bundled faces plus the pinned craft-fonts input, never the
|
||||
//! host's installed fonts.
|
||||
//!
|
||||
//! `egui_kittest` keeps `MissingGlyphPolicy::Panic` and accessibility checks on. The production
|
||||
//! font set probes system fonts for scripts a workstation may lack; the suite must not, or a
|
||||
//! minimal CI image would fail for a reason unrelated to the change under test. `CRAFT_FONTS_DIR`
|
||||
//! is the pinned input (never a font copy in this repository); without it only egui's bundled
|
||||
//! faces are installed and the non-Latin language cases are skipped explicitly in the one test
|
||||
//! that renders every catalog.
|
||||
|
||||
use egui_kittest::HarnessBuilder;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::{Services, SheetApp};
|
||||
|
||||
pub type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
/// A kittest builder with the deterministic font set and GridCraft's visuals installed.
|
||||
pub fn builder<State>() -> HarnessBuilder<State> {
|
||||
egui_kittest::Harness::builder().with_context_setup(|ctx| {
|
||||
ctx.set_fonts(gridcraft_ui_egui::theme::pinned_font_definitions());
|
||||
gridcraft_ui_egui::theme::apply(ctx, false);
|
||||
})
|
||||
}
|
||||
|
||||
/// A test app whose first `logic` pass keeps the harness's pinned font set.
|
||||
pub fn app(session: Session) -> SheetApp {
|
||||
prepare(SheetApp::new(session, Services::default()))
|
||||
}
|
||||
|
||||
/// Mark `app` as already using the harness's pinned font set.
|
||||
pub fn prepare(mut app: SheetApp) -> SheetApp {
|
||||
app.use_prepared_fonts();
|
||||
app
|
||||
}
|
||||
|
||||
/// Drive `app`'s `logic` then `ui` each frame in a harness built with `builder`.
|
||||
///
|
||||
/// Tests that need a non-standard size, step timing, OS or pixel ratio configure the builder
|
||||
/// here so those details stay explicit; the shared font and lifecycle setup lives in `builder`
|
||||
/// and `app`.
|
||||
pub fn harness_with(builder: HarnessBuilder<SheetApp>, app: SheetApp, steps: usize) -> Harness {
|
||||
let mut h = builder.build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(steps);
|
||||
h
|
||||
}
|
||||
|
||||
/// A 1200×800 harness that reports `os` to egui, for platform-specific input.
|
||||
pub fn os_harness(session: Session, os: egui::os::OperatingSystem) -> Harness {
|
||||
harness_with(builder().with_os(os).with_size(egui::vec2(1200.0, 800.0)), app(session), 4)
|
||||
}
|
||||
@@ -20,12 +20,12 @@ fn harness() -> Harness {
|
||||
s.execute("home.columnWidth", json!({"cols": "A:A", "chars": 7})).unwrap();
|
||||
s.execute("home.columnWidth", json!({"cols": "B:B", "chars": 20})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "H20"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(s, Default::default()),
|
||||
crate::common::app(s),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
//! Advanced Filter's dialog must route both actions to the same engine command.
|
||||
|
||||
use egui::vec2;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use gridcraft_ui_egui::control::{Outcome, handle};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
fn harness() -> egui_kittest::Harness<'static, SheetApp> {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(6);
|
||||
h
|
||||
crate::support::blank_harness(6)
|
||||
}
|
||||
|
||||
fn control(h: &mut egui_kittest::Harness<'static, SheetApp>, method: &str, params: Json) -> Json {
|
||||
@@ -37,7 +25,7 @@ fn dialog_offers_copy_action_and_runs_unique_extract() {
|
||||
h.run_steps(6);
|
||||
h.get_by_label("Copy matching rows elsewhere").click();
|
||||
h.run_steps(6);
|
||||
let rect = h.ctx.memory(|m| m.area_rect(egui::Id::new("dialog").with("advancedFilter"))).unwrap();
|
||||
let rect = h.ctx.memory(|m| m.area_rect(egui::Id::unique("dialog").with("advancedFilter"))).unwrap();
|
||||
assert!(rect.width() < 500.0 && rect.height() < 400.0, "dialog must fit its controls: {rect:?}");
|
||||
assert!(
|
||||
h.state()
|
||||
|
||||
@@ -29,7 +29,7 @@ fn editing(text: &str) -> (Context, SheetApp) {
|
||||
let ctx = Context::default();
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut app = SheetApp::new(session, Default::default());
|
||||
let mut app = crate::common::app(session);
|
||||
for t in 0..4 {
|
||||
frame(&ctx, &mut app, f64::from(t) * 0.01, vec![]);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
//! #1901: AutoSave is a preference — it must survive a restart like the other UI settings.
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
|
||||
#[test]
|
||||
fn autosave_survives_a_restart() {
|
||||
let mut app = SheetApp::new(Session::new(), Default::default());
|
||||
let mut app = crate::common::app(Session::new());
|
||||
assert!(!app.ui.autosave, "off by default");
|
||||
app.toggle_autosave();
|
||||
assert!(app.ui.autosave, "the toggle flips the persisted flag");
|
||||
|
||||
@@ -13,12 +13,12 @@ fn harness() -> egui_kittest::Harness<'static, SheetApp> {
|
||||
session.new_workbook();
|
||||
session.run("range.setValues", json!({"range": "A1", "values": [[1, 2], [3, 4]]})).unwrap();
|
||||
session.run("selection.set", json!({"range": "A1:B2"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
crate::common::app(session),
|
||||
);
|
||||
h.run_steps(6);
|
||||
h
|
||||
|
||||
@@ -21,7 +21,7 @@ fn caret(fill: Option<&str>, dark: bool) -> Color32 {
|
||||
theme::apply(&ctx, dark);
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut app = SheetApp::new(session, Default::default());
|
||||
let mut app = crate::common::app(session);
|
||||
if let Some(color) = fill {
|
||||
app.run("home.fillColor", json!({"range": "A1", "color": color})).unwrap();
|
||||
}
|
||||
|
||||
@@ -28,12 +28,12 @@ fn png() -> Vec<u8> {
|
||||
fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, services),
|
||||
crate::common::prepare(SheetApp::new(session, services)),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
|
||||
@@ -14,12 +14,12 @@ fn harness() -> Harness {
|
||||
session.execute("home.insertSheet", json!({})).unwrap();
|
||||
session.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
session.execute("selection.set", json!({"range": "F8:G9"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
let mut h = crate::common::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
crate::common::app(session),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h.state_mut().open_dialog("cfQuick", json!({"type": "expression", "title": "New Formatting Rule"}));
|
||||
|
||||
@@ -17,13 +17,13 @@ fn fixture() -> (Harness, u32) {
|
||||
session.execute("selection.set", json!({"range": "A1:A2"})).unwrap();
|
||||
let chart = session.execute("insert.chart", json!({"type": "column", "at": "D3", "width": 300, "height": 200})).unwrap();
|
||||
let id = chart["chart"].as_u64().unwrap() as u32;
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
crate::common::app(session),
|
||||
);
|
||||
h.run_steps(4);
|
||||
(h, id)
|
||||
@@ -79,7 +79,7 @@ fn open_size_fields(h: &mut Harness, id: u32) -> egui::Rect {
|
||||
h.state_mut().selected_chart = Some(id);
|
||||
h.state_mut().grid.pane = Some("formatChart".into());
|
||||
h.run_steps(2);
|
||||
h.get_by_role_and_label(Role::Button, "Size").click();
|
||||
h.get_by_role_and_label(Role::DisclosureTriangle, "Size").click();
|
||||
h.run_steps(2);
|
||||
h.get_all_by_role(Role::SpinButton).next().unwrap().rect()
|
||||
}
|
||||
@@ -151,7 +151,7 @@ fn right_clicking_a_chart_opens_its_menu_not_the_cells() {
|
||||
let (mut h, id) = fixture();
|
||||
let inside = corner(&h, id) - vec2(100.0, 80.0);
|
||||
right_click(&mut h, inside, vec2(0.0, 0.0));
|
||||
h.get_by_label("Delete").click();
|
||||
h.get_by_role_and_label(Role::Button, "Delete").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().session.doc().unwrap().wb.active().unwrap().charts.is_empty());
|
||||
assert!(h.state().grid.object_menu.is_none());
|
||||
|
||||
@@ -16,13 +16,13 @@ fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> {
|
||||
session.new_workbook();
|
||||
session.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Score"], ["Ada", 42]]})).unwrap();
|
||||
session.execute("selection.set", json!({"range": "A1:B2"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, services),
|
||||
crate::common::prepare(SheetApp::new(session, services)),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
|
||||
@@ -13,12 +13,12 @@ const WIDTH: f32 = 1200.0;
|
||||
fn harness() -> Harness {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(WIDTH, 800.0)).build_ui_state(
|
||||
let mut h = crate::common::builder().with_step_dt(1.0 / 60.0).with_size(vec2(WIDTH, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
crate::common::app(session),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
|
||||
@@ -13,12 +13,12 @@ fn pointing_on_second_sheet(range: &str) -> egui_kittest::Harness<'static, Sheet
|
||||
session.execute("home.insertSheet", json!({})).unwrap();
|
||||
session.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
session.execute("selection.set", json!({"range": range})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
crate::common::app(session),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h.state_mut().begin_edit(Some("=1+2".into()), false);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Text fields over workbook values (chart titles, table name) keep what the user types until they commit it.
|
||||
|
||||
use egui::{Event, Key, Modifiers, accesskit::Role, vec2};
|
||||
use egui::{Event, Key, Modifiers, accesskit::Role};
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
@@ -9,16 +9,7 @@ use serde_json::json;
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn harness(session: Session) -> Harness {
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
crate::support::harness(session, 4)
|
||||
}
|
||||
|
||||
fn session() -> Session {
|
||||
@@ -55,7 +46,7 @@ fn typing_a_chart_title_in_format_chart_area_sets_it() {
|
||||
h.state_mut().selected_chart = Some(id);
|
||||
h.state_mut().grid.pane = Some("formatChart".into());
|
||||
h.run_steps(2);
|
||||
h.get_by_role_and_label(Role::Button, "Chart Title").click();
|
||||
h.get_by_role_and_label(Role::DisclosureTriangle, "Chart Title").click();
|
||||
h.run_steps(2);
|
||||
// The pane sits on the right; the formula bar and name box are further left.
|
||||
let field = h.get_all_by_role(Role::TextInput).map(|n| n.rect()).find(|r| r.left() > 850.0).unwrap();
|
||||
|
||||
@@ -16,12 +16,12 @@ fn harness() -> Harness {
|
||||
fn harness_at(ppp: f32) -> Harness {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_pixels_per_point(ppp).build_ui_state(
|
||||
let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).with_pixels_per_point(ppp).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
crate::common::app(session),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
//! The tab remembered in the saved UI state, or left behind when the selection moves away, must
|
||||
//! not leave the ribbon blank: it falls back to Home.
|
||||
|
||||
use egui::vec2;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use serde_json::json;
|
||||
@@ -10,16 +9,7 @@ use serde_json::json;
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn harness(session: Session) -> Harness {
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
crate::support::harness(session, 4)
|
||||
}
|
||||
|
||||
fn session_with_table() -> Session {
|
||||
@@ -32,18 +22,9 @@ fn session_with_table() -> Session {
|
||||
}
|
||||
|
||||
fn app_restored_on(tab: &str, session: Session) -> Harness {
|
||||
let mut app = SheetApp::new(session, Default::default());
|
||||
let mut app = crate::common::app(session);
|
||||
app.ui.ribbon_tab = tab.into();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
crate::support::app_harness(app, 4)
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -8,7 +8,7 @@ use serde_json::json;
|
||||
fn app() -> (SheetApp, egui::Context) {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
(SheetApp::new(s, Default::default()), egui::Context::default())
|
||||
(crate::common::app(s), egui::Context::default())
|
||||
}
|
||||
|
||||
fn a1(app: &SheetApp) -> String {
|
||||
|
||||
@@ -10,13 +10,13 @@ use serde_json::json;
|
||||
fn ok_and_cancel_sit_under_the_last_field() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 1000.0)).build_ui_state(
|
||||
let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 1000.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(s, Default::default()),
|
||||
crate::common::app(s),
|
||||
);
|
||||
h.state_mut().open_dialog("cfQuick", json!({"type": "cellIs", "operator": "greaterThan", "title": "Greater Than…"}));
|
||||
h.run_steps(4);
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
//! `ui.dialog.confirm` runs a dialog's command with the parameters its OK button sends (#1459).
|
||||
|
||||
use egui::vec2;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::model::CfRule;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use gridcraft_ui_egui::control::{Outcome, handle};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
fn harness() -> egui_kittest::Harness<'static, SheetApp> {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(6);
|
||||
h
|
||||
crate::support::blank_harness(6)
|
||||
}
|
||||
|
||||
fn control(h: &mut egui_kittest::Harness<'static, SheetApp>, method: &str, params: Json) -> Json {
|
||||
|
||||
@@ -11,12 +11,12 @@ fn harness() -> egui_kittest::Harness<'static, SheetApp> {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("selection.set", json!({"cell": "C3"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
crate::common::app(session),
|
||||
);
|
||||
h.run_steps(6);
|
||||
h
|
||||
|
||||
@@ -10,19 +10,19 @@ type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
fn harness() -> Harness {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
crate::common::app(session),
|
||||
);
|
||||
h.run_steps(6);
|
||||
h
|
||||
}
|
||||
|
||||
fn dialog_rect(h: &Harness, name: &str) -> egui::Rect {
|
||||
h.ctx.memory(|m| m.area_rect(egui::Id::new("dialog").with(name))).unwrap()
|
||||
h.ctx.memory(|m| m.area_rect(egui::Id::unique("dialog").with(name))).unwrap()
|
||||
}
|
||||
|
||||
fn button(h: &mut Harness, pos: egui::Pos2, pressed: bool) {
|
||||
|
||||
@@ -13,12 +13,12 @@ fn harness() -> Harness {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("range.setValues", json!({"range": "A1", "values": [["A", "B", "C", "D", "E"], [1, 2]]})).unwrap();
|
||||
let mut h = Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
let mut h = crate::common::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
crate::common::app(session),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
|
||||
@@ -10,8 +10,9 @@ use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
/// The submenu item, with the arrow egui puts after a submenu's label.
|
||||
const CIRCULAR: &str = "Circular References ⏵";
|
||||
/// The submenu item's accessible name. egui 0.37 draws the submenu arrow as decoration and no
|
||||
/// longer folds it into the label, so screen readers (and kittest) see the purpose without it.
|
||||
const CIRCULAR: &str = "Circular References";
|
||||
|
||||
fn harness(loops: bool) -> Harness {
|
||||
let mut s = Session::new();
|
||||
@@ -26,12 +27,12 @@ fn harness(loops: bool) -> Harness {
|
||||
s.execute("cell.set", json!({"cell": "C2", "input": "=C2+1"})).unwrap();
|
||||
}
|
||||
s.execute("selection.set", json!({"range": "H20"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 800.0)).build_ui_state(
|
||||
let mut h = crate::common::builder().with_size(vec2(1400.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(s, Default::default()),
|
||||
crate::common::app(s),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h.state_mut().ui.ribbon_tab = "Formulas".into();
|
||||
@@ -145,6 +146,7 @@ fn circular_references_scrolls_a_list_taller_than_the_window() {
|
||||
for _ in 0..20 {
|
||||
h.input_mut().events.push(Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Line,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
delta: vec2(0.0, -10.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
|
||||
@@ -3,23 +3,12 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::{Session, core::CellRef, core::Value};
|
||||
use gridcraft_engine::{core::CellRef, core::Value};
|
||||
use gridcraft_ui_egui::i18n::Language;
|
||||
use gridcraft_ui_egui::{Services, SheetApp};
|
||||
|
||||
fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, services),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
crate::support::services_harness(services, 4)
|
||||
}
|
||||
|
||||
/// Opens File (the Backstage view) and clicks a navigation entry or page button.
|
||||
@@ -115,12 +104,15 @@ fn file_options_opens_the_options_page_and_pages_are_reachable_by_command() {
|
||||
let r = h.state_mut().run("file.options", serde_json::json!({})).unwrap();
|
||||
assert_eq!(r["calculation"]["mode"], "automatic");
|
||||
h.run_steps(3);
|
||||
h.check_accessibility();
|
||||
assert_eq!(page(&h), Some("options"));
|
||||
click(&mut h, "Info");
|
||||
h.check_accessibility();
|
||||
assert_eq!(page(&h), Some("info"));
|
||||
for id in ["print", "export", "saveAs", "new", "open", "home"] {
|
||||
h.state_mut().run("ui.backstage", serde_json::json!({"page": id})).unwrap();
|
||||
h.run_steps(2);
|
||||
h.check_accessibility();
|
||||
assert_eq!(page(&h), Some(id));
|
||||
}
|
||||
assert!(h.state_mut().run("ui.backstage", serde_json::json!({"page": "feedback"})).is_err());
|
||||
@@ -130,6 +122,7 @@ fn file_options_opens_the_options_page_and_pages_are_reachable_by_command() {
|
||||
h.state_mut().run("ui.backstage", serde_json::json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
click(&mut h, "Back");
|
||||
h.check_accessibility();
|
||||
assert!(h.state().backstage.is_none());
|
||||
}
|
||||
|
||||
|
||||
@@ -13,15 +13,7 @@ fn harness() -> egui_kittest::Harness<'static, SheetApp> {
|
||||
session.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4], [5], [6]]})).unwrap();
|
||||
session.execute("range.setValues", json!({"range": "B1", "values": [[10], [20], [30]]})).unwrap();
|
||||
session.execute("selection.set", json!({"range": "B1:B3"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
crate::support::stepped_harness(session)
|
||||
}
|
||||
|
||||
fn handle(h: &egui_kittest::Harness<'_, SheetApp>) -> Pos2 {
|
||||
|
||||
@@ -15,15 +15,7 @@ fn harness(setup: impl FnOnce(&mut Session)) -> Harness {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
setup(&mut session);
|
||||
let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
crate::support::stepped_harness(session)
|
||||
}
|
||||
|
||||
/// B1:B2 holds 1, 2 and is selected.
|
||||
|
||||
@@ -15,15 +15,7 @@ fn harness(setup: impl FnOnce(&mut Session)) -> Harness {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
setup(&mut session);
|
||||
let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
crate::support::stepped_harness(session)
|
||||
}
|
||||
|
||||
/// B1:B2 holds 1, 2 and is selected.
|
||||
|
||||
@@ -18,12 +18,12 @@ fn filter_menu_on_a_long_list() -> Harness {
|
||||
values.extend((1..=80).map(|i| vec![json!(format!("ITEM-{i:03}"))]));
|
||||
session.execute("range.setValues", json!({"range": "A1", "values": values})).unwrap();
|
||||
session.execute("data.filter", json!({"range": "A1:A81"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
crate::common::app(session),
|
||||
);
|
||||
h.run_steps(4);
|
||||
let button = {
|
||||
@@ -51,6 +51,7 @@ fn filter_list_scrolls_with_the_pointer_right_of_the_values() {
|
||||
h.run_steps(2);
|
||||
h.input_mut().events.push(Event::MouseWheel {
|
||||
unit: MouseWheelUnit::Line,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
delta: vec2(0.0, -3.0),
|
||||
phase: TouchPhase::Move,
|
||||
modifiers: Modifiers::NONE,
|
||||
|
||||
@@ -17,12 +17,12 @@ fn filter_menu_open() -> Harness {
|
||||
session.execute("range.setValues", json!({"range": "A1", "values": [["Fruit"], ["Apple"], ["Banana"], ["Cherry"]]})).unwrap();
|
||||
session.execute("data.filter", json!({"range": "A1:A4"})).unwrap();
|
||||
session.execute("selection.set", json!({"cell": "A1"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
crate::common::app(session),
|
||||
);
|
||||
h.run_steps(4);
|
||||
key(&mut h, Key::ArrowDown, Modifiers::ALT);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Opening Find and Replace puts the cursor in "Find what", so the user can type at once.
|
||||
|
||||
use egui::{Event, Key, Modifiers};
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -11,18 +10,7 @@ type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
const CTRL: Modifiers = Modifiers { alt: false, ctrl: true, shift: false, mac_cmd: false, command: true };
|
||||
|
||||
fn harness() -> Harness {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(6);
|
||||
h
|
||||
crate::support::blank_harness(6)
|
||||
}
|
||||
|
||||
fn type_after_shortcut(k: Key) -> Option<serde_json::Value> {
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use egui::{Shape, TextureId, vec2};
|
||||
use gridcraft_engine::Session;
|
||||
use egui::{Shape, TextureId};
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -15,17 +14,7 @@ fn png(color: [u8; 4]) -> Vec<u8> {
|
||||
}
|
||||
|
||||
fn harness() -> egui_kittest::Harness<'static, SheetApp> {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
crate::support::blank_harness(4)
|
||||
}
|
||||
|
||||
fn picture_textures(h: &egui_kittest::Harness<'static, SheetApp>) -> Vec<TextureId> {
|
||||
|
||||
@@ -13,12 +13,12 @@ fn harness() -> Harness {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("selection.set", json!({"range": "A1:B2"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(s, Default::default()),
|
||||
crate::common::app(s),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
@@ -107,7 +107,7 @@ fn escape_cancels_the_typed_size() {
|
||||
#[test]
|
||||
fn the_arrow_opens_the_plain_list_of_sizes() {
|
||||
let mut h = harness();
|
||||
h.get_by_label("Font Size").click();
|
||||
h.get_by_role_and_label(Role::Button, "Font Size").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_label("Type to filter…").is_none(), "the size list has no filter field");
|
||||
// The grid's row headers carry the same numbers as labels: pick the list's button.
|
||||
@@ -116,7 +116,7 @@ fn the_arrow_opens_the_plain_list_of_sizes() {
|
||||
assert_eq!(size(&h, "B2"), 16.0, "clicking a size applies it");
|
||||
|
||||
let mut h = harness();
|
||||
h.get_by_label("Font Size").click();
|
||||
h.get_by_role_and_label(Role::Button, "Font Size").click();
|
||||
h.run_steps(3);
|
||||
key(&mut h, Key::ArrowDown);
|
||||
key(&mut h, Key::Enter);
|
||||
|
||||
@@ -18,16 +18,7 @@ fn harness() -> Harness {
|
||||
session.execute("home.bold", json!({"range": "A1", "on": true})).unwrap();
|
||||
session.execute("home.fillColor", json!({"range": "A1", "color": "#FFFF00"})).unwrap();
|
||||
session.execute("selection.set", json!({"cell": "A1"})).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
crate::support::harness(session, 4)
|
||||
}
|
||||
|
||||
fn style(h: &Harness, a1: &str) -> Style {
|
||||
|
||||
@@ -11,12 +11,12 @@ use serde_json::json;
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn harness(session: Session) -> Harness {
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
crate::common::app(session),
|
||||
);
|
||||
settle(&mut h);
|
||||
h
|
||||
@@ -157,7 +157,7 @@ fn in_cell_height(lines: usize) -> f32 {
|
||||
h.state_mut().begin_edit(Some(text.clone()), false);
|
||||
settle(&mut h);
|
||||
assert_eq!(h.state().editor.as_ref().unwrap().text, text, "the text is kept whole");
|
||||
let rect: Rect = h.ctx.data(|d| d.get_temp(egui::Id::new("gridcraft.cell_editor.rect"))).expect("the in-cell editor painted");
|
||||
let rect: Rect = h.ctx.data(|d| d.get_temp(egui::Id::unique("gridcraft.cell_editor.rect"))).expect("the in-cell editor painted");
|
||||
rect.height()
|
||||
}
|
||||
|
||||
@@ -176,17 +176,9 @@ fn code_editor_indents_and_keeps_indentation() {
|
||||
text: String,
|
||||
focused: bool,
|
||||
}
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(400.0, 300.0)).build_ui_state(
|
||||
let mut h = crate::common::builder().with_size(vec2(400.0, 300.0)).build_ui_state(
|
||||
|ui, s: &mut State| {
|
||||
if ui.ctx().cumulative_pass_nr() == 0 {
|
||||
// egui's own fonts stand in for the system ones under the theme's family names.
|
||||
let mut fonts = egui::FontDefinitions::default();
|
||||
let mono = fonts.families.get(&egui::FontFamily::Monospace).cloned().unwrap_or_default();
|
||||
fonts.families.insert(egui::FontFamily::Name(theme::MONO.into()), mono);
|
||||
ui.ctx().set_fonts(fonts);
|
||||
return;
|
||||
}
|
||||
let id = egui::Id::new("code");
|
||||
let id = egui::Id::unique("code");
|
||||
if !s.focused {
|
||||
ui.memory_mut(|m| m.request_focus(id));
|
||||
s.focused = true;
|
||||
@@ -196,6 +188,8 @@ fn code_editor_indents_and_keeps_indentation() {
|
||||
State { text: String::new(), focused: false },
|
||||
);
|
||||
h.run_steps(2);
|
||||
// The code editor's text field has no visible label; the strict policy checks its name.
|
||||
h.check_accessibility();
|
||||
h.input_mut().events.push(Event::Text("if x:".into()));
|
||||
h.run_steps(1);
|
||||
h.input_mut().events.extend(key(Key::Enter, Modifiers::NONE));
|
||||
|
||||
@@ -18,16 +18,7 @@ fn harness() -> Harness {
|
||||
session.new_workbook();
|
||||
session.execute("cell.set", json!({"cell": "A1", "input": "text"})).unwrap();
|
||||
session.execute("selection.set", json!({"range": "A1"})).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
crate::support::harness(session, 4)
|
||||
}
|
||||
|
||||
fn ctrl_shift_u(h: &mut Harness, m: Modifiers) {
|
||||
|
||||
@@ -9,7 +9,7 @@ use serde_json::json;
|
||||
fn app() -> SheetApp {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut app = SheetApp::new(session, Default::default());
|
||||
let mut app = crate::common::app(session);
|
||||
app.ui.language = Language::En;
|
||||
app
|
||||
}
|
||||
@@ -81,7 +81,7 @@ fn localized_argument_hints_ignore_decimal_commas_arrays_and_nested_calls() {
|
||||
fn localized_autocomplete_is_rendered_and_inserts_the_selected_name() {
|
||||
let mut app = app();
|
||||
app.run("app.language.set", json!({"language":"es"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
@@ -158,7 +158,7 @@ fn canonical_engine_templates_enter_the_selected_formula_language() {
|
||||
if let Ok(path) = std::env::var("GRIDCRAFT_LOCALE_SCREENSHOT") {
|
||||
app.ui.ribbon_tab = "Formulas".into();
|
||||
app.run("formulas.insertFunction", json!({"name":"SUM"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
|
||||
@@ -10,12 +10,12 @@ fn harness() -> egui_kittest::Harness<'static, SheetApp> {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("selection.set", json!({"range": "F8"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
let mut h = crate::common::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
crate::common::app(session),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h.state_mut().begin_edit(Some("=SUM(".into()), false);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Formula helpers must stay in the worksheet while its editor scrolls.
|
||||
use egui::{Context, Id, LayerId, Order, RawInput, Rect, pos2, vec2};
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::CellRef;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
|
||||
fn frame(ctx: &Context, app: &mut SheetApp) -> egui::FullOutput {
|
||||
@@ -21,7 +22,7 @@ fn frames(ctx: &Context, app: &mut SheetApp) {
|
||||
}
|
||||
|
||||
fn visible(ctx: &Context, editor: &str) -> bool {
|
||||
ctx.memory(|m| m.areas().is_visible(&LayerId::new(Order::Tooltip, Id::new(editor).with("ac"))))
|
||||
ctx.memory(|m| m.areas().is_visible(&LayerId::new(Order::Tooltip, Id::unique(editor).with("ac"))))
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -30,7 +31,7 @@ fn cell_formula_helpers_hide_when_scrolled_out_and_return_with_the_editor() {
|
||||
let ctx = Context::default();
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut app = SheetApp::new(session, Default::default());
|
||||
let mut app = crate::common::app(session);
|
||||
frames(&ctx, &mut app);
|
||||
app.begin_edit(Some(text.into()), false);
|
||||
if text == "=SU" {
|
||||
@@ -48,12 +49,50 @@ fn cell_formula_helpers_hide_when_scrolled_out_and_return_with_the_editor() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A wrapped in-cell edit in a hidden row/column must not paint (or show its helper) over the
|
||||
/// visible line; the edit survives and comes back when the row and column are shown again.
|
||||
#[test]
|
||||
fn hidden_row_and_column_hide_the_wrapped_editor_until_unhidden() {
|
||||
let ctx = Context::default();
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session.run("home.wrapText", serde_json::json!({"range": "A2", "on": true})).unwrap();
|
||||
session.run("home.hideRows", serde_json::json!({"rows": "2:2"})).unwrap();
|
||||
session.run("home.hideColumns", serde_json::json!({"cols": "A:A"})).unwrap();
|
||||
session.run("selection.set", serde_json::json!({"range": "A2"})).unwrap();
|
||||
let mut app = crate::common::app(session);
|
||||
frames(&ctx, &mut app);
|
||||
app.begin_edit(Some("=SUM(".into()), false);
|
||||
frames(&ctx, &mut app);
|
||||
assert!(!visible(&ctx, "gridcraft.cell_editor"), "a hidden cell must not show its helper");
|
||||
assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(", "hiding the cell preserves the edit");
|
||||
let cells = app.grid.cells_rect.expect("the grid drew");
|
||||
assert!(!paints_visible_text(&frame(&ctx, &mut app), "=SUM(", cells), "a hidden cell must not paint its editor");
|
||||
|
||||
app.session.run("home.unhideRows", serde_json::json!({"rows": "2:2"})).unwrap();
|
||||
app.session.run("home.unhideColumns", serde_json::json!({"cols": "A:A"})).unwrap();
|
||||
frames(&ctx, &mut app);
|
||||
assert!(visible(&ctx, "gridcraft.cell_editor"), "unhiding the cell restores its helper");
|
||||
assert!(paints_visible_text(&frame(&ctx, &mut app), "=SUM(", cells), "unhiding the cell restores its editor");
|
||||
assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(", "unhiding the cell preserves the edit");
|
||||
}
|
||||
|
||||
/// Whether `text` is painted with a non-empty clip that reaches the worksheet (a hidden cell's
|
||||
/// editor may still be in the shape list, clipped to nothing; the formula bar shows the same text
|
||||
/// above the grid and must not satisfy this check).
|
||||
fn paints_visible_text(output: &egui::FullOutput, text: &str, cells: Rect) -> bool {
|
||||
output
|
||||
.shapes
|
||||
.iter()
|
||||
.any(|s| matches!(&s.shape, egui::Shape::Text(t) if t.galley.text() == text && s.clip_rect.intersect(t.visual_bounding_rect()).intersect(cells).is_positive()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formula_bar_hint_remains_visible_when_the_worksheet_scrolls() {
|
||||
let ctx = Context::default();
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut app = SheetApp::new(session, Default::default());
|
||||
let mut app = crate::common::app(session);
|
||||
frames(&ctx, &mut app);
|
||||
app.begin_edit(Some("=SUM(".into()), true);
|
||||
frames(&ctx, &mut app);
|
||||
@@ -68,7 +107,7 @@ fn cell_argument_hint_is_clipped_to_the_worksheet() {
|
||||
let ctx = Context::default();
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut app = SheetApp::new(session, Default::default());
|
||||
let mut app = crate::common::app(session);
|
||||
frames(&ctx, &mut app);
|
||||
app.begin_edit(Some("=SUM(".into()), false);
|
||||
frames(&ctx, &mut app);
|
||||
@@ -85,7 +124,7 @@ fn scrolled_out_cell_editor_still_handles_escape() {
|
||||
let ctx = Context::default();
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut app = SheetApp::new(session, Default::default());
|
||||
let mut app = crate::common::app(session);
|
||||
frames(&ctx, &mut app);
|
||||
app.begin_edit(Some("=SUM(".into()), false);
|
||||
frames(&ctx, &mut app);
|
||||
@@ -116,7 +155,7 @@ fn cell_editor_and_helper_stay_below_the_frozen_top_row() {
|
||||
session.new_workbook();
|
||||
session.run("view.freezeTopRow", serde_json::json!({})).unwrap();
|
||||
session.run("selection.set", serde_json::json!({"range": "B5"})).unwrap();
|
||||
let mut app = SheetApp::new(session, Default::default());
|
||||
let mut app = crate::common::app(session);
|
||||
frames(&ctx, &mut app);
|
||||
let sh = app.session.active().unwrap().wb.active().unwrap();
|
||||
let frozen_h = sh.row_height(0);
|
||||
@@ -136,6 +175,18 @@ fn cell_editor_and_helper_stay_below_the_frozen_top_row() {
|
||||
assert!(editor.clip_rect.top() >= frozen_bottom, "editor must be clipped below the frozen row: {:?}", editor.clip_rect);
|
||||
app.view_mut().unwrap().scroll.y = scroll;
|
||||
frames(&ctx, &mut app);
|
||||
let geo = app.grid.geo.as_ref().expect("grid drew");
|
||||
let sh = app.session.active().unwrap().wb.active().unwrap();
|
||||
let edited = gridcraft_engine::core::CellRef::new(4, 1);
|
||||
assert!(
|
||||
geo.hidden_under_frozen(sh, edited),
|
||||
"B5 must be scrolled fully under the frozen row: requested {scroll}, kept {:?}, geo scroll {:?}, cells {:?}, frozen_h {}, cell {:?}",
|
||||
app.view().scroll,
|
||||
geo.scroll,
|
||||
geo.cells,
|
||||
geo.frozen_h,
|
||||
geo.cell_rect(sh, edited)
|
||||
);
|
||||
assert!(!visible(&ctx, "gridcraft.cell_editor"), "cell hidden under the frozen row must hide its helper");
|
||||
assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(");
|
||||
app.view_mut().unwrap().scroll.y = 0.0;
|
||||
@@ -143,13 +194,58 @@ fn cell_editor_and_helper_stay_below_the_frozen_top_row() {
|
||||
assert!(visible(&ctx, "gridcraft.cell_editor"), "scrolling back restores the helper");
|
||||
}
|
||||
|
||||
/// The editor is drawn in screen coordinates, so its frozen-pane quadrant must be mirrored
|
||||
/// before the visibility test and clip on a right-to-left sheet. Both panes are checked: using
|
||||
/// the logical quadrant hides the frozen editor, and lets the scrolling editor's clip reach into
|
||||
/// the frozen pane.
|
||||
#[test]
|
||||
fn frozen_column_editor_paints_in_both_panes_on_a_right_to_left_sheet() {
|
||||
for (cell, text) in [("A2", "frozen"), ("B2", "scrolling")] {
|
||||
let ctx = Context::default();
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session.run("view.rightToLeft", serde_json::json!({"on": true})).unwrap();
|
||||
session.run("view.freezeFirstColumn", serde_json::json!({})).unwrap();
|
||||
session.run("selection.set", serde_json::json!({"cell": cell})).unwrap();
|
||||
let mut app = crate::common::app(session);
|
||||
frames(&ctx, &mut app);
|
||||
app.begin_edit(Some(text.into()), false);
|
||||
frames(&ctx, &mut app);
|
||||
let output = frame(&ctx, &mut app);
|
||||
let geo = app.grid.geo.as_ref().expect("the grid drew");
|
||||
assert!(geo.rtl && geo.fc == 1, "the test needs a right-to-left frozen column");
|
||||
let sh = app.session.active().unwrap().wb.active().unwrap();
|
||||
let target = CellRef::parse(cell).unwrap();
|
||||
let screen_cell = geo.screen_cell_rect(sh, target);
|
||||
let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h);
|
||||
let quadrant = Rect::from_min_max(
|
||||
pos2(if target.col >= geo.fc { split.x } else { geo.cells.left() }, if target.row >= geo.fr { split.y } else { geo.cells.top() }),
|
||||
pos2(if target.col >= geo.fc { geo.cells.right() } else { split.x }, if target.row >= geo.fr { geo.cells.bottom() } else { split.y }),
|
||||
);
|
||||
let screen_quadrant = geo.mirror_rect(quadrant);
|
||||
let (clipped, _) = output
|
||||
.shapes
|
||||
.iter()
|
||||
.find_map(|s| {
|
||||
let egui::Shape::Text(t) = &s.shape else { return None };
|
||||
(t.galley.text() == text && s.clip_rect.intersect(t.visual_bounding_rect()).intersect(screen_cell).is_positive()).then_some((s, t))
|
||||
})
|
||||
.expect("the editor text is painted inside its screen cell");
|
||||
assert!(
|
||||
screen_quadrant.contains_rect(clipped.clip_rect),
|
||||
"the editor of {cell} must stay inside its screen pane {screen_quadrant:?}, not {:?}",
|
||||
clipped.clip_rect
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn argument_hint_shows_the_function_description() {
|
||||
// #363: inside DATEDIF( the hint lists the units the third argument takes.
|
||||
let ctx = Context::default();
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut app = SheetApp::new(session, Default::default());
|
||||
let mut app = crate::common::app(session);
|
||||
frames(&ctx, &mut app);
|
||||
app.begin_edit(Some("=DATEDIF(A1,B1,".into()), false);
|
||||
frames(&ctx, &mut app);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Freezing panes by dragging the freeze handles on the header edges (#195).
|
||||
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2};
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, pos2};
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::CellRef;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
@@ -11,15 +11,7 @@ type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
fn harness() -> Harness {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
crate::support::stepped_harness(session)
|
||||
}
|
||||
|
||||
fn geo(h: &Harness) -> Geo {
|
||||
|
||||
@@ -18,12 +18,12 @@ fn harness() -> Harness {
|
||||
for (name, refers, scope) in [("Price", "=Sheet1!$B$2:$B$5", "Workbook"), ("Local", "=Sheet2!$C$3", "Sheet2"), ("Rate", "=0.07", "Workbook")] {
|
||||
session.execute("formulas.defineName", json!({"name": name, "refersTo": refers, "scope": scope})).unwrap();
|
||||
}
|
||||
let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
let mut h = crate::common::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
crate::common::app(session),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
|
||||
@@ -21,12 +21,12 @@ fn harness(selection: &str) -> egui_kittest::Harness<'static, SheetApp> {
|
||||
session.execute("home.columnWidth", json!({"cols": "A:D", "width": 100})).unwrap();
|
||||
session.execute("home.rowHeight", json!({"rows": "1:4", "height": 60})).unwrap();
|
||||
session.execute("selection.set", json!({"range": selection})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
let mut h = crate::common::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
crate::common::app(session),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h.state_mut().session.active_mut().unwrap().undo.clear();
|
||||
|
||||
@@ -10,12 +10,12 @@ fn harness() -> egui_kittest::Harness<'static, SheetApp> {
|
||||
session.new_workbook();
|
||||
session.execute("range.setValues", json!({"range": "A1", "values": [["A", "B", "C", "D", "E"]]})).unwrap();
|
||||
session.execute("selection.set", json!({"range": "B:C"})).unwrap();
|
||||
let mut harness = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
let mut harness = crate::common::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
crate::common::app(session),
|
||||
);
|
||||
harness.run_steps(4);
|
||||
harness.state_mut().session.active_mut().unwrap().undo.clear();
|
||||
|
||||
@@ -7,9 +7,9 @@ use gridcraft_ui_egui::{Chrome, Services, SheetApp, backstage::Page};
|
||||
fn harness(chrome: Chrome) -> egui_kittest::Harness<'static, SheetApp> {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut app = SheetApp::new(session, Services::default());
|
||||
let mut app = crate::common::prepare(SheetApp::new(session, Services::default()));
|
||||
app.chrome = chrome;
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
|
||||
@@ -15,12 +15,12 @@ fn harness() -> Harness {
|
||||
session.execute("insert.link", json!({"cell": "B2", "target": "https://example.org/a"})).unwrap();
|
||||
session.execute("cell.set", json!({"cell": "B4", "input": "jump"})).unwrap();
|
||||
session.execute("insert.link", json!({"cell": "B4", "target": "Sheet1!F9"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
crate::common::app(session),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
|
||||
@@ -11,12 +11,12 @@ type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
fn harness() -> Harness {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
crate::common::app(session),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
|
||||
@@ -27,11 +27,11 @@ fn editor_rect(wrap: bool) -> Rect {
|
||||
session.new_workbook();
|
||||
session.execute("home.columnWidth", json!({"cols": "A:A", "chars": 12})).unwrap();
|
||||
session.execute("home.wrapText", json!({"range": "A1", "on": wrap})).unwrap();
|
||||
let mut app = SheetApp::new(session, Default::default());
|
||||
let mut app = crate::common::app(session);
|
||||
frames(&ctx, &mut app);
|
||||
app.begin_edit(Some(TEXT.into()), false);
|
||||
frames(&ctx, &mut app);
|
||||
ctx.read_response(Id::new("gridcraft.cell_editor")).expect("the in-cell editor is shown").rect
|
||||
ctx.read_response(Id::unique("gridcraft.cell_editor")).expect("the in-cell editor is shown").rect
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -50,11 +50,11 @@ fn a_short_wrap_text_edit_stays_one_line_tall() {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("home.wrapText", json!({"range": "A1", "on": true})).unwrap();
|
||||
let mut app = SheetApp::new(session, Default::default());
|
||||
let mut app = crate::common::app(session);
|
||||
frames(&ctx, &mut app);
|
||||
app.begin_edit(Some("short".into()), false);
|
||||
frames(&ctx, &mut app);
|
||||
let field = ctx.read_response(Id::new("gridcraft.cell_editor")).expect("the in-cell editor is shown").rect;
|
||||
let field = ctx.read_response(Id::unique("gridcraft.cell_editor")).expect("the in-cell editor is shown").rect;
|
||||
assert!(field.height() < 30.0, "a one-line edit doesn't reach over the rows below: {field:?}");
|
||||
}
|
||||
|
||||
@@ -68,11 +68,11 @@ fn the_editor_measures_the_text_at_the_fields_wrap_width() {
|
||||
session.new_workbook();
|
||||
session.execute("home.columnWidth", json!({"cols": "A:A", "chars": 12})).unwrap();
|
||||
session.execute("home.wrapText", json!({"range": "A1", "on": true})).unwrap();
|
||||
let mut app = SheetApp::new(session, Default::default());
|
||||
let mut app = crate::common::app(session);
|
||||
frames(&ctx, &mut app);
|
||||
app.begin_edit(Some(TEXT.into()), false);
|
||||
frames(&ctx, &mut app);
|
||||
let field = ctx.read_response(Id::new("gridcraft.cell_editor")).expect("the in-cell editor is shown").rect;
|
||||
let field = ctx.read_response(Id::unique("gridcraft.cell_editor")).expect("the in-cell editor is shown").rect;
|
||||
let sheet = app.session.active().unwrap().wb.active().unwrap().clone();
|
||||
let st = app.session.active().unwrap().wb.styles.get(sheet.style_id(gridcraft_engine::cell(0, 0))).clone();
|
||||
// The grid draws at its zoom: the cell is the field plus the 2 px inset a side, the font scales.
|
||||
@@ -93,14 +93,21 @@ fn an_unwrapped_multiline_edit_shows_every_line() {
|
||||
let ctx = Context::default();
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut app = SheetApp::new(session, Default::default());
|
||||
let mut app = crate::common::app(session);
|
||||
frames(&ctx, &mut app);
|
||||
app.begin_edit(Some(text.into()), false);
|
||||
frames(&ctx, &mut app);
|
||||
ctx.read_response(Id::new("gridcraft.cell_editor")).expect("the in-cell editor is shown").rect
|
||||
ctx.read_response(Id::unique("gridcraft.cell_editor")).expect("the in-cell editor is shown").rect
|
||||
};
|
||||
let one = field("one").height();
|
||||
let two = field("one\ntwo").height();
|
||||
let three = field("one\ntwo\nthree").height();
|
||||
assert!(three > 2.5 * one, "three lines are shown: {three} vs one line {one}");
|
||||
assert!(three < 3.5 * one, "and no more rows are reserved: {three} vs one line {one}");
|
||||
let four = field("one\ntwo\nthree\nfour").height();
|
||||
// egui 0.37 gives a one-line TextEdit a widget-height floor, so compare the increments from
|
||||
// two lines up rather than a fraction of the one-line field.
|
||||
let line = three - two;
|
||||
assert!(line > 0.0, "a third line adds height: {two} then {three}");
|
||||
assert!((four - three - line).abs() < 1.0, "each extra line takes the same height: {two}, {three}, {four}");
|
||||
assert!(three > one + line / 2.0, "the third line is taller than the one-line floor: {one} then {three}");
|
||||
assert!(four < three + line + 1.0, "and no more rows are reserved: {three} then {four}");
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user