├── .gitignore ├── LICENSE ├── README.md ├── src ├── termui.nim └── termui │ ├── ansi.nim │ ├── buffer.nim │ ├── confirmfield.nim │ ├── input.nim │ ├── inputfield.nim │ ├── progressbar.nim │ ├── selectfield.nim │ ├── selectmultiplefield.nim │ ├── spinner.nim │ ├── spinners.nim │ └── widget.nim ├── termui.nimble └── tests ├── test.nim └── test.nims /.gitignore: -------------------------------------------------------------------------------- 1 | *.exe -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 jjv360 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](https://img.shields.io/badge/status-beta-orange) 2 | ![](https://img.shields.io/badge/windows-✓-green) 3 | ![](https://img.shields.io/badge/mac-✓-green) 4 | ![](https://img.shields.io/badge/linux-%3F-lightgray) 5 | 6 | # Nim Terminal UI 7 | 8 | This library provides simple UI components for the terminal. To install, run: 9 | 10 | ```sh 11 | nimble install termui 12 | ``` 13 | 14 | ## Examples 15 | 16 | ```nim 17 | import termui 18 | 19 | # Ask for user input 20 | let name = termuiAsk("What is your name?", defaultValue = "John") 21 | 22 | # Ask for password 23 | let password = termuiAskPassword("Enter your password:") 24 | 25 | # Select from a list 26 | let gender = termuiSelect("What is your gender?", options = @["Male", "Female"]) 27 | 28 | # Select multiple 29 | let categories = termuiSelectMultiple("Select categories:", options = @["Games", "Productivity", "Utilities"]) 30 | 31 | # Confirmation 32 | let confirmed = termuiConfirm("Are you sure you want to continue?") 33 | 34 | # Show a label, as if the user had entered it in a input field 35 | termuiLabel("Your name", "John") 36 | 37 | # Progress bar 38 | let progress = termuiProgress("Uploading file...") 39 | progress.update(0.1) 40 | progress.complete("Finished!") 41 | progress.warn("Couldn't upload!") 42 | progress.fail("No internet access!") 43 | 44 | # Spinner (requires --threads:on when compiling) 45 | let spinner = termuiSpinner("Checking your internet...") 46 | spinner.update("Almost done...") 47 | spinner.complete("Finished!") 48 | spinner.warn("Couldn't test!") 49 | spinner.fail("No internet access!") 50 | ``` -------------------------------------------------------------------------------- /src/termui.nim: -------------------------------------------------------------------------------- 1 | import termui/widget 2 | import termui/inputfield 3 | import termui/spinner 4 | import termui/spinners 5 | import termui/confirmfield 6 | import termui/selectfield 7 | import termui/selectmultiplefield 8 | import termui/progressbar 9 | import termui/ansi 10 | 11 | export spinner 12 | export spinners 13 | export progressbar 14 | 15 | ## Ask for some input from the user 16 | proc termuiAsk*(question : string, defaultValue : string = "", mask : string = "") : string = 17 | 18 | # Create widget 19 | let widget = TermuiInputField.init(question, defaultValue, mask) 20 | widget.start() 21 | 22 | # Return result, or default value if no value was entered 23 | if widget.value.len() == 0: return defaultValue 24 | else: return widget.value 25 | 26 | 27 | ## Ask for a password from the user 28 | proc termuiAskPassword*(question : string) : string = 29 | return termuiAsk(question, mask = "•") 30 | 31 | 32 | ## Show a spinner. Remember to call spinner.complete() when done. 33 | proc termuiSpinner*(text : string = "Please wait...", spinnerIcons : Spinner = Spinners[Line]) : TermuiSpinner = 34 | 35 | # Create widget 36 | let widget = TermuiSpinner.init(text, spinnerIcons) 37 | widget.start() 38 | return widget 39 | 40 | 41 | ## Ask for confirmation from the user 42 | proc termuiConfirm*(question : string) : bool = 43 | 44 | # Create widget 45 | let widget = TermuiConfirmField.init(question) 46 | widget.start() 47 | return widget.value 48 | 49 | 50 | ## Ask for a single selection 51 | proc termuiSelect*(question : string, options : seq[string]) : string = 52 | 53 | # Create widget 54 | let widget = TermuiSelectField.init(question, options) 55 | widget.start() 56 | return options[widget.selectedIndex] 57 | 58 | 59 | ## Ask for multiple selection 60 | proc termuiSelectMultiple*(question : string, options : seq[string]) : seq[string] = 61 | 62 | # Create widget 63 | let widget = TermuiSelectMultipleField.init(question, options, @[]) 64 | widget.start() 65 | 66 | # Create list of selected items 67 | var selectedList : seq[string] 68 | for i in 0 ..< options.len: 69 | if widget.selectedItems[i]: 70 | selectedList.add(options[i]) 71 | 72 | # Done 73 | return selectedList 74 | 75 | 76 | ## Show a progress bar. Remember to call progressbar.complete() when done. 77 | proc termuiProgress*(text : string = "Please wait...") : TermuiProgressBar = 78 | 79 | # Create widget 80 | let widget = TermuiProgressBar.init(text) 81 | widget.start() 82 | return widget 83 | 84 | 85 | ## Show a label output, as if it has been entered in a text field 86 | proc termuiLabel*(text : string, value : string) = 87 | 88 | # Just output it here 89 | echo ansiEraseLine() & ansiForegroundYellow & "> " & ansiResetStyle & text & ansiForegroundYellow & " => " & ansiResetStyle & value -------------------------------------------------------------------------------- /src/termui/ansi.nim: -------------------------------------------------------------------------------- 1 | # https://github.com/molnarmark/colorize 2 | const ansiResetStyle* = "\e[0m" 3 | 4 | # foreground colors 5 | const ansiForegroundRed* = "\e[31m" 6 | const ansiForegroundGreen* = "\e[32m" 7 | const ansiForegroundYellow* = "\e[33m" 8 | const ansiForegroundDarkGray* = "\e[90m" 9 | const ansiForegroundLightGreen* = "\e[92m" 10 | const ansiForegroundLightBlue* = "\e[94m" 11 | # proc fgRed*(s: string): string = "\e[31m" & s & reset() 12 | # proc fgBlack*(s: string): string = "\e[30m" & s & reset() 13 | # proc fgGreen*(s: string): string= "\e[32m" & s & reset() 14 | # proc fgYellow*(s: string): string= "\e[33m" & s & reset() 15 | # proc fgBlue*(s: string): string= "\e[34m" & s & reset() 16 | # proc fgMagenta*(s: string): string= "\e[35m" & s & reset() 17 | # proc fgCyan*(s: string): string= "\e[36m" & s & reset() 18 | # proc fgLightGray*(s: string): string= "\e[37m" & s & reset() 19 | # proc fgDarkGray*(s: string): string= "\e[90m" & s & reset() 20 | # proc fgLightRed*(s: string): string= "\e[91m" & s & reset() 21 | # proc fgLightGreen*(s: string): string= "\e[92m" & s & reset() 22 | # proc fgLightYellow*(s: string): string= "\e[93m" & s & reset() 23 | # proc fgLightBlue*(s: string): string= "\e[94m" & s & reset() 24 | # proc fgLightMagenta*(s: string): string= "\e[95m" & s & reset() 25 | # proc fgLightCyan*(s: string): string= "\e[96m" & s & reset() 26 | # proc fgWhite*(s: string): string= "\e[97m" & s & reset() 27 | 28 | # # background colors 29 | # proc bgBlack*(s: string): string= "\e[40m" & s & reset() 30 | # proc bgRed*(s: string): string= "\e[41m" & s & reset() 31 | # proc bgGreen*(s: string): string= "\e[42m" & s & reset() 32 | # proc bgYellow*(s: string): string= "\e[43m" & s & reset() 33 | # proc bgBlue*(s: string): string= "\e[44m" & s & reset() 34 | # proc bgMagenta*(s: string): string= "\e[45m" & s & reset() 35 | # proc bgCyan*(s: string): string= "\e[46m" & s & reset() 36 | # proc bgLightGray*(s: string): string= "\e[47m" & s & reset() 37 | # proc bgDarkGray*(s: string): string= "\e[100m" & s & reset() 38 | # proc bgLightRed*(s: string): string= "\e[101m" & s & reset() 39 | # proc bgLightGreen*(s: string): string= "\e[102m" & s & reset() 40 | # proc bgLightYellow*(s: string): string= "\e[103m" & s & reset() 41 | # proc bgLightBlue*(s: string): string= "\e[104m" & s & reset() 42 | # proc bgLightMagenta*(s: string): string= "\e[105m" & s & reset() 43 | # proc bgLightCyan*(s: string): string= "\e[106m" & s & reset() 44 | # proc bgWhite*(s: string): string= "\e[107m" & s & reset() 45 | 46 | # # formatting functions 47 | const ansiBold* = "\e[1m" 48 | const ansiUnderline* = "\e[2m" 49 | # proc bold*(s: string): string= "\e[1m" & s & reset() 50 | # proc underline*(s: string): string= "\e[4m" & s & reset() 51 | # proc hidden*(s: string): string= "\e[8m" & s & reset() 52 | # proc invert*(s: string): string= "\e[7m" & s & reset() 53 | 54 | # cursor control 55 | proc ansiMoveCursorToBeginningOfLine*() : string = "\r" 56 | proc ansiMoveCursorUp*(numLines : int = 1) : string = "\e[" & $numLines & "A" 57 | proc ansiMoveCursorDown*(numLines : int = 1) : string = "\e[" & $numLines & "B" 58 | proc ansiMoveCursorRight*(numColumns : int = 1) : string = "\e[" & $numColumns & "C" 59 | proc ansiMoveCursorLeft*(numColumns : int = 1) : string = "\e[" & $numColumns & "D" 60 | proc ansiSaveCursorPosition*() : string = "\e[s" 61 | proc ansiRestoreCursorPosition*() : string = "\e[u" 62 | proc ansiEraseLine*() : string = "\e[2K" & ansiMoveCursorToBeginningOfLine() 63 | 64 | 65 | 66 | ## Check if on Windows, and if so then define some useful win32 APIs we're going to call later 67 | when defined(windows): 68 | import winlean 69 | 70 | ## Flag to enable ANSI support in the terminal 71 | const ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004 72 | 73 | ## Retrieves the current input mode of a console's input buffer or the current output mode of a console screen buffer. 74 | proc GetConsoleMode(hConsoleHandle: Handle, dwMode: ptr DWORD): WINBOOL{. stdcall, dynlib: "kernel32", importc: "GetConsoleMode" .} 75 | 76 | ## Sets the input mode of a console's input buffer or the output mode of a console screen buffer. 77 | proc SetConsoleMode(hConsoleHandle: Handle, dwMode : DWORD) : WINBOOL {. stdcall, dynlib: "kernel32", importc: "SetConsoleMode" .} 78 | 79 | ## Allow ANSI codes on Windows 80 | proc enableAnsiOnWindowsConsole*() = 81 | 82 | # Prepare the Windows terminal for ANSI color codes: SetConsoleMode ENABLE_VIRTUAL_TERMINAL_PROCESSING 83 | when defined(windows): 84 | 85 | # Get Handle to the Windows terminal 86 | let hTerm = getStdHandle(STD_OUTPUT_HANDLE) 87 | 88 | # Get current console mode flags 89 | var flags : DWORD = 0 90 | let success = GetConsoleMode(hTerm, addr flags) 91 | if success != 0: 92 | 93 | # Add the processing flag in 94 | flags = flags or ENABLE_VIRTUAL_TERMINAL_PROCESSING 95 | 96 | # Set the terminal mode 97 | discard SetConsoleMode(hTerm, flags) -------------------------------------------------------------------------------- /src/termui/buffer.nim: -------------------------------------------------------------------------------- 1 | import classes 2 | import unicode 3 | import terminal 4 | import ./ansi 5 | 6 | ## Represents a character on the screen 7 | class Character: 8 | 9 | ## The character in this spot 10 | var character : Rune = " ".runeAt(0) 11 | 12 | ## The ANSI style code in this spot 13 | var ansiStyle = "" 14 | 15 | 16 | 17 | ## Represents a terminal screen buffer. Used for efficiently displaying and updating text in the 18 | ## terminal without using fullscreen mode. 19 | class TerminalBuffer: 20 | 21 | ## Character buffer 22 | var characterBuffer : seq[seq[Character]] 23 | 24 | ## Screen buffer, represents what's actually on the screen 25 | var screenBuffer : seq[seq[Character]] 26 | var screenCursorX = 0 27 | var screenCursorY = 0 28 | var screenLastAnsiStyle = "" 29 | 30 | ## Current width 31 | var width = 0 32 | 33 | ## Current height 34 | var height = 0 35 | 36 | ## Current ansi style format 37 | var ansiStyle = "" 38 | var fontStyle = "" 39 | var fgColor = "" 40 | var bgColor = "" 41 | 42 | ## Current cursor position for writing text 43 | var textCursorX = 0 44 | var textCursorY = 0 45 | 46 | ## Should the cursor be visible? 47 | var cursorVisible = true 48 | 49 | ## Thread ID of the thread which first wrote data to the buffer 50 | var ownerThreadID : int = -1 51 | 52 | ## True if the draw() call has not been done yet. 53 | var isFirstDraw = true 54 | 55 | ## Number of lines we are controlling 56 | var numLines = 1 57 | 58 | 59 | ## Set background color 60 | method setBackgroundColor(ansiStyle : string = "") = 61 | this.bgColor = ansiStyle 62 | this.ansiStyle = this.bgColor & this.fgColor & this.fontStyle 63 | 64 | 65 | ## Set foreground color 66 | method setForegroundColor(ansiStyle : string = "") = 67 | this.fgColor = ansiStyle 68 | this.ansiStyle = this.bgColor & this.fgColor & this.fontStyle 69 | 70 | 71 | ## Set font style 72 | method setFontStyle(ansiStyle : string = "") = 73 | this.fontStyle = ansiStyle 74 | this.ansiStyle = this.bgColor & this.fgColor & this.fontStyle 75 | 76 | 77 | ## Throw an error if we are not on the same thread that first modified this buffer. This 78 | ## is due to nim's Seq type being entirely un-thread-safe, as in the data literally gets 79 | ## corrupted just by accessing it from another thread which added items to it! 80 | method checkThread() = 81 | 82 | # Only if thread support is enabled... 83 | when compileOption("threads"): 84 | 85 | # Check if first run 86 | if this.ownerThreadID == -1: 87 | this.ownerThreadID = getThreadID() 88 | return 89 | 90 | # Compare thread IDs 91 | if this.ownerThreadID != getThreadID(): 92 | raiseAssert("Only the thread which first used the TerminalBuffer is allowed to access it.") 93 | 94 | 95 | 96 | 97 | ## Clear all data 98 | method clear() = 99 | 100 | # Check thread 101 | this.checkThread() 102 | 103 | # Go through all characters and set to empty 104 | for line in this.characterBuffer: 105 | for chr in line: 106 | chr.character = " ".runeAt(0) 107 | chr.ansiStyle = "" 108 | 109 | 110 | ## Get character at position 111 | method charAt(x : int, y : int, screenBuffer : bool = false) : Character = 112 | 113 | # Check thread 114 | this.checkThread() 115 | 116 | # Check which one they want 117 | if screenBuffer: 118 | 119 | # Ensure cell exists, then return it 120 | while this.screenBuffer.len <= y: this.screenBuffer.add(newSeq[Character]()) 121 | while this.screenBuffer[y].len <= x: this.screenBuffer[y].add(Character.init()) 122 | return this.screenBuffer[y][x] 123 | 124 | else: 125 | 126 | # Ensure cell exists, then return it 127 | while this.characterBuffer.len <= y: this.characterBuffer.add(newSeq[Character]()) 128 | while this.characterBuffer[y].len <= x: this.characterBuffer[y].add(Character.init()) 129 | return this.characterBuffer[y][x] 130 | 131 | 132 | ## Move cursor to position 133 | method moveTo(x, y: int) = 134 | this.textCursorX = x 135 | this.textCursorY = y 136 | 137 | 138 | ## Check if a specific line is empty 139 | method isLineEmpty(line : int) : bool = 140 | 141 | # Check thread 142 | this.checkThread() 143 | 144 | # Go through line 145 | for chr in this.screenBuffer[line]: 146 | if $chr.character != " ": 147 | return false 148 | 149 | # Line is empty 150 | return true 151 | 152 | 153 | ## Draw text 154 | method write(text : string) = 155 | 156 | # Check thread 157 | this.checkThread() 158 | 159 | # Get terminal width 160 | let maxWidth = terminalWidth() - 1 161 | 162 | # Go through each character 163 | for rune in text.toRunes(): 164 | 165 | # Set character cell 166 | var cell = this.charAt(this.textCursorX, this.textCursorY) 167 | cell.ansiStyle = this.ansiStyle 168 | cell.character = rune 169 | 170 | # Move cursor forwards one 171 | this.textCursorX += 1 172 | if this.textCursorX >= maxWidth: 173 | 174 | # Move to beginning of next line 175 | this.textCursorX = 0 176 | this.textCursorY += 1 177 | 178 | 179 | 180 | ## Draw the change from the screen buffer 181 | method draw() = 182 | 183 | # Check thread 184 | this.checkThread() 185 | 186 | # Get terminal width 187 | let maxWidth = terminalWidth() - 1 188 | 189 | # Check if this is the first draw 190 | if this.isFirstDraw: 191 | 192 | # Move cursor to the start of the line and save this as our 0, 0 position 193 | this.isFirstDraw = false 194 | stdout.write(ansiEraseLine()) 195 | this.screenCursorX = 0 196 | this.screenCursorY = 0 197 | 198 | # Go through every cell 199 | var didUpdateSomething = false 200 | for x in 0 ..< maxWidth: 201 | for y in 0 ..< this.characterBuffer.len: 202 | 203 | # Compare, ignore if it's the same 204 | let cellCharacter = this.charAt(x, y, screenBuffer = false) 205 | let cellScreen = this.charAt(x, y, screenBuffer = true) 206 | if cellCharacter.character == cellScreen.character and cellCharacter.ansiStyle == cellScreen.ansiStyle: 207 | continue 208 | 209 | # First update this loop, hide the cursor 210 | if not didUpdateSomething: 211 | hideCursor() 212 | didUpdateSomething = true 213 | 214 | # If we have moved onto a new line that we are not controlling, extend the terminal buffer by printing a newline 215 | while y >= this.numLines: 216 | 217 | # Move down if necessary to get to the bottom of the lines we control 218 | let offsetY = this.numLines - this.screenCursorY - 1 219 | if offsetY > 0: stdout.write(ansiMoveCursorDown(offsetY)) 220 | 221 | # Create a new line 222 | this.numLines += 1 223 | stdout.write("\n" & ansiEraseLine()) 224 | this.screenCursorY = this.numLines - 1 225 | this.screenCursorX = 0 226 | 227 | # Find cursor offset 228 | let offsetX = x - this.screenCursorX 229 | let offsetY = y - this.screenCursorY 230 | 231 | # Move horizontally 232 | if offsetX > 0: stdout.write(ansiMoveCursorRight(offsetX)) 233 | if offsetX < 0: stdout.write(ansiMoveCursorLeft(-offsetX)) 234 | this.screenCursorX = x 235 | 236 | # Move vertically 237 | if offsetY > 0: stdout.write(ansiMoveCursorDown(offsetY)) 238 | if offsetY < 0: stdout.write(ansiMoveCursorUp(-offsetY)) 239 | this.screenCursorY = y 240 | 241 | # Check if ANSI style has changed 242 | if cellCharacter.ansiStyle != this.screenLastAnsiStyle: 243 | 244 | # Set the new style 245 | this.screenLastAnsiStyle = cellCharacter.ansiStyle 246 | stdout.write(ansiResetStyle) 247 | stdout.write(cellCharacter.ansiStyle) 248 | 249 | # Write the new character 250 | stdout.write(cellCharacter.character) 251 | this.screenCursorX += 1 252 | # if this.screenCursorX >= maxWidth: 253 | # this.screenCursorX = 0 254 | # this.screenCursorY += 1 255 | 256 | # Store updated character 257 | cellScreen.character = cellCharacter.character 258 | cellScreen.ansiStyle = cellCharacter.ansiStyle 259 | 260 | # If we updated something and hid the cursor, show it again 261 | if didUpdateSomething and this.cursorVisible: 262 | 263 | # Move it to the correct position... Find cursor offset 264 | let offsetX = this.textCursorX - this.screenCursorX 265 | let offsetY = this.textCursorY - this.screenCursorY 266 | 267 | # Move horizontally 268 | if offsetX > 0: stdout.write(ansiMoveCursorRight(offsetX)) 269 | if offsetX < 0: stdout.write(ansiMoveCursorLeft(-offsetX)) 270 | this.screenCursorX = this.textCursorX 271 | 272 | # Move vertically 273 | if offsetY > 0: stdout.write(ansiMoveCursorDown(offsetY)) 274 | if offsetY < 0: stdout.write(ansiMoveCursorUp(-offsetY)) 275 | this.screenCursorY = this.textCursorY 276 | 277 | # Show it 278 | showCursor() 279 | 280 | 281 | # Flush changes 282 | if didUpdateSomething: 283 | stdout.flushFile() 284 | 285 | 286 | ## Clean up the terminal for standard output again 287 | method finish() = 288 | 289 | # Show cursor in case it was hidden 290 | if not this.cursorVisible: 291 | showCursor() 292 | 293 | # Find last line with text in it 294 | var lastY = 0 295 | for i in countdown(this.numLines-1, 0): 296 | if not this.isLineEmpty(i): 297 | lastY = i 298 | break 299 | 300 | # Move cursor to the end of the widget 301 | let offsetY = lastY - this.screenCursorY 302 | if offsetY > 0: stdout.write(ansiMoveCursorDown(offsetY)) 303 | if offsetY < 0: stdout.write(ansiMoveCursorUp(-offsetY)) 304 | this.screenCursorY = lastY 305 | 306 | # Reset terminal style 307 | stdout.write(ansiResetStyle) 308 | 309 | # Move cursor to a new fresh line 310 | echo "" -------------------------------------------------------------------------------- /src/termui/confirmfield.nim: -------------------------------------------------------------------------------- 1 | import classes 2 | import ./widget 3 | import ./ansi 4 | import ./buffer 5 | import ./input 6 | import elvis 7 | 8 | ## Input field 9 | class TermuiConfirmField of TermuiWidget: 10 | 11 | ## The user's question 12 | var question = "" 13 | 14 | ## Confirm value 15 | var value = false 16 | 17 | ## Constructor 18 | method init(question : string) = 19 | super.init() 20 | 21 | # Store vars 22 | this.question = question 23 | 24 | 25 | ## Render 26 | method render() = 27 | 28 | # Clear the buffer 29 | this.buffer.clear() 30 | 31 | # Draw indicator 32 | this.buffer.moveTo(0, 0) 33 | this.buffer.setForegroundColor(ansiForegroundYellow) 34 | this.buffer.write("> ") 35 | 36 | # Show the question 37 | this.buffer.setForegroundColor() 38 | this.buffer.write(this.question) 39 | 40 | # Show input prompt or result 41 | if this.isFinished: 42 | this.buffer.setForegroundColor(ansiForegroundYellow) 43 | this.buffer.write(" => ") 44 | this.buffer.setForegroundColor() 45 | this.buffer.write(this.value ? "Yes" ! "No") 46 | else: 47 | this.buffer.setForegroundColor(ansiForegroundYellow) 48 | this.buffer.write(" (y/n) ") 49 | 50 | 51 | ## Overrride character input 52 | method onInput(event : KeyboardEvent) = 53 | 54 | # Check what character was pressed 55 | if event.key == "y" or event.key == "Y": 56 | this.value = true 57 | this.finish() 58 | elif event.key == "n" or event.key == "N": 59 | this.value = false 60 | this.finish() -------------------------------------------------------------------------------- /src/termui/input.nim: -------------------------------------------------------------------------------- 1 | import classes 2 | import bitops 3 | import terminal 4 | when defined(windows): 5 | import winlean 6 | 7 | ## Represents a key event, kinda following the JavaScript standard defined at https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent 8 | class KeyboardEvent: 9 | 10 | ## Returns a Boolean that is true if the Alt (Option or ⌥ on OS X) key was active when the key event was generated. 11 | var altKey = false 12 | 13 | ## Returns a string with the code value of the physical key represented by the event. 14 | # var code = "" 15 | 16 | ## Returns a Boolean that is true if the Ctrl key was active when the key event was generated. 17 | var ctrlKey = false 18 | 19 | ## Returns a string representing the key value of the key represented by the event. This can be used for saving the typed character to a text field, etc. 20 | ## If the length of this string is 1, it is most likely an input character and not a control key. 21 | var key = "" 22 | 23 | ## Returns a Boolean that is true if the Meta key (on Mac keyboards, the ⌘ Command key; on Windows keyboards, the Windows key (⊞)) was active when the key event was generated. 24 | var metaKey = false 25 | 26 | ## Returns a Boolean that is true if the Shift key was active when the key event was generated. 27 | var shiftKey = false 28 | 29 | 30 | ## Read the next key input 31 | proc readTerminalInput*() : KeyboardEvent = 32 | 33 | # Create new event 34 | var event = KeyboardEvent.init() 35 | 36 | # Check OS 37 | when defined(windows): 38 | 39 | # Get handle to terminal 40 | let fd = getStdHandle(STD_INPUT_HANDLE) 41 | var keyEvent = KEY_EVENT_RECORD() 42 | var numRead: cint = 0 43 | 44 | # Read input 45 | while true: 46 | 47 | # Read it 48 | let success = readConsoleInput(fd, addr keyEvent, 1, addr numRead) != 0 49 | if not success or numRead == 0: 50 | raiseAssert("Unable to read from the terminal.") 51 | 52 | # Skip events unrelated to the keyboard 53 | if keyEvent.eventType != 1: 54 | continue 55 | 56 | # Skip key UP events, we only want key DOWN 57 | if keyEvent.bKeyDown == 0: 58 | continue 59 | 60 | # We can use this event 61 | break 62 | 63 | # Process control keys 64 | if bitand(keyEvent.dwControlKeyState, 0x0001) != 0: event.altKey = true # <-- RIGHT_ALT_PRESSED 65 | if bitand(keyEvent.dwControlKeyState, 0x0002) != 0: event.altKey = true # <-- LEFT_ALT_PRESSED 66 | if bitand(keyEvent.dwControlKeyState, 0x0004) != 0: event.ctrlKey = true # <-- RIGHT_CTRL_PRESSED 67 | if bitand(keyEvent.dwControlKeyState, 0x0008) != 0: event.ctrlKey = true # <-- LEFT_CTRL_PRESSED 68 | if bitand(keyEvent.dwControlKeyState, 0x0010) != 0: event.shiftKey = true # <-- SHIFT_PRESSED 69 | 70 | # Process character code 71 | event.key = $char(keyEvent.uChar) 72 | 73 | # Read virtual key code for control keys 74 | if keyEvent.wVirtualKeyCode == 0x08: event.key = "Backspace" # <-- VK_BACK 75 | if keyEvent.wVirtualKeyCode == 0x09: event.key = "Tab" # <-- VK_TAB 76 | if keyEvent.wVirtualKeyCode == 0x0C: event.key = "Clear" # <-- VK_CLEAR 77 | if keyEvent.wVirtualKeyCode == 0x0D: event.key = "Enter" # <-- VK_RETURN 78 | if keyEvent.wVirtualKeyCode == 0x10: event.key = "Shift" # <-- VK_SHIFT 79 | if keyEvent.wVirtualKeyCode == 0x11: event.key = "Control" # <-- VK_CONTROL 80 | if keyEvent.wVirtualKeyCode == 0x12: event.key = "Alt" # <-- VK_MENU 81 | if keyEvent.wVirtualKeyCode == 0x14: event.key = "CapsLock" # <-- VK_CAPITAL 82 | if keyEvent.wVirtualKeyCode == 0x1B: event.key = "Escape" # <-- VK_ESCAPE 83 | if keyEvent.wVirtualKeyCode == 0x25: event.key = "ArrowLeft" # <-- VK_LEFT 84 | if keyEvent.wVirtualKeyCode == 0x26: event.key = "ArrowUp" # <-- VK_UP 85 | if keyEvent.wVirtualKeyCode == 0x27: event.key = "ArrowRight" # <-- VK_RIGHT 86 | if keyEvent.wVirtualKeyCode == 0x28: event.key = "ArrowDown" # <-- VK_DOWN 87 | if keyEvent.wVirtualKeyCode == 0x2E: event.key = "Delete" # <-- VK_DELETE 88 | 89 | 90 | else: 91 | 92 | # Get first code 93 | let chr1 = getch() 94 | if chr1.int == 27: 95 | 96 | # Keyboard control command, this next char should be '[' 97 | let chr2 = getch() 98 | 99 | # Next should be the arrow direction 100 | let chr3 = getch() 101 | if chr3 == 'A': event.key = "ArrowUp" 102 | if chr3 == 'B': event.key = "ArrowDown" 103 | if chr3 == 'C': event.key = "ArrowRight" 104 | if chr3 == 'D': event.key = "ArrowLeft" 105 | 106 | elif chr1.int == 127: 107 | 108 | # Enter key 109 | event.key = "Backspace" 110 | 111 | elif chr1.int == 13: 112 | 113 | # Enter key 114 | event.key = "Enter" 115 | 116 | elif chr1.int > 31: 117 | 118 | # Normal key 119 | event.key = $chr1 120 | 121 | 122 | # Done 123 | return event -------------------------------------------------------------------------------- /src/termui/inputfield.nim: -------------------------------------------------------------------------------- 1 | import classes 2 | import ./ansi 3 | import ./widget 4 | import ./buffer 5 | import ./input 6 | import strutils 7 | 8 | ## Input field 9 | class TermuiInputField of TermuiWidget: 10 | 11 | ## The user's question 12 | var question = "" 13 | 14 | ## The default value, if the user presses Enter with no text entered 15 | var defaultValue = "" 16 | 17 | ## Mask characters 18 | var mask = "" 19 | 20 | ## Current value 21 | var value = "" 22 | 23 | ## Constructor 24 | method init(question : string, defaultValue : string = "", mask : string = "") = 25 | super.init() 26 | 27 | # Store vars 28 | this.question = question 29 | this.defaultValue = defaultValue 30 | this.mask = mask 31 | 32 | 33 | ## Render 34 | method render() = 35 | 36 | # Clear the buffer 37 | this.buffer.clear() 38 | 39 | # Draw indicator 40 | this.buffer.moveTo(0, 0) 41 | this.buffer.setForegroundColor(ansiForegroundYellow) 42 | this.buffer.write("> ") 43 | 44 | # Show the question 45 | this.buffer.setForegroundColor() 46 | this.buffer.write(this.question) 47 | 48 | # Show the default value if necessary 49 | if this.defaultValue.len() > 0 and this.value.len() == 0: 50 | this.buffer.setForegroundColor(ansiForegroundDarkGray) 51 | this.buffer.write(" [" & this.defaultValue & "]") 52 | 53 | # Show input prompt 54 | this.buffer.setForegroundColor(ansiForegroundYellow) 55 | this.buffer.write(" => ") 56 | 57 | # Show current input, or show mask 58 | this.buffer.setForegroundColor() 59 | if this.mask.len() > 0: 60 | this.buffer.write(repeat(this.mask, this.value.len())) 61 | else: 62 | this.buffer.write(this.value) 63 | 64 | 65 | ## Called when the user inputs a character 66 | method onInput(event : KeyboardEvent) = 67 | 68 | # Check for special codes 69 | if event.key == "Backspace": 70 | 71 | # Backspace key! Remove a character 72 | if this.value.len() > 0: this.value = this.value.substr(0, this.value.len() - 2) 73 | return 74 | 75 | elif event.key == "Enter": 76 | 77 | # Enter key! Finish this 78 | if this.value.len == 0: this.value = this.defaultValue 79 | this.finish() 80 | return 81 | 82 | elif event.key == "Delete": 83 | 84 | # Delete key! 85 | return 86 | 87 | elif event.key.len != 1: 88 | 89 | # Ignore other control characters 90 | return 91 | 92 | # Add it to the current value 93 | this.value &= event.key -------------------------------------------------------------------------------- /src/termui/progressbar.nim: -------------------------------------------------------------------------------- 1 | import classes 2 | import ./ansi 3 | import ./widget 4 | import ./spinners 5 | import ./buffer 6 | import times 7 | import terminal 8 | import strutils 9 | 10 | ## States 11 | type ProgressBarState = enum 12 | Running, Complete, Warning, Error 13 | 14 | ## Input field 15 | class TermuiProgressBar of TermuiWidget: 16 | 17 | ## Status text 18 | var statusText = "" 19 | 20 | ## Current progress from 0 to 1 21 | var progress = 0.0 22 | 23 | ## Current state 24 | var state : ProgressBarState = Running 25 | 26 | ## Length of the bar 27 | var barLength = 16 28 | 29 | ## Constructor 30 | method init(statusText : string) = 31 | super.init() 32 | 33 | # Store vars 34 | this.redrawMode = TermuiRedrawManually 35 | this.buffer.cursorVisible = false 36 | this.statusText = statusText 37 | 38 | 39 | ## Render 40 | method render() = 41 | 42 | # Clear the buffer 43 | this.buffer.clear() 44 | this.buffer.moveTo(0, 0) 45 | 46 | # Draw frame icon 47 | if this.state == Complete: 48 | 49 | # Draw checkmark 50 | this.buffer.setForegroundColor(ansiForegroundGreen) 51 | this.buffer.write("√ ") 52 | this.buffer.setForegroundColor() 53 | this.buffer.write(this.statusText) 54 | return 55 | 56 | elif this.state == Warning: 57 | 58 | # Draw warning icon 59 | this.buffer.setForegroundColor(ansiForegroundYellow) 60 | this.buffer.write("! ") 61 | this.buffer.setForegroundColor() 62 | this.buffer.write(this.statusText) 63 | return 64 | 65 | elif this.state == Error: 66 | 67 | # Draw error icon 68 | this.buffer.setForegroundColor(ansiForegroundRed) 69 | this.buffer.write("! ") 70 | this.buffer.setForegroundColor() 71 | this.buffer.write(this.statusText) 72 | return 73 | 74 | # Draw progress bar 75 | let numFilled = (this.progress * this.barLength.float).int 76 | let numEmpty = this.barLength - numFilled 77 | this.buffer.setForegroundColor(ansiForegroundYellow) 78 | this.buffer.write("[") 79 | this.buffer.write("■".repeat(numFilled)) 80 | this.buffer.setForegroundColor(ansiForegroundLightGreen) 81 | this.buffer.write(" ".repeat(numEmpty)) 82 | this.buffer.setForegroundColor(ansiForegroundYellow) 83 | this.buffer.write("] ") 84 | 85 | # Draw text 86 | this.buffer.setForegroundColor() 87 | this.buffer.write(this.statusText) 88 | 89 | 90 | ## Update text 91 | method update(progress : float, text : string = "") = 92 | 93 | # Update text 94 | this.progress = progress 95 | if text.len > 0: 96 | this.statusText = text 97 | 98 | # Redraw frame if no thread support 99 | this.renderFrame() 100 | 101 | 102 | ## Finish with completion 103 | method complete(text : string = "") = 104 | 105 | # Update text 106 | if text.len() > 0: 107 | this.statusText = text 108 | 109 | # Update state re-render 110 | this.state = Complete 111 | this.finish() 112 | 113 | 114 | ## Finish with warning 115 | method warn(text : string = "") = 116 | 117 | # Update text 118 | if text.len() > 0: 119 | this.statusText = text 120 | 121 | # Update state re-render 122 | this.state = Warning 123 | this.finish() 124 | 125 | 126 | ## Finish with error 127 | method fail(text : string = "") = 128 | 129 | # Update text 130 | if text.len() > 0: 131 | this.statusText = text 132 | 133 | # Update state re-render 134 | this.state = Error 135 | this.finish() -------------------------------------------------------------------------------- /src/termui/selectfield.nim: -------------------------------------------------------------------------------- 1 | import classes 2 | import ./widget 3 | import ./ansi 4 | import ./buffer 5 | import ./input 6 | import elvis 7 | 8 | ## Select field 9 | class TermuiSelectField of TermuiWidget: 10 | 11 | ## The user's question 12 | var question = "" 13 | 14 | ## Input values 15 | var options : seq[string] 16 | 17 | ## Currently selected option 18 | var selectedIndex = 0 19 | 20 | ## Maximum items to display 21 | var maxItems = 5 22 | 23 | ## Constructor 24 | method init(question : string, options : seq[string]) = 25 | super.init() 26 | 27 | # Store vars 28 | this.question = question 29 | this.options = options 30 | this.buffer.cursorVisible = false 31 | 32 | 33 | ## Render 34 | method render() = 35 | 36 | # Clear the buffer 37 | this.buffer.clear() 38 | 39 | # Draw indicator 40 | this.buffer.moveTo(0, 0) 41 | this.buffer.setForegroundColor(ansiForegroundYellow) 42 | this.buffer.write("> ") 43 | 44 | # Show the question 45 | this.buffer.setForegroundColor() 46 | this.buffer.write(this.question) 47 | 48 | # If finished, show the selected option instead 49 | if this.isFinished: 50 | 51 | # Show picked value 52 | this.buffer.setForegroundColor(ansiForegroundYellow) 53 | this.buffer.write(" => ") 54 | this.buffer.setForegroundColor() 55 | this.buffer.write(this.options[this.selectedIndex]) 56 | return 57 | 58 | # Calculate offset 59 | let smallestOffset = 0 60 | let biggestOffset = max(this.options.len - this.maxItems, 0) 61 | var offset = this.selectedIndex - (this.maxItems / 2).int 62 | if offset < smallestOffset: offset = smallestOffset 63 | if offset > biggestOffset: offset = biggestOffset 64 | 65 | # Show each option 66 | for i in offset ..< min(offset + this.maxItems, this.options.len): 67 | 68 | # Move to position 69 | this.buffer.moveTo(2, i+1 - offset) 70 | 71 | # Draw selector icon 72 | this.buffer.setForegroundColor(ansiForegroundLightBlue) 73 | this.buffer.write(this.selectedIndex == i ? "> " ! " ") 74 | 75 | # Draw option name 76 | this.buffer.setForegroundColor(this.selectedIndex == i ? ansiForegroundGreen ! "") 77 | this.buffer.write(this.options[i]) 78 | 79 | # Draw selector icon 80 | this.buffer.setForegroundColor(ansiForegroundLightBlue) 81 | this.buffer.write(this.selectedIndex == i ? " <" ! " ") 82 | 83 | 84 | ## Overrride character input 85 | method onInput(event : KeyboardEvent) = 86 | 87 | # Check what character was pressed 88 | if event.key == "Enter": 89 | 90 | # Enter key! Finish this 91 | this.finish() 92 | 93 | elif event.key == "ArrowUp": 94 | 95 | # Up arrow! 96 | this.selectedIndex -= 1 97 | if this.selectedIndex < 0: 98 | this.selectedIndex = this.options.len - 1 99 | 100 | elif event.key == "ArrowDown": 101 | 102 | # Down arrow! 103 | this.selectedIndex += 1 104 | if this.selectedIndex >= this.options.len: 105 | this.selectedIndex = 0 -------------------------------------------------------------------------------- /src/termui/selectmultiplefield.nim: -------------------------------------------------------------------------------- 1 | import classes 2 | import ./widget 3 | import ./ansi 4 | import ./buffer 5 | import ./input 6 | import elvis 7 | import strutils 8 | 9 | ## Select field 10 | class TermuiSelectMultipleField of TermuiWidget: 11 | 12 | ## The user's question 13 | var question = "" 14 | 15 | ## Input values 16 | var options : seq[string] 17 | 18 | ## Cursor index 19 | var cursorIndex = 0 20 | 21 | ## Currently selected option 22 | var selectedItems : seq[bool] 23 | 24 | ## Maximum items to display 25 | var maxItems = 5 26 | 27 | ## Constructor 28 | method init(question : string, options : seq[string], defaultValue : seq[bool] = @[]) = 29 | super.init() 30 | 31 | # Store vars 32 | this.question = question 33 | this.options = options 34 | this.buffer.cursorVisible = false 35 | 36 | # Expand selectedItems 37 | this.selectedItems = defaultValue 38 | while this.selectedItems.len < this.options.len: 39 | this.selectedItems.add(false) 40 | 41 | 42 | ## Render 43 | method render() = 44 | 45 | # Clear the buffer 46 | this.buffer.clear() 47 | 48 | # Draw indicator 49 | this.buffer.moveTo(0, 0) 50 | this.buffer.setForegroundColor(ansiForegroundYellow) 51 | this.buffer.write("> ") 52 | 53 | # Show the question 54 | this.buffer.setForegroundColor() 55 | this.buffer.write(this.question) 56 | 57 | # If finished, show the selected option instead 58 | if this.isFinished: 59 | 60 | # Show picked value 61 | this.buffer.setForegroundColor(ansiForegroundYellow) 62 | this.buffer.write(" => ") 63 | 64 | # Create a list of selected items 65 | var selectedText : seq[string] 66 | for i in 0 ..< this.options.len: 67 | if this.selectedItems[i]: 68 | selectedText.add(this.options[i]) 69 | 70 | # Create text 71 | let txt = selectedText.join(", ") 72 | 73 | # Output it 74 | this.buffer.setForegroundColor() 75 | this.buffer.write(txt) 76 | return 77 | 78 | # Calculate length of longest option 79 | var longestLen = 0 80 | for opt in this.options: 81 | if opt.len > longestLen: 82 | longestLen = opt.len 83 | 84 | # Calculate offset 85 | let smallestOffset = 0 86 | let biggestOffset = max(this.options.len - this.maxItems, 0) 87 | var offset = this.cursorIndex - (this.maxItems / 2).int 88 | if offset < smallestOffset: offset = smallestOffset 89 | if offset > biggestOffset: offset = biggestOffset 90 | 91 | # Show each option 92 | for i in offset ..< min(offset + this.maxItems, this.options.len): 93 | 94 | # Move to position 95 | this.buffer.moveTo(2, i+1 - offset) 96 | 97 | # Draw selector icon 98 | this.buffer.setForegroundColor(this.selectedItems[i] ? ansiForegroundGreen ! "") 99 | this.buffer.write(this.selectedItems[i] ? "√ " ! " ") 100 | 101 | # Draw option name 102 | this.buffer.write(this.options[i]) 103 | 104 | # Draw cursor if necessary 105 | if this.cursorIndex == i: 106 | this.buffer.setForegroundColor(ansiForegroundLightBlue) 107 | this.buffer.write(" ".repeat(longestLen - this.options[i].len)) 108 | this.buffer.write(" <-- ") 109 | this.buffer.setForegroundColor(ansiForegroundDarkGray) 110 | this.buffer.write("space to select") 111 | 112 | 113 | 114 | ## Overrride character input 115 | method onInput(event : KeyboardEvent) = 116 | 117 | # Check what character was pressed 118 | if event.key == "Enter": 119 | 120 | # Enter key! Finish this 121 | this.finish() 122 | return 123 | 124 | elif event.key == " ": 125 | 126 | # Space key! Toggle the current one 127 | this.selectedItems[this.cursorIndex] = not this.selectedItems[this.cursorIndex] 128 | 129 | elif event.key == "ArrowUp": 130 | 131 | # Up arrow! 132 | this.cursorIndex -= 1 133 | if this.cursorIndex < 0: 134 | this.cursorIndex = this.options.len - 1 135 | 136 | elif event.key == "ArrowDown": 137 | 138 | # Down arrow! 139 | this.cursorIndex += 1 140 | if this.cursorIndex >= this.options.len: 141 | this.cursorIndex = 0 -------------------------------------------------------------------------------- /src/termui/spinner.nim: -------------------------------------------------------------------------------- 1 | import classes 2 | import ./ansi 3 | import ./widget 4 | import ./spinners 5 | import ./buffer 6 | import times 7 | import terminal 8 | 9 | ## States 10 | type SpinnerState = enum 11 | Running, Complete, Warning, Error 12 | 13 | ## Input field 14 | class TermuiSpinner of TermuiWidget: 15 | 16 | ## Status text 17 | var statusText = "" 18 | 19 | ## Spinner 20 | var spinnerIcon : Spinner 21 | 22 | ## Current spinner frame 23 | var currentFrame = 0 24 | 25 | ## Last frame update 26 | var lastFrameUpdate : float = epochTime() 27 | 28 | ## Current state 29 | var state : SpinnerState = Running 30 | 31 | ## Constructor 32 | method init(statusText : string = "Loading...", spinnerIcon : Spinner) = 33 | super.init() 34 | 35 | # Store vars 36 | this.redrawMode = TermuiRedrawInThread 37 | this.buffer.cursorVisible = false 38 | this.statusText = statusText 39 | this.spinnerIcon = spinnerIcon 40 | 41 | 42 | ## Render 43 | method render() = 44 | 45 | # Check if the frame should be advanced 46 | let lastFrameMillis = (epochTime() - this.lastFrameUpdate) * 1000 47 | if lastFrameMillis >= this.spinnerIcon.interval.float(): 48 | 49 | # Increase frame 50 | this.lastFrameUpdate = epochTime() 51 | this.currentFrame += 1 52 | 53 | # Reset to 0 if gone past the end 54 | if this.currentFrame >= this.spinnerIcon.frames.len(): 55 | this.currentFrame = 0 56 | 57 | # Clear the buffer 58 | this.buffer.clear() 59 | 60 | # Draw frame icon 61 | if this.state == Running: 62 | 63 | # Draw progress frame 64 | this.buffer.moveTo(0, 0) 65 | this.buffer.setForegroundColor(ansiForegroundLightBlue) 66 | this.buffer.write(this.spinnerIcon.frames[this.currentFrame]) 67 | 68 | elif this.state == Complete: 69 | 70 | # Draw checkmark 71 | this.buffer.moveTo(0, 0) 72 | this.buffer.setForegroundColor(ansiForegroundGreen) 73 | this.buffer.write("√") 74 | 75 | elif this.state == Warning: 76 | 77 | # Draw warning icon 78 | this.buffer.moveTo(0, 0) 79 | this.buffer.setForegroundColor(ansiForegroundYellow) 80 | this.buffer.write("!") 81 | 82 | elif this.state == Error: 83 | 84 | # Draw error icon 85 | this.buffer.moveTo(0, 0) 86 | this.buffer.setForegroundColor(ansiForegroundRed) 87 | this.buffer.write("!") 88 | 89 | # Add text 90 | this.buffer.setForegroundColor() 91 | this.buffer.write(" ") 92 | this.buffer.write(this.statusText) 93 | 94 | 95 | ## Update text 96 | method update(text : string) = 97 | 98 | # Update text 99 | this.statusText = text 100 | 101 | # Redraw frame if no thread support 102 | when not compileOption("threads"): 103 | this.renderFrame() 104 | 105 | 106 | ## Finish with completion 107 | method complete(text : string = "") = 108 | 109 | # Update text 110 | if text.len() > 0: 111 | this.statusText = text 112 | 113 | # Update state re-render 114 | this.state = Complete 115 | this.finish() 116 | 117 | 118 | ## Finish with warning 119 | method warn(text : string = "") = 120 | 121 | # Update text 122 | if text.len() > 0: 123 | this.statusText = text 124 | 125 | # Update state re-render 126 | this.state = Warning 127 | this.finish() 128 | 129 | 130 | ## Finish with error 131 | method fail(text : string = "") = 132 | 133 | # Update text 134 | if text.len() > 0: 135 | this.statusText = text 136 | 137 | # Update state re-render 138 | this.state = Error 139 | this.finish() -------------------------------------------------------------------------------- /src/termui/spinners.nim: -------------------------------------------------------------------------------- 1 | # File copied from: https://github.com/molnarmark/spinny/blob/master/src/spinny/spinners.nim 2 | # Spinners here are from https://github.com/sindresorhus/cli-spinners 3 | # converted to Nim with 4 | # https://gist.github.com/Yardanico/4137a09f171bfceae0b1dc531fdcc631 5 | type 6 | SpinnerKind* = enum 7 | Dots, Dots2, Dots3, Dots4, Dots5, Dots6, Dots7, Dots8, Dots9, 8 | Dots10, Dots11, Dots12, Line, Line​‌​2, Pipe, SimpleDots, 9 | SimpleDotsScrolling, Star, Star2, Flip, Hamburger, GrowVertical, 10 | GrowHorizontal​‌​, Balloon, Balloon2, Noise, Bounce, BoxBounce, BoxBounce2, 11 | Triangle, Arc, Circle, SquareCorners, Cir​‌​cleQuarters, CircleHalves, Squish, 12 | Toggle, Toggle2, Toggle3, Toggle4, Toggle5, Toggle6, Toggle7, Tog​‌​gle8, 13 | Toggle9, Toggle10, Toggle11, Toggle12, Toggle13, Arrow, Arrow2, Arrow3, 14 | BouncingBar, BouncingB​‌​all, Smiley, Monkey, Hearts, 15 | Clock, Earth, Moon, Runner, Pong, Shark, Dqpb 16 | 17 | Spinner* = object 18 | interval*: int 19 | frames*: seq[string] 20 | 21 | proc makeSpinner*(interval: int, frames: seq[string]): Spinner = 22 | Spinner(interval: interval, frames: frames) 23 | 24 | const Spinners*: array[SpinnerKind, Spinner] = [ 25 | # Dots 26 | Spinner( 27 | interval: 80, frames: @[ 28 | "⠋", 29 | "⠙", 30 | "⠹", 31 | "⠸", 32 | "⠼", 33 | "⠴", 34 | "⠦", 35 | "⠧", 36 | "⠇", 37 | "⠏", 38 | ] 39 | ), 40 | # Dots2 41 | Spinner( 42 | interval: 80, frames: @[ 43 | "⣾", 44 | "⣽", 45 | "⣻", 46 | "⢿", 47 | "⡿", 48 | "⣟", 49 | "⣯", 50 | "⣷", 51 | ] 52 | ), 53 | # Dots3 54 | Spinner( 55 | interval: 80, frames: @[ 56 | "⠋", 57 | "⠙", 58 | "⠚", 59 | "⠞", 60 | "⠖", 61 | "⠦", 62 | "⠴", 63 | "⠲", 64 | "⠳", 65 | "⠓", 66 | ] 67 | ), 68 | # Dots4 69 | Spinner( 70 | interval: 80, frames: @[ 71 | "⠄", 72 | "⠆", 73 | "⠇", 74 | "⠋", 75 | "⠙", 76 | "⠸", 77 | "⠰", 78 | "⠠", 79 | "⠰", 80 | "⠸", 81 | "⠙", 82 | "⠋", 83 | "⠇", 84 | "⠆", 85 | ] 86 | ), 87 | # Dots5 88 | Spinner( 89 | interval: 80, frames: @[ 90 | "⠋", 91 | "⠙", 92 | "⠚", 93 | "⠒", 94 | "⠂", 95 | "⠂", 96 | "⠒", 97 | "⠲", 98 | "⠴", 99 | "⠦", 100 | "⠖", 101 | "⠒", 102 | "⠐", 103 | "⠐", 104 | "⠒", 105 | "⠓", 106 | "⠋", 107 | ] 108 | ), 109 | # Dots6 110 | Spinner( 111 | interval: 80, frames: @[ 112 | "⠁", 113 | "⠉", 114 | "⠙", 115 | "⠚", 116 | "⠒", 117 | "⠂", 118 | "⠂", 119 | "⠒", 120 | "⠲", 121 | "⠴", 122 | "⠤", 123 | "⠄", 124 | "⠄", 125 | "⠤", 126 | "⠴", 127 | "⠲", 128 | "⠒", 129 | "⠂", 130 | "⠂", 131 | "⠒", 132 | "⠚", 133 | "⠙", 134 | "⠉", 135 | "⠁", 136 | ] 137 | ), 138 | # Dots7 139 | Spinner( 140 | interval: 80, frames: @[ 141 | "⠈", 142 | "⠉", 143 | "⠋", 144 | "⠓", 145 | "⠒", 146 | "⠐", 147 | "⠐", 148 | "⠒", 149 | "⠖", 150 | "⠦", 151 | "⠤", 152 | "⠠", 153 | "⠠", 154 | "⠤", 155 | "⠦", 156 | "⠖", 157 | "⠒", 158 | "⠐", 159 | "⠐", 160 | "⠒", 161 | "⠓", 162 | "⠋", 163 | "⠉", 164 | "⠈", 165 | ] 166 | ), 167 | # Dots8 168 | Spinner( 169 | interval: 80, frames: @[ 170 | "⠁", 171 | "⠁", 172 | "⠉", 173 | "⠙", 174 | "⠚", 175 | "⠒", 176 | "⠂", 177 | "⠂", 178 | "⠒", 179 | "⠲", 180 | "⠴", 181 | "⠤", 182 | "⠄", 183 | "⠄", 184 | "⠤", 185 | "⠠", 186 | "⠠", 187 | "⠤", 188 | "⠦", 189 | "⠖", 190 | "⠒", 191 | "⠐", 192 | "⠐", 193 | "⠒", 194 | "⠓", 195 | "⠋", 196 | "⠉", 197 | "⠈", 198 | "⠈", 199 | ] 200 | ), 201 | # Dots9 202 | Spinner( 203 | interval: 80, frames: @[ 204 | "⢹", 205 | "⢺", 206 | "⢼", 207 | "⣸", 208 | "⣇", 209 | "⡧", 210 | "⡗", 211 | "⡏", 212 | ] 213 | ), 214 | # Dots10 215 | Spinner( 216 | interval: 80, frames: @[ 217 | "⢄", 218 | "⢂", 219 | "⢁", 220 | "⡁", 221 | "⡈", 222 | "⡐", 223 | "⡠", 224 | ] 225 | ), 226 | # Dots11 227 | Spinner( 228 | interval: 100, frames: @[ 229 | "⠁", 230 | "⠂", 231 | "⠄", 232 | "⡀", 233 | "⢀", 234 | "⠠", 235 | "⠐", 236 | "⠈", 237 | ] 238 | ), 239 | # Dots12 240 | Spinner( 241 | interval: 80, frames: @[ 242 | "⢀⠀", 243 | "⡀⠀", 244 | "⠄⠀", 245 | "⢂⠀", 246 | "⡂⠀", 247 | "⠅⠀", 248 | "⢃⠀", 249 | "⡃⠀", 250 | "⠍⠀", 251 | "⢋⠀", 252 | "⡋⠀", 253 | "⠍⠁", 254 | "⢋⠁", 255 | "⡋⠁", 256 | "⠍⠉", 257 | "⠋⠉", 258 | "⠋⠉", 259 | "⠉⠙", 260 | "⠉⠙", 261 | "⠉⠩", 262 | "⠈⢙", 263 | "⠈⡙", 264 | "⢈⠩", 265 | "⡀⢙", 266 | "⠄⡙", 267 | "⢂⠩", 268 | "⡂⢘", 269 | "⠅⡘", 270 | "⢃⠨", 271 | "⡃⢐", 272 | "⠍⡐", 273 | "⢋⠠", 274 | "⡋⢀", 275 | "⠍⡁", 276 | "⢋⠁", 277 | "⡋⠁", 278 | "⠍⠉", 279 | "⠋⠉", 280 | "⠋⠉", 281 | "⠉⠙", 282 | "⠉⠙", 283 | "⠉⠩", 284 | "⠈⢙", 285 | "⠈⡙", 286 | "⠈⠩", 287 | "⠀⢙", 288 | "⠀⡙", 289 | "⠀⠩", 290 | "⠀⢘", 291 | "⠀⡘", 292 | "⠀⠨", 293 | "⠀⢐", 294 | "⠀⡐", 295 | "⠀⠠", 296 | "⠀⢀", 297 | "⠀⡀", 298 | ] 299 | ), 300 | # Line 301 | Spinner( 302 | interval: 130, frames: @[ 303 | "-", 304 | "\\", 305 | "|", 306 | "/", 307 | ] 308 | ), 309 | # Line2 310 | Spinner( 311 | interval: 100, frames: @[ 312 | "⠂", 313 | "-", 314 | "–", 315 | "—", 316 | "–", 317 | "-", 318 | ] 319 | ), 320 | # Pipe 321 | Spinner( 322 | interval: 100, frames: @[ 323 | "┤", 324 | "┘", 325 | "┴", 326 | "└", 327 | "├", 328 | "┌", 329 | "┬", 330 | "┐", 331 | ] 332 | ), 333 | # SimpleDots 334 | Spinner( 335 | interval: 400, frames: @[ 336 | ". ", 337 | "..", 338 | "...", 339 | " ", 340 | ] 341 | ), 342 | # SimpleDotsScrolling 343 | Spinner( 344 | interval: 200, frames: @[ 345 | ". ", 346 | "..", 347 | "...", 348 | " ..", 349 | " .", 350 | " ", 351 | ] 352 | ), 353 | # Star 354 | Spinner( 355 | interval: 70, frames: @[ 356 | "✶", 357 | "✸", 358 | "✹", 359 | "✺", 360 | "✹", 361 | "✷", 362 | ] 363 | ), 364 | # Star2 365 | Spinner( 366 | interval: 80, frames: @[ 367 | "+", 368 | "x", 369 | "*", 370 | ] 371 | ), 372 | # Flip 373 | Spinner( 374 | interval: 70, frames: @[ 375 | "_", 376 | "_", 377 | "_", 378 | "-", 379 | "`", 380 | "`", 381 | "'", 382 | "´", 383 | "-", 384 | "_", 385 | "_", 386 | "_", 387 | ] 388 | ), 389 | # Hamburger 390 | Spinner( 391 | interval: 100, frames: @[ 392 | "☱", 393 | "☲", 394 | "☴", 395 | ] 396 | ), 397 | # GrowVertical 398 | Spinner( 399 | interval: 120, frames: @[ 400 | "▁", 401 | "▃", 402 | "▄", 403 | "▅", 404 | "▆", 405 | "▇", 406 | "▆", 407 | "▅", 408 | "▄", 409 | "▃", 410 | ] 411 | ), 412 | # GrowHorizontal 413 | Spinner( 414 | interval: 120, frames: @[ 415 | "▏", 416 | "▎", 417 | "▍", 418 | "▌", 419 | "▋", 420 | "▊", 421 | "▉", 422 | "▊", 423 | "▋", 424 | "▌", 425 | "▍", 426 | "▎", 427 | ] 428 | ), 429 | # Balloon 430 | Spinner( 431 | interval: 140, frames: @[ 432 | "", 433 | ".", 434 | "o", 435 | "O", 436 | "@", 437 | "*", 438 | "", 439 | ] 440 | ), 441 | # Balloon2 442 | Spinner( 443 | interval: 120, frames: @[ 444 | ".", 445 | "o", 446 | "O", 447 | "°", 448 | "O", 449 | "o", 450 | ".", 451 | ] 452 | ), 453 | # Noise 454 | Spinner( 455 | interval: 100, frames: @[ 456 | "▓", 457 | "▒", 458 | "░", 459 | ] 460 | ), 461 | # Bounce 462 | Spinner( 463 | interval: 120, frames: @[ 464 | "⠁", 465 | "⠂", 466 | "⠄", 467 | "⠂", 468 | ] 469 | ), 470 | # BoxBounce 471 | Spinner( 472 | interval: 120, frames: @[ 473 | "▖", 474 | "▘", 475 | "▝", 476 | "▗", 477 | ] 478 | ), 479 | # BoxBounce2 480 | Spinner( 481 | interval: 100, frames: @[ 482 | "▌", 483 | "▀", 484 | "▐", 485 | "▄", 486 | ] 487 | ), 488 | # Triangle 489 | Spinner( 490 | interval: 50, frames: @[ 491 | "◢", 492 | "◣", 493 | "◤", 494 | "◥", 495 | ] 496 | ), 497 | # Arc 498 | Spinner( 499 | interval: 100, frames: @[ 500 | "◜", 501 | "◠", 502 | "◝", 503 | "◞", 504 | "◡", 505 | "◟", 506 | ] 507 | ), 508 | # Circle 509 | Spinner( 510 | interval: 120, frames: @[ 511 | "◡", 512 | "⊙", 513 | "◠", 514 | ] 515 | ), 516 | # SquareCorners 517 | Spinner( 518 | interval: 180, frames: @[ 519 | "◰", 520 | "◳", 521 | "◲", 522 | "◱", 523 | ] 524 | ), 525 | # CircleQuarters 526 | Spinner( 527 | interval: 120, frames: @[ 528 | "◴", 529 | "◷", 530 | "◶", 531 | "◵", 532 | ] 533 | ), 534 | # CircleHalves 535 | Spinner( 536 | interval: 50, frames: @[ 537 | "◐", 538 | "◓", 539 | "◑", 540 | "◒", 541 | ] 542 | ), 543 | # Squish 544 | Spinner( 545 | interval: 100, frames: @[ 546 | "╫", 547 | "╪", 548 | ] 549 | ), 550 | # Toggle 551 | Spinner( 552 | interval: 250, frames: @[ 553 | "⊶", 554 | "⊷", 555 | ] 556 | ), 557 | # Toggle2 558 | Spinner( 559 | interval: 80, frames: @[ 560 | "▫", 561 | "▪", 562 | ] 563 | ), 564 | # Toggle3 565 | Spinner( 566 | interval: 120, frames: @[ 567 | "□", 568 | "■", 569 | ] 570 | ), 571 | # Toggle4 572 | Spinner( 573 | interval: 100, frames: @[ 574 | "■", 575 | "□", 576 | "▪", 577 | "▫", 578 | ] 579 | ), 580 | # Toggle5 581 | Spinner( 582 | interval: 100, frames: @[ 583 | "▮", 584 | "▯", 585 | ] 586 | ), 587 | # Toggle6 588 | Spinner( 589 | interval: 300, frames: @[ 590 | "ဝ", 591 | "၀", 592 | ] 593 | ), 594 | # Toggle7 595 | Spinner( 596 | interval: 80, frames: @[ 597 | "⦾", 598 | "⦿", 599 | ] 600 | ), 601 | # Toggle8 602 | Spinner( 603 | interval: 100, frames: @[ 604 | "◍", 605 | "◌", 606 | ] 607 | ), 608 | # Toggle9 609 | Spinner( 610 | interval: 100, frames: @[ 611 | "◉", 612 | "◎", 613 | ] 614 | ), 615 | # Toggle10 616 | Spinner( 617 | interval: 100, frames: @[ 618 | "㊂", 619 | "㊀", 620 | "㊁", 621 | ] 622 | ), 623 | # Toggle11 624 | Spinner( 625 | interval: 50, frames: @[ 626 | "⧇", 627 | "⧆", 628 | ] 629 | ), 630 | # Toggle12 631 | Spinner( 632 | interval: 120, frames: @[ 633 | "☗", 634 | "☖", 635 | ] 636 | ), 637 | # Toggle13 638 | Spinner( 639 | interval: 80, frames: @[ 640 | "=", 641 | "*", 642 | "-", 643 | ] 644 | ), 645 | # Arrow 646 | Spinner( 647 | interval: 100, frames: @[ 648 | "←", 649 | "↖", 650 | "↑", 651 | "↗", 652 | "→", 653 | "↘", 654 | "↓", 655 | "↙", 656 | ] 657 | ), 658 | # Arrow2 659 | Spinner( 660 | interval: 80, frames: @[ 661 | "⬆", 662 | "↗", 663 | "➡", 664 | "↘", 665 | "⬇", 666 | "↙", 667 | "⬅", 668 | "↖", 669 | ] 670 | ), 671 | # Arrow3 672 | Spinner( 673 | interval: 120, frames: @[ 674 | "▹▹▹▹▹", 675 | "▸▹▹▹▹", 676 | "▹▸▹▹▹", 677 | "▹▹▸▹▹", 678 | "▹▹▹▸▹", 679 | "▹▹▹▹▸", 680 | ] 681 | ), 682 | # BouncingBar 683 | Spinner( 684 | interval: 80, frames: @[ 685 | "[ ]", 686 | "[ =]", 687 | "[ ==]", 688 | "[ ===]", 689 | "[====]", 690 | "[=== ]", 691 | "[== ]", 692 | "[= ]", 693 | ] 694 | ), 695 | # BouncingBall 696 | Spinner( 697 | interval: 80, frames: @[ 698 | "( ● )", 699 | "( ● )", 700 | "( ● )", 701 | "( ● )", 702 | "( ●)", 703 | "( ● )", 704 | "( ● )", 705 | "( ● )", 706 | "( ● )", 707 | "(● )", 708 | ] 709 | ), 710 | # Smiley 711 | Spinner( 712 | interval: 200, frames: @[ 713 | "😄", 714 | "😝", 715 | ] 716 | ), 717 | # Monkey 718 | Spinner( 719 | interval: 300, frames: @[ 720 | "🙈", 721 | "🙈", 722 | "🙉", 723 | "🙊", 724 | ] 725 | ), 726 | # Hearts 727 | Spinner( 728 | interval: 100, frames: @[ 729 | "💛", 730 | "💙", 731 | "💜", 732 | "💚", 733 | "❤", 734 | ] 735 | ), 736 | # Clock 737 | Spinner( 738 | interval: 100, frames: @[ 739 | "🕐", 740 | "🕑", 741 | "🕒", 742 | "🕓", 743 | "🕔", 744 | "🕕", 745 | "🕖", 746 | "🕗", 747 | "🕘", 748 | "🕙", 749 | "🕚", 750 | ] 751 | ), 752 | # Earth 753 | Spinner( 754 | interval: 180, frames: @[ 755 | "🌍", 756 | "🌎", 757 | "🌏", 758 | ] 759 | ), 760 | # Moon 761 | Spinner( 762 | interval: 80, frames: @[ 763 | "🌑", 764 | "🌒", 765 | "🌓", 766 | "🌔", 767 | "🌕", 768 | "🌖", 769 | "🌗", 770 | "🌘", 771 | ] 772 | ), 773 | # Runner 774 | Spinner( 775 | interval: 140, frames: @[ 776 | "🚶", 777 | "🏃", 778 | ] 779 | ), 780 | # Pong 781 | Spinner( 782 | interval: 80, frames: @[ 783 | "▐⠂ ▌", 784 | "▐⠈ ▌", 785 | "▐ ⠂ ▌", 786 | "▐ ⠠ ▌", 787 | "▐ ⡀ ▌", 788 | "▐ ⠠ ▌", 789 | "▐ ⠂ ▌", 790 | "▐ ⠈ ▌", 791 | "▐ ⠂ ▌", 792 | "▐ ⠠ ▌", 793 | "▐ ⡀ ▌", 794 | "▐ ⠠ ▌", 795 | "▐ ⠂ ▌", 796 | "▐ ⠈ ▌", 797 | "▐ ⠂▌", 798 | "▐ ⠠▌", 799 | "▐ ⡀▌", 800 | "▐ ⠠ ▌", 801 | "▐ ⠂ ▌", 802 | "▐ ⠈ ▌", 803 | "▐ ⠂ ▌", 804 | "▐ ⠠ ▌", 805 | "▐ ⡀ ▌", 806 | "▐ ⠠ ▌", 807 | "▐ ⠂ ▌", 808 | "▐ ⠈ ▌", 809 | "▐ ⠂ ▌", 810 | "▐ ⠠ ▌", 811 | "▐ ⡀ ▌", 812 | "▐⠠ ▌", 813 | ] 814 | ), 815 | # Shark 816 | Spinner( 817 | interval: 120, frames: @[ 818 | "▐|\\____________▌", 819 | "▐_|\\___________▌", 820 | "▐__|\\__________▌", 821 | "▐___|\\_________▌", 822 | "▐____|\\________▌", 823 | "▐_____|\\_______▌", 824 | "▐______|\\______▌", 825 | "▐_______|\\_____▌", 826 | "▐________|\\____▌", 827 | "▐_________|\\___▌", 828 | "▐__________|\\__▌", 829 | "▐___________|\\_▌", 830 | "▐____________|\\▌", 831 | "▐____________/|▌", 832 | "▐___________/|_▌", 833 | "▐__________/|__▌", 834 | "▐_________/|___▌", 835 | "▐________/|____▌", 836 | "▐_______/|_____▌", 837 | "▐______/|______▌", 838 | "▐_____/|_______▌", 839 | "▐____/|________▌", 840 | "▐___/|_________▌", 841 | "▐__/|__________▌", 842 | "▐_/|___________▌", 843 | "▐/|____________▌", 844 | ] 845 | ), 846 | # Dqpb 847 | Spinner( 848 | interval: 100, frames: @[ 849 | "d", 850 | "q", 851 | "p", 852 | "b", 853 | ] 854 | ), 855 | ] -------------------------------------------------------------------------------- /src/termui/widget.nim: -------------------------------------------------------------------------------- 1 | import classes 2 | import terminal 3 | import ./ansi 4 | import ./buffer 5 | import ./input 6 | import os 7 | import strutils 8 | 9 | 10 | ## Widget draw modes 11 | type TermuiWidgetDrawMode* = enum 12 | 13 | ## The widget start() method will block until the widget is finished. The widget will receive key events, 14 | ## and will redraw after each one. 15 | TermuiRedrawOnUserInput, 16 | 17 | ## The widget will continually redraw on a background thread. It will not receive key events. 18 | TermuiRedrawInThread, 19 | 20 | ## The widget will redraw only when the caller updates the widget. It will not receive key events. 21 | TermuiRedrawManually 22 | 23 | 24 | ## Last terminal mode 25 | when not defined(windows): 26 | import termios 27 | 28 | # From terminal.nim 29 | proc setRaw(fd: FileHandle, time: cint = TCSAFLUSH) = 30 | var mode: Termios 31 | discard fd.tcGetAttr(addr mode) 32 | mode.c_iflag = mode.c_iflag and not Cflag(BRKINT or ICRNL or INPCK or 33 | ISTRIP or IXON) 34 | mode.c_oflag = mode.c_oflag and not Cflag(OPOST) 35 | mode.c_cflag = (mode.c_cflag and not Cflag(CSIZE or PARENB)) or CS8 36 | mode.c_lflag = mode.c_lflag and not Cflag(ECHO or ICANON or IEXTEN or ISIG) 37 | mode.c_cc[VMIN] = 1.cuchar 38 | mode.c_cc[VTIME] = 0.cuchar 39 | discard fd.tcSetAttr(time, addr mode) 40 | 41 | var lastTermiosMode: Termios 42 | 43 | 44 | ## Parent class for widgets 45 | class TermuiWidgetBase: 46 | 47 | ## Redraw mode 48 | var redrawMode : TermuiWidgetDrawMode = TermuiRedrawOnUserInput 49 | 50 | ## True once this component is done and no longer updating 51 | var isFinished = false 52 | 53 | ## Frame buffer 54 | var buffer : TerminalBuffer = TerminalBuffer.init() 55 | 56 | ## Render function, subclasses should override this and update the buffer 57 | method render() = discard 58 | 59 | ## Called when the user inputs something on the keyboard while we are blocking 60 | method onInput(event : KeyboardEvent) = discard 61 | 62 | ## Start rendering. This will block until isBlocking becomes false. Subclasses should make it false. 63 | method start() = 64 | 65 | # Enable ANSI support for Windows terminals 66 | enableAnsiOnWindowsConsole() 67 | 68 | # TODO: Enable UTF8 output for Windows terminals (chcp 65001) 69 | 70 | # Prevent keyboard echoing on Linux/mac 71 | when not defined(windows): 72 | let fd = getFileHandle(stdin) 73 | discard fd.tcGetAttr(addr lastTermiosMode) 74 | fd.setRaw() 75 | 76 | # If rendering continuously, start thread now 77 | if this.redrawMode == TermuiRedrawInThread: 78 | this.startThread() 79 | return 80 | 81 | # If rendering on updates only, just draw one frame now 82 | if this.redrawMode == TermuiRedrawManually: 83 | this.renderFrame() 84 | return 85 | 86 | # Start rendering on same thread as user input 87 | while true: 88 | 89 | # Stop if done 90 | if this.isFinished: 91 | break 92 | 93 | # Render next frame 94 | this.renderFrame() 95 | 96 | # Wait for user input 97 | let event = readTerminalInput() 98 | this.onInput(event) 99 | 100 | 101 | ## Render the next frame. This can be called either on the main thread or a background thread, depending 102 | ## if renderInBackgroundContinuously is true or not. 103 | method renderFrame() = 104 | 105 | # Allow subclass to update the buffer 106 | this.render() 107 | 108 | # Draw buffer to the screen 109 | this.buffer.draw() 110 | 111 | 112 | ## Finish this widget 113 | method finish() = 114 | 115 | # Stop the loop 116 | this.isFinished = true 117 | 118 | # Run one last output just in case it changed when finishing 119 | if this.redrawMode != TermuiRedrawInThread: 120 | this.renderFrame() 121 | this.buffer.finish() 122 | 123 | # In the case where threading is not supported but this widget wanted to be threaded, 124 | # we can still draw our last frame here. It's something, at least... 125 | when not compileOption("threads"): 126 | if this.redrawMode == TermuiRedrawInThread: 127 | this.renderFrame() 128 | this.buffer.finish() 129 | 130 | # Restore keyboard echoing 131 | when not defined(windows): 132 | let fd = getFileHandle(stdin) 133 | discard fd.tcSetAttr(TCSADRAIN, addr lastTermiosMode) 134 | 135 | 136 | ## Starts the background thread. Called on the main thread. 137 | method startThread() = 138 | 139 | # Not supported! Let's just draw one iteration now 140 | this.renderFrame() 141 | 142 | 143 | # Check for thread support 144 | when not compileOption("threads"): 145 | 146 | # Just use the base class 147 | class TermuiWidget of TermuiWidgetBase 148 | 149 | else: 150 | 151 | # Extra imports 152 | # import locks 153 | 154 | # Create subclass with thread support 155 | class TermuiWidget of TermuiWidgetBase: 156 | 157 | ## Thread 158 | var thread : Thread[pointer] 159 | 160 | ## Starts the backgound thread. Called on the main thread. 161 | method startThread() = 162 | 163 | # HACK: Lock it so the instance doesn't get garbage collected. Without this, it is giving a SIGSEGV at random places AFTER the widget 164 | # is already completed and the thread ended! I don't understand it at all. 165 | GC_ref(this) 166 | 167 | # Create thread 168 | this.thread.createThread(proc(thisPtr : pointer) {.thread.} = 169 | 170 | # Run thread code 171 | var this = cast[TermuiWidget](thisPtr) 172 | this.runThread() 173 | 174 | , cast[pointer](this)) 175 | 176 | 177 | ## Runs on a background thread 178 | method runThread() {.thread.} = 179 | 180 | # Continually re-render 181 | while true: 182 | 183 | # Check if should end 184 | if this.isFinished: 185 | break 186 | 187 | # Render next frame 188 | this.renderFrame() 189 | 190 | # Wait a bit 191 | sleep(int(1000 / 30)) 192 | 193 | # Render one more time before exiting 194 | this.renderFrame() 195 | 196 | # Clean up the terminal 197 | this.buffer.finish() 198 | 199 | 200 | ## Finish this widget 201 | method finish() = 202 | 203 | # Kill the thread, wait for it to finish 204 | if this.redrawMode == TermuiRedrawInThread: 205 | this.isFinished = true 206 | this.thread.joinThread() 207 | 208 | # HACK: Unlock this for garbage collection. Well, normally, right? Except doing this triggers that same SIGSEGV at random intervals. 209 | # Unfortunately, we're going to have to just waste this memory for now... 210 | #GC_unref(this) 211 | 212 | # Continue 213 | super.finish() -------------------------------------------------------------------------------- /termui.nimble: -------------------------------------------------------------------------------- 1 | # Package 2 | 3 | version = "0.1.9" 4 | author = "jjv360" 5 | description = "Simple UI components for the terminal." 6 | license = "MIT" 7 | srcDir = "src" 8 | installExt = @["nim"] 9 | 10 | 11 | # Dependencies 12 | 13 | requires "nim >= 1.4.4" 14 | requires "classes >= 0.2.13" 15 | requires "elvis >= 0.5.0" -------------------------------------------------------------------------------- /tests/test.nim: -------------------------------------------------------------------------------- 1 | import ../src/termui 2 | import strformat 3 | import os 4 | 5 | # Do a fake login 6 | echo "== Welcome to a fake user survey! Do not enter any real information. ==" 7 | echo "" 8 | echo "Please enter your login details to continue..." 9 | let username = termuiAsk("Username:") 10 | let password = termuiAskPassword("Password:") 11 | 12 | # Show login progress bar 13 | var loader = termuiSpinner("Logging you in...") 14 | for i in 0 .. 2: 15 | loader.update(fmt"Logging in ({i}s)...") 16 | sleep(1000) 17 | loader.warn("Login failed! Retrying...") 18 | 19 | # Show second login attempt 20 | loader = termuiSpinner("Logging you in...") 21 | for i in 0 .. 2: 22 | loader.update(fmt"Logging in ({i}s)...") 23 | sleep(1000) 24 | 25 | # login complete 26 | loader.complete("Successfully logged in!") 27 | 28 | # Fake login complete 29 | echo "" 30 | echo fmt"Welcome {username}, your password length is {password.len()}. Enter the following details to set up a new package." 31 | 32 | # Ask for information 33 | discard termuiAsk("Package name?", defaultValue = "com.user.pkg") 34 | discard termuiConfirm("Override existing package?") 35 | discard termuiSelect("What kind of package?", options = @["Library", "Executable", "Hybrid (both)"]) 36 | discard termuiSelectMultiple("What categories to use?", options = @["Books", "Business", "Entertainment", "Finance", "Food & Drink", "Lifestyle", "Music", "Navigation", "News", "Productivity", "Reference", "Sports", "Travel", "Utilities", "Weather"]) 37 | 38 | # Progress bar 39 | let progress = termuiProgress("0% : Uploading package.json") 40 | for i in 0 ..< 100: 41 | sleep(20) 42 | progress.update(i / 100, fmt"{i}% : Uploading package.json") 43 | progress.complete("Uploaded package.json successfully.") 44 | 45 | # Progress bar 46 | let progress2 = termuiProgress("Uploading package.data...") 47 | for i in 0 ..< 100: 48 | sleep(100) 49 | progress2.update(i / 100, fmt"{i}% : Uploading package.data...") 50 | progress2.complete("Uploaded package.data successfully.") -------------------------------------------------------------------------------- /tests/test.nims: -------------------------------------------------------------------------------- 1 | --threads:on --------------------------------------------------------------------------------