completion.dart 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. import 'dart:convert';
  2. import 'dart:io';
  3. import 'dart:math';
  4. import 'package:example/src/platform_menu.dart';
  5. import 'package:example/src/suggestion.dart';
  6. import 'package:flutter/material.dart';
  7. import 'package:flutter/services.dart';
  8. import 'package:flutter_pty/flutter_pty.dart';
  9. import 'package:xterm/xterm.dart';
  10. final engine = SuggestionEngine();
  11. Future<Map<String, dynamic>> loadSuggestion() async {
  12. final data = await rootBundle.load('assets/specs_v1.json.gz');
  13. return await Stream.value(data.buffer.asUint8List())
  14. .cast<List<int>>()
  15. .transform(gzip.decoder)
  16. .transform(utf8.decoder)
  17. .transform(json.decoder)
  18. .first as Map<String, dynamic>;
  19. }
  20. void main() async {
  21. WidgetsFlutterBinding.ensureInitialized();
  22. engine.load(await loadSuggestion());
  23. runApp(MyApp());
  24. }
  25. class MyApp extends StatelessWidget {
  26. @override
  27. Widget build(BuildContext context) {
  28. return MaterialApp(
  29. title: 'xterm.dart demo',
  30. debugShowCheckedModeBanner: false,
  31. home: AppPlatformMenu(child: Home()),
  32. );
  33. }
  34. }
  35. class Home extends StatefulWidget {
  36. Home({Key? key}) : super(key: key);
  37. @override
  38. // ignore: library_private_types_in_public_api
  39. _HomeState createState() => _HomeState();
  40. }
  41. class _HomeState extends State<Home> {
  42. late final terminal = Terminal(
  43. maxLines: 10000,
  44. onPrivateOSC: _handlePrivateOSC,
  45. );
  46. final terminalController = TerminalController();
  47. final terminalKey = GlobalKey<TerminalViewState>();
  48. final suggestionOverlay = OverlayPortalController();
  49. late final Pty pty;
  50. @override
  51. void initState() {
  52. super.initState();
  53. terminal.addListener(_handleTerminalChanged);
  54. WidgetsBinding.instance.endOfFrame.then(
  55. (_) {
  56. if (mounted) _startPty();
  57. },
  58. );
  59. }
  60. @override
  61. void dispose() {
  62. super.dispose();
  63. terminal.removeListener(_handleTerminalChanged);
  64. }
  65. void _startPty() {
  66. pty = Pty.start(
  67. shell,
  68. columns: terminal.viewWidth,
  69. rows: terminal.viewHeight,
  70. );
  71. pty.output
  72. .cast<List<int>>()
  73. .transform(Utf8Decoder())
  74. .listen(terminal.write);
  75. pty.exitCode.then((code) {
  76. terminal.write('the process exited with exit code $code');
  77. });
  78. terminal.onOutput = (data) {
  79. pty.write(const Utf8Encoder().convert(data));
  80. };
  81. terminal.onResize = (w, h, pw, ph) {
  82. pty.resize(h, w);
  83. };
  84. }
  85. CellAnchor? _promptStart;
  86. CellAnchor? _commandStart;
  87. CellAnchor? _commandEnd;
  88. CellAnchor? _commandFinished;
  89. void _handlePrivateOSC(String code, List<String> args) {
  90. switch (code) {
  91. case '133':
  92. _handleFinalTermOSC(args);
  93. }
  94. }
  95. void _handleFinalTermOSC(List<String> args) {
  96. switch (args) {
  97. case ['A']:
  98. _promptStart?.dispose();
  99. _promptStart = terminal.buffer.createAnchorFromCursor();
  100. _commandStart?.dispose();
  101. _commandStart = null;
  102. _commandEnd?.dispose();
  103. _commandEnd = null;
  104. _commandFinished?.dispose();
  105. _commandFinished = null;
  106. case ['B']:
  107. _commandStart?.dispose();
  108. _commandStart = terminal.buffer.createAnchorFromCursor();
  109. break;
  110. case ['C', ..._]:
  111. _commandEnd?.dispose();
  112. _commandEnd = terminal.buffer.createAnchorFromCursor();
  113. // _handleCommandEnd();
  114. break;
  115. case ['D', String exitCode]:
  116. _commandFinished?.dispose();
  117. _commandFinished = terminal.buffer.createAnchorFromCursor();
  118. // _handleCommandFinished(int.tryParse(exitCode));
  119. break;
  120. }
  121. }
  122. // void _handleCommandEnd() {
  123. // if (_commandStart == null || _commandEnd == null) return;
  124. // final command = terminal.buffer
  125. // .getText(BufferRangeLine(_commandStart!.offset, _commandEnd!.offset))
  126. // .trim();
  127. // print('command: $command');
  128. // }
  129. // void _handleCommandFinished(int? exitCode) {
  130. // if (_commandEnd == null || _commandFinished == null) return;
  131. // final result = terminal.buffer
  132. // .getText(BufferRangeLine(_commandEnd!.offset, _commandFinished!.offset))
  133. // .trim();
  134. // print('result: $result');
  135. // print('exit code $exitCode');
  136. // }
  137. final suggestions = ValueNotifier<List<FigSuggestion>>([]);
  138. void _handleTerminalChanged() {
  139. final commandStart = _commandStart;
  140. if (commandStart == null || _commandEnd != null) {
  141. suggestionOverlay.hide();
  142. return;
  143. }
  144. var commandRange = BufferRangeLine(
  145. commandStart.offset,
  146. CellOffset(
  147. terminal.buffer.cursorX,
  148. terminal.buffer.absoluteCursorY,
  149. ),
  150. );
  151. final command = terminal.buffer.getText(commandRange).trim();
  152. if (command.isEmpty) {
  153. suggestionOverlay.hide();
  154. return;
  155. }
  156. print('command: $command');
  157. suggestions.value = engine.getSuggestions(command).toList();
  158. print(suggestions.value);
  159. if (suggestions.value.isNotEmpty) {
  160. suggestionOverlay.show();
  161. } else {
  162. suggestionOverlay.hide();
  163. }
  164. }
  165. @override
  166. Widget build(BuildContext context) {
  167. return Scaffold(
  168. backgroundColor: Colors.transparent,
  169. body: OverlayPortal(
  170. controller: suggestionOverlay,
  171. overlayChildBuilder: (context) {
  172. return ValueListenableBuilder<List<FigSuggestion>>(
  173. valueListenable: suggestions,
  174. builder: (context, suggestions, _) {
  175. return SuggestionOverlay(
  176. suggestions,
  177. cursorRect: terminalKey.currentState!.cursorRect,
  178. );
  179. },
  180. );
  181. },
  182. child: TerminalView(
  183. terminal,
  184. key: terminalKey,
  185. controller: terminalController,
  186. autofocus: true,
  187. backgroundOpacity: 0.7,
  188. ),
  189. ),
  190. );
  191. }
  192. }
  193. class SuggestionOverlay extends StatelessWidget {
  194. const SuggestionOverlay(
  195. this.suggestions, {
  196. super.key,
  197. required this.cursorRect,
  198. });
  199. final Rect cursorRect;
  200. final List<FigSuggestion> suggestions;
  201. @override
  202. Widget build(BuildContext context) {
  203. print('build suggestions');
  204. const kScreenPadding = 8.0;
  205. const kPanelContentDistance = 8.0;
  206. const kPanelWidth = 300.0;
  207. const kPanelHeight = 300.0;
  208. final paddingAbove = MediaQuery.paddingOf(context).top + kScreenPadding;
  209. final availableHeight =
  210. cursorRect.top - kPanelContentDistance - paddingAbove;
  211. final fitsAbove = kPanelHeight <= availableHeight;
  212. return CustomSingleChildLayout(
  213. delegate: _SuggestionOverlayDelegate(cursorRect, fitsAbove),
  214. child: ConstrainedBox(
  215. constraints: BoxConstraints(
  216. maxWidth: kPanelWidth,
  217. maxHeight: kPanelHeight,
  218. ),
  219. child: _buildSuggestions(context),
  220. ),
  221. );
  222. }
  223. Widget _buildSuggestions(BuildContext context) {
  224. final list = ListView.builder(
  225. itemCount: suggestions.length,
  226. itemBuilder: (context, index) {
  227. final suggestion = suggestions[index];
  228. final (icon, color, content) = _suggestionContent(suggestion);
  229. return SuggestionTile(icon: icon, color: color, content: content ?? '');
  230. },
  231. );
  232. return Container(
  233. decoration: BoxDecoration(
  234. color: Colors.grey[800],
  235. borderRadius: BorderRadius.circular(4),
  236. // border: Border.all(color: Colors.grey[900]!),
  237. ),
  238. child: DefaultTextStyle(
  239. style: TerminalStyle().toTextStyle().copyWith(height: 1.5),
  240. child: Column(
  241. children: [
  242. Expanded(
  243. child: list,
  244. ),
  245. ],
  246. ),
  247. ),
  248. );
  249. }
  250. static (IconData, Color, String?) _suggestionContent(
  251. FigSuggestion suggestion) {
  252. return switch (suggestion) {
  253. FigSubCommand(:final names) => (
  254. Icons.subdirectory_arrow_right,
  255. Colors.blue,
  256. names.join(', '),
  257. ),
  258. FigOption(:final name) => (
  259. Icons.settings,
  260. Colors.green,
  261. name.join(', '),
  262. ),
  263. FigArgument(:final name) => (
  264. Icons.text_fields,
  265. Colors.yellow,
  266. name,
  267. ),
  268. };
  269. }
  270. }
  271. class _SuggestionOverlayDelegate extends SingleChildLayoutDelegate {
  272. _SuggestionOverlayDelegate(this.cursorRect, this.fitsAbove);
  273. final Rect cursorRect;
  274. final bool fitsAbove;
  275. @override
  276. BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
  277. return constraints.loosen();
  278. }
  279. @override
  280. Offset getPositionForChild(Size size, Size childSize) {
  281. TextSelectionToolbarLayoutDelegate;
  282. const kPanelContentDistance = 8.0;
  283. final dx = min(cursorRect.left, size.width - childSize.width);
  284. final dy = fitsAbove
  285. ? cursorRect.top - childSize.height
  286. : cursorRect.bottom + kPanelContentDistance;
  287. return Offset(dx, dy);
  288. }
  289. @override
  290. bool shouldRelayout(_SuggestionOverlayDelegate oldDelegate) {
  291. return cursorRect != oldDelegate.cursorRect;
  292. }
  293. }
  294. class SuggestionTile extends StatelessWidget {
  295. const SuggestionTile({
  296. super.key,
  297. required this.icon,
  298. required this.content,
  299. required this.color,
  300. });
  301. final IconData icon;
  302. final Color color;
  303. final String content;
  304. @override
  305. Widget build(BuildContext context) {
  306. return Row(
  307. crossAxisAlignment: CrossAxisAlignment.center,
  308. children: [
  309. SizedBox(width: 4),
  310. Icon(
  311. icon,
  312. size: 14,
  313. color: color,
  314. ),
  315. SizedBox(width: 4),
  316. Text(content),
  317. ],
  318. );
  319. }
  320. }
  321. String get shell {
  322. if (Platform.isMacOS || Platform.isLinux) {
  323. return Platform.environment['SHELL'] ?? 'bash';
  324. }
  325. if (Platform.isWindows) {
  326. return 'cmd.exe';
  327. }
  328. return 'sh';
  329. }