Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,36 @@
## 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'`**, 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
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
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
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

> **Upgrading from 0.0.x?** See the
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
7 changes: 6 additions & 1 deletion docs/rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,12 @@ 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` (platform-resolved by
default — `monospace` on Android/Fuchsia/Linux/web, `Menlo` on iOS/macOS,
`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`.

Expand Down
3 changes: 2 additions & 1 deletion lib/src/render/blocks/code.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ class BlockPainter$Code with SelectableTextBlock implements BlockPainter {
MarkdownThemeData theme,
) {
final baseStyle = theme.textStyle.copyWith(
fontFamily: 'monospace',
fontFamily: theme.monospaceFontFamily,
fontFamilyFallback: theme.monospaceFallbackChain,
fontSize: theme.textStyle.fontSize ?? kDefaultFontSize,
);
final highlighter = theme.highlighter;
Expand Down
116 changes: 114 additions & 2 deletions lib/src/theme.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,58 @@
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 [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
/// 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 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) {
// 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 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.
const List<String> _defaultMonospaceFontFamilyFallback = <String>[
'Menlo', // Apple
'Consolas', // Windows
'DejaVu Sans Mono', // Linux
'Roboto Mono', // Android
'Courier New',
'monospace',
];

/// {@template markdown_theme_data}
/// Theme data for Markdown widgets.
/// {@endtemplate}
Expand All @@ -27,14 +75,20 @@ class MarkdownThemeData implements ThemeExtension<MarkdownThemeData> {
this.surfaceColor = const Color.fromARGB(255, 235, 235, 235),
this.highlightBackgroundColor = const Color(0x40FF5722),
this.monospaceBackgroundColor = const Color(0x409E9E9E),
String? monospaceFontFamily,
List<String>? monospaceFontFamilyFallback,
this.dividerColor,
this.alertColors,
this.blockFilter,
this.spanFilter,
this.builder,
this.onLinkTap,
this.highlighter,
}) : _headingStyles = List<TextStyle?>.filled(8, null),
}) : monospaceFontFamily = monospaceFontFamily ??
_defaultMonospaceFontFamily(defaultTargetPlatform),
monospaceFontFamilyFallback =
monospaceFontFamilyFallback ?? _defaultMonospaceFontFamilyFallback,
_headingStyles = List<TextStyle?>.filled(8, null),
_textStyles = HashMap<int, TextStyle>();

/// Creates a [MarkdownThemeData] from the given [ThemeData].
Expand All @@ -55,6 +109,8 @@ class MarkdownThemeData implements ThemeExtension<MarkdownThemeData> {
Color? surfaceColor,
Color? highlightBackgroundColor,
Color? monospaceBackgroundColor,
String? monospaceFontFamily,
List<String>? monospaceFontFamilyFallback,
Color? dividerColor,
Map<MD$AlertType, Color>? alertColors,
bool Function(MD$Block block)? blockFilter,
Expand Down Expand Up @@ -86,6 +142,12 @@ class MarkdownThemeData implements ThemeExtension<MarkdownThemeData> {
highlightBackgroundColor ?? theme.colorScheme.errorContainer,
monospaceBackgroundColor:
monospaceBackgroundColor ?? theme.colorScheme.surfaceContainerHigh,
// 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,
blockFilter: blockFilter,
Expand Down Expand Up @@ -145,6 +207,40 @@ class MarkdownThemeData implements ThemeExtension<MarkdownThemeData> {
/// The color to use for the background of monospace text.
final Color? monospaceBackgroundColor;

/// Font family for inline `` `code` `` and fenced blocks. One knob covers
/// both — [BlockPainter$Code] reads it too.
///
/// 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].
///
/// Defaults to a chain ending in the CSS generic, so a platform missing its
/// 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<String> 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<String> monospaceFallbackChain = <String>[
...monospaceFontFamilyFallback,
...?textStyle.fontFamilyFallback,
];

/// The color to use for the divider.
final Color? dividerColor;

Expand Down Expand Up @@ -267,7 +363,11 @@ class MarkdownThemeData implements ThemeExtension<MarkdownThemeData> {
TextDecoration.lineThrough,
_ => null,
},
fontFamily: style.contains(MD$Style.monospace) ? 'monospace' : null,
fontFamily:
style.contains(MD$Style.monospace) ? monospaceFontFamily : null,
fontFamilyFallback: style.contains(MD$Style.monospace)
? monospaceFallbackChain
: null,
color: switch (style) {
var s when s.contains(MD$Style.link) => linkColor,
_ => null,
Expand Down Expand Up @@ -305,6 +405,8 @@ class MarkdownThemeData implements ThemeExtension<MarkdownThemeData> {
Color? surfaceColor,
Color? highlightBackgroundColor,
Color? monospaceBackgroundColor,
String? monospaceFontFamily,
List<String>? monospaceFontFamilyFallback,
Color? dividerColor,
Map<MD$AlertType, Color>? alertColors,
bool Function(MD$Block block)? blockFilter,
Expand All @@ -331,6 +433,9 @@ class MarkdownThemeData implements ThemeExtension<MarkdownThemeData> {
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,
Expand Down Expand Up @@ -367,6 +472,13 @@ class MarkdownThemeData implements ThemeExtension<MarkdownThemeData> {
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 ?? monospaceFontFamily,
monospaceFontFamilyFallback: t < 0.5
? 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,
Expand Down
Loading
Loading