diff --git a/examples/term.ui/cursor_chaser.v b/examples/term.ui/cursor_chaser.v new file mode 100644 index 0000000000..386226e2c3 --- /dev/null +++ b/examples/term.ui/cursor_chaser.v @@ -0,0 +1,94 @@ +import term.ui as tui + +struct Point { + x int + y int +} + +const ( + colors = [ + tui.Color{33, 150, 243} + tui.Color{0, 150, 136} + tui.Color{205, 220, 57} + tui.Color{255, 152, 0} + tui.Color{244, 67, 54} + tui.Color{156, 39, 176} + ] +) + +struct App { +mut: + tui &tui.Context = 0 + points []Point + color tui.Color = colors[0] + color_idx int + cut_rate f64 = 5 +} + +fn frame(x voidptr) { + mut app := &App(x) + + app.tui.clear() + + if app.points.len > 0 { + app.tui.set_bg_color(app.color) + mut last := app.points[0] + for segment in app.points { + // if the cursor moveds quickly enough, different events are not + // necessarily touching, so we need to draw a line between them + app.tui.draw_line(last.x, last.y, segment.x, segment.y) + last = segment + } + app.tui.reset() + + l := int(app.points.len / app.cut_rate) + 1 + app.points = app.points[l..].clone() + } + + ww := app.tui.window_width + + app.tui.bold() + app.tui.draw_text(ww / 6, 2, 'V term.input: cursor chaser demo') + app.tui.draw_text((ww - ww / 6) - 14, 2, 'cut rate: ${(100 / app.cut_rate):3.0f}%') + app.tui.horizontal_separator(3) + app.tui.reset() + app.tui.flush() +} + +fn event(e &tui.Event, x voidptr) { + mut app := &App(x) + + match e.typ { + .key_down { + match e.code { + .escape { + app.tui.set_cursor_position(0, 0) + app.tui.flush() + exit(0) + } + .space, .enter { + app.color_idx++ + if app.color_idx == colors.len { app.color_idx = 0 } + app.color = colors[app.color_idx] + } else {} + } + } .mouse_move, .mouse_drag, .mouse_down { + app.points << Point{ e.x, e.y } + } .mouse_scroll { + d := if e.direction == .up { 0.1 } else { -0.1 } + app.cut_rate += d + if app.cut_rate < 1 { app.cut_rate = 1 } + } else {} + } +} + +mut app := &App{} +app.tui = tui.init( + user_data: app, + frame_fn: frame, + event_fn: event, + + hide_cursor: true +) + +app.tui.run() diff --git a/examples/term.ui/event_viewer.v b/examples/term.ui/event_viewer.v new file mode 100644 index 0000000000..a7bca5c69e --- /dev/null +++ b/examples/term.ui/event_viewer.v @@ -0,0 +1,33 @@ +import term.ui as tui + +struct App { +mut: + tui &tui.Context = 0 +} + +fn event(e &tui.Event, x voidptr) { + print('\x1b[0;0H\x1b[2J\x1b[3J') // Clear everything + println('V term.input event viewer (press `esc` to exit)\n\n') + + println(e) + println('Raw event bytes: "${e.utf8.bytes().hex()}" = ${e.utf8.bytes()}') + + if e.modifiers == tui.ctrl | tui.alt { + println('CTRL + ALT') + } + + if e.typ == .key_down && e.code == .escape { exit(0) } +} + +mut app := &App{} +app.tui = tui.init( + user_data: app, + event_fn: event + + hide_cursor: true + capture_events: true + frame_rate: 60 +) + +println('V term.input event viewer (press `esc` to exit)\n\n') +app.tui.run() diff --git a/examples/term.ui/rectangles.v b/examples/term.ui/rectangles.v new file mode 100644 index 0000000000..20d0fa81e1 --- /dev/null +++ b/examples/term.ui/rectangles.v @@ -0,0 +1,90 @@ +import term.ui as tui +import rand + +struct Rect { +mut: + c tui.Color + x int + y int + x2 int + y2 int +} + +struct App { +mut: + tui &tui.Context = 0 + rects []Rect + cur_rect Rect + is_drag bool + redraw bool +} + +fn random_color() tui.Color { + return { + r: byte(rand.intn(256)) + g: byte(rand.intn(256)) + b: byte(rand.intn(256)) + } +} + +fn event(e &tui.Event, x voidptr) { + mut app := &App(x) + match e.typ { + .mouse_down { + app.is_drag = true + app.cur_rect = { + c: random_color() + x: e.x + y: e.y + x2: e.x + y2: e.y + } + } + .mouse_drag { + app.cur_rect.x2 = e.x + app.cur_rect.y2 = e.y + } .mouse_up { + app.rects << app.cur_rect + app.is_drag = false + } .key_down { + if e.code == .c { app.rects.clear() } + else if e.code == .escape { exit(0) } + } else {} + } + app.redraw = true +} + +fn frame(x voidptr) { + mut app := &App(x) + if !app.redraw { return } + + app.tui.clear() + + for rect in app.rects { + app.tui.set_bg_color(rect.c) + app.tui.draw_rect(rect.x, rect.y, rect.x2, rect.y2) + } + + if app.is_drag { + r := app.cur_rect + app.tui.set_bg_color(r.c) + app.tui.draw_empty_rect(r.x, r.y, r.x2, r.y2) + } + + app.tui.reset_bg_color() + app.tui.flush() + app.redraw = false +} + + +mut app := &App{} +app.tui = tui.init( + user_data: app, + event_fn: event, + frame_fn: frame + + hide_cursor: true + frame_rate: 60 +) + +app.tui.run() diff --git a/examples/term.ui/term_drawing.v b/examples/term.ui/term_drawing.v new file mode 100644 index 0000000000..9b4efea1af --- /dev/null +++ b/examples/term.ui/term_drawing.v @@ -0,0 +1,335 @@ +module main + +import term.ui as tui + +// The color palette, taken from Google's Material design +const ( + colors = [ + [ + tui.Color{239, 154, 154} + tui.Color{244, 143, 177} + tui.Color{206, 147, 216} + tui.Color{179, 157, 219} + tui.Color{159, 168, 218} + tui.Color{144, 202, 249} + tui.Color{129, 212, 250} + tui.Color{128, 222, 234} + tui.Color{128, 203, 196} + tui.Color{165, 214, 167} + tui.Color{197, 225, 165} + tui.Color{230, 238, 156} + tui.Color{255, 245, 157} + tui.Color{255, 224, 130} + tui.Color{255, 204, 128} + tui.Color{255, 171, 145} + tui.Color{188, 170, 164} + tui.Color{238, 238, 238} + tui.Color{176, 190, 197} + ], [ + tui.Color{244, 67, 54} + tui.Color{233, 30, 99} + tui.Color{156, 39, 176} + tui.Color{103, 58, 183} + tui.Color{63, 81, 181} + tui.Color{33, 150, 243} + tui.Color{3, 169, 244} + tui.Color{0, 188, 212} + tui.Color{0, 150, 136} + tui.Color{76, 175, 80} + tui.Color{139, 195, 74} + tui.Color{205, 220, 57} + tui.Color{255, 235, 59} + tui.Color{255, 193, 7} + tui.Color{255, 152, 0} + tui.Color{255, 87, 34} + tui.Color{121, 85, 72} + tui.Color{120, 120, 120} + tui.Color{96, 125, 139} + ], [ + tui.Color{198, 40, 40} + tui.Color{173, 20, 87} + tui.Color{106, 27, 154} + tui.Color{69, 39, 160} + tui.Color{40, 53, 147} + tui.Color{21, 101, 192} + tui.Color{2, 119, 189} + tui.Color{0, 131, 143} + tui.Color{0, 105, 92} + tui.Color{46, 125, 50} + tui.Color{85, 139, 47} + tui.Color{158, 157, 36} + tui.Color{249, 168, 37} + tui.Color{255, 143, 0} + tui.Color{239, 108, 0} + tui.Color{216, 67, 21} + tui.Color{78, 52, 46} + tui.Color{33, 33, 33} + tui.Color{55, 71, 79} + ] + ] +) + +const ( + frame_rate = 30 // fps + msg_display_time = 5 * frame_rate + + w = 200 + h = 100 + + space = ' ' + spaces = ' ' + + select_color = 'Select color:' + select_size = 'Size: + -' + + help_1 = '╭────────╮' + help_2 = '│ HELP │' + help_3 = '╰────────╯' +) + +struct App { +mut: + tui &tui.Context = 0 + header_text []string + mouse_pos Point + msg string + msg_hide_tick int + primary_color tui.Color = colors[1][6] + secondary_color tui.Color = colors[1][9] + drawing [][]tui.Color = [][]tui.Color{ len: h, init: []tui.Color{ len: w } } + size int = 1 + should_redraw bool = true + is_dragging bool +} + +struct Point { + x int + y int +} + +fn main() { + mut app := &App{} + app.tui = tui.init( + user_data: app + frame_fn: frame + event_fn: event + + frame_rate: frame_rate + hide_cursor: true + ) + + app.tui.run() +} + +fn frame(x voidptr) { + mut app := &App(x) + + mut redraw := app.should_redraw + + if app.msg != '' && app.tui.frame_count >= app.msg_hide_tick { + app.msg = '' + redraw = true + } + + if redraw { + app.render(false) + app.should_redraw = false + } +} + +fn event(event &tui.Event, x voidptr) { + mut app := &App(x) + + match event.typ { + .mouse_down { + app.is_dragging = true + if app.tui.window_height - event.y < 5 { + app.footer_click(event) + } else { + app.paint(event) + } + } .mouse_up { + app.is_dragging = false + } .mouse_drag { + app.mouse_pos = { x: event.x, y: event.y } + app.paint(event) + } .mouse_move { + app.mouse_pos = { x: event.x, y: event.y } + } .mouse_scroll { + if event.direction == .down { app.inc_size() } else { app.dec_size() } + } .key_down { + match event.code { + .c { + app.drawing = [][]tui.Color{ len: h, init: []tui.Color{ len: w } } + } .escape { + app.render(true) + exit(0) + } else {} + } + } else {} + } + + app.should_redraw = true +} + +fn (mut app App) render(paint_only bool) { + app.tui.clear() + + app.draw_header() + app.draw_content() + + if !paint_only { + app.draw_footer() + app.draw_cursor() + } + + app.tui.flush() +} + +fn (mut app App) set_pixel(x_ int, y_ int, c tui.Color) { + // Term coords start at 1, and adjust for the header + x, y := x_ - 1, y_ - 4 + if y < 0 || app.tui.window_height - y < 3 { return } + if y >= app.drawing.len || x < 0 || x >= app.drawing[0].len { return } + app.drawing[y][x] = c +} + +fn (mut app App) paint(event &tui.Event) { + x_start, y_start := int(f32((event.x - 1) / 2) - app.size / 2 + 1), event.y - app.size / 2 + color := if event.button == .primary { app.primary_color } else { app.secondary_color } + + for x in x_start .. x_start + app.size { + for y in y_start .. y_start + app.size { + app.set_pixel(x, y, color) + } + } +} + +fn (mut app App) draw_content() { + w, mut h := app.tui.window_width / 2, app.tui.window_height - 8 + if h > app.drawing.len { + h = app.drawing.len + } + + for row_idx, row in app.drawing[..h] { + app.tui.set_cursor_position(0, row_idx + 4) + mut last := tui.Color{ 0, 0, 0 } + for cell in row[..w] { + if cell.r == 0 && cell.g == 0 && cell.b == 0 { + if !(cell.r == last.r && cell.g == last.g && cell.b == last.b) { + app.tui.reset() + } + } else { + if !(cell.r == last.r && cell.g == last.g && cell.b == last.b) { + app.tui.set_bg_color(cell) + } + } + app.tui.write(spaces) + last = cell + } + app.tui.reset() + } +} + +fn (mut app App) draw_cursor() { + if app.mouse_pos.y in [3, app.tui.window_height - 5] { + // inside the horizontal separators + return + } + + cursor_color := if app.is_dragging { tui.Color{ 220, 220, 220 } } else { tui.Color{ 160, 160, 160 } } + app.tui.set_bg_color(cursor_color) + + if app.mouse_pos.y >= 3 && app.mouse_pos.y <= app.tui.window_height - 4 { + // inside the main content + mut x_start := int(f32((app.mouse_pos.x - 1) / 2) - app.size / 2 + 1) * 2 - 1 + mut y_start := app.mouse_pos.y - app.size / 2 + mut x_end := x_start + app.size * 2 - 1 + mut y_end := y_start + app.size - 1 + + if x_start < 1 { x_start = 1 } + if y_start < 4 { y_start = 4 } + if x_end > app.tui.window_width { x_end = app.tui.window_width } + if y_end > app.tui.window_height - 5 { y_end = app.tui.window_height - 5 } + + app.tui.draw_rect(x_start, y_start, x_end, y_end) + } else { + app.tui.draw_text(app.mouse_pos.x, app.mouse_pos.y, space) + } + app.tui.reset() +} + +fn (mut app App) draw_header() { + if app.msg != '' { + app.tui.set_color(r: 0, g: 0, b: 0) + app.tui.set_bg_color(r: 220, g: 220, b: 220) + app.tui.draw_text(0, 0, ' $app.msg ') + app.tui.reset() + } + + app.tui.draw_text(3, 2, /* 'tick: $app.tui.frame_count | ' + */ 'terminal size: ($app.tui.window_width, $app.tui.window_height) | primary color: $app.primary_color.hex() | secondary color: $app.secondary_color.hex()') + app.tui.horizontal_separator(3) +} + +fn (mut app App) draw_footer() { + ww, wh := app.tui.window_width, app.tui.window_height + + app.tui.horizontal_separator(wh - 4) + + for i, color_row in colors { + for j, color in color_row { + x := j * 3 + 19 + y := wh - 3 + i + + app.tui.set_bg_color(color) + app.tui.draw_rect(x, y, x+1, y) + } + } + app.tui.reset_bg_color() + + app.tui.draw_text(3, wh - 3, select_color) + app.tui.bold() + app.tui.draw_text(3, wh - 1, select_size) + app.tui.reset() + + if ww >= 90 { + app.tui.draw_text(80, wh - 3, help_1) + app.tui.draw_text(80, wh - 2, help_2) + app.tui.draw_text(80, wh - 1, help_3) + } +} + +[inline] +fn (mut app App) inc_size() { + if app.size < 20 { app.size++ } + app.show_msg('inc. size: $app.size', 1) +} + +[inline] +fn (mut app App) dec_size() { + if app.size > 1 { app.size-- } + app.show_msg('dec. size: $app.size', 1) +} + +fn (mut app App) footer_click(event &tui.Event) { + footer_y := 3 - (app.tui.window_height - event.y) + match event.x { + 8...11 { + app.inc_size() + } 12...15 { + app.dec_size() + } 18...75 { + if (event.x % 3) == 0 { return } // Inside the gap between tiles + idx := footer_y * 19 - 6 + event.x / 3 + color := colors[idx / 19][idx % 19] + if event.button == .primary { app.primary_color = color } else { app.secondary_color = color } + app.show_msg('set ${event.button.str().to_lower()} color idx: $idx', 1) + } else {} + } +} + +fn (mut app App) show_msg(text string, time int) { + frames := time * frame_rate + app.msg_hide_tick = if time > 0 { int(app.tui.frame_count) + frames } else { -1 } + app.msg = text +} diff --git a/vlib/term/README.md b/vlib/term/README.md index 3f3ab5b0b7..2c3e299bae 100644 --- a/vlib/term/README.md +++ b/vlib/term/README.md @@ -1,6 +1,8 @@ # Quickstart -The V `term` module is a module which is made to provide an interactive api that helps building TUI apps. +The V `term` module is a module designed to provide the building blocks for building very simple TUI apps. +For more complex apps, you should really look at the `term.input` module, as it includes terminal events, is easier to use, +and is much more performant for large draws. # Use diff --git a/vlib/term/ui/README.md b/vlib/term/ui/README.md new file mode 100644 index 0000000000..07964f4af2 --- /dev/null +++ b/vlib/term/ui/README.md @@ -0,0 +1,42 @@ +## `term.ui` + +A V module for designing terminal UI apps + +#### Quickstart + +```v +// todo +``` + +See the `/examples/term.ui/` folder for more usage examples. + +#### Configuration + + - `user_data voidptr` - a pointer to any `user_data`, it will be passed as the last argument to each callback. Used for accessing your app context from the different callbacks. + - `init_fn fn(voidptr)` - a callback that will be called after initialization, and before the first event / frame. Useful for initializing any user data. + - `frame_fn fn(voidptr)` - a callback that will be fired on each frame, at a rate of `frame_rate` frames per second. +`event_fn fn(&Event, voidptr)` - a callback that will be fired for every event received + - `cleanup_fn fn(voidptr)` - a callback that will be fired once, before the application exits. +`fail_fn fn(string)` - a callback that will be fired if a fatal error occurs during app initialization. + - `buffer_size int = 256` - the internal size of the read buffer. Increasing it may help in case you're missing events, but you probably shouldn't lower this value unless you make sure you're still receiving all events. In general, higher frame rates work better with lower buffer sizes, and vice versa. + - `frame_rate int = 30` - the number of times per second that the `frame` callback will be fired. 30fps is a nice balance between smoothness and performance, but you can increase or lower it as you wish. + - `hide_cursor bool` - whether to hide the mouse cursor. Useful if you want to use your own. + - `capture_events bool` - sets the terminal into raw mode, which makes it intercept some escape codes such as `ctrl + c` and `ctrl + z`. Useful if you want to use those key combinations in your app. + - `reset []int = [1, 2, 3, 4, 6, 7, 8, 9, 11, 13, 14, 15, 19]` - a list of reset signals, to setup handlers to cleanup the terminal state when they're received. You should not need to change this, unless you know what you're doing. + +All of these fields may be omitted, in which case, the default value will be used. In the case of the various callbacks, they will not be fired if a handler has not been specified. + + +#### FAQ + +Q: Why does this module not work on Windows? +A: As with many other things, Windows has a completely different and incompatible way of handling input parsing and drawing primitives, and support has not been implemented yet. Contributions are definitely welcome though. + +Q: My terminal (doesn't receive events / doesn't print anything / prints gibberish characters), what's up with that? +A: Please check if your terminal. The module has been tested with `xterm`-based terminals on Linux (like `gnome-terminal` and `konsole`), and `Terminal.app` and `iterm2` on macOS. If your terminal does not work, open an issue with the output of `echo $TERM`. + +Q: There are screen tearing issues when doing large prints +A: This is an issue with how terminals render frames, as they may decide to do so in the middle of receiving a frame, and cannot be fully fixed unless your console implements the [synchronized updates spec](https://gitlab.com/gnachman/iterm2/-/wikis/synchronized-updates-spec). It can be reduced *drastically*, though, by using the rendering methods built in to the module, and by only painting frames when your app's content has actually changed. + +Q: Why does the module only emit `keydown` events, and not `keyup` like `sokol`/`gg`? +A: It's because of the way terminals emit events. Every key event is received as a keypress, and there isn't a way of telling terminals to send keyboard events differently, nor a reliable way of converting these into `keydown` / `keyup` events. diff --git a/vlib/term/ui/input.v b/vlib/term/ui/input.v new file mode 100644 index 0000000000..643017bd8d --- /dev/null +++ b/vlib/term/ui/input.v @@ -0,0 +1,193 @@ +module ui + +pub enum KeyCode { + null = 0 + tab = 9 + enter = 10 + escape = 27 + space = 32 + backspace = 127 + + exclamation = 33 + double_quote = 34 + hashtag = 35 + dollar = 36 + percent = 37 + ampersand = 38 + single_quote = 39 + left_paren = 40 + right_paren = 41 + asterisk = 42 + plus = 43 + comma = 44 + minus = 45 + period = 46 + slash = 47 + + _0 = 48 + _1 = 49 + _2 = 50 + _3 = 51 + _4 = 52 + _5 = 53 + _6 = 54 + _7 = 55 + _8 = 56 + _9 = 57 + + colon = 58 + semicolon = 59 + less_than = 60 + equal = 61 + greater_than = 62 + question_mark = 63 + at = 64 + + a = 97 + b = 98 + c = 99 + d = 100 + e = 101 + f = 102 + g = 103 + h = 104 + i = 105 + j = 106 + k = 107 + l = 108 + m = 109 + n = 110 + o = 111 + p = 112 + q = 113 + r = 114 + s = 115 + t = 116 + u = 117 + v = 118 + w = 119 + x = 120 + y = 121 + z = 122 + + left_square_bracket = 91 + backslash = 92 + right_square_bracket = 93 + caret = 94 + underscore = 95 + backtick = 96 + + left_curly_bracket = 123 + vertical_bar = 124 + right_curly_bracket = 125 + tilde = 126 + + insert = 260 + delete = 261 + up = 262 + down = 263 + right = 264 + left = 265 + page_up = 266 + page_down = 267 + home = 268 + end = 269 + + f1 = 290 + f2 = 291 + f3 = 292 + f4 = 293 + f5 = 294 + f6 = 295 + f7 = 296 + f8 = 297 + f9 = 298 + f10 = 299 + f11 = 300 + f12 = 301 +} + +pub const ( + shift = u32(1 << 0) + ctrl = u32(1 << 1) + alt = u32(1 << 2) +) + +pub enum Direction { + unknown + up + down + left + right +} + +pub enum MouseButton { + unknown + primary + secondary +} + +pub enum EventType { + unknown + mouse_down + mouse_up + mouse_move + mouse_drag + mouse_scroll + key_down + resized +} + +pub struct Event { +pub: + typ EventType + + // Mouse event info + x int + y int + button MouseButton + direction Direction + + // Keyboard event info + code KeyCode + modifiers u32 + ascii byte + utf8 string + + // Resized event info + width int + height int +} + +pub struct Context { +pub: + cfg Config +mut: + termios C.termios + read_buf []byte + print_buf []byte + // init_called bool + // quit_ordered bool +pub mut: + frame_count u64 + window_width int + window_height int +} + +pub struct Config { + user_data voidptr + init_fn fn(voidptr) + frame_fn fn(voidptr) + cleanup_fn fn(voidptr) + event_fn fn(&Event, voidptr) + fail_fn fn(string) + + buffer_size int = 256 + frame_rate int = 30 + use_x11 bool + + hide_cursor bool + capture_events bool + // All kill signals + reset []int = [1, 2, 3, 4, 6, 7, 8, 9, 11, 13, 14, 15, 19] +} diff --git a/vlib/term/ui/input_nix.c.v b/vlib/term/ui/input_nix.c.v new file mode 100644 index 0000000000..95d661c6c2 --- /dev/null +++ b/vlib/term/ui/input_nix.c.v @@ -0,0 +1,86 @@ +module ui + +const ( + ctx_ptr = &Context(0) +) + +pub fn init(cfg Config) &Context { + mut ctx := &Context{ + cfg: cfg, + read_buf: []byte{ cap: cfg.buffer_size } + } + + if cfg.hide_cursor { + s := '\x1b[?25l' + C.write(C.STDOUT_FILENO, s.str, s.len) + } + + unsafe { + x := &ctx_ptr + *x = ctx + } + + return ctx +} + +pub fn (mut ctx Context) run() { + if ctx.cfg.use_x11 { + ctx.fail('error: x11 backend not implemented yet') + exit(1) + } else { + ctx.termios_setup() + ctx.termios_loop() + } +} + +[inline] +// shifts the array left, to remove any data that was just read, and updates its len +// TODO: remove +fn (mut ctx Context) shift(len int) { + unsafe { + C.memmove(ctx.read_buf.data, ctx.read_buf.data + len, ctx.read_buf.cap - len) + ctx.resize_arr(ctx.read_buf.len - len) + } +} + +// TODO: don't actually do this, lmao +[inline] +fn (mut ctx Context) resize_arr(size int) { + mut l := &ctx.read_buf.len + unsafe { *l = size } +} + +[inline] +fn (ctx &Context) init() { + if ctx.cfg.init_fn != voidptr(0) { + ctx.cfg.init_fn(ctx.cfg.user_data) + } +} + +[inline] +fn (ctx &Context) frame() { + if ctx.cfg.frame_fn != voidptr(0) { + ctx.cfg.frame_fn(ctx.cfg.user_data) + } +} + +[inline] +fn (ctx &Context) cleanup() { + if ctx.cfg.cleanup_fn != voidptr(0) { + ctx.cfg.cleanup_fn(ctx.cfg.user_data) + } +} + +[inline] +fn (ctx &Context) fail(error string) { + if ctx.cfg.fail_fn != voidptr(0) { + ctx.cfg.fail_fn(error) + } +} + +[inline] +fn (ctx &Context) event(event &Event) { + if ctx.cfg.event_fn != voidptr(0) { + ctx.cfg.event_fn(event, ctx.cfg.user_data) + } +} diff --git a/vlib/term/ui/input_windows.c.v b/vlib/term/ui/input_windows.c.v new file mode 100644 index 0000000000..6cdf1e6625 --- /dev/null +++ b/vlib/term/ui/input_windows.c.v @@ -0,0 +1,10 @@ +module ui + +pub fn init(cfg Config) &Context { + panic("term.input: error: Windows support isn't implemented yet") + return &Context{} +} + +pub fn (mut ctx Context) run() { + panic("term.input: error: Windows support isn't implemented yet") +} diff --git a/vlib/term/ui/termios_nix.c.v b/vlib/term/ui/termios_nix.c.v new file mode 100644 index 0000000000..91f6253af1 --- /dev/null +++ b/vlib/term/ui/termios_nix.c.v @@ -0,0 +1,377 @@ +module ui + +import os +import time + +#include +#include +#include + +fn C.tcgetattr() +fn C.tcsetattr() +fn C.ioctl(fd int, request u64, arg voidptr) int + +struct C.termios { +mut: + c_iflag u32 + c_lflag u32 + c_cc [32]byte +} + +struct C.winsize { + ws_row u16 + ws_col u16 +} + +const ( + termios_at_startup = get_termios() +) + +[inline] +fn get_termios() C.termios { + mut t := C.termios{} + C.tcgetattr(C.STDIN_FILENO, &t) + return t +} + +[inline] +fn get_terminal_size() (u16, u16) { + winsz := C.winsize{} + C.ioctl(0, C.TIOCGWINSZ, &winsz) + return winsz.ws_row, winsz.ws_col +} + +fn (mut ctx Context) termios_setup() { + mut termios := get_termios() + + if ctx.cfg.capture_events { + // Set raw input mode by unsetting ICANON and ECHO, + // as well as disable e.g. ctrl+c and ctrl.z + termios.c_iflag &= ~u32(C.IGNBRK | C.BRKINT | C.PARMRK | C.IXON) + termios.c_lflag &= ~u32(C.ICANON | C.ISIG | C.ECHO | C.IEXTEN | C.TOSTOP) + } else { + // Set raw input mode by unsetting ICANON and ECHO + termios.c_lflag &= ~u32(C.ICANON | C.ECHO) + } + // Prevent stdin from blocking by making its read time 0 + termios.c_cc[C.VTIME] = 0 + termios.c_cc[C.VMIN] = 0 + C.tcsetattr(C.STDIN_FILENO, C.TCSAFLUSH, &termios) + print('\x1b[?1003h\x1b[?1015h\x1b[?1006h') + + ctx.termios = termios + + ctx.window_height, ctx.window_width = get_terminal_size() + + // Reset console on exit + C.atexit(termios_reset) + for code in ctx.cfg.reset { + os.signal(code, fn() { + mut c := ctx_ptr + if c != 0 { + c.cleanup() + } + exit(0) + }) + } + + os.signal(C.SIGWINCH, fn() { + mut c := ctx_ptr + if c != 0 { + c.window_height, c.window_width = get_terminal_size() + + mut event := &Event{ + typ: .resized + width: c.window_width + height: c.window_height + } + c.event(event) + } + }) +} + +fn termios_reset() { + C.tcsetattr(C.STDIN_FILENO, C.TCSAFLUSH /* C.TCSANOW ?? */, &termios_at_startup) + print('\x1b[?1003l\x1b[?1015l\x1b[?1006l\x1b[0J\x1b[?25h') +} + +/////////////////////////////////////////// + +/* +fn (mut ctx Context) termios_loop() { + frame_time := 1_000_000 / ctx.cfg.frame_rate + mut init_called := false + + mut sw := time.new_stopwatch(auto_start: false) + + mut last_frame_time := 0 + mut sleep_len := 0 + + for { + sw.restart() + if !init_called { + ctx.init() + init_called = true + } + for _ in 0 .. 7 { + // println('SLEEPING: $sleep_len') + if sleep_len > 0 { + time.usleep(sleep_len) + } + if ctx.cfg.event_fn != voidptr(0) { + len := C.read(C.STDIN_FILENO, ctx.read_buf.data, ctx.read_buf.cap - ctx.read_buf.len) + if len > 0 { + ctx.resize_arr(len) + ctx.parse_events() + } + } + } + + ctx.frame() + + sw.pause() + last_frame_time = int(sw.elapsed().microseconds()) + + if + println('Sleeping for $frame_time - $last_frame_time = ${frame_time - last_frame_time}') + // time.usleep(frame_time - last_frame_time - sleep_len * 7) + last_frame_time = 0 + + sw.start() + sw.pause() + last_frame_time += int(sw.elapsed().microseconds()) + sleep_len = (frame_time - last_frame_time) / 8 + + ctx.frame_count++ + } +} +*/ + +// TODO: do multiple sleep/read cycles, rather than one big one +fn (mut ctx Context) termios_loop() { + frame_time := 1_000_000 / ctx.cfg.frame_rate + mut init_called := false + mut sw := time.new_stopwatch(auto_start: false) + mut sleep_len := 0 + for { + if !init_called { + ctx.init() + init_called = true + } + // println('SLEEPING: $sleep_len') + if sleep_len > 0 { + time.usleep(sleep_len) + } + sw.restart() + if ctx.cfg.event_fn != voidptr(0) { + len := C.read(C.STDIN_FILENO, ctx.read_buf.data, ctx.read_buf.cap - ctx.read_buf.len) + if len > 0 { + ctx.resize_arr(len) + ctx.parse_events() + } + } + ctx.frame() + sw.pause() + e := sw.elapsed().microseconds() + sleep_len = frame_time - int(e) + + ctx.frame_count++ + } +} + +fn (mut ctx Context) parse_events() { + // Stop this from getting stuck in rare cases where something isn't parsed correctly + mut nr_iters := 0 + for ctx.read_buf.len > 0 { + nr_iters++ + if nr_iters > 100 { ctx.shift(1) } + mut event := &Event(0) + if ctx.read_buf[0] == 0x1b { + e, len := escape_sequence(ctx.read_buf.bytestr()) + event = e + ctx.shift(len) + } else { + event = single_char(ctx.read_buf.bytestr()) + ctx.shift(1) + } + if event != 0 { + ctx.event(event) + nr_iters = 0 + } + } +} + +fn single_char(buf string) &Event { + ch := buf[0] + + mut event := &Event{ + typ: .key_down + ascii: ch + code: KeyCode(ch) + utf8: buf + } + + match ch { + // special handling for `ctrl + letter` + + // TODO: Fix assoc in V and remove this workaround :/ + // 1 ... 26 { event = { event | code: KeyCode(96 | ch), modifiers: ctrl } } + // 65 ... 90 { event = { event | code: KeyCode(32 | ch), modifiers: shift } } + + + // The bit `or`s here are really just `+`'s, just written in this way for a tiny performance improvement + // 10 == `\n` == enter, don't treat it as ctrl + j + 1 ... 9, 11 ... 26 { event = &Event{ typ: event.typ, ascii: event.ascii, utf8: event.utf8, code: KeyCode(96 | ch), modifiers: ctrl } } + 65 ... 90 { event = &Event{ typ: event.typ, ascii: event.ascii, utf8: event.utf8, code: KeyCode(32 | ch), modifiers: shift } } + + else {} + } + + return event +} + +[inline] +// Gets an entire, independent escape sequence from the buffer +// Normally, this just means reading until the first letter, but there are some exceptions... +fn escape_end(buf string) int { + mut i := 0 + for { + if i + 1 == buf.len { return buf.len } + + if buf[i].is_letter() { + if buf[i] == `O` && i + 2 <= buf.len { + n := buf[i+1] + if (n >= `A` && n <= `D`) || (n >= `P` && n <= `S`) || n == `F` || n == `H` { + return i + 2 + } + } + return i + 1 + } + i++ + } + // this point should be unreachable + assert false + return 0 +} + +fn escape_sequence(buf_ string) (&Event, int) { + end := escape_end(buf_) + single := buf_[..end] // read until the end of the sequence + buf := single[1..] // skip the escape character + + if buf.len == 0 { + return &Event{ + typ: .key_down + ascii: 27 + code: .escape + utf8: single + }, 1 + } + + if buf.len == 1 { + c := single_char(buf) + // return { c | modifiers: c.modifiers | alt }, 2 + + return &Event{ + typ: c.typ + ascii: c.ascii + code: c.code + utf8: single + modifiers: c.modifiers | alt + }, 2 + } + + // ---------------- + // Mouse events + // ---------------- + + if buf.len > 2 && buf[1] == `<` { // Mouse control + split := buf[2..].split(';') + if split.len < 3 { return &Event(0), 0 } + + typ, x, y := split[0].int(), split[1].int(), split[2].int() + + match typ { + 0, 2 { + last := buf[buf.len - 1] + button := if typ == 0 { MouseButton.primary } else { MouseButton.secondary } + event := if last == `M` { EventType.mouse_down } else { EventType.mouse_up } + return &Event{ typ: event, x: x, y: y, button: button, utf8: single }, end + } + 32, 34 { + button := if typ == 32 { MouseButton.primary } else { MouseButton.secondary } + return &Event{ typ: .mouse_drag, x: x, y: y, button: button, utf8: single }, end + } + 35 { + return &Event{ typ: .mouse_move, x: x, y: y, utf8: single }, end + } + 64, 65 { + direction := if typ == 64 { Direction.down } else { Direction.up } + return &Event{ typ: .mouse_scroll, x: x, y: y, direction: direction, utf8: single }, end + } else {} + } + } + + // ---------------------------- + // Special key combinations + // ---------------------------- + + mut code := KeyCode.null + mut modifiers := u32(0) + match buf { + '[A', 'OA' { code = .up } + '[B', 'OB' { code = .down } + '[C', 'OC' { code = .right } + '[D', 'OD' { code = .left } + '[5~', '[[5~' { code = .page_up } + '[6~', '[[6~' { code = .page_down } + '[F', 'OF', '[4~', '[[8~' { code = .end } + '[H', 'OH', '[1~', '[[7~' { code = .home } + '[2~' { code = .insert } + '[3~' { code = .delete } + 'OP', '[11~' { code = .f1 } + 'OQ', '[12~' { code = .f2 } + 'OR', '[13~' { code = .f3 } + 'OS', '[14~' { code = .f4 } + '[15~' { code = .f5 } + '[17~' { code = .f6 } + '[18~' { code = .f7 } + '[19~' { code = .f8 } + '[20~' { code = .f9 } + '[21~' { code = .f10 } + '[23~' { code = .f11 } + '[24~' { code = .f12 } + else {} + } + + if buf.len == 5 && buf[0] == `[` && buf[1] == `1` && buf[2] == `;` { + // code = KeyCode(buf[4] + 197) + modifiers = match buf[3] { + `2` { shift } + `3` { alt } + `4` { shift | alt } + `5` { ctrl } + `6` { ctrl | shift } + `7` { ctrl | alt } + `8` { ctrl | alt | shift } + else { modifiers } // probably unreachable? idk, terminal events are strange + } + + code = match buf[4] { + `A` { KeyCode.up } + `B` { KeyCode.down } + `C` { KeyCode.right } + `D` { KeyCode.left } + `F` { KeyCode.end } + `H` { KeyCode.home } + `P` { KeyCode.f1 } + `Q` { KeyCode.f2 } + `R` { KeyCode.f3 } + `S` { KeyCode.f4 } + else { code } + } + // && buf[3] >= `2` && buf[3] <= `8` && buf[4] >= `A` && buf[4] <= `D` + } + + return &Event{ typ: .key_down, code: code, utf8: single, modifiers: modifiers }, end +} diff --git a/vlib/term/ui/ui.v b/vlib/term/ui/ui.v new file mode 100644 index 0000000000..85dc1af6bf --- /dev/null +++ b/vlib/term/ui/ui.v @@ -0,0 +1,171 @@ +module ui + +import strings + +pub struct Color { +pub: + r byte + g byte + b byte +} + +pub fn (c Color) hex() string { + return '#${c.r.hex()}${c.g.hex()}${c.b.hex()}' +} + +// Synchronized Updates spec, designed to avoid tearing during renders +// https://gitlab.com/gnachman/iterm2/-/wikis/synchronized-updates-spec +const ( + bsu = '\x1bP=1s\x1b\\' + esu = '\x1bP=2s\x1b\\' +) + +[inline] +pub fn (mut ctx Context) write(s string) { + if s == '' { return } + ctx.print_buf.push_many(s.str, s.len) +} + +[inline] +pub fn (mut ctx Context) flush() { + $if windows { + // TODO + } $else { + // TODO: Diff the previous frame against this one, and only render things that changed? + C.write(C.STDOUT_FILENO, bsu.str, bsu.len) + C.write(C.STDOUT_FILENO, ctx.print_buf.data, ctx.print_buf.len) + C.write(C.STDOUT_FILENO, esu.str, esu.len) + ctx.print_buf.clear() + } +} + +[inline] +pub fn (mut ctx Context) bold() { + ctx.write('\x1b[1m') +} + +[inline] +pub fn (mut ctx Context) set_cursor_position(x int, y int) { + ctx.write('\x1b[$y;${x}H') +} + +[inline] +pub fn (mut ctx Context) set_color(c Color) { + ctx.write('\x1b[38;2;${int(c.r)};${int(c.g)};${int(c.b)}m') +} + +[inline] +pub fn (mut ctx Context) set_bg_color(c Color) { + ctx.write('\x1b[48;2;${int(c.r)};${int(c.g)};${int(c.b)}m') +} + +[inline] +pub fn (mut ctx Context) reset_color() { + ctx.write('\x1b[39m') +} + +[inline] +pub fn (mut ctx Context) reset_bg_color() { + ctx.write('\x1b[49m') +} + +[inline] +pub fn (mut ctx Context) reset() { + ctx.write('\x1b[0m') +} + +[inline] +pub fn (mut ctx Context) clear() { + ctx.write('\x1b[2J\x1b[3J') +} + +// pub const ( +// default_color = gx.rgb(183, 101, 94) // hopefully nobody actually tries to use this color... +// ) + +// pub struct DrawConfig { +// pub mut: +// fg_color gx.Color = default_color +// bg_color gx.Color = default_color +// } + +[inline] +pub fn (mut ctx Context) draw_point(x int, y int) { + ctx.set_cursor_position(x, y) + ctx.write(' ') +} + +[inline] +pub fn (mut ctx Context) draw_text(x int, y int, s string) { + ctx.set_cursor_position(x, y) + ctx.write(s) +} + +pub fn (mut ctx Context) draw_line(x int, y int, x2 int, y2 int) { + min_x, min_y := if x < x2 { x } else { x2 }, if y < y2 { y } else { y2 } + max_x, _ := if x > x2 { x } else { x2 }, if y > y2 { y } else { y2 } + + if y == y2 { + // Horizontal line, performance improvement + ctx.set_cursor_position(min_x, min_y) + ctx.write(strings.repeat(` `, max_x + 1 - min_x)) + return + } + + // Draw the various points with Bresenham's line algorithm: + mut x0, x1 := x, x2 + mut y0, y1 := y, y2 + + sx := if x0 < x1 { 1 } else { -1 } + sy := if y0 < y1 { 1 } else { -1 } + dx := if x0 < x1 { x1 - x0 } else { x0 - x1 } + dy := if y0 < y1 { y0 - y1 } else { y1 - y0 } // reversed + + mut err := dx + dy + + for { + // res << Segment{ x0, y0 } + ctx.draw_point(x0, y0) + if x0 == x1 && y0 == y1 { break } + e2 := 2 * err + if e2 >= dy { + err += dy + x0 += sx + } + if e2 <= dx { + err += dx + y0 += sy + } + } +} + +pub fn (mut ctx Context) draw_rect(x int, y int, x2 int, y2 int) { + if y == y2 || x == x2 { + ctx.draw_line(x, y, x2, y2) + return + } + + min_y, max_y := if y < y2 { y } else { y2 }, if y > y2 { y } else { y2 } + + for y_pos in min_y .. max_y + 1 { + ctx.draw_line(x, y_pos, x2, y_pos) + } +} + +pub fn (mut ctx Context) draw_empty_rect(x int, y int, x2 int, y2 int) { + if y == y2 || x == x2 { + ctx.draw_line(x, y, x2, y2) + return + } + + ctx.draw_line(x, y, x2, y) + ctx.draw_line(x, y2, x2, y2) + ctx.draw_line(x, y, x, y2) + ctx.draw_line(x2, y, x2, y2) +} + +[inline] +pub fn (mut ctx Context) horizontal_separator(y int) { + ctx.set_cursor_position(0, y) + ctx.write(strings.repeat(/* `⎽` */`-`, ctx.window_width)) +}