// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'terminfo.dart'; typedef List CommandCompleter(List commandParts); class Commando { // Ctrl keys static const runeCtrlA = 0x01; static const runeCtrlB = 0x02; static const runeCtrlD = 0x04; static const runeCtrlE = 0x05; static const runeCtrlF = 0x06; static const runeTAB = 0x09; static const runeNewline = 0x0a; static const runeCtrlK = 0x0b; static const runeCtrlL = 0x0c; static const runeCtrlN = 0x0e; static const runeCtrlP = 0x10; static const runeCtrlU = 0x15; static const runeCtrlY = 0x19; static const runeESC = 0x1b; static const runeSpace = 0x20; static const runeDEL = 0x7F; StreamController _commandController; Stream get commands => _commandController.stream; Commando({consoleIn, consoleOut, this.prompt : '> ', this.completer : null}) { _stdin = (consoleIn != null ? consoleIn : stdin); _stdout = (consoleOut != null ? consoleOut : stdout); _commandController = new StreamController( onCancel: _onCancel); _stdin.echoMode = false; _stdin.lineMode = false; _screenWidth = _term.cols - 1; _writePrompt(); // TODO(turnidge): Handle errors in _stdin here. _stdinSubscription = _stdin.transform(UTF8.decoder).listen(_handleText, onDone:_done); } Future _onCancel() { _stdin.echoMode = true; _stdin.lineMode = true; var future = _stdinSubscription.cancel(); if (future != null) { return future; } else { return new Future.value(); } } // Before terminating, call close() to restore terminal settings. void _done() { _onCancel().then((_) { _commandController.close(); }); } void _handleText(String text) { try { if (!_promptShown) { _bufferedInput.write(text); return; } var runes = text.runes.toList(); var pos = 0; while (pos < runes.length) { if (!_promptShown) { // A command was processed which hid the prompt. Buffer // the rest of the input. // // TODO(turnidge): Here and elsewhere in the file I pass // runes to String.fromCharCodes. Does this work? _bufferedInput.write( new String.fromCharCodes(runes.skip(pos))); return; } var rune = runes[pos]; // Count consecutive tabs because double-tab is meaningful. if (rune == runeTAB) { _tabCount++; } else { _tabCount = 0; } if (_isControlRune(rune)) { pos += _handleControlSequence(runes, pos); } else { pos += _handleRegularSequence(runes, pos); } } } catch(e, trace) { _commandController.addError(e, trace); } } int _handleControlSequence(List runes, int pos) { var runesConsumed = 1; // Most common result. var char = runes[pos]; switch (char) { case runeCtrlA: _home(); break; case runeCtrlB: _leftArrow(); break; case runeCtrlD: if (_currentLine.length == 0) { // ^D on an empty line means quit. _stdout.writeln("^D"); _done(); } else { _delete(); } break; case runeCtrlE: _end(); break; case runeCtrlF: _rightArrow(); break; case runeTAB: if (_complete(_tabCount > 1)) { _tabCount = 0; } break; case runeNewline: _newline(); break; case runeCtrlK: _kill(); break; case runeCtrlL: _clearScreen(); break; case runeCtrlN: _historyNext(); break; case runeCtrlP: _historyPrevious(); break; case runeCtrlU: _clearLine(); break; case runeCtrlY: _yank(); break; case runeESC: // Check to see if this is an arrow key. if (pos + 2 < runes.length && // must be a 3 char sequence. runes[pos + 1] == 0x5b) { // second char must be '['. switch (runes[pos + 2]) { case 0x41: // ^[[A = up arrow _historyPrevious(); runesConsumed = 3; break; case 0x42: // ^[[B = down arrow _historyNext(); runesConsumed = 3; break; case 0x43: // ^[[C = right arrow _rightArrow(); runesConsumed = 3; break; case 0x44: // ^[[D = left arrow _leftArrow(); runesConsumed = 3; break; default: // Ignore the escape character. break; } } break; case runeDEL: _backspace(); break; default: // Ignore the escape character. break; } return runesConsumed; } int _handleRegularSequence(List runes, int pos) { var len = pos + 1; while (len < runes.length && !_isControlRune(runes[len])) { len++; } _addChars(runes.getRange(pos, len)); return len; } bool _isControlRune(int char) { return (char >= 0x00 && char < 0x20) || (char == 0x7f); } void _writePromptAndLine() { _writePrompt(); var pos = _writeRange(_currentLine, 0, _currentLine.length); _cursorPos = _move(pos, _cursorPos); } void _writePrompt() { _stdout.write(prompt); } void _addChars(Iterable chars) { var newLine = []; newLine..addAll(_currentLine.take(_cursorPos)) ..addAll(chars) ..addAll(_currentLine.skip(_cursorPos)); _update(newLine, (_cursorPos + chars.length)); } void _backspace() { if (_cursorPos == 0) { return; } var newLine = []; newLine..addAll(_currentLine.take(_cursorPos - 1)) ..addAll(_currentLine.skip(_cursorPos)); _update(newLine, (_cursorPos - 1)); } void _delete() { if (_cursorPos == _currentLine.length) { return; } var newLine = []; newLine..addAll(_currentLine.take(_cursorPos)) ..addAll(_currentLine.skip(_cursorPos + 1)); _update(newLine, _cursorPos); } void _home() { _updatePos(0); } void _end() { _updatePos(_currentLine.length); } void _clearScreen() { _stdout.write(_term.clear); _term.resize(); _screenWidth = _term.cols - 1; _writePromptAndLine(); } void _kill() { var newLine = []; newLine.addAll(_currentLine.take(_cursorPos)); _killBuffer = _currentLine.skip(_cursorPos).toList(); _update(newLine, _cursorPos); } void _clearLine() { _update([], 0); } void _yank() { var newLine = []; newLine..addAll(_currentLine.take(_cursorPos)) ..addAll(_killBuffer) ..addAll(_currentLine.skip(_cursorPos)); _update(newLine, (_cursorPos + _killBuffer.length)); } static String _trimLeadingSpaces(String line) { bool _isSpace(int rune) { return rune == runeSpace; } return new String.fromCharCodes(line.runes.skipWhile(_isSpace)); } static String _sharedPrefix(String one, String two) { var len = min(one.length, two.length); var runesOne = one.runes.toList(); var runesTwo = two.runes.toList(); var pos; for (pos = 0; pos < len; pos++) { if (runesOne[pos] != runesTwo[pos]) { break; } } var shared = new String.fromCharCodes(runesOne.take(pos)); return shared; } bool _complete(bool showCompletions) { if (completer == null) { return false; } var linePrefix = _currentLine.take(_cursorPos).toList(); List commandParts = _trimLeadingSpaces(new String.fromCharCodes(linePrefix)).split(' '); List completionList = completer(commandParts); var completion = ''; if (completionList.length == 0) { // The current line admits no possible completion. return false; } else if (completionList.length == 1) { // There is a single, non-ambiguous completion for the current line. completion = completionList[0]; // If we are at the end of the line, add a space to signal that // the completion is unambiguous. if (_currentLine.length == _cursorPos) { completion = completion + ' '; } } else { // There are ambiguous completions. Find the longest common // shared prefix of all of the completions. completion = completionList.fold(completionList[0], _sharedPrefix); } var lastWord = commandParts.last; if (completion == lastWord) { // The completion does not add anything. if (showCompletions) { // User hit double-TAB. Show them all possible completions. _move(_cursorPos, _currentLine.length); _stdout.writeln(); _stdout.writeln(completionList); _writePromptAndLine(); } return false; } else { // Apply the current completion. var completionRunes = completion.runes.toList(); var newLine = []; newLine..addAll(linePrefix) ..addAll(completionRunes.skip(lastWord.length)) ..addAll(_currentLine.skip(_cursorPos)); _update(newLine, _cursorPos + completionRunes.length - lastWord.length); return true; } } void _newline() { _addLineToHistory(_currentLine); _linePos = _lines.length; _end(); _stdout.writeln(); // Call the user's command handler. _commandController.add(new String.fromCharCodes(_currentLine)); _currentLine = []; _cursorPos = 0; if (_promptShown) { _writePrompt(); } } void _leftArrow() { _updatePos(_cursorPos - 1); } void _rightArrow() { _updatePos(_cursorPos + 1); } void _addLineToHistory(List line) { if (_tempLineAdded) { _lines.removeLast(); _tempLineAdded = false; } if (line.length > 0) { _lines.add(line); } } void _addTempLineToHistory(List line) { _lines.add(line); _tempLineAdded = true; } void _replaceHistory(List line, int linePos) { _lines[linePos] = line; } void _historyPrevious() { if (_linePos == 0) { return; } if (_linePos == _lines.length) { // The current in-progress line gets temporarily stored in history. _addTempLineToHistory(_currentLine); } else { // Any edits get committed to history. _replaceHistory(_currentLine, _linePos); } _linePos -= 1; var line = _lines[_linePos]; _update(line, line.length); } void _historyNext() { // For the very first command, _linePos (0) will exceed // (_lines.length - 1) (-1) so we use a ">=" here instead of an "==". if (_linePos >= (_lines.length - 1)) { return; } // Any edits get committed to history. _replaceHistory(_currentLine, _linePos); _linePos += 1; var line = _lines[_linePos]; _update(line, line.length); } void _updatePos(int newCursorPos) { if (newCursorPos < 0) { return; } if (newCursorPos > _currentLine.length) { return; } _cursorPos = _move(_cursorPos, newCursorPos); } void _update(List newLine, int newCursorPos) { var pos = _cursorPos; var diffPos; var sharedLen = min(_currentLine.length, newLine.length); // Find first difference. for (diffPos = 0; diffPos < sharedLen; diffPos++) { if (_currentLine[diffPos] != newLine[diffPos]) { break; } } // Move the cursor to where the difference begins. pos = _move(pos, diffPos); // Write the new text. pos = _writeRange(newLine, pos, newLine.length); // Clear any extra characters at the end. pos = _clearRange(pos, _currentLine.length); // Move the cursor back to the input point. _cursorPos = _move(pos, newCursorPos); _currentLine = newLine; } void hide() { if (!_promptShown) { return; } _promptShown = false; // We need to erase everything, including the prompt. var curLine = _getLine(_cursorPos); var lastLine = _getLine(_currentLine.length); // Go to last line. if (curLine < lastLine) { for (var i = 0; i < (lastLine - curLine); i++) { // This moves us to column 0. _stdout.write(_term.cursorDown); } curLine = lastLine; } else { // Move to column 0. _stdout.write('\r'); } // Work our way up, clearing lines. while (true) { _stdout.write(_term.clrEOL); if (curLine > 0) { _stdout.write(_term.cursorUp); } else { break; } } } void show() { if (_promptShown) { return; } _promptShown = true; _writePromptAndLine(); // If input was buffered while the prompt was hidden, process it // now. if (!_bufferedInput.isEmpty) { var input = _bufferedInput.toString(); _bufferedInput.clear(); _handleText(input); } } int _writeRange(List text, int pos, int writeToPos) { if (pos >= writeToPos) { return pos; } while (pos < writeToPos) { var margin = _nextMargin(pos); var limit = min(writeToPos, margin); _stdout.write(new String.fromCharCodes(text.getRange(pos, limit))); pos = limit; if (pos == margin) { _stdout.write('\n'); } } return pos; } int _clearRange(int pos, int clearToPos) { if (pos >= clearToPos) { return pos; } while (true) { var limit = _nextMargin(pos); _stdout.write(_term.clrEOL); if (limit >= clearToPos) { return pos; } _stdout.write('\n'); pos = limit; } } int _move(int pos, int newPos) { if (pos == newPos) { return pos; } var curCol = _getCol(pos); var curLine = _getLine(pos); var newCol = _getCol(newPos); var newLine = _getLine(newPos); if (curLine > newLine) { for (var i = 0; i < (curLine - newLine); i++) { _stdout.write(_term.cursorUp); } } if (curLine < newLine) { for (var i = 0; i < (newLine - curLine); i++) { _stdout.write(_term.cursorDown); } // Moving down resets column to zero, oddly. curCol = 0; } if (curCol > newCol) { for (var i = 0; i < (curCol - newCol); i++) { _stdout.write(_term.cursorBack); } } if (curCol < newCol) { for (var i = 0; i < (newCol - curCol); i++) { _stdout.write(_term.cursorForward); } } return newPos; } int _nextMargin(int pos) { var truePos = pos + prompt.length; return ((truePos ~/ _screenWidth) + 1) * _screenWidth - prompt.length; } int _getLine(int pos) { var truePos = pos + prompt.length; return truePos ~/ _screenWidth; } int _getCol(int pos) { var truePos = pos + prompt.length; return truePos % _screenWidth; } Stdin _stdin; StreamSubscription _stdinSubscription; IOSink _stdout; final String prompt; bool _promptShown = true; final CommandCompleter completer; TermInfo _term = new TermInfo(); // TODO(turnidge): See if we can get screen resize events. int _screenWidth; List _currentLine = []; // A list of runes. StringBuffer _bufferedInput = new StringBuffer(); List> _lines = []; // When using the command history, the current line is temporarily // added to the history to allow the user to return to it. This // values tracks whether the history has a temporary line at the end. bool _tempLineAdded = false; int _linePos = 0; int _cursorPos = 0; int _tabCount = 0; List _killBuffer = []; } // Demo code. List _myCompleter(List commandTokens) { List completions = new List(); // First word completions. if (commandTokens.length <= 1) { String prefix = ''; if (commandTokens.length == 1) { prefix = commandTokens.first; } if ('quit'.startsWith(prefix)) { completions.add('quit'); } if ('help'.startsWith(prefix)) { completions.add('help'); } if ('happyface'.startsWith(prefix)) { completions.add('happyface'); } } // Complete 'foobar' or 'gondola' anywhere in string. String lastWord = commandTokens.last; if ('foobar'.startsWith(lastWord)) { completions.add('foobar'); } if ('gondola'.startsWith(lastWord)) { completions.add('gondola'); } return completions; } int _helpCount = 0; Commando cmdo; void _handleCommand(String rawCommand) { String command = rawCommand.trim(); cmdo.hide(); if (command == 'quit') { cmdo.close().then((_) { print('Exiting'); }); } else if (command == 'help') { switch (_helpCount) { case 0: print('I will not help you.'); break; case 1: print('I mean it.'); break; case 2: print('Seriously.'); break; case 100: print('Well now.'); break; default: print("Okay. Type 'quit' to quit"); break; } _helpCount++; } else if (command == 'happyface') { print(':-)'); } else { print('Received command($command)'); } cmdo.show(); } void main() { print('[Commando demo]'); cmdo = new Commando(completer:_myCompleter); cmdo.commands.listen(_handleCommand); }