diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 45dad39..ee5aaaa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -155,6 +155,13 @@ jobs: timeStyle: 'short', timeZone: 'UTC', }).format(new Date(timestamp))} UTC`; + const formatDate = (timestamp) => + new Intl.DateTimeFormat('en-GB', { + dateStyle: 'medium', + timeZone: 'UTC', + }).format(new Date(timestamp)); + const buildDate = formatDate(run.created_at); + const buildDateMarkup = `${buildDate}`; const commitShortSha = commitSha.slice(0, 7); const artifacts = [ { @@ -175,7 +182,7 @@ jobs: const body = [ marker, - '## 📦 PR builds', + `## 📦 PR builds · ${buildDateMarkup}`, '', '| Platform | Download |', '| --- | --- |', diff --git a/.github/workflows/comment-artifacts.yml b/.github/workflows/comment-artifacts.yml index d0c87f0..80fd2c9 100644 --- a/.github/workflows/comment-artifacts.yml +++ b/.github/workflows/comment-artifacts.yml @@ -33,6 +33,13 @@ jobs: timeStyle: 'short', timeZone: 'UTC', }).format(new Date(timestamp))} UTC`; + const formatDate = (timestamp) => + new Intl.DateTimeFormat('en-GB', { + dateStyle: 'medium', + timeZone: 'UTC', + }).format(new Date(timestamp)); + const buildDate = formatDate(run.created_at); + const buildDateMarkup = `${buildDate}`; const artifacts = await github.paginate( github.rest.actions.listWorkflowRunArtifacts, { owner, repo, run_id: run.id, per_page: 100 }, @@ -53,7 +60,7 @@ jobs: const body = [ marker, - '## 📦 PR builds', + `## 📦 PR builds · ${buildDateMarkup}`, '', '| Platform | Download |', '| --- | --- |', diff --git a/bench.sh b/bench.sh index a49ffcc..e5aab5f 100755 --- a/bench.sh +++ b/bench.sh @@ -11,6 +11,8 @@ fi RATE="${3:-100}" SYNC_UPDATES="${4:-4}" +CHUNK_BYTES="${5:-128}" +WRITE_DELAY_MS="${6:-0}" run_blocking_child() { local result_path="$2" @@ -153,7 +155,7 @@ if [[ "$MODE" == "nvim-restart" ]]; then exec "$SCRIPT_DIR/launch.sh" --app-arg="--startup-command" --app-arg=":restart" --app-arg="--startup-command-delay-frames" --app-arg="$DELAY_FRAMES" --app-arg="--snapshot-dump" --app-arg="$OUT_PATH" fi -python3 - "$MODE" "$COUNT" "$RATE" "$SYNC_UPDATES" <<'PY' +python3 - "$MODE" "$COUNT" "$RATE" "$SYNC_UPDATES" "$CHUNK_BYTES" "$WRITE_DELAY_MS" <<'PY' import os import re import select @@ -166,6 +168,10 @@ import tty mode = sys.argv[1] count = int(sys.argv[2]) +rate_arg = int(sys.argv[3]) if len(sys.argv) > 3 else 100 +sync_updates_arg = int(sys.argv[4]) if len(sys.argv) > 4 else 4 +chunk_bytes = max(1, int(sys.argv[5])) if len(sys.argv) > 5 else 128 +write_delay_ms = max(0.0, float(sys.argv[6])) if len(sys.argv) > 6 else 0.0 cols, rows = shutil.get_terminal_size((80, 24)) rows = max(4, rows) cols = max(20, cols) @@ -187,7 +193,7 @@ def cleanup(*_): global running running = False try: - sys.stdout.write(RESET + SHOW + ALT_OFF) + sys.stdout.write(RESET + SHOW + SYNC_OFF + ALT_OFF) sys.stdout.flush() except Exception: pass @@ -612,6 +618,87 @@ def run_sync_output(batches, rate_hz=100, updates_per_batch=4): f"mode: CSI ?2026h/l updates_per_batch={updates_per_batch}\n" ) +def chunked_frame_text(frame, row): + red = (frame * 17 + row * 11) % 200 + 30 + green = (frame * 7 + row * 19) % 200 + 30 + blue = (frame * 23 + row * 5) % 200 + 30 + label = f" frame={frame:05d} row={row:03d} " + body = (label + (" <>[]{}()##==++--" * 32))[:cols].ljust(cols) + return ( + f"{CSI}{row + 1};1H{CSI}2K" + f"{CSI}48;2;{red};{green};{blue}m{CSI}38;2;240;240;240m" + f"{body}{RESET}" + ) + +def run_chunked(frames, rate_hz=100, updates_per_frame=4, write_chunk_bytes=128, synchronized=True, write_delay_ms=0.0): + """Split alternate-screen redraws into small PTY writes.""" + interval = 1.0 / rate_hz + updates_per_frame = max(1, updates_per_frame) + write_chunk_bytes = max(1, write_chunk_bytes) + write_delay_s = write_delay_ms / 1000.0 + + sys.stdout.write(HIDE + ALT_ON + CLEAR + HOME) + sys.stdout.flush() + frame_times = [] + written = 0 + done = 0 + start = time.perf_counter() + + for frame in range(frames): + if not running: + break + batch_start = time.perf_counter() + if synchronized: + sys.stdout.write(SYNC_ON) + sys.stdout.flush() + for update in range(updates_per_frame): + logical_frame = frame * updates_per_frame + update + for row in range(rows - 1): + data = chunked_frame_text(logical_frame, row).encode() + for offset in range(0, len(data), write_chunk_bytes): + chunk = data[offset:offset + write_chunk_bytes] + os.write(sys.stdout.fileno(), chunk) + written += len(chunk) + if write_delay_s > 0: + time.sleep(write_delay_s) + status = f"{CSI}{rows};1H{CSI}0mchunked bench frame={logical_frame:05d} size={cols}x{rows}" + status_bytes = status.encode() + for offset in range(0, len(status_bytes), write_chunk_bytes): + chunk = status_bytes[offset:offset + write_chunk_bytes] + os.write(sys.stdout.fileno(), chunk) + written += len(chunk) + if update + 1 < updates_per_frame: + time.sleep(interval / updates_per_frame) + if synchronized: + sys.stdout.write(SYNC_OFF) + sys.stdout.flush() + + frame_times.append(time.perf_counter() - batch_start) + done += 1 + sleep_for = interval - frame_times[-1] + if sleep_for > 0.0001: + time.sleep(sleep_for) + + elapsed = time.perf_counter() - start + cleanup() + if frame_times: + avg_ms = sum(frame_times) / len(frame_times) * 1000 + min_ms = min(frame_times) * 1000 + max_ms = max(frame_times) * 1000 + p99_ms = sorted(frame_times)[int(len(frame_times) * 0.99)] * 1000 + else: + avg_ms = min_ms = max_ms = p99_ms = 0.0 + + sys.stdout.write(stat_line("chunked", elapsed, "frames", max(1, done))) + sys.stdout.write( + f"bytes: {written} ({written / max(elapsed, 1e-9):.0f}/s)\n" + f"frame duration (ms): avg={avg_ms:.2f} min={min_ms:.2f} " + f"max={max_ms:.2f} p99={p99_ms:.2f}\n" + f"mode: {'CSI ?2026h/l' if synchronized else 'raw'} " + f"updates_per_frame={updates_per_frame} write_chunk_bytes={write_chunk_bytes} " + f"write_delay_ms={write_delay_ms:.1f}\n" + ) + def run_keypress(presses, rate_hz=100): """ Simulate rapid j/k scrolling in nvim: @@ -695,16 +782,16 @@ if mode == "scroll": elif mode == "repaint": run_repaint(count) elif mode == "keypress": - rate = int(sys.argv[3]) if len(sys.argv) > 3 else 100 - run_keypress(count, rate_hz=rate) + run_keypress(count, rate_hz=rate_arg) elif mode == "split-scroll": - rate = int(sys.argv[3]) if len(sys.argv) > 3 else 100 - run_split_scroll(count, rate_hz=rate) + run_split_scroll(count, rate_hz=rate_arg) elif mode == "sync-output": - rate = int(sys.argv[3]) if len(sys.argv) > 3 else 100 - updates = int(sys.argv[4]) if len(sys.argv) > 4 else 4 - run_sync_output(count, rate_hz=rate, updates_per_batch=updates) + run_sync_output(count, rate_hz=rate_arg, updates_per_batch=sync_updates_arg) +elif mode == "chunked": + run_chunked(count, rate_hz=rate_arg, updates_per_frame=sync_updates_arg, write_chunk_bytes=chunk_bytes, write_delay_ms=write_delay_ms) +elif mode == "chunked-raw": + run_chunked(count, rate_hz=rate_arg, updates_per_frame=sync_updates_arg, write_chunk_bytes=chunk_bytes, synchronized=False, write_delay_ms=write_delay_ms) else: - sys.stderr.write("usage: ./bench.sh [scroll|repaint|keypress|split-scroll|sync-output|bypass-blocking|nvim-restart|minimize-restore] ...\n") + sys.stderr.write("usage: ./bench.sh [scroll|repaint|keypress|split-scroll|sync-output|chunked|chunked-raw|bypass-blocking|nvim-restart|minimize-restore] ...\n") sys.exit(2) PY diff --git a/src/app.zig b/src/app.zig index 8345aca..8ae0e58 100644 --- a/src/app.zig +++ b/src/app.zig @@ -1876,6 +1876,7 @@ pub const App = struct { if (self.mux) |*mux| { var panes = mux.paneIterator(); while (panes.next()) |pane| { + pane.applyTerminalTheme(runtime, &self.config.terminal_theme); pane.refreshTitle(runtime, self.config.windowTitle(), self.config.shellForDomain(if (pane.domain_name.len > 0) pane.domain_name else null) catch self.config.shellOrDefault()); _ = pane.refreshCwd(); } diff --git a/src/app/lua_callbacks.zig b/src/app/lua_callbacks.zig index 4c06923..2ce5f45 100644 --- a/src/app/lua_callbacks.zig +++ b/src/app/lua_callbacks.zig @@ -393,6 +393,12 @@ pub fn luaRefreshLiveConfigCallback(app_ptr: *anyopaque) void { std.log.info("config: command_timing={}", .{app.config.command_timing}); cmd_ipc.syncCommandTimingEnv(app); app.pending_renderer_refresh = app.config.backend == .sokol or app.config.backend == .webgpu; + if (app.ghostty) |*runtime| { + if (app.mux) |*mux| { + var panes = mux.paneIterator(); + while (panes.next()) |pane| pane.applyTerminalTheme(runtime, &app.config.terminal_theme); + } + } mux_ops.invalidateAllPanes(app); app.requestLayoutResize(true); } diff --git a/src/app/terminal_callbacks.zig b/src/app/terminal_callbacks.zig index 3f54a65..325f1dc 100644 --- a/src/app/terminal_callbacks.zig +++ b/src/app/terminal_callbacks.zig @@ -63,8 +63,19 @@ fn sizeCallback(_: ?*anyopaque, userdata: ?*anyopaque, out: ?*ghostty.SizeReport return true; } -fn colorSchemeCallback(_: ?*anyopaque, _: ?*anyopaque, _: ?*ghostty.ColorScheme) callconv(.c) bool { - return false; +fn colorSchemeCallback(_: ?*anyopaque, userdata: ?*anyopaque, out: ?*ghostty.ColorScheme) callconv(.c) bool { + const out_ptr = out orelse return false; + const pane = paneFromUserdata(userdata) orelse return false; + const app: *App = @ptrCast(@alignCast(pane.host_context orelse return false)); + out_ptr.* = colorSchemeForBackground(app.config.terminal_theme.background); + return true; +} + +fn colorSchemeForBackground(background: ghostty.ColorRgb) ghostty.ColorScheme { + const brightness: u32 = @as(u32, background.r) * 299 + + @as(u32, background.g) * 587 + + @as(u32, background.b) * 114; + return if (brightness > 128_000) .light else .dark; } fn deviceAttributesCallback(_: ?*anyopaque, _: ?*anyopaque, out: ?*ghostty.DeviceAttributes) callconv(.c) bool { @@ -86,3 +97,14 @@ fn titleChangedCallback(_: ?*anyopaque, userdata: ?*anyopaque) callconv(.c) void pane.title_dirty = true; } } + +test "color scheme follows configured terminal background" { + try std.testing.expectEqual( + ghostty.ColorScheme.dark, + colorSchemeForBackground(.{ .r = 25, .g = 26, .b = 28 }), + ); + try std.testing.expectEqual( + ghostty.ColorScheme.light, + colorSchemeForBackground(.{ .r = 240, .g = 240, .b = 240 }), + ); +} diff --git a/src/bench/renderer_bench.zig b/src/bench/renderer_bench.zig index deaf5ae..e739045 100644 --- a/src/bench/renderer_bench.zig +++ b/src/bench/renderer_bench.zig @@ -14,6 +14,7 @@ const default_iterations: usize = 10; const Scenario = enum { repaint, + chunked, scroll, styled, replay, @@ -457,6 +458,32 @@ fn buildCorpus(allocator: std.mem.Allocator, options: Options) ![]u8 { try appendFormat(&corpus, allocator, "\x1b[0mrepaint benchmark frame={d} size={d}x{d}", .{ frame, options.cols, options.rows }); } }, + .chunked => { + // Model OpenCode/Copilot-style alternate-screen updates: each frame + // repaints rows independently inside DEC synchronized output, with + // truecolor backgrounds and small erase/write sequences. The bench + // feeds this corpus in chunks so render-state snapshots can land + // between writes. + try corpus.appendSlice(allocator, "\x1b[?1049h\x1b[2J\x1b[H"); + var frame: usize = 0; + while (frame < options.frames) : (frame += 1) { + try corpus.appendSlice(allocator, "\x1b[?2026h"); + var row: usize = 0; + while (row < options.rows) : (row += 1) { + const red: u8 = @intCast((frame * 17 + row * 11) % 200 + 30); + const green: u8 = @intCast((frame * 7 + row * 19) % 200 + 30); + const blue: u8 = @intCast((frame * 23 + row * 5) % 200 + 30); + try appendFormat(&corpus, allocator, "\x1b[{d};1H\x1b[2K\x1b[48;2;{d};{d};{d}m\x1b[38;2;240;240;240m", .{ + row + 1, red, green, blue, + }); + var label_buffer: [128]u8 = undefined; + const label = try std.fmt.bufPrint(&label_buffer, " frame={d} row={d} ", .{ frame, row }); + try corpus.appendSlice(allocator, label); + if (label.len < options.cols) try appendRepeated(&corpus, allocator, " ", options.cols - label.len); + } + try corpus.appendSlice(allocator, "\x1b[0m\x1b[?2026l"); + } + }, .scroll => { const lines = try std.math.mul(usize, options.frames, options.rows); var line: usize = 0; @@ -528,12 +555,15 @@ fn pipelineSample(harness: *Harness, corpus: []const u8) !struct { total: i128, parse_ns += io.nanoTimestamp() - start; start = io.nanoTimestamp(); + harness.runtime.clearRenderStateDirty(session.render_state); try harness.runtime.updateRenderState(session.render_state, session.terminal); render_state_ns += io.nanoTimestamp() - start; harness.captureState(&session); start = io.nanoTimestamp(); - const force_full = offset == 0; + // Match cached-pane policy: full dirty means screen-wide replacement; + // ordinary chunked output uses retained partial rows. + const force_full = offset == 0 or harness.dirty_level == .full; harness.queue(&session, force_full); if (force_full) harness.clearDirtyRows(&session); render_ns += io.nanoTimestamp() - start; @@ -654,6 +684,7 @@ fn printJsonStats(writer: anytype, name: []const u8, samples: []i128) !void { fn parseScenario(value: []const u8) !Scenario { if (std.mem.eql(u8, value, "repaint")) return .repaint; + if (std.mem.eql(u8, value, "chunked")) return .chunked; if (std.mem.eql(u8, value, "scroll")) return .scroll; if (std.mem.eql(u8, value, "styled")) return .styled; if (std.mem.eql(u8, value, "replay")) return .replay; diff --git a/src/lua_bridge.zig b/src/lua_bridge.zig index fada6cd..88f4743 100644 --- a/src/lua_bridge.zig +++ b/src/lua_bridge.zig @@ -3644,12 +3644,14 @@ fn l_set_config(state: *State) callconv(.c) c_int { if (std.mem.eql(u8, key, "theme") and value_type == .table) { const theme_idx = absoluteIndex(api, state, -1); applyThemeTable(ctx.cfg, api, state, theme_idx) catch |err| std.log.err("config theme field failed: {s}", .{@errorName(err)}); + refresh_live_config = true; continue; } if (std.mem.eql(u8, key, "terminal_theme") and value_type == .table) { const theme_idx = absoluteIndex(api, state, -1); applyThemeTable(ctx.cfg, api, state, theme_idx) catch |err| std.log.err("config terminal_theme field failed: {s}", .{@errorName(err)}); + refresh_live_config = true; continue; } diff --git a/src/pane.zig b/src/pane.zig index b378714..3ede7eb 100644 --- a/src/pane.zig +++ b/src/pane.zig @@ -268,6 +268,17 @@ pub const Pane = struct { return .{ .allocator = allocator }; } + pub fn applyTerminalTheme(self: *Pane, runtime: *GhosttyRuntime, theme: *const Config.TerminalTheme) void { + runtime.setTerminalDefaultColors( + self.terminal, + theme.foreground, + theme.background, + theme.cursor, + &theme.palette, + ); + self.render_dirty = .full; + } + pub fn deinit(self: *Pane, runtime: *GhosttyRuntime) void { self.boot_output.deinit(self.allocator); self.osc1337_buf.deinit(self.allocator); @@ -300,6 +311,14 @@ pub const Pane = struct { const terminal = try runtime.createTerminal(cfg.cols, cfg.rows, cfg.scrollback); errdefer runtime.freeTerminal(terminal); + runtime.setTerminalDefaultColors( + terminal, + cfg.terminal_theme.foreground, + cfg.terminal_theme.background, + cfg.terminal_theme.cursor, + &cfg.terminal_theme.palette, + ); + runtime.setTerminalUserdata(terminal, self); runtime.setKittyImageStorageLimit(terminal, 64 * 1024 * 1024); diff --git a/src/render/sokol_runtime.zig b/src/render/sokol_runtime.zig index 35429fd..227d202 100644 --- a/src/render/sokol_runtime.zig +++ b/src/render/sokol_runtime.zig @@ -424,6 +424,9 @@ const PaneCacheEntry = struct { /// and ghostty does not mark the old cursor row as dirty (content unchanged). /// Initialised to maxInt(usize) so it matches no row on the first frame. prev_cursor_row: usize = std.math.maxInt(usize), + /// True when the previous cached render consumed PTY output. The first + /// quiet frame after a burst gets one full refresh before cache reuse. + pty_burst_active: bool = false, last_cursor_row: usize = std.math.maxInt(usize), last_cursor_col: usize = std.math.maxInt(usize), last_cursor_visible: bool = false, @@ -462,6 +465,7 @@ fn getOrCreatePaneCacheEntry(pane: *const Pane, w: u32, h: u32) ?*PaneCacheEntry @memset(&entry.row_map_keys, ROW_MAP_EMPTY); @memset(&entry.row_map_vals, 0); entry.prev_cursor_row = std.math.maxInt(usize); + entry.pty_burst_active = false; entry.last_cursor_row = std.math.maxInt(usize); entry.last_cursor_col = std.math.maxInt(usize); entry.last_cursor_visible = false; @@ -498,12 +502,24 @@ fn getOrCreatePaneCacheEntry(pane: *const Pane, w: u32, h: u32) ?*PaneCacheEntry .last_rows = 0, .validity = .invalid, .last_atlas_reset_epoch = 0, + .pty_burst_active = false, }; new_entry.cache.clear(); g_pane_caches[free_slot] = new_entry; return &g_pane_caches[free_slot].?; } +fn paneCacheReadyForPresent(pane: *const Pane, w: u32, h: u32) bool { + for (&g_pane_caches) |*slot| { + const entry = if (slot.*) |*entry| entry else continue; + if (entry.pane != pane) continue; + return !entry.cache.needsResize(w, h) and + entry.validity != .invalid and + !entry.needs_clear; + } + return false; +} + /// Release the cache entry for a pane that has been destroyed. fn releasePaneCache(pane: *const Pane) void { for (&g_pane_caches) |*slot| { @@ -3029,6 +3045,7 @@ fn invalidateAllPaneCaches() void { @memset(&entry.row_map_keys, ROW_MAP_EMPTY); @memset(&entry.row_map_vals, 0); entry.prev_cursor_row = std.math.maxInt(usize); + entry.pty_burst_active = false; } } } @@ -3050,6 +3067,7 @@ pub fn invalidatePaneCacheForPane(pane: *const Pane) void { @memset(&entry.row_map_keys, ROW_MAP_EMPTY); @memset(&entry.row_map_vals, 0); entry.prev_cursor_row = std.math.maxInt(usize); + entry.pty_burst_active = false; return; } } @@ -3229,11 +3247,24 @@ fn frameCb(user_data: ?*anyopaque) callconv(.c) void { const single_visible_pane = if (leaves.len == 0) app.activePane() else null; const auto_disable_multi_pane_cache = leaves.len > MAX_CACHED_VISIBLE_PANES; const use_direct_multi_pane = (app.config.renderer_disable_multi_pane_cache or auto_disable_multi_pane_cache) and leaves.len > 1 and !sync_cache_supported; + const sync_cache_unready = if (!visible_sync_output or use_safe_render or use_direct_multi_pane or use_direct_render) + false + else if (leaves.len > 0) + for (leaves) |leaf| { + if (leaf.pane.synchronized_output_active and + !paneCacheReadyForPresent(leaf.pane, leaf.bounds.width, leaf.bounds.height)) break true; + } else false + else if (single_visible_pane) |pane| + pane.synchronized_output_active and + !paneCacheReadyForPresent(pane, @intFromFloat(width), @intFromFloat(height)) + else + false; // Direct paths have no retained terminal surface. Keep the last - // presented frame until synchronized output ends instead of clearing - // the swapchain and drawing newer terminal state. + // presented frame until synchronized output ends instead of clearing the + // swapchain and drawing newer terminal state. Cached paths must do the same + // when a screen transition invalidated their retained surface mid-batch. if (visible_sync_output and - (use_safe_render or use_direct_multi_pane or use_direct_render) and + (use_safe_render or use_direct_multi_pane or use_direct_render or sync_cache_unready) and g_renderer_ready) { c.sapp_skip_present(); @@ -3384,7 +3415,7 @@ fn frameCb(user_data: ?*anyopaque) callconv(.c) void { if (grid_changed) { cache_entry.stable_after_resize = false; } - const settled_clean = dirty_level == .false_value and selection_redraw_range == null and !pty_active and !atlas_stale and !cache_entry.needs_clear and !geometry_stale and !size_mismatch and !grid_changed and !cursor_state_changed; + const settled_clean = dirty_level == .false_value and selection_redraw_range == null and !pty_active and !cache_entry.pty_burst_active and !atlas_stale and !cache_entry.needs_clear and !geometry_stale and !size_mismatch and !grid_changed and !cursor_state_changed; if (dirty_level == .false_value and cache_entry.validity == .valid and settled_clean and cache_entry.stable_after_resize) { if (cfg.debug_terminal_trace and focused) { std.log.info("terminal-trace cache pane={x} mode=cached_clean dirty={s} cursor_changed={} cursor_visible={} cursor_blinking={} cursor_blink_visible={} cursor_style={s} cursor_row={d} cursor_col={d}", .{ @@ -3407,7 +3438,13 @@ fn frameCb(user_data: ?*anyopaque) callconv(.c) void { return .cached_clean; } const unsettled = size_mismatch or grid_changed or !cache_entry.stable_after_resize; - const force_full = dirty_level == .full or atlas_stale or cache_entry.needs_clear or geometry_stale or unsettled or background_changed; + // PTY output may contain only part of a TUI repaint when the + // read budget expires. Do not mix those rows with an older + // retained surface: larger windows make that mismatch both + // more likely and visible for longer. The quiet frame after + // the burst remains a final full reconciliation. + const pty_burst_refresh = pty_active or cache_entry.pty_burst_active; + const force_full = dirty_level == .full or pty_burst_refresh or atlas_stale or cache_entry.needs_clear or geometry_stale or unsettled or background_changed; if (cfg.debug_terminal_trace and focused) { std.log.info("terminal-trace cache pane={x} mode=cached_dirty dirty={s} force_full={} cursor_changed={} cursor_visible={} cursor_blinking={} cursor_blink_visible={} cursor_style={s} cursor_row={d} cursor_col={d} pty_active={} size_mismatch={} grid_changed={}", .{ @intFromPtr(pane), @@ -3559,10 +3596,11 @@ fn frameCb(user_data: ?*anyopaque) callconv(.c) void { // scroll frames redraw the dirty rows without forcing a CLEAR. pane.pty_wrote_this_frame = false; // consumed by renderer cache_entry.needs_clear = false; - const now_stable = !pty_active and dirty_level == .false_value and !atlas_stale and !geometry_stale and !size_mismatch and !grid_changed; + const now_stable = !atlas_stale and !geometry_stale and !size_mismatch and !grid_changed; cache_entry.stable_after_resize = now_stable; cache_entry.validity = if (cache_entry.stable_after_resize) .valid else .priming; - if (cache_entry.force_full_frames > 0 and !pty_active) cache_entry.force_full_frames -= 1; + if (cache_entry.force_full_frames > 0) cache_entry.force_full_frames -= 1; + cache_entry.pty_burst_active = pty_active; cache_entry.last_bg_color = bg_color; cache_entry.has_bg_color = true; cache_entry.prev_cursor_row = cursor_row; @@ -5018,6 +5056,7 @@ fn handleMouseButton(app: *App, event: c.sapp_event, action: ghostty.MouseAction entry.force_full_frames = 3; entry.stable_after_resize = false; entry.needs_clear = true; + entry.pty_burst_active = false; @memset(&entry.row_map_keys, ROW_MAP_EMPTY); @memset(&entry.row_map_vals, 0); } diff --git a/src/term/ghostty.zig b/src/term/ghostty.zig index 8cc2090..19464b4 100644 --- a/src/term/ghostty.zig +++ b/src/term/ghostty.zig @@ -915,6 +915,35 @@ pub const Runtime = struct { return handle; } + /// Set the embedder's terminal color defaults. Ghostty uses these values + /// both for cells that use the default SGR colors and for OSC 10/11/12 + /// queries from programs running in the terminal. + pub fn setTerminalDefaultColors( + self: *Runtime, + handle: ?*anyopaque, + foreground: ColorRgb, + background: ColorRgb, + cursor: ?ColorRgb, + palette: *const [256]ColorRgb, + ) void { + const terminal = handle orelse return; + + var foreground_value = foreground; + _ = self.terminal_set(terminal, @intFromEnum(TerminalOpt.color_foreground), &foreground_value); + + var background_value = background; + _ = self.terminal_set(terminal, @intFromEnum(TerminalOpt.color_background), &background_value); + + if (cursor) |cursor_value| { + var cursor_color = cursor_value; + _ = self.terminal_set(terminal, @intFromEnum(TerminalOpt.color_cursor), &cursor_color); + } else { + _ = self.terminal_set(terminal, @intFromEnum(TerminalOpt.color_cursor), null); + } + + _ = self.terminal_set(terminal, @intFromEnum(TerminalOpt.color_palette), palette); + } + pub fn freeTerminal(self: *Runtime, handle: ?*anyopaque) void { if (handle) |terminal| self.terminal_free(terminal); } @@ -1738,3 +1767,26 @@ test "synchronized output mode round trips through Ghostty" { try std.testing.expect(runtime.setTerminalMode(terminal, .synchronized_output, false)); try std.testing.expect(!runtime.terminalMode(terminal, .synchronized_output)); } + +test "terminal default colors are exposed through render state" { + var runtime = try Runtime.init(std.testing.allocator, null); + defer runtime.deinit(); + + const terminal = try runtime.createTerminal(80, 24, 0); + defer runtime.freeTerminal(terminal); + + var palette = [_]ColorRgb{.{ .r = 0, .g = 0, .b = 0 }} ** 256; + palette[0] = .{ .r = 1, .g = 2, .b = 3 }; + const foreground = ColorRgb{ .r = 220, .g = 221, .b = 222 }; + const background = ColorRgb{ .r = 25, .g = 26, .b = 28 }; + runtime.setTerminalDefaultColors(terminal, foreground, background, null, &palette); + + const render_state = try runtime.createRenderState(); + defer runtime.freeRenderState(render_state); + try runtime.updateRenderState(render_state, terminal); + + const colors = runtime.renderStateColors(render_state).?; + try std.testing.expectEqual(foreground, colors.foreground); + try std.testing.expectEqual(background, colors.background); + try std.testing.expectEqual(palette[0], colors.palette[0]); +}