terminal.dart 11.5 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5 6
import 'package:meta/meta.dart';

7
import '../convert.dart';
8
import '../globals.dart' as globals;
9
import 'io.dart' as io;
10
import 'logger.dart';
11
import 'platform.dart';
12

13 14 15 16 17 18 19 20 21 22
enum TerminalColor {
  red,
  green,
  blue,
  cyan,
  yellow,
  magenta,
  grey,
}

23 24
/// Warning mark to use in stdout or stderr.
String get warningMark {
25
  return globals.terminal.bolden(globals.terminal.color('[!]', TerminalColor.red));
26 27 28 29
}

/// Success mark to use in stdout.
String get successMark {
30
  return globals.terminal.bolden(globals.terminal.color('✓', TerminalColor.green));
31 32
}

33 34 35 36 37 38 39
/// A class that contains the context settings for command text output to the
/// console.
class OutputPreferences {
  OutputPreferences({
    bool wrapText,
    int wrapColumn,
    bool showColor,
40
  }) : wrapText = wrapText ?? globals.stdio.hasTerminal,
41
       _overrideWrapColumn = wrapColumn,
42
       showColor = showColor ?? globals.platform.stdoutSupportsAnsi ?? false;
43

44
  /// A version of this class for use in tests.
45 46
  OutputPreferences.test({this.wrapText = false, int wrapColumn = kDefaultTerminalColumns, this.showColor = false})
    : _overrideWrapColumn = wrapColumn;
47

48 49 50 51 52
  /// 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
53
  /// stdio.
54 55
  final bool wrapText;

56 57 58 59
  /// 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;

60 61 62 63
  /// 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.
64 65
  final int _overrideWrapColumn;
  int get wrapColumn {
66
    return _overrideWrapColumn ?? globals.stdio.terminalColumns ?? kDefaultTerminalColumns;
67
  }
68 69 70 71 72 73 74 75 76 77 78 79

  /// 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]';
  }
}

80 81
/// The command line terminal, if available.
abstract class Terminal {
82 83 84 85
  /// Create a new test [Terminal].
  ///
  /// If not specified, [supportsColor] defaults to `false`.
  factory Terminal.test({bool supportsColor}) = _TestTerminal;
86 87 88 89 90 91 92 93 94 95 96 97 98

  /// Whether the current terminal supports color escape codes.
  bool get supportsColor;

  /// Whether the current terminal can display emoji.
  bool get supportsEmoji;

  /// Whether we are interacting with the flutter tool via the terminal.
  ///
  /// If not set, defaults to false.
  bool get usesTerminalUi;
  set usesTerminalUi(bool value);

99 100 101 102 103 104 105 106
  /// 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;

107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131
  String bolden(String message);

  String color(String message, TerminalColor color);

  String clearScreen();

  set singleCharMode(bool value);

  /// Return keystrokes from the console.
  ///
  /// 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`.
  ///
132 133 134
  /// The accepted characters must be a String with a length of 1, excluding any
  /// whitespace characters such as `\t`, `\n`, or ` `.
  ///
135 136 137 138 139 140 141 142 143 144 145
  /// 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 {
146 147 148 149
  AnsiTerminal({
    @required io.Stdio stdio,
    @required Platform platform,
  })
150 151 152 153 154 155
    : _stdio = stdio,
      _platform = platform;

  final io.Stdio _stdio;
  final Platform _platform;

156
  static const String bold = '\u001B[1m';
157 158 159
  static const String resetAll = '\u001B[0m';
  static const String resetColor = '\u001B[39m';
  static const String resetBold = '\u001B[22m';
160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178
  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[1;30m';

  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,
  };
179

180 181
  static String colorCode(TerminalColor color) => _colorMap[color];

182
  @override
183
  bool get supportsColor => _platform?.stdoutSupportsAnsi ?? false;
184

185 186 187 188
  // 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
189
  @override
190 191 192 193 194 195
  bool get supportsEmoji => !_platform.isWindows
    || _platform.environment.containsKey('WT_SESSION');

