diff --git a/AGENTS.md b/AGENTS.md
index 46a7a48..95812e7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -85,7 +85,7 @@ Status legend: ⬜ Todo · ✅ Done
| 18 | Suggestion & Suggestion Group | plain & outlined rows; scroll, wrap, column | ✅ |
| 19 | Confirmation | default, approved, rejected | ⬜ |
| 20 | Error state | | ⬜ |
-| 21 | Code block | | ⬜ |
+| 21 | Code block | built-in synchronous highlighter; languages host-extensible | ✅ |
| 22 | Thinking indicator | turning, breathing asterisk + shimmer label; active & settled | ✅ |
| 23 | Shimmer | text only; sweeping highlight, static when settled | ✅ |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5451eb9..376da86 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,15 @@
## 0.2.0 (unreleased)
+- **Code block** — `FlowCodeBlock`, and a `FlowCodePart` message part
+ rendered by `FlowMessage`/`FlowThread` with copy intent surfaced on
+ `onCodeCopy`. Highlighting is built in and synchronous (Dart, JSON,
+ JavaScript/TypeScript, Python, shell, YAML, HTML, CSS and SQL;
+ host-extensible via `FlowCodeLanguage.register`), colored by new
+ `FlowSyntaxColors` theme tokens. The package now also bundles Geist Mono (three weights, SIL OFL)
+ behind new `code` / `codeInline` typography roles — `withFontFamily()`
+ no longer touches the mono roles; swap those with
+ `withCodeFontFamily()`.
- **Breaking**: migrated from `package:flutter/material.dart` to
`package:material_ui` (Material's home since Flutter 3.47) — no API
changes, but the two Materials are distinct types, so the host app must
diff --git a/CLAUDE.md b/CLAUDE.md
index 1253858..a04ddff 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Two hard constraints shape everything here:
-- **No third-party dependencies.** `dependencies:` in `pubspec.yaml` contains only the Flutter SDK and Flutter's own first-party packages — `material_ui` (Material's home since Flutter 3.47) and its transitive set, all published by flutter.dev. Nothing outside that. Dev dependencies (`flutter_test`, `flutter_lints`) are fine. Bundled *assets* are not dependencies: the package ships Figtree (`fonts/`, SIL OFL) because the design system is set in it, declared under `flutter: fonts:` and referenced as `package: 'flow_ui'`.
+- **No third-party dependencies.** `dependencies:` in `pubspec.yaml` contains only the Flutter SDK and Flutter's own first-party packages — `material_ui` (Material's home since Flutter 3.47) and its transitive set, all published by flutter.dev. Nothing outside that. Dev dependencies (`flutter_test`, `flutter_lints`) are fine. Bundled *assets* are not dependencies: the package ships Figtree and Geist Mono (`fonts/`, SIL OFL) because the design system is set in them, declared under `flutter: fonts:` and referenced as `package: 'flow_ui'`.
- **Nothing model-facing.** Components render state passed in and report intent out through callbacks. No prompts, schemas, provider/network calls, or any LLM awareness — that belongs to the layers built on top.
The theme, the conversation components (message, thread, streaming text, actions, loading), the composer and its menus, attachments with their preview, suggestions, and the chat surface are implemented; the roadmap below tracks the rest. Message content is modeled as typed parts (`lib/src/models/`) — sealed `FlowMessagePart` subtypes rendered by `FlowMessage`, with `FlowCustomPart` + `FlowCustomPartBuilder` as the extension seam for host-injected content.
@@ -87,7 +87,7 @@ Values come from the Flow UI Figma file. Role names follow Material 3's `ColorSc
| 18 | Suggestion & Suggestion Group | plain & outlined rows; scroll, wrap, column | ✅ |
| 19 | Confirmation | default, approved, rejected | ⬜ |
| 20 | Error state | | ⬜ |
-| 21 | Code block | | ⬜ |
+| 21 | Code block | built-in synchronous highlighter; languages host-extensible | ✅ |
| 22 | Thinking indicator | turning, breathing asterisk + shimmer label; active & settled | ✅ |
| 23 | Shimmer | text only; sweeping highlight, static when settled | ✅ |
diff --git a/README.md b/README.md
index f0aff52..f0852a4 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,7 @@
| [`FlowStreamingText`](https://flowui.stac.dev/components/streaming-text) | Animated text reveal while a reply arrives |
| [`FlowThinkingIndicator`](https://flowui.stac.dev/components/thinking-indicator) | Turning, breathing asterisk with a shimmering label |
| [`FlowShimmerText`](https://flowui.stac.dev/components/shimmer-text) | Sweeping text highlight, static once settled |
+| [`FlowCodeBlock`](https://flowui.stac.dev/components/code-block) | Fenced code with built-in synchronous highlighting, a header label, and a copy affordance — languages host-extensible |
| [`FlowMessageActions`](https://flowui.stac.dev/components/message-actions) | Copy / regenerate / edit / feedback row under a message |
| [`FlowComposer`](https://flowui.stac.dev/components/composer) | Multiline input with send/stop, attachments strip, and leading/trailing action slots |
| [`FlowMenu`](https://flowui.stac.dev/components/menu) | Icon-triggered menu with groups, submenus, and toggles — anchored card on desktop, bottom sheet on phones |
diff --git a/docs/src/content/docs/components/code-block.mdx b/docs/src/content/docs/components/code-block.mdx
new file mode 100644
index 0000000..d6eb5d6
--- /dev/null
+++ b/docs/src/content/docs/components/code-block.mdx
@@ -0,0 +1,168 @@
+---
+title: Code block
+description: Fenced code on its own ground — built-in synchronous highlighting, a header label, and a copy affordance that reports intent.
+sidebar:
+ order: 13
+---
+
+import FlowDemo from '../../../components/FlowDemo.astro';
+
+`FlowCodeBlock` renders a fenced snippet on its own washed ground, under a
+header that names the content and offers copy. Highlighting is built in
+and synchronous — no setup call, no assets, nothing async. `language`
+picks a rule table (Dart, JSON, JavaScript/TypeScript, Python, shell,
+YAML, HTML, CSS and SQL ship in the box); unknown ids render plain,
+never an error. Token inks come from the theme's `FlowSyntaxColors`, so
+code recolors with the rest of the palette in both themes.
+
+In a message, code arrives as a `FlowCodePart` and `FlowMessage` renders
+the block for you — copy intent surfaces on `FlowThread.onCodeCopy` with
+the part in hand.
+
+## Dart
+
+
+
+```dart title="The block reports intent; the host owns the clipboard"
+FlowCodeBlock(
+ code: source,
+ language: 'dart',
+ filename: 'point.dart',
+ copyTooltip: 'Copy code',
+ copied: copied,
+ onCopy: () async {
+ await Clipboard.setData(ClipboardData(text: source));
+ setState(() => copied = true);
+ // Hold `copied` for a beat, then clear it — the affordance shows a
+ // primary-tinted check for as long as the host says so.
+ },
+)
+```
+
+## JSON
+
+Keys read apart from string values, and long lines scroll horizontally
+inside the block (`wrap: true` wraps them instead):
+
+
+
+```dart title="A payload with a long line"
+FlowCodeBlock(
+ code: payload,
+ language: 'json',
+ filename: 'screen.json',
+ copyTooltip: 'Copy code',
+ onCopy: () => copy(payload),
+)
+```
+
+## Plain
+
+No `language` — or one the registry doesn't know — renders plain ink;
+the header can still carry a filename:
+
+
+
+```dart title="Logs, output, anything unhighlightable"
+FlowCodeBlock(
+ code: log,
+ filename: 'build.log',
+ copyTooltip: 'Copy',
+ onCopy: () => copy(log),
+)
+```
+
+## Streaming
+
+While `isStreaming` is true the copy affordance hides — there is nothing
+complete to copy — and each delivery renders whole, with no per-character
+reveal (streamed code arrives re-highlighted as often as appended, which
+a reveal would restart on). In a thread this is automatic while the
+message streams:
+
+
+
+```dart title="Code parts in a streaming thread"
+FlowThread(
+ messages: messages,
+ codeCopyTooltip: 'Copy code',
+ copiedCodePart: copiedPart,
+ onCodeCopy: (part) async {
+ await Clipboard.setData(ClipboardData(text: part.code));
+ setState(() => copiedPart = part);
+ },
+)
+```
+
+## Languages
+
+Resolution is by id or alias, case-insensitive, and anything unknown
+renders plain — a `rust` fence degrades gracefully rather than erroring:
+
+| Language | id | Aliases |
+|---|---|---|
+| Dart | `dart` | — |
+| JSON | `json` | `jsonc` |
+| JavaScript / TypeScript | `javascript` | `js`, `jsx`, `typescript`, `ts`, `tsx` |
+| Python | `python` | `py` |
+| Shell | `bash` | `sh`, `shell`, `zsh` |
+| YAML | `yaml` | `yml` |
+| HTML | `html` | `xml` |
+| CSS | `css` | `scss`, `less` |
+| SQL | `sql` | `mysql`, `postgres`, `postgresql`, `sqlite` |
+| Plain | `plain` | `text`, `txt` |
+
+YAML, for one, knows that `on:` is a key before it is a boolean:
+
+
+
+The registry is host-extensible. `FlowCodeLanguage` is an ordered rule
+table — earlier rules win, so comments and strings come first — and
+`register` makes it resolvable from every block. A table like SQL's,
+whose keywords come in either case, sets `caseSensitive: false`:
+
+```dart title="Registering a language"
+FlowCodeLanguage.register(
+ const FlowCodeLanguage(
+ id: 'lisp',
+ rules: [
+ FlowSyntaxRule(FlowSyntaxToken.comment, r';[^\n]*'),
+ FlowSyntaxRule(FlowSyntaxToken.string, r'"(?:\\.|[^"\\])*"'),
+ FlowSyntaxRule(FlowSyntaxToken.punctuation, r'[()]+'),
+ ],
+ ),
+);
+```
+
+## Theming
+
+Token inks are theme tokens. Install a `FlowSyntaxColors` on the theme to
+recolor them; the code face is Geist Mono, bundled with the package, and
+swaps via the typography:
+
+```dart title="Custom syntax palette and mono face"
+FlowTheme(
+ colors: FlowColors.light,
+ syntax: FlowSyntaxColors.light.copyWith(keyword: myBrandAccent),
+ typography: FlowTypography.standard.withCodeFontFamily('JetBrainsMono'),
+)
+```
+
+## Key API
+
+- `FlowCodeBlock` — `code`, `language` (id or alias, case-insensitive),
+ `filename` (header label; falls back to `language`), `onCopy` /
+ `copied` / `copyTooltip` (intent out, host-owned confirmation),
+ `isStreaming`, `wrap`, plus `padding` and `borderRadius` over the
+ design's 16/12 inset and 12px corner.
+- `FlowCodePart` — `code`, `language`, `filename`; rendered by
+ `FlowMessage`, skipped in system messages.
+- `FlowThread` / `FlowMessage` — `onCodeCopy` (handed the tapped part),
+ `copiedCodePart` (pass the instance back while the confirmation lasts),
+ `codeCopyTooltip`.
+- `FlowCodeLanguage` — `find`, `register`, `caseSensitive`, and the
+ built-ins (`dart`, `json`, `javascript`, `python`, `bash`, `yaml`,
+ `html`, `css`, `sql`, `plain`); `FlowSyntaxRule` pairs a regex with a
+ `FlowSyntaxToken`.
+- `FlowSyntaxColors` — eight token roles with light and dark presets, on
+ `FlowTheme.syntax`.
diff --git a/docs/src/content/docs/roadmap.md b/docs/src/content/docs/roadmap.md
index 024ad9e..355b009 100644
--- a/docs/src/content/docs/roadmap.md
+++ b/docs/src/content/docs/roadmap.md
@@ -40,7 +40,7 @@ elements and the remaining AI states are on the way.
| Suggestions | Shipped |
| Confirmation | Planned |
| Error state | Planned |
-| Code block | Planned |
+| Code block | Shipped |
| Thinking indicator | Shipped |
| Shimmer | Shipped |
diff --git a/fonts/GeistMono-Medium.ttf b/fonts/GeistMono-Medium.ttf
new file mode 100644
index 0000000..ff49ece
Binary files /dev/null and b/fonts/GeistMono-Medium.ttf differ
diff --git a/fonts/GeistMono-Regular.ttf b/fonts/GeistMono-Regular.ttf
new file mode 100644
index 0000000..50c9d5a
Binary files /dev/null and b/fonts/GeistMono-Regular.ttf differ
diff --git a/fonts/GeistMono-SemiBold.ttf b/fonts/GeistMono-SemiBold.ttf
new file mode 100644
index 0000000..1b21724
Binary files /dev/null and b/fonts/GeistMono-SemiBold.ttf differ
diff --git a/fonts/OFL-GeistMono.txt b/fonts/OFL-GeistMono.txt
new file mode 100644
index 0000000..04e95fc
--- /dev/null
+++ b/fonts/OFL-GeistMono.txt
@@ -0,0 +1,93 @@
+Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://openfontlicense.org
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
\ No newline at end of file
diff --git a/lib/flow_ui.dart b/lib/flow_ui.dart
index 2d1abab..846b47d 100644
--- a/lib/flow_ui.dart
+++ b/lib/flow_ui.dart
@@ -10,11 +10,13 @@ export 'src/models/flow_attachment.dart';
export 'src/models/flow_message_data.dart';
export 'src/models/flow_message_part.dart';
export 'src/theme/flow_colors.dart';
+export 'src/theme/flow_syntax_colors.dart';
export 'src/theme/flow_theme.dart';
export 'src/theme/flow_typography.dart';
export 'src/widgets/flow_attachment_group.dart';
export 'src/widgets/flow_attachment_preview.dart';
export 'src/widgets/flow_chat_screen.dart';
+export 'src/widgets/flow_code_block.dart';
export 'src/widgets/flow_composer.dart';
export 'src/widgets/flow_greeting.dart';
export 'src/widgets/flow_menu.dart';
diff --git a/lib/src/models/flow_message_part.dart b/lib/src/models/flow_message_part.dart
index 8bfdcd6..1b3cf49 100644
--- a/lib/src/models/flow_message_part.dart
+++ b/lib/src/models/flow_message_part.dart
@@ -4,7 +4,7 @@ import 'flow_attachment.dart';
/// One piece of content inside a `FlowMessageData`.
///
-/// Sealed so renderers can switch exhaustively. More part types (code, tool)
+/// Sealed so renderers can switch exhaustively. More part types (tool)
/// arrive alongside their components; [FlowCustomPart] is how hosts inject
/// arbitrary content today.
@immutable
@@ -26,6 +26,22 @@ class FlowAttachmentPart extends FlowMessagePart {
final List attachments;
}
+/// Fenced code, rendered by a `FlowCodeBlock`.
+class FlowCodePart extends FlowMessagePart {
+ const FlowCodePart(this.code, {this.language, this.filename});
+
+ /// The source, verbatim.
+ final String code;
+
+ /// `FlowCodeLanguage` id or alias — usually the fence info string, e.g.
+ /// `'dart'`. Null or unknown renders plain.
+ final String? language;
+
+ /// The block's header label, e.g. a file hint beside the fence. Null
+ /// falls back to [language].
+ final String? filename;
+}
+
/// Host-defined content, rendered through a `FlowCustomPartBuilder`.
class FlowCustomPart extends FlowMessagePart {
const FlowCustomPart({required this.type, this.data});
diff --git a/lib/src/theme/flow_syntax_colors.dart b/lib/src/theme/flow_syntax_colors.dart
new file mode 100644
index 0000000..70c1c40
--- /dev/null
+++ b/lib/src/theme/flow_syntax_colors.dart
@@ -0,0 +1,110 @@
+import 'package:material_ui/material_ui.dart';
+
+/// Syntax token colors for the code block.
+///
+/// Eight roles cover what the built-in highlighter distinguishes; plain
+/// code takes the ambient ink (`FlowColors.onSurface`), so no role exists
+/// for it. Unlike the ink ramp these are resolved, opaque colors — a hue
+/// at an alpha would drift on the code block's washed ground.
+///
+/// The presets are drawn to sit with the shipped palettes: hues keyed to
+/// the warm paper and rose accent of the light theme, the same set lifted
+/// for dark.
+@immutable
+class FlowSyntaxColors {
+ const FlowSyntaxColors({
+ required this.keyword,
+ required this.type,
+ required this.function,
+ required this.string,
+ required this.number,
+ required this.comment,
+ required this.meta,
+ required this.punctuation,
+ });
+
+ /// Reserved words and control flow — `class`, `if`, `return`.
+ final Color keyword;
+
+ /// Type names: uppercase identifiers and language primitives.
+ final Color type;
+
+ /// A call or declaration site — an identifier ahead of its `(`.
+ final Color function;
+
+ /// String literals, quotes included.
+ final Color string;
+
+ /// Numeric literals.
+ final Color number;
+
+ /// Comments.
+ final Color comment;
+
+ /// Annotations, decorators and shell variables — `@override`, `$HOME`.
+ final Color meta;
+
+ /// Brackets, separators and operators.
+ final Color punctuation;
+
+ /// Light preset: saturated inks on warm paper, the keyword in the
+ /// palette's rose family.
+ static const FlowSyntaxColors light = FlowSyntaxColors(
+ keyword: Color(0xFFA62457),
+ type: Color(0xFF96540A),
+ function: Color(0xFF6E4DBE),
+ string: Color(0xFF0E7264),
+ number: Color(0xFF1F63BC),
+ comment: Color(0xFF8C8579),
+ meta: Color(0xFF5F6E8C),
+ punctuation: Color(0xFF6F6A60),
+ );
+
+ /// Dark preset: the same hues lifted to read on the dark ground.
+ static const FlowSyntaxColors dark = FlowSyntaxColors(
+ keyword: Color(0xFFEF6E9F),
+ type: Color(0xFFD9A054),
+ function: Color(0xFFB49AEC),
+ string: Color(0xFF5BC4AF),
+ number: Color(0xFF74A9EC),
+ comment: Color(0xFF8A8177),
+ meta: Color(0xFF8FA3CE),
+ punctuation: Color(0xFF938D82),
+ );
+
+ FlowSyntaxColors copyWith({
+ Color? keyword,
+ Color? type,
+ Color? function,
+ Color? string,
+ Color? number,
+ Color? comment,
+ Color? meta,
+ Color? punctuation,
+ }) {
+ return FlowSyntaxColors(
+ keyword: keyword ?? this.keyword,
+ type: type ?? this.type,
+ function: function ?? this.function,
+ string: string ?? this.string,
+ number: number ?? this.number,
+ comment: comment ?? this.comment,
+ meta: meta ?? this.meta,
+ punctuation: punctuation ?? this.punctuation,
+ );
+ }
+
+ FlowSyntaxColors lerp(FlowSyntaxColors? other, double t) {
+ if (other == null) return this;
+ return FlowSyntaxColors(
+ keyword: Color.lerp(keyword, other.keyword, t)!,
+ type: Color.lerp(type, other.type, t)!,
+ function: Color.lerp(function, other.function, t)!,
+ string: Color.lerp(string, other.string, t)!,
+ number: Color.lerp(number, other.number, t)!,
+ comment: Color.lerp(comment, other.comment, t)!,
+ meta: Color.lerp(meta, other.meta, t)!,
+ punctuation: Color.lerp(punctuation, other.punctuation, t)!,
+ );
+ }
+}
diff --git a/lib/src/theme/flow_theme.dart b/lib/src/theme/flow_theme.dart
index e8848b3..2363ba2 100644
--- a/lib/src/theme/flow_theme.dart
+++ b/lib/src/theme/flow_theme.dart
@@ -1,6 +1,7 @@
import 'package:material_ui/material_ui.dart';
import 'flow_colors.dart';
+import 'flow_syntax_colors.dart';
import 'flow_typography.dart';
/// The flow_ui design tokens — colors and typography — installed as a
@@ -28,22 +29,36 @@ class FlowTheme extends ThemeExtension {
const FlowTheme({
required this.colors,
this.typography = FlowTypography.standard,
+ this.syntax,
});
/// Light preset.
- factory FlowTheme.light() => const FlowTheme(colors: FlowColors.light);
+ factory FlowTheme.light() =>
+ const FlowTheme(colors: FlowColors.light, syntax: FlowSyntaxColors.light);
/// Dark preset.
- factory FlowTheme.dark() => const FlowTheme(colors: FlowColors.dark);
+ factory FlowTheme.dark() =>
+ const FlowTheme(colors: FlowColors.dark, syntax: FlowSyntaxColors.dark);
final FlowColors colors;
final FlowTypography typography;
+ /// Syntax token colors for code blocks. Null resolves to the preset
+ /// matching the ambient brightness — unlike [typography], the right
+ /// default depends on which way the theme leans, which a constructor
+ /// default can't see.
+ final FlowSyntaxColors? syntax;
+
@override
- FlowTheme copyWith({FlowColors? colors, FlowTypography? typography}) {
+ FlowTheme copyWith({
+ FlowColors? colors,
+ FlowTypography? typography,
+ FlowSyntaxColors? syntax,
+ }) {
return FlowTheme(
colors: colors ?? this.colors,
typography: typography ?? this.typography,
+ syntax: syntax ?? this.syntax,
);
}
@@ -53,6 +68,7 @@ class FlowTheme extends ThemeExtension {
return FlowTheme(
colors: colors.lerp(other.colors, t),
typography: typography.lerp(other.typography, t),
+ syntax: syntax == null ? other.syntax : syntax!.lerp(other.syntax, t),
);
}
}
@@ -71,4 +87,14 @@ extension FlowThemeContext on BuildContext {
FlowColors get flowColors => flowTheme.colors;
FlowTypography get flowTypography => flowTheme.typography;
+
+ /// Syntax colors: the installed set, or the preset matching the ambient
+ /// brightness when the theme carries none.
+ FlowSyntaxColors get flowSyntaxColors {
+ final syntax = flowTheme.syntax;
+ if (syntax != null) return syntax;
+ return Theme.of(this).brightness == Brightness.dark
+ ? FlowSyntaxColors.dark
+ : FlowSyntaxColors.light;
+ }
}
diff --git a/lib/src/theme/flow_typography.dart b/lib/src/theme/flow_typography.dart
index 1cd6f3b..6721102 100644
--- a/lib/src/theme/flow_typography.dart
+++ b/lib/src/theme/flow_typography.dart
@@ -1,13 +1,34 @@
import 'package:material_ui/material_ui.dart';
-/// The typeface the presets are drawn in, bundled with this package under
-/// `fonts/` (SIL Open Font License — see `fonts/OFL.txt`).
+/// The typefaces the presets are drawn in, bundled with this package under
+/// `fonts/` (SIL Open Font License — see `fonts/OFL.txt` and
+/// `fonts/OFL-GeistMono.txt`): Figtree for prose, Geist Mono for code.
///
-/// Declared with `package:` so the family resolves to `packages/flow_ui/…`
-/// and a host gets the design's typeface without adding a font of their own.
+/// Declared with `package:` so the families resolve to `packages/flow_ui/…`
+/// and a host gets the design's typefaces without adding a font of their
+/// own.
const String _fontFamily = 'Figtree';
+const String _monoFontFamily = 'GeistMono';
const String _fontPackage = 'flow_ui';
+/// The mono roles' standard cuts — file-level so they can double as the
+/// constructor defaults, which keeps [FlowTypography]'s pre-code
+/// constructor calls compiling unchanged.
+const TextStyle _standardCode = TextStyle(
+ fontFamily: _monoFontFamily,
+ package: _fontPackage,
+ fontSize: 13,
+ fontWeight: FontWeight.w400,
+ height: 1.6,
+);
+const TextStyle _standardCodeInline = TextStyle(
+ fontFamily: _monoFontFamily,
+ package: _fontPackage,
+ fontSize: 14,
+ fontWeight: FontWeight.w400,
+ height: 1.5,
+);
+
/// Text style tokens for flow_ui components.
///
/// Follows the Material 3 type scale (display / headline / title / body /
@@ -44,6 +65,8 @@ class FlowTypography {
required this.labelLarge,
required this.labelMedium,
required this.labelSmall,
+ this.code = _standardCode,
+ this.codeInline = _standardCodeInline,
});
final TextStyle displayLarge;
@@ -74,7 +97,15 @@ class FlowTypography {
final TextStyle labelMedium;
final TextStyle labelSmall;
- /// The Flow type scale, in Figtree.
+ /// Code, set in the bundled Geist Mono — the code block's body. A step
+ /// under prose (13) on a taller line (1.6), so a block reads as inset
+ /// material rather than continuing the paragraph.
+ final TextStyle code;
+
+ /// The mono face at prose size, for future inline code spans.
+ final TextStyle codeInline;
+
+ /// The Flow type scale: Figtree, with the code roles in Geist Mono.
static const FlowTypography standard = FlowTypography(
displayLarge: TextStyle(
fontFamily: _fontFamily,
@@ -228,6 +259,8 @@ class FlowTypography {
);
/// The same scale set in [fontFamily] instead of the bundled Figtree.
+ /// The mono roles keep their own face — swap those with
+ /// [withCodeFontFamily].
///
/// Pass [package] when the font ships inside a package rather than the app.
/// Each style is rebuilt from the four things the scale carries — size,
@@ -265,9 +298,28 @@ class FlowTypography {
labelLarge: reface(labelLarge),
labelMedium: reface(labelMedium),
labelSmall: reface(labelSmall),
+ code: code,
+ codeInline: codeInline,
);
}
+ /// The same scale with only [code] and [codeInline] set in [fontFamily] —
+ /// for hosts swapping the mono face while keeping the prose one.
+ ///
+ /// Rebuilt from size, weight and line height, like [withFontFamily].
+ FlowTypography withCodeFontFamily(String fontFamily, {String? package}) {
+ TextStyle reface(TextStyle style) => TextStyle(
+ fontFamily: fontFamily,
+ package: package,
+ fontSize: style.fontSize,
+ fontWeight: style.fontWeight,
+ height: style.height,
+ letterSpacing: style.letterSpacing,
+ );
+
+ return copyWith(code: reface(code), codeInline: reface(codeInline));
+ }
+
FlowTypography copyWith({
TextStyle? displayLarge,
TextStyle? displayMedium,
@@ -290,6 +342,8 @@ class FlowTypography {
TextStyle? labelLarge,
TextStyle? labelMedium,
TextStyle? labelSmall,
+ TextStyle? code,
+ TextStyle? codeInline,
}) {
return FlowTypography(
displayLarge: displayLarge ?? this.displayLarge,
@@ -313,6 +367,8 @@ class FlowTypography {
labelLarge: labelLarge ?? this.labelLarge,
labelMedium: labelMedium ?? this.labelMedium,
labelSmall: labelSmall ?? this.labelSmall,
+ code: code ?? this.code,
+ codeInline: codeInline ?? this.codeInline,
);
}
@@ -352,6 +408,8 @@ class FlowTypography {
labelLarge: TextStyle.lerp(labelLarge, other.labelLarge, t)!,
labelMedium: TextStyle.lerp(labelMedium, other.labelMedium, t)!,
labelSmall: TextStyle.lerp(labelSmall, other.labelSmall, t)!,
+ code: TextStyle.lerp(code, other.code, t)!,
+ codeInline: TextStyle.lerp(codeInline, other.codeInline, t)!,
);
}
}
diff --git a/lib/src/utils/flow_syntax_highlighter.dart b/lib/src/utils/flow_syntax_highlighter.dart
new file mode 100644
index 0000000..c51b908
--- /dev/null
+++ b/lib/src/utils/flow_syntax_highlighter.dart
@@ -0,0 +1,549 @@
+import 'package:material_ui/material_ui.dart';
+
+import '../theme/flow_syntax_colors.dart';
+
+// The highlighting engine behind FlowCodeBlock. The registry types are
+// re-exported through flow_code_block.dart so hosts can add languages; the
+// engine itself is not part of the public surface.
+//
+// The string-in / TextSpan-out shape follows serverpod's syntax_highlight,
+// but where that package walks TextMate grammars loaded from assets (and so
+// needs async setup), this one compiles a language's ordered rule table
+// into a single alternated regex and colors matches in one synchronous
+// pass. Rule order is precedence: at the same position the earlier rule's
+// alternative wins, which is how a keyword inside a comment stays a
+// comment.
+
+/// The token classes the built-in highlighter distinguishes, each colored
+/// by the matching [FlowSyntaxColors] role. Anything no rule claims stays
+/// plain ink.
+enum FlowSyntaxToken {
+ /// Reserved words and control flow — `class`, `if`, `return`.
+ keyword,
+
+ /// Type names: uppercase identifiers and language primitives.
+ type,
+
+ /// A call or declaration site — an identifier ahead of its `(`.
+ function,
+
+ /// String literals, quotes included.
+ string,
+
+ /// Numeric literals.
+ number,
+
+ /// Comments.
+ comment,
+
+ /// Annotations, decorators and shell variables — `@override`, `$HOME`.
+ meta,
+
+ /// Brackets, separators and operators.
+ punctuation,
+}
+
+/// One highlighting rule: every match of [pattern] reads as [token].
+///
+/// [pattern] is regex *source*, not a [RegExp] — the language joins its
+/// rules into one alternation, so a pattern must not contain named groups
+/// or numbered backreferences (plain `(…)` groups are fine).
+@immutable
+class FlowSyntaxRule {
+ const FlowSyntaxRule(this.token, this.pattern);
+
+ final FlowSyntaxToken token;
+ final String pattern;
+}
+
+/// A language the code block can highlight: an id, lookup aliases, and an
+/// ordered rule table.
+///
+/// The built-ins cover the fences an assistant most often emits — Dart,
+/// JSON, JavaScript/TypeScript, Python, shell, YAML, HTML, CSS and SQL.
+/// Hosts extend the set:
+///
+/// ```dart
+/// FlowCodeLanguage.register(
+/// const FlowCodeLanguage(
+/// id: 'lisp',
+/// rules: [FlowSyntaxRule(FlowSyntaxToken.comment, r';[^\n]*')],
+/// ),
+/// );
+/// ```
+///
+/// Rules earlier in the table take precedence, so comments and strings
+/// come first.
+@immutable
+class FlowCodeLanguage {
+ const FlowCodeLanguage({
+ required this.id,
+ this.aliases = const [],
+ required this.rules,
+ this.caseSensitive = true,
+ });
+
+ /// Canonical name, lowercase by convention — what a fence info string
+ /// usually carries.
+ final String id;
+
+ /// Other names [find] resolves, e.g. `'py'` for `'python'`.
+ final List aliases;
+
+ /// Ordered by precedence.
+ final List rules;
+
+ /// False compiles the whole table case-insensitively — for languages
+ /// like SQL whose keywords come in either case (Dart regexes have no
+ /// inline `(?i)`).
+ final bool caseSensitive;
+
+ static final Map _registry = () {
+ final map = {};
+ for (final language in [
+ dart,
+ json,
+ javascript,
+ python,
+ bash,
+ yaml,
+ html,
+ css,
+ sql,
+ plain,
+ ]) {
+ _add(map, language);
+ }
+ return map;
+ }();
+
+ static void _add(
+ Map map,
+ FlowCodeLanguage language,
+ ) {
+ map[language.id.toLowerCase()] = language;
+ for (final alias in language.aliases) {
+ map[alias.toLowerCase()] = language;
+ }
+ }
+
+ /// Makes [language] resolvable through [find], replacing any earlier
+ /// registration of the same id or alias.
+ static void register(FlowCodeLanguage language) {
+ // Drop every key still resolving to the replaced language: an alias
+ // the replacement doesn't relist must not keep serving the old
+ // instance, which shares the new one's compiled-pattern slot.
+ final id = language.id.toLowerCase();
+ _registry.removeWhere((_, earlier) => earlier.id.toLowerCase() == id);
+ _add(_registry, language);
+ FlowSyntaxHighlighter._invalidate(language.id);
+ }
+
+ /// The language for [id] — or an alias, case-insensitively — and null
+ /// when unknown: the caller renders plain, never an error.
+ static FlowCodeLanguage? find(String? id) {
+ if (id == null) return null;
+ return _registry[id.trim().toLowerCase()];
+ }
+
+ /// No rules: source renders in plain ink. The explicit registration lets
+ /// `'text'` fences resolve rather than fall through as unknown (the
+ /// rendering is the same either way).
+ static const FlowCodeLanguage plain = FlowCodeLanguage(
+ id: 'plain',
+ aliases: ['text', 'txt'],
+ rules: [],
+ );
+
+ static const FlowCodeLanguage dart = FlowCodeLanguage(
+ id: 'dart',
+ rules: [
+ FlowSyntaxRule(FlowSyntaxToken.comment, r'//[^\n]*'),
+ FlowSyntaxRule(FlowSyntaxToken.comment, r'/\*[\s\S]*?\*/'),
+ FlowSyntaxRule(FlowSyntaxToken.string, r"r?'''[\s\S]*?'''"),
+ FlowSyntaxRule(FlowSyntaxToken.string, r'r?"""[\s\S]*?"""'),
+ FlowSyntaxRule(FlowSyntaxToken.string, r"r?'(?:\\.|[^'\\\n])*'"),
+ FlowSyntaxRule(FlowSyntaxToken.string, r'r?"(?:\\.|[^"\\\n])*"'),
+ FlowSyntaxRule(FlowSyntaxToken.meta, r'@[A-Za-z_][A-Za-z0-9_]*'),
+ FlowSyntaxRule(FlowSyntaxToken.number, r'\b0[xX][0-9a-fA-F_]+\b'),
+ FlowSyntaxRule(
+ FlowSyntaxToken.number,
+ r'\b\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?\b',
+ ),
+ FlowSyntaxRule(
+ FlowSyntaxToken.keyword,
+ r'\b(?:abstract|as|assert|async|await|base|break|case|catch|class|'
+ r'const|continue|covariant|default|deferred|do|dynamic|else|enum|'
+ r'export|extends|extension|external|factory|false|final|finally|for|'
+ r'get|hide|if|implements|import|in|interface|is|late|library|mixin|'
+ r'new|null|on|operator|part|required|rethrow|return|sealed|set|show|'
+ r'static|super|switch|sync|this|throw|true|try|typedef|var|void|when|'
+ r'while|with|yield)\b',
+ ),
+ FlowSyntaxRule(FlowSyntaxToken.type, r'\b(?:int|double|num|bool)\b'),
+ FlowSyntaxRule(FlowSyntaxToken.type, r'\b[A-Z][A-Za-z0-9_]*\b'),
+ FlowSyntaxRule(
+ FlowSyntaxToken.function,
+ r'\b[a-z_][A-Za-z0-9_]*(?=\s*\()',
+ ),
+ FlowSyntaxRule(
+ FlowSyntaxToken.punctuation,
+ r'[{}\[\]().,;:]+|[-+*/%=<>!&|^~?]+',
+ ),
+ ],
+ );
+
+ static const FlowCodeLanguage json = FlowCodeLanguage(
+ id: 'json',
+ aliases: ['jsonc'],
+ rules: [
+ // Line comments aren't JSON, but assistants emit them anyway (and
+ // jsonc makes them official).
+ FlowSyntaxRule(FlowSyntaxToken.comment, r'//[^\n]*'),
+ // A key: a string ahead of its colon — must outrank the value rule.
+ FlowSyntaxRule(FlowSyntaxToken.type, r'"(?:\\.|[^"\\])*"(?=\s*:)'),
+ FlowSyntaxRule(FlowSyntaxToken.string, r'"(?:\\.|[^"\\])*"'),
+ FlowSyntaxRule(FlowSyntaxToken.keyword, r'\b(?:true|false|null)\b'),
+ FlowSyntaxRule(
+ FlowSyntaxToken.number,
+ r'-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b',
+ ),
+ FlowSyntaxRule(FlowSyntaxToken.punctuation, r'[{}\[\],:]+'),
+ ],
+ );
+
+ /// One C-family table serves both: TypeScript is close enough to
+ /// JavaScript at highlighting depth that separate tables would only
+ /// drift.
+ static const FlowCodeLanguage javascript = FlowCodeLanguage(
+ id: 'javascript',
+ aliases: ['js', 'jsx', 'typescript', 'ts', 'tsx'],
+ rules: [
+ FlowSyntaxRule(FlowSyntaxToken.comment, r'//[^\n]*'),
+ FlowSyntaxRule(FlowSyntaxToken.comment, r'/\*[\s\S]*?\*/'),
+ FlowSyntaxRule(FlowSyntaxToken.string, r'`[\s\S]*?`'),
+ FlowSyntaxRule(FlowSyntaxToken.string, r"'(?:\\.|[^'\\\n])*'"),
+ FlowSyntaxRule(FlowSyntaxToken.string, r'"(?:\\.|[^"\\\n])*"'),
+ FlowSyntaxRule(FlowSyntaxToken.meta, r'@[A-Za-z_$][A-Za-z0-9_$]*'),
+ FlowSyntaxRule(FlowSyntaxToken.number, r'\b0[xX][0-9a-fA-F_]+n?\b'),
+ FlowSyntaxRule(
+ FlowSyntaxToken.number,
+ r'\b\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?n?\b',
+ ),
+ FlowSyntaxRule(
+ FlowSyntaxToken.keyword,
+ r'\b(?:abstract|as|async|await|break|case|catch|class|const|continue|'
+ r'debugger|declare|default|delete|do|else|enum|export|extends|false|'
+ r'finally|for|from|function|get|if|implements|import|in|infer|'
+ r'instanceof|interface|is|keyof|let|namespace|new|null|of|override|'
+ r'private|protected|public|readonly|return|satisfies|set|static|'
+ r'super|switch|this|throw|true|try|type|typeof|undefined|var|void|'
+ r'while|with|yield)\b',
+ ),
+ FlowSyntaxRule(
+ FlowSyntaxToken.type,
+ r'\b(?:any|bigint|boolean|never|number|object|string|symbol|'
+ r'unknown)\b',
+ ),
+ FlowSyntaxRule(FlowSyntaxToken.type, r'\b[A-Z][A-Za-z0-9_$]*\b'),
+ FlowSyntaxRule(
+ FlowSyntaxToken.function,
+ r'\b[A-Za-z_$][A-Za-z0-9_$]*(?=\s*\()',
+ ),
+ FlowSyntaxRule(
+ FlowSyntaxToken.punctuation,
+ r'[{}\[\]().,;:]+|[-+*/%=<>!&|^~?]+',
+ ),
+ ],
+ );
+
+ static const FlowCodeLanguage python = FlowCodeLanguage(
+ id: 'python',
+ aliases: ['py'],
+ rules: [
+ FlowSyntaxRule(FlowSyntaxToken.comment, r'#[^\n]*'),
+ FlowSyntaxRule(
+ FlowSyntaxToken.string,
+ r"(?:[rbfuRBFU]{1,2})?'''[\s\S]*?'''",
+ ),
+ FlowSyntaxRule(
+ FlowSyntaxToken.string,
+ r'(?:[rbfuRBFU]{1,2})?"""[\s\S]*?"""',
+ ),
+ FlowSyntaxRule(
+ FlowSyntaxToken.string,
+ r"(?:[rbfuRBFU]{1,2})?'(?:\\.|[^'\\\n])*'",
+ ),
+ FlowSyntaxRule(
+ FlowSyntaxToken.string,
+ r'(?:[rbfuRBFU]{1,2})?"(?:\\.|[^"\\\n])*"',
+ ),
+ FlowSyntaxRule(FlowSyntaxToken.meta, r'@[A-Za-z_][A-Za-z0-9_.]*'),
+ FlowSyntaxRule(FlowSyntaxToken.number, r'\b0[xXoObB][0-9a-fA-F_]+\b'),
+ FlowSyntaxRule(
+ FlowSyntaxToken.number,
+ r'\b\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?\b',
+ ),
+ FlowSyntaxRule(
+ FlowSyntaxToken.keyword,
+ r'\b(?:and|as|assert|async|await|break|case|class|continue|def|del|'
+ r'elif|else|except|False|finally|for|from|global|if|import|in|is|'
+ r'lambda|match|None|nonlocal|not|or|pass|raise|return|self|True|try|'
+ r'while|with|yield)\b',
+ ),
+ FlowSyntaxRule(
+ FlowSyntaxToken.type,
+ r'\b(?:bool|bytes|dict|float|frozenset|int|list|object|set|str|'
+ r'tuple)\b',
+ ),
+ FlowSyntaxRule(FlowSyntaxToken.type, r'\b[A-Z][A-Za-z0-9_]*\b'),
+ FlowSyntaxRule(
+ FlowSyntaxToken.function,
+ r'\b[a-z_][A-Za-z0-9_]*(?=\s*\()',
+ ),
+ FlowSyntaxRule(
+ FlowSyntaxToken.punctuation,
+ r'[{}\[\]().,;:]+|[-+*/%=<>!&|@^~]+',
+ ),
+ ],
+ );
+
+ static const FlowCodeLanguage bash = FlowCodeLanguage(
+ id: 'bash',
+ aliases: ['sh', 'shell', 'zsh'],
+ rules: [
+ FlowSyntaxRule(FlowSyntaxToken.comment, r'#[^\n]*'),
+ FlowSyntaxRule(FlowSyntaxToken.string, r"'[^']*'"),
+ FlowSyntaxRule(FlowSyntaxToken.string, r'"(?:\\.|[^"\\])*"'),
+ // Variables and expansions get the meta ink — the shell's one real
+ // syntax.
+ FlowSyntaxRule(
+ FlowSyntaxToken.meta,
+ r'\$\{[^}\n]*\}|\$[A-Za-z_][A-Za-z0-9_]*|\$[?$!#@*0-9-]',
+ ),
+ FlowSyntaxRule(
+ FlowSyntaxToken.keyword,
+ r'\b(?:alias|break|case|cd|continue|declare|do|done|echo|elif|else|'
+ r'esac|exit|export|fi|for|function|if|in|local|read|return|select|'
+ r'set|shift|source|then|unset|until|while)\b',
+ ),
+ FlowSyntaxRule(FlowSyntaxToken.number, r'\b\d+\b'),
+ FlowSyntaxRule(FlowSyntaxToken.punctuation, r'[|&;<>(){}\[\]=]+'),
+ ],
+ );
+
+ static const FlowCodeLanguage yaml = FlowCodeLanguage(
+ id: 'yaml',
+ aliases: ['yml'],
+ rules: [
+ // Line-start or whitespace before the #, so a fragment mid-word
+ // (an URL) doesn't comment the rest of the line. The consumed
+ // space is invisible in the comment ink.
+ FlowSyntaxRule(FlowSyntaxToken.comment, r'(?:^|[ \t])#[^\n]*'),
+ FlowSyntaxRule(FlowSyntaxToken.meta, r'^(?:---|\.\.\.)'),
+ // Quoted and plain keys, ahead of the string and keyword rules so
+ // `on:` (GitHub Actions) reads as a key, not a boolean.
+ FlowSyntaxRule(FlowSyntaxToken.type, r'"(?:\\.|[^"\\\n])*"(?=\s*:)'),
+ FlowSyntaxRule(FlowSyntaxToken.type, r"'[^'\n]*'(?=\s*:)"),
+ FlowSyntaxRule(
+ FlowSyntaxToken.type,
+ r'[A-Za-z_][\w./$-]*(?=:(?:[ \t]|$))',
+ ),
+ FlowSyntaxRule(FlowSyntaxToken.string, r'"(?:\\.|[^"\\\n])*"'),
+ FlowSyntaxRule(FlowSyntaxToken.string, r"'[^'\n]*'"),
+ // Anchors, aliases and tags.
+ FlowSyntaxRule(FlowSyntaxToken.meta, r'[&*][\w-]+|!!?[\w/-]+'),
+ FlowSyntaxRule(
+ FlowSyntaxToken.keyword,
+ r'\b(?:true|True|TRUE|false|False|FALSE|null|Null|NULL|yes|Yes|YES|'
+ r'no|No|NO|on|On|ON|off|Off|OFF)\b|~',
+ ),
+ FlowSyntaxRule(
+ FlowSyntaxToken.number,
+ r'\b\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?\b',
+ ),
+ FlowSyntaxRule(FlowSyntaxToken.punctuation, r'[\[\]{}:,>|-]+'),
+ ],
+ );
+
+ static const FlowCodeLanguage html = FlowCodeLanguage(
+ id: 'html',
+ aliases: ['xml'],
+ rules: [
+ FlowSyntaxRule(FlowSyntaxToken.comment, r''),
+ // Doctype, CDATA and processing instructions — CDATA spelled out
+ // first, since its body may hold the > that ends the generic .
+ FlowSyntaxRule(
+ FlowSyntaxToken.meta,
+ r'|]*>|<\?[\s\S]*?\?>',
+ ),
+ // Attribute values only — anchored on the =, so quoted prose in
+ // text content stays plain.
+ FlowSyntaxRule(FlowSyntaxToken.string, r'=\s*"[^"]*"'),
+ FlowSyntaxRule(FlowSyntaxToken.string, r"=\s*'[^']*'"),
+ FlowSyntaxRule(FlowSyntaxToken.keyword, r'?[A-Za-z][\w-]*'),
+ FlowSyntaxRule(FlowSyntaxToken.meta, r'[A-Za-z-]+(?==)'),
+ // Entities.
+ FlowSyntaxRule(FlowSyntaxToken.meta, r'&[a-zA-Z]+;|\d+;'),
+ // One angle at a time: a + run would swallow the < opening a
+ // comment or CDATA section straight after a closing >.
+ FlowSyntaxRule(FlowSyntaxToken.punctuation, r'[<>/=]'),
+ ],
+ );
+
+ static const FlowCodeLanguage css = FlowCodeLanguage(
+ id: 'css',
+ aliases: ['scss', 'less'],
+ rules: [
+ FlowSyntaxRule(FlowSyntaxToken.comment, r'/\*[\s\S]*?\*/'),
+ // Line-start or whitespace before the // — as with YAML's # — so an
+ // unquoted url(http://…) doesn't comment out the rest of the line.
+ FlowSyntaxRule(FlowSyntaxToken.comment, r'(?:^|[ \t])//[^\n]*'),
+ FlowSyntaxRule(FlowSyntaxToken.string, r'"(?:\\.|[^"\\\n])*"'),
+ FlowSyntaxRule(FlowSyntaxToken.string, r"'(?:\\.|[^'\\\n])*'"),
+ FlowSyntaxRule(FlowSyntaxToken.keyword, r'@[\w-]+'),
+ FlowSyntaxRule(FlowSyntaxToken.keyword, r'!important\b'),
+ // Hex colors before the class/id selector rule — at the same `#`
+ // they win, and `#bada55` is a color even when it spells a word.
+ FlowSyntaxRule(FlowSyntaxToken.number, r'#[0-9a-fA-F]{3,8}\b'),
+ FlowSyntaxRule(FlowSyntaxToken.function, r'[.#][A-Za-z_][\w-]*'),
+ FlowSyntaxRule(
+ FlowSyntaxToken.number,
+ r'\b\d+(?:\.\d+)?(?:px|em|rem|vh|vw|vmin|vmax|ms|s|fr|deg|ch|pt|%)?',
+ ),
+ // Property names — also custom properties (`--ink`).
+ FlowSyntaxRule(FlowSyntaxToken.type, r'[-a-zA-Z][-\w]*(?=\s*:)'),
+ // Value functions: rgb(), var(), calc().
+ FlowSyntaxRule(FlowSyntaxToken.function, r'[a-zA-Z-]+(?=\()'),
+ FlowSyntaxRule(FlowSyntaxToken.punctuation, r'[{}();:,>+~*]+'),
+ ],
+ );
+
+ static const FlowCodeLanguage sql = FlowCodeLanguage(
+ id: 'sql',
+ aliases: ['mysql', 'postgres', 'postgresql', 'sqlite'],
+ // SQL keywords come uppercase and lowercase; one flag beats listing
+ // both cases through the whole table.
+ caseSensitive: false,
+ rules: [
+ FlowSyntaxRule(FlowSyntaxToken.comment, r'--[^\n]*'),
+ FlowSyntaxRule(FlowSyntaxToken.comment, r'/\*[\s\S]*?\*/'),
+ FlowSyntaxRule(FlowSyntaxToken.comment, r'#[^\n]*'),
+ // Doubled '' is the escape; " and ` quote identifiers.
+ FlowSyntaxRule(FlowSyntaxToken.string, r"'(?:''|[^'\n])*'"),
+ FlowSyntaxRule(FlowSyntaxToken.type, r'"[^"\n]*"|`[^`\n]*`'),
+ // Bind parameters and session variables.
+ FlowSyntaxRule(FlowSyntaxToken.meta, r'@\w+|:\w+|\$\d+'),
+ FlowSyntaxRule(
+ FlowSyntaxToken.keyword,
+ r'\b(?:select|from|where|insert|into|values|update|set|delete|create|'
+ r'table|view|index|drop|alter|add|column|primary|key|foreign|'
+ r'references|constraint|unique|not|null|default|and|or|in|is|like|'
+ r'between|exists|case|when|then|else|end|join|inner|left|right|full|'
+ r'outer|cross|on|as|group|by|having|order|asc|desc|limit|offset|'
+ r'union|all|distinct|with|returning|begin|commit|rollback|'
+ r'transaction|if|replace|temporary|cascade|check|database|schema|'
+ r'grant|revoke|true|false)\b',
+ ),
+ FlowSyntaxRule(
+ FlowSyntaxToken.type,
+ r'\b(?:int|integer|bigint|smallint|serial|decimal|numeric|real|float|'
+ r'double|precision|boolean|bool|char|varchar|text|date|time|'
+ r'timestamp|timestamptz|interval|blob|bytea|json|jsonb|uuid)\b',
+ ),
+ FlowSyntaxRule(FlowSyntaxToken.number, r'\b\d+(?:\.\d+)?\b'),
+ FlowSyntaxRule(FlowSyntaxToken.function, r'[a-z_]\w*(?=\s*\()'),
+ FlowSyntaxRule(FlowSyntaxToken.punctuation, r'[();,.=<>!:+*/-]+'),
+ ],
+ );
+}
+
+/// Colors source through a language's rule table — synchronously: no
+/// assets, no setup call, safe to run in `build`.
+abstract final class FlowSyntaxHighlighter {
+ /// Compiled alternations, keyed by language id. [FlowCodeLanguage.register]
+ /// invalidates its id here so a replacement table takes effect.
+ static final Map _compiled = {};
+
+ static void _invalidate(String id) => _compiled.remove(id);
+
+ static RegExp _patternFor(FlowCodeLanguage language) {
+ return _compiled.putIfAbsent(language.id, () {
+ // Each rule in its own named group: whichever group a match filled
+ // names the rule that won, without counting the rule's internal
+ // groups.
+ final source = [
+ for (var i = 0; i < language.rules.length; i++)
+ '(?${language.rules[i].pattern})',
+ ].join('|');
+ return RegExp(
+ source,
+ multiLine: true,
+ caseSensitive: language.caseSensitive,
+ );
+ });
+ }
+
+ /// [code] as a single [TextSpan]: plain stretches in [style], each rule
+ /// match in the [colors] role of its token. Null or empty [language]
+ /// renders the whole string plain.
+ static TextSpan highlight(
+ String code, {
+ FlowCodeLanguage? language,
+ required TextStyle style,
+ required FlowSyntaxColors colors,
+ }) {
+ if (language == null || language.rules.isEmpty || code.isEmpty) {
+ return TextSpan(text: code, style: style);
+ }
+
+ final rules = language.rules;
+ final pattern = _patternFor(language);
+ final children = [];
+ var position = 0;
+
+ for (final match in pattern.allMatches(code)) {
+ if (match.end == match.start) continue;
+ if (match.start > position) {
+ children.add(TextSpan(text: code.substring(position, match.start)));
+ }
+
+ FlowSyntaxToken? token;
+ for (var i = 0; i < rules.length; i++) {
+ if (match.namedGroup('g$i') != null) {
+ token = rules[i].token;
+ break;
+ }
+ }
+
+ children.add(
+ TextSpan(
+ text: code.substring(match.start, match.end),
+ style: token == null
+ ? null
+ : TextStyle(color: _colorFor(token, colors)),
+ ),
+ );
+ position = match.end;
+ }
+
+ if (position < code.length) {
+ children.add(TextSpan(text: code.substring(position)));
+ }
+
+ return TextSpan(style: style, children: children);
+ }
+
+ static Color _colorFor(FlowSyntaxToken token, FlowSyntaxColors colors) {
+ return switch (token) {
+ FlowSyntaxToken.keyword => colors.keyword,
+ FlowSyntaxToken.type => colors.type,
+ FlowSyntaxToken.function => colors.function,
+ FlowSyntaxToken.string => colors.string,
+ FlowSyntaxToken.number => colors.number,
+ FlowSyntaxToken.comment => colors.comment,
+ FlowSyntaxToken.meta => colors.meta,
+ FlowSyntaxToken.punctuation => colors.punctuation,
+ };
+ }
+}
diff --git a/lib/src/widgets/flow_code_block.dart b/lib/src/widgets/flow_code_block.dart
new file mode 100644
index 0000000..fa2e1b9
--- /dev/null
+++ b/lib/src/widgets/flow_code_block.dart
@@ -0,0 +1,297 @@
+import 'package:material_ui/material_ui.dart';
+
+import '../theme/flow_syntax_colors.dart';
+import '../theme/flow_theme.dart';
+import '../utils/flow_syntax_highlighter.dart';
+
+export '../utils/flow_syntax_highlighter.dart'
+ show FlowCodeLanguage, FlowSyntaxRule, FlowSyntaxToken;
+
+/// A code block: a fenced snippet on its own washed ground, under a header
+/// that names the content and offers copy.
+///
+/// ```dart
+/// FlowCodeBlock(
+/// code: source,
+/// language: 'dart',
+/// copyTooltip: 'Copy code',
+/// copied: copied,
+/// onCopy: () => copy(source),
+/// )
+/// ```
+///
+/// Highlighting is built in and synchronous — no setup call, nothing
+/// async. [language] picks a rule table via [FlowCodeLanguage.find];
+/// unknown ids render plain, never an error, and hosts add languages with
+/// [FlowCodeLanguage.register]. Token inks come from the theme's
+/// [FlowSyntaxColors].
+///
+/// Copying reports intent, per the package's contract: [onCopy] fires, the
+/// host writes the clipboard, then holds [copied] true while its
+/// confirmation lasts — the affordance swaps to a check, tinted primary
+/// like a selected message action. The package touches no clipboard and
+/// ships no strings; [copyTooltip] is the affordance's accessible name.
+///
+/// Fills the width it's given, so it needs a bounded width — any column,
+/// list or message slot provides one.
+class FlowCodeBlock extends StatefulWidget {
+ const FlowCodeBlock({
+ super.key,
+ required this.code,
+ this.language,
+ this.filename,
+ this.onCopy,
+ this.copyTooltip,
+ this.copied = false,
+ this.isStreaming = false,
+ this.wrap = false,
+ this.padding,
+ this.borderRadius,
+ });
+
+ /// The source, rendered verbatim.
+ final String code;
+
+ /// [FlowCodeLanguage] id or alias, case-insensitive — usually the fence
+ /// info string. Null or unknown renders plain.
+ final String? language;
+
+ /// Header label; null falls back to [language], and with neither the
+ /// header keeps only the copy affordance.
+ final String? filename;
+
+ /// Copy intent. Null hides the affordance.
+ final VoidCallback? onCopy;
+
+ /// Host-localized; also the affordance's accessible name.
+ final String? copyTooltip;
+
+ /// Swaps the affordance to a check while true. The host owns the
+ /// confirmation and its timing, as with `FlowMessageAction.thumbUp`.
+ final bool copied;
+
+ /// While true the copy affordance hides — there is nothing complete to
+ /// copy — and each delivery renders whole, with no per-character reveal:
+ /// streamed code arrives re-highlighted as often as appended, which a
+ /// reveal would restart on.
+ final bool isStreaming;
+
+ /// False scrolls long lines horizontally inside the block, keeping the
+ /// author's line breaks; true wraps them instead.
+ final bool wrap;
+
+ /// Around the code. Defaults to the design's 16/12.
+ final EdgeInsetsGeometry? padding;
+
+ /// The block's corner. Defaults to the design's 12.
+ final BorderRadius? borderRadius;
+
+ @override
+ State createState() => _FlowCodeBlockState();
+}
+
+class _FlowCodeBlockState extends State {
+ /// The design's block: the message bubble's 12px corner, code inset
+ /// 16/12, and a 36 header — 6px around the copy affordance's 24 frame,
+ /// with the label a full 16 from the edge so it aligns with the code.
+ static const BorderRadius _radius = BorderRadius.all(Radius.circular(12));
+ static const EdgeInsetsGeometry _bodyPadding = EdgeInsets.symmetric(
+ horizontal: 16,
+ vertical: 12,
+ );
+ static const double _headerHeight = 36;
+ static const EdgeInsetsGeometry _headerPadding = EdgeInsets.only(
+ left: 16,
+ right: 6,
+ );
+
+ /// The block's ground: the same 4% ink wash as the user bubble and the
+ /// attachment tiles, edged with the tiles' outline hairline.
+ static const double _groundOpacity = 0.04;
+
+ /// The last highlight and the inputs it was computed from. Tokenizing
+ /// re-scans the whole source, so a rebuild that changes none of the
+ /// inputs — an ancestor animating, a hover elsewhere — must not pay for
+ /// one.
+ TextSpan? _span;
+ String? _spanCode;
+ FlowCodeLanguage? _spanLanguage;
+ TextStyle? _spanStyle;
+ FlowSyntaxColors? _spanColors;
+
+ TextSpan _highlight(
+ FlowCodeLanguage? language,
+ TextStyle style,
+ FlowSyntaxColors colors,
+ ) {
+ final cached = _span;
+ if (cached != null &&
+ _spanCode == widget.code &&
+ identical(_spanLanguage, language) &&
+ _spanStyle == style &&
+ identical(_spanColors, colors)) {
+ return cached;
+ }
+ final span = FlowSyntaxHighlighter.highlight(
+ widget.code,
+ language: language,
+ style: style,
+ colors: colors,
+ );
+ _span = span;
+ _spanCode = widget.code;
+ _spanLanguage = language;
+ _spanStyle = style;
+ _spanColors = colors;
+ return span;
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = context.flowColors;
+ final typography = context.flowTypography;
+ final syntaxColors = context.flowSyntaxColors;
+
+ final language = FlowCodeLanguage.find(widget.language);
+ final span = _highlight(
+ language,
+ typography.code.copyWith(color: colors.onSurface),
+ syntaxColors,
+ );
+
+ final label = widget.filename ?? widget.language;
+ final showCopy = widget.onCopy != null && !widget.isStreaming;
+ final hasHeader = label != null || widget.onCopy != null;
+
+ final padding = widget.padding ?? _bodyPadding;
+ final code = SelectableText.rich(span);
+ final body = widget.wrap
+ ? Padding(padding: padding, child: code)
+ // Padding inside the scroll view, so the last column of a long
+ // line clears the edge instead of sitting against the clip.
+ : SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ padding: padding,
+ child: code,
+ );
+
+ return Container(
+ width: double.infinity,
+ clipBehavior: Clip.antiAlias,
+ decoration: BoxDecoration(
+ color: colors.onSurface.withValues(alpha: _groundOpacity),
+ borderRadius: widget.borderRadius ?? _radius,
+ border: Border.all(color: colors.outline),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (hasHeader)
+ Container(
+ height: _headerHeight,
+ padding: _headerPadding,
+ decoration: BoxDecoration(
+ border: Border(
+ bottom: BorderSide(color: colors.outlineVariant),
+ ),
+ ),
+ child: Row(
+ children: [
+ Expanded(
+ child: label == null
+ ? const SizedBox.shrink()
+ : Text(
+ label,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: typography.labelMedium.copyWith(
+ color: colors.onSurfaceVariant,
+ ),
+ ),
+ ),
+ if (showCopy)
+ _CopyButton(
+ copied: widget.copied,
+ tooltip: widget.copyTooltip,
+ onTap: widget.onCopy!,
+ ),
+ ],
+ ),
+ ),
+ body,
+ ],
+ ),
+ );
+ }
+}
+
+/// The header's copy affordance: a glyph on a 24 frame, resting at the
+/// muted ink and lifting to full on hover, with the copied check tinted
+/// primary — the message-action dress on a rounder corner.
+class _CopyButton extends StatefulWidget {
+ const _CopyButton({
+ required this.copied,
+ required this.tooltip,
+ required this.onTap,
+ });
+
+ final bool copied;
+ final String? tooltip;
+ final VoidCallback onTap;
+
+ @override
+ State<_CopyButton> createState() => _CopyButtonState();
+}
+
+class _CopyButtonState extends State<_CopyButton> {
+ /// Breathing room around the 16 glyph inside its 24 frame.
+ static const double _framePadding = 4;
+ static const double _iconSize = 16;
+
+ /// The frame's corner — a step under the block's 12, per the ratio the
+ /// menu rows keep to their card.
+ static const BorderRadius _frameRadius = BorderRadius.all(Radius.circular(6));
+
+ bool _hovered = false;
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = context.flowColors;
+
+ final Color foreground;
+ if (widget.copied) {
+ foreground = colors.primary;
+ } else if (_hovered) {
+ foreground = colors.onSurface;
+ } else {
+ foreground = colors.onSurfaceMuted;
+ }
+
+ // Transparent Material so ink and hover fills render inside the
+ // block's decorated container.
+ Widget button = Material(
+ type: MaterialType.transparency,
+ child: InkWell(
+ onTap: widget.onTap,
+ onHover: (value) => setState(() => _hovered = value),
+ borderRadius: _frameRadius,
+ hoverColor: colors.surfaceContainerHigh,
+ child: Padding(
+ padding: const EdgeInsets.all(_framePadding),
+ child: Icon(
+ widget.copied ? Icons.check : Icons.copy_outlined,
+ size: _iconSize,
+ color: foreground,
+ ),
+ ),
+ ),
+ );
+
+ final tooltip = widget.tooltip;
+ if (tooltip != null) {
+ button = Tooltip(message: tooltip, child: button);
+ }
+ return button;
+ }
+}
diff --git a/lib/src/widgets/flow_message.dart b/lib/src/widgets/flow_message.dart
index 6e44899..a50f3f7 100644
--- a/lib/src/widgets/flow_message.dart
+++ b/lib/src/widgets/flow_message.dart
@@ -4,6 +4,7 @@ import '../models/flow_message_data.dart';
import '../models/flow_message_part.dart';
import '../theme/flow_theme.dart';
import 'flow_attachment_group.dart';
+import 'flow_code_block.dart';
import 'flow_thinking_indicator.dart';
import 'flow_streaming_text.dart';
@@ -35,6 +36,9 @@ class FlowMessage extends StatelessWidget {
this.customPartBuilder,
this.onAttachmentTap,
this.previewCloseTooltip,
+ this.onCodeCopy,
+ this.copiedCodePart,
+ this.codeCopyTooltip,
this.leading,
this.footer,
this.maxBubbleWidthFraction = 0.75,
@@ -64,6 +68,19 @@ class FlowMessage extends StatelessWidget {
/// Host-localized label for the built-in preview's close button.
final String? previewCloseTooltip;
+ /// Copy intent from a [FlowCodePart]'s block, handed the part so the
+ /// host knows which code to write to the clipboard. Null hides every
+ /// block's copy affordance.
+ final ValueChanged? onCodeCopy;
+
+ /// The part whose block shows the copied check — pass back the instance
+ /// received from [onCodeCopy] for as long as the confirmation should
+ /// last; the host owns the timing.
+ final FlowCodePart? copiedCodePart;
+
+ /// Host-localized label for each code block's copy affordance.
+ final String? codeCopyTooltip;
+
/// Slot beside the content, e.g. an avatar.
final Widget? leading;
@@ -257,9 +274,9 @@ class FlowMessage extends StatelessWidget {
style: style,
textAlign: TextAlign.center,
),
- // System messages are centered notices; attachments belong to
- // user and assistant turns.
- FlowAttachmentPart() => const SizedBox.shrink(),
+ // System messages are centered notices; attachments and code
+ // belong to user and assistant turns.
+ FlowAttachmentPart() || FlowCodePart() => const SizedBox.shrink(),
FlowCustomPart() =>
customPartBuilder?.call(context, message, part) ??
const SizedBox.shrink(),
@@ -276,7 +293,11 @@ class FlowMessage extends StatelessWidget {
final style = typography.bodyLarge
.copyWith(color: foreground, height: height)
.merge(textStyle);
+ final onCodeCopy = this.onCodeCopy;
+ // Only text parts get the streaming reveal; a message that ends in a
+ // code part streams its code without one (FlowCodeBlock renders each
+ // delivery whole).
final lastTextIndex = message.parts.lastIndexWhere(
(part) => part is FlowTextPart,
);
@@ -306,6 +327,20 @@ class FlowMessage extends StatelessWidget {
onTap: onAttachmentTap,
previewCloseTooltip: previewCloseTooltip,
),
+ FlowCodePart(:final code, :final language, :final filename) =>
+ FlowCodeBlock(
+ code: code,
+ language: language,
+ filename: filename,
+ onCopy: onCodeCopy == null ? null : () => onCodeCopy(part),
+ copyTooltip: codeCopyTooltip,
+ copied: identical(part, copiedCodePart),
+ // Streaming only while it's the growing tail — a part with
+ // content after it is already complete.
+ isStreaming:
+ message.status == FlowMessageStatus.streaming &&
+ i == message.parts.length - 1,
+ ),
FlowCustomPart() => customPartBuilder?.call(context, message, part),
};
if (child == null) continue;
diff --git a/lib/src/widgets/flow_thread.dart b/lib/src/widgets/flow_thread.dart
index 473f549..c386bea 100644
--- a/lib/src/widgets/flow_thread.dart
+++ b/lib/src/widgets/flow_thread.dart
@@ -1,6 +1,7 @@
import 'package:material_ui/material_ui.dart';
import '../models/flow_message_data.dart';
+import '../models/flow_message_part.dart';
import 'flow_message.dart';
/// The scrollable conversation: a bottom-anchored list of [FlowMessageData]s.
@@ -16,6 +17,9 @@ class FlowThread extends StatelessWidget {
this.customPartBuilder,
this.onAttachmentTap,
this.previewCloseTooltip,
+ this.onCodeCopy,
+ this.copiedCodePart,
+ this.codeCopyTooltip,
this.controller,
this.padding,
this.itemSpacing,
@@ -42,6 +46,17 @@ class FlowThread extends StatelessWidget {
/// Host-localized label for the built-in preview's close button.
final String? previewCloseTooltip;
+ /// Copy intent from any code block in the thread, handed the tapped
+ /// [FlowCodePart]. Forwarded to each [FlowMessage].
+ final ValueChanged? onCodeCopy;
+
+ /// The part whose block shows the copied check — the instance received
+ /// from [onCodeCopy], passed back while the host's confirmation lasts.
+ final FlowCodePart? copiedCodePart;
+
+ /// Host-localized label for the code blocks' copy affordance.
+ final String? codeCopyTooltip;
+
/// Optional external scroll controller.
final ScrollController? controller;
@@ -94,6 +109,9 @@ class FlowThread extends StatelessWidget {
? null
: (attachmentId) => onAttachmentTap(message, attachmentId),
previewCloseTooltip: previewCloseTooltip,
+ onCodeCopy: onCodeCopy,
+ copiedCodePart: copiedCodePart,
+ codeCopyTooltip: codeCopyTooltip,
charactersPerSecond: charactersPerSecond,
thinkingLabel: thinkingLabel,
),
diff --git a/playground/lib/src/demo_registry.dart b/playground/lib/src/demo_registry.dart
index e4b13c8..6213699 100644
--- a/playground/lib/src/demo_registry.dart
+++ b/playground/lib/src/demo_registry.dart
@@ -2,6 +2,7 @@ import 'package:material_ui/material_ui.dart';
import 'demos/add_to_chat_demo.dart';
import 'demos/attachments_demo.dart';
+import 'demos/code_block_demo.dart';
import 'demos/composer_demo.dart';
import 'demos/full_chat_demo.dart';
import 'demos/greeting_demo.dart';
@@ -30,6 +31,7 @@ Widget demoFor(PlaygroundItem item, {String? variant}) {
key: key,
variant: variant,
),
+ PlaygroundItem.codeBlock => CodeBlockDemo(key: key, variant: variant),
PlaygroundItem.addToChat => AddToChatDemo(key: key),
PlaygroundItem.attachments => AttachmentsDemo(key: key, variant: variant),
PlaygroundItem.thread => ThreadDemo(key: key, variant: variant),
@@ -65,6 +67,16 @@ List<(String, String)> variantsFor(PlaygroundItem item) {
('animated', 'Animated'),
('static', 'Static'),
],
+ PlaygroundItem.codeBlock => const [
+ ('dart', 'Dart'),
+ ('json', 'JSON'),
+ ('yaml', 'YAML'),
+ ('html', 'HTML'),
+ ('css', 'CSS'),
+ ('sql', 'SQL'),
+ ('plain', 'Plain'),
+ ('streaming', 'Streaming'),
+ ],
PlaygroundItem.attachments => const [
('composer', 'In composer'),
('tiles', 'Tiles only'),
@@ -111,6 +123,7 @@ String snippetFor(PlaygroundItem item) {
PlaygroundItem.modalSelector => modelSelectorSnippet,
PlaygroundItem.message => messageSnippet,
PlaygroundItem.streamingMessage => streamingMessageSnippet,
+ PlaygroundItem.codeBlock => codeBlockSnippet,
PlaygroundItem.addToChat => addToChatSnippet,
PlaygroundItem.attachments => attachmentsSnippet,
PlaygroundItem.thread => threadSnippet,
diff --git a/playground/lib/src/demos/code_block_demo.dart b/playground/lib/src/demos/code_block_demo.dart
new file mode 100644
index 0000000..a8a083a
--- /dev/null
+++ b/playground/lib/src/demos/code_block_demo.dart
@@ -0,0 +1,261 @@
+import 'dart:async';
+
+import 'package:flow_ui/flow_ui.dart';
+import 'package:flutter/services.dart';
+import 'package:material_ui/material_ui.dart';
+
+const String codeBlockSnippet = r'''
+// The block reports intent; the host owns the clipboard and the
+// confirmation's timing.
+FlowCodeBlock(
+ code: source,
+ language: 'dart',
+ filename: 'point.dart',
+ copyTooltip: 'Copy code',
+ copied: copied,
+ onCopy: () async {
+ await Clipboard.setData(ClipboardData(text: source));
+ setState(() => copied = true);
+ },
+)
+
+// In a thread, FlowCodePart renders blocks and copy hands back the part:
+FlowThread(
+ messages: messages,
+ codeCopyTooltip: 'Copy code',
+ copiedCodePart: copiedPart,
+ onCodeCopy: (part) => copy(part),
+)
+
+// Unknown languages render plain; hosts can register their own:
+FlowCodeLanguage.register(
+ const FlowCodeLanguage(
+ id: 'lisp',
+ rules: [FlowSyntaxRule(FlowSyntaxToken.comment, r';[^\n]*')],
+ ),
+);''';
+
+/// Dart with some of everything the highlighter distinguishes: comments,
+/// strings, numbers, keywords, types, calls and an annotation.
+const String _dartSample = r'''
+import 'dart:math';
+
+/// A point on the unit circle.
+class Point {
+ const Point(this.x, this.y);
+
+ final double x;
+ final double y;
+
+ @override
+ String toString() => 'Point($x, $y)';
+}
+
+Point pointAt(num turns) {
+ final angle = turns * 2 * pi;
+ return Point(cos(angle), sin(angle));
+}
+
+void main() {
+ for (var i = 0; i < 4; i++) {
+ // Quarter turns land on the axes.
+ print(pointAt(i / 4));
+ }
+}''';
+
+/// A Stac screen payload — keys read apart from string values, and the
+/// long line exercises the block's horizontal scroll.
+const String _jsonSample = r'''
+{
+ "type": "scaffold",
+ "appBar": {"type": "appBar", "title": {"type": "text", "data": "Home"}},
+ "body": {
+ "type": "column",
+ "children": [
+ {"type": "text", "data": "Hello, Stac!", "maxLines": 1},
+ {"type": "sizedBox", "height": 12.5},
+ {"type": "button", "enabled": true, "onTap": null}
+ ]
+ }
+}''';
+
+/// Keys against strings and booleans — and `on:` reading as a key, not
+/// a boolean.
+const String _yamlSample = r'''
+# Deploy the playground on push.
+name: deploy
+on:
+ push:
+ branches: [main]
+
+env:
+ FLUTTER_VERSION: "3.44"
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - run: flutter build web --release
+ - run: firebase deploy --only hosting
+ if: startsWith(github.ref, 'refs/heads/main')''';
+
+/// Tags, attributes and entities; quoted prose in text content stays
+/// plain.
+const String _htmlSample = r'''
+
+
+
+
+
+ Flow UI
+
+
+
+
+
+''';
+
+/// Selectors, custom properties, units and value functions.
+const String _cssSample = r'''
+/* The stage's ground. */
+:root {
+ --stage-ink: #1e1e1e;
+ --stage-gap: 1.5rem;
+}
+
+.stage {
+ display: grid;
+ gap: var(--stage-gap);
+ padding: 24px 16px;
+ color: var(--stage-ink);
+}
+
+.stage:hover > .pill {
+ opacity: 0.75;
+ transition: opacity 200ms ease;
+}
+
+@media (max-width: 760px) {
+ .stage { padding: 12px; }
+}''';
+
+/// Keywords in either case, quoted strings, bind-style aliases.
+const String _sqlSample = r'''
+-- Threads with their newest message.
+SELECT t.id, t.title, m.body AS latest
+FROM threads AS t
+JOIN messages AS m ON m.thread_id = t.id
+WHERE m.created_at = (
+ SELECT MAX(created_at) FROM messages
+ WHERE thread_id = t.id
+)
+ AND t.visibility = 'public'
+ORDER BY m.created_at DESC
+LIMIT 20;''';
+
+/// No language: plain ink, header from the filename alone.
+const String _plainSample = r'''
+$ flutter build web --release
+Compiling lib/main.dart for the web... 12.4s
+Font asset "MaterialIcons-Regular.otf" was tree-shaken.
+Built build/web in release mode.''';
+
+/// Stage demo for `FlowCodeBlock` — highlighted Dart and JSON, a plain
+/// fallback, and a streaming feed. The demo owns the clipboard write and
+/// the copied confirmation, the way a host would.
+class CodeBlockDemo extends StatefulWidget {
+ const CodeBlockDemo({super.key, this.variant});
+
+ final String? variant;
+
+ @override
+ State createState() => _CodeBlockDemoState();
+}
+
+class _CodeBlockDemoState extends State {
+ static const Duration _feedTick = Duration(milliseconds: 45);
+ static const int _feedStep = 6;
+ static const Duration _feedRestart = Duration(milliseconds: 1400);
+
+ bool _copied = false;
+ Timer? _reset;
+
+ /// Streaming variant: how much of the sample has "arrived".
+ int _fed = 0;
+ Timer? _feed;
+
+ bool get _streaming => widget.variant == 'streaming';
+
+ @override
+ void initState() {
+ super.initState();
+ if (_streaming) _startFeed();
+ }
+
+ @override
+ void dispose() {
+ _reset?.cancel();
+ _feed?.cancel();
+ super.dispose();
+ }
+
+ void _startFeed() {
+ _fed = 0;
+ _feed = Timer.periodic(_feedTick, (timer) {
+ setState(() {
+ _fed = (_fed + _feedStep).clamp(0, _dartSample.length);
+ });
+ if (_fed == _dartSample.length) {
+ timer.cancel();
+ _feed = Timer(_feedRestart, () {
+ if (mounted) setState(_startFeed);
+ });
+ }
+ });
+ }
+
+ Future _copy(String code) async {
+ await Clipboard.setData(ClipboardData(text: code));
+ if (!mounted) return;
+ setState(() => _copied = true);
+ _reset?.cancel();
+ _reset = Timer(const Duration(milliseconds: 1500), () {
+ if (mounted) setState(() => _copied = false);
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final (code, language, filename) = switch (widget.variant) {
+ 'json' => (_jsonSample, 'json', 'screen.json'),
+ 'yaml' => (_yamlSample, 'yaml', 'deploy.yml'),
+ 'html' => (_htmlSample, 'html', 'index.html'),
+ 'css' => (_cssSample, 'css', 'stage.css'),
+ 'sql' => (_sqlSample, 'sql', 'threads.sql'),
+ 'plain' => (_plainSample, null, 'build.log'),
+ 'streaming' => (_dartSample.substring(0, _fed), 'dart', 'point.dart'),
+ _ => (_dartSample, 'dart', 'point.dart'),
+ };
+
+ return Center(
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.all(24),
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 560),
+ child: FlowCodeBlock(
+ code: code,
+ language: language,
+ filename: filename,
+ copyTooltip: 'Copy code',
+ copied: _copied,
+ isStreaming: _streaming && _fed < _dartSample.length,
+ onCopy: () => _copy(code),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/playground/lib/src/demos/thread_demo.dart b/playground/lib/src/demos/thread_demo.dart
index 0c6f80f..6d77f25 100644
--- a/playground/lib/src/demos/thread_demo.dart
+++ b/playground/lib/src/demos/thread_demo.dart
@@ -1,4 +1,7 @@
+import 'dart:async';
+
import 'package:flow_ui/flow_ui.dart';
+import 'package:flutter/services.dart';
import 'package:material_ui/material_ui.dart';
const String threadSnippet = '''
@@ -10,6 +13,11 @@ SizedBox(
messages: messages,
controller: scroll,
thinkingLabel: 'thinking..',
+ // Code parts render FlowCodeBlocks; copy hands the part back, and
+ // the host passes it along while its confirmation lasts.
+ codeCopyTooltip: 'Copy code',
+ copiedCodePart: copiedPart,
+ onCodeCopy: copyPart,
),
)''';
@@ -17,7 +25,16 @@ const String _reply =
'FlowThread lays the conversation out as a reversed list, so the '
'newest message sits at the bottom and history loads upward. Messages '
'keep their identity by id, which is what makes streaming updates '
- 'cheap.';
+ 'cheap. Give it bounded height and it does the rest:';
+
+const String _replyCode = '''
+SizedBox(
+ height: 480,
+ child: FlowThread(
+ messages: messages,
+ controller: scroll,
+ ),
+)''';
List _seed(bool streaming) => [
FlowMessageData.text(
@@ -40,20 +57,52 @@ List _seed(bool streaming) => [
FlowMessageData(
id: 'a2',
role: FlowMessageRole.assistant,
- parts: const [FlowTextPart(_reply)],
+ // Prose then code: the reveal plays on the text part, while the code
+ // part renders whole — and hides its copy affordance mid-stream.
+ parts: const [
+ FlowTextPart(_reply),
+ FlowCodePart(_replyCode, language: 'dart'),
+ ],
status: streaming
? FlowMessageStatus.streaming
: FlowMessageStatus.complete,
),
];
-/// The conversation list on its own, at a bounded height. The Streaming
-/// variant mounts the last reply mid-stream, so the reveal plays.
-class ThreadDemo extends StatelessWidget {
+/// The conversation list on its own, at a bounded height, closing on a
+/// reply that carries a code part. The Streaming variant mounts that
+/// reply mid-stream, so the text reveal plays above the code block. The
+/// demo owns the clipboard write and the copied confirmation, the way a
+/// host would.
+class ThreadDemo extends StatefulWidget {
const ThreadDemo({super.key, this.variant});
final String? variant;
+ @override
+ State createState() => _ThreadDemoState();
+}
+
+class _ThreadDemoState extends State {
+ FlowCodePart? _copiedPart;
+ Timer? _reset;
+
+ @override
+ void dispose() {
+ _reset?.cancel();
+ super.dispose();
+ }
+
+ Future _copy(FlowCodePart part) async {
+ await Clipboard.setData(ClipboardData(text: part.code));
+ if (!mounted) return;
+ setState(() => _copiedPart = part);
+ _reset?.cancel();
+ _reset = Timer(const Duration(milliseconds: 1500), () {
+ if (mounted) setState(() => _copiedPart = null);
+ });
+ }
+
@override
Widget build(BuildContext context) {
return Center(
@@ -61,7 +110,12 @@ class ThreadDemo extends StatelessWidget {
constraints: const BoxConstraints(maxWidth: 640),
child: SizedBox(
height: 480,
- child: FlowThread(messages: _seed(variant == 'streaming')),
+ child: FlowThread(
+ messages: _seed(widget.variant == 'streaming'),
+ codeCopyTooltip: 'Copy code',
+ copiedCodePart: _copiedPart,
+ onCodeCopy: _copy,
+ ),
),
),
);
diff --git a/playground/lib/src/playground_item.dart b/playground/lib/src/playground_item.dart
index b63dd47..ca25016 100644
--- a/playground/lib/src/playground_item.dart
+++ b/playground/lib/src/playground_item.dart
@@ -28,6 +28,7 @@ enum PlaygroundItem {
PhosphorIconsRegular.chatCircleDots,
'flow_streaming_message.dart',
),
+ codeBlock('Code Block', PhosphorIconsRegular.code, 'flow_code_block.dart'),
addToChat(
'Add to Chat',
PhosphorIconsRegular.plus,
diff --git a/pubspec.yaml b/pubspec.yaml
index ceef0bd..fd47331 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -27,13 +27,16 @@ dev_dependencies:
flutter_lints: ^6.0.0
flutter:
- # Figtree is the typeface of the Flow UI design file. It ships with the
- # package (SIL Open Font License, see fonts/OFL.txt) so the default theme
- # renders as designed without the host bundling a font — and without a
- # font dependency.
+ # Figtree is the typeface of the Flow UI design file, and Geist Mono its
+ # code face. Both ship with the package (SIL Open Font License, see
+ # fonts/OFL.txt and fonts/OFL-GeistMono.txt) so the default theme renders
+ # as designed without the host bundling a font — and without a font
+ # dependency.
#
- # Static instances rather than the variable TTF: Flutter maps FontWeight to
- # the nearest declared weight, so a weight axis would not be interpolated.
+ # Static instances rather than the variable TTFs: Flutter maps FontWeight
+ # to the nearest declared weight, so a weight axis would not be
+ # interpolated. Geist Mono carries only the three weights the mono roles
+ # reach for, to keep what every host app inherits small.
fonts:
- family: Figtree
fonts:
@@ -51,3 +54,11 @@ flutter:
weight: 800
- asset: fonts/Figtree-Black.ttf
weight: 900
+ - family: GeistMono
+ fonts:
+ - asset: fonts/GeistMono-Regular.ttf
+ weight: 400
+ - asset: fonts/GeistMono-Medium.ttf
+ weight: 500
+ - asset: fonts/GeistMono-SemiBold.ttf
+ weight: 600