From 8d006775bda7a4691cd00a2af1d466e0653e7612 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Mon, 14 Sep 2026 20:22:13 +0400 Subject: [PATCH 1/5] fix(theme): resolve a monospace family the platform actually knows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flutter passes fontFamily straight to the platform font manager. Android's font config and fontconfig on Linux resolve the CSS generic 'monospace'; CoreText (iOS, macOS) and DirectWrite (Windows) do not, so inline code and fenced blocks silently rendered in the proportional body face on both. Substitute a real face on exactly those two (Menlo, Consolas) and keep the generic everywhere it already worked, web included: its renderer cannot reach arbitrary system fonts, so naming one could only be worse than the generic the browser can answer. Rendering on Android, Fuchsia, Linux and web is unchanged. Also makes the face replaceable. Inline code went through textStyleFor, so subclassing the theme moved that half; fenced blocks did not, because BlockPainter$Code built its base style in a static method reading a global. The only ways out were abusing SyntaxHighlighter.baseStyleFor to set a font family or reimplementing the fence painter through theme.builder — and the two halves could not be changed by the same mechanism. MarkdownThemeData .monospaceFontFamily / .monospaceFontFamilyFallback now feed both. Split out of DoctorinaAI/md#27, where this arrived inside a commit about hit-testing, hardcoded Menlo-first for every platform, and was mentioned nowhere in the description or changelog. NOTE: font resolution is not observable in flutter_test (the harness uses a stub font), so the tests pin the family string we pass, not that it resolves. Needs a look on a real iOS / macOS / Windows build before merge. --- CHANGELOG.md | 21 +++++ README.md | 6 ++ docs/rendering.md | 5 +- lib/src/render/blocks/code.dart | 3 +- lib/src/theme.dart | 101 ++++++++++++++++++++- test/theme/theme_test.dart | 156 ++++++++++++++++++++++++++++++++ 6 files changed, 289 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0402849..7ba628d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,24 @@ +## Unreleased + +### Monospace font resolution +- **FIXED**: Inline `` `code` `` and fenced blocks pick a monospace family the + host platform can actually resolve. Flutter hands the family straight to the + platform font manager, and CoreText (iOS / macOS) and DirectWrite (Windows) + do not know the CSS generic `'monospace'` — code silently rendered in the + proportional body face there. Those two now get `Menlo` / `Consolas`. + **Android, Fuchsia, Linux and web keep `'monospace'`**: they resolve it + natively, so rendering there is unchanged. +- **ADDED**: `kMonospaceFontFamily` (platform-resolved) and + `kMonospaceFontFamilyFallback` (ends in `'monospace'`, so a platform missing + its primary face still lands on a real monospace one). +- **ADDED**: `MarkdownThemeData.monospaceFontFamily` / + `monospaceFontFamilyFallback`, plus the `effectiveMonospace…` getters, to pin + a bundled face. One knob covers both inline `` `code` `` and fenced blocks — + there was previously no supported way to change the fenced-block face at all: + `BlockPainter$Code` built its style in a static method reading a global, so + even overriding `textStyleFor` moved inline code only. An empty fallback list + means "no fallbacks", not "unset". + ## 0.2.0 > **Upgrading from 0.0.x?** See the diff --git a/README.md b/README.md index 1ede646..c07df4c 100644 --- a/README.md +++ b/README.md @@ -441,6 +441,12 @@ MarkdownTheme( linkStyle: const TextStyle( decoration: TextDecoration.underline, ), + // Monospace face for inline `code` AND fenced blocks. Leave it unset to + // get a family the host platform can actually resolve: `monospace` on + // Android / Linux / web, Menlo on iOS + macOS, Consolas on Windows + // (CoreText and DirectWrite do not know the CSS generic, so code would + // otherwise render in the proportional body face there). + monospaceFontFamily: 'JetBrains Mono', // Per-type accent colors for GitHub alert blocks alertColors: const { MD$AlertType.warning: Color(0xFF9A6700), diff --git a/docs/rendering.md b/docs/rendering.md index 9a3869b..3f5701d 100644 --- a/docs/rendering.md +++ b/docs/rendering.md @@ -194,7 +194,10 @@ Styling: `textStyle`, per-level `h1Style..h6Style` (+ cached `headingStyleFor`), `textStyleFor(MD$Style)` (cached mapping of the bitmask → bold/italic/underline/ strikethrough/monospace + highlight/monospace backgrounds + link color), `linkColor`/`linkStyle`, `surfaceColor` (code/table/quote backgrounds), -`highlightBackgroundColor`, `monospaceBackgroundColor`, `dividerColor`, +`highlightBackgroundColor`, `monospaceBackgroundColor`, +`monospaceFontFamily`/`monospaceFontFamilyFallback` (null → the +platform-resolved `kMonospaceFontFamily`; one knob for both inline `` `code` `` +and fenced blocks), `dividerColor`, `alertColors` (+ built-in GitHub palette fallback via `alertColorFor`), `textDirection`, `textScaler`, and `onLinkTap`. diff --git a/lib/src/render/blocks/code.dart b/lib/src/render/blocks/code.dart index d44733c..4e68e33 100644 --- a/lib/src/render/blocks/code.dart +++ b/lib/src/render/blocks/code.dart @@ -41,7 +41,8 @@ class BlockPainter$Code with SelectableTextBlock implements BlockPainter { MarkdownThemeData theme, ) { final baseStyle = theme.textStyle.copyWith( - fontFamily: 'monospace', + fontFamily: theme.effectiveMonospaceFontFamily, + fontFamilyFallback: theme.effectiveMonospaceFontFamilyFallback, fontSize: theme.textStyle.fontSize ?? kDefaultFontSize, ); final highlighter = theme.highlighter; diff --git a/lib/src/theme.dart b/lib/src/theme.dart index bca6016..b8d8b7d 100644 --- a/lib/src/theme.dart +++ b/lib/src/theme.dart @@ -1,10 +1,59 @@ import 'dart:collection'; +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, kIsWeb; import 'package:flutter/material.dart'; import '../flutter_md.dart'; import 'highlight/engine.dart'; +/// Primary monospace face for inline `` `code` `` and fenced blocks, chosen for +/// the host platform. +/// +/// Flutter passes the family straight to the platform font manager, and only +/// some of them know the CSS generic `'monospace'`: Android's font config and +/// fontconfig on Linux resolve it, CoreText (iOS / macOS) and DirectWrite +/// (Windows) do not — there a `'monospace'` span silently falls back to the +/// proportional body face, so inline code stops looking like code. +/// +/// So a real system family is substituted on exactly those two platforms, and +/// every other target keeps the generic it already resolved. Web keeps it too: +/// its renderer has no access to arbitrary system fonts, so naming one could +/// not help and the generic is what the browser (or the Noto fallback) is able +/// to answer. +/// +/// [kMonospaceFontFamilyFallback] follows, ending in `'monospace'` again, so a +/// platform whose primary is missing still lands on a real monospace face. +/// +/// Override per theme with [MarkdownThemeData.monospaceFontFamily]. +String get kMonospaceFontFamily { + if (kIsWeb) return 'monospace'; + return switch (defaultTargetPlatform) { + // Ships with every iOS / macOS release; SF Mono is not user-installable. + TargetPlatform.iOS || TargetPlatform.macOS => 'Menlo', + // Ships with Windows since Vista. + TargetPlatform.windows => 'Consolas', + TargetPlatform.android || + TargetPlatform.fuchsia || + TargetPlatform.linux => + 'monospace', + }; +} + +/// Families tried after [kMonospaceFontFamily], for a platform whose primary +/// face is missing (a trimmed Linux image, a custom Android ROM). +/// +/// Ends in the CSS generic so anything that resolves it still gets a real +/// monospace face rather than the proportional default. +const List kMonospaceFontFamilyFallback = [ + 'Menlo', // Apple + 'Consolas', // Windows + 'DejaVu Sans Mono', // Linux + 'Roboto Mono', // Android + 'Courier New', + 'monospace', +]; + /// {@template markdown_theme_data} /// Theme data for Markdown widgets. /// {@endtemplate} @@ -27,6 +76,8 @@ class MarkdownThemeData implements ThemeExtension { this.surfaceColor = const Color.fromARGB(255, 235, 235, 235), this.highlightBackgroundColor = const Color(0x40FF5722), this.monospaceBackgroundColor = const Color(0x409E9E9E), + this.monospaceFontFamily, + this.monospaceFontFamilyFallback, this.dividerColor, this.alertColors, this.blockFilter, @@ -55,6 +106,8 @@ class MarkdownThemeData implements ThemeExtension { Color? surfaceColor, Color? highlightBackgroundColor, Color? monospaceBackgroundColor, + String? monospaceFontFamily, + List? monospaceFontFamilyFallback, Color? dividerColor, Map? alertColors, bool Function(MD$Block block)? blockFilter, @@ -86,6 +139,8 @@ class MarkdownThemeData implements ThemeExtension { highlightBackgroundColor ?? theme.colorScheme.errorContainer, monospaceBackgroundColor: monospaceBackgroundColor ?? theme.colorScheme.surfaceContainerHigh, + monospaceFontFamily: monospaceFontFamily, + monospaceFontFamilyFallback: monospaceFontFamilyFallback, dividerColor: dividerColor ?? theme.dividerColor.withValues(alpha: 0.12), alertColors: alertColors, blockFilter: blockFilter, @@ -145,6 +200,30 @@ class MarkdownThemeData implements ThemeExtension { /// The color to use for the background of monospace text. final Color? monospaceBackgroundColor; + /// Font family for inline `` `code` `` and fenced blocks. + /// + /// Null (the default) resolves [kMonospaceFontFamily] for the host platform. + /// Set it to pin a bundled face (`JetBrains Mono`, `Fira Code`, …) — + /// [effectiveMonospaceFontFamily] is what both the inline span styles and + /// [BlockPainter$Code] actually read. + final String? monospaceFontFamily; + + /// Families tried after [monospaceFontFamily]. + /// + /// Null (the default) uses [kMonospaceFontFamilyFallback]. Pass an empty + /// list to disable fallbacks entirely (a bundled face with full coverage). + final List? monospaceFontFamilyFallback; + + /// The monospace family in effect: [monospaceFontFamily], else the + /// platform-resolved [kMonospaceFontFamily]. + String get effectiveMonospaceFontFamily => + monospaceFontFamily ?? kMonospaceFontFamily; + + /// The monospace fallbacks in effect: [monospaceFontFamilyFallback], else + /// [kMonospaceFontFamilyFallback]. + List get effectiveMonospaceFontFamilyFallback => + monospaceFontFamilyFallback ?? kMonospaceFontFamilyFallback; + /// The color to use for the divider. final Color? dividerColor; @@ -267,7 +346,16 @@ class MarkdownThemeData implements ThemeExtension { TextDecoration.lineThrough, _ => null, }, - fontFamily: style.contains(MD$Style.monospace) ? 'monospace' : null, + // The CSS generic `'monospace'` does not resolve on Apple or + // Windows; [kMonospaceFontFamily] substitutes a real face there + // and keeps the generic everywhere it already worked. A host that + // bundles its own face sets [monospaceFontFamily]. + fontFamily: style.contains(MD$Style.monospace) + ? effectiveMonospaceFontFamily + : null, + fontFamilyFallback: style.contains(MD$Style.monospace) + ? effectiveMonospaceFontFamilyFallback + : null, color: switch (style) { var s when s.contains(MD$Style.link) => linkColor, _ => null, @@ -305,6 +393,8 @@ class MarkdownThemeData implements ThemeExtension { Color? surfaceColor, Color? highlightBackgroundColor, Color? monospaceBackgroundColor, + String? monospaceFontFamily, + List? monospaceFontFamilyFallback, Color? dividerColor, Map? alertColors, bool Function(MD$Block block)? blockFilter, @@ -331,6 +421,9 @@ class MarkdownThemeData implements ThemeExtension { highlightBackgroundColor ?? this.highlightBackgroundColor, monospaceBackgroundColor: monospaceBackgroundColor ?? this.monospaceBackgroundColor, + monospaceFontFamily: monospaceFontFamily ?? this.monospaceFontFamily, + monospaceFontFamilyFallback: + monospaceFontFamilyFallback ?? this.monospaceFontFamilyFallback, dividerColor: dividerColor ?? this.dividerColor, alertColors: alertColors ?? this.alertColors, blockFilter: blockFilter ?? this.blockFilter, @@ -367,6 +460,12 @@ class MarkdownThemeData implements ThemeExtension { highlightBackgroundColor, other?.highlightBackgroundColor, t), monospaceBackgroundColor: Color.lerp( monospaceBackgroundColor, other?.monospaceBackgroundColor, t), + // A font family cannot be interpolated — snap at the midpoint. + monospaceFontFamily: + t < 0.5 ? monospaceFontFamily : other?.monospaceFontFamily, + monospaceFontFamilyFallback: t < 0.5 + ? monospaceFontFamilyFallback + : other?.monospaceFontFamilyFallback, dividerColor: Color.lerp(dividerColor, other?.dividerColor, t), alertColors: t < 0.5 ? alertColors : other?.alertColors, blockFilter: t < 0.5 ? blockFilter : other?.blockFilter, diff --git a/test/theme/theme_test.dart b/test/theme/theme_test.dart index aedde4f..75f8b5a 100644 --- a/test/theme/theme_test.dart +++ b/test/theme/theme_test.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_md/flutter_md.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -208,4 +209,159 @@ void main() => group('MarkdownThemeData', () { expect(a.updateShouldNotify(different), isTrue); }); }); + + group('Monospace family', () { + /// Reads the resolved inline-code family under [platform]. + /// + /// Reset via try/finally rather than `addTearDown` — the framework's + /// `debugAssertAllFoundationVarsUnset` check runs first. The theme is + /// rebuilt inside the override because `textStyleFor` memoises. + (String?, List?) familyOn(TargetPlatform platform) { + debugDefaultTargetPlatformOverride = platform; + try { + final style = base().textStyleFor(MD$Style.monospace); + return (style.fontFamily, style.fontFamilyFallback); + } finally { + debugDefaultTargetPlatformOverride = null; + } + } + + test('keeps the CSS generic where the platform resolves it', () { + // Android's font config and fontconfig on Linux both map + // `monospace`; substituting a named face there could only make the + // result worse than 0.2.x. + for (final platform in const [ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + ]) { + expect(familyOn(platform).$1, 'monospace', reason: '$platform'); + } + }); + + test('substitutes a real face where the generic does not resolve', () { + // CoreText and DirectWrite do not know `monospace`, so inline code + // silently fell back to the proportional body face. + expect(familyOn(TargetPlatform.iOS).$1, 'Menlo'); + expect(familyOn(TargetPlatform.macOS).$1, 'Menlo'); + expect(familyOn(TargetPlatform.windows).$1, 'Consolas'); + }); + + test('every platform falls back through the generic last', () { + for (final platform in TargetPlatform.values) { + final (family, fallback) = familyOn(platform); + expect(fallback, isNotNull, reason: '$platform'); + expect(fallback!.last, 'monospace', reason: '$platform'); + expect( + fallback.contains(family) || family == 'monospace', + isTrue, + reason: '$platform primary must also appear in the chain', + ); + } + }); + + test('non-monospace spans keep the body face', () { + for (final platform in TargetPlatform.values) { + debugDefaultTargetPlatformOverride = platform; + try { + final style = base().textStyleFor(MD$Style.bold); + expect(style.fontFamily, isNull, reason: '$platform'); + expect(style.fontFamilyFallback, isNull, reason: '$platform'); + } finally { + debugDefaultTargetPlatformOverride = null; + } + } + }); + + test('a host face overrides the platform default everywhere', () { + // Both the inline span styles and BlockPainter$Code read the + // effective getters, so one knob covers `code` and ``` fences. + final theme = MarkdownThemeData( + textStyle: const TextStyle(fontSize: 14), + monospaceFontFamily: 'JetBrains Mono', + monospaceFontFamilyFallback: const ['Fira Code'], + ); + + expect(theme.effectiveMonospaceFontFamily, 'JetBrains Mono'); + expect( + theme.effectiveMonospaceFontFamilyFallback, + const ['Fira Code'], + ); + + final inline = theme.textStyleFor(MD$Style.monospace); + expect(inline.fontFamily, 'JetBrains Mono'); + expect(inline.fontFamilyFallback, const ['Fira Code']); + }); + + test('an override survives on every platform', () { + for (final platform in TargetPlatform.values) { + debugDefaultTargetPlatformOverride = platform; + try { + final theme = MarkdownThemeData( + textStyle: const TextStyle(fontSize: 14), + monospaceFontFamily: 'JetBrains Mono', + ); + expect( + theme.textStyleFor(MD$Style.monospace).fontFamily, + 'JetBrains Mono', + reason: '$platform', + ); + // Fallbacks stay on the shared chain unless overridden too. + expect( + theme.effectiveMonospaceFontFamilyFallback, + kMonospaceFontFamilyFallback, + reason: '$platform', + ); + } finally { + debugDefaultTargetPlatformOverride = null; + } + } + }); + + test('an empty fallback list is honored, not treated as unset', () { + final theme = MarkdownThemeData( + textStyle: const TextStyle(fontSize: 14), + monospaceFontFamilyFallback: const [], + ); + expect(theme.effectiveMonospaceFontFamilyFallback, isEmpty); + expect( + theme.textStyleFor(MD$Style.monospace).fontFamilyFallback, + isEmpty, + ); + }); + + test('copyWith carries the override', () { + // The cast is only needed because copyWith still returns the base + // ThemeExtension here; DoctorinaAI/md#27 narrows it. + final theme = MarkdownThemeData(textStyle: const TextStyle()) + .copyWith(monospaceFontFamily: 'Fira Code') as MarkdownThemeData; + expect(theme.effectiveMonospaceFontFamily, 'Fira Code'); + }); + + testWidgets('a fenced block renders with the same family', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + try { + expect(kMonospaceFontFamily, 'Menlo'); + await tester.pumpWidget(MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: MarkdownWidget( + markdown: Markdown.fromString( + '```dart\nvoid main() {}\n```', + ), + theme: base(), + ), + ), + ), + )); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + }); }); From e81effb7711f4682dbdd0faf88305824939fafdb Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Mon, 14 Sep 2026 20:40:36 +0400 Subject: [PATCH 2/5] style: dart format pana docks 10 points off the pub.dev score for an unformatted file, so this is not cosmetic. --- lib/src/theme.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/src/theme.dart b/lib/src/theme.dart index b8d8b7d..5cea7c0 100644 --- a/lib/src/theme.dart +++ b/lib/src/theme.dart @@ -1,7 +1,6 @@ import 'dart:collection'; -import 'package:flutter/foundation.dart' - show defaultTargetPlatform, kIsWeb; +import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb; import 'package:flutter/material.dart'; import '../flutter_md.dart'; From fc9a5a383ab57bc8112888ceb695a572cab5b921 Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Mon, 14 Sep 2026 20:54:08 +0400 Subject: [PATCH 3/5] refactor(theme): keep the monospace defaults out of the public API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `kMonospaceFontFamily`, `kMonospaceFontFamilyFallback` and the two `effectiveMonospace…` getters were six new public symbols for what is one knob. The platform table is an implementation detail — a host either accepts the default or names its own face. Privatise both globals and resolve the default in the constructor, so `monospaceFontFamily` / `monospaceFontFamilyFallback` are plain non-nullable fields like `surfaceColor` and `linkColor`. Painters (and `BlockPainter$Code`) read them directly; the effective-value getters go away. New public surface: two fields. Also corrects the changelog's "rendering is unchanged" claim for Android / Fuchsia / Linux / web: code spans now carry a `fontFamilyFallback` everywhere, and it replaces any chain the host set on `textStyle` (verified against `origin/master`). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NDVBM7Eu7JTmar3LUC96Vm --- CHANGELOG.md | 22 +++++----- docs/rendering.md | 7 +-- lib/src/render/blocks/code.dart | 4 +- lib/src/theme.dart | 78 ++++++++++++++------------------- test/theme/theme_test.dart | 17 +++---- 5 files changed, 61 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ba628d..233867d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,17 +7,19 @@ do not know the CSS generic `'monospace'` — code silently rendered in the proportional body face there. Those two now get `Menlo` / `Consolas`. **Android, Fuchsia, Linux and web keep `'monospace'`**: they resolve it - natively, so rendering there is unchanged. -- **ADDED**: `kMonospaceFontFamily` (platform-resolved) and - `kMonospaceFontFamilyFallback` (ends in `'monospace'`, so a platform missing - its primary face still lands on a real monospace one). + natively, so the primary face there is unchanged. +- **CHANGED**: code spans now carry a `fontFamilyFallback` on every platform. + It **replaces** any `fontFamilyFallback` set on `textStyle`, so a host that + relied on that chain for scripts the monospace face does not cover (CJK, + emoji) must repeat it in `monospaceFontFamilyFallback`. - **ADDED**: `MarkdownThemeData.monospaceFontFamily` / - `monospaceFontFamilyFallback`, plus the `effectiveMonospace…` getters, to pin - a bundled face. One knob covers both inline `` `code` `` and fenced blocks — - there was previously no supported way to change the fenced-block face at all: - `BlockPainter$Code` built its style in a static method reading a global, so - even overriding `textStyleFor` moved inline code only. An empty fallback list - means "no fallbacks", not "unset". + `monospaceFontFamilyFallback` to pin a bundled face. Both default to the + platform-resolved chain above and are resolved once, in the constructor. One + knob covers both inline `` `code` `` and fenced blocks — there was previously + no supported way to change the fenced-block face at all: `BlockPainter$Code` + built its style in a static method reading a global, so even overriding + `textStyleFor` moved inline code only. An empty fallback list means "no + fallbacks", not "unset". ## 0.2.0 diff --git a/docs/rendering.md b/docs/rendering.md index 3f5701d..3798aa2 100644 --- a/docs/rendering.md +++ b/docs/rendering.md @@ -195,9 +195,10 @@ Styling: `textStyle`, per-level `h1Style..h6Style` (+ cached `headingStyleFor`), strikethrough/monospace + highlight/monospace backgrounds + link color), `linkColor`/`linkStyle`, `surfaceColor` (code/table/quote backgrounds), `highlightBackgroundColor`, `monospaceBackgroundColor`, -`monospaceFontFamily`/`monospaceFontFamilyFallback` (null → the -platform-resolved `kMonospaceFontFamily`; one knob for both inline `` `code` `` -and fenced blocks), `dividerColor`, +`monospaceFontFamily`/`monospaceFontFamilyFallback` (platform-resolved by +default — `monospace` on Android/Fuchsia/Linux/web, `Menlo` on iOS/macOS, +`Consolas` on Windows; one knob for both inline `` `code` `` and fenced +blocks), `dividerColor`, `alertColors` (+ built-in GitHub palette fallback via `alertColorFor`), `textDirection`, `textScaler`, and `onLinkTap`. diff --git a/lib/src/render/blocks/code.dart b/lib/src/render/blocks/code.dart index 4e68e33..02c2fe0 100644 --- a/lib/src/render/blocks/code.dart +++ b/lib/src/render/blocks/code.dart @@ -41,8 +41,8 @@ class BlockPainter$Code with SelectableTextBlock implements BlockPainter { MarkdownThemeData theme, ) { final baseStyle = theme.textStyle.copyWith( - fontFamily: theme.effectiveMonospaceFontFamily, - fontFamilyFallback: theme.effectiveMonospaceFontFamilyFallback, + fontFamily: theme.monospaceFontFamily, + fontFamilyFallback: theme.monospaceFontFamilyFallback, fontSize: theme.textStyle.fontSize ?? kDefaultFontSize, ); final highlighter = theme.highlighter; diff --git a/lib/src/theme.dart b/lib/src/theme.dart index 5cea7c0..3ec1a32 100644 --- a/lib/src/theme.dart +++ b/lib/src/theme.dart @@ -6,8 +6,8 @@ import 'package:flutter/material.dart'; import '../flutter_md.dart'; import 'highlight/engine.dart'; -/// Primary monospace face for inline `` `code` `` and fenced blocks, chosen for -/// the host platform. +/// Primary monospace face for the host platform — the default for +/// [MarkdownThemeData.monospaceFontFamily]. /// /// Flutter passes the family straight to the platform font manager, and only /// some of them know the CSS generic `'monospace'`: Android's font config and @@ -20,12 +20,7 @@ import 'highlight/engine.dart'; /// its renderer has no access to arbitrary system fonts, so naming one could /// not help and the generic is what the browser (or the Noto fallback) is able /// to answer. -/// -/// [kMonospaceFontFamilyFallback] follows, ending in `'monospace'` again, so a -/// platform whose primary is missing still lands on a real monospace face. -/// -/// Override per theme with [MarkdownThemeData.monospaceFontFamily]. -String get kMonospaceFontFamily { +String get _defaultMonospaceFontFamily { if (kIsWeb) return 'monospace'; return switch (defaultTargetPlatform) { // Ships with every iOS / macOS release; SF Mono is not user-installable. @@ -39,12 +34,12 @@ String get kMonospaceFontFamily { }; } -/// Families tried after [kMonospaceFontFamily], for a platform whose primary -/// face is missing (a trimmed Linux image, a custom Android ROM). +/// Families tried after [_defaultMonospaceFontFamily], for a platform whose +/// primary face is missing (a trimmed Linux image, a custom Android ROM). /// /// Ends in the CSS generic so anything that resolves it still gets a real /// monospace face rather than the proportional default. -const List kMonospaceFontFamilyFallback = [ +const List _defaultMonospaceFontFamilyFallback = [ 'Menlo', // Apple 'Consolas', // Windows 'DejaVu Sans Mono', // Linux @@ -75,8 +70,8 @@ class MarkdownThemeData implements ThemeExtension { this.surfaceColor = const Color.fromARGB(255, 235, 235, 235), this.highlightBackgroundColor = const Color(0x40FF5722), this.monospaceBackgroundColor = const Color(0x409E9E9E), - this.monospaceFontFamily, - this.monospaceFontFamilyFallback, + String? monospaceFontFamily, + List? monospaceFontFamilyFallback, this.dividerColor, this.alertColors, this.blockFilter, @@ -84,7 +79,11 @@ class MarkdownThemeData implements ThemeExtension { this.builder, this.onLinkTap, this.highlighter, - }) : _headingStyles = List.filled(8, null), + }) : monospaceFontFamily = + monospaceFontFamily ?? _defaultMonospaceFontFamily, + monospaceFontFamilyFallback = + monospaceFontFamilyFallback ?? _defaultMonospaceFontFamilyFallback, + _headingStyles = List.filled(8, null), _textStyles = HashMap(); /// Creates a [MarkdownThemeData] from the given [ThemeData]. @@ -199,29 +198,24 @@ class MarkdownThemeData implements ThemeExtension { /// The color to use for the background of monospace text. final Color? monospaceBackgroundColor; - /// Font family for inline `` `code` `` and fenced blocks. + /// Font family for inline `` `code` `` and fenced blocks. One knob covers + /// both — [BlockPainter$Code] reads it too. /// - /// Null (the default) resolves [kMonospaceFontFamily] for the host platform. - /// Set it to pin a bundled face (`JetBrains Mono`, `Fira Code`, …) — - /// [effectiveMonospaceFontFamily] is what both the inline span styles and - /// [BlockPainter$Code] actually read. - final String? monospaceFontFamily; + /// Defaults to a family the host platform can actually resolve: the CSS + /// generic `'monospace'` on Android, Fuchsia, Linux and web, `Menlo` on + /// iOS / macOS and `Consolas` on Windows, because CoreText and DirectWrite + /// do not know the generic and would fall back to the proportional body + /// face. Pass a bundled face (`JetBrains Mono`, `Fira Code`, …) to pin it. + /// + /// Resolved once, when the theme is constructed. + final String monospaceFontFamily; /// Families tried after [monospaceFontFamily]. /// - /// Null (the default) uses [kMonospaceFontFamilyFallback]. Pass an empty - /// list to disable fallbacks entirely (a bundled face with full coverage). - final List? monospaceFontFamilyFallback; - - /// The monospace family in effect: [monospaceFontFamily], else the - /// platform-resolved [kMonospaceFontFamily]. - String get effectiveMonospaceFontFamily => - monospaceFontFamily ?? kMonospaceFontFamily; - - /// The monospace fallbacks in effect: [monospaceFontFamilyFallback], else - /// [kMonospaceFontFamilyFallback]. - List get effectiveMonospaceFontFamilyFallback => - monospaceFontFamilyFallback ?? kMonospaceFontFamilyFallback; + /// Defaults to a chain ending in the CSS generic, so a platform missing its + /// primary face still lands on a real monospace one. Pass an empty list to + /// disable fallbacks entirely (a bundled face with full coverage). + final List monospaceFontFamilyFallback; /// The color to use for the divider. final Color? dividerColor; @@ -345,15 +339,10 @@ class MarkdownThemeData implements ThemeExtension { TextDecoration.lineThrough, _ => null, }, - // The CSS generic `'monospace'` does not resolve on Apple or - // Windows; [kMonospaceFontFamily] substitutes a real face there - // and keeps the generic everywhere it already worked. A host that - // bundles its own face sets [monospaceFontFamily]. - fontFamily: style.contains(MD$Style.monospace) - ? effectiveMonospaceFontFamily - : null, + fontFamily: + style.contains(MD$Style.monospace) ? monospaceFontFamily : null, fontFamilyFallback: style.contains(MD$Style.monospace) - ? effectiveMonospaceFontFamilyFallback + ? monospaceFontFamilyFallback : null, color: switch (style) { var s when s.contains(MD$Style.link) => linkColor, @@ -460,11 +449,12 @@ class MarkdownThemeData implements ThemeExtension { monospaceBackgroundColor: Color.lerp( monospaceBackgroundColor, other?.monospaceBackgroundColor, t), // A font family cannot be interpolated — snap at the midpoint. - monospaceFontFamily: - t < 0.5 ? monospaceFontFamily : other?.monospaceFontFamily, + monospaceFontFamily: t < 0.5 + ? monospaceFontFamily + : other?.monospaceFontFamily ?? monospaceFontFamily, monospaceFontFamilyFallback: t < 0.5 ? monospaceFontFamilyFallback - : other?.monospaceFontFamilyFallback, + : other?.monospaceFontFamilyFallback ?? monospaceFontFamilyFallback, dividerColor: Color.lerp(dividerColor, other?.dividerColor, t), alertColors: t < 0.5 ? alertColors : other?.alertColors, blockFilter: t < 0.5 ? blockFilter : other?.blockFilter, diff --git a/test/theme/theme_test.dart b/test/theme/theme_test.dart index 75f8b5a..9080452 100644 --- a/test/theme/theme_test.dart +++ b/test/theme/theme_test.dart @@ -215,7 +215,8 @@ void main() => group('MarkdownThemeData', () { /// /// Reset via try/finally rather than `addTearDown` — the framework's /// `debugAssertAllFoundationVarsUnset` check runs first. The theme is - /// rebuilt inside the override because `textStyleFor` memoises. + /// rebuilt inside the override because the family is resolved once, + /// in the constructor. (String?, List?) familyOn(TargetPlatform platform) { debugDefaultTargetPlatformOverride = platform; try { @@ -282,9 +283,9 @@ void main() => group('MarkdownThemeData', () { monospaceFontFamilyFallback: const ['Fira Code'], ); - expect(theme.effectiveMonospaceFontFamily, 'JetBrains Mono'); + expect(theme.monospaceFontFamily, 'JetBrains Mono'); expect( - theme.effectiveMonospaceFontFamilyFallback, + theme.monospaceFontFamilyFallback, const ['Fira Code'], ); @@ -308,8 +309,8 @@ void main() => group('MarkdownThemeData', () { ); // Fallbacks stay on the shared chain unless overridden too. expect( - theme.effectiveMonospaceFontFamilyFallback, - kMonospaceFontFamilyFallback, + theme.monospaceFontFamilyFallback, + base().monospaceFontFamilyFallback, reason: '$platform', ); } finally { @@ -323,7 +324,7 @@ void main() => group('MarkdownThemeData', () { textStyle: const TextStyle(fontSize: 14), monospaceFontFamilyFallback: const [], ); - expect(theme.effectiveMonospaceFontFamilyFallback, isEmpty); + expect(theme.monospaceFontFamilyFallback, isEmpty); expect( theme.textStyleFor(MD$Style.monospace).fontFamilyFallback, isEmpty, @@ -335,14 +336,14 @@ void main() => group('MarkdownThemeData', () { // ThemeExtension here; DoctorinaAI/md#27 narrows it. final theme = MarkdownThemeData(textStyle: const TextStyle()) .copyWith(monospaceFontFamily: 'Fira Code') as MarkdownThemeData; - expect(theme.effectiveMonospaceFontFamily, 'Fira Code'); + expect(theme.monospaceFontFamily, 'Fira Code'); }); testWidgets('a fenced block renders with the same family', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.macOS; try { - expect(kMonospaceFontFamily, 'Menlo'); + expect(base().monospaceFontFamily, 'Menlo'); await tester.pumpWidget(MaterialApp( home: Align( alignment: Alignment.topLeft, From a33c557b30e6a5019ba022679d8a911ee9bb7bdf Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Mon, 14 Sep 2026 21:02:45 +0400 Subject: [PATCH 4/5] fix(theme): keep the body face's fallback chain in code spans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from the review of this PR. `textStyleFor` and `BlockPainter$Code` passed the monospace chain as `fontFamilyFallback`, and `TextStyle.copyWith` *replaces* that list. So a host whose `textStyle.fontFamilyFallback` carried the coverage a body face needs — CJK, emoji, right-to-left scripts — silently lost it inside `code` and fenced blocks, on every platform including the ones whose primary face this PR does not touch. Verified against `origin/master`: the chain went from the host's `[Noto Sans SC, Noto Color Emoji]` to `[Menlo, Consolas, …, monospace]`. Append the host's chain after the monospace one instead: monospace faces still win wherever they have the glyph, and everything they do not cover falls back to what the host already chose. The composed list lives in `monospaceFallbackChain` (`@meta.internal`, computed once per theme) so both the inline and the fenced path read the same value. `mergeTheme` now resolves the default face from `ThemeData.platform` rather than the global `defaultTargetPlatform`. An app that adapts its Material theme to another platform is stating which platform it is imitating, and the factory had that answer in hand. `ThemeData.platform` defaults to `defaultTargetPlatform`, so nothing moves unless an app deliberately overrode it. Tests: the fenced-block case now reads the style `BlockPainter$Code` actually builds instead of only asserting that it renders. 991 pass; each new case was checked to fail without its fix. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NDVBM7Eu7JTmar3LUC96Vm --- CHANGELOG.md | 16 +++-- docs/rendering.md | 5 +- lib/src/render/blocks/code.dart | 2 +- lib/src/theme.dart | 42 +++++++++--- test/theme/theme_test.dart | 118 +++++++++++++++++++++++++++++++- 5 files changed, 163 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 233867d..41de1f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,11 @@ proportional body face there. Those two now get `Menlo` / `Consolas`. **Android, Fuchsia, Linux and web keep `'monospace'`**: they resolve it natively, so the primary face there is unchanged. -- **CHANGED**: code spans now carry a `fontFamilyFallback` on every platform. - It **replaces** any `fontFamilyFallback` set on `textStyle`, so a host that - relied on that chain for scripts the monospace face does not cover (CJK, - emoji) must repeat it in `monospaceFontFamilyFallback`. +- **CHANGED**: code spans now carry a `fontFamilyFallback` on every platform: + the monospace chain followed by whatever `textStyle` already carried. A + monospace face covers Latin and little else, so the body face's chain is + appended rather than replaced — a host's CJK / emoji coverage keeps working + inside `` `code` `` and fenced blocks. - **ADDED**: `MarkdownThemeData.monospaceFontFamily` / `monospaceFontFamilyFallback` to pin a bundled face. Both default to the platform-resolved chain above and are resolved once, in the constructor. One @@ -19,7 +20,12 @@ no supported way to change the fenced-block face at all: `BlockPainter$Code` built its style in a static method reading a global, so even overriding `textStyleFor` moved inline code only. An empty fallback list means "no - fallbacks", not "unset". + monospace fallbacks", not "unset" — `textStyle`'s own chain still applies. +- **CHANGED**: `MarkdownThemeData.mergeTheme` resolves the default face from + `ThemeData.platform` rather than the global `defaultTargetPlatform`, so an + app that adapts its Material theme to another platform gets that platform's + face. `ThemeData.platform` defaults to `defaultTargetPlatform`, so this only + moves for an app that deliberately overrode it. ## 0.2.0 diff --git a/docs/rendering.md b/docs/rendering.md index 3798aa2..c0564ab 100644 --- a/docs/rendering.md +++ b/docs/rendering.md @@ -197,8 +197,9 @@ strikethrough/monospace + highlight/monospace backgrounds + link color), `highlightBackgroundColor`, `monospaceBackgroundColor`, `monospaceFontFamily`/`monospaceFontFamilyFallback` (platform-resolved by default — `monospace` on Android/Fuchsia/Linux/web, `Menlo` on iOS/macOS, -`Consolas` on Windows; one knob for both inline `` `code` `` and fenced -blocks), `dividerColor`, +`Consolas` on Windows, from `ThemeData.platform` under `mergeTheme`; one knob +for both inline `` `code` `` and fenced blocks, and `textStyle`'s own fallback +chain is appended after it), `dividerColor`, `alertColors` (+ built-in GitHub palette fallback via `alertColorFor`), `textDirection`, `textScaler`, and `onLinkTap`. diff --git a/lib/src/render/blocks/code.dart b/lib/src/render/blocks/code.dart index 02c2fe0..d65689c 100644 --- a/lib/src/render/blocks/code.dart +++ b/lib/src/render/blocks/code.dart @@ -42,7 +42,7 @@ class BlockPainter$Code with SelectableTextBlock implements BlockPainter { ) { final baseStyle = theme.textStyle.copyWith( fontFamily: theme.monospaceFontFamily, - fontFamilyFallback: theme.monospaceFontFamilyFallback, + fontFamilyFallback: theme.monospaceFallbackChain, fontSize: theme.textStyle.fontSize ?? kDefaultFontSize, ); final highlighter = theme.highlighter; diff --git a/lib/src/theme.dart b/lib/src/theme.dart index 3ec1a32..c5ec0c1 100644 --- a/lib/src/theme.dart +++ b/lib/src/theme.dart @@ -2,11 +2,12 @@ import 'dart:collection'; import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb; import 'package:flutter/material.dart'; +import 'package:meta/meta.dart' as meta show internal; import '../flutter_md.dart'; import 'highlight/engine.dart'; -/// Primary monospace face for the host platform — the default for +/// Primary monospace face for [platform] — the default for /// [MarkdownThemeData.monospaceFontFamily]. /// /// Flutter passes the family straight to the platform font manager, and only @@ -20,9 +21,9 @@ import 'highlight/engine.dart'; /// its renderer has no access to arbitrary system fonts, so naming one could /// not help and the generic is what the browser (or the Noto fallback) is able /// to answer. -String get _defaultMonospaceFontFamily { +String _defaultMonospaceFontFamily(TargetPlatform platform) { if (kIsWeb) return 'monospace'; - return switch (defaultTargetPlatform) { + return switch (platform) { // Ships with every iOS / macOS release; SF Mono is not user-installable. TargetPlatform.iOS || TargetPlatform.macOS => 'Menlo', // Ships with Windows since Vista. @@ -34,8 +35,8 @@ String get _defaultMonospaceFontFamily { }; } -/// Families tried after [_defaultMonospaceFontFamily], for a platform whose -/// primary face is missing (a trimmed Linux image, a custom Android ROM). +/// Families tried after the primary face, for a platform whose own is +/// missing (a trimmed Linux image, a custom Android ROM). /// /// Ends in the CSS generic so anything that resolves it still gets a real /// monospace face rather than the proportional default. @@ -79,8 +80,8 @@ class MarkdownThemeData implements ThemeExtension { this.builder, this.onLinkTap, this.highlighter, - }) : monospaceFontFamily = - monospaceFontFamily ?? _defaultMonospaceFontFamily, + }) : monospaceFontFamily = monospaceFontFamily ?? + _defaultMonospaceFontFamily(defaultTargetPlatform), monospaceFontFamilyFallback = monospaceFontFamilyFallback ?? _defaultMonospaceFontFamilyFallback, _headingStyles = List.filled(8, null), @@ -137,7 +138,11 @@ class MarkdownThemeData implements ThemeExtension { highlightBackgroundColor ?? theme.colorScheme.errorContainer, monospaceBackgroundColor: monospaceBackgroundColor ?? theme.colorScheme.surfaceContainerHigh, - monospaceFontFamily: monospaceFontFamily, + // ThemeData.platform is the app's own answer to "which platform are + // we adapting to". It defaults to defaultTargetPlatform, so this only + // diverges for an app that deliberately overrode it. + monospaceFontFamily: + monospaceFontFamily ?? _defaultMonospaceFontFamily(theme.platform), monospaceFontFamilyFallback: monospaceFontFamilyFallback, dividerColor: dividerColor ?? theme.dividerColor.withValues(alpha: 0.12), alertColors: alertColors, @@ -213,10 +218,25 @@ class MarkdownThemeData implements ThemeExtension { /// Families tried after [monospaceFontFamily]. /// /// Defaults to a chain ending in the CSS generic, so a platform missing its - /// primary face still lands on a real monospace one. Pass an empty list to - /// disable fallbacks entirely (a bundled face with full coverage). + /// primary face still lands on a real monospace one. Whatever [textStyle] + /// carries is appended after it — see [monospaceFallbackChain] — so pass an + /// empty list to fall straight through to the body face's own chain. final List monospaceFontFamilyFallback; + /// The fallback chain code spans actually use: [monospaceFontFamilyFallback] + /// followed by [textStyle]'s own. + /// + /// A monospace face covers Latin and little else, so dropping the body + /// face's chain would lose the host's coverage for everything it was there + /// for — CJK, emoji, right-to-left scripts — and lose it inside `` `code` `` + /// and fenced blocks only. Appending keeps the monospace faces winning + /// wherever they have the glyph and hands the rest back to the host. + @meta.internal + late final List monospaceFallbackChain = [ + ...monospaceFontFamilyFallback, + ...?textStyle.fontFamilyFallback, + ]; + /// The color to use for the divider. final Color? dividerColor; @@ -342,7 +362,7 @@ class MarkdownThemeData implements ThemeExtension { fontFamily: style.contains(MD$Style.monospace) ? monospaceFontFamily : null, fontFamilyFallback: style.contains(MD$Style.monospace) - ? monospaceFontFamilyFallback + ? monospaceFallbackChain : null, color: switch (style) { var s when s.contains(MD$Style.link) => linkColor, diff --git a/test/theme/theme_test.dart b/test/theme/theme_test.dart index 9080452..367142f 100644 --- a/test/theme/theme_test.dart +++ b/test/theme/theme_test.dart @@ -331,6 +331,54 @@ void main() => group('MarkdownThemeData', () { ); }); + test('an empty fallback list falls through to the body chain', () { + // "No monospace fallbacks" means exactly that — it does not also + // throw away the coverage the host put on textStyle. + final theme = MarkdownThemeData( + textStyle: const TextStyle( + fontSize: 14, + fontFamilyFallback: ['Noto Sans SC'], + ), + monospaceFontFamilyFallback: const [], + ); + expect( + theme.textStyleFor(MD$Style.monospace).fontFamilyFallback, + const ['Noto Sans SC'], + ); + }); + + test("the body face's fallback chain is appended, not replaced", () { + // A monospace face covers Latin and little else. Before this was + // appended, a host chain carrying CJK / emoji coverage was dropped + // for code spans only — on every platform, generic or not. + final theme = MarkdownThemeData( + textStyle: const TextStyle( + fontSize: 14, + fontFamily: 'Inter', + fontFamilyFallback: ['Noto Sans SC', 'Noto Color Emoji'], + ), + monospaceFontFamily: 'JetBrains Mono', + monospaceFontFamilyFallback: const ['Fira Code'], + ); + + const expected = [ + 'Fira Code', + 'Noto Sans SC', + 'Noto Color Emoji', + ]; + // Monospace faces still win wherever they have the glyph. + expect(theme.monospaceFallbackChain, expected); + expect( + theme.textStyleFor(MD$Style.monospace).fontFamilyFallback, + expected, + ); + // A non-monospace span is untouched by any of this. + expect( + theme.textStyleFor(MD$Style.bold).fontFamilyFallback, + const ['Noto Sans SC', 'Noto Color Emoji'], + ); + }); + test('copyWith carries the override', () { // The cast is only needed because copyWith still returns the base // ThemeExtension here; DoctorinaAI/md#27 narrows it. @@ -339,7 +387,39 @@ void main() => group('MarkdownThemeData', () { expect(theme.monospaceFontFamily, 'Fira Code'); }); - testWidgets('a fenced block renders with the same family', + test('a fenced block uses the same family and chain', () { + // BlockPainter$Code builds its base style in a static method, so it + // used to be the half that no theme knob could move. Read the + // painter's own span rather than trusting that it shares the code + // path with textStyleFor. + final theme = MarkdownThemeData( + textStyle: const TextStyle( + fontSize: 14, + fontFamilyFallback: ['Noto Sans SC'], + ), + monospaceFontFamily: 'JetBrains Mono', + monospaceFontFamilyFallback: const ['Fira Code'], + ); + final painter = BlockPainter$Code( + text: 'void main() {}', + language: 'dart', + theme: theme, + ); + + final style = painter.painter.text?.style; + expect(style?.fontFamily, 'JetBrains Mono'); + expect( + style?.fontFamilyFallback, + const ['Fira Code', 'Noto Sans SC'], + ); + expect( + style?.fontFamily, + theme.textStyleFor(MD$Style.monospace).fontFamily, + reason: 'inline code and fences must not drift apart', + ); + }); + + testWidgets('a fenced block renders on the platform default', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.macOS; try { @@ -364,5 +444,41 @@ void main() => group('MarkdownThemeData', () { debugDefaultTargetPlatformOverride = null; } }); + + testWidgets('mergeTheme follows ThemeData.platform', (tester) async { + // An app that adapts its Material theme to another platform is + // stating which platform it is imitating; the global + // defaultTargetPlatform is not that answer. + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + expect( + MarkdownThemeData.mergeTheme( + ThemeData(platform: TargetPlatform.iOS), + ).monospaceFontFamily, + 'Menlo', + ); + expect( + MarkdownThemeData.mergeTheme( + ThemeData(platform: TargetPlatform.windows), + ).monospaceFontFamily, + 'Consolas', + ); + // Unset, ThemeData.platform *is* defaultTargetPlatform. + expect( + MarkdownThemeData.mergeTheme(ThemeData()).monospaceFontFamily, + 'monospace', + ); + // An explicit face still wins over both. + expect( + MarkdownThemeData.mergeTheme( + ThemeData(platform: TargetPlatform.iOS), + monospaceFontFamily: 'JetBrains Mono', + ).monospaceFontFamily, + 'JetBrains Mono', + ); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); }); }); From a45cc5f1f4d619eeb936806e71a9fccd887cb364 Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Mon, 14 Sep 2026 21:23:03 +0400 Subject: [PATCH 5/5] docs(theme): correct the web claim, measured in a browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Building the example and measuring in a real engine contradicts the platform table this PR was written against. On web, CanvasKit / skwasm reach no system fonts at all: `'monospace'`, `'Courier New'`, `'DejaVu Sans Mono'` and a deliberately nonexistent family all lay out byte-identically to the default proportional face (W×10 = 142.0 for all four). Code has never rendered monospace on web — this PR neither fixes nor regresses that, but the table claimed web was already correct. Linux, measured the same way, is exactly as described: `'monospace'` resolves to a real monospace face (i×10 == W×10 == 96.3), an unknown family falls to the proportional default (161.2), and the fallback chain rescues a missing primary. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NDVBM7Eu7JTmar3LUC96Vm --- CHANGELOG.md | 8 ++++++-- lib/src/theme.dart | 12 ++++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41de1f7..de8cac3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,12 @@ platform font manager, and CoreText (iOS / macOS) and DirectWrite (Windows) do not know the CSS generic `'monospace'` — code silently rendered in the proportional body face there. Those two now get `Menlo` / `Consolas`. - **Android, Fuchsia, Linux and web keep `'monospace'`**: they resolve it - natively, so the primary face there is unchanged. + **Android, Fuchsia, Linux and web keep `'monospace'`**, so the primary face + there is unchanged. Note that on web it is unchanged but still wrong: + CanvasKit / skwasm reach no system fonts, so no family name resolves there + and code has always rendered in the proportional default. Bundling a + monospace asset and pointing `monospaceFontFamily` at it is the only fix, + and that is a host decision. - **CHANGED**: code spans now carry a `fontFamilyFallback` on every platform: the monospace chain followed by whatever `textStyle` already carried. A monospace face covers Latin and little else, so the body face's chain is diff --git a/lib/src/theme.dart b/lib/src/theme.dart index c5ec0c1..263566d 100644 --- a/lib/src/theme.dart +++ b/lib/src/theme.dart @@ -17,10 +17,14 @@ import 'highlight/engine.dart'; /// proportional body face, so inline code stops looking like code. /// /// So a real system family is substituted on exactly those two platforms, and -/// every other target keeps the generic it already resolved. Web keeps it too: -/// its renderer has no access to arbitrary system fonts, so naming one could -/// not help and the generic is what the browser (or the Noto fallback) is able -/// to answer. +/// every other target keeps the generic it already resolved. +/// +/// Web keeps the generic as well, but not because it works: CanvasKit and +/// skwasm reach no system fonts at all, so `'monospace'` resolves no better +/// than `'Courier New'` or a name nobody has — measured in a browser, all +/// three lay out identically to the default proportional face. Naming a +/// family cannot fix that; only bundling a monospace asset and setting +/// [MarkdownThemeData.monospaceFontFamily] to it can. String _defaultMonospaceFontFamily(TargetPlatform platform) { if (kIsWeb) return 'monospace'; return switch (platform) {