blob: aeb8f2812fd6ba9e3921358bf868502f1e2aa45f [file] [log] [blame]
// Copyright 2013 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 'dart:convert';
import 'dart:io';
import 'common.dart';
import 'io.dart' as io;
import 'logger.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 [wrapText] is true, then any text sent to the context's [Logger]
/// instance (e.g. from the [printError] or [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 int kDefaultTerminalColumns = 100;
/// The column at which output sent to the context's [Logger] instance
/// (e.g. from the [printError] or [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 'OutputPreferences[wrapText: $wrapText, wrapColumn: $wrapColumn, showColor: $showColor]';
}
}
/// The command line terminal, if available.
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.
bool get supportsColor;
/// 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;
/// 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 io.Stdio stdio,
DateTime?
now, // Time used to determine preferredStyle. Defaults to 0001-01-01 00:00.
bool? supportsColor,
}) : _stdio = stdio,
_now = now ?? DateTime(1),
_supportsColor = supportsColor;
final io.Stdio _stdio;
final DateTime _now;
static const String bold = '\u001B[1m';
static const String resetAll = '\u001B[0m';
static const String resetColor = '\u001B[39m';
static const String resetBold = '\u001B[22m';
static const String clear = '\u001B[2J\u001B[H';
static const String red = '\u001b[31m';
static const String green = '\u001b[32m';
static const String blue = '\u001b[34m';
static const String cyan = '\u001b[36m';
static const String magenta = '\u001b[35m';
static const String yellow = '\u001b[33m';
static const String grey = '\u001b[90m';
static const Map<TerminalColor, String> _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]!;
@override
bool get supportsColor => _supportsColor ?? stdout.supportsAnsiEscapes;
final bool? _supportsColor;
// 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://github.com/microsoft/terminal/blob/master/doc/user-docs/index.md#tips-and-tricks
@override
bool get supportsEmoji =>
!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 RegExp _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) {
assert(message != null);
if (!supportsColor || message.isEmpty) {
return message;
}
final StringBuffer 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 String 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) {
assert(message != null);
if (!supportsColor || color == null || message.isEmpty) {
return message;
}
final StringBuffer 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 String 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 ? clear : '\n\n';
@override
bool get singleCharMode {
if (!_stdio.stdinHasTerminal) {
return false;
}
final io.Stdin stdin = _stdio.stdin as io.Stdin;
return stdin.lineMode && stdin.echoMode;
}
@override
set singleCharMode(bool value) {
if (!_stdio.stdinHasTerminal) {
return;
}
final io.Stdin stdin = _stdio.stdin as io.Stdin;
// 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;
}
}
@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();
}
@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');
}
List<String> 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> 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
final bool supportsEmoji;
@override
int get preferredStyle => 0;
@override
bool get stdinHasTerminal => false;
@override
String get successMark => '✓';
@override
String get warningMark => '[!]';
}