├── lib ├── theme.dart ├── terminal.dart └── src │ ├── model │ ├── display_attributes.dart │ ├── glyph.dart │ └── model.dart │ ├── theme.dart │ ├── input │ ├── input.dart │ └── input_keys.dart │ ├── controller.dart │ ├── output │ ├── output.dart │ └── escape_handler.dart │ └── terminal.dart ├── .gitignore ├── pubspec.yaml ├── example ├── server │ ├── web │ │ ├── index.html │ │ ├── main.css │ │ └── index.dart │ └── main.dart └── websocket │ └── web │ ├── index.html │ ├── main.css │ └── index.dart ├── CHANGELOG.md ├── LICENSE └── README.md /lib/theme.dart: -------------------------------------------------------------------------------- 1 | library theme; 2 | 3 | export 'src/theme.dart'; -------------------------------------------------------------------------------- /lib/terminal.dart: -------------------------------------------------------------------------------- 1 | library terminal; 2 | 3 | export 'src/terminal.dart'; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Don’t commit the following directories created by pub. 2 | .buildlog 3 | .pub/ 4 | build/ 5 | packages 6 | 7 | # Or the files created by dart2js. 8 | *.dart.js 9 | *.js_ 10 | *.js.deps 11 | *.js.map 12 | 13 | # Include when developing application packages. 14 | pubspec.lock 15 | 16 | # IDE/Editor stuff 17 | .idea -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: terminal 2 | version: 0.1.3 3 | authors: 4 | - Mike Lewis 5 | description: A terminal emulator written in Dart. Connect its I/O to a WebSocket or whatever you like. 6 | homepage: https://www.github.com/updroidinc/terminal 7 | environment: 8 | sdk: ">=1.10.1 <2.0.0" 9 | dependencies: 10 | browser: ^0.10.0 11 | quiver: ^0.21.3 12 | http_server: ^0.9.5 13 | path: ^1.3.9 14 | -------------------------------------------------------------------------------- /example/server/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | index 8 | 9 | 10 | 11 | 12 | 13 | 14 | Disconnected 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /example/websocket/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | index 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | Disconnected 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /example/server/web/main.css: -------------------------------------------------------------------------------- 1 | #invert { 2 | display: inline; 3 | margin: 2px 2px 2px 0; 4 | } 5 | 6 | #status { 7 | display: inline; 8 | color: red; 9 | } 10 | 11 | #status.connected { 12 | color: green; 13 | } 14 | 15 | .console { 16 | position: relative; 17 | width: 542px; 18 | height: 360px; 19 | overflow-y: hidden; 20 | overflow-x: hidden; 21 | } 22 | 23 | .terminal-output, 24 | .terminal-cursor { 25 | display: inline-block; 26 | font-size: 11px; 27 | font-family: "DejaVu Sans Mono", "Liberation Mono", "Andale Mono", monospace; 28 | } 29 | 30 | .terminal-output { 31 | width: calc(100% - 10px); 32 | height: calc(100% - 10px); 33 | padding: 5px; 34 | } 35 | 36 | .terminal-output:focus { 37 | outline: none; 38 | } 39 | 40 | .terminal-cursor { 41 | position: absolute; 42 | width: calc(299px / 45); 43 | height: 14px; 44 | background-color: transparent; 45 | } 46 | 47 | .termrow { 48 | height: 14px; 49 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.1.3 4 | 5 | - Fixed crash when server is not run from the project root 6 | - README updates 7 | - Organizational changes 8 | 9 | ## v0.1.2 10 | 11 | - Added another example to demonstrate alternate usage (with a webserver) 12 | - Updated README with example usage 13 | - Added 'invert' button to examples for toggling the built-in theme 14 | - Fixed bug with Theme not getting set properly 15 | - Fixed bug with some states not being carried over between models 16 | - Added a default constructor for Theme to pass in custom attributes 17 | 18 | ## v0.1.1 19 | 20 | - Fixed cursor blinking even when the terminal is not focused 21 | - Added simple controls to the example 22 | - Added a demo gif to the README 23 | 24 | ## v0.1.0 25 | 26 | - VT100 terminal emulation 27 | - Built-in Solarized Dark and Light themes 28 | - Programmable options such as enable/disable cursor blink 29 | - Example of a client-server application using Websockets 30 | -------------------------------------------------------------------------------- /example/websocket/web/main.css: -------------------------------------------------------------------------------- 1 | #invert, 2 | #connect { 3 | display: inline; 4 | margin: 2px; 5 | } 6 | 7 | #invert { 8 | margin-left: 0; 9 | } 10 | 11 | #status { 12 | display: inline; 13 | color: red; 14 | } 15 | 16 | #status.connected { 17 | color: green; 18 | } 19 | 20 | .console { 21 | position: relative; 22 | width: 542px; 23 | height: 360px; 24 | overflow-y: hidden; 25 | overflow-x: hidden; 26 | } 27 | 28 | .terminal-output, 29 | .terminal-cursor { 30 | display: inline-block; 31 | font-size: 11px; 32 | font-family: "DejaVu Sans Mono", "Liberation Mono", "Andale Mono", monospace; 33 | } 34 | 35 | .terminal-output { 36 | width: calc(100% - 10px); 37 | height: calc(100% - 10px); 38 | padding: 5px; 39 | } 40 | 41 | .terminal-output:focus { 42 | outline: none; 43 | } 44 | 45 | .terminal-cursor { 46 | position: absolute; 47 | width: calc(299px / 45); 48 | height: 14px; 49 | background-color: transparent; 50 | } 51 | 52 | .termrow { 53 | height: 14px; 54 | } -------------------------------------------------------------------------------- /lib/src/model/display_attributes.dart: -------------------------------------------------------------------------------- 1 | part of model; 2 | 3 | /// Holds the current state of [Terminal] display attributes. 4 | class DisplayAttributes { 5 | bool bright, dim, underscore, blink, reverse, hidden; 6 | String fgColor, bgColor; 7 | 8 | DisplayAttributes ({this.bright: false, this.dim: false, this.underscore: false, 9 | this.blink: false, this.reverse: false, this.hidden: false, 10 | this.fgColor: 'white', this.bgColor: 'black'}); 11 | 12 | String toString() { 13 | Map properties = { 14 | 'bright': bright, 15 | 'dim': dim, 16 | 'underscore': underscore, 17 | 'blink': blink, 18 | 'reverse': reverse, 19 | 'hidden': hidden, 20 | 'fgColor': fgColor, 21 | 'bgColor': bgColor 22 | }; 23 | return JSON.encode(properties); 24 | } 25 | 26 | void resetAll() { 27 | bright = false; 28 | dim = false; 29 | underscore = false; 30 | blink = false; 31 | reverse = false; 32 | hidden = false; 33 | 34 | fgColor = 'white'; 35 | bgColor = 'black'; 36 | } 37 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Mike Lewis (mike@updroid.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /lib/src/theme.dart: -------------------------------------------------------------------------------- 1 | library terminal.src.theme; 2 | 3 | /// A class for encapsulating various color themes 4 | /// for [Terminal]; 5 | class Theme { 6 | String name; 7 | Map colors; 8 | String foregroundColor, backgroundColor; 9 | 10 | // Manually calculated via trial-and-error. 11 | // TODO: make the character size customizable. 12 | final double charWidth = 299 / 45; 13 | final int charHeight = 14; 14 | 15 | Theme(this.name, this.colors, this.foregroundColor, this.backgroundColor); 16 | 17 | Theme.SolarizedDark() { 18 | name = 'solarized-dark'; 19 | colors = { 20 | 'black' : '#002b36', 21 | 'red' : '#dc322f', 22 | 'green' : '#859900', 23 | 'yellow' : '#b58900', 24 | 'blue' : '#268bd2', 25 | 'magenta' : '#d33682', 26 | 'cyan' : '#2aa198', 27 | 'white' : '#93a1a1' 28 | }; 29 | 30 | foregroundColor = colors['white']; 31 | backgroundColor = colors['black']; 32 | } 33 | 34 | Theme.SolarizedLight() { 35 | name = 'solarized-light'; 36 | colors = { 37 | 'black' : '#fdf6e3', 38 | 'red' : '#dc322f', 39 | 'green' : '#859900', 40 | 'yellow' : '#b58900', 41 | 'blue' : '#268bd2', 42 | 'magenta' : '#d33682', 43 | 'cyan' : '#2aa198', 44 | 'white' : '#586e75' 45 | }; 46 | 47 | foregroundColor = colors['white']; 48 | backgroundColor = colors['black']; 49 | } 50 | } -------------------------------------------------------------------------------- /lib/src/model/glyph.dart: -------------------------------------------------------------------------------- 1 | part of model; 2 | 3 | /// The data model class for an individual glyph within [Model]. 4 | class Glyph { 5 | static const SPACE = ' '; 6 | static const AMP = '&'; 7 | static const LT = '<'; 8 | static const GT = '>'; 9 | static const CURSOR = '▏'; 10 | 11 | // Cursor types: 12 | // (226 150 129) ▁ 13 | // (226 150 136) █ 14 | // (226 150 143) ▏ 15 | 16 | bool bright, dim, underscore, blink, reverse, hidden; 17 | String value, fgColor, bgColor; 18 | 19 | Glyph (this.value, DisplayAttributes attr) { 20 | bright = attr.bright; 21 | dim = attr.dim; 22 | underscore = attr.underscore; 23 | blink = attr.blink; 24 | reverse = attr.reverse; 25 | hidden = attr.hidden; 26 | fgColor = attr.fgColor; 27 | bgColor = attr.bgColor; 28 | } 29 | 30 | operator ==(Glyph other) { 31 | return (value == other.value 32 | && bright == other.bright 33 | && dim == other.dim 34 | && underscore == other.underscore 35 | && blink == other.blink 36 | && reverse == other.reverse 37 | && hidden == other.hidden 38 | && fgColor == other.fgColor 39 | && bgColor == other.bgColor); 40 | } 41 | 42 | bool hasSameAttributes(Glyph other) { 43 | return (bright == other.bright 44 | && dim == other.dim 45 | && underscore == other.underscore 46 | && blink == other.blink 47 | && reverse == other.reverse 48 | && hidden == other.hidden 49 | && fgColor == other.fgColor 50 | && bgColor == other.bgColor); 51 | } 52 | 53 | bool hasDefaults() { 54 | return (bright == false 55 | && dim == false 56 | && underscore == false 57 | && blink == false 58 | && reverse == false 59 | && hidden == false 60 | && fgColor == 'white' 61 | && bgColor == 'black'); 62 | } 63 | 64 | int get hashCode { 65 | List members = [bright, dim, underscore, blink, reverse, hidden, fgColor, bgColor]; 66 | return hashObjects(members); 67 | } 68 | 69 | String toString() { 70 | Map properties = { 71 | 'value': value, 72 | 'bright': bright, 73 | 'dim': dim, 74 | 'underscore': underscore, 75 | 'blink': blink, 76 | 'reverse': reverse, 77 | 'hidden': hidden, 78 | 'fgColor': fgColor, 79 | 'bgColor': bgColor 80 | }; 81 | return JSON.encode(properties); 82 | } 83 | } -------------------------------------------------------------------------------- /example/server/web/index.dart: -------------------------------------------------------------------------------- 1 | import 'dart:html'; 2 | import 'dart:async'; 3 | import 'dart:typed_data'; 4 | import 'package:terminal/terminal.dart'; 5 | import 'package:terminal/theme.dart'; 6 | 7 | WebSocket ws; 8 | SpanElement status; 9 | ButtonElement invert; 10 | Terminal term; 11 | 12 | void main() { 13 | status = querySelector('#status'); 14 | invert = querySelector('#invert'); 15 | 16 | term = new Terminal(querySelector('#console')) 17 | ..scrollSpeed = 3 18 | ..cursorBlink = true 19 | ..theme = new Theme.SolarizedDark(); 20 | 21 | List size = term.currentSize(); 22 | int rows = size[0]; 23 | int cols = size[1]; 24 | print('Terminal spawned with size: $rows x $cols'); 25 | print('└─> cmdr-pty size should be set to $rows x ${cols - 1}'); 26 | 27 | invert.onClick.listen((_) => invertTheme()); 28 | 29 | // Terminal input. 30 | term.stdin.stream.listen((data) { 31 | ws.sendByteBuffer(new Uint8List.fromList(data).buffer); 32 | }); 33 | 34 | restartWebsocket(); 35 | } 36 | 37 | void updateStatusConnect() { 38 | status.classes.add('connected'); 39 | status.text = 'Connected'; 40 | print('Terminal connected to server with status ${ws.readyState}.'); 41 | } 42 | 43 | void updateStatusDisconnect() { 44 | status.classes.remove('connected'); 45 | status.text = 'Disconnected'; 46 | print('Terminal disconnected due to CLOSE.'); 47 | } 48 | 49 | void restartWebsocket() { 50 | if (ws != null && ws.readyState == WebSocket.OPEN) ws.close(); 51 | String url = window.location.host.split(':')[0]; 52 | initWebSocket('ws://$url:8080/pty'); 53 | } 54 | 55 | void initWebSocket(String url, [int retrySeconds = 2]) { 56 | bool encounteredError = false; 57 | 58 | ws = new WebSocket(url); 59 | ws.binaryType = "arraybuffer"; 60 | 61 | ws.onOpen.listen((e) => updateStatusConnect()); 62 | 63 | // Terminal output. 64 | ws.onMessage.listen((e) { 65 | ByteBuffer buf = e.data; 66 | term.stdout.add(buf.asUint8List()); 67 | }); 68 | 69 | ws.onClose.listen((e) => updateStatusDisconnect()); 70 | 71 | ws.onError.listen((e) { 72 | print('Terminal disconnected due to ERROR. Retrying...'); 73 | if (!encounteredError) { 74 | new Timer(new Duration(seconds:retrySeconds), () => initWebSocket(url, 4)); 75 | } 76 | encounteredError = true; 77 | }); 78 | } 79 | 80 | void invertTheme() { 81 | if (term.theme.name == 'solarized-dark') { 82 | term.theme = new Theme.SolarizedLight(); 83 | } else { 84 | term.theme = new Theme.SolarizedDark(); 85 | } 86 | } -------------------------------------------------------------------------------- /example/websocket/web/index.dart: -------------------------------------------------------------------------------- 1 | import 'dart:html'; 2 | import 'dart:async'; 3 | import 'dart:typed_data'; 4 | import 'package:terminal/terminal.dart'; 5 | import 'package:terminal/theme.dart'; 6 | 7 | WebSocket ws; 8 | InputElement address; 9 | ButtonElement connect, invert; 10 | SpanElement status; 11 | Terminal term; 12 | 13 | void main() { 14 | address = querySelector('#address'); 15 | connect = querySelector('#connect'); 16 | invert = querySelector('#invert'); 17 | status = querySelector('#status'); 18 | 19 | term = new Terminal(querySelector('#console')) 20 | ..scrollSpeed = 3 21 | ..cursorBlink = true 22 | ..theme = new Theme.SolarizedDark(); 23 | 24 | List size = term.currentSize(); 25 | int rows = size[0]; 26 | int cols = size[1]; 27 | print('Terminal spawned with size: $rows x $cols'); 28 | print('└─> cmdr-pty size should be set to $rows x ${cols - 1}'); 29 | 30 | address.onKeyPress 31 | .where((e) => e.keyCode == KeyCode.ENTER) 32 | .listen((_) => restartWebsocket()); 33 | 34 | connect.onClick.listen((_) => restartWebsocket()); 35 | invert.onClick.listen((_) => invertTheme()); 36 | 37 | // Terminal input. 38 | term.stdin.stream.listen((data) { 39 | ws.sendByteBuffer(new Uint8List.fromList(data).buffer); 40 | }); 41 | } 42 | 43 | void updateStatusConnect() { 44 | status.classes.add('connected'); 45 | status.text = 'Connected'; 46 | print('Terminal connected to server with status ${ws.readyState}.'); 47 | } 48 | 49 | void updateStatusDisconnect(Event e) { 50 | status.classes.remove('connected'); 51 | status.text = 'Disconnected'; 52 | print('Terminal disconnected with status ${ws.readyState}.'); 53 | } 54 | 55 | void restartWebsocket() { 56 | if (address.value == '') return; 57 | 58 | if (ws != null && ws.readyState == WebSocket.OPEN) ws.close(); 59 | initWebSocket('ws://${address.value}/pty'); 60 | } 61 | 62 | void initWebSocket(String url, [int retrySeconds = 2]) { 63 | ws = new WebSocket(url); 64 | ws.binaryType = "arraybuffer"; 65 | 66 | ws.onOpen.listen((e) => updateStatusConnect()); 67 | 68 | // Terminal output. 69 | ws.onMessage.listen((e) { 70 | ByteBuffer buf = e.data; 71 | term.stdout.add(buf.asUint8List()); 72 | }); 73 | 74 | ws.onClose.listen((e) => updateStatusDisconnect(e)); 75 | ws.onError.listen((e) => updateStatusDisconnect(e)); 76 | } 77 | 78 | void invertTheme() { 79 | if (term.theme.name == 'solarized-dark') { 80 | term.theme = new Theme.SolarizedLight(); 81 | } else { 82 | term.theme = new Theme.SolarizedDark(); 83 | } 84 | } -------------------------------------------------------------------------------- /lib/src/input/input.dart: -------------------------------------------------------------------------------- 1 | library terminal.src.input.input; 2 | 3 | import 'dart:html'; 4 | import 'dart:async'; 5 | 6 | import '../model/model.dart'; 7 | 8 | part 'input_keys.dart'; 9 | 10 | class InputHandler { 11 | StreamController> stdin; 12 | 13 | InputHandler() { 14 | stdin = new StreamController>(); 15 | } 16 | 17 | /// Handles a given [KeyboardEvent]. 18 | void handleInput(KeyboardEvent e, Model model, StreamController stdout) { 19 | int key = e.keyCode; 20 | 21 | // Don't let solo modifier keys through (Shift=16, Ctrl=17, Meta=91, Alt=18). 22 | if (key == 16 || key == 17 || key == 91 || key == 18) return; 23 | 24 | // Don't let other keys that don't make sense in a vt100 terminal through. 25 | // (INSERT=45, PAGEUP=33, PAGEDOWN=34). 26 | if (key == 45 || key == 33 || key == 34) return; 27 | 28 | // Special handling of DELETE, HOME, and END keys. 29 | // TODO: fix this when xterm emulation is supported. 30 | switch (key) { 31 | case 46: 32 | _handleDeleteKey(stdout); 33 | return; 34 | case 36: 35 | _handleHomeKey(stdout); 36 | return; 37 | case 35: 38 | _handleEndKey(stdout); 39 | return; 40 | } 41 | 42 | // Arrow keys. 43 | if (CURSOR_KEYS_NORMAL.containsKey(key)) { 44 | bool normKeys = model.cursorkeys == CursorkeysMode.NORMAL; 45 | stdin.add(normKeys ? CURSOR_KEYS_NORMAL[key] : CURSOR_KEYS_APP[key]); 46 | return; 47 | } 48 | 49 | // keyCode behaves very oddly. 50 | if (!e.shiftKey) { 51 | if (NOSHIFT_KEYS.containsKey(key)) { 52 | key = NOSHIFT_KEYS[key]; 53 | } 54 | } else { 55 | if (SHIFT_KEYS.containsKey(key)) { 56 | key = SHIFT_KEYS[key]; 57 | } 58 | } 59 | 60 | // Carriage Return (13) => New Line (10). 61 | if (key == 13) key = 10; 62 | 63 | // Ctrl-V, Ctrl-C, Ctrl-Z. 64 | if (e.ctrlKey) { 65 | print(key.toString()); 66 | if (key == 118) { 67 | document.execCommand('paste', null, ""); 68 | return; 69 | } 70 | if (key == 99) key = 3; 71 | if (key == 122) key = 26; 72 | } 73 | 74 | stdin.add([key]); 75 | } 76 | 77 | Future _listenForBell(StreamController stdout) { 78 | return stdout.stream.first.then((e) => e.contains(7)); 79 | } 80 | 81 | void _handleDeleteKey(StreamController stdout) { 82 | stdin.add([27, 91, 67, 8]); 83 | } 84 | 85 | Future _handleHomeKey(StreamController stdout) async { 86 | stdin.add([27, 91, 68]); 87 | while (!await _listenForBell(stdout)) { 88 | stdin.add([27, 91, 68]); 89 | } 90 | } 91 | 92 | Future _handleEndKey(StreamController stdout) async { 93 | stdin.add([27, 91, 67]); 94 | while (!await _listenForBell(stdout)) { 95 | stdin.add([27, 91, 67]); 96 | } 97 | } 98 | } -------------------------------------------------------------------------------- /lib/src/input/input_keys.dart: -------------------------------------------------------------------------------- 1 | part of input; 2 | 3 | // Remapping of Dart keyCodes (whatever they really are) 4 | // to UTF8 integers of their Non-Shift equivalents. 5 | const Map NOSHIFT_KEYS = const { 6 | 65: 97, // A => a 7 | 66: 98, // B => b 8 | 67: 99, // C => c 9 | 68: 100, // D => d 10 | 69: 101, // E => e 11 | 70: 102, // F => f 12 | 71: 103, // G => g 13 | 72: 104, // H => h 14 | 73: 105, // I => i 15 | 74: 106, // J => j 16 | 75: 107, // K => k 17 | 76: 108, // L => l 18 | 77: 109, // M => m 19 | 78: 110, // N => n 20 | 79: 111, // O => o 21 | 80: 112, // P => p 22 | 81: 113, // Q => q 23 | 82: 114, // R => r 24 | 83: 115, // S => s 25 | 84: 116, // T => t 26 | 85: 117, // U => u 27 | 86: 118, // V => v 28 | 87: 119, // W => w 29 | 88: 120, // X => x 30 | 89: 121, // Y => y 31 | 90: 122, // Z => z 32 | 33 | // Num Lock 34 | 96: 48, // ` => 0 35 | 97: 49, // a => 1 36 | 98: 50, // b => 2 37 | 99: 51, // c => 3 38 | 100: 52, // d => 4 39 | 101: 53, // e => 5 40 | 102: 54, // f => 6 41 | 103: 55, // g => 7 42 | 104: 56, // h => 8 43 | 105: 57, // i => 9 44 | 110: 46, // n => . 45 | 111: 47, // o => / 46 | 106: 42, // j => * 47 | 109: 45, // m => - 48 | 107: 43, // k => + 49 | 50 | 186: 59, // : => ; 51 | 187: 61, // + => = 52 | 188: 44, // < => , 53 | 189: 45, // _ => - 54 | 190: 46, // > => . 55 | 191: 47, // ? => / 56 | 192: 96, // ~ => ` 57 | 219: 91, // { => [ 58 | 220: 92, // | => \ 59 | 221: 93, // } => ] 60 | 222: 39 // " => ' 61 | }; 62 | 63 | // Remapping of Dart keyCodes (whatever they really are) 64 | // to UTF8 integers of their Non-Shift equivalents. 65 | const SHIFT_KEYS = const { 66 | 48: 41, // 0 => ) 67 | 49: 33, // 1 => ! 68 | 50: 64, // 2 => @ 69 | 51: 35, // 3 => # 70 | 52: 36, // 4 => $ 71 | 53: 37, // 5 => % 72 | 54: 94, // 6 => ^ 73 | 55: 38, // 7 => & 74 | 56: 42, // 8 => * 75 | 57: 40, // 9 => ( 76 | 186: 58, // : 77 | 187: 43, // + 78 | 188: 60, // < 79 | 189: 95, // _ 80 | 190: 62, // > 81 | 191: 63, // ? 82 | 192: 126, // ~ 83 | 219: 123, // { 84 | 220: 124, // | 85 | 221: 125, // } 86 | 222: 34 // " 87 | }; 88 | 89 | Map CURSOR_KEYS_NORMAL = { 90 | 38: [27, 91, 65], // UP 91 | 40: [27, 91, 66], // DOWN 92 | 37: [27, 91, 68], // LEFT 93 | 39: [27, 91, 67] // RIGHT 94 | }; 95 | 96 | Map CURSOR_KEYS_APP = { 97 | 38: [107], // UP 98 | 40: [106], // DOWN 99 | 37: [104], // LEFT 100 | 39: [108] // RIGHT 101 | }; 102 | 103 | const Map NON_MODIFIABLE_KEYS = const { 104 | 8: 'BACKSPACE', 105 | 9: 'TAB', 106 | 13: 'ENTER', 107 | 19: 'PAUSE', 108 | 20: 'CAPS_LOCK', 109 | 27: 'ESC', 110 | 32: 'SPACE', 111 | 33: 'PAGE_UP', 112 | 34: 'PAGE_DOWN', 113 | 35: 'END', 114 | 36: 'HOME', 115 | 37: 'LEFT', 116 | 38: 'UP', 117 | 39: 'RIGHT', 118 | 40: 'DOWN', 119 | 45: 'INSERT', 120 | 46: 'DELETE', 121 | 91: 'LWIN', 122 | 92: 'RWIN', 123 | 112: 'F1', 124 | 113: 'F2', 125 | 114: 'F3', 126 | 115: 'F4', 127 | 116: 'F5', 128 | 117: 'F6', 129 | 118: 'F7', 130 | 119: 'F8', 131 | 120: 'F9', 132 | 121: 'F10', 133 | 122: 'F11', 134 | 123: 'F12', 135 | 144: 'NUM_LOCK', 136 | 145: 'SCROLL_LOCK' 137 | }; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Terminal 2 | 3 | A terminal emulator written in Dart. 4 | 5 | Connect its I/O to a WebSocket or whatever you like. Originally developed for use with [cmdr-pty] in [UpDroid Commander]. 6 | 7 | ![Imgur](http://i.imgur.com/Bz8St7a.gif) 8 | 9 | ## Package Usage 10 | 11 | ***Tested with Dart 1.10.1*** 12 | 13 | Terminal's only parameter is a DivElement - essentially a box that Terminal will be rendered in. 14 | 15 | The major components are the I/O streams. They are expected to be hooked up to ByteBuffers of Uint8Lists. Best used by hooking up to a backend, like [cmdr-pty], that sends/receives data in UTF-8. But it could also be used with text/data that resides solely in the browser application. 16 | 17 | Theme is a separate library that contains built-in color schemes. 18 | 19 | There are other options like scroll speed, cursor blink, and the theme. 20 | 21 | See example/ for more details. 22 | 23 | ## Examples 24 | 25 | There are two examples, found in the example/ dir. Both of these require Dartium (special Dart-enabled build of Chromium) unless you know how to compile the examples using dart2js (you're on your own for that). 26 | 27 | Common setup: 28 | ```bash 29 | cd /path/to/terminal 30 | pub get 31 | ``` 32 | 33 | ### Server 34 | 35 | This example demonstrates how to build a client-server application that serves a Terminal client and also connects this client to a pty [cmdr-pty] on the server side via TCP socket. 36 | 37 | ```bash 38 | chmod +x example/server/main.dart 39 | dart example/server/main.dart 40 | ``` 41 | 42 | A webserver will begin running, then you may open Dartium and point it to "localhost:8080". 43 | 44 | ### Websocket 45 | 46 | This example demonstrates how to build a client-side-only Terminal application that connects directly to a pty backend via Websocket. In other words, this is like the server example, but with the built-in webserver removed. 47 | 48 | Requires [cmdr-pty]. Follow the directions to run cmdr-pty in a regular terminal - the default settings should be fine. 49 | 50 | Then run your own webserver (like [http-server] via npm) on `/path/to/terminal/example/websocket/web/index.html`. 51 | 52 | ## Known Issues 53 | 54 | - Terminal only supports vt100 mode at the moment. Make sure to `export TERM=vt100` if using [cmdr-pty] before running it. 55 | - Appearance is fine-tuned to the specific styling used in example/main.css. Deviation from the font-family and size will break Terminal's appearance at best. 56 | 57 | ## Contribute 58 | 59 | Pull requests welcome! Though, I reserve the right to review and/or reject them at will. 60 | Can also file issues with the issue tracker. 61 | 62 | I prefer commit messages to start with the library/component mostly affected by the commit, but this style isn't required for contributions. 63 | 64 | ### TODO: 65 | 66 | - Support for vt102, xterm. 67 | - Add resizing to the example clients. 68 | - Improve performance. 69 | - More themes. 70 | - Upgrade supported SDK version to 1.13.x. 71 | - Add dartfmt to [git pre-commit hook]. 72 | - Unit tests. 73 | 74 | ## Acknowledgements 75 | 76 | Heavily inspired by the [term.js] project by (chjj) Christopher Jeffrey. But I needed a more flexible, native-Dart implementation for [UpDroid Commander]. 77 | 78 | [cmdr-pty]: https://github.com/updroidinc/cmdr-pty/ 79 | [UpDroid Commander]: http://updroid.com/upcom/ 80 | [term.js]: https://github.com/chjj/term.js/ 81 | [http-server]: https://www.npmjs.com/package/http-server 82 | [git pre-commit hook]: http://blog.sethladd.com/2015/04/formatting-dart-code-before-every-git.html 83 | -------------------------------------------------------------------------------- /lib/src/controller.dart: -------------------------------------------------------------------------------- 1 | library terminal.src.controller; 2 | 3 | import 'dart:async'; 4 | import 'dart:html'; 5 | 6 | import 'theme.dart'; 7 | import 'model/model.dart'; 8 | 9 | class Controller { 10 | DivElement div; 11 | DivElement cursor; 12 | Model _model; 13 | Theme _theme; 14 | bool resizing; 15 | bool cursorBlink = true; 16 | Timer _blinkTimer, _blinkTimeout; 17 | bool blinkOn; 18 | 19 | /// Returns current [Theme]. 20 | Theme get theme => _theme; 21 | /// Sets a [Terminal]'s [Theme]. Default: Solarized-Dark. 22 | void set theme(Theme thm) => setTheme(thm); 23 | 24 | Controller(this.div, this.cursor, Model model, Theme theme) { 25 | _model = model; 26 | _theme = theme; 27 | resizing = false; 28 | 29 | blinkOn = false; 30 | setUpBlink(); 31 | } 32 | 33 | void setTheme(Theme thm) { 34 | _theme = thm; 35 | div.style.backgroundColor = _theme.backgroundColor; 36 | div.style.color = _theme.colors['white']; 37 | refreshDisplay(); 38 | } 39 | 40 | void setCursorBlink(bool b) { 41 | cursorBlink = b; 42 | 43 | cancelBlink(); 44 | setUpBlink(); 45 | } 46 | 47 | void setUpBlink() { 48 | if (!cursorBlink) return; 49 | 50 | _blinkTimeout = new Timer(new Duration(milliseconds: 1000), () { 51 | _blinkTimer = new Timer.periodic(new Duration(milliseconds: 500), (timer) { 52 | blinkOn = !blinkOn; 53 | _drawCursor(); 54 | }); 55 | }); 56 | } 57 | 58 | void cancelBlink() { 59 | if (_blinkTimeout != null) _blinkTimeout.cancel(); 60 | if (_blinkTimer != null) _blinkTimer.cancel(); 61 | } 62 | 63 | void _drawCursor() { 64 | if (document.activeElement == div) { 65 | cursor.style.visibility = blinkOn ? 'visible' : 'hidden'; 66 | } else { 67 | cursor.style.visibility = 'visible'; 68 | } 69 | 70 | cursor.style.color = _theme.colors['white']; 71 | // TODO: make offset calculation dynamic. 72 | cursor.style.left = ((_model.cursor.col * _theme.charWidth) + 5).toString() + 'px'; 73 | cursor.style.top = ((_model.cursor.row * _theme.charHeight) + 5).toString() + 'px'; 74 | } 75 | 76 | /// Generates the HTML for an individual row given 77 | /// the [Glyph]s contained in the model at that 78 | /// corresponding row. 79 | DivElement _generateRow(int r) { 80 | Glyph prev, curr; 81 | 82 | DivElement row = new DivElement(); 83 | String str = ''; 84 | prev = _model.getGlyphAt(r, 0); 85 | for (int c = 0; c < _model.numCols; c++) { 86 | curr = _model.getGlyphAt(r, c); 87 | 88 | if (!curr.hasSameAttributes(prev) || c == _model.numCols - 1) { 89 | if (prev.hasDefaults()) { 90 | row.append(new DocumentFragment.html(str)); 91 | } else { 92 | SpanElement span = new SpanElement(); 93 | span.style.color = _theme.colors[prev.fgColor]; 94 | span.style.backgroundColor = _theme.colors[prev.bgColor]; 95 | span.append(new DocumentFragment.html(str)); 96 | row.append(span); 97 | } 98 | 99 | str = ''; 100 | } 101 | 102 | str += curr.value; 103 | prev = curr; 104 | } 105 | 106 | return row; 107 | } 108 | 109 | /// Refreshes the entire console [DivElement] by setting its 110 | /// contents to null and regenerating each row [DivElement]. 111 | void refreshDisplay() { 112 | div.innerHtml = ''; 113 | 114 | DivElement row; 115 | for (int r = 0; r < _model.numRows; r++) { 116 | row = _generateRow(r); 117 | row.classes.add('termrow'); 118 | 119 | div.append(row); 120 | } 121 | 122 | _drawCursor(); 123 | } 124 | } -------------------------------------------------------------------------------- /lib/src/output/output.dart: -------------------------------------------------------------------------------- 1 | library terminal.src.output.output; 2 | 3 | import 'dart:async'; 4 | import 'dart:convert'; 5 | 6 | import '../model/model.dart'; 7 | import '../controller.dart'; 8 | 9 | part 'escape_handler.dart'; 10 | 11 | class OutputHandler { 12 | static const int ESC = 27; 13 | 14 | StreamController> stdout; 15 | 16 | List _incompleteEscape; 17 | 18 | OutputHandler() { 19 | stdout = new StreamController>.broadcast(); 20 | _incompleteEscape = []; 21 | } 22 | 23 | /// Processes [output] by coordinating handling of strings 24 | /// and escape parsing. 25 | void processStdOut(List output, Controller controller, StreamController stdin, Model model, DisplayAttributes currAttributes) { 26 | //print('incoming output: ' + output.toString()); 27 | 28 | // Insert the incompleteEscape from last processing if exists. 29 | List outputToProcess = new List.from(_incompleteEscape); 30 | _incompleteEscape = []; 31 | outputToProcess.addAll(output); 32 | 33 | int nextEsc; 34 | while (outputToProcess.isNotEmpty) { 35 | nextEsc = outputToProcess.indexOf(ESC); 36 | if (nextEsc == -1) { 37 | _handleOutString(outputToProcess, model, controller, currAttributes); 38 | return; 39 | } else { 40 | _handleOutString(outputToProcess.sublist(0, nextEsc), model, controller, currAttributes); 41 | outputToProcess = _parseEscape(outputToProcess.sublist(nextEsc), controller, stdin, model, currAttributes); 42 | } 43 | } 44 | } 45 | 46 | /// Parses out escape sequences. When it finds one, 47 | /// it handles it and returns the remainder of [output]. 48 | List _parseEscape(List output, Controller controller, StreamController stdin, Model model, DisplayAttributes currAttributes) { 49 | List escape; 50 | int termIndex; 51 | 52 | for (int i = 1; i <= output.length; i++) { 53 | termIndex = i; 54 | escape = output.sublist(0, i); 55 | 56 | bool escapeHandled = EscapeHandler.handleEscape(escape, stdin, model, currAttributes); 57 | if (escapeHandled) { 58 | controller.refreshDisplay(); 59 | return output.sublist(termIndex); 60 | } 61 | } 62 | 63 | _incompleteEscape = new List.from(output); 64 | return []; 65 | } 66 | 67 | /// Appends a new [SpanElement] with the contents of [_outString] 68 | /// to the [_buffer] and updates the display. 69 | void _handleOutString(List string, Model model, Controller controller, DisplayAttributes currAttributes) { 70 | //print('string: ' + string.toString()); 71 | var codes = UTF8.decode(string).codeUnits; 72 | for (var code in codes) { 73 | String char = new String.fromCharCode(code); 74 | 75 | if (code == 8) { 76 | model.backspace(); 77 | continue; 78 | } 79 | 80 | switch (code) { 81 | case 32: 82 | char = Glyph.SPACE; 83 | break; 84 | case 60: 85 | char = Glyph.LT; 86 | break; 87 | case 62: 88 | char = Glyph.GT; 89 | break; 90 | case 38: 91 | char = Glyph.AMP; 92 | break; 93 | case 10: 94 | if (controller.resizing) { 95 | controller.resizing = false; 96 | continue; 97 | } 98 | model.cursorNewLine(); 99 | continue; 100 | case 13: 101 | model.cursorCarriageReturn(); 102 | continue; 103 | case 7: 104 | continue; 105 | case 8: 106 | continue; 107 | } 108 | 109 | // To differentiate between an early CR (like from a prompt) and linewrap. 110 | if (model.cursor.col >= model.numCols - 1) { 111 | model.cursorCarriageReturn(); 112 | model.cursorNewLine(); 113 | } else { 114 | Glyph g = new Glyph(char, currAttributes); 115 | model.setGlyphAt(g, model.cursor.row, model.cursor.col); 116 | model.cursorForward(); 117 | } 118 | } 119 | 120 | controller.refreshDisplay(); 121 | } 122 | } -------------------------------------------------------------------------------- /example/server/main.dart: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env dart 2 | 3 | import 'dart:io'; 4 | import 'dart:convert'; 5 | 6 | import 'package:http_server/http_server.dart'; 7 | import 'package:path/path.dart' as path; 8 | 9 | Process pty; 10 | Socket socket; 11 | WebSocket websocket; 12 | 13 | /// Initializes and HTTP server to serve the gui and handle [WebSocket] requests. 14 | void initServer() { 15 | // Set up an HTTP webserver and listen for standard page requests or upgraded 16 | // [WebSocket] requests. 17 | HttpServer.bind(InternetAddress.ANY_IP_V4, 8080).then((HttpServer server) { 18 | print("HttpServer listening on port:${server.port}..."); 19 | server.asBroadcastStream() 20 | .listen((HttpRequest request) => routeRequest(request)) 21 | .asFuture() // Automatically cancels on error. 22 | .catchError((_) => print("caught error")); 23 | }); 24 | } 25 | 26 | /// Routes a request between standard requests and upgraded (websocket) requests. 27 | void routeRequest(HttpRequest request) { 28 | // WebSocket requests are considered "upgraded" HTTP requests. 29 | if (!WebSocketTransformer.isUpgradeRequest(request)) { 30 | handleStandardRequest(request); 31 | return; 32 | } 33 | 34 | print('Upgraded request received: ${request.uri.path}'); 35 | WebSocketTransformer.upgrade(request).then((WebSocket w) { 36 | websocket = w; 37 | startPty(); 38 | }); 39 | } 40 | 41 | /// Returns a [VirtualDirectory] set up with a path to the web directory. 42 | VirtualDirectory getVirDir() { 43 | String guiPath = path.join(path.dirname(Platform.script.toFilePath()), 'web'); 44 | 45 | VirtualDirectory virDir = new VirtualDirectory(guiPath) 46 | ..allowDirectoryListing = true 47 | ..followLinks = true 48 | ..jailRoot = false; 49 | 50 | // Redirects '/' to 'index.html' 51 | virDir.directoryHandler = (dir, request) { 52 | var indexUri = new Uri.file(dir.path).resolve('index.html'); 53 | virDir.serveFile(new File(indexUri.toFilePath()), request); 54 | }; 55 | 56 | return virDir; 57 | } 58 | 59 | /// Handler for standard HTTP requests, like file transfers. 60 | void handleStandardRequest(HttpRequest request) { 61 | print('${request.method} request for: ${request.uri.path}'); 62 | 63 | VirtualDirectory virDir = getVirDir(); 64 | if (virDir != null) { 65 | virDir.serveRequest(request); 66 | } else { 67 | print('ERROR: no Virtual Directory to serve'); 68 | } 69 | } 70 | 71 | /// Spawns an instance of cmdr-pty as the terminal backend. 72 | /// cmdr-pty is a go program that provides a direct hook to a system pty. 73 | /// See http://www.github.com/updroidinc/cmdr-pty 74 | void startPty() { 75 | Process.start('cmdr-pty', ['-p', 'tcp'], environment: {'TERM':'vt100'}).then((Process p) { 76 | pty = p; 77 | 78 | pty.stderr.transform(UTF8.decoder).listen((data) => print('[cmdr-pty stderr]: $data')); 79 | pty.stdout.transform(UTF8.decoder).listen((data) { 80 | if (data.contains('listening on port: ')) { 81 | // Get the port returned by cmdr-pty. 82 | String port = data.replaceFirst('listening on port: ', ''); 83 | 84 | // Connect and start handling IO. 85 | Socket.connect('127.0.0.1', int.parse(port)).then((s) => handleIO(s)); 86 | } 87 | 88 | print('[cmdr-pty stdout]: $data'); 89 | }); 90 | }).catchError((error) { 91 | if (error is! ProcessException) throw error; 92 | print('cmdr-pty: run failed. check installation and/or path.'); 93 | return; 94 | }); 95 | } 96 | 97 | /// Manages the IO between websocket and socket for the terminal. 98 | void handleIO(Socket s) { 99 | socket = s; 100 | print('server example connected to cmdr-pty via: ${socket.remoteAddress.address}:${socket.remotePort}'); 101 | 102 | // Output from cmdr-pty -> the client connected via websocket. 103 | socket.listen((data) => websocket.add((data))); 104 | // Input from the client connected via websocket -> cmdr-pty. 105 | websocket.listen((data) => socket.add(data)).onDone(() => cleanUp()); 106 | } 107 | 108 | /// Cleans up all the sockets and processes started by this program. 109 | void cleanUp() { 110 | socket.close(); 111 | websocket.close(); 112 | pty.kill(); 113 | } 114 | 115 | void main() { 116 | initServer(); 117 | } -------------------------------------------------------------------------------- /lib/src/terminal.dart: -------------------------------------------------------------------------------- 1 | library terminal.src.terminal; 2 | 3 | import 'dart:html'; 4 | import 'dart:async'; 5 | 6 | import 'theme.dart'; 7 | import 'controller.dart'; 8 | import 'model/model.dart'; 9 | import 'input/input.dart'; 10 | import 'output/output.dart'; 11 | 12 | /// A class for rendering a terminal emulator in a [DivElement] (param). 13 | /// [stdout] needs to receive individual UTF8 integers and will handle 14 | /// them appropriately. 15 | class Terminal { 16 | /// The [DivElement] within which all [Terminal] graphical elements 17 | /// are rendered. 18 | DivElement div; 19 | 20 | /// A stream of [String], JSON-encoded UTF8 bytes (List). 21 | StreamController> get stdout => _outputHandler.stdout; 22 | 23 | /// A stream of [String], JSON-encoded UTF8 bytes (List). 24 | StreamController> get stdin => _inputHandler.stdin; 25 | 26 | /// An int that sets the number of lines scrolled per mouse 27 | /// wheel event. Default: 3 28 | int scrollSpeed = 3; 29 | 30 | /// Returns true if cursor blink is enabled. 31 | bool get cursorBlink => _controller.cursorBlink; 32 | /// Enable/disable cursor blink. Default: true 33 | void set cursorBlink(bool b) => _controller.setCursorBlink(b); 34 | 35 | /// Returns current [Theme]. 36 | Theme get theme => _controller.theme; 37 | /// Sets a [Terminal]'s [Theme]. Default: Solarized-Dark. 38 | void set theme(Theme thm) { 39 | _theme = thm; 40 | _controller.setTheme(thm); 41 | } 42 | 43 | // Private 44 | Model _model; 45 | DivElement _terminal; 46 | DivElement _cursor; 47 | Controller _controller; 48 | InputHandler _inputHandler; 49 | OutputHandler _outputHandler; 50 | DisplayAttributes _currAttributes; 51 | Theme _theme; 52 | 53 | Terminal (this.div) { 54 | _terminal = _createTerminalOutputDiv(); 55 | _cursor = _createTerminalCursorDiv(); 56 | 57 | _inputHandler = new InputHandler(); 58 | _outputHandler = new OutputHandler(); 59 | 60 | _currAttributes = new DisplayAttributes(); 61 | _theme = new Theme.SolarizedDark(); 62 | 63 | List size = calculateSize(); 64 | _model = new Model(size[0], size[1]); 65 | _controller = new Controller(_terminal, _cursor, _model, _theme); 66 | 67 | _controller.refreshDisplay(); 68 | 69 | _registerEventHandlers(); 70 | } 71 | 72 | List currentSize() { 73 | return [_model.numRows, _model.numCols]; 74 | } 75 | 76 | void resize(int newRows, int newCols) { 77 | _model = new Model.fromOldModel(newRows, newCols, _model); 78 | _controller.cancelBlink(); 79 | _controller = new Controller(_terminal, _cursor, _model, _theme); 80 | 81 | // User expects the prompt to appear after a resize. 82 | // Sending a \n results in a blank line above the first 83 | // prompt, so we handle this special case with a flag. 84 | _controller.resizing = true; 85 | stdin.add([10]); 86 | } 87 | 88 | List calculateSize() { 89 | // The +1 on width is needed because bash throws an extra space 90 | // ahead of a linewrap for some reason. So if bash cols = 80, 91 | // then terminal cols = 81. 92 | int rows = _terminal.contentEdge.height ~/ _theme.charHeight; 93 | int cols = _terminal.contentEdge.width ~/ _theme.charWidth + 1; 94 | 95 | // Set a default if the calculated size is unusable. 96 | if (rows < 10 || cols < 10) { 97 | rows = 25; 98 | cols = 80; 99 | } 100 | 101 | return [rows, cols]; 102 | } 103 | 104 | DivElement _createTerminalOutputDiv() { 105 | // contenteditable is important for clipboard paste functionality. 106 | DivElement termOutput = new DivElement() 107 | ..tabIndex = 0 108 | ..classes.add('terminal-output') 109 | ..spellcheck = false; 110 | 111 | // TODO: figure out how to enable copy/paste via context menu with this, 112 | // and hide the contenteditable cursor that comes with it. 113 | //termOutput.contentEditable = 'true'; 114 | 115 | div.children.add(termOutput); 116 | return termOutput; 117 | } 118 | 119 | DivElement _createTerminalCursorDiv() { 120 | DivElement termCursor = new DivElement() 121 | ..classes.add('terminal-cursor') 122 | ..text = Glyph.CURSOR; 123 | 124 | div.children.add(termCursor); 125 | return termCursor; 126 | } 127 | 128 | void _registerEventHandlers() { 129 | stdout.stream.listen((List out) => _outputHandler.processStdOut(new List.from(out), _controller, stdin, _model, _currAttributes)); 130 | 131 | _terminal.onKeyDown.listen((e) { 132 | e.preventDefault(); 133 | 134 | // Deactivate blinking while the user is typing. 135 | // Reactivate after an idle period. 136 | _controller.cancelBlink(); 137 | _controller.blinkOn = true; 138 | _model.scrollToBottom(); 139 | _controller.setUpBlink(); 140 | 141 | _inputHandler.handleInput(e, _model, stdout); 142 | }); 143 | 144 | _terminal.onMouseWheel.listen((wheelEvent) { 145 | // Scrolling should target only the console. 146 | wheelEvent.preventDefault(); 147 | 148 | cursorBlink = (_model.atBottom) ? true : false; 149 | _controller.blinkOn = false; 150 | (wheelEvent.deltaY < 0) ? _model.scrollUp(scrollSpeed) : _model.scrollDown(scrollSpeed); 151 | _controller.refreshDisplay(); 152 | }); 153 | 154 | _terminal.onPaste.listen((e) { 155 | String pasteString = e.clipboardData.getData('text'); 156 | for (int i in pasteString.runes) { 157 | stdin.add([i]); 158 | } 159 | }); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /lib/src/model/model.dart: -------------------------------------------------------------------------------- 1 | library terminal.src.model.model; 2 | 3 | import 'dart:async'; 4 | import 'dart:convert'; 5 | 6 | import 'package:quiver/core.dart'; 7 | 8 | part 'display_attributes.dart'; 9 | part 'glyph.dart'; 10 | 11 | class Cursor { 12 | int row = 0; 13 | int col = 0; 14 | 15 | String toString () { 16 | return 'row: $row, col: $col'; 17 | } 18 | } 19 | 20 | enum KeypadMode { NUMERIC, APPLICATION } 21 | enum CursorkeysMode { NORMAL, APPLICATION } 22 | 23 | /// Represents the data model for [Terminal]. 24 | class Model { 25 | static const int _MAXBUFFER = 500; 26 | 27 | bool get atBottom =>_forwardBuffer.isEmpty; 28 | 29 | Cursor cursor; 30 | int numRows, numCols; 31 | KeypadMode keypad; 32 | CursorkeysMode cursorkeys; 33 | 34 | 35 | // Implemented as stacks in scrolling. 36 | List _reverseBuffer; 37 | List _forwardBuffer; 38 | 39 | // Implemented as a queue in scrolling. 40 | List _frame; 41 | 42 | // Tab locations. 43 | List _tabs; 44 | 45 | int _scrollStart, _scrollEnd; 46 | 47 | Model (this.numRows, this.numCols) { 48 | cursor = new Cursor(); 49 | keypad = KeypadMode.NUMERIC; 50 | cursorkeys = CursorkeysMode.NORMAL; 51 | 52 | _reverseBuffer = []; 53 | _forwardBuffer = []; 54 | _frame = []; 55 | 56 | _initModel(); 57 | } 58 | 59 | Model.fromOldModel(this.numRows, this.numCols, Model oldModel) { 60 | cursor = new Cursor(); 61 | keypad = oldModel.keypad; 62 | cursorkeys = oldModel.cursorkeys; 63 | 64 | oldModel.scrollToBottom(); 65 | // Puts all old content into the reverse buffer and starts clean. 66 | _reverseBuffer = oldModel._reverseBuffer; 67 | // Don't add blank lines. 68 | for (List row in oldModel._frame) { 69 | bool blank = true; 70 | for (Glyph g in row) { 71 | if (g.value != Glyph.SPACE && g.value != Glyph.CURSOR) { 72 | blank = false; 73 | break; 74 | } 75 | } 76 | if (!blank) _reverseBuffer.add(row); 77 | } 78 | 79 | // Trim off oldest content to keep buffer below max. 80 | if (_reverseBuffer.length > _MAXBUFFER) { 81 | _reverseBuffer = _reverseBuffer.sublist(_reverseBuffer.length - _MAXBUFFER); 82 | } 83 | _forwardBuffer = []; 84 | _frame = []; 85 | 86 | _initModel(); 87 | } 88 | 89 | /// Returns the [Glyph] at row, col. 90 | Glyph getGlyphAt(int row, int col) { 91 | if (col >= _frame[row].length) { 92 | _frame[row].add(new Glyph(Glyph.SPACE, new DisplayAttributes())); 93 | } 94 | return _frame[row][col]; 95 | } 96 | 97 | /// Sets a [Glyph] at location row, col. 98 | void setGlyphAt(Glyph g, int row, int col) { 99 | // TODO: add guards for setting Glyphs that are out of bounds 100 | // after a resize. 101 | if (_forwardBuffer.isEmpty) { 102 | _frame[row][col] = g; 103 | return; 104 | } 105 | 106 | _forwardBuffer.first[col] = g; 107 | } 108 | 109 | void backspace() { 110 | //setGlyphAt(new Glyph(Glyph.SPACE, new DisplayAttributes()), cursor.row, cursor.col); 111 | cursorBackward(); 112 | } 113 | 114 | void cursorHome(int row, int col) { 115 | // Detect screen scrolling when _scrollEnd switches from last line to second-to-last line. 116 | if (_scrollEnd == numRows - 2) { 117 | if (row == _scrollEnd) { 118 | _scrollDown(1); 119 | } else if (row == _scrollStart) { 120 | _scrollUp(1); 121 | } 122 | } 123 | 124 | cursor.row = row; 125 | cursor.col = col; 126 | } 127 | 128 | void cursorUp([int count]) { 129 | if (cursor.row <= 0) return; 130 | 131 | if (count == null) { 132 | cursor.row--; 133 | } else { 134 | cursor.row = (cursor.row - count <= 0) ? 0 : cursor.row - count; 135 | } 136 | } 137 | 138 | void cursorDown([int count]) { 139 | if (cursor.row >= numRows) return; 140 | 141 | if (count == null) { 142 | cursor.row++; 143 | } else { 144 | cursor.row = (cursor.row + count >= numRows) ? numRows - 1 : cursor.row + count; 145 | } 146 | } 147 | 148 | void cursorForward([int count]) { 149 | if (cursor.col >= numCols) return; 150 | 151 | if (count == null) { 152 | cursor.col++; 153 | } else { 154 | cursor.col = (cursor.col + count >= numCols) ? numCols - 1 : cursor.col + count; 155 | } 156 | } 157 | 158 | void cursorBackward([int count]) { 159 | if (cursor.col <= 0) return; 160 | 161 | if (count == null) { 162 | cursor.col--; 163 | } else { 164 | cursor.col = (cursor.col - count <= 0) ? 0 : cursor.col - count; 165 | } 166 | } 167 | 168 | void cursorCarriageReturn() { 169 | //print('cursorCarriageReturn'); 170 | cursor.col = 0; 171 | } 172 | 173 | void cursorNewLine() { 174 | //print('cursorNewLine'); 175 | if (_forwardBuffer.isNotEmpty) { 176 | _forwardBuffer.insert(0, new List()); 177 | for (int c = 0; c < numCols; c++) { 178 | _forwardBuffer.first.add(new Glyph(Glyph.SPACE, new DisplayAttributes())); 179 | } 180 | return; 181 | } 182 | 183 | if (cursor.row < numRows - 1) { 184 | cursor.row++; 185 | } else { 186 | _pushBuffer(); 187 | } 188 | } 189 | 190 | void setTab() { 191 | _tabs.add([cursor.row, cursor.col]); 192 | } 193 | 194 | void clearAllTabs() { 195 | _tabs.clear(); 196 | } 197 | 198 | /// Erases from the current cursor position to the end of the current line. 199 | void eraseEndOfLine() { 200 | //cursorBackward(); 201 | for (int i = cursor.col; i < _frame[cursor.row].length; i++) { 202 | setGlyphAt(new Glyph(Glyph.SPACE, new DisplayAttributes()), cursor.row, i); 203 | } 204 | } 205 | 206 | void eraseDown() { 207 | int cursorRow = cursor.row; 208 | for (List r in _frame.sublist(cursorRow)) { 209 | for (int c = 0; c < r.length; c++) { 210 | r[c].value = Glyph.SPACE; 211 | } 212 | } 213 | } 214 | 215 | void eraseScreen() { 216 | for (List r in _frame) { 217 | for (int c = 0; c < r.length; c++) { 218 | r[c].value = Glyph.SPACE; 219 | } 220 | } 221 | 222 | cursor.row = 0; 223 | cursor.col = 0; 224 | } 225 | 226 | void setKeypadMode(KeypadMode mode) { 227 | keypad = mode; 228 | } 229 | 230 | /// Manipulates the frame & scroll bubber to handle scrolling down in normal, 231 | /// non-application mode. 232 | void scrollUp(int numLines) { 233 | for (int i = 0; i < numLines; i++) { 234 | if (_reverseBuffer.isEmpty) return; 235 | 236 | _frame.insert(0, _reverseBuffer.last); 237 | _reverseBuffer.removeLast(); 238 | _forwardBuffer.add(_frame[_frame.length - 1]); 239 | _frame.removeLast(); 240 | } 241 | } 242 | 243 | /// Manipulates the frame & scroll bubber to handle scrolling down in normal, 244 | /// non-application mode. 245 | void scrollDown(int numLines) { 246 | for (int i = 0; i < numLines; i++) { 247 | if (_forwardBuffer.isEmpty) return; 248 | 249 | _frame.add(_forwardBuffer.last); 250 | _forwardBuffer.removeLast(); 251 | _reverseBuffer.add(_frame[0]); 252 | _frame.removeAt(0); 253 | } 254 | } 255 | 256 | void scrollToBottom() { 257 | while (_forwardBuffer.isNotEmpty) { 258 | _frame.add(_forwardBuffer.last); 259 | _forwardBuffer.removeLast(); 260 | _reverseBuffer.add(_frame[0]); 261 | _frame.removeAt(0); 262 | } 263 | } 264 | 265 | void _pushBuffer() { 266 | _reverseBuffer.add(_frame[0]); 267 | if (_reverseBuffer.length > _MAXBUFFER) _reverseBuffer.removeAt(0); 268 | _frame.removeAt(0); 269 | 270 | List newRow = []; 271 | for (int c = 0; c < numCols; c++) { 272 | newRow.add(new Glyph(Glyph.SPACE, new DisplayAttributes())); 273 | } 274 | _frame.add(newRow); 275 | } 276 | 277 | void scrollScreen(int start, int end) { 278 | _scrollStart = start; 279 | _scrollEnd = end; 280 | } 281 | 282 | /// Manipulates the frame to handle scrolling 283 | /// upward of a single line in application mode. 284 | void _scrollUp(int numLines) { 285 | for (int i = 0; i < numLines; i++) { 286 | _frame.removeAt(numRows - 2); 287 | _frame.insert(0, new List()); 288 | for (int c = 0; c < numCols; c++) { 289 | _frame[0].add(new Glyph(Glyph.SPACE, new DisplayAttributes())); 290 | } 291 | } 292 | } 293 | 294 | /// Manipulates the frame to handle scrolling 295 | /// downward of a single line in application mode. 296 | void _scrollDown(int numLines) { 297 | for (int i = 0; i < numLines; i++) { 298 | _frame.removeAt(0); 299 | _frame.insert(numRows - 2, new List()); 300 | for (int c = 0; c < numCols; c++) { 301 | _frame[numRows - 2].add(new Glyph(Glyph.SPACE, new DisplayAttributes())); 302 | } 303 | } 304 | } 305 | 306 | /// Initializes the internal model with a List of Lists. 307 | /// Each location defaults to a Glyph.SPACE. 308 | void _initModel() { 309 | for (int r = 0; r < numRows; r++) { 310 | _frame.add(new List()); 311 | for (int c = 0; c < numCols; c++) { 312 | _frame[r].add(new Glyph(Glyph.SPACE, new DisplayAttributes())); 313 | } 314 | } 315 | } 316 | } -------------------------------------------------------------------------------- /lib/src/output/escape_handler.dart: -------------------------------------------------------------------------------- 1 | part of output; 2 | 3 | class EscapeHandler { 4 | // Taken from: http://www.termsys.demon.co.uk/vtansi.htm 5 | // And for VT102: http://www.ibiblio.org/pub/historic-linux/ftp-archives/tsx-11.mit.edu/Oct-07-1996/info/vt102.codes 6 | static Map constantEscapes = { 7 | // Device Status 8 | JSON.encode([27, 91, 99]): 'Query Device Code', 9 | JSON.encode([27, 91, 48, 99]): 'Query Device Code', 10 | JSON.encode([27, 90]): 'Query Device Code', 11 | JSON.encode([27, 91, 53, 110]): 'Query Device Status', 12 | JSON.encode([27, 91, 54, 110]): 'Query Cursor Position', 13 | // Terminal Setup 14 | JSON.encode([27, 99]): 'Reset Device', 15 | JSON.encode([27, 55, 104]): 'Enable Line Wrap', 16 | JSON.encode([27, 55, 108]): 'Disable Line Wrap', 17 | // Fonts 18 | JSON.encode([27, 40]): 'Font Set G0', 19 | JSON.encode([27, 41]): 'Font Set G1', 20 | // Cursor Control 21 | JSON.encode([27, 91, 115]): 'Save Cursor', 22 | JSON.encode([27, 91, 117]): 'Unsave Cursor', 23 | JSON.encode([27, 55]): 'Save Cursor & Attrs', 24 | JSON.encode([27, 56]): 'Restore Cursor & Attrs', 25 | // Scrolling 26 | JSON.encode([27, 91, 114]): 'Scroll Screen', 27 | JSON.encode([27, 68]): 'Scroll Down', 28 | JSON.encode([27, 77]): 'Scroll Up', 29 | // Tab Control 30 | JSON.encode([27, 72]): 'Set Tab', 31 | JSON.encode([27, 91, 103]): 'Clear Tab', 32 | JSON.encode([27, 91, 51, 103]): 'Clear All Tabs', 33 | // Keypad Character Selection 34 | JSON.encode([27, 61]): 'Keypad Application', 35 | JSON.encode([27, 62]): 'Keypad Numeric', 36 | // Erasing Text 37 | JSON.encode([27, 91, 75]): 'Erase End of Line', 38 | JSON.encode([27, 91, 49, 75]): 'Erase Start of Line', 39 | JSON.encode([27, 91, 50, 75]): 'Erase Line', 40 | JSON.encode([27, 91, 74]): 'Erase Down', 41 | JSON.encode([27, 91, 49, 74]): 'Erase Up', 42 | JSON.encode([27, 91, 50, 74]): 'Erase Screen', 43 | // Printing 44 | JSON.encode([27, 91, 105]): 'Print Screen', 45 | JSON.encode([27, 91, 49, 105]): 'Print Line', 46 | JSON.encode([27, 91, 52, 105]): 'Stop Print Log', 47 | JSON.encode([27, 91, 53, 105]): 'Start Print Log' 48 | }; 49 | 50 | static Map variableEscapeTerminators = { 51 | // Device Status 52 | 99: 'Report Device Code', 53 | 82: 'Report Cursor Position', 54 | // Cursor Control 55 | 72: 'Cursor Home', 56 | 65: 'Cursor Up', 57 | 66: 'Cursor Down', 58 | 67: 'Cursor Forward', 59 | 68: 'Cursor Backward', 60 | 102: 'Force Cursor Position', 61 | // Scrolling 62 | 114: 'Scroll Screen', 63 | // Define Key 64 | 112: 'Set Key Definition', 65 | // Set Display Attribute 66 | 109: 'Set Attribute Mode', 67 | // Reset and Set Modes 68 | 104: 'Set Mode', 69 | 108: 'Reset Mode' 70 | }; 71 | 72 | static String printEsc(List escape) { 73 | return '' + UTF8.decode(escape.sublist(1)); 74 | } 75 | 76 | static bool handleEscape(List escape, StreamController> stdin, Model model, DisplayAttributes currAttributes) { 77 | if (escape.length != 1 && escape.last == 27) { 78 | print('Unknown escape detected: ${printEsc(escape.sublist(0, escape.length - 1))}'); 79 | return true; 80 | } 81 | 82 | String encodedEscape = JSON.encode(escape); 83 | if (constantEscapes.containsKey(encodedEscape)) { 84 | _handleConstantEscape(encodedEscape, stdin, model, currAttributes, escape); 85 | return true; 86 | } else if (variableEscapeTerminators.containsKey(escape.last)) { 87 | _handleVariableEscape(encodedEscape, escape, currAttributes, model); 88 | return true; 89 | } 90 | 91 | return false; 92 | } 93 | 94 | static void _handleConstantEscape(String encodedEscape, StreamController> stdin, Model model, DisplayAttributes currAttributes, List escape) { 95 | //print('Constant escape: ${constantEscapes[encodedEscape]} ${printEsc(escape)}'); 96 | switch (constantEscapes[encodedEscape]) { 97 | case 'Query Cursor Position': 98 | _queryCursorPosition(stdin, model); 99 | break; 100 | case 'Set Tab': 101 | model.setTab(); 102 | break; 103 | case 'Clear All Tabs': 104 | model.clearAllTabs(); 105 | break; 106 | case 'Erase End of Line': 107 | model.eraseEndOfLine(); 108 | break; 109 | case 'Erase Down': 110 | model.eraseDown(); 111 | break; 112 | case 'Erase Screen': 113 | model.eraseScreen(); 114 | break; 115 | case 'Scroll Down': 116 | _scrollDown(model); 117 | break; 118 | case 'Scroll Up': 119 | _scrollUp(model); 120 | break; 121 | case 'Keypad Application': 122 | model.setKeypadMode(KeypadMode.APPLICATION); 123 | break; 124 | case 'Keypad Numeric': 125 | model.setKeypadMode(KeypadMode.NUMERIC); 126 | break; 127 | default: 128 | print('Constant escape : ${constantEscapes[encodedEscape]} (${escape.toString()}) not yet supported'); 129 | } 130 | } 131 | 132 | static void _handleVariableEscape(String encodedEscape, List escape, DisplayAttributes currAttributes, Model model) { 133 | //print('Variable escape: ${EscapeHandler.variableEscapeTerminators[escape.last]} ${printEsc(escape)}'); 134 | switch (EscapeHandler.variableEscapeTerminators[escape.last]) { 135 | case 'Set Attribute Mode': 136 | _setAttributeMode(escape, currAttributes); 137 | break; 138 | case 'Cursor Home': 139 | _cursorHome(escape, model); 140 | break; 141 | case 'Cursor Up': 142 | _cursorUp(escape, model); 143 | break; 144 | case 'Cursor Down': 145 | _cursorDown(escape, model); 146 | break; 147 | case 'Cursor Forward': 148 | _cursorRight(escape, model); 149 | break; 150 | case 'Cursor Backward': 151 | _cursorLeft(escape, model); 152 | break; 153 | case 'Set Mode': 154 | _setMode(escape, model); 155 | break; 156 | case 'Reset Mode': 157 | _resetMode(escape, model); 158 | break; 159 | case 'Scroll Screen': 160 | _scrollScreen(escape, model); 161 | break; 162 | default: 163 | print('Variable escape : ${variableEscapeTerminators[escape.last]} (${escape.toString()}) not yet supported'); 164 | } 165 | } 166 | 167 | static void _queryCursorPosition(StreamController> stdin, Model model) { 168 | // Sends back a Report Cursor Position - [{ROW};{COLUMN}R 169 | stdin.add([27, 91, model.cursor.row, 59, model.cursor.col, 82]); 170 | } 171 | 172 | static void _setMode(List escape, Model model) { 173 | //print('Set Mode: ${printEsc(escape)}'); 174 | switch (printEsc(escape)) { 175 | case '[?1h': 176 | model.cursorkeys = CursorkeysMode.APPLICATION; 177 | break; 178 | default: 179 | print('Set Mode: ${printEsc(escape)} not yet supported'); 180 | } 181 | } 182 | 183 | static void _resetMode(List escape, Model model) { 184 | //print('Reset Mode: ${printEsc(escape)}'); 185 | switch (printEsc(escape)) { 186 | case '[?1l': 187 | model.cursorkeys = CursorkeysMode.NORMAL; 188 | break; 189 | default: 190 | print('Reset Mode: ${printEsc(escape)} not yet supported'); 191 | } 192 | } 193 | 194 | static void _scrollScreen(List escape, Model model) { 195 | int indexOfSemi = escape.indexOf(59); 196 | int start = int.parse(UTF8.decode(escape.sublist(2, indexOfSemi))) - 1; 197 | int end = int.parse(UTF8.decode(escape.sublist(indexOfSemi + 1, escape.length - 1))) - 1; 198 | //print('Scrolling: $start to $end'); 199 | model.scrollScreen(start, end); 200 | } 201 | 202 | static void _scrollDown(Model model) => print('Scroll Down not handled!'); 203 | static void _scrollUp(Model model) => print('Scroll Up not handled!'); 204 | 205 | /// Sets the cursor position where subsequent text will begin. 206 | /// If no row/column parameters are provided (ie. [H), 207 | /// the cursor will move to the home position, at the upper left of the screen. 208 | static void _cursorHome(List escape, Model model) { 209 | int row, col; 210 | 211 | if (escape.length == 3) { 212 | row = 0; 213 | col = 0; 214 | } else { 215 | int indexOfSemi = escape.indexOf(59); 216 | row = int.parse(UTF8.decode(escape.sublist(2, indexOfSemi))) - 1; 217 | col = int.parse(UTF8.decode(escape.sublist(indexOfSemi + 1, escape.length - 1))) - 1; 218 | } 219 | 220 | model.cursorHome(row, col); 221 | } 222 | 223 | static void _cursorUp(List escape, Model model) { 224 | if (escape.length == 3) { 225 | model.cursorUp(); 226 | } else { 227 | escape = escape.sublist(2, escape.length - 1); 228 | model.cursorUp(int.parse(UTF8.decode(escape))); 229 | } 230 | } 231 | 232 | static void _cursorDown(List escape, Model model) { 233 | if (escape.length == 3) { 234 | model.cursorDown(); 235 | } else { 236 | escape = escape.sublist(2, escape.length - 1); 237 | model.cursorDown(int.parse(UTF8.decode(escape))); 238 | } 239 | } 240 | 241 | static void _cursorRight(List escape, Model model) { 242 | if (escape.length == 3) { 243 | model.cursorForward(); 244 | } else { 245 | escape = escape.sublist(2, escape.length - 1); 246 | model.cursorForward(int.parse(UTF8.decode(escape))); 247 | } 248 | } 249 | 250 | static void _cursorLeft(List escape, Model model) { 251 | if (escape.length == 3) { 252 | model.cursorBackward(); 253 | } else { 254 | escape = escape.sublist(2, escape.length - 1); 255 | model.cursorBackward(int.parse(UTF8.decode(escape))); 256 | } 257 | } 258 | 259 | /// Sets multiple display attribute settings. 260 | /// Sets local [DisplayAttributes], given [escape]. 261 | static void _setAttributeMode(List escape, DisplayAttributes attr) { 262 | String decodedEsc = UTF8.decode(escape); 263 | 264 | if (decodedEsc.contains('0m')) { 265 | attr.resetAll(); 266 | } 267 | 268 | // TODO: implement these when necessary. 269 | if (decodedEsc.contains(';1')) attr.bright = true; 270 | if (decodedEsc.contains(';2')) attr.dim = true; 271 | if (decodedEsc.contains(';4')) attr.underscore = true; 272 | if (decodedEsc.contains(';5')) attr.blink = true; 273 | if (decodedEsc.contains(';7')) attr.reverse = true; 274 | if (decodedEsc.contains(';8')) attr.hidden = true; 275 | 276 | if (decodedEsc.contains(';30')) attr.fgColor = 'black'; 277 | if (decodedEsc.contains(';31')) attr.fgColor = 'red'; 278 | if (decodedEsc.contains(';32')) attr.fgColor = 'green'; 279 | if (decodedEsc.contains(';33')) attr.fgColor = 'yellow'; 280 | if (decodedEsc.contains(';34')) attr.fgColor = 'blue'; 281 | if (decodedEsc.contains(';35')) attr.fgColor = 'magenta'; 282 | if (decodedEsc.contains(';36')) attr.fgColor = 'cyan'; 283 | if (decodedEsc.contains(';37')) attr.fgColor = 'white'; 284 | 285 | if (decodedEsc.contains(';40')) attr.bgColor = 'black'; 286 | if (decodedEsc.contains(';41')) attr.bgColor = 'red'; 287 | if (decodedEsc.contains(';42')) attr.bgColor = 'green'; 288 | if (decodedEsc.contains(';43')) attr.bgColor = 'yellow'; 289 | if (decodedEsc.contains(';44')) attr.bgColor = 'blue'; 290 | if (decodedEsc.contains(';45')) attr.bgColor = 'magenta'; 291 | if (decodedEsc.contains(';46')) attr.bgColor = 'cyan'; 292 | if (decodedEsc.contains(';47')) attr.bgColor = 'white'; 293 | } 294 | } --------------------------------------------------------------------------------