diff --git a/super_editor/lib/src/core/styles.dart b/super_editor/lib/src/core/styles.dart index d09fa97d6..0bd47cd57 100644 --- a/super_editor/lib/src/core/styles.dart +++ b/super_editor/lib/src/core/styles.dart @@ -3,7 +3,7 @@ import 'package:flutter/painting.dart'; import 'package:super_editor/src/default_editor/text/custom_underlines.dart'; import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; -import 'document.dart'; +import 'package:super_editor/src/core/document.dart'; /// Stylesheet for styling content within a document. /// @@ -17,6 +17,7 @@ class Stylesheet { required this.inlineTextStyler, this.inlineWidgetBuilders = const [], this.selectedTextColorStrategy, + this.inheritDefaultTextStyle = false, }); /// Padding applied around the interior edge of the document. @@ -36,6 +37,14 @@ class Stylesheet { /// The strategy that chooses the color for selected text. final SelectedTextColorStrategy? selectedTextColorStrategy; + /// Whether to inherit the default text style from the context. + /// + /// If `true` the [TextStyle] obtained from the [rules] will be merged with + /// the closest enclosing [DefaultTextStyle]. + /// + /// Defaults to `false`. + final bool inheritDefaultTextStyle; + /// Priority-order list of style rules. final List rules; @@ -44,6 +53,7 @@ class Stylesheet { AttributionStyleAdjuster? inlineTextStyler, InlineWidgetBuilderChain? inlineWidgetBuilders, SelectedTextColorStrategy? selectedTextColorStrategy, + bool? inheritDefaultTextStyle, List addRulesBefore = const [], List? rules, List addRulesAfter = const [], @@ -53,6 +63,7 @@ class Stylesheet { inlineTextStyler: inlineTextStyler ?? this.inlineTextStyler, inlineWidgetBuilders: inlineWidgetBuilders ?? this.inlineWidgetBuilders, selectedTextColorStrategy: selectedTextColorStrategy ?? this.selectedTextColorStrategy, + inheritDefaultTextStyle: inheritDefaultTextStyle ?? this.inheritDefaultTextStyle, rules: [ ...addRulesBefore, ...(rules ?? this.rules), diff --git a/super_editor/lib/src/default_editor/layout_single_column/_styler_shylesheet.dart b/super_editor/lib/src/default_editor/layout_single_column/_styler_shylesheet.dart index 552c46a13..a0d8ffe7a 100644 --- a/super_editor/lib/src/default_editor/layout_single_column/_styler_shylesheet.dart +++ b/super_editor/lib/src/default_editor/layout_single_column/_styler_shylesheet.dart @@ -1,14 +1,23 @@ import 'package:flutter/painting.dart'; import 'package:super_editor/src/core/styles.dart'; -import '../../core/document.dart'; -import '_presenter.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/default_editor/layout_single_column/_presenter.dart'; /// Style phase that applies a given [Stylesheet] to the document view model. class SingleColumnStylesheetStyler extends SingleColumnLayoutStylePhase { + /// Creates a [SingleColumnStylesheetStyler] that applies the given [stylesheet] + /// to the document view model. + /// + /// The `defaultTextStyle` is Flutter's default text style, which should be provided + /// as the result of `DefaultTextStyle.of(context)`. If it's `non-null`, the stylesheet text + /// styles are applied on top of the base style, taking priority. If `null`, the stylesheet + /// text styles are applied directly. Has no effect if [Stylesheet.inheritDefaultTextStyle] is `false`. SingleColumnStylesheetStyler({ required Stylesheet stylesheet, - }) : _stylesheet = stylesheet; + TextStyle? defaultTextStyle, + }) : _stylesheet = stylesheet, + _defaultTextStyle = defaultTextStyle; Stylesheet _stylesheet; @@ -30,6 +39,34 @@ class SingleColumnStylesheetStyler extends SingleColumnLayoutStylePhase { markDirty(); } + TextStyle? _defaultTextStyle; + + /// The default Flutter text style that applies to the document that this presenter is styling, + /// which users must set to `DefaultTextStyle.of(context)`. + /// + /// Stylesheets include the concept of "inheriting the default text style". This requires that + /// stylesheets have access to the default text style. However, stylesheets are not given access + /// to the widget tree, and therefore they can't query this value on their own. Instead, users + /// of `SingleColumnStylesheetStyler` must provide the default text style to this styler, so that + /// this styler can provide it to the stylesheet. + /// + /// Has no effect if [Stylesheet.inheritDefaultTextStyle] is `false`. + /// + /// If [newDefaultTextStyle] is the same as the existing default text style, + /// this method does nothing. + /// + /// If [newDefaultTextStyle] is different than the existing default text style, + /// this method marks this style phase a dirty, which will cause the associated presenter + /// to re-run this style phase, and all presentation phases after it. + set defaultTextStyle(TextStyle? newDefaultTextStyle) { + if (newDefaultTextStyle == _defaultTextStyle) { + return; + } + + _defaultTextStyle = newDefaultTextStyle; + markDirty(); + } + @override SingleColumnLayoutViewModel style(Document document, SingleColumnLayoutViewModel viewModel) { return SingleColumnLayoutViewModel( @@ -56,6 +93,14 @@ class SingleColumnStylesheetStyler extends SingleColumnLayoutStylePhase { Styles.inlineTextStyler: _stylesheet.inlineTextStyler, Styles.inlineWidgetBuilders: _stylesheet.inlineWidgetBuilders, }; + + if (_stylesheet.inheritDefaultTextStyle && _defaultTextStyle != null) { + // We have a default text style, use it as the base for all text styles. + // + // Stylesheet text styles are applied on top of the base style, taking priority. + aggregateStyles[Styles.textStyle] = _defaultTextStyle!; + } + for (final rule in _stylesheet.rules) { if (rule.selector.matches(document, node)) { _mergeStyles( diff --git a/super_editor/lib/src/default_editor/super_editor.dart b/super_editor/lib/src/default_editor/super_editor.dart index cc4c5fbfb..7cb1dd705 100644 --- a/super_editor/lib/src/default_editor/super_editor.dart +++ b/super_editor/lib/src/default_editor/super_editor.dart @@ -461,7 +461,19 @@ class SuperEditorState extends State { ); _createEditContext(); - _createLayoutPresenter(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + if (_docLayoutPresenter == null) { + _createLayoutPresenter(); + } else if (widget.stylesheet.inheritDefaultTextStyle) { + // The default text style might have changed. Update it in the stylesheet styler. + final defaultTextStyle = DefaultTextStyle.of(context).style; + _docStylesheetStyler.defaultTextStyle = defaultTextStyle; + } } @override @@ -590,7 +602,10 @@ class SuperEditorState extends State { final document = editContext.document; - _docStylesheetStyler = SingleColumnStylesheetStyler(stylesheet: widget.stylesheet); + _docStylesheetStyler = SingleColumnStylesheetStyler( + stylesheet: widget.stylesheet, + defaultTextStyle: DefaultTextStyle.of(context).style, + ); _docLayoutPerComponentBlockStyler = SingleColumnLayoutCustomComponentStyler(); diff --git a/super_editor/lib/src/infrastructure/attributed_text_styles.dart b/super_editor/lib/src/infrastructure/attributed_text_styles.dart index 37496ef98..72105ec06 100644 --- a/super_editor/lib/src/infrastructure/attributed_text_styles.dart +++ b/super_editor/lib/src/infrastructure/attributed_text_styles.dart @@ -21,14 +21,23 @@ extension ComputeTextSpan on AttributedText { /// /// The given [inlineWidgetBuilders] interprets every placeholder `Object` /// and builds a corresponding inline widget. + /// + /// If [defaultTextStyle] is non-`null`, the [TextStyle] computed by [styleBuilder] + /// is applied on top of the [defaultTextStyle], taking priority. InlineSpan computeInlineSpan( BuildContext context, AttributionStyleBuilder styleBuilder, - InlineWidgetBuilderChain inlineWidgetBuilders, - ) { + InlineWidgetBuilderChain inlineWidgetBuilders, { + TextStyle? defaultTextStyle, + }) { if (isEmpty) { // There is no text and therefore no attributions. - return TextSpan(text: '', style: styleBuilder({})); + return TextSpan( + text: '', + style: defaultTextStyle != null // + ? defaultTextStyle.merge(styleBuilder({})) + : styleBuilder({}), + ); } final inlineSpans = []; @@ -36,7 +45,9 @@ extension ComputeTextSpan on AttributedText { final collapsedSpans = spans.collapseSpans(contentLength: length); for (final span in collapsedSpans) { - final textStyle = styleBuilder(span.attributions); + final textStyle = defaultTextStyle != null + ? defaultTextStyle.merge(styleBuilder(span.attributions)) + : styleBuilder(span.attributions); // A single span might be divided in multiple inline spans if there are placeholders. // Keep track of the start of the current inline span. diff --git a/super_editor/lib/src/super_reader/super_reader.dart b/super_editor/lib/src/super_reader/super_reader.dart index 3670bae50..ce20ef0ed 100644 --- a/super_editor/lib/src/super_reader/super_reader.dart +++ b/super_editor/lib/src/super_reader/super_reader.dart @@ -270,8 +270,19 @@ class SuperReaderState extends State { _docLayoutKey = widget.documentLayoutKey ?? GlobalKey(); _createReaderContext(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); - _createLayoutPresenter(); + if (_docLayoutPresenter == null) { + _createLayoutPresenter(); + } else if (widget.stylesheet.inheritDefaultTextStyle) { + // The default text style might have changed. Update it in the stylesheet styler. + final defaultTextStyle = DefaultTextStyle.of(context).style; + _docStylesheetStyler.defaultTextStyle = defaultTextStyle; + } } @override @@ -326,6 +337,7 @@ class SuperReaderState extends State { _docStylesheetStyler = SingleColumnStylesheetStyler( stylesheet: widget.stylesheet, + defaultTextStyle: DefaultTextStyle.of(context).style, ); _docLayoutPerComponentBlockStyler = SingleColumnLayoutCustomComponentStyler(); diff --git a/super_editor/lib/src/super_textfield/android/android_textfield.dart b/super_editor/lib/src/super_textfield/android/android_textfield.dart index 1a957038c..947a5ddc4 100644 --- a/super_editor/lib/src/super_textfield/android/android_textfield.dart +++ b/super_editor/lib/src/super_textfield/android/android_textfield.dart @@ -19,9 +19,9 @@ import 'package:super_editor/src/super_textfield/infrastructure/text_scrollview. import 'package:super_editor/src/super_textfield/input_method_engine/_ime_text_editing_controller.dart'; import 'package:super_text_layout/super_text_layout.dart'; -import '../../infrastructure/_logging.dart'; -import '../metrics.dart'; -import '../styles.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/super_textfield/metrics.dart'; +import 'package:super_editor/src/super_textfield/styles.dart'; export '_caret.dart'; @@ -36,6 +36,7 @@ class SuperAndroidTextField extends StatefulWidget { this.textAlign, this.textStyleBuilder = defaultTextFieldStyleBuilder, this.inlineWidgetBuilders = const [], + this.inheritDefaultTextStyle = false, this.hintBehavior = HintBehavior.displayHintUntilFocus, this.hintBuilder, this.minLines, @@ -77,6 +78,9 @@ class SuperAndroidTextField extends StatefulWidget { /// {@macro super_text_field_inline_widget_builders} final InlineWidgetBuilderChain inlineWidgetBuilders; + /// {@macro super_text_field_inherit_default_text_style} + final bool inheritDefaultTextStyle; + /// Policy for when the hint should be displayed. final HintBehavior hintBehavior; @@ -629,9 +633,20 @@ class SuperAndroidTextFieldState extends State } Widget _buildSelectableText() { + final defaultTextStyle = widget.inheritDefaultTextStyle ? DefaultTextStyle.of(context).style : null; final textSpan = _textEditingController.text.isNotEmpty - ? _textEditingController.text.computeInlineSpan(context, widget.textStyleBuilder, widget.inlineWidgetBuilders) - : TextSpan(text: "", style: widget.textStyleBuilder({})); + ? _textEditingController.text.computeInlineSpan( + context, + widget.textStyleBuilder, + widget.inlineWidgetBuilders, + defaultTextStyle: defaultTextStyle, + ) + : TextSpan( + text: "", + style: defaultTextStyle != null + ? defaultTextStyle.merge(widget.textStyleBuilder({})) + : widget.textStyleBuilder({}), + ); return Directionality( textDirection: _textDirection, diff --git a/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart b/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart index f3c1e2403..c22e7c6b5 100644 --- a/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart +++ b/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart @@ -52,6 +52,7 @@ class SuperDesktopTextField extends StatefulWidget { this.textController, this.textStyleBuilder = defaultTextFieldStyleBuilder, this.inlineWidgetBuilders = const [], + this.inheritDefaultTextStyle = false, this.textAlign = TextAlign.left, this.hintBehavior = HintBehavior.displayHintUntilFocus, this.hintBuilder, @@ -96,6 +97,9 @@ class SuperDesktopTextField extends StatefulWidget { /// {@macro super_text_field_inline_widget_builders} final InlineWidgetBuilderChain inlineWidgetBuilders; + /// {@macro super_text_field_inherit_default_text_style} + final bool inheritDefaultTextStyle; + /// Policy for when the hint should be displayed. final HintBehavior hintBehavior; @@ -529,7 +533,12 @@ class SuperDesktopTextFieldState extends State implements textDirection: _textDirection, child: SuperText( key: _textKey, - richText: _controller.text.computeInlineSpan(context, widget.textStyleBuilder, widget.inlineWidgetBuilders), + richText: _controller.text.computeInlineSpan( + context, + widget.textStyleBuilder, + widget.inlineWidgetBuilders, + defaultTextStyle: widget.inheritDefaultTextStyle ? DefaultTextStyle.of(context).style : null, + ), textAlign: _textAlign, textDirection: _textDirection, textScaler: _textScaler, diff --git a/super_editor/lib/src/super_textfield/ios/ios_textfield.dart b/super_editor/lib/src/super_textfield/ios/ios_textfield.dart index ef8886f1e..9c4f78746 100644 --- a/super_editor/lib/src/super_textfield/ios/ios_textfield.dart +++ b/super_editor/lib/src/super_textfield/ios/ios_textfield.dart @@ -20,11 +20,11 @@ import 'package:super_editor/src/super_textfield/input_method_engine/_ime_text_e import 'package:super_editor/src/super_textfield/ios/editing_controls.dart'; import 'package:super_text_layout/super_text_layout.dart'; -import '../metrics.dart'; -import '../styles.dart'; -import 'floating_cursor.dart'; -import '../../infrastructure/platforms/ios/ios_system_context_menu.dart'; -import 'user_interaction.dart'; +import 'package:super_editor/src/super_textfield/metrics.dart'; +import 'package:super_editor/src/super_textfield/styles.dart'; +import 'package:super_editor/src/super_textfield/ios/floating_cursor.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/ios_system_context_menu.dart'; +import 'package:super_editor/src/super_textfield/ios/user_interaction.dart'; export '../infrastructure/magnifier.dart'; export 'caret.dart'; @@ -42,6 +42,7 @@ class SuperIOSTextField extends StatefulWidget { this.tapHandlers = const [], this.textController, this.textStyleBuilder = defaultTextFieldStyleBuilder, + this.inheritDefaultTextStyle = false, this.inlineWidgetBuilders = const [], this.textAlign = TextAlign.left, this.padding, @@ -87,6 +88,9 @@ class SuperIOSTextField extends StatefulWidget { /// {@macro super_text_field_inline_widget_builders} final InlineWidgetBuilderChain inlineWidgetBuilders; + /// {@macro super_text_field_inherit_default_text_style} + final bool inheritDefaultTextStyle; + /// Padding placed around the text content of this text field, but within the /// scrollable viewport. final EdgeInsets? padding; @@ -629,7 +633,12 @@ class SuperIOSTextFieldState extends State Widget _buildSelectableText() { final textSpan = _textEditingController.text // - .computeInlineSpan(context, widget.textStyleBuilder, widget.inlineWidgetBuilders); + .computeInlineSpan( + context, + widget.textStyleBuilder, + widget.inlineWidgetBuilders, + defaultTextStyle: widget.inheritDefaultTextStyle ? DefaultTextStyle.of(context).style : null, + ); CaretStyle caretStyle = widget.caretStyle; diff --git a/super_editor/lib/src/super_textfield/super_textfield.dart b/super_editor/lib/src/super_textfield/super_textfield.dart index 887de51a8..4dbb130c9 100644 --- a/super_editor/lib/src/super_textfield/super_textfield.dart +++ b/super_editor/lib/src/super_textfield/super_textfield.dart @@ -62,6 +62,7 @@ class SuperTextField extends StatefulWidget { this.textController, this.textAlign, this.textStyleBuilder = defaultTextFieldStyleBuilder, + this.inheritDefaultTextStyle = false, this.inlineWidgetBuilders = const [], this.hintBehavior = HintBehavior.displayHintUntilFocus, this.hintBuilder, @@ -116,6 +117,16 @@ class SuperTextField extends StatefulWidget { /// {@endtemplate} final InlineWidgetBuilderChain inlineWidgetBuilders; + /// {@template super_text_field_inherit_default_text_style} + /// Whether this text field should inherit the enclosing [DefaultTextStyle]. + /// + /// If `true`, the text styles in [textStyleBuilder] will be merged with the + /// enclosing [DefaultTextStyle] in the widget tree. + /// + /// Defaults to `false`. + /// {@endtemplate} + final bool inheritDefaultTextStyle; + /// Policy for when the hint should be displayed. final HintBehavior hintBehavior; @@ -374,6 +385,7 @@ class SuperTextFieldState extends State implements ImeInputOwner textAlign: widget.textAlign, textStyleBuilder: widget.textStyleBuilder, inlineWidgetBuilders: widget.inlineWidgetBuilders, + inheritDefaultTextStyle: widget.inheritDefaultTextStyle, hintBehavior: widget.hintBehavior, hintBuilder: widget.hintBuilder, selectionHighlightStyle: SelectionHighlightStyle( @@ -409,6 +421,7 @@ class SuperTextFieldState extends State implements ImeInputOwner textAlign: widget.textAlign, textStyleBuilder: widget.textStyleBuilder, inlineWidgetBuilders: widget.inlineWidgetBuilders, + inheritDefaultTextStyle: widget.inheritDefaultTextStyle, hintBehavior: widget.hintBehavior, hintBuilder: widget.hintBuilder, caretStyle: widget.caretStyle ?? @@ -439,6 +452,7 @@ class SuperTextFieldState extends State implements ImeInputOwner textAlign: widget.textAlign, textStyleBuilder: widget.textStyleBuilder, inlineWidgetBuilders: widget.inlineWidgetBuilders, + inheritDefaultTextStyle: widget.inheritDefaultTextStyle, padding: widget.padding, hintBehavior: widget.hintBehavior, hintBuilder: widget.hintBuilder, diff --git a/super_editor/test/super_editor/supereditor_style_test.dart b/super_editor/test/super_editor/supereditor_style_test.dart index 4c2c25803..c05ab7f7d 100644 --- a/super_editor/test/super_editor/supereditor_style_test.dart +++ b/super_editor/test/super_editor/supereditor_style_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test_robots/flutter_test_robots.dart'; import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:golden_bricks/golden_bricks.dart'; import 'package:super_editor/super_editor.dart'; import 'package:super_editor/super_editor_test.dart'; import 'package:super_text_layout/super_text_layout.dart'; @@ -242,6 +243,161 @@ A paragraph lessThanOrEqualTo(SuperEditorInspector.findComponentOffset("2", Alignment.topLeft).dy), ); }); + + testWidgetsOnArbitraryDesktop('does not inherit the enclosing default text style by default', (tester) async { + await tester + .createDocument() // + .withSingleParagraph() + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + body: DefaultTextStyle( + style: const TextStyle(fontFamily: goldenBricks), + child: superEditor, + ), + ), + ), + ) + .pump(); + + // Ensure we didn't inherit the font family from the enclosing text style. + expect( + _findSpanAtOffset(tester, offset: 0).style!.fontFamily, + isNull, + ); + }); + + testWidgetsOnArbitraryDesktop('inherits the enclosing default text style if requested', (tester) async { + await tester + .createDocument() // + .withSingleParagraph() + // Use an empty stylesheet to ensure the default text style is inherited when + // there is no style rule for a node. + .useStylesheet( + const Stylesheet( + inheritDefaultTextStyle: true, + rules: [], + inlineTextStyler: defaultInlineTextStyler, + inlineWidgetBuilders: defaultInlineWidgetBuilderChain, + ), + ) + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + body: DefaultTextStyle( + style: const TextStyle(fontFamily: goldenBricks), + child: superEditor, + ), + ), + ), + ) + .pump(); + + // Ensure the font family from the default text style was applied. + expect( + _findSpanAtOffset(tester, offset: 0).style!.fontFamily, + goldenBricks, + ); + }); + + testWidgetsOnArbitraryDesktop('merges style with the enclosing default text style if requested', (tester) async { + await tester + .createDocument() // + .withSingleParagraph() + .useStylesheet( + Stylesheet( + inheritDefaultTextStyle: true, + rules: [ + StyleRule( + BlockSelector.all, + (doc, docNode) { + return { + Styles.textStyle: const TextStyle(fontSize: 24), + }; + }, + ), + ], + inlineTextStyler: defaultInlineTextStyler, + inlineWidgetBuilders: defaultInlineWidgetBuilderChain, + ), + ) + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + body: DefaultTextStyle( + style: const TextStyle(fontFamily: goldenBricks), + child: superEditor, + ), + ), + ), + ) + .pump(); + + final appliedStyle = _findSpanAtOffset(tester, offset: 0).style!; + + // Ensure the font family from the default text style was applied. + expect( + appliedStyle.fontFamily, + goldenBricks, + ); + + // Ensure the font size from the style rule was applied. + expect(appliedStyle.fontSize, 24); + }); + + testWidgetsOnArbitraryDesktop('changes visual text when the enclosing default text style changes', (tester) async { + final styleNotifier = ValueNotifier( + const TextStyle(fontFamily: goldenBricks), + ); + + await tester + .createDocument() // + .withSingleParagraph() + // Use an empty stylesheet to ensure the default text style is inherited when + // there is no style rule for a node. + .useStylesheet( + const Stylesheet( + inheritDefaultTextStyle: true, + rules: [], + inlineTextStyler: defaultInlineTextStyler, + inlineWidgetBuilders: defaultInlineWidgetBuilderChain, + ), + ) + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + body: ValueListenableBuilder( + valueListenable: styleNotifier, + builder: (context, style, child) { + return DefaultTextStyle( + style: style, + child: superEditor, + ); + }, + ), + ), + ), + ) + .pump(); + + // Ensure the font family from the default text style was applied. + expect( + _findSpanAtOffset(tester, offset: 0).style!.fontFamily, + goldenBricks, + ); + + // Change the default text style. + styleNotifier.value = const TextStyle( + fontFamily: 'Roboto', + ); + await tester.pump(); + + // Ensure the font family from the new default text style was applied. + expect( + _findSpanAtOffset(tester, offset: 0).style!.fontFamily, + 'Roboto', + ); + }); }); } diff --git a/super_editor/test/super_reader/super_reader_stylesheet_test.dart b/super_editor/test/super_reader/super_reader_stylesheet_test.dart index 6a17d9122..0995168cb 100644 --- a/super_editor/test/super_reader/super_reader_stylesheet_test.dart +++ b/super_editor/test/super_reader/super_reader_stylesheet_test.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:golden_bricks/golden_bricks.dart'; import 'package:super_editor/super_editor.dart'; import 'package:super_text_layout/super_text_layout.dart'; +import 'reader_test_tools.dart'; import 'test_documents.dart'; void main() { @@ -33,6 +35,161 @@ void main() { expect(_findTextWithAlignment(TextAlign.justify), findsOneWidget); }); }); + + testWidgetsOnArbitraryDesktop('does not inherit the enclosing default text style by default', (tester) async { + await tester + .createDocument() // + .withSingleParagraph() + .withCustomWidgetTreeBuilder( + (superReader) => MaterialApp( + home: Scaffold( + body: DefaultTextStyle( + style: const TextStyle(fontFamily: goldenBricks), + child: superReader, + ), + ), + ), + ) + .pump(); + + // Ensure we didn't inherit the font family from the enclosing text style. + expect( + _findSpanAtOffset(tester, offset: 0).style!.fontFamily, + isNull, + ); + }); + + testWidgetsOnArbitraryDesktop('inherits the enclosing default text style if requested', (tester) async { + await tester + .createDocument() // + .withSingleParagraph() + // Use an empty stylesheet to ensure the default text style is inherited when + // there is no style rule for a node. + .useStylesheet( + const Stylesheet( + inheritDefaultTextStyle: true, + rules: [], + inlineTextStyler: defaultInlineTextStyler, + inlineWidgetBuilders: defaultInlineWidgetBuilderChain, + ), + ) + .withCustomWidgetTreeBuilder( + (superReader) => MaterialApp( + home: Scaffold( + body: DefaultTextStyle( + style: const TextStyle(fontFamily: goldenBricks), + child: superReader, + ), + ), + ), + ) + .pump(); + + // Ensure the font family from the default text style was applied. + expect( + _findSpanAtOffset(tester, offset: 0).style!.fontFamily, + goldenBricks, + ); + }); + + testWidgetsOnArbitraryDesktop('merges style with the enclosing default text style if requested', (tester) async { + await tester + .createDocument() // + .withSingleParagraph() + .useStylesheet( + Stylesheet( + inheritDefaultTextStyle: true, + rules: [ + StyleRule( + BlockSelector.all, + (doc, docNode) { + return { + Styles.textStyle: const TextStyle(fontSize: 24), + }; + }, + ), + ], + inlineTextStyler: defaultInlineTextStyler, + inlineWidgetBuilders: defaultInlineWidgetBuilderChain, + ), + ) + .withCustomWidgetTreeBuilder( + (superReader) => MaterialApp( + home: Scaffold( + body: DefaultTextStyle( + style: const TextStyle(fontFamily: goldenBricks), + child: superReader, + ), + ), + ), + ) + .pump(); + + final spanStyle = _findSpanAtOffset(tester, offset: 0).style!; + + // Ensure the font family from the default text style was applied. + expect( + spanStyle.fontFamily, + goldenBricks, + ); + + // Ensure the font size from the style rule was applied. + expect(spanStyle.fontSize, 24); + }); + + testWidgetsOnArbitraryDesktop('changes visual text when the enclosing default text style changes', (tester) async { + final styleNotifier = ValueNotifier( + const TextStyle(fontFamily: goldenBricks), + ); + + await tester + .createDocument() // + .withSingleParagraph() + // Use an empty stylesheet to ensure the default text style is inherited when + // there is no style rule for a node. + .useStylesheet( + const Stylesheet( + inheritDefaultTextStyle: true, + rules: [], + inlineTextStyler: defaultInlineTextStyler, + inlineWidgetBuilders: defaultInlineWidgetBuilderChain, + ), + ) + .withCustomWidgetTreeBuilder( + (superReader) => MaterialApp( + home: Scaffold( + body: ValueListenableBuilder( + valueListenable: styleNotifier, + builder: (context, style, child) { + return DefaultTextStyle( + style: style, + child: superReader, + ); + }, + ), + ), + ), + ) + .pump(); + + // Ensure the font family from the default text style was applied. + expect( + _findSpanAtOffset(tester, offset: 0).style!.fontFamily, + goldenBricks, + ); + + // Change the default text style. + styleNotifier.value = const TextStyle( + fontFamily: 'Roboto', + ); + await tester.pump(); + + // Ensure the font family from the new default text style was applied. + expect( + _findSpanAtOffset(tester, offset: 0).style!.fontFamily, + 'Roboto', + ); + }); }); } @@ -73,3 +230,11 @@ Stylesheet _stylesheetWithTextAlignment(TextAlign textAlign) { ], ); } + +InlineSpan _findSpanAtOffset( + WidgetTester tester, { + required int offset, +}) { + final superText = tester.widget(find.byType(SuperText)); + return superText.richText.getSpanForPosition(TextPosition(offset: offset))!; +} diff --git a/super_editor/test/super_textfield/super_textfield_style_test.dart b/super_editor/test/super_textfield/super_textfield_style_test.dart new file mode 100644 index 000000000..0cfd578e7 --- /dev/null +++ b/super_editor/test/super_textfield/super_textfield_style_test.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:golden_bricks/golden_bricks.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_text_layout/super_text_layout.dart'; + +void main() { + group('SuperTextField > DefaultTextStyle >', () { + testWidgetsOnAllPlatforms('does not inherit the enclosing DefaultTextStyle by default', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DefaultTextStyle( + style: const TextStyle(fontFamily: goldenBricks), + child: SuperTextField( + textController: AttributedTextEditingController( + text: AttributedText('Hello, world!'), + ), + ), + ), + ), + ), + ); + + final textField = find.byType(SuperTextField); + expect(textField, findsOneWidget); + + // Ensure the font family was not applied from the default text style. + expect( + _findSpanAtOffset(tester, offset: 0).style!.fontFamily, + isNull, + ); + }); + + testWidgetsOnAllPlatforms('inherits the enclosing DefaultTextStyle if requested', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DefaultTextStyle( + style: const TextStyle(fontFamily: goldenBricks), + child: SuperTextField( + inheritDefaultTextStyle: true, + textController: AttributedTextEditingController( + text: AttributedText('Hello, world!'), + ), + ), + ), + ), + ), + ); + + final textField = find.byType(SuperTextField); + expect(textField, findsOneWidget); + + // Ensure the font family from the default text style was applied. + expect( + _findSpanAtOffset(tester, offset: 0).style!.fontFamily, + goldenBricks, + ); + }); + + testWidgetsOnAllPlatforms('merges style with the enclosing default text style if requested', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DefaultTextStyle( + style: const TextStyle(fontFamily: goldenBricks), + child: SuperTextField( + inheritDefaultTextStyle: true, + textController: AttributedTextEditingController( + text: AttributedText('Hello, world!'), + ), + textStyleBuilder: (attributions) => const TextStyle(fontSize: 24), + ), + ), + ), + ), + ); + + final textField = find.byType(SuperTextField); + expect(textField, findsOneWidget); + + final appliedStyle = _findSpanAtOffset(tester, offset: 0).style!; + + // Ensure the font family from the default text style was applied. + expect( + appliedStyle.fontFamily, + goldenBricks, + ); + + // Ensure the font size from the text style builder was applied. + expect( + appliedStyle.fontSize, + 24, + ); + }); + + testWidgetsOnAllPlatforms('changes visual text when the enclosing default text style changes', (tester) async { + final styleNotifier = ValueNotifier( + const TextStyle(fontFamily: goldenBricks), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ValueListenableBuilder( + valueListenable: styleNotifier, + builder: (context, style, child) { + return DefaultTextStyle( + style: style, + child: SuperTextField( + inheritDefaultTextStyle: true, + textController: AttributedTextEditingController( + text: AttributedText('Hello, world!'), + ), + ), + ); + }), + ), + ), + ); + + final textField = find.byType(SuperTextField); + expect(textField, findsOneWidget); + + // Ensure the font family from the default text style was applied. + expect( + _findSpanAtOffset(tester, offset: 0).style!.fontFamily, + goldenBricks, + ); + + // Change the default text style. + styleNotifier.value = const TextStyle( + fontFamily: 'Roboto', + ); + await tester.pump(); + + // Ensure the font family from the new default text style was applied. + expect( + _findSpanAtOffset(tester, offset: 0).style!.fontFamily, + 'Roboto', + ); + }); + }); +} + +InlineSpan _findSpanAtOffset( + WidgetTester tester, { + required int offset, +}) { + final superText = tester.widget(find.byType(SuperText)); + return superText.richText.getSpanForPosition(TextPosition(offset: offset))!; +}