summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-30 14:20:10 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:17 -0300
commit17c0d06618085de8d0bbf8152750ffa862db0080 (patch)
tree71a2d33dcf14e4e95387f3aa556e9952135de894
parentb546b578baadbd8fdd15b42988ad4d66c9c8ff21 (diff)
downloadpardes-17c0d06618085de8d0bbf8152750ffa862db0080.tar.gz
pardes-17c0d06618085de8d0bbf8152750ffa862db0080.zip
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 <[email protected]>
-rw-r--r--src/detached/wire.zig11
-rw-r--r--src/gui/gui.zig5
-rw-r--r--src/pardes.zig27
-rw-r--r--src/terminal.zig59
-rw-r--r--src/tty/tty.zig14
5 files changed, 108 insertions, 8 deletions
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:<bytes>\n1:<bytes>\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;