├── .gitignore ├── LICENSE ├── README.md ├── examples ├── paint │ └── main.odin ├── snake │ └── main.odin ├── user_input │ └── main.odin └── window │ └── main.odin ├── input.odin ├── platform_dependent.odin ├── termcl.odin └── widgets ├── button.odin ├── common.odin ├── panel.odin └── progress.odin /.gitignore: -------------------------------------------------------------------------------- 1 | TermCL 2 | *.bin 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2025, Raph 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |
3 |

4 |

TermCL

5 |

Terminal control and ANSI escape code library for Odin

6 |

7 |
8 | 9 | 10 |
11 |
12 | · 13 | Report Bug 14 | · 15 | Request Feature 16 |

17 |

18 | 19 | 20 |
21 | Table of Contents 22 |
    23 |
  1. 24 | About The Project 25 |
  2. 26 |
  3. How it works
  4. 27 |
  5. Usage
  6. 28 |
29 |
30 | 31 | 32 | 33 | TermCL is an Odin library for writing TUIs and CLIs with. 34 | The library is compatible with any ANSI escape code compatible terminal, which is to say, almost every single modern terminal worth using :) 35 | 36 | The library should also work on windows and any posix compatible operating system. 37 | 38 | ## How it works 39 | The library uses a Screen struct to represent the terminal. To start a CLI/TUI you need to call `init_screen`, 40 | this function calls the operating system to get information on the terminal state. 41 | 42 | > [!NOTE] 43 | > you should call destroy_screen before you exit to restore the terminal state otherwise you might end up with a weird behaving terminal 44 | 45 | After that you should just set the terminal to whatever mode you want with the `set_term_mode` function, there are 3 modes you can use: 46 | - Raw mode (`.Raw`) - prevents the terminal from processing the user input so that you can handle them yourself 47 | - Cooked mode (`.Cbreak`) - prevents user input but unlike raw, it still processed for signals like Ctrl + C and others 48 | - Restored mode (`.Restored`) - restores the terminal to the state it was in before the program started messing with it, this is also called when the screen is destroyed 49 | 50 | After doing this, you should be good to go to do whatever you want. 51 | 52 | Here's a few minor things to take into consideration: 53 | - To handle input you can use the `read` function or the `read_blocking` function, as the default read is nonblocking. 54 | - There's convenience functions that allow you to more easily process input, they're called `parse_keyboard_input` and `parse_mouse_input` 55 | - Whatever you do won't show up on screen until you `blit`, since everything is cached first, windows also have their own cached escapes, so make sure you blit them as well 56 | 57 | ## Usage 58 | 59 | ```odin 60 | package main 61 | 62 | import "termcl" 63 | 64 | main :: proc() { 65 | scr := termcl.init_screen() 66 | defer termcl.destroy_screen(&scr) 67 | 68 | termcl.set_text_style(&scr, {.Bold, .Italic}) 69 | termcl.write(&scr, "Hello ") 70 | termcl.reset_styles(&scr) 71 | 72 | termcl.set_text_style(&scr, {.Dim}) 73 | termcl.set_color_style_8(&scr, .Green, nil) 74 | termcl.write(&scr, "from ANSI escapes") 75 | termcl.reset_styles(&scr) 76 | 77 | termcl.move_cursor(&scr, 10, 10) 78 | termcl.write(&scr, "Alles Ordnung") 79 | 80 | termcl.blit(&scr) 81 | } 82 | ``` 83 | 84 | Check the `examples` directory to see more on how to use it. 85 | -------------------------------------------------------------------------------- /examples/paint/main.odin: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import t "../../" 4 | 5 | Brush :: struct { 6 | color: t.Color_8, 7 | size: uint, 8 | } 9 | 10 | PAINT_BUFFER_WIDTH :: 80 11 | PAINT_BUFFER_HEIGHT :: 30 12 | 13 | Paint_Buffer :: struct { 14 | buffer: [PAINT_BUFFER_HEIGHT][PAINT_BUFFER_WIDTH]Maybe(t.Color_8), 15 | screen: ^t.Screen, 16 | window: t.Window, 17 | } 18 | 19 | paint_buffer_init :: proc(s: ^t.Screen) -> Paint_Buffer { 20 | return Paint_Buffer { 21 | window = t.init_window(0, 0, PAINT_BUFFER_HEIGHT, PAINT_BUFFER_WIDTH), 22 | screen = s, 23 | } 24 | } 25 | 26 | paint_buffer_destroy :: proc(pbuf: ^Paint_Buffer) { 27 | t.destroy_window(&pbuf.window) 28 | } 29 | 30 | paint_buffer_to_screen :: proc(pbuf: ^Paint_Buffer) { 31 | termsize := t.get_term_size(pbuf.screen) 32 | pbuf.window.x_offset = termsize.w / 2 - PAINT_BUFFER_WIDTH / 2 33 | pbuf.window.y_offset = termsize.h / 2 - PAINT_BUFFER_HEIGHT / 2 34 | 35 | t.set_color_style_8(&pbuf.window, .White, .White) 36 | t.clear(&pbuf.window, .Everything) 37 | 38 | defer { 39 | t.reset_styles(&pbuf.window) 40 | t.blit(&pbuf.window) 41 | } 42 | 43 | for y in 0 ..< PAINT_BUFFER_HEIGHT { 44 | for x in 0 ..< PAINT_BUFFER_WIDTH { 45 | t.move_cursor(&pbuf.window, uint(y), uint(x)) 46 | color := pbuf.buffer[y][x] 47 | if color == nil do continue 48 | t.set_color_style_8(&pbuf.window, color, color) 49 | t.write(&pbuf.window, ' ') 50 | } 51 | } 52 | } 53 | 54 | paint_buffer_set_cell :: proc(pbuf: ^Paint_Buffer, y, x: uint, color: Maybe(t.Color_8)) { 55 | pbuf.buffer[y][x] = color 56 | } 57 | 58 | main :: proc() { 59 | s := t.init_screen() 60 | defer t.destroy_screen(&s) 61 | t.set_term_mode(&s, .Raw) 62 | t.hide_cursor(true) 63 | 64 | t.clear(&s, .Everything) 65 | t.blit(&s) 66 | 67 | pbuf := paint_buffer_init(&s) 68 | defer paint_buffer_destroy(&pbuf) 69 | 70 | for { 71 | defer { 72 | t.blit(&s) 73 | paint_buffer_to_screen(&pbuf) 74 | } 75 | 76 | termsize := t.get_term_size(&s) 77 | 78 | help_msg := "Draw (Right Click) / Delete (Left Click) / Quit (Q or CTRL + C)" 79 | t.move_cursor(&s, termsize.h - 2, termsize.w / 2 - len(help_msg) / 2) 80 | t.write(&s, help_msg) 81 | 82 | if termsize.w <= PAINT_BUFFER_WIDTH || termsize.w <= PAINT_BUFFER_WIDTH { 83 | t.clear(&s, .Everything) 84 | size_small_msg := "Size is too small, increase size to continue or press 'q' to exit" 85 | t.move_cursor(&s, termsize.h / 2, termsize.w / 2 - len(size_small_msg) / 2) 86 | t.write(&s, size_small_msg) 87 | continue 88 | } 89 | 90 | 91 | input := t.read(&s) or_continue 92 | 93 | keyboard, kb_has_input := t.parse_keyboard_input(input) 94 | if kb_has_input && (keyboard.mod == .Ctrl && keyboard.key == .C) || keyboard.key == .Q { 95 | break 96 | } 97 | 98 | mouse := t.parse_mouse_input(input) or_continue 99 | win_cursor := t.window_coord_from_global( 100 | &pbuf.window, 101 | mouse.coord.y, 102 | mouse.coord.x, 103 | ) or_continue 104 | 105 | #partial switch mouse.key { 106 | case .Left: 107 | if mouse.mod == nil && .Pressed in mouse.event { 108 | paint_buffer_set_cell(&pbuf, win_cursor.y, win_cursor.x, .Black) 109 | } 110 | case .Right: 111 | if mouse.mod == nil && .Pressed in mouse.event { 112 | paint_buffer_set_cell(&pbuf, win_cursor.y, win_cursor.x, nil) 113 | } 114 | } 115 | 116 | } 117 | } 118 | 119 | -------------------------------------------------------------------------------- /examples/snake/main.odin: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import t "../.." 4 | import "core:math/rand" 5 | import "core:time" 6 | 7 | Direction :: enum { 8 | Up, 9 | Down, 10 | Left, 11 | Right, 12 | } 13 | 14 | Snake :: struct { 15 | head: [2]uint, 16 | body: [dynamic][2]uint, 17 | dir: Direction, 18 | } 19 | 20 | get_term_center :: proc(s: ^t.Screen) -> [2]uint { 21 | termsize := t.get_term_size(s) 22 | pos := [2]uint{termsize.w / 2, termsize.h / 2} 23 | return pos 24 | } 25 | 26 | snake_init :: proc(s: ^t.Screen) -> Snake { 27 | DEFAULT_SNAKE_SIZE :: 4 28 | pos := get_term_center(s) 29 | pos.x -= DEFAULT_SNAKE_SIZE 30 | 31 | body := make([dynamic][2]uint) 32 | 33 | for i in 0 ..< DEFAULT_SNAKE_SIZE { 34 | curr_pos := pos 35 | curr_pos.x += uint(i) 36 | append(&body, curr_pos) 37 | } 38 | 39 | pos.x += DEFAULT_SNAKE_SIZE 40 | 41 | return Snake{head = pos, body = body, dir = .Right} 42 | } 43 | 44 | snake_destroy :: proc(snake: Snake) { 45 | delete(snake.body) 46 | } 47 | 48 | snake_draw :: proc(snake: Snake, s: ^t.Screen) { 49 | t.set_color_style_8(s, nil, .Green) 50 | defer t.reset_styles(s) 51 | 52 | for part in snake.body { 53 | t.move_cursor(s, part.y, part.x) 54 | t.write(s, ' ') 55 | } 56 | 57 | t.set_color_style_8(s, nil, .White) 58 | t.move_cursor(s, snake.head.y, snake.head.x) 59 | t.write(s, ' ') 60 | } 61 | 62 | Game_Box :: struct { 63 | x, y, w, h: uint, 64 | } 65 | 66 | box_init :: proc(s: ^t.Screen) -> Game_Box { 67 | termcenter := get_term_center(s) 68 | 69 | BOX_HEIGHT :: 20 70 | BOX_WIDTH :: 60 71 | 72 | return Game_Box { 73 | x = termcenter.x - BOX_WIDTH / 2, 74 | y = termcenter.y - BOX_HEIGHT / 2, 75 | w = BOX_WIDTH, 76 | h = BOX_HEIGHT, 77 | } 78 | } 79 | 80 | box_draw :: proc(game: Game, s: ^t.Screen) { 81 | box := game.box 82 | t.set_color_style_8(s, .Black, .White) 83 | defer t.reset_styles(s) 84 | 85 | draw_row :: proc(box: Game_Box, s: ^t.Screen, y: uint) { 86 | for i in 0 ..= box.w { 87 | t.move_cursor(s, y, box.x + i) 88 | t.write(s, ' ') 89 | } 90 | } 91 | 92 | draw_row(box, s, box.y) 93 | draw_row(box, s, box.y + box.h) 94 | 95 | draw_col :: proc(box: Game_Box, s: ^t.Screen, x: uint) { 96 | for i in 0 ..= box.h { 97 | t.move_cursor(s, box.y + i, x) 98 | t.write(s, ' ') 99 | } 100 | } 101 | 102 | draw_col(box, s, box.x) 103 | draw_col(box, s, box.x + box.w) 104 | 105 | t.set_text_style(s, {.Bold}) 106 | msg := "Press 'q' to exit. Player Score: %d" 107 | t.move_cursor(s, box.y + box.h, box.x + (box.w / 2 - len(msg) / 2)) 108 | t.writef(s, msg, game.score) 109 | t.reset_styles(s) 110 | } 111 | 112 | snake_handle_input :: proc(s: ^t.Screen, game: ^Game, input: t.Input_Seq) { 113 | snake := &game.snake 114 | box := &game.box 115 | 116 | #partial switch input.key { 117 | case .Arrow_Left, .A: 118 | if snake.dir != .Right do snake.dir = .Left 119 | case .Arrow_Right, .D: 120 | if snake.dir != .Left do snake.dir = .Right 121 | case .Arrow_Up, .W: 122 | if snake.dir != .Down do snake.dir = .Up 123 | case .Arrow_Down, .S: 124 | if snake.dir != .Up do snake.dir = .Down 125 | } 126 | 127 | ordered_remove(&snake.body, 0) 128 | append(&snake.body, snake.head) 129 | 130 | switch snake.dir { 131 | case .Up: 132 | snake.head.y -= 1 133 | case .Down: 134 | snake.head.y += 1 135 | case .Left: 136 | snake.head.x -= 1 137 | case .Right: 138 | snake.head.x += 1 139 | } 140 | 141 | if snake.head.y <= box.y { 142 | snake.head.y = box.y + box.h - 1 143 | } 144 | 145 | if snake.head.y >= box.y + box.h { 146 | snake.head.y = box.y 147 | } 148 | 149 | if snake.head.x >= box.x + box.w { 150 | snake.head.x = box.x + 1 151 | } 152 | 153 | if snake.head.x <= box.x { 154 | snake.head.x = box.x + box.w - 1 155 | } 156 | } 157 | 158 | Food :: struct { 159 | x, y: uint, 160 | } 161 | 162 | food_generate :: proc(game: Game) -> Food { 163 | box := game.box 164 | x, y: uint 165 | 166 | for { 167 | x = (cast(uint)rand.uint32() % (box.w - 1)) + box.x + 1 168 | y = (cast(uint)rand.uint32() % (box.h - 1)) + box.y + 1 169 | 170 | no_collision := true 171 | for part in game.snake.body { 172 | if part.x == x && part.y == y { 173 | no_collision = false 174 | } 175 | } 176 | 177 | if no_collision do break 178 | } 179 | 180 | return Food{x = x, y = y} 181 | } 182 | 183 | Game :: struct { 184 | snake: Snake, 185 | box: Game_Box, 186 | food: Food, 187 | score: uint, 188 | } 189 | 190 | game_init :: proc(s: ^t.Screen) -> Game { 191 | game := Game { 192 | snake = snake_init(s), 193 | box = box_init(s), 194 | } 195 | game.food = food_generate(game) 196 | return game 197 | } 198 | 199 | game_destroy :: proc(game: Game) { 200 | snake_destroy(game.snake) 201 | } 202 | 203 | game_is_over :: proc(game: Game, s: ^t.Screen) -> bool { 204 | snake := game.snake 205 | for part in snake.body { 206 | if part.x == snake.head.x && part.y == snake.head.y { 207 | return true 208 | } 209 | } 210 | 211 | return false 212 | } 213 | 214 | game_tick :: proc(game: ^Game, s: ^t.Screen) { 215 | if game.snake.head.x == game.food.x && game.snake.head.y == game.food.y { 216 | game.score += 1 217 | game.food = food_generate(game^) 218 | append(&game.snake.body, game.snake.body[len(game.snake.body) - 1]) 219 | } 220 | 221 | t.move_cursor(s, game.food.y, game.food.x) 222 | t.set_color_style_8(s, nil, .Yellow) 223 | t.write(s, ' ') 224 | t.reset_styles(s) 225 | 226 | snake_draw(game.snake, s) 227 | box_draw(game^, s) 228 | } 229 | 230 | 231 | main :: proc() { 232 | s := t.init_screen() 233 | defer t.destroy_screen(&s) 234 | t.set_term_mode(&s, .Cbreak) 235 | t.hide_cursor(true) 236 | t.clear(&s, .Everything) 237 | t.blit(&s) 238 | 239 | game := game_init(&s) 240 | stopwatch: time.Stopwatch 241 | 242 | for { 243 | defer t.blit(&s) 244 | 245 | if game_is_over(game, &s) { 246 | t.move_cursor(&s, game.box.y + game.box.h + 5, 0) 247 | t.write(&s, "=== Game Over ===\n") 248 | break 249 | } 250 | 251 | time.stopwatch_start(&stopwatch) 252 | defer time.stopwatch_reset(&stopwatch) 253 | t.clear(&s, .Everything) 254 | 255 | input, _ := t.read(&s) 256 | keys, kb_has_input := t.parse_keyboard_input(input) 257 | 258 | if kb_has_input && (keys.key == .Q) { 259 | break 260 | } 261 | 262 | 263 | snake_handle_input(&s, &game, keys) 264 | 265 | for { 266 | duration := time.stopwatch_duration(stopwatch) 267 | millisecs := time.duration_milliseconds(duration) 268 | if millisecs > 80 { 269 | break 270 | } 271 | } 272 | 273 | game_tick(&game, &s) 274 | } 275 | } 276 | 277 | -------------------------------------------------------------------------------- /examples/user_input/main.odin: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import t "../.." 4 | 5 | main :: proc() { 6 | s := t.init_screen() 7 | defer t.destroy_screen(&s) 8 | t.set_term_mode(&s, .Raw) 9 | 10 | t.clear(&s, .Everything) 11 | t.move_cursor(&s, 0, 0) 12 | t.write(&s, "Please type something or move your mouse to start") 13 | t.blit(&s) 14 | 15 | for { 16 | t.clear(&s, .Everything) 17 | defer t.blit(&s) 18 | 19 | input := t.read_blocking(&s) or_continue 20 | 21 | t.move_cursor(&s, 0, 0) 22 | t.write(&s, "Press `Esc` to exit") 23 | 24 | kb_input, has_kb_input := t.parse_keyboard_input(input) 25 | 26 | if has_kb_input && kb_input.key != .None { 27 | t.move_cursor(&s, 2, 0) 28 | t.writef(&s, "%v", kb_input) 29 | } 30 | 31 | mouse_input, has_mouse_input := t.parse_mouse_input(input) 32 | if has_mouse_input { 33 | t.move_cursor(&s, 4, 0) 34 | t.writef(&s, "%v", mouse_input) 35 | } 36 | 37 | if kb_input.key == .Escape do break 38 | } 39 | } 40 | 41 | -------------------------------------------------------------------------------- /examples/window/main.odin: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import t "../.." 4 | 5 | main :: proc() { 6 | s := t.init_screen() 7 | defer t.destroy_screen(&s) 8 | t.set_term_mode(&s, .Raw) 9 | t.hide_cursor(true) 10 | 11 | termsize := t.get_term_size(&s) 12 | 13 | msg := "Use WASD or arrow keys to move window and 'q' to quit." 14 | window := t.init_window(termsize.h / 2, termsize.w / 2 - len(msg) / 2, 3, (uint)(len(msg) + 2)) 15 | defer t.destroy_window(&window) 16 | 17 | main_loop: for { 18 | t.clear(&window, .Everything) 19 | defer t.blit(&window) 20 | 21 | t.clear(&s, .Everything) 22 | defer t.blit(&s) 23 | 24 | t.set_color_style_8(&window, .Black, .White) 25 | // this proves that no matter what the window will never be overflowed by moving the cursor 26 | for i in 0 ..= 10 { 27 | t.move_cursor(&window, cast(uint)i, 1) 28 | t.write_string(&window, msg) 29 | } 30 | t.reset_styles(&window) 31 | 32 | input := t.read(&s) or_continue 33 | kb_input := t.parse_keyboard_input(input) or_continue 34 | 35 | #partial switch kb_input.key { 36 | case .Arrow_Left, .A: 37 | window.x_offset -= 1 38 | case .Arrow_Right, .D: 39 | window.x_offset += 1 40 | case .Arrow_Up, .W: 41 | window.y_offset -= 1 42 | case .Arrow_Down, .S: 43 | window.y_offset += 1 44 | 45 | case .Q: 46 | break main_loop 47 | } 48 | 49 | } 50 | } 51 | 52 | -------------------------------------------------------------------------------- /input.odin: -------------------------------------------------------------------------------- 1 | package termcl 2 | 3 | import "core:fmt" 4 | import "core:os" 5 | import "core:strconv" 6 | import "core:unicode" 7 | 8 | Input :: distinct []byte 9 | 10 | Key :: enum { 11 | None, 12 | Arrow_Left, 13 | Arrow_Right, 14 | Arrow_Up, 15 | Arrow_Down, 16 | Page_Up, 17 | Page_Down, 18 | Home, 19 | End, 20 | Insert, 21 | Delete, 22 | F1, 23 | F2, 24 | F3, 25 | F4, 26 | F5, 27 | F6, 28 | F7, 29 | F8, 30 | F9, 31 | F10, 32 | F11, 33 | F12, 34 | Escape, 35 | Num_0, 36 | Num_1, 37 | Num_2, 38 | Num_3, 39 | Num_4, 40 | Num_5, 41 | Num_6, 42 | Num_7, 43 | Num_8, 44 | Num_9, 45 | Enter, 46 | Tab, 47 | Backspace, 48 | A, 49 | B, 50 | C, 51 | D, 52 | E, 53 | F, 54 | G, 55 | H, 56 | I, 57 | J, 58 | K, 59 | L, 60 | M, 61 | N, 62 | O, 63 | P, 64 | Q, 65 | R, 66 | S, 67 | T, 68 | U, 69 | V, 70 | W, 71 | X, 72 | Y, 73 | Z, 74 | Minus, 75 | Plus, 76 | Equal, 77 | Open_Paren, 78 | Close_Paren, 79 | Open_Curly_Bracket, 80 | Close_Curly_Bracket, 81 | Open_Square_Bracket, 82 | Close_Square_Bracket, 83 | Colon, 84 | Semicolon, 85 | Slash, 86 | Backslash, 87 | Single_Quote, 88 | Double_Quote, 89 | Period, 90 | Asterisk, 91 | Backtick, 92 | Space, 93 | Dollar, 94 | Exclamation, 95 | Hash, 96 | Percent, 97 | Ampersand, 98 | Tick, 99 | Underscore, 100 | Caret, 101 | Comma, 102 | Pipe, 103 | At, 104 | Tilde, 105 | } 106 | 107 | Mod :: enum { 108 | None, 109 | Alt, 110 | Ctrl, 111 | Shift, 112 | } 113 | 114 | Input_Seq :: struct { 115 | mod: Mod, 116 | key: Key, 117 | } 118 | 119 | // Parses the raw bytes sent by the terminal in `Input` and returns an input sequence 120 | // If there's no valid keyboard input, `has_input` is false 121 | // 122 | // Note: the terminal processes some inputs making them be treated the same so if you try to 123 | // parse an input and find that it's not being detected, check what value it is processed into. 124 | // Example: Escape might be Esc, Ctrl + [ and Ctrl + 3 125 | parse_keyboard_input :: proc(input: Input) -> (keyboard_input: Input_Seq, has_input: bool) { 126 | input := input 127 | seq: Input_Seq 128 | 129 | if len(input) == 0 do return 130 | 131 | if len(input) == 1 { 132 | input_rune := cast(rune)input[0] 133 | if unicode.is_upper(input_rune) { 134 | seq.mod = .Shift 135 | } 136 | 137 | if unicode.is_control(input_rune) { 138 | switch input_rune { 139 | case '\r', 140 | '\n', 141 | '\b', 142 | 127, /* backspace */ 143 | '\t', 144 | '\x1b': 145 | case: 146 | seq.mod = .Ctrl 147 | input[0] += 64 148 | } 149 | } 150 | 151 | switch input[0] { 152 | case '\x1b': 153 | seq.key = .Escape 154 | case '1': 155 | seq.key = .Num_1 156 | case '2': 157 | seq.key = .Num_2 158 | case '3': 159 | seq.key = .Num_3 160 | case '4': 161 | seq.key = .Num_4 162 | case '5': 163 | seq.key = .Num_5 164 | case '6': 165 | seq.key = .Num_6 166 | case '7': 167 | seq.key = .Num_7 168 | case '8': 169 | seq.key = .Num_8 170 | case '9': 171 | seq.key = .Num_9 172 | case '0': 173 | seq.key = .Num_0 174 | case '\r', '\n': 175 | seq.key = .Enter 176 | case '\t': 177 | seq.key = .Tab 178 | case 8, 127: 179 | seq.key = .Backspace 180 | case 'a', 'A': 181 | seq.key = .A 182 | case 'b', 'B': 183 | seq.key = .B 184 | case 'c', 'C': 185 | seq.key = .C 186 | case 'd', 'D': 187 | seq.key = .D 188 | case 'e', 'E': 189 | seq.key = .E 190 | case 'f', 'F': 191 | seq.key = .F 192 | case 'g', 'G': 193 | seq.key = .G 194 | case 'h', 'H': 195 | seq.key = .H 196 | case 'i', 'I': 197 | seq.key = .I 198 | case 'j', 'J': 199 | seq.key = .J 200 | case 'k', 'K': 201 | seq.key = .K 202 | case 'l', 'L': 203 | seq.key = .L 204 | case 'm', 'M': 205 | seq.key = .M 206 | case 'n', 'N': 207 | seq.key = .N 208 | case 'o', 'O': 209 | seq.key = .O 210 | case 'p', 'P': 211 | seq.key = .P 212 | case 'q', 'Q': 213 | seq.key = .Q 214 | case 'r', 'R': 215 | seq.key = .R 216 | case 's', 'S': 217 | seq.key = .S 218 | case 't', 'T': 219 | seq.key = .T 220 | case 'u', 'U': 221 | seq.key = .U 222 | case 'v', 'V': 223 | seq.key = .V 224 | case 'w', 'W': 225 | seq.key = .W 226 | case 'x', 'X': 227 | seq.key = .X 228 | case 'y', 'Y': 229 | seq.key = .Y 230 | case 'z', 'Z': 231 | seq.key = .Z 232 | case ',': 233 | seq.key = .Comma 234 | case ':': 235 | seq.key = .Colon 236 | case ';': 237 | seq.key = .Semicolon 238 | case '-': 239 | seq.key = .Minus 240 | case '+': 241 | seq.key = .Plus 242 | case '=': 243 | seq.key = .Equal 244 | case '{': 245 | seq.key = .Open_Curly_Bracket 246 | case '}': 247 | seq.key = .Close_Curly_Bracket 248 | case '(': 249 | seq.key = .Open_Paren 250 | case ')': 251 | seq.key = .Close_Paren 252 | case '[': 253 | seq.key = .Open_Square_Bracket 254 | case ']': 255 | seq.key = .Close_Square_Bracket 256 | case '/': 257 | seq.key = .Slash 258 | case '\'': 259 | seq.key = .Single_Quote 260 | case '"': 261 | seq.key = .Double_Quote 262 | case '.': 263 | seq.key = .Period 264 | case '*': 265 | seq.key = .Asterisk 266 | case '`': 267 | seq.key = .Backtick 268 | case '\\': 269 | seq.key = .Backslash 270 | case ' ': 271 | seq.key = .Space 272 | case '$': 273 | seq.key = .Dollar 274 | case '!': 275 | seq.key = .Exclamation 276 | case '#': 277 | seq.key = .Hash 278 | case '%': 279 | seq.key = .Percent 280 | case '&': 281 | seq.key = .Ampersand 282 | case '´': 283 | seq.key = .Tick 284 | case '_': 285 | seq.key = .Underscore 286 | case '^': 287 | seq.key = .Caret 288 | case '|': 289 | seq.key = .Pipe 290 | case '@': 291 | seq.key = .At 292 | case '~': 293 | seq.key = .Tilde 294 | case: 295 | return 296 | } 297 | 298 | return seq, true 299 | } 300 | 301 | if input[0] != '\x1b' do return 302 | 303 | if input[1] == 10 { 304 | seq.mod = .Alt 305 | seq.key = .Enter 306 | return seq, true 307 | } 308 | 309 | if len(input) > 3 { 310 | input_len := len(input) 311 | 312 | if input[input_len - 3] == ';' { 313 | switch input[input_len - 2] { 314 | case '2': 315 | seq.mod = .Shift 316 | case '3': 317 | seq.mod = .Alt 318 | case '5': 319 | seq.mod = .Ctrl 320 | 321 | } 322 | } 323 | } 324 | 325 | if len(input) >= 2 { 326 | switch input[len(input) - 1] { 327 | case 'P': 328 | seq.key = .F1 329 | case 'Q': 330 | seq.key = .F2 331 | case 'R': 332 | seq.key = .F3 333 | case 'S': 334 | seq.key = .F4 335 | 336 | } 337 | 338 | if input[1] == 'O' do return seq, true 339 | } 340 | 341 | if input[1] == '[' { 342 | input = input[2:] 343 | 344 | if len(input) > 2 && input[0] == '1' && input[1] == ';' { 345 | switch input[2] { 346 | case '2': 347 | seq.mod = .Shift 348 | case '3': 349 | seq.mod = .Alt 350 | case '5': 351 | seq.mod = .Ctrl 352 | } 353 | 354 | input = input[3:] 355 | } 356 | 357 | 358 | if len(input) == 1 { 359 | switch input[0] { 360 | case 'H': 361 | seq.key = .Home 362 | case 'F': 363 | seq.key = .End 364 | case 'A': 365 | seq.key = .Arrow_Up 366 | case 'B': 367 | seq.key = .Arrow_Down 368 | case 'C': 369 | seq.key = .Arrow_Right 370 | case 'D': 371 | seq.key = .Arrow_Left 372 | case 'Z': 373 | seq.key = .Tab 374 | seq.mod = .Shift 375 | } 376 | } 377 | 378 | 379 | if len(input) >= 2 { 380 | switch input[0] { 381 | case 'O': 382 | switch input[1] { 383 | case 'H': 384 | seq.key = .Home 385 | case 'F': 386 | seq.key = .End 387 | } 388 | case '1': 389 | switch input[1] { 390 | case 'P': 391 | seq.key = .F1 392 | case 'Q': 393 | seq.key = .F2 394 | case 'R': 395 | seq.key = .F3 396 | case 'S': 397 | seq.key = .F4 398 | } 399 | } 400 | } 401 | 402 | 403 | if input[len(input) - 1] == '~' { 404 | switch input[0] { 405 | case '1', '7': 406 | seq.key = .Home 407 | case '2': 408 | seq.key = .Insert 409 | case '3': 410 | seq.key = .Delete 411 | case '4', '8': 412 | seq.key = .End 413 | case '5': 414 | seq.key = .Page_Up 415 | case '6': 416 | seq.key = .Page_Down 417 | } 418 | 419 | switch input[0] { 420 | case '1': 421 | switch input[1] { 422 | case '1': 423 | seq.key = .F1 424 | case '2': 425 | seq.key = .F2 426 | case '3': 427 | seq.key = .F3 428 | case '4': 429 | seq.key = .F4 430 | case '5': 431 | seq.key = .F5 432 | case '7': 433 | seq.key = .F6 434 | case '8': 435 | seq.key = .F7 436 | case '9': 437 | seq.key = .F8 438 | } 439 | 440 | case '2': 441 | switch input[1] { 442 | case '0': 443 | seq.key = .F9 444 | case '1': 445 | seq.key = .F10 446 | case '3': 447 | seq.key = .F11 448 | case '4': 449 | seq.key = .F12 450 | } 451 | } 452 | } 453 | 454 | return seq, true 455 | } 456 | 457 | return 458 | } 459 | 460 | Mouse_Event :: enum { 461 | Pressed, 462 | Released, 463 | } 464 | 465 | Mouse_Key :: enum { 466 | None, 467 | Left, 468 | Middle, 469 | Right, 470 | Scroll_Up, 471 | Scroll_Down, 472 | } 473 | 474 | Mouse_Input :: struct { 475 | event: bit_set[Mouse_Event], 476 | mod: bit_set[Mod], 477 | key: Mouse_Key, 478 | coord: Cursor_Position, 479 | } 480 | 481 | // Parses the raw bytes sent by the terminal in `Input` and returns an input sequence 482 | // If there's no valid mouse input, `has_input` is false 483 | parse_mouse_input :: proc(input: Input) -> (mouse_input: Mouse_Input, has_input: bool) { 484 | if len(input) < 6 do return 485 | 486 | if input[0] != '\x1b' && input[1] != '[' && input[2] != '<' do return 487 | 488 | consume_semicolon :: proc(input: ^string) -> bool { 489 | is_semicolon := len(input) >= 1 && input[0] == ';' 490 | if is_semicolon do input^ = input[1:] 491 | return is_semicolon 492 | } 493 | 494 | consumed: int 495 | input := cast(string)input[3:] 496 | 497 | mod, _ := strconv.parse_uint(input, n = &consumed) 498 | input = input[consumed:] 499 | consume_semicolon(&input) or_return 500 | 501 | x_coord, _ := strconv.parse_uint(input, n = &consumed) 502 | input = input[consumed:] 503 | consume_semicolon(&input) or_return 504 | 505 | y_coord, _ := strconv.parse_uint(input, n = &consumed) 506 | input = input[consumed:] 507 | 508 | mouse_event: bit_set[Mouse_Event] 509 | if input[0] == 'm' do mouse_event |= {.Released} 510 | if input[0] == 'M' do mouse_event |= {.Pressed} 511 | 512 | mouse_key: Mouse_Key 513 | low_two_bits := mod & 0b11 514 | switch low_two_bits { 515 | case 0: 516 | mouse_key = .Left 517 | case 1: 518 | mouse_key = .Middle 519 | case 2: 520 | mouse_key = .Right 521 | } 522 | 523 | next_three_bits := mod & 0b11100 524 | mouse_mod: bit_set[Mod] 525 | if next_three_bits & 4 == 4 do mouse_mod |= {.Shift} 526 | if next_three_bits & 8 == 8 do mouse_mod |= {.Alt} 527 | if next_three_bits & 16 == 16 do mouse_mod |= {.Ctrl} 528 | 529 | if mod & 64 == 64 do mouse_key = .Scroll_Up 530 | if mod & 65 == 65 do mouse_key = .Scroll_Down 531 | 532 | return Mouse_Input { 533 | event = mouse_event, 534 | mod = mouse_mod, 535 | key = mouse_key, 536 | // coords are converted so it's 0 based index 537 | coord = {x = x_coord - 1, y = y_coord - 1}, 538 | }, true 539 | } 540 | 541 | -------------------------------------------------------------------------------- /platform_dependent.odin: -------------------------------------------------------------------------------- 1 | package termcl 2 | 3 | import "base:runtime" 4 | import "core:c" 5 | import "core:fmt" 6 | import "core:os" 7 | import "core:strings" 8 | import "core:sys/linux" 9 | import "core:sys/posix" 10 | import "core:sys/windows" 11 | 12 | VALID_POSIX_OSES :: bit_set[runtime.Odin_OS_Type] { 13 | .Linux, 14 | .Haiku, 15 | .Darwin, 16 | .FreeBSD, 17 | .NetBSD, 18 | .OpenBSD, 19 | } 20 | 21 | when ODIN_OS in VALID_POSIX_OSES { 22 | Terminal_State :: struct { 23 | state: posix.termios, 24 | } 25 | } else when ODIN_OS == .Windows { 26 | Terminal_State :: struct { 27 | mode: windows.DWORD, 28 | input_codepage: windows.CODEPAGE, 29 | input_mode: windows.DWORD, 30 | output_codepage: windows.CODEPAGE, 31 | output_mode: windows.DWORD, 32 | } 33 | } else { 34 | Terminal_State :: struct {} 35 | } 36 | 37 | @(private) 38 | get_terminal_state :: proc() -> (Terminal_State, bool) { 39 | when ODIN_OS in VALID_POSIX_OSES { 40 | termstate: posix.termios 41 | ok := posix.tcgetattr(posix.STDIN_FILENO, &termstate) == .OK 42 | return Terminal_State{state = termstate}, ok 43 | } else when ODIN_OS == .Windows { 44 | termstate: Terminal_State 45 | windows.GetConsoleMode(windows.HANDLE(os.stdout), &termstate.output_mode) 46 | termstate.output_codepage = windows.GetConsoleOutputCP() 47 | 48 | windows.GetConsoleMode(windows.HANDLE(os.stdin), &termstate.input_mode) 49 | termstate.input_codepage = windows.GetConsoleCP() 50 | 51 | return termstate, true 52 | } else { 53 | return {}, false 54 | } 55 | } 56 | 57 | @(private) 58 | change_terminal_mode :: proc(screen: ^Screen, mode: Term_Mode) { 59 | termstate, ok := get_terminal_state() 60 | if !ok { 61 | panic("failed to get terminal state") 62 | } 63 | 64 | when ODIN_OS in VALID_POSIX_OSES { 65 | raw := termstate.state 66 | 67 | switch mode { 68 | case .Raw: 69 | raw.c_lflag -= {.ECHO, .ICANON, .ISIG, .IEXTEN} 70 | raw.c_iflag -= {.ICRNL, .IXON} 71 | raw.c_oflag -= {.OPOST} 72 | 73 | // probably meaningless on modern terminals but apparently it's good practice 74 | raw.c_iflag -= {.BRKINT, .INPCK, .ISTRIP} 75 | raw.c_cflag |= {.CS8} 76 | 77 | case .Cbreak: 78 | raw.c_lflag -= {.ECHO, .ICANON} 79 | 80 | case .Restored: 81 | raw = screen.original_termstate.state 82 | } 83 | 84 | if posix.tcsetattr(posix.STDIN_FILENO, .TCSAFLUSH, &raw) != .OK { 85 | panic("failed to set new terminal state") 86 | } 87 | } else when ODIN_OS == .Windows { 88 | switch mode { 89 | case .Raw: 90 | termstate.output_mode |= windows.DISABLE_NEWLINE_AUTO_RETURN 91 | termstate.output_mode |= windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING 92 | termstate.output_mode |= windows.ENABLE_PROCESSED_OUTPUT 93 | 94 | termstate.input_mode &= ~windows.ENABLE_PROCESSED_INPUT 95 | termstate.input_mode &= ~windows.ENABLE_ECHO_INPUT 96 | termstate.input_mode &= ~windows.ENABLE_LINE_INPUT 97 | termstate.input_mode |= windows.ENABLE_VIRTUAL_TERMINAL_INPUT 98 | 99 | case .Cbreak: 100 | termstate.output_mode |= windows.ENABLE_PROCESSED_OUTPUT 101 | termstate.output_mode |= windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING 102 | 103 | termstate.input_mode |= windows.ENABLE_VIRTUAL_TERMINAL_INPUT 104 | termstate.input_mode &= ~windows.ENABLE_LINE_INPUT 105 | termstate.input_mode &= ~windows.ENABLE_ECHO_INPUT 106 | 107 | case .Restored: 108 | termstate = screen.original_termstate 109 | } 110 | 111 | if !windows.SetConsoleMode(windows.HANDLE(os.stdout), termstate.output_mode) || 112 | !windows.SetConsoleMode(windows.HANDLE(os.stdin), termstate.input_mode) { 113 | panic("failed to set new terminal state") 114 | } 115 | 116 | if mode != .Restored { 117 | windows.SetConsoleOutputCP(.UTF8) 118 | windows.SetConsoleCP(.UTF8) 119 | } else { 120 | windows.SetConsoleOutputCP(termstate.output_codepage) 121 | windows.SetConsoleCP(termstate.input_codepage) 122 | } 123 | 124 | } 125 | } 126 | 127 | get_term_size_via_syscall :: proc() -> (Screen_Size, bool) { 128 | when ODIN_OS == .Linux { 129 | winsize :: struct { 130 | ws_row, ws_col: c.ushort, 131 | ws_xpixel, ws_ypixel: c.ushort, 132 | } 133 | 134 | // right now this is supported by all odin platforms 135 | // but there's a few platforms that have a different value 136 | // check: https://github.com/search?q=repo%3Atorvalds%2Flinux%20TIOCGWINSZ&type=code 137 | TIOCGWINSZ :: 0x5413 138 | 139 | w: winsize 140 | if linux.ioctl(linux.STDOUT_FILENO, TIOCGWINSZ, cast(uintptr)&w) != 0 do return {}, false 141 | 142 | win := Screen_Size { 143 | h = uint(w.ws_row), 144 | w = uint(w.ws_col), 145 | } 146 | 147 | return win, true 148 | } else when ODIN_OS == .Windows { 149 | sbi: windows.CONSOLE_SCREEN_BUFFER_INFO 150 | 151 | if !windows.GetConsoleScreenBufferInfo(windows.HANDLE(os.stdout), &sbi) { 152 | return {}, false 153 | } 154 | 155 | screen_size := Screen_Size { 156 | w = uint(sbi.srWindow.Right - sbi.srWindow.Left) + 1, 157 | h = uint(sbi.srWindow.Bottom - sbi.srWindow.Top) + 1, 158 | } 159 | 160 | return screen_size, true 161 | } else { 162 | return {}, false 163 | } 164 | } 165 | 166 | // Reads input from the terminal. 167 | // The read blocks execution until a value is read. 168 | // If you want it to not block, use `read` instead. 169 | read_blocking :: proc(screen: ^Screen) -> (user_input: Input, has_input: bool) { 170 | bytes_read, err := os.read_ptr(os.stdin, &screen.input_buf, len(screen.input_buf)) 171 | if err != nil { 172 | fmt.eprintln("failing to get user input") 173 | os.exit(1) 174 | } 175 | 176 | return Input(screen.input_buf[:bytes_read]), bytes_read > 0 177 | } 178 | 179 | 180 | // Reads input from the terminal 181 | // Reading is nonblocking, if you want it to block, use `read_blocking` 182 | // 183 | // The Input returned is a slice of bytes returned from the terminal. 184 | // If you want to read a single character, you could just handle it directly without 185 | // having to parse the input. 186 | // 187 | // example: 188 | // ```odin 189 | // input := read(&screen) 190 | // if len(input) == 1 do switch input[0] { 191 | // case 'a': 192 | // case 'b': 193 | // } 194 | // ``` 195 | read :: proc(screen: ^Screen) -> (user_input: Input, has_input: bool) { 196 | when ODIN_OS in VALID_POSIX_OSES { 197 | stdin_pollfd := posix.pollfd { 198 | fd = posix.STDIN_FILENO, 199 | events = {.IN}, 200 | } 201 | 202 | if posix.poll(&stdin_pollfd, 1, 8) > 0 { 203 | return read_blocking(screen) 204 | } 205 | } else when ODIN_OS == .Windows { 206 | num_events: u32 207 | if !windows.GetNumberOfConsoleInputEvents(windows.HANDLE(os.stdin), &num_events) { 208 | error_id := windows.GetLastError() 209 | error_msg: ^u16 210 | 211 | strsize := windows.FormatMessageW( 212 | windows.FORMAT_MESSAGE_ALLOCATE_BUFFER | 213 | windows.FORMAT_MESSAGE_FROM_SYSTEM | 214 | windows.FORMAT_MESSAGE_IGNORE_INSERTS, 215 | nil, 216 | error_id, 217 | windows.MAKELANGID(windows.LANG_NEUTRAL, windows.SUBLANG_DEFAULT), 218 | cast(^u16)&error_msg, 219 | 0, 220 | nil, 221 | ) 222 | windows.WriteConsoleW(windows.HANDLE(os.stdout), error_msg, strsize, nil, nil) 223 | windows.LocalFree(error_msg) 224 | panic("Failed to get console input events") 225 | } 226 | 227 | if num_events > 0 { 228 | return read_blocking(screen) 229 | } 230 | } else { 231 | #panic("nonblocking read is not supported in the target platform") 232 | } 233 | 234 | return 235 | } 236 | 237 | -------------------------------------------------------------------------------- /termcl.odin: -------------------------------------------------------------------------------- 1 | package termcl 2 | 3 | import "base:runtime" 4 | import "core:encoding/ansi" 5 | import "core:fmt" 6 | import "core:mem" 7 | import "core:mem/virtual" 8 | import "core:os" 9 | import "core:strconv" 10 | import "core:strings" 11 | import "core:unicode/utf8" 12 | 13 | // Sends instructions to terminal 14 | blit :: proc(win: $T/^Window) { 15 | fmt.print(strings.to_string(win.seq_builder)) 16 | strings.builder_reset(&win.seq_builder) 17 | os.flush(os.stdout) 18 | } 19 | 20 | Window :: struct { 21 | allocator: runtime.Allocator, 22 | seq_builder: strings.Builder, 23 | y_offset, x_offset: uint, 24 | width, height: Maybe(uint), 25 | cursor: Cursor_Position, 26 | } 27 | 28 | // you should never init a window with size zero unless you're going to assign the sizes later. 29 | // using a window with width and height of zero will result in a division by zero 30 | init_window :: proc( 31 | y, x: uint, 32 | height, width: Maybe(uint), 33 | allocator := context.allocator, 34 | ) -> Window { 35 | return Window { 36 | seq_builder = strings.builder_make(allocator = allocator), 37 | y_offset = y, 38 | x_offset = x, 39 | height = height, 40 | width = width, 41 | } 42 | } 43 | 44 | destroy_window :: proc(win: ^Window) { 45 | strings.builder_destroy(&win.seq_builder) 46 | } 47 | 48 | // Screen is a special derivate of Window, so it can mostly be used anywhere a Window can be used 49 | Screen :: struct { 50 | using winbuf: Window, 51 | original_termstate: Terminal_State, 52 | input_buf: [512]byte, 53 | } 54 | 55 | // Initializes screen and saves terminal state 56 | init_screen :: proc(allocator := context.allocator) -> Screen { 57 | context.allocator = allocator 58 | 59 | termstate, ok := get_terminal_state() 60 | if !ok { 61 | panic("failed to get terminal state") 62 | } 63 | 64 | return Screen { 65 | original_termstate = termstate, 66 | winbuf = init_window(0, 0, nil, nil, allocator = allocator), 67 | } 68 | } 69 | 70 | // Restores terminal settings and does necessary memory cleanup 71 | destroy_screen :: proc(screen: ^Screen) { 72 | set_term_mode(screen, .Restored) 73 | destroy_window(&screen.winbuf) 74 | fmt.print("\x1b[?1049l") 75 | } 76 | 77 | // converts coordinates from window coordinates to the global terminal coordinate 78 | global_coord_from_window :: proc(win: $T/^Window, y, x: uint) -> Cursor_Position { 79 | cursor_pos := Cursor_Position { 80 | x = x, 81 | y = y, 82 | } 83 | 84 | when type_of(win) == ^Screen { 85 | term_size := get_term_size(win) 86 | height := term_size.h 87 | width := term_size.w 88 | } else { 89 | height, h_ok := win.height.? 90 | width, w_ok := win.width.? 91 | 92 | if !w_ok && !h_ok { 93 | return cursor_pos 94 | } 95 | } 96 | 97 | cursor_pos.y = (y % height) + win.y_offset 98 | cursor_pos.x = (x % width) + win.x_offset 99 | return cursor_pos 100 | } 101 | 102 | // converts coordinates from global coordinates to window coordinates 103 | window_coord_from_global :: proc( 104 | win: ^Window, 105 | y, x: uint, 106 | ) -> ( 107 | cursor_pos: Cursor_Position, 108 | in_window: bool, 109 | ) { 110 | height, h_ok := win.height.? 111 | width, w_ok := win.width.? 112 | 113 | if !w_ok && !h_ok { 114 | return 115 | } 116 | 117 | if y < win.y_offset || y >= win.y_offset + height { 118 | return 119 | } 120 | 121 | if x < win.x_offset || x >= win.x_offset + width { 122 | return 123 | } 124 | 125 | cursor_pos.y = (y - win.y_offset) % height 126 | cursor_pos.x = (x - win.x_offset) % width 127 | in_window = true 128 | return 129 | } 130 | 131 | // Changes the cursor's absolute position 132 | move_cursor :: proc(win: $T/^Window, y, x: uint) { 133 | win.cursor = { 134 | x = x, 135 | y = y, 136 | } 137 | 138 | global_cursor_pos := global_coord_from_window(win, y, x) 139 | CURSOR_POSITION :: ansi.CSI + "%d;%dH" 140 | strings.write_string(&win.seq_builder, ansi.CSI) 141 | // x and y are shifted by one position so that programmers can keep using 0 based indexing 142 | strings.write_uint(&win.seq_builder, global_cursor_pos.y + 1) 143 | strings.write_rune(&win.seq_builder, ';') 144 | strings.write_uint(&win.seq_builder, global_cursor_pos.x + 1) 145 | strings.write_rune(&win.seq_builder, 'H') 146 | } 147 | 148 | Text_Style :: enum { 149 | Bold, 150 | Italic, 151 | Underline, 152 | Crossed, 153 | Inverted, 154 | Dim, 155 | } 156 | 157 | // Hides the terminal cursor 158 | hide_cursor :: proc(hide: bool) { 159 | SHOW_CURSOR :: ansi.CSI + "?25h" 160 | HIDE_CURSOR :: ansi.CSI + "?25l" 161 | fmt.print(HIDE_CURSOR if hide else SHOW_CURSOR) 162 | } 163 | 164 | set_text_style :: proc(win: $T/^Window, styles: bit_set[Text_Style]) { 165 | SGR_BOLD :: ansi.CSI + ansi.BOLD + "m" 166 | SGR_DIM :: ansi.CSI + ansi.FAINT + "m" 167 | SGR_ITALIC :: ansi.CSI + ansi.ITALIC + "m" 168 | SGR_UNDERLINE :: ansi.CSI + ansi.UNDERLINE + "m" 169 | SGR_INVERTED :: ansi.CSI + ansi.INVERT + "m" 170 | SGR_CROSSED :: ansi.CSI + ansi.STRIKE + "m" 171 | 172 | if .Bold in styles do strings.write_string(&win.seq_builder, SGR_BOLD) 173 | if .Dim in styles do strings.write_string(&win.seq_builder, SGR_DIM) 174 | if .Italic in styles do strings.write_string(&win.seq_builder, SGR_ITALIC) 175 | if .Underline in styles do strings.write_string(&win.seq_builder, SGR_UNDERLINE) 176 | if .Inverted in styles do strings.write_string(&win.seq_builder, SGR_INVERTED) 177 | if .Crossed in styles do strings.write_string(&win.seq_builder, SGR_CROSSED) 178 | } 179 | 180 | Color_8 :: enum { 181 | Black, 182 | Red, 183 | Green, 184 | Yellow, 185 | Blue, 186 | Magenta, 187 | Cyan, 188 | White, 189 | } 190 | 191 | // Sets background and foreground colors based on the original 8 color palette 192 | set_color_style_8 :: proc(win: $T/^Window, fg: Maybe(Color_8), bg: Maybe(Color_8)) { 193 | get_color_code :: proc(c: Color_8, is_bg: bool) -> uint { 194 | code: uint 195 | switch c { 196 | case .Black: 197 | code = 30 198 | case .Red: 199 | code = 31 200 | case .Green: 201 | code = 32 202 | case .Yellow: 203 | code = 33 204 | case .Blue: 205 | code = 34 206 | case .Magenta: 207 | code = 35 208 | case .Cyan: 209 | code = 36 210 | case .White: 211 | code = 37 212 | } 213 | 214 | if is_bg do code += 10 215 | return code 216 | } 217 | 218 | SGR_COLOR :: ansi.CSI + "%dm" 219 | set_color :: proc(builder: ^strings.Builder, color: uint) { 220 | strings.write_string(builder, ansi.CSI) 221 | strings.write_uint(builder, color) 222 | strings.write_rune(builder, 'm') 223 | } 224 | 225 | DEFAULT_FG :: 39 226 | DEFAULT_BG :: 49 227 | set_color(&win.seq_builder, get_color_code(fg.?, false) if fg != nil else DEFAULT_FG) 228 | set_color(&win.seq_builder, get_color_code(bg.?, true) if bg != nil else DEFAULT_BG) 229 | } 230 | 231 | RGB_Color :: struct { 232 | r, g, b: u8, 233 | } 234 | 235 | // Sets background and foreground colors based on the RGB values. 236 | // The terminal has to support true colors for it to work. 237 | set_color_style_rgb :: proc(win: $T/^Window, fg: Maybe(RGB_Color), bg: Maybe(RGB_Color)) { 238 | RGB_FG_COLOR :: ansi.CSI + "38;2;%d;%d;%dm" 239 | RGB_BG_COLOR :: ansi.CSI + "48;2;%d;%d;%dm" 240 | 241 | set_color :: proc(builder: ^strings.Builder, is_fg: bool, color: RGB_Color) { 242 | strings.write_string(builder, ansi.CSI) 243 | strings.write_uint(builder, 38 if is_fg else 48) 244 | strings.write_string(builder, ";2;") 245 | strings.write_uint(builder, cast(uint)color.r) 246 | strings.write_rune(builder, ';') 247 | strings.write_uint(builder, cast(uint)color.g) 248 | strings.write_rune(builder, ';') 249 | strings.write_uint(builder, cast(uint)color.b) 250 | strings.write_rune(builder, 'm') 251 | } 252 | 253 | fg_color, has_fg := fg.? 254 | bg_color, has_bg := bg.? 255 | 256 | if !has_fg || !has_bg { 257 | set_color_style_8(win, nil, nil) 258 | } 259 | 260 | if has_fg do set_color(&win.seq_builder, true, fg_color) 261 | if has_bg do set_color(&win.seq_builder, false, bg_color) 262 | 263 | } 264 | 265 | // Sets foreground and background colors 266 | set_color_style :: proc { 267 | set_color_style_8, 268 | set_color_style_rgb, 269 | } 270 | 271 | // Resets all styles previously set. 272 | // It is good practice to reset after being done with a style as to prevent styles to be applied erroneously. 273 | reset_styles :: proc(win: $T/^Window) { 274 | strings.write_string(&win.seq_builder, ansi.CSI + "0m") 275 | } 276 | 277 | Clear_Mode :: enum { 278 | Before_Cursor, 279 | After_Cursor, 280 | Everything, 281 | } 282 | 283 | // Clears screen starting from current line. 284 | clear :: proc(win: $T/^Window, mode: Clear_Mode) { 285 | height, h_ok := win.height.? 286 | width, w_ok := win.width.? 287 | 288 | if !h_ok && !w_ok do switch mode { 289 | case .After_Cursor: 290 | strings.write_string(&win.seq_builder, ansi.CSI + "0J") 291 | case .Before_Cursor: 292 | strings.write_string(&win.seq_builder, ansi.CSI + "1J") 293 | case .Everything: 294 | strings.write_string(&win.seq_builder, ansi.CSI + "H" + ansi.CSI + "2J") 295 | win.cursor = {0, 0} 296 | } 297 | else { 298 | // we compute the number of spaces required to clear a window and then 299 | // let the write_rune function take care of properly moving the cursor 300 | // through its own window isolation logic 301 | space_num: uint 302 | curr_pos := get_cursor_position(win) 303 | 304 | switch mode { 305 | case .After_Cursor: 306 | space_in_same_line := width - (win.cursor.x + 1) 307 | space_after_same_line := width * (height - ((win.cursor.y + 1) % height)) 308 | space_num = space_in_same_line + space_after_same_line 309 | move_cursor(win, curr_pos.y, curr_pos.x + 1) 310 | case .Before_Cursor: 311 | space_num = win.cursor.x + 1 + win.cursor.y * width 312 | move_cursor(win, 0, 0) 313 | case .Everything: 314 | space_num = (width + 1) * height 315 | move_cursor(win, 0, 0) 316 | } 317 | 318 | for i in 0 ..< space_num { 319 | write_rune(win, ' ') 320 | } 321 | 322 | move_cursor(win, curr_pos.y, curr_pos.x) 323 | 324 | } 325 | } 326 | 327 | // Only clears the line the cursor is in 328 | clear_line :: proc(win: $T/^Window, mode: Clear_Mode) { 329 | switch mode { 330 | case .After_Cursor: 331 | strings.write_string(&win.seq_builder, ansi.CSI + "0K") 332 | case .Before_Cursor: 333 | strings.write_string(&win.seq_builder, ansi.CSI + "1K") 334 | case .Everything: 335 | strings.write_string(&win.seq_builder, ansi.CSI + "2K") 336 | } 337 | } 338 | 339 | // Ring terminal bell. (potentially annoying to users :P) 340 | ring_bell :: proc() { 341 | fmt.print("\a") 342 | } 343 | 344 | // This is used internally to figure out and update where the cursor will be after a string is written to the terminal 345 | _get_cursor_pos_from_string :: proc(win: $T/^Window, str: string) -> [2]uint { 346 | calculate_cursor_pos :: proc( 347 | cursor: ^Cursor_Position, 348 | height, width: uint, 349 | str: string, 350 | ) -> [2]uint { 351 | new_pos := [2]uint{cursor.x, cursor.y} 352 | for r in str { 353 | if new_pos.y >= height && r == '\n' { 354 | new_pos.x = 0 355 | continue 356 | } 357 | 358 | if new_pos.x >= width || r == '\n' { 359 | new_pos.y += 1 360 | new_pos.x = 0 361 | } else { 362 | new_pos.x += 1 363 | } 364 | } 365 | return new_pos 366 | } 367 | 368 | when type_of(win) == ^Screen { 369 | term_size := get_term_size(win) 370 | height := term_size.h 371 | width := term_size.w 372 | return calculate_cursor_pos(&win.cursor, height, width, str) 373 | } else { 374 | height, h_ok := win.height.? 375 | width, w_ok := win.width.? 376 | 377 | if h_ok && w_ok { 378 | return calculate_cursor_pos(&win.cursor, height, width, str) 379 | } else { 380 | return [2]uint{win.cursor.x, win.cursor.y} 381 | } 382 | } 383 | } 384 | 385 | // Writes a rune to the terminal 386 | write_rune :: proc(win: $T/^Window, r: rune) { 387 | strings.write_rune(&win.seq_builder, r) 388 | // the new cursor position has to be calculated after writing the rune 389 | // otherwise the rune will be misplaced when blitted to terminal 390 | r_bytes, r_len := utf8.encode_rune(r) 391 | r_str := string(r_bytes[:r_len]) 392 | new_pos := _get_cursor_pos_from_string(win, r_str) 393 | move_cursor(win, new_pos.y, new_pos.x) 394 | } 395 | 396 | // Writes a string to the terminal 397 | write_string :: proc(win: $T/^Window, str: string) { 398 | when type_of(win) == ^Screen { 399 | term_size := get_term_size(win) 400 | win_width := term_size.w 401 | } else { 402 | win_width, w_ok := win.width.? 403 | if !w_ok { 404 | strings.write_string(&win.seq_builder, str) 405 | return 406 | } 407 | } 408 | 409 | // the string is written in chunks so that it doesn't overflow the 410 | // window in which it is contained 411 | str_slice_start: uint 412 | for str_slice_start < len(str) { 413 | chunk_len := win_width - win.cursor.x 414 | str_slice_end := str_slice_start + chunk_len 415 | if str_slice_end > cast(uint)len(str) { 416 | str_slice_end = len(str) 417 | } 418 | 419 | str_slice := str[str_slice_start:str_slice_end] 420 | strings.write_string(&win.seq_builder, str_slice) 421 | 422 | str_slice_start = str_slice_end 423 | 424 | new_pos := _get_cursor_pos_from_string(win, str_slice) 425 | win.cursor.x = new_pos.x 426 | win.cursor.y = new_pos.y 427 | // we try an empty string so that we can compute starting from the next character 428 | // that's going to be inserted 429 | new_pos = _get_cursor_pos_from_string(win, " ") 430 | move_cursor(win, new_pos.y, new_pos.x) 431 | } 432 | } 433 | 434 | // Write a formatted string to the terminal 435 | writef :: proc(win: $T/^Window, format: string, args: ..any) { 436 | str_builder: strings.Builder 437 | _, err := strings.builder_init(&str_builder, allocator = win.allocator) 438 | if err != nil { 439 | panic("Failed to get more memory for format string") 440 | } 441 | defer strings.builder_destroy(&str_builder) 442 | str := fmt.sbprintf(&str_builder, format, ..args) 443 | write_string(win, str) 444 | } 445 | 446 | // Write to the terminal 447 | write :: proc { 448 | write_string, 449 | write_rune, 450 | } 451 | 452 | 453 | Term_Mode :: enum { 454 | // Raw mode, prevents the terminal from preprocessing inputs 455 | Raw, 456 | // Restores the mode the terminal was in before program started 457 | Restored, 458 | // A sort of "soft" raw mode that allows interrupts to still work 459 | Cbreak, 460 | } 461 | 462 | // Change terminal mode. 463 | // 464 | // This changes how the terminal processes inputs. 465 | // By default the terminal will process inputs, preventing you to have full access to user input. 466 | // Changing the terminal mode will your program to process every input. 467 | set_term_mode :: proc(screen: ^Screen, mode: Term_Mode) { 468 | change_terminal_mode(screen, mode) 469 | 470 | #partial switch mode { 471 | case .Restored: 472 | // enables main screen buffer 473 | fmt.print("\x1b[?1049l") 474 | enable_mouse(false) 475 | 476 | case: 477 | // enables alternate screen buffer 478 | fmt.print("\x1b[?1049h") 479 | enable_mouse(true) 480 | } 481 | 482 | hide_cursor(false) 483 | // when changing modes some OSes (like windows) might put garbage that we don't care about 484 | // in stdin potentially causing nonblocking reads to block on the first read, so to avoid this, 485 | // stdin is always flushed when the mode is changed 486 | os.flush(os.stdin) 487 | } 488 | 489 | Screen_Size :: struct { 490 | h, w: uint, 491 | } 492 | 493 | // Get the terminal screen size. 494 | // 495 | // The width and height are measured in number of cells not pixels. 496 | // Aka the same value you use with `move_mouse` and other functions. 497 | get_term_size :: proc(screen: ^Screen) -> Screen_Size { 498 | win, ok := get_term_size_via_syscall() 499 | if ok do return win 500 | 501 | curr_pos := get_cursor_position(screen) 502 | 503 | MAX_CURSOR_POSITION :: ansi.CSI + "9999;9999H" 504 | fmt.print(MAX_CURSOR_POSITION) 505 | pos := get_cursor_position(screen) 506 | 507 | // restore cursor position 508 | fmt.printf(ansi.CSI + "%d;%dH", curr_pos.y, curr_pos.x) 509 | 510 | return Screen_Size{w = pos.x, h = pos.y} 511 | } 512 | 513 | // Enable mouse to be able to respond to mouse inputs. 514 | // 515 | // It's enabled by default. This is here so you can opt-out of it or control when to enable or disable it. 516 | enable_mouse :: proc(enable: bool) { 517 | ANY_EVENT :: "\x1b[?1003" 518 | SGR_MOUSE :: "\x1b[?1006" 519 | 520 | if enable { 521 | fmt.print(ANY_EVENT + "h", SGR_MOUSE + "h") 522 | } else { 523 | fmt.print(ANY_EVENT + "l", SGR_MOUSE + "l") 524 | } 525 | } 526 | 527 | Cursor_Position :: struct { 528 | y, x: uint, 529 | } 530 | 531 | // Get the current cursor position. 532 | get_cursor_position :: #force_inline proc(win: $T/^Window) -> Cursor_Position { 533 | return win.cursor 534 | } 535 | 536 | -------------------------------------------------------------------------------- /widgets/button.odin: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import t ".." 4 | 5 | Button_Style :: struct { 6 | bg, fg: Any_Color, 7 | text: bit_set[t.Text_Style], 8 | padding: uint, 9 | width, height: Maybe(uint), 10 | y, x: uint, 11 | } 12 | 13 | Button :: struct { 14 | _screen: ^t.Screen, 15 | _window: t.Window, 16 | _content_box: t.Window, 17 | content: string, 18 | style: Button_Style, 19 | } 20 | 21 | button_init :: proc(screen: ^t.Screen) -> Button { 22 | return Button { 23 | _screen = screen, 24 | _window = t.init_window(0, 0, 0, 0), 25 | _content_box = t.init_window(0, 0, 0, 0), 26 | } 27 | } 28 | 29 | button_destroy :: proc(btn: ^Button) { 30 | t.destroy_window(&btn._window) 31 | t.destroy_window(&btn._content_box) 32 | } 33 | 34 | _button_set_layout :: proc(btn: ^Button) { 35 | btn._window.width = btn.style.width 36 | btn._window.height = btn.style.height 37 | btn._window.x_offset = btn.style.x 38 | btn._window.y_offset = btn.style.y 39 | 40 | y_padding := btn.style.padding * 2 41 | x_padding := btn.style.padding * 4 42 | 43 | if btn.style.height == nil { 44 | btn._window.height = y_padding + 1 45 | btn._content_box.height = 1 46 | } else { 47 | btn._content_box.height = btn._window.height.? - y_padding 48 | } 49 | box_y_padding_to_center := (btn._window.height.? - btn._content_box.height.?) / 2 50 | btn._content_box.y_offset = btn._window.y_offset + box_y_padding_to_center 51 | 52 | if btn.style.width == nil { 53 | // for width padding is doubled because cursor bar is taller than it is wider 54 | btn._window.width = x_padding + len(btn.content) 55 | btn._content_box.width = cast(uint)len(btn.content) 56 | } else { 57 | btn._content_box.width = btn._window.width.? - x_padding 58 | } 59 | box_x_padding_to_center := (btn._window.width.? - btn._content_box.width.?) / 2 60 | btn._content_box.x_offset = btn._window.x_offset + box_x_padding_to_center 61 | } 62 | 63 | button_blit :: proc(btn: ^Button) { 64 | if len(btn.content) == 0 do return 65 | 66 | _button_set_layout(btn) 67 | if btn._window.width == nil || btn._window.width == 0 { 68 | return 69 | } 70 | if btn._window.height == nil || btn._window.height == 0 { 71 | return 72 | } 73 | 74 | defer { 75 | t.reset_styles(&btn._window) 76 | t.blit(&btn._window) 77 | t.reset_styles(&btn._content_box) 78 | t.blit(&btn._content_box) 79 | } 80 | 81 | set_any_color_style(&btn._window, btn.style.fg, btn.style.bg) 82 | t.set_text_style(&btn._window, btn.style.text) 83 | 84 | set_any_color_style(&btn._content_box, btn.style.fg, btn.style.bg) 85 | t.set_text_style(&btn._content_box, btn.style.text) 86 | 87 | t.clear(&btn._window, .Everything) 88 | t.move_cursor(&btn._content_box, 0, 0) 89 | t.write(&btn._content_box, btn.content) 90 | } 91 | 92 | button_hovered :: proc(btn: ^Button) -> bool { 93 | mouse_input := t.parse_mouse_input(t.Input(btn._screen.input_buf[:])) or_return 94 | _, in_window := t.window_coord_from_global( 95 | &btn._window, 96 | mouse_input.coord.y, 97 | mouse_input.coord.x, 98 | ) 99 | 100 | return in_window && mouse_input.key == .None 101 | } 102 | 103 | button_clicked :: proc(btn: ^Button) -> bool { 104 | // we avoid using button_hovered here so we don't have to parse input twice 105 | mouse_input := t.parse_mouse_input(t.Input(btn._screen.input_buf[:])) or_return 106 | _, in_window := t.window_coord_from_global( 107 | &btn._window, 108 | mouse_input.coord.y, 109 | mouse_input.coord.x, 110 | ) 111 | 112 | return in_window && .Pressed in mouse_input.event && mouse_input.key == .Left 113 | } 114 | 115 | -------------------------------------------------------------------------------- /widgets/common.odin: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import t ".." 4 | 5 | Any_Color :: union { 6 | t.RGB_Color, 7 | t.Color_8, 8 | } 9 | 10 | set_any_color_style :: proc(win: $T/^t.Window, fg: Any_Color, bg: Any_Color) { 11 | // sadly this is the only way I found to allow either RGB or 8 color palette colors 12 | // without having to make the user cast into union types 13 | fg_rgb, has_fg_rgb := fg.(t.RGB_Color) 14 | bg_rgb, has_bg_rgb := bg.(t.RGB_Color) 15 | 16 | fg_8, has_fg_8 := fg.(t.Color_8) 17 | bg_8, has_bg_8 := bg.(t.Color_8) 18 | 19 | if (has_fg_8 || has_bg_8) && (has_fg_rgb || has_bg_rgb) { 20 | panic("both fg and bg have to be the same color type or nil") 21 | } 22 | 23 | if has_fg_8 || has_bg_8 { 24 | t.set_color_style_8(win, fg_8 if fg != nil else nil, bg_8 if bg != nil else nil) 25 | } 26 | 27 | if has_fg_rgb || has_bg_rgb { 28 | t.set_color_style_rgb(win, fg_rgb if fg != nil else nil, bg_rgb if bg != nil else nil) 29 | } 30 | } 31 | 32 | -------------------------------------------------------------------------------- /widgets/panel.odin: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import t ".." 4 | 5 | Panel_Item_Position :: enum { 6 | None, 7 | Left, 8 | Right, 9 | Center, 10 | } 11 | 12 | Panel_Item :: struct { 13 | position: Panel_Item_Position, 14 | content: string, 15 | } 16 | 17 | Panel_Style :: struct { 18 | fg, bg: Any_Color, 19 | space_between: uint, 20 | } 21 | 22 | Panel :: struct { 23 | _screen: ^t.Screen, 24 | _window: t.Window, 25 | items: []Panel_Item, 26 | style: Panel_Style, 27 | } 28 | 29 | panel_init :: proc(screen: ^t.Screen) -> Panel { 30 | termsize := t.get_term_size(screen) 31 | return Panel { 32 | _screen = screen, 33 | _window = t.init_window(0, 0, 1, 0), 34 | style = {space_between = 2, fg = .Black, bg = .White}, 35 | } 36 | } 37 | 38 | _panel_set_layout :: proc(panel: ^Panel) { 39 | termsize := t.get_term_size(panel._screen) 40 | panel._window.y_offset = termsize.h 41 | panel._window.width = termsize.w 42 | } 43 | 44 | panel_destroy :: proc(panel: ^Panel) { 45 | t.destroy_window(&panel._window) 46 | } 47 | 48 | panel_blit :: proc(panel: ^Panel) { 49 | _panel_set_layout(panel) 50 | 51 | defer t.reset_styles(&panel._window) 52 | set_any_color_style(&panel._window, panel.style.fg, panel.style.bg) 53 | t.clear(&panel._window, .Everything) 54 | 55 | cursor_on_left := t.Cursor_Position { 56 | x = 1, 57 | y = 0, 58 | } 59 | cursor_on_right := t.Cursor_Position { 60 | x = panel._window.width.? - 1, 61 | y = 0, 62 | } 63 | 64 | t.move_cursor(&panel._window, 0, 1) 65 | center_items := make([dynamic]Panel_Item) 66 | defer delete(center_items) 67 | 68 | drawing_panel: for item in panel.items { 69 | switch item.position { 70 | case .None: 71 | continue drawing_panel 72 | 73 | case .Left: 74 | t.move_cursor(&panel._window, cursor_on_left.y, cursor_on_left.x) 75 | t.write(&panel._window, item.content) 76 | cursor_pos := t.get_cursor_position(&panel._window) 77 | cursor_pos.x += panel.style.space_between 78 | cursor_on_left = cursor_pos 79 | 80 | case .Right: 81 | t.move_cursor(&panel._window, cursor_on_right.y, cursor_on_right.x - len(item.content)) 82 | cursor_on_right.x -= len(item.content) + panel.style.space_between 83 | t.write(&panel._window, item.content) 84 | 85 | case .Center: 86 | append(¢er_items, item) 87 | } 88 | } 89 | 90 | // the space in between each item will always be num_of_items - 1 91 | center_items_width: uint = (len(center_items) - 1) * panel.style.space_between 92 | for item in center_items { 93 | center_items_width += len(item.content) 94 | } 95 | 96 | panel_width := panel._window.width.? 97 | t.move_cursor(&panel._window, 0, panel_width / 2 - center_items_width / 2) 98 | for item in center_items { 99 | t.write(&panel._window, item.content) 100 | cursor_pos := t.get_cursor_position(&panel._window) 101 | cursor_pos.x += panel.style.space_between 102 | t.move_cursor(&panel._window, cursor_pos.y, cursor_pos.x) 103 | } 104 | 105 | t.blit(&panel._window) 106 | } 107 | 108 | -------------------------------------------------------------------------------- /widgets/progress.odin: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import t ".." 4 | import "core:bytes" 5 | import "core:strconv" 6 | 7 | // TODO: implement color gradient in the future 8 | 9 | @(rodata) 10 | progression_bar := [?]rune{'▏', '▎', '▍', '▌', '▋', '▊', '▉'} 11 | 12 | Progress_Style :: struct { 13 | description_color: Any_Color, 14 | bg, fg: Any_Color, 15 | text: bit_set[t.Text_Style], 16 | width: Maybe(uint), 17 | y, x: uint, 18 | } 19 | 20 | Progress :: struct { 21 | _screen: ^t.Screen, 22 | _window: t.Window, 23 | max, curr: uint, 24 | description: string, 25 | style: Progress_Style, 26 | } 27 | 28 | progress_init :: proc(s: ^t.Screen) -> Progress { 29 | return Progress{_screen = s, _window = t.init_window(0, 0, 0, 0)} 30 | } 31 | 32 | progress_destroy :: proc(prog: ^Progress) { 33 | t.destroy_window(&prog._window) 34 | } 35 | 36 | progress_add :: proc(prog: ^Progress, done: uint) { 37 | if prog.curr < prog.max do prog.curr += done 38 | } 39 | 40 | progress_done :: proc(prog: ^Progress) -> bool { 41 | return prog.curr >= prog.max 42 | } 43 | 44 | // size of ` 100.0%` 45 | @(private) 46 | PROGRESS_PERCENT_SIZE :: 7 47 | 48 | // TODO: set height to 1 if no description and 2 if has desc 49 | // set width dependent on len(description) and prog.max 50 | _progress_set_layout :: proc(prog: ^Progress) { 51 | prog._window.height = 1 if len(prog.description) == 0 else 2 52 | 53 | progress_width: uint 54 | if prog.style.width != nil { 55 | progress_width = prog.style.width.? 56 | } else { 57 | DEFAULT_WIDTH :: 15 58 | progress_width = 59 | len(prog.description) if len(prog.description) > DEFAULT_WIDTH else DEFAULT_WIDTH 60 | } 61 | 62 | progress_width += PROGRESS_PERCENT_SIZE 63 | prog._window.width = progress_width 64 | 65 | prog._window.y_offset = prog.style.y 66 | prog._window.x_offset = prog.style.x 67 | } 68 | 69 | progress_blit :: proc(prog: ^Progress) { 70 | if prog.max == 0 do return 71 | _progress_set_layout(prog) 72 | t.clear(&prog._window, .Everything) 73 | t.move_cursor(&prog._window, 0, 0) 74 | 75 | percentage := cast(f64)prog.curr / cast(f64)prog.max 76 | curr_progress_width := uint(cast(f64)(prog._window.width.? - 8) * percentage) 77 | 78 | set_any_color_style(&prog._window, prog.style.fg, prog.style.fg) 79 | for i in 0 ..< curr_progress_width { 80 | t.write(&prog._window, progression_bar[len(progression_bar) - 1]) 81 | } 82 | 83 | total_progress_width := prog._window.width.? - PROGRESS_PERCENT_SIZE 84 | 85 | set_any_color_style(&prog._window, prog.style.bg, prog.style.bg) 86 | for i in 0 ..< total_progress_width - curr_progress_width - 1 { 87 | t.write(&prog._window, progression_bar[len(progression_bar) - 1]) 88 | } 89 | 90 | 91 | set_any_color_style(&prog._window, prog.style.fg, nil) 92 | t.move_cursor(&prog._window, 0, total_progress_width) 93 | t.writef(&prog._window, " %1.1f%%", percentage * 100) 94 | 95 | if len(prog.description) != 0 { 96 | t.move_cursor(&prog._window, 1, 0) 97 | t.write(&prog._window, prog.description) 98 | } 99 | 100 | t.reset_styles(&prog._window) 101 | t.blit(&prog._window) 102 | } 103 | 104 | --------------------------------------------------------------------------------