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 }