a73x

b44b035d

Improve terminal responsiveness and state handling

a73x   2026-04-08 17:33

Commit message
Improve terminal responsiveness and state handling

.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 };