| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588 |
- 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<Map<String, dynamic>> loadSuggestion() async {
- final data = await rootBundle.load('assets/specs_v1.json.gz');
- return await Stream.value(data.buffer.asUint8List())
- .cast<List<int>>()
- .transform(gzip.decoder)
- .transform(utf8.decoder)
- .transform(json.decoder)
- .first as Map<String, dynamic>;
- }
- 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<Home> {
- late final terminal = Terminal(
- maxLines: 10000,
- onPrivateOSC: _handlePrivateOSC,
- );
- final terminalController = TerminalController();
- final terminalKey = GlobalKey<TerminalViewState>();
- 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<List<int>>()
- .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<String> args) {
- switch (code) {
- case '133':
- _handleFinalTermOSC(args);
- }
- }
- void _handleFinalTermOSC(List<String> 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<FigToken> get suggestions => _suggestions;
- List<FigToken> _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<FigToken> 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<SuggestionDescriptionView> createState() =>
- _SuggestionDescriptionViewState();
- }
- class _SuggestionDescriptionViewState extends State<SuggestionDescriptionView> {
- 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<InlineSpan> buildArgs() sync* {
- final args = switch (suggestion) {
- FigCommand(:var args) => args,
- FigOption(:var args) => args,
- _ => <FigArgument>[],
- };
- 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;
- }
- }
|