terminal.dart 14.3 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
import '../convert.dart';
6
import '../features.dart';
7
import 'io.dart' as io;
8
import 'logger.dart';
9
import 'platform.dart';
10

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

21 22 23 24
/// A class that contains the context settings for command text output to the
/// console.
class OutputPreferences {
  OutputPreferences({
25 26 27 28
    bool? wrapText,
    int? wrapColumn,
    bool? showColor,
    io.Stdio? stdio,
29
  }) : _stdio = stdio,
30
       wrapText = wrapText ?? stdio?.hasTerminal ?? false,
31
       _overrideWrapColumn = wrapColumn,
32
       showColor = showColor ?? false;
33

34
  /// A version of this class for use in tests.
35
  OutputPreferences.test({this.wrapText = false, int wrapColumn = kDefaultTerminalColumns, this.showColor = false})
36 37
    : _overrideWrapColumn = wrapColumn, _stdio = null;

38
  final io.Stdio? _stdio;
39

40 41 42 43 44
  /// 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
45
  /// stdio.
46 47
  final bool wrapText;

48 49 50 51
  /// 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;

52 53 54 55
  /// 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.
56
  final int? _overrideWrapColumn;
57
  int get wrapColumn {
58
    return _overrideWrapColumn ?? _stdio?.terminalColumns ?? kDefaultTerminalColumns;
59
  }
60 61 62 63 64 65 66 67 68 69 70 71

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

72
/// The command line terminal, if available.
73
// TODO(ianh): merge this with AnsiTerminal, the abstraction isn't giving us anything.
74
abstract class Terminal {
75 76 77
  /// Create a new test [Terminal].
  ///
  /// If not specified, [supportsColor] defaults to `false`.
78
  factory Terminal.test({bool supportsColor, bool supportsEmoji}) = _TestTerminal;
79 80

  /// Whether the current terminal supports color escape codes.
81 82 83
  ///
  /// Check [isCliAnimationEnabled] as well before using `\r` or ANSI sequences
  /// to perform animations.
84 85
  bool get supportsColor;

86
  /// Whether animations should be used in the output.
87 88
  bool get isCliAnimationEnabled;

89 90 91
  /// Configures isCliAnimationEnabled based on a [FeatureFlags] object.
  void applyFeatureFlags(FeatureFlags flags);

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

95 96 97 98
  /// When we have a choice of styles (e.g. animated spinners), this selects the
  /// style to use.
  int get preferredStyle;

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

105 106 107 108 109 110 111 112
  /// 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;

113 114 115 116 117 118
  /// Warning mark to use in stdout or stderr.
  String get warningMark;

  /// Success mark to use in stdout.
  String get successMark;

119 120 121 122 123 124
  String bolden(String message);

  String color(String message, TerminalColor color);

  String clearScreen();

125
  bool get singleCharMode;
126 127 128 129
  set singleCharMode(bool value);

  /// Return keystrokes from the console.
  ///
130 131 132
  /// This is a single-subscription stream. This stream may be closed before
  /// the application exits.
  ///
133 134 135 136 137 138 139 140 141 142 143 144 145 146 147
  /// 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`.
  ///
148 149 150
  /// The accepted characters must be a String with a length of 1, excluding any
  /// whitespace characters such as `\t`, `\n`, or ` `.
  ///
151 152 153
  /// If [usesTerminalUi] is false, throws a [StateError].
  Future<String> promptForCharInput(
    List<String> acceptedCharacters, {
154 155 156
    required Logger logger,
    String? prompt,
    int? defaultChoiceIndex,
157 158 159 160 161
    bool displayAcceptedCharacters = true,
  });
}

class AnsiTerminal implements Terminal {
162
  AnsiTerminal({
163 164
    required io.Stdio stdio,
    required Platform platform,
165
    DateTime? now, // Time used to determine preferredStyle. Defaults to 0001-01-01 00:00.
166
    bool defaultCliAnimationEnabled = true,
167
  })
168
    : _stdio = stdio,
169
      _platform = platform,
170 171
      _now = now ?? DateTime(1),
      _isCliAnimationEnabled = defaultCliAnimationEnabled;
172 173 174

