import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'package:example/src/platform_menu.dart'; import 'package:example/src/suggestion_engine.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_pty/flutter_pty.dart'; import 'package:xterm/xterm.dart'; import 'package:xterm/suggestion.dart'; final engine = SuggestionEngine(); Future> loadSuggestion() async { final data = await rootBundle.load('assets/specs_v1.json.gz'); return await Stream.value(data.buffer.asUint8List()) .cast>() .transform(gzip.decoder) .transform(utf8.decoder) .transform(json.decoder) .first as Map; } void main() async { WidgetsFlutterBinding.ensureInitialized(); engine.load(await loadSuggestion()); runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'xterm.dart demo', debugShowCheckedModeBanner: false, home: AppPlatformMenu(child: Home()), ); } } class Home extends StatefulWidget { Home({super.key}); @override // ignore: library_private_types_in_public_api _HomeState createState() => _HomeState(); } class _HomeState extends State { late final terminal = Terminal( maxLines: 10000, onPrivateOSC: _handlePrivateOSC, ); final terminalController = TerminalController(); final terminalKey = GlobalKey(); final suggestionOverlay = SuggestionPortalController(); late final Pty pty; @override void initState() { super.initState(); terminal.addListener(_handleTerminalChanged); WidgetsBinding.instance.endOfFrame.then( (_) { if (mounted) _startPty(); }, ); } @override void dispose() { super.dispose(); terminal.removeListener(_handleTerminalChanged); } void _startPty() { pty = Pty.start( shell, columns: terminal.viewWidth, rows: terminal.viewHeight, ); pty.output .cast>() .transform(Utf8Decoder()) .listen(terminal.write); pty.exitCode.then((code) { terminal.write('the process exited with exit code $code'); }); terminal.onOutput = (data) { pty.write(const Utf8Encoder().convert(data)); }; terminal.onResize = (w, h, pw, ph) { pty.resize(h, w); }; } /// Where the current shell prompt starts CellAnchor? _promptStart; /// Where the current user input starts CellAnchor? _commandStart; /// Where the current user input ends and the command starts to execute CellAnchor? _commandEnd; /// Where the command finishes CellAnchor? _commandFinished; void _handlePrivateOSC(String code, List args) { switch (code) { case '133': _handleFinalTermOSC(args); } } void _handleFinalTermOSC(List args) { switch (args) { case ['A']: _promptStart?.dispose(); _promptStart = terminal.buffer.createAnchorFromCursor(); _commandStart?.dispose(); _commandStart = null; _commandEnd?.dispose(); _commandEnd = null; _commandFinished?.dispose(); _commandFinished = null; case ['B']: _commandStart?.dispose(); _commandStart = terminal.buffer.createAnchorFromCursor(); break; case ['C', ..._]: _commandEnd?.dispose(); _commandEnd = terminal.buffer.createAnchorFromCursor(); _handleCommandEnd(); break; case ['D', String exitCode]: _commandFinished?.dispose(); _commandFinished = terminal.buffer.createAnchorFromCursor(); _handleCommandFinished(int.tryParse(exitCode)); break; } } void _handleCommandEnd() { if (_commandStart == null || _commandEnd == null) return; final command = terminal.buffer .getText(BufferRangeLine(_commandStart!.offset, _commandEnd!.offset)) .trim(); print('command: $command'); } void _handleCommandFinished(int? exitCode) { if (_commandEnd == null || _commandFinished == null) return; final result = terminal.buffer .getText(BufferRangeLine(_commandEnd!.offset, _commandFinished!.offset)) .trim(); print('result: $result'); print('exitCode: $exitCode'); } final suggestionView = SuggestionViewController(); String? get commandBuffer { final commandStart = _commandStart; if (commandStart == null || _commandEnd != null) { return null; } var commandRange = BufferRangeLine( commandStart.offset, CellOffset( terminal.buffer.cursorX, terminal.buffer.absoluteCursorY, ), ); return terminal.buffer.getText(commandRange).trimRightNewline(); } void _handleTerminalChanged() { final command = commandBuffer; if (command == null || command.isEmpty) { suggestionOverlay.hide(); return; } final suggestions = engine.getSuggestions(command).toList(); suggestionView.update(suggestions); print('suggestions: $suggestions'); if (suggestions.isNotEmpty) { suggestionOverlay.update(terminalKey.currentState!.globalCursorRect); } else { suggestionOverlay.hide(); } } void _handleSuggestionSelected(FigToken suggestion) { final command = commandBuffer; if (command == null) { return; } final incompleteCommand = command.endsWith(' ') ? null : command.split(' ').last; switch (suggestion) { case FigCommand(:var names): if (incompleteCommand == null) { _emitSuggestion(names.first); } else { for (final name in names) { if (name.startsWith(incompleteCommand)) { _emitSuggestion(name.substring(incompleteCommand.length)); break; } } } case FigOption(:var names): if (incompleteCommand == null) { _emitSuggestion(names.first); } else { for (final name in names) { if (name.startsWith(incompleteCommand)) { _emitSuggestion(name.substring(incompleteCommand.length)); break; } } } break; default: } } void _emitSuggestion(String text) { pty.write(const Utf8Encoder().convert(text)); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.transparent, body: SuggestionPortal( controller: suggestionOverlay, overlayBuilder: (context) { return SuggestionView( suggestionView, onSuggestionSelected: _handleSuggestionSelected, ); }, child: TerminalView( terminal, key: terminalKey, controller: terminalController, autofocus: true, backgroundOpacity: 0.7, onKeyEvent: (node, event) { if (event is! KeyDownEvent) { return KeyEventResult.ignored; } if (suggestionOverlay.isShowing) { switch (event.logicalKey) { case LogicalKeyboardKey.escape: suggestionOverlay.hide(); return KeyEventResult.handled; case LogicalKeyboardKey.tab: final suggestion = suggestionView.currentSuggestion; if (suggestion != null) { _handleSuggestionSelected(suggestion); return KeyEventResult.handled; } case LogicalKeyboardKey.arrowUp: suggestionView.selectPrevious(); return KeyEventResult.handled; case LogicalKeyboardKey.arrowDown: suggestionView.selectNext(); return KeyEventResult.handled; default: } } return KeyEventResult.ignored; }, ), ), ); } } /// The state of the suggestion overlay. class SuggestionViewController extends ChangeNotifier { final scrollController = ScrollController(); List get suggestions => _suggestions; List _suggestions = []; var _selected = 0; set selected(int index) { _selected = max(0, min(index, suggestions.length - 1)); notifyListeners(); } double get itemExtent => _itemExtent; double _itemExtent = 20; set itemExtent(double value) { if (value == _itemExtent) return; _itemExtent = value; notifyListeners(); } FigToken? get currentSuggestion { if (_suggestions.isEmpty) return null; return _suggestions[_selected]; } void update(List suggestions) { _suggestions = suggestions; _selected = 0; notifyListeners(); } void selectNext() { _selected = (_selected + 1) % _suggestions.length; ensureVisible(_selected); notifyListeners(); } void selectPrevious() { _selected = (_selected - 1) % _suggestions.length; ensureVisible(_selected); notifyListeners(); } void ensureVisible(int index) { if (!scrollController.hasClients) { return; } final position = scrollController.position; final targetOffset = itemExtent * index; final viewportBottomOffset = position.pixels + position.viewportDimension; if (targetOffset < position.pixels) { position.jumpTo(targetOffset); } else if (targetOffset + itemExtent > viewportBottomOffset) { position.jumpTo( max(0, targetOffset + itemExtent - position.viewportDimension), ); } } } /// The suggestion popup shown above the terminal when user is typing a command. class SuggestionView extends StatelessWidget { const SuggestionView( this.controller, { super.key, this.onSuggestionSelected, }); final SuggestionViewController controller; final void Function(FigToken)? onSuggestionSelected; @override Widget build(BuildContext context) { return ListenableBuilder( listenable: controller, builder: (context, child) { return _build(context); }, ); } Widget _build(BuildContext context) { return ClipRRect( borderRadius: BorderRadius.circular(4), child: Container( constraints: BoxConstraints( maxWidth: 300, maxHeight: 200, ), decoration: BoxDecoration( color: Colors.grey[800], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: ListView.builder( controller: controller.scrollController, itemExtent: controller.itemExtent, itemCount: controller._suggestions.length, itemBuilder: (context, index) { final suggestion = controller._suggestions[index]; return GestureDetector( onTapDown: (_) => controller.selected = index, onDoubleTapDown: (_) => onSuggestionSelected?.call(suggestion), child: ClipRect( child: SuggestionTile( selected: index == controller._selected, suggestion: suggestion, ), ), ); }, ), ), if (controller.currentSuggestion != null) ...[ Divider( height: 1, thickness: 1, color: Colors.grey[700], ), SuggestionDescriptionView(controller.currentSuggestion!), ], ], ), ), ); } } /// The area at the bottom of [SuggestionView] that shows the description of /// the currently selected suggestion. class SuggestionDescriptionView extends StatefulWidget { const SuggestionDescriptionView( this.suggestion, { super.key, }); final FigToken suggestion; @override State createState() => _SuggestionDescriptionViewState(); } class _SuggestionDescriptionViewState extends State { var isHovering = false; @override Widget build(BuildContext context) { return MouseRegion( onEnter: (event) { setState(() { isHovering = true; }); }, onExit: (event) { setState(() { isHovering = false; }); }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), child: Text( widget.suggestion.description ?? '', maxLines: isHovering ? null : 1, overflow: isHovering ? null : TextOverflow.ellipsis, style: TextStyle(color: Colors.grey[400], fontSize: 12), ), ), ); } } /// An item in [SuggestionView]. class SuggestionTile extends StatelessWidget { const SuggestionTile({ super.key, required this.selected, required this.suggestion, }); final bool selected; final FigToken suggestion; static final primaryStyle = TerminalStyle().toTextStyle().copyWith( leadingDistribution: TextLeadingDistribution.even, ); static final argumentStyle = TerminalStyle().toTextStyle().copyWith( leadingDistribution: TextLeadingDistribution.even, color: Colors.grey[500], fontSize: 12, ); @override Widget build(BuildContext context) { final (icon, iconColor) = _getIcon(suggestion); final canSelect = suggestion is! FigArgument; return Container( color: selected ? Colors.blue[800] : Colors.transparent, child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ SizedBox(width: 4), Icon(icon, size: 14, color: iconColor), SizedBox(width: 4), Expanded( child: RichText( overflow: TextOverflow.ellipsis, maxLines: 1, text: TextSpan( text: _getContent(suggestion) ?? '', children: [...buildArgs()], style: canSelect ? primaryStyle : primaryStyle.copyWith(fontStyle: FontStyle.italic), ), ), ), if (selected && canSelect) ...[ Icon(Icons.keyboard_tab_rounded, size: 16, color: Colors.grey[400]), SizedBox(width: 4), ], ], ), ); } Iterable buildArgs() sync* { final args = switch (suggestion) { FigCommand(:var args) => args, FigOption(:var args) => args, _ => [], }; const indent = ' '; for (final arg in args) { yield TextSpan( text: arg.isOptional ? '$indent[${arg.name}]' : '$indent<${arg.name}>', style: argumentStyle, ); } } static (IconData, Color) _getIcon(FigToken suggestion) { return switch (suggestion) { FigCommand() => (Icons.subdirectory_arrow_right, Colors.blue), FigOption() => (Icons.settings, Colors.green), FigArgument() => (Icons.text_fields, Colors.yellow), }; } static String? _getContent(FigToken suggestion) { return switch (suggestion) { FigCommand(:final names) => names.join(', '), FigOption(names: final name) => name.join(', '), FigArgument(:final name) => name, }; } } String get shell { if (Platform.isMacOS || Platform.isLinux) { return Platform.environment['SHELL'] ?? 'bash'; } if (Platform.isWindows) { return 'cmd.exe'; } return 'sh'; } extension on String { String trimRightNewline() { return endsWith('\n') ? substring(0, length - 1) : this; } }