From ec12eba83e75816b9bbb9eac191159572dbc5d27 Mon Sep 17 00:00:00 2001 From: RockerHX Date: Tue, 10 Nov 2026 19:44:19 +0800 Subject: [PATCH] UI.1265: align CJK fallback text with the Latin baseline (#3081) * UI.1265: align CJK fallback text with the Latin baseline Use epaint's existing baseline alignment for system CJK fallback faces and embedded faces tagged with CJK scripts. Mixed CJK, Latin and digits then share a baseline in cells, editors and window titles. Add regression coverage for zoom, font styles, wrapping, fixed-height clipping, vertical alignment, editor height and title truncation. Refs #1265, #2621. * UI.1265: assert title baselines and document visual validation * UI.1265: add baseline comparison and review validation * UI.1265: adapt tooltip regression after upstream merge The upstream shortcut update uses the runtime OS to choose tooltip text. Use the harness OS for the existing split-button test expectation too. Validation: cargo xtask ci; all 10 steps pass; 3,002 tests passed, 1 ignored, no exclusions. * UI.1265: propagate CJK fallback to installed fonts * UI.1265: align fallback baselines across cells * UI.1265: align wrapped and unwrapped cell text * UI.1265: preserve blank lines in wrapped cell alignment * UI.1265: preserve centered fallback placement * UI.1265: find CJK cell text by its visible bounds Use the overlap of text ink, clip rect and cell rect to locate painted text instead of testing a fixed offset from the galley origin. Baseline-aligned fallbacks can move that origin above the cell on Ubuntu. Keep the existing baseline and wrapping assertions unchanged. Validation: cargo xtask ci on macOS; all 3 cjk_baseline tests on Ubuntu 24.04 x86_64. --------- Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- ROADMAP.md | 5 +- contributors/people.toml | 3 + crates/ui-egui/src/grid/cell_paint.rs | 35 ++- crates/ui-egui/src/grid/quadrant.rs | 17 +- crates/ui-egui/src/grid/tests.rs | 67 +++++- crates/ui-egui/src/ribbon/title_bar.rs | 9 +- crates/ui-egui/src/theme/font_fallback.rs | 4 +- crates/ui-egui/src/theme/font_setup.rs | 31 ++- crates/ui-egui/tests/cjk_baseline.rs | 206 ++++++++++++++++++ .../ui-egui/tests/it/text_overflow_scroll.rs | 4 +- docs/architecture.md | 9 +- docs/localization-parity.md | 8 +- docs/ui-parity.md | 27 ++- 13 files changed, 390 insertions(+), 35 deletions(-) create mode 100644 crates/ui-egui/tests/cjk_baseline.rs diff --git a/ROADMAP.md b/ROADMAP.md index 5906fda99..36f8919a3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (form controls: buttons, sliders and spin buttons; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis take ranges by selection on the grid; earlier: Python mode in the formula bar; earlier: Python cells stored and kept in files; earlier: equations: Insert › Equation, editor, gridcraft-math; earlier: What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-05 · **Change:** minor (CJK fallback text shares the Latin baseline; earlier: form controls: buttons, sliders and spin buttons; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis take ranges by selection on the grid; earlier: Python mode in the formula bar; earlier: Python cells stored and kept in files; earlier: equations: Insert › Equation, editor, gridcraft-math; earlier: What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -148,6 +148,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-31 | CSV and text open detects each column's date order: a value that reads only day first (`31/05/2026`) makes the column day first, a value that reads only month first (`12/31/2026`) makes it month first, an all-ambiguous column keeps month first with a warning, and a column with both kinds stays text with a warning (#2765) | | 2026-10-31 | Recently Used lists the functions most recently entered in a formula from any source (ribbon, control channel, MCP, scripts), kept with the preferences and returned by `formulas.recentlyUsed`; the Insert Function dialog takes Excel's title and a working category list (Most Recently Used, All, each category) | | 2026-10-31 | Formulas › Function Library as a big Insert Function button, the AutoSum split button and three columns of labelled small buttons, each category book in its own colour; category menus end with Insert Function… on that category; More Functions cascades into the remaining categories; Home › Editing shares the AutoSum menu; the MR key tip opens Recently Used | +| 2026-10-31 | System and embedded CJK fallback text shares the Latin baseline in cells, editors and window titles (#1265, #2621). Symbol and emoji alignment, manual row heights and clipping are unchanged | | 2026-10-26 | Arabic bidi display/editing preserve logical source text; per-cell reading direction is editable and survives undo, XLSX and conditional-format priority | | 2026-10-26 | Arabic text shows in cells and the interface through a system, fontconfig or craft-fonts Arabic face; workbook fonts keep their script fallbacks | | 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before | @@ -265,6 +266,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| | 2026-11-06 | trivial | Progress log: scroll positions follow their sheet through sheet inserts, deletes and moves (#3485) | +| 2026-11-05 | minor | Reconcile upstream form-control progress with the CJK baseline update | | 2026-11-02 | minor | Form controls; catalog 277 / 292 → 281 / 296 (Developer › Controls); progress log row | | 2026-11-02 | minor | Progress log: RefEdit reference boxes in dialogs | | 2026-11-02 | minor | Progress log: Python mode in the formula bar | @@ -277,6 +279,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-31 | minor | File-page text fields keep the view open when Esc cancels an edit (#3035) | | 2026-10-31 | minor | Progress log: Recently Used and Insert Function categories | | 2026-10-31 | minor | Progress log: Function Library | +| 2026-10-31 | minor | Progress log: shared CJK/Latin baselines in cells, editors and window titles | | 2026-10-26 | minor | Progress log: Arabic bidi display and editing | | 2026-10-26 | minor | Progress log: Arabic font fallback | | 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column | diff --git a/contributors/people.toml b/contributors/people.toml index 1b8bcf996..395aecf6e 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -110,3 +110,6 @@ sync_github_name = true [people.maxm11] sync_github_name = true + +[people.RockerHX] +sync_github_name = true diff --git a/crates/ui-egui/src/grid/cell_paint.rs b/crates/ui-egui/src/grid/cell_paint.rs index ee03f2726..f16e5e5bc 100644 --- a/crates/ui-egui/src/grid/cell_paint.rs +++ b/crates/ui-egui/src/grid/cell_paint.rs @@ -133,11 +133,10 @@ pub(super) fn display_text( } pub(crate) fn wrapped_cell_text_y(valign: VAlign, rect: Rect, gh: f32, z: f32) -> f32 { - let pad_v = 1.5 * z; - let y_top = rect.top() + pad_v; + let y_top = rect.top() + 1.5 * z; // Not clamped to `y_top`: text taller than the row overflows upward (Bottom) or both ways // (Center), and the cell's clip rect trims it, so Middle and Bottom stay distinct from Top. - let y_bottom = rect.bottom() - gh - pad_v; + let y_bottom = rect.bottom() - gh - 0.5 * z; match valign { VAlign::Top => y_top, VAlign::Center | VAlign::Justify | VAlign::Distributed => (y_top + y_bottom) / 2.0, @@ -145,15 +144,29 @@ pub(crate) fn wrapped_cell_text_y(valign: VAlign, rect: Rect, gh: f32, z: f32) - } } +/// Bounds of the primary font's line boxes, excluding extra row height added only for fallbacks. +/// Centered fallbacks do not identify that baseline; empty or centered-only rows use their layout bounds. +pub(crate) fn galley_baseline_bounds(galley: &egui::Galley) -> (f32, f32) { + let row_bounds = |row: &egui::epaint::text::PlacedRow| { + row.glyphs + .iter() + .find(|glyph| { + glyph.font_face_align == egui::epaint::text::FallbackAlign::Baseline + || (glyph.font_face_ascent == glyph.font_ascent && glyph.font_face_height == glyph.font_height) + }) + .map_or((row.rect().top(), row.rect().bottom()), |glyph| { + let top = row.pos.y + glyph.pos.y - glyph.font_ascent; + (top, top + glyph.font_height) + }) + }; + let first = galley.rows.first().map(|row| row_bounds(row).0); + let last = galley.rows.last().map(|row| row_bounds(row).1); + first.zip(last).unwrap_or((0.0, galley.size().y)) +} + pub(crate) fn cell_text_y(valign: VAlign, rect: Rect, gh: f32, z: f32) -> f32 { - let top_inset = 1.0 * z; - let y_top = rect.top() - top_inset; - let y_bottom = (rect.bottom() - gh - 0.5 * z).max(y_top); - match valign { - VAlign::Top => y_top, - VAlign::Center | VAlign::Justify | VAlign::Distributed => (y_top + y_bottom) / 2.0, - VAlign::Bottom => y_bottom, - } + let y_top = wrapped_cell_text_y(VAlign::Top, rect, gh, z); + wrapped_cell_text_y(valign, rect, gh, z).max(y_top) } /// Where to put a `TextShape` holding a galley of `size` turned by `angle` (egui radians, so diff --git a/crates/ui-egui/src/grid/quadrant.rs b/crates/ui-egui/src/grid/quadrant.rs index c278c4bab..dedead69e 100644 --- a/crates/ui-egui/src/grid/quadrant.rs +++ b/crates/ui-egui/src/grid/quadrant.rs @@ -1,7 +1,7 @@ use super::active_row::paint_active_row; use super::cell_paint::{ - Look, MergedArea, cell_text_y, center_across_end, col32, display_text, look, merged_edges, paint_border, paint_cf_icon, painted_halign, - rotated_text_pos, shrink_font, wrapped_cell_text_y, + Look, MergedArea, cell_text_y, center_across_end, col32, display_text, galley_baseline_bounds, look, merged_edges, paint_border, paint_cf_icon, + painted_halign, rotated_text_pos, shrink_font, wrapped_cell_text_y, }; use super::geo::Geo; use super::tables::{filter_button_rect, paint_filter_button}; @@ -394,7 +394,8 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option rect.right() - pad - galley.rect.right(), _ => text_left + pad + indent - galley.rect.left(), }; - let y = wrapped_cell_text_y(st.align.v, *rect, galley.size().y, z); + let (text_top, text_bottom) = galley_baseline_bounds(&galley); + let y = wrapped_cell_text_y(st.align.v, *rect, text_bottom - text_top, z) - text_top; let text_clip = wrapped_text_clip(p, *rect, &galley, y, st.align.v); p.with_clip_rect(text_clip).galley(pos2(x, y), galley, color); return; @@ -498,8 +499,9 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option rect.right() - pad - w - indent, _ => text_left + pad + indent, }; - let gh = galley.size().y; - let y = cell_text_y(st.align.v, *rect, gh, z); + let (text_top, text_bottom) = galley_baseline_bounds(&galley); + let text_height = text_bottom - text_top; + let y = cell_text_y(st.align.v, *rect, text_height, z) - text_top; let cp = p.with_clip_rect(clip.intersect(p.clip_rect())); if rotated { let angle = -(st.align.rotation as f32).to_radians(); @@ -518,7 +520,10 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option= rect.top()); + assert_eq!(y_top_ovf, y_top); } #[test] @@ -277,9 +330,9 @@ fn wrapped_cell_text_y_keeps_vertical_alignment_when_text_is_taller_than_the_row let y_center = wrapped_cell_text_y(VAlign::Center, rect, gh, z); let y_bottom = wrapped_cell_text_y(VAlign::Bottom, rect, gh, z); // Taller text overflows upward for Bottom and both ways for Center (the cell clips it). - assert!((y_top - 1.5).abs() < 0.01, "Top starts at the top edge, got {y_top}"); - assert!((y_center + gh / 2.0 - rect.center().y).abs() < 0.01, "Center is centred, got {y_center}"); - assert!((y_bottom + gh - (rect.bottom() - 1.5)).abs() < 0.01, "Bottom ends at the bottom edge, got {y_bottom}"); + assert!((y_top - 1.5 * z).abs() < 0.01, "Top leaves space inside the cell, got {y_top}"); + assert!((y_center + gh / 2.0 - (rect.center().y + 0.5 * z)).abs() < 0.01, "Center follows the shared insets, got {y_center}"); + assert!((y_bottom + gh - (rect.bottom() - 0.5 * z)).abs() < 0.01, "Bottom keeps the single-line inset, got {y_bottom}"); } /// The screen box of a galley of `size` drawn at `pos` turned by `angle` about `pos`, as egui diff --git a/crates/ui-egui/src/ribbon/title_bar.rs b/crates/ui-egui/src/ribbon/title_bar.rs index 9f7c2197b..29bd4f270 100644 --- a/crates/ui-egui/src/ribbon/title_bar.rs +++ b/crates/ui-egui/src/ribbon/title_bar.rs @@ -227,13 +227,18 @@ mod title_bar_tests { assert_eq!(title_max_width(500.0, 520.0, 900.0), 0.0); } - /// Lay out a title in a real egui frame, returning (title text, title width, dot width). + /// Lay out a title in a real egui frame, checking baselines and returning (text, width, dot width). fn layout_title(title: &str, dirty: bool, max_w: f32) -> (String, f32, f32) { let ctx = egui::Context::default(); ctx.set_fonts(theme::font_definitions()); let mut out = (String::new(), 0.0, 0.0); let mut frame = ctx.run_ui(egui::RawInput::default(), |ui| { let (galley, dot) = title_galleys(ui.painter(), title, dirty, max_w, Color32::WHITE); + for row in &galley.rows { + if let Some(first) = row.glyphs.first() { + assert!(row.glyphs.iter().all(|glyph| (glyph.pos.y - first.pos.y).abs() < 0.01), "title glyphs share a baseline: {title:?}"); + } + } // The glyphs actually drawn: `galley.text()` is the untruncated source. let shown = galley.rows.iter().flat_map(|r| r.glyphs.iter().map(|g| g.chr)).collect(); out = (shown, galley.size().x, dot.map_or(0.0, |d| d.size().x)); @@ -244,7 +249,7 @@ mod title_bar_tests { #[test] fn long_title_is_truncated_with_an_ellipsis() { - let long = format!("upload-{}-cleaned (1).xlsx", "3FMyNTYg2gDaANiAAACq20AAAAQMBqug".repeat(4)); + let long = format!("资产清单2026第4季度-{}-中文 (1).xlsx", "3FMyNTYg2gDaANiAAACq20AAAAQMBqug".repeat(4)); let (text, w, _) = layout_title(&long, false, 300.0); assert!(text.ends_with('…'), "truncated title ends with an ellipsis: {text:?}"); assert!(text.chars().count() < long.chars().count()); diff --git a/crates/ui-egui/src/theme/font_fallback.rs b/crates/ui-egui/src/theme/font_fallback.rs index 65479d017..7d99a7e88 100644 --- a/crates/ui-egui/src/theme/font_fallback.rs +++ b/crates/ui-egui/src/theme/font_fallback.rs @@ -35,7 +35,9 @@ pub(super) fn load_faces(fonts: &mut FontDefinitions, faces: Vec<(&'static str, if loaded.contains(path) { break; // a face loaded for an earlier script already covers this one } - if let Some(fd) = try_load(&[(path.as_str(), *index)]) { + if let Some(mut fd) = try_load(&[(path.as_str(), *index)]) { + // CJK text shares the Latin baseline; symbols and emoji keep box centering. + fd.tweak.set_baseline_fallback(key.starts_with("sys-cjk-")); fonts.font_data.insert(key.to_string(), Arc::new(fd)); keys.push(key.to_string()); loaded.push(path.clone()); diff --git a/crates/ui-egui/src/theme/font_setup.rs b/crates/ui-egui/src/theme/font_setup.rs index da995c489..31e4c63eb 100644 --- a/crates/ui-egui/src/theme/font_setup.rs +++ b/crates/ui-egui/src/theme/font_setup.rs @@ -15,6 +15,19 @@ pub struct CraftFont { pub bytes: &'static [u8], } +impl CraftFont { + /// CJK fallback faces share the primary baseline; symbols keep their centered metrics. + fn font_data(&self) -> FontData { + let mut data = FontData::from_static(self.bytes); + data.tweak.set_baseline_fallback( + self.scripts + .iter() + .any(|script| matches!(*script, "Hani" | "Hans" | "Hant" | "Jpan" | "Hira" | "Kana" | "Hrkt" | "Hang" | "Kore" | "Bopo")), + ); + data + } +} + mod craft_fonts { use super::CraftFont; include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs")); @@ -199,7 +212,7 @@ pub(super) fn craft_font_faces() -> Vec<(String, &'static CraftFont)> { fn add_craft_fonts(fonts: &mut FontDefinitions) -> Vec { 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))); + fonts.font_data.insert(key.clone(), Arc::new(font.font_data())); keys.push(key); } keys @@ -621,6 +634,22 @@ fn append_craft_faces(fonts: &mut FontDefinitions) { mod font_binding_tests { use super::*; + /// Synthetic bytes make this metadata check independent of the host's installed fonts. + #[test] + fn craft_cjk_scripts_opt_into_the_shared_baseline() { + use egui::epaint::text::FallbackAlign; + const CJK: &[&[&str]] = &[&["Hani", "Hans"], &["Hant", "Bopo"], &["Jpan", "Latn"], &["Hira", "Kana"], &["Hrkt", "Latn"], &["Hang", "Kore"]]; + for scripts in CJK { + let font = CraftFont { family: "Synthetic", style: "Regular", scripts, bytes: epaint_default_fonts::UBUNTU_LIGHT }; + assert_eq!(font.font_data().tweak.fallback_align, FallbackAlign::Baseline); + } + const OTHER: &[&[&str]] = &[&["Zsym", "Zsye"], &["Latn", "Arab"]]; + for scripts in OTHER { + let font = CraftFont { family: "Synthetic", style: "Regular", scripts, bytes: epaint_default_fonts::UBUNTU_LIGHT }; + assert_eq!(font.font_data().tweak.fallback_align, FallbackAlign::Center, "symbols and non-CJK keep their alignment"); + } + } + /// The families [`font_definitions`] always binds, whatever is installed. fn always_bound() -> Vec { [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO].iter().map(|s| s.to_string()).collect() diff --git a/crates/ui-egui/tests/cjk_baseline.rs b/crates/ui-egui/tests/cjk_baseline.rs new file mode 100644 index 000000000..21b62e557 --- /dev/null +++ b/crates/ui-egui/tests/cjk_baseline.rs @@ -0,0 +1,206 @@ +//! Mixed-script baselines and clipping, without starting the installed-font scanner. + +use egui::{Color32, Context, FontFamily, FontId}; +use gridcraft_engine::{Session, core::CellRef}; +use gridcraft_ui_egui::{SheetApp, i18n::Language, theme}; +use serde_json::json; + +fn activate(ctx: &Context, definitions: egui::FontDefinitions) { + ctx.set_fonts(definitions); + let mut out = ctx.run_ui(Default::default(), |_| {}); + out.textures_delta.clear(); +} + +/// Allow deferred font binding and editor layout to settle before inspecting painted shapes. +fn frames(ctx: &Context, app: &mut SheetApp) -> egui::FullOutput { + let mut out = egui::FullOutput::default(); + for _ in 0..4 { + out = ctx.run_ui( + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1800.0, 900.0))), ..Default::default() }, + |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }, + ); + out.textures_delta.clear(); + } + out +} + +/// Check layout baselines rather than bitmap bounds, which differ between scripts. +fn assert_shared_baselines(galley: &egui::Galley) { + for row in &galley.rows { + if let Some(first) = row.glyphs.first() { + for glyph in &row.glyphs { + assert!((glyph.pos.y - first.pos.y).abs() < 0.01, "{:?} is off the shared baseline in {:?}", glyph.chr, galley.text()); + } + } + } +} + +fn primary_text_bounds(galley: &egui::Galley) -> (f32, f32) { + let first = galley.rows.iter().find_map(|row| row.glyphs.first().map(|glyph| row.pos.y + glyph.pos.y - glyph.font_ascent)); + let last = + galley.rows.iter().rev().find_map(|row| row.glyphs.first().map(|glyph| row.pos.y + glyph.pos.y - glyph.font_ascent + glyph.font_height)); + first.zip(last).unwrap_or((0.0, galley.size().y)) +} + +/// Exercise fallback alignment across cell styles, title fonts, zoom, and automatic wrapping. +#[test] +fn cjk_and_latin_share_a_baseline_in_cells_and_window_titles() { + let ctx = Context::default(); + ctx.set_pixels_per_point(2.0); + activate(&ctx, theme::font_definitions_for_language(Language::Zh)); + for role in [theme::CELL, theme::CELL_BOLD, theme::CELL_ITALIC, theme::CELL_BOLD_ITALIC, theme::UI_BOLD] { + for zoom in [0.8, 1.0, 2.8] { + let font = FontId::new(if role == theme::UI_BOLD { 13.0 } else { 16.0 * zoom }, FontFamily::Name(role.into())); + if !ctx.fonts_mut(|f| "中文".chars().all(|c| f.has_glyph(&font, c))) { + eprintln!("skipped {role} baseline: no CJK face"); + break; + } + for width in [f32::INFINITY, 100.0 * zoom] { + let galley = ctx.fonts_mut(|f| f.layout("中文ABC2026中文ABC2026".into(), font.clone(), Color32::BLACK, width)); + assert_shared_baselines(&galley); + } + } + } +} + +/// Baseline alignment must not override workbook row heights, clipping, or editor sizing. +#[test] +fn mixed_script_wrapping_keeps_fixed_row_clips_alignment_and_editor_height() { + const TEXT: &str = "中文ABC2026\n文字xyz123"; + for zoom in [80, 100, 280] { + let ctx = Context::default(); + ctx.set_pixels_per_point(2.0); + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range":"A1:C1", "values":[[TEXT, TEXT, TEXT]]})).unwrap(); + session.execute("home.columnWidth", json!({"cols":"A:C", "width":180})).unwrap(); + session.execute("home.rowHeight", json!({"rows":"1:1", "height":32})).unwrap(); + for (cell, align) in [("A1", "Top"), ("B1", "Center"), ("C1", "Bottom")] { + session.execute("home.formatCells", json!({"range":cell, "style":{"align":{"wrap":true, "v":align}}})).unwrap(); + } + session.execute("view.zoom", json!({"percent":zoom})).unwrap(); + let mut app = SheetApp::new(session, Default::default()); + let out = frames(&ctx, &mut app); + let font = { + let doc = app.session.active().unwrap(); + doc.wb.styles.get(doc.wb.active().unwrap().style_id(CellRef::new(0, 0))).font.clone() + }; + let id = FontId::new(font.size * 96.0 / 72.0 * zoom as f32 / 100.0, theme::cell_family(&font.name, font.bold, font.italic)); + if !ctx.fonts_mut(|f| "中文字".chars().all(|c| f.has_glyph(&id, c))) { + eprintln!("skipped CJK grid/editor layout: no CJK face"); + return; + } + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = gridcraft_ui_egui::grid::Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + assert_eq!(sheet.row_height(0), 32.0, "rendering must preserve the manual row height"); + for col in 0..3 { + let rect = geo.cell_rect(sheet, CellRef::new(0, col)); + let (shape, clip) = out + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::Text(t) if t.galley.text() == TEXT && (t.pos.x - rect.left()).abs() < 5.0 * geo.z => Some((t, s.clip_rect)), + _ => None, + }) + .expect("cell text is painted"); + assert_shared_baselines(&shape.galley); + assert_eq!(shape.galley.rows.len(), 2); + assert!(clip.top() >= rect.top() && clip.bottom() <= rect.bottom(), "fixed-height cells clip their own text"); + let (text_top, text_bottom) = primary_text_bounds(&shape.galley); + let expected_top = match col { + 0 => rect.top() + 1.5 * geo.z, + 1 => rect.center().y - (text_bottom - text_top) / 2.0 + 0.5 * geo.z, + _ => rect.bottom() - (text_bottom - text_top) - 0.5 * geo.z, + }; + assert!((shape.pos.y + text_top - expected_top).abs() < 1.0, "vertical alignment stays effective at {zoom}%"); + } + app.begin_edit(None, false); + let out = frames(&ctx, &mut app); + let field = ctx.read_response(egui::Id::unique("gridcraft.cell_editor")).expect("editor is visible").rect; + let editor = out + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::Text(t) if t.galley.text() == TEXT && field.contains(t.pos) => Some(t), + _ => None, + }) + .expect("editor text is painted"); + assert!(field.height() >= editor.galley.size().y, "the editor grows to fit its painted CJK lines at {zoom}%"); + assert_shared_baselines(&editor.galley); + } +} + +#[test] +fn same_row_cells_keep_their_baseline_with_baseline_aligned_fallbacks() { + for wraps in [[false, false], [true, true], [false, true], [true, false]] { + for valign in ["Top", "Bottom", "Center"] { + let mixed = if wraps[1] && valign == "Top" { "Hello\n中文" } else { "Hello 中文" }; + let ctx = Context::default(); + ctx.set_pixels_per_point(2.0); + activate(&ctx, theme::font_definitions_for_language(Language::Zh)); + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range":"A1:B1", "values":[[1, mixed]]})).unwrap(); + session.execute("home.columnWidth", json!({"cols":"A:B", "width":180})).unwrap(); + for (cell, wrap) in ["A1", "B1"].into_iter().zip(wraps) { + session.execute("home.formatCells", json!({"range":cell, "style":{"align":{"wrap":wrap, "v":valign}}})).unwrap(); + } + let font = { + let doc = session.active().unwrap(); + doc.wb.styles.get(doc.wb.active().unwrap().style_id(CellRef::new(0, 0))).font.clone() + }; + let font_id = FontId::new(font.size * 96.0 / 72.0, theme::cell_family(&font.name, font.bold, font.italic)); + if !ctx.fonts_mut(|fonts| fonts.has_glyph(&font_id, '中')) { + eprintln!("skipped same-row fallback baseline: no CJK face"); + return; + } + let mut app = SheetApp::new(session, Default::default()); + let out = frames(&ctx, &mut app); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = gridcraft_ui_egui::grid::Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + let mut baselines = Vec::new(); + for (col, text) in ["1", mixed].iter().enumerate() { + let rect = geo.cell_rect(sheet, CellRef::new(0, col as u32)); + let shape = out + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::Text(t) + if t.galley.text() == *text && t.visual_bounding_rect().intersect(s.clip_rect).intersect(rect).is_positive() => + { + Some(t) + } + _ => None, + }) + .expect("cell text is painted"); + assert_shared_baselines(&shape.galley); + if valign == "Top" { + let (text_top, _) = primary_text_bounds(&shape.galley); + assert!(shape.pos.y + text_top > rect.top(), "top-aligned text keeps space inside the cell above its primary line box"); + } + let row = shape.galley.rows.first().expect("cell text has a row"); + let glyph = row.glyphs.first().expect("first-line baseline is present"); + if text.contains('中') { + assert_eq!(shape.galley.rows.len(), if mixed.contains('\n') { 2 } else { 1 }); + assert!( + shape + .galley + .rows + .iter() + .flat_map(|row| &row.glyphs) + .any(|glyph| { glyph.chr == '中' && glyph.font_face_align == egui::epaint::text::FallbackAlign::Baseline }), + "CJK fallback must use baseline alignment" + ); + } + baselines.push(shape.pos.y + row.pos.y + glyph.pos.y); + } + assert!( + (baselines[0] - baselines[1]).abs() < 0.6, + "same-row cells with wraps={wraps:?}, {valign} alignment share a baseline: {baselines:?}" + ); + } + } +} diff --git a/crates/ui-egui/tests/it/text_overflow_scroll.rs b/crates/ui-egui/tests/it/text_overflow_scroll.rs index 371c0aeee..4790674c6 100644 --- a/crates/ui-egui/tests/it/text_overflow_scroll.rs +++ b/crates/ui-egui/tests/it/text_overflow_scroll.rs @@ -211,8 +211,8 @@ fn wrapped_text_in_fixed_height_row_shows_only_complete_lines() { let mut full_rows_visible = 0; for (index, row) in text.galley.rows.iter().enumerate() { - let top = text.pos.y + row.rect().top(); - let bottom = text.pos.y + row.rect().bottom(); + let top = text.pos.y + row.pos.y + row.visuals.mesh_bounds.top(); + let bottom = text.pos.y + row.pos.y + row.visuals.mesh_bounds.bottom(); let full_height = bottom - top; let visible_height = (bottom.min(clip.bottom()) - top.max(clip.top())).max(0.0); diff --git a/docs/architecture.md b/docs/architecture.md index 68639541e..febe96fcd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (Python mode in the formula bar; earlier: Python cells as data; earlier: equations: the `math` crate and the layout cache; earlier: recently used functions kept in the preferences; Function Library module; accent icons; earlier: the hidden names that hold macros) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (shared CJK fallback baselines; earlier: Python mode in the formula bar; earlier: Python cells as data; earlier: equations: the `math` crate and the layout cache; earlier: recently used functions kept in the preferences; Function Library module; accent icons; earlier: the hidden names that hold macros) · **Target:** Microsoft Excel (Microsoft 365) How GridCraft is built today. Rules for contributors are in [`AGENTS.md`](../AGENTS.md). @@ -84,6 +84,12 @@ drawing its own: ahead of its input, so the field never gets a Tab or an Enter of a focused editor; and it scrolls past `max_lines` with the caret in view. The tokenizer and the key edits are pure functions with tests, including hostile input. +- **CJK text baselines**: system CJK fallback faces and permitted embedded faces tagged with + CJK scripts use epaint's baseline fallback alignment in cells, editors and window titles. + Other scripts, symbols and emoji keep their existing alignment. Baseline alignment can + increase line height; manual row heights and clipping remain unchanged. Reports: + [#1265](https://github.com/storytold/gridcraft/issues/1265) and + [#2621](https://github.com/storytold/gridcraft/issues/2621). - **Code editor marks**: `code_editor_marked` underlines a byte range in the error colour (the equation editor marks the position of a LaTeX error with it). - **Formula bar**: grows with its content from one line to `formula_bar::MAX_LINES` (five), then @@ -242,6 +248,7 @@ rpm, tarball), FreeBSD, and web (WASM; WebGPU or WebGL2). Release workflows in | 2026-11-01 | minor | Equations: the `math` crate (L1), the layout cache and code editor marks | | 2026-10-31 | minor | Recently used functions (`recent.rs`, `Prefs::recent_functions`) behind `formulas.recentlyUsed` | | 2026-10-31 | minor | Function Library module (`function_library.rs`); accent icons (`icons::paint_accent`, `widgets::small_button_accent`) | +| 2026-10-31 | minor | System and embedded CJK fallback faces share the Latin baseline without changing other script, symbol or emoji alignment | | 2026-10-25 | trivial | The hidden names that hold macros are `_gc_macro__` | | 2026-10-24 | minor | Macro engine on the building blocks (`macros/`, `cmd/macros.rs`) | | 2026-10-24 | minor | Engine building blocks for multi-command actions: undo groups, recording buffer, document trust, chunked hidden-name storage | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index b59b5dcef..9f5188948 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (Arabic bidi display, editing and cell reading direction; missing translations are listed by `cargo xtask i18n-report` and never fail a check); previously minor (Arabic font fallback; missing translations are listed by `cargo xtask i18n-report` and never fail a check); previously minor (number formats honour the `B1`/`B2` calendar prefixes, checked against Excel; earlier: missing translations are listed by `cargo xtask i18n-report` and never fail a check; Arabic text sorts by its letters, not its vowel marks); previously minor (AutoFit estimates Arabic text width from measured glyph widths); previously minor (missing translations are listed by `cargo xtask i18n-report` and never fail a check; Arabic text sorts by its letters, not its vowel marks) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (CJK fallback baseline alignment); previously minor (Arabic bidi display, editing and cell reading direction; missing translations are listed by `cargo xtask i18n-report` and never fail a check); previously minor (Arabic font fallback; missing translations are listed by `cargo xtask i18n-report` and never fail a check); previously minor (number formats honour the `B1`/`B2` calendar prefixes, checked against Excel; earlier: missing translations are listed by `cargo xtask i18n-report` and never fail a check; Arabic text sorts by its letters, not its vowel marks); previously minor (AutoFit estimates Arabic text width from measured glyph widths); previously minor (missing translations are listed by `cargo xtask i18n-report` and never fail a check; Arabic text sorts by its letters, not its vowel marks) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Per-language status against Excel, which ships its UI in dozens of languages (Windows language packs cover 100+), with localized function names, argument separators, number and date formats, @@ -61,7 +61,10 @@ Cell text now uses shared Unicode bidi layout while retaining logical source ind #142 is still open pending confirmation). Native CJK fallback also probes Flatpak's read-only `/run/host/fonts`, `/run/host/local-fonts` and `/run/host/user-fonts` layouts, so installed Noto CJK and WenQuanYi faces can render Chinese without a broad filesystem grant. This does - not change the installed-font catalogue or add bundled fonts. Lao falls back to Lao Sangam MN / Lao MN (macOS), + not change the installed-font catalogue or add bundled fonts. System CJK fallback faces and + embedded faces tagged with CJK scripts share the Latin baseline in cells, editors and titles + (#1265, #2621); other script, emoji and symbol fallbacks retain their alignment. + Lao falls back to Lao Sangam MN / Lao MN (macOS), Leelawadee UI / DokChampa (Windows) or Noto Sans Lao / Phetsarath OT (Linux). egui uses harfrust shaping and Unicode bidi layout; script-specific coverage beyond Arabic still needs verification. **Sheet Right-to-Left** Leelawadee UI / DokChampa (Windows) or Noto Sans Lao / Phetsarath OT (Linux). Arabic falls back to a system Arabic face, fontconfig's @@ -130,6 +133,7 @@ language (none so far) and of localized function names. | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | System and embedded CJK text fallbacks share the Latin baseline in cells, editors and window titles; other scripts, symbols and emoji keep their alignment | | 2026-10-26 | minor | Shared bidi layout supports Arabic clusters, marks, visual cursor navigation and directional accessibility; cell Context/LTR/RTL is stored separately from sheet RTL and round-trips through XLSX | | 2026-10-26 | minor | Arabic text renders in cells and the interface: a system Arabic face (Tahoma, Arial or Segoe UI on Windows, Geeza Pro on macOS, Noto Sans or Naskh Arabic on Linux) joins the script fallbacks, fontconfig's Arabic choice, when it has Arabic letters, leads the Arabic fallbacks and draws Arabic wherever the UI or cell font has none, such as the default cell fonts (part of #229; a UI font with Arabic of its own, such as DejaVu Sans in the Flatpak, still draws interface Arabic, left for a follow-up), and craft-fonts' Noto Sans Arabic is a required face allowed on the web. Workbook fonts without Arabic keep every script fallback. Shaping and bidirectional layout remain separate work | | 2026-10-27 | minor | Number formats honour the `B1`/`B2` calendar prefixes: never shown; `B2` renders year, month, day and Arabic month and weekday names in the tabular Hijri calendar (1 Jan 2001 = 6 Shawwal 1421; leap years 2, 5, 7, 10, 13, 15, 18, 21, 24, 26 and 29 of the 30-year cycle, which is what Excel shows). Checked against Excel 16.0 (build 20527, en-US) on every serial and about 1,700 format/value pairs: a locale or currency tag before `B2` keeps the date Gregorian, a misplaced `B` and digit or a prefix before a number format is rejected, serial 60 has no Hijri date, serial 0 shows as serial 1, two-word month names use a no-break space. Not matched: Excel's weekday/month-name mix-ups in one `B2` section and its weekday-only quirks | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b77acc0b4..f5996328e 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (form controls: buttons, sliders and spin buttons on the sheet; earlier: Highlight the active cell's row, a global preference in File › Options › General; earlier: a linked cell's menu edits, opens or removes the link; earlier: Display options for this workbook: sheet tabs and scroll bars; earlier: dragging over row or column headers in point mode extends the reference; earlier: a picture on the system clipboard pastes as a floating picture at the active cell with Cmd/Ctrl+V or Paste (#699); earlier: frozen columns keep their text, gridlines and merged borders whole while the sheet scrolls (#2544); earlier: every Paste Special option has its access key; earlier: the Draw tab's live stroke preview uses the pen's colour and thickness; earlier: copy quotes only cells with a tab or line break, as Excel does; earlier: Ctrl+. Next Corner of Selection; earlier: dragging the selection's border onto data asks before replacing it; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (preserve centered emoji and symbol fallback placement; earlier: preserve leading and trailing blank cell lines; earlier: align wrapped and unwrapped cells; earlier: CJK title baseline regression and mixed-script rendering validation; earlier: form controls: buttons, sliders and spin buttons on the sheet; earlier: Highlight the active cell's row, a global preference in File › Options › General; earlier: a linked cell's menu edits, opens or removes the link; earlier: Display options for this workbook: sheet tabs and scroll bars; earlier: dragging over row or column headers in point mode extends the reference; earlier: a picture on the system clipboard pastes as a floating picture at the active cell with Cmd/Ctrl+V or Paste (#699); earlier: frozen columns keep their text, gridlines and merged borders whole while the sheet scrolls (#2544); earlier: every Paste Special option has its access key; earlier: the Draw tab's live stroke preview uses the pen's colour and thickness; earlier: copy quotes only cells with a tab or line break, as Excel does; earlier: Ctrl+. Next Corner of Selection; earlier: dragging the selection's border onto data asks before replacing it; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -235,6 +235,24 @@ pairs (text 4.5:1, glyphs 3:1) are checked by `theme::contrast_tests`. ## Feel +Mixed CJK, Latin and digits share a baseline in the grid, editor and window title when CJK +fallback fonts are used. Baseline alignment can increase line height; manual row heights +continue to clip oversized text. + +Wrapped and unwrapped cells use the same vertical margins. With the same font and top +alignment, a number aligns with the first line of neighbouring wrapped text; fallback font +height does not shift the primary font's baseline between cells. Leading and trailing blank +lines retain their space when positioning wrapped text. Emoji and symbol fallbacks keep their +centering when they start a line or occupy it alone. + +Offscreen macOS renders at 80%, 100% and 280% cover mixed text, regular/bold/italic/bold-italic +roles, automatic wrapping, explicit line breaks and a `yyyy年m月d日` date. The before/after +comparison uses the same synthetic cells and the default 10 pt cell role: Arial with +Hiragino Sans GB fallback on the tested Mac. +Automated regressions check baseline positions, including the actual truncated title layout. +Glyph scaling is unchanged; the original Calibri 10 visual-size case still needs confirmation +on a host with that font installed. + Pixel comparison against Excel screenshots hasn't been run since M1; fonts depend on the system (no Calibri; Carlito would match metrics, #29, #4, #168 for weights); icons are GridCraft's own by rule and will never match Office's, by design. @@ -249,6 +267,10 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-05 | minor | Reconcile upstream form-control coverage with the CJK baseline and cell-alignment updates | +| 2026-11-04 | minor | Keep centered emoji and symbol fallback placement when a row starts with a fallback or contains only fallbacks; use primary or baseline-aligned glyphs to anchor cell alignment | +| 2026-11-04 | minor | Preserve leading and trailing blank lines in wrapped cells when calculating vertical alignment; regression covers single, multiple and all-empty lines | +| 2026-11-04 | minor | Align wrapped and unwrapped cells with the same vertical margins; regressions compare numbers with mixed CJK/Latin text, including explicit line breaks | | 2026-11-02 | minor | Form controls: buttons, sliders (scroll bars) and spin buttons placed, worked, selected and edited on the sheet (Insert › Controls, Assign Action, Format Control); catalog 281 / 296 | | 2026-11-03 | minor | Highlight the active cell's row: a global preference that tints the whole row, off by default | | 2026-11-03 | minor | The formula bar's bottom edge is a resize handle: dragging it sets the bar's height in whole lines, up to half the window, and dragging it to one line collapses it; expanding (the chevron, Ctrl+Shift+U) returns to the dragged height, which is kept with the UI preferences. `view.expandFormulaBar {lines}` sets it (#1246) | @@ -267,13 +289,16 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | | 2026-11-02 | minor | Floating objects: one selection for charts, pictures, shapes and slicers, eight resize handles with matching cursors (right-to-left sheets included), Escape drops the selection | +| 2026-11-02 | minor | Record macOS mixed CJK/Latin rendering validation, the actual fallback fonts and the unverified Calibri 10 case | | 2026-11-01 | minor | Chart Design › Add Chart Element offers Data Table with/no legend keys and None; Format Chart Area has a Data Table section with visibility, legend keys, horizontal/vertical borders and outline. Both use engine commands. | | 2026-11-01 | minor | Page Setup has a Page order choice (Down, then over, the default, or Over, then down) with a small picture of the order, also when Print Titles opens it on its Sheet tab; OK sets it with the margins in one undo step. Printing, PDF export, the print preview, Page Break Preview's page numbers and Page Layout view's headers and footers follow the order. The dialog is in German now too | | 2026-11-01 | minor | Formulas › Error Checking › Trace Error (`formulas.traceError`): red arrows from the cell an error starts in to the selected cell, through the first reference showing the same error at each step, and blue arrows from that cell's own references, as measured in Excel; the command returns the source and the arrows | | 2026-11-01 | minor | Trace arrows stay when the sheet is zoomed (View › Zoom, the status bar, Ctrl+wheel), as in Excel; every zoom used to remove them (#3097) | +| 2026-11-01 | minor | Assert shared baselines in the actual title layout and record manual visual-size validation of a compiled macOS build | | 2026-10-31 | minor | Esc in a File-page text field leaves the field and cancels editing; the next Esc returns to the sheet (#3035) | | 2026-10-31 | minor | Recently Used lists the functions most recently entered in a formula from any source, kept with the preferences; the Insert Function dialog is titled as in Excel, its category list (Most Recently Used, All, each category) works, and its category column follows the widest name shown | | 2026-10-31 | minor | Formulas › Function Library: Insert Function, the AutoSum split button and three columns of labelled small buttons with category colours; category menus end with Insert Function…; More Functions cascades; Home › Editing shares the AutoSum menu; MR key tip for Recently Used | +| 2026-10-31 | minor | CJK fallback text shares the Latin baseline in cells, editors and window titles; fixed row heights and clipping remain workbook-controlled | | 2026-10-30 | minor | Checkbox cells print and export to PDF as boxes, as on screen: unchecked white with a grey outline, checked green with a white tick (#2972). They printed their value as TRUE or FALSE; other values in a checkbox cell still print as text | | 2026-10-29 | minor | Formula pointing from the keyboard: Ctrl+arrow (⌘arrow) moves the pointed cell to the edge of its data and Ctrl+Shift+arrow (⌘⇧arrow) extends the pointed range there, as the same keys move and extend the selection, so typing `=COUNTIF(`, clicking the top of a column and pressing ⌘⇧↓ points at the whole column of data; the keys used to move or extend by one cell. A range reaching the sheet edge is written as whole columns or rows (`A:B`). The editor reads the modifiers of the arrow's own key event, so a keystroke pressed and released within one frame (a control client's `ui.key`, an automation tool) keeps its Shift and Ctrl | | 2026-10-29 | minor | Insert Hyperlink opens with an existing link's address, display text and ScreenTip (#2876). An address-only edit preserves the cell's content and formula; Cancel leaves the link unchanged, and Undo restores the edit |