diff --git a/CLAUDE.md b/CLAUDE.md index 1312910..2d8391c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,7 +67,7 @@ Both iterate ghostty-vt's page grid cell-by-cell: Commonly used Emacs symbols (`:foreground`, `:background`, `bold`, etc.) are pre-interned at module load via `make_global_ref` for performance. ### Persistent VT Stream -The `ReadonlyStream` is stored on `GtermInstance` (not recreated per feed call) so escape sequences split across PTY output chunks are handled correctly. +The `TerminalStream` is stored on `GtermInstance` (not recreated per feed call) so escape sequences split across PTY output chunks are handled correctly. ### Linefeed Mode ANSI mode 20 is enabled by default because Emacs strips `\r` from PTY output. Without this, `\n` alone doesn't return the cursor to column 0. diff --git a/gterm.el b/gterm.el index 55beb69..01cb2bc 100644 --- a/gterm.el +++ b/gterm.el @@ -50,6 +50,22 @@ ;; ── Module loading ────────────────────────────────────────────────────── +(declare-function gterm-new "gterm-module") +(declare-function gterm-free "gterm-module") +(declare-function gterm-feed "gterm-module") +(declare-function gterm-content "gterm-module") +(declare-function gterm-cursor-pos "gterm-module") +(declare-function gterm-resize "gterm-module") +(declare-function gterm-render "gterm-module") +(declare-function gterm-render-dirty "gterm-module") +(declare-function gterm-cursor-keys-mode "gterm-module") +(declare-function gterm-cursor-info "gterm-module") +(declare-function gterm-mode-enabled "gterm-module") +(declare-function gterm-scroll-viewport "gterm-module") +(declare-function gterm-viewport-is-bottom "gterm-module") +(declare-function gterm-dirty-p "gterm-module") +(declare-function gterm-clear-dirty "gterm-module") + (defvar gterm-source-dir (file-name-directory (or load-file-name buffer-file-name)) "Directory containing the gterm source files.") @@ -184,22 +200,33 @@ Automatically clones Ghostty and applies the build patch if needed." (defun gterm--refresh () "Refresh the buffer with current terminal content. Uses incremental rendering after the first full render." - (when gterm--term - (let* ((inhibit-read-only t) - (cursor-pos - ;; Always do full render for now (incremental disabled) - (progn - (erase-buffer) - (gterm-render gterm--term)))) - (when (integerp cursor-pos) - (goto-char cursor-pos)) - ;; Update cursor visibility and style from terminal state - (when (fboundp 'gterm-cursor-info) - (let* ((info (gterm-cursor-info gterm--term)) - (visible (car info)) - (style (cdr info))) - (setq-local cursor-type - (if visible style nil))))))) + (when (and gterm--term (gterm-dirty-p gterm--term)) + (let ((inhibit-read-only t) + (inhibit-modification-hooks t) + (inhibit-redisplay t)) + ;; Loop to drain all pending output before we allow redisplay + (while (and gterm--term (gterm-dirty-p gterm--term)) + (let ((cursor-pos + (if gterm--rendered + (gterm-render-dirty gterm--term) + (progn + (erase-buffer) + (setq gterm--rendered t) + (gterm-render gterm--term))))) + ;; Clear dirty flags after rendering this frame + (gterm-clear-dirty gterm--term) + + (when (integerp cursor-pos) + (goto-char cursor-pos)) + ;; Update cursor visibility and style from terminal state + (when (fboundp 'gterm-cursor-info) + (let* ((info (gterm-cursor-info gterm--term)) + (visible (car info)) + (style (cdr info))) + (setq-local cursor-type + (if visible style nil))))))) + ;; Force a single redisplay now that all pending data is rendered + (redisplay t))) (defun gterm--full-refresh () "Force a full screen re-render (not incremental)." @@ -207,19 +234,20 @@ Uses incremental rendering after the first full render." (gterm--refresh)) (defun gterm--schedule-refresh () - "Schedule a batched refresh. Uses idle timer so Emacs drains all -pending PTY output before rendering, preventing backpressure." + "Schedule a batched refresh. Uses a short non-idle timer to ensure +updates happen even during constant output, while still coalescing +multiple small PTY chunks into one render frame." (unless gterm--needs-refresh (setq gterm--needs-refresh t) (let ((buf (current-buffer))) (setq gterm--refresh-timer - (run-with-idle-timer 0.01 nil - (lambda () - (when (buffer-live-p buf) - (with-current-buffer buf - (setq gterm--needs-refresh nil - gterm--refresh-timer nil) - (gterm--refresh))))))))) + (run-at-time 0.005 nil + (lambda () + (when (buffer-live-p buf) + (with-current-buffer buf + (setq gterm--needs-refresh nil + gterm--refresh-timer nil) + (gterm--refresh))))))))) ;; ── Process filter ────────────────────────────────────────────────────── diff --git a/src/gterm.zig b/src/gterm.zig index f5bca8d..202fa6e 100644 --- a/src/gterm.zig +++ b/src/gterm.zig @@ -57,6 +57,7 @@ var sym_mouse_face: emacs.emacs_value = undefined; var sym_highlight: emacs.emacs_value = undefined; var sym_keymap: emacs.emacs_value = undefined; var sym_gterm_link_map: emacs.emacs_value = undefined; +var sym_add_text_properties: emacs.emacs_value = undefined; fn initGlobalSymbols(env: *emacs.emacs_env) void { sym_face = emacs.make_global_ref(env, env.intern.?(env, "face")); @@ -81,15 +82,19 @@ fn initGlobalSymbols(env: *emacs.emacs_env) void { sym_highlight = emacs.make_global_ref(env, env.intern.?(env, "highlight")); sym_keymap = emacs.make_global_ref(env, env.intern.?(env, "keymap")); sym_gterm_link_map = emacs.make_global_ref(env, env.intern.?(env, "gterm-link-map")); + sym_add_text_properties = emacs.make_global_ref(env, env.intern.?(env, "add-text-properties")); } // ── Terminal wrapper ──────────────────────────────────────────────────── const GtermInstance = struct { terminal: Terminal, - stream: ghostty_vt.ReadonlyStream, + stream: ghostty_vt.TerminalStream, rows: u16, cols: u16, + last_cursor_row: u16 = 0, + last_cursor_col: u16 = 0, + last_content_hash: u64 = 0, freed: bool = false, pub fn init(cols: u16, rows: u16) !*GtermInstance { @@ -109,6 +114,9 @@ const GtermInstance = struct { self.stream = self.terminal.vtStream(); self.rows = rows; self.cols = cols; + self.last_cursor_row = @intCast(self.terminal.screens.active.cursor.y); + self.last_cursor_col = @intCast(self.terminal.screens.active.cursor.x); + self.last_content_hash = self.computeContentHash(); return self; } @@ -127,6 +135,82 @@ const GtermInstance = struct { // Instead, the finalizer is the sole owner of the struct memory. } + /// Compute a simple hash of the visible screen content for change detection. + pub fn computeContentHash(self: *GtermInstance) u64 { + const screen = self.terminal.screens.active; + const page_list = &screen.pages; + var h: u64 = 0x811c9dc5; + + var row: u16 = 0; + while (row < self.rows) : (row += 1) { + const pin = page_list.pin(.{ .viewport = .{ + .x = 0, + .y = row, + } }) orelse continue; + const page = &pin.node.data; + const page_row = page.getRow(pin.y); + const page_cells = page.getCells(page_row); + + for (0..@min(self.cols, page_cells.len)) |c| { + const cell = &page_cells[c]; + // Hash codepoint and style + h = h ^ (@as(u64, cell.codepoint()) << 16 | cell.style_id); + h = h *% 0x01000193; + } + } + return h; + } + + /// Check if the terminal screen is dirty or the cursor has moved. + pub fn isDirty(self: *GtermInstance) bool { + const screen = self.terminal.screens.active; + if (screen.dirty.selection or screen.dirty.hyperlink_hover) return true; + + const cursor_row: u16 = @intCast(screen.cursor.y); + const cursor_col: u16 = @intCast(screen.cursor.x); + if (cursor_row != self.last_cursor_row or cursor_col != self.last_cursor_col) return true; + + // If content hash changed, it's dirty + if (self.computeContentHash() != self.last_content_hash) return true; + + const page_list = &screen.pages; + var row: u16 = 0; + while (row < self.rows) : (row += 1) { + const pin = page_list.pin(.{ .viewport = .{ + .x = 0, + .y = row, + } }) orelse continue; + + if (pin.isDirty()) return true; + } + + return false; + } + + /// Clear all dirty flags and update the last known cursor position. + pub fn clearDirty(self: *GtermInstance) void { + const screen = self.terminal.screens.active; + const page_list = &screen.pages; + + self.last_cursor_row = @intCast(screen.cursor.y); + self.last_cursor_col = @intCast(screen.cursor.x); + self.last_content_hash = self.computeContentHash(); + + var row: u16 = 0; + while (row < self.rows) : (row += 1) { + const pin = page_list.pin(.{ .viewport = .{ + .x = 0, + .y = row, + } }) orelse continue; + pin.rowAndCell().row.dirty = false; + } + + var page_node = page_list.pages.first; + while (page_node) |p| : (page_node = p.next) { + p.data.dirty = false; + } + } + /// Feed raw bytes through the terminal's VT parser. pub fn feed(self: *GtermInstance, bytes: []const u8) void { self.stream.nextSlice(bytes); @@ -565,7 +649,10 @@ fn flushRun( const style = page.styles.get(page.memory, style_id); const face = buildFacePlist(env, style, palette); if (!emacs.check_exit(env) and env.is_not_nil.?(env, face)) { - emacs.put_text_property(env, start, end, sym_face, face); + var props_args = [_]emacs.emacs_value{ sym_face, face }; + const props = env.funcall.?(env, env.intern.?(env, "list"), 2, &props_args); + var add_props_args = [_]emacs.emacs_value{ start, end, props }; + _ = env.funcall.?(env, sym_add_text_properties, 3, &add_props_args); } } @@ -586,15 +673,16 @@ fn applyHyperlink( const uri = entry.uri.slice(page.memory); if (uri.len == 0) return; - // Set help-echo (tooltip showing URL) + // Set properties (tooltip, hover highlight, click map) in one call const uri_str = env.make_string.?(env, uri.ptr, @intCast(uri.len)); - emacs.put_text_property(env, start, end, sym_help_echo, uri_str); - - // Set mouse-face for hover highlight - emacs.put_text_property(env, start, end, sym_mouse_face, sym_highlight); - - // Set keymap for click handling - emacs.put_text_property(env, start, end, sym_keymap, sym_gterm_link_map); + var props_args = [_]emacs.emacs_value{ + sym_help_echo, uri_str, + sym_mouse_face, sym_highlight, + sym_keymap, sym_gterm_link_map, + }; + const props = env.funcall.?(env, env.intern.?(env, "list"), 6, &props_args); + var add_props_args = [_]emacs.emacs_value{ start, end, props }; + _ = env.funcall.?(env, sym_add_text_properties, 3, &add_props_args); } /// Render only dirty rows into an existing buffer. @@ -910,17 +998,43 @@ fn gtermResize( /// (gterm-free TERM) -> nil fn gtermFree( - env: ?*emacs.emacs_env, + env_opt: ?*emacs.emacs_env, _: emacs.ptrdiff_t, args: [*c]emacs.emacs_value, _: ?*anyopaque, ) callconv(.c) emacs.emacs_value { - const e = env.?; - const instance = getInstanceFromArg(e, args[0]) orelse return emacs.nil(e); + const env = env_opt.?; + const instance = getInstanceFromArg(env, args[0]) orelse return emacs.nil(env); instance.deinit(); - return emacs.nil(e); + return emacs.nil(env); +} + +/// (gterm-dirty-p TERM) -> boolean +fn gtermDirtyP( + env_opt: ?*emacs.emacs_env, + _: emacs.ptrdiff_t, + args: [*c]emacs.emacs_value, + _: ?*anyopaque, +) callconv(.c) emacs.emacs_value { + const env = env_opt.?; + const instance = getInstanceFromArg(env, args[0]) orelse return emacs.nil(env); + return if (instance.isDirty()) sym_t else emacs.nil(env); } +/// (gterm-clear-dirty TERM) -> nil +fn gtermClearDirty( + env_opt: ?*emacs.emacs_env, + _: emacs.ptrdiff_t, + args: [*c]emacs.emacs_value, + _: ?*anyopaque, +) callconv(.c) emacs.emacs_value { + const env = env_opt.?; + const instance = getInstanceFromArg(env, args[0]) orelse return emacs.nil(env); + instance.clearDirty(); + return emacs.nil(env); +} + + /// (gterm-cursor-keys-mode TERM) -> t or nil /// Return t if terminal is in application cursor keys mode (DECCKM). fn gtermCursorKeysMode( @@ -1061,6 +1175,14 @@ export fn emacs_module_init(runtime: ?*emacs.emacs_runtime) callconv(.c) c_int { "Free a gterm terminal instance.\nTERM is a terminal handle from `gterm-new'.\nThis is optional; the GC finalizer also handles cleanup.", ); + emacs.defun(env, "gterm-dirty-p", 1, 1, >ermDirtyP, + "Return t if terminal screen is dirty or cursor has moved.\nTERM is a terminal handle from `gterm-new'.", + ); + + emacs.defun(env, "gterm-clear-dirty", 1, 1, >ermClearDirty, + "Clear terminal dirty flags.\nTERM is a terminal handle from `gterm-new'.", + ); + emacs.defun(env, "gterm-render", 1, 1, >ermRender, "Render terminal content with ANSI styling into the current buffer.\nTERM is a terminal handle from `gterm-new'.\nInserts styled text directly using face properties.", );