b44b035d
Improve terminal responsiveness and state handling
a73x 2026-04-08 17:33
Commit message
.codex
No textual changes available.
src/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -161,13 +161,14 @@ fn runTerminal(alloc: std.mem.Allocator) !void { | |||
| 161 | var key_buf: [32]u8 = undefined; | 161 | var key_buf: [32]u8 = undefined; |
| 162 | var last_window_w = window.width; | 162 | var last_window_w = window.width; |
| 163 | var last_window_h = window.height; | 163 | var last_window_h = window.height; |
| 164 | var render_pending = true; | ||
| 164 | 165 | ||
| 165 | while (!window.should_close and p.isChildAlive()) { | 166 | while (!window.should_close and p.isChildAlive()) { |
| 166 | // Flush any pending wayland requests | 167 | // Flush any pending wayland requests |
| 167 | _ = conn.display.flush(); | 168 | _ = conn.display.flush(); |
| 168 | 169 | ||
| 169 | // Poll with 16ms timeout so we get a render tick even without events | 170 | const repeat_timeout_ms = remainingRepeatTimeoutMs(keyboard.nextRepeatDeadlineNs()); |
| 170 | _ = std.posix.poll(&pollfds, 16) catch {}; | 171 | _ = std.posix.poll(&pollfds, computePollTimeoutMs(repeat_timeout_ms, render_pending)) catch {}; |
| 171 | 172 | ||
| 172 | // Wayland events: prepare_read / read_events / dispatch_pending | 173 | // Wayland events: prepare_read / read_events / dispatch_pending |
| 173 | if (pollfds[0].revents & std.posix.POLL.IN != 0) { | 174 | if (pollfds[0].revents & std.posix.POLL.IN != 0) { |
| @@ -187,6 +188,7 @@ fn runTerminal(alloc: std.mem.Allocator) !void { | |||
| 187 | }; | 188 | }; |
| 188 | if (n == 0) break; | 189 | if (n == 0) break; |
| 189 | term.write(read_buf[0..n]); | 190 | term.write(read_buf[0..n]); |
| 191 | render_pending = true; | ||
| 190 | } | 192 | } |
| 191 | } | 193 | } |
| 192 | 194 | ||
| @@ -236,8 +238,11 @@ fn runTerminal(alloc: std.mem.Allocator) !void { | |||
| 236 | } | 238 | } |
| 237 | last_window_w = window.width; | 239 | last_window_w = window.width; |
| 238 | last_window_h = window.height; | 240 | last_window_h = window.height; |
| 241 | render_pending = true; | ||
| 239 | } | 242 | } |
| 240 | 243 | ||
| 244 | if (!shouldRenderFrame(render_pending, false, false)) continue; | ||
| 245 | |||
| 241 | // === render === | 246 | // === render === |
| 242 | try term.snapshot(); | 247 | try term.snapshot(); |
| 243 | 248 | ||
| @@ -318,10 +323,12 @@ fn runTerminal(alloc: std.mem.Allocator) !void { | |||
| 318 | error.OutOfDateKHR => { | 323 | error.OutOfDateKHR => { |
| 319 | _ = try ctx.vkd.deviceWaitIdle(ctx.device); | 324 | _ = try ctx.vkd.deviceWaitIdle(ctx.device); |
| 320 | try ctx.recreateSwapchain(window.width, window.height); | 325 | try ctx.recreateSwapchain(window.width, window.height); |
| 326 | render_pending = true; | ||
| 321 | continue; | 327 | continue; |
| 322 | }, | 328 | }, |
| 323 | else => return err, | 329 | else => return err, |
| 324 | }; | 330 | }; |
| 331 | render_pending = false; | ||
| 325 | } | 332 | } |
| 326 | 333 | ||
| 327 | _ = try ctx.vkd.deviceWaitIdle(ctx.device); | 334 | _ = try ctx.vkd.deviceWaitIdle(ctx.device); |
| @@ -343,6 +350,23 @@ fn isClipboardPasteEvent(ev: wayland_client.KeyboardEvent) bool { | |||
| 343 | ev.keysym == c.XKB_KEY_V; | 350 | ev.keysym == c.XKB_KEY_V; |
| 344 | } | 351 | } |
| 345 | 352 | ||
| 353 | fn remainingRepeatTimeoutMs(deadline_ns: ?i128) ?i32 { | ||
| 354 | const deadline = deadline_ns orelse return null; | ||
| 355 | const now = std.time.nanoTimestamp(); | ||
| 356 | if (deadline <= now) return 0; | ||
| 357 | const remaining_ns = deadline - now; | ||
| 358 | return @intCast(@divTrunc(remaining_ns + std.time.ns_per_ms - 1, std.time.ns_per_ms)); | ||
| 359 | } | ||
| 360 | |||
| 361 | fn computePollTimeoutMs(next_repeat_in_ms: ?i32, render_pending: bool) i32 { | ||
| 362 | if (render_pending) return 0; | ||
| 363 | return next_repeat_in_ms orelse -1; | ||
| 364 | } | ||
| 365 | |||
| 366 | fn shouldRenderFrame(terminal_dirty: bool, window_dirty: bool, forced: bool) bool { | ||
| 367 | return terminal_dirty or window_dirty or forced; | ||
| 368 | } | ||
| 369 | |||
| 346 | fn appendCellInstances( | 370 | fn appendCellInstances( |
| 347 | alloc: std.mem.Allocator, | 371 | alloc: std.mem.Allocator, |
| 348 | instances: *std.ArrayListUnmanaged(renderer.Instance), | 372 | instances: *std.ArrayListUnmanaged(renderer.Instance), |
| @@ -439,6 +463,19 @@ fn mapKeysymToInputKey(keysym: u32) ?vt.InputKey { | |||
| 439 | }; | 463 | }; |
| 440 | } | 464 | } |
| 441 | 465 | ||
| 466 | test "event loop waits indefinitely when idle and wakes for imminent repeat" { | ||
| 467 | try std.testing.expectEqual(@as(i32, -1), computePollTimeoutMs(null, false)); | ||
| 468 | try std.testing.expectEqual(@as(i32, 0), computePollTimeoutMs(5, true)); | ||
| 469 | try std.testing.expectEqual(@as(i32, 17), computePollTimeoutMs(17, false)); | ||
| 470 | } | ||
| 471 | |||
| 472 | test "event loop redraws only when terminal or window state changed" { | ||
| 473 | try std.testing.expect(shouldRenderFrame(true, false, false)); | ||
| 474 | try std.testing.expect(shouldRenderFrame(false, true, false)); | ||
| 475 | try std.testing.expect(shouldRenderFrame(false, false, true)); | ||
| 476 | try std.testing.expect(!shouldRenderFrame(false, false, false)); | ||
| 477 | } | ||
| 478 | |||
| 442 | fn runDrawSmokeTest(alloc: std.mem.Allocator) !void { | 479 | fn runDrawSmokeTest(alloc: std.mem.Allocator) !void { |
| 443 | var conn = try wayland_client.Connection.init(); | 480 | var conn = try wayland_client.Connection.init(); |
| 444 | defer conn.deinit(); | 481 | defer conn.deinit(); |
src/vt.zig
| Old | New | ||
|---|---|---|---|
| @@ -49,6 +49,7 @@ | |||
| 49 | 49 | ||
| 50 | const std = @import("std"); | 50 | const std = @import("std"); |
| 51 | const ghostty_vt = @import("ghostty-vt"); | 51 | const ghostty_vt = @import("ghostty-vt"); |
| 52 | const color = ghostty_vt.color; | ||
| 52 | const DeviceAttributesCallback = std.meta.Child( | 53 | const DeviceAttributesCallback = std.meta.Child( |
| 53 | @FieldType(ghostty_vt.TerminalStream.Handler.Effects, "device_attributes"), | 54 | @FieldType(ghostty_vt.TerminalStream.Handler.Effects, "device_attributes"), |
| 54 | ); | 55 | ); |
| @@ -97,13 +98,19 @@ pub const Terminal = struct { | |||
| 97 | .cols = @intCast(opts.cols), | 98 | .cols = @intCast(opts.cols), |
| 98 | .rows = @intCast(opts.rows), | 99 | .rows = @intCast(opts.rows), |
| 99 | .max_scrollback = @intCast(opts.max_scrollback), | 100 | .max_scrollback = @intCast(opts.max_scrollback), |
| 101 | .colors = .{ | ||
| 102 | .background = color.DynamicRGB.init(.{ .r = 0x00, .g = 0x00, .b = 0x00 }), | ||
| 103 | .foreground = color.DynamicRGB.init(.{ .r = 0xff, .g = 0xff, .b = 0xff }), | ||
| 104 | .cursor = .unset, | ||
| 105 | .palette = .default, | ||
| 106 | }, | ||
| 100 | }); | 107 | }); |
| 101 | errdefer inner.deinit(alloc); | 108 | errdefer inner.deinit(alloc); |
| 102 | 109 | ||
| 103 | self.* = .{ | 110 | self.* = .{ |
| 104 | .alloc = alloc, | 111 | .alloc = alloc, |
| 105 | .inner = inner, | 112 | .inner = inner, |
| 106 | .stream = .init(.{ | 113 | .stream = .initAlloc(alloc, .{ |
| 107 | .terminal = &self.inner, | 114 | .terminal = &self.inner, |
| 108 | }), | 115 | }), |
| 109 | .render_state = .empty, | 116 | .render_state = .empty, |
| @@ -328,6 +335,27 @@ test "Terminal resolves ANSI fg and bg colors from render state" { | |||
| 328 | try std.testing.expectEqualDeep(rgbToFloat4(palette[4]), colors.bg); | 335 | try std.testing.expectEqualDeep(rgbToFloat4(palette[4]), colors.bg); |
| 329 | } | 336 | } |
| 330 | 337 | ||
| 338 | test "Terminal applies OSC 11 background color updates" { | ||
| 339 | var term = try Terminal.init(std.testing.allocator, .{ | ||
| 340 | .cols = 80, | ||
| 341 | .rows = 24, | ||
| 342 | }); | ||
| 343 | defer term.deinit(); | ||
| 344 | |||
| 345 | term.write("\x1b]11;rgb:12/34/56\x1b\\"); | ||
| 346 | try term.snapshot(); | ||
| 347 | |||
| 348 | try std.testing.expectEqualDeep( | ||
| 349 | [_]f32{ | ||
| 350 | @as(f32, 0x12) / 255.0, | ||
| 351 | @as(f32, 0x34) / 255.0, | ||
| 352 | @as(f32, 0x56) / 255.0, | ||
| 353 | 1.0, | ||
| 354 | }, | ||
| 355 | term.backgroundColor(), | ||
| 356 | ); | ||
| 357 | } | ||
| 358 | |||
| 331 | test "Terminal title callback fires on OSC 2" { | 359 | test "Terminal title callback fires on OSC 2" { |
| 332 | var term = try Terminal.init(std.testing.allocator, .{ | 360 | var term = try Terminal.init(std.testing.allocator, .{ |
| 333 | .cols = 80, | 361 | .cols = 80, |
src/wayland.zig
| Old | New | ||
|---|---|---|---|
| @@ -103,6 +103,14 @@ pub const Keyboard = struct { | |||
| 103 | 40; | 103 | 40; |
| 104 | self.next_repeat_time_ns = now + interval_ms * std.time.ns_per_ms; | 104 | self.next_repeat_time_ns = now + interval_ms * std.time.ns_per_ms; |
| 105 | } | 105 | } |
| 106 | |||
| 107 | pub fn nextRepeatDeadlineNs(self: *const Keyboard) ?i128 { | ||
| 108 | if (!self.has_focus) return null; | ||
| 109 | _ = self.xkb_state orelse return null; | ||
| 110 | _ = self.last_keycode orelse return null; | ||
| 111 | if (self.repeat_rate == 0) return null; | ||
| 112 | return self.next_repeat_time_ns; | ||
| 113 | } | ||
| 106 | }; | 114 | }; |
| 107 | 115 | ||
| 108 | pub const Globals = struct { | 116 | pub const Globals = struct { |
| @@ -114,7 +122,7 @@ pub const Globals = struct { | |||
| 114 | 122 | ||
| 115 | const ClipboardOffer = struct { | 123 | const ClipboardOffer = struct { |
| 116 | offer: *wl.DataOffer, | 124 | offer: *wl.DataOffer, |
| 117 | mime_types: std.ArrayList([]u8), | 125 | mime_types: std.ArrayList([:0]u8), |
| 118 | 126 | ||
| 119 | fn init(offer: *wl.DataOffer) ClipboardOffer { | 127 | fn init(offer: *wl.DataOffer) ClipboardOffer { |
| 120 | return .{ | 128 | return .{ |
| @@ -176,7 +184,7 @@ pub const Clipboard = struct { | |||
| 176 | std.posix.close(pipefds[1]); | 184 | std.posix.close(pipefds[1]); |
| 177 | write_fd_closed = true; | 185 | write_fd_closed = true; |
| 178 | 186 | ||
| 179 | const roundtrip_ctx = struct { | 187 | var roundtrip_ctx = struct { |
| 180 | display: *wl.Display, | 188 | display: *wl.Display, |
| 181 | 189 | ||
| 182 | fn roundtrip(ctx: *@This()) !void { | 190 | fn roundtrip(ctx: *@This()) !void { |
| @@ -210,7 +218,7 @@ pub fn drainSelectionPipeThenRoundtrip( | |||
| 210 | return try out.toOwnedSlice(alloc); | 218 | return try out.toOwnedSlice(alloc); |
| 211 | } | 219 | } |
| 212 | 220 | ||
| 213 | fn choosePreferredMime(mime_types: []const []const u8) ?[]const u8 { | 221 | fn choosePreferredMime(mime_types: []const [:0]const u8) ?[:0]const u8 { |
| 214 | for (mime_types) |mime| { | 222 | for (mime_types) |mime| { |
| 215 | if (std.mem.eql(u8, mime, "text/plain;charset=utf-8")) return mime; | 223 | if (std.mem.eql(u8, mime, "text/plain;charset=utf-8")) return mime; |
| 216 | } | 224 | } |
| @@ -407,7 +415,7 @@ fn dataOfferListener(_: *wl.DataOffer, event: wl.DataOffer.Event, clipboard: *Cl | |||
| 407 | switch (event) { | 415 | switch (event) { |
| 408 | .offer => |mime| { | 416 | .offer => |mime| { |
| 409 | var offer = &(clipboard.pending_offer orelse return); | 417 | var offer = &(clipboard.pending_offer orelse return); |
| 410 | const dup = clipboard.alloc.dupe(u8, std.mem.span(mime.mime_type)) catch return; | 418 | const dup = clipboard.alloc.dupeZ(u8, std.mem.span(mime.mime_type)) catch return; |
| 411 | offer.mime_types.append(clipboard.alloc, dup) catch { | 419 | offer.mime_types.append(clipboard.alloc, dup) catch { |
| 412 | clipboard.alloc.free(dup); | 420 | clipboard.alloc.free(dup); |
| 413 | }; | 421 | }; |
| @@ -443,6 +451,18 @@ fn dataDeviceListener(_: *wl.DataDevice, event: wl.DataDevice.Event, clipboard: | |||
| 443 | } | 451 | } |
| 444 | } | 452 | } |
| 445 | 453 | ||
| 454 | test "choosePreferredMime preserves sentinel-terminated mime type" { | ||
| 455 | const mime_types = [_][:0]const u8{ | ||
| 456 | "text/html", | ||
| 457 | "text/plain;charset=utf-8", | ||
| 458 | "text/plain", | ||
| 459 | }; | ||
| 460 | |||
| 461 | const mime = choosePreferredMime(&mime_types) orelse return error.TestUnexpectedResult; | ||
| 462 | try std.testing.expectEqualStrings("text/plain;charset=utf-8", mime); | ||
| 463 | try std.testing.expect(mime[mime.len] == 0); | ||
| 464 | } | ||
| 465 | |||
| 446 | fn wmBaseListener(wm_base: *xdg.WmBase, event: xdg.WmBase.Event, _: *xdg.WmBase) void { | 466 | fn wmBaseListener(wm_base: *xdg.WmBase, event: xdg.WmBase.Event, _: *xdg.WmBase) void { |
| 447 | switch (event) { | 467 | switch (event) { |
| 448 | .ping => |p| wm_base.pong(p.serial), | 468 | .ping => |p| wm_base.pong(p.serial), |
| @@ -537,7 +557,8 @@ test "drainSelectionPipeThenRoundtrip drains large payload before roundtrip" { | |||
| 537 | } | 557 | } |
| 538 | }; | 558 | }; |
| 539 | 559 | ||
| 540 | const thread = try std.Thread.spawn(.{}, Writer.run, .{.{ .fd = pipefds[1] }}); | 560 | const writer = Writer{ .fd = pipefds[1] }; |
| 561 | const thread = try std.Thread.spawn(.{}, Writer.run, .{writer}); | ||
| 541 | defer thread.join(); | 562 | defer thread.join(); |
| 542 | 563 | ||
| 543 | var roundtrip_ctx = RoundtripCtx{ .called = &roundtrip_called }; | 564 | var roundtrip_ctx = RoundtripCtx{ .called = &roundtrip_called }; |