[et] Improve the logger for the ninja build, adds a spinner (#50952)

For https://github.com/flutter/flutter/issues/132807

The spinner is mostly copied from the flutter_tool.
diff --git a/tools/engine_tool/lib/src/commands/build_command.dart b/tools/engine_tool/lib/src/commands/build_command.dart
index c623a47..1561322 100644
--- a/tools/engine_tool/lib/src/commands/build_command.dart
+++ b/tools/engine_tool/lib/src/commands/build_command.dart
@@ -5,10 +5,9 @@
 import 'package:engine_build_configs/engine_build_configs.dart';
 
 import '../build_utils.dart';
-
+import '../logger.dart';
 import 'command.dart';
-
-const String _configFlag = 'config';
+import 'flags.dart';
 
 // TODO(johnmccutchan): Should BuildConfig be BuilderConfig and GlobalBuild be BuildConfig?
 // TODO(johnmccutchan): List all available build targets and allow the user
@@ -25,7 +24,7 @@
     builds = runnableBuilds(environment, configs);
     // Add options here that are common to all queries.
     argParser.addOption(
-      _configFlag,
+      configFlag,
       abbr: 'c',
       defaultsTo: 'host_debug',
       help: 'Specify the build config to use',
@@ -51,7 +50,7 @@
 
   @override
   Future<int> run() async {
-    final String configName = argResults![_configFlag] as String;
+    final String configName = argResults![configFlag] as String;
     final GlobalBuild? build = builds
         .where((GlobalBuild build) => build.name == configName)
         .firstOrNull;
@@ -60,28 +59,38 @@
       return 1;
     }
     final GlobalBuildRunner buildRunner = GlobalBuildRunner(
-        platform: environment.platform,
-        processRunner: environment.processRunner,
-        abi: environment.abi,
-        engineSrcDir: environment.engine.srcDir,
-        build: build);
+      platform: environment.platform,
+      processRunner: environment.processRunner,
+      abi: environment.abi,
+      engineSrcDir: environment.engine.srcDir,
+      build: build,
+      runTests: false,
+    );
+
+    Spinner? spinner;
     void handler(RunnerEvent event) {
       switch (event) {
         case RunnerStart():
-          environment.logger.info('$event: ${event.command.join(' ')}');
+          environment.logger.status('$event     ', newline: false);
+          spinner = environment.logger.startSpinner();
         case RunnerProgress(done: true):
+          spinner?.finish();
+          spinner = null;
           environment.logger.clearLine();
           environment.logger.status(event);
-        case RunnerProgress(done: false):
-          {
-            final String percent = '${event.percent.toStringAsFixed(1)}%';
-            final String fraction = '(${event.completed}/${event.total})';
-            final String prefix = '[${event.name}] $percent $fraction ';
-            final String what = event.what;
-            environment.logger.clearLine();
-            environment.logger.status('$prefix$what');
-          }
+        case RunnerProgress(done: false): {
+          spinner?.finish();
+          spinner = null;
+          final String percent = '${event.percent.toStringAsFixed(1)}%';
+          final String fraction = '(${event.completed}/${event.total})';
+          final String prefix = '[${event.name}] $percent $fraction ';
+          final String what = event.what;
+          environment.logger.clearLine();
+          environment.logger.status('$prefix$what', newline: false, fit: true);
+        }
         default:
+          spinner?.finish();
+          spinner = null;
           environment.logger.status(event);
       }
     }
diff --git a/tools/engine_tool/lib/src/commands/flags.dart b/tools/engine_tool/lib/src/commands/flags.dart
index d08514b..c31d18f 100644
--- a/tools/engine_tool/lib/src/commands/flags.dart
+++ b/tools/engine_tool/lib/src/commands/flags.dart
@@ -13,6 +13,8 @@
 // Keep this list alphabetized.
 const String allFlag = 'all';
 const String builderFlag = 'builder';
+const String configFlag = 'config';
 const String dryRunFlag = 'dry-run';
 const String quietFlag = 'quiet';
+const String runTestsFlag = 'run-tests';
 const String verboseFlag = 'verbose';
diff --git a/tools/engine_tool/lib/src/logger.dart b/tools/engine_tool/lib/src/logger.dart
index 3cb7c60..cbcb344 100644
--- a/tools/engine_tool/lib/src/logger.dart
+++ b/tools/engine_tool/lib/src/logger.dart
@@ -2,7 +2,7 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-import 'dart:async' show runZoned;
+import 'dart:async' show Timer, runZoned;
 import 'dart:io' as io show
   IOSink,
   stderr,
@@ -29,7 +29,7 @@
 /// which can be inspected by unit tetss.
 class Logger {
   /// Constructs a logger for use in the tool.
-  Logger() : _logger = log.Logger.detached('et') {
+  Logger() : _logger = log.Logger.detached('et'), _test = false {
     _logger.level = statusLevel;
     _logger.onRecord.listen(_handler);
     _setupIoSink(io.stderr);
@@ -38,7 +38,7 @@
 
   /// A logger for tests.
   @visibleForTesting
-  Logger.test() : _logger = log.Logger.detached('et') {
+  Logger.test() : _logger = log.Logger.detached('et'), _test = true {
     _logger.level = statusLevel;
     _logger.onRecord.listen((log.LogRecord r) => _testLogs.add(r));
   }
@@ -94,6 +94,9 @@
 
   final log.Logger _logger;
   final List<log.LogRecord> _testLogs = <log.LogRecord>[];
+  final bool _test;
+
+  Spinner? _status;
 
   /// Get the current logging level.
   log.Level get level => _logger.level;
@@ -104,39 +107,135 @@
   }
 
   /// Record a log message at level [Logger.error].
-  void error(Object? message, {int indent = 0, bool newline = true}) {
-    _emitLog(errorLevel, message, indent, newline);
+  void error(
+    Object? message, {
+    int indent = 0,
+    bool newline = true,
+    bool fit = false,
+  }) {
+    _emitLog(errorLevel, message, indent, newline, fit);
   }
 
   /// Record a log message at level [Logger.warning].
-  void warning(Object? message, {int indent = 0, bool newline = true}) {
-    _emitLog(warningLevel, message, indent, newline);
+  void warning(
+    Object? message, {
+    int indent = 0,
+    bool newline = true,
+    bool fit = false,
+  }) {
+    _emitLog(warningLevel, message, indent, newline, fit);
   }
 
   /// Record a log message at level [Logger.warning].
-  void status(Object? message, {int indent = 0, bool newline = true}) {
-    _emitLog(statusLevel, message, indent, newline);
+  void status(
+    Object? message, {
+    int indent = 0,
+    bool newline = true,
+    bool fit = false,
+  }) {
+    _emitLog(statusLevel, message, indent, newline, fit);
   }
 
   /// Record a log message at level [Logger.info].
-  void info(Object? message, {int indent = 0, bool newline = true}) {
-    _emitLog(infoLevel, message, indent, newline);
+  void info(
+    Object? message, {
+    int indent = 0,
+    bool newline = true,
+    bool fit = false,
+  }) {
+    _emitLog(infoLevel, message, indent, newline, fit);
   }
 
   /// Writes a number of spaces to stdout equal to the width of the terminal
   /// and emits a carriage return.
   void clearLine() {
-    if (!io.stdout.hasTerminal) {
+    if (!io.stdout.hasTerminal || _test) {
+      return;
+    }
+    _status?.pause();
+    _emitClearLine();
+    _status?.resume();
+  }
+
+  /// Starts printing a progress spinner.
+  Spinner startSpinner({
+    void Function()? onFinish,
+  }) {
+    void finishCallback() {
+      onFinish?.call();
+      _status = null;
+    }
+    _status = io.stdout.hasTerminal && !_test
+      ? FlutterSpinner(onFinish: finishCallback)
+      : Spinner(onFinish: finishCallback);
+    _status!.start();
+    return _status!;
+  }
+
+  static void _emitClearLine() {
+    if (io.stdout.supportsAnsiEscapes) {
+      // Go to start of the line and clear the line.
+      _ioSinkWrite(io.stdout, '\r\x1B[K');
       return;
     }
     final int width = io.stdout.terminalColumns;
+    final String backspaces = '\b' * width;
     final String spaces = ' ' * width;
-    _ioSinkWrite(io.stdout, '$spaces\r');
+    _ioSinkWrite(io.stdout, '$backspaces$spaces$backspaces');
   }
 
-  void _emitLog(log.Level level, Object? message, int indent, bool newline) {
-    final String m = '${' ' * indent}$message${newline ? '\n' : ''}';
+  void _emitLog(
+    log.Level level,
+    Object? message,
+    int indent,
+    bool newline,
+    bool fit,
+  ) {
+    String m = '${' ' * indent}$message${newline ? '\n' : ''}';
+    if (fit && io.stdout.hasTerminal) {
+      m = fitToWidth(m, io.stdout.terminalColumns);
+    }
+    _status?.pause();
     _logger.log(level, m);
+    _status?.resume();
+  }
+
+  /// Shorten a string such that its length will be `w` by replacing
+  /// enough characters in the middle with '...'. Trailing whitespace will not
+  /// be preserved or counted against 'w', but if the input ends with a newline,
+  /// then the output will end with a newline that is not counted against 'w'.
+  /// That is, if the input string ends with a newline, the output string will
+  /// have length up to (w + 1) and end with a newline.
+  ///
+  /// If w <= 0, the result will be the empty string.
+  /// If w <= 3, the result will be a string containing w '.'s.
+  /// If there are a different number of non-'...' characters to the right and
+  /// left of '...' in the result, then the right will have one more than the
+  /// left.
+  @visibleForTesting
+  static String fitToWidth(String s, int w) {
+    // Preserve a trailing newline if needed.
+    final String maybeNewline = s.endsWith('\n') ? '\n' : '';
+    if (w <= 0) {
+      return maybeNewline;
+    }
+    if (w <= 3) {
+      return '${'.' * w}$maybeNewline';
+    }
+
+    // But remove trailing whitespace before removing the middle of the string.
+    s = s.trimRight();
+    if (s.length <= w) {
+      return '$s$maybeNewline';
+    }
+
+    // remove (s.length + 3 - w) characters from the middle of `s` and
+    // replace them with '...'.
+    final int diff = (s.length + 3) - w;
+    final int leftEnd = (s.length - diff) ~/ 2;
+    final int rightStart = (s.length + diff) ~/ 2;
+    s = s.replaceRange(leftEnd, rightStart, '...');
+    return s + maybeNewline;
   }
 
   /// In a [Logger] constructed by [Logger.test], this list will contain all of
@@ -144,3 +243,98 @@
   @visibleForTesting
   List<log.LogRecord> get testLogs => _testLogs;
 }
+
+
+/// A base class for progress spinners, and a no-op implementation that prints
+/// nothing.
+class Spinner {
+  /// Creates a progress spinner. If supplied the `onDone` callback will be
+  /// called when `finish()` is called.
+  Spinner({
+    this.onFinish,
+  });
+
+  /// The callback called when `finish()` is called.
+  final void Function()? onFinish;
+
+  /// Starts the spinner animation.
+  void start() {}
+
+  /// Pauses the spinner animation. That is, this call causes printing to the
+  /// terminal to stop.
+  void pause() {}
+
+  /// Resumes the animation at the same from where `pause()` was called.
+  void resume() {}
+
+  /// Ends an animation, calling the `onFinish` callback if one was provided.
+  void finish() {
+    onFinish?.call();
+  }
+}
+
+/// A [Spinner] implementation that prints an animated "Flutter" banner.
+class FlutterSpinner extends Spinner {
+  // ignore: public_member_api_docs
+  FlutterSpinner({
+    super.onFinish,
+  });
+
+  @visibleForTesting
+  /// The frames of the animation.
+  static const String frames = '⢸⡯⠭⠅⢸⣇⣀⡀⢸⣇⣸⡇⠈⢹⡏⠁⠈⢹⡏⠁⢸⣯⣭⡅⢸⡯⢕⡂⠀⠀';
+
+  static final List<String> _flutterAnimation = frames
+      .runes
+      .map<String>((int scalar) => String.fromCharCode(scalar))
+      .toList();
+
+  Timer? _timer;
+  int _ticks = 0;
+  int _lastAnimationFrameLength = 0;
+
+  @override
+  void start() {
+    _startSpinner();
+  }
+
+  void _startSpinner() {
+    _timer = Timer.periodic(const Duration(milliseconds: 100), _callback);
+    _callback(_timer!);
+  }
+
+  void _callback(Timer timer) {
+    Logger._ioSinkWrite(io.stdout, '\b' * _lastAnimationFrameLength);
+    _ticks += 1;
+    final String newFrame = _currentAnimationFrame;
+    _lastAnimationFrameLength = newFrame.runes.length;
+    Logger._ioSinkWrite(io.stdout, newFrame);
+  }
+
+  String get _currentAnimationFrame {
+    return _flutterAnimation[_ticks % _flutterAnimation.length];
+  }
+
+  @override
+  void pause() {
+    Logger._emitClearLine();
+    _lastAnimationFrameLength = 0;
+    _timer?.cancel();
+  }
+
+  @override
+  void resume() {
+    _startSpinner();
+  }
+
+  @override
+  void finish() {
+    _timer?.cancel();
+    _timer = null;
+    Logger._emitClearLine();
+    _lastAnimationFrameLength = 0;
+    if (onFinish != null) {
+      onFinish!();
+    }
+  }
+}
diff --git a/tools/engine_tool/test/build_command_test.dart b/tools/engine_tool/test/build_command_test.dart
index c34e854..b55f7d8 100644
--- a/tools/engine_tool/test/build_command_test.dart
+++ b/tools/engine_tool/test/build_command_test.dart
@@ -119,4 +119,39 @@
     expect(runHistory[1].length, greaterThanOrEqualTo(1));
     expect(runHistory[1][0], contains('ninja'));
   });
+
+  test('build command invokes generator', () async {
+    final Logger logger = Logger.test();
+    final (Environment env, List<List<String>> runHistory) = linuxEnv(logger);
+    final ToolCommandRunner runner = ToolCommandRunner(
+      environment: env,
+      configs: configs,
+    );
+    final int result = await runner.run(<String>[
+      'build',
+      '--config',
+      'build_name',
+    ]);
+    expect(result, equals(0));
+    expect(runHistory.length, greaterThanOrEqualTo(3));
+    expect(runHistory[2].length, greaterThanOrEqualTo(2));
+    expect(runHistory[2][0], contains('python3'));
+    expect(runHistory[2][1], contains('gen/script.py'));
+  });
+
+  test('build command does not invoke tests', () async {
+    final Logger logger = Logger.test();
+    final (Environment env, List<List<String>> runHistory) = linuxEnv(logger);
+    final ToolCommandRunner runner = ToolCommandRunner(
+      environment: env,
+      configs: configs,
+    );
+    final int result = await runner.run(<String>[
+      'build',
+      '--config',
+      'build_name',
+    ]);
+    expect(result, equals(0));
+    expect(runHistory.length, lessThanOrEqualTo(3));
+  });
 }
diff --git a/tools/engine_tool/test/logger_test.dart b/tools/engine_tool/test/logger_test.dart
index 5170f4d..86e739d 100644
--- a/tools/engine_tool/test/logger_test.dart
+++ b/tools/engine_tool/test/logger_test.dart
@@ -78,4 +78,48 @@
     logger.info('info', newline: false);
     expect(stringsFromLogs(logger.testLogs), equals(<String>['info']));
   });
+
+  test('fitToWidth', () {
+    expect(Logger.fitToWidth('hello', 0), equals(''));
+    expect(Logger.fitToWidth('hello', 1), equals('.'));
+    expect(Logger.fitToWidth('hello', 2), equals('..'));
+    expect(Logger.fitToWidth('hello', 3), equals('...'));
+    expect(Logger.fitToWidth('hello', 4), equals('...o'));
+    expect(Logger.fitToWidth('hello', 5), equals('hello'));
+
+    expect(Logger.fitToWidth('foobar', 5), equals('f...r'));
+
+    expect(Logger.fitToWidth('foobarb', 5), equals('f...b'));
+    expect(Logger.fitToWidth('foobarb', 6), equals('f...rb'));
+
+    expect(Logger.fitToWidth('foobarba', 5), equals('f...a'));
+    expect(Logger.fitToWidth('foobarba', 6), equals('f...ba'));
+    expect(Logger.fitToWidth('foobarba', 7), equals('fo...ba'));
+
+    expect(Logger.fitToWidth('hello\n', 0), equals('\n'));
+    expect(Logger.fitToWidth('hello\n', 1), equals('.\n'));
+    expect(Logger.fitToWidth('hello\n', 2), equals('..\n'));
+    expect(Logger.fitToWidth('hello\n', 3), equals('...\n'));
+    expect(Logger.fitToWidth('hello\n', 4), equals('...o\n'));
+    expect(Logger.fitToWidth('hello\n', 5), equals('hello\n'));
+
+    expect(Logger.fitToWidth('foobar\n', 5), equals('f...r\n'));
+
+    expect(Logger.fitToWidth('foobarb\n', 5), equals('f...b\n'));
+    expect(Logger.fitToWidth('foobarb\n', 6), equals('f...rb\n'));
+
+    expect(Logger.fitToWidth('foobarba\n', 5), equals('f...a\n'));
+    expect(Logger.fitToWidth('foobarba\n', 6), equals('f...ba\n'));
+    expect(Logger.fitToWidth('foobarba\n', 7), equals('fo...ba\n'));
+  });
+
+  test('Spinner calls onFinish callback', () {
+    final Logger logger = Logger.test();
+    bool called = false;
+    final Spinner spinner = logger.startSpinner(
+      onFinish: () { called = true; },
+    );
+    spinner.finish();
+    expect(called, isTrue);
+  });
 }