| // Copyright 2014 The Flutter 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 '../convert.dart'; | |
| import '../features.dart'; | |
| import 'io.dart' as io; | |
| import 'logger.dart'; | |
| import 'platform.dart'; | |
| import 'process.dart'; | |
| import 'utils.dart'; | |
| enum TerminalColor { red, green, blue, cyan, yellow, magenta, grey } | |
| /// A class that contains the context settings for command text output to the | |
| /// console. | |
| class OutputPreferences { | |
| OutputPreferences({bool? wrapText, int? wrapColumn, bool? showColor, io.Stdio? stdio}) | |
| : _stdio = stdio, | |
| wrapText = wrapText ?? stdio?.hasTerminal ?? false, | |
| _overrideWrapColumn = wrapColumn, | |
| showColor = showColor ?? false; | |
| /// A version of this class for use in tests. | |
| OutputPreferences.test({ | |
| this.wrapText = false, | |
| int wrapColumn = kDefaultTerminalColumns, | |
| this.showColor = false, | |
| }) : _overrideWrapColumn = wrapColumn, | |
| _stdio = null; | |
| final io.Stdio? _stdio; | |
| /// If `true`, then any text sent to the context's [Logger] instance, | |
| /// such as from the [Logger.printError] and [Logger.printStatus] functions, | |
| /// will be wrapped (newlines added between words) to | |
| /// be no longer than the [wrapColumn] specifies. | |
| /// Defaults to `true` if there is a terminal. | |
| /// | |
| /// To determine if there's a terminal, | |
| /// [OutputPreferences] asks the context's stdio. | |
| final bool wrapText; | |
| /// The terminal width used by the [wrapText] function if there is no terminal | |
| /// attached to [io.Stdio], --wrap is on, and --wrap-columns was not specified. | |
| static const kDefaultTerminalColumns = 100; | |
| /// The column at which output sent to the context's [Logger] instance, | |
| /// such as from the [Logger.printError] and [Logger.printStatus] functions, | |
| /// will be wrapped. Ignored if [wrapText] is `false`. | |
| /// Defaults to the width of the output terminal, or to | |
| /// [kDefaultTerminalColumns] if not writing to a terminal. | |
| final int? _overrideWrapColumn; | |
| int get wrapColumn { | |
| return _overrideWrapColumn ?? _stdio?.terminalColumns ?? kDefaultTerminalColumns; | |
| } | |
| /// Whether or not to output ANSI color codes when writing to the output | |
| /// terminal. Defaults to whatever [Platform.stdoutSupportsAnsi] says if | |
| /// writing to a terminal, and false otherwise. | |
| final bool showColor; | |
| @override | |
| String toString() { | |
| return '$runtimeType[wrapText: $wrapText, wrapColumn: $wrapColumn, showColor: $showColor]'; | |
| } | |
| } | |
| /// The command line terminal, if available. | |
| // TODO(ianh): merge this with AnsiTerminal, the abstraction isn't giving us anything. | |
| abstract class Terminal { | |
| /// Create a new test [Terminal]. | |
| /// | |
| /// If not specified, [supportsColor] defaults to `false`. | |
| factory Terminal.test({bool supportsColor, bool supportsEmoji}) = _TestTerminal; | |
| /// Whether the current terminal supports color escape codes. | |
| /// | |
| /// Check [isCliAnimationEnabled] as well before using `\r` or ANSI sequences | |
| /// to perform animations. | |
| bool get supportsColor; | |
| /// Whether animations should be used in the output. | |
| bool get isCliAnimationEnabled; | |
| /// Configures isCliAnimationEnabled based on a [FeatureFlags] object. | |
| void applyFeatureFlags(FeatureFlags flags); | |
| /// Whether the current terminal can display emoji. | |
| bool get supportsEmoji; | |
| /// When we have a choice of styles (e.g. animated spinners), this selects the | |
| /// style to use. | |
| int get preferredStyle; | |
| /// Whether we are interacting with the flutter tool via the terminal. | |
| /// | |
| /// If not set, defaults to false. | |
| bool get usesTerminalUi; | |
| set usesTerminalUi(bool value); | |
| /// Whether there is a terminal attached to stdin. | |
| /// | |
| /// If true, this usually indicates that a user is using the CLI as | |
| /// opposed to using an IDE. This can be used to determine | |
| /// whether it is appropriate to show a terminal prompt, | |
| /// or whether an automatic selection should be made instead. | |
| bool get stdinHasTerminal; | |
| /// Warning mark to use in stdout or stderr. | |
| String get warningMark; | |
| /// Success mark to use in stdout. | |
| String get successMark; | |
| String bolden(String message); | |
| String color(String message, TerminalColor color); | |
| String clearScreen(); | |
| bool get singleCharMode; | |
| set singleCharMode(bool value); | |
| /// Return keystrokes from the console. | |
| /// | |
| /// This is a single-subscription stream. This stream may be closed before | |
| /// the application exits. | |
| /// | |
| /// Useful when the console is in [singleCharMode]. | |
| Stream<String> get keystrokes; | |
| /// Reads a full line from the console. | |
| /// | |
| /// Useful when the console is not in [singleCharMode]. | |
| Future<String> readLine(); | |
| /// Prompts the user to input a character within a given list. Re-prompts if | |
| /// entered character is not in the list. | |
| /// | |
| /// The `prompt`, if non-null, is the text displayed prior to waiting for user | |
| /// input each time. If `prompt` is non-null and `displayAcceptedCharacters` | |
| /// is true, the accepted keys are printed next to the `prompt`. | |
| /// | |
| /// The returned value is the user's input; if `defaultChoiceIndex` is not | |
| /// null, and the user presses enter without any other input, the return value | |
| /// will be the character in `acceptedCharacters` at the index given by | |
| /// `defaultChoiceIndex`. | |
| /// | |
| /// The accepted characters must be a String with a length of 1, excluding any | |
| /// whitespace characters such as `\t`, `\n`, or ` `. | |
| /// | |
| /// If [usesTerminalUi] is false, throws a [StateError]. | |
| Future<String> promptForCharInput( | |
| List<String> acceptedCharacters, { | |
| required Logger logger, | |
| String? prompt, | |
| int? defaultChoiceIndex, | |
| bool displayAcceptedCharacters = true, | |
| }); | |
| } | |
| class AnsiTerminal implements Terminal { | |
| AnsiTerminal({ | |
| required this._stdio, | |
| required this._platform, | |
| DateTime? now, // Time used to determine preferredStyle. Defaults to 0001-01-01 00:00. | |
| bool defaultCliAnimationEnabled = true, | |
| ShutdownHooks? shutdownHooks, | |
| }) : _now = now ?? DateTime(1), | |
| _isCliAnimationEnabled = defaultCliAnimationEnabled { | |
| shutdownHooks?.addShutdownHook(() { | |
| singleCharMode = false; | |
| }); | |
| } | |
| final io.Stdio _stdio; | |
| final Platform _platform; | |
| final DateTime _now; | |
| static const bold = '\u001B[1m'; | |
| static const resetAll = '\u001B[0m'; | |
| static const resetColor = '\u001B[39m'; | |
| static const resetBold = '\u001B[22m'; | |
| static const clear = '\u001B[2J\u001B[H'; | |
| static const red = '\u001b[31m'; | |
| static const green = '\u001b[32m'; | |
| static const blue = '\u001b[34m'; | |
| static const cyan = '\u001b[36m'; | |
| static const magenta = '\u001b[35m'; | |
| static const yellow = '\u001b[33m'; | |
| static const grey = '\u001b[90m'; | |
| // Moves cursor up 1 line. | |
| static const cursorUpLineCode = '\u001b[1A'; | |
| // Moves cursor to the beginning of the line. | |
| static const cursorBeginningOfLineCode = '\u001b[1G'; | |
| // Clear the entire line, cursor position does not change. | |
| static const clearEntireLineCode = '\u001b[2K'; | |
| // Move cursor to column 0 and erase from cursor to end of line (\x1B[K = \x1B[0K). | |
| // Clears the entire current line regardless of cursor position. | |
| static const clearAndReturnCode = '\r\x1B[K'; | |
| static const _colorMap = <TerminalColor, String>{ | |
| TerminalColor.red: red, | |
| TerminalColor.green: green, | |
| TerminalColor.blue: blue, | |
| TerminalColor.cyan: cyan, | |
| TerminalColor.magenta: magenta, | |
| TerminalColor.yellow: yellow, | |
| TerminalColor.grey: grey, | |
| }; | |
| static String colorCode(TerminalColor color) => _colorMap[color]!; | |
| // See https://no-color.org/. | |
| bool get _noColorSet => _platform.environment.containsKey('NO_COLOR'); | |
| @override | |
| bool get supportsColor => _platform.stdoutSupportsAnsi && !_noColorSet; | |
| @override | |
| bool get isCliAnimationEnabled => _isCliAnimationEnabled; | |
| bool _isCliAnimationEnabled; | |
| @override | |
| void applyFeatureFlags(FeatureFlags flags) { | |
| _isCliAnimationEnabled = flags.isCliAnimationEnabled; | |
| } | |
| // Assume unicode emojis are supported when not on Windows. | |
| // If we are on Windows, unicode emojis are supported in Windows Terminal, | |
| // which sets the WT_SESSION environment variable. See: | |
| // https://learn.microsoft.com/en-us/windows/terminal/tips-and-tricks | |
| @override | |
| bool get supportsEmoji => !_platform.isWindows || _platform.environment.containsKey('WT_SESSION'); | |
| @override | |
| int get preferredStyle { | |
| const int workdays = DateTime.friday; | |
| if (_now.weekday <= workdays) { | |
| return _now.weekday - 1; | |
| } | |
| return _now.hour + workdays; | |
| } | |
| final _boldControls = RegExp('(${RegExp.escape(resetBold)}|${RegExp.escape(bold)})'); | |
| @override | |
| bool usesTerminalUi = false; | |
| @override | |
| String get warningMark { | |
| return bolden(color('[!]', TerminalColor.red)); | |
| } | |
| @override | |
| String get successMark { | |
| return bolden(color('✓', TerminalColor.green)); | |
| } | |
| @override | |
| String bolden(String message) { | |
| if (!supportsColor || message.isEmpty) { | |
| return message; | |
| } | |
| final buffer = StringBuffer(); | |
| for (String line in message.split('\n')) { | |
| // If there were bolds or resetBolds in the string before, then nuke them: | |
| // they're redundant. This prevents previously embedded resets from | |
| // stopping the boldness. | |
| line = line.replaceAll(_boldControls, ''); | |
| buffer.writeln('$bold$line$resetBold'); | |
| } | |
| final result = buffer.toString(); | |
| // avoid introducing a new newline to the emboldened text | |
| return (!message.endsWith('\n') && result.endsWith('\n')) | |
| ? result.substring(0, result.length - 1) | |
| : result; | |
| } | |
| @override | |
| String color(String message, TerminalColor color) { | |
| if (!supportsColor || message.isEmpty) { | |
| return message; | |
| } | |
| final buffer = StringBuffer(); | |
| final String colorCodes = _colorMap[color]!; | |
| for (String line in message.split('\n')) { | |
| // If there were resets in the string before, then keep them, but | |
| // restart the color right after. This prevents embedded resets from | |
| // stopping the colors, and allows nesting of colors. | |
| line = line.replaceAll(resetColor, '$resetColor$colorCodes'); | |
| buffer.writeln('$colorCodes$line$resetColor'); | |
| } | |
| final result = buffer.toString(); | |
| // avoid introducing a new newline to the colored text | |
| return (!message.endsWith('\n') && result.endsWith('\n')) | |
| ? result.substring(0, result.length - 1) | |
| : result; | |
| } | |
| @override | |
| String clearScreen() => supportsColor && isCliAnimationEnabled ? clear : '\n\n'; | |
| /// Returns ANSI codes to clear [numberOfLines] lines starting with the line | |
| /// the cursor is on. | |
| /// | |
| /// If the terminal does not support ANSI codes, returns an empty string. | |
| String clearLines(int numberOfLines) { | |
| if (!supportsColor || !isCliAnimationEnabled) { | |
| return ''; | |
| } | |
| return cursorBeginningOfLineCode + | |
| clearEntireLineCode + | |
| (cursorUpLineCode + clearEntireLineCode) * (numberOfLines - 1); | |
| } | |
| @override | |
| bool get singleCharMode { | |
| if (!_stdio.stdinHasTerminal) { | |
| return false; | |
| } | |
| final stdin = _stdio.stdin as io.Stdin; | |
| return !stdin.lineMode && !stdin.echoMode; | |
| } | |
| @override | |
| set singleCharMode(bool value) { | |
| if (!_stdio.stdinHasTerminal) { | |
| return; | |
| } | |
| final stdin = _stdio.stdin as io.Stdin; | |
| try { | |
| // The order of setting lineMode and echoMode is important on Windows. | |
| if (value) { | |
| stdin.echoMode = false; | |
| stdin.lineMode = false; | |
| } else { | |
| stdin.lineMode = true; | |
| stdin.echoMode = true; | |
| } | |
| } on io.StdinException { | |
| // If the pipe to STDIN has been closed it's probably because the | |
| // terminal has been closed, and there is nothing actionable to do here. | |
| } | |
| } | |
| @override | |
| bool get stdinHasTerminal => _stdio.stdinHasTerminal; | |
| Stream<String>? _broadcastStdInString; | |
| @override | |
| Stream<String> get keystrokes { | |
| return _broadcastStdInString ??= _stdio.stdin | |
| .transform<String>(const AsciiDecoder(allowInvalid: true)) | |
| .asBroadcastStream(); | |
| } | |
| Stream<String>? _broadcastStdInLines; | |
| @override | |
| Future<String> readLine() { | |
| return (_broadcastStdInLines ??= _stdio.stdin | |
| .transform<String>(utf8AllowMalformedLineDecoder) | |
| .asBroadcastStream()) | |
| .first; | |
| } | |
| @override | |
| Future<String> promptForCharInput( | |
| List<String> acceptedCharacters, { | |
| required Logger logger, | |
| String? prompt, | |
| int? defaultChoiceIndex, | |
| bool displayAcceptedCharacters = true, | |
| }) async { | |
| assert(acceptedCharacters.isNotEmpty); | |
| assert(prompt == null || prompt.isNotEmpty); | |
| if (!usesTerminalUi) { | |
| throw StateError('cannot prompt without a terminal ui'); | |
| } | |
| var charactersToDisplay = acceptedCharacters; | |
| if (defaultChoiceIndex != null) { | |
| assert(defaultChoiceIndex >= 0 && defaultChoiceIndex < acceptedCharacters.length); | |
| charactersToDisplay = List<String>.of(charactersToDisplay); | |
| charactersToDisplay[defaultChoiceIndex] = bolden(charactersToDisplay[defaultChoiceIndex]); | |
| acceptedCharacters.add(''); | |
| } | |
| String? choice; | |
| singleCharMode = true; | |
| while (choice == null || choice.length > 1 || !acceptedCharacters.contains(choice)) { | |
| if (prompt != null) { | |
| logger.printStatus(prompt, emphasis: true, newline: false); | |
| if (displayAcceptedCharacters) { | |
| logger.printStatus(' [${charactersToDisplay.join("|")}]', newline: false); | |
| } | |
| // prompt ends with ': ' | |
| logger.printStatus(': ', emphasis: true, newline: false); | |
| } | |
| choice = (await keystrokes.first).trim(); | |
| logger.printStatus(choice); | |
| } | |
| singleCharMode = false; | |
| if (defaultChoiceIndex != null && choice == '') { | |
| choice = acceptedCharacters[defaultChoiceIndex]; | |
| } | |
| return choice; | |
| } | |
| } | |
| class _TestTerminal implements Terminal { | |
| _TestTerminal({this.supportsColor = false, this.supportsEmoji = false}); | |
| @override | |
| bool usesTerminalUi = false; | |
| @override | |
| String bolden(String message) => message; | |
| @override | |
| String clearScreen() => '\n\n'; | |
| @override | |
| String color(String message, TerminalColor color) => message; | |
| @override | |
| Stream<String> get keystrokes => const Stream<String>.empty(); | |
| @override | |
| Future<String> readLine() { | |
| throw UnsupportedError('readLine not supported in the test terminal.'); | |
| } | |
| @override | |
| Future<String> promptForCharInput( | |
| List<String> acceptedCharacters, { | |
| required Logger logger, | |
| String? prompt, | |
| int? defaultChoiceIndex, | |
| bool displayAcceptedCharacters = true, | |
| }) { | |
| throw UnsupportedError('promptForCharInput not supported in the test terminal.'); | |
| } | |
| @override | |
| bool get singleCharMode => false; | |
| @override | |
| set singleCharMode(bool value) {} | |
| @override | |
| final bool supportsColor; | |
| @override | |
| bool get isCliAnimationEnabled => supportsColor && _isCliAnimationEnabled; | |
| var _isCliAnimationEnabled = true; | |
| @override | |
| void applyFeatureFlags(FeatureFlags flags) { | |
| _isCliAnimationEnabled = flags.isCliAnimationEnabled; | |
| } | |
| @override | |
| final bool supportsEmoji; | |
| @override | |
| int get preferredStyle => 0; | |
| @override | |
| bool get stdinHasTerminal => false; | |
| @override | |
| String get successMark => '✓'; | |
| @override | |
| String get warningMark => '[!]'; | |
| } |