From b0533c16bfc608c1d66229e508eb53a2c3eb4035 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 17 Oct 2026 11:09:14 +0300 Subject: [PATCH] perf(grid): CJK font fallback for cells and the shared mono stack; cells share their text style and decoration (#1553, #1519) --- lib/core/theme/querya_typography.dart | 31 ++++ .../workspace/data_grid_groupings_view.dart | 3 + .../workspace/grid/grid_cell_renderer.dart | 156 +++++++++++++----- lib/features/workspace/grid_cell_editor.dart | 2 + lib/features/workspace/result_grid_view.dart | 1 + test/core/theme/querya_typography_test.dart | 16 +- 6 files changed, 162 insertions(+), 47 deletions(-) diff --git a/lib/core/theme/querya_typography.dart b/lib/core/theme/querya_typography.dart index a0fb4567..0ced8252 100644 --- a/lib/core/theme/querya_typography.dart +++ b/lib/core/theme/querya_typography.dart @@ -13,6 +13,9 @@ abstract class QueryaTypography { /// - macOS: Menlo, SF Mono, Monaco /// - Linux / BSD: Fira Code, Ubuntu Mono, DejaVu Sans Mono /// - Generic system fallback: monospace + /// + /// Ends with [cjkFontFamilyFallback], so CJK text in the editor, inspectors + /// and badges does not go through the engine's per-paragraph search. static const List monoFontFamilyFallback = [ 'Cascadia Code', 'Consolas', @@ -24,5 +27,33 @@ abstract class QueryaTypography { 'DejaVu Sans Mono', 'Courier New', 'monospace', + ...cjkFontFamilyFallback, + ]; + + /// Families that have CJK glyphs, to put after the primary family (#1553). + /// + /// When the primary family has none, the engine asks fontconfig (or the OS) + /// for a fallback for every paragraph and does not reuse the answer: about + /// 400 us per 18-character string instead of 10-30 us measured with an + /// explicit family. A family that is not installed costs 2-5 us, so the + /// list can be long. Use it alone where the primary family must stay the + /// generic `monospace` (result grid cells). + static const List cjkFontFamilyFallback = [ + // Linux + 'Noto Sans Mono CJK SC', + 'Noto Sans Mono CJK JP', + 'Noto Sans Mono CJK KR', + 'Noto Sans CJK SC', + 'Noto Sans CJK JP', + 'Noto Sans CJK KR', + 'WenQuanYi Micro Hei', + // macOS + 'PingFang SC', + 'Hiragino Sans', + 'Apple SD Gothic Neo', + // Windows + 'Microsoft YaHei', + 'Yu Gothic', + 'Malgun Gothic', ]; } diff --git a/lib/features/workspace/data_grid_groupings_view.dart b/lib/features/workspace/data_grid_groupings_view.dart index fa0722d7..a83284f2 100644 --- a/lib/features/workspace/data_grid_groupings_view.dart +++ b/lib/features/workspace/data_grid_groupings_view.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; import 'package:querya_desktop/features/workspace/grid_groupings_engine.dart'; import 'package:querya_desktop/features/workspace/result_grid_view.dart'; @@ -320,6 +321,8 @@ class _DataGridGroupingsViewState fontSize: 13, fontWeight: FontWeight.w600, fontFamily: 'monospace', + fontFamilyFallback: + QueryaTypography.cjkFontFamilyFallback, ), ), ), diff --git a/lib/features/workspace/grid/grid_cell_renderer.dart b/lib/features/workspace/grid/grid_cell_renderer.dart index 1fc339a0..4d9c889c 100644 --- a/lib/features/workspace/grid/grid_cell_renderer.dart +++ b/lib/features/workspace/grid/grid_cell_renderer.dart @@ -109,30 +109,29 @@ class _GridCell extends material.StatelessWidget { final isInserted = rowStatus == StagedRowStatus.inserted; final isModified = cellStatus == StagedCellStatus.modified; - final style = material.TextStyle( - fontSize: 12, - fontWeight: material.FontWeight.normal, - fontFamily: 'monospace', - color: isDeleted - ? colorScheme.destructive.withValues(alpha: 0.7) - : (isNull - ? colorScheme.mutedForeground.withValues(alpha: 0.5) - : colorScheme.foreground), - decoration: isDeleted ? material.TextDecoration.lineThrough : null, - fontStyle: isNull ? material.FontStyle.italic : material.FontStyle.normal, + final style = _CellLook.style( + colorScheme, + isNull: isNull, + isDeleted: isDeleted, + ); + final decoration = _CellLook.decoration( + colorScheme, + background: isSelected + ? _CellBackground.selected + : (isDeleted + ? _CellBackground.deleted + : (isModified + ? _CellBackground.modified + : (isInserted + ? _CellBackground.inserted + : (striped + ? _CellBackground.striped + : _CellBackground.none)))), + right: isSelectionRight, + left: isSelectionLeft, + top: isSelectionTop, + bottom: isSelectionBottom, ); - - final bg = isSelected - ? colorScheme.primary.withValues(alpha: 0.18) - : (isDeleted - ? colorScheme.destructive.withValues(alpha: 0.08) - : (isModified - ? colorScheme.primary.withValues(alpha: 0.14) - : (isInserted - ? colorScheme.primary.withValues(alpha: 0.08) - : (striped - ? colorScheme.muted.withValues(alpha: 0.12) - : material.Colors.transparent)))); // Primary/secondary gestures and hover tooltips are hoisted to the grid // level (#983, #1346). _GridCell is a lightweight leaf widget. @@ -144,28 +143,7 @@ class _GridCell extends material.StatelessWidget { height: double.infinity, padding: const material.EdgeInsets.symmetric(horizontal: 10), alignment: material.Alignment.centerLeft, - decoration: material.BoxDecoration( - color: bg, - border: material.Border( - right: material.BorderSide( - color: isSelectionRight - ? colorScheme.primary - : colorScheme.border.withValues(alpha: 0.3), - width: isSelectionRight ? 1.5 : 1.0, - ), - left: isSelectionLeft - ? material.BorderSide(color: colorScheme.primary, width: 1.5) - : material.BorderSide.none, - top: isSelectionTop - ? material.BorderSide(color: colorScheme.primary, width: 1.5) - : material.BorderSide.none, - bottom: isSelectionBottom - ? material.BorderSide(color: colorScheme.primary, width: 1.5) - : material.BorderSide( - color: colorScheme.border.withValues(alpha: 0.15), - ), - ), - ), + decoration: decoration, child: material.Stack( clipBehavior: material.Clip.none, alignment: material.Alignment.centerLeft, @@ -192,6 +170,94 @@ class _GridCell extends material.StatelessWidget { } } +enum _CellBackground { selected, deleted, modified, inserted, striped, none } + +/// The looks a cell can have are few, so each is built once per colour scheme +/// and shared (#1519): a scroll that builds a new row reuses them instead of +/// allocating a style, a decoration, a border and four sides per cell. +abstract final class _CellLook { + static ColorScheme? _scheme; + static final _styles = List.filled(4, null); + static final _decorations = {}; + + /// A new colour scheme (theme switch) drops what was built for the old one. + static void _forget(ColorScheme scheme) { + if (identical(_scheme, scheme)) return; + _scheme = scheme; + _styles.fillRange(0, _styles.length, null); + _decorations.clear(); + } + + static material.TextStyle style( + ColorScheme scheme, { + required bool isNull, + required bool isDeleted, + }) { + _forget(scheme); + final key = (isNull ? 1 : 0) | (isDeleted ? 2 : 0); + return _styles[key] ??= material.TextStyle( + fontSize: 12, + fontWeight: material.FontWeight.normal, + fontFamily: 'monospace', + // Without a CJK family the engine searches for a fallback per paragraph + // and is ~40x slower on CJK text (#1553). + fontFamilyFallback: QueryaTypography.cjkFontFamilyFallback, + color: isDeleted + ? scheme.destructive.withValues(alpha: 0.7) + : (isNull + ? scheme.mutedForeground.withValues(alpha: 0.5) + : scheme.foreground), + decoration: isDeleted ? material.TextDecoration.lineThrough : null, + fontStyle: isNull ? material.FontStyle.italic : material.FontStyle.normal, + ); + } + + static material.BoxDecoration decoration( + ColorScheme scheme, { + required _CellBackground background, + required bool right, + required bool left, + required bool top, + required bool bottom, + }) { + _forget(scheme); + final key = background.index | + (right ? 1 << 3 : 0) | + (left ? 1 << 4 : 0) | + (top ? 1 << 5 : 0) | + (bottom ? 1 << 6 : 0); + return _decorations[key] ??= material.BoxDecoration( + color: switch (background) { + _CellBackground.selected => scheme.primary.withValues(alpha: 0.18), + _CellBackground.deleted => scheme.destructive.withValues(alpha: 0.08), + _CellBackground.modified => scheme.primary.withValues(alpha: 0.14), + _CellBackground.inserted => scheme.primary.withValues(alpha: 0.08), + _CellBackground.striped => scheme.muted.withValues(alpha: 0.12), + _CellBackground.none => material.Colors.transparent, + }, + border: material.Border( + right: material.BorderSide( + color: right + ? scheme.primary + : scheme.border.withValues(alpha: 0.3), + width: right ? 1.5 : 1.0, + ), + left: left + ? material.BorderSide(color: scheme.primary, width: 1.5) + : material.BorderSide.none, + top: top + ? material.BorderSide(color: scheme.primary, width: 1.5) + : material.BorderSide.none, + bottom: bottom + ? material.BorderSide(color: scheme.primary, width: 1.5) + : material.BorderSide( + color: scheme.border.withValues(alpha: 0.15), + ), + ), + ); + } +} + class _TriangleCornerPainter extends material.CustomPainter { const _TriangleCornerPainter({required this.color}); final material.Color color; diff --git a/lib/features/workspace/grid_cell_editor.dart b/lib/features/workspace/grid_cell_editor.dart index 26196ac5..4ae8b71b 100644 --- a/lib/features/workspace/grid_cell_editor.dart +++ b/lib/features/workspace/grid_cell_editor.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_context.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/features/workspace/grid_data_type_validator.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; @@ -188,6 +189,7 @@ class _GridCellEditorState extends material.State { style: const material.TextStyle( fontSize: 12, fontFamily: 'monospace', + fontFamilyFallback: QueryaTypography.cjkFontFamilyFallback, ), decoration: const material.InputDecoration( border: material.InputBorder.none, diff --git a/lib/features/workspace/result_grid_view.dart b/lib/features/workspace/result_grid_view.dart index 13a7c306..bf1b8cdc 100644 --- a/lib/features/workspace/result_grid_view.dart +++ b/lib/features/workspace/result_grid_view.dart @@ -16,6 +16,7 @@ import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_context.dart'; import 'package:querya_desktop/core/security/pii_masker.dart'; import 'package:querya_desktop/core/security/pii_masking_controller.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/core/ui/querya_tooltip.dart'; import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; import 'package:querya_desktop/features/workspace/grid_cell_editor.dart'; diff --git a/test/core/theme/querya_typography_test.dart b/test/core/theme/querya_typography_test.dart index 8e43d56d..f9b136b9 100644 --- a/test/core/theme/querya_typography_test.dart +++ b/test/core/theme/querya_typography_test.dart @@ -17,8 +17,20 @@ void main() { expect(stack, contains('DejaVu Sans Mono')); expect(stack, contains('monospace')); - // Ensure fallback ends with generic 'monospace' - expect(stack.last, equals('monospace')); + // The generic 'monospace' closes the Latin stack; the CJK tail follows it + // (#1553), so Latin text keeps rendering in the monospace fonts. + const cjk = QueryaTypography.cjkFontFamilyFallback; + expect(stack.sublist(stack.length - cjk.length), equals(cjk)); + expect(stack[stack.length - cjk.length - 1], equals('monospace')); + }); + + test('CJK fallback names a family for each desktop platform (#1553)', () { + const cjk = QueryaTypography.cjkFontFamilyFallback; + expect(cjk, contains('Noto Sans CJK SC')); + expect(cjk, contains('PingFang SC')); + expect(cjk, contains('Microsoft YaHei')); + expect(cjk, isNot(contains('monospace'))); + expect(cjk.toSet().length, cjk.length); // no duplicates }); test('QueryaEditorTheme wires monospace typography stack by default', () {