From ba7b7dca8723135ce539e32fa3c013ae15061ab8 Mon Sep 17 00:00:00 2001 From: Daniel Hammer Date: Wed, 4 Mar 2026 16:46:11 +0100 Subject: [PATCH] feat: terminal performance improvements, add scrolling and tabs --- programs/include/gui/terminal.hpp | 233 ++++++++++++------- programs/src/desktop/app_klog.cpp | 75 ++++-- programs/src/desktop/app_terminal.cpp | 323 ++++++++++++++++++++++++-- 3 files changed, 516 insertions(+), 115 deletions(-) diff --git a/programs/include/gui/terminal.hpp b/programs/include/gui/terminal.hpp index 29300d4..09da368 100644 --- a/programs/include/gui/terminal.hpp +++ b/programs/include/gui/terminal.hpp @@ -27,14 +27,17 @@ struct TerminalState { int cols, rows; int cursor_x, cursor_y; int saved_cursor_x, saved_cursor_y; // saved cursor for alternate screen - int scroll_top; - int total_rows; + int scrollback_lines; // lines of history above visible screen (0..max_scrollback) + int max_scrollback; // 0 for viewers (klog), TERM_MAX_SCROLLBACK for terminal + int view_offset; // how many rows scrolled back (0 = live view) int child_pid; + void* desktop; // DesktopState* for closing window on child exit Color current_fg; Color current_bg; bool cursor_visible; bool alt_screen_active; bool reverse_video; + bool dirty; // true when content changed since last render enum { STATE_NORMAL, STATE_ESC, STATE_CSI } parse_state; bool csi_private; // true if '?' was seen after CSI @@ -66,52 +69,77 @@ static inline Color term_ansi_color(int idx) { } } +// Helper: pointer to start of screen-relative row r in the cells buffer +static inline TermCell* term_screen_row(TerminalState* t, int r) { + return &t->cells[(t->scrollback_lines + r) * t->cols]; +} + static inline void terminal_scroll_up(TerminalState* t) { - // Move all rows up by one - for (int r = 0; r < t->rows - 1; r++) { - for (int c = 0; c < t->cols; c++) { - t->cells[r * t->cols + c] = t->cells[(r + 1) * t->cols + c]; - } + if (!t->alt_screen_active && t->scrollback_lines < t->max_scrollback) { + // Room for scrollback: top visible row becomes scrollback, no data movement + t->scrollback_lines++; + } else if (!t->alt_screen_active && t->max_scrollback > 0) { + // Scrollback full: discard oldest line, shift entire buffer up by one row + int total = (t->max_scrollback + t->rows - 1) * t->cols * sizeof(TermCell); + montauk::memmove(t->cells, t->cells + t->cols, total); + } else { + // Alt screen or no scrollback (klog): shift visible area up + TermCell* screen = term_screen_row(t, 0); + montauk::memmove(screen, screen + t->cols, (t->rows - 1) * t->cols * sizeof(TermCell)); } - // Clear last row - int last = t->rows - 1; + + // Clear the new bottom visible row + TermCell* bottom = term_screen_row(t, t->rows - 1); for (int c = 0; c < t->cols; c++) { - t->cells[last * t->cols + c] = {' ', t->current_fg, colors::TERM_BG}; + bottom[c] = {' ', t->current_fg, colors::TERM_BG}; + } + + // Keep user's scrolled-back viewport stable + if (t->view_offset > 0) { + t->view_offset++; + if (t->view_offset > t->scrollback_lines) + t->view_offset = t->scrollback_lines; } } // Initialize only the cell grid (no child process). Used by viewers like klog. -static inline void terminal_init_cells(TerminalState* t, int cols, int rows) { +// max_sb = 0 for viewers, TERM_MAX_SCROLLBACK for the real terminal. +static inline void terminal_init_cells(TerminalState* t, int cols, int rows, int max_sb = 0) { t->cols = cols; t->rows = rows; t->cursor_x = 0; t->cursor_y = 0; t->saved_cursor_x = 0; t->saved_cursor_y = 0; - t->scroll_top = 0; - t->total_rows = rows; + t->scrollback_lines = 0; + t->max_scrollback = max_sb; + t->view_offset = 0; t->current_fg = colors::TERM_FG; t->current_bg = colors::TERM_BG; t->cursor_visible = false; t->alt_screen_active = false; t->reverse_video = false; + t->dirty = true; t->parse_state = TerminalState::STATE_NORMAL; t->csi_private = false; t->csi_param_count = 0; t->csi_current_param = 0; t->child_pid = 0; - int total_cells = cols * rows; + int total_cells = (rows + max_sb) * cols; + int screen_cells = rows * cols; t->cells = (TermCell*)montauk::alloc(total_cells * sizeof(TermCell)); - t->alt_cells = (TermCell*)montauk::alloc(total_cells * sizeof(TermCell)); + t->alt_cells = (TermCell*)montauk::alloc(screen_cells * sizeof(TermCell)); for (int i = 0; i < total_cells; i++) { t->cells[i] = {' ', colors::TERM_FG, colors::TERM_BG}; + } + for (int i = 0; i < screen_cells; i++) { t->alt_cells[i] = {' ', colors::TERM_FG, colors::TERM_BG}; } } static inline void terminal_init(TerminalState* t, int cols, int rows) { - terminal_init_cells(t, cols, rows); + terminal_init_cells(t, cols, rows, TERM_MAX_SCROLLBACK); t->cursor_visible = true; t->child_pid = montauk::spawn_redir("0:/os/shell.elf"); @@ -127,25 +155,28 @@ static inline void terminal_put_char(TerminalState* t, char ch) { terminal_scroll_up(t); t->cursor_y = t->rows - 1; } - int idx = t->cursor_y * t->cols + t->cursor_x; - t->cells[idx].ch = ch; - t->cells[idx].fg = t->current_fg; - t->cells[idx].bg = t->current_bg; + TermCell* row = term_screen_row(t, t->cursor_y); + row[t->cursor_x].ch = ch; + row[t->cursor_x].fg = t->current_fg; + row[t->cursor_x].bg = t->current_bg; t->cursor_x++; } static inline void terminal_enter_alt_screen(TerminalState* t) { if (t->alt_screen_active) return; t->alt_screen_active = true; + t->dirty = true; // Save cursor t->saved_cursor_x = t->cursor_x; t->saved_cursor_y = t->cursor_y; - // Swap buffers: save main screen to alt_cells, clear main + // Save visible screen to alt_cells, clear visible screen int total = t->cols * t->rows; + TermCell* screen = term_screen_row(t, 0); for (int i = 0; i < total; i++) { - t->alt_cells[i] = t->cells[i]; - t->cells[i] = {' ', colors::TERM_FG, colors::TERM_BG}; + t->alt_cells[i] = screen[i]; + screen[i] = {' ', colors::TERM_FG, colors::TERM_BG}; } + t->view_offset = 0; t->cursor_x = 0; t->cursor_y = 0; } @@ -153,10 +184,12 @@ static inline void terminal_enter_alt_screen(TerminalState* t) { static inline void terminal_exit_alt_screen(TerminalState* t) { if (!t->alt_screen_active) return; t->alt_screen_active = false; - // Restore main screen from alt_cells + t->dirty = true; + // Restore visible screen from alt_cells int total = t->cols * t->rows; + TermCell* screen = term_screen_row(t, 0); for (int i = 0; i < total; i++) { - t->cells[i] = t->alt_cells[i]; + screen[i] = t->alt_cells[i]; } // Restore cursor t->cursor_x = t->saved_cursor_x; @@ -244,22 +277,31 @@ static inline void terminal_process_csi(TerminalState* t, char cmd) { // Erase in display if (p0 == 0) { // Clear from cursor to end + TermCell* row = term_screen_row(t, t->cursor_y); for (int x = t->cursor_x; x < t->cols; x++) - t->cells[t->cursor_y * t->cols + x] = {' ', t->current_fg, colors::TERM_BG}; - for (int r = t->cursor_y + 1; r < t->rows; r++) + row[x] = {' ', t->current_fg, colors::TERM_BG}; + for (int r = t->cursor_y + 1; r < t->rows; r++) { + TermCell* rp = term_screen_row(t, r); for (int c = 0; c < t->cols; c++) - t->cells[r * t->cols + c] = {' ', t->current_fg, colors::TERM_BG}; + rp[c] = {' ', t->current_fg, colors::TERM_BG}; + } } else if (p0 == 1) { // Clear from start to cursor - for (int r = 0; r < t->cursor_y; r++) + for (int r = 0; r < t->cursor_y; r++) { + TermCell* rp = term_screen_row(t, r); for (int c = 0; c < t->cols; c++) - t->cells[r * t->cols + c] = {' ', t->current_fg, colors::TERM_BG}; + rp[c] = {' ', t->current_fg, colors::TERM_BG}; + } + TermCell* row = term_screen_row(t, t->cursor_y); for (int x = 0; x <= t->cursor_x; x++) - t->cells[t->cursor_y * t->cols + x] = {' ', t->current_fg, colors::TERM_BG}; + row[x] = {' ', t->current_fg, colors::TERM_BG}; } else if (p0 == 2) { // Clear entire screen - for (int i = 0; i < t->rows * t->cols; i++) - t->cells[i] = {' ', t->current_fg, colors::TERM_BG}; + for (int r = 0; r < t->rows; r++) { + TermCell* rp = term_screen_row(t, r); + for (int c = 0; c < t->cols; c++) + rp[c] = {' ', t->current_fg, colors::TERM_BG}; + } t->cursor_x = 0; t->cursor_y = 0; } @@ -271,8 +313,9 @@ static inline void terminal_process_csi(TerminalState* t, char cmd) { if (p0 == 0) { start = t->cursor_x; end = t->cols; } else if (p0 == 1) { start = 0; end = t->cursor_x + 1; } else if (p0 == 2) { start = 0; end = t->cols; } + TermCell* row = term_screen_row(t, t->cursor_y); for (int x = start; x < end; x++) - t->cells[t->cursor_y * t->cols + x] = {' ', t->current_fg, colors::TERM_BG}; + row[x] = {' ', t->current_fg, colors::TERM_BG}; break; } case 'm': { @@ -348,6 +391,7 @@ static inline void terminal_process_csi(TerminalState* t, char cmd) { } static inline void terminal_feed(TerminalState* t, const char* data, int len) { + if (len > 0) t->dirty = true; for (int i = 0; i < len; i++) { char ch = data[i]; @@ -423,18 +467,26 @@ static inline void terminal_feed(TerminalState* t, const char* data, int len) { } static inline void terminal_render(TerminalState* t, uint32_t* pixels, int pw, int ph) { + if (!t->dirty) return; + t->dirty = false; + int cell_w = mono_cell_width(); int cell_h = mono_cell_height(); bool use_ttf = fonts::mono && fonts::mono->valid; GlyphCache* gc = use_ttf ? fonts::mono->get_cache(fonts::TERM_SIZE) : nullptr; - // Fill background + // Fill background using row-copy: fill first row, then memcpy to the rest uint32_t bg_px = colors::TERM_BG.to_pixel(); - int total = pw * ph; - for (int i = 0; i < total; i++) { - pixels[i] = bg_px; + int row_bytes = pw * sizeof(uint32_t); + for (int i = 0; i < pw; i++) pixels[i] = bg_px; + for (int r = 1; r < ph; r++) { + montauk::memcpy(&pixels[r * pw], pixels, row_bytes); } + // Determine which rows of the buffer to display + int base_row = t->scrollback_lines - t->view_offset; + if (base_row < 0) base_row = 0; + // Render each visible cell int visible_rows = ph / cell_h; int visible_cols = pw / cell_w; @@ -442,23 +494,22 @@ static inline void terminal_render(TerminalState* t, uint32_t* pixels, int pw, i if (visible_cols > t->cols) visible_cols = t->cols; for (int r = 0; r < visible_rows; r++) { + int py = r * cell_h; + int src_row = base_row + r; for (int c = 0; c < visible_cols; c++) { - int idx = r * t->cols + c; + int idx = src_row * t->cols + c; TermCell& cell = t->cells[idx]; int px = c * cell_w; - int py = r * cell_h; + // Only draw cell background if it differs from terminal bg uint32_t cell_bg = cell.bg.to_pixel(); - - // Draw cell background - for (int fy = 0; fy < cell_h; fy++) { - int dy = py + fy; - if (dy >= ph) break; - for (int fx = 0; fx < cell_w; fx++) { - int dx = px + fx; - if (dx >= pw) break; - pixels[dy * pw + dx] = cell_bg; + if (cell_bg != bg_px) { + for (int fy = 0; fy < cell_h && py + fy < ph; fy++) { + uint32_t* row = &pixels[(py + fy) * pw + px]; + for (int fx = 0; fx < cell_w && px + fx < pw; fx++) { + row[fx] = cell_bg; + } } } @@ -488,8 +539,9 @@ static inline void terminal_render(TerminalState* t, uint32_t* pixels, int pw, i } } - // Draw cursor - if (t->cursor_visible && t->cursor_x < visible_cols && t->cursor_y < visible_rows) { + // Draw cursor (only when viewing live position) + if (t->view_offset == 0 && t->cursor_visible && + t->cursor_x < visible_cols && t->cursor_y < visible_rows) { int cx = t->cursor_x * cell_w; int cy = t->cursor_y * cell_h; uint32_t cursor_px = colors::WHITE.to_pixel(); @@ -504,8 +556,8 @@ static inline void terminal_render(TerminalState* t, uint32_t* pixels, int pw, i } // Draw character on top of cursor in black if (t->cursor_y < t->rows && t->cursor_x < t->cols) { - int idx = t->cursor_y * t->cols + t->cursor_x; - char ch = t->cells[idx].ch; + TermCell* row = term_screen_row(t, t->cursor_y); + char ch = row[t->cursor_x].ch; if (ch > 32 || ch < 0) { if (use_ttf) { int baseline = cy + gc->ascent; @@ -535,48 +587,55 @@ static inline void terminal_render(TerminalState* t, uint32_t* pixels, int pw, i static inline void terminal_resize(TerminalState* t, int new_cols, int new_rows) { if (new_cols == t->cols && new_rows == t->rows) return; if (new_cols < 1 || new_rows < 1) return; + t->dirty = true; - int new_total = new_cols * new_rows; + int new_capacity = new_rows + t->max_scrollback; + int new_total = new_capacity * new_cols; TermCell* new_cells = (TermCell*)montauk::alloc(new_total * sizeof(TermCell)); - TermCell* new_alt = (TermCell*)montauk::alloc(new_total * sizeof(TermCell)); + TermCell* new_alt = (TermCell*)montauk::alloc(new_rows * new_cols * sizeof(TermCell)); // Clear new buffers - for (int i = 0; i < new_total; i++) { + for (int i = 0; i < new_total; i++) new_cells[i] = {' ', colors::TERM_FG, colors::TERM_BG}; + for (int i = 0; i < new_rows * new_cols; i++) new_alt[i] = {' ', colors::TERM_FG, colors::TERM_BG}; - } - // Copy existing content (as much as fits) - int copy_rows = t->rows < new_rows ? t->rows : new_rows; + // Copy content: scrollback + visible screen + int old_content = t->scrollback_lines + t->rows; + int keep = old_content < new_capacity ? old_content : new_capacity; + int discard = old_content - keep; int copy_cols = t->cols < new_cols ? t->cols : new_cols; - // If cursor is beyond the new grid, scroll content up to keep cursor visible - int row_offset = 0; - if (t->cursor_y >= new_rows) { - row_offset = t->cursor_y - new_rows + 1; - } - - for (int r = 0; r < copy_rows && (r + row_offset) < t->rows; r++) { + for (int r = 0; r < keep; r++) { for (int c = 0; c < copy_cols; c++) { - new_cells[r * new_cols + c] = t->cells[(r + row_offset) * t->cols + c]; + new_cells[r * new_cols + c] = t->cells[(discard + r) * t->cols + c]; } } + int new_scrollback = keep - new_rows; + if (new_scrollback < 0) new_scrollback = 0; + + // Adjust cursor + int abs_cursor_y = t->scrollback_lines + t->cursor_y - discard; + int new_cursor_y = abs_cursor_y - new_scrollback; + if (new_cursor_y < 0) new_cursor_y = 0; + if (new_cursor_y >= new_rows) new_cursor_y = new_rows - 1; + int new_cursor_x = t->cursor_x < new_cols ? t->cursor_x : new_cols - 1; + if (t->cells) montauk::free(t->cells); if (t->alt_cells) montauk::free(t->alt_cells); t->cells = new_cells; t->alt_cells = new_alt; - - int old_cols = t->cols; t->cols = new_cols; t->rows = new_rows; + t->scrollback_lines = new_scrollback; + t->cursor_x = new_cursor_x; + t->cursor_y = new_cursor_y; - // Adjust cursor position - t->cursor_y -= row_offset; - if (t->cursor_x >= new_cols) t->cursor_x = new_cols - 1; - if (t->cursor_y >= new_rows) t->cursor_y = new_rows - 1; - if (t->cursor_y < 0) t->cursor_y = 0; + // Clamp view offset + if (t->view_offset > t->scrollback_lines) + t->view_offset = t->scrollback_lines; // Notify child process of new terminal size if (t->child_pid > 0) { @@ -585,18 +644,32 @@ static inline void terminal_resize(TerminalState* t, int new_cols, int new_rows) } static inline void terminal_handle_key(TerminalState* t, const Montauk::KeyEvent& key) { + // Snap to live on any keyboard input + if (t->view_offset > 0) { + t->view_offset = 0; + t->dirty = true; + } if (t->child_pid > 0) { montauk::childio_writekey(t->child_pid, &key); } } -static inline void terminal_poll(TerminalState* t) { - if (t->child_pid <= 0) return; - char buf[512]; - int n = montauk::childio_read(t->child_pid, buf, sizeof(buf)); - if (n > 0) { - terminal_feed(t, buf, n); +// Returns false if the child process has exited +static inline bool terminal_poll(TerminalState* t) { + if (t->child_pid <= 0) return false; + char buf[4096]; + // Drain all available data so large output renders in one frame + for (;;) { + int n = montauk::childio_read(t->child_pid, buf, sizeof(buf)); + if (n > 0) { + terminal_feed(t, buf, n); + } else { + // n == -1 means child process is gone; n == 0 means no data yet + if (n < 0) { t->child_pid = 0; return false; } + break; + } } + return true; } } // namespace gui diff --git a/programs/src/desktop/app_klog.cpp b/programs/src/desktop/app_klog.cpp index 4c21037..30d10db 100644 --- a/programs/src/desktop/app_klog.cpp +++ b/programs/src/desktop/app_klog.cpp @@ -22,29 +22,35 @@ struct KlogState { }; // ============================================================================ -// Re-feed the last screenful of log into the terminal +// Re-feed log into the terminal (fills scrollback + visible screen) // ============================================================================ static void klog_refeed(KlogState* klog, int n) { - // Find start of last `rows` lines from the end of the buffer - int lines_needed = klog->term.rows; + // Find enough lines to fill scrollback + visible screen + int lines_needed = klog->term.rows + klog->term.max_scrollback; int start = 0; - for (int i = n - 1; i >= 0 && lines_needed > 0; i--) { + int line_count = 0; + for (int i = n - 1; i >= 0; i--) { if (klog->klog_buf[i] == '\n') { - lines_needed--; - if (lines_needed == 0) { start = i + 1; break; } + line_count++; + if (line_count >= lines_needed) { start = i + 1; break; } } } - // Clear terminal - int total = klog->term.cols * klog->term.rows; - for (int i = 0; i < total; i++) - klog->term.cells[i] = {' ', colors::TERM_FG, colors::TERM_BG}; + // Reset terminal state + klog->term.scrollback_lines = 0; + klog->term.view_offset = 0; klog->term.cursor_x = 0; klog->term.cursor_y = 0; klog->term.current_fg = colors::TERM_FG; klog->term.current_bg = colors::TERM_BG; + // Clear visible screen + TermCell* screen = term_screen_row(&klog->term, 0); + int total = klog->term.cols * klog->term.rows; + for (int i = 0; i < total; i++) + screen[i] = {' ', colors::TERM_FG, colors::TERM_BG}; + terminal_feed(&klog->term, klog->klog_buf + start, n - start); } @@ -57,11 +63,54 @@ static void klog_on_draw(Window* win, Framebuffer& fb) { if (!klog) return; Rect cr = win->content_rect(); + + // Handle resize + int new_cols = cr.w / mono_cell_width(); + int new_rows = cr.h / mono_cell_height(); + if (new_cols != klog->term.cols || new_rows != klog->term.rows) { + terminal_resize(&klog->term, new_cols, new_rows); + // Refeed to fill the new grid with log content + if (klog->last_len > 0) { + klog_refeed(klog, klog->last_len); + } + } + + bool was_dirty = klog->term.dirty; terminal_render(&klog->term, win->content, cr.w, cr.h); + + // Draw scrollbar overlay when scrollback exists + if (was_dirty && klog->term.scrollback_lines > 0) { + int cell_h = mono_cell_height(); + int total_rows = klog->term.scrollback_lines + klog->term.rows; + int total_h = total_rows * cell_h; + int view_h = cr.h; + if (total_h > view_h) { + Canvas c(win->content, cr.w, cr.h); + int sb_w = 4; + int sb_x = cr.w - sb_w - 2; + int thumb_h = (view_h * view_h) / total_h; + if (thumb_h < 20) thumb_h = 20; + int scroll_from_top = klog->term.scrollback_lines - klog->term.view_offset; + int max_scroll = klog->term.scrollback_lines; + int thumb_y = (max_scroll > 0) + ? (scroll_from_top * (view_h - thumb_h)) / max_scroll : 0; + c.fill_rect(sb_x, 0, sb_w, view_h, Color::from_rgb(0x33, 0x33, 0x33)); + c.fill_rect(sb_x, thumb_y, sb_w, thumb_h, Color::from_rgb(0x88, 0x88, 0x88)); + } + } } static void klog_on_mouse(Window* win, MouseEvent& ev) { - // Read-only viewer — no mouse interaction needed + KlogState* klog = (KlogState*)win->app_data; + if (!klog) return; + + if (ev.scroll != 0) { + klog->term.view_offset += (ev.scroll < 0) ? 3 : -3; + if (klog->term.view_offset < 0) klog->term.view_offset = 0; + if (klog->term.view_offset > klog->term.scrollback_lines) + klog->term.view_offset = klog->term.scrollback_lines; + klog->term.dirty = true; + } } static void klog_on_key(Window* win, const Montauk::KeyEvent& key) { @@ -125,8 +174,8 @@ void open_klog(DesktopState* ds) { KlogState* klog = (KlogState*)montauk::malloc(sizeof(KlogState)); montauk::memset(klog, 0, sizeof(KlogState)); - // Initialize the terminal cell grid (reuse terminal infrastructure) - terminal_init_cells(&klog->term, cols, rows); + // Initialize with scrollback enabled + terminal_init_cells(&klog->term, cols, rows, TERM_MAX_SCROLLBACK); // Allocate klog read buffer klog->klog_buf = (char*)montauk::malloc(KLOG_READ_SIZE); diff --git a/programs/src/desktop/app_terminal.cpp b/programs/src/desktop/app_terminal.cpp index 77f04a6..f5b2aac 100644 --- a/programs/src/desktop/app_terminal.cpp +++ b/programs/src/desktop/app_terminal.cpp @@ -1,55 +1,326 @@ /* * app_terminal.cpp - * MontaukOS Desktop - Terminal application + * MontaukOS Desktop - Terminal application with tab support * Copyright (c) 2026 Daniel Hammer */ #include "apps_common.hpp" +// ============================================================================ +// Tab data model +// ============================================================================ + +static constexpr int TERM_TAB_BAR_H = 34; +static constexpr int TERM_MAX_TABS = 8; +static constexpr int TERM_TAB_W = 130; +static constexpr int TERM_TAB_GAP = 4; +static constexpr int TERM_PLUS_W = 28; +static constexpr int TERM_PLUS_PAD = 8; + +// Compute the x-offset that centers all tabs within bar_w +static int term_tabs_origin(int bar_w, int tab_count) { + int total = tab_count * TERM_TAB_W + (tab_count - 1) * TERM_TAB_GAP; + int x = (bar_w - total) / 2; + if (x < 4) x = 4; + return x; +} + +struct TermTabState { + TerminalState* tabs[TERM_MAX_TABS]; + int tab_count; + int active_tab; + int prev_active_tab; // track tab switches to force redraw + uint32_t* last_content; // detect content buffer reallocation + DesktopState* desktop; +}; + +// ============================================================================ +// Tab helpers +// ============================================================================ + +static void term_free_tab(TerminalState* ts) { + if (!ts) return; + if (ts->child_pid > 0) montauk::kill(ts->child_pid); + if (ts->cells) montauk::free(ts->cells); + if (ts->alt_cells) montauk::free(ts->alt_cells); + montauk::mfree(ts); +} + +static TerminalState* term_create_tab(DesktopState* ds, int cols, int rows) { + TerminalState* ts = (TerminalState*)montauk::malloc(sizeof(TerminalState)); + montauk::memset(ts, 0, sizeof(TerminalState)); + terminal_init(ts, cols, rows); + ts->desktop = ds; + return ts; +} + +static void term_close_tab(TermTabState* tts, Window* win, int idx) { + if (idx < 0 || idx >= tts->tab_count) return; + + term_free_tab(tts->tabs[idx]); + + // Shift remaining tabs left + for (int i = idx; i < tts->tab_count - 1; i++) + tts->tabs[i] = tts->tabs[i + 1]; + tts->tab_count--; + tts->tabs[tts->tab_count] = nullptr; + + if (tts->tab_count == 0) { + // Last tab closed — close the window + DesktopState* ds = tts->desktop; + if (ds) { + for (int i = 0; i < ds->window_count; i++) { + if (&ds->windows[i] == win) { + desktop_close_window(ds, i); + return; + } + } + } + return; + } + + // Adjust active tab index + if (idx == tts->active_tab) { + // Closed the active tab: prefer left neighbor, fall back to right + if (tts->active_tab > 0) + tts->active_tab--; + // else stays at 0 (right neighbor shifted here) + } else if (tts->active_tab > idx) { + tts->active_tab--; + } + if (tts->active_tab >= tts->tab_count) + tts->active_tab = tts->tab_count - 1; + + // Mark new active tab dirty so it redraws + tts->tabs[tts->active_tab]->dirty = true; +} + +static void term_add_tab(TermTabState* tts, int cols, int rows) { + if (tts->tab_count >= TERM_MAX_TABS) return; + TerminalState* ts = term_create_tab(tts->desktop, cols, rows); + tts->tabs[tts->tab_count] = ts; + tts->active_tab = tts->tab_count; + tts->tab_count++; +} + // ============================================================================ // Terminal callbacks // ============================================================================ static void terminal_on_draw(Window* win, Framebuffer& fb) { - TerminalState* ts = (TerminalState*)win->app_data; - if (!ts) return; + TermTabState* tts = (TermTabState*)win->app_data; + if (!tts || tts->tab_count == 0) return; Rect cr = win->content_rect(); + int term_h = cr.h - TERM_TAB_BAR_H; + if (term_h < 1) term_h = 1; + + // Force full redraw when active tab changed + if (tts->active_tab != tts->prev_active_tab) { + tts->tabs[tts->active_tab]->dirty = true; + tts->prev_active_tab = tts->active_tab; + } + + // Force full redraw when content buffer was reallocated (resize/maximize) + if (win->content != tts->last_content) { + tts->tabs[tts->active_tab]->dirty = true; + tts->last_content = win->content; + } + + TerminalState* ts = tts->tabs[tts->active_tab]; // Check if window was resized and update terminal grid int new_cols = cr.w / mono_cell_width(); - int new_rows = cr.h / mono_cell_height(); + int new_rows = term_h / mono_cell_height(); + if (new_cols < 1) new_cols = 1; + if (new_rows < 1) new_rows = 1; if (new_cols != ts->cols || new_rows != ts->rows) { - terminal_resize(ts, new_cols, new_rows); + for (int i = 0; i < tts->tab_count; i++) + terminal_resize(tts->tabs[i], new_cols, new_rows); } - terminal_render(ts, win->content, cr.w, cr.h); + // Only redraw when terminal content changed + if (!ts->dirty) return; + + // ---- Draw tab bar ---- + // Bar is darker than TERM_BG; active tab matches TERM_BG to merge with content + Canvas c(win->content, cr.w, cr.h); + Color bar_bg = Color::from_hex(0x1C1C1C); + c.fill_rect(0, 0, cr.w, TERM_TAB_BAR_H, bar_bg); + + int fh = system_font_height(); + int tab_x = term_tabs_origin(cr.w, tts->tab_count); + + for (int i = 0; i < tts->tab_count; i++) { + bool active = (i == tts->active_tab); + char label[16]; + snprintf(label, sizeof(label), "Tab %d", i + 1); + + if (active) { + // Active tab: TERM_BG pill with squared-off bottom → merges into content + int ty = 5; + int th = TERM_TAB_BAR_H - ty; + c.fill_rounded_rect(tab_x, ty, TERM_TAB_W, th, 6, colors::TERM_BG); + c.fill_rect(tab_x, TERM_TAB_BAR_H - 6, TERM_TAB_W, 6, colors::TERM_BG); + + int text_y = ty + (th - fh) / 2; + c.text(tab_x + 12, text_y, label, Color::from_hex(0xE0E0E0)); + c.text(tab_x + TERM_TAB_W - 20, text_y, "x", Color::from_hex(0x707070)); + } else { + // Inactive tab: subtle rounded pill, sits above the bottom edge + int ty = 7; + int th = 22; + c.fill_rounded_rect(tab_x, ty, TERM_TAB_W, th, 5, Color::from_hex(0x262626)); + + int text_y = ty + (th - fh) / 2; + c.text(tab_x + 12, text_y, label, Color::from_hex(0x6E6E6E)); + c.text(tab_x + TERM_TAB_W - 20, text_y, "x", Color::from_hex(0x444444)); + } + + tab_x += TERM_TAB_W + TERM_TAB_GAP; + } + + // "+" button pinned to right edge + if (tts->tab_count < TERM_MAX_TABS) { + int plus_h = 22; + int py = 7; + int px = cr.w - TERM_PLUS_PAD - TERM_PLUS_W; + c.fill_rounded_rect(px, py, TERM_PLUS_W, plus_h, 5, Color::from_hex(0x262626)); + int pw_text = text_width("+"); + c.text(px + (TERM_PLUS_W - pw_text) / 2, py + (plus_h - fh) / 2, "+", + Color::from_hex(0x6E6E6E)); + } + + // ---- Render active terminal into offset buffer ---- + uint32_t* term_pixels = win->content + TERM_TAB_BAR_H * cr.w; + terminal_render(ts, term_pixels, cr.w, term_h); + + // Draw scrollbar overlay when scrollback exists + if (ts->scrollback_lines > 0 && !ts->alt_screen_active) { + int cell_h = mono_cell_height(); + int total_rows = ts->scrollback_lines + ts->rows; + int total_h = total_rows * cell_h; + int view_h = term_h; + if (total_h > view_h) { + Canvas sc(term_pixels, cr.w, term_h); + int sb_w = 4; + int sb_x = cr.w - sb_w - 2; + int thumb_h = (view_h * view_h) / total_h; + if (thumb_h < 20) thumb_h = 20; + int scroll_from_top = ts->scrollback_lines - ts->view_offset; + int max_scroll = ts->scrollback_lines; + int thumb_y = (max_scroll > 0) + ? (scroll_from_top * (view_h - thumb_h)) / max_scroll : 0; + sc.fill_rect(sb_x, 0, sb_w, view_h, Color::from_rgb(0x33, 0x33, 0x33)); + sc.fill_rect(sb_x, thumb_y, sb_w, thumb_h, Color::from_rgb(0x88, 0x88, 0x88)); + } + } } static void terminal_on_mouse(Window* win, MouseEvent& ev) { - // Terminal doesn't need mouse handling for now + TermTabState* tts = (TermTabState*)win->app_data; + if (!tts || tts->tab_count == 0) return; + + // Scroll wheel: forward to active tab regardless of mouse position + TerminalState* ts = tts->tabs[tts->active_tab]; + if (ev.scroll != 0 && !ts->alt_screen_active) { + ts->view_offset += (ev.scroll < 0) ? 3 : -3; + if (ts->view_offset < 0) ts->view_offset = 0; + if (ts->view_offset > ts->scrollback_lines) + ts->view_offset = ts->scrollback_lines; + ts->dirty = true; + } + + // Tab bar click handling + if (!ev.left_pressed()) return; + + Rect cr = win->content_rect(); + int lx = ev.x - cr.x; + int ly = ev.y - cr.y; + + if (ly >= TERM_TAB_BAR_H) return; // click in terminal area, ignore + + int tab_x = term_tabs_origin(cr.w, tts->tab_count); + for (int i = 0; i < tts->tab_count; i++) { + if (lx >= tab_x && lx < tab_x + TERM_TAB_W) { + // Check if click is on the close "x" (last 24px of tab) + if (lx >= tab_x + TERM_TAB_W - 24) { + term_close_tab(tts, win, i); + } else if (i != tts->active_tab) { + tts->active_tab = i; + tts->tabs[i]->dirty = true; + } + return; + } + tab_x += TERM_TAB_W + TERM_TAB_GAP; + } + + // "+" button pinned to right edge + int px = cr.w - TERM_PLUS_PAD - TERM_PLUS_W; + if (tts->tab_count < TERM_MAX_TABS && lx >= px && lx < px + TERM_PLUS_W) { + int term_h = cr.h - TERM_TAB_BAR_H; + int cols = cr.w / mono_cell_width(); + int rows = term_h / mono_cell_height(); + if (cols < 1) cols = 1; + if (rows < 1) rows = 1; + term_add_tab(tts, cols, rows); + } } static void terminal_on_key(Window* win, const Montauk::KeyEvent& key) { - TerminalState* ts = (TerminalState*)win->app_data; - if (!ts) return; + TermTabState* tts = (TermTabState*)win->app_data; + if (!tts || tts->tab_count == 0) return; + + // Ctrl+T: new tab (scancode 0x14 = 'T') + if (key.ctrl && key.pressed && key.scancode == 0x14) { + if (tts->tab_count < TERM_MAX_TABS) { + Rect cr = win->content_rect(); + int term_h = cr.h - TERM_TAB_BAR_H; + int cols = cr.w / mono_cell_width(); + int rows = term_h / mono_cell_height(); + if (cols < 1) cols = 1; + if (rows < 1) rows = 1; + term_add_tab(tts, cols, rows); + } + return; + } + + // Ctrl+W: close current tab (scancode 0x11 = 'W') + if (key.ctrl && key.pressed && key.scancode == 0x11) { + term_close_tab(tts, win, tts->active_tab); + return; + } + + // Forward all other keys to active tab + TerminalState* ts = tts->tabs[tts->active_tab]; terminal_handle_key(ts, key); } static void terminal_on_close(Window* win) { - TerminalState* ts = (TerminalState*)win->app_data; - if (ts) { - if (ts->child_pid > 0) montauk::kill(ts->child_pid); - if (ts->cells) montauk::free(ts->cells); - if (ts->alt_cells) montauk::free(ts->alt_cells); - montauk::mfree(ts); + TermTabState* tts = (TermTabState*)win->app_data; + if (tts) { + for (int i = 0; i < tts->tab_count; i++) + term_free_tab(tts->tabs[i]); + montauk::mfree(tts); win->app_data = nullptr; } } static void terminal_on_poll(Window* win) { - TerminalState* ts = (TerminalState*)win->app_data; - if (ts) terminal_poll(ts); + TermTabState* tts = (TermTabState*)win->app_data; + if (!tts) return; + + // Poll ALL tabs — background shells still produce output + for (int i = tts->tab_count - 1; i >= 0; i--) { + if (!terminal_poll(tts->tabs[i])) { + // This tab's shell exited + term_close_tab(tts, win, i); + // term_close_tab may have closed the window (last tab). + // desktop_close_window calls on_close which nulls app_data + // before the window slot is recycled, so win is still valid here. + if (!win->app_data) return; + } + } } // ============================================================================ @@ -62,14 +333,22 @@ void open_terminal(DesktopState* ds) { Window* win = &ds->windows[idx]; Rect cr = win->content_rect(); + int term_h = cr.h - TERM_TAB_BAR_H; int cols = cr.w / mono_cell_width(); - int rows = cr.h / mono_cell_height(); + int rows = term_h / mono_cell_height(); + if (cols < 1) cols = 1; + if (rows < 1) rows = 1; - TerminalState* ts = (TerminalState*)montauk::malloc(sizeof(TerminalState)); - montauk::memset(ts, 0, sizeof(TerminalState)); - terminal_init(ts, cols, rows); + TermTabState* tts = (TermTabState*)montauk::malloc(sizeof(TermTabState)); + montauk::memset(tts, 0, sizeof(TermTabState)); + tts->desktop = ds; + tts->tabs[0] = term_create_tab(ds, cols, rows); + tts->tab_count = 1; + tts->active_tab = 0; + tts->prev_active_tab = 0; + tts->last_content = win->content; - win->app_data = ts; + win->app_data = tts; win->on_draw = terminal_on_draw; win->on_mouse = terminal_on_mouse; win->on_key = terminal_on_key;