ghostty_vte_flutter 0.1.1-dev.1 copy "ghostty_vte_flutter: ^0.1.1-dev.1" to clipboard
ghostty_vte_flutter: ^0.1.1-dev.1 copied to clipboard

Flutter terminal UI widgets powered by Ghostty's VT engine. Provides GhosttyTerminalView, GhosttyTerminalController, and automatic wasm initialisation for web targets.

example/lib/main.dart

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:ghostty_vte_flutter/ghostty_vte_flutter.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await initializeGhosttyVteWeb();
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key, this.controller, this.autoStart = true});

  final GhosttyTerminalController? controller;
  final bool autoStart;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Ghostty VT Studio',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xFF0E8F74),
          brightness: Brightness.dark,
        ),
        scaffoldBackgroundColor: const Color(0xFF071019),
        useMaterial3: true,
      ),
      home: TerminalStudioPage(controller: controller, autoStart: autoStart),
    );
  }
}

class TerminalStudioPage extends StatefulWidget {
  const TerminalStudioPage({super.key, this.controller, this.autoStart = true});

  final GhosttyTerminalController? controller;
  final bool autoStart;

  @override
  State<TerminalStudioPage> createState() => _TerminalStudioPageState();
}

enum _DemoMouseTrackingProfile { disabled, x10, normal, button, any }

enum _DemoMouseFormatProfile { x10, utf8, sgr, urxvt, sgrPixels }