  final RegExp _boldControls = RegExp(
    '(${RegExp.escape(resetBold)}|${RegExp.escape(bold)})',
  );
196

197
  @override
198 199
  bool usesTerminalUi = false;

200
  @override
201
  String bolden(String message) {
202
    assert(message != null);
203
    if (!supportsColor || message.isEmpty) {
204
      return message;
205
    }
206
    final StringBuffer buffer = StringBuffer();
207 208 209 210 211 212 213
    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');
    }
214 215 216 217 218 219 220
    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;
  }

221
  @override
222 223
  String color(String message, TerminalColor color) {
    assert(message != null);
224
    if (!supportsColor || color == null || message.isEmpty) {
225
      return message;
226
    }
227
    final StringBuffer buffer = StringBuffer();
228 229 230 231 232 233 234 235
    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');
    }
236 237 238 239 240 241 242
    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;
  }

243
  @override
244
  String clearScreen() => supportsColor ? clear : '\n\n';
245

246
  @override
247
  set singleCharMode(bool value) {
248
    if (!_stdio.stdinHasTerminal) {
249 250
      return;
    }
251
    final io.Stdin stdin = _stdio.stdin as io.Stdin;
252 253 254 255 256 257 258
    // 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;
259 260 261
    }
  }

262 263 264
  @override
  bool get stdinHasTerminal => _stdio.stdinHasTerminal;

265 266
  Stream<String> _broadcastStdInString;

267
  @override
268
  Stream<String> get keystrokes {
269
    _broadcastStdInString ??= _stdio.stdin.transform<String>(const AsciiDecoder(allowInvalid: true)).asBroadcastStream();
270 271 272
    return _broadcastStdInString;
  }

273
  @override
274 275
  Future<String> promptForCharInput(
    List<String> acceptedCharacters, {
276
    @required Logger logger,
277
    String prompt,
278
    int defaultChoiceIndex,
279
    bool displayAcceptedCharacters = true,
280 281 282
  }) async {
    assert(acceptedCharacters != null);
    assert(acceptedCharacters.isNotEmpty);
283 284
    assert(prompt == null || prompt.isNotEmpty);
    assert(displayAcceptedCharacters != null);
285 286 287
    if (!usesTerminalUi) {
      throw StateError('cannot prompt without a terminal ui');
    }
288 289 290
    List<String> charactersToDisplay = acceptedCharacters;
    if (defaultChoiceIndex != null) {
      assert(defaultChoiceIndex >= 0 && defaultChoiceIndex < acceptedCharacters.length);
291
      charactersToDisplay = List<String>.of(charactersToDisplay);
292
      charactersToDisplay[defaultChoiceIndex] = bolden(charactersToDisplay[defaultChoiceIndex]);
293
      acceptedCharacters.add('');
294
    }
295 296
    String choice;
    singleCharMode = true;
297 298
    while (choice == null || choice.length > 1 || !acceptedCharacters.contains(choice)) {
      if (prompt != null) {
299
        logger.printStatus(prompt, emphasis: true, newline: false);
300
        if (displayAcceptedCharacters) {
301
          logger.printStatus(' [${charactersToDisplay.join("|")}]', newline: false);
302
        }
303
        // prompt ends with ': '
304
        logger.printStatus(': ', emphasis: true, newline: false);
305
      }
306
      choice = (await keystrokes.first).trim();
307
      logger.printStatus(choice);
308 309
    }
    singleCharMode = false;
310
    if (defaultChoiceIndex != null && choice == '') {
311
      choice = acceptedCharacters[defaultChoiceIndex];
312
    }
313 314 315
    return choice;
  }
}
316 317

class _TestTerminal implements Terminal {
318 319
  _TestTerminal({this.supportsColor = false});

320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348
  @override
  bool usesTerminalUi;

  @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
  set singleCharMode(bool value) { }

  @override
349
  final bool supportsColor;
350 351 352

  @override
  bool get supportsEmoji => false;
353 354 355

  @override
  bool get stdinHasTerminal => false;
356
}