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

import 'dart:async';

7 8
import 'package:meta/meta.dart';

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

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

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

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

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

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

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

58 59 60 61
  /// 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;

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

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

82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 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 132 133
/// The command line terminal, if available.
abstract class Terminal {
  factory Terminal.test() = _TestTerminal;

  /// 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);

  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`.
  ///
  /// 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 {
134 135 136 137
  AnsiTerminal({
    @required io.Stdio stdio,
    @required Platform platform,
  })
138 139 140 141 142 143
    : _stdio = stdio,
      _platform = platform;

  final io.Stdio _stdio;
  final Platform _platform;

144
  static const String bold = '\u001B[1m';
145 146 147
  static const String resetAll = '\u001B[0m';
  static const String resetColor = '\u001B[39m';
  static const String resetBold = '\u001B[22m';
148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166
  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,
  };
167

168 169
  static String colorCode(TerminalColor color) => _colorMap[color];

170
  @override
171
  bool get supportsColor => _platform?.stdoutSupportsAnsi ?? false;
172

173 174 175 176
  // 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
177
  @override
178 179 180 181 182 183
  bool get supportsEmoji => !_platform.isWindows
    || _platform.environment.containsKey('WT_SESSION');

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

185
  @override
186 187
  bool usesTerminalUi = false;

188
  @override
189
  String bolden(String message) {
190
    assert(message != null);
191
    if (!supportsColor || message.isEmpty) {
192
      return message;
193
    }
194
    final StringBuffer buffer = StringBuffer();
195 196 197 198 199 200 201
    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');
    }
202 203 204 205 206 207 208
    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;
  }

209
  @override
210 211
  String color(String message, TerminalColor color) {
    assert(message != null);
212
    if (!supportsColor || color == null || message.isEmpty) {
213
      return message;
214
    }
215
    final StringBuffer buffer = StringBuffer();
216 217 218 219 220 221 222 223
    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');
    }
224 225 226 227 228 229 230
    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;
  }

231
  @override
232
  String clearScreen() => supportsColor ? clear : '\n\n';
233

234
  @override
235
  set singleCharMode(bool value) {
236
    if (!_stdio.stdinHasTerminal) {
237 238
      return;
    }
239
    final io.Stdin stdin = _stdio.stdin as io.Stdin;
240 241 242 243 244 245 246
    // 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;
247 248 249 250 251
    }
  }

  Stream<String> _broadcastStdInString;

252
  @override
253
  Stream<String> get keystrokes {
254
    _broadcastStdInString ??= _stdio.stdin.transform<String>(const AsciiDecoder(allowInvalid: true)).asBroadcastStream();
255 256 257
    return _broadcastStdInString;
  }

258
  @override
259 260
  Future<String> promptForCharInput(
    List<String> acceptedCharacters, {
261
    @required Logger logger,
262
    String prompt,
263
    int defaultChoiceIndex,
264
    bool displayAcceptedCharacters = true,
265 266 267
  }) async {
    assert(acceptedCharacters != null);
    assert(acceptedCharacters.isNotEmpty);
268 269
    assert(prompt == null || prompt.isNotEmpty);
    assert(displayAcceptedCharacters != null);
270 271 272
    if (!usesTerminalUi) {
      throw StateError('cannot prompt without a terminal ui');
    }
273 274 275
    List<String> charactersToDisplay = acceptedCharacters;
    if (defaultChoiceIndex != null) {
      assert(defaultChoiceIndex >= 0 && defaultChoiceIndex < acceptedCharacters.length);
276
      charactersToDisplay = List<String>.of(charactersToDisplay);
277 278 279
      charactersToDisplay[defaultChoiceIndex] = bolden(charactersToDisplay[defaultChoiceIndex]);
      acceptedCharacters.add('\n');
    }
280 281
    String choice;
    singleCharMode = true;
282 283
    while (choice == null || choice.length > 1 || !acceptedCharacters.contains(choice)) {
      if (prompt != null) {
284
        logger.printStatus(prompt, emphasis: true, newline: false);
285
        if (displayAcceptedCharacters) {
286
          logger.printStatus(' [${charactersToDisplay.join("|")}]', newline: false);
287
        }
288
        logger.printStatus(': ', emphasis: true, newline: false);
289
      }
290
      choice = await keystrokes.first;
291
      logger.printStatus(choice);
292 293
    }
    singleCharMode = false;
294
    if (defaultChoiceIndex != null && choice == '\n') {
295
      choice = acceptedCharacters[defaultChoiceIndex];
296
    }
297 298 299
    return choice;
  }
}
300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335

class _TestTerminal implements Terminal {
  @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
  bool get supportsColor => false;

  @override
  bool get supportsEmoji => false;
}