  final io.Stdio _stdio;
  final Platform _platform;
175
  final DateTime _now;
176

177
  static const String bold = '\u001B[1m';
178 179 180
  static const String resetAll = '\u001B[0m';
  static const String resetColor = '\u001B[39m';
  static const String resetBold = '\u001B[22m';
181 182 183 184 185 186 187 188
  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';
189
  static const String grey = '\u001b[90m';
190

191 192 193 194 195 196 197 198 199
  // Moves cursor up 1 line.
  static const String cursorUpLineCode = '\u001b[1A';

  // Moves cursor to the beginning of the line.
  static const String cursorBeginningOfLineCode = '\u001b[1G';

  // Clear the entire line, cursor position does not change.
  static const String clearEntireLineCode = '\u001b[2K';

200 201 202 203 204 205 206 207 208
  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,
  };
209

210
  static String colorCode(TerminalColor color) => _colorMap[color]!;
211

212
  @override
213
  bool get supportsColor => _platform.stdoutSupportsAnsi;
214

215
  @override
216 217 218 219 220 221 222 223
  bool get isCliAnimationEnabled => _isCliAnimationEnabled;

  bool _isCliAnimationEnabled;

  @override
  void applyFeatureFlags(FeatureFlags flags) {
    _isCliAnimationEnabled = flags.isCliAnimationEnabled;
  }
224

225 226 227 228
  // 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
229
  @override
230 231 232
  bool get supportsEmoji => !_platform.isWindows
    || _platform.environment.containsKey('WT_SESSION');

233 234 235 236 237 238 239 240 241
  @override
  int get preferredStyle {
    const int workdays = DateTime.friday;
    if (_now.weekday <= workdays) {
      return _now.weekday - 1;
    }
    return _now.hour + workdays;
  }

242 243 244
  final RegExp _boldControls = RegExp(
    '(${RegExp.escape(resetBold)}|${RegExp.escape(bold)})',
  );
245

246
  @override
247 248
  bool usesTerminalUi = false;

249 250 251 252 253 254 255 256 257 258
  @override
  String get warningMark {
    return bolden(color('[!]', TerminalColor.red));
  }

  @override
  String get successMark {
    return bolden(color('✓', TerminalColor.green));
  }

259
  @override
260
  String bolden(String message) {
261
    if (!supportsColor || message.isEmpty) {
262
      return message;
263
    }
264
    final StringBuffer buffer = StringBuffer();
265 266 267 268 269 270 271
    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');
    }
272 273 274 275 276 277 278
    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;
  }

279
  @override
280
  String color(String message, TerminalColor color) {
281
    if (!supportsColor || message.isEmpty) {
282
      return message;
283
    }
284
    final StringBuffer buffer = StringBuffer();
285
    final String colorCodes = _colorMap[color]!;
286 287 288 289 290 291 292
    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');
    }
293 294 295 296 297 298 299
    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;
  }

300
  @override
301
  String clearScreen() => supportsColor && isCliAnimationEnabled ? clear : '\n\n';
302

303 304 305 306 307
  /// 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) {
308
    if (!supportsColor || !isCliAnimationEnabled) {
309 310 311 312 313 314 315
      return '';
    }
    return cursorBeginningOfLineCode +
        clearEntireLineCode +
        (cursorUpLineCode + clearEntireLineCode) * (numberOfLines - 1);
  }

316 317 318 319 320 321 322 323
  @override
  bool get singleCharMode {
    if (!_stdio.stdinHasTerminal) {
      return false;
    }
    final io.Stdin stdin = _stdio.stdin as io.Stdin;
    return stdin.lineMode && stdin.echoMode;
  }
324
  @override
325
  set singleCharMode(bool value) {
326
    if (!_stdio.stdinHasTerminal) {
327 328
      return;
    }
329
    final io.Stdin stdin = _stdio.stdin as io.Stdin;
330 331 332 333 334 335 336 337 338 339 340 341 342

    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.
343 344 345
    }
  }

346 347 348
  @override
  bool get stdinHasTerminal => _stdio.stdinHasTerminal;

349
  Stream<String>? _broadcastStdInString;
350

351
  @override
352
  Stream<String> get keystrokes {
353
    return _broadcastStdInString ??= _stdio.stdin.transform<String>(const AsciiDecoder(allowInvalid: true)).asBroadcastStream();
354 355
  }

356
  @override
357 358
  Future<String> promptForCharInput(
    List<String> acceptedCharacters, {
359 360 361
    required Logger logger,
    String? prompt,
    int? defaultChoiceIndex,
362
    bool displayAcceptedCharacters = true,
363 364
  }) async {
    assert(acceptedCharacters.isNotEmpty);
365
    assert(prompt == null || prompt.isNotEmpty);
366 367 368
    if (!usesTerminalUi) {
      throw StateError('cannot prompt without a terminal ui');
    }
369 370 371
    List<String> charactersToDisplay = acceptedCharacters;
    if (defaultChoiceIndex != null) {
      assert(defaultChoiceIndex >= 0 && defaultChoiceIndex < acceptedCharacters.length);
372
      charactersToDisplay = List<String>.of(charactersToDisplay);
373
      charactersToDisplay[defaultChoiceIndex] = bolden(charactersToDisplay[defaultChoiceIndex]);
374
      acceptedCharacters.add('');
375
    }
376
    String? choice;
377
    singleCharMode = true;
378 379
    while (choice == null || choice.length > 1 || !acceptedCharacters.contains(choice)) {
      if (prompt != null) {
380
        logger.printStatus(prompt, emphasis: true, newline: false);
381
        if (displayAcceptedCharacters) {
382
          logger.printStatus(' [${charactersToDisplay.join("|")}]', newline: false);
383
        }
384
        // prompt ends with ': '
385
        logger.printStatus(': ', emphasis: true, newline: false);
386
      }
387
      choice = (await keystrokes.first).trim();
388
      logger.printStatus(choice);
389 390
    }
    singleCharMode = false;
391
    if (defaultChoiceIndex != null && choice == '') {
392
      choice = acceptedCharacters[defaultChoiceIndex];
393
    }
394 395 396
    return choice;
  }
}
397 398

class _TestTerminal implements Terminal {
399
  _TestTerminal({this.supportsColor = false, this.supportsEmoji = false});
400

401
  @override
402
  bool usesTerminalUi = false;
403 404 405 406 407 408 409 410 411 412 413 414 415 416 417

  @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, {
418 419 420
    required Logger logger,
    String? prompt,
    int? defaultChoiceIndex,
421 422 423 424 425
    bool displayAcceptedCharacters = true,
  }) {
    throw UnsupportedError('promptForCharInput not supported in the test terminal.');
  }

426 427
  @override
  bool get singleCharMode => false;
428 429 430 431
  @override
  set singleCharMode(bool value) { }

  @override
432
  final bool supportsColor;
433

434
  @override
435 436 437 438 439 440 441 442
  bool get isCliAnimationEnabled => supportsColor && _isCliAnimationEnabled;

  bool _isCliAnimationEnabled = true;

  @override
  void applyFeatureFlags(FeatureFlags flags) {
    _isCliAnimationEnabled = flags.isCliAnimationEnabled;
  }
443

444
  @override
445
  final bool supportsEmoji;
446

447 448 449
  @override
  int get preferredStyle => 0;

450 451
  @override
  bool get stdinHasTerminal => false;
452 453 454 455 456 457

  @override
  String get successMark => '✓';

  @override
  String get warningMark => '[!]';
458
}