editor/client/odin/terminal.odin
2026-07-08 10:28:32 +02:00

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(&params, value if has_value else 0)
value = 0
has_value = false
case b == '?':
private = true
}
}
if has_value || len(params) > 0 {
append(&params, 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
}