b44b035d
Improve terminal responsiveness and state handling
a73x 2026-04-08 17:33
diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/.codex diff --git a/src/main.zig b/src/main.zig index c347b40..d0abf95 100644 --- a/src/main.zig +++ b/src/main.zig @@ -161,13 +161,14 @@ fn runTerminal(alloc: std.mem.Allocator) !void { var key_buf: [32]u8 = undefined; var last_window_w = window.width; var last_window_h = window.height; var render_pending = true; while (!window.should_close and p.isChildAlive()) { // Flush any pending wayland requests _ = conn.display.flush(); // Poll with 16ms timeout so we get a render tick even without events _ = std.posix.poll(&pollfds, 16) catch {}; const repeat_timeout_ms = remainingRepeatTimeoutMs(keyboard.nextRepeatDeadlineNs()); _ = std.posix.poll(&pollfds, computePollTimeoutMs(repeat_timeout_ms, render_pending)) catch {}; // Wayland events: prepare_read / read_events / dispatch_pending if (pollfds[0].revents & std.posix.POLL.IN != 0) { @@ -187,6 +188,7 @@ fn runTerminal(alloc: std.mem.Allocator) !void { }; if (n == 0) break; term.write(read_buf[0..n]); render_pending = true; } } @@ -236,8 +238,11 @@ fn runTerminal(alloc: std.mem.Allocator) !void { } last_window_w = window.width; last_window_h = window.height; render_pending = true; } if (!shouldRenderFrame(render_pending, false, false)) continue; // === render === try term.snapshot(); @@ -318,10 +323,12 @@ fn runTerminal(alloc: std.mem.Allocator) !void { error.OutOfDateKHR => { _ = try ctx.vkd.deviceWaitIdle(ctx.device); try ctx.recreateSwapchain(window.width, window.height); render_pending = true; continue; }, else => return err, }; render_pending = false; } _ = try ctx.vkd.deviceWaitIdle(ctx.device); @@ -343,6 +350,23 @@ fn isClipboardPasteEvent(ev: wayland_client.KeyboardEvent) bool { ev.keysym == c.XKB_KEY_V; } fn remainingRepeatTimeoutMs(deadline_ns: ?i128) ?i32 { const deadline = deadline_ns orelse return null; const now = std.time.nanoTimestamp(); if (deadline <= now) return 0; const remaining_ns = deadline - now; return @intCast(@divTrunc(remaining_ns + std.time.ns_per_ms - 1, std.time.ns_per_ms)); } fn computePollTimeoutMs(next_repeat_in_ms: ?i32, render_pending: bool) i32 { if (render_pending) return 0; return next_repeat_in_ms orelse -1; } fn shouldRenderFrame(terminal_dirty: bool, window_dirty: bool, forced: bool) bool { return terminal_dirty or window_dirty or forced; } fn appendCellInstances( alloc: std.mem.Allocator, instances: *std.ArrayListUnmanaged(renderer.Instance), @@ -439,6 +463,19 @@ fn mapKeysymToInputKey(keysym: u32) ?vt.InputKey { }; } test "event loop waits indefinitely when idle and wakes for imminent repeat" { try std.testing.expectEqual(@as(i32, -1), computePollTimeoutMs(null, false)); try std.testing.expectEqual(@as(i32, 0), computePollTimeoutMs(5, true)); try std.testing.expectEqual(@as(i32, 17), computePollTimeoutMs(17, false)); } test "event loop redraws only when terminal or window state changed" { try std.testing.expect(shouldRenderFrame(true, false, false)); try std.testing.expect(shouldRenderFrame(false, true, false)); try std.testing.expect(shouldRenderFrame(false, false, true)); try std.testing.expect(!shouldRenderFrame(false, false, false)); } fn runDrawSmokeTest(alloc: std.mem.Allocator) !void { var conn = try wayland_client.Connection.init(); defer conn.deinit(); diff --git a/src/vt.zig b/src/vt.zig index c216316..3d4912c 100644 --- a/src/vt.zig +++ b/src/vt.zig @@ -49,6 +49,7 @@ const std = @import("std"); const ghostty_vt = @import("ghostty-vt"); const color = ghostty_vt.color; const DeviceAttributesCallback = std.meta.Child( @FieldType(ghostty_vt.TerminalStream.Handler.Effects, "device_attributes"), ); @@ -97,13 +98,19 @@ pub const Terminal = struct { .cols = @intCast(opts.cols), .rows = @intCast(opts.rows), .max_scrollback = @intCast(opts.max_scrollback), .colors = .{ .background = color.DynamicRGB.init(.{ .r = 0x00, .g = 0x00, .b = 0x00 }), .foreground = color.DynamicRGB.init(.{ .r = 0xff, .g = 0xff, .b = 0xff }), .cursor = .unset, .palette = .default, }, }); errdefer inner.deinit(alloc); self.* = .{ .alloc = alloc, .inner = inner, .stream = .init(.{ .stream = .initAlloc(alloc, .{ .terminal = &self.inner, }), .render_state = .empty, @@ -328,6 +335,27 @@ test "Terminal resolves ANSI fg and bg colors from render state" { try std.testing.expectEqualDeep(rgbToFloat4(palette[4]), colors.bg); } test "Terminal applies OSC 11 background color updates" { var term = try Terminal.init(std.testing.allocator, .{ .cols = 80, .rows = 24, }); defer term.deinit(); term.write("\x1b]11;rgb:12/34/56\x1b\\"); try term.snapshot(); try std.testing.expectEqualDeep( [_]f32{ @as(f32, 0x12) / 255.0, @as(f32, 0x34) / 255.0, @as(f32, 0x56) / 255.0, 1.0, }, term.backgroundColor(), ); } test "Terminal title callback fires on OSC 2" { var term = try Terminal.init(std.testing.allocator, .{ .cols = 80, diff --git a/src/wayland.zig b/src/wayland.zig index 5fe1b53..5443aba 100644 --- a/src/wayland.zig +++ b/src/wayland.zig @@ -103,6 +103,14 @@ pub const Keyboard = struct { 40; self.next_repeat_time_ns = now + interval_ms * std.time.ns_per_ms; } pub fn nextRepeatDeadlineNs(self: *const Keyboard) ?i128 { if (!self.has_focus) return null; _ = self.xkb_state orelse return null; _ = self.last_keycode orelse return null; if (self.repeat_rate == 0) return null; return self.next_repeat_time_ns; } }; pub const Globals = struct { @@ -114,7 +122,7 @@ pub const Globals = struct { const ClipboardOffer = struct { offer: *wl.DataOffer, mime_types: std.ArrayList([]u8), mime_types: std.ArrayList([:0]u8), fn init(offer: *wl.DataOffer) ClipboardOffer { return .{ @@ -176,7 +184,7 @@ pub const Clipboard = struct { std.posix.close(pipefds[1]); write_fd_closed = true; const roundtrip_ctx = struct { var roundtrip_ctx = struct { display: *wl.Display, fn roundtrip(ctx: *@This()) !void { @@ -210,7 +218,7 @@ pub fn drainSelectionPipeThenRoundtrip( return try out.toOwnedSlice(alloc); } fn choosePreferredMime(mime_types: []const []const u8) ?[]const u8 { fn choosePreferredMime(mime_types: []const [:0]const u8) ?[:0]const u8 { for (mime_types) |mime| { if (std.mem.eql(u8, mime, "text/plain;charset=utf-8")) return mime; } @@ -407,7 +415,7 @@ fn dataOfferListener(_: *wl.DataOffer, event: wl.DataOffer.Event, clipboard: *Cl switch (event) { .offer => |mime| { var offer = &(clipboard.pending_offer orelse return); const dup = clipboard.alloc.dupe(u8, std.mem.span(mime.mime_type)) catch return; const dup = clipboard.alloc.dupeZ(u8, std.mem.span(mime.mime_type)) catch return; offer.mime_types.append(clipboard.alloc, dup) catch { clipboard.alloc.free(dup); }; @@ -443,6 +451,18 @@ fn dataDeviceListener(_: *wl.DataDevice, event: wl.DataDevice.Event, clipboard: } } test "choosePreferredMime preserves sentinel-terminated mime type" { const mime_types = [_][:0]const u8{ "text/html", "text/plain;charset=utf-8", "text/plain", }; const mime = choosePreferredMime(&mime_types) orelse return error.TestUnexpectedResult; try std.testing.expectEqualStrings("text/plain;charset=utf-8", mime); try std.testing.expect(mime[mime.len] == 0); } fn wmBaseListener(wm_base: *xdg.WmBase, event: xdg.WmBase.Event, _: *xdg.WmBase) void { switch (event) { .ping => |p| wm_base.pong(p.serial), @@ -537,7 +557,8 @@ test "drainSelectionPipeThenRoundtrip drains large payload before roundtrip" { } }; const thread = try std.Thread.spawn(.{}, Writer.run, .{.{ .fd = pipefds[1] }}); const writer = Writer{ .fd = pipefds[1] }; const thread = try std.Thread.spawn(.{}, Writer.run, .{writer}); defer thread.join(); var roundtrip_ctx = RoundtripCtx{ .called = &roundtrip_called };