diff --git a/src/Surface.zig b/src/Surface.zig index 7a70c09862c..394fafb3bd7 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -930,6 +930,7 @@ pub fn init( .size = size, .full_config = config, .config = try termio.Termio.DerivedConfig.init(alloc, config), + .color_scheme = self.config_conditional_state.theme, .backend = io_backend, .suppress_terminal_responses = if (comptime @hasDecl(apprt.runtime.Surface, "suppressTerminalResponses")) rt_surface.suppressTerminalResponses() @@ -6847,6 +6848,9 @@ pub fn colorSchemeCallback(self: *Surface, scheme: apprt.ColorScheme) !void { // Setup our conditional state which has the current color theme. self.config_conditional_state.theme = new_scheme; + // Reports must follow the surface even if a plain config has no conditional + // theme to reload, or the embedder coalesces the reload notification. + self.io.updateColorScheme(new_scheme); self.notifyConfigConditionalState(); // If mode 2031 is on, then we report the change live. diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index 1b562eb565d..d578dd1982c 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -89,6 +89,11 @@ pub const Options = struct { /// screen contents as it is rendered on the page in the given size. unwrap: bool = false, + /// Preserve blank rows at the end of VT selections when the caller also + /// requests cursor/state restoration. Replay consumers need those rows to + /// keep the active screen anchored after retained scrollback. + preserve_trailing_blank_rows: bool = false, + /// Trim trailing whitespace on lines with other text. Trailing blank /// lines are always trimmed. This only affects trailing whitespace /// on rows that have at least one other cell with text. Whitespace @@ -335,7 +340,12 @@ pub const TerminalFormatter = struct { } } - var screen_formatter: ScreenFormatter = .init(self.terminal.screens.active, self.opts); + var screen_opts = self.opts; + screen_opts.preserve_trailing_blank_rows = + screen_opts.preserve_trailing_blank_rows or + (self.opts.emit == .vt and self.extra.screen.cursor); + var screen_formatter: ScreenFormatter = + .init(self.terminal.screens.active, screen_opts); screen_formatter.content = self.content; screen_formatter.extra = self.extra.screen; screen_formatter.pin_map = self.pin_map; @@ -1428,6 +1438,40 @@ pub const PageFormatter = struct { } } + // Plain dumps intentionally omit trailing blank rows because their + // meaning cannot be recovered without the cursor. VT replay asks for + // cursor restoration, so preserve those physical rows before the + // terminal formatter emits the cursor and other state footer. + if (self.opts.preserve_trailing_blank_rows and blank_rows > 0) { + const sequence: []const u8 = switch (self.opts.emit) { + .plain => "\n", + .vt => "\r\n", + .html => "\n", + }; + for (0..blank_rows) |_| try writer.writeAll(sequence); + + if (self.point_map) |*map| { + const start: Coordinate = if (map.map.items.len > 0) + map.map.items[map.map.items.len - 1] + else + .{ .x = 0, .y = 0 }; + map.map.appendNTimes( + map.alloc, + .{ .x = start.x, .y = start.y }, + sequence.len, + ) catch return error.WriteFailed; + for (1..blank_rows) |y_offset_usize| { + const y_offset: size.CellCountInt = @intCast(y_offset_usize); + map.map.appendNTimes( + map.alloc, + .{ .x = 0, .y = start.y + y_offset }, + sequence.len, + ) catch return error.WriteFailed; + } + } + blank_rows = 0; + } + // If the style is non-default, we need to close our style tag. if (!style.default()) try self.formatStyleClose(writer); diff --git a/src/termio/Options.zig b/src/termio/Options.zig index ffcbf517995..029144b5c7a 100644 --- a/src/termio/Options.zig +++ b/src/termio/Options.zig @@ -17,6 +17,10 @@ full_config: *const Config, /// The derived configuration for this termio implementation. config: termio.Termio.DerivedConfig, +/// The surface's current runtime scheme. This is independent of whether the +/// configuration contains any theme-conditional settings. +color_scheme: @import("../config.zig").ConditionalState.Theme, + /// The backend for termio that implements where reads/writes are sourced. backend: termio.Backend, diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index f02d9c4c388..3a9261e34aa 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -44,6 +44,10 @@ backend: termio.Backend, /// The derived configuration for this termio implementation. config: DerivedConfig, +/// Runtime scheme used by CSI 996 queries and mode 2031 reports. Guarded by +/// renderer_state.mutex; config swaps must never overwrite appearance state. +color_scheme: configpkg.ConditionalState.Theme, + /// The terminal emulator internal state. This is the abstract "terminal" /// that manages input, grid updating, etc. and is renderer-agnostic. It /// just stores internal state about a grid. @@ -209,7 +213,6 @@ pub const DerivedConfig = struct { osc_color_report_format: configpkg.Config.OSCColorReportFormat, clipboard_write: configpkg.ClipboardAccess, enquiry_response: []const u8, - conditional_state: configpkg.ConditionalState, pub fn init( alloc_gpa: Allocator, @@ -249,7 +252,6 @@ pub const DerivedConfig = struct { .osc_color_report_format = config.@"osc-color-report-format", .clipboard_write = config.@"clipboard-write", .enquiry_response = try alloc.dupe(u8, config.@"enquiry-response"), - .conditional_state = config._conditional_state, // This has to be last so that we copy AFTER the arena allocations // above happen (Zig assigns in order). @@ -374,6 +376,7 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { .alloc = alloc, .terminal = term, .config = opts.config, + .color_scheme = opts.color_scheme, .renderer_state = opts.renderer_state, .renderer_wakeup = opts.renderer_wakeup, .renderer_mailbox = opts.renderer_mailbox, @@ -625,6 +628,14 @@ pub fn changeConfig(self: *Termio, td: *ThreadData, config: *DerivedConfig) !voi self.terminal.setKittyGraphicsLoadingLimits(.allWithTempDir(global.tmpDirPath())); } +/// Update report state synchronously with the surface appearance callback. +/// The IO thread reads it under the same lock when it handles a queued query. +pub fn updateColorScheme(self: *Termio, scheme: configpkg.ConditionalState.Theme) void { + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); + self.color_scheme = scheme; +} + /// Update only the terminal color defaults used by OSC resets. /// /// Manual-IO embedders call this on the same serial executor as processOutput. @@ -972,7 +983,7 @@ pub fn colorSchemeReportLocked(self: *Termio, td: *ThreadData, force: bool) !voi if (!force and !self.renderer_state.terminal.modes.get(.report_color_scheme)) { return; } - const scheme: terminalpkg.device_status.ColorScheme = switch (self.config.conditional_state.theme) { + const scheme: terminalpkg.device_status.ColorScheme = switch (self.color_scheme) { .light => .light, .dark => .dark, };