├── .github └── workflows │ └── dart.yml ├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── analysis_options.yaml ├── example ├── calendar.dart ├── clear.dart ├── demo.dart ├── ffi │ ├── README.md │ ├── ffi_rawmode.dart │ └── ffi_win32.dart ├── keys.dart ├── kilo.dart ├── life.dart ├── main.dart ├── rawkeys.dart ├── readline.dart ├── readline_scrolling.dart ├── readme.dart └── table.dart ├── lib ├── dart_console.dart └── src │ ├── ansi.dart │ ├── calendar.dart │ ├── console.dart │ ├── consolecolor.dart │ ├── ffi │ ├── termlib.dart │ ├── unix │ │ ├── termios.dart │ │ ├── termlib_unix.dart │ │ └── unistd.dart │ └── win │ │ └── termlib_win.dart │ ├── key.dart │ ├── progressbar.dart │ ├── scrollbackbuffer.dart │ ├── string_utils.dart │ ├── table.dart │ └── textalignment.dart ├── pubspec.yaml └── test ├── calendar_test.dart ├── interactive_test.dart ├── string_test.dart └── table_test.dart /.github/workflows/dart.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ${{matrix.os}} 12 | strategy: 13 | matrix: 14 | os: [windows-latest, ubuntu-latest] 15 | sdk: [stable] 16 | 17 | steps: 18 | - name: Fetch sources 19 | uses: actions/checkout@v2 20 | 21 | - name: Set up Dart 22 | uses: dart-lang/setup-dart@v1 23 | with: 24 | sdk: ${{matrix.sdk}} 25 | 26 | - name: Print Dart SDK version 27 | run: dart --version 28 | 29 | - name: Install dependencies 30 | run: dart pub get 31 | 32 | - name: Verify formatting 33 | run: dart format --output=none --set-exit-if-changed . 34 | 35 | - name: Analyze project source 36 | run: dart analyze --fatal-warnings 37 | 38 | - name: Run tests 39 | run: dart test 40 | 41 | - name: Add code coverage package 42 | run: dart pub global activate coverage 43 | 44 | - name: Generate code coverage 45 | run: dart pub global run coverage:test_with_coverage 46 | 47 | - name: Upload code coverage 48 | uses: codecov/codecov-action@v3 49 | with: 50 | files: coverage/lcov.info -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Files and directories created by pub 2 | .dart_tool/ 3 | .packages 4 | 5 | # Library packages do not typically check in pubspec.lock 6 | pubspec.lock 7 | 8 | # Conventional directory for build output. 9 | build/ 10 | 11 | # Directory created by dartdoc 12 | doc/api/ 13 | 14 | # Don't need to include code coverage in the package 15 | coverage/ 16 | 17 | **/*.so 18 | **/*.dylib 19 | **/*.dll 20 | **/*.obj 21 | **/*.exe 22 | **/*.aot 23 | 24 | **/*.pdb 25 | **/*.ilk 26 | 27 | # Remove this if it gets generated by running an example 28 | **/golden.txt 29 | 30 | # Test scratchpad for development 31 | example/scratch.dart -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Dart", 9 | "program": "example/keys.dart", 10 | "request": "launch", 11 | "type": "dart" 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "markdownlint.config": 3 | { 4 | "MD041": false, 5 | } 6 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.1.2 2 | 3 | - Remove ioctl and rely on ANSI escape sequences instead. Increases 4 | compatibility on non-interactive terminals and fixes ARM64 compatibility 5 | issue. 6 | 7 | ## 1.1.1 8 | 9 | - Update some lints and platform specifications to satisfy pana. 10 | 11 | ## 1.1.0 12 | 13 | - Add table class for tabulated text (and demo `example/table.dart`) 14 | - Add calendar display, building on table primitives (and demo 15 | `example/calendar.dart`) 16 | - Update to lints package and fix various issues. 17 | - Add support for faint and strikethru font styles. 18 | - Add support for testing whether the terminal supports emojis. 19 | - Add support for resizing console window (thanks @FaFre) 20 | - Add `writeAligned` function for center- and right-justified text. 21 | - Fix error with forward deleting last character on a line (thanks 22 | @mhdolatabadi) 23 | - Bump dependencies and support package:ffi 2.x 24 | - Add lots of tests, including Github Actions automation. 25 | 26 | ## 1.0.0 27 | 28 | - Support sound null safety and latest win32 and ffi dependencies. 29 | 30 | ## 0.6.2 31 | 32 | - Bumped minver of win32 33 | 34 | ## 0.6.1 35 | 36 | - Update to latest win32 package 37 | - Add scrollback ignore option (thanks @averynortonsmith) 38 | - Fix readme (thanks @md-weber) 39 | 40 | ## 0.6.0 41 | 42 | - Shift all FFI code to the new [win32](https://pub.dev/packages/win32) package 43 | 44 | ## 0.5.0 45 | 46 | - Add `writeErrorLine` function which writes to stderr 47 | - *BREAKING*: Automatically print a new line to the console after every 48 | `readLine` input 49 | - *BREAKING*: `readLine` now returns a null string, rather than an empty 50 | string, when Ctrl+Break or Escape are pressed. 51 | - Add Game of Life example (thanks to @erf) 52 | 53 | ## 0.4.0 54 | 55 | - Update to new FFI API 56 | 57 | ## 0.3.0 58 | 59 | - Add support for Windows consoles that recognize ANSI escape sequences 60 | 61 | ## 0.2.0 62 | 63 | - Add `readline` function and escape character support 64 | 65 | ## 0.1.0 66 | 67 | - Initial version 68 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019, the Dart project authors. All rights reserved. 2 | Redistribution and use in source and binary forms, with or without 3 | modification, are permitted provided that the following conditions are 4 | met: 5 | 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above 9 | copyright notice, this list of conditions and the following 10 | disclaimer in the documentation and/or other materials provided 11 | with the distribution. 12 | * Neither the name of Google Inc. nor the names of its 13 | contributors may be used to endorse or promote products derived 14 | from this software without specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 17 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 18 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 19 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 20 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 21 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 22 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 23 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 24 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 26 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A Dart library for building console applications. 2 | 3 | [![pub package](https://img.shields.io/pub/v/dart_console.svg)](https://pub.dev/packages/dart_console) 4 | [![Language](https://img.shields.io/badge/language-Dart-blue.svg)](https://dart.dev) 5 | ![Build](https://github.com/timsneath/dart_console/workflows/Build/badge.svg) 6 | [![codecov](https://codecov.io/gh/timsneath/dart_console/branch/main/graph/badge.svg?token=2N19H7OHZJ)](https://codecov.io/gh/timsneath/dart_console) 7 | 8 | This library contains a variety of useful functions for console application 9 | development, including: 10 | 11 | - Reading the current window dimensions (height, width) 12 | - Reading and setting the cursor location 13 | - Setting foreground and background colors 14 | - Manipulating the console into "raw mode", which allows more advanced 15 | keyboard input processing than the default `dart:io` library. 16 | - Reading keys and control sequences from the keyboard 17 | - Writing aligned text to the screen 18 | - Tabular data display, including month calendar 19 | 20 | The library has been used to implement a [Dart][dart] version of the 21 | [Kilo][kilo] text editor; when compiled with Dart it results in a self-contained 22 | `kilo` executable. The library is sufficient for a reasonably complete set of 23 | usage, including `readline`-style CLI and basic text games. 24 | 25 | The library assumes a terminal that recognizes and implements common ANSI escape 26 | sequences. The package has been tested on macOS, Linux and Windows 10. Note that 27 | Windows did not support ANSI escape sequences in [earlier 28 | versions][vt-win10-roadmap]. 29 | 30 | The library uses the [win32](https://pub.dev/packages/win32) package for 31 | accessing the Win32 API through FFI. That package contains many examples of 32 | using Dart FFI for more complex examples. 33 | 34 | ## Usage 35 | 36 | A simple example for the `dart_console` package: 37 | 38 | ```dart 39 | import 'package:dart_console/dart_console.dart'; 40 | 41 | main() { 42 | final console = Console(); 43 | 44 | console.clearScreen(); 45 | console.resetCursorPosition(); 46 | 47 | console.writeLine( 48 | 'Console size is ${console.windowWidth} cols and ${console.windowHeight} rows.', 49 | TextAlignment.center, 50 | ); 51 | 52 | console.writeLine(); 53 | 54 | return 0; 55 | } 56 | ``` 57 | 58 | More comprehensive demos of the `Console` class are provided, as follows: 59 | 60 | | Example | Description | 61 | | ---- | ---- | 62 | | `demo.dart` | Suite of test demos that showcase various capabilities | 63 | | `main.dart` | Basic demo of how to get started with the `dart_console` | 64 | | `keys.dart` | Demonstrates how `dart_console` processes control characters | 65 | | `readline.dart` | Sample command-line interface / REPL | 66 | | `kilo.dart` | Rudimentary text editor | 67 | | `life.dart` | Game of Life | 68 | | `table.dart` | Demonstrates tabular data display | 69 | | `calendar.dart` | Prints a monthly calendar on the screen | 70 | 71 | ## Acknowledgements 72 | 73 | Special thanks to [Matt Sullivan (@mjohnsullivan)][@mjohnsullivan] and [Samir 74 | Jindel (@sjindel-google)][@sjindel-google] for their help in explaining FFI to 75 | me when it was first introduced and still undocumented. 76 | 77 | Thanks to [@erf] for contributing the Game of Life example. 78 | 79 | ## Features and bugs 80 | 81 | Please file feature requests and bugs at the [issue tracker][tracker]. 82 | 83 | [kilo]: https://github.com/antirez/kilo 84 | [dart]: https://dart.dev/get-dart 85 | [dart_kilo]: https://github.com/timsneath/dart_kilo 86 | [vt-win10]: https://docs.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences 87 | [vt-win10-roadmap]: https://docs.microsoft.com/en-us/windows/console/ecosystem-roadmap#virtual-terminal-server 88 | [winterm]: https://www.microsoft.com/en-us/p/windows-terminal/9n0dx20hk701 89 | [FFI]: https://dart.dev/server/c-interop 90 | [@mjohnsullivan]: https://github.com/mjohnsullivan 91 | [@sjindel-google]: https://github.com/sjindel-google 92 | [@erf]: https://github.com/erf 93 | [tracker]: https://github.com/timsneath/dart_console/issues 94 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:lints/recommended.yaml 2 | 3 | analyzer: 4 | language: 5 | strict-casts: true 6 | strict-inference: true 7 | strict-raw-types: true 8 | errors: 9 | todo: ignore -------------------------------------------------------------------------------- /example/calendar.dart: -------------------------------------------------------------------------------- 1 | import 'package:dart_console/dart_console.dart'; 2 | import 'dart:io'; 3 | 4 | void main() { 5 | final calendar = Calendar(DateTime(1969, 08, 15)); 6 | // or 7 | // final calendar = Calendar.now(); 8 | 9 | print(calendar); 10 | 11 | final golden = File('golden.txt').openSync(mode: FileMode.writeOnly); 12 | golden.writeStringSync(calendar.toString()); 13 | golden.closeSync(); 14 | } 15 | -------------------------------------------------------------------------------- /example/clear.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | const ansiEraseInDisplayAll = '\x1b[2J'; 4 | const ansiResetCursorPosition = '\x1b[H'; 5 | 6 | void main() { 7 | stdout.write(ansiEraseInDisplayAll + ansiResetCursorPosition); 8 | } 9 | -------------------------------------------------------------------------------- /example/demo.dart: -------------------------------------------------------------------------------- 1 | import 'dart:collection'; 2 | import 'dart:io'; 3 | import 'dart:math'; 4 | 5 | import 'package:dart_console/dart_console.dart'; 6 | 7 | import 'table.dart'; 8 | 9 | final console = Console(); 10 | 11 | List demoScreens = [ 12 | // SCREEN 1: Whimsical loading screen :) 13 | (() { 14 | console.setBackgroundColor(ConsoleColor.blue); 15 | console.setForegroundColor(ConsoleColor.white); 16 | console.clearScreen(); 17 | 18 | final row = (console.windowHeight / 2).round() - 1; 19 | 20 | console.cursorPosition = Coordinate(row - 2, 0); 21 | console.writeLine('L O A D I N G', TextAlignment.center); 22 | 23 | console.cursorPosition = Coordinate(row + 2, 0); 24 | console.writeLine('Please wait while we make you some avocado toast...', 25 | TextAlignment.center); 26 | 27 | console.hideCursor(); 28 | 29 | final progressBar = ProgressBar( 30 | maxValue: 100, 31 | startCoordinate: Coordinate(row, 4), 32 | barWidth: max(console.windowWidth - 10, 10), 33 | showSpinner: false, 34 | tickCharacters: ['#'], 35 | ); 36 | 37 | for (var i = 0; i < 100; i++) { 38 | progressBar.tick(); 39 | sleep(const Duration(milliseconds: 20)); 40 | } 41 | progressBar.complete(); 42 | 43 | console.showCursor(); 44 | 45 | console.cursorPosition = Coordinate(console.windowHeight - 3, 0); 46 | }), 47 | 48 | // SCREEN 2: General demonstration of basic color set and alignment. 49 | (() { 50 | console.setBackgroundColor(ConsoleColor.blue); 51 | console.setForegroundColor(ConsoleColor.white); 52 | console.writeLine('Simple Demo', TextAlignment.center); 53 | console.resetColorAttributes(); 54 | 55 | console.writeLine(); 56 | 57 | console.writeLine('This console window has ${console.windowWidth} cols and ' 58 | '${console.windowHeight} rows.'); 59 | console.writeLine(); 60 | 61 | console.writeLine('This text is left aligned.', TextAlignment.left); 62 | console.writeLine('This text is center aligned.', TextAlignment.center); 63 | console.writeLine('This text is right aligned.', TextAlignment.right); 64 | 65 | console.writeLine(); 66 | console.setTextStyle(italic: true); 67 | console.writeLine('Text is italic (terminal dependent).'); 68 | console.setTextStyle(bold: true); 69 | console.writeLine('Text is bold (terminal dependent).'); 70 | console.resetColorAttributes(); 71 | console.writeLine(); 72 | 73 | for (final color in ConsoleColor.values) { 74 | console.setForegroundColor(color); 75 | console.writeLine(color.toString().split('.').last); 76 | } 77 | console.resetColorAttributes(); 78 | }), 79 | 80 | // SCREEN 3: Show extended foreground colors 81 | (() { 82 | console.setBackgroundColor(ConsoleColor.red); 83 | console.setForegroundColor(ConsoleColor.white); 84 | console.writeLine( 85 | 'ANSI Extended 256-Color Foreground Test', TextAlignment.center); 86 | console.resetColorAttributes(); 87 | 88 | console.writeLine(); 89 | 90 | for (var i = 0; i < 16; i++) { 91 | for (var j = 0; j < 16; j++) { 92 | final color = i * 16 + j; 93 | console.setForegroundExtendedColor(color); 94 | console.write(color.toString().padLeft(4)); 95 | } 96 | console.writeLine(); 97 | } 98 | 99 | console.resetColorAttributes(); 100 | }), 101 | 102 | // SCREEN 4: Show extended background colors 103 | (() { 104 | console.setBackgroundColor(ConsoleColor.green); 105 | console.setForegroundColor(ConsoleColor.white); 106 | console.writeLine( 107 | 'ANSI Extended 256-Color Background Test', TextAlignment.center); 108 | console.resetColorAttributes(); 109 | 110 | console.writeLine(); 111 | 112 | for (var i = 0; i < 16; i++) { 113 | for (var j = 0; j < 16; j++) { 114 | final color = i * 16 + j; 115 | console.setBackgroundExtendedColor(color); 116 | console.write(color.toString().padLeft(4)); 117 | } 118 | console.writeLine(); 119 | } 120 | 121 | console.resetColorAttributes(); 122 | }), 123 | 124 | // SCREEN 5: Tabular display 125 | (() { 126 | console.setBackgroundColor(ConsoleColor.magenta); 127 | console.setForegroundColor(ConsoleColor.white); 128 | console.writeLine('Tabular Display Examples', TextAlignment.center); 129 | console.resetColorAttributes(); 130 | 131 | console.writeLine(); 132 | 133 | final calendar = Calendar.now(); 134 | console.write(calendar); 135 | 136 | console.writeLine(); 137 | 138 | final table = Table() 139 | ..borderColor = ConsoleColor.blue 140 | ..borderStyle = BorderStyle.rounded 141 | ..borderType = BorderType.horizontal 142 | ..insertColumn(header: 'Number', alignment: TextAlignment.center) 143 | ..insertColumn(header: 'Presidency', alignment: TextAlignment.right) 144 | ..insertColumn(header: 'President') 145 | ..insertColumn(header: 'Party') 146 | ..insertRows(earlyPresidents) 147 | ..title = 'Early Presidents of the United States'; 148 | console.write(table); 149 | }), 150 | 151 | // SCREEN 6: Twinkling stars 152 | (() { 153 | final stars = Queue(); 154 | final rng = Random(); 155 | const numStars = 750; 156 | const maxStarsOnScreen = 250; 157 | 158 | void addStar() { 159 | final star = Coordinate(rng.nextInt(console.windowHeight - 1) + 1, 160 | rng.nextInt(console.windowWidth)); 161 | console.cursorPosition = star; 162 | console.write('*'); 163 | stars.addLast(star); 164 | } 165 | 166 | void removeStar() { 167 | final star = stars.first; 168 | console.cursorPosition = star; 169 | console.write(' '); 170 | stars.removeFirst(); 171 | } 172 | 173 | console.setBackgroundColor(ConsoleColor.yellow); 174 | console.setForegroundColor(ConsoleColor.brightBlack); 175 | console.writeLine('Stars', TextAlignment.center); 176 | console.resetColorAttributes(); 177 | 178 | console.hideCursor(); 179 | console.setForegroundColor(ConsoleColor.brightYellow); 180 | 181 | for (var i = 0; i < numStars; i++) { 182 | if (i < numStars - maxStarsOnScreen) { 183 | addStar(); 184 | } 185 | if (i >= maxStarsOnScreen) { 186 | removeStar(); 187 | } 188 | sleep(const Duration(milliseconds: 1)); 189 | } 190 | 191 | console.resetColorAttributes(); 192 | console.cursorPosition = Coordinate(console.windowHeight - 3, 0); 193 | console.showCursor(); 194 | }), 195 | ]; 196 | 197 | // 198 | // main 199 | // 200 | int main(List arguments) { 201 | if (arguments.isNotEmpty) { 202 | final selectedDemo = int.tryParse(arguments.first); 203 | if (selectedDemo != null && 204 | selectedDemo > 0 && 205 | selectedDemo <= demoScreens.length) { 206 | demoScreens = [demoScreens[selectedDemo - 1]]; 207 | } 208 | } 209 | 210 | for (final demo in demoScreens) { 211 | console.clearScreen(); 212 | demo(); 213 | console.writeLine(); 214 | if (demoScreens.indexOf(demo) != demoScreens.length - 1) { 215 | console.writeLine('Press any key to continue, or Ctrl+C to quit...'); 216 | } else { 217 | console.writeLine('Press any key to end the demo sequence...'); 218 | } 219 | 220 | final key = console.readKey(); 221 | console.resetColorAttributes(); 222 | 223 | if (key.controlChar == ControlCharacter.ctrlC) { 224 | return 1; 225 | } 226 | } 227 | 228 | return 0; 229 | } 230 | -------------------------------------------------------------------------------- /example/ffi/README.md: -------------------------------------------------------------------------------- 1 | This folder contains examples of using FFI for various underlying system 2 | calls. 3 | -------------------------------------------------------------------------------- /example/ffi/ffi_rawmode.dart: -------------------------------------------------------------------------------- 1 | // Ignore these lints, since these are UNIX identifiers that we're replicating 2 | // 3 | // ignore_for_file: non_constant_identifier_names, constant_identifier_names 4 | 5 | import 'dart:ffi'; 6 | import 'dart:io'; 7 | 8 | import 'package:ffi/ffi.dart'; 9 | 10 | // int tcgetattr(int, struct termios *); 11 | typedef TCGetAttrNative = Int32 Function( 12 | Int32 fildes, Pointer termios); 13 | typedef TCGetAttrDart = int Function(int fildes, Pointer termios); 14 | 15 | // int tcsetattr(int, int, const struct termios *); 16 | typedef TCSetAttrNative = Int32 Function( 17 | Int32 fildes, Int32 optional_actions, Pointer termios); 18 | typedef TCSetAttrDart = int Function( 19 | int fildes, int optional_actions, Pointer termios); 20 | 21 | const STDIN_FILENO = 0; 22 | const STDOUT_FILENO = 1; 23 | const STDERR_FILENO = 2; 24 | 25 | // INPUT FLAGS 26 | const int IGNBRK = 0x00000001; // ignore BREAK condition 27 | const int BRKINT = 0x00000002; // map BREAK to SIGINTR 28 | const int IGNPAR = 0x00000004; // ignore (discard) parity errors 29 | const int PARMRK = 0x00000008; // mark parity and framing errors 30 | const int INPCK = 0x00000010; // enable checking of parity errors 31 | const int ISTRIP = 0x00000020; // strip 8th bit off chars 32 | const int INLCR = 0x00000040; // map NL into CR 33 | const int IGNCR = 0x00000080; // ignore CR 34 | const int ICRNL = 0x00000100; // map CR to NL (ala CRMOD) 35 | const int IXON = 0x00000200; // enable output flow control 36 | const int IXOFF = 0x00000400; // enable input flow control 37 | const int IXANY = 0x00000800; // any char will restart after stop 38 | const int IMAXBEL = 0x00002000; // ring bell on input queue full 39 | const int IUTF8 = 0x00004000; // maintain state for UTF-8 VERASE 40 | 41 | // OUTPUT FLAGS 42 | const int OPOST = 0x00000001; // enable following output processing 43 | const int ONLCR = 0x00000002; // map NL to CR-NL (ala CRMOD) 44 | const int OXTABS = 0x00000004; // expand tabs to spaces 45 | const int ONOEOT = 0x00000008; // discard EOT's (^D) on output) 46 | 47 | // CONTROL FLAGS 48 | const int CIGNORE = 0x00000001; // ignore control flags 49 | const int CSIZE = 0x00000300; // character size mask 50 | const int CS5 = 0x00000000; // 5 bits (pseudo) 51 | const int CS6 = 0x00000100; // 6 bits 52 | const int CS7 = 0x00000200; // 7 bits 53 | const int CS8 = 0x00000300; // 8 bits 54 | 55 | // LOCAL FLAGS 56 | const int ECHOKE = 0x00000001; // visual erase for line kill 57 | const int ECHOE = 0x00000002; // visually erase chars 58 | const int ECHOK = 0x00000004; // echo NL after line kill 59 | const int ECHO = 0x00000008; // enable echoing 60 | const int ECHONL = 0x00000010; // echo NL even if ECHO is off 61 | const int ECHOPRT = 0x00000020; // visual erase mode for hardcopy 62 | const int ECHOCTL = 0x00000040; // echo control chars as ^(Char) 63 | const int ISIG = 0x00000080; // enable signals INTR, QUIT, [D]SUSP 64 | const int ICANON = 0x00000100; // canonicalize input lines 65 | const int ALTWERASE = 0x00000200; // use alternate WERASE algorithm 66 | const int IEXTEN = 0x00000400; // enable DISCARD and LNEXT 67 | const int EXTPROC = 0x00000800; // external processing 68 | const int TOSTOP = 0x00400000; // stop background jobs from output 69 | const int FLUSHO = 0x00800000; // output being flushed (state) 70 | const int NOKERNINFO = 0x02000000; // no kernel output from VSTATUS 71 | const int PENDIN = 0x20000000; // retype pending input (state) 72 | const int NOFLSH = 0x80000000; // don't flush after interrupt 73 | 74 | const int TCSANOW = 0; // make change immediate 75 | const int TCSADRAIN = 1; // drain output, then change 76 | const int TCSAFLUSH = 2; // drain output, flush input 77 | 78 | const int VMIN = 16; // minimum number of characters to receive 79 | const int VTIME = 17; // time in 1/10s before returning 80 | 81 | // typedef unsigned long tcflag_t; 82 | // typedef unsigned char cc_t; 83 | // typedef unsigned long speed_t; 84 | // #define NCCS 20 85 | 86 | // struct termios { 87 | // tcflag_t c_iflag; /* input flags */ 88 | // tcflag_t c_oflag; /* output flags */ 89 | // tcflag_t c_cflag; /* control flags */ 90 | // tcflag_t c_lflag; /* local flags */ 91 | // cc_t c_cc[NCCS]; /* control chars */ 92 | // speed_t c_ispeed; /* input speed */ 93 | // speed_t c_ospeed; /* output speed */ 94 | // }; 95 | class TermIOS extends Struct { 96 | @IntPtr() 97 | external int c_iflag; 98 | @IntPtr() 99 | external int c_oflag; 100 | @IntPtr() 101 | external int c_cflag; 102 | @IntPtr() 103 | external int c_lflag; 104 | 105 | @Array(20) 106 | external Array c_cc; 107 | 108 | @IntPtr() 109 | external int c_ispeed; 110 | @IntPtr() 111 | external int c_ospeed; 112 | } 113 | 114 | void main() { 115 | final libc = Platform.isMacOS 116 | ? DynamicLibrary.open('/usr/lib/libSystem.dylib') 117 | : DynamicLibrary.open('libc-2.28.so'); 118 | 119 | final tcgetattr = 120 | libc.lookupFunction('tcgetattr'); 121 | final tcsetattr = 122 | libc.lookupFunction('tcsetattr'); 123 | 124 | final origTermIOS = calloc(); 125 | var result = tcgetattr(STDIN_FILENO, origTermIOS); 126 | print('result is $result'); 127 | 128 | print('origTermIOS.c_iflag: 0b${origTermIOS.ref.c_iflag.toRadixString(2)}'); 129 | print('Copying and modifying...'); 130 | 131 | final newTermIOS = calloc() 132 | ..ref.c_iflag = 133 | origTermIOS.ref.c_iflag & ~(BRKINT | ICRNL | INPCK | ISTRIP | IXON) 134 | ..ref.c_oflag = origTermIOS.ref.c_oflag & ~OPOST 135 | ..ref.c_cflag = origTermIOS.ref.c_cflag | CS8 136 | ..ref.c_lflag = origTermIOS.ref.c_lflag & ~(ECHO | ICANON | IEXTEN | ISIG) 137 | ..ref.c_ispeed = origTermIOS.ref.c_ispeed 138 | ..ref.c_oflag = origTermIOS.ref.c_ospeed 139 | ..ref.c_cc = origTermIOS.ref.c_cc 140 | ..ref.c_cc[VMIN] = 0 // VMIN -- return each byte, or 0 for timeout 141 | ..ref.c_cc[VTIME] = 1; // VTIME -- 100ms timeout (unit is 1/10s) 142 | 143 | print('origTermIOS.c_iflag: 0b${origTermIOS.ref.c_iflag.toRadixString(2)}'); 144 | print('newTermIOS.c_iflag: 0b${newTermIOS.ref.c_iflag.toRadixString(2)}'); 145 | print('origTermIOS.c_oflag: 0b${origTermIOS.ref.c_oflag.toRadixString(2)}'); 146 | print('newTermIOS.c_oflag: 0b${newTermIOS.ref.c_oflag.toRadixString(2)}'); 147 | print('origTermIOS.c_cflag: 0b${origTermIOS.ref.c_cflag.toRadixString(2)}'); 148 | print('newTermIOS.c_cflag: 0b${newTermIOS.ref.c_cflag.toRadixString(2)}'); 149 | print('origTermIOS.c_lflag: 0b${origTermIOS.ref.c_lflag.toRadixString(2)}'); 150 | print('newTermIOS.c_lflag: 0b${newTermIOS.ref.c_lflag.toRadixString(2)}'); 151 | 152 | result = tcsetattr(STDIN_FILENO, TCSAFLUSH, newTermIOS); 153 | print('result is $result\n'); 154 | 155 | print('RAW MODE: Here is some text.\nHere is some more text.'); 156 | result = tcsetattr(STDIN_FILENO, TCSAFLUSH, origTermIOS); 157 | print('result is $result\n'); 158 | 159 | print('\nORIGINAL MODE: Here is some text.\nHere is some more text.'); 160 | 161 | calloc.free(origTermIOS); 162 | calloc.free(newTermIOS); 163 | } 164 | -------------------------------------------------------------------------------- /example/ffi/ffi_win32.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ffi'; 2 | import 'package:ffi/ffi.dart'; 3 | 4 | import 'package:win32/win32.dart'; 5 | 6 | void main() { 7 | final outputHandle = GetStdHandle(STD_OUTPUT_HANDLE); 8 | print('Output handle (DWORD): $outputHandle'); 9 | 10 | final pBufferInfo = calloc(); 11 | final bufferInfo = pBufferInfo.ref; 12 | GetConsoleScreenBufferInfo(outputHandle, pBufferInfo); 13 | print('Window dimensions LTRB: (${bufferInfo.srWindow.Left}, ' 14 | '${bufferInfo.srWindow.Top}, ${bufferInfo.srWindow.Right}, ' 15 | '${bufferInfo.srWindow.Bottom})'); 16 | print('Cursor position X/Y: (${bufferInfo.dwCursorPosition.X}, ' 17 | '${bufferInfo.dwCursorPosition.Y})'); 18 | print('Window size X/Y: (${bufferInfo.dwSize.X}, ${bufferInfo.dwSize.Y})'); 19 | print('Maximum window size X/Y: (${bufferInfo.dwMaximumWindowSize.X}, ' 20 | '${bufferInfo.dwMaximumWindowSize.Y})'); 21 | final cursorPosition = calloc() 22 | ..ref.X = 15 23 | ..ref.Y = 3; 24 | 25 | SetConsoleCursorPosition(outputHandle, cursorPosition.ref); 26 | GetConsoleScreenBufferInfo(outputHandle, pBufferInfo); 27 | print('Cursor position X/Y: (${bufferInfo.dwCursorPosition.X}, ' 28 | '${bufferInfo.dwCursorPosition.Y})'); 29 | 30 | calloc.free(pBufferInfo); 31 | calloc.free(cursorPosition); 32 | } 33 | -------------------------------------------------------------------------------- /example/keys.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:dart_console/dart_console.dart'; 4 | 5 | final console = Console(); 6 | 7 | void main() { 8 | console.writeLine( 9 | 'This sample demonstrates keyboard input. Press any key including control keys'); 10 | console.writeLine( 11 | 'such as arrow keys, page up/down, home, end etc. to see it echoed to the'); 12 | console.writeLine('screen. Press Ctrl+Q to end the sample.'); 13 | var key = console.readKey(); 14 | 15 | while (true) { 16 | if (key.isControl && key.controlChar == ControlCharacter.ctrlQ) { 17 | console.clearScreen(); 18 | console.resetCursorPosition(); 19 | console.rawMode = false; 20 | exit(0); 21 | } else { 22 | print(key); 23 | } 24 | key = console.readKey(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /example/kilo.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | import 'dart:math' show min; 3 | 4 | import 'package:dart_console/dart_console.dart'; 5 | 6 | const kiloVersion = '0.0.3'; 7 | const kiloTabStopLength = 4; 8 | 9 | // 10 | // GLOBAL VARIABLES 11 | // 12 | 13 | final console = Console(); 14 | 15 | String editedFilename = ''; 16 | bool isFileDirty = false; 17 | 18 | // We keep two copies of the file contents, as follows: 19 | // 20 | // fileRows represents the actual contents of the document 21 | // 22 | // renderRows represents what we'll render on screen. This may be different to 23 | // the actual contents of the file. For example, tabs are rendered as a series 24 | // of spaces even though they are only one character; control characters may 25 | // be shown in some form in the future. 26 | List fileRows = []; 27 | List renderRows = []; 28 | 29 | // Cursor location relative to file (not the screen) 30 | int cursorCol = 0, cursorRow = 0; 31 | 32 | // Also store cursor column position relative to the rendered row 33 | int cursorRenderCol = 0; 34 | 35 | // The row in the file that is currently at the top of the screen 36 | int screenFileRowOffset = 0; 37 | // The column in the row that is currently on the left of the screen 38 | int screenRowColOffset = 0; 39 | 40 | // Allow lines for the status bar and message bar 41 | final editorWindowHeight = console.windowHeight - 2; 42 | final editorWindowWidth = console.windowWidth; 43 | 44 | // Index of the row last find match was on, or -1 if no match 45 | int findLastMatchRow = -1; 46 | 47 | // Current search direction 48 | enum FindDirection { forwards, backwards } 49 | 50 | FindDirection findDirection = FindDirection.forwards; 51 | 52 | String messageText = ''; 53 | late DateTime messageTimestamp; 54 | 55 | void initEditor() { 56 | isFileDirty = false; 57 | } 58 | 59 | void crash(String message) { 60 | console.clearScreen(); 61 | console.resetCursorPosition(); 62 | console.rawMode = false; 63 | console.write(message); 64 | exit(1); 65 | } 66 | 67 | String truncateString(String text, int length) => 68 | length < text.length ? text.substring(0, length) : text; 69 | 70 | // 71 | // EDITOR OPERATIONS 72 | // 73 | void editorInsertChar(String char) { 74 | if (cursorRow == fileRows.length) { 75 | fileRows.add(char); 76 | renderRows.add(char); 77 | } else { 78 | fileRows[cursorRow] = fileRows[cursorRow].substring(0, cursorCol) + 79 | char + 80 | fileRows[cursorRow].substring(cursorCol); 81 | } 82 | editorUpdateRenderRow(cursorRow); 83 | cursorCol++; 84 | isFileDirty = true; 85 | } 86 | 87 | void editorBackspaceChar() { 88 | // If we're past the end of the file, then there's nothing to delete 89 | if (cursorRow == fileRows.length) return; 90 | 91 | // Nothing to do if we're at the first character of the file 92 | if (cursorCol == 0 && cursorRow == 0) return; 93 | 94 | if (cursorCol > 0) { 95 | fileRows[cursorRow] = fileRows[cursorRow].substring(0, cursorCol - 1) + 96 | fileRows[cursorRow].substring(cursorCol); 97 | editorUpdateRenderRow(cursorRow); 98 | cursorCol--; 99 | } else { 100 | // delete the carriage return by appending the current line to the previous 101 | // one and then removing the current line altogether. 102 | cursorCol = fileRows[cursorRow - 1].length; 103 | fileRows[cursorRow - 1] += fileRows[cursorRow]; 104 | editorUpdateRenderRow(cursorRow - 1); 105 | fileRows.removeAt(cursorRow); 106 | renderRows.removeAt(cursorRow); 107 | cursorRow--; 108 | } 109 | isFileDirty = true; 110 | } 111 | 112 | void editorInsertNewline() { 113 | if (cursorCol == 0) { 114 | fileRows.insert(cursorRow, ''); 115 | renderRows.insert(cursorRow, ''); 116 | } else { 117 | fileRows.insert(cursorRow + 1, fileRows[cursorRow].substring(cursorCol)); 118 | fileRows[cursorRow] = fileRows[cursorRow].substring(0, cursorCol); 119 | 120 | renderRows.insert(cursorRow + 1, ''); 121 | editorUpdateRenderRow(cursorRow); 122 | editorUpdateRenderRow(cursorRow + 1); 123 | } 124 | cursorRow++; 125 | cursorCol = 0; 126 | } 127 | 128 | void editorFindCallback(String query, Key key) { 129 | if (key.controlChar == ControlCharacter.enter || 130 | key.controlChar == ControlCharacter.escape) { 131 | findLastMatchRow = -1; 132 | findDirection = FindDirection.forwards; 133 | return; 134 | } else if (key.controlChar == ControlCharacter.arrowRight || 135 | key.controlChar == ControlCharacter.arrowDown) { 136 | findDirection = FindDirection.forwards; 137 | } else if (key.controlChar == ControlCharacter.arrowLeft || 138 | key.controlChar == ControlCharacter.arrowUp) { 139 | findDirection = FindDirection.backwards; 140 | } else { 141 | findLastMatchRow = -1; 142 | findDirection = FindDirection.forwards; 143 | } 144 | 145 | if (findLastMatchRow == -1) findDirection = FindDirection.forwards; 146 | 147 | var currentRow = findLastMatchRow; 148 | if (query.isNotEmpty) { 149 | // we loop through all the rows, rotating back to the beginning/end as 150 | // necessary 151 | for (var i = 0; i < renderRows.length; i++) { 152 | if (findDirection == FindDirection.forwards) { 153 | currentRow++; 154 | } else { 155 | currentRow--; 156 | } 157 | 158 | if (currentRow == -1) { 159 | currentRow = fileRows.length - 1; 160 | } else if (currentRow == fileRows.length) { 161 | currentRow = 0; 162 | } 163 | 164 | if (renderRows[currentRow].contains(query)) { 165 | findLastMatchRow = currentRow; 166 | cursorRow = currentRow; 167 | cursorCol = 168 | getFileCol(currentRow, renderRows[currentRow].indexOf(query)); 169 | screenFileRowOffset = fileRows.length; 170 | editorSetStatusMessage( 171 | 'Search (ESC to cancel, use arrows for prev/next): $query'); 172 | editorRefreshScreen(); 173 | break; 174 | } 175 | } 176 | } 177 | } 178 | 179 | void editorFind() { 180 | final savedCursorCol = cursorCol; 181 | final savedCursorRow = cursorRow; 182 | final savedScreenFileRowOffset = screenFileRowOffset; 183 | final savedScreenRowColOffset = screenRowColOffset; 184 | 185 | final query = editorPrompt( 186 | 'Search (ESC to cancel, use arrows for prev/next): ', editorFindCallback); 187 | 188 | if (query == null) { 189 | // Escape pressed 190 | cursorCol = savedCursorCol; 191 | cursorRow = savedCursorRow; 192 | screenFileRowOffset = savedScreenFileRowOffset; 193 | screenRowColOffset = savedScreenRowColOffset; 194 | } 195 | } 196 | 197 | // 198 | // FILE I/O 199 | // 200 | void editorOpen(String filename) { 201 | final file = File(filename); 202 | try { 203 | fileRows = file.readAsLinesSync(); 204 | } on FileSystemException catch (e) { 205 | editorSetStatusMessage('Error opening file: $e'); 206 | return; 207 | } 208 | 209 | for (var rowIndex = 0; rowIndex < fileRows.length; rowIndex++) { 210 | renderRows.add(''); 211 | editorUpdateRenderRow(rowIndex); 212 | } 213 | 214 | assert(fileRows.length == renderRows.length); 215 | 216 | isFileDirty = false; 217 | } 218 | 219 | void editorSave() { 220 | if (editedFilename.isEmpty) { 221 | final saveFilename = editorPrompt('Save as: '); 222 | if (saveFilename == null) { 223 | editorSetStatusMessage('Save aborted.'); 224 | return; 225 | } else { 226 | editedFilename = saveFilename; 227 | } 228 | } 229 | 230 | // TODO: This is hopelessly naive, as with kilo.c. We should write to a 231 | // temporary file and rename to ensure that we have written successfully. 232 | final file = File(editedFilename); 233 | final fileContents = '${fileRows.join('\n')}\n'; 234 | file.writeAsStringSync(fileContents); 235 | 236 | isFileDirty = false; 237 | 238 | editorSetStatusMessage('${fileContents.length} bytes written to disk.'); 239 | } 240 | 241 | void editorQuit() { 242 | if (isFileDirty) { 243 | editorSetStatusMessage('File is unsaved. Quit anyway (y or n)?'); 244 | editorRefreshScreen(); 245 | final response = console.readKey(); 246 | if (response.char != 'y' && response.char != 'Y') { 247 | { 248 | editorSetStatusMessage(''); 249 | return; 250 | } 251 | } 252 | } 253 | console.clearScreen(); 254 | console.resetCursorPosition(); 255 | console.rawMode = false; 256 | exit(0); 257 | } 258 | 259 | // 260 | // RENDERING OPERATIONS 261 | // 262 | 263 | // Takes a column in a given row of the file and converts it to the rendered 264 | // column. For example, if the file contains \t\tFoo and tab stops are 265 | // configured to display as eight spaces, the 'F' should display as rendered 266 | // column 16 even though it is only the third character in the file. 267 | int getRenderedCol(int fileRow, int fileCol) { 268 | var col = 0; 269 | 270 | if (fileRow >= fileRows.length) return 0; 271 | 272 | final rowText = fileRows[fileRow]; 273 | for (var i = 0; i < fileCol; i++) { 274 | if (rowText[i] == '\t') { 275 | col += (kiloTabStopLength - 1) - (col % kiloTabStopLength); 276 | } 277 | col++; 278 | } 279 | return col; 280 | } 281 | 282 | // Inversion of the getRenderedCol method. Converts a rendered column index 283 | // into its corresponding position in the file. 284 | int getFileCol(int row, int renderCol) { 285 | var currentRenderCol = 0; 286 | int fileCol; 287 | final rowText = fileRows[row]; 288 | for (fileCol = 0; fileCol < rowText.length; fileCol++) { 289 | if (rowText[fileCol] == '\t') { 290 | currentRenderCol += 291 | (kiloTabStopLength - 1) - (currentRenderCol % kiloTabStopLength); 292 | } 293 | currentRenderCol++; 294 | 295 | if (currentRenderCol > renderCol) return fileCol; 296 | } 297 | return fileCol; 298 | } 299 | 300 | void editorUpdateRenderRow(int rowIndex) { 301 | assert(renderRows.length == fileRows.length); 302 | 303 | var renderBuffer = ''; 304 | final fileRow = fileRows[rowIndex]; 305 | 306 | for (var fileCol = 0; fileCol < fileRow.length; fileCol++) { 307 | if (fileRow[fileCol] == '\t') { 308 | // Add at least one space for the tab stop, plus as many more as needed to 309 | // get to the next tab stop 310 | renderBuffer += ' '; 311 | while (renderBuffer.length % kiloTabStopLength != 0) { 312 | // ignore: use_string_buffers 313 | renderBuffer += ' '; 314 | } 315 | } else { 316 | renderBuffer += fileRow[fileCol]; 317 | } 318 | renderRows[rowIndex] = renderBuffer; 319 | } 320 | } 321 | 322 | void editorScroll() { 323 | cursorRenderCol = 0; 324 | 325 | if (cursorRow < fileRows.length) { 326 | cursorRenderCol = getRenderedCol(cursorRow, cursorCol); 327 | } 328 | 329 | if (cursorRow < screenFileRowOffset) { 330 | screenFileRowOffset = cursorRow; 331 | } 332 | 333 | if (cursorRow >= screenFileRowOffset + editorWindowHeight) { 334 | screenFileRowOffset = cursorRow - editorWindowHeight + 1; 335 | } 336 | 337 | if (cursorRenderCol < screenRowColOffset) { 338 | screenRowColOffset = cursorRenderCol; 339 | } 340 | 341 | if (cursorRenderCol >= screenRowColOffset + editorWindowWidth) { 342 | screenRowColOffset = cursorRenderCol - editorWindowWidth + 1; 343 | } 344 | } 345 | 346 | void editorDrawRows() { 347 | final screenBuffer = StringBuffer(); 348 | 349 | for (var screenRow = 0; screenRow < editorWindowHeight; screenRow++) { 350 | // fileRow is the row of the file we want to print to screenRow 351 | final fileRow = screenRow + screenFileRowOffset; 352 | 353 | // If we're beyond the text buffer, print tilde in column 0 354 | if (fileRow >= fileRows.length) { 355 | // Show a welcome message 356 | if (fileRows.isEmpty && (screenRow == (editorWindowHeight / 3).round())) { 357 | // Print the welcome message centered a third of the way down the screen 358 | final welcomeMessage = truncateString( 359 | 'Kilo editor -- version $kiloVersion', editorWindowWidth); 360 | var padding = ((editorWindowWidth - welcomeMessage.length) / 2).round(); 361 | if (padding > 0) { 362 | screenBuffer.write('~'); 363 | padding--; 364 | } 365 | while (padding-- > 0) { 366 | screenBuffer.write(' '); 367 | } 368 | screenBuffer.write(welcomeMessage); 369 | } else { 370 | screenBuffer.write('~'); 371 | } 372 | } 373 | 374 | // Otherwise print the onscreen portion of the current file row, 375 | // trimmed if necessary 376 | else { 377 | if (renderRows[fileRow].length - screenRowColOffset > 0) { 378 | screenBuffer.write(truncateString( 379 | renderRows[fileRow].substring(screenRowColOffset), 380 | editorWindowWidth)); 381 | } 382 | } 383 | 384 | screenBuffer.write(console.newLine); 385 | } 386 | console.write(screenBuffer.toString()); 387 | } 388 | 389 | void editorDrawStatusBar() { 390 | console.setTextStyle(inverted: true); 391 | 392 | // TODO: Displayed filename should not include path. 393 | var leftString = 394 | '${truncateString(editedFilename.isEmpty ? "[No Name]" : editedFilename, (editorWindowWidth / 2).ceil())}' 395 | ' - ${fileRows.length} lines'; 396 | if (isFileDirty) leftString += ' (modified)'; 397 | final rightString = '${cursorRow + 1}/${fileRows.length}'; 398 | final padding = editorWindowWidth - leftString.length - rightString.length; 399 | 400 | console.write('$leftString' 401 | '${" " * padding}' 402 | '$rightString'); 403 | 404 | console.resetColorAttributes(); 405 | console.writeLine(); 406 | } 407 | 408 | void editorDrawMessageBar() { 409 | if (DateTime.now().difference(messageTimestamp) < 410 | const Duration(seconds: 5)) { 411 | console.write(truncateString(messageText, editorWindowWidth) 412 | .padRight(editorWindowWidth)); 413 | } 414 | } 415 | 416 | void editorRefreshScreen() { 417 | editorScroll(); 418 | 419 | console.hideCursor(); 420 | console.clearScreen(); 421 | 422 | editorDrawRows(); 423 | editorDrawStatusBar(); 424 | editorDrawMessageBar(); 425 | 426 | console.cursorPosition = Coordinate( 427 | cursorRow - screenFileRowOffset, cursorRenderCol - screenRowColOffset); 428 | console.showCursor(); 429 | } 430 | 431 | void editorSetStatusMessage(String message) { 432 | messageText = message; 433 | messageTimestamp = DateTime.now(); 434 | } 435 | 436 | String? editorPrompt(String message, 437 | [void Function(String text, Key lastPressed)? callback]) { 438 | final originalCursorRow = cursorRow; 439 | 440 | editorSetStatusMessage(message); 441 | editorRefreshScreen(); 442 | 443 | console.cursorPosition = Coordinate(console.windowHeight - 1, message.length); 444 | 445 | final response = console.readLine(cancelOnEscape: true, callback: callback); 446 | cursorRow = originalCursorRow; 447 | editorSetStatusMessage(''); 448 | 449 | return response; 450 | } 451 | 452 | // 453 | // INPUT OPERATIONS 454 | // 455 | void editorMoveCursor(ControlCharacter key) { 456 | switch (key) { 457 | case ControlCharacter.arrowLeft: 458 | if (cursorCol != 0) { 459 | cursorCol--; 460 | } else if (cursorRow > 0) { 461 | cursorRow--; 462 | cursorCol = fileRows[cursorRow].length; 463 | } 464 | break; 465 | case ControlCharacter.arrowRight: 466 | if (cursorRow < fileRows.length) { 467 | if (cursorCol < fileRows[cursorRow].length) { 468 | cursorCol++; 469 | } else if (cursorCol == fileRows[cursorRow].length) { 470 | cursorCol = 0; 471 | cursorRow++; 472 | } 473 | } 474 | break; 475 | case ControlCharacter.arrowUp: 476 | if (cursorRow != 0) cursorRow--; 477 | break; 478 | case ControlCharacter.arrowDown: 479 | if (cursorRow < fileRows.length) cursorRow++; 480 | break; 481 | case ControlCharacter.pageUp: 482 | cursorRow = screenFileRowOffset; 483 | for (var i = 0; i < editorWindowHeight; i++) { 484 | editorMoveCursor(ControlCharacter.arrowUp); 485 | } 486 | break; 487 | case ControlCharacter.pageDown: 488 | cursorRow = screenFileRowOffset + editorWindowHeight - 1; 489 | for (var i = 0; i < editorWindowHeight; i++) { 490 | editorMoveCursor(ControlCharacter.arrowDown); 491 | } 492 | break; 493 | case ControlCharacter.home: 494 | cursorCol = 0; 495 | break; 496 | case ControlCharacter.end: 497 | if (cursorRow < fileRows.length) { 498 | cursorCol = fileRows[cursorRow].length; 499 | } 500 | break; 501 | default: 502 | } 503 | 504 | if (cursorRow < fileRows.length) { 505 | cursorCol = min(cursorCol, fileRows[cursorRow].length); 506 | } 507 | } 508 | 509 | void editorProcessKeypress() { 510 | final key = console.readKey(); 511 | 512 | if (key.isControl) { 513 | switch (key.controlChar) { 514 | case ControlCharacter.ctrlQ: 515 | editorQuit(); 516 | break; 517 | case ControlCharacter.ctrlS: 518 | editorSave(); 519 | break; 520 | case ControlCharacter.ctrlF: 521 | editorFind(); 522 | break; 523 | case ControlCharacter.backspace: 524 | case ControlCharacter.ctrlH: 525 | editorBackspaceChar(); 526 | break; 527 | case ControlCharacter.delete: 528 | editorMoveCursor(ControlCharacter.arrowRight); 529 | editorBackspaceChar(); 530 | break; 531 | case ControlCharacter.enter: 532 | editorInsertNewline(); 533 | break; 534 | case ControlCharacter.arrowLeft: 535 | case ControlCharacter.arrowUp: 536 | case ControlCharacter.arrowRight: 537 | case ControlCharacter.arrowDown: 538 | case ControlCharacter.pageUp: 539 | case ControlCharacter.pageDown: 540 | case ControlCharacter.home: 541 | case ControlCharacter.end: 542 | editorMoveCursor(key.controlChar); 543 | break; 544 | case ControlCharacter.ctrlA: 545 | editorMoveCursor(ControlCharacter.home); 546 | break; 547 | case ControlCharacter.ctrlE: 548 | editorMoveCursor(ControlCharacter.end); 549 | break; 550 | default: 551 | } 552 | } else { 553 | editorInsertChar(key.char); 554 | } 555 | } 556 | 557 | // 558 | // ENTRY POINT 559 | // 560 | 561 | void main(List arguments) { 562 | try { 563 | console.rawMode = true; 564 | initEditor(); 565 | if (arguments.isNotEmpty) { 566 | editedFilename = arguments[0]; 567 | editorOpen(editedFilename); 568 | } 569 | 570 | editorSetStatusMessage( 571 | 'HELP: Ctrl-S = save | Ctrl-Q = quit | Ctrl-F = find'); 572 | 573 | while (true) { 574 | editorRefreshScreen(); 575 | editorProcessKeypress(); 576 | } 577 | } catch (exception) { 578 | // Make sure raw mode gets disabled if we hit some unrelated problem 579 | console.rawMode = false; 580 | rethrow; 581 | } 582 | } 583 | -------------------------------------------------------------------------------- /example/life.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | import 'dart:math'; 4 | 5 | import 'package:dart_console/dart_console.dart'; 6 | 7 | final console = Console(); 8 | final random = Random(); 9 | 10 | final int rows = console.windowHeight; 11 | final int cols = console.windowWidth; 12 | final int size = rows * cols; 13 | 14 | final temp = List.filled(size, false, growable: false); 15 | final data = 16 | List.generate(size, (i) => random.nextBool(), growable: false); 17 | 18 | final buffer = StringBuffer(); 19 | 20 | bool done = false; 21 | 22 | final neighbors = [ 23 | [-1, -1], 24 | [0, -1], 25 | [1, -1], 26 | [-1, 0], 27 | [1, 0], 28 | [-1, 1], 29 | [0, 1], 30 | [1, 1], 31 | ]; 32 | 33 | void draw() { 34 | console.setBackgroundColor(ConsoleColor.black); 35 | console.setForegroundColor(ConsoleColor.blue); 36 | console.clearScreen(); 37 | 38 | buffer.clear(); 39 | 40 | for (var row = 0; row < rows; row++) { 41 | for (var col = 0; col < cols; col++) { 42 | final index = row * rows + col; 43 | buffer.write(data[index] ? '#' : ' '); 44 | } 45 | buffer.write(console.newLine); 46 | } 47 | 48 | console.write(buffer.toString()); 49 | } 50 | 51 | int numLiveNeighbors(int row, int col) { 52 | var sum = 0; 53 | for (var i = 0; i < 8; i++) { 54 | final x = col + neighbors[i][0]; 55 | if (x < 0 || x >= cols) continue; 56 | final y = row + neighbors[i][1]; 57 | if (y < 0 || y >= rows) continue; 58 | sum += data[y * rows + x] ? 1 : 0; 59 | } 60 | return sum; 61 | } 62 | 63 | /* 64 | * 1. Any live cell with fewer than two live neighbors dies, as if caused 65 | * by underpopulation. 66 | * 2. Any live cell with two or three live neighbors lives on to the next 67 | * generation. 68 | * 3. Any live cell with more than three live neighbors dies, as if by 69 | * overpopulation. 70 | * 4. Any dead cell with exactly three live neighbors becomes a live cell, as 71 | * if by reproduction. 72 | */ 73 | void update() { 74 | for (var row = 0; row < rows; row++) { 75 | for (var col = 0; col < cols; col++) { 76 | final n = numLiveNeighbors(row, col); 77 | final index = row * rows + col; 78 | final v = data[index]; 79 | temp[index] = (v == true && (n == 2 || n == 3)) || (v == false && n == 3); 80 | } 81 | } 82 | data.setAll(0, temp); 83 | } 84 | 85 | void input() { 86 | final key = console.readKey(); 87 | if (key.isControl) { 88 | switch (key.controlChar) { 89 | case ControlCharacter.escape: 90 | done = true; 91 | break; 92 | default: 93 | } 94 | } 95 | } 96 | 97 | void resetConsole() { 98 | console.clearScreen(); 99 | console.resetCursorPosition(); 100 | console.resetColorAttributes(); 101 | console.rawMode = false; 102 | } 103 | 104 | void crash(String message) { 105 | resetConsole(); 106 | console.write(message); 107 | exit(1); 108 | } 109 | 110 | void quit() { 111 | resetConsole(); 112 | exit(0); 113 | } 114 | 115 | void main(List arguments) { 116 | try { 117 | console.rawMode = false; 118 | console.hideCursor(); 119 | 120 | Timer.periodic(const Duration(milliseconds: 200), (t) { 121 | draw(); 122 | update(); 123 | //input(); // TODO: need async input 124 | if (done) quit(); 125 | }); 126 | } catch (exception) { 127 | crash(exception.toString()); 128 | rethrow; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /example/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:dart_console/dart_console.dart'; 2 | 3 | void main() { 4 | final console = Console(); 5 | console.setBackgroundColor(ConsoleColor.blue); 6 | console.setForegroundColor(ConsoleColor.white); 7 | console.writeLine('Simple Demo', TextAlignment.center); 8 | console.resetColorAttributes(); 9 | 10 | console.writeLine(); 11 | 12 | console.writeLine('This console window has ${console.windowWidth} cols and ' 13 | '${console.windowHeight} rows.'); 14 | console.writeLine(); 15 | 16 | console.writeLine('This text is left aligned.', TextAlignment.left); 17 | console.writeLine('This text is center aligned.', TextAlignment.center); 18 | console.writeLine('This text is right aligned.', TextAlignment.right); 19 | 20 | for (final color in ConsoleColor.values) { 21 | console.setForegroundColor(color); 22 | console.writeLine(color.toString().split('.').last); 23 | } 24 | console.resetColorAttributes(); 25 | } 26 | -------------------------------------------------------------------------------- /example/rawkeys.dart: -------------------------------------------------------------------------------- 1 | // rawkeys.dart 2 | // 3 | // Diagnostic test for tracking down differences in raw key input from different 4 | // platforms. 5 | 6 | import 'dart:io'; 7 | 8 | import 'package:dart_console/dart_console.dart'; 9 | 10 | final console = Console(); 11 | 12 | void main() { 13 | console.writeLine('Purely for testing purposes.'); 14 | console.writeLine(); 15 | console.writeLine( 16 | 'This method echos what stdin reads. Useful for testing unusual terminals.'); 17 | console.writeLine("Press 'q' to return to the command prompt."); 18 | console.rawMode = true; 19 | 20 | while (true) { 21 | var codeUnit = 0; 22 | while (codeUnit <= 0) { 23 | codeUnit = stdin.readByteSync(); 24 | } 25 | 26 | if (codeUnit < 0x20 || codeUnit == 0x7F) { 27 | print('${codeUnit.toRadixString(16)}\r'); 28 | } else { 29 | print( 30 | '${codeUnit.toRadixString(16)} (${String.fromCharCode(codeUnit)})\r'); 31 | } 32 | 33 | if (String.fromCharCode(codeUnit) == 'q') { 34 | console.rawMode = false; 35 | exit(0); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /example/readline.dart: -------------------------------------------------------------------------------- 1 | // readline.dart 2 | // 3 | // Demonstrates a simple command-line interface that does not require line 4 | // editing services from the shell. 5 | 6 | import 'dart:io'; 7 | 8 | import 'package:dart_console/dart_console.dart'; 9 | 10 | final console = Console(); 11 | 12 | const prompt = '>>> '; 13 | 14 | // Inspired by 15 | // http://www.lihaoyi.com/post/BuildyourownCommandLinewithANSIescapecodes.html#writing-a-command-line 16 | // as a test of the Console class capabilities 17 | 18 | void main() { 19 | console.write('The '); 20 | console.setForegroundColor(ConsoleColor.brightYellow); 21 | console.write('Console.readLine()'); 22 | console.resetColorAttributes(); 23 | console.writeLine(' method provides a basic readline implementation.'); 24 | 25 | console.write('Unlike the built-in '); 26 | console.setForegroundColor(ConsoleColor.brightYellow); 27 | console.write('stdin.readLineSync()'); 28 | console.resetColorAttributes(); 29 | console.writeLine(' method, you can use arrow keys as well as home/end.'); 30 | console.writeLine(); 31 | 32 | console.writeLine('As a demo, this command-line reader "shouts" all text ' 33 | 'back in upper case.'); 34 | console.writeLine('Enter a blank line or press Ctrl+C to exit.'); 35 | 36 | while (true) { 37 | console.write(prompt); 38 | final response = console.readLine(cancelOnBreak: true); 39 | if (response == null || response.isEmpty) { 40 | exit(0); 41 | } else { 42 | console.writeLine('YOU SAID: ${response.toUpperCase()}'); 43 | console.writeLine(); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /example/readline_scrolling.dart: -------------------------------------------------------------------------------- 1 | // readline.dart 2 | // 3 | // Demonstrates a simple command-line interface that does not require line 4 | // editing services from the shell. 5 | 6 | import 'dart:io'; 7 | 8 | import 'package:dart_console/dart_console.dart'; 9 | 10 | final console = Console.scrolling(); 11 | 12 | const prompt = '>>> '; 13 | 14 | // Inspired by 15 | // http://www.lihaoyi.com/post/BuildyourownCommandLinewithANSIescapecodes.html#writing-a-command-line 16 | // as a test of the Console class capabilities 17 | 18 | void main() { 19 | console.write('The '); 20 | console.setForegroundColor(ConsoleColor.brightYellow); 21 | console.write('Console.readLine()'); 22 | console.resetColorAttributes(); 23 | console.writeLine(' method provides a basic readline implementation.'); 24 | 25 | console.write('Unlike the built-in '); 26 | console.setForegroundColor(ConsoleColor.brightYellow); 27 | console.write('stdin.readLineSync()'); 28 | console.resetColorAttributes(); 29 | console.writeLine(' method, you can use arrow keys as well as home/end.'); 30 | console.writeLine( 31 | 'In this demo, you can use the up-arrow key to scroll back to previous entries'); 32 | console.writeLine( 33 | 'and the down-arrow key to scroll forward after scrolling back.'); 34 | console.writeLine(); 35 | 36 | console.writeLine('As a demo, this command-line reader "shouts" all text ' 37 | 'back in upper case.'); 38 | console.writeLine('Enter a blank line or press Ctrl+C to exit.'); 39 | 40 | while (true) { 41 | console.write(prompt); 42 | final response = console.readLine(cancelOnBreak: true); 43 | if (response == null || response.isEmpty) { 44 | exit(0); 45 | } else { 46 | console.writeLine('YOU SAID: ${response.toUpperCase()}'); 47 | console.writeLine(); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /example/readme.dart: -------------------------------------------------------------------------------- 1 | import 'package:dart_console/dart_console.dart'; 2 | 3 | int main() { 4 | final console = Console(); 5 | 6 | console.clearScreen(); 7 | console.resetCursorPosition(); 8 | 9 | console.writeLine( 10 | 'Console size is ${console.windowWidth} cols and ${console.windowHeight} rows.', 11 | TextAlignment.center, 12 | ); 13 | 14 | console.writeLine(); 15 | 16 | return 0; 17 | } 18 | -------------------------------------------------------------------------------- /example/table.dart: -------------------------------------------------------------------------------- 1 | import 'package:dart_console/dart_console.dart'; 2 | 3 | const earlyPresidents = [ 4 | [ 5 | 1, 6 | 'April 30, 1789 - March 4, 1797', 7 | 'George Washington', 8 | 'unaffiliated', 9 | ], 10 | [ 11 | 2, 12 | 'March 4, 1797 - March 4, 1801', 13 | 'John Adams', 14 | 'Federalist', 15 | ], 16 | [ 17 | 3, 18 | 'March 4, 1801 - March 4, 1809', 19 | 'Thomas Jefferson', 20 | 'Democratic-Republican', 21 | ], 22 | [ 23 | 4, 24 | 'March 4, 1809 - March 4, 1817', 25 | 'James Madison', 26 | 'Democratic-Republican', 27 | ], 28 | [ 29 | 5, 30 | 'March 4, 1817 - March 4, 1825', 31 | 'James Monroe', 32 | 'Democratic-Republican', 33 | ], 34 | ]; 35 | 36 | void main() { 37 | final table = Table() 38 | ..insertColumn(header: 'Number', alignment: TextAlignment.center) 39 | ..insertColumn(header: 'Presidency', alignment: TextAlignment.right) 40 | ..insertColumn(header: 'President') 41 | ..insertColumn(header: 'Party') 42 | ..insertRows(earlyPresidents) 43 | ..borderStyle = BorderStyle.square 44 | ..borderColor = ConsoleColor.brightBlue 45 | ..borderType = BorderType.vertical 46 | ..headerStyle = FontStyle.bold; 47 | print(table); 48 | } 49 | -------------------------------------------------------------------------------- /lib/dart_console.dart: -------------------------------------------------------------------------------- 1 | // dart_console.dart 2 | 3 | /// Console library. 4 | /// 5 | /// Provides enhanced console capabilities for a CLI application that wants to 6 | /// do more than write plain text to stdout. 7 | /// 8 | /// ## Console manipulation 9 | /// 10 | /// The [Console] class provides simple methods to control the cursor position, 11 | /// hide the cursor, change the foreground or background color, clear the 12 | /// terminal display, read individual keys without echoing to the terminal, 13 | /// erase text and write aligned text to the screen. 14 | /// 15 | /// ## Table display 16 | /// 17 | /// The [Table] class allows two-dimensional data sets to be displayed in a 18 | /// tabular form, with headers, custom box characters and line drawing, row and 19 | /// column formatting and manipulation. 20 | /// 21 | /// ## Calendar 22 | /// 23 | /// The [Calendar] class displays a monthly calendar, with options for 24 | /// controlling color and whether the current date is highlighted. 25 | /// 26 | /// ## Progress Bar 27 | /// 28 | /// The [ProgressBar] class presents a progress bar for long-running operations, 29 | /// optionally including a spinner. It supports headless displays (where it is 30 | /// silent), as well as interactive consoles, and can be adjusted for custom 31 | /// presentation. 32 | /// 33 | /// The class works on any desktop environment that supports ANSI escape 34 | /// characters, and has some fallback capabilities for older versions of Windows 35 | /// that use traditional console APIs. 36 | library dart_console; 37 | 38 | export 'src/calendar.dart'; 39 | export 'src/console.dart'; 40 | export 'src/consolecolor.dart'; 41 | export 'src/key.dart'; 42 | export 'src/progressbar.dart'; 43 | export 'src/scrollbackbuffer.dart'; 44 | export 'src/string_utils.dart'; 45 | export 'src/table.dart'; 46 | export 'src/textalignment.dart'; 47 | -------------------------------------------------------------------------------- /lib/src/ansi.dart: -------------------------------------------------------------------------------- 1 | // ansi.dart 2 | // 3 | // Contains ANSI escape sequences used by dart_console. Other classes should 4 | // use these constants rather than embedding raw control codes. 5 | // 6 | // For more information on commonly-accepted ANSI mode control sequences, read 7 | // https://vt100.net/docs/vt100-ug/chapter3.html. 8 | 9 | const ansiDeviceStatusReportCursorPosition = '\x1b[6n'; 10 | const ansiEraseInDisplayAll = '\x1b[2J'; 11 | const ansiEraseInLineAll = '\x1b[2K'; 12 | const ansiEraseCursorToEnd = '\x1b[K'; 13 | 14 | const ansiHideCursor = '\x1b[?25l'; 15 | const ansiShowCursor = '\x1b[?25h'; 16 | 17 | const ansiCursorLeft = '\x1b[D'; 18 | const ansiCursorRight = '\x1b[C'; 19 | const ansiCursorUp = '\x1b[A'; 20 | const ansiCursorDown = '\x1b[B'; 21 | 22 | const ansiResetCursorPosition = '\x1b[H'; 23 | const ansiMoveCursorToScreenEdge = '\x1b[999C\x1b[999B'; 24 | String ansiCursorPosition(int row, int col) => '\x1b[$row;${col}H'; 25 | 26 | String ansiSetColor(int color) => '\x1b[${color}m'; 27 | String ansiSetExtendedForegroundColor(int color) => '\x1b[38;5;${color}m'; 28 | String ansiSetExtendedBackgroundColor(int color) => '\x1b[48;5;${color}m'; 29 | const ansiResetColor = '\x1b[m'; 30 | 31 | String ansiSetTextStyles( 32 | {bool bold = false, 33 | bool faint = false, 34 | bool italic = false, 35 | bool underscore = false, 36 | bool blink = false, 37 | bool inverted = false, 38 | bool invisible = false, 39 | bool strikethru = false}) { 40 | final styles = []; 41 | if (bold) styles.add(1); 42 | if (faint) styles.add(2); 43 | if (italic) styles.add(3); 44 | if (underscore) styles.add(4); 45 | if (blink) styles.add(5); 46 | if (inverted) styles.add(7); 47 | if (invisible) styles.add(8); 48 | if (strikethru) styles.add(9); 49 | return '\x1b[${styles.join(";")}m'; 50 | } 51 | -------------------------------------------------------------------------------- /lib/src/calendar.dart: -------------------------------------------------------------------------------- 1 | import 'package:dart_console/src/ansi.dart'; 2 | import 'package:intl/intl.dart'; 3 | 4 | import 'consolecolor.dart'; 5 | import 'table.dart'; 6 | import 'textalignment.dart'; 7 | 8 | class Calendar extends Table { 9 | final DateTime calendarDate; 10 | bool highlightTodaysDate = true; 11 | 12 | List dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; 13 | 14 | Calendar(DateTime dateTime) 15 | : calendarDate = dateTime.subtract(Duration(days: dateTime.day - 1)) { 16 | for (final day in dayLabels) { 17 | insertColumn(header: day, alignment: TextAlignment.right); 18 | } 19 | 20 | // ISO format has 1..7 for Mon..Sun, so we adjust this to match the array 21 | final startDate = calendarDate.weekday == 7 ? 0 : calendarDate.weekday; 22 | 23 | final todayColor = ConsoleColor.brightYellow.ansiSetForegroundColorSequence; 24 | 25 | final calendarDates = [ 26 | for (int i = 0; i < startDate; i++) '', 27 | for (int i = 1; i <= 31; i++) 28 | if (calendarDate.add(Duration(days: i - 1)).month == calendarDate.month) 29 | if (calendarDate.year == DateTime.now().year && 30 | calendarDate.month == DateTime.now().month && 31 | i == DateTime.now().day) 32 | '$todayColor$i$ansiResetColor' 33 | else 34 | '$i', 35 | ]; 36 | 37 | while (true) { 38 | insertRow(calendarDates.take(7).toList()); 39 | if (calendarDates.length > 7) { 40 | calendarDates.removeRange(0, 7); 41 | } else { 42 | break; 43 | } 44 | } 45 | 46 | title = DateFormat('MMMM yyyy').format(calendarDate); 47 | } 48 | 49 | factory Calendar.now() => Calendar(DateTime.now()); 50 | } 51 | -------------------------------------------------------------------------------- /lib/src/console.dart: -------------------------------------------------------------------------------- 1 | // console.dart 2 | // 3 | // Contains the primary API for dart_console, exposed through the `Console` 4 | // class. 5 | 6 | import 'dart:io'; 7 | import 'dart:math'; 8 | 9 | import 'ffi/termlib.dart'; 10 | import 'ffi/win/termlib_win.dart'; 11 | 12 | import 'ansi.dart'; 13 | import 'consolecolor.dart'; 14 | import 'key.dart'; 15 | import 'scrollbackbuffer.dart'; 16 | import 'string_utils.dart'; 17 | import 'textalignment.dart'; 18 | 19 | /// A screen position, measured in rows and columns from the top-left origin 20 | /// of the screen. Coordinates are zero-based, and converted as necessary 21 | /// for the underlying system representation (e.g. one-based for VT-style 22 | /// displays). 23 | class Coordinate extends Point { 24 | const Coordinate(int row, int col) : super(row, col); 25 | 26 | int get row => x; 27 | int get col => y; 28 | 29 | @override 30 | String toString() => '($row, $col)'; 31 | } 32 | 33 | /// A representation of the current console window. 34 | /// 35 | /// Use the [Console] to get information about the current window and to read 36 | /// and write to it. 37 | /// 38 | /// A comprehensive set of demos of using the Console class can be found in the 39 | /// `examples/` subdirectory. 40 | class Console { 41 | bool _isRawMode = false; 42 | 43 | final _termlib = TermLib(); 44 | 45 | // Declare the type explicitly: Initializing the _scrollbackBuffer 46 | // in the constructor means that we can no longer infer the type 47 | // here. 48 | final ScrollbackBuffer? _scrollbackBuffer; 49 | 50 | // Declaring the named constructor means that Dart no longer 51 | // supplies the default constructor. Besides, we need to set 52 | // _scrollbackBuffer to null for the regular console to work as 53 | // before. 54 | Console() : _scrollbackBuffer = null; 55 | 56 | // Create a named constructor specifically for scrolling consoles 57 | // Use `Console.scrolling(recordBlanks: false)` to omit blank lines 58 | // from console history 59 | Console.scrolling({bool recordBlanks = true}) 60 | : _scrollbackBuffer = ScrollbackBuffer(recordBlanks: recordBlanks); 61 | 62 | /// Enables or disables raw mode. 63 | /// 64 | /// There are a series of flags applied to a UNIX-like terminal that together 65 | /// constitute 'raw mode'. These flags turn off echoing of character input, 66 | /// processing of input signals like Ctrl+C, and output processing, as well as 67 | /// buffering of input until a full line is entered. 68 | /// 69 | /// Raw mode is useful for console applications like text editors, which 70 | /// perform their own input and output processing, as well as for reading a 71 | /// single key from the input. 72 | /// 73 | /// In general, you should not need to enable or disable raw mode explicitly; 74 | /// you should call the [readKey] command, which takes care of handling raw 75 | /// mode for you. 76 | /// 77 | /// If you use raw mode, you should disable it before your program returns, to 78 | /// avoid the console being left in a state unsuitable for interactive input. 79 | /// 80 | /// When raw mode is enabled, the newline command (`\n`) does not also perform 81 | /// a carriage return (`\r`). You can use the [newLine] property or the 82 | /// [writeLine] function instead of explicitly using `\n` to ensure the 83 | /// correct results. 84 | /// 85 | set rawMode(bool value) { 86 | _isRawMode = value; 87 | if (value) { 88 | _termlib.enableRawMode(); 89 | } else { 90 | _termlib.disableRawMode(); 91 | } 92 | } 93 | 94 | /// Returns whether the terminal is in raw mode. 95 | /// 96 | /// There are a series of flags applied to a UNIX-like terminal that together 97 | /// constitute 'raw mode'. These flags turn off echoing of character input, 98 | /// processing of input signals like Ctrl+C, and output processing, as well as 99 | /// buffering of input until a full line is entered. 100 | bool get rawMode => _isRawMode; 101 | 102 | /// Returns whether the terminal supports Unicode emojis (👍) 103 | /// 104 | /// Assume Unicode emojis are supported when not on Windows. 105 | /// If we are on Windows, Unicode emojis are supported in Windows Terminal, 106 | /// which sets the WT_SESSION environment variable. See: 107 | /// https://github.com/microsoft/terminal/issues/1040 108 | bool get supportsEmoji => 109 | !Platform.isWindows || Platform.environment.containsKey('WT_SESSION'); 110 | 111 | /// Clears the entire screen 112 | void clearScreen() { 113 | if (Platform.isWindows) { 114 | final winTermlib = _termlib as TermLibWindows; 115 | winTermlib.clearScreen(); 116 | } else { 117 | stdout.write(ansiEraseInDisplayAll + ansiResetCursorPosition); 118 | } 119 | } 120 | 121 | /// Erases all the characters in the current line. 122 | void eraseLine() => stdout.write(ansiEraseInLineAll); 123 | 124 | /// Erases the current line from the cursor to the end of the line. 125 | void eraseCursorToEnd() => stdout.write(ansiEraseCursorToEnd); 126 | 127 | /// Returns the width of the current console window in characters. 128 | int get windowWidth { 129 | if (hasTerminal) { 130 | return stdout.terminalColumns; 131 | } else { 132 | // Treat a window that has no terminal as if it is 80x25. This should be 133 | // more compatible with CI/CD environments. 134 | return 80; 135 | } 136 | } 137 | 138 | /// Returns the height of the current console window in characters. 139 | int get windowHeight { 140 | if (hasTerminal) { 141 | return stdout.terminalLines; 142 | } else { 143 | // Treat a window that has no terminal as if it is 80x25. This should be 144 | // more compatible with CI/CD environments. 145 | return 25; 146 | } 147 | } 148 | 149 | /// Whether there is a terminal attached to stdout. 150 | bool get hasTerminal => stdout.hasTerminal; 151 | 152 | /// Hides the cursor. 153 | /// 154 | /// If you hide the cursor, you should take care to return the cursor to 155 | /// a visible status at the end of the program, even if it throws an 156 | /// exception, by calling the [showCursor] method. 157 | void hideCursor() => stdout.write(ansiHideCursor); 158 | 159 | /// Shows the cursor. 160 | void showCursor() => stdout.write(ansiShowCursor); 161 | 162 | /// Moves the cursor one position to the left. 163 | void cursorLeft() => stdout.write(ansiCursorLeft); 164 | 165 | /// Moves the cursor one position to the right. 166 | void cursorRight() => stdout.write(ansiCursorRight); 167 | 168 | /// Moves the cursor one position up. 169 | void cursorUp() => stdout.write(ansiCursorUp); 170 | 171 | /// Moves the cursor one position down. 172 | void cursorDown() => stdout.write(ansiCursorDown); 173 | 174 | /// Moves the cursor to the top left corner of the screen. 175 | void resetCursorPosition() => stdout.write(ansiCursorPosition(1, 1)); 176 | 177 | /// Returns the current cursor position as a coordinate. 178 | /// 179 | /// Warning: Linux and macOS terminals report their cursor position by 180 | /// posting an escape sequence to stdin in response to a request. However, 181 | /// if there is lots of other keyboard input at the same time, some 182 | /// terminals may interleave that input in the response. There is no 183 | /// easy way around this; the recommendation is therefore to use this call 184 | /// before reading keyboard input, to get an original offset, and then 185 | /// track the local cursor independently based on keyboard input. 186 | /// 187 | /// 188 | Coordinate? get cursorPosition { 189 | rawMode = true; 190 | stdout.write(ansiDeviceStatusReportCursorPosition); 191 | // returns a Cursor Position Report result in the form [24;80R 192 | // which we have to parse apart, unfortunately 193 | var result = ''; 194 | var i = 0; 195 | 196 | // avoid infinite loop if we're getting a bad result 197 | while (i < 16) { 198 | final readByte = stdin.readByteSync(); 199 | 200 | if (readByte == -1) break; // headless console may not report back 201 | 202 | // ignore: use_string_buffers 203 | result += String.fromCharCode(readByte); 204 | if (result.endsWith('R')) break; 205 | i++; 206 | } 207 | rawMode = false; 208 | 209 | if (result[0] != '\x1b') { 210 | print(' result: $result result.length: ${result.length}'); 211 | return null; 212 | } 213 | 214 | result = result.substring(2, result.length - 1); 215 | final coords = result.split(';'); 216 | 217 | if (coords.length != 2) { 218 | print(' coords.length: ${coords.length}'); 219 | return null; 220 | } 221 | if ((int.tryParse(coords[0]) != null) && 222 | (int.tryParse(coords[1]) != null)) { 223 | return Coordinate(int.parse(coords[0]) - 1, int.parse(coords[1]) - 1); 224 | } else { 225 | print(' coords[0]: ${coords[0]} coords[1]: ${coords[1]}'); 226 | return null; 227 | } 228 | } 229 | 230 | /// Sets the cursor to a specific coordinate. 231 | /// 232 | /// Coordinates are measured from the top left of the screen, and are 233 | /// zero-based. 234 | set cursorPosition(Coordinate? cursor) { 235 | if (cursor != null) { 236 | if (Platform.isWindows) { 237 | final winTermlib = _termlib as TermLibWindows; 238 | winTermlib.setCursorPosition(cursor.col, cursor.row); 239 | } else { 240 | stdout.write(ansiCursorPosition(cursor.row + 1, cursor.col + 1)); 241 | } 242 | } 243 | } 244 | 245 | /// Sets the console foreground color to a named ANSI color. 246 | /// 247 | /// There are 16 named ANSI colors, as defined in the [ConsoleColor] 248 | /// enumeration. Depending on the console theme and background color, 249 | /// some colors may not offer a legible contrast against the background. 250 | void setForegroundColor(ConsoleColor foreground) { 251 | stdout.write(foreground.ansiSetForegroundColorSequence); 252 | } 253 | 254 | /// Sets the console background color to a named ANSI color. 255 | /// 256 | /// There are 16 named ANSI colors, as defined in the [ConsoleColor] 257 | /// enumeration. Depending on the console theme and background color, 258 | /// some colors may not offer a legible contrast against the background. 259 | void setBackgroundColor(ConsoleColor background) { 260 | stdout.write(background.ansiSetBackgroundColorSequence); 261 | } 262 | 263 | /// Sets the foreground to one of 256 extended ANSI colors. 264 | /// 265 | /// See https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit for 266 | /// the full set of colors. You may also run `examples/demo.dart` for this 267 | /// package, which provides a sample of each color in this list. 268 | void setForegroundExtendedColor(int colorValue) { 269 | assert(colorValue >= 0 && colorValue <= 0xFF, 270 | 'Color must be a value between 0 and 255.'); 271 | 272 | stdout.write(ansiSetExtendedForegroundColor(colorValue)); 273 | } 274 | 275 | /// Sets the background to one of 256 extended ANSI colors. 276 | /// 277 | /// See https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit for 278 | /// the full set of colors. You may also run `examples/demo.dart` for this 279 | /// package, which provides a sample of each color in this list. 280 | void setBackgroundExtendedColor(int colorValue) { 281 | assert(colorValue >= 0 && colorValue <= 0xFF, 282 | 'Color must be a value between 0 and 255.'); 283 | 284 | stdout.write(ansiSetExtendedBackgroundColor(colorValue)); 285 | } 286 | 287 | /// Sets the text style. 288 | /// 289 | /// Note that not all styles may be supported by all terminals. 290 | void setTextStyle( 291 | {bool bold = false, 292 | bool faint = false, 293 | bool italic = false, 294 | bool underscore = false, 295 | bool blink = false, 296 | bool inverted = false, 297 | bool invisible = false, 298 | bool strikethru = false}) { 299 | stdout.write(ansiSetTextStyles( 300 | bold: bold, 301 | faint: faint, 302 | italic: italic, 303 | underscore: underscore, 304 | blink: blink, 305 | inverted: inverted, 306 | invisible: invisible, 307 | strikethru: strikethru)); 308 | } 309 | 310 | /// Resets all color attributes and text styles to the default terminal 311 | /// setting. 312 | void resetColorAttributes() => stdout.write(ansiResetColor); 313 | 314 | /// Writes the text to the console. 315 | void write(Object text) => stdout.write(text); 316 | 317 | /// Returns the current newline string. 318 | String get newLine => _isRawMode ? '\r\n' : '\n'; 319 | 320 | /// Writes an error message to the console, with newline automatically 321 | /// appended. 322 | void writeErrorLine(Object text) { 323 | stderr.write(text); 324 | 325 | // Even if we're in raw mode, we write '\n', since raw mode only applies 326 | // to stdout 327 | stderr.write('\n'); 328 | } 329 | 330 | /// Writes a line to the console, optionally with alignment provided by the 331 | /// [TextAlignment] enumeration. 332 | /// 333 | /// If no parameters are supplied, the command simply writes a new line 334 | /// to the console. By default, text is left aligned. 335 | /// 336 | /// Text alignment operates based off the current window width, and pads 337 | /// the remaining characters with a space character. 338 | void writeLine([Object? text, TextAlignment alignment = TextAlignment.left]) { 339 | final int width = windowWidth; 340 | if (text != null) { 341 | writeAligned(text.toString(), width, alignment); 342 | } 343 | stdout.writeln(); 344 | } 345 | 346 | /// Writes a quantity of text to the console with padding to the given width. 347 | void writeAligned(Object text, 348 | [int? width, TextAlignment alignment = TextAlignment.left]) { 349 | final textAsString = text.toString(); 350 | stdout.write(textAsString.alignText( 351 | width: width ?? textAsString.length, alignment: alignment)); 352 | } 353 | 354 | /// Reads a single key from the input, including a variety of control 355 | /// characters. 356 | /// 357 | /// Keys are represented by the [Key] class. Keys may be printable (if so, 358 | /// `Key.isControl` is `false`, and the `Key.char` property may be used to 359 | /// identify the key pressed. Non-printable keys have `Key.isControl` set 360 | /// to `true`, and if so the `Key.char` property is empty and instead the 361 | /// `Key.controlChar` property will be set to a value from the 362 | /// [ControlCharacter] enumeration that describes which key was pressed. 363 | /// 364 | /// Owing to the limitations of terminal key handling, certain keys may 365 | /// be represented by multiple control key sequences. An example showing 366 | /// basic key handling can be found in the `example/command_line.dart` 367 | /// file in the package source code. 368 | Key readKey() { 369 | Key key; 370 | int charCode; 371 | var codeUnit = 0; 372 | 373 | rawMode = true; 374 | while (codeUnit <= 0) { 375 | codeUnit = stdin.readByteSync(); 376 | } 377 | 378 | if (codeUnit >= 0x01 && codeUnit <= 0x1a) { 379 | // Ctrl+A thru Ctrl+Z are mapped to the 1st-26th entries in the 380 | // enum, so it's easy to convert them across 381 | key = Key.control(ControlCharacter.values[codeUnit]); 382 | } else if (codeUnit == 0x1b) { 383 | // escape sequence (e.g. \x1b[A for up arrow) 384 | key = Key.control(ControlCharacter.escape); 385 | 386 | final escapeSequence = []; 387 | 388 | charCode = stdin.readByteSync(); 389 | if (charCode == -1) { 390 | rawMode = false; 391 | return key; 392 | } 393 | escapeSequence.add(String.fromCharCode(charCode)); 394 | 395 | if (charCode == 127) { 396 | key = Key.control(ControlCharacter.wordBackspace); 397 | } else if (escapeSequence[0] == '[') { 398 | charCode = stdin.readByteSync(); 399 | if (charCode == -1) { 400 | rawMode = false; 401 | return key; 402 | } 403 | escapeSequence.add(String.fromCharCode(charCode)); 404 | 405 | switch (escapeSequence[1]) { 406 | case 'A': 407 | key.controlChar = ControlCharacter.arrowUp; 408 | break; 409 | case 'B': 410 | key.controlChar = ControlCharacter.arrowDown; 411 | break; 412 | case 'C': 413 | key.controlChar = ControlCharacter.arrowRight; 414 | break; 415 | case 'D': 416 | key.controlChar = ControlCharacter.arrowLeft; 417 | break; 418 | case 'H': 419 | key.controlChar = ControlCharacter.home; 420 | break; 421 | case 'F': 422 | key.controlChar = ControlCharacter.end; 423 | break; 424 | default: 425 | if (escapeSequence[1].codeUnits[0] > '0'.codeUnits[0] && 426 | escapeSequence[1].codeUnits[0] < '9'.codeUnits[0]) { 427 | charCode = stdin.readByteSync(); 428 | if (charCode == -1) { 429 | rawMode = false; 430 | return key; 431 | } 432 | escapeSequence.add(String.fromCharCode(charCode)); 433 | if (escapeSequence[2] != '~') { 434 | key.controlChar = ControlCharacter.unknown; 435 | } else { 436 | switch (escapeSequence[1]) { 437 | case '1': 438 | key.controlChar = ControlCharacter.home; 439 | break; 440 | case '3': 441 | key.controlChar = ControlCharacter.delete; 442 | break; 443 | case '4': 444 | key.controlChar = ControlCharacter.end; 445 | break; 446 | case '5': 447 | key.controlChar = ControlCharacter.pageUp; 448 | break; 449 | case '6': 450 | key.controlChar = ControlCharacter.pageDown; 451 | break; 452 | case '7': 453 | key.controlChar = ControlCharacter.home; 454 | break; 455 | case '8': 456 | key.controlChar = ControlCharacter.end; 457 | break; 458 | default: 459 | key.controlChar = ControlCharacter.unknown; 460 | } 461 | } 462 | } else { 463 | key.controlChar = ControlCharacter.unknown; 464 | } 465 | } 466 | } else if (escapeSequence[0] == 'O') { 467 | charCode = stdin.readByteSync(); 468 | if (charCode == -1) { 469 | rawMode = false; 470 | return key; 471 | } 472 | escapeSequence.add(String.fromCharCode(charCode)); 473 | assert(escapeSequence.length == 2); 474 | switch (escapeSequence[1]) { 475 | case 'H': 476 | key.controlChar = ControlCharacter.home; 477 | break; 478 | case 'F': 479 | key.controlChar = ControlCharacter.end; 480 | break; 481 | case 'P': 482 | key.controlChar = ControlCharacter.F1; 483 | break; 484 | case 'Q': 485 | key.controlChar = ControlCharacter.F2; 486 | break; 487 | case 'R': 488 | key.controlChar = ControlCharacter.F3; 489 | break; 490 | case 'S': 491 | key.controlChar = ControlCharacter.F4; 492 | break; 493 | default: 494 | } 495 | } else if (escapeSequence[0] == 'b') { 496 | key.controlChar = ControlCharacter.wordLeft; 497 | } else if (escapeSequence[0] == 'f') { 498 | key.controlChar = ControlCharacter.wordRight; 499 | } else { 500 | key.controlChar = ControlCharacter.unknown; 501 | } 502 | } else if (codeUnit == 0x7f) { 503 | key = Key.control(ControlCharacter.backspace); 504 | } else if (codeUnit == 0x00 || (codeUnit >= 0x1c && codeUnit <= 0x1f)) { 505 | key = Key.control(ControlCharacter.unknown); 506 | } else { 507 | // assume other characters are printable 508 | key = Key.printable(String.fromCharCode(codeUnit)); 509 | } 510 | rawMode = false; 511 | return key; 512 | } 513 | 514 | /// Reads a line of input, handling basic keyboard navigation commands. 515 | /// 516 | /// The Dart [stdin.readLineSync()] function reads a line from the input, 517 | /// however it does not handle cursor navigation (e.g. arrow keys, home and 518 | /// end keys), and has side-effects that may be unhelpful for certain console 519 | /// applications. For example, Ctrl+C is processed as the break character, 520 | /// which causes the application to immediately exit. 521 | /// 522 | /// The implementation does not currently allow for multi-line input. It 523 | /// is best suited for short text fields that are not longer than the width 524 | /// of the current screen. 525 | /// 526 | /// By default, readLine ignores break characters (e.g. Ctrl+C) and the Esc 527 | /// key, but if enabled, the function will exit and return a null string if 528 | /// those keys are pressed. 529 | /// 530 | /// A callback function may be supplied, as a peek-ahead for what is being 531 | /// entered. This is intended for scenarios like auto-complete, where the 532 | /// text field is coupled with some other content. 533 | String? readLine( 534 | {bool cancelOnBreak = false, 535 | bool cancelOnEscape = false, 536 | bool cancelOnEOF = false, 537 | void Function(String text, Key lastPressed)? callback}) { 538 | var buffer = ''; 539 | var index = 0; // cursor position relative to buffer, not screen 540 | 541 | final screenRow = cursorPosition!.row; 542 | final screenColOffset = cursorPosition!.col; 543 | 544 | final bufferMaxLength = windowWidth - screenColOffset - 3; 545 | 546 | while (true) { 547 | final key = readKey(); 548 | 549 | if (key.isControl) { 550 | switch (key.controlChar) { 551 | case ControlCharacter.enter: 552 | if (_scrollbackBuffer != null) { 553 | _scrollbackBuffer!.add(buffer); 554 | } 555 | writeLine(); 556 | return buffer; 557 | case ControlCharacter.ctrlC: 558 | if (cancelOnBreak) return null; 559 | break; 560 | case ControlCharacter.escape: 561 | if (cancelOnEscape) return null; 562 | break; 563 | case ControlCharacter.backspace: 564 | case ControlCharacter.ctrlH: 565 | if (index > 0) { 566 | buffer = buffer.substring(0, index - 1) + buffer.substring(index); 567 | index--; 568 | } 569 | break; 570 | case ControlCharacter.ctrlU: 571 | buffer = buffer.substring(index, buffer.length); 572 | index = 0; 573 | break; 574 | case ControlCharacter.delete: 575 | case ControlCharacter.ctrlD: 576 | if (index < buffer.length) { 577 | buffer = buffer.substring(0, index) + buffer.substring(index + 1); 578 | } else if (cancelOnEOF) { 579 | return null; 580 | } 581 | break; 582 | case ControlCharacter.ctrlK: 583 | buffer = buffer.substring(0, index); 584 | break; 585 | case ControlCharacter.arrowLeft: 586 | case ControlCharacter.ctrlB: 587 | index = index > 0 ? index - 1 : index; 588 | break; 589 | case ControlCharacter.arrowUp: 590 | if (_scrollbackBuffer != null) { 591 | buffer = _scrollbackBuffer!.up(buffer); 592 | index = buffer.length; 593 | } 594 | break; 595 | case ControlCharacter.arrowDown: 596 | if (_scrollbackBuffer != null) { 597 | final temp = _scrollbackBuffer!.down(); 598 | if (temp != null) { 599 | buffer = temp; 600 | index = buffer.length; 601 | } 602 | } 603 | break; 604 | case ControlCharacter.arrowRight: 605 | case ControlCharacter.ctrlF: 606 | index = index < buffer.length ? index + 1 : index; 607 | break; 608 | case ControlCharacter.wordLeft: 609 | if (index > 0) { 610 | final bufferLeftOfCursor = buffer.substring(0, index - 1); 611 | final lastSpace = bufferLeftOfCursor.lastIndexOf(' '); 612 | index = lastSpace != -1 ? lastSpace + 1 : 0; 613 | } 614 | break; 615 | case ControlCharacter.wordRight: 616 | if (index < buffer.length) { 617 | final bufferRightOfCursor = buffer.substring(index + 1); 618 | final nextSpace = bufferRightOfCursor.indexOf(' '); 619 | index = nextSpace != -1 620 | ? min(index + nextSpace + 2, buffer.length) 621 | : buffer.length; 622 | } 623 | break; 624 | case ControlCharacter.home: 625 | case ControlCharacter.ctrlA: 626 | index = 0; 627 | break; 628 | case ControlCharacter.end: 629 | case ControlCharacter.ctrlE: 630 | index = buffer.length; 631 | break; 632 | default: 633 | break; 634 | } 635 | } else { 636 | if (buffer.length < bufferMaxLength) { 637 | if (index == buffer.length) { 638 | buffer += key.char; 639 | index++; 640 | } else { 641 | buffer = 642 | buffer.substring(0, index) + key.char + buffer.substring(index); 643 | index++; 644 | } 645 | } 646 | } 647 | 648 | cursorPosition = Coordinate(screenRow, screenColOffset); 649 | eraseCursorToEnd(); 650 | write(buffer); // allow for backspace condition 651 | cursorPosition = Coordinate(screenRow, screenColOffset + index); 652 | 653 | if (callback != null) callback(buffer, key); 654 | } 655 | } 656 | } 657 | -------------------------------------------------------------------------------- /lib/src/consolecolor.dart: -------------------------------------------------------------------------------- 1 | // enums.dart 2 | 3 | // Externally exposed enumerations used by the `Console` class. 4 | 5 | // TODO: Update this with an enhanced enum that includes RGB and 256-color 6 | // values, and returns a string to enable and a string to disable. 7 | 8 | enum ConsoleColor { 9 | /// The named ANSI colors. 10 | black('\x1b[30m', '\x1b[40m'), 11 | red('\x1b[31m', '\x1b[41m'), 12 | green('\x1b[32m', '\x1b[42m'), 13 | yellow('\x1b[33m', '\x1b[43m'), 14 | blue('\x1b[34m', '\x1b[44m'), 15 | magenta('\x1b[35m', '\x1b[45m'), 16 | cyan('\x1b[36m', '\x1b[46m'), 17 | white('\x1b[37m', '\x1b[47m'), 18 | brightBlack('\x1b[90m', '\x1b[100m'), 19 | brightRed('\x1b[91m', '\x1b[101m'), 20 | brightGreen('\x1b[92m', '\x1b[102m'), 21 | brightYellow('\x1b[93m', '\x1b[103'), 22 | brightBlue('\x1b[94m', '\x1b[104m'), 23 | brightMagenta('\x1b[95m', '\x1b[105m'), 24 | brightCyan('\x1b[96m', '\x1b[106m'), 25 | brightWhite('\x1b[97m', '\x1b[107m'); 26 | 27 | final String ansiSetForegroundColorSequence; 28 | final String ansiSetBackgroundColorSequence; 29 | 30 | const ConsoleColor( 31 | this.ansiSetForegroundColorSequence, this.ansiSetBackgroundColorSequence); 32 | } 33 | -------------------------------------------------------------------------------- /lib/src/ffi/termlib.dart: -------------------------------------------------------------------------------- 1 | // termlib.dart 2 | // 3 | // Platform-independent library for interrogating and manipulating the console. 4 | // 5 | // This class provides raw wrappers for the underlying terminal system calls 6 | // that are not available through ANSI mode control sequences, and is not 7 | // designed to be called directly. Package consumers should normally use the 8 | // `Console` class to call these methods. 9 | 10 | import 'dart:io'; 11 | 12 | import 'unix/termlib_unix.dart'; 13 | import 'win/termlib_win.dart'; 14 | 15 | abstract class TermLib { 16 | int setWindowHeight(int height); 17 | int setWindowWidth(int width); 18 | 19 | void enableRawMode(); 20 | void disableRawMode(); 21 | 22 | factory TermLib() { 23 | if (Platform.isWindows) { 24 | return TermLibWindows(); 25 | } else { 26 | return TermLibUnix(); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lib/src/ffi/unix/termios.dart: -------------------------------------------------------------------------------- 1 | // termios.dart 2 | // 3 | // Dart representations of functions and constants used in termios.h 4 | 5 | // Ignore these lints, since these are UNIX identifiers that we're replicating 6 | // 7 | // ignore_for_file: non_constant_identifier_names, constant_identifier_names, camel_case_types 8 | 9 | import 'dart:ffi'; 10 | 11 | // INPUT FLAGS 12 | const int IGNBRK = 0x00000001; // ignore BREAK condition 13 | const int BRKINT = 0x00000002; // map BREAK to SIGINTR 14 | const int IGNPAR = 0x00000004; // ignore (discard) parity errors 15 | const int PARMRK = 0x00000008; // mark parity and framing errors 16 | const int INPCK = 0x00000010; // enable checking of parity errors 17 | const int ISTRIP = 0x00000020; // strip 8th bit off chars 18 | const int INLCR = 0x00000040; // map NL into CR 19 | const int IGNCR = 0x00000080; // ignore CR 20 | const int ICRNL = 0x00000100; // map CR to NL (ala CRMOD) 21 | const int IXON = 0x00000200; // enable output flow control 22 | const int IXOFF = 0x00000400; // enable input flow control 23 | const int IXANY = 0x00000800; // any char will restart after stop 24 | const int IMAXBEL = 0x00002000; // ring bell on input queue full 25 | const int IUTF8 = 0x00004000; // maintain state for UTF-8 VERASE 26 | 27 | // OUTPUT FLAGS 28 | const int OPOST = 0x00000001; // enable following output processing 29 | const int ONLCR = 0x00000002; // map NL to CR-NL (ala CRMOD) 30 | const int OXTABS = 0x00000004; // expand tabs to spaces 31 | const int ONOEOT = 0x00000008; // discard EOT's (^D) on output) 32 | 33 | // CONTROL FLAGS 34 | const int CIGNORE = 0x00000001; // ignore control flags 35 | const int CSIZE = 0x00000300; // character size mask 36 | const int CS5 = 0x00000000; // 5 bits (pseudo) 37 | const int CS6 = 0x00000100; // 6 bits 38 | const int CS7 = 0x00000200; // 7 bits 39 | const int CS8 = 0x00000300; // 8 bits 40 | 41 | // LOCAL FLAGS 42 | const int ECHOKE = 0x00000001; // visual erase for line kill 43 | const int ECHOE = 0x00000002; // visually erase chars 44 | const int ECHOK = 0x00000004; // echo NL after line kill 45 | const int ECHO = 0x00000008; // enable echoing 46 | const int ECHONL = 0x00000010; // echo NL even if ECHO is off 47 | const int ECHOPRT = 0x00000020; // visual erase mode for hardcopy 48 | const int ECHOCTL = 0x00000040; // echo control chars as ^(Char) 49 | const int ISIG = 0x00000080; // enable signals INTR, QUIT, [D]SUSP 50 | const int ICANON = 0x00000100; // canonicalize input lines 51 | const int ALTWERASE = 0x00000200; // use alternate WERASE algorithm 52 | const int IEXTEN = 0x00000400; // enable DISCARD and LNEXT 53 | const int EXTPROC = 0x00000800; // external processing 54 | const int TOSTOP = 0x00400000; // stop background jobs from output 55 | const int FLUSHO = 0x00800000; // output being flushed (state) 56 | const int NOKERNINFO = 0x02000000; // no kernel output from VSTATUS 57 | const int PENDIN = 0x20000000; // retype pending input (state) 58 | const int NOFLSH = 0x80000000; // don't flush after interrupt 59 | 60 | const int TCSANOW = 0; // make change immediate 61 | const int TCSADRAIN = 1; // drain output, then change 62 | const int TCSAFLUSH = 2; // drain output, flush input 63 | 64 | const int VMIN = 16; // minimum number of characters to receive 65 | const int VTIME = 17; // time in 1/10s before returning 66 | 67 | // typedef unsigned long tcflag_t; 68 | typedef tcflag_t = UnsignedLong; 69 | 70 | // typedef unsigned char cc_t; 71 | typedef cc_t = UnsignedChar; 72 | 73 | // typedef unsigned long speed_t; 74 | typedef speed_t = UnsignedLong; 75 | 76 | // #define NCCS 20 77 | const _NCCS = 20; 78 | 79 | // struct termios { 80 | // tcflag_t c_iflag; /* input flags */ 81 | // tcflag_t c_oflag; /* output flags */ 82 | // tcflag_t c_cflag; /* control flags */ 83 | // tcflag_t c_lflag; /* local flags */ 84 | // cc_t c_cc[NCCS]; /* control chars */ 85 | // speed_t c_ispeed; /* input speed */ 86 | // speed_t c_ospeed; /* output speed */ 87 | // }; 88 | class TermIOS extends Struct { 89 | @tcflag_t() 90 | external int c_iflag; 91 | @tcflag_t() 92 | external int c_oflag; 93 | @tcflag_t() 94 | external int c_cflag; 95 | @tcflag_t() 96 | external int c_lflag; 97 | 98 | @Array(_NCCS) 99 | external Array c_cc; 100 | 101 | @speed_t() 102 | external int c_ispeed; 103 | @speed_t() 104 | external int c_ospeed; 105 | } 106 | 107 | // int tcgetattr(int, struct termios *); 108 | typedef TCGetAttrNative = Int32 Function( 109 | Int32 fildes, Pointer termios); 110 | typedef TCGetAttrDart = int Function(int fildes, Pointer termios); 111 | 112 | // int tcsetattr(int, int, const struct termios *); 113 | typedef TCSetAttrNative = Int32 Function( 114 | Int32 fildes, Int32 optional_actions, Pointer termios); 115 | typedef TCSetAttrDart = int Function( 116 | int fildes, int optional_actions, Pointer termios); 117 | -------------------------------------------------------------------------------- /lib/src/ffi/unix/termlib_unix.dart: -------------------------------------------------------------------------------- 1 | // termlib-unix.dart 2 | // 3 | // glibc-dependent library for interrogating and manipulating the console. 4 | // 5 | // This class provides raw wrappers for the underlying terminal system calls 6 | // that are not available through ANSI mode control sequences, and is not 7 | // designed to be called directly. Package consumers should normally use the 8 | // `Console` class to call these methods. 9 | 10 | import 'dart:ffi'; 11 | import 'dart:io'; 12 | 13 | import 'package:ffi/ffi.dart'; 14 | 15 | import '../termlib.dart'; 16 | import 'termios.dart'; 17 | import 'unistd.dart'; 18 | 19 | class TermLibUnix implements TermLib { 20 | late final DynamicLibrary _stdlib; 21 | 22 | late final Pointer _origTermIOSPointer; 23 | 24 | late final TCGetAttrDart tcgetattr; 25 | late final TCSetAttrDart tcsetattr; 26 | 27 | @override 28 | int setWindowHeight(int height) { 29 | stdout.write('\x1b[8;$height;t'); 30 | return height; 31 | } 32 | 33 | @override 34 | int setWindowWidth(int width) { 35 | stdout.write('\x1b[8;;${width}t'); 36 | return width; 37 | } 38 | 39 | @override 40 | void enableRawMode() { 41 | final origTermIOS = _origTermIOSPointer.ref; 42 | 43 | final newTermIOSPointer = calloc() 44 | ..ref.c_iflag = 45 | origTermIOS.c_iflag & ~(BRKINT | ICRNL | INPCK | ISTRIP | IXON) 46 | ..ref.c_oflag = origTermIOS.c_oflag & ~OPOST 47 | ..ref.c_cflag = (origTermIOS.c_cflag & ~CSIZE) | CS8 48 | ..ref.c_lflag = origTermIOS.c_lflag & ~(ECHO | ICANON | IEXTEN | ISIG) 49 | ..ref.c_cc = origTermIOS.c_cc 50 | ..ref.c_cc[VMIN] = 0 // VMIN -- return each byte, or 0 for timeout 51 | ..ref.c_cc[VTIME] = 1 // VTIME -- 100ms timeout (unit is 1/10s) 52 | ..ref.c_ispeed = origTermIOS.c_ispeed 53 | ..ref.c_oflag = origTermIOS.c_ospeed; 54 | 55 | tcsetattr(STDIN_FILENO, TCSANOW, newTermIOSPointer); 56 | 57 | calloc.free(newTermIOSPointer); 58 | } 59 | 60 | @override 61 | void disableRawMode() { 62 | if (nullptr == _origTermIOSPointer.cast()) return; 63 | tcsetattr(STDIN_FILENO, TCSANOW, _origTermIOSPointer); 64 | } 65 | 66 | TermLibUnix() { 67 | _stdlib = Platform.isMacOS 68 | ? DynamicLibrary.open('/usr/lib/libSystem.dylib') 69 | : DynamicLibrary.open('libc.so.6'); 70 | 71 | tcgetattr = 72 | _stdlib.lookupFunction('tcgetattr'); 73 | tcsetattr = 74 | _stdlib.lookupFunction('tcsetattr'); 75 | 76 | // store console mode settings so we can return them again as necessary 77 | _origTermIOSPointer = calloc(); 78 | tcgetattr(STDIN_FILENO, _origTermIOSPointer); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /lib/src/ffi/unix/unistd.dart: -------------------------------------------------------------------------------- 1 | // unistd.dart 2 | // 3 | // Dart representations of functions and constants used in unistd.h 4 | 5 | // Ignore this lint, since these are UNIX identifiers that we're replicating 6 | // 7 | // ignore_for_file: constant_identifier_names 8 | 9 | const STDIN_FILENO = 0; 10 | const STDOUT_FILENO = 1; 11 | const STDERR_FILENO = 2; 12 | -------------------------------------------------------------------------------- /lib/src/ffi/win/termlib_win.dart: -------------------------------------------------------------------------------- 1 | // termlib-win.dart 2 | // 3 | // Win32-dependent library for interrogating and manipulating the console. 4 | // 5 | // This class provides raw wrappers for the underlying terminal system calls 6 | // that are not available through ANSI mode control sequences, and is not 7 | // designed to be called directly. Package consumers should normally use the 8 | // `Console` class to call these methods. 9 | 10 | import 'dart:ffi'; 11 | 12 | import 'package:ffi/ffi.dart'; 13 | import 'package:win32/win32.dart'; 14 | 15 | import '../termlib.dart'; 16 | 17 | class TermLibWindows implements TermLib { 18 | late final int inputHandle; 19 | late final int outputHandle; 20 | 21 | @override 22 | int setWindowHeight(int height) { 23 | throw UnsupportedError( 24 | 'Setting window height is not supported for Windows terminals.'); 25 | } 26 | 27 | @override 28 | int setWindowWidth(int width) { 29 | throw UnsupportedError( 30 | 'Setting window width is not supported for Windows terminals.'); 31 | } 32 | 33 | @override 34 | void enableRawMode() { 35 | final dwMode = (~ENABLE_ECHO_INPUT) & 36 | (~ENABLE_PROCESSED_INPUT) & 37 | (~ENABLE_LINE_INPUT) & 38 | (~ENABLE_WINDOW_INPUT); 39 | SetConsoleMode(inputHandle, dwMode); 40 | } 41 | 42 | @override 43 | void disableRawMode() { 44 | final dwMode = ENABLE_ECHO_INPUT & 45 | ENABLE_EXTENDED_FLAGS & 46 | ENABLE_INSERT_MODE & 47 | ENABLE_LINE_INPUT & 48 | ENABLE_MOUSE_INPUT & 49 | ENABLE_PROCESSED_INPUT & 50 | ENABLE_QUICK_EDIT_MODE & 51 | ENABLE_VIRTUAL_TERMINAL_INPUT; 52 | SetConsoleMode(inputHandle, dwMode); 53 | } 54 | 55 | void hideCursor() { 56 | final lpConsoleCursorInfo = calloc()..ref.bVisible = 0; 57 | try { 58 | SetConsoleCursorInfo(outputHandle, lpConsoleCursorInfo); 59 | } finally { 60 | calloc.free(lpConsoleCursorInfo); 61 | } 62 | } 63 | 64 | void showCursor() { 65 | final lpConsoleCursorInfo = calloc()..ref.bVisible = 1; 66 | try { 67 | SetConsoleCursorInfo(outputHandle, lpConsoleCursorInfo); 68 | } finally { 69 | calloc.free(lpConsoleCursorInfo); 70 | } 71 | } 72 | 73 | void clearScreen() { 74 | final pBufferInfo = calloc(); 75 | final pCharsWritten = calloc(); 76 | final origin = calloc(); 77 | try { 78 | final bufferInfo = pBufferInfo.ref; 79 | GetConsoleScreenBufferInfo(outputHandle, pBufferInfo); 80 | 81 | final consoleSize = bufferInfo.dwSize.X * bufferInfo.dwSize.Y; 82 | 83 | FillConsoleOutputCharacter(outputHandle, ' '.codeUnitAt(0), consoleSize, 84 | origin.ref, pCharsWritten); 85 | 86 | GetConsoleScreenBufferInfo(outputHandle, pBufferInfo); 87 | 88 | FillConsoleOutputAttribute(outputHandle, bufferInfo.wAttributes, 89 | consoleSize, origin.ref, pCharsWritten); 90 | 91 | SetConsoleCursorPosition(outputHandle, origin.ref); 92 | } finally { 93 | calloc.free(origin); 94 | calloc.free(pCharsWritten); 95 | calloc.free(pBufferInfo); 96 | } 97 | } 98 | 99 | void setCursorPosition(int x, int y) { 100 | final coord = calloc() 101 | ..ref.X = x 102 | ..ref.Y = y; 103 | try { 104 | SetConsoleCursorPosition(outputHandle, coord.ref); 105 | } finally { 106 | calloc.free(coord); 107 | } 108 | } 109 | 110 | TermLibWindows() { 111 | outputHandle = GetStdHandle(STD_OUTPUT_HANDLE); 112 | inputHandle = GetStdHandle(STD_INPUT_HANDLE); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /lib/src/key.dart: -------------------------------------------------------------------------------- 1 | // key.dart 2 | // 3 | // Representation of keyboard input and control characters. 4 | 5 | /// Non-printable characters that can be entered from the keyboard. 6 | enum ControlCharacter { 7 | none, 8 | 9 | ctrlA, 10 | ctrlB, 11 | ctrlC, // Break 12 | ctrlD, // End of File 13 | ctrlE, 14 | ctrlF, 15 | ctrlG, // Bell 16 | ctrlH, // Backspace 17 | tab, 18 | ctrlJ, 19 | ctrlK, 20 | ctrlL, 21 | enter, 22 | ctrlN, 23 | ctrlO, 24 | ctrlP, 25 | ctrlQ, 26 | ctrlR, 27 | ctrlS, 28 | ctrlT, 29 | ctrlU, 30 | ctrlV, 31 | ctrlW, 32 | ctrlX, 33 | ctrlY, 34 | ctrlZ, // Suspend 35 | 36 | arrowLeft, 37 | arrowRight, 38 | arrowUp, 39 | arrowDown, 40 | pageUp, 41 | pageDown, 42 | wordLeft, 43 | wordRight, 44 | 45 | home, 46 | end, 47 | escape, 48 | delete, 49 | backspace, 50 | wordBackspace, 51 | 52 | // ignore: constant_identifier_names 53 | F1, 54 | // ignore: constant_identifier_names 55 | F2, 56 | // ignore: constant_identifier_names 57 | F3, 58 | // ignore: constant_identifier_names 59 | F4, 60 | 61 | unknown 62 | } 63 | 64 | /// A representation of a keystroke. 65 | class Key { 66 | bool isControl = false; 67 | String char = ''; 68 | ControlCharacter controlChar = ControlCharacter.unknown; 69 | 70 | Key.printable(this.char) : assert(char.length == 1) { 71 | controlChar = ControlCharacter.none; 72 | } 73 | 74 | Key.control(this.controlChar) { 75 | char = ''; 76 | isControl = true; 77 | } 78 | 79 | @override 80 | String toString() => isControl ? controlChar.toString() : char.toString(); 81 | } 82 | -------------------------------------------------------------------------------- /lib/src/progressbar.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | // Adapted from 6 | // https://github.com/dart-lang/sdk/blob/main/pkg/nnbd_migration/lib/src/utilities/progress_bar.dart 7 | 8 | import 'dart:math' as math; 9 | 10 | import 'package:dart_console/dart_console.dart'; 11 | 12 | /// A facility for drawing a progress bar in the terminal. 13 | /// 14 | /// The bar is instantiated with the total number of "ticks" to be completed, 15 | /// and progress is made by calling [tick]. The bar is drawn across one entire 16 | /// line, like so: 17 | /// 18 | /// [---------- ] 19 | /// 20 | /// The hyphens represent completed progress, and the whitespace represents 21 | /// remaining progress. The character representing completed progress can be 22 | /// changed by specifying [tickCharacters] in the ProgressBar constructor. 23 | /// 24 | /// If there is no terminal, the progress bar will not be drawn. 25 | class ProgressBar { 26 | /// The value that represents completion of the progress bar. 27 | /// 28 | /// By default, the progress bar shows a percentage value from 0 to 100. 29 | final int maxValue; 30 | 31 | /// Whether the spinner should be shown. 32 | /// 33 | /// Useful where the progress bar is narrow, or where the task being 34 | /// demonstrated is long-running. 35 | final bool showSpinner; 36 | 37 | /// The character used to draw the progress "tick". 38 | /// 39 | /// If multiple characters are specified, they are used to draw a "spinner", 40 | /// representing partial completion of the next "tick. 41 | final List tickCharacters; 42 | 43 | /// The starting position from which the progress bar should be drawn. 44 | Coordinate? _startCoordinate; 45 | 46 | /// The width of the terminal, in terms of characters. 47 | late final int _width; 48 | 49 | /// Whether the progress bar should be drawn. 50 | late final bool _shouldDrawProgress; 51 | 52 | /// The inner width of the terminal, in terms of characters. 53 | /// 54 | /// This represents the number of characters available for drawing progress. 55 | late final int _innerWidth; 56 | 57 | int _tickCount = 0; 58 | 59 | final _console = Console(); 60 | 61 | ProgressBar( 62 | {this.maxValue = 100, 63 | Coordinate? startCoordinate, 64 | int? barWidth, 65 | this.showSpinner = true, 66 | this.tickCharacters = const ['-', '\\', '|', '/']}) { 67 | if (!_console.hasTerminal) { 68 | _shouldDrawProgress = false; 69 | } else { 70 | _shouldDrawProgress = true; 71 | _startCoordinate = startCoordinate ?? _console.cursorPosition; 72 | _width = barWidth ?? _console.windowWidth; 73 | _innerWidth = (barWidth ?? _console.windowWidth) - 2; 74 | 75 | _printProgressBar('[${' ' * _innerWidth}]'); 76 | } 77 | } 78 | 79 | /// Clear the progress bar from the terminal, allowing other logging to be 80 | /// printed. 81 | void clear() { 82 | if (!_shouldDrawProgress) { 83 | return; 84 | } 85 | _printProgressBar(' ' * _width); 86 | } 87 | 88 | /// Draw the progress bar as complete. 89 | void complete() { 90 | if (!_shouldDrawProgress) { 91 | return; 92 | } 93 | 94 | _printProgressBar('[${tickCharacters[0] * _innerWidth}]'); 95 | } 96 | 97 | /// Progress the bar by one tick towards its maxValue. 98 | void tick() { 99 | if (!_shouldDrawProgress) { 100 | return; 101 | } 102 | _tickCount++; 103 | final fractionComplete = 104 | math.max(0, _tickCount * _innerWidth ~/ maxValue - 1); 105 | final remaining = _innerWidth - fractionComplete - 1; 106 | final spinner = 107 | showSpinner ? tickCharacters[_tickCount % tickCharacters.length] : ' '; 108 | 109 | _printProgressBar( 110 | '[${tickCharacters[0] * fractionComplete}$spinner${' ' * remaining}]'); 111 | } 112 | 113 | void _printProgressBar(String progressBar) { 114 | // Push current location, so we can restore it after we've printed the 115 | // progress bar. 116 | final originalCursorPosition = _console.cursorPosition; 117 | 118 | // Go to the starting location for the progress bar; if none specified, go 119 | // to the start of the current column. 120 | if (_startCoordinate != null) { 121 | _console.cursorPosition = _startCoordinate; 122 | } else { 123 | _console.write('\r'); 124 | } 125 | 126 | // And write the progress bar to the terminal. 127 | _console.write(progressBar); 128 | 129 | // Pop current cursor location. 130 | _console.cursorPosition = originalCursorPosition; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /lib/src/scrollbackbuffer.dart: -------------------------------------------------------------------------------- 1 | /// The ScrollbackBuffer class is a utility for handling multi-line user 2 | /// input in readline(). It doesn't support history editing a la bash, 3 | /// but it should handle the most common use cases. 4 | class ScrollbackBuffer { 5 | final lineList = []; 6 | int? lineIndex; 7 | String? currentLineBuffer; 8 | bool recordBlanks; 9 | 10 | // called by Console.scolling() 11 | ScrollbackBuffer({required this.recordBlanks}); 12 | 13 | /// Add a new line to the scrollback buffer. This would normally happen 14 | /// when the user finishes typing/editing the line and taps the 'enter' 15 | /// key. 16 | void add(String buffer) { 17 | // don't add blank line to scrollback history if !recordBlanks 18 | if (buffer == '' && !recordBlanks) { 19 | return; 20 | } 21 | lineList.add(buffer); 22 | lineIndex = lineList.length; 23 | currentLineBuffer = null; 24 | } 25 | 26 | /// Scroll 'up' -- Replace the user-input buffer with the contents of the 27 | /// previous line. ScrollbackBuffer tracks which lines are the 'current' 28 | /// and 'previous' lines. The up() method stores the current line buffer 29 | /// so that the contents will not be lost in the event the user starts 30 | /// typing/editing the line and then wants to review a previous line. 31 | String up(String buffer) { 32 | // Handle the case of the user tapping 'up' before there is a 33 | // scrollback buffer to scroll through. 34 | if (lineIndex == null) { 35 | return buffer; 36 | } else { 37 | // Only store the current line buffer once while scrolling up 38 | currentLineBuffer ??= buffer; 39 | lineIndex = lineIndex! - 1; 40 | lineIndex = lineIndex! < 0 ? 0 : lineIndex; 41 | return lineList[lineIndex!]; 42 | } 43 | } 44 | 45 | /// Scroll 'down' -- Replace the user-input buffer with the contents of 46 | /// the next line. The final 'next line' is the original contents of the 47 | /// line buffer. 48 | String? down() { 49 | // Handle the case of the user tapping 'down' before there is a 50 | // scrollback buffer to scroll through. 51 | if (lineIndex == null) { 52 | return null; 53 | } else { 54 | lineIndex = lineIndex! + 1; 55 | lineIndex = lineIndex! > lineList.length ? lineList.length : lineIndex; 56 | if (lineIndex == lineList.length) { 57 | // Once the user scrolls to the bottom, reset the current line 58 | // buffer so that up() can store it again: The user might have 59 | // edited it between down() and up(). 60 | final temp = currentLineBuffer; 61 | currentLineBuffer = null; 62 | return temp; 63 | } else { 64 | return lineList[lineIndex!]; 65 | } 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /lib/src/string_utils.dart: -------------------------------------------------------------------------------- 1 | import 'package:characters/characters.dart'; 2 | 3 | import 'textalignment.dart'; 4 | 5 | extension StringUtils on String { 6 | /// Take an input string and wrap it across multiple lines. 7 | String wrapText([int wrapLength = 76]) { 8 | if (isEmpty) { 9 | return ''; 10 | } 11 | 12 | final words = split(' '); 13 | final textLine = StringBuffer(words.first); 14 | final outputText = StringBuffer(); 15 | 16 | for (final word in words.skip(1)) { 17 | if ((textLine.length + word.length) > wrapLength) { 18 | textLine.write('\n'); 19 | outputText.write(textLine); 20 | textLine 21 | ..clear() 22 | ..write(word); 23 | } else { 24 | textLine.write(' $word'); 25 | } 26 | } 27 | 28 | outputText.write(textLine); 29 | return outputText.toString().trimRight(); 30 | } 31 | 32 | String alignText( 33 | {required int width, TextAlignment alignment = TextAlignment.left}) { 34 | // We can't use the padLeft() and padRight() methods here, since they 35 | // don't account for ANSI escape sequences. 36 | switch (alignment) { 37 | case TextAlignment.center: 38 | // By using ceil _and_ floor, we ensure that the target width is reached 39 | // even if the padding is uneven (e.g. a single character wrapped in a 4 40 | // character width should be wrapped as '··c·' rather than '··c··'). 41 | final leftPadding = ' ' * ((width - displayWidth) / 2).ceil(); 42 | final rightPadding = ' ' * ((width - displayWidth) / 2).floor(); 43 | return leftPadding + this + rightPadding; 44 | case TextAlignment.right: 45 | final padding = ' ' * (width - displayWidth); 46 | return padding + this; 47 | case TextAlignment.left: 48 | default: 49 | final padding = ' ' * (width - displayWidth); 50 | return this + padding; 51 | } 52 | } 53 | 54 | String stripEscapeCharacters() { 55 | return replaceAll(RegExp(r'\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]'), '') 56 | .replaceAll(RegExp(r'\x1b[PX^_].*?\x1b\\'), '') 57 | .replaceAll(RegExp(r'\x1b\][^\a]*(?:\a|\x1b\\)'), '') 58 | .replaceAll(RegExp(r'\x1b[\[\]A-Z\\^_@]'), ''); 59 | } 60 | 61 | /// The number of displayed character cells that are represented by the 62 | /// string. 63 | /// 64 | /// This should never be more than the length of the string; it excludes ANSI 65 | /// control characters. 66 | int get displayWidth => stripEscapeCharacters().length; 67 | 68 | /// Given a string of numerals, returns their superscripted form. 69 | /// 70 | /// If the string contains non-numeral characters, they are returned 71 | /// unchanged. 72 | String superscript() => _convertNumerals('⁰¹²³⁴⁵⁶⁷⁸⁹'); 73 | 74 | /// Given a string of numerals, returns their subscripted form. 75 | /// 76 | /// If the string contains non-numeral characters, they are returned 77 | /// unchanged. 78 | String subscript() => _convertNumerals('₀₁₂₃₄₅₆₇₈₉'); 79 | 80 | String _convertNumerals(String replacementNumerals) { 81 | const zeroCodeUnit = 0x30; 82 | const nineCodeUnit = 0x39; 83 | 84 | final buffer = StringBuffer(); 85 | for (var c in characters) { 86 | final firstCodeUnit = c.codeUnits.first; 87 | if (c.codeUnits.length == 1 && 88 | firstCodeUnit >= zeroCodeUnit && 89 | firstCodeUnit <= nineCodeUnit) { 90 | buffer.write(replacementNumerals[firstCodeUnit - zeroCodeUnit]); 91 | } else { 92 | buffer.write(c); 93 | } 94 | } 95 | return buffer.toString(); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /lib/src/table.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math' show max; 2 | 3 | import 'package:dart_console/src/ansi.dart'; 4 | 5 | import 'consolecolor.dart'; 6 | import 'string_utils.dart'; 7 | import 'textalignment.dart'; 8 | 9 | enum BorderStyle { none, ascii, square, rounded, bold, double } 10 | 11 | enum BorderType { outline, header, grid, vertical, horizontal } 12 | 13 | enum FontStyle { normal, bold, underscore, boldUnderscore } 14 | 15 | class BoxGlyphSet { 16 | final String? glyphs; 17 | const BoxGlyphSet(this.glyphs); 18 | 19 | String get horizontalLine => glyphs?[0] ?? ''; 20 | String get verticalLine => glyphs?[1] ?? ''; 21 | String get topLeftCorner => glyphs?[2] ?? ''; 22 | String get topRightCorner => glyphs?[3] ?? ''; 23 | String get bottomLeftCorner => glyphs?[4] ?? ''; 24 | String get bottomRightCorner => glyphs?[5] ?? ''; 25 | String get cross => glyphs?[6] ?? ''; 26 | String get teeUp => glyphs?[7] ?? ''; 27 | String get teeDown => glyphs?[8] ?? ''; 28 | String get teeLeft => glyphs?[9] ?? ''; 29 | String get teeRight => glyphs?[10] ?? ''; 30 | 31 | factory BoxGlyphSet.none() { 32 | return BoxGlyphSet(null); 33 | } 34 | 35 | factory BoxGlyphSet.ascii() { 36 | return BoxGlyphSet('-|----+--||'); 37 | } 38 | 39 | factory BoxGlyphSet.square() { 40 | return BoxGlyphSet('─│┌┐└┘┼┴┬┤├'); 41 | } 42 | 43 | factory BoxGlyphSet.rounded() { 44 | return BoxGlyphSet('─│╭╮╰╯┼┴┬┤├'); 45 | } 46 | 47 | factory BoxGlyphSet.bold() { 48 | return BoxGlyphSet('━┃┏┓┗┛╋┻┳┫┣'); 49 | } 50 | 51 | factory BoxGlyphSet.double() { 52 | return BoxGlyphSet('═║╔╗╚╝╬╩╦╣╠'); 53 | } 54 | } 55 | 56 | /// An experimental class for drawing tables. The API is not final yet. 57 | class Table { 58 | // The data model for table layout consists of three primary objects: 59 | // 60 | // - _table contains the cell data in rows. 61 | // - _columnAlignments contains text justification settings for each column. 62 | // - _columnWidths contains the width of each column. By default, columns size 63 | // to content, but by setting a custom column width the table should wrap at 64 | // that point. 65 | // 66 | // It is important that _columnAlignments and _columnWidths have the same 67 | // number of elements as _table[n], and that _table is not a ragged array. A 68 | // consumer of the table will not directly manipulate these members, but will 69 | // instead call methods like insertColumn, deleteRow, etc. to ensure that the 70 | // internal data structure remains consistent. 71 | final List> _table = [[]]; 72 | final List _columnAlignments = []; 73 | final List _columnWidths = []; 74 | 75 | // Asserts that the internal table structure is consistent and that the _table 76 | // object is not ragged. This should be called after every function that 77 | // manipulates the table, using `assert(_tableIntegrity)`. 78 | bool get _tableIntegrity => 79 | _table.length == rows + 1 && 80 | _columnAlignments.length == columns && 81 | _columnWidths.length == columns && 82 | _table.every((row) => row.length == columns); 83 | 84 | // Class members that manage the style and formatting of the table follow. 85 | // These may be manipulated directly. 86 | 87 | /// A title to be displayed above the table. 88 | String title = ''; 89 | 90 | /// The font formatting for the title. 91 | FontStyle titleStyle = FontStyle.bold; 92 | 93 | bool showHeader = true; 94 | 95 | /// The font formatting for the header row. 96 | /// 97 | /// By default, headers are rendered in the same way as the rest of the table, 98 | /// but you can specify a different format. 99 | FontStyle headerStyle = FontStyle.normal; 100 | 101 | /// The color to be used for rendering the header row. 102 | ConsoleColor? headerColor; 103 | 104 | /// The color to be used for rendering the table border. 105 | ConsoleColor? borderColor; 106 | 107 | /// Whether line separators should be drawn between rows and columns, and 108 | /// whether the table should include a border. 109 | /// 110 | /// Options available: 111 | /// 112 | /// - `grid`: draw an outline box and a rule line between each row 113 | /// and column. 114 | /// - `header`: draw a line border around the table with a rule line between 115 | /// the table header and the rows, 116 | /// - `horizontal`: draw rule lines between each row only. 117 | /// - `vertical`: draw rule lines between each column only. 118 | /// - `outline`: draw an outline box, with no rule lines between rows 119 | /// and columns. 120 | /// 121 | /// The default is `header`, drawing outline box and header borders only. 122 | /// 123 | /// To display a table with no borders, instead set the [borderStyle] property 124 | /// to [BorderStyle.none]. 125 | // TODO: Add BorderType.interior that is grid without outline 126 | BorderType borderType = BorderType.header; 127 | 128 | /// Which line drawing characters are used to display boxes. 129 | /// 130 | /// Options available: 131 | /// 132 | /// - `ascii`: use simple ASCII characters. Suitable for a very limited 133 | /// terminal environment: ------ 134 | /// - `bold`: use bold line drawing characters: ┗━━━━┛ 135 | /// - `double`: use double border characters: ╚════╝ 136 | /// - `none`: do not draw any borders 137 | /// - `rounded`: regular thickness borders with rounded corners: ╰────╯ 138 | /// - `square`: regular thickness borders that have sharp corners: └────┘ 139 | /// 140 | /// The default is to draw rounded borders. 141 | BorderStyle borderStyle = BorderStyle.rounded; 142 | 143 | // Properties 144 | 145 | /// Returns the number of columns in the table. 146 | /// 147 | /// To add a new column, use [insertColumn]. 148 | int get columns => _table[0].length; 149 | 150 | /// Returns the number of rows in the table, excluding the header row. 151 | int get rows => _table.length - 1; 152 | 153 | // Methods to manipulate the table structure. 154 | 155 | /// Insert a new column into the table. 156 | /// 157 | /// If a width is specified, this is used to wrap the table contents. If no 158 | /// width is specified, the column will be set to the maximum width of content 159 | /// in the cell. 160 | /// 161 | /// An index may be specified with a value from 0..[columns] to insert the 162 | /// column in a specific location. 163 | void insertColumn( 164 | {String header = '', 165 | TextAlignment alignment = TextAlignment.left, 166 | int width = 0, 167 | int? index}) { 168 | final insertIndex = index ?? columns; 169 | _table[0].insert(insertIndex, header); 170 | _columnAlignments.insert(insertIndex, alignment); 171 | _columnWidths.insert(insertIndex, width); 172 | 173 | // Skip header and add empty cells 174 | for (var i = 1; i < _table.length; i++) { 175 | _table[i].insert(insertIndex, ''); 176 | } 177 | 178 | assert(_tableIntegrity); 179 | } 180 | 181 | /// Adjusts the formatting for a given column. 182 | void setColumnFormatting( 183 | int column, { 184 | String? header, 185 | TextAlignment? alignment, 186 | int? width, 187 | }) { 188 | if (header != null) _table[0][column] = header; 189 | if (alignment != null) _columnAlignments[column] = alignment; 190 | if (width != null) _columnWidths[column] = width; 191 | 192 | assert(_tableIntegrity); 193 | } 194 | 195 | /// Removes the column with given index. 196 | /// 197 | /// The index must be in the range 0..[columns]-1. 198 | void deleteColumn(int index) { 199 | if (index >= columns || index < 0) { 200 | throw ArgumentError('index must be a valid column index'); 201 | } 202 | 203 | for (var row in _table) { 204 | row.removeAt(index); 205 | } 206 | 207 | _columnAlignments.removeAt(index); 208 | _columnWidths.removeAt(index); 209 | 210 | assert(_tableIntegrity); 211 | } 212 | 213 | /// Adds a new row to the table. 214 | void insertRow(List row, {int? index}) { 215 | // If this is the first row to be added to the table, and there are no 216 | // column definitions added so far, then treat this as a headerless table, 217 | // adding an empty header row and setting defaults for the table structure. 218 | if (_table[0].isEmpty) { 219 | _table[0] = List.filled(row.length, '', growable: true); 220 | _columnAlignments.clear(); 221 | _columnAlignments.insertAll( 222 | 0, List.filled(columns, TextAlignment.left)); 223 | _columnWidths.clear(); 224 | _columnWidths.insertAll(0, List.filled(columns, 0)); 225 | showHeader = false; 226 | } 227 | 228 | // Take as many elements as there are columns, padding as necessary. Extra 229 | // elements are discarded. This enables a sparse row to be added while 230 | // ensuring that the table remains non-ragged. 231 | final fullRow = [...row, for (var i = row.length; i < columns; i++) '']; 232 | _table.insert(index ?? rows + 1, fullRow.take(columns).toList()); 233 | 234 | assert(_tableIntegrity); 235 | } 236 | 237 | void insertRows(List> rows) => rows.forEach(insertRow); 238 | 239 | /// Removes the row with given index. 240 | /// 241 | /// The index must be in the range 0..[rows]-1. 242 | void deleteRow(int index) { 243 | if (!(index >= 0 && index < rows)) { 244 | throw ArgumentError('index must be a valid row index'); 245 | } 246 | 247 | _table.removeAt(index + 1); // Header is row 0 248 | 249 | assert(_tableIntegrity); 250 | } 251 | 252 | bool get _hasBorder => borderStyle != BorderStyle.none; 253 | 254 | BoxGlyphSet get _borderGlyphs { 255 | switch (borderStyle) { 256 | case BorderStyle.none: 257 | return BoxGlyphSet.none(); 258 | case BorderStyle.ascii: 259 | return BoxGlyphSet.ascii(); 260 | case BorderStyle.square: 261 | return BoxGlyphSet.square(); 262 | case BorderStyle.bold: 263 | return BoxGlyphSet.bold(); 264 | case BorderStyle.double: 265 | return BoxGlyphSet.double(); 266 | default: 267 | return BoxGlyphSet.rounded(); 268 | } 269 | } 270 | 271 | int _calculateTableWidth() { 272 | if (_table[0].isEmpty) return 0; 273 | 274 | final columnWidths = 275 | _calculateColumnWidths().reduce((value, element) => value + element); 276 | 277 | // Allow padding: either a single space between columns if no border, or 278 | // a padded vertical marker between columns. 279 | if (!_hasBorder) { 280 | return columnWidths + columns - 1; 281 | } else { 282 | if (borderType == BorderType.outline) { 283 | return columnWidths + 4 + columns - 1; 284 | } else { 285 | return columnWidths + 4 + (3 * (columns - 1)); 286 | } 287 | } 288 | } 289 | 290 | List _calculateColumnWidths() { 291 | return List.generate(columns, (column) { 292 | int maxLength = 0; 293 | for (final row in _table) { 294 | maxLength = max( 295 | maxLength, row[column].toString().stripEscapeCharacters().length); 296 | } 297 | return maxLength; 298 | }, growable: false); 299 | } 300 | 301 | int _calculateRowHeight(List row) { 302 | int maxHeight = 1; 303 | for (final column in row) { 304 | maxHeight = max(maxHeight, column.toString().split('\n').length); 305 | } 306 | return maxHeight; 307 | } 308 | 309 | String _tablePrologue(int tableWidth, List columnWidths) { 310 | if (!_hasBorder) return ''; 311 | 312 | String delimiter; 313 | 314 | if (borderType == BorderType.outline) { 315 | delimiter = _borderGlyphs.horizontalLine; 316 | } else { 317 | delimiter = [ 318 | _borderGlyphs.horizontalLine, 319 | borderType == BorderType.horizontal 320 | ? _borderGlyphs.horizontalLine 321 | : _borderGlyphs.teeDown, 322 | _borderGlyphs.horizontalLine, 323 | ].join(); 324 | } 325 | 326 | return [ 327 | if (borderColor != null) borderColor!.ansiSetForegroundColorSequence, 328 | _borderGlyphs.topLeftCorner, 329 | _borderGlyphs.horizontalLine, 330 | [for (final column in columnWidths) _borderGlyphs.horizontalLine * column] 331 | .join(delimiter), 332 | _borderGlyphs.horizontalLine, 333 | _borderGlyphs.topRightCorner, 334 | if (borderColor != null) ansiResetColor, 335 | '\n', 336 | ].join(); 337 | } 338 | 339 | String _tableRule(int tableWidth, List columnWidths) { 340 | if (!_hasBorder) return ''; 341 | 342 | if (borderType == BorderType.outline) { 343 | return [ 344 | if (borderColor != null) borderColor!.ansiSetForegroundColorSequence, 345 | _borderGlyphs.verticalLine, 346 | ' ' * (tableWidth - 2), 347 | _borderGlyphs.verticalLine, 348 | if (borderColor != null) ansiResetColor, 349 | '\n' 350 | ].join(); 351 | } 352 | 353 | final delimiter = [ 354 | borderType == BorderType.vertical ? ' ' : _borderGlyphs.horizontalLine, 355 | if (borderType == BorderType.horizontal) 356 | _borderGlyphs.horizontalLine 357 | else if (borderType == BorderType.vertical) 358 | _borderGlyphs.verticalLine 359 | else 360 | _borderGlyphs.cross, 361 | borderType == BorderType.vertical ? ' ' : _borderGlyphs.horizontalLine, 362 | ].join(); 363 | 364 | final horizontalLine = 365 | borderType == BorderType.vertical ? ' ' : _borderGlyphs.horizontalLine; 366 | 367 | return [ 368 | if (borderColor != null) borderColor!.ansiSetForegroundColorSequence, 369 | borderType == BorderType.vertical 370 | ? _borderGlyphs.verticalLine 371 | : _borderGlyphs.teeRight, 372 | horizontalLine, 373 | [for (final column in columnWidths) horizontalLine * column] 374 | .join(delimiter), 375 | horizontalLine, 376 | borderType == BorderType.vertical 377 | ? _borderGlyphs.verticalLine 378 | : _borderGlyphs.teeLeft, 379 | if (borderColor != null) ansiResetColor, 380 | '\n', 381 | ].join(); 382 | } 383 | 384 | String _tableEpilogue(int tableWidth, List columnWidths) { 385 | if (!_hasBorder) return ''; 386 | 387 | String delimiter; 388 | 389 | if (borderType == BorderType.outline) { 390 | delimiter = _borderGlyphs.horizontalLine; 391 | } else { 392 | delimiter = [ 393 | _borderGlyphs.horizontalLine, 394 | borderType == BorderType.horizontal 395 | ? _borderGlyphs.horizontalLine 396 | : _borderGlyphs.teeUp, 397 | _borderGlyphs.horizontalLine, 398 | ].join(); 399 | } 400 | 401 | return [ 402 | if (borderColor != null) borderColor!.ansiSetForegroundColorSequence, 403 | _borderGlyphs.bottomLeftCorner, 404 | _borderGlyphs.horizontalLine, 405 | [for (final column in columnWidths) _borderGlyphs.horizontalLine * column] 406 | .join(delimiter), 407 | _borderGlyphs.horizontalLine, 408 | _borderGlyphs.bottomRightCorner, 409 | if (borderColor != null) ansiResetColor, 410 | '\n', 411 | ].join(); 412 | } 413 | 414 | String _rowStart() { 415 | if (!_hasBorder) return ''; 416 | 417 | return [ 418 | if (borderColor != null) borderColor!.ansiSetForegroundColorSequence, 419 | _borderGlyphs.verticalLine, 420 | ' ', 421 | if (borderColor != null) ansiResetColor, 422 | ].join(); 423 | } 424 | 425 | String _rowDelimiter() { 426 | if (!_hasBorder) return ' '; 427 | 428 | if (borderType == BorderType.outline) return ' '; 429 | if (borderType == BorderType.horizontal) return ' '; 430 | 431 | return [ 432 | if (borderColor != null) borderColor!.ansiSetForegroundColorSequence, 433 | ' ', 434 | _borderGlyphs.verticalLine, 435 | ' ', 436 | if (borderColor != null) ansiResetColor, 437 | ].join(); 438 | } 439 | 440 | String _rowEnd() { 441 | if (!_hasBorder) return '\n'; 442 | 443 | return [ 444 | if (borderColor != null) borderColor!.ansiSetForegroundColorSequence, 445 | ' ', 446 | _borderGlyphs.verticalLine, 447 | if (borderColor != null) ansiResetColor, 448 | '\n', 449 | ].join(); 450 | } 451 | 452 | String _setFontStyle(FontStyle style) { 453 | return ansiSetTextStyles( 454 | bold: (style == FontStyle.bold || style == FontStyle.boldUnderscore), 455 | underscore: (style == FontStyle.underscore || 456 | style == FontStyle.boldUnderscore)); 457 | } 458 | 459 | String _resetFontStyle() => ansiResetColor; 460 | 461 | /// Renders the table as a string, for printing or further manipulation. 462 | String render({bool plainText = false}) { 463 | if (_table[0].isEmpty) return ''; 464 | 465 | final buffer = StringBuffer(); 466 | 467 | final tableWidth = _calculateTableWidth(); 468 | final columnWidths = _calculateColumnWidths(); 469 | 470 | // Title 471 | if (title != '') { 472 | buffer.writeln([ 473 | _setFontStyle(titleStyle), 474 | title.alignText(width: tableWidth, alignment: TextAlignment.center), 475 | _resetFontStyle(), 476 | ].join()); 477 | } 478 | 479 | // Top line of table bounding box 480 | buffer.write(_tablePrologue(tableWidth, columnWidths)); 481 | 482 | // Print table rows 483 | final startRow = showHeader ? 0 : 1; 484 | for (int row = startRow; row < _table.length; row++) { 485 | final wrappedRow = []; 486 | for (int column = 0; column < columns; column++) { 487 | // Wrap the text if there's a viable width 488 | if (column < _columnWidths.length && _columnWidths[column] > 0) { 489 | wrappedRow.add( 490 | _table[row][column].toString().wrapText(_columnWidths[column])); 491 | } else { 492 | wrappedRow.add(_table[row][column].toString()); 493 | } 494 | } 495 | // Count number of lines in each row 496 | final rowHeight = _calculateRowHeight(wrappedRow); 497 | 498 | for (int line = 0; line < rowHeight; line++) { 499 | buffer.write(_rowStart()); 500 | 501 | for (int column = 0; column < columns; column++) { 502 | final lines = wrappedRow[column].toString().split('\n'); 503 | final cell = line < lines.length ? lines[line] : ''; 504 | final columnAlignment = column < _columnAlignments.length 505 | ? _columnAlignments[column] 506 | : TextAlignment.left; 507 | 508 | // TODO: Only the text of a header should be underlined 509 | 510 | // Write text, with header formatting if appropriate 511 | if (row == 0 && headerStyle != FontStyle.normal) { 512 | buffer.write(_setFontStyle(headerStyle)); 513 | } 514 | if (row == 0 && headerColor != null) { 515 | buffer.write(headerColor!.ansiSetForegroundColorSequence); 516 | } 517 | buffer.write(cell.alignText( 518 | width: columnWidths[column], alignment: columnAlignment)); 519 | if (row == 0 && 520 | (headerStyle != FontStyle.normal || headerColor != null)) { 521 | buffer.write(_resetFontStyle()); 522 | } 523 | 524 | if (column < columns - 1) { 525 | buffer.write(_rowDelimiter()); 526 | } 527 | } 528 | 529 | buffer.write(_rowEnd()); 530 | } 531 | 532 | // Print a rule line underneath the header only 533 | if (row == 0) { 534 | buffer.write(_tableRule(tableWidth, columnWidths)); 535 | } 536 | 537 | // Print a rule line after all internal rows for grid type 538 | else if (borderType == BorderType.grid && row != _table.length - 1) { 539 | buffer.write(_tableRule(tableWidth, columnWidths)); 540 | } 541 | } 542 | buffer.write(_tableEpilogue(tableWidth, columnWidths)); 543 | 544 | if (plainText) { 545 | return buffer.toString().stripEscapeCharacters(); 546 | } else { 547 | return buffer.toString(); 548 | } 549 | } 550 | 551 | @override 552 | String toString() => render(); 553 | } 554 | -------------------------------------------------------------------------------- /lib/src/textalignment.dart: -------------------------------------------------------------------------------- 1 | /// Text alignments for line output. 2 | enum TextAlignment { left, center, right } 3 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: dart_console 2 | version: 1.1.2 3 | homepage: https://github.com/timsneath/dart_console 4 | description: 5 | A helper library for command-line applications that need more control over input/output than the standard library provides. 6 | 7 | environment: 8 | sdk: '>=2.17.0 <3.0.0' 9 | 10 | # Explicitly declare that this package works on all desktop platforms. 11 | platforms: 12 | linux: 13 | macos: 14 | windows: 15 | 16 | dependencies: 17 | ffi: '>=1.0.0 <3.0.0' 18 | win32: ^3.0.0 19 | intl: ^0.17.0 20 | characters: ^1.2.1 21 | 22 | dev_dependencies: 23 | lints: ^2.0.0 24 | test: ^1.21.4 25 | -------------------------------------------------------------------------------- /test/calendar_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:dart_console/dart_console.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | test('Basic calendar test', () { 6 | final aug1969 = Calendar(DateTime(1969, 08, 15)); 7 | expect(aug1969.toString(), equals(''' 8 |  August 1969  9 | ╭─────┬─────┬─────┬─────┬─────┬─────┬─────╮ 10 | │ Sun │ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ 11 | ├─────┼─────┼─────┼─────┼─────┼─────┼─────┤ 12 | │ │ │ │ │ │ 1 │ 2 │ 13 | │ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │ 14 | │ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │ 15 | │ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │ 16 | │ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │ 17 | │ 31 │ │ │ │ │ │ │ 18 | ╰─────┴─────┴─────┴─────┴─────┴─────┴─────╯ 19 | ''')); 20 | }); 21 | 22 | test('Color calendar test', () { 23 | final nowCalendar = Calendar.now(); 24 | final today = DateTime.now().day; 25 | expect(nowCalendar.toString(), contains('$today')); 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /test/interactive_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:dart_console/dart_console.dart'; 4 | 5 | import 'package:test/test.dart'; 6 | import '../example/readme.dart' as readme_example; 7 | 8 | void main() { 9 | test('Coordinate positioning', () { 10 | final console = Console(); 11 | if (stdout.hasTerminal && stdin.hasTerminal) { 12 | const coordinate = Coordinate(5, 8); 13 | 14 | console.cursorPosition = coordinate; 15 | 16 | final returnedCoordinate = console.cursorPosition!; 17 | 18 | expect(coordinate.row, equals(returnedCoordinate.row)); 19 | expect(coordinate.col, equals(returnedCoordinate.col)); 20 | } 21 | }); 22 | 23 | test('Should run readme example', () { 24 | if (stdout.hasTerminal && stdin.hasTerminal) { 25 | expect(readme_example.main(), 0); 26 | } 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /test/string_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:dart_console/dart_console.dart'; 2 | import 'package:dart_console/src/ansi.dart'; 3 | 4 | import 'package:test/test.dart'; 5 | 6 | void main() { 7 | test('Accurate displayWidth', () { 8 | const hello = 'Hello'; 9 | final yellowAttr = ConsoleColor.brightYellow.ansiSetForegroundColorSequence; 10 | final yellowHello = yellowAttr + hello + ansiResetColor; 11 | 12 | expect(yellowHello.displayWidth, equals(hello.length)); 13 | }); 14 | 15 | test('Wrap short text', () { 16 | const hello = 'Hello'; 17 | expect(hello.wrapText(7), equals('Hello')); 18 | }); 19 | 20 | test('Wrap long text', () { 21 | const hello = 'HELLO HELLO Hello Hello hello hello'; 22 | expect(hello.wrapText(11), equals('HELLO HELLO\nHello Hello\nhello hello')); 23 | }); 24 | 25 | test('Align plain text single line left', () { 26 | const hello = 'Hello'; 27 | expect(hello.alignText(width: 7), equals('Hello ')); 28 | expect(hello.alignText(width: 7).length, equals(7)); 29 | }); 30 | 31 | test('Align color text single line left', () { 32 | const hello = 'Hello'; 33 | final yellowAttr = ConsoleColor.brightYellow.ansiSetForegroundColorSequence; 34 | final yellowHello = yellowAttr + hello + ansiResetColor; 35 | 36 | expect(yellowHello.stripEscapeCharacters().alignText(width: 7), 37 | equals('Hello ')); 38 | }); 39 | 40 | test('Align odd length in even space', () { 41 | const char = 'c'; 42 | expect(char.alignText(width: 4, alignment: TextAlignment.center), 43 | equals(' c ')); 44 | }); 45 | 46 | test('Align color text single line centered', () { 47 | const hello = 'Hello'; 48 | final yellowAttr = ConsoleColor.brightYellow.ansiSetForegroundColorSequence; 49 | final yellowHello = yellowAttr + hello + ansiResetColor; 50 | 51 | expect(yellowHello.displayWidth, equals(5)); 52 | 53 | final paddedWidth = 7; 54 | final padding = ((paddedWidth - yellowHello.displayWidth) / 2).round(); 55 | expect(padding, equals(1)); 56 | 57 | expect(yellowHello.stripEscapeCharacters().alignText(width: 7).length, 58 | equals(7)); 59 | expect(yellowHello.alignText(width: 7, alignment: TextAlignment.center), 60 | equals(' \x1B[93mHello\x1B[m ')); 61 | }); 62 | 63 | test('Strip escape characters', () { 64 | final calendar = Calendar(DateTime(1969, 08, 15)); 65 | final colorCal = calendar.toString(); 66 | 67 | final monoCal = colorCal.stripEscapeCharacters(); 68 | expect(monoCal, equals(''' 69 | August 1969 70 | ╭─────┬─────┬─────┬─────┬─────┬─────┬─────╮ 71 | │ Sun │ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ 72 | ├─────┼─────┼─────┼─────┼─────┼─────┼─────┤ 73 | │ │ │ │ │ │ 1 │ 2 │ 74 | │ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │ 75 | │ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │ 76 | │ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │ 77 | │ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │ 78 | │ 31 │ │ │ │ │ │ │ 79 | ╰─────┴─────┴─────┴─────┴─────┴─────┴─────╯ 80 | ''')); 81 | }); 82 | 83 | test('Superscript', () { 84 | expect(''.superscript(), equals('')); 85 | expect('⁰¹²³⁴⁵⁶⁷⁸⁹'.superscript(), equals('⁰¹²³⁴⁵⁶⁷⁸⁹')); 86 | expect('x2'.superscript(), equals('x²')); 87 | expect('0123456789'.superscript(), equals('⁰¹²³⁴⁵⁶⁷⁸⁹')); 88 | expect('///000999:::'.superscript(), equals('///⁰⁰⁰⁹⁹⁹:::')); 89 | expect('₀₁₂₃₄₅₆₇₈₉'.superscript(), equals('₀₁₂₃₄₅₆₇₈₉')); 90 | }); 91 | 92 | test('Subscript', () { 93 | expect(''.subscript(), equals('')); 94 | expect('₀₁₂₃₄₅₆₇₈₉'.subscript(), equals('₀₁₂₃₄₅₆₇₈₉')); 95 | expect('x2'.subscript(), equals('x₂')); 96 | expect('0123456789'.subscript(), equals('₀₁₂₃₄₅₆₇₈₉')); 97 | expect('///000999:::'.subscript(), equals('///₀₀₀₉₉₉:::')); 98 | expect('⁰¹²³⁴⁵⁶⁷⁸⁹'.subscript(), equals('⁰¹²³⁴⁵⁶⁷⁸⁹')); 99 | }); 100 | } 101 | -------------------------------------------------------------------------------- /test/table_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math' as math; 2 | 3 | import 'package:dart_console/dart_console.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | const earlyPresidents = [ 7 | [ 8 | 1, 9 | 'April 30, 1789 - March 4, 1797', 10 | 'George Washington', 11 | 'unaffiliated', 12 | ], 13 | [ 14 | 2, 15 | 'March 4, 1797 - March 4, 1801', 16 | 'John Adams', 17 | 'Federalist', 18 | ], 19 | [ 20 | 3, 21 | 'March 4, 1801 - March 4, 1809', 22 | 'Thomas Jefferson', 23 | 'Democratic-Republican', 24 | ], 25 | [ 26 | 4, 27 | 'March 4, 1809 - March 4, 1817', 28 | 'James Madison', 29 | 'Democratic-Republican', 30 | ], 31 | [ 32 | 5, 33 | 'March 4, 1817 - March 4, 1825', 34 | 'James Monroe', 35 | 'Democratic-Republican', 36 | ], 37 | ]; 38 | 39 | const planets = [ 40 | ['Mercury', '5.7909227 × 10⁷'], 41 | ['Venus', '1.0820948 × 10⁸'], 42 | ['Earth', '1.4959826 × 10⁸'], 43 | ['Mars', '2.2794382 × 10⁸'], 44 | ['Jupiter', '7.7834082 × 10⁸'], 45 | ['Saturn', '1.4266664 × 10⁹'], 46 | ['Uranus', '2.8706582 × 10⁹'], 47 | ['Neptune', '4.4983964 × 10⁹'], 48 | // sorry Pluto :( 49 | ]; 50 | 51 | void main() { 52 | group('Table operations', () { 53 | test('Empty table should not render', () { 54 | final table = Table(); 55 | expect(table.toString(), isEmpty); 56 | }); 57 | 58 | test('Table with no column defs should not render header', () { 59 | final table = Table()..insertRows(earlyPresidents); 60 | expect(table.toString(), equals(''' 61 | ╭───┬────────────────────────────────┬───────────────────┬───────────────────────╮ 62 | │ 1 │ April 30, 1789 - March 4, 1797 │ George Washington │ unaffiliated │ 63 | │ 2 │ March 4, 1797 - March 4, 1801 │ John Adams │ Federalist │ 64 | │ 3 │ March 4, 1801 - March 4, 1809 │ Thomas Jefferson │ Democratic-Republican │ 65 | │ 4 │ March 4, 1809 - March 4, 1817 │ James Madison │ Democratic-Republican │ 66 | │ 5 │ March 4, 1817 - March 4, 1825 │ James Monroe │ Democratic-Republican │ 67 | ╰───┴────────────────────────────────┴───────────────────┴───────────────────────╯ 68 | ''')); 69 | }); 70 | 71 | test('Can add columns and make other changes after table is defined', () { 72 | final table = Table() 73 | ..insertColumn(header: 'Planet') 74 | ..insertColumn( 75 | header: 'Orbital Distance', alignment: TextAlignment.right) 76 | ..insertRows(planets) 77 | ..borderStyle = BorderStyle.square; 78 | 79 | table 80 | ..insertColumn(header: 'Mass') 81 | ..insertColumn(header: 'Radius', index: 1) 82 | ..insertColumn(header: 'Density') 83 | ..borderStyle = BorderStyle.rounded; 84 | 85 | expect(table.toString(), equals(''' 86 | ╭─────────┬────────┬──────────────────┬──────┬─────────╮ 87 | │ Planet │ Radius │ Orbital Distance │ Mass │ Density │ 88 | ├─────────┼────────┼──────────────────┼──────┼─────────┤ 89 | │ Mercury │ │ 5.7909227 × 10⁷ │ │ │ 90 | │ Venus │ │ 1.0820948 × 10⁸ │ │ │ 91 | │ Earth │ │ 1.4959826 × 10⁸ │ │ │ 92 | │ Mars │ │ 2.2794382 × 10⁸ │ │ │ 93 | │ Jupiter │ │ 7.7834082 × 10⁸ │ │ │ 94 | │ Saturn │ │ 1.4266664 × 10⁹ │ │ │ 95 | │ Uranus │ │ 2.8706582 × 10⁹ │ │ │ 96 | │ Neptune │ │ 4.4983964 × 10⁹ │ │ │ 97 | ╰─────────┴────────┴──────────────────┴──────┴─────────╯ 98 | ''')); 99 | }); 100 | 101 | test('Removing all columns should leave an empty table', () { 102 | final table = Table()..insertRows(planets); 103 | table 104 | ..deleteColumn(1) 105 | ..deleteColumn(0); 106 | expect(table.toString(), isEmpty); 107 | }); 108 | 109 | test('Not possible to remove more columns than exist', () { 110 | final table = Table()..insertRows(planets); 111 | table 112 | ..deleteColumn(1) 113 | ..deleteColumn(0); 114 | expect(() => table.deleteColumn(0), throwsArgumentError); 115 | }); 116 | 117 | test('Delete rows', () { 118 | final table = Table()..insertRows(planets); 119 | expect(() => table.deleteRow(table.rows + 1), throwsArgumentError); 120 | expect(() => table.deleteRow(table.rows), throwsArgumentError); 121 | expect(() => table.deleteRow(-1), throwsArgumentError); 122 | 123 | expect(table.rows, equals(8)); 124 | expect(() => table.deleteRow(table.rows - 1), returnsNormally); 125 | expect(table.toString(), isNot(contains('Neptune'))); 126 | expect(table.toString(), contains('Uranus')); 127 | 128 | expect(table.rows, equals(7)); 129 | expect(() => table.deleteRow(0), returnsNormally); 130 | expect(table.toString(), isNot(contains('Mercury'))); 131 | 132 | expect(table.rows, equals(6)); 133 | }); 134 | 135 | test('Add rows without column definitions should give a valid result', () { 136 | final table = Table()..insertRows(planets); 137 | expect(table.toString(), equals(''' 138 | ╭─────────┬─────────────────╮ 139 | │ Mercury │ 5.7909227 × 10⁷ │ 140 | │ Venus │ 1.0820948 × 10⁸ │ 141 | │ Earth │ 1.4959826 × 10⁸ │ 142 | │ Mars │ 2.2794382 × 10⁸ │ 143 | │ Jupiter │ 7.7834082 × 10⁸ │ 144 | │ Saturn │ 1.4266664 × 10⁹ │ 145 | │ Uranus │ 2.8706582 × 10⁹ │ 146 | │ Neptune │ 4.4983964 × 10⁹ │ 147 | ╰─────────┴─────────────────╯ 148 | ''')); 149 | }); 150 | 151 | test('Delete rows', () { 152 | final table = Table()..insertRows(planets); 153 | 154 | table.deleteRow(2); 155 | expect(table.toString, isNot(contains('Earth'))); 156 | }); 157 | 158 | test('Different types', () { 159 | final table = Table() 160 | ..borderColor = ConsoleColor.brightGreen 161 | ..borderStyle = BorderStyle.double 162 | ..borderType = BorderType.grid 163 | ..headerStyle = FontStyle.boldUnderscore 164 | ..insertColumn(header: 'Strings', alignment: TextAlignment.left) 165 | ..insertColumn(header: 'Coordinates', alignment: TextAlignment.right) 166 | ..insertColumn(header: 'Integers', alignment: TextAlignment.right) 167 | ..insertColumn(header: 'Doubles', alignment: TextAlignment.right) 168 | ..insertRow(['qwertyuiop', Coordinate(0, 0), 0, 1.234567]) 169 | ..insertRow(['asdfghjkl', Coordinate(80, 24), 2 << 60, math.pi]) 170 | ..insertRow(['zxcvbnm', Coordinate(17, 17), 42, math.e]); 171 | expect(table.render(), equals(''' 172 | ╔════════════╦═════════════╦═════════════════════╦═══════════════════╗ 173 | ║ Strings  ║ Coordinates ║  Integers ║  Doubles ║ 174 | ╠════════════╬═════════════╬═════════════════════╬═══════════════════╣ 175 | ║ qwertyuiop ║  (0, 0) ║  0 ║  1.234567 ║ 176 | ╠════════════╬═════════════╬═════════════════════╬═══════════════════╣ 177 | ║ asdfghjkl  ║  (80, 24) ║ 2305843009213693952 ║ 3.141592653589793 ║ 178 | ╠════════════╬═════════════╬═════════════════════╬═══════════════════╣ 179 | ║ zxcvbnm  ║  (17, 17) ║  42 ║ 2.718281828459045 ║ 180 | ╚════════════╩═════════════╩═════════════════════╩═══════════════════╝ 181 | ''')); 182 | }); 183 | 184 | test('Add a row with too many columns should crop remaining columns', () { 185 | final table = Table() 186 | ..borderStyle = BorderStyle.none 187 | ..insertColumn(header: 'Column 1') 188 | ..insertColumn(header: 'Column 2') 189 | ..insertColumn(header: 'Column 3') 190 | ..insertRows([ 191 | ['1', '2', '3'], 192 | ['a', 'b', 'c', 'd'] 193 | ]); 194 | expect(table.toString(), isNot(contains('d'))); 195 | }); 196 | 197 | test('Adding a sparse row should not throw an error', () { 198 | final table = Table() 199 | ..borderStyle = BorderStyle.none 200 | ..insertColumn(header: 'Column 1') 201 | ..insertColumn(header: 'Column 2') 202 | ..insertColumn(header: 'Column 3') 203 | ..insertRows([ 204 | ['1', '2', '3'], 205 | ['a', 'b'], 206 | ['_'], 207 | [] 208 | ]); 209 | 210 | expect(table.toString(), equals(''' 211 | Column 1 Column 2 Column 3 212 | 1 2 3 213 | a b 214 | _ 215 | 216 | ''')); 217 | expect(table.rows, equals(4)); 218 | }); 219 | }); 220 | 221 | group('Table formatting', () { 222 | test('None', () { 223 | final table = Table() 224 | ..borderStyle = BorderStyle.none 225 | ..headerStyle = FontStyle.underscore 226 | ..insertColumn(header: 'Fruit') 227 | ..insertColumn(header: 'Qty', alignment: TextAlignment.right) 228 | ..insertRows([ 229 | ['apples', 10], 230 | ['bananas', 5], 231 | ['apricots', 7] 232 | ]); 233 | expect(table.toString(), equals(''' 234 | Fruit  Qty 235 | apples 10 236 | bananas 5 237 | apricots 7 238 | ''')); 239 | }); 240 | 241 | test('ASCII grid', () { 242 | final table = Table() 243 | ..borderStyle = BorderStyle.ascii 244 | ..borderType = BorderType.grid 245 | ..insertColumn(header: 'Fruit') 246 | ..insertColumn(header: 'Qty', alignment: TextAlignment.right) 247 | ..insertColumn(header: 'Notes') 248 | ..insertRows([ 249 | ['apples', '10'], 250 | ['bananas', '5'], 251 | ['apricots', '7'] 252 | ]) 253 | ..insertRow(['dates', '10000', 'a big number']) 254 | ..insertRow(['kumquats', '59']); 255 | expect(table.toString(), equals(''' 256 | ----------------------------------- 257 | | Fruit | Qty | Notes | 258 | |----------+-------+--------------| 259 | | apples | 10 | | 260 | |----------+-------+--------------| 261 | | bananas | 5 | | 262 | |----------+-------+--------------| 263 | | apricots | 7 | | 264 | |----------+-------+--------------| 265 | | dates | 10000 | a big number | 266 | |----------+-------+--------------| 267 | | kumquats | 59 | | 268 | ----------------------------------- 269 | ''')); 270 | }); 271 | 272 | test('ASCII header', () { 273 | final table = Table() 274 | ..borderStyle = BorderStyle.ascii 275 | ..borderType = BorderType.header 276 | ..insertColumn(header: 'Fruit') 277 | ..insertColumn(header: 'Qty', alignment: TextAlignment.right) 278 | ..insertColumn(header: 'Notes') 279 | ..insertRows([ 280 | ['apples', '10'], 281 | ['bananas', '5'], 282 | ['apricots', '7'] 283 | ]) 284 | ..insertRow(['dates', '10000', 'a big number']) 285 | ..insertRow(['kumquats', '59']); 286 | expect(table.toString(), equals(''' 287 | ----------------------------------- 288 | | Fruit | Qty | Notes | 289 | |----------+-------+--------------| 290 | | apples | 10 | | 291 | | bananas | 5 | | 292 | | apricots | 7 | | 293 | | dates | 10000 | a big number | 294 | | kumquats | 59 | | 295 | ----------------------------------- 296 | ''')); 297 | }); 298 | 299 | test('ASCII outline', () { 300 | final table = Table() 301 | ..borderStyle = BorderStyle.ascii 302 | ..borderType = BorderType.outline 303 | ..insertColumn(header: 'Fruit') 304 | ..insertColumn(header: 'Qty', alignment: TextAlignment.right) 305 | ..insertRows([ 306 | ['apples', 10], 307 | ['bananas', 5], 308 | ['apricots', 7] 309 | ]); 310 | expect(table.toString(), equals(''' 311 | ---------------- 312 | | Fruit Qty | 313 | | | 314 | | apples 10 | 315 | | bananas 5 | 316 | | apricots 7 | 317 | ---------------- 318 | ''')); 319 | }); 320 | 321 | test('Borderless table', () { 322 | final table = Table() 323 | ..borderStyle = BorderStyle.none 324 | ..borderType = BorderType.header 325 | ..insertColumn(header: 'Fruit') 326 | ..insertColumn(header: 'Qty', alignment: TextAlignment.right) 327 | ..insertColumn(header: 'Notes') 328 | ..insertRows([ 329 | ['apples', '10'], 330 | ['bananas', '5'], 331 | ['apricots', '7'] 332 | ]) 333 | ..insertRow(['dates', '10000', 'a big number']) 334 | ..insertRow(['kumquats', '59']); 335 | 336 | final golden = ''' 337 | Fruit Qty Notes 338 | apples 10 339 | bananas 5 340 | apricots 7 341 | dates 10000 a big number 342 | kumquats 59 343 | '''; 344 | expect(table.toString(), equals(golden)); 345 | 346 | // Changing border type shouldn't have any impact if there's no border 347 | table.borderType = BorderType.grid; 348 | expect(table.toString(), equals(golden)); 349 | 350 | table.borderType = BorderType.outline; 351 | expect(table.toString(), equals(golden)); 352 | }); 353 | 354 | test('Glyphs', () { 355 | final table = Table() 356 | ..insertColumn(header: 'Number', alignment: TextAlignment.right) 357 | ..insertColumn(header: 'Presidency') 358 | ..insertColumn(header: 'President') 359 | ..insertColumn(header: 'Party') 360 | ..insertRows(earlyPresidents) 361 | ..borderStyle = BorderStyle.square; 362 | 363 | expect(table.toString(), equals(''' 364 | ┌────────┬────────────────────────────────┬───────────────────┬───────────────────────┐ 365 | │ Number │ Presidency │ President │ Party │ 366 | ├────────┼────────────────────────────────┼───────────────────┼───────────────────────┤ 367 | │ 1 │ April 30, 1789 - March 4, 1797 │ George Washington │ unaffiliated │ 368 | │ 2 │ March 4, 1797 - March 4, 1801 │ John Adams │ Federalist │ 369 | │ 3 │ March 4, 1801 - March 4, 1809 │ Thomas Jefferson │ Democratic-Republican │ 370 | │ 4 │ March 4, 1809 - March 4, 1817 │ James Madison │ Democratic-Republican │ 371 | │ 5 │ March 4, 1817 - March 4, 1825 │ James Monroe │ Democratic-Republican │ 372 | └────────┴────────────────────────────────┴───────────────────┴───────────────────────┘ 373 | ''')); 374 | }); 375 | 376 | test('Color border', () { 377 | final table = Table() 378 | ..borderColor = ConsoleColor.brightCyan 379 | ..borderStyle = BorderStyle.bold 380 | ..insertColumn(header: 'Number', alignment: TextAlignment.right) 381 | ..insertColumn(header: 'Presidency') 382 | ..insertColumn(header: 'President') 383 | ..insertColumn(header: 'Party') 384 | ..insertRows(earlyPresidents); 385 | 386 | expect(table.toString(), equals(''' 387 | ┏━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┓ 388 | ┃ Number ┃ Presidency  ┃ President  ┃ Party  ┃ 389 | ┣━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━┫ 390 | ┃  1 ┃ April 30, 1789 - March 4, 1797 ┃ George Washington ┃ unaffiliated  ┃ 391 | ┃  2 ┃ March 4, 1797 - March 4, 1801  ┃ John Adams  ┃ Federalist  ┃ 392 | ┃  3 ┃ March 4, 1801 - March 4, 1809  ┃ Thomas Jefferson  ┃ Democratic-Republican ┃ 393 | ┃  4 ┃ March 4, 1809 - March 4, 1817  ┃ James Madison  ┃ Democratic-Republican ┃ 394 | ┃  5 ┃ March 4, 1817 - March 4, 1825  ┃ James Monroe  ┃ Democratic-Republican ┃ 395 | ┗━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━┛ 396 | ''')); 397 | }); 398 | 399 | test('Horizontal double border', () { 400 | final table = Table() 401 | ..borderColor = ConsoleColor.blue 402 | ..borderStyle = BorderStyle.double 403 | ..borderType = BorderType.horizontal 404 | ..insertColumn(header: 'Number', alignment: TextAlignment.center) 405 | ..insertColumn(header: 'Presidency', alignment: TextAlignment.right) 406 | ..insertColumn(header: 'President') 407 | ..insertColumn(header: 'Party') 408 | ..insertRows(earlyPresidents); 409 | 410 | expect(table.toString(), equals(''' 411 | ╔═════════════════════════════════════════════════════════════════════════════════════╗ 412 | ║ Number Presidency President Party  ║ 413 | ╠═════════════════════════════════════════════════════════════════════════════════════╣ 414 | ║  1 April 30, 1789 - March 4, 1797 George Washington unaffiliated  ║ 415 | ║  2 March 4, 1797 - March 4, 1801 John Adams Federalist  ║ 416 | ║  3 March 4, 1801 - March 4, 1809 Thomas Jefferson Democratic-Republican ║ 417 | ║  4 March 4, 1809 - March 4, 1817 James Madison Democratic-Republican ║ 418 | ║  5 March 4, 1817 - March 4, 1825 James Monroe Democratic-Republican ║ 419 | ╚═════════════════════════════════════════════════════════════════════════════════════╝ 420 | ''')); 421 | }); 422 | 423 | test('Rounded border vertical', () { 424 | final table = Table(); 425 | table 426 | ..borderColor = ConsoleColor.green 427 | ..borderStyle = BorderStyle.rounded 428 | ..borderType = BorderType.vertical 429 | ..insertColumn(header: 'Number', alignment: TextAlignment.right) 430 | ..insertColumn(header: 'Presidency') 431 | ..insertColumn(header: 'President') 432 | ..insertRows(earlyPresidents.take(3).toList()); 433 | 434 | expect(table.toString(), equals(''' 435 | ╭────────┬────────────────────────────────┬───────────────────╮ 436 | │ Number │ Presidency  │ President  │ 437 | │ │ │ │ 438 | │  1 │ April 30, 1789 - March 4, 1797 │ George Washington │ 439 | │  2 │ March 4, 1797 - March 4, 1801  │ John Adams  │ 440 | │  3 │ March 4, 1801 - March 4, 1809  │ Thomas Jefferson  │ 441 | ╰────────┴────────────────────────────────┴───────────────────╯ 442 | ''')); 443 | }); 444 | 445 | test('Wrapped text', () { 446 | final table = Table() 447 | ..borderStyle = BorderStyle.rounded 448 | ..borderType = BorderType.grid 449 | ..insertColumn(header: 'Number', alignment: TextAlignment.center) 450 | ..insertColumn( 451 | header: 'Presidency', alignment: TextAlignment.right, width: 18) 452 | ..insertColumn(header: 'President') 453 | ..insertColumn(header: 'Party') 454 | ..insertRows(earlyPresidents); 455 | 456 | expect(table.toString(), equals(''' 457 | ╭────────┬────────────────────────────────┬───────────────────┬───────────────────────╮ 458 | │ Number │ Presidency │ President │ Party │ 459 | ├────────┼────────────────────────────────┼───────────────────┼───────────────────────┤ 460 | │ 1 │ April 30, 1789 - │ George Washington │ unaffiliated │ 461 | │ │ March 4, 1797 │ │ │ 462 | ├────────┼────────────────────────────────┼───────────────────┼───────────────────────┤ 463 | │ 2 │ March 4, 1797 - │ John Adams │ Federalist │ 464 | │ │ March 4, 1801 │ │ │ 465 | ├────────┼────────────────────────────────┼───────────────────┼───────────────────────┤ 466 | │ 3 │ March 4, 1801 - │ Thomas Jefferson │ Democratic-Republican │ 467 | │ │ March 4, 1809 │ │ │ 468 | ├────────┼────────────────────────────────┼───────────────────┼───────────────────────┤ 469 | │ 4 │ March 4, 1809 - │ James Madison │ Democratic-Republican │ 470 | │ │ March 4, 1817 │ │ │ 471 | ├────────┼────────────────────────────────┼───────────────────┼───────────────────────┤ 472 | │ 5 │ March 4, 1817 - │ James Monroe │ Democratic-Republican │ 473 | │ │ March 4, 1825 │ │ │ 474 | ╰────────┴────────────────────────────────┴───────────────────┴───────────────────────╯ 475 | ''')); 476 | }); 477 | 478 | test('Borders do not render when style is none', () { 479 | final table = Table() 480 | ..insertColumn(header: 'Planet') 481 | ..insertColumn( 482 | header: 'Orbital Distance', alignment: TextAlignment.right) 483 | ..insertRows(planets) 484 | ..headerStyle = FontStyle.boldUnderscore 485 | ..borderStyle = BorderStyle.none 486 | ..borderColor = ConsoleColor.brightRed 487 | ..borderType = BorderType.vertical; 488 | 489 | expect(table.toString(), equals(''' 490 | Planet  Orbital Distance 491 | Mercury 5.7909227 × 10⁷ 492 | Venus 1.0820948 × 10⁸ 493 | Earth 1.4959826 × 10⁸ 494 | Mars 2.2794382 × 10⁸ 495 | Jupiter 7.7834082 × 10⁸ 496 | Saturn 1.4266664 × 10⁹ 497 | Uranus 2.8706582 × 10⁹ 498 | Neptune 4.4983964 × 10⁹ 499 | ''')); 500 | }); 501 | 502 | test('Outline table has rule line with right colors', () { 503 | final table = Table() 504 | ..insertColumn(header: 'Planet') 505 | ..insertColumn( 506 | header: 'Orbital Distance', alignment: TextAlignment.right) 507 | ..insertRows(planets) 508 | ..headerStyle = FontStyle.bold 509 | ..borderColor = ConsoleColor.brightRed 510 | ..borderType = BorderType.outline; 511 | 512 | expect(table.toString(), equals(''' 513 | ╭──────────────────────────╮ 514 | │ Planet  Orbital Distance │ 515 | │ │ 516 | │ Mercury 5.7909227 × 10⁷ │ 517 | │ Venus 1.0820948 × 10⁸ │ 518 | │ Earth 1.4959826 × 10⁸ │ 519 | │ Mars 2.2794382 × 10⁸ │ 520 | │ Jupiter 7.7834082 × 10⁸ │ 521 | │ Saturn 1.4266664 × 10⁹ │ 522 | │ Uranus 2.8706582 × 10⁹ │ 523 | │ Neptune 4.4983964 × 10⁹ │ 524 | ╰──────────────────────────╯ 525 | ''')); 526 | }); 527 | 528 | test('Can strip out ANSI formatting successfully', () { 529 | final table = Table() 530 | ..insertColumn(header: 'Number', alignment: TextAlignment.right) 531 | ..insertColumn(header: 'Presidency') 532 | ..insertColumn(header: 'President') 533 | ..insertColumn(header: 'Party') 534 | ..insertRows(earlyPresidents) 535 | ..borderStyle = BorderStyle.square 536 | ..borderColor = ConsoleColor.brightBlue 537 | ..borderType = BorderType.vertical 538 | ..headerStyle = FontStyle.bold; 539 | 540 | expect(table.render(plainText: true), equals(''' 541 | ┌────────┬────────────────────────────────┬───────────────────┬───────────────────────┐ 542 | │ Number │ Presidency │ President │ Party │ 543 | │ │ │ │ │ 544 | │ 1 │ April 30, 1789 - March 4, 1797 │ George Washington │ unaffiliated │ 545 | │ 2 │ March 4, 1797 - March 4, 1801 │ John Adams │ Federalist │ 546 | │ 3 │ March 4, 1801 - March 4, 1809 │ Thomas Jefferson │ Democratic-Republican │ 547 | │ 4 │ March 4, 1809 - March 4, 1817 │ James Madison │ Democratic-Republican │ 548 | │ 5 │ March 4, 1817 - March 4, 1825 │ James Monroe │ Democratic-Republican │ 549 | └────────┴────────────────────────────────┴───────────────────┴───────────────────────┘ 550 | ''')); 551 | }); 552 | 553 | test('Color header rows', () { 554 | final table = Table() 555 | ..borderColor = ConsoleColor.brightRed 556 | ..headerColor = ConsoleColor.brightBlue 557 | ..insertColumn(header: '#') 558 | ..insertColumn(header: 'Presidency') 559 | ..insertColumn(header: 'President') 560 | ..insertColumn(header: 'Party') 561 | ..insertRows(earlyPresidents) 562 | ..deleteColumn(1); 563 | expect(table.toString(), equals(''' 564 | ╭───┬───────────────────┬───────────────────────╮ 565 | │ # │ President  │ Party  │ 566 | ├───┼───────────────────┼───────────────────────┤ 567 | │ 1 │ George Washington │ unaffiliated  │ 568 | │ 2 │ John Adams  │ Federalist  │ 569 | │ 3 │ Thomas Jefferson  │ Democratic-Republican │ 570 | │ 4 │ James Madison  │ Democratic-Republican │ 571 | │ 5 │ James Monroe  │ Democratic-Republican │ 572 | ╰───┴───────────────────┴───────────────────────╯ 573 | ''')); 574 | }); 575 | }); 576 | } 577 | --------------------------------------------------------------------------------