From f660ca7411420f91298d1fbdbafc9803d56ff6ce Mon Sep 17 00:00:00 2001 From: Denis Chuvasov Date: Wed, 3 Sep 2025 08:50:34 +0200 Subject: [PATCH 1/2] feat: support theming --- README.md | 3 + example/lib/main.dart | 69 ++++++++++++++++++----- lib/src/render.dart | 22 ++++---- lib/src/theme.dart | 125 +++++++++++++++++++++++++++++++++++++----- 4 files changed, 181 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index f30b157..3a45cf4 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,9 @@ MarkdownTheme( ) ``` +Or you can use the `MarkdownThemeData.mergeTheme(Theme.of(context))` factory to create a theme that inherits from the application's theme. +This approach allows you to easily support both light and dark themes, and keeps your markdown styling consistent with the rest of your application. + ### Custom Block Painters For advanced customization, you can provide custom block painters: diff --git a/example/lib/main.dart b/example/lib/main.dart index d72b0d0..e6a7210 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -6,7 +6,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_md/flutter_md.dart'; void main() => runZonedGuarded( - () => runApp(const App()), + () => runApp(ThemeModel( + notifier: ValueNotifier(ThemeMode.dark), + child: const App())), (e, s) => print(e), ); @@ -17,21 +19,16 @@ class App extends StatelessWidget { /// {@macro app} const App({super.key}); - /// Light theme for the app. - static final ThemeData theme = ThemeData.light(); - @override Widget build(BuildContext context) => MaterialApp( title: 'Markdown', - themeMode: ThemeMode.light, - theme: theme, - darkTheme: theme, + themeMode: ThemeModel.of(context).value, + theme: ThemeData.light(), + darkTheme: ThemeData.dark(), home: const HomeScreen(), builder: (context, child) => MarkdownTheme( - data: MarkdownThemeData( - textStyle: const TextStyle(fontSize: 14.0, color: Colors.black), - textDirection: TextDirection.ltr, - textScaler: TextScaler.noScaling, + data: MarkdownThemeData.mergeTheme( + Theme.of(context), // Exclude images from the markdown rendering, // so they are not rendered in the output. // Because image spans are not supported yet. @@ -52,6 +49,42 @@ class App extends StatelessWidget { ); } +/// {@template theme_model} +/// A widget that provides the [ThemeMode] to its descendants. +/// {@endtemplate} +class ThemeModel extends InheritedNotifier> { + /// {@macro theme_model} + const ThemeModel({super.key, super.notifier, required super.child}); + + /// The state from the closest instance of this class + /// that encloses the given context, if any. + /// e.g. `Theme.maybeOf(context)`. + static ValueNotifier? maybeOf(BuildContext context, + {bool listen = true}) => + listen + ? context.dependOnInheritedWidgetOfExactType()?.notifier + : context.getInheritedWidgetOfExactType()?.notifier; + + static Never _notFoundInheritedWidgetOfExactType() => throw ArgumentError( + 'Out of scope, not found inherited widget ' + 'a ThemeModel of the exact type', + 'out_of_scope', + ); + + /// The state from the closest instance of this class + /// that encloses the given context. + /// e.g. `Theme.of(context)` + static ValueNotifier of(BuildContext context, + {bool listen = true}) => + maybeOf(context, listen: listen) ?? _notFoundInheritedWidgetOfExactType(); + + @override + bool updateShouldNotify( + covariant InheritedNotifier> oldWidget) { + return !identical(notifier, oldWidget.notifier); + } +} + /// {@template home_screen} /// HomeScreen widget. /// {@endtemplate} @@ -104,9 +137,17 @@ class _HomeScreenState extends State { @override Widget build(BuildContext context) => Scaffold( appBar: AppBar( - centerTitle: true, - title: const Text('Markdown'), - ), + centerTitle: true, + title: const Text('Markdown'), + actions: [ + // theme switch widget + Switch.adaptive( + value: ThemeModel.of(context).value == ThemeMode.dark, + onChanged: (value) { + ThemeModel.of(context).value = + value ? ThemeMode.dark : ThemeMode.light; + }), + ]), body: SafeArea( child: CustomMultiChildLayout( delegate: _layoutDelegate, diff --git a/lib/src/render.dart b/lib/src/render.dart index 0e6fe8d..5cc91b8 100644 --- a/lib/src/render.dart +++ b/lib/src/render.dart @@ -807,7 +807,7 @@ class BlockPainter$Quote with ParagraphGestureHandler implements BlockPainter { const Radius.circular(4.0), // Rounded corners for the quote block. ), Paint() - ..color = const Color.fromARGB(255, 235, 235, 235) + ..color = theme.surfaceColor ?? const Color.fromARGB(255, 235, 235, 235) ..isAntiAlias = false ..style = PaintingStyle.fill, ); @@ -819,8 +819,9 @@ class BlockPainter$Quote with ParagraphGestureHandler implements BlockPainter { const quoteFamily = 'MaterialIcons'; final textStyle = TextStyle( fontFamily: quoteFamily, - fontSize: - theme.quoteStyle?.fontSize ?? theme.textStyle.fontSize ?? 14.0, + fontSize: theme.quoteStyle?.fontSize ?? + theme.textStyle.fontSize ?? + kDefaultFontSize, color: const Color(0xFF7F7F7F), // Gray color for the quote icon. ); final quotePainter = TextPainter( @@ -1025,7 +1026,7 @@ class BlockPainter$Spacer implements BlockPainter { @override Size layout(double width) { - final height = theme.textStyle.fontSize ?? 14.0; + final height = theme.textStyle.fontSize ?? kDefaultFontSize; return _size = Size(0, height * count); } @@ -1065,7 +1066,7 @@ class BlockPainter$Divider implements BlockPainter { @override Size layout(double width) { - final height = theme.textStyle.fontSize ?? 14.0; + final height = theme.textStyle.fontSize ?? kDefaultFontSize; return _size = Size(0, height); } @@ -1093,7 +1094,7 @@ class BlockPainter$Code implements BlockPainter { text: text, style: theme.textStyle.copyWith( fontFamily: 'monospace', - fontSize: theme.textStyle.fontSize ?? 14.0, + fontSize: theme.textStyle.fontSize ?? kDefaultFontSize, ), ), textAlign: TextAlign.start, @@ -1144,7 +1145,7 @@ class BlockPainter$Code implements BlockPainter { const Radius.circular(padding), ), Paint() - ..color = const Color.fromARGB(255, 235, 235, 235) + ..color = theme.surfaceColor ?? const Color.fromARGB(255, 235, 235, 235) ..isAntiAlias = false ..style = PaintingStyle.fill, ); @@ -1203,7 +1204,7 @@ class BlockPainter$Table implements BlockPainter { return _size = Size( width, // The width of the table is the same as the available width. (header.cells.length + rows.length) * - ((theme.textStyle.fontSize ?? 14.0) + padding * 2), + ((theme.textStyle.fontSize ?? kDefaultFontSize) + padding * 2), ); } @@ -1215,7 +1216,8 @@ class BlockPainter$Table implements BlockPainter { // Draw the header row. final columnWidth = size.width / columns; final cellMaxWidth = columnWidth - padding * 2; - final rowHeight = (theme.textStyle.fontSize ?? 14.0) + padding * 2; + final rowHeight = + (theme.textStyle.fontSize ?? kDefaultFontSize) + padding * 2; canvas.drawRRect( RRect.fromLTRBR( 0, // Left @@ -1225,7 +1227,7 @@ class BlockPainter$Table implements BlockPainter { const Radius.circular(padding), // Radius for rounded corners ), Paint() - ..color = const Color.fromARGB(255, 235, 235, 235) + ..color = theme.surfaceColor ?? const Color.fromARGB(255, 235, 235, 235) ..style = PaintingStyle.fill ..isAntiAlias = false, ); diff --git a/lib/src/theme.dart b/lib/src/theme.dart index f692960..63e9c55 100644 --- a/lib/src/theme.dart +++ b/lib/src/theme.dart @@ -21,6 +21,10 @@ class MarkdownThemeData implements ThemeExtension { this.h5Style, this.h6Style, this.quoteStyle, + this.linkColor = Colors.indigo, + this.surfaceColor = const Color.fromARGB(255, 235, 235, 235), + this.highlightBackgroundColor = const Color(0x40FF5722), + this.monospaceBackgroundColor = const Color(0x409E9E9E), this.blockFilter, this.spanFilter, this.builder, @@ -28,6 +32,53 @@ class MarkdownThemeData implements ThemeExtension { }) : _headingStyles = List.filled(8, null), _textStyles = HashMap(); + /// Creates a [MarkdownThemeData] from the given [ThemeData]. + factory MarkdownThemeData.mergeTheme( + ThemeData theme, { + TextStyle? textStyle, + TextDirection? textDirection, + TextScaler? textScaler, + TextStyle? h1Style, + TextStyle? h2Style, + TextStyle? h3Style, + TextStyle? h4Style, + TextStyle? h5Style, + TextStyle? h6Style, + TextStyle? quoteStyle, + Color? linkColor, + Color? surfaceColor, + Color? highlightBackgroundColor, + Color? monospaceBackgroundColor, + bool Function(MD$Block block)? blockFilter, + bool Function(MD$Span span)? spanFilter, + BlockPainter? Function(MD$Block block, MarkdownThemeData theme)? builder, + void Function(String title, String url)? onLinkTap, + }) { + return MarkdownThemeData( + textStyle: textStyle ?? + theme.textTheme.bodyMedium ?? + const TextStyle(color: Colors.black, fontSize: kDefaultFontSize), + textDirection: textDirection ?? TextDirection.ltr, + textScaler: textScaler ?? TextScaler.noScaling, + h1Style: h1Style ?? theme.textTheme.headlineLarge, + h2Style: h2Style ?? theme.textTheme.headlineMedium, + h3Style: h3Style ?? theme.textTheme.headlineSmall, + h4Style: h4Style ?? theme.textTheme.titleLarge, + h5Style: h5Style ?? theme.textTheme.titleMedium, + h6Style: h6Style ?? theme.textTheme.titleSmall, + linkColor: linkColor ?? theme.colorScheme.primary, + surfaceColor: surfaceColor ?? theme.colorScheme.surfaceContainerHigh, + highlightBackgroundColor: + highlightBackgroundColor ?? theme.colorScheme.errorContainer, + monospaceBackgroundColor: + monospaceBackgroundColor ?? theme.colorScheme.surfaceContainerHigh, + blockFilter: blockFilter, + spanFilter: spanFilter, + builder: builder, + onLinkTap: onLinkTap, + ); + } + @override Object get type => MarkdownThemeData; @@ -61,6 +112,18 @@ class MarkdownThemeData implements ThemeExtension { /// Default text style for quote blocks. final TextStyle? quoteStyle; + /// The color to use for link text. + final Color? linkColor; + + /// The color to use for the background of the quote, block, table and etc. + final Color? surfaceColor; + + /// The color to use for the background of highlighted text. + final Color? highlightBackgroundColor; + + /// The color to use for the background of monospace text. + final Color? monospaceBackgroundColor; + /// A filter function to determine whether a block should be rendered. /// If the function returns `true`, the block will be rendered. /// @@ -98,34 +161,34 @@ class MarkdownThemeData implements ThemeExtension { _headingStyles[level] ??= switch (level.clamp(1, 7)) { 1 => h1Style ?? textStyle.copyWith( - fontSize: (textStyle.fontSize ?? 14.0) + 10.0, + fontSize: (textStyle.fontSize ?? kDefaultFontSize) + 10.0, fontWeight: FontWeight.bold, decoration: TextDecoration.underline, decorationStyle: TextDecorationStyle.solid, ), 2 => h2Style ?? textStyle.copyWith( - fontSize: (textStyle.fontSize ?? 14.0) + 8.0, + fontSize: (textStyle.fontSize ?? kDefaultFontSize) + 8.0, fontWeight: FontWeight.bold, ), 3 => h3Style ?? textStyle.copyWith( - fontSize: (textStyle.fontSize ?? 14.0) + 6.0, + fontSize: (textStyle.fontSize ?? kDefaultFontSize) + 6.0, fontWeight: FontWeight.bold, ), 4 => h4Style ?? textStyle.copyWith( - fontSize: (textStyle.fontSize ?? 14.0) + 4.0, + fontSize: (textStyle.fontSize ?? kDefaultFontSize) + 4.0, fontWeight: FontWeight.bold, ), 5 => h5Style ?? textStyle.copyWith( - fontSize: (textStyle.fontSize ?? 14.0) + 2.0, + fontSize: (textStyle.fontSize ?? kDefaultFontSize) + 2.0, fontWeight: FontWeight.bold, ), 6 => h6Style ?? textStyle.copyWith( - fontSize: (textStyle.fontSize ?? 14.0) + 0.0, + fontSize: (textStyle.fontSize ?? kDefaultFontSize) + 0.0, fontWeight: FontWeight.bold, ), _ => textStyle, @@ -153,16 +216,14 @@ class MarkdownThemeData implements ThemeExtension { }, fontFamily: style.contains(MD$Style.monospace) ? 'monospace' : null, color: switch (style) { - var s when s.contains(MD$Style.link) => Colors.indigo, - var s when s.contains(MD$Style.highlight) => Colors.black, - var s when s.contains(MD$Style.monospace) => Colors.black, + var s when s.contains(MD$Style.link) => linkColor, _ => null, }, backgroundColor: switch (style) { var s when s.contains(MD$Style.highlight) => - Colors.deepOrange.withValues(alpha: 0.25), + highlightBackgroundColor, var s when s.contains(MD$Style.monospace) => - Colors.grey.withValues(alpha: 0.25), + monospaceBackgroundColor, _ => null, }, ), @@ -180,6 +241,10 @@ class MarkdownThemeData implements ThemeExtension { TextStyle? h5Style, TextStyle? h6Style, TextStyle? quoteStyle, + Color? linkColor, + Color? surfaceColor, + Color? highlightBackgroundColor, + Color? monospaceBackgroundColor, bool Function(MD$Block block)? blockFilter, bool Function(MD$Span span)? spanFilter, }) => @@ -194,16 +259,48 @@ class MarkdownThemeData implements ThemeExtension { h5Style: h5Style ?? this.h5Style, h6Style: h6Style ?? this.h6Style, quoteStyle: quoteStyle ?? this.quoteStyle, + linkColor: linkColor ?? this.linkColor, + surfaceColor: surfaceColor ?? this.surfaceColor, + highlightBackgroundColor: + highlightBackgroundColor ?? this.highlightBackgroundColor, + monospaceBackgroundColor: + monospaceBackgroundColor ?? this.monospaceBackgroundColor, blockFilter: blockFilter ?? this.blockFilter, spanFilter: spanFilter ?? this.spanFilter, ); @override ThemeExtension lerp( - covariant ThemeExtension? other, + covariant MarkdownThemeData? other, double t, - ) => - other ?? this; + ) { + if (identical(this, other)) return this; + + return MarkdownThemeData( + textDirection: + t < 0.5 ? textDirection : other?.textDirection ?? TextDirection.ltr, + textScaler: + t < 0.5 ? textScaler : other?.textScaler ?? TextScaler.noScaling, + textStyle: TextStyle.lerp(textStyle, other?.textStyle, t)!, + h1Style: TextStyle.lerp(h1Style, other?.h1Style, t), + h2Style: TextStyle.lerp(h2Style, other?.h2Style, t), + h3Style: TextStyle.lerp(h3Style, other?.h3Style, t), + h4Style: TextStyle.lerp(h4Style, other?.h4Style, t), + h5Style: TextStyle.lerp(h5Style, other?.h5Style, t), + h6Style: TextStyle.lerp(h6Style, other?.h6Style, t), + quoteStyle: TextStyle.lerp(quoteStyle, other?.quoteStyle, t), + linkColor: Color.lerp(linkColor, other?.linkColor, t), + surfaceColor: Color.lerp(surfaceColor, other?.surfaceColor, t), + highlightBackgroundColor: Color.lerp( + highlightBackgroundColor, other?.highlightBackgroundColor, t), + monospaceBackgroundColor: Color.lerp( + monospaceBackgroundColor, other?.monospaceBackgroundColor, t), + blockFilter: t < 0.5 ? blockFilter : other?.blockFilter, + spanFilter: t < 0.5 ? spanFilter : other?.spanFilter, + builder: t < 0.5 ? builder : other?.builder, + onLinkTap: t < 0.5 ? onLinkTap : other?.onLinkTap, + ); + } @override String toString() => 'MarkdownThemeData{}'; From 5ba16d4d8bba964a16811a1d16df82b1a05bb490 Mon Sep 17 00:00:00 2001 From: Denis Chuvasov Date: Wed, 3 Sep 2025 14:00:27 +0200 Subject: [PATCH 2/2] feat: add divider color to MarkdownThemeData and update BlockPainter$Quote --- lib/src/render.dart | 98 ++++++++++----------------------------------- lib/src/theme.dart | 13 ++++++ 2 files changed, 35 insertions(+), 76 deletions(-) diff --git a/lib/src/render.dart b/lib/src/render.dart index 5cc91b8..3ed07aa 100644 --- a/lib/src/render.dart +++ b/lib/src/render.dart @@ -731,7 +731,7 @@ class BlockPainter$Quote with ParagraphGestureHandler implements BlockPainter { required List spans, required this.indent, required this.theme, - }) : painter = TextPainter( + }) : painter = TextPainter( text: _paragraphFromMarkdownSpans( spans: spans, theme: theme, @@ -740,7 +740,13 @@ class BlockPainter$Quote with ParagraphGestureHandler implements BlockPainter { textAlign: TextAlign.start, textDirection: theme.textDirection, textScaler: theme.textScaler, - ); + ), + linePaint = Paint() + ..color = theme.dividerColor ?? + const Color(0x7F7F7F7F) // Gray color for the line. + ..isAntiAlias = false + ..strokeWidth = 4.0 + ..style = PaintingStyle.fill; final MarkdownThemeData theme; @@ -750,11 +756,7 @@ class BlockPainter$Quote with ParagraphGestureHandler implements BlockPainter { static const double lineIndent = 10.0; // Indentation for quote blocks. - static final Paint linePaint = Paint() - ..color = const Color(0x7F7F7F7F) // Gray color for the line. - ..isAntiAlias = false - ..strokeWidth = 4.0 - ..style = PaintingStyle.fill; + final Paint linePaint; @override Size get size => _size; @@ -801,75 +803,19 @@ class BlockPainter$Quote with ParagraphGestureHandler implements BlockPainter { // If the width is less than required do not paint anything. if (size.width < _size.width) return; - canvas.drawRRect( - RRect.fromRectAndRadius( - Rect.fromLTWH(0, offset, size.width, _size.height), - const Radius.circular(4.0), // Rounded corners for the quote block. - ), - Paint() - ..color = theme.surfaceColor ?? const Color.fromARGB(255, 235, 235, 235) - ..isAntiAlias = false - ..style = PaintingStyle.fill, - ); - - { - // --- Icons.format_quote_outlined --- // - try { - const quoteCodePoint = 0xf0a9; - const quoteFamily = 'MaterialIcons'; - final textStyle = TextStyle( - fontFamily: quoteFamily, - fontSize: theme.quoteStyle?.fontSize ?? - theme.textStyle.fontSize ?? - kDefaultFontSize, - color: const Color(0xFF7F7F7F), // Gray color for the quote icon. - ); - final quotePainter = TextPainter( - text: TextSpan( - text: String.fromCharCode(quoteCodePoint), - style: textStyle, - ), - textAlign: TextAlign.start, - textDirection: theme.textDirection, - textScaler: theme.textScaler, - )..layout(); - canvas - ..save() - ..translate( - _size.width + quotePainter.width, - offset + _size.height, - ) - ..rotate(math.pi); - quotePainter.paint( - canvas, - Offset( - _size.width, - _size.height - quotePainter.height, - ), - ); - canvas.restore(); - quotePainter.paint( - canvas, - Offset( - size.width - quotePainter.width - 2.0, - offset + _size.height - quotePainter.height, - ), - ); - } on Object { - for (var i = 1; i <= indent; i++) - canvas.drawLine( - Offset( - i * lineIndent - lineIndent / 2, - offset + 12, - ), - Offset( - i * lineIndent - lineIndent / 2, - offset + _size.height - 12, - ), - linePaint, - ); - } - } + // --- Draw vertical lines --- // + for (var i = 1; i <= indent; i++) + canvas.drawLine( + Offset( + i * lineIndent, + offset, + ), + Offset( + i * lineIndent, + offset + _size.height, + ), + linePaint, + ); painter.paint( canvas, diff --git a/lib/src/theme.dart b/lib/src/theme.dart index 63e9c55..e749c2c 100644 --- a/lib/src/theme.dart +++ b/lib/src/theme.dart @@ -25,6 +25,7 @@ class MarkdownThemeData implements ThemeExtension { this.surfaceColor = const Color.fromARGB(255, 235, 235, 235), this.highlightBackgroundColor = const Color(0x40FF5722), this.monospaceBackgroundColor = const Color(0x409E9E9E), + this.dividerColor, this.blockFilter, this.spanFilter, this.builder, @@ -49,6 +50,7 @@ class MarkdownThemeData implements ThemeExtension { Color? surfaceColor, Color? highlightBackgroundColor, Color? monospaceBackgroundColor, + Color? dividerColor, bool Function(MD$Block block)? blockFilter, bool Function(MD$Span span)? spanFilter, BlockPainter? Function(MD$Block block, MarkdownThemeData theme)? builder, @@ -66,12 +68,17 @@ class MarkdownThemeData implements ThemeExtension { h4Style: h4Style ?? theme.textTheme.titleLarge, h5Style: h5Style ?? theme.textTheme.titleMedium, h6Style: h6Style ?? theme.textTheme.titleSmall, + quoteStyle: quoteStyle ?? + theme.textTheme.bodyMedium?.copyWith( + color: + theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.75)), linkColor: linkColor ?? theme.colorScheme.primary, surfaceColor: surfaceColor ?? theme.colorScheme.surfaceContainerHigh, highlightBackgroundColor: highlightBackgroundColor ?? theme.colorScheme.errorContainer, monospaceBackgroundColor: monospaceBackgroundColor ?? theme.colorScheme.surfaceContainerHigh, + dividerColor: dividerColor ?? theme.dividerColor.withValues(alpha: 0.12), blockFilter: blockFilter, spanFilter: spanFilter, builder: builder, @@ -124,6 +131,9 @@ class MarkdownThemeData implements ThemeExtension { /// The color to use for the background of monospace text. final Color? monospaceBackgroundColor; + /// The color to use for the divider. + final Color? dividerColor; + /// A filter function to determine whether a block should be rendered. /// If the function returns `true`, the block will be rendered. /// @@ -245,6 +255,7 @@ class MarkdownThemeData implements ThemeExtension { Color? surfaceColor, Color? highlightBackgroundColor, Color? monospaceBackgroundColor, + Color? dividerColor, bool Function(MD$Block block)? blockFilter, bool Function(MD$Span span)? spanFilter, }) => @@ -265,6 +276,7 @@ class MarkdownThemeData implements ThemeExtension { highlightBackgroundColor ?? this.highlightBackgroundColor, monospaceBackgroundColor: monospaceBackgroundColor ?? this.monospaceBackgroundColor, + dividerColor: dividerColor ?? this.dividerColor, blockFilter: blockFilter ?? this.blockFilter, spanFilter: spanFilter ?? this.spanFilter, ); @@ -295,6 +307,7 @@ class MarkdownThemeData implements ThemeExtension { highlightBackgroundColor, other?.highlightBackgroundColor, t), monospaceBackgroundColor: Color.lerp( monospaceBackgroundColor, other?.monospaceBackgroundColor, t), + dividerColor: Color.lerp(dividerColor, other?.dividerColor, t), blockFilter: t < 0.5 ? blockFilter : other?.blockFilter, spanFilter: t < 0.5 ? spanFilter : other?.spanFilter, builder: t < 0.5 ? builder : other?.builder,