mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-28 12:46:48 -06:00
E37: migrate GridCraft to egui 0.37
Pin the reviewed fork and shared dependencies, adapt input and rendering to the new frame lifecycle, and preserve bounded capture handling and font notices. Add representative accessibility checks and meaningful control labels. Validation: full repository CI passed all ten stages, with 2,249 tests passed, zero failed and one existing timing test ignored. Native workflows and paired performance qualification remain documented separately.
This commit is contained in:
@@ -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 }}
|
||||
|
||||
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 = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "ae48c756a3b81125eb796c0cc6dccf60990d1181" }
|
||||
|
||||
@@ -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 = "c488caa9fbc2fb1a86e4674887e9b80b598ebbfa" }
|
||||
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 = "c488caa9fbc2fb1a86e4674887e9b80b598ebbfa" }
|
||||
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`, 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 = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some();
|
||||
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) {
|
||||
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) -> Result<String, String> {
|
||||
let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some();
|
||||
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 {
|
||||
|
||||
+1649
-78
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,244 @@
|
||||
//! 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 `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 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");
|
||||
|
||||
@@ -1157,7 +1157,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)
|
||||
@@ -1643,7 +1643,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)
|
||||
@@ -1651,9 +1651,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() {
|
||||
@@ -1664,9 +1664,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"));
|
||||
@@ -1674,11 +1675,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));
|
||||
@@ -1687,17 +1688,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() {
|
||||
@@ -1828,8 +1834,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",
|
||||
@@ -1965,8 +1971,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;
|
||||
}
|
||||
@@ -2094,8 +2100,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();
|
||||
@@ -2104,8 +2110,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);
|
||||
@@ -2216,6 +2222,7 @@ fn zoom(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool)
|
||||
}
|
||||
|
||||
fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) {
|
||||
let lang = app.ui.language;
|
||||
let names: Vec<String> = app.session.active().map(|doc| doc.wb.names.iter().map(|n| n.name.clone()).collect()).unwrap_or_default();
|
||||
for n in names {
|
||||
if ui.selectable_label(false, &n).clicked() {
|
||||
@@ -2224,8 +2231,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() {
|
||||
@@ -2244,6 +2251,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));
|
||||
@@ -2274,16 +2282,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();
|
||||
@@ -2297,13 +2309,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);
|
||||
}
|
||||
});
|
||||
@@ -2515,9 +2530,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));
|
||||
}
|
||||
};
|
||||
@@ -2860,16 +2875,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| {
|
||||
@@ -2879,10 +2895,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
|
||||
@@ -2901,17 +2917,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");
|
||||
@@ -2944,13 +2965,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() {
|
||||
|
||||
@@ -138,7 +138,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.
|
||||
|
||||
@@ -38,7 +38,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
ui.painter().rect_filled(field, 4.0, t.input_bg);
|
||||
ui.painter().rect_stroke(field, 4.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(field.shrink2(vec2(6.0, 3.0))));
|
||||
let id = egui::Id::new("gridcraft.formula_bar");
|
||||
let id = egui::Id::unique("gridcraft.formula_bar");
|
||||
let read_only = active_spill_anchor(app).is_some();
|
||||
if app.editor.is_some() {
|
||||
editor_widget(app, &mut child, id, theme::ui_font(13.0), expanded, field.width() - 12.0);
|
||||
@@ -102,14 +102,19 @@ fn active_input(app: &SheetApp) -> String {
|
||||
}
|
||||
|
||||
fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
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());
|
||||
}
|
||||
@@ -175,7 +180,7 @@ fn entry_shrank(chars_before: usize, text: &str) -> bool {
|
||||
/// The text editor used in the cell and in the formula bar (they share `app.editor`).
|
||||
pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: FontId, multiline: bool, width: f32) {
|
||||
let Some(mut ed) = app.editor.take() else { return };
|
||||
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()));
|
||||
@@ -294,7 +299,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 text_color = Tokens::get(ui.ctx()).text;
|
||||
let cell_text_color = if id == egui::Id::new("gridcraft.cell_editor") { Color32::BLACK } else { text_color };
|
||||
let cell_text_color = if id == egui::Id::unique("gridcraft.cell_editor") { Color32::BLACK } else { text_color };
|
||||
let font2 = font.clone();
|
||||
let mut layouter = move |ui: &egui::Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| {
|
||||
let s = buf.as_str();
|
||||
@@ -316,7 +321,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
||||
};
|
||||
let chars_before = ed.text.chars().count();
|
||||
// The in-cell editor is as tall as its lines (a multiline field reserves four rows).
|
||||
let in_cell = id == egui::Id::new("gridcraft.cell_editor");
|
||||
let in_cell = id == egui::Id::unique("gridcraft.cell_editor");
|
||||
let edit = if multiline || ed.text.contains('\n') {
|
||||
TextEdit::multiline(&mut ed.text).desired_rows(if in_cell { 1 } else { 4 })
|
||||
} else {
|
||||
@@ -331,6 +336,9 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
||||
.lock_focus(true)
|
||||
.event_filter(egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true })
|
||||
.show(ui);
|
||||
// Both editors are text inputs without a visible label next to them: name the one being shown.
|
||||
let accessible_name = (if in_cell { app.ui.language.tr("Cell Editor") } else { app.ui.language.tr("Formula Bar") }).to_string();
|
||||
(*output.response).clone().accessible_name(accessible_name);
|
||||
let resp = output.response.clone();
|
||||
let synced = mine && (ed.request_focus || ed.sync_caret);
|
||||
if synced {
|
||||
@@ -344,7 +352,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;
|
||||
}
|
||||
@@ -394,8 +402,11 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
||||
ui.painter().rect_filled(r, 0.0, Color32::from_rgb(0x2E, 0x6F, 0xD8));
|
||||
ui.painter().galley(pos, g, Color32::WHITE);
|
||||
}
|
||||
// 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.
|
||||
@@ -639,6 +650,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))));
|
||||
@@ -649,6 +663,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));
|
||||
}
|
||||
}
|
||||
|
||||
+50
-30
@@ -894,7 +894,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() {
|
||||
@@ -947,7 +947,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() {
|
||||
@@ -2875,7 +2875,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)];
|
||||
@@ -2964,7 +2964,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();
|
||||
@@ -3009,6 +3009,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);
|
||||
@@ -3156,11 +3164,20 @@ 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;
|
||||
let on_screen = cell_visible && erect.intersect(quadrant).is_positive() && !geo.hidden_under_frozen(sh, cell);
|
||||
let clip = if on_screen { ui.clip_rect().intersect(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
|
||||
@@ -3214,16 +3231,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);
|
||||
@@ -3306,7 +3323,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);
|
||||
@@ -3430,8 +3447,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 });
|
||||
@@ -3475,7 +3492,7 @@ fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet
|
||||
fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) {
|
||||
let Some((src, target, at)) = app.grid.fill_menu else { return };
|
||||
let mut close = false;
|
||||
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 items = [FILL_MODES[0], FILL_MODES[1], FILL_MODES[2], FILL_MODES[3], ("-", ""), ("Series…", "ui:series")];
|
||||
@@ -3507,7 +3524,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")];
|
||||
@@ -3564,8 +3581,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 });
|
||||
@@ -3658,15 +3675,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;
|
||||
@@ -3683,7 +3703,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| {
|
||||
@@ -3703,7 +3723,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);
|
||||
});
|
||||
}
|
||||
@@ -3772,7 +3792,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| {
|
||||
@@ -3796,7 +3816,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
|
||||
|
||||
@@ -186,6 +186,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",
|
||||
@@ -413,6 +416,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).
|
||||
|
||||
@@ -190,6 +190,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",
|
||||
@@ -421,6 +424,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",
|
||||
@@ -449,6 +453,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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -184,6 +184,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" => "Μεγέθυνση",
|
||||
@@ -393,6 +396,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:" => "Χρησιμοποιήστε το μενού ενός πεδίου για να το μετακινήσετε μεταξύ περιοχών:",
|
||||
|
||||
@@ -189,6 +189,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",
|
||||
@@ -418,6 +421,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:",
|
||||
@@ -465,6 +469,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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -210,6 +210,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",
|
||||
@@ -308,6 +311,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",
|
||||
@@ -435,6 +439,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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -204,6 +204,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",
|
||||
@@ -301,6 +304,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",
|
||||
|
||||
@@ -209,6 +209,9 @@ pub(super) const CORE: &[(&str, &str)] = &[
|
||||
("Page Break\nPreview", "改ページ プレビュー"),
|
||||
("Page\nLayout", "ページ レイアウト"),
|
||||
("Formula Bar", "数式バー"),
|
||||
("Name Box", "名前ボックス"),
|
||||
("Cell Editor", "セル エディター"),
|
||||
("Font Size", "フォント サイズ"),
|
||||
("Gridlines", "目盛線"),
|
||||
("Headings", "見出し"),
|
||||
("Zoom", "ズーム"),
|
||||
@@ -306,6 +309,7 @@ pub(super) const CORE: &[(&str, &str)] = &[
|
||||
("More\nFunctions", "その他の関数"),
|
||||
("Sheet Options|View", "画面"),
|
||||
("Print", "印刷"),
|
||||
("Print What", "印刷する内容"),
|
||||
("Click to add data", "クリックしてデータを追加"),
|
||||
("Add header", "ヘッダーの追加"),
|
||||
("Add footer", "フッターの追加"),
|
||||
|
||||
@@ -207,6 +207,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" => "확대/축소",
|
||||
@@ -306,6 +309,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" => "머리글 추가",
|
||||
@@ -433,6 +437,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Next sheet" => "다음 시트",
|
||||
"All sheets" => "모든 시트",
|
||||
"New sheet (⇧F11)" => "새 시트 (⇧F11)",
|
||||
"Text Box" => "텍스트 상자",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -205,6 +205,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",
|
||||
@@ -301,6 +304,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",
|
||||
|
||||
@@ -200,6 +200,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",
|
||||
@@ -297,6 +300,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ę",
|
||||
|
||||
@@ -204,6 +204,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",
|
||||
@@ -302,6 +305,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",
|
||||
|
||||
@@ -207,6 +207,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",
|
||||
@@ -304,6 +307,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",
|
||||
|
||||
@@ -207,6 +207,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" => "Масштаб",
|
||||
@@ -306,6 +309,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" => "Добавить верхний колонтитул",
|
||||
@@ -435,6 +439,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Next sheet" => "Следующий лист",
|
||||
"All sheets" => "Все листы",
|
||||
"New sheet (⇧F11)" => "Новый лист (⇧F11)",
|
||||
"Text Box" => "Надпись",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -202,6 +202,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",
|
||||
@@ -300,6 +303,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",
|
||||
@@ -433,6 +437,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Next sheet" => "Следећи лист",
|
||||
"All sheets" => "Сви листови",
|
||||
"New sheet (⇧F11)" => "Нови лист (⇧F11)",
|
||||
"Text Box" => "Текстуално поље",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -187,6 +187,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",
|
||||
@@ -407,6 +410,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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -202,6 +202,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" => "Масштаб",
|
||||
@@ -299,6 +302,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" => "Додати верхній колонтитул",
|
||||
@@ -418,7 +422,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",
|
||||
|
||||
@@ -208,6 +208,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",
|
||||
@@ -304,6 +307,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",
|
||||
@@ -409,6 +413,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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -207,6 +207,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" => "缩放",
|
||||
@@ -304,6 +307,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" => "添加页眉",
|
||||
@@ -425,6 +429,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Next sheet" => "下一个工作表",
|
||||
"All sheets" => "所有工作表",
|
||||
"New sheet (⇧F11)" => "新建工作表 (⇧F11)",
|
||||
"Text Box" => "文本框",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -192,6 +192,17 @@ pub const YELLOW: Color32 = Color32::from_rgb(0xF2, 0xC3, 0x2E);
|
||||
pub const ORANGE: Color32 = Color32::from_rgb(0xE8, 0x7D, 0x2B);
|
||||
pub const PURPLE: Color32 = Color32::from_rgb(0x88, 0x55, 0xC8);
|
||||
|
||||
/// 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`.
|
||||
pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
let side = rect.width().min(rect.height());
|
||||
|
||||
+391
-28
@@ -11,6 +11,7 @@ mod border_preview;
|
||||
mod cell_pictures;
|
||||
pub mod chartview;
|
||||
pub mod control;
|
||||
mod craft_fonts_policy;
|
||||
pub mod credits;
|
||||
pub mod dialogs;
|
||||
pub mod editor;
|
||||
@@ -260,13 +261,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
|
||||
@@ -287,6 +298,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
|
||||
@@ -324,6 +345,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(),
|
||||
@@ -334,6 +357,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,
|
||||
@@ -344,6 +368,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,
|
||||
@@ -368,6 +395,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> {
|
||||
@@ -1094,6 +1136,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 {
|
||||
@@ -1164,16 +1208,26 @@ impl SheetApp {
|
||||
// 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)) {
|
||||
// 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.
|
||||
// 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.
|
||||
if self.fonts_set {
|
||||
self.fonts_ready = true;
|
||||
} else {
|
||||
SheetApp::setup_context_for_language(ctx, self.ui.dark, self.ui.language);
|
||||
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();
|
||||
@@ -1184,7 +1238,7 @@ impl SheetApp {
|
||||
} else if self.fonts_rebind {
|
||||
// A font-set change made last frame is active now.
|
||||
self.fonts_rebind = false;
|
||||
} else if fonts::scan_done() {
|
||||
} else if !defer_font_change && !self.prepared_fonts && 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.
|
||||
@@ -1232,7 +1286,21 @@ impl SheetApp {
|
||||
ctx.set_zoom_factor(zoom);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
// 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).
|
||||
let arrived: Vec<(String, Vec<u8>)> = self
|
||||
@@ -1257,14 +1325,79 @@ impl SheetApp {
|
||||
}
|
||||
self.after_engine();
|
||||
}
|
||||
// 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.
|
||||
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
|
||||
&& self.session.active().is_some_and(|d| d.is_dirty() && d.path.as_deref().is_some_and(|p| p.ends_with(".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 && self.session.active().is_some_and(|d| d.is_dirty() && d.path.as_deref().is_some_and(|p| p.ends_with(".xlsx"))) {
|
||||
// Keep checking even when no input arrives while the window is hidden.
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs(2));
|
||||
}
|
||||
// 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.
|
||||
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);
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
if self.shots.needs_hidden_paint() || self.synthetic_pending || !self.synthetic.is_empty() {
|
||||
// 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();
|
||||
}
|
||||
if self.session.active().is_none() {
|
||||
self.session.new_workbook();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1277,7 +1410,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;
|
||||
@@ -1323,7 +1455,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.
|
||||
}
|
||||
@@ -1336,24 +1467,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1442,10 +1563,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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ fn comments(app: &mut SheetApp, ui: &mut Ui) {
|
||||
for (a, txt) in &c.replies {
|
||||
ui.label(egui::RichText::new(format!("↳ {a}: {txt}")).color(Color32::from_gray(60)));
|
||||
}
|
||||
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.
|
||||
@@ -125,7 +125,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();
|
||||
@@ -153,7 +153,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}));
|
||||
}
|
||||
});
|
||||
@@ -171,7 +171,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}));
|
||||
}
|
||||
@@ -216,14 +216,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) }}));
|
||||
}
|
||||
@@ -270,7 +270,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));
|
||||
|
||||
@@ -467,7 +467,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);
|
||||
}
|
||||
}
|
||||
@@ -476,9 +476,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)
|
||||
@@ -486,7 +486,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)
|
||||
@@ -774,8 +774,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)]
|
||||
@@ -785,7 +785,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);
|
||||
}
|
||||
@@ -143,7 +143,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!({}));
|
||||
}
|
||||
@@ -376,7 +376,7 @@ fn act(app: &mut SheetApp, id: &str, params: serde_json::Value) {
|
||||
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);
|
||||
@@ -614,7 +614,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"])
|
||||
{
|
||||
@@ -677,7 +677,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}));
|
||||
@@ -742,7 +742,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"),
|
||||
@@ -1194,7 +1194,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
|
||||
@@ -1213,6 +1213,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));
|
||||
@@ -1242,7 +1243,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();
|
||||
@@ -1522,7 +1523,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() {
|
||||
@@ -1705,7 +1706,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}));
|
||||
}
|
||||
}
|
||||
@@ -1994,7 +1996,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!({}));
|
||||
@@ -2344,20 +2346,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() {
|
||||
@@ -2367,7 +2374,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")
|
||||
@@ -2379,22 +2386,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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2434,7 +2445,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
|
||||
{
|
||||
|
||||
@@ -70,7 +70,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();
|
||||
@@ -94,8 +94,8 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
|
||||
let tabs_width = (ui.available_width() * 0.6).max(100.0);
|
||||
// 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);
|
||||
@@ -154,8 +154,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();
|
||||
@@ -310,6 +311,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;
|
||||
|
||||
+280
-60
@@ -1,14 +1,37 @@
|
||||
//! 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.
|
||||
|
||||
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,
|
||||
@@ -243,43 +266,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);
|
||||
@@ -291,6 +311,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
|
||||
@@ -451,22 +555,38 @@ 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) {
|
||||
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 {
|
||||
@@ -481,14 +601,25 @@ pub fn font_definitions_with(language: crate::i18n::Language, used: &[String]) -
|
||||
chain.push(format!("sys-{CELL}"));
|
||||
}
|
||||
chain.extend(if role == MONO { base_mono.clone() } else { base_prop.clone() });
|
||||
// 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));
|
||||
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
|
||||
@@ -508,6 +639,21 @@ pub fn font_definitions_with(language: crate::i18n::Language, used: &[String]) -
|
||||
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.
|
||||
let craft = add_craft_fonts(&mut fonts);
|
||||
if !craft.is_empty() {
|
||||
for chain in fonts.families.values_mut() {
|
||||
for key in &craft {
|
||||
if !chain.contains(key) {
|
||||
chain.push(key.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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()));
|
||||
@@ -819,6 +965,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 };
|
||||
@@ -910,13 +1062,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,
|
||||
@@ -990,7 +1212,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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -998,16 +1220,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 ŘŠČŽÝÁÍÉÚŮĚŤĎŇÓ"),
|
||||
@@ -1017,26 +1244,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:?})");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1062,7 +1282,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));
|
||||
@@ -1095,13 +1315,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);
|
||||
}
|
||||
|
||||
@@ -1116,11 +1336,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");
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,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`).
|
||||
@@ -43,10 +46,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.
|
||||
@@ -62,6 +67,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());
|
||||
@@ -146,8 +165,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));
|
||||
@@ -209,7 +228,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.
|
||||
@@ -243,7 +262,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);
|
||||
@@ -324,7 +343,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);
|
||||
@@ -438,7 +457,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());
|
||||
@@ -474,7 +493,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| {
|
||||
@@ -565,6 +584,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;
|
||||
@@ -581,7 +620,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,32 @@
|
||||
//! 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};
|
||||
|
||||
/// 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -10,12 +10,12 @@ 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(
|
||||
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
|
||||
@@ -37,7 +37,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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -9,13 +9,13 @@ 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(
|
||||
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
|
||||
@@ -55,7 +55,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
|
||||
|
||||
@@ -10,13 +10,13 @@ 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(
|
||||
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
|
||||
@@ -32,9 +32,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(
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -10,12 +10,12 @@ 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(
|
||||
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
|
||||
|
||||
@@ -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,13 +10,13 @@ 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(
|
||||
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
|
||||
@@ -115,12 +115,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 +133,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,12 +13,12 @@ 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(
|
||||
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
|
||||
|
||||
@@ -14,12 +14,12 @@ 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(
|
||||
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
|
||||
|
||||
@@ -14,12 +14,12 @@ 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(
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -13,13 +13,13 @@ const CTRL: Modifiers = Modifiers { alt: false, ctrl: true, shift: false, mac_cm
|
||||
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(
|
||||
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, Default::default()),
|
||||
crate::common::app(session),
|
||||
);
|
||||
h.run_steps(6);
|
||||
h
|
||||
|
||||
@@ -17,12 +17,12 @@ 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(
|
||||
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(4);
|
||||
h
|
||||
|
||||
@@ -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,13 +18,13 @@ 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(
|
||||
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, Default::default()),
|
||||
crate::common::app(session),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -21,7 +21,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 +30,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 +48,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 +106,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 +123,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 +154,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 +174,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;
|
||||
@@ -149,7 +199,7 @@ fn argument_hint_shows_the_function_description() {
|
||||
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);
|
||||
|
||||
@@ -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_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}");
|
||||
}
|
||||
|
||||
@@ -13,12 +13,12 @@ fn harness() -> Harness {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4]]})).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
|
||||
|
||||
@@ -9,13 +9,13 @@ use gridcraft_ui_egui::{SheetApp, grid::Geo};
|
||||
use serde_json::json;
|
||||
|
||||
fn harness(session: Session) -> egui_kittest::Harness<'static, SheetApp> {
|
||||
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, Default::default()),
|
||||
crate::common::app(session),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
@@ -215,7 +215,7 @@ fn name_box_enter_goes_to_the_typed_cell_not_the_one_below() {
|
||||
let mut h = harness(blank());
|
||||
h.state_mut().session.run("selection.set", json!({"cell": "C5"})).unwrap();
|
||||
h.run_steps(2);
|
||||
h.ctx.memory_mut(|m| m.request_focus(egui::Id::new("gridcraft.name_box")));
|
||||
h.ctx.memory_mut(|m| m.request_focus(egui::Id::unique("gridcraft.name_box")));
|
||||
h.run_steps(2);
|
||||
h.input_mut().events.push(Event::Key { key: Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND });
|
||||
h.input_mut().events.push(Event::Text("A1".into()));
|
||||
@@ -247,9 +247,11 @@ fn typing_enters_values_and_moves_down() {
|
||||
let mut h = harness(blank());
|
||||
text(&mut h, "42");
|
||||
assert!(h.state().editor.is_some(), "typing starts editing");
|
||||
h.check_accessibility();
|
||||
key(&mut h, Key::Enter, Modifiers::NONE);
|
||||
assert_eq!(value(&h, "A1"), Value::Number(42.0));
|
||||
text(&mut h, "=A1*2");
|
||||
h.check_accessibility();
|
||||
key(&mut h, Key::Tab, Modifiers::NONE);
|
||||
assert_eq!(value(&h, "A2"), Value::Number(84.0));
|
||||
let active = h.state().session.active().map(|d| d.selection.active.a1());
|
||||
@@ -395,9 +397,11 @@ fn renders_every_ribbon_tab_without_panicking() {
|
||||
for tab in ["Home", "Insert", "Draw", "Page Layout", "Formulas", "Data", "Review", "View", "Automate"] {
|
||||
h.state_mut().ui.ribbon_tab = tab.into();
|
||||
h.run_steps(2);
|
||||
h.check_accessibility();
|
||||
}
|
||||
h.state_mut().ui.dark = true;
|
||||
h.run_steps(3);
|
||||
h.check_accessibility();
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -415,9 +419,17 @@ fn language_switch_renders_the_ribbon_and_persists() {
|
||||
for id in ["app.language.set", "app.language.english", "app.language.japanese"] {
|
||||
assert!(ids.contains(&id), "engine.commands lists {id}");
|
||||
}
|
||||
for language in Language::ALL {
|
||||
// A minimal CI image has egui's bundled faces but no Japanese/Korean/Chinese face. Those
|
||||
// catalogs are exercised when the pinned craft-fonts input is present; skipping them here is
|
||||
// the documented no-font gap, not a missing-glyph or accessibility opt-out.
|
||||
let languages: Vec<Language> = Language::ALL
|
||||
.into_iter()
|
||||
.filter(|l| gridcraft_ui_egui::theme::craft_fonts_loaded() || !matches!(l, Language::Ja | Language::Ko | Language::Zh))
|
||||
.collect();
|
||||
for language in languages {
|
||||
h.state_mut().run("app.language.set", json!({"language": language.code()})).unwrap();
|
||||
h.run_steps(2); // every locale renders the ribbon without panicking
|
||||
h.run_steps(2); // every available locale renders the ribbon without panicking
|
||||
h.check_accessibility();
|
||||
assert_eq!(h.state().ui.language, language);
|
||||
let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap();
|
||||
assert_eq!(restored.language, language);
|
||||
@@ -434,6 +446,7 @@ fn pivot_fields_pane_follows_the_interface_language() {
|
||||
h.state_mut().run("insert.pivotTable", json!({"source": "Sheet1!$A$1:$B$3", "rows": ["Region"]})).unwrap();
|
||||
h.state_mut().run("app.language.set", json!({"language": "es"})).unwrap();
|
||||
h.run_steps(4);
|
||||
h.check_accessibility();
|
||||
let es = |english| Language::Es.tr(english);
|
||||
for english in ["PivotTable Fields", "Rows", "Values", "Refresh", "Calculated Field…"] {
|
||||
assert!(h.query_by_label(es(english)).is_some(), "the pane shows `{}`", es(english));
|
||||
@@ -443,6 +456,7 @@ fn pivot_fields_pane_follows_the_interface_language() {
|
||||
}
|
||||
h.get_by_label("Region ▾").click();
|
||||
h.run_steps(2);
|
||||
h.check_accessibility();
|
||||
for english in ["Move to Column Labels", "Group by Years", "Remove Field"] {
|
||||
assert!(h.query_by_label(es(english)).is_some(), "the field menu shows `{}`", es(english));
|
||||
}
|
||||
@@ -619,6 +633,7 @@ fn dialogs_open_and_close() {
|
||||
] {
|
||||
h.state_mut().open_dialog(name, json!({}));
|
||||
h.run_steps(2);
|
||||
h.check_accessibility();
|
||||
key(&mut h, Key::Escape, Modifiers::NONE);
|
||||
h.state_mut().dialog = None;
|
||||
h.state_mut().message = None;
|
||||
|
||||
@@ -8,12 +8,12 @@ use serde_json::json;
|
||||
fn interface_size_applies_persists_and_clamps() {
|
||||
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(2);
|
||||
assert_eq!(h.ctx.zoom_factor(), 1.0);
|
||||
|
||||
@@ -11,13 +11,13 @@ use serde_json::json;
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn harness(session: Session) -> Harness {
|
||||
let mut h = 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, Default::default()),
|
||||
crate::common::app(session),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
@@ -172,7 +172,7 @@ fn alt_down_opens_autofilter_headers_without_changing_the_workbook() {
|
||||
// Opening focuses the existing search box; typing must not edit the header.
|
||||
h.input_mut().events.push(Event::Text("1".into()));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.ctx.data(|data| data.get_temp::<String>(egui::Id::new(("filter_search", column)))).as_deref(), Some("1"));
|
||||
assert_eq!(h.ctx.data(|data| data.get_temp::<String>(egui::Id::unique(("filter_search", column)))).as_deref(), Some("1"));
|
||||
assert!(h.state().editor.is_none());
|
||||
assert_eq!(h.state().session.active().unwrap().wb, before);
|
||||
key(&mut h, Key::Escape, Modifiers::NONE);
|
||||
|
||||
@@ -14,12 +14,12 @@ use gridcraft_ui_egui::SheetApp;
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn harness(session: Session) -> Harness {
|
||||
let mut h = egui_kittest::Harness::builder().with_os(OperatingSystem::Nix).with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
let mut h = crate::common::builder().with_os(OperatingSystem::Nix).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
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
//! `header_autofit_fonts.rs` and `keytips.rs` stay separate binaries: they set process-wide state
|
||||
//! (the installed-font catalog, a registered test command) that would leak into these tests.
|
||||
|
||||
#[path = "../common/mod.rs"]
|
||||
mod common;
|
||||
|
||||
mod accounting_fill;
|
||||
mod advanced_filter;
|
||||
mod argument_names;
|
||||
|
||||
@@ -12,12 +12,12 @@ type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
fn harness() -> Harness {
|
||||
let mut s = Session::new();
|
||||
s.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(s, Default::default()),
|
||||
crate::common::app(s),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
|
||||
@@ -14,12 +14,12 @@ fn harness(input: &str) -> Harness {
|
||||
session.new_workbook();
|
||||
session.execute("home.mergeCells", json!({"range": "A1:B2"})).unwrap();
|
||||
session.execute("cell.set", json!({"cell": "A1", "input": input})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user