From bddf06c633b170206f0fdb2e55c28cff68020fcb Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Thu, 9 Apr 2026 09:17:07 +0200 Subject: [PATCH 1/6] fix(gui): synthesize modifier release events on unfocus --- src/gui/wio/app.zig | 38 +++++++++++++++++++++++++++++--------- src/gui/wio/input.zig | 30 ++++++++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/src/gui/wio/app.zig b/src/gui/wio/app.zig index 61ec5a23..0cd9edcd 100644 --- a/src/gui/wio/app.zig +++ b/src/gui/wio/app.zig @@ -490,17 +490,22 @@ fn wioLoop() void { cp.yoff, }) catch {}; } else { - const base_cp = input_translate.codepointFromButton(btn, .{}); - const shifted_cp = if (mods.shift) input_translate.codepointFromButton(btn, .{ .shift = true }) else base_cp; - if (base_cp != 0) sendKey(1, base_cp, shifted_cp, mods); + if (input_translate.codepointFromButton(btn, .{})) |base_cp| { + const shifted_cp = if (mods.shift) input_translate.codepointFromButton(btn, .{ .shift = true }) else base_cp; + sendKey(1, base_cp, shifted_cp orelse base_cp, mods); + } else { + if (input_translate.modifierCodepoint(btn)) |mod_cp| + sendKey(1, mod_cp, mod_cp, mods); + } } }, .button_repeat => |btn| { const mods = input_translate.Mods.fromButtons(held_buttons); if (input_translate.mouseButtonId(btn) == null) { - const base_cp = input_translate.codepointFromButton(btn, .{}); - const shifted_cp = if (mods.shift) input_translate.codepointFromButton(btn, .{ .shift = true }) else base_cp; - if (base_cp != 0) sendKey(2, base_cp, shifted_cp, mods); + if (input_translate.codepointFromButton(btn, .{})) |base_cp| { + const shifted_cp = if (mods.shift) input_translate.codepointFromButton(btn, .{ .shift = true }) else base_cp; + sendKey(2, base_cp, shifted_cp orelse base_cp, mods); + } } }, .button_release => |btn| { @@ -518,9 +523,12 @@ fn wioLoop() void { cp.yoff, }) catch {}; } else { - const base_cp = input_translate.codepointFromButton(btn, .{}); - const shifted_cp = if (mods.shift) input_translate.codepointFromButton(btn, .{ .shift = true }) else base_cp; - if (base_cp != 0) sendKey(3, base_cp, shifted_cp, mods); + if (input_translate.codepointFromButton(btn, .{})) |base_cp| { + const shifted_cp = if (mods.shift) input_translate.codepointFromButton(btn, .{ .shift = true }) else base_cp; + sendKey(3, base_cp, shifted_cp orelse base_cp, mods); + } else if (input_translate.modifierCodepoint(btn)) |mod_cp| { + sendKey(3, mod_cp, mod_cp, mods); + } } }, .char => |cp| { @@ -557,6 +565,18 @@ fn wioLoop() void { }, .unfocused => { window.disableTextInput(); + // Synthesize release events for any modifier keys still held so the TUI + // doesn't see them as stuck. Release in order so mods reflects reality after + // each release. + for (input_translate.modifier_buttons) |mod_btn| { + if (held_buttons.has(mod_btn)) { + held_buttons.release(mod_btn); + const mods = input_translate.Mods.fromButtons(held_buttons); + if (input_translate.modifierCodepoint(mod_btn)) |mod_cp| + sendKey(3, mod_cp, mod_cp, mods); + } + } + held_buttons = .{}; tui_pid.send(.{"focus_out"}) catch {}; }, else => {}, diff --git a/src/gui/wio/input.zig b/src/gui/wio/input.zig index 0d2a6fe9..4c22e413 100644 --- a/src/gui/wio/input.zig +++ b/src/gui/wio/input.zig @@ -58,7 +58,7 @@ pub const KeyEvent = struct { }; // Map a wio.Button to the primary codepoint for that key -pub fn codepointFromButton(b: wio.Button, mods: Mods) u21 { +pub fn codepointFromButton(b: wio.Button, mods: Mods) ?u21 { return switch (b) { .a => if (mods.shiftOnly()) 'A' else 'a', .b => if (mods.shiftOnly()) 'B' else 'b', @@ -153,7 +153,7 @@ pub fn codepointFromButton(b: wio.Button, mods: Mods) u21 { .kp_plus => vaxis.Key.kp_add, .kp_enter => vaxis.Key.kp_enter, .kp_equals => vaxis.Key.kp_equal, - else => 0, + else => null, }; } @@ -161,6 +161,32 @@ pub const mouse_button_left: u8 = 0; pub const mouse_button_middle: u8 = 1; pub const mouse_button_right: u8 = 2; +// Map modifier wio.Button values to kitty protocol codepoints (vaxis.Key.*). +// Returns 0 for non-modifier buttons. +pub fn modifierCodepoint(b: wio.Button) ?u21 { + return switch (b) { + .left_shift => vaxis.Key.left_shift, + .left_control => vaxis.Key.left_control, + .left_alt => vaxis.Key.left_alt, + .left_gui => vaxis.Key.left_super, + .right_shift => vaxis.Key.right_shift, + .right_control => vaxis.Key.right_control, + .right_alt => vaxis.Key.right_alt, + .right_gui => vaxis.Key.right_super, + .caps_lock => vaxis.Key.caps_lock, + .num_lock => vaxis.Key.num_lock, + .scroll_lock => vaxis.Key.scroll_lock, + else => null, + }; +} + +// All buttons that contribute to modifier state, for unfocus cleanup. +pub const modifier_buttons = [_]wio.Button{ + .left_shift, .left_control, .left_alt, .left_gui, + .right_shift, .right_control, .right_alt, .right_gui, + .caps_lock, .num_lock, .scroll_lock, +}; + pub fn mouseButtonId(b: wio.Button) ?u8 { return switch (b) { .mouse_left => mouse_button_left, From 1b99b89b2c41022db534cd97633e1cc75500ee94 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Thu, 9 Apr 2026 13:58:51 +0200 Subject: [PATCH 2/6] fix(terminal): use mode.cursor instead of scr.cursor_vis The latter is never cleared between frames and would leave the cursor visible after the terminal app hides it. --- src/tui/terminal_view.zig | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index 9204b655..eadb52ab 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -401,10 +401,9 @@ pub fn render(self: *Self, theme: *const Widget.Theme) bool { } } - if (!software_cursor and self.focused and tui.terminal_has_focus()) { + if (!software_cursor and self.focused and tui.terminal_has_focus() and self.vt.vt.mode.cursor) { const scr = &tui.rdr().vx.screen; - if (scr.cursor_vis) - tui.rdr().cursor_enable(@intCast(scr.cursor.row), @intCast(scr.cursor.col), scr.cursor_shape) catch {}; + tui.rdr().cursor_enable(@intCast(scr.cursor.row), @intCast(scr.cursor.col), scr.cursor_shape) catch {}; } return false; From 7b8a0376279ce9611156d7d494992b82d69ba517 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Thu, 9 Apr 2026 14:00:46 +0200 Subject: [PATCH 3/6] fix: avoid input storms causing render storms by setting idle_frames to 2 --- src/tui/tui.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tui/tui.zig b/src/tui/tui.zig index 54896607..c0050d97 100644 --- a/src/tui/tui.zig +++ b/src/tui/tui.zig @@ -113,7 +113,7 @@ pub const ClipboardEntry = struct { }; const keepalive = std.time.us_per_day * 365; // one year -const idle_frames = 0; +const idle_frames = 2; const mouse_idle_time_milliseconds = 3000; const init_delay = 1; // ms From 2adad0b05b8c6efbebb226bd919ba61ae3384dd0 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Thu, 9 Apr 2026 15:08:55 +0200 Subject: [PATCH 4/6] feat(gui): introduce deadline rendering --- src/renderer/gui/renderer.zig | 13 ++++++----- src/renderer/vaxis/renderer.zig | 6 +++--- src/renderer/win32/renderer.zig | 6 +++--- src/tui/tui.zig | 38 ++++++++++++++++++++++++++++----- 4 files changed, 47 insertions(+), 16 deletions(-) diff --git a/src/renderer/gui/renderer.zig b/src/renderer/gui/renderer.zig index 2cc582f2..5a645703 100644 --- a/src/renderer/gui/renderer.zig +++ b/src/renderer/gui/renderer.zig @@ -137,8 +137,8 @@ fn fmtmsg(self: *Self, value: anytype) std.Io.Writer.Error![]const u8 { return self.event_buffer.written(); } -pub fn render(self: *Self) error{}!bool { - if (!self.window_ready) return false; +pub fn render(self: *Self) error{}!?i64 { + if (!self.window_ready) return null; var cursor = self.cursor_info; @@ -175,9 +175,12 @@ pub fn render(self: *Self) error{}!bool { app.updateScreen(&self.vx.screen, cursor, self.secondary_cursors.items); - if (!self.cursor_info.vis or !self.cursor_blink) return false; - const idle = std.time.microTimestamp() - self.blink_last_change; - return idle < self.blink_idle_us; + if (!self.cursor_info.vis or !self.cursor_blink) return null; + const now_check = std.time.microTimestamp(); + if (now_check - self.blink_last_change >= self.blink_idle_us) return null; + const elapsed = @mod(now_check - self.blink_epoch, self.blink_period_us * 2); + const deadline = now_check + (self.blink_period_us - @mod(elapsed, self.blink_period_us)); + return deadline; } pub fn sigwinch(self: *Self) !void { diff --git a/src/renderer/vaxis/renderer.zig b/src/renderer/vaxis/renderer.zig index 30710f43..ccfb7383 100644 --- a/src/renderer/vaxis/renderer.zig +++ b/src/renderer/vaxis/renderer.zig @@ -219,11 +219,11 @@ pub fn run(self: *Self) Error!void { try self.loop.start(); } -pub fn render(self: *Self) !bool { - if (in_panic.load(.acquire)) return false; +pub fn render(self: *Self) !?i64 { + if (in_panic.load(.acquire)) return null; try self.vx.render(self.tty.writer()); try self.tty.writer().flush(); - return false; + return null; } pub fn sigwinch(self: *Self) !void { diff --git a/src/renderer/win32/renderer.zig b/src/renderer/win32/renderer.zig index 40e4d6a1..8506b86a 100644 --- a/src/renderer/win32/renderer.zig +++ b/src/renderer/win32/renderer.zig @@ -165,10 +165,10 @@ fn fmtmsg(self: *Self, value: anytype) std.Io.Writer.Error![]const u8 { return self.event_buffer.written(); } -pub fn render(self: *Self) error{}!bool { - const hwnd = self.hwnd orelse return false; +pub fn render(self: *Self) error{}!?i64 { + const hwnd = self.hwnd orelse return null; _ = gui.updateScreen(hwnd, &self.vx.screen); - return false; + return null; } pub fn stop(self: *Self) void { // this is guaranteed because stop won't be called until after diff --git a/src/tui/tui.zig b/src/tui/tui.zig index c0050d97..40a99bbc 100644 --- a/src/tui/tui.zig +++ b/src/tui/tui.zig @@ -75,6 +75,7 @@ render_pending: bool = false, keepalive_timer: ?tp.Cancellable = null, input_idle_timer: ?tp.Cancellable = null, mouse_idle_timer: ?tp.Cancellable = null, +render_deadline_timer: ?tp.Cancellable = null, fontface_: []const u8 = "", fontfaces_: std.ArrayListUnmanaged([]const u8) = .{}, input_is_idle: bool = false, @@ -293,6 +294,11 @@ fn deinit(self: *Self) void { t.deinit(); self.keepalive_timer = null; } + if (self.render_deadline_timer) |*t| { + t.cancel() catch {}; + t.deinit(); + self.render_deadline_timer = null; + } if (self.input_mode_) |*m| { m.deinit(); self.input_mode_ = null; @@ -681,17 +687,24 @@ fn render(self: *Self) void { top_layer_.draw(self.rdr_.stdplane()); } - const renderer_more = ret: { + if (self.render_deadline_timer) |*t| { + t.cancel() catch {}; + t.deinit(); + self.render_deadline_timer = null; + } + + const render_deadline: ?i64 = ret: { const frame = tracy.initZone(@src(), .{ .name = renderer.log_name ++ " render" }); defer frame.deinit(); - const m = self.rdr_.render() catch |e| blk: { + const dl = self.rdr_.render() catch |e| blk: { self.logger.err("render", e); - break :blk false; + break :blk @as(?i64, null); }; tracy.frameMark(); self.unrendered_input_events_count = 0; - break :ret m; + break :ret dl; }; + self.top_layer_reset(); self.idle_frame_count = if (self.unrendered_input_events_count > 0) @@ -699,13 +712,28 @@ fn render(self: *Self) void { else self.idle_frame_count + 1; - if (more or renderer_more or self.idle_frame_count < idle_frames or self.no_sleep) { + const deadline_within_frame = if (render_deadline) |dl| + dl - std.time.microTimestamp() < @as(i64, @intCast(self.frame_time)) + else + false; + + if (more or deadline_within_frame or self.idle_frame_count < idle_frames or self.no_sleep) { if (!self.frame_clock_running) { self.frame_clock.start() catch {}; self.frame_clock_running = true; tp.trace(tp.channel.widget, .{ "frame_clock_running", "started", more }); } } else { + if (render_deadline) |deadline| { + const delay_us: u64 = @intCast(@max(0, deadline - std.time.microTimestamp())); + self.render_deadline_timer = tp.self_pid().delay_send_cancellable( + self.allocator, + "tui.render_deadline", + delay_us, + .{"render"}, + ) catch null; + } + if (self.frame_clock_running) { self.frame_clock.stop() catch {}; self.frame_clock_running = false; From 4b4e2465c76a1280f40e905db6ddd5ccee04fc79 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Thu, 9 Apr 2026 15:09:30 +0200 Subject: [PATCH 5/6] fix(gui): re-enable blinking cursors by default --- src/tui/tui.zig | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/tui/tui.zig b/src/tui/tui.zig index 40a99bbc..a4637eb2 100644 --- a/src/tui/tui.zig +++ b/src/tui/tui.zig @@ -2202,9 +2202,7 @@ pub fn get_cursor_shape() renderer.CursorShape { default_cursor else default_cursor; - const shape = if (build_options.gui and shape_ == .default) - .beam - else if (self.rdr_.vx.caps.multi_cursor and shape_ == .default) + const shape = if (self.rdr_.vx.caps.multi_cursor and shape_ == .default) .beam_blink else shape_; From 27794ef4f2b073931393ccc9528fc6fee2d9d436 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Thu, 9 Apr 2026 16:42:02 +0200 Subject: [PATCH 6/6] refactor(gui): trace wio events --- src/gui/wio/app.zig | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/gui/wio/app.zig b/src/gui/wio/app.zig index 0cd9edcd..bc53e8a9 100644 --- a/src/gui/wio/app.zig +++ b/src/gui/wio/app.zig @@ -439,6 +439,7 @@ fn wioLoop() void { // during createWindow. This ensures dpi_scale and win_size are correct before // the first reloadFont / sendResize, avoiding a brief render at the wrong scale. while (window.getEvent()) |event| { + thespian.trace(thespian.channel.event, .{ "wio", "init", event }); switch (event) { .scale => |s| dpi_scale = s, .size_physical => |sz| win_size = sz, @@ -447,6 +448,7 @@ fn wioLoop() void { } // Notify the tui that the window is ready + thespian.trace(thespian.channel.event, .{ "wio", "WindowCreated", win_size.width, win_size.height }); reloadFont(); sendResize(win_size, &state, &cell_width, &cell_height); tui_pid.send(.{ "RDR", "WindowCreated", @as(usize, 0) }) catch {}; @@ -463,6 +465,7 @@ fn wioLoop() void { maybeReloadFont(win_size, &state, &cell_width, &cell_height); while (window.getEvent()) |event| { + thespian.trace(thespian.channel.event, .{ "wio", "event", event }); switch (event) { .close => { running = false; @@ -579,7 +582,9 @@ fn wioLoop() void { held_buttons = .{}; tui_pid.send(.{"focus_out"}) catch {}; }, - else => {}, + else => { + std.log.debug("wio unhandled event: {}", .{event}); + }, } }