From 17c0d06618085de8d0bbf8152750ffa862db0080 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 30 Sep 2026 14:20:10 -0300 Subject: A program that asked for focus reports (1004) hears CSI I when its pane takes the keyboard and CSI O when it gives it up, to another pane, a tag or another window The core has a window-focus event now, from SDL's window focus and from the tty's own focus reports (vaxis turns 1004 on outside); the detached wire carries it: version 10. The test shell's ESC]777;mouse;focus;<1|0> fakes the window's. Co-Authored-By: Claude Opus 5.5 --- src/detached/wire.zig | 11 ++++++---- src/gui/gui.zig | 5 +++++ src/pardes.zig | 27 +++++++++++++++++++++++ src/terminal.zig | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/tty/tty.zig | 14 ++++++++---- 5 files changed, 108 insertions(+), 8 deletions(-) (limited to 'src') diff --git a/src/detached/wire.zig b/src/detached/wire.zig index 9a1ab608..c9d9b578 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -12,7 +12,7 @@ const ShaderAnimation = pardes.config.Runtime.ShaderAnimation; /// to be refused, which is what this number is for. /// 8: one layer type: a tag band carries all its rows (not a layer per /// line), and a frame carries the core's placed regions after its layers. -pub const version: u16 = 9; +pub const version: u16 = 10; /// The Chrome palette is carried field by field as its declaration lists /// them, so any change to it changes the wire: bump `version`, then this. @@ -58,6 +58,7 @@ pub const ClientTag = enum(u8) { pinch = 0x1b, touch_scroll = 0x1c, pointer_leave = 0x1d, + focus = 0x1e, }; // Session control precedes display-local effects. @@ -1076,6 +1077,7 @@ pub fn encodeClient(out: []u8, msg: ClientMsg) Error![]const u8 { .pinch => |v| try w.putF32(v), .touch_scroll => |v| try w.putF32(v), .pointer_leave => {}, + .focus => |focused| try w.putBool(focused), // See `clientTag`: no tag, so nothing to encode. .output, .eof, .exited, .lsp_resp, .pipe_resp, .file_changed, .tick => unreachable, }, @@ -1138,6 +1140,7 @@ pub fn decodeClient(tag: u8, payload: []const u8) Error!ClientMsg { .pinch => .{ .event = .{ .pinch = try r.getF32() } }, .touch_scroll => .{ .event = .{ .touch_scroll = try r.getF32() } }, .pointer_leave => .{ .event = .pointer_leave }, + .focus => .{ .event = .{ .focus = try r.getBool() } }, }; try r.end(); return msg; @@ -1222,7 +1225,7 @@ pub fn clientBound(msg: ClientMsg) usize { return msg_slack + switch (msg) { .hello, .bye => 0, .event => |ev| switch (ev) { - .mouse, .resize, .pdf_scroll, .pinch, .touch_scroll, .pointer_leave => 0, + .mouse, .resize, .pdf_scroll, .pinch, .touch_scroll, .pointer_leave, .focus => 0, .key => |k| k.text.len, .paste => |b| b.len, .command => |line| line.len, @@ -1455,7 +1458,7 @@ test "detached wire: a session is never told to do a frontend's remembering" { // Adding a name here is meant to be an ARGUMENT, not a formality, and the // bar is one sentence: A HUMAN DID IT. A keystroke, a click, a pinch, a // window resized, a paste, a command line executed, a pointer leaving the - // window — plus the two session words that say who is speaking. A pty's + // window, the window's focus — plus the two session words that say who is speaking. A pty's // output, a worker's answer, a watched file's new bytes and an animation // tick all fail that bar the same way: nobody did them, a machine reported // them, and the machine that reports them is the one already holding the @@ -1468,7 +1471,7 @@ test "detached wire: a session is never told to do a frontend's remembering" { "resize", "paste", "command", "pdf_scroll", "pinch", "touch_scroll", - "pointer_leave", + "pointer_leave", "focus", }; // One: the tag set is exactly that, named rather than counted, so diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 590d749d..81b68afd 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -3172,6 +3172,9 @@ const StdinFeed = struct { sev.wheel.integer_y = @intFromFloat(@trunc(amount)); sev.wheel.mouse_x = x; sev.wheel.mouse_y = y; + } else if (std.mem.eql(u8, kind, "focus")) { + // ESC]777;mouse;focus;<1 gained, 0 lost>;0;0BEL + sev.type = if (button != 0) c.SDL_EVENT_WINDOW_FOCUS_GAINED else c.SDL_EVENT_WINDOW_FOCUS_LOST; } else if (std.mem.eql(u8, kind, "motion")) { sev.type = c.SDL_EVENT_MOUSE_MOTION; sev.motion.x = x; @@ -3469,6 +3472,8 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { } switch (sev.type) { c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => in.close(), + c.SDL_EVENT_WINDOW_FOCUS_GAINED => in.post(.{ .focus = true }), + c.SDL_EVENT_WINDOW_FOCUS_LOST => in.post(.{ .focus = false }), c.SDL_EVENT_WINDOW_MOUSE_LEAVE => { g.pointer_present = false; g.pointer_mapped = false; diff --git a/src/pardes.zig b/src/pardes.zig index dcf0bf55..1009d13d 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -3548,6 +3548,8 @@ pub const Event = union(enum) { /// The host pointer left the drawable area. Unlike an out-of-range motion, /// this must not clamp onto and preview the final grid cell. pointer_leave, + /// The window gained (true) or lost the host's keyboard focus. + focus: bool, tick, }; @@ -4322,6 +4324,10 @@ pub const Pardes = struct { /// A press a terminal's program took (mouse.programMouse): its drags /// and release are the program's too, wherever the pointer goes. mouse_grab: ?mouse.Grab = null, + /// Whether the host window has the keyboard (Event.focus), and the pane + /// whose program was last told it has it (reportFocus). + window_focused: bool = true, + focus_told: ?struct { id: usize, serial: u32 } = null, hover_col: u16 = 0, hover_row: u16 = 0, pointer_raw_col: u16 = 0, @@ -5007,6 +5013,25 @@ pub const Pardes = struct { } /// The column the keyboard is in: its tag's, or its pane's. + /// DECSET 1004: a program that asked is told `CSI I` when its pane + /// takes the keyboard (the active pane, its body, the window focused) + /// and `CSI O` when it gives it up, to another pane, a tag or header, + /// or another window. + fn reportFocus(p: *Pardes) void { + const want: @FieldType(Pardes, "focus_told") = blk: { + if (!p.window_focused or p.header_focus) break :blk null; + const pane = p.panes[p.active] orelse break :blk null; + if (pane.focus != .body) break :blk null; + break :blk .{ .id = p.active, .serial = pane.serial }; + }; + if (std.meta.eql(want, p.focus_told)) return; + if (p.focus_told) |old| if (p.panes[old.id]) |pane| { + if (pane.serial == old.serial and panes.terminal.reportsFocus(pane)) p.emitWrite(old.id, "\x1b[O"); + }; + if (want) |new| if (panes.terminal.reportsFocus(p.panes[new.id].?)) p.emitWrite(new.id, "\x1b[I"); + p.focus_told = want; + } + pub fn noteTyped(p: *Pardes) void { if (p.header_focus) { if (p.header_column) |c| p.setActiveColumn(c); @@ -5653,6 +5678,7 @@ pub const Pardes = struct { } pub fn update(p: *Pardes, ev: Event) void { + defer p.reportFocus(); // A tick with nothing animating is the one event that cannot change // the screen. p.needs_frame = p.needs_frame or ev != .tick; @@ -5785,6 +5811,7 @@ pub const Pardes = struct { .command => |line| _ = p.executeBuiltinLine(p.active, line), .pinch => |scale| p.ov_pinch_scale = scale, .touch_scroll => |delta| p.ov_touch_scroll_delta = delta, + .focus => |focused| p.window_focused = focused, .pointer_leave => { p.pointer_inside = false; p.pointer_body_hit = null; diff --git a/src/terminal.zig b/src/terminal.zig index 63c5479f..af540202 100644 --- a/src/terminal.zig +++ b/src/terminal.zig @@ -380,6 +380,13 @@ pub fn reportsMouse(pane: *const Pane) bool { return state.vt.flags.mouse_event != .none; } +/// DECSET 1004: the program wants to hear when it gains and loses focus. +pub fn reportsFocus(pane: *const Pane) bool { + if (comptime !enabled) return false; + const state = pane.terminal orelse return false; + return state.vt.modes.get(.focus_event); +} + /// ...every move of it, a button down or not (1003). pub fn tracksAllMotion(pane: *const Pane) bool { if (comptime !enabled) return false; @@ -2738,3 +2745,55 @@ const DeviceAttrs = @typeInfo(@typeInfo(@typeInfo( pub fn ptyDeviceAttrs(_: *ghostty_vt.TerminalStream.Handler) DeviceAttrs { return .{}; } + +/// Every byte the core wrote to each pane's pty since the last call, `\n` +/// between panes' shares: "0:\n1:\n" for panes that got any. +fn ptyWritesByPane(p: *Pardes, out: *std.ArrayList(u8)) ![]const u8 { + out.clearRetainingCapacity(); + var per: [pardes.MAX_PANES]std.ArrayList(u8) = @splat(.empty); + defer for (&per) |*b| b.deinit(p.gpa); + while (p.nextEffect()) |effect| { + if (effect == .write) try per[effect.write.pane].appendSlice(p.gpa, effect.write.bytes.slice()); + } + for (per, 0..) |b, id| if (b.items.len > 0) try out.print(p.gpa, "{d}:{s}\n", .{ id, b.items }); + return out.items; +} + +test "a program that asked for focus reports hears CSI I and CSI O as its pane takes and gives up the keyboard, and as the window does" { + if (comptime !enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .cols = 90, .rows = 30, .shells = 3 }); + defer p.deinit(); + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + var out: std.ArrayList(u8) = .empty; + defer out.deinit(gpa); + _ = try ptyWritesByPane(p, &out); + p.active = 0; + // Asking while its pane has the keyboard: told at once that it has. + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1004h" } }); + try std.testing.expectEqualStrings("0:\x1b[I\n", try ptyWritesByPane(p, &out)); + p.update(.{ .output = .{ .pane = 1, .bytes = "\x1b[?1004h" } }); + try std.testing.expectEqualStrings("", try ptyWritesByPane(p, &out)); + // A click into the other pane moves the keyboard: out of one, into the + // other. + const r = p.rects[1]; + const at_col = r.x + config.GUTTER + 2; + const at_row = p.bodyTop(p.panes[1].?, r) + 1; + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = at_col, .row = at_row } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = at_col, .row = at_row } }); + try std.testing.expectEqual(@as(usize, 1), p.active); + try std.testing.expectEqualStrings("0:\x1b[O\n1:\x1b[I\n", try ptyWritesByPane(p, &out)); + // The window: away and back. + p.update(.{ .focus = false }); + try std.testing.expectEqualStrings("1:\x1b[O\n", try ptyWritesByPane(p, &out)); + p.update(.{ .focus = false }); + try std.testing.expectEqualStrings("", try ptyWritesByPane(p, &out)); + p.update(.{ .focus = true }); + try std.testing.expectEqualStrings("1:\x1b[I\n", try ptyWritesByPane(p, &out)); + // A program that did not ask hears nothing: pane 2. + p.active = 2; + p.update(.tick); + try std.testing.expectEqualStrings("1:\x1b[O\n", try ptyWritesByPane(p, &out)); +} diff --git a/src/tty/tty.zig b/src/tty/tty.zig index 0b933065..67807cd5 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -1469,8 +1469,11 @@ const Shell = struct { core.quit = true; return true; }, - .focus_in => {}, - .focus_out => core.update(.pointer_leave), + .focus_in => core.update(.{ .focus = true }), + .focus_out => { + core.update(.pointer_leave); + core.update(.{ .focus = false }); + }, .winsize => |ws| { s.vx.resize(s.gpa, s.tty.writer(), ws) catch {}; if (comptime pardes.pdf_enabled) @@ -2674,8 +2677,11 @@ const Attach = struct { switch (event) { .nop, .wake, .fs_ready, .lsp_done, .lsp_status, .pipe_done, .pty_read, .pty_eof, .files_changed => {}, .quit => return .none, - .focus_in => {}, - .focus_out => return a.send(.pointer_leave), + .focus_in => return a.send(.{ .focus = true }), + .focus_out => { + if (a.send(.pointer_leave)) |end| return end; + return a.send(.{ .focus = false }); + }, .winsize => |ws| { a.vx.resize(a.gpa, a.tty.writer(), ws) catch {}; a.dirty = true; -- cgit v1.3