Initial project import

This commit is contained in:
Agila Forge Importer committed 2026-07-13 15:20:32 +00:00
commit 23f2d29e32
11957 files changed
+1754577

No files matched your search

@@ -0,0 +1,7 @@
import term.ui
import term
fn test_term_and_term_ui_can_compile_together() {
println('${term.bold('hello')} world ${ui.color_table[0]}')
assert true
}
@@ -0,0 +1,7 @@
import term
import term.ui
fn test_term_and_term_ui_can_compile_together() {
println('${term.bold('hello')} world ${ui.color_table[0]}')
assert true
}
+103
View File
@@ -0,0 +1,103 @@
## `term.ui`
A V module for designing terminal UI apps
#### Quickstart
```v
import term.ui as tui
struct App {
mut:
tui &tui.Context = unsafe { nil }
}
fn event(e &tui.Event, x voidptr) {
if e.typ == .key_down && e.code == .escape {
exit(0)
}
}
fn frame(x voidptr) {
mut app := unsafe { &App(x) }
app.tui.clear()
app.tui.set_bg_color(r: 63, g: 81, b: 181)
app.tui.draw_rect(20, 6, 41, 10)
app.tui.draw_text(24, 8, 'Hello from V!')
app.tui.set_cursor_position(0, 0)
app.tui.reset()
app.tui.flush()
}
fn main() {
mut app := &App{}
app.tui = tui.init(
user_data: app
event_fn: event
frame_fn: frame
hide_cursor: true
)
app.tui.run()!
}
```
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.
- `mouse_enabled bool` - enables terminal mouse tracking for `mouse_*` events.
Leave this off unless your app actually handles mouse input, so the terminal can still use the
mouse for regular text selection and copy/paste.
- `window_title string` - sets the title of the terminal window.
This may be changed later, by calling the `set_window_title()` method.
- `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: 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: `term.ui` emits `key_up` on Windows and on terminals that support the kitty keyboard
protocol. Legacy terminal input still only provides `key_down`, so `key_up` is not
available everywhere.
+89
View File
@@ -0,0 +1,89 @@
// radare - LGPL - Copyright 2013-2020 - pancake, xarkes
// ansi 256 color extension for r_cons
// https://en.wikipedia.org/wiki/ANSI_color
module ui
const value_range = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff]!
pub const color_table = init_color_table()
@[direct_array_access]
fn init_color_table() []u32 {
mut color_table_ := []u32{len: 256}
// ansi colors
color_table_[0] = 0x000000
color_table_[1] = 0x800000
color_table_[2] = 0x008000
color_table_[3] = 0x808000
color_table_[4] = 0x000080
color_table_[5] = 0x800080
color_table_[6] = 0x008080
color_table_[7] = 0xc0c0c0
color_table_[8] = 0x808080
color_table_[9] = 0xff0000
color_table_[10] = 0x00ff00
color_table_[11] = 0xffff00
color_table_[12] = 0x0000ff
color_table_[13] = 0xff00ff
color_table_[14] = 0x00ffff
color_table_[15] = 0xffffff
// color palette
for i in 0 .. 216 {
r := value_range[(i / 36) % 6]
g := value_range[(i / 6) % 6]
b := value_range[i % 6]
color_table_[i + 16] = ((u32(r) << 16) & 0xffffff) + ((u32(g) << 8) & 0xffff) +
(u32(b) & 0xff)
}
// grayscale
for i in 0 .. 24 {
r := 8 + (i * 10)
color_table_[i + 232] = ((u32(r) << 16) & 0xffffff) + ((u32(r) << 8) & 0xffff) +
(u32(r) & 0xff)
}
return color_table_
}
fn clamp(x int, y int, z int) int {
if x < y {
return y
}
if x > z {
return z
}
return x
}
fn approximate_rgb(r int, g int, b int) int {
grey := r > 0 && r < 255 && r == g && r == b
if grey {
return 232 + int(f64(r) / (255 / 24.1))
}
k := int(256.0 / 6)
r2 := clamp(r / k, 0, 5)
g2 := clamp(g / k, 0, 5)
b2 := clamp(b / k, 0, 5)
return 16 + (r2 * 36) + (g2 * 6) + b2
}
fn lookup_rgb(r int, g int, b int) int {
color := (u32(r) << 16) + (u32(g) << 8) + u32(b)
// lookup extended colors only, coz non-extended can be changed by users.
for i in 16 .. 256 {
if color_table[i] == color {
return i
}
}
return -1
}
// converts an RGB color to an ANSI 256-color, approximating it to the nearest available color
// if an exact match is not found
fn rgb2ansi(r int, g int, b int) int {
c := lookup_rgb(r, g, b)
if c == -1 {
return approximate_rgb(r, g, b)
}
return c
}
@@ -0,0 +1,86 @@
module ui
pub union C.Event {
KeyEvent C.KEY_EVENT_RECORD
MouseEvent C.MOUSE_EVENT_RECORD
WindowBufferSizeEvent C.WINDOW_BUFFER_SIZE_RECORD
MenuEvent C.MENU_EVENT_RECORD
FocusEvent C.FOCUS_EVENT_RECORD
}
@[typedef]
pub struct C.INPUT_RECORD {
EventType u16
Event C.Event
}
pub union C.uChar {
mut:
UnicodeChar rune
AsciiChar u8
}
@[typedef]
pub struct C.KEY_EVENT_RECORD {
bKeyDown int
wRepeatCount u16
wVirtualKeyCode u16
wVirtualScanCode u16
uChar C.uChar
dwControlKeyState u32
}
@[typedef]
pub struct C.MOUSE_EVENT_RECORD {
dwMousePosition C.COORD
dwButtonState u32
dwControlKeyState u32
dwEventFlags u32
}
@[typedef]
pub struct C.WINDOW_BUFFER_SIZE_RECORD {
dwSize C.COORD
}
@[typedef]
pub struct C.MENU_EVENT_RECORD {
dwCommandId u32
}
@[typedef]
pub struct C.FOCUS_EVENT_RECORD {
bSetFocus int
}
@[typedef]
pub struct C.COORD {
mut:
X i16
Y i16
}
@[typedef]
pub struct C.SMALL_RECT {
mut:
Left u16
Top u16
Right u16
Bottom u16
}
@[typedef]
pub struct C.CONSOLE_SCREEN_BUFFER_INFO {
mut:
dwSize C.COORD
dwCursorPosition C.COORD
wAttributes u16
srWindow C.SMALL_RECT
dwMaximumWindowSize C.COORD
}
fn C.ReadConsoleInput(hConsoleInput C.HANDLE, lpBuffer &C.INPUT_RECORD, nLength u32, lpNumberOfEventsRead &u32) bool
fn C.GetNumberOfConsoleInputEvents(hConsoleInput C.HANDLE, lpcNumberOfEvents &u32) bool
fn C.GetConsoleScreenBufferInfo(handle C.HANDLE, info &C.CONSOLE_SCREEN_BUFFER_INFO) bool
+268
View File
@@ -0,0 +1,268 @@
// Copyright (c) 2020-2024 Raúl Hernández. All rights reserved.
// Use of this source code is governed by an MIT license
// that can be found in the LICENSE file.
module ui
import os
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
f13 = 302
f14 = 303
f15 = 304
f16 = 305
f17 = 306
f18 = 307
f19 = 308
f20 = 309
f21 = 310
f22 = 311
f23 = 312
f24 = 313
}
pub enum Direction {
unknown
up
down
left
right
}
pub enum MouseButton {
unknown
left
middle
right
}
pub enum EventType {
unknown
mouse_down
mouse_up
mouse_move
mouse_drag
mouse_scroll
key_down
key_up
resized
}
@[flag]
pub enum Modifiers {
ctrl
shift
alt
}
struct TerminalCapabilities {
enable_ansi256 bool = true
supports_alternate_buffer bool = true
supports_sgr_mouse bool = true
supports_sync_updates bool = true
supports_window_title bool = true
}
fn terminal_capabilities_for(term_name string) TerminalCapabilities {
if term_name == 'linux' {
return TerminalCapabilities{
enable_ansi256: false
supports_alternate_buffer: false
supports_sgr_mouse: false
supports_sync_updates: false
supports_window_title: false
}
}
return TerminalCapabilities{}
}
fn current_terminal_capabilities() TerminalCapabilities {
return terminal_capabilities_for(os.getenv('TERM'))
}
pub struct Event {
pub:
typ EventType
// Mouse event info
x int
y int
button MouseButton
direction Direction
// Keyboard event info
code KeyCode
modifiers Modifiers
ascii u8
utf8 string
// Resized event info
width int
height int
}
pub struct Context {
ExtraContext // contains fields specific to an implementation
pub:
cfg Config // the initial configuration, passed to ui.init()
mut:
print_buf []u8
paused bool
enable_su bool
enable_rgb bool
enable_ansi256 bool = true
supports_alternate_buffer bool = true
supports_sgr_mouse bool = true
supports_sync_updates bool = true
supports_window_title bool = true
pub mut:
frame_count u64
window_width int
window_height int
}
pub struct Config {
pub:
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
window_title string
hide_cursor bool
capture_events bool
mouse_enabled bool
use_alternate_buffer bool = true
skip_init_checks bool
// All kill signals to set up exit listeners on:
reset []os.Signal = [.hup, .int, .quit, .ill, .abrt, .bus, .fpe, .kill, .segv, .pipe, .alrm, .term,
.stop]
}
@[inline]
fn (ctx &Context) init() {
f := ctx.cfg.init_fn or { return }
f(ctx.cfg.user_data)
}
@[inline]
fn (ctx &Context) frame() {
f := ctx.cfg.frame_fn or { return }
f(ctx.cfg.user_data)
}
@[inline]
fn (ctx &Context) cleanup() {
f := ctx.cfg.cleanup_fn or { return }
f(ctx.cfg.user_data)
}
@[inline]
fn (ctx &Context) fail(error string) {
f := ctx.cfg.fail_fn or { return }
f(error)
}
@[inline]
fn (ctx &Context) event(event &Event) {
f := ctx.cfg.event_fn or { return }
f(event, ctx.cfg.user_data)
}
+85
View File
@@ -0,0 +1,85 @@
// Copyright (c) 2020-2024 Raúl Hernández. All rights reserved.
// Use of this source code is governed by an MIT license
// that can be found in the LICENSE file.
@[has_globals]
module ui
struct ExtraContext {
mut:
read_buf []u8
// read_all_bytes causes all the raw bytes to be read as one event unit.
// This is cruicial for UTF-8 support since Unicode codepoints can span several bytes.
read_all_bytes bool = true
}
__global ctx_ptr = &Context(unsafe { nil })
// init initializes the terminal console with Config `cfg`.
pub fn init(cfg Config) &Context {
caps := current_terminal_capabilities()
mut ctx := &Context{
cfg: cfg
enable_ansi256: caps.enable_ansi256
supports_alternate_buffer: caps.supports_alternate_buffer
supports_sgr_mouse: caps.supports_sgr_mouse
supports_sync_updates: caps.supports_sync_updates
supports_window_title: caps.supports_window_title
}
ctx.read_buf = []u8{cap: cfg.buffer_size}
ctx_ptr = ctx
return ctx
}
@[inline]
fn save_title() {
mut ctx := ctx_ptr
if unsafe { ctx == 0 } || !ctx.supports_window_title {
return
}
// restore the previously saved terminal title
print('\x1b[22;0t')
flush_stdout()
}
@[inline]
fn load_title() {
mut ctx := ctx_ptr
if unsafe { ctx == 0 } || !ctx.supports_window_title {
return
}
// restore the previously saved terminal title
print('\x1b[23;0t')
flush_stdout()
}
// run sets up and starts the terminal.
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() or { panic(err) }
ctx.termios_loop()
}
}
// shifts the array left, to remove any data that was just read, and updates its len
// TODO: remove
@[inline]
fn (mut ctx Context) shift(len int) {
unsafe {
C.memmove(ctx.read_buf.data, &u8(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 := unsafe { &ctx.read_buf.len }
unsafe {
*l = size
_ = l
}
}
+103
View File
@@ -0,0 +1,103 @@
module ui
fn test_escape_sequence_parses_csi_u_plain_key() {
seq := '\x1b[97;1u'
event, len := escape_sequence(seq)
assert len == seq.len
assert event.typ == .key_down
assert event.code == .a
assert event.modifiers.is_empty()
assert event.utf8 == seq
}
fn test_escape_sequence_parses_csi_u_plain_key_without_modifier_param() {
seq := '\x1b[27u'
event, len := escape_sequence(seq)
assert len == seq.len
assert event.typ == .key_down
assert event.code == .escape
assert event.modifiers.is_empty()
assert event.utf8 == seq
}
fn test_escape_sequence_parses_csi_u_modified_key() {
seq := '\x1b[97;5u'
event, len := escape_sequence(seq)
assert len == seq.len
assert event.typ == .key_down
assert event.code == .a
assert event.modifiers.has(.ctrl)
assert !event.modifiers.has(.shift)
assert !event.modifiers.has(.alt)
assert event.utf8 == seq
}
fn test_escape_sequence_parses_csi_u_key_release() {
seq := '\x1b[97;1:3u'
event, len := escape_sequence(seq)
assert len == seq.len
assert event.typ == .key_up
assert event.code == .a
assert event.modifiers.is_empty()
assert event.utf8 == seq
}
fn test_escape_sequence_parses_csi_u_key_repeat_with_text() {
seq := '\x1b[97;1:2;97u'
event, len := escape_sequence(seq)
assert len == seq.len
assert event.typ == .key_down
assert event.code == .a
assert event.ascii == `a`
assert event.utf8 == 'a'
}
fn test_escape_sequence_parses_csi_u_shifted_text_with_associated_text() {
seq := '\x1b[97;2;65u'
event, len := escape_sequence(seq)
assert len == seq.len
assert event.typ == .key_down
assert event.code == .a
assert event.ascii == `A`
assert event.modifiers == .shift
assert event.utf8 == 'A'
}
fn test_escape_sequence_parses_csi_u_enter_key() {
seq := '\x1b[13;1u'
event, len := escape_sequence(seq)
assert len == seq.len
assert event.typ == .key_down
assert event.code == .enter
assert event.utf8 == seq
}
fn test_escape_sequence_parses_modify_other_keys_sequence() {
seq := '\x1b[27;3;97~'
event, len := escape_sequence(seq)
assert len == seq.len
assert event.typ == .key_down
assert event.code == .a
assert event.modifiers == .alt
assert event.utf8 == seq
}
fn test_escape_sequence_parses_modified_special_key_sequence() {
seq := '\x1b[3;5~'
event, len := escape_sequence(seq)
assert len == seq.len
assert event.typ == .key_down
assert event.code == .delete
assert event.modifiers == .ctrl
assert event.utf8 == seq
}
fn test_escape_sequence_parses_modified_special_key_release_sequence() {
seq := '\x1b[1;5:3A'
event, len := escape_sequence(seq)
assert len == seq.len
assert event.typ == .key_up
assert event.code == .up
assert event.modifiers == .ctrl
assert event.utf8 == seq
}
+344
View File
@@ -0,0 +1,344 @@
// Copyright (c) 2020-2024 Raúl Hernández. All rights reserved.
// Use of this source code is governed by an MIT license
// that can be found in the LICENSE file.
@[has_globals]
module ui
import os
import time
const buf_size = 64
__global ctx_ptr = &Context(unsafe { nil })
__global stdin_at_startup = u32(0)
struct ExtraContext {
mut:
stdin_handle C.HANDLE
stdout_handle C.HANDLE
read_buf [buf_size]C.INPUT_RECORD
mouse_down MouseButton
}
fn restore_terminal_state() {
if unsafe { ctx_ptr != 0 } {
if ctx_ptr.cfg.use_alternate_buffer {
// clear the terminal and set the cursor to the origin
print('\x1b[2J\x1b[3J')
print('\x1b[?1049l')
flush_stdout()
}
C.SetConsoleMode(ctx_ptr.stdin_handle, stdin_at_startup)
}
load_title()
os.flush()
}
// init initializes the context of a windows console given the `cfg`.
pub fn init(cfg Config) &Context {
mut ctx := &Context{
cfg: cfg
}
// get the standard input handle
stdin_handle := C.GetStdHandle(C.STD_INPUT_HANDLE)
stdout_handle := C.GetStdHandle(C.STD_OUTPUT_HANDLE)
if stdin_handle == C.INVALID_HANDLE_VALUE {
panic('could not get stdin handle')
}
// save the current input mode, to be restored on exit
if !C.GetConsoleMode(stdin_handle, &stdin_at_startup) {
panic('could not get stdin console mode')
}
// enable extended input flags (see https://stackoverflow.com/a/46802726)
// 0x80 == C.ENABLE_EXTENDED_FLAGS
if !C.SetConsoleMode(stdin_handle, 0x80) {
panic('could not set raw input mode')
}
mut input_mode := u32(C.ENABLE_WINDOW_INPUT)
if ctx.cfg.mouse_enabled {
input_mode |= u32(C.ENABLE_MOUSE_INPUT)
}
// enable window input and optionally mouse input events.
if !C.SetConsoleMode(stdin_handle, input_mode) {
panic('could not set raw input mode')
}
// store the current title, so restore_terminal_state can get it back
save_title()
if ctx.cfg.use_alternate_buffer {
// switch to the alternate buffer
print('\x1b[?1049h')
// clear the terminal and set the cursor to the origin
print('\x1b[2J\x1b[3J\x1b[1;1H')
flush_stdout()
}
if ctx.cfg.hide_cursor {
ctx.hide_cursor()
ctx.flush()
}
if ctx.cfg.window_title != '' {
print('\x1b]0;${ctx.cfg.window_title}\x07')
flush_stdout()
}
ctx_ptr = ctx
at_exit(restore_terminal_state) or {}
for code in ctx.cfg.reset {
os.signal_opt(code, fn (_ os.Signal) {
mut c := ctx_ptr
if unsafe { c != 0 } {
c.cleanup()
}
exit(0)
}) or {}
}
ctx.stdin_handle = stdin_handle
ctx.stdout_handle = stdout_handle
return ctx
}
// run starts the windows console or restarts if it was paused.
pub fn (mut ctx Context) run() ! {
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
}
if sleep_len > 0 {
time.sleep(sleep_len * time.microsecond)
}
if !ctx.paused {
sw.restart()
if ctx.cfg.event_fn != none {
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() {
nr_events := u32(0)
if !C.GetNumberOfConsoleInputEvents(ctx.stdin_handle, &nr_events) {
panic('could not get number of events in stdin')
}
if nr_events < 1 {
return
}
// print('${nr_events} | ')
if !C.ReadConsoleInput(ctx.stdin_handle, &ctx.read_buf[0], buf_size, &nr_events) {
panic('could not read from stdin')
}
for i in 0 .. nr_events {
// print('E ')
match int(ctx.read_buf[i].EventType) {
C.KEY_EVENT {
e := unsafe { ctx.read_buf[i].Event.KeyEvent }
ch := e.wVirtualKeyCode
ascii := unsafe { e.uChar.AsciiChar }
code := match int(ch) {
C.VK_BACK { KeyCode.backspace }
C.VK_RETURN { KeyCode.enter }
C.VK_PRIOR { KeyCode.page_up }
14...20 { KeyCode.null }
C.VK_NEXT { KeyCode.page_down }
C.VK_END { KeyCode.end }
C.VK_HOME { KeyCode.home }
C.VK_LEFT { KeyCode.left }
C.VK_UP { KeyCode.up }
C.VK_RIGHT { KeyCode.right }
C.VK_DOWN { KeyCode.down }
C.VK_INSERT { KeyCode.insert }
C.VK_DELETE { KeyCode.delete }
65...90 { unsafe { KeyCode(ch + 32) } } // letters
91...93 { KeyCode.null } // special keys
96...105 { unsafe { KeyCode(ch - 48) } } // numpad numbers
112...135 { unsafe { KeyCode(ch + 178) } } // f1 - f24
else { unsafe { KeyCode(ascii) } }
}
mut modifiers := unsafe { Modifiers(0) }
if e.dwControlKeyState & (0x1 | 0x2) != 0 {
modifiers.set(.alt)
}
if e.dwControlKeyState & (0x4 | 0x8) != 0 {
modifiers.set(.ctrl)
}
if e.dwControlKeyState & 0x10 != 0 {
modifiers.set(.shift)
}
event_type := if e.bKeyDown == 0 {
EventType.key_up
} else {
EventType.key_down
}
repeat_count := if event_type == .key_down && e.wRepeatCount > 0 {
int(e.wRepeatCount)
} else {
1
}
for _ in 0 .. repeat_count {
mut event := &Event{
typ: event_type
modifiers: modifiers
code: code
ascii: ascii
width: int(e.dwControlKeyState)
height: int(e.wVirtualKeyCode)
utf8: unsafe { e.uChar.UnicodeChar.str() }
}
ctx.event(event)
}
}
C.MOUSE_EVENT {
e := unsafe { ctx.read_buf[i].Event.MouseEvent }
sb_info := C.CONSOLE_SCREEN_BUFFER_INFO{}
if !C.GetConsoleScreenBufferInfo(ctx.stdout_handle, &sb_info) {
panic('could not get screenbuffer info')
}
x := e.dwMousePosition.X + 1
y := int(e.dwMousePosition.Y) - sb_info.srWindow.Top + 1
mut modifiers := unsafe { Modifiers(0) }
if e.dwControlKeyState & (0x1 | 0x2) != 0 {
modifiers.set(.alt)
}
if e.dwControlKeyState & (0x4 | 0x8) != 0 {
modifiers.set(.ctrl)
}
if e.dwControlKeyState & 0x10 != 0 {
modifiers.set(.shift)
}
// TODO: handle capslock/numlock/etc?? events exist for those keys
match int(e.dwEventFlags) {
C.MOUSE_MOVED {
mut button := match int(e.dwButtonState) {
0 { MouseButton.unknown }
1 { MouseButton.left }
2 { MouseButton.right }
else { MouseButton.middle }
}
typ := if e.dwButtonState == 0 {
if ctx.mouse_down != .unknown {
button = ctx.mouse_down
ctx.mouse_down = .unknown
EventType.mouse_up
} else {
EventType.mouse_move
}
} else {
EventType.mouse_drag
}
ctx.event(&Event{
typ: typ
x: x
y: y
button: button
modifiers: modifiers
})
}
C.MOUSE_WHEELED {
ctx.event(&Event{
typ: .mouse_scroll
direction: if i16(e.dwButtonState >> 16) < 0 {
Direction.up
} else {
Direction.down
}
x: x
y: y
modifiers: modifiers
})
}
0x0008 { // C.MOUSE_HWHEELED
ctx.event(&Event{
typ: .mouse_scroll
direction: if i16(e.dwButtonState >> 16) < 0 {
Direction.right
} else {
Direction.left
}
x: x
y: y
modifiers: modifiers
})
}
0, C.DOUBLE_CLICK {
button := match int(e.dwButtonState) {
0 { ctx.mouse_down }
1 { MouseButton.left }
2 { MouseButton.right }
else { MouseButton.middle }
}
ctx.mouse_down = button
ctx.event(&Event{
typ: .mouse_down
x: x
y: y
button: button
modifiers: modifiers
})
}
else {}
}
}
C.WINDOW_BUFFER_SIZE_EVENT {
// e := unsafe { ctx.read_buf[i].Event.WindowBufferSizeEvent }
sb := C.CONSOLE_SCREEN_BUFFER_INFO{}
if !C.GetConsoleScreenBufferInfo(ctx.stdout_handle, &sb) {
panic('could not get screenbuffer info')
}
w := sb.srWindow.Right - sb.srWindow.Left + 1
h := sb.srWindow.Bottom - sb.srWindow.Top + 1
utf8 := '(${ctx.window_width}, ${ctx.window_height}) -> (${w}, ${h})'
if w != ctx.window_width || h != ctx.window_height {
ctx.window_width, ctx.window_height = w, h
mut event := &Event{
typ: .resized
width: ctx.window_width
height: ctx.window_height
utf8: utf8
}
ctx.event(event)
}
}
// C.MENU_EVENT {
// e := unsafe { ctx.read_buf[i].Event.MenuEvent }
// }
// C.FOCUS_EVENT {
// e := unsafe { ctx.read_buf[i].Event.FocusEvent }
// }
else {}
}
}
}
@[inline]
fn save_title() {
// restore the previously saved terminal title
print('\x1b[22;0t')
flush_stdout()
}
@[inline]
fn load_title() {
// restore the previously saved terminal title
print('\x1b[23;0t')
flush_stdout()
}
+13
View File
@@ -0,0 +1,13 @@
module ui
fn test_mouse_tracking_is_disabled_by_default() {
cfg := Config{}
assert !cfg.mouse_enabled
}
fn test_mouse_tracking_can_be_enabled_explicitly() {
cfg := Config{
mouse_enabled: true
}
assert cfg.mouse_enabled
}
+765
View File
@@ -0,0 +1,765 @@
// Copyright (c) 2020-2024 Raúl Hernández. All rights reserved.
// Use of this source code is governed by an MIT license
// that can be found in the LICENSE file.
module ui
import os
import strings
import time
import term.termios
#include <signal.h>
pub struct C.winsize {
ws_row u16
ws_col u16
}
const termios_at_startup = get_termios()
const kitty_keyboard_flags = 0b10 | 0b1000 | 0b10000
@[inline]
fn get_termios() termios.Termios {
mut t := termios.Termios{}
termios.tcgetattr(C.STDIN_FILENO, mut t)
return t
}
@[inline]
fn get_terminal_size() (u16, u16) {
winsz := C.winsize{}
termios.ioctl(0, u64(termios.flag(C.TIOCGWINSZ)), voidptr(&winsz))
return winsz.ws_row, winsz.ws_col
}
fn restore_terminal_state_signal(_ os.Signal) {
restore_terminal_state()
}
fn restore_terminal_state() {
termios_reset()
mut c := ctx_ptr
if unsafe { c != 0 } {
c.paused = true
load_title()
}
os.flush()
}
fn (mut ctx Context) termios_setup() ! {
// store the current title, so restore_terminal_state can get it back
save_title()
if !ctx.cfg.skip_init_checks && !(os.is_atty(C.STDIN_FILENO) != 0
&& os.is_atty(C.STDOUT_FILENO) != 0) {
return error('not running under a TTY')
}
mut tios := 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
tios.c_iflag &=
termios.invert(termios.flag(int(C.IGNBRK) | int(C.BRKINT) | int(C.PARMRK) | int(C.IXON)))
tios.c_lflag &=
termios.invert(termios.flag(int(C.ICANON) | int(C.ISIG) | int(C.ECHO) | int(C.IEXTEN) | int(C.TOSTOP)))
} else {
// Set raw input mode by unsetting ICANON and ECHO
tios.c_lflag &= termios.invert(termios.flag(int(C.ICANON) | int(C.ECHO)))
}
if ctx.cfg.hide_cursor {
ctx.hide_cursor()
ctx.flush()
}
if ctx.cfg.window_title != '' {
print('\x1b]0;${ctx.cfg.window_title}\x07')
flush_stdout()
}
if !ctx.cfg.skip_init_checks {
// prevent blocking during the feature detections, but allow enough time for the terminal
// to send back the relevant input data
tios.c_cc[C.VTIME] = 1
tios.c_cc[C.VMIN] = 0
termios.tcsetattr(C.STDIN_FILENO, C.TCSAFLUSH, mut tios)
// feature-test the SU spec
sx, sy := get_cursor_position()
print('${bsu}${esu}')
flush_stdout()
ex, ey := get_cursor_position()
if sx == ex && sy == ey {
// the terminal either ignored or handled the sequence properly, enable SU
ctx.enable_su = true
} else {
ctx.draw_line(sx, sy, ex, ey)
ctx.set_cursor_position(sx, sy)
ctx.flush()
}
// feature-test rgb (truecolor) support
ctx.enable_rgb = supports_truecolor()
}
// Prevent stdin from blocking by making its read time 0
tios.c_cc[C.VTIME] = 0
tios.c_cc[C.VMIN] = 0
termios.tcsetattr(C.STDIN_FILENO, C.TCSAFLUSH, mut tios)
if ctx.cfg.mouse_enabled {
// enable mouse input
print('\x1b[?1003h\x1b[?1006h')
flush_stdout()
}
if ctx.cfg.use_alternate_buffer {
// switch to the alternate buffer
print('\x1b[?1049h')
flush_stdout()
// clear the terminal and set the cursor to the origin
print('\x1b[2J\x1b[3J\x1b[1;1H')
flush_stdout()
}
if ctx.cfg.capture_events {
// Ask supporting terminals to report press/repeat/release in CSI-u form.
print('\x1b[>${kitty_keyboard_flags}u')
flush_stdout()
}
ctx.window_height, ctx.window_width = get_terminal_size()
// Reset console on exit
at_exit(restore_terminal_state) or {}
os.signal_opt(.tstp, restore_terminal_state_signal) or {}
os.signal_opt(.cont, fn (_ os.Signal) {
mut c := ctx_ptr
if unsafe { c != 0 } {
c.termios_setup() or { panic(err) }
c.window_height, c.window_width = get_terminal_size()
mut event := &Event{
typ: .resized
width: c.window_width
height: c.window_height
}
c.paused = false
c.event(event)
}
}) or {}
for code in ctx.cfg.reset {
os.signal_opt(code, fn (_ os.Signal) {
mut c := ctx_ptr
if unsafe { c != 0 } {
c.cleanup()
}
exit(0)
}) or {}
}
os.signal_opt(.winch, fn (_ os.Signal) {
mut c := ctx_ptr
if unsafe { 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)
}
}) or {}
os.flush()
}
fn get_cursor_position() (int, int) {
print('\033[6n')
flush_stdout()
mut s := ''
unsafe {
buf := malloc_noscan(25)
len := C.read(C.STDIN_FILENO, buf, 24)
buf[len] = 0
s = tos(buf, len)
}
if s.len == 0 {
return -1, -1
}
a := s[2..].split(';')
if a.len != 2 {
return -1, -1
}
return a[0].int(), a[1].int()
}
fn supports_truecolor() bool {
// faster/simpler, but less reliable, check
if os.getenv('COLORTERM') in ['truecolor', '24bit'] {
return true
}
// set the bg color to some arbitrary value (#010203), assumed not to be the default
print('\x1b[48:2:1:2:3m')
flush_stdout()
// andquery the current color
print('\x1bP\$qm\x1b\\')
flush_stdout()
mut s := ''
unsafe {
buf := malloc_noscan(25)
len := C.read(C.STDIN_FILENO, buf, 24)
buf[len] = 0
s = tos(buf, len)
}
return s.contains('1:2:3')
}
fn termios_reset() {
// C.TCSANOW ??
mut startup := termios_at_startup
termios.tcsetattr(C.STDIN_FILENO, C.TCSAFLUSH, mut startup)
c := ctx_ptr
if unsafe { c != 0 } && c.cfg.capture_events {
// Pop the keyboard mode stack before leaving the current screen.
print('\x1b[<u')
}
print('\x1b[?1003l\x1b[?1006l\x1b[?25h')
flush_stdout()
if unsafe { c != 0 } && c.cfg.use_alternate_buffer {
print('\x1b[?1049l')
}
os.flush()
}
///////////////////////////////////////////
// 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.sleep(sleep_len * time.microsecond)
}
if !ctx.paused {
sw.restart()
if ctx.cfg.event_fn != none {
unsafe {
len := C.read(C.STDIN_FILENO, &u8(ctx.read_buf.data) + ctx.read_buf.len,
ctx.read_buf.cap - ctx.read_buf.len)
ctx.resize_arr(ctx.read_buf.len + len)
}
if ctx.read_buf.len > 0 {
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(unsafe { nil })
if ctx.read_buf[0] == 0x1b {
e, len := escape_sequence(ctx.read_buf.bytestr())
event = unsafe { e }
ctx.shift(len)
} else {
if ctx.read_all_bytes {
e, len := multi_char(ctx.read_buf.bytestr())
event = unsafe { e }
ctx.shift(len)
} else {
event = single_char(ctx.read_buf.bytestr())
ctx.shift(1)
}
}
if unsafe { 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: unsafe { KeyCode(ch) }
utf8: ch.ascii_str()
}
match ch {
// special handling for `ctrl + letter`
// TODO: Fix assoc in V and remove this workaround :/
// 1 ... 26 { event = Event{ ...event, code: KeyCode(96 | ch), modifiers: .ctrl } }
// 65 ... 90 { event = 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
// don't treat tab, enter as ctrl+i, ctrl+j
1...8, 11...26 {
event = &Event{
typ: event.typ
ascii: event.ascii
utf8: event.utf8
code: unsafe { KeyCode(96 | ch) }
modifiers: .ctrl
}
}
65...90 {
event = &Event{
typ: event.typ
ascii: event.ascii
utf8: event.utf8
code: unsafe { KeyCode(32 | ch) }
modifiers: .shift
}
}
else {}
}
return event
}
fn multi_char(buf string) (&Event, int) {
ch := buf[0]
mut event := &Event{
typ: .key_down
ascii: ch
code: unsafe { KeyCode(ch) }
utf8: buf
}
match ch {
// special handling for `ctrl + letter`
// TODO: Fix assoc in V and remove this workaround :/
// 1 ... 26 { event = Event{ ...event, code: KeyCode(96 | ch), modifiers: .ctrl } }
// 65 ... 90 { event = 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
// don't treat tab, enter as ctrl+i, ctrl+j
1...8, 11...26 {
event = &Event{
typ: event.typ
ascii: event.ascii
utf8: event.utf8
code: unsafe { KeyCode(96 | ch) }
modifiers: .ctrl
}
}
65...90 {
event = &Event{
typ: event.typ
ascii: event.ascii
utf8: event.utf8
code: unsafe { KeyCode(32 | ch) }
modifiers: .shift
}
}
else {}
}
return event, buf.len
}
// 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() || buf[i] == `~` {
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
// escape hatch to avoid potential issues/crashes, although ideally this should never eval to true
} else if buf[i + 1] == 0x1b {
return i + 1
}
i++
}
// this point should be unreachable
assert false
return 0
}
@[inline]
fn modifiers_from_report_param(param int) Modifiers {
flags := if param > 0 { param - 1 } else { 0 }
mut modifiers := unsafe { Modifiers(0) }
if flags & 0b001 != 0 {
modifiers.set(.shift)
}
if flags & 0b010 != 0 {
modifiers.set(.alt)
}
if flags & 0b100 != 0 {
modifiers.set(.ctrl)
}
return modifiers
}
@[inline]
fn key_event_type_from_report_param(param int) EventType {
return match param {
3 { .key_up }
else { .key_down }
}
}
@[inline]
fn parse_key_report_param(param string) (Modifiers, EventType) {
if param.len == 0 {
return unsafe { Modifiers(0) }, EventType.key_down
}
parts := param.split(':')
modifiers := modifiers_from_report_param(parts[0].int())
event_type := if parts.len > 1 {
key_event_type_from_report_param(parts[1].int())
} else {
EventType.key_down
}
return modifiers, event_type
}
fn utf8_from_reported_text(param string) string {
if param.len == 0 {
return ''
}
mut builder := strings.new_builder(param.len)
for part in param.split(':') {
codepoint := part.int()
if codepoint <= 0 || codepoint > 0x10ffff {
continue
}
builder.write_string(utf32_to_str(u32(codepoint)))
}
return builder.str()
}
@[inline]
fn event_from_reported_key(codepoint int, raw string, modifiers Modifiers, event_type EventType, text string) &Event {
mut utf8 := raw
if text.len > 0 {
utf8 = text
}
mut ascii := u8(0)
if text.len == 1 {
ascii = text[0]
}
if codepoint <= 0 || codepoint > 0x10ffff {
return &Event{
typ: event_type
ascii: ascii
utf8: utf8
modifiers: modifiers
}
}
if codepoint <= 255 {
ch := u8(codepoint)
if ch == `\r` {
return &Event{
typ: event_type
ascii: ch
code: .enter
utf8: utf8
modifiers: modifiers
}
}
base := single_char(ch.ascii_str())
event_ascii := if ascii != 0 { ascii } else { base.ascii }
return &Event{
typ: event_type
ascii: event_ascii
code: base.code
utf8: utf8
modifiers: base.modifiers | modifiers
}
}
return &Event{
typ: event_type
ascii: ascii
utf8: utf8
modifiers: modifiers
}
}
fn parse_csi_u_key_sequence(single string, buf string) &Event {
if buf.len < 4 || buf[0] != `[` || buf[buf.len - 1] != `u` {
return unsafe { nil }
}
parts := buf[1..buf.len - 1].split(';')
if parts.len < 1 || parts.len > 3 {
return unsafe { nil }
}
codepoint := parts[0].split(':')[0].int()
mut modifiers := unsafe { Modifiers(0) }
mut event_type := EventType.key_down
if parts.len > 1 {
modifiers, event_type = parse_key_report_param(parts[1])
}
text := if parts.len > 2 { utf8_from_reported_text(parts[2]) } else { '' }
return event_from_reported_key(codepoint, single, modifiers, event_type, text)
}
fn parse_modify_other_keys_sequence(single string, buf string) &Event {
if buf.len < 7 || buf[0] != `[` || buf[buf.len - 1] != `~` {
return unsafe { nil }
}
parts := buf[1..buf.len - 1].split(';')
if parts.len != 3 || parts[0] != '27' {
return unsafe { nil }
}
return event_from_reported_key(parts[2].int(), single,
modifiers_from_report_param(parts[1].int()), .key_down, '')
}
@[inline]
fn key_code_from_csi_final(final u8) KeyCode {
return match final {
`A` { .up }
`B` { .down }
`C` { .right }
`D` { .left }
`F` { .end }
`H` { .home }
`P` { .f1 }
`Q` { .f2 }
`R` { .f3 }
`S` { .f4 }
else { .null }
}
}
@[inline]
fn key_code_from_tilde_param(param string) KeyCode {
return match param {
'2' { .insert }
'3' { .delete }
'5' { .page_up }
'6' { .page_down }
'11' { .f1 }
'12' { .f2 }
'13' { .f3 }
'14' { .f4 }
'15' { .f5 }
'17' { .f6 }
'18' { .f7 }
'19' { .f8 }
'20' { .f9 }
'21' { .f10 }
'23' { .f11 }
'24' { .f12 }
else { .null }
}
}
fn parse_csi_modified_key_sequence(buf string) (KeyCode, Modifiers, EventType, bool) {
if buf.len < 5 || buf[0] != `[` {
return KeyCode.null, unsafe { Modifiers(0) }, EventType.key_down, false
}
final := buf[buf.len - 1]
params := buf[1..buf.len - 1].split(';')
if params.len != 2 {
return KeyCode.null, unsafe { Modifiers(0) }, EventType.key_down, false
}
modifiers, event_type := parse_key_report_param(params[1])
if final == `~` {
code := key_code_from_tilde_param(params[0])
return code, modifiers, event_type, code != .null
}
code := key_code_from_csi_final(final)
return code, modifiers, event_type, code != .null
}
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)
mut modifiers := c.modifiers
modifiers.set(.alt)
return &Event{
typ: c.typ
ascii: c.ascii
code: c.code
utf8: single
modifiers: modifiers
}, 2
}
// ----------------
// Mouse events
// ----------------
// Documentation: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Mouse-Tracking
if buf.len > 2 && buf[1] == `<` {
split := buf[2..].split(';')
if split.len < 3 {
return &Event(unsafe { nil }), 0
}
typ, x, y := split[0].int(), split[1].int(), split[2].int()
lo := typ & 0b00011
hi := typ & 0b11100
mut modifiers := unsafe { Modifiers(0) }
if hi & 4 != 0 {
modifiers.set(.shift)
}
if hi & 8 != 0 {
modifiers.set(.alt)
}
if hi & 16 != 0 {
modifiers.set(.ctrl)
}
match typ {
0...31 {
last := buf[buf.len - 1]
button := if lo < 3 { unsafe { MouseButton(lo + 1) } } else { MouseButton.unknown }
event := if last == `m` || lo == 3 {
EventType.mouse_up
} else {
EventType.mouse_down
}
return &Event{
typ: event
x: x
y: y
button: button
modifiers: modifiers
utf8: single
}, end
}
32...63 {
button, event := if lo < 3 {
unsafe { MouseButton(lo + 1), EventType.mouse_drag }
} else {
MouseButton.unknown, EventType.mouse_move
}
return &Event{
typ: event
x: x
y: y
button: button
modifiers: modifiers
utf8: single
}, end
}
64...95 {
direction := if typ & 1 == 0 { Direction.down } else { Direction.up }
return &Event{
typ: .mouse_scroll
x: x
y: y
direction: direction
modifiers: modifiers
utf8: single
}, end
}
else {
return &Event{
typ: .unknown
utf8: single
}, end
}
}
}
// ----------------------------
// Special key combinations
// ----------------------------
e := parse_csi_u_key_sequence(single, buf)
if unsafe { e != nil } {
return e, end
}
e2 := parse_modify_other_keys_sequence(single, buf)
if unsafe { e2 != nil } {
return e2, end
}
mut code := KeyCode.null
mut modifiers := unsafe { Modifiers(0) }
mut event_type := EventType.key_down
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 == '[Z' {
code = .tab
modifiers.set(.shift)
}
if code == .null {
parsed_code, parsed_modifiers, parsed_event_type, ok := parse_csi_modified_key_sequence(buf)
if ok {
code = parsed_code
modifiers = parsed_modifiers
event_type = parsed_event_type
}
}
return &Event{
typ: event_type
code: code
utf8: single
modifiers: modifiers
}, end
}
+75
View File
@@ -0,0 +1,75 @@
module ui
import os
fn test_terminal_capabilities_disable_xterm_features_for_linux_console() {
caps := terminal_capabilities_for('linux')
assert !caps.enable_ansi256
assert !caps.supports_alternate_buffer
assert !caps.supports_sgr_mouse
assert !caps.supports_sync_updates
assert !caps.supports_window_title
}
fn test_terminal_capabilities_keep_xterm_defaults() {
caps := terminal_capabilities_for('xterm-256color')
assert caps.enable_ansi256
assert caps.supports_alternate_buffer
assert caps.supports_sgr_mouse
assert caps.supports_sync_updates
assert caps.supports_window_title
}
fn test_get_cursor_position_reads_valid_row_column_data() ! {
mut original_stdin_fd := -1
unsafe {
mut pipe := os.pipe()!
fake_cursor_pos_data := '\033[45;70R'
written_bytes := pipe.write_string(fake_cursor_pos_data)!
if written_bytes == -1 {
pipe.close()
return error('error writing into pipe: ${C.strerror(C.errno)}')
}
C.close(pipe.write_fd)
if C.dup2(pipe.read_fd, C.STDIN_FILENO) == -1 {
C.close(pipe.read_fd)
return error('error redirecting stdin with dup2: ${C.strerror(C.errno)}')
}
C.close(pipe.read_fd)
cursor_pos_x, cursor_pos_y := get_cursor_position()
assert cursor_pos_x == 45
assert cursor_pos_y == 70
}
}
fn test_get_cursor_position_reads_empty_position_data() ! {
mut original_stdin_fd := -1
unsafe {
mut pipe := os.pipe()!
fake_cursor_pos_data := ''
written_bytes := pipe.write_string(fake_cursor_pos_data)!
if written_bytes == -1 {
pipe.close()
return error('error writing into pipe: ${C.strerror(C.errno)}')
}
C.close(pipe.write_fd)
if C.dup2(pipe.read_fd, C.STDIN_FILENO) == -1 {
C.close(pipe.read_fd)
return error('error redirecting stdin with dup2: ${C.strerror(C.errno)}')
}
C.close(pipe.read_fd)
cursor_pos_x, cursor_pos_y := get_cursor_position()
assert cursor_pos_x == -1
assert cursor_pos_y == -1
}
}
+284
View File
@@ -0,0 +1,284 @@
// Copyright (c) 2020-2024 Raúl Hernández. All rights reserved.
// Use of this source code is governed by an MIT license
// that can be found in the LICENSE file.
module ui
import strings
pub struct Color {
pub:
r u8
g u8
b u8
}
// hex returns `c`'s RGB color in hex format.
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
// The alternate `CSI ? 2026 h/l` sequences are preferred when no parameters
// are passed, since they are correctly interpreted by terminals like screen.
const bsu = '\x1b[?2026h'
const esu = '\x1b[?2026l'
// write puts the string `s` into the print buffer.
@[inline]
pub fn (mut ctx Context) write(s string) {
if s == '' {
return
}
unsafe { ctx.print_buf.push_many(s.str, s.len) }
}
// flush displays the accumulated print buffer to the screen.
@[inline]
pub fn (mut ctx Context) flush() {
// TODO: Diff the previous frame against this one, and only render things that changed?
if !ctx.enable_su {
C.write(1, ctx.print_buf.data, ctx.print_buf.len)
} else {
C.write(1, bsu.str, bsu.len)
C.write(1, ctx.print_buf.data, ctx.print_buf.len)
C.write(1, esu.str, esu.len)
}
ctx.print_buf.clear()
}
// bold sets the character state to bold.
@[inline]
pub fn (mut ctx Context) bold() {
ctx.write('\x1b[1m')
}
// set_cursor_position positions the cusor at the given coordinates `x`,`y`.
@[inline]
pub fn (mut ctx Context) set_cursor_position(x int, y int) {
ctx.write('\x1b[${y};${x}H')
}
// show_cursor will make the cursor appear if it is not already visible
@[inline]
pub fn (mut ctx Context) show_cursor() {
ctx.write('\x1b[?25h')
}
// hide_cursor will make the cursor invisible
@[inline]
pub fn (mut ctx Context) hide_cursor() {
ctx.write('\x1b[?25l')
}
// set_color sets the current foreground color used by any succeeding `draw_*` calls.
@[inline]
pub fn (mut ctx Context) set_color(c Color) {
if ctx.enable_rgb {
ctx.write('\x1b[38;2;${int(c.r)};${int(c.g)};${int(c.b)}m')
} else if ctx.enable_ansi256 {
ctx.write('\x1b[38;5;${rgb2ansi(c.r, c.g, c.b)}m')
} else {
ctx.write('\x1b[${30 + rgb2basic_ansi(c.r, c.g, c.b)}m')
}
}
// set_color sets the current background color used by any succeeding `draw_*` calls.
@[inline]
pub fn (mut ctx Context) set_bg_color(c Color) {
if ctx.enable_rgb {
ctx.write('\x1b[48;2;${int(c.r)};${int(c.g)};${int(c.b)}m')
} else if ctx.enable_ansi256 {
ctx.write('\x1b[48;5;${rgb2ansi(c.r, c.g, c.b)}m')
} else {
ctx.write('\x1b[${40 + rgb2basic_ansi(c.r, c.g, c.b)}m')
}
}
// reset_color sets the current foreground color back to it's default value.
@[inline]
pub fn (mut ctx Context) reset_color() {
ctx.write('\x1b[39m')
}
// reset_bg_color sets the current background color back to it's default value.
@[inline]
pub fn (mut ctx Context) reset_bg_color() {
ctx.write('\x1b[49m')
}
// reset restores the state of all colors and text formats back to their default values.
@[inline]
pub fn (mut ctx Context) reset() {
ctx.write('\x1b[0m')
}
// clear erases the entire terminal window and any saved lines.
@[inline]
pub fn (mut ctx Context) clear() {
ctx.write('\x1b[2J\x1b[3J')
}
// set_window_title sets the string `s` as the window title.
@[inline]
pub fn (mut ctx Context) set_window_title(s string) {
if !ctx.supports_window_title {
return
}
print('\x1b]0;${s}\x07')
flush_stdout()
}
fn rgb2basic_ansi(r int, g int, b int) int {
mut best_index := 0
mut best_distance := -1
for i in 0 .. 8 {
ref := color_table[i]
dr := r - int((ref >> 16) & 0xff)
dg := g - int((ref >> 8) & 0xff)
db := b - int(ref & 0xff)
distance := dr * dr + dg * dg + db * db
if best_distance == -1 || distance < best_distance {
best_distance = distance
best_index = i
}
}
return best_index
}
// draw_point draws a point at position `x`,`y`.
@[inline]
pub fn (mut ctx Context) draw_point(x int, y int) {
ctx.set_cursor_position(x, y)
ctx.write(' ')
}
// draw_text draws the string `s`, starting from position `x`,`y`.
@[inline]
pub fn (mut ctx Context) draw_text(x int, y int, s string) {
ctx.set_cursor_position(x, y)
ctx.write(s)
}
// draw_line draws a line segment, starting at point `x`,`y`, and ending at point `x2`,`y2`.
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
}
}
}
// draw_dashed_line draws a dashed line segment, starting at point `x`,`y`, and ending at point `x2`,`y2`.
pub fn (mut ctx Context) draw_dashed_line(x int, y int, x2 int, y2 int) {
// 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
mut i := 0
for {
if i % 2 == 0 {
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
}
i++
}
}
// draw_rect draws a rectangle, starting at top left `x`,`y`, and ending at bottom right `x2`,`y2`.
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, y2 } else { y2, y }
for y_pos in min_y .. max_y + 1 {
ctx.draw_line(x, y_pos, x2, y_pos)
}
}
// draw_empty_dashed_rect draws a rectangle with dashed lines, starting at top left `x`,`y`, and ending at bottom right `x2`,`y2`.
pub fn (mut ctx Context) draw_empty_dashed_rect(x int, y int, x2 int, y2 int) {
if y == y2 || x == x2 {
ctx.draw_dashed_line(x, y, x2, y2)
return
}
min_x, max_x := if x < x2 { x, x2 } else { x2, x }
min_y, max_y := if y < y2 { y, y2 } else { y2, y }
ctx.draw_dashed_line(min_x, min_y, max_x, min_y)
ctx.draw_dashed_line(min_x, min_y, min_x, max_y)
if (max_y - min_y) & 1 == 0 {
ctx.draw_dashed_line(min_x, max_y, max_x, max_y)
} else {
ctx.draw_dashed_line(min_x + 1, max_y, max_x, max_y)
}
if (max_x - min_x) & 1 == 0 {
ctx.draw_dashed_line(max_x, min_y, max_x, max_y)
} else {
ctx.draw_dashed_line(max_x, min_y + 1, max_x, max_y)
}
}
// draw_empty_rect draws a rectangle with no fill, starting at top left `x`,`y`, and ending at bottom right `x2`,`y2`.
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)
}
// horizontal_separator draws a horizontal separator, spanning the width of the screen.
@[inline]
pub fn (mut ctx Context) horizontal_separator(y int) {
ctx.set_cursor_position(0, y)
ctx.write(strings.repeat(`-`, ctx.window_width)) // /* `⎽` */
}
+8
View File
@@ -0,0 +1,8 @@
import term.ui
// This test just ensures that programs importing term.ui can compile
fn test_a_simple_term_ui_program_can_be_compiled() {
println(ui.color_table)
assert true
}