diff --git a/flutter_highlight/README.md b/flutter_highlight/README.md index 4c3f34f..0af7a85 100644 --- a/flutter_highlight/README.md +++ b/flutter_highlight/README.md @@ -44,6 +44,14 @@ class MyWidget extends StatelessWidget { } ``` +### Background processing + +Processing large amounts of text can be slow. To perform text processing in the background, add a +`HighlightBackgroundEnvironment` above any `HighlightView`s in your widget tree. + +A background isolate will be automatically started in `HighlightBackgroundEnvironment.initState`, and stopped in +`HighlightBackgroundEnvironment.dispose`. + ## References - [All available languages](https://github.com/pd4d10/highlight/tree/master/highlight/lib/languages) diff --git a/flutter_highlight/example/lib/main.dart b/flutter_highlight/example/lib/main.dart index 74ada48..86edb9e 100644 --- a/flutter_highlight/example/lib/main.dart +++ b/flutter_highlight/example/lib/main.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_highlight/flutter_highlight.dart'; +import 'package:flutter_highlight/flutter_highlight_background.dart'; import 'package:flutter_highlight/theme_map.dart'; import 'package:url_launcher/url_launcher.dart'; + import 'example_map.dart'; void main() => runApp(MyApp()); @@ -95,14 +97,16 @@ class _MyHomePageState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - HighlightView( - exampleMap[language], - language: language, - theme: themeMap[theme], - padding: EdgeInsets.all(12), - textStyle: TextStyle( - fontFamily: - 'SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace'), + HighlightBackgroundEnvironment( + child: HighlightView( + exampleMap[language], + language: language, + theme: themeMap[theme], + padding: EdgeInsets.all(12), + textStyle: TextStyle( + fontFamily: + 'SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace'), + ), ) ], ), diff --git a/flutter_highlight/lib/flutter_highlight.dart b/flutter_highlight/lib/flutter_highlight.dart index 9afbc47..7c2327d 100644 --- a/flutter_highlight/lib/flutter_highlight.dart +++ b/flutter_highlight/lib/flutter_highlight.dart @@ -1,9 +1,11 @@ -import 'package:flutter/material.dart'; +import 'dart:async'; + import 'package:flutter/widgets.dart'; +import 'package:flutter_highlight/flutter_highlight_background.dart'; import 'package:highlight/highlight.dart' show highlight, Node; /// Highlight Flutter Widget -class HighlightView extends StatelessWidget { +class HighlightView extends StatefulWidget { /// The original code to be highlighted final String source; @@ -27,6 +29,12 @@ class HighlightView extends StatelessWidget { /// Specify text styles such as font family and font size final TextStyle? textStyle; + /// Progress indicator + /// + /// A widget that is displayed while the [source] is being processed. + /// This may only be used if a [HighlightBackgroundEnvironment] is available. + final Widget? progressIndicator; + HighlightView( String input, { this.language, @@ -34,9 +42,27 @@ class HighlightView extends StatelessWidget { this.padding, this.textStyle, int tabSize = 8, // TODO: https://github.com/flutter/flutter/issues/50087 + this.progressIndicator, }) : source = input.replaceAll('\t', ' ' * tabSize); - List _convert(List nodes) { + static const _rootKey = 'root'; + static const _defaultFontColor = Color(0xff000000); + static const _defaultBackgroundColor = Color(0xffffffff); + + // TODO: dart:io is not available at web platform currently + // See: https://github.com/flutter/flutter/issues/39998 + // So we just use monospace here for now + static const _defaultFontFamily = 'monospace'; + + @override + State createState() => _HighlightViewState(); + + /// Renders a list of [nodes] into a list of [TextSpan]s using the given + /// [theme]. + static List render( + List nodes, + Map theme, + ) { List spans = []; var currentSpans = spans; List> stack = []; @@ -48,7 +74,8 @@ class HighlightView extends StatelessWidget { : TextSpan(text: node.value, style: theme[node.className!])); } else if (node.children != null) { List tmp = []; - currentSpans.add(TextSpan(children: tmp, style: theme[node.className!])); + currentSpans + .add(TextSpan(children: tmp, style: theme[node.className!])); stack.add(currentSpans); currentSpans = tmp; @@ -67,34 +94,96 @@ class HighlightView extends StatelessWidget { return spans; } +} - static const _rootKey = 'root'; - static const _defaultFontColor = Color(0xff000000); - static const _defaultBackgroundColor = Color(0xffffffff); +class _HighlightViewState extends State { + late Future> _nodesFuture; + late Future> _spansFuture; - // TODO: dart:io is not available at web platform currently - // See: https://github.com/flutter/flutter/issues/39998 - // So we just use monospace here for now - static const _defaultFontFamily = 'monospace'; + void _parse(HighlightBackgroundProvider? backgroundProvider) => _nodesFuture = + backgroundProvider?.parse(widget.source, language: widget.language) ?? + Future.value( + highlight.parse(widget.source, language: widget.language).nodes, + ); + + void _render(HighlightBackgroundProvider? backgroundProvider) => + _spansFuture = _nodesFuture.then((nodes) => + (backgroundProvider?.render(nodes, widget.theme) ?? + HighlightView.render(nodes, widget.theme)) + as FutureOr>); + + void _parseAndRender(HighlightBackgroundProvider? backgroundProvider) { + if (backgroundProvider == null) { + _parse(null); + _render(null); + } else { + final resultFuture = backgroundProvider.parseAndRender( + widget.source, + widget.theme, + language: widget.language, + ); + _nodesFuture = resultFuture.then((result) => result.nodes); + _spansFuture = resultFuture.then((result) => result.spans); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final backgroundProvider = HighlightBackgroundProvider.maybeOf(context); + _parseAndRender(backgroundProvider); + } + + @override + void didUpdateWidget(HighlightView oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.source != oldWidget.source || + widget.language != oldWidget.language) { + final backgroundProvider = HighlightBackgroundProvider.maybeOf(context); + _parseAndRender(backgroundProvider); + } else if (widget.theme != oldWidget.theme) { + final backgroundProvider = HighlightBackgroundProvider.maybeOf(context); + _render(backgroundProvider); + } + } @override Widget build(BuildContext context) { var _textStyle = TextStyle( - fontFamily: _defaultFontFamily, - color: theme[_rootKey]?.color ?? _defaultFontColor, + fontFamily: HighlightView._defaultFontFamily, + color: widget.theme[HighlightView._rootKey]?.color ?? + HighlightView._defaultFontColor, ); - if (textStyle != null) { - _textStyle = _textStyle.merge(textStyle); + if (widget.textStyle != null) { + _textStyle = _textStyle.merge(widget.textStyle); } return Container( - color: theme[_rootKey]?.backgroundColor ?? _defaultBackgroundColor, - padding: padding, - child: RichText( - text: TextSpan( - style: _textStyle, - children: _convert(highlight.parse(source, language: language).nodes!), - ), + color: widget.theme[HighlightView._rootKey]?.backgroundColor ?? + HighlightView._defaultBackgroundColor, + padding: widget.padding, + child: FutureBuilder>( + future: _spansFuture, + builder: (context, snapshot) { + if (!snapshot.hasData) { + final progressIndicator = widget.progressIndicator; + if (progressIndicator == null) { + return const SizedBox.shrink(); + } else { + assert( + HighlightBackgroundProvider.maybeOf(context) != null, + 'Cannot display a progress indicator unless a HighlightBackgroundEnvironment is available!', + ); + return progressIndicator; + } + } + return RichText( + text: TextSpan( + style: _textStyle, + children: snapshot.requireData, + ), + ); + }, ), ); } diff --git a/flutter_highlight/lib/flutter_highlight_background.dart b/flutter_highlight/lib/flutter_highlight_background.dart new file mode 100644 index 0000000..b79572a --- /dev/null +++ b/flutter_highlight/lib/flutter_highlight_background.dart @@ -0,0 +1,308 @@ +import 'dart:async'; +import 'dart:isolate'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_highlight/flutter_highlight.dart'; +import 'package:highlight/highlight.dart' show highlight, Node; + +/// A result of a combined parse and render operation done in a +/// [HighlightBackgroundEnvironment]. +class HighlightBackgroundResult { + final List nodes; + final List spans; + + const HighlightBackgroundResult(this.nodes, this.spans); +} + +/// A widget that provides a background [Isolate] to do expensive highlighting +/// work in. +/// +/// The [HighlightView] will detect and use the background environment +/// automatically. +/// It can also be used manually through the [HighlightBackgroundProvider] +/// [InheritedWidget]. +class HighlightBackgroundEnvironment extends StatefulWidget { + final Widget child; + + const HighlightBackgroundEnvironment({ + Key? key, + required this.child, + }) : super(key: key); + + @override + State createState() => + _HighlightBackgroundEnvironmentState(); +} + +class _HighlightBackgroundEnvironmentState + extends State { + late final Completer _sendPortCompleter; + late final StreamController<_IdentifiableIsolateMessage> + _resultStreamController; + + @override + void initState() { + super.initState(); + _sendPortCompleter = Completer(); + _resultStreamController = StreamController.broadcast(); + final receivePort = ReceivePort(); + receivePort.listen((response) { + if (response is _IdentifiableIsolateResponse) { + _resultStreamController.add(response); + } else if (response is _IsolateStartedResponse) { + _sendPortCompleter.complete(response.sendPort); + } else if (response is _IsolateEndedResponse) { + receivePort.close(); + } + }); + Isolate.spawn(_isolateEntrypoint, receivePort.sendPort); + } + + @override + void dispose() { + super.dispose(); + _sendPortCompleter.future + .then((sendPort) => sendPort.send(_IsolateEndRequest())); + } + + Future _performTask( + _IdentifiableIsolateRequest request) { + _sendPortCompleter.future.then((sendPort) => sendPort.send(request)); + return _resultStreamController.stream + .firstWhere( + (message) => identical(message.identifier, request.identifier)) + .then((response) => response as T); + } + + Future> _parse(String source, {String? language}) => + _performTask(_ParseRequest(source, language: language)) + .then((response) => response.nodes); + + Future> _render( + List nodes, + Map theme, + ) => + _performTask(_RenderRequest(nodes, theme)) + .then((response) => response.spans); + + Future _parseAndRender( + String source, + Map theme, { + String? language, + }) => + _performTask(_ParseAndRenderRequest(source, theme, language: language)) + .then((response) => + HighlightBackgroundResult(response.nodes, response.spans)); + + @override + Widget build(BuildContext context) { + return HighlightBackgroundProvider._( + environmentIdentifier: this, + parse: _parse, + render: _render, + parseAndRender: _parseAndRender, + child: widget.child, + ); + } +} + +class HighlightBackgroundProvider extends InheritedWidget { + final Object environmentIdentifier; + final Future> Function(String source, {String? language}) parse; + final Future> Function( + List nodes, + Map theme, + ) render; + final Future Function( + String source, + Map theme, { + String? language, + }) parseAndRender; + + HighlightBackgroundProvider._({ + Key? key, + required this.environmentIdentifier, + required this.parse, + required this.render, + required this.parseAndRender, + required Widget child, + }) : super( + key: key, + child: child, + ); + + @override + bool updateShouldNotify(HighlightBackgroundProvider oldWidget) => + !identical(environmentIdentifier, oldWidget.environmentIdentifier); + + static HighlightBackgroundProvider of(BuildContext context) { + final backgroundProvider = maybeOf(context); + assert(backgroundProvider != null, + 'No HighlightBackgroundProvider found in context'); + return backgroundProvider!; + } + + static HighlightBackgroundProvider? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType(); +} + +void _isolateEntrypoint(SendPort sendPort) { + final receivePort = ReceivePort(); + receivePort.listen((request) { + if (request is _ParsingRequest || request is _RenderingRequest) { + late final List nodes; + if (request is _ParsingRequest) { + nodes = + highlight.parse(request.source, language: request.language).nodes!; + } else if (request is _RenderRequest) { + nodes = request.nodes; + } + if (request is _ParseRequest) { + sendPort.send(_ParseResponse(request, nodes)); + } else if (request is _RenderingRequest) { + final spans = HighlightView.render(nodes, request.theme); + if (request is _RenderRequest) { + sendPort.send(_RenderResponse(request, spans)); + } else if (request is _ParseAndRenderRequest) { + sendPort.send(_ParseAndRenderResponse(request, nodes, spans)); + } + } + } else if (request is _IsolateEndRequest) { + receivePort.close(); + sendPort.send(const _IsolateEndedResponse()); + } + }); + sendPort.send(_IsolateStartedResponse(receivePort.sendPort)); +} + +abstract class _IdentifiableIsolateMessage { + Capability get identifier; +} + +abstract class _IsolateRequest {} + +abstract class _IdentifiableIsolateRequest< + T extends _IdentifiableIsolateResponse> + implements _IsolateRequest, _IdentifiableIsolateMessage {} + +class _IsolateEndRequest implements _IsolateRequest { + const _IsolateEndRequest(); +} + +abstract class _ParsingRequest + implements _IdentifiableIsolateRequest { + String get source; + + String? get language; +} + +class _ParseRequest implements _ParsingRequest<_ParseResponse> { + @override + final Capability identifier = Capability(); + + @override + final String source; + + @override + final String? language; + + _ParseRequest(this.source, {this.language}); +} + +abstract class _RenderingRequest + implements _IdentifiableIsolateRequest { + Map get theme; +} + +class _RenderRequest implements _RenderingRequest { + @override + final Capability identifier = Capability(); + + final List nodes; + + @override + final Map theme; + + _RenderRequest(this.nodes, this.theme); +} + +class _ParseAndRenderRequest + implements + _ParsingRequest<_ParseAndRenderResponse>, + _RenderingRequest<_ParseAndRenderResponse> { + @override + final Capability identifier = Capability(); + + @override + final String source; + + @override + final String? language; + + @override + final Map theme; + + _ParseAndRenderRequest(this.source, this.theme, {this.language}); +} + +abstract class _IsolateResponse {} + +abstract class _IdentifiableIsolateResponse + implements _IsolateResponse, _IdentifiableIsolateMessage {} + +class _IsolateStartedResponse implements _IsolateResponse { + final SendPort sendPort; + + const _IsolateStartedResponse(this.sendPort); +} + +class _IsolateEndedResponse implements _IsolateResponse { + const _IsolateEndedResponse(); +} + +abstract class _ParsingResponse implements _IdentifiableIsolateResponse { + List get nodes; +} + +class _ParseResponse implements _ParsingResponse { + @override + final Capability identifier; + + @override + final List nodes; + + _ParseResponse(_ParseRequest request, this.nodes) + : identifier = request.identifier; +} + +abstract class _RenderingResponse implements _IdentifiableIsolateResponse { + List get spans; +} + +class _RenderResponse implements _RenderingResponse { + @override + final Capability identifier; + + @override + final List spans; + + _RenderResponse(_RenderRequest request, this.spans) + : identifier = request.identifier; +} + +class _ParseAndRenderResponse implements _ParsingResponse, _RenderingResponse { + @override + final Capability identifier; + + @override + final List nodes; + + @override + final List spans; + + _ParseAndRenderResponse( + _ParseAndRenderRequest request, + this.nodes, + this.spans, + ) : identifier = request.identifier; +}