fix(mobile): style inline code with the app mono face (#6631)

## Summary

Inline code on mobile renders as **bold body text on a faint background
wash** — no monospace face, no chip, and it cannot wrap. #5257 diagnosed
this as a missing `highlightBuilder`.

That is no longer the right fix. `gpt_markdown` 1.2.0 deprecates
`highlightBuilder` (removal in 2.0.0), renders inline code as a real
chip, and adds `InlineCodeStyle` for restyling it. The package author
confirmed this on the issue. So this PR is an upgrade — 1.1.6 → 1.2.1 —
plus one theme declaration, rather than the builder the issue originally
asked for.

**Where the style is declared.** `GptMarkdownThemeData` goes in
`AppTheme._buildTheme`, which both `light()` and `dark()` call. That
reaches all four `GptMarkdown` call sites — `message_content`,
`transcript_item_widget`, `token_pill`, `custom_emoji_render` — so the
style is stated once instead of per widget. A widget-level
`inlineCodeStyle` would have covered channel messages only, leaving the
other three on the package's defaults.

**What is declared.** Face, size, ink, chip fill and outline — not the
face alone. A face name on its own leaves the rest on the package's
defaults, which put inline code at 14.1sp beside a fenced block's 13, on
a neutral `onSurface` tint rather than the app's code surface. In dark
that tint is *lighter* than the surface, while every other code surface
in the app is recessed, so the chip read as a different kind of object.
All of it now comes from one `CodeStyle` declaration that the fenced
block reads from too, so the two cannot be edited apart.

**Three adaptations the upgrade requires.** Each was found by running
the gate, not by reading the changelog:

1. **`imageBuilder` widened** to `(context, url, width, height)`. This
is a hard compile error, and it is **not listed in the package's
migration guide**, which states "nothing here stops code compiling".
Worth reporting upstream.
2. **`autolink` now defaults to `true`.** `normalizeBareLinks()` already
rewrites bare URLs into Markdown links before rendering, so both would
run. `message_content` opts out with `autolink: false` to keep current
behaviour exactly. The migration guide argues for dropping the
pre-processor instead — a better fix, but a behavioural change that
belongs in its own PR.
3. **`gpt_markdown.dart` now re-exports `markdown_config.dart`**, making
two direct imports redundant. `flutter analyze` reports `No issues
found!` on 1.1.6 and flags both on 1.2.1, so these warnings are new, not
pre-existing.

**Deliberately out of scope.** The three non-message call sites now
autolink bare URLs, since only `message_content` has a pre-processor to
collide with. Custom inline components (`_MentionMd`, `CustomEmojiMd`,
`_ChannelLinkMd`) could additionally declare `allScopesExceptLinkLabel`
— 1.2.0 offers it as the fix for a `WidgetSpan` chip going blank inside
a link label on iOS — but current behaviour is unchanged without it, so
that stays a separate change.

### Related issue

Fixes #5257

Duplicate scan: searched `gpt_markdown`, `inline code mobile`,
`highlightBuilder` and `InlineCodeStyle` across both PRs and issues. No
open PR touches inline code styling. #6135 (link labels) and #6166 (text
selection) also touch mobile Markdown but address different defects.

### Testing

Full gate, `just ci` — exit 0:

| Stage | Result |
|---|---|
| Rust (33 suites) | 4768 passed, 0 failed |
| Desktop | 5799 passed, 0 failed |
| Mobile | **2011 passed**, 0 failed |
| `flutter analyze` | `No issues found!` |
| Desktop + web build | ok |

Run on the branch with `main` merged in, so these numbers match what CI
builds.

**New regression test** — `renders inline code in the app code style`.
It resolves the `CodeTextSpan` the package tags inline code with, which
carries both the resolved `TextStyle` and the colours the chip behind it
is painted with, so face, size, ink, fill and outline are all asserted
rather than a widget's presence. It is negative-controlled: reverting
only the theme declaration fails it with

```text
Expected: a numeric value within <0.001> of <13.0>
  Actual: <14.1>
```

and dropping the declaration entirely falls back to
`packages/gpt_markdown/JetBrainsMono` — so the test measures the real
thing, and it would catch a future regression that silently drops the
theme extension.

The test passes `baseStyle: messageBodyTextStyle`, the style the message
surfaces actually use; the widget's own fallback is the smaller
`bodyMedium`, which would move the expected size.

The test finds paragraphs with `find.byWidgetPredicate((widget) =>
widget is RichText)`, not `find.byType(RichText)`: inline code renders
through `BidiRichText`, a `RichText` subclass, and `byType` matches
exact runtime types.

That is a hazard for any test that reads text back out of a paragraph,
and one landed after this branch was cut:
`message_content_custom_emoji_test.dart` arrived with #6996 and its
`code keeps literal emoji while adjacent known tokens render` case reads
a code span through `find.byType(RichText)`. It passes on `main` and
fails on the merge result, which is what CI builds, so it went red only
once CI was authorized. It now uses the same predicate. The two other
`byType(RichText)` call sites — the rest of that file and
`message_author_meta_test.dart` — were re-run and pass: their content
carries no code span, so the exact type still matches. They were left
alone.

### Screenshots

Rendered through the real `MessageContent` widget with the app's own
fonts loaded, at 390pt wide, 3x DPR. Sample text: ``Set `BUZZ_RELAY_URL`
before launch, then run `just mobile-test` to verify.``

| | Before (1.1.6) | After (1.2.1) |
|---|---|---|
| Light |
![before-inline-code-light](https://raw.githubusercontent.com/TolgaCinisli/buzz/2d2d846291416d9b32d3fb9cfead950bcc4fe123/pr-6631--before-inline-code-light.png)
|
![after-inline-code-light](https://raw.githubusercontent.com/TolgaCinisli/buzz/f230b95c7260a32bd5d76b1ac42130720a168521/pr-6631--after-inline-code-light.png)
|
| Dark |
![before-inline-code-dark](https://raw.githubusercontent.com/TolgaCinisli/buzz/2d2d846291416d9b32d3fb9cfead950bcc4fe123/pr-6631--before-inline-code-dark.png)
|
![after-inline-code-dark](https://raw.githubusercontent.com/TolgaCinisli/buzz/f230b95c7260a32bd5d76b1ac42130720a168521/pr-6631--after-inline-code-dark.png)
|

Before: bold Inter on a flat wash, no chip edge, and `just mobile-test`
breaks across the line with the wash simply ending. After: Geist Mono in
a bordered, rounded chip, and the wrapped fragment gets its own chip on
each line.

---------

Signed-off-by: Tolga Cinisli <tolgacinisli@gmail.com>
Co-authored-by: Tolga Cinisli <tolgacinisli@gmail.com>
This commit is contained in:
TolgaCinisli
2026-09-08 17:05:11 +03:00
committed by GitHub
parent 3c7f288c60
commit fa1b27bcc8
13 changed files with 284 additions and 18 deletions

View File

@@ -374,9 +374,11 @@ class App extends HookConsumerWidget {
// Above the navigator, so a burst keeps playing over a pushed thread page
// or a modal sheet — the same reason desktop pins its canvas to the
// viewport rather than to the message row.
builder: (context, child) => MobileHuddleShell(
navigatorKey: _mobileRootNavigatorKey,
child: EmojiBurstOverlay(child: child ?? const SizedBox.shrink()),
builder: (context, child) => AppMarkdownTheme(
child: MobileHuddleShell(
navigatorKey: _mobileRootNavigatorKey,
child: EmojiBurstOverlay(child: child ?? const SizedBox.shrink()),
),
),
home: authState.when(
loading: () => const _SplashScreen(),

View File

@@ -72,6 +72,10 @@ class _MessageItemWidget extends StatelessWidget {
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurface,
),
// No link handler here, so an autolinked URL would draw as a
// link and do nothing when tapped. Transcript URLs stay text,
// as they were before gpt_markdown started autolinking.
autolink: false,
),
],
),
@@ -143,6 +147,8 @@ class _ThoughtItemWidget extends HookWidget {
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
// As above: no link handler on this surface either.
autolink: false,
),
],
],

View File

@@ -7,7 +7,6 @@ import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:gpt_markdown/gpt_markdown.dart';
import 'package:gpt_markdown/custom_widgets/markdown_config.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:open_filex/open_filex.dart';
import 'package:path_provider/path_provider.dart';
@@ -266,6 +265,9 @@ class MessageContent extends HookConsumerWidget {
finalContent,
style: style,
followLinkColor: false,
// normalizeBareLinks() already turns bare URLs into Markdown links;
// gpt_markdown 1.2.0 autolinks by default, so both would run.
autolink: false,
codeBuilder: (context, name, code, closed) =>
_MessageCodeBlock(name: name, code: code),
linkBuilder: (context, linkText, url, linkStyle) => _buildLink(
@@ -279,7 +281,7 @@ class MessageContent extends HookConsumerWidget {
resolvedChannelTap,
resolvedChannelNames,
),
imageBuilder: (context, imageUrl) =>
imageBuilder: (context, imageUrl, _, _) =>
_buildMedia(context, imageUrl, imetaByUrl[imageUrl]),
textAlign: textAlign,
maxLines: maxLines,
@@ -721,9 +723,9 @@ class _MessageCodeBlock extends HookWidget {
}
final codeBaseStyle = TextStyle(
fontFamily: 'GeistMono',
fontSize: 13,
height: 1.5,
fontFamily: CodeStyle.fontFamily,
fontSize: CodeStyle.fontSize,
height: CodeStyle.lineHeight,
color: context.colors.onSurface,
);
final isDark = context.theme.brightness == Brightness.dark;
@@ -735,11 +737,9 @@ class _MessageCodeBlock extends HookWidget {
return Container(
margin: const EdgeInsets.only(top: Grid.half),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest.withValues(alpha: 0.6),
color: CodeStyle.background(context.colors),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: context.colors.outline.withValues(alpha: 0.7),
),
border: Border.all(color: CodeStyle.border(context.colors)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:gpt_markdown/gpt_markdown.dart';
import 'package:gpt_markdown/custom_widgets/markdown_config.dart';
import '../relay/relay.dart';

View File

@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:gpt_markdown/gpt_markdown.dart';
import 'app_theme.dart';
import 'code_style.dart';
/// States the app's code style on every `GptMarkdown` below it and leaves the
/// rest of the Markdown theme as it already was.
///
/// Registering a [GptMarkdownThemeData] as a `ThemeExtension` would be the
/// shorter route, but its factory is not a partial override: it builds a fresh
/// stock Material theme and fills every heading, rule and highlight field from
/// it, so registering one stops `GptMarkdownTheme.of` deriving those from the
/// app. Restating them in the extension does not close the gap either — the
/// ambient headings carry the localized text geometry, which exists only once
/// `Theme.of` has run and a `ThemeData` factory cannot see. Overriding the one
/// field on the ambient theme is what leaves non-code Markdown untouched.
class AppMarkdownTheme extends StatelessWidget {
const AppMarkdownTheme({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return GptMarkdownTheme(
gptThemeData: GptMarkdownTheme.of(context).copyWith(
// Inline code is otherwise drawn in gpt_markdown's own bundled face, at
// its own size, tinted from `onSurface`. Stating the app's code style
// gives every Markdown surface the face, size and chip colours a fenced
// code block already uses.
inlineCode: InlineCodeStyle(
fontFamily: CodeStyle.fontFamily,
fontSizeFactor: CodeStyle.fontSizeFactor,
color: scheme.onSurface,
backgroundColor: CodeStyle.background(scheme),
borderColor: CodeStyle.border(scheme),
// A chip is small, so it takes the smallest step of the app's radius
// scale rather than the `Radii.card` a block uses.
borderRadius: const Radius.circular(Radii.xs),
).resolve(scheme),
),
child: child,
);
}
}

View File

@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'message_typography.dart';
/// The app's own code style — one place stating the mono face, its size and
/// the colours a code surface is drawn with.
///
/// Two surfaces render code and they have to read as one family: the fenced
/// block in `MessageContent`, and the inline chip `gpt_markdown` paints from
/// the `InlineCodeStyle` declared in `AppTheme`. Both take their values from
/// here, so neither can drift from the other by editing one copy.
abstract final class CodeStyle {
/// The mono face, already used by the composer and by code blocks.
static const fontFamily = 'GeistMono';
/// Code size at message body scale.
static const fontSize = 13.0;
/// Line height of code text.
static const lineHeight = 1.5;
/// [fontSize] stated against the message body size, for APIs that scale code
/// relative to the text around it rather than fixing a point size. Keeping
/// it a ratio means code follows the body if that size ever changes.
static final fontSizeFactor = fontSize / messageBodyTextStyle.fontSize!;
/// Fill behind a code surface.
static Color background(ColorScheme scheme) =>
scheme.surfaceContainerHighest.withValues(alpha: 0.6);
/// Outline around a code surface.
static Color border(ColorScheme scheme) =>
scheme.outline.withValues(alpha: 0.7);
}

View File

@@ -1,8 +1,10 @@
export 'accent_colors.dart';
export 'adaptive_theme.dart';
export 'app_colors.dart';
export 'app_markdown_theme.dart';
export 'app_theme.dart';
export 'buzz_theme.dart';
export 'code_style.dart';
export 'color_scheme.dart';
export 'community_theme_preference.dart';
export 'community_theme_provider.dart';

View File

@@ -620,10 +620,10 @@ packages:
dependency: "direct main"
description:
name: gpt_markdown
sha256: "5c565a438e569b3b546023d0d4eccccf87e260a3289a17c4e241c41d5e7545df"
sha256: "42887afaf5c7a71348221048a6c1e34c9836703ee0e6b4865e789253fcaa204a"
url: "https://pub.dev"
source: hosted
version: "1.1.6"
version: "1.2.1"
gtk:
dependency: transitive
description:

View File

@@ -27,7 +27,7 @@ dependencies:
nostr: ^2.0.0
pointycastle: ^4.0.0
url_launcher: ^6.3.2
gpt_markdown: ^1.1.6
gpt_markdown: ^1.2.1
highlight: ^0.7.0
intl: ^0.20.2
uuid: ^4.5.1

View File

@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gpt_markdown/custom_widgets/link_button.dart';
import 'package:buzz/features/channels/agent_activity/observer_models.dart';
import 'package:buzz/features/channels/agent_activity/transcript_item_widget.dart';
import 'package:buzz/shared/theme/theme.dart';
Widget _testable(Widget child) {
return MaterialApp(
theme: AppTheme.light(),
home: Scaffold(body: SingleChildScrollView(child: child)),
);
}
void main() {
// Neither transcript surface passes a link handler, so an autolinked bare
// URL would draw as a link and then do nothing when tapped. They stay text.
testWidgets('leaves a bare URL in a message as plain text', (tester) async {
await tester.pumpWidget(
_testable(
TranscriptItemWidget(
item: MessageItem(
id: 'm1',
role: 'assistant',
title: 'Assistant',
text: 'Report is at https://example.com/report today.',
timestamp: '2026-08-29T09:00:00Z',
),
),
),
);
expect(find.byType(LinkButton), findsNothing);
});
testWidgets('leaves a bare URL in a thought as plain text', (tester) async {
await tester.pumpWidget(
_testable(
TranscriptItemWidget(
item: ThoughtItem(
id: 't1',
title: 'Thinking',
text: 'Checking https://example.com/report first.',
timestamp: '2026-08-29T09:00:00Z',
),
),
),
);
expect(find.byType(LinkButton), findsNothing);
});
}

View File

@@ -111,8 +111,12 @@ void main() {
) async {
await tester.pumpWidget(_testable('`:wave:` :unknown:wave:'));
expect(find.byType(CustomEmojiImage), findsOneWidget);
// A paragraph holding inline code renders through BidiRichText, a RichText
// subclass, and `find.byType` matches the exact runtime type only.
final text = tester
.widgetList<RichText>(find.byType(RichText))
.widgetList<RichText>(
find.byWidgetPredicate((widget) => widget is RichText),
)
.map((widget) => widget.text.toPlainText())
.join();
expect(text, contains(':wave:'));

View File

@@ -45,7 +45,8 @@ Widget _testable(
data: MediaQuery.of(
context,
).copyWith(disableAnimations: disableAnimations),
child: Scaffold(body: child),
// The app states its code style here, above the navigator.
child: AppMarkdownTheme(child: Scaffold(body: child)),
),
),
),
@@ -2813,6 +2814,50 @@ Photos
expect(_allRichText(tester), isNot(contains('**')));
});
testWidgets('renders inline code in the app code style', (tester) async {
// The message surfaces pass this style in; the widget's own fallback
// is the smaller `bodyMedium`, which would move the expected size.
await tester.pumpWidget(
_testable(
const MessageContent(
content: 'Run `just test` now',
baseStyle: messageBodyTextStyle,
),
),
);
// gpt_markdown tags inline code with a CodeTextSpan carrying both the
// resolved text style and the colours the chip behind it is painted
// with. It renders through BidiRichText, a RichText subclass, so
// find.byType(RichText) would miss it.
final codeSpans = <CodeTextSpan>[];
for (final rich in tester.widgetList<RichText>(
find.byWidgetPredicate((widget) => widget is RichText),
)) {
rich.text.visitChildren((span) {
if (span is CodeTextSpan && span.text == 'just test') {
codeSpans.add(span);
}
return true;
});
}
expect(codeSpans, hasLength(1));
final code = codeSpans.single;
const scheme = lightColorScheme;
// Face, size and ink — the values a fenced code block is drawn with,
// not the package's bundled mono at its own 0.94 of the body size.
expect(code.style?.fontFamily, CodeStyle.fontFamily);
expect(code.style?.fontSize, closeTo(CodeStyle.fontSize, 0.001));
expect(code.style?.color, scheme.onSurface);
// Chip fill and outline come from the app's code surface rather than
// the package's `onSurface` tints.
expect(code.codeStyle.backgroundColor, CodeStyle.background(scheme));
expect(code.codeStyle.borderColor, CodeStyle.border(scheme));
});
testWidgets('renders code block between paragraphs', (tester) async {
await tester.pumpWidget(
_testable(

View File

@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gpt_markdown/gpt_markdown.dart';
import 'package:buzz/shared/theme/app_markdown_theme.dart';
import 'package:buzz/shared/theme/app_theme.dart';
import 'package:buzz/shared/theme/code_style.dart';
/// The Markdown theme a widget reads under [theme], with [wrapped] deciding
/// whether the app states its code style above it.
Future<GptMarkdownThemeData> _markdownTheme(
WidgetTester tester,
ThemeData theme, {
required bool wrapped,
}) async {
late GptMarkdownThemeData read;
final reader = Builder(
builder: (context) {
read = GptMarkdownTheme.of(context);
return const SizedBox.shrink();
},
);
await tester.pumpWidget(
MaterialApp(
theme: theme,
home: wrapped ? AppMarkdownTheme(child: reader) : reader,
),
);
// MaterialApp animates a theme change, so the first frame after a pump can
// still carry the previous one.
await tester.pumpAndSettle();
return read;
}
void main() {
testWidgets('states inline code and leaves the rest of Markdown alone', (
tester,
) async {
for (final theme in [AppTheme.light(), AppTheme.dark()]) {
final ambient = await _markdownTheme(tester, theme, wrapped: false);
final stated = await _markdownTheme(tester, theme, wrapped: true);
// Headings, rules and the rest keep the values gpt_markdown derives from
// the app theme: a code-only change must not restyle anything else.
expect(stated.h1, ambient.h1);
expect(stated.h2, ambient.h2);
expect(stated.h3, ambient.h3);
expect(stated.h4, ambient.h4);
expect(stated.h5, ambient.h5);
expect(stated.h6, ambient.h6);
expect(stated.hrLineColor, ambient.hrLineColor);
expect(stated.hrLineThickness, ambient.hrLineThickness);
expect(stated.hrLinePadding, ambient.hrLinePadding);
expect(stated.highlightColor, ambient.highlightColor);
expect(stated.linkColor, ambient.linkColor);
expect(stated.linkHoverColor, ambient.linkHoverColor);
expect(
stated.autoAddDividerLineAfterH1,
ambient.autoAddDividerLineAfterH1,
);
expect(stated.styleSheet, ambient.styleSheet);
// Inline code is the whole of the difference.
final scheme = theme.colorScheme;
expect(stated.inlineCode.fontFamily, CodeStyle.fontFamily);
expect(stated.inlineCode.color, scheme.onSurface);
expect(stated.inlineCode.backgroundColor, CodeStyle.background(scheme));
expect(stated.inlineCode.borderColor, CodeStyle.border(scheme));
expect(
ambient.inlineCode.fontFamily,
isNot(CodeStyle.fontFamily),
reason: 'the package default is what this widget exists to replace',
);
}
});
}