| // Copyright 2020 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import 'package:flutter/foundation.dart'; |
| import 'package:flutter/material.dart'; |
| |
| import '../primitives/auto_dispose_mixin.dart'; |
| import '../primitives/utils.dart'; |
| import '../screens/debugger/debugger_controller.dart'; |
| import '../screens/debugger/variables.dart'; |
| import 'common_widgets.dart'; |
| import 'console_service.dart'; |
| import 'theme.dart'; |
| import 'utils.dart'; |
| |
| // TODO(devoncarew): Allow scrolling horizontally as well. |
| |
| // TODO(devoncarew): Support hyperlinking to stack traces. |
| |
| /// Renders a ConsoleOutput widget with ConsoleControls overlaid on the |
| /// top-right corner. |
| class Console extends StatelessWidget { |
| const Console({ |
| this.controls = const <Widget>[], |
| required this.lines, |
| this.title, |
| this.footer, |
| }) : super(); |
| |
| final Widget? title; |
| final Widget? footer; |
| final List<Widget> controls; |
| final ValueListenable<List<ConsoleLine>> lines; |
| |
| @visibleForTesting |
| String get textContent => lines.value.join('\n'); |
| |
| @override |
| Widget build(BuildContext context) { |
| return ConsoleFrame( |
| controls: controls, |
| title: title, |
| child: _ConsoleOutput(lines: lines, footer: footer), |
| ); |
| } |
| } |
| |
| class ConsoleFrame extends StatelessWidget { |
| const ConsoleFrame({ |
| this.controls = const <Widget>[], |
| required this.child, |
| this.title, |
| }) : super(); |
| |
| final Widget? title; |
| final Widget child; |
| final List<Widget> controls; |
| |
| @override |
| Widget build(BuildContext context) { |
| return Column( |
| crossAxisAlignment: CrossAxisAlignment.stretch, |
| children: [ |
| if (title != null) title!, |
| Expanded( |
| child: Material( |
| child: Stack( |
| children: [ |
| child, |
| if (controls.isNotEmpty) |
| _ConsoleControls( |
| controls: controls, |
| ), |
| ], |
| ), |
| ), |
| ), |
| ], |
| ); |
| } |
| } |
| |
| /// Renders a top-right aligned ButtonBar wrapping a List of IconButtons |
| /// (`controls`). |
| class _ConsoleControls extends StatelessWidget { |
| const _ConsoleControls({ |
| required this.controls, |
| }) : super(); |
| |
| final List<Widget> controls; |
| |
| @override |
| Widget build(BuildContext context) { |
| return Container( |
| alignment: Alignment.topRight, |
| child: ButtonBar( |
| buttonPadding: EdgeInsets.zero, |
| alignment: MainAxisAlignment.end, |
| children: controls, |
| ), |
| ); |
| } |
| } |
| |
| /// Renders a widget with the output of the console. |
| /// |
| /// This is a ListView of text lines, with a monospace font and a border. |
| class _ConsoleOutput extends StatefulWidget { |
| const _ConsoleOutput({ |
| Key? key, |
| required this.lines, |
| this.footer, |
| }) : super(key: key); |
| |
| final ValueListenable<List<ConsoleLine>> lines; |
| |
| final Widget? footer; |
| |
| @override |
| _ConsoleOutputState createState() => _ConsoleOutputState(); |
| } |
| |
| class _ConsoleOutputState extends State<_ConsoleOutput> |
| with |
| AutoDisposeMixin<_ConsoleOutput>, |
| ProvidedControllerMixin<DebuggerController, _ConsoleOutput> { |
| // The scroll controller must survive ConsoleOutput re-renders |
| // to work as intended, so it must be part of the "state". |
| final ScrollController _scroll = ScrollController(); |
| |
| static const _scrollBarKey = Key('console-scrollbar'); |
| |
| List<ConsoleLine> _currentLines = const []; |
| bool _scrollToBottom = true; |
| bool _considerScrollAtBottom = true; |
| double _lastScrollOffset = 0.0; |
| |
| @override |
| void initState() { |
| super.initState(); |
| _initHelper(); |
| } |
| |
| void _onScrollChanged() { |
| // Detect if the user has scrolled up and stop scrolling to the bottom if |
| // they have scrolled up. |
| if (_scroll.hasClients) { |
| if (_scroll.atScrollBottom) { |
| _considerScrollAtBottom = true; |
| } else if (_lastScrollOffset > _scroll.offset) { |
| _considerScrollAtBottom = false; |
| } |
| _lastScrollOffset = _scroll.offset; |
| } |
| } |
| |
| // Whenever the widget updates, refresh the scroll position if needed. |
| @override |
| void didUpdateWidget(_ConsoleOutput oldWidget) { |
| if (oldWidget.lines != widget.lines) { |
| _initHelper(); |
| } |
| super.didUpdateWidget(oldWidget); |
| } |
| |
| void _initHelper() { |
| cancelListeners(); |
| addAutoDisposeListener(widget.lines, _onConsoleLinesChanged); |
| addAutoDisposeListener(_scroll, _onScrollChanged); |
| _onConsoleLinesChanged(); |
| } |
| |
| void _onConsoleLinesChanged() { |
| final nextLines = widget.lines.value; |
| if (nextLines == _currentLines) return; |
| |
| var forceScrollIntoView = false; |
| for (int i = _currentLines.length; i < nextLines.length; i++) { |
| if (nextLines[i].forceScrollIntoView) { |
| forceScrollIntoView = true; |
| break; |
| } |
| } |
| setState(() { |
| _currentLines = nextLines; |
| }); |
| |
| if (forceScrollIntoView || |
| _considerScrollAtBottom || |
| (_scroll.hasClients && _scroll.atScrollBottom)) { |
| _scrollToBottom = true; |
| } |
| } |
| |
| @override |
| void didChangeDependencies() { |
| super.didChangeDependencies(); |
| initController(); |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| final theme = Theme.of(context); |
| |
| if (_scrollToBottom) { |
| _scrollToBottom = false; |
| WidgetsBinding.instance.addPostFrameCallback((timeStamp) { |
| if (_scroll.hasClients) { |
| _scroll.autoScrollToBottom(); |
| } else { |
| // Set back to true to retry scrolling when we are back in view. |
| // We expected to be in view after the frame but it turns out we were |
| // not. |
| _scrollToBottom = true; |
| } |
| }); |
| } |
| return Scrollbar( |
| controller: _scroll, |
| thumbVisibility: true, |
| key: _scrollBarKey, |
| child: ListView.separated( |
| padding: const EdgeInsets.all(denseSpacing), |
| itemCount: _currentLines.length + (widget.footer != null ? 1 : 0), |
| controller: _scroll, |
| // Scroll physics to try to keep content within view and avoid bouncing. |
| physics: const ClampingScrollPhysics( |
| parent: RangeMaintainingScrollPhysics(), |
| ), |
| separatorBuilder: (_, __) { |
| return const Divider(); |
| }, |
| itemBuilder: (context, index) { |
| if (index == _currentLines.length && widget.footer != null) { |
| return widget.footer!; |
| } |
| final line = _currentLines[index]; |
| if (line is TextConsoleLine) { |
| return SelectableText.rich( |
| TextSpan( |
| // TODO(jacobr): consider caching the processed ansi terminal |
| // codes. |
| children: processAnsiTerminalCodes( |
| line.text, |
| theme.fixedFontStyle, |
| ), |
| ), |
| ); |
| } else if (line is VariableConsoleLine) { |
| return ExpandableVariable( |
| variable: line.variable, |
| debuggerController: controller, |
| ); |
| } else { |
| assert( |
| false, |
| 'ConsoleLine of unsupported type ${line.runtimeType} encountered', |
| ); |
| return const SizedBox(); |
| } |
| }, |
| ), |
| ); |
| } |
| } |
| |
| // CONTROLS |
| |
| /// A Console Control to "delete" the contents of the console. |
| /// |
| /// This just preconfigures a ConsoleControl with the `delete` icon, |
| /// and the `onPressed` function passed from the outside. |
| class DeleteControl extends StatelessWidget { |
| const DeleteControl({ |
| this.onPressed, |
| this.tooltip = 'Clear contents', |
| this.buttonKey, |
| }); |
| |
| final VoidCallback? onPressed; |
| final String tooltip; |
| final Key? buttonKey; |
| |
| @override |
| Widget build(BuildContext context) { |
| return ToolbarAction( |
| icon: Icons.delete, |
| tooltip: tooltip, |
| onPressed: onPressed, |
| key: buttonKey, |
| ); |
| } |
| } |