class _TerminalStudioPageState extends State<TerminalStudioPage>
    with SingleTickerProviderStateMixin {
  late final GhosttyTerminalController _terminal;
  late final bool _ownsTerminal;
  GhosttyTerminalShellProfile _selectedShellProfile =
      GhosttyTerminalShellProfile.auto;
  String _activeShellLabel = 'not started';
  String _activeShellCommand = '(not started)';
  Map<String, String> _activeShellEnvironment = const <String, String>{};
  final TextEditingController _commandController = TextEditingController(
    text:
        'printf "\\e]2;Ghostty VT Studio\\a\\e[32mreal terminal ready\\e[0m\\n"',
  );
  final TextEditingController _oscController = TextEditingController(
    text: '2;Ghostty VT Studio',
  );
  final TextEditingController _sgrController = TextEditingController(
    text: '1;38;2;14;143;116;4',
  );
  final TextEditingController _utf8Controller = TextEditingController(
    text: 'c',
  );
  final TextEditingController _codepointController = TextEditingController(
    text: '0x63',
  );
  final TextEditingController _fontFamilyController = TextEditingController();

  // Clipboard/selection/hyperlink state
  String _selectionText = '';
  String _lastCopiedText = '';
  String _lastHyperlink = '';
  int _pasteRequestCount = 0;

  // onWritePty activity log
  final List<String> _writePtyLog = <String>[];
  int _writePtyTotalBytes = 0;

  // Effect callback activity tracking
  int _bellCount = 0;
  int _titleChangedCount = 0;
  String _lastTitle = '';
  int _sizeQueryCount = 0;
  int _colorSchemeQueryCount = 0;
  int _deviceAttributesQueryCount = 0;
  int _enquiryCount = 0;
  int _xtversionCount = 0;
  final List<String> _effectLog = <String>[];

  static const List<_ActionOption> _actions = <_ActionOption>[
    _ActionOption('Press', GhosttyKeyAction.GHOSTTY_KEY_ACTION_PRESS),
    _ActionOption('Repeat', GhosttyKeyAction.GHOSTTY_KEY_ACTION_REPEAT),
    _ActionOption('Release', GhosttyKeyAction.GHOSTTY_KEY_ACTION_RELEASE),
  ];

  static const List<_KeyOption> _keys = <_KeyOption>[
    _KeyOption('C', GhosttyKey.GHOSTTY_KEY_C),
    _KeyOption('Enter', GhosttyKey.GHOSTTY_KEY_ENTER),
    _KeyOption('Tab', GhosttyKey.GHOSTTY_KEY_TAB),
    _KeyOption('Up', GhosttyKey.GHOSTTY_KEY_ARROW_UP),
    _KeyOption('Down', GhosttyKey.GHOSTTY_KEY_ARROW_DOWN),
    _KeyOption('Left', GhosttyKey.GHOSTTY_KEY_ARROW_LEFT),
    _KeyOption('Right', GhosttyKey.GHOSTTY_KEY_ARROW_RIGHT),
    _KeyOption('F1', GhosttyKey.GHOSTTY_KEY_F1),
    _KeyOption('F2', GhosttyKey.GHOSTTY_KEY_F2),
  ];

  static const List<_ModOption> _mods = <_ModOption>[
    _ModOption('Shift', GhosttyModsMask.shift),
    _ModOption('Ctrl', GhosttyModsMask.ctrl),
    _ModOption('Alt', GhosttyModsMask.alt),
    _ModOption('Super', GhosttyModsMask.superKey),
  ];

  static const List<_RendererModeOption> _rendererModes = <_RendererModeOption>[
    _RendererModeOption(
      label: 'Formatter Paint',
      value: GhosttyTerminalRendererMode.formatter,
      enabledOnWeb: true,
    ),
    _RendererModeOption(
      label: 'Render Paint',
      value: GhosttyTerminalRendererMode.renderState,
      enabledOnWeb: false,
      unavailableReason: 'Native render requires non-web platforms.',
    ),
  ];

  late final TabController _tabs = TabController(length: 5, vsync: this);

  final List<String> _activity = <String>[];
  GhosttyKeyAction _selectedAction = GhosttyKeyAction.GHOSTTY_KEY_ACTION_PRESS;
  GhosttyKey _selectedKey = GhosttyKey.GHOSTTY_KEY_C;
  final Set<int> _selectedMods = <int>{GhosttyModsMask.ctrl};
  bool _composing = false;
  bool _formatterPalette = false;
  bool _formatterModes = false;
  bool _formatterScrollingRegion = false;
  bool _formatterTabstops = false;
  bool _formatterPwd = false;
  bool _formatterKeyboard = false;
  bool _formatterCursor = false;
  bool _formatterStyle = false;
  bool _formatterHyperlink = false;
  bool _formatterProtection = false;
  bool _formatterKittyKeyboard = false;
  bool _formatterCharsets = false;
  Uint8List _encodedBytes = Uint8List(0);
  String _plainSnapshot = '';
  String _vtSnapshot = '';
  String _htmlSnapshot = '';
  bool _pasteSafe = true;
  double _cellWidthScale = 1;
  GhosttyTerminalRendererMode _renderer = GhosttyTerminalRendererMode.formatter;
  GhosttyTerminalInteractionPolicy _interactionPolicy =
      GhosttyTerminalInteractionPolicy.auto;
  _DemoMouseTrackingProfile _mouseTrackingProfile =
      _DemoMouseTrackingProfile.disabled;
  _DemoMouseFormatProfile _mouseFormatProfile = _DemoMouseFormatProfile.sgr;
  bool _mouseFocusEvents = false;
  bool _mouseAltScroll = false;
  VtOscCommand? _oscCommand;
  String? _oscError;
  List<VtSgrAttributeData> _sgrAttributes = <VtSgrAttributeData>[];
  String? _sgrError;

  GhosttyTerminalShellLaunch? get _controllerLaunch =>
      _terminal.activeShellLaunch;

  String get _currentShellLabel =>
      _controllerLaunch?.label ?? _activeShellLabel;

  String get _currentShellCommand =>
      _controllerLaunch?.commandLine ?? _activeShellCommand;

  Map<String, String> get _currentShellEnvironment =>
      _controllerLaunch?.environment ?? _activeShellEnvironment;

  @override
  void initState() {
    super.initState();
    _terminal = widget.controller ?? GhosttyTerminalController();
    _ownsTerminal = widget.controller == null;
    _terminal.addListener(_onTerminalChanged);
    _terminal.onWritePtyData = _onWritePtyData;
    _terminal.onBellData = _onBell;
    _terminal.onTitleChangedData = _onTitleChanged;
    _terminal.onSizeQueryData = _onSizeQuery;
    _terminal.onColorSchemeQueryData = _onColorSchemeQuery;
    _terminal.onDeviceAttributesQueryData = _onDeviceAttributesQuery;
    _terminal.onEnquiryData = _onEnquiry;
    _terminal.onXtversionData = _onXtversion;
    if (widget.autoStart) {
      _bootstrap();
    } else {
      _recomputeInspectorState(
        addLog: false,
        refreshSnapshots: false,
        skipNativeChecks: true,
      );
    }
  }

  @override
  void dispose() {
    _terminal.onWritePtyData = null;
    _terminal.onBellData = null;
    _terminal.onTitleChangedData = null;
    _terminal.onSizeQueryData = null;
    _terminal.onColorSchemeQueryData = null;
    _terminal.onDeviceAttributesQueryData = null;
    _terminal.onEnquiryData = null;
    _terminal.onXtversionData = null;
    _terminal.removeListener(_onTerminalChanged);
    if (_ownsTerminal) {
      _terminal.dispose();
    }
    _tabs.dispose();
    _commandController.dispose();
    _oscController.dispose();
    _sgrController.dispose();
    _utf8Controller.dispose();
    _codepointController.dispose();
    _fontFamilyController.dispose();
    super.dispose();
  }

  Future<void> _bootstrap() async {
    final launch = await _startDemoShell();
    _activeShellLabel = launch.label;
    _activeShellCommand = launch.commandLine;
    _activeShellEnvironment = launch.environment ?? const <String, String>{};
    if (kIsWeb) {
      _terminal.appendDebugOutput(
        '\x1b]2;Ghostty VT Studio\x07'
        '\x1b[32mweb demo backend attached\x1b[0m\n'
        '\x1b[90mType into the terminal and inspect formatter outputs on the right.\x1b[0m\n',
      );
    }
    _appendLog('Terminal session started (${launch.label}).');
    _recomputeInspectorState(addLog: false);
    if (mounted) {
      setState(() {});
    }
  }

  Future<_DemoShellLaunch> _startDemoShell() async {
    if (kIsWeb) {
      await _terminal.start();
      return const _DemoShellLaunch(
        label: 'web transport demo',
        commandLine: 'web transport demo',
      );
    }

    final launch = await _terminal.startShellProfile(
      profile: _selectedShellProfile,
      platformEnvironment: ghosttyTerminalPlatformEnvironment(),
    );
    if (launch != null) {
      return _DemoShellLaunch(
        label: launch.label,
        commandLine: launch.commandLine,
        environment: launch.environment,
      );
    }

    final fallbackEnvironment = ghosttyTerminalShellEnvironment(
      platformEnvironment: ghosttyTerminalPlatformEnvironment(),
      overrides: const <String, String>{'TERM': 'xterm-256color'},
    );
    await _terminal.start(environment: fallbackEnvironment);
    return _DemoShellLaunch(
      label: 'default shell fallback',
      commandLine: '(default shell)',
      environment: fallbackEnvironment,
    );
  }

  Future<void> _selectShellProfile(GhosttyTerminalShellProfile profile) async {
    if (_selectedShellProfile == profile) {
      return;
    }
    setState(() {
      _selectedShellProfile = profile;
    });
    if (_terminal.isRunning) {
      await _restartTerminal();
    }
  }

  bool _safeTerminalMode(VtMode mode) {
    try {
      return _terminal.terminal.getMode(mode);
    } catch (_) {
      return false;
    }
  }

  void _applyMouseProtocolModes() {
    try {
      final terminal = _terminal.terminal;
      terminal.setMode(VtModes.x10Mouse, false);
      terminal.setMode(VtModes.normalMouse, false);
      terminal.setMode(VtModes.buttonMouse, false);
      terminal.setMode(VtModes.anyMouse, false);
      terminal.setMode(VtModes.utf8Mouse, false);
      terminal.setMode(VtModes.sgrMouse, false);
      terminal.setMode(VtModes.urxvtMouse, false);
      terminal.setMode(VtModes.sgrPixelsMouse, false);

      switch (_mouseTrackingProfile) {
        case _DemoMouseTrackingProfile.disabled:
          break;
        case _DemoMouseTrackingProfile.x10:
          terminal.setMode(VtModes.x10Mouse, true);
        case _DemoMouseTrackingProfile.normal:
          terminal.setMode(VtModes.normalMouse, true);
        case _DemoMouseTrackingProfile.button:
          terminal.setMode(VtModes.buttonMouse, true);
        case _DemoMouseTrackingProfile.any:
          terminal.setMode(VtModes.anyMouse, true);
      }

      switch (_mouseFormatProfile) {
        case _DemoMouseFormatProfile.x10:
          break;
        case _DemoMouseFormatProfile.utf8:
          terminal.setMode(VtModes.utf8Mouse, true);
        case _DemoMouseFormatProfile.sgr:
          terminal.setMode(VtModes.sgrMouse, true);
        case _DemoMouseFormatProfile.urxvt:
          terminal.setMode(VtModes.urxvtMouse, true);
        case _DemoMouseFormatProfile.sgrPixels:
          terminal.setMode(VtModes.sgrPixelsMouse, true);
      }

      terminal.setMode(VtModes.focusEvent, _mouseFocusEvents);
      terminal.setMode(VtModes.altScroll, _mouseAltScroll);
    } catch (_) {}
    _recomputeInspectorState(addLog: false);
  }

  void _syncMouseProtocolControlsFromTerminal() {
    if (_safeTerminalMode(VtModes.anyMouse)) {
      _mouseTrackingProfile = _DemoMouseTrackingProfile.any;
    } else if (_safeTerminalMode(VtModes.buttonMouse)) {
      _mouseTrackingProfile = _DemoMouseTrackingProfile.button;
    } else if (_safeTerminalMode(VtModes.normalMouse)) {
      _mouseTrackingProfile = _DemoMouseTrackingProfile.normal;
    } else if (_safeTerminalMode(VtModes.x10Mouse)) {
      _mouseTrackingProfile = _DemoMouseTrackingProfile.x10;
    } else {
      _mouseTrackingProfile = _DemoMouseTrackingProfile.disabled;
    }

    if (_safeTerminalMode(VtModes.sgrPixelsMouse)) {
      _mouseFormatProfile = _DemoMouseFormatProfile.sgrPixels;
    } else if (_safeTerminalMode(VtModes.sgrMouse)) {
      _mouseFormatProfile = _DemoMouseFormatProfile.sgr;
    } else if (_safeTerminalMode(VtModes.urxvtMouse)) {
      _mouseFormatProfile = _DemoMouseFormatProfile.urxvt;
    } else if (_safeTerminalMode(VtModes.utf8Mouse)) {
      _mouseFormatProfile = _DemoMouseFormatProfile.utf8;
    } else {
      _mouseFormatProfile = _DemoMouseFormatProfile.x10;
    }

    _mouseFocusEvents = _safeTerminalMode(VtModes.focusEvent);
    _mouseAltScroll = _safeTerminalMode(VtModes.altScroll);
  }

  void _onTerminalChanged() {
    if (!mounted) {
      return;
    }
    _refreshSnapshots();
    _syncMouseProtocolControlsFromTerminal();
    setState(() {});
  }

  void _onWritePtyData(Uint8List data) {
    final now = DateTime.now();
    final hh = now.hour.toString().padLeft(2, '0');
    final mm = now.minute.toString().padLeft(2, '0');
    final ss = now.second.toString().padLeft(2, '0');
    final ms = now.millisecond.toString().padLeft(3, '0');
    final hex = data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
    final preview = data.length <= 32 ? hex : '${hex.substring(0, 95)}...';
    _writePtyLog.insert(0, '$hh:$mm:$ss.$ms  ${data.length}B  $preview');
    if (_writePtyLog.length > 200) {
      _writePtyLog.removeLast();
    }
    _writePtyTotalBytes += data.length;
    if (mounted) {
      setState(() {});
    }
  }

  void _appendEffectLog(String message) {
    final now = DateTime.now();
    final hh = now.hour.toString().padLeft(2, '0');
    final mm = now.minute.toString().padLeft(2, '0');
    final ss = now.second.toString().padLeft(2, '0');
    final ms = now.millisecond.toString().padLeft(3, '0');
    _effectLog.insert(0, '$hh:$mm:$ss.$ms  $message');
    if (_effectLog.length > 200) {
      _effectLog.removeLast();
    }
  }

  void _onBell() {
    _bellCount++;
    _appendEffectLog('BEL received (#$_bellCount)');
    if (mounted) setState(() {});
  }

  void _onTitleChanged() {
    _titleChangedCount++;
    _lastTitle = _terminal.title;
    _appendEffectLog('Title changed to "$_lastTitle" (#$_titleChangedCount)');
    if (mounted) setState(() {});
  }

  VtSizeReportSize? _onSizeQuery() {
    _sizeQueryCount++;
    _appendEffectLog('Size query (#$_sizeQueryCount)');
    if (mounted) setState(() {});
    return VtSizeReportSize(
      rows: _terminal.rows,
      columns: _terminal.cols,
      cellWidth: 8,
      cellHeight: 16,
    );
  }

  GhosttyColorScheme? _onColorSchemeQuery() {
    _colorSchemeQueryCount++;
    _appendEffectLog('Color scheme query (#$_colorSchemeQueryCount)');
    if (mounted) setState(() {});
    return GhosttyColorScheme.GHOSTTY_COLOR_SCHEME_DARK;
  }

  VtDeviceAttributes? _onDeviceAttributesQuery() {
    _deviceAttributesQueryCount++;
    _appendEffectLog('Device attributes query (#$_deviceAttributesQueryCount)');
    if (mounted) setState(() {});
    return const VtDeviceAttributes(
      primary: VtDeviceAttributesPrimary(
        conformanceLevel: 62,
        features: <int>[1, 6, 7, 22],
      ),
      secondary: VtDeviceAttributesSecondary(
        deviceType: 1,
        firmwareVersion: 10,
      ),
      tertiary: VtDeviceAttributesTertiary(unitId: 0),
    );
  }

  Uint8List _onEnquiry() {
    _enquiryCount++;
    _appendEffectLog('ENQ received (#$_enquiryCount)');
    if (mounted) setState(() {});
    // Respond with an empty answerback by default.
    return Uint8List(0);
  }

  String _onXtversion() {
    _xtversionCount++;
    _appendEffectLog('XTVERSION query (#$_xtversionCount)');
    if (mounted) setState(() {});
    return 'GhosttyVTStudio 1.0';
  }

  void _appendLog(String message) {
    final now = DateTime.now();
    final hh = now.hour.toString().padLeft(2, '0');
    final mm = now.minute.toString().padLeft(2, '0');
    final ss = now.second.toString().padLeft(2, '0');
    _activity.insert(0, '$hh:$mm:$ss  $message');
    if (_activity.length > 120) {
      _activity.removeLast();
    }
  }

  void _refreshSnapshots() {
    _plainSnapshot = _terminal.plainText;
    final extra = _formatterExtra;
    _vtSnapshot = _terminal.formatTerminal(
      emit: GhosttyFormatterFormat.GHOSTTY_FORMATTER_FORMAT_VT,
      extra: extra,
      trim: false,
    );
    _htmlSnapshot = _terminal.formatTerminal(
      emit: GhosttyFormatterFormat.GHOSTTY_FORMATTER_FORMAT_HTML,
      extra: extra,
      trim: false,
    );
  }

  VtFormatterTerminalExtra get _formatterExtra => VtFormatterTerminalExtra(
    palette: _formatterPalette,
    modes: _formatterModes,
    scrollingRegion: _formatterScrollingRegion,
    tabstops: _formatterTabstops,
    pwd: _formatterPwd,
    keyboard: _formatterKeyboard,
    screen: VtFormatterScreenExtra(
      cursor: _formatterCursor,
      style: _formatterStyle,
      hyperlink: _formatterHyperlink,
      protection: _formatterProtection,
      kittyKeyboard: _formatterKittyKeyboard,
      charsets: _formatterCharsets,
    ),
  );

  bool get _allFormatterExtrasEnabled =>
      _formatterPalette &&
      _formatterModes &&
      _formatterScrollingRegion &&
      _formatterTabstops &&
      _formatterPwd &&
      _formatterKeyboard &&
      _formatterCursor &&
      _formatterStyle &&
      _formatterHyperlink &&
      _formatterProtection &&
      _formatterKittyKeyboard &&
      _formatterCharsets;

  void _setAllFormatterExtras(bool enabled) {
    setState(() {
      _formatterPalette = enabled;
      _formatterModes = enabled;
      _formatterScrollingRegion = enabled;
      _formatterTabstops = enabled;
      _formatterPwd = enabled;
      _formatterKeyboard = enabled;
      _formatterCursor = enabled;
      _formatterStyle = enabled;
      _formatterHyperlink = enabled;
      _formatterProtection = enabled;
      _formatterKittyKeyboard = enabled;
      _formatterCharsets = enabled;
      _recomputeInspectorState(addLog: false);
    });
  }

  String _mouseProtocolSummary() {
    try {
      final state = _terminal.terminal.mouseProtocolState;
      if (!state.enabled) {
        return 'Mouse reporting: disabled';
      }
      return 'Mouse reporting: ${state.trackingMode?.name ?? 'unknown'}'
          ' • ${state.format?.name ?? 'unknown'}'
          ' • focus ${state.focusEvents ? 'on' : 'off'}'
          ' • altScroll ${state.altScroll ? 'on' : 'off'}';
    } catch (_) {
      return 'Mouse reporting unavailable before native terminal init.';
    }
  }

  String _renderSemanticSummary() {
    final snapshot = _terminal.renderSnapshot;
    if (snapshot == null || !snapshot.hasViewportData) {
      return 'Render snapshot unavailable on this platform or before native viewport update.';
    }

    var promptRows = 0;
    var continuationRows = 0;
    var promptTextCells = 0;
    var promptInputCells = 0;
    var promptOutputCells = 0;

    for (final row in snapshot.rowsData) {
      if (row.isPrompt) {
        promptRows += 1;
      }
      if (row.isPromptContinuation) {
        continuationRows += 1;
      }
      for (final cell in row.cells) {
        if (cell.isPromptText) {
          promptTextCells += 1;
        }
        if (cell.isPromptInput) {
          promptInputCells += 1;
        }
        if (cell.isPromptOutput) {
          promptOutputCells += 1;
        }
      }
    }

    final cursor = snapshot.cursor;
    return 'rows=${snapshot.rowsData.length}\n'
        'promptRows=$promptRows\n'
        'promptContinuationRows=$continuationRows\n'
        'promptTextCells=$promptTextCells\n'
        'promptInputCells=$promptInputCells\n'
        'promptOutputCells=$promptOutputCells\n'
        'cursorVisible=${cursor.visible}\n'
        'cursorViewport=${cursor.hasViewportPosition ? '${cursor.row},${cursor.col}' : '(offscreen)'}';
  }

  void _recomputeInspectorState({
    bool addLog = true,
    bool refreshSnapshots = true,
    bool skipNativeChecks = false,
  }) {
    if (refreshSnapshots) {
      _refreshSnapshots();
    } else {
      _plainSnapshot = '';
      _vtSnapshot = '';
      _htmlSnapshot = '';
    }
    if (skipNativeChecks) {
      _pasteSafe = true;
      _oscCommand = null;
      _oscError = 'OSC parser unavailable without native assets.';
      _sgrAttributes = <VtSgrAttributeData>[];
      _sgrError = 'SGR parser unavailable without native assets.';
      _encodedBytes = Uint8List(0);
    } else {
      _pasteSafe = GhosttyVt.isPasteSafe(_commandController.text);
      _parseOsc();
      _parseSgr();
      _encodeKeyPreview();
    }
    if (addLog) {
      _appendLog('Refreshed formatter, parser, and key inspector state.');
    }
  }

  Future<void> _restartTerminal() async {
    await _terminal.stop();
    final launch = await _startDemoShell();
    _activeShellLabel = launch.label;
    _activeShellCommand = launch.commandLine;
    _activeShellEnvironment = launch.environment ?? const <String, String>{};
    _appendLog('Terminal session restarted (${launch.label}).');
    _recomputeInspectorState(addLog: false);
    setState(() {});
  }

  Future<void> _stopTerminal() async {
    await _terminal.stop();
    _appendLog('Terminal session stopped.');
    setState(() {});
  }

  Future<void> _copyShellEnvironment() async {
    final text = _formatEnvironment(_currentShellEnvironment);
    await Clipboard.setData(ClipboardData(text: text));
    if (!mounted) {
      return;
    }
    ScaffoldMessenger.of(
      context,
    ).showSnackBar(const SnackBar(content: Text('Copied shell environment.')));
  }

  void _sendCommand() {
    final sent = _terminal.write(
      '${_commandController.text}\n',
      sanitizePaste: true,
    );
    if (sent) {
      _appendLog('Sent command to shell stdin.');
    } else {
      _appendLog(
        'Command send blocked (session stopped or paste safety failed).',
      );
    }
    setState(() {});
  }

  void _injectDemoOutput() {
    _terminal.appendDebugOutput(
      '\x1b]2;Ghostty VT Studio\x07'
      '\x1b[1;32mVT demo\x1b[0m  '
      '\x1b[4;38;2;255;190;92mwrapped formatter output\x1b[0m\n'
      'normal line\n'
      '\x1b[2Koverwritten line\rrepainted line\n'
      '\x1b[90mOSC title and SGR styling are feeding the live terminal.\x1b[0m\n',
    );
    _appendLog('Injected demo VT output into the terminal buffer.');
    _recomputeInspectorState(addLog: false);
    setState(() {});
  }

  void _clearTerminal() {
    _terminal.clear();
    _appendLog('Reset terminal and cleared scrollback snapshot.');
    _recomputeInspectorState(addLog: false);
    setState(() {});
  }

  void _sendQuickKey(GhosttyKey key, {int mods = 0, String utf8Text = ''}) {
    final sent = _terminal.sendKey(
      key: key,
      mods: mods,
      utf8Text: utf8Text,
      unshiftedCodepoint: utf8Text.isEmpty ? 0 : utf8Text.runes.first,
    );
    _appendLog(
      sent ? 'Sent key ${key.name}.' : 'Key send failed (terminal stopped).',
    );
    setState(() {});
  }

  void _sendDemoMouse() {
    final sent = _terminal.sendMouse(
      action: GhosttyMouseAction.GHOSTTY_MOUSE_ACTION_PRESS,
      button: GhosttyMouseButton.GHOSTTY_MOUSE_BUTTON_LEFT,
      position: const VtMousePosition(x: 16, y: 16),
      size: const VtMouseEncoderSize(
        screenWidth: 1280,
        screenHeight: 720,
        cellWidth: 10,
        cellHeight: 20,
      ),
      trackingMode: GhosttyMouseTrackingMode.GHOSTTY_MOUSE_TRACKING_NORMAL,
      format: GhosttyMouseFormat.GHOSTTY_MOUSE_FORMAT_SGR,
    );
    _appendLog(sent ? 'Sent demo mouse event.' : 'Mouse send failed.');
    setState(() {});
  }

  void _encodeKeyPreview() {
    final encoder = VtKeyEncoder();
    final event = VtKeyEvent();
    try {
      encoder.setOptionsFromTerminal(_terminal.terminal);
      event
        ..action = _selectedAction
        ..key = _selectedKey
        ..mods = _maskFrom(_selectedMods)
        ..composing = _composing
        ..utf8Text = _utf8Controller.text
        ..unshiftedCodepoint = _parseCodepoint(_codepointController.text);
      _encodedBytes = encoder.encode(event);
    } catch (_) {
      _encodedBytes = Uint8List(0);
    } finally {
      event.close();
      encoder.close();
    }
  }

  int _maskFrom(Set<int> values) {
    var out = 0;
    for (final value in values) {
      out |= value;
    }
    return out;
  }

  int _parseCodepoint(String raw) {
    final trimmed = raw.trim();
    if (trimmed.isEmpty) {
      return 0;
    }
    if (trimmed.startsWith('0x') || trimmed.startsWith('0X')) {
      return int.parse(trimmed.substring(2), radix: 16);
    }
    return int.parse(trimmed);
  }

  void _parseOsc() {
    final parser = VtOscParser();
    try {
      parser.addText(_oscController.text);
      _oscCommand = parser.end();
      _oscError = null;
    } catch (error) {
      _oscCommand = null;
      _oscError = error.toString();
    } finally {
      parser.close();
    }
  }

  void _parseSgr() {
    final matches = RegExp(r'\d+').allMatches(_sgrController.text);
    final values = matches.map((m) => int.parse(m.group(0)!)).toList();
    if (values.isEmpty) {
      _sgrAttributes = <VtSgrAttributeData>[];
      _sgrError = 'Enter one or more integer params such as 1;31;4.';
      return;
    }

    final parser = VtSgrParser();
    try {
      _sgrAttributes = parser.parseParams(values);
      _sgrError = null;
    } catch (error) {
      _sgrAttributes = <VtSgrAttributeData>[];
      _sgrError = error.toString();
    } finally {
      parser.close();
    }
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final wide = MediaQuery.sizeOf(context).width >= 1180;

    return Scaffold(
      appBar: AppBar(
        title: const Text('Ghostty VT Studio'),
        actions: <Widget>[
          TextButton.icon(
            onPressed: _terminal.isRunning ? _stopTerminal : _restartTerminal,
            icon: Icon(
              _terminal.isRunning
                  ? Icons.stop_circle_outlined
                  : Icons.play_arrow_outlined,
            ),
            label: Text(_terminal.isRunning ? 'Stop' : 'Start'),
          ),
          const SizedBox(width: 8),
        ],
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: wide
            ? Row(
                crossAxisAlignment: CrossAxisAlignment.stretch,
                children: <Widget>[
                  Expanded(flex: 8, child: _buildTerminalColumn(theme)),
                  const SizedBox(width: 16),
                  Expanded(flex: 7, child: _buildInspector(theme)),
                ],
              )
            : ListView(
                children: <Widget>[
                  SizedBox(height: 620, child: _buildTerminalColumn(theme)),
                  const SizedBox(height: 16),
                  SizedBox(height: 560, child: _buildInspector(theme)),
                ],
              ),
      ),
    );
  }

  Widget _buildTerminalColumn(ThemeData theme) {
    return LayoutBuilder(
      builder: (context, constraints) {
        final terminalHeight = constraints.maxHeight.isFinite
            ? (constraints.maxHeight * 0.42).clamp(220.0, 420.0)
            : 320.0;
        return ListView(
          children: <Widget>[
            if (!kIsWeb) ...<Widget>[
              Wrap(
                spacing: 8,
                runSpacing: 8,
                children: GhosttyTerminalShellProfile.values
                    .map(
                      (profile) => ChoiceChip(
                        label: Text(profile.label),
                        selected: _selectedShellProfile == profile,
                        onSelected: (_) => _selectShellProfile(profile),
                      ),
                    )
                    .toList(),
              ),
              const SizedBox(height: 12),
            ],
            Wrap(
              spacing: 8,
              runSpacing: 8,
              crossAxisAlignment: WrapCrossAlignment.center,
              children: <Widget>[
                FilledButton.icon(
                  onPressed: _sendCommand,
                  icon: const Icon(Icons.subdirectory_arrow_left),
                  label: const Text('Send Command'),
                ),
                OutlinedButton.icon(
                  onPressed: _injectDemoOutput,
                  icon: const Icon(Icons.auto_awesome),
                  label: const Text('Inject VT Demo'),
                ),
                OutlinedButton.icon(
                  onPressed: _clearTerminal,
                  icon: const Icon(Icons.layers_clear),
                  label: const Text('Reset'),
                ),
                OutlinedButton.icon(
                  onPressed: _restartTerminal,
                  icon: const Icon(Icons.refresh),
                  label: const Text('Restart Shell'),
                ),
                _StatusPill(
                  label: _terminal.isRunning ? 'Running' : 'Stopped',
                  color: _terminal.isRunning
                      ? const Color(0xFF2BD576)
                      : const Color(0xFFD65C5C),
                ),
                _StatusPill(
                  label: '${_terminal.cols} x ${_terminal.rows}',
                  color: theme.colorScheme.secondary,
                ),
                _StatusPill(
                  label: '${_terminal.lineCount} lines',
                  color: theme.colorScheme.tertiary,
                ),
                _StatusPill(
                  label: _currentShellLabel,
                  color: theme.colorScheme.primary,
                ),
              ],
            ),
            const SizedBox(height: 12),
            TextField(
              controller: _commandController,
              onChanged: (_) => setState(() {
                _pasteSafe = GhosttyVt.isPasteSafe(_commandController.text);
              }),
              decoration: InputDecoration(
                labelText: 'Shell command or pasted text',
                helperText: _pasteSafe
                    ? 'Paste-safe input'
                    : 'Paste safety would block this input',
                helperStyle: TextStyle(
                  color: _pasteSafe
                      ? const Color(0xFF76E5B1)
                      : const Color(0xFFFFA899),
                ),
                border: const OutlineInputBorder(),
                suffixIcon: Icon(
                  _pasteSafe ? Icons.verified : Icons.warning_amber_rounded,
                ),
              ),
            ),
            const SizedBox(height: 12),
            Wrap(
              spacing: 12,
              runSpacing: 12,
              crossAxisAlignment: WrapCrossAlignment.center,
              children: <Widget>[
                SizedBox(
                  width: 280,
                  child: TextField(
                    controller: _fontFamilyController,
                    onChanged: (_) => setState(() {}),
                    decoration: const InputDecoration(
                      labelText: 'Terminal font family',
                      hintText: 'JetBrainsMono Nerd Font',
                      border: OutlineInputBorder(),
                    ),
                  ),
                ),
                ConstrainedBox(
                  constraints: const BoxConstraints(
                    minWidth: 240,
                    maxWidth: 360,
                  ),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: <Widget>[
                      Text(
                        'Cell width scale ${_cellWidthScale.toStringAsFixed(2)}',
                      ),
                      Slider(
                        value: _cellWidthScale,
                        min: 0.75,
                        max: 1.4,
                        divisions: 13,
                        label: _cellWidthScale.toStringAsFixed(2),
                        onChanged: (value) => setState(() {
                          _cellWidthScale = value;
                        }),
                      ),
                    ],
                  ),
                ),
                Wrap(
                  spacing: 8,
                  runSpacing: 8,
                  children: <Widget>[
                    for (final mode in _rendererModes)
                      mode.enabledOnWeb || !kIsWeb
                          ? ChoiceChip(
                              key: ValueKey<String>(
                                'render-mode-${mode.value.name}',
                              ),
                              label: Text(mode.label),
                              selected: _renderer == mode.value,
                              onSelected: (_) => setState(() {
                                _renderer = mode.value;
                              }),
                            )
                          : Tooltip(
                              message:
                                  mode.unavailableReason ??
                                  'Render mode unavailable on web',
                              child: ChoiceChip(
                                key: ValueKey<String>(
                                  'render-mode-${mode.value.name}',
                                ),
                                label: Text(mode.label),
                                selected: _renderer == mode.value,
                                onSelected: null,
                              ),
                            ),
                  ],
                ),
                const SizedBox(height: 8),
                Text('Renderer: ${_renderer.name}'),
                const SizedBox(height: 8),
                SegmentedButton<GhosttyTerminalInteractionPolicy>(
                  segments:
                      const <ButtonSegment<GhosttyTerminalInteractionPolicy>>[
                        ButtonSegment<GhosttyTerminalInteractionPolicy>(
                          value: GhosttyTerminalInteractionPolicy.auto,
                          label: Text('Auto'),
                          icon: Icon(Icons.tune),
                        ),
                        ButtonSegment<GhosttyTerminalInteractionPolicy>(
                          value:
                              GhosttyTerminalInteractionPolicy.selectionFirst,
                          label: Text('Selection First'),
                          icon: Icon(Icons.select_all),
                        ),
                        ButtonSegment<GhosttyTerminalInteractionPolicy>(
                          value: GhosttyTerminalInteractionPolicy
                              .terminalMouseFirst,
                          label: Text('Terminal Mouse'),
                          icon: Icon(Icons.mouse),
                        ),
                      ],
                  selected: <GhosttyTerminalInteractionPolicy>{
                    _interactionPolicy,
                  },
                  onSelectionChanged:
                      (Set<GhosttyTerminalInteractionPolicy> selection) {
                        setState(() {
                          _interactionPolicy = selection.first;
                        });
                      },
                ),
                const SizedBox(height: 8),
                Text('Interaction: ${_interactionPolicy.name}'),
                const SizedBox(height: 4),
                Text(_mouseProtocolSummary()),
                const SizedBox(height: 8),
                const Text('Mouse Tracking'),
                SegmentedButton<_DemoMouseTrackingProfile>(
                  segments: const <ButtonSegment<_DemoMouseTrackingProfile>>[
                    ButtonSegment<_DemoMouseTrackingProfile>(
                      value: _DemoMouseTrackingProfile.disabled,
                      label: Text('Disabled'),
                    ),
                    ButtonSegment<_DemoMouseTrackingProfile>(
                      value: _DemoMouseTrackingProfile.x10,
                      label: Text('X10'),
                    ),
                    ButtonSegment<_DemoMouseTrackingProfile>(
                      value: _DemoMouseTrackingProfile.normal,
                      label: Text('Normal'),
                    ),
                    ButtonSegment<_DemoMouseTrackingProfile>(
                      value: _DemoMouseTrackingProfile.button,
                      label: Text('Button'),
                    ),
                    ButtonSegment<_DemoMouseTrackingProfile>(
                      value: _DemoMouseTrackingProfile.any,
                      label: Text('Any'),
                    ),
                  ],
                  selected: <_DemoMouseTrackingProfile>{_mouseTrackingProfile},
                  onSelectionChanged:
                      (Set<_DemoMouseTrackingProfile> selection) {
                        setState(() {
                          _mouseTrackingProfile = selection.first;
                        });
                        _applyMouseProtocolModes();
                      },
                ),
                const SizedBox(height: 8),
                const Text('Mouse Format'),
                SegmentedButton<_DemoMouseFormatProfile>(
                  segments: const <ButtonSegment<_DemoMouseFormatProfile>>[
                    ButtonSegment<_DemoMouseFormatProfile>(
                      value: _DemoMouseFormatProfile.x10,
                      label: Text('X10'),
                    ),
                    ButtonSegment<_DemoMouseFormatProfile>(
                      value: _DemoMouseFormatProfile.utf8,
                      label: Text('UTF8'),
                    ),
                    ButtonSegment<_DemoMouseFormatProfile>(
                      value: _DemoMouseFormatProfile.sgr,
                      label: Text('SGR'),
                    ),
                    ButtonSegment<_DemoMouseFormatProfile>(
                      value: _DemoMouseFormatProfile.urxvt,
                      label: Text('URXVT'),
                    ),
                    ButtonSegment<_DemoMouseFormatProfile>(
                      value: _DemoMouseFormatProfile.sgrPixels,
                      label: Text('SGR Pixels'),
                    ),
                  ],
                  selected: <_DemoMouseFormatProfile>{_mouseFormatProfile},
                  onSelectionChanged: (Set<_DemoMouseFormatProfile> selection) {
                    setState(() {
                      _mouseFormatProfile = selection.first;
                    });
                    _applyMouseProtocolModes();
                  },
                ),
                Wrap(
                  spacing: 12,
                  runSpacing: 8,
                  crossAxisAlignment: WrapCrossAlignment.center,
                  children: <Widget>[
                    FilterChip(
                      label: const Text('Focus Events'),
                      selected: _mouseFocusEvents,
                      onSelected: (selected) {
                        setState(() {
                          _mouseFocusEvents = selected;
                        });
                        _applyMouseProtocolModes();
                      },
                    ),
                    FilterChip(
                      label: const Text('Alt Scroll'),
                      selected: _mouseAltScroll,
                      onSelected: (selected) {
                        setState(() {
                          _mouseAltScroll = selected;
                        });
                        _applyMouseProtocolModes();
                      },
                    ),
                  ],
                ),
              ],
            ),
            const SizedBox(height: 12),
            SizedBox(
              height: terminalHeight,
              child: DecoratedBox(
                decoration: BoxDecoration(
                  borderRadius: BorderRadius.circular(18),
                  border: Border.all(color: theme.colorScheme.outlineVariant),
                  boxShadow: const <BoxShadow>[
                    BoxShadow(
                      color: Color(0x33000000),
                      blurRadius: 28,
                      offset: Offset(0, 12),
                    ),
                  ],
                ),
                child: ClipRRect(
                  borderRadius: BorderRadius.circular(18),
                  child: GhosttyTerminalView(
                    controller: _terminal,
                    autofocus: true,
                    chromeColor: const Color(0xFF10212C),
                    backgroundColor: const Color(0xFF060D13),
                    foregroundColor: const Color(0xFFE7F8F5),
                    fontSize: 14,
                    lineHeight: 1.32,
                    fontFamily: _fontFamilyController.text.trim().isEmpty
                        ? null
                        : _fontFamilyController.text.trim(),
                    cellWidthScale: _cellWidthScale,
                    renderer: _renderer,
                    interactionPolicy: _interactionPolicy,
                    padding: const EdgeInsets.fromLTRB(14, 14, 14, 12),
                    onSelectionChanged: (selection) {
                      setState(() {
                        _selectionText = selection != null
                            ? '${selection.base} -> ${selection.extent}'
                            : '';
                      });
                      _appendLog(
                        selection != null
                            ? 'Selection changed: ${selection.base} -> ${selection.extent}'
                            : 'Selection cleared.',
                      );
                    },
                    onCopySelection: (text) async {
                      await Clipboard.setData(ClipboardData(text: text));
                      setState(() {
                        _lastCopiedText = text.length > 120
                            ? '${text.substring(0, 120)}...'
                            : text;
                      });
                      _appendLog('Copied ${text.length} chars to clipboard.');
                    },
                    onPasteRequest: () async {
                      setState(() {
                        _pasteRequestCount += 1;
                      });
                      _appendLog('Paste requested (#$_pasteRequestCount).');
                      final data = await Clipboard.getData(
                        Clipboard.kTextPlain,
                      );
                      return data?.text;
                    },
                    onOpenHyperlink: (uri) async {
                      setState(() {
                        _lastHyperlink = uri;
                      });
                      _appendLog('Hyperlink activated: $uri');
                    },
                  ),
                ),
              ),
            ),
            const SizedBox(height: 12),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: <Widget>[
                FilledButton.tonal(
                  onPressed: () =>
                      _sendQuickKey(GhosttyKey.GHOSTTY_KEY_ARROW_UP),
                  child: const Text('Up'),
                ),
                FilledButton.tonal(
                  onPressed: () =>
                      _sendQuickKey(GhosttyKey.GHOSTTY_KEY_ARROW_LEFT),
                  child: const Text('Left'),
                ),
                FilledButton.tonal(
                  onPressed: () =>
                      _sendQuickKey(GhosttyKey.GHOSTTY_KEY_ARROW_RIGHT),
                  child: const Text('Right'),
                ),
                FilledButton.tonal(
                  onPressed: () =>
                      _sendQuickKey(GhosttyKey.GHOSTTY_KEY_BACKSPACE),
                  child: const Text('Backspace'),
                ),
                FilledButton.tonal(
                  onPressed: () => _sendQuickKey(
                    GhosttyKey.GHOSTTY_KEY_C,
                    mods: GhosttyModsMask.ctrl,
                    utf8Text: 'c',
                  ),
                  child: const Text('Ctrl+C'),
                ),
                FilledButton.tonal(
                  onPressed: () => _sendQuickKey(GhosttyKey.GHOSTTY_KEY_TAB),
                  child: const Text('Tab'),
                ),
                FilledButton.tonal(
                  onPressed: () => _sendQuickKey(GhosttyKey.GHOSTTY_KEY_ENTER),
                  child: const Text('Enter'),
                ),
              ],
            ),
          ],
        );
      },
    );
  }

  Widget _buildInspector(ThemeData theme) {
    return DecoratedBox(
      decoration: BoxDecoration(
        color: const Color(0xFF09131C),
        borderRadius: BorderRadius.circular(18),
        border: Border.all(color: theme.colorScheme.outlineVariant),
      ),
      child: Column(
        children: <Widget>[
          TabBar(
            controller: _tabs,
            tabs: const <Tab>[
              Tab(text: 'Snapshots'),
              Tab(text: 'Key Encoder'),
              Tab(text: 'Parsers'),
              Tab(text: 'Session'),
              Tab(text: 'Terminal'),
            ],
          ),
          Expanded(
            child: TabBarView(
              controller: _tabs,
              children: <Widget>[
                _buildSnapshotsTab(),
                _buildKeyTab(),
                _buildParserTab(),
                _buildSessionTab(),
                _buildTerminalTab(),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildSnapshotsTab() {
    return ListView(
      padding: const EdgeInsets.all(16),
      children: <Widget>[
        const Text(
          'Formatter Extras',
          style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
        ),
        const SizedBox(height: 12),
        Wrap(
          spacing: 8,
          runSpacing: 8,
          children: <Widget>[
            FilterChip(
              label: const Text('All Extras'),
              selected: _allFormatterExtrasEnabled,
              onSelected: _setAllFormatterExtras,
            ),
            _boolChip(
              'Palette',
              _formatterPalette,
              (v) => setState(() {
                _formatterPalette = v;
                _recomputeInspectorState(addLog: false);
              }),
            ),
            _boolChip(
              'Modes',
              _formatterModes,
              (v) => setState(() {
                _formatterModes = v;
                _recomputeInspectorState(addLog: false);
              }),
            ),
            _boolChip(
              'Scrolling Region',
              _formatterScrollingRegion,
              (v) => setState(() {
                _formatterScrollingRegion = v;
                _recomputeInspectorState(addLog: false);
              }),
            ),
            _boolChip(
              'Tabstops',
              _formatterTabstops,
              (v) => setState(() {
                _formatterTabstops = v;
                _recomputeInspectorState(addLog: false);
              }),
            ),
            _boolChip(
              'PWD',
              _formatterPwd,
              (v) => setState(() {
                _formatterPwd = v;
                _recomputeInspectorState(addLog: false);
              }),
            ),
            _boolChip(
              'Keyboard',
              _formatterKeyboard,
              (v) => setState(() {
                _formatterKeyboard = v;
                _recomputeInspectorState(addLog: false);
              }),
            ),
            _boolChip(
              'Cursor',
              _formatterCursor,
              (v) => setState(() {
                _formatterCursor = v;
                _recomputeInspectorState(addLog: false);
              }),
            ),
            _boolChip(
              'Style',
              _formatterStyle,
              (v) => setState(() {
                _formatterStyle = v;
                _recomputeInspectorState(addLog: false);
              }),
            ),
            _boolChip(
              'Hyperlink',
              _formatterHyperlink,
              (v) => setState(() {
                _formatterHyperlink = v;
                _recomputeInspectorState(addLog: false);
              }),
            ),
            _boolChip(
              'Protection',
              _formatterProtection,
              (v) => setState(() {
                _formatterProtection = v;
                _recomputeInspectorState(addLog: false);
              }),
            ),
            _boolChip(
              'Kitty Keyboard',
              _formatterKittyKeyboard,
              (v) => setState(() {
                _formatterKittyKeyboard = v;
                _recomputeInspectorState(addLog: false);
              }),
            ),
            _boolChip(
              'Charsets',
              _formatterCharsets,
              (v) => setState(() {
                _formatterCharsets = v;
                _recomputeInspectorState(addLog: false);
              }),
            ),
          ],
        ),
        const SizedBox(height: 16),
        _snapshotCard('Plain Text', _plainSnapshot),
        const SizedBox(height: 12),
        _snapshotCard('VT Output', _vtSnapshot),
        const SizedBox(height: 12),
        _snapshotCard('HTML Output', _htmlSnapshot),
        const SizedBox(height: 12),
        _snapshotCard('Render Semantics', _renderSemanticSummary()),
      ],
    );
  }

  Widget _buildKeyTab() {
    return ListView(
      padding: const EdgeInsets.all(16),
      children: <Widget>[
        Row(
          children: <Widget>[
            Expanded(
              child: DropdownButtonFormField<GhosttyKeyAction>(
                initialValue: _selectedAction,
                decoration: const InputDecoration(
                  labelText: 'Action',
                  border: OutlineInputBorder(),
                ),
                items: _actions
                    .map(
                      (option) => DropdownMenuItem<GhosttyKeyAction>(
                        value: option.value,
                        child: Text(option.label),
                      ),
                    )
                    .toList(),
                onChanged: (value) {
                  if (value == null) {
                    return;
                  }
                  setState(() {
                    _selectedAction = value;
                    _encodeKeyPreview();
                  });
                },
              ),
            ),
            const SizedBox(width: 12),
            Expanded(
              child: DropdownButtonFormField<GhosttyKey>(
                initialValue: _selectedKey,
                decoration: const InputDecoration(
                  labelText: 'Key',
                  border: OutlineInputBorder(),
                ),
                items: _keys
                    .map(
                      (option) => DropdownMenuItem<GhosttyKey>(
                        value: option.value,
                        child: Text(option.label),
                      ),
                    )
                    .toList(),
                onChanged: (value) {
                  if (value == null) {
                    return;
                  }
                  setState(() {
                    _selectedKey = value;
                    _encodeKeyPreview();
                  });
                },
              ),
            ),
          ],
        ),
        const SizedBox(height: 12),
        Wrap(
          spacing: 8,
          runSpacing: 8,
          children: _mods
              .map(
                (option) => FilterChip(
                  label: Text(option.label),
                  selected: _selectedMods.contains(option.mask),
                  onSelected: (selected) {
                    setState(() {
                      if (selected) {
                        _selectedMods.add(option.mask);
                      } else {
                        _selectedMods.remove(option.mask);
                      }
                      _encodeKeyPreview();
                    });
                  },
                ),
              )
              .toList(),
        ),
        const SizedBox(height: 12),
        Row(
          children: <Widget>[
            Expanded(
              child: TextField(
                controller: _utf8Controller,
                decoration: const InputDecoration(
                  labelText: 'UTF-8 text',
                  border: OutlineInputBorder(),
                ),
                onChanged: (_) => setState(_encodeKeyPreview),
              ),
            ),
            const SizedBox(width: 12),
            Expanded(
              child: TextField(
                controller: _codepointController,
                decoration: const InputDecoration(
                  labelText: 'Unshifted codepoint',
                  border: OutlineInputBorder(),
                ),
                onChanged: (_) => setState(_encodeKeyPreview),
              ),
            ),
          ],
        ),
        SwitchListTile.adaptive(
          value: _composing,
          title: const Text('Composing'),
          contentPadding: EdgeInsets.zero,
          onChanged: (value) {
            setState(() {
              _composing = value;
              _encodeKeyPreview();
            });
          },
        ),
        const SizedBox(height: 8),
        _snapshotCard(
          'Encoded bytes',
          _encodedBytes.isEmpty
              ? '(empty)'
              : _encodedBytes
                    .map((b) => b.toRadixString(16).padLeft(2, '0'))
                    .join(' '),
        ),
        const SizedBox(height: 12),
        FilledButton.icon(
          onPressed: () {
            final sent = _terminal.sendKey(
              key: _selectedKey,
              action: _selectedAction,
              mods: _maskFrom(_selectedMods),
              composing: _composing,
              utf8Text: _utf8Controller.text,
              unshiftedCodepoint: _parseCodepoint(_codepointController.text),
            );
            _appendLog(
              sent ? 'Sent custom key event.' : 'Key event send failed.',
            );
            setState(() {});
          },
          icon: const Icon(Icons.keyboard),
          label: const Text('Send Key Event'),
        ),
        const SizedBox(height: 8),
        OutlinedButton.icon(
          onPressed: _sendDemoMouse,
          icon: const Icon(Icons.mouse),
          label: const Text('Send Demo Mouse Event'),
        ),
      ],
    );
  }

  Widget _buildParserTab() {
    return ListView(
      padding: const EdgeInsets.all(16),
      children: <Widget>[
        const Text(
          'Paste Safety',
          style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
        ),
        const SizedBox(height: 8),
        Text(
          _pasteSafe
              ? 'Current command is safe to paste.'
              : 'Current command would be blocked by paste safety.',
        ),
        const SizedBox(height: 20),
        TextField(
          controller: _oscController,
          decoration: const InputDecoration(
            labelText: 'OSC payload',
            border: OutlineInputBorder(),
          ),
          onChanged: (_) => setState(() {
            _parseOsc();
          }),
        ),
        const SizedBox(height: 8),
        _snapshotCard(
          'OSC result',
          _oscError ??
              'type=${_oscCommand?.type.name ?? 'unknown'}\nwindowTitle=${_oscCommand?.windowTitle ?? '(none)'}',
        ),
        const SizedBox(height: 20),
        TextField(
          controller: _sgrController,
          decoration: const InputDecoration(
            labelText: 'SGR params',
            border: OutlineInputBorder(),
          ),
          onChanged: (_) => setState(() {
            _parseSgr();
          }),
        ),
        const SizedBox(height: 8),
        _snapshotCard(
          'SGR attributes',
          _sgrError ??
              (_sgrAttributes.isEmpty
                  ? '(none)'
                  : _sgrAttributes
                        .map((attr) => _describeSgr(attr))
                        .join('\n')),
        ),
      ],
    );
  }

  Widget _buildTerminalTab() {
    String terminalState;
    try {
      final vt = _terminal.terminal;
      final cursor = vt.cursorPosition;
      final screen = vt.isPrimaryScreen
          ? 'primary'
          : vt.isAlternateScreen
          ? 'alternate'
          : 'unknown';
      final scrollbar = vt.scrollbar;
      terminalState =
          'title="${vt.title}"\n'
          'pwd="${vt.pwd}"\n'
          'cursor=(${cursor.x}, ${cursor.y})\n'
          'cursorPendingWrap=${vt.cursorPendingWrap}\n'
          'activeScreen=$screen\n'
          'totalRows=${vt.totalRows}  scrollbackRows=${vt.scrollbackRows}\n'
          'size=${vt.widthPx}x${vt.heightPx} px\n'
          'scrollbar: offset=${scrollbar.offset} / total=${scrollbar.total} visible=${scrollbar.length}\n'
          'mouseTracking=${vt.mouseTracking}\n'
          'bracketedPaste=${_safeTerminalMode(VtModes.bracketedPaste)}\n'
          'cursorKeys=${_safeTerminalMode(VtModes.cursorKeys)}\n'
          'kittyKeyboardFlags=${vt.kittyKeyboardFlags}\n'
          '${_mouseProtocolSummary()}';
    } catch (_) {
      terminalState = '(terminal not yet initialized)';
    }

    return ListView(
      padding: const EdgeInsets.all(16),
      children: <Widget>[
        _snapshotCard('Terminal State', terminalState),
        const SizedBox(height: 12),
        _snapshotCard(
          'Clipboard / Selection',
          'selection=$_selectionText\n'
              'lastCopied=${_lastCopiedText.isEmpty ? '(none)' : _lastCopiedText}\n'
              'lastHyperlink=${_lastHyperlink.isEmpty ? '(none)' : _lastHyperlink}\n'
              'pasteRequests=$_pasteRequestCount',
        ),
        const SizedBox(height: 12),

        // --- Trigger Effects ---
        Row(
          children: <Widget>[
            const Text(
              'Trigger Effects',
              style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
            ),
            const Spacer(),
            TextButton(
              onPressed: () => setState(() {
                _bellCount = 0;
                _titleChangedCount = 0;
                _lastTitle = '';
                _sizeQueryCount = 0;
                _colorSchemeQueryCount = 0;
                _deviceAttributesQueryCount = 0;
                _enquiryCount = 0;
                _xtversionCount = 0;
                _effectLog.clear();
              }),
              child: const Text('Reset'),
            ),
          ],
        ),
        const SizedBox(height: 8),
        Wrap(
          spacing: 8,
          runSpacing: 8,
          children: <Widget>[
            _triggerButton(
              label: 'BEL',
              tooltip: 'Send BEL character (0x07)',
              sequence: '\x07',
            ),
            _triggerButton(
              label: 'Set Title',
              tooltip: r'Send OSC 2 ; Demo Title BEL',
              sequence: '\x1b]2;Demo Title\x07',
            ),
            _triggerButton(
              label: 'Size Query',
              tooltip: 'Send CSI 18 t (text area size)',
              sequence: '\x1b[18t',
            ),
            _triggerButton(
              label: 'Color Scheme',
              tooltip: 'Send CSI ? 996 n',
              sequence: '\x1b[?996n',
            ),
            _triggerButton(
              label: 'DA1',
              tooltip: 'Send CSI c (primary device attributes)',
              sequence: '\x1b[c',
            ),
            _triggerButton(
              label: 'DA2',
              tooltip: 'Send CSI > c (secondary device attributes)',
              sequence: '\x1b[>c',
            ),
            _triggerButton(
              label: 'DA3',
              tooltip: 'Send CSI = c (tertiary device attributes)',
              sequence: '\x1b[=c',
            ),
            _triggerButton(
              label: 'ENQ',
              tooltip: 'Send ENQ character (0x05)',
              sequence: '\x05',
            ),
            _triggerButton(
              label: 'XTVERSION',
              tooltip: 'Send CSI > q',
              sequence: '\x1b[>q',
            ),
            _triggerButton(
              label: 'DSR',
              tooltip: 'Send CSI 5 n (device status report)',
              sequence: '\x1b[5n',
            ),
          ],
        ),
        const SizedBox(height: 12),
        _snapshotCard(
          'Effect Callback Counters',
          'bell=$_bellCount\n'
              'titleChanged=$_titleChangedCount  title="$_lastTitle"\n'
              'sizeQuery=$_sizeQueryCount\n'
              'colorSchemeQuery=$_colorSchemeQueryCount\n'
              'deviceAttributesQuery=$_deviceAttributesQueryCount\n'
              'enquiry=$_enquiryCount\n'
              'xtversion=$_xtversionCount',
        ),
        const SizedBox(height: 12),
        Row(
          children: <Widget>[
            const Text(
              'Effect Callback Log',
              style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
            ),
            const Spacer(),
            TextButton(
              onPressed: () => setState(() => _effectLog.clear()),
              child: const Text('Clear'),
            ),
          ],
        ),
        const SizedBox(height: 8),
        _snapshotCard(
          'Effect Log',
          _effectLog.isEmpty
              ? '(no effect callbacks triggered yet — press a button above)'
              : _effectLog.take(60).join('\n'),
        ),
        const SizedBox(height: 12),
        Row(
          children: <Widget>[
            const Text(
              'onWritePty Activity',
              style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
            ),
            const Spacer(),
            Text(
              '$_writePtyTotalBytes bytes total',
              style: const TextStyle(fontSize: 12, color: Color(0xFF76E5B1)),
            ),
            const SizedBox(width: 8),
            TextButton(
              onPressed: () => setState(() {
                _writePtyLog.clear();
                _writePtyTotalBytes = 0;
              }),
              child: const Text('Clear'),
            ),
          ],
        ),
        const SizedBox(height: 8),
        _snapshotCard(
          'Write PTY Log',
          _writePtyLog.isEmpty
              ? '(no pty writes yet — trigger with DSR queries, DA responses, etc.)'
              : _writePtyLog.take(60).join('\n'),
        ),
      ],
    );
  }

  Widget _buildSessionTab() {
    return ListView(
      padding: const EdgeInsets.all(16),
      children: <Widget>[
        _snapshotCard(
          'Session Stats',
          'running=${_terminal.isRunning}\n'
              'title=${_terminal.title}\n'
              'size=${_terminal.cols}x${_terminal.rows}\n'
              'lines=${_terminal.lineCount}\n'
              'scrollback=${_terminal.maxScrollback}',
        ),
        const SizedBox(height: 12),
        _snapshotCard(
          'Launch',
          'profile=${_selectedShellProfile.label}\n'
              'label=$_currentShellLabel\n'
              'command=$_currentShellCommand',
        ),
        const SizedBox(height: 12),
        _snapshotCard(
          'Environment',
          _formatEnvironment(_currentShellEnvironment),
        ),
        const SizedBox(height: 12),
        Align(
          alignment: Alignment.centerRight,
          child: TextButton.icon(
            onPressed: _copyShellEnvironment,
            icon: const Icon(Icons.copy_rounded),
            label: const Text('Copy Environment'),
          ),
        ),
        const SizedBox(height: 12),
        _snapshotCard(
          'Recent Activity',
          _activity.isEmpty ? '(no activity yet)' : _activity.join('\n'),
        ),
      ],
    );
  }

  Widget _snapshotCard(String title, String body) {
    return DecoratedBox(
      decoration: BoxDecoration(
        color: const Color(0xFF0D1A24),
        borderRadius: BorderRadius.circular(14),
        border: Border.all(color: const Color(0xFF193041)),
      ),
      child: Padding(
        padding: const EdgeInsets.all(12),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Text(title, style: const TextStyle(fontWeight: FontWeight.w700)),
            const SizedBox(height: 8),
            SelectableText(
              body,
              style: const TextStyle(fontFamily: 'monospace', height: 1.35),
            ),
          ],
        ),
      ),
    );
  }

  Widget _triggerButton({
    required String label,
    required String tooltip,
    required String sequence,
  }) {
    return Tooltip(
      message: tooltip,
      child: ElevatedButton(
        style: ElevatedButton.styleFrom(
          backgroundColor: const Color(0xFF193041),
          foregroundColor: const Color(0xFF76E5B1),
          padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
          textStyle: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
        ),
        onPressed: () {
          _terminal.terminal.write(sequence);
          _appendEffectLog('Injected: $label');
          setState(() {});
        },
        child: Text(label),
      ),
    );
  }

  Widget _boolChip(String label, bool value, ValueChanged<bool> onChanged) {
    return FilterChip(
      label: Text(label),
      selected: value,
      onSelected: onChanged,
    );
  }

  String _describeSgr(VtSgrAttributeData attr) {
    final buffer = StringBuffer(attr.tag.name);
    if (attr.paletteIndex != null) {
      buffer.write(' index=${attr.paletteIndex}');
    }
    if (attr.rgb != null) {
      buffer.write(' rgb=${attr.rgb}');
    }
    if (attr.underline != null) {
      buffer.write(' underline=${attr.underline!.name}');
    }
    if (attr.unknown != null) {
      buffer.write(' unknown=${attr.unknown!.partial}');
    }
    return buffer.toString();
  }

  String _formatEnvironment(Map<String, String> environment) {
    if (environment.isEmpty) {
      return '(inherited or unavailable)';
    }
    final entries = environment.entries.toList()
      ..sort((a, b) => a.key.compareTo(b.key));
    return entries.map((entry) => '${entry.key}=${entry.value}').join('\n');
  }
}

class _DemoShellLaunch {
  const _DemoShellLaunch({
    required this.label,
    required this.commandLine,
    this.environment,
  });

  final String label;
  final String commandLine;
  final Map<String, String>? environment;
}

class _StatusPill extends StatelessWidget {
  const _StatusPill({required this.label, required this.color});

  final String label;
  final Color color;

  @override
  Widget build(BuildContext context) {
    return DecoratedBox(
      decoration: BoxDecoration(
        color: color.withValues(alpha: 0.16),
        borderRadius: BorderRadius.circular(999),
        border: Border.all(color: color.withValues(alpha: 0.32)),
      ),
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
        child: Text(
          label,
          style: TextStyle(color: color, fontWeight: FontWeight.w600),
        ),
      ),
    );
  }
}

class _ActionOption {
  const _ActionOption(this.label, this.value);

  final String label;
  final GhosttyKeyAction value;
}

class _KeyOption {
  const _KeyOption(this.label, this.value);

  final String label;
  final GhosttyKey value;
}

class _ModOption {
  const _ModOption(this.label, this.mask);

  final String label;
  final int mask;
}

class _RendererModeOption {
  const _RendererModeOption({
    required this.label,
    required this.value,
    required this.enabledOnWeb,
    this.unavailableReason,
  });

  final String label;
  final GhosttyTerminalRendererMode value;
  final bool enabledOnWeb;
  final String? unavailableReason;
}
1
likes
0
points
151
downloads

Publisher

unverified uploader

Weekly Downloads

Flutter terminal UI widgets powered by Ghostty's VT engine. Provides GhosttyTerminalView, GhosttyTerminalController, and automatic wasm initialisation for web targets.

Homepage
Repository (GitHub)
View/report issues

Topics

#terminal #flutter #widget #ghostty

License

unknown (license)

Dependencies

characters, flutter, ghostty_vte, portable_pty, url_launcher

More

Packages that depend on ghostty_vte_flutter