runTerminal<S> function

Future<void> runTerminal<S>({
  1. required S initialState,
  2. required OnEventFn<S> onEvent,
  3. required RenderFn<S> render,
  4. ThemeData? theme,
  5. ThemeBuilder<S>? themeBuilder,
  6. RenderMode mode = const RenderMode.flow(),
  7. List<Stream<Event>> sources = const [],
  8. Duration? frameRate,
  9. Terminal? terminal,
  10. CommanderLogger logger = const SilentLogger(),
  11. ColorMode? colorMode,
  12. bool allowNonInteractive = false,
  13. bool enableMouse = true,
  14. bool exitOnCtrlC = true,
  15. Duration cursorQueryTimeout = const Duration(milliseconds: 500),
})

Implementation

Future<void> runTerminal<S>({
  required S initialState,
  required OnEventFn<S> onEvent,
  required RenderFn<S> render,
  ThemeData? theme,
  ThemeBuilder<S>? themeBuilder,
  RenderMode mode = const RenderMode.flow(),
  List<Stream<Event>> sources = const [],
  Duration? frameRate,
  Terminal? terminal,
  CommanderLogger logger = const SilentLogger(),
  ColorMode? colorMode,
  bool allowNonInteractive = false,
  bool enableMouse = true,
  bool exitOnCtrlC = true,
  Duration cursorQueryTimeout = const Duration(milliseconds: 500),
}) async {
  if (!allowNonInteractive) {
    final inIsTty = stdin.hasTerminal;
    final outIsTty = stdout.hasTerminal;
    if (!inIsTty || !outIsTty) {
      final sb = StringBuffer(
          'commander: TUI mode requires an interactive terminal.\n');
      sb.writeln(inIsTty
          ? '  stdin: TTY OK'
          : '  stdin: not a TTY (pipe or redirection)');
      sb.writeln(outIsTty
          ? '  stdout: TTY OK'
          : '  stdout: not a TTY (file redirection)');
      stderr.write(sb.toString());
      throw NotATerminalException(
        message: 'TUI mode requires interactive stdin/stdout.',
        stdinIsTty: inIsTty,
        stdoutIsTty: outIsTty,
      );
    }
  }

  final ownedTerminal = terminal == null;
  final term = terminal ?? Terminal();
  final detectedMode = colorMode ?? AnsiEncoder.detect();
  final encoder = AnsiEncoder(detectedMode);
  final renderer = Renderer(encoder);
  final focus = FocusController();
  final async_ = AsyncRegistry();
  final handle = RunHandle(focus: focus);
  final state = initialState;

  List<({Rect rect, Key key})> lastHitZones = const [];

  int yOffset = 0;
  int flowEffectiveHeight(FlowMode m) {
    final remaining = term.size.height - yOffset;
    final desired = m.height ?? remaining;
    final clamped = desired.clamp(1, term.size.height);
    final minH = m.minHeight ?? 1;
    return clamped < minH ? minH.clamp(1, term.size.height) : clamped;
  }

  Size currentSize = switch (mode) {
    AlternateScreenMode() => term.size,
    FullScreenMode() => term.size,
    FlowMode() => term.size, // placeholder, recomputed after setup
    InlineMode(:final height) => Size(term.size.width, height),
  };

  Buffer back = Buffer(currentSize);

  Future<void> drawFrame() async {
    // Drain pending microtasks so async flows (e.g. Chain) can advance to
    // their next state before we render. Avoids a 1-frame visual gap.
    await Future<void>(() {});

    final activeTheme = themeBuilder != null
        ? themeBuilder(state)
        : (theme ?? const ThemeData());

    final Size desired = switch (mode) {
      AlternateScreenMode() => term.size,
      FullScreenMode() => term.size,
      FlowMode m => Size(term.size.width, flowEffectiveHeight(m)),
      InlineMode(:final height) => Size(term.size.width, height),
    };
    if (desired != currentSize) {
      // Erase the old TUI region before resizing so stale rows from the
      // previous size don't linger in the terminal scrollback. Only applies to
      // flow / inline modes; alternate-screen modes always own the viewport.
      if (mode is FlowMode || mode is InlineMode) {
        final clampedY = yOffset.clamp(0, term.size.height);
        term.moveTo(0, clampedY);
        term.write(AnsiSequences.clearAfterCursor);
      }
      currentSize = desired;
      back = Buffer(currentSize);
      renderer.resize(currentSize);
      renderer.forceRepaint();
    } else {
      back.clear();
    }

    RenderContext makeCtx() {
      focus.resetFrame();
      async_.beginFrame();
      final c = RenderContext(
        buffer: back,
        area: Rect(0, 0, currentSize.width, currentSize.height),
        theme: activeTheme,
        focus: focus,
        async_: async_,
        logger: logger,
        requestRedraw: handle.requestRedraw,
        exit: handle.stop,
      );
      c.resetFrame();
      return c;
    }

    RenderContext ctx = makeCtx();
    render(ctx, state);
    ctx.flushOverlays();

    final isFlowAndRequiredMoreHight = mode is FlowMode &&
        mode.autoGrow &&
        ctx.maxDesiredHeight > currentSize.height;

    if (isFlowAndRequiredMoreHight) {
      final delta = ctx.maxDesiredHeight - currentSize.height;
      // Erase the old buffer area BEFORE scrolling so no stale content
      // ends up shifted into a visible row after scroll.
      term.moveTo(0, yOffset);
      term.write(AnsiSequences.clearAfterCursor);
      term.moveTo(0, yOffset + currentSize.height - 1);

      for (var i = 0; i < delta; i++) {
        term.write('\n');
      }

      await term.flush();

      final (_, newBottomRow) =
          await term.queryCursorPosition(timeout: cursorQueryTimeout);
      final newH = currentSize.height + delta;
      yOffset = (newBottomRow - newH + 1).clamp(0, term.size.height);
      currentSize = Size(currentSize.width, newH);
      back = Buffer(currentSize);
      renderer.resize(currentSize);
      renderer.yOffset = yOffset;
      renderer.forceRepaint();
      term.moveTo(0, yOffset);
      term.write(AnsiSequences.clearAfterCursor);
      ctx = makeCtx();

      render(ctx, state);

      ctx.flushOverlays();
    }

    final newTitle = ctx.pendingTitle;
    if (newTitle != null) {
      term.setTitle(newTitle);
    }

    focus.finalizeFrame();
    async_.endFrame();
    lastHitZones = ctx.hitZones;

    if (mode is AlternateScreenMode || mode is FullScreenMode) {
      term.moveTo(0, 0);
    } else if (mode is InlineMode || mode is FlowMode) {
      term.write('\r');
    }

    final out = renderer.paint(back);
    if (out.isNotEmpty) {
      term.write(out);
      await term.flush();
    }
  }

  Future<void> setup() async {
    term.enterRawMode();

    if (mode is AlternateScreenMode) {
      term.enterAlternateScreen();
    } else if (mode is FullScreenMode) {
      term.write(AnsiSequences.clear);
      term.write(AnsiSequences.home);
    } else if (mode is FlowMode) {
      await term.flush();

      final (_, row0) =
          await term.queryCursorPosition(timeout: cursorQueryTimeout);

      yOffset = row0.clamp(0, term.size.height);

      final height = flowEffectiveHeight(mode);
      for (int i = 0; i < height; i++) {
        term.write('\n');
      }

      term.write(AnsiSequences.moveUp(height));
      await term.flush();
      final (_, row1) =
          await term.queryCursorPosition(timeout: cursorQueryTimeout);
      yOffset = row1.clamp(0, term.size.height);
      currentSize = Size(term.size.width, height);
      term.moveTo(0, yOffset);
      term.write(AnsiSequences.clearAfterCursor);
    } else if (mode is InlineMode) {
      final h = mode.height;
      for (int i = 0; i < h; i++) {
        term.write('\n');
      }
      term.write(AnsiSequences.moveUp(h));

      await term.flush();
      final (_, row1) =
          await term.queryCursorPosition(timeout: cursorQueryTimeout);

      yOffset = row1.clamp(0, term.size.height);

      term.moveTo(0, yOffset);
      term.write(AnsiSequences.clearAfterCursor);
    }
    renderer.yOffset = yOffset;
    back = Buffer(currentSize);
    term.hideCursor();

    if (enableMouse) {
      term.enableMouse();
    }

    await term.flush();
  }

  int lastUsedRow() {
    for (var y = back.height - 1; y >= 0; y--) {
      for (var x = 0; x < back.width; x++) {
        final cell = back.get(x, y);
        if (cell.char != ' ' ||
            cell.style.bg != null ||
            cell.style.fg != null) {
          return y;
        }
      }
    }
    return -1;
  }

  Future<void> teardown() async {
    if (mode is FullScreenMode) {
      term.moveTo(0, term.size.height - 1);
      term.write('\n');
    } else if (mode is InlineMode || mode is FlowMode) {
      final used = lastUsedRow();
      final targetRow = used >= 0 ? yOffset + used + 1 : yOffset;
      // If the row after content would fall off-screen, scroll one line so
      // the cursor lands on a fresh row instead of overwriting content.
      // Otherwise just position on the row right after the last content
      // row — no extra blank line.
      if (targetRow >= term.size.height) {
        term.moveTo(0, term.size.height - 1);
        term.write('\n');
      } else {
        term.moveTo(0, targetRow);
      }
    }
    // When the caller passed their own terminal, only restore terminal
    // modes — leave the stdin subscription and event stream intact so
    // subsequent `runTerminal` calls on the same terminal still work.
    if (ownedTerminal) {
      await term.shutdown();
    } else {
      await term.restore();
    }
    async_.disposeAll();
  }

  try {
    await setup();
  } catch (e) {
    // If setup throws after partially mutating terminal state (e.g. raw mode
    // enabled before alternate screen failed), restore the terminal before
    // propagating to avoid leaving the user with a corrupted shell.
    try {
      await teardown();
    } catch (_) {}
    rethrow;
  }

  final controller = StreamController<Event>();
  async_.onCallbackError = (e, st) {
    logger.error('AsyncRegistry onResolved callback threw',
        error: e, stack: st);
  };
  async_.onResolved = (key) {
    handle.requestRedraw();
    if (!controller.isClosed) {
      controller.add(AsyncResolvedEvent(key));
    }
  };
  final subs = <StreamSubscription>[];
  subs.add(term.events.listen(controller.add));
  for (final s in sources) {
    subs.add(s.listen(controller.add));
  }

  Timer? frameTimer;
  var tickCount = 0;
  if (frameRate != null) {
    frameTimer = Timer.periodic(frameRate, (_) {
      controller.add(TickEvent(++tickCount));
    });
  }

  try {
    await drawFrame();
    await for (final event in controller.stream) {
      if (!handle.running) break;
      var consumed = false;

      try {
        if (event is KeyEvent) {
          if (exitOnCtrlC && event.ctrl && event.char == 'c') {
            handle.stop();
            break;
          }
          consumed = focus.dispatchKey(event);
          if (consumed) handle.requestRedraw();
          if (!consumed && event.key == NamedKey.tab) {
            if (event.shift) {
              focus.previous();
            } else {
              focus.next();
            }
            handle.requestRedraw();
            consumed = true;
          }
        } else if (event is MouseEvent && event.action == MouseAction.down) {
          for (final z in lastHitZones) {
            if (z.rect.contains(event.x, event.y)) {
              focus.focus(z.key);
              handle.requestRedraw();
              break;
            }
          }
        } else if (event is MouseEvent &&
            (event.action == MouseAction.scrollUp ||
                event.action == MouseAction.scrollDown)) {
          for (final z in lastHitZones) {
            if (z.rect.contains(event.x, event.y)) {
              focus.focus(z.key);
              final synthetic = KeyEvent(
                key: event.action == MouseAction.scrollUp
                    ? NamedKey.arrowUp
                    : NamedKey.arrowDown,
              );
              consumed = focus.dispatchKey(synthetic);
              if (consumed) handle.requestRedraw();
              break;
            }
          }
        }
      } catch (e, st) {
        logger.error('dispatch failed', error: e, stack: st);
      }

      if (!consumed) {
        try {
          await onEvent(state, event, handle);
        } catch (e, st) {
          logger.error('onEvent failed', error: e, stack: st);
        }
      }

      if (handle._redrawRequested ||
          event is ResizeEvent ||
          event is AsyncResolvedEvent ||
          event is TickEvent) {
        handle._redrawRequested = false;
        await drawFrame();
      }

      if (!handle.running) break;
    }
  } finally {
    frameTimer?.cancel();
    for (final s in subs) {
      await s.cancel();
    }
    await controller.close();
    await teardown();
  }
}