#module_parameters(COLOR_MODE := 24); #scope_file // - fix/implement/finish `TODO` on `TUI\module` (use some sort of buffet to reduce io/print calls) // - fix/implement/finish `TODO` on `TUI\module` (use `dirty_bit_flag` to only update ehat has been changed) #if OS == { case .LINUX; #load "unix.jai"; case .MACOS; #load "unix.jai"; case .WINDOWS; #load "windows.jai"; case; #assert(false, "Unsupported OS."); } #import "Basic"; #import "String"; #import "Thread"; #import "UTF8"; #load "key_map.jai"; #run { assert(COLOR_MODE == 4 || COLOR_MODE == 8 || COLOR_MODE == 24, "Invalid COLOR_MODE. Valid values are 4, 8, or 24 (default)."); assert(input_buffer.count >= KEY_SIZE, "The input buffer size must be capable to hold an entire Key, which should be able to hold an UTF8 code (4 bytes) or a terminal escape code (6 bytes)."); } #scope_export; // Special Graphics Characters Drawings :: struct { Blank :: "\x5F"; Diamond :: "\x60"; Checkerboard :: "\x61"; HorizontalTab :: "\x62"; FormFeed :: "\x63"; CarriageReturn :: "\x64"; LineFeed :: "\x65"; DegreeSymbol :: "\x66"; PlusMinus :: "\x67"; NewLine :: "\x68"; VerticalTab :: "\x69"; CornerBR :: "\x6A"; CornerTR :: "\x6B"; CornerTL :: "\x6C"; CornerBL :: "\x6D"; Cross :: "\x6E"; LineHT :: "\x6F"; LineHt :: "\x70"; LineH :: "\x71"; LineHb :: "\x72"; LineHB :: "\x73"; TeeL :: "\x74"; TeeR :: "\x75"; TeeB :: "\x76"; TeeT :: "\x77"; LineV :: "\x78"; LessThanOrEqual :: "\x79"; GreaterThanOrEqual :: "\x7A"; Pi :: "\x7B"; NotEqual :: "\x7C"; PoundSign :: "\x7D"; CenteredDot :: "\x7E"; } Commands :: struct { // Screen buffers AlternateScreenBuffer :: "\e[?1049h"; MainScreenBuffer :: "\e[?1049l"; // Device. Bell :: "\x07"; QueryDeviceAttributes :: "\e[0c"; // Draw/text. DrawingMode :: "\e(0"; TextMode :: "\e(B"; ClearScreen :: "\e[2J"; ClearLine :: "\e[2K"; ClearScrollBack :: "\e[3J"; SetGraphicsRendition :: "\e[%m"; // Character encoding. EncodingIEC2022 :: "\e%@"; EncodingUTF8 :: "\e%G"; // Window. SetWindowTitle :: "\e]0;%\e\\"; RefreshWindow :: "\e[7t"; QueryWindowSizeInChars :: "\e[18t"; // Cursor position. SaveCursorPosition :: "\e7"; RestoreCursorPosition :: "\e8"; SetCursorPosition :: "\e[%;%H"; QueryCursorPosition :: "\e[6n"; // Cursor visibility. ShowCursor :: "\e[?25h"; HideCursor :: "\e[?25l"; StartBlinking :: "\e[?12h"; StopBlinking :: "\e[?12l"; // Cursor shape DefaultShape :: "\e[0 q"; BlinkingBlockShape :: "\e[1 q"; SteadyBlockShape :: "\e[2 q"; BlinkingUnderlineShape :: "\e[3 q"; SteadyUnderlineShape :: "\e[4 q"; BlinkingBarShape :: "\e[5 q"; SteadyBarShape :: "\e[6 q"; // Input mode. KeypadAppMode :: "\e="; KeypadNumMode :: "\e>"; CursorAppMode :: "\e[?1h"; CursorNormalMode :: "\e[?1l"; } #if COLOR_MODE == 4 { #load "palette_4b.jai"; set_colors :: inline (foreground: Palette, background: Palette) { print( #run sprint("%0%0", Commands.SetGraphicsRendition, Commands.SetGraphicsRendition), cast(u8)foreground + 30, cast(u8)background + 40); } } else #if COLOR_MODE == 8 { #load "palette_8b.jai"; set_colors :: inline (foreground: Palette, background: Palette) { print( #run sprint(Commands.SetGraphicsRendition, "38;5;%;48;5;%"), cast(u8)foreground, cast(u8)background); } } else { #load "palette_24b.jai"; Color_24b :: struct { r: u8; g: u8; b: u8; } set_colors :: inline (foreground: Color_24b, background: Color_24b) { print( #run sprint(Commands.SetGraphicsRendition, "38;2;%;%;%;48;2;%;%;%"), foreground.r, foreground.g, foreground.b, background.r, background.g, background.b); } } #add_context tui_style: Style; Style :: struct { #if COLOR_MODE == 4 || COLOR_MODE == 8 { background: Palette; foreground: Palette; } else { background: Color_24b; foreground: Color_24b; } background = Palette.BLACK; foreground = Palette.WHITE; bold: bool; underline: bool; strike_through: bool; negative: bool; } set_font_style :: inline (bold: bool, underline: bool = false, strike_through: bool = false, negative: bool = false) { print( #run sprint(Commands.SetGraphicsRendition, "%;%;%;%"), ifx bold then 1 else 22, ifx underline then 4 else 24, ifx strike_through then 9 else 29, ifx negative then 7 else 27); } set_style :: (style: Style) { set_font_style(style.bold, style.underline, style.strike_through, style.negative); set_colors(style.foreground, style.background); context.tui_style = style; } clear_style :: () { write_string(#run sprint(Commands.SetGraphicsRendition, "0")); } using_style :: (style: Style) #expand { __style := context.tui_style; set_style(style); `defer set_style(__style); } // TODO Maybe make the OS_* procedures as inline?! // TODO Terminal action codes are encoded with values incompatible with UTF-8 to avoid collisions. /* We wanted the Key type to represent either UTF-8 encoded characters and also keyboard keys. The UTF-8 only requires up to 4 bytes, but some keyboard keys return up to 6 bytes. Therefore, we rounded it up to 8 bytes to support all this and more (if needed). This has to be compatible with: (#char "a" == key) ... so "a" must be stored in the LSB of key |-|-|-|-|-| string |a|b|c|0|0| key/u64 |0|0|c|b|a| -> that in memory lays as (BE:|0|0|c|b|a|) and (LE:|a|b|c|0|0|) */ Key :: u64; // Terminal key-codes have 1 to 6 bytes so we'll use 8 bytes. KEY_SIZE :: #run type_info(Key).runtime_size; to_key :: inline (str: $T) -> Key #modify { return T == ([]u8) || T == string; } { k: Key; // #if DEBUG { // assert(str.count <= 8); // TODO Add DEBUG to module parameters. // } for 0..str.count-1 #no_abc { k |= ((cast(u64)str[it]) << (it*8)); } return k; } to_string :: inline (key: Key) -> string { // TODO FIXME TEMPORARY MEMORY str := talloc_string(KEY_SIZE); str.count = 0; while key != 0 #no_abc { str[str.count] = xx key & 0xFF; key >>= 8; str.count += 1; } return str; } is_escape_code :: inline (key: Key) -> bool { beginsWithEscape := ((key & 0xFF) ^ #char "#") == 0; hasSomethingElse := (key & (~0xFF)) != 0; return beginsWithEscape && hasSomethingElse; } Keys :: struct #type_info_none { None : Key : #run to_key("#none"); Resize : Key : #run to_key("#resize"); Space : Key : #char " "; Enter : Key : #char "\r"; Tab : Key : #char "\t"; Escape : Key : 0x00000000_0000001B; Backspace : Key : 0x00000000_0000007F; Pause : Key : 0x00000000_0000001A; Up : Key : #run to_key("#up"); Down : Key : #run to_key("#down"); Right : Key : #run to_key("#right"); Left : Key : #run to_key("#left"); Home : Key : #run to_key("#home"); End : Key : #run to_key("#end"); Insert : Key : #run to_key("#ins"); Delete : Key : #run to_key("#del"); PgUp : Key : #run to_key("#pup"); PgDown : Key : #run to_key("#pdown"); F1 : Key : #run to_key("#f1"); F2 : Key : #run to_key("#f2"); F3 : Key : #run to_key("#f3"); F4 : Key : #run to_key("#f4"); F5 : Key : #run to_key("#f5"); F6 : Key : #run to_key("#f6"); F7 : Key : #run to_key("#f7"); F8 : Key : #run to_key("#f8"); F9 : Key : #run to_key("#f9"); F10 : Key : #run to_key("#f10"); F11 : Key : #run to_key("#f11"); F12 : Key : #run to_key("#f12"); } active := false; input_buffer : [1024] u8; input_string : string; input_override : Key; previous_logger : (message: string, data: *void, info: Log_Info); module_logger :: (message: string, data: *void, info: Log_Info) { write_strings(Commands.SaveCursorPosition, Commands.MainScreenBuffer); previous_logger(message, data, info); write_strings(Commands.AlternateScreenBuffer, Commands.RestoreCursorPosition); } assert_is_active :: inline () { assert(active, "TUI is not ready."); } //////////////////////////////////////////////////////////////////////////////// // #scope_export TODO Setup the scope_export and scope_file set_next_key :: (key: Key) { assert_is_active(); input_override = key; } get_key :: (timeout_milliseconds: s32 = -1) -> Key { assert_is_active(); if input_override != xx Keys.None { defer input_override = xx Keys.None; return input_override; } if OS_was_terminal_resized() return Keys.Resize; should_read_input := false; is_input_available := false; if input_string.count == 0 { should_read_input = true; is_input_available = OS_wait_for_input(timeout_milliseconds); } else if input_string.count < KEY_SIZE { should_read_input = true; is_input_available = OS_wait_for_input(0); } if OS_was_terminal_resized() return Keys.Resize; if should_read_input && is_input_available { // Copy buffered bytes to the start, and read the remaining ones from input. for 0..input_string.count-1 { input_buffer[it] = input_string[it]; } // Read input into remaining part of buffer. bytes_read := OS_read_input(input_buffer.data + input_string.count, input_buffer.count - input_string.count); input_string.data = input_buffer.data; input_string.count += bytes_read; } if input_string.count == 0 return Keys.None; // By default, parse a single UTF8 character (1 to 4 bytes). to_parse := input_string; to_parse.count = count_utf8_bytes(input_string[0]); defer advance(*input_string, to_parse.count); // Advance over parsed input. // Try to parse escape code. if input_string[0] == #char "\e" && input_string.count > 1 { // Limit number of chars to parse. to_parse.count = ifx input_string.count > KEY_SIZE then KEY_SIZE else input_string.count; // Search for the longest escape code. key, success := table_find(*key_map, to_parse); while success == false && to_parse.count > 1 { to_parse.count -= 1; key, success = table_find(*key_map, to_parse); } if success { return key; // Escape code found, return it. } else { to_parse.count = 1; // No escape code found, return a single (escape) character. } } return to_key(to_parse); } // TODO Review me! read_input :: (count_limit: int = -1, terminators: .. u8) -> string { assert_is_active(); assert(count_limit >= 0 || terminators.count > 0, "Infinite loop detected, aborting."); // TODO Maybe just return!? if count_limit < 0 { builder: String_Builder; init_string_builder(*builder); while read_loop := true { buffer := get_current_buffer(*builder); buffer_data := get_buffer_data(buffer); previous_count := buffer.count; buffer.count += OS_read_input(buffer_data + buffer.count, buffer.allocated - buffer.count); for previous_count..buffer.count-1 { for t: terminators { if buffer_data[it] == t then break read_loop; } } if buffer.count == buffer.allocated then expand(*builder); OS_wait_for_input(); } return builder_to_string(*builder); } else { buffer := alloc_string(count_limit); buffer.count = 0; while read_loop := true { previous_count := buffer.count; buffer.count += OS_read_input(buffer.data + buffer.count, count_limit - buffer.count); if buffer.count == count_limit then break; for previous_count..buffer.count-1 { for t: terminators { if buffer[it] == t then break read_loop; } } OS_wait_for_input(); } return buffer; } } read_input_line :: (count_limit: int, is_visible: bool = true) -> string, Key { /* Uses the get_key to read user input and show it on screen. Allows to move the cursor left and right and to delete/backspace. Enter ends the input, returning the input string and the Enter key. Escape discards the input returning an empty string and a Escape key. Resize discards the input returning an empty string and a Resize key. */ assert(count_limit >= 0, "Invalid value on count_limit parameter."); str := alloc_string(count_limit); str.count = 0; idx := 0; // TODO Some of these may be nice to have: // > https://unix.stackexchange.com/questions/255707/what-are-the-keyboard-shortcuts-for-the-command-line x, y := get_cursor_position(); write_strings(Commands.ShowCursor, Commands.StartBlinking, Commands.BlinkingBarShape); key := Keys.None; while true { auto_release_temp(); chars_count := count_characters(str); // Preview input. if is_visible { set_cursor_position(x, y); write_string(str); for chars_count..count_limit-1 print_character(#char " "); } else { set_cursor_position(x, y); for 1..chars_count print_character(#char "*"); for chars_count..count_limit-1 print_character(#char " "); } set_cursor_position(x+idx, y); // Process input key. key = get_key(); if key == { case Keys.Resize; #through; case Keys.Escape; #through; case Keys.Enter; break; case Keys.Left; if idx > 0 then idx -= 1; case Keys.Right; if idx < chars_count then idx += 1; case Keys.Home; idx = 0; case Keys.End; idx = chars_count; case Keys.Delete; if idx == chars_count continue; delete_character(*str, idx); case Keys.Backspace; if idx == 0 continue; idx -= 1; delete_character(*str, idx); case; if is_escape_code(key) continue; buff_idx := map_character_to_buffer_idx(str, idx); key_str := to_string(key); // Make sure we have space to append the new character at the end (in case we're trying to do it). if buff_idx > count_limit - key_str.count then continue; // Move text to allow inserting new character. for < count_limit..buff_idx+key_str.count { str.data[it] = str.data[it-key_str.count]; } memcpy(*str.data[buff_idx], key_str.data, key_str.count); if str.count < count_limit then str.count += key_str.count; idx += 1; // Truncate string to avoid incomplete utf8 codes on the string tail. str.count = truncate_string(str, count_limit); } } write_strings(Commands.StopBlinking, Commands.DefaultShape, Commands.HideCursor); result := ifx key == Keys.Enter then str else ""; return result, key; } start :: () -> success := true #must { if active == true return; input_string.data = input_buffer.data; input_string.count = 0; input_override = xx Keys.None; previous_logger = context.logger; context.logger = module_logger; setup_key_map(); write_strings( Commands.HideCursor, Commands.SaveCursorPosition, Commands.AlternateScreenBuffer, Commands.EncodingUTF8, Commands.CursorNormalMode, Commands.KeypadNumMode ); if !OS_prepare_terminal() then return false; active = true; return; } stop :: () -> success := true #must { if active == false return; active = false; clear_style(); if !OS_reset_terminal() then return false; context.logger = previous_logger; write_strings( Commands.MainScreenBuffer, Commands.RestoreCursorPosition, Commands.ShowCursor ); return; } flush_input :: () { OS_flush_input(); input_string.data = input_buffer.data; input_string.count = 0; } draw_box :: (x: int, y: int, width: int, height: int) { assert_is_active(); // TODO Check if using a String_Builder improves performance (measure it)! // TODO Validate input parameters against the terminal size. assert(x > 0 && y > 0 && width > 1 && height > 1, "Invalid arguments."); auto_release_temp(); tmp_string: string; tmp_string = tprint(Commands.SetCursorPosition, y, x); write_strings( Commands.DrawingMode, tmp_string, Drawings.CornerTL ); for 1..width-2 { write_string(Drawings.LineH); } write_string(Drawings.CornerTR); for idx: y+1..y+height-2 { tmpL := tprint(Commands.SetCursorPosition, idx, x); tmpR := tprint(Commands.SetCursorPosition, idx, x+width-1); write_strings( tmpL, Drawings.LineV, tmpR, Drawings.LineV); } tmpBL := tprint(Commands.SetCursorPosition, y+height-1, x); write_strings( tmpBL, Drawings.CornerBL); for 1..width-2 { write_string(Drawings.LineH); } write_string(Drawings.CornerBR); write_string(Commands.TextMode); } // TODO Maybe rename to "clear()" clear_terminal :: inline () { assert_is_active(); write_string(Commands.ClearScreen); } // TODO Maybe rename to "get_size()" get_terminal_size :: () -> width: int, height: int { assert_is_active(); auto_release_temp(); flush_input(); write_string(Commands.QueryWindowSizeInChars); rows, columns: int = ---; if OS_wait_for_input(0) { // Expected response format: \e[8;;t // where is the number of rows and of columns. FORMAT :: "\e[8;;t"; input := read_input(64, #char "t",, temporary_allocator); // Discard head noise. while input.count >= 3 && (input[0] != FORMAT[0] || input[1] != FORMAT[1] || input[2] != FORMAT[2]) { advance(*input); } // Discard tail noise. while input.count >= 3 && input[input.count-1] != FORMAT[FORMAT.count-1] { input.count -= 1; } assert(input.count >= 3 && input[0] == FORMAT[0] && input[1] == FORMAT[1] && input[2] == FORMAT[2] && input[input.count-1] == FORMAT[FORMAT.count-1], "Query window size in chars returned invalid response."); parts := split(input, ";",, temporary_allocator); rows = parse_int(*parts[1]); columns = parse_int(*parts[2]); } // Some systems don't allow to query the terminal size directly... or the answer takes too much time. // In such cases, measure it indirectly by the maximum possible cursor position. else { x, y := get_cursor_position(); defer set_cursor_position(x, y); set_cursor_position(0xFFFF, 0xFFFF); columns, rows = get_cursor_position(); } return columns, rows; } set_cursor_position :: (x: int, y: int) { assert_is_active(); print(Commands.SetCursorPosition, y, x); } get_cursor_position :: () -> x: int, y: int { assert_is_active(); auto_release_temp(); flush_input(); write_string(Commands.QueryCursorPosition); // Expected response format: \e[;R // where is the number of rows and of columns. FORMAT :: "\e[;R"; input := read_input(64, #char "R"); // Discard head noise. while input.count >= 2 && (input[0] != FORMAT[0] || input[1] != FORMAT[1]) { advance(*input); } // Discard tail noise. while input.count >= 2 && input[input.count-1] != FORMAT[FORMAT.count-1] { input.count -= 1; } assert(input.count >= 2 && input[0] == FORMAT[0] && input[1] == FORMAT[1] && input[input.count-1] == FORMAT[FORMAT.count-1], "Query cursor position returned invalid response."); advance(*input, 2); parts := split(input, ";",, temporary_allocator); row := parse_int(*parts[0]); column := parse_int(*parts[1]); return column, row; } set_terminal_title :: (title: string) { assert_is_active(); print(Commands.SetWindowTitle, title); }