1251 lines
43 KiB
Odin
1251 lines
43 KiB
Odin
package main
|
|
|
|
import "core:fmt"
|
|
import "core:os"
|
|
import "core:strings"
|
|
import "core:sync"
|
|
import "core:sys/linux"
|
|
import "core:sys/posix"
|
|
import "core:thread"
|
|
import "core:time"
|
|
import SDL "vendor:sdl3"
|
|
|
|
// Bottom terminal panel: the user's shell on a PTY, driven by a VT100-subset
|
|
// emulator. The screen is a rows*cols cell grid with colors, cursor
|
|
// addressing, scroll regions and an alternate screen, so full-screen TUI
|
|
// programs work. Lines scrolling off the primary screen go to a scrollback.
|
|
|
|
SDL_TERMINAL_DEFAULT_HEIGHT :: 320
|
|
SDL_TERMINAL_MIN_HEIGHT :: 120
|
|
SDL_TERMINAL_HEADER :: 26
|
|
SDL_TERMINAL_LINE_HEIGHT :: 16
|
|
SDL_TERMINAL_SCROLLBACK :: 1000
|
|
|
|
TIOCSWINSZ :: 0x5414
|
|
|
|
TERM_DEFAULT_COLOR :: 255 // sentinel palette index: use the theme default
|
|
|
|
Pty_Winsize :: struct {
|
|
row: u16,
|
|
col: u16,
|
|
xpixel: u16,
|
|
ypixel: u16,
|
|
}
|
|
|
|
Term_Cell :: struct {
|
|
ch: rune,
|
|
fg: u8,
|
|
bg: u8,
|
|
}
|
|
|
|
Bottom_Tab :: enum {
|
|
Terminal,
|
|
Git,
|
|
}
|
|
|
|
bottom_tab_label :: proc(tab: Bottom_Tab) -> string {
|
|
switch tab {
|
|
case .Terminal:
|
|
return "Terminal"
|
|
case .Git:
|
|
return "Git"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
Terminal_Escape_State :: enum {
|
|
Ground,
|
|
Escape,
|
|
Escape_Charset, // ESC ( or ESC ) — discard one byte
|
|
Csi,
|
|
Osc,
|
|
}
|
|
|
|
Terminal_Panel :: struct {
|
|
open: bool,
|
|
focused: bool,
|
|
running: bool,
|
|
active_tab: Bottom_Tab,
|
|
master: posix.FD,
|
|
process: os.Process,
|
|
reader: ^thread.Thread,
|
|
mutex: sync.Mutex,
|
|
pending: [dynamic]u8,
|
|
|
|
// Screen grid state.
|
|
rows: int,
|
|
cols: int,
|
|
cells: [dynamic]Term_Cell,
|
|
alt_cells: [dynamic]Term_Cell,
|
|
alt_active: bool,
|
|
cursor_row: int,
|
|
cursor_col: int,
|
|
saved_row: int,
|
|
saved_col: int,
|
|
scroll_top: int, // scroll region, inclusive
|
|
scroll_bot: int,
|
|
scrollback: [dynamic][dynamic]Term_Cell,
|
|
scroll_view: int, // lines scrolled up in the view; 0 = live
|
|
|
|
// Current write attributes.
|
|
fg: u8,
|
|
bg: u8,
|
|
bold: bool,
|
|
reverse: bool,
|
|
|
|
// Modes.
|
|
cursor_visible: bool,
|
|
app_cursor_keys: bool,
|
|
autowrap: bool,
|
|
|
|
// Parser state.
|
|
escape: Terminal_Escape_State,
|
|
csi: [dynamic]u8,
|
|
utf8_need: int,
|
|
utf8_value: rune,
|
|
|
|
pty_rows: int,
|
|
pty_cols: int,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PTY plumbing
|
|
|
|
terminal_destroy :: proc(term: ^Terminal_Panel) {
|
|
terminal_stop(term)
|
|
if term.reader != nil {
|
|
thread.join(term.reader)
|
|
thread.destroy(term.reader)
|
|
term.reader = nil
|
|
}
|
|
delete(term.pending)
|
|
delete(term.cells)
|
|
delete(term.alt_cells)
|
|
for line in term.scrollback {
|
|
delete(line)
|
|
}
|
|
delete(term.scrollback)
|
|
delete(term.csi)
|
|
}
|
|
|
|
terminal_stop :: proc(term: ^Terminal_Panel) {
|
|
// Closing the master first hangs up the shell's controlling terminal.
|
|
// Interactive shells ignore SIGTERM, so escalate to SIGKILL after a
|
|
// short grace period instead of waiting forever.
|
|
if term.master > 0 {
|
|
_ = posix.close(term.master)
|
|
term.master = 0
|
|
}
|
|
if term.running {
|
|
term.running = false
|
|
_ = os.process_terminate(term.process)
|
|
state, wait_err := os.process_wait(term.process, 500 * time.Millisecond)
|
|
if wait_err != nil || !state.exited {
|
|
_ = os.process_kill(term.process)
|
|
_, _ = os.process_wait(term.process)
|
|
}
|
|
}
|
|
}
|
|
|
|
terminal_start :: proc(term: ^Terminal_Panel, workspace: string) -> bool {
|
|
if term.running do return true
|
|
terminal_ensure_grid(term)
|
|
|
|
master := posix.posix_openpt({.RDWR, .NOCTTY})
|
|
if master < 0 {
|
|
terminal_append_note(term, "terminal: failed to open pty")
|
|
return false
|
|
}
|
|
if posix.grantpt(master) != .OK || posix.unlockpt(master) != .OK {
|
|
_ = posix.close(master)
|
|
terminal_append_note(term, "terminal: failed to set up pty")
|
|
return false
|
|
}
|
|
slave_name := posix.ptsname(master)
|
|
slave := posix.open(slave_name, {.RDWR, .NOCTTY})
|
|
if slave < 0 {
|
|
_ = posix.close(master)
|
|
terminal_append_note(term, "terminal: failed to open pty slave")
|
|
return false
|
|
}
|
|
|
|
slave_file := os.new_file(uintptr(slave), string(slave_name))
|
|
if slave_file == nil {
|
|
_ = posix.close(slave)
|
|
_ = posix.close(master)
|
|
terminal_append_note(term, "terminal: failed to wrap pty slave")
|
|
return false
|
|
}
|
|
|
|
shell := os.get_env("SHELL", context.temp_allocator)
|
|
if len(shell) == 0 do shell = "/bin/bash"
|
|
|
|
env: [dynamic]string
|
|
defer delete(env)
|
|
if current, env_err := os.environ(context.temp_allocator); env_err == nil {
|
|
for entry in current {
|
|
if strings.has_prefix(entry, "TERM=") do continue
|
|
append(&env, entry)
|
|
}
|
|
}
|
|
append(&env, "TERM=xterm-256color")
|
|
|
|
// setsid --ctty makes the pty a proper controlling terminal so the shell
|
|
// gets job control; fall back to a bare shell when setsid is missing.
|
|
process, start_err := os.process_start(os.Process_Desc{
|
|
working_dir = workspace,
|
|
command = []string{"setsid", "--ctty", shell, "-i"},
|
|
env = env[:],
|
|
stdin = slave_file,
|
|
stdout = slave_file,
|
|
stderr = slave_file,
|
|
})
|
|
if start_err != nil {
|
|
process, start_err = os.process_start(os.Process_Desc{
|
|
working_dir = workspace,
|
|
command = []string{shell, "-i"},
|
|
env = env[:],
|
|
stdin = slave_file,
|
|
stdout = slave_file,
|
|
stderr = slave_file,
|
|
})
|
|
}
|
|
_ = os.close(slave_file)
|
|
if start_err != nil {
|
|
_ = posix.close(master)
|
|
terminal_append_note(term, fmt.tprintf("terminal: failed to start %s", shell))
|
|
return false
|
|
}
|
|
|
|
if term.reader != nil {
|
|
thread.join(term.reader)
|
|
thread.destroy(term.reader)
|
|
term.reader = nil
|
|
}
|
|
|
|
term.master = master
|
|
term.process = process
|
|
term.running = true
|
|
term.pty_rows = 0
|
|
term.pty_cols = 0
|
|
term.reader = thread.create_and_start_with_data(rawptr(term), terminal_reader_thread)
|
|
terminal_resize(term, term.rows, term.cols)
|
|
return true
|
|
}
|
|
|
|
terminal_reader_thread :: proc(data: rawptr) {
|
|
term := (^Terminal_Panel)(data)
|
|
buf: [4096]u8
|
|
for term.running {
|
|
n := posix.read(term.master, &buf[0], len(buf))
|
|
if n <= 0 do break
|
|
if sync.mutex_guard(&term.mutex) {
|
|
for b in buf[:n] {
|
|
append(&term.pending, b)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
terminal_send :: proc(term: ^Terminal_Panel, bytes: string) {
|
|
if !term.running || len(bytes) == 0 do return
|
|
data := transmute([]u8)bytes
|
|
_ = posix.write(term.master, &data[0], len(data))
|
|
}
|
|
|
|
terminal_toggle :: proc(term: ^Terminal_Panel, git: ^Git_Panel, view: ^SDL_View, workspace: string) {
|
|
if term.open {
|
|
term.open = false
|
|
term.focused = false
|
|
} else {
|
|
term.open = true
|
|
if term.active_tab == .Terminal {
|
|
term.focused = true
|
|
// The shell keeps running while the panel is hidden; only start
|
|
// one if there is none yet (or the previous one exited).
|
|
_ = terminal_start(term, workspace)
|
|
} else {
|
|
git_panel_request_log(git, workspace)
|
|
}
|
|
}
|
|
view.terminal_visible = term.open
|
|
ui_state_save(view)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Grid
|
|
|
|
terminal_blank_cell :: proc(term: ^Terminal_Panel) -> Term_Cell {
|
|
return Term_Cell{ch = ' ', fg = TERM_DEFAULT_COLOR, bg = term.bg}
|
|
}
|
|
|
|
terminal_ensure_grid :: proc(term: ^Terminal_Panel) {
|
|
if term.rows > 0 && term.cols > 0 do return
|
|
term.fg = TERM_DEFAULT_COLOR
|
|
term.bg = TERM_DEFAULT_COLOR
|
|
term.cursor_visible = true
|
|
term.autowrap = true
|
|
terminal_grid_reset(term, 24, 80)
|
|
}
|
|
|
|
terminal_grid_reset :: proc(term: ^Terminal_Panel, rows, cols: int) {
|
|
term.rows = rows
|
|
term.cols = cols
|
|
resize(&term.cells, rows * cols)
|
|
resize(&term.alt_cells, rows * cols)
|
|
blank := Term_Cell{ch = ' ', fg = TERM_DEFAULT_COLOR, bg = TERM_DEFAULT_COLOR}
|
|
for i in 0 ..< rows * cols {
|
|
term.cells[i] = blank
|
|
term.alt_cells[i] = blank
|
|
}
|
|
term.cursor_row = 0
|
|
term.cursor_col = 0
|
|
term.scroll_top = 0
|
|
term.scroll_bot = rows - 1
|
|
}
|
|
|
|
terminal_screen :: proc(term: ^Terminal_Panel) -> ^[dynamic]Term_Cell {
|
|
return &term.alt_cells if term.alt_active else &term.cells
|
|
}
|
|
|
|
terminal_cell_at :: proc(term: ^Terminal_Panel, row, col: int) -> ^Term_Cell {
|
|
screen := terminal_screen(term)
|
|
return &screen[row * term.cols + col]
|
|
}
|
|
|
|
// Resizes the grid, keeping the top-left contents; TUIs repaint on the
|
|
// SIGWINCH that follows the ioctl.
|
|
terminal_resize :: proc(term: ^Terminal_Panel, rows, cols: int) {
|
|
terminal_ensure_grid(term)
|
|
if rows <= 0 || cols <= 0 do return
|
|
if rows != term.rows || cols != term.cols {
|
|
old_rows, old_cols := term.rows, term.cols
|
|
old_cells := make([]Term_Cell, old_rows * old_cols, context.temp_allocator)
|
|
copy(old_cells, term.cells[:])
|
|
old_alt := make([]Term_Cell, old_rows * old_cols, context.temp_allocator)
|
|
copy(old_alt, term.alt_cells[:])
|
|
|
|
terminal_grid_reset(term, rows, cols)
|
|
for r in 0 ..< min_int(rows, old_rows) {
|
|
for c in 0 ..< min_int(cols, old_cols) {
|
|
term.cells[r * cols + c] = old_cells[r * old_cols + c]
|
|
term.alt_cells[r * cols + c] = old_alt[r * old_cols + c]
|
|
}
|
|
}
|
|
term.cursor_row = clamp_int(term.cursor_row, 0, rows - 1)
|
|
term.cursor_col = clamp_int(term.cursor_col, 0, cols - 1)
|
|
}
|
|
|
|
if term.running && (rows != term.pty_rows || cols != term.pty_cols) {
|
|
term.pty_rows = rows
|
|
term.pty_cols = cols
|
|
size := Pty_Winsize{row = u16(rows), col = u16(cols)}
|
|
_ = linux.ioctl(linux.Fd(term.master), TIOCSWINSZ, uintptr(&size))
|
|
}
|
|
}
|
|
|
|
terminal_erase_cells :: proc(term: ^Terminal_Panel, row, from, to: int) {
|
|
blank := terminal_blank_cell(term)
|
|
for c in from ..< to {
|
|
terminal_cell_at(term, row, c)^ = blank
|
|
}
|
|
}
|
|
|
|
// Scrolls the region up by one line; the top line of a full-width primary
|
|
// screen region is preserved in the scrollback.
|
|
terminal_scroll_up :: proc(term: ^Terminal_Panel) {
|
|
if !term.alt_active && term.scroll_top == 0 && term.scroll_bot == term.rows - 1 {
|
|
line := make([dynamic]Term_Cell, term.cols)
|
|
for c in 0 ..< term.cols {
|
|
line[c] = terminal_cell_at(term, 0, c)^
|
|
}
|
|
append(&term.scrollback, line)
|
|
for len(term.scrollback) > SDL_TERMINAL_SCROLLBACK {
|
|
delete(term.scrollback[0])
|
|
ordered_remove(&term.scrollback, 0)
|
|
}
|
|
}
|
|
for r in term.scroll_top ..< term.scroll_bot {
|
|
for c in 0 ..< term.cols {
|
|
terminal_cell_at(term, r, c)^ = terminal_cell_at(term, r + 1, c)^
|
|
}
|
|
}
|
|
terminal_erase_cells(term, term.scroll_bot, 0, term.cols)
|
|
}
|
|
|
|
terminal_scroll_down :: proc(term: ^Terminal_Panel) {
|
|
for r := term.scroll_bot; r > term.scroll_top; r -= 1 {
|
|
for c in 0 ..< term.cols {
|
|
terminal_cell_at(term, r, c)^ = terminal_cell_at(term, r - 1, c)^
|
|
}
|
|
}
|
|
terminal_erase_cells(term, term.scroll_top, 0, term.cols)
|
|
}
|
|
|
|
terminal_linefeed :: proc(term: ^Terminal_Panel) {
|
|
if term.cursor_row == term.scroll_bot {
|
|
terminal_scroll_up(term)
|
|
} else if term.cursor_row < term.rows - 1 {
|
|
term.cursor_row += 1
|
|
}
|
|
}
|
|
|
|
terminal_put_char :: proc(term: ^Terminal_Panel, ch: rune) {
|
|
if term.cursor_col >= term.cols {
|
|
if term.autowrap {
|
|
term.cursor_col = 0
|
|
terminal_linefeed(term)
|
|
} else {
|
|
term.cursor_col = term.cols - 1
|
|
}
|
|
}
|
|
cell := terminal_cell_at(term, term.cursor_row, term.cursor_col)
|
|
fg, bg := term.fg, term.bg
|
|
if term.reverse {
|
|
fg, bg = bg, fg
|
|
// Reversed defaults need concrete colors to actually swap visibly.
|
|
if fg == TERM_DEFAULT_COLOR do fg = 0
|
|
if bg == TERM_DEFAULT_COLOR do bg = 7
|
|
}
|
|
if term.bold && fg < 8 do fg += 8
|
|
cell^ = Term_Cell{ch = ch, fg = fg, bg = bg}
|
|
term.cursor_col += 1
|
|
}
|
|
|
|
terminal_append_note :: proc(term: ^Terminal_Panel, note: string) {
|
|
terminal_ensure_grid(term)
|
|
for b in transmute([]u8)note {
|
|
terminal_consume_byte(term, b)
|
|
}
|
|
terminal_consume_byte(term, '\r')
|
|
terminal_consume_byte(term, '\n')
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Parser
|
|
|
|
// Folds pending pty output into the grid. Called once per frame.
|
|
terminal_pump :: proc(term: ^Terminal_Panel) {
|
|
chunk: [dynamic]u8
|
|
defer delete(chunk)
|
|
if sync.mutex_guard(&term.mutex) {
|
|
if len(term.pending) == 0 do return
|
|
for b in term.pending {
|
|
append(&chunk, b)
|
|
}
|
|
clear(&term.pending)
|
|
}
|
|
|
|
terminal_ensure_grid(term)
|
|
for b in chunk {
|
|
terminal_consume_byte(term, b)
|
|
}
|
|
}
|
|
|
|
terminal_consume_byte :: proc(term: ^Terminal_Panel, b: u8) {
|
|
switch term.escape {
|
|
case .Escape:
|
|
switch b {
|
|
case '[':
|
|
term.escape = .Csi
|
|
clear(&term.csi)
|
|
case ']':
|
|
term.escape = .Osc
|
|
case '(', ')':
|
|
term.escape = .Escape_Charset
|
|
case '7':
|
|
term.saved_row = term.cursor_row
|
|
term.saved_col = term.cursor_col
|
|
term.escape = .Ground
|
|
case '8':
|
|
term.cursor_row = clamp_int(term.saved_row, 0, term.rows - 1)
|
|
term.cursor_col = clamp_int(term.saved_col, 0, term.cols - 1)
|
|
term.escape = .Ground
|
|
case 'D':
|
|
terminal_linefeed(term)
|
|
term.escape = .Ground
|
|
case 'E':
|
|
term.cursor_col = 0
|
|
terminal_linefeed(term)
|
|
term.escape = .Ground
|
|
case 'M':
|
|
if term.cursor_row == term.scroll_top {
|
|
terminal_scroll_down(term)
|
|
} else if term.cursor_row > 0 {
|
|
term.cursor_row -= 1
|
|
}
|
|
term.escape = .Ground
|
|
case 'c':
|
|
terminal_grid_reset(term, term.rows, term.cols)
|
|
term.fg = TERM_DEFAULT_COLOR
|
|
term.bg = TERM_DEFAULT_COLOR
|
|
term.bold = false
|
|
term.reverse = false
|
|
term.escape = .Ground
|
|
case:
|
|
term.escape = .Ground
|
|
}
|
|
return
|
|
case .Escape_Charset:
|
|
term.escape = .Ground
|
|
return
|
|
case .Csi:
|
|
if (b >= '0' && b <= '9') || b == ';' || b == '?' || b == '>' || b == '<' || b == '=' || b == ':' || b == ' ' {
|
|
if len(term.csi) < 64 do append(&term.csi, b)
|
|
return
|
|
}
|
|
if b >= 0x40 && b <= 0x7e {
|
|
terminal_csi_dispatch(term, b)
|
|
}
|
|
term.escape = .Ground
|
|
return
|
|
case .Osc:
|
|
if b == 0x07 {
|
|
term.escape = .Ground
|
|
} else if b == 0x1b {
|
|
// ST arrives as ESC \; the backslash is eaten by Escape state.
|
|
term.escape = .Escape
|
|
}
|
|
return
|
|
case .Ground:
|
|
}
|
|
|
|
// UTF-8: continuation handling maps multi-byte characters to an ASCII
|
|
// approximation, since the font atlas covers ASCII only.
|
|
if term.utf8_need > 0 {
|
|
if b & 0xc0 == 0x80 {
|
|
term.utf8_value = (term.utf8_value << 6) | rune(b & 0x3f)
|
|
term.utf8_need -= 1
|
|
if term.utf8_need == 0 {
|
|
terminal_put_char(term, term.utf8_value)
|
|
}
|
|
return
|
|
}
|
|
term.utf8_need = 0
|
|
}
|
|
|
|
switch {
|
|
case b == 0x1b:
|
|
term.escape = .Escape
|
|
case b == '\n' || b == 0x0b || b == 0x0c:
|
|
terminal_linefeed(term)
|
|
case b == '\r':
|
|
term.cursor_col = 0
|
|
case b == 0x08:
|
|
term.cursor_col = max_int(term.cursor_col - 1, 0)
|
|
case b == '\t':
|
|
next := (term.cursor_col / 8 + 1) * 8
|
|
term.cursor_col = min_int(next, term.cols - 1)
|
|
case b == 0x07, b == 0x0e, b == 0x0f, b == 0:
|
|
// Bell and charset shifts: ignore.
|
|
case b >= 0xc0:
|
|
switch {
|
|
case b & 0xe0 == 0xc0:
|
|
term.utf8_need = 1
|
|
term.utf8_value = rune(b & 0x1f)
|
|
case b & 0xf0 == 0xe0:
|
|
term.utf8_need = 2
|
|
term.utf8_value = rune(b & 0x0f)
|
|
case:
|
|
term.utf8_need = 3
|
|
term.utf8_value = rune(b & 0x07)
|
|
}
|
|
case b >= 32 && b < 127:
|
|
terminal_put_char(term, rune(b))
|
|
}
|
|
}
|
|
|
|
terminal_ascii_for_rune :: proc(r: rune) -> u8 {
|
|
switch r {
|
|
case 0x00a0:
|
|
return ' '
|
|
case 0x2018, 0x2019:
|
|
return '\''
|
|
case 0x201c, 0x201d:
|
|
return '"'
|
|
case 0x2010 ..= 0x2015, 0x2212:
|
|
return '-'
|
|
case 0x2026:
|
|
return '.'
|
|
case 0x2022, 0x25cf, 0x25cb, 0x25c6, 0x25c7, 0x2b24:
|
|
return '*'
|
|
case 0x2190:
|
|
return '<'
|
|
case 0x2192:
|
|
return '>'
|
|
case 0x2191, 0x2193:
|
|
return '|'
|
|
case 0x2713, 0x2714:
|
|
return 'v'
|
|
case 0x2717, 0x2718:
|
|
return 'x'
|
|
case 0x2800:
|
|
return ' '
|
|
case 0x2801 ..= 0x28ff:
|
|
// Braille patterns (spinners); the bundled font has no braille.
|
|
return '*'
|
|
}
|
|
// Box drawing: anything with vertical-only strokes becomes '|', pure
|
|
// horizontals '-', and joints/corners '+'; shaded blocks become '#'.
|
|
switch r {
|
|
case 0x2500, 0x2501, 0x2504, 0x2505, 0x2508, 0x2509, 0x254c, 0x254d, 0x2574, 0x2576, 0x2578, 0x257a, 0x2550:
|
|
return '-'
|
|
case 0x2502, 0x2503, 0x2506, 0x2507, 0x250a, 0x250b, 0x254e, 0x254f, 0x2575, 0x2577, 0x2579, 0x257b, 0x2551:
|
|
return '|'
|
|
case 0x250c ..= 0x254b, 0x2552 ..= 0x256c, 0x256d ..= 0x2570:
|
|
return '+'
|
|
case 0x2571 ..= 0x2573:
|
|
return '/'
|
|
case 0x2580 ..= 0x259f, 0x25a0 ..= 0x25ab:
|
|
return '#'
|
|
}
|
|
return '?'
|
|
}
|
|
|
|
terminal_csi_params :: proc(term: ^Terminal_Panel) -> ([dynamic]int, bool) {
|
|
params := make([dynamic]int, context.temp_allocator)
|
|
private := false
|
|
value := 0
|
|
has_value := false
|
|
for b in term.csi {
|
|
switch {
|
|
case b >= '0' && b <= '9':
|
|
value = value * 10 + int(b - '0')
|
|
has_value = true
|
|
case b == ';' || b == ':':
|
|
append(¶ms, value if has_value else 0)
|
|
value = 0
|
|
has_value = false
|
|
case b == '?':
|
|
private = true
|
|
}
|
|
}
|
|
if has_value || len(params) > 0 {
|
|
append(¶ms, value if has_value else 0)
|
|
}
|
|
return params, private
|
|
}
|
|
|
|
terminal_csi_param :: proc(params: [dynamic]int, index, default_value: int) -> int {
|
|
if index >= len(params) || params[index] == 0 do return default_value
|
|
return params[index]
|
|
}
|
|
|
|
terminal_csi_dispatch :: proc(term: ^Terminal_Panel, final: u8) {
|
|
params, private := terminal_csi_params(term)
|
|
|
|
if private {
|
|
enable := final == 'h'
|
|
if final != 'h' && final != 'l' do return
|
|
for mode in params {
|
|
switch mode {
|
|
case 1:
|
|
term.app_cursor_keys = enable
|
|
case 7:
|
|
term.autowrap = enable
|
|
case 25:
|
|
term.cursor_visible = enable
|
|
case 47, 1047, 1049:
|
|
if enable && !term.alt_active {
|
|
term.saved_row = term.cursor_row
|
|
term.saved_col = term.cursor_col
|
|
term.alt_active = true
|
|
blank := Term_Cell{ch = ' ', fg = TERM_DEFAULT_COLOR, bg = TERM_DEFAULT_COLOR}
|
|
for i in 0 ..< len(term.alt_cells) {
|
|
term.alt_cells[i] = blank
|
|
}
|
|
term.cursor_row = 0
|
|
term.cursor_col = 0
|
|
term.scroll_top = 0
|
|
term.scroll_bot = term.rows - 1
|
|
term.scroll_view = 0
|
|
} else if !enable && term.alt_active {
|
|
term.alt_active = false
|
|
term.cursor_row = clamp_int(term.saved_row, 0, term.rows - 1)
|
|
term.cursor_col = clamp_int(term.saved_col, 0, term.cols - 1)
|
|
term.scroll_top = 0
|
|
term.scroll_bot = term.rows - 1
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
switch final {
|
|
case 'A':
|
|
term.cursor_row = max_int(term.cursor_row - terminal_csi_param(params, 0, 1), 0)
|
|
case 'B':
|
|
term.cursor_row = min_int(term.cursor_row + terminal_csi_param(params, 0, 1), term.rows - 1)
|
|
case 'C':
|
|
term.cursor_col = min_int(term.cursor_col + terminal_csi_param(params, 0, 1), term.cols - 1)
|
|
case 'D':
|
|
term.cursor_col = max_int(term.cursor_col - terminal_csi_param(params, 0, 1), 0)
|
|
case 'E':
|
|
term.cursor_col = 0
|
|
term.cursor_row = min_int(term.cursor_row + terminal_csi_param(params, 0, 1), term.rows - 1)
|
|
case 'F':
|
|
term.cursor_col = 0
|
|
term.cursor_row = max_int(term.cursor_row - terminal_csi_param(params, 0, 1), 0)
|
|
case 'G', '`':
|
|
term.cursor_col = clamp_int(terminal_csi_param(params, 0, 1) - 1, 0, term.cols - 1)
|
|
case 'd':
|
|
term.cursor_row = clamp_int(terminal_csi_param(params, 0, 1) - 1, 0, term.rows - 1)
|
|
case 'H', 'f':
|
|
term.cursor_row = clamp_int(terminal_csi_param(params, 0, 1) - 1, 0, term.rows - 1)
|
|
term.cursor_col = clamp_int(terminal_csi_param(params, 1, 1) - 1, 0, term.cols - 1)
|
|
case 'J':
|
|
mode := 0
|
|
if len(params) > 0 do mode = params[0]
|
|
switch mode {
|
|
case 0:
|
|
terminal_erase_cells(term, term.cursor_row, term.cursor_col, term.cols)
|
|
for r in term.cursor_row + 1 ..< term.rows {
|
|
terminal_erase_cells(term, r, 0, term.cols)
|
|
}
|
|
case 1:
|
|
terminal_erase_cells(term, term.cursor_row, 0, term.cursor_col + 1)
|
|
for r in 0 ..< term.cursor_row {
|
|
terminal_erase_cells(term, r, 0, term.cols)
|
|
}
|
|
case 2, 3:
|
|
for r in 0 ..< term.rows {
|
|
terminal_erase_cells(term, r, 0, term.cols)
|
|
}
|
|
}
|
|
case 'K':
|
|
mode := 0
|
|
if len(params) > 0 do mode = params[0]
|
|
switch mode {
|
|
case 0:
|
|
terminal_erase_cells(term, term.cursor_row, term.cursor_col, term.cols)
|
|
case 1:
|
|
terminal_erase_cells(term, term.cursor_row, 0, term.cursor_col + 1)
|
|
case 2:
|
|
terminal_erase_cells(term, term.cursor_row, 0, term.cols)
|
|
}
|
|
case 'L':
|
|
count := terminal_csi_param(params, 0, 1)
|
|
if term.cursor_row >= term.scroll_top && term.cursor_row <= term.scroll_bot {
|
|
saved_top := term.scroll_top
|
|
term.scroll_top = term.cursor_row
|
|
for _ in 0 ..< count {
|
|
terminal_scroll_down(term)
|
|
}
|
|
term.scroll_top = saved_top
|
|
}
|
|
case 'M':
|
|
count := terminal_csi_param(params, 0, 1)
|
|
if term.cursor_row >= term.scroll_top && term.cursor_row <= term.scroll_bot {
|
|
saved_top := term.scroll_top
|
|
term.scroll_top = term.cursor_row
|
|
for _ in 0 ..< count {
|
|
terminal_scroll_up(term)
|
|
}
|
|
term.scroll_top = saved_top
|
|
}
|
|
case 'P':
|
|
count := min_int(terminal_csi_param(params, 0, 1), term.cols - term.cursor_col)
|
|
for c in term.cursor_col ..< term.cols {
|
|
source := c + count
|
|
if source < term.cols {
|
|
terminal_cell_at(term, term.cursor_row, c)^ = terminal_cell_at(term, term.cursor_row, source)^
|
|
} else {
|
|
terminal_cell_at(term, term.cursor_row, c)^ = terminal_blank_cell(term)
|
|
}
|
|
}
|
|
case '@':
|
|
count := min_int(terminal_csi_param(params, 0, 1), term.cols - term.cursor_col)
|
|
for c := term.cols - 1; c >= term.cursor_col + count; c -= 1 {
|
|
terminal_cell_at(term, term.cursor_row, c)^ = terminal_cell_at(term, term.cursor_row, c - count)^
|
|
}
|
|
terminal_erase_cells(term, term.cursor_row, term.cursor_col, min_int(term.cursor_col + count, term.cols))
|
|
case 'X':
|
|
count := terminal_csi_param(params, 0, 1)
|
|
terminal_erase_cells(term, term.cursor_row, term.cursor_col, min_int(term.cursor_col + count, term.cols))
|
|
case 'S':
|
|
for _ in 0 ..< terminal_csi_param(params, 0, 1) {
|
|
terminal_scroll_up(term)
|
|
}
|
|
case 'T':
|
|
for _ in 0 ..< terminal_csi_param(params, 0, 1) {
|
|
terminal_scroll_down(term)
|
|
}
|
|
case 'r':
|
|
top := clamp_int(terminal_csi_param(params, 0, 1) - 1, 0, term.rows - 1)
|
|
bottom := clamp_int(terminal_csi_param(params, 1, term.rows) - 1, 0, term.rows - 1)
|
|
if top < bottom {
|
|
term.scroll_top = top
|
|
term.scroll_bot = bottom
|
|
term.cursor_row = 0
|
|
term.cursor_col = 0
|
|
}
|
|
case 'm':
|
|
terminal_apply_sgr(term, params)
|
|
case 's':
|
|
term.saved_row = term.cursor_row
|
|
term.saved_col = term.cursor_col
|
|
case 'u':
|
|
term.cursor_row = clamp_int(term.saved_row, 0, term.rows - 1)
|
|
term.cursor_col = clamp_int(term.saved_col, 0, term.cols - 1)
|
|
case 'n':
|
|
// Device status reports: TUIs block waiting for these.
|
|
request := 0
|
|
if len(params) > 0 do request = params[0]
|
|
if request == 5 {
|
|
terminal_send(term, "\x1b[0n")
|
|
} else if request == 6 {
|
|
terminal_send(term, fmt.tprintf("\x1b[%d;%dR", term.cursor_row + 1, term.cursor_col + 1))
|
|
}
|
|
case 'c':
|
|
if len(term.csi) > 0 && term.csi[0] == '>' {
|
|
terminal_send(term, "\x1b[>0;0;0c")
|
|
} else {
|
|
terminal_send(term, "\x1b[?6c")
|
|
}
|
|
}
|
|
}
|
|
|
|
terminal_apply_sgr :: proc(term: ^Terminal_Panel, params: [dynamic]int) {
|
|
if len(params) == 0 {
|
|
term.fg = TERM_DEFAULT_COLOR
|
|
term.bg = TERM_DEFAULT_COLOR
|
|
term.bold = false
|
|
term.reverse = false
|
|
return
|
|
}
|
|
|
|
index := 0
|
|
for index < len(params) {
|
|
p := params[index]
|
|
switch {
|
|
case p == 0:
|
|
term.fg = TERM_DEFAULT_COLOR
|
|
term.bg = TERM_DEFAULT_COLOR
|
|
term.bold = false
|
|
term.reverse = false
|
|
case p == 1:
|
|
term.bold = true
|
|
case p == 22:
|
|
term.bold = false
|
|
case p == 7:
|
|
term.reverse = true
|
|
case p == 27:
|
|
term.reverse = false
|
|
case p >= 30 && p <= 37:
|
|
term.fg = u8(p - 30)
|
|
case p == 39:
|
|
term.fg = TERM_DEFAULT_COLOR
|
|
case p >= 90 && p <= 97:
|
|
term.fg = u8(p - 90 + 8)
|
|
case p >= 40 && p <= 47:
|
|
term.bg = u8(p - 40)
|
|
case p == 49:
|
|
term.bg = TERM_DEFAULT_COLOR
|
|
case p >= 100 && p <= 107:
|
|
term.bg = u8(p - 100 + 8)
|
|
case p == 38 || p == 48:
|
|
color := TERM_DEFAULT_COLOR
|
|
if index + 1 < len(params) && params[index + 1] == 5 && index + 2 < len(params) {
|
|
color = clamp_int(params[index + 2], 0, 255)
|
|
index += 2
|
|
} else if index + 1 < len(params) && params[index + 1] == 2 && index + 4 < len(params) {
|
|
color = terminal_nearest_256(params[index + 2], params[index + 3], params[index + 4])
|
|
index += 4
|
|
}
|
|
if p == 38 {
|
|
term.fg = u8(color)
|
|
} else {
|
|
term.bg = u8(color)
|
|
}
|
|
}
|
|
index += 1
|
|
}
|
|
}
|
|
|
|
terminal_nearest_256 :: proc(r, g, b: int) -> int {
|
|
scale :: proc(v: int) -> int {
|
|
return clamp_int((v * 5 + 127) / 255, 0, 5)
|
|
}
|
|
return 16 + 36 * scale(r) + 6 * scale(g) + scale(b)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Input
|
|
|
|
// Translates control keys for the pty. Printable characters arrive through
|
|
// TEXT_INPUT instead. Returns true when the key was consumed.
|
|
terminal_handle_key :: proc(term: ^Terminal_Panel, key: SDL.Keycode, mod: SDL.Keymod) -> bool {
|
|
if !term.open || !term.focused do return false
|
|
|
|
if key == SDL.K_ESCAPE {
|
|
term.focused = false
|
|
return true
|
|
}
|
|
|
|
ctrl := (mod & SDL.KMOD_CTRL) != SDL.KMOD_NONE
|
|
if ctrl {
|
|
if key >= SDL.K_A && key <= SDL.K_Z {
|
|
buf := [1]u8{u8(key - SDL.K_A) + 1}
|
|
terminal_send(term, string(buf[:]))
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
switch key {
|
|
case SDL.K_RETURN, SDL.K_KP_ENTER:
|
|
terminal_send(term, "\r")
|
|
case SDL.K_BACKSPACE:
|
|
terminal_send(term, "\x7f")
|
|
case SDL.K_TAB:
|
|
terminal_send(term, "\t")
|
|
case SDL.K_UP:
|
|
terminal_send(term, "\x1bOA" if term.app_cursor_keys else "\x1b[A")
|
|
case SDL.K_DOWN:
|
|
terminal_send(term, "\x1bOB" if term.app_cursor_keys else "\x1b[B")
|
|
case SDL.K_RIGHT:
|
|
terminal_send(term, "\x1bOC" if term.app_cursor_keys else "\x1b[C")
|
|
case SDL.K_LEFT:
|
|
terminal_send(term, "\x1bOD" if term.app_cursor_keys else "\x1b[D")
|
|
case SDL.K_HOME:
|
|
terminal_send(term, "\x1bOH" if term.app_cursor_keys else "\x1b[H")
|
|
case SDL.K_END:
|
|
terminal_send(term, "\x1bOF" if term.app_cursor_keys else "\x1b[F")
|
|
case SDL.K_DELETE:
|
|
terminal_send(term, "\x1b[3~")
|
|
case SDL.K_PAGEUP:
|
|
terminal_send(term, "\x1b[5~")
|
|
case SDL.K_PAGEDOWN:
|
|
terminal_send(term, "\x1b[6~")
|
|
case:
|
|
// Not a terminal key; TEXT_INPUT will deliver printable characters.
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Layout, view scrolling, rendering
|
|
|
|
// The panel spans the editor area: from the file tree to the Gradle sidebar
|
|
// (or the tool strip when the sidebar is closed).
|
|
terminal_panel_geometry :: proc(view: ^SDL_View, tasks_panel: ^Gradle_Tasks_Panel, window_width, window_height: int) -> (x, y, width, height: f32) {
|
|
right := content_right_edge(window_width)
|
|
if tasks_panel != nil && tasks_panel.open {
|
|
right -= right_sidebar_width_view(view, window_width)
|
|
}
|
|
x = f32(left_sidebar_width(view))
|
|
width = f32(right) - x
|
|
height = f32(bottom_panel_height(view))
|
|
y = f32(window_height - SDL_STATUS_BAR_HEIGHT) - height
|
|
return
|
|
}
|
|
|
|
terminal_panel_hit :: proc(term: ^Terminal_Panel, view: ^SDL_View, tasks_panel: ^Gradle_Tasks_Panel, x, y: f32) -> bool {
|
|
if !term.open do return false
|
|
panel_x, panel_y, width, height := terminal_panel_geometry(view, tasks_panel, view.window_width, view.window_height)
|
|
return x >= panel_x && x < panel_x + width && y >= panel_y && y < panel_y + height
|
|
}
|
|
|
|
handle_terminal_wheel :: proc(term: ^Terminal_Panel, git: ^Git_Panel, view: ^SDL_View, tasks_panel: ^Gradle_Tasks_Panel, x, y: f32, wheel_y: int) -> bool {
|
|
if !terminal_panel_hit(term, view, tasks_panel, x, y) do return false
|
|
|
|
if term.active_tab == .Git {
|
|
panel_x, panel_y, width, height := terminal_panel_geometry(view, tasks_panel, view.window_width, view.window_height)
|
|
if x < panel_x + git_panel_list_width(width) {
|
|
git.list_scroll = clamp_int(git.list_scroll - wheel_y * 3, 0, max_int(len(git.commits) - 1, 0))
|
|
return true
|
|
}
|
|
layout := git_detail_layout(git, panel_y + SDL_TERMINAL_HEADER, height - SDL_TERMINAL_HEADER)
|
|
git.file_scroll = clamp_int(git.file_scroll - wheel_y * 3, 0, max_int(len(git.files) - layout.file_rows, 0))
|
|
return true
|
|
}
|
|
|
|
if term.alt_active {
|
|
// Full-screen apps get the wheel as arrow keys, like most emulators.
|
|
arrow := ("\x1bOA" if term.app_cursor_keys else "\x1b[A") if wheel_y > 0 else ("\x1bOB" if term.app_cursor_keys else "\x1b[B")
|
|
for _ in 0 ..< 3 {
|
|
terminal_send(term, arrow)
|
|
}
|
|
return true
|
|
}
|
|
term.scroll_view = clamp_int(term.scroll_view + wheel_y * 3, 0, len(term.scrollback))
|
|
return true
|
|
}
|
|
|
|
// Theme palette: xterm 16 colors tuned slightly toward the editor theme,
|
|
// plus the standard 256-color cube and grayscale ramp.
|
|
terminal_palette :: proc(index: u8) -> (u8, u8, u8) {
|
|
if index < 16 {
|
|
@(static, rodata)
|
|
base := [16][3]u8{
|
|
{40, 42, 48}, {224, 108, 117}, {152, 195, 121}, {229, 192, 123},
|
|
{97, 175, 239}, {198, 120, 221}, {86, 182, 194}, {200, 205, 215},
|
|
{110, 115, 126}, {236, 130, 138}, {170, 216, 140}, {240, 208, 144},
|
|
{120, 190, 250}, {215, 145, 235}, {105, 200, 212}, {235, 238, 245},
|
|
}
|
|
color := base[index]
|
|
return color[0], color[1], color[2]
|
|
}
|
|
if index < 232 {
|
|
value := int(index) - 16
|
|
levels := [6]u8{0, 95, 135, 175, 215, 255}
|
|
return levels[value / 36], levels[(value / 6) % 6], levels[value % 6]
|
|
}
|
|
gray := u8(8 + (int(index) - 232) * 10)
|
|
return gray, gray, gray
|
|
}
|
|
|
|
terminal_visible_rows :: proc(view: ^SDL_View) -> int {
|
|
return max_int((bottom_panel_height(view) - SDL_TERMINAL_HEADER - 8) / SDL_TERMINAL_LINE_HEIGHT, 1)
|
|
}
|
|
|
|
// Header tab strip: shared geometry for rendering and click handling.
|
|
bottom_tab_rect :: proc(panel_x, panel_y: f32, tab: Bottom_Tab) -> (x, y, width, height: f32) {
|
|
x = panel_x + 10
|
|
for t in Bottom_Tab {
|
|
width = f32(gpu_text_width(bottom_tab_label(t))) + 20
|
|
if t == tab do break
|
|
x += width + 4
|
|
}
|
|
y = panel_y + 2
|
|
height = SDL_TERMINAL_HEADER - 4
|
|
return
|
|
}
|
|
|
|
// Handles all clicks inside the bottom panel: tab switching, terminal focus,
|
|
// and commit selection on the Git tab.
|
|
handle_bottom_panel_click :: proc(term: ^Terminal_Panel, git: ^Git_Panel, view: ^SDL_View, tasks_panel: ^Gradle_Tasks_Panel, workspace: string, x, y: f32) -> bool {
|
|
if !terminal_panel_hit(term, view, tasks_panel, x, y) do return false
|
|
panel_x, panel_y, width, height := terminal_panel_geometry(view, tasks_panel, view.window_width, view.window_height)
|
|
|
|
if y < panel_y + SDL_TERMINAL_HEADER {
|
|
for tab in Bottom_Tab {
|
|
tab_x, tab_y, tab_width, tab_height := bottom_tab_rect(panel_x, panel_y, tab)
|
|
if x >= tab_x && x < tab_x + tab_width && y >= tab_y && y < tab_y + tab_height {
|
|
term.active_tab = tab
|
|
if tab == .Git {
|
|
term.focused = false
|
|
git_panel_activate(git, workspace)
|
|
} else {
|
|
term.focused = true
|
|
_ = terminal_start(term, workspace)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
if term.active_tab == .Terminal {
|
|
term.focused = true
|
|
return true
|
|
}
|
|
|
|
// Git tab: list column selects a commit; detail pane selects a file.
|
|
term.focused = false
|
|
body_y := panel_y + SDL_TERMINAL_HEADER
|
|
if x < panel_x + git_panel_list_width(width) {
|
|
row := int((y - body_y - 4) / GIT_PANEL_ROW_HEIGHT)
|
|
index := git.list_scroll + row
|
|
if row >= 0 && index >= 0 && index < len(git.commits) {
|
|
git.selected = index
|
|
git_panel_request_show(git, workspace, git.commits[index].hash)
|
|
}
|
|
return true
|
|
}
|
|
layout := git_detail_layout(git, body_y, height - SDL_TERMINAL_HEADER)
|
|
if y >= layout.files_y && y < layout.files_y + f32(layout.file_rows) * GIT_PANEL_ROW_HEIGHT {
|
|
row := int((y - layout.files_y) / GIT_PANEL_ROW_HEIGHT)
|
|
index := git.file_scroll + row
|
|
if index >= 0 && index < len(git.files) {
|
|
git.file_selected = index
|
|
git_panel_request_diff(git, workspace, git.detail_hash, git.files[index].path)
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
render_terminal_panel_gpu :: proc(gpu: ^GPU_Renderer, view: ^SDL_View, term: ^Terminal_Panel, git: ^Git_Panel, tasks_panel: ^Gradle_Tasks_Panel) {
|
|
if !term.open do return
|
|
terminal_ensure_grid(term)
|
|
|
|
x, y, width, height := terminal_panel_geometry(view, tasks_panel, gpu.width, gpu.height)
|
|
gpu_rect(gpu, x, y, width, height, 24, 25, 29, 255)
|
|
if term.focused && term.active_tab == .Terminal {
|
|
gpu_rect(gpu, x, y, width, 1, 77, 155, 230, 255)
|
|
} else {
|
|
gpu_rect(gpu, x, y, width, 1, 56, 58, 64, 255)
|
|
}
|
|
|
|
// Tab strip.
|
|
status_x := x
|
|
for tab in Bottom_Tab {
|
|
tab_x, tab_y, tab_width, tab_height := bottom_tab_rect(x, y, tab)
|
|
active := term.active_tab == tab
|
|
if active {
|
|
gpu_rect(gpu, tab_x, tab_y, tab_width, tab_height, 35, 37, 44, 255)
|
|
gpu_rect(gpu, tab_x, y + SDL_TERMINAL_HEADER - 3, tab_width, 2, 77, 155, 230, 255)
|
|
gpu_text(gpu, tab_x + 10, tab_y + 5, bottom_tab_label(tab), 220, 223, 228, 255)
|
|
} else {
|
|
gpu_text(gpu, tab_x + 10, tab_y + 5, bottom_tab_label(tab), 150, 154, 162, 255)
|
|
}
|
|
status_x = tab_x + tab_width + 16
|
|
}
|
|
if term.active_tab == .Terminal {
|
|
if !term.running {
|
|
gpu_text(gpu, status_x, y + 7, "(shell exited)", 170, 120, 120, 255)
|
|
} else if term.scroll_view > 0 {
|
|
gpu_text(gpu, status_x, y + 7, fmt.tprintf("(scrolled %d)", term.scroll_view), 150, 154, 162, 255)
|
|
}
|
|
}
|
|
|
|
// Match the grid and pty size to the panel before drawing, so the shell
|
|
// reflows even while the Git tab is showing.
|
|
advance := max_f32(gpu.font_advance, 1)
|
|
rows := terminal_visible_rows(view)
|
|
cols := max_int(int((width - 24) / advance), 8)
|
|
terminal_resize(term, rows, cols)
|
|
|
|
if term.active_tab == .Git {
|
|
render_git_panel_body(gpu, view, git, x, y + SDL_TERMINAL_HEADER, width, height - SDL_TERMINAL_HEADER)
|
|
return
|
|
}
|
|
|
|
if term.alt_active do term.scroll_view = 0
|
|
term.scroll_view = clamp_int(term.scroll_view, 0, len(term.scrollback))
|
|
|
|
text_x := x + 12
|
|
line_y := y + SDL_TERMINAL_HEADER
|
|
// The view window ends scroll_view lines above the live screen bottom.
|
|
top := len(term.scrollback) - term.scroll_view
|
|
for r in 0 ..< rows {
|
|
global := top + r
|
|
row_cells: []Term_Cell
|
|
if global < len(term.scrollback) {
|
|
row_cells = term.scrollback[global][:]
|
|
} else {
|
|
screen_row := global - len(term.scrollback)
|
|
if screen_row >= rows do break
|
|
screen := terminal_screen(term)
|
|
row_cells = screen[screen_row * cols : (screen_row + 1) * cols]
|
|
}
|
|
|
|
// Background runs first, then text runs grouped by color.
|
|
run_start := 0
|
|
for c := 0; c <= len(row_cells); c += 1 {
|
|
flush := c == len(row_cells)
|
|
if !flush && row_cells[c].bg == row_cells[run_start].bg do continue
|
|
bg := row_cells[run_start].bg
|
|
if bg != TERM_DEFAULT_COLOR {
|
|
red, green, blue := terminal_palette(bg)
|
|
gpu_rect(gpu, text_x + f32(run_start) * advance, line_y - 1, f32(c - run_start) * advance, SDL_TERMINAL_LINE_HEIGHT, red, green, blue, 255)
|
|
}
|
|
run_start = c
|
|
}
|
|
for c in 0 ..< len(row_cells) {
|
|
cell := row_cells[c]
|
|
if cell.ch == ' ' || cell.ch == 0 do continue
|
|
red, green, blue := u8(210), u8(215), u8(224)
|
|
if cell.fg != TERM_DEFAULT_COLOR {
|
|
red, green, blue = terminal_palette(cell.fg)
|
|
}
|
|
cell_x := text_x + f32(c) * advance
|
|
if !gpu_rune(gpu, cell_x, line_y, cell.ch, red, green, blue, 255) {
|
|
fallback := [1]u8{terminal_ascii_for_rune(cell.ch)}
|
|
gpu_text(gpu, cell_x, line_y, string(fallback[:]), red, green, blue, 255)
|
|
}
|
|
}
|
|
|
|
// Cursor block on the live screen.
|
|
if term.focused && term.cursor_visible && term.scroll_view == 0 && global - len(term.scrollback) == term.cursor_row {
|
|
cursor_x := text_x + f32(min_int(term.cursor_col, cols - 1)) * advance
|
|
gpu_rect(gpu, cursor_x, line_y - 1, advance, SDL_TERMINAL_LINE_HEIGHT, 145, 180, 235, 170)
|
|
}
|
|
line_y += SDL_TERMINAL_LINE_HEIGHT
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Self-test: `editor --terminal-selftest` runs the emulator against known
|
|
// sequences without a pty and reports mismatches.
|
|
|
|
terminal_selftest_row :: proc(term: ^Terminal_Panel, row: int) -> string {
|
|
builder: strings.Builder
|
|
strings.builder_init(&builder, context.temp_allocator)
|
|
for c in 0 ..< term.cols {
|
|
strings.write_rune(&builder, terminal_cell_at(term, row, c).ch)
|
|
}
|
|
return strings.trim_right(strings.to_string(builder), " ")
|
|
}
|
|
|
|
terminal_selftest_feed :: proc(term: ^Terminal_Panel, bytes: string) {
|
|
for b in transmute([]u8)bytes {
|
|
terminal_consume_byte(term, b)
|
|
}
|
|
}
|
|
|
|
terminal_selftest :: proc() -> bool {
|
|
term := Terminal_Panel{}
|
|
defer terminal_destroy(&term)
|
|
terminal_ensure_grid(&term)
|
|
ok := true
|
|
|
|
check :: proc(ok: ^bool, name: string, condition: bool) {
|
|
if condition {
|
|
fmt.printf("ok %s\n", name)
|
|
} else {
|
|
fmt.printf("FAIL %s\n", name)
|
|
ok^ = false
|
|
}
|
|
}
|
|
|
|
terminal_selftest_feed(&term, "hello\r\nworld")
|
|
check(&ok, "plain text", terminal_selftest_row(&term, 0) == "hello" && terminal_selftest_row(&term, 1) == "world")
|
|
check(&ok, "cursor after text", term.cursor_row == 1 && term.cursor_col == 5)
|
|
|
|
terminal_selftest_feed(&term, "\x1b[2J\x1b[H")
|
|
check(&ok, "clear+home", terminal_selftest_row(&term, 0) == "" && term.cursor_row == 0 && term.cursor_col == 0)
|
|
|
|
terminal_selftest_feed(&term, "\x1b[3;5Habc")
|
|
check(&ok, "cursor addressing", terminal_selftest_row(&term, 2) == " abc")
|
|
|
|
terminal_selftest_feed(&term, "\x1b[31mR\x1b[0m")
|
|
check(&ok, "sgr color", terminal_cell_at(&term, 2, 7).ch == 'R' && terminal_cell_at(&term, 2, 7).fg == 1 && term.fg == TERM_DEFAULT_COLOR)
|
|
|
|
terminal_selftest_feed(&term, "\x1b[38;5;208mX")
|
|
check(&ok, "sgr 256", terminal_cell_at(&term, 2, 8).fg == 208)
|
|
|
|
terminal_selftest_feed(&term, "\x1b[3;1H\x1b[K")
|
|
check(&ok, "erase line", terminal_selftest_row(&term, 2) == "")
|
|
|
|
terminal_selftest_feed(&term, "\x1b[Hline1\r\nline2")
|
|
terminal_selftest_feed(&term, "\x1b[?1049halt-screen")
|
|
check(&ok, "alt screen", term.alt_active && terminal_selftest_row(&term, 0) == "alt-screen")
|
|
terminal_selftest_feed(&term, "\x1b[?1049l")
|
|
check(&ok, "primary restored", !term.alt_active && terminal_selftest_row(&term, 0) == "line1" && terminal_selftest_row(&term, 1) == "line2")
|
|
|
|
terminal_selftest_feed(&term, "\x1b[2J\x1b[H")
|
|
for i in 0 ..< term.rows {
|
|
terminal_selftest_feed(&term, fmt.tprintf("row%d", i))
|
|
if i < term.rows - 1 do terminal_selftest_feed(&term, "\r\n")
|
|
}
|
|
terminal_selftest_feed(&term, "\r\nextra")
|
|
check(&ok, "scroll into scrollback", len(term.scrollback) > 0 && terminal_selftest_row(&term, term.rows - 1) == "extra")
|
|
top_line := term.scrollback[len(term.scrollback) - 1]
|
|
check(&ok, "scrollback content", top_line[0].ch == 'r' && top_line[1].ch == 'o' && top_line[2].ch == 'w' && top_line[3].ch == '0')
|
|
|
|
terminal_selftest_feed(&term, "\x1b[2J\x1b[H\xe2\x94\x80\xe2\x94\x82\xe2\x94\x8c")
|
|
check(&ok, "utf8 runes stored", terminal_selftest_row(&term, 0) == "\u2500\u2502\u250c")
|
|
|
|
terminal_selftest_feed(&term, "\x1b]0;window title\x07after")
|
|
check(&ok, "osc ignored", strings.has_suffix(terminal_selftest_row(&term, 0), "after"))
|
|
|
|
terminal_selftest_feed(&term, "\x1b[2J\x1b[Habcdef\x1b[1;3H\x1b[2P")
|
|
check(&ok, "delete chars", terminal_selftest_row(&term, 0) == "abef")
|
|
|
|
terminal_selftest_feed(&term, "\x1b[2J\x1b[HAB\x1b[1;1H\x1b[2@")
|
|
check(&ok, "insert chars", terminal_selftest_row(&term, 0) == " AB")
|
|
|
|
fmt.println(ok ? "terminal selftest: PASS" : "terminal selftest: FAIL")
|
|
return ok
|
|
}
|