a73x

cf196f16

feat: fullscreen native panes

a73x   2026-09-07 16:16

Commit message
feat: fullscreen native panes

README.md
Old New
@@ -174,9 +174,14 @@ may leave the new creation request unanswered; upgrade the daemon to enable it.
174 174
175 Prefix followed by **h/j/k/l** or an arrow moves focus; clicking a pane also focuses 175 Prefix followed by **h/j/k/l** or an arrow moves focus; clicking a pane also focuses
176 it. Prefix then **Esc** cancels a pending split. Press the prefix twice to send its 176 it. Prefix then **Esc** cancels a pending split. Press the prefix twice to send its
177 normal terminal encoding through once. Window resizing updates every pane's PTY. 177 normal terminal encoding through once. Window resizing updates each visible pane's PTY.
178 An exited or unavailable pane leaves other panes usable. 178 An exited or unavailable pane leaves other panes usable.
179 179
180 Prefix then **f** toggles the focused pane fullscreen within the window. Press it
181 again to restore the splits. Focus movement keeps the newly focused pane fullscreen;
182 hidden sessions stay attached at their last size. Opening or detaching a pane, or
183 entering resize mode, returns to the split view. Fullscreen is not saved on exit.
184
180 Drag a divider to resize its panes, or press the prefix then **r** for resize mode. 185 Drag a divider to resize its panes, or press the prefix then **r** for resize mode.
181 **h/j/k/l** or arrows move the nearest divider on that axis in the indicated 186 **h/j/k/l** or arrows move the nearest divider on that axis in the indicated
182 direction by one terminal cell; **Enter** or **Esc** exits. Pane minimum sizes 187 direction by one terminal cell; **Enter** or **Esc** exits. Pane minimum sizes
src/gui/frame.zig
Old New
@@ -144,7 +144,7 @@ pub fn parseHook(line: []const u8) ?Hook {
144 if (std.mem.eql(u8, name, "copy")) return .{ .key = .{ .code = c.SDLK_C, .mods = c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT } }; 144 if (std.mem.eql(u8, name, "copy")) return .{ .key = .{ .code = c.SDLK_C, .mods = c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT } };
145 if (std.mem.eql(u8, name, "paste")) return .{ .key = .{ .code = c.SDLK_V, .mods = c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT } }; 145 if (std.mem.eql(u8, name, "paste")) return .{ .key = .{ .code = c.SDLK_V, .mods = c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT } };
146 if (std.mem.eql(u8, name, "interrupt")) return .{ .key = .{ .code = c.SDLK_C, .mods = c.SDL_KMOD_CTRL } }; 146 if (std.mem.eql(u8, name, "interrupt")) return .{ .key = .{ .code = c.SDLK_C, .mods = c.SDL_KMOD_CTRL } };
147 if (name.len == 1 and std.mem.indexOfScalar(u8, "hjklvbrdxp", name[0]) != null) return .{ .key = .{ .code = name[0] } }; 147 if (name.len == 1 and std.mem.indexOfScalar(u8, "hjklvbfrdxp", name[0]) != null) return .{ .key = .{ .code = name[0] } };
148 inline for (.{ .{ "enter", c.SDLK_RETURN }, .{ "tab", c.SDLK_TAB }, .{ "escape", c.SDLK_ESCAPE }, .{ "backspace", c.SDLK_BACKSPACE }, .{ "up", c.SDLK_UP }, .{ "down", c.SDLK_DOWN }, .{ "left", c.SDLK_LEFT }, .{ "right", c.SDLK_RIGHT } }) |pair| { 148 inline for (.{ .{ "enter", c.SDLK_RETURN }, .{ "tab", c.SDLK_TAB }, .{ "escape", c.SDLK_ESCAPE }, .{ "backspace", c.SDLK_BACKSPACE }, .{ "up", c.SDLK_UP }, .{ "down", c.SDLK_DOWN }, .{ "left", c.SDLK_LEFT }, .{ "right", c.SDLK_RIGHT } }) |pair| {
149 if (std.mem.eql(u8, name, pair[0])) return .{ .key = .{ .code = pair[1] } }; 149 if (std.mem.eql(u8, name, pair[0])) return .{ .key = .{ .code = pair[1] } };
150 } 150 }
@@ -536,6 +536,7 @@ fn interactionKey(ev: c.SDL_KeyboardEvent) interaction.KeyDown {
536 .kind = switch (ev.key) { 536 .kind = switch (ev.key) {
537 c.SDLK_V => .v, 537 c.SDLK_V => .v,
538 c.SDLK_B => .b, 538 c.SDLK_B => .b,
539 c.SDLK_F => .f,
539 c.SDLK_R => .r, 540 c.SDLK_R => .r,
540 c.SDLK_D => .d, 541 c.SDLK_D => .d,
541 c.SDLK_X => .x, 542 c.SDLK_X => .x,
@@ -1010,7 +1011,7 @@ const Header = struct {
1010 const pending = events.ui.rt.workspace.tab().pending; 1011 const pending = events.ui.rt.workspace.tab().pending;
1011 const ending = if (events.ui.pending_end) |end| end.key.pane == p.id and events.ui.rt.accepts(end.key) else false; 1012 const ending = if (events.ui.pending_end) |end| end.key.pane == p.id and events.ui.rt.accepts(end.key) else false;
1012 const quick = events.ui.opening != null and events.ui.opening.?.mode == .quick; 1013 const quick = events.ui.opening != null and events.ui.opening.?.mode == .quick;
1013 const hint = if (events.ui.save_notice_len != 0) events.ui.save_notice[0..events.ui.save_notice_len] else if (ending) " [End requested; waiting for daemon]" else if (focused and events.ui.resize_mode) (if (events.ui.notice.len != 0) events.ui.notice else " [resize: arrows/hjkl move divider, Enter/Esc finish]") else if (focused and events.ui.command_mode) " [command: v new below, b new beside, h/j/k/l focus, r resize, Enter picks session, Esc cancel]" else if (quick) events.ui.opening.?.noticeText() else if (pending != null and pending.?.pane == p.id) (if (events.ui.modalPicker() != null and events.ui.modalPicker().?.mode == .insert) (if (pending.?.direction == .beside) " [split beside]" else " [split below]") else (if (pending.?.direction == .beside) " [split beside: prefix Enter chooses session, Esc cancels]" else " [split below: prefix Enter chooses session, Esc cancels]")) else events.ui.notice; 1014 const hint = if (events.ui.save_notice_len != 0) events.ui.save_notice[0..events.ui.save_notice_len] else if (ending) " [End requested; waiting for daemon]" else if (focused and events.ui.resize_mode) (if (events.ui.notice.len != 0) events.ui.notice else " [resize: arrows/hjkl move divider, Enter/Esc finish]") else if (focused and events.ui.command_mode) " [command: f fullscreen, v new below, b new beside, h/j/k/l focus, r resize, Enter picks session, Esc cancel]" else if (quick) events.ui.opening.?.noticeText() else if (pending != null and pending.?.pane == p.id) (if (events.ui.modalPicker() != null and events.ui.modalPicker().?.mode == .insert) (if (pending.?.direction == .beside) " [split beside]" else " [split below]") else (if (pending.?.direction == .beside) " [split beside: prefix Enter chooses session, Esc cancels]" else " [split below: prefix Enter chooses session, Esc cancels]")) else if (events.ui.notice.len != 0) events.ui.notice else if (events.ui.rt.workspace.tab().fullscreen) " [fullscreen]" else "";
1014 const label = events.ui.rt.workspace.pane(p.id).?.identity.label; 1015 const label = events.ui.rt.workspace.pane(p.id).?.identity.label;
1015 var status_buf: [48]u8 = undefined; 1016 var status_buf: [48]u8 = undefined;
1016 const status: []const u8 = switch (live.status.phase) { 1017 const status: []const u8 = switch (live.status.phase) {
src/gui/interaction.zig
Old New
@@ -10,7 +10,7 @@ const runtime = @import("runtime.zig");
10 const picker_mod = @import("picker.zig"); 10 const picker_mod = @import("picker.zig");
11 const persistence = @import("persistence.zig"); 11 const persistence = @import("persistence.zig");
12 12
13 pub const Key = enum { v, b, r, d, x, p, escape, enter, keypad_enter, up, k, down, j, left, h, right, l, backspace, other }; 13 pub const Key = enum { v, b, f, r, d, x, p, escape, enter, keypad_enter, up, k, down, j, left, h, right, l, backspace, other };
14 /// code preserves platform key identity so aliases release independently. 14 /// code preserves platform key identity so aliases release independently.
15 /// kind describes GUI meaning; terminal carries the platform's input translation. 15 /// kind describes GUI meaning; terminal carries the platform's input translation.
16 pub const KeyDown = struct { 16 pub const KeyDown = struct {
@@ -212,7 +212,8 @@ pub const Controller = struct {
212 const ws = &self.rt.workspace; 212 const ws = &self.rt.workspace;
213 if (keyDirection(key)) |direction| { 213 if (keyDirection(key)) |direction| {
214 const before = ws.tab().focus; 214 const before = ws.tab().focus;
215 ws.moveFocus(&self.layout, direction); 215 ws.moveFocus(direction, @intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics);
216 if (before != ws.tab().focus) try self.relayout();
216 self.intent_dirty = self.intent_dirty or before != ws.tab().focus; 217 self.intent_dirty = self.intent_dirty or before != ws.tab().focus;
217 } else switch (key) { 218 } else switch (key) {
218 .v, .b, .enter, .keypad_enter => { 219 .v, .b, .enter, .keypad_enter => {
@@ -228,7 +229,15 @@ pub const Controller = struct {
228 }; 229 };
229 } else self.open(.insert) catch |err| self.setNotice(@errorName(err)); 230 } else self.open(.insert) catch |err| self.setNotice(@errorName(err));
230 }, 231 },
231 .r => self.resize_mode = true, 232 .f => {
233 ws.tab().fullscreen = !ws.tab().fullscreen and ws.tab().focus != null;
234 try self.relayout();
235 },
236 .r => {
237 ws.tab().fullscreen = false;
238 try self.relayout();
239 self.resize_mode = true;
240 },
232 .d => if (ws.tab().focus) |id| try self.detach(id), 241 .d => if (ws.tab().focus) |id| try self.detach(id),
233 .x => if (ws.tab().focus) |id| try self.beginEnd(self.rt.get(id).?.key, false), 242 .x => if (ws.tab().focus) |id| try self.beginEnd(self.rt.get(id).?.key, false),
234 .p => if (ws.tab().focus) |id| { 243 .p => if (ws.tab().focus) |id| {
@@ -887,6 +896,8 @@ test "controller preserves exact held keys across modal dismissal and text edges
887 ui.focusLost(); 896 ui.focusLost();
888 try std.testing.expect(!ui.resize_mode and !ui.command_mode and !ui.suppress_text); 897 try std.testing.expect(!ui.resize_mode and !ui.command_mode and !ui.suppress_text);
889 try std.testing.expect(ui.consumed_key == null and ui.modal_held.count() == 0); 898 try std.testing.expect(ui.consumed_key == null and ui.modal_held.count() == 0);
899 try ui.command(.f);
900 try std.testing.expect(!rt.workspace.tab().fullscreen);
890 } 901 }
891 902
892 test "controller modal input takes precedence over the prefix" { 903 test "controller modal input takes precedence over the prefix" {
src/gui/workspace.zig
Old New
@@ -197,9 +197,9 @@ const Tree = struct {
197 .empty => unreachable, 197 .empty => unreachable,
198 }; 198 };
199 } 199 }
200 fn layout(self: *const Tree, width: u32, height: u32, m: Metrics) Layout { 200 fn layout(self: *const Tree, at: ?u8, width: u32, height: u32, m: Metrics) Layout {
201 var out: Layout = .{ .viewport = .{ .w = width, .h = height } }; 201 var out: Layout = .{ .viewport = .{ .w = width, .h = height } };
202 if (self.root) |root| { 202 if (at) |root| {
203 const min = self.minimum(root, m); 203 const min = self.minimum(root, m);
204 out.fits = width >= min.w and height >= min.h; 204 out.fits = width >= min.w and height >= min.h;
205 self.flatten(root, .{ .w = @max(width, min.w), .h = @max(height, min.h) }, .{ .w = width, .h = height }, m, &out); 205 self.flatten(root, .{ .w = @max(width, min.w), .h = @max(height, min.h) }, .{ .w = width, .h = height }, m, &out);
@@ -252,7 +252,15 @@ const Tree = struct {
252 } 252 }
253 }; 253 };
254 pub const Pending = struct { pane: PaneId, direction: Direction }; 254 pub const Pending = struct { pane: PaneId, direction: Direction };
255 pub const Tab = struct { id: TabId = 1, tree: Tree = .{}, focus: ?PaneId = null, pending: ?Pending = null, panes: [max_panes]?*Pane = @splat(null) }; 255 pub const Tab = struct {
256 id: TabId = 1,
257 tree: Tree = .{},
258 focus: ?PaneId = null,
259 // Transient view of the focused leaf; the saved split tree is unchanged.
260 fullscreen: bool = false,
261 pending: ?Pending = null,
262 panes: [max_panes]?*Pane = @splat(null),
263 };
256 pub const Prepared = struct { 264 pub const Prepared = struct {
257 pane: *Pane, 265 pane: *Pane,
258 tree: Tree, 266 tree: Tree,
@@ -286,7 +294,9 @@ pub const Workspace = struct {
286 return null; 294 return null;
287 } 295 }
288 pub fn layout(self: *Workspace, width: u32, height: u32, m: Metrics) Layout { 296 pub fn layout(self: *Workspace, width: u32, height: u32, m: Metrics) Layout {
289 return self.tab().tree.layout(width, height, m); 297 const t = self.tab();
298 const root = if (t.fullscreen and t.focus != null) t.tree.leaf(t.focus.?) else t.tree.root;
299 return t.tree.layout(root, width, height, m);
290 } 300 }
291 /// Both interaction paths use a fresh layout and the same minimum clamp. 301 /// Both interaction paths use a fresh layout and the same minimum clamp.
292 /// A no-op must not replace an intended ratio with its temporary clamp. 302 /// A no-op must not replace an intended ratio with its temporary clamp.
@@ -369,7 +379,7 @@ pub const Workspace = struct {
369 if (t.tree.root) |r| { 379 if (t.tree.root) |r| {
370 const min = t.tree.minimum(r, m); 380 const min = t.tree.minimum(r, m);
371 if (min.w > width or min.h > height) return error.TooSmall; 381 if (min.w > width or min.h > height) return error.TooSmall;
372 const flat = self.layout(width, height, m); 382 const flat = t.tree.layout(t.tree.root, width, height, m);
373 const old = flat.get(origin orelse return error.MissingPane) orelse return error.MissingPane; 383 const old = flat.get(origin orelse return error.MissingPane) orelse return error.MissingPane;
374 const leaf_min = m.minimum(); 384 const leaf_min = m.minimum();
375 if ((direction == .beside and old.outer.w < leaf_min.w * 2 + m.divider) or (direction == .stacked and old.outer.h < leaf_min.h * 2 + m.divider)) return error.TooSmall; 385 if ((direction == .beside and old.outer.w < leaf_min.w * 2 + m.divider) or (direction == .stacked and old.outer.h < leaf_min.h * 2 + m.divider)) return error.TooSmall;
@@ -377,7 +387,7 @@ pub const Workspace = struct {
377 var tree = t.tree; 387 var tree = t.tree;
378 const id = self.next_pane_id; 388 const id = self.next_pane_id;
379 try tree.insert(origin, id, direction); 389 try tree.insert(origin, id, direction);
380 const flat = tree.layout(width, height, m); 390 const flat = tree.layout(tree.root, width, height, m);
381 return .{ .tree = tree, .placement = flat.get(id).? }; 391 return .{ .tree = tree, .placement = flat.get(id).? };
382 } 392 }
383 /// Call immediately after preparing the attachment; no other model edits 393 /// Call immediately after preparing the attachment; no other model edits
@@ -390,6 +400,7 @@ pub const Workspace = struct {
390 }; 400 };
391 t.tree = prepared.tree; 401 t.tree = prepared.tree;
392 t.focus = prepared.pane.id; 402 t.focus = prepared.pane.id;
403 t.fullscreen = false;
393 t.pending = null; 404 t.pending = null;
394 } 405 }
395 /// The caller has already joined this pane's pump and discarded its wake 406 /// The caller has already joined this pane's pump and discarded its wake
@@ -409,6 +420,7 @@ pub const Workspace = struct {
409 t.pending = null; 420 t.pending = null;
410 }; 421 };
411 if (t.focus == id) { 422 if (t.focus == id) {
423 t.fullscreen = false;
412 t.focus = null; 424 t.focus = null;
413 for (t.panes) |p| if (p) |v| { 425 for (t.panes) |p| if (p) |v| {
414 t.focus = v.id; 426 t.focus = v.id;
@@ -416,7 +428,9 @@ pub const Workspace = struct {
416 }; 428 };
417 } 429 }
418 } 430 }
419 pub fn moveFocus(self: *Workspace, flat: *const Layout, direction: Neighbor) void { 431 pub fn moveFocus(self: *Workspace, direction: Neighbor, width: u32, height: u32, m: Metrics) void {
432 const t = self.tab();
433 const flat = t.tree.layout(t.tree.root, width, height, m);
420 const from = flat.get(self.tab().focus orelse return) orelse return; 434 const from = flat.get(self.tab().focus orelse return) orelse return;
421 var best: ?PaneId = null; 435 var best: ?PaneId = null;
422 var score: u64 = std.math.maxInt(u64); 436 var score: u64 = std.math.maxInt(u64);
@@ -483,9 +497,9 @@ test "workspace nested insertion focus cancellation removal and tiny clipping pr
483 flat = w.layout(800, 600, .{ .cell_w = 10, .cell_h = 20 }); 497 flat = w.layout(800, 600, .{ .cell_w = 10, .cell_h = 20 });
484 try std.testing.expectEqual(left, flat.get(a).?.outer); 498 try std.testing.expectEqual(left, flat.get(a).?.outer);
485 try std.testing.expect(flat.get(c).?.outer.y > flat.get(b).?.outer.y); 499 try std.testing.expect(flat.get(c).?.outer.y > flat.get(b).?.outer.y);
486 w.moveFocus(&flat, .up); 500 w.moveFocus(.up, 800, 600, .{ .cell_w = 10, .cell_h = 20 });
487 try std.testing.expectEqual(b, w.tab().focus.?); 501 try std.testing.expectEqual(b, w.tab().focus.?);
488 w.moveFocus(&flat, .left); 502 w.moveFocus(.left, 800, 600, .{ .cell_w = 10, .cell_h = 20 });
489 try std.testing.expectEqual(a, w.tab().focus.?); 503 try std.testing.expectEqual(a, w.tab().focus.?);
490 const tiny = w.layout(5, 5, .{ .cell_w = 10, .cell_h = 20 }); 504 const tiny = w.layout(5, 5, .{ .cell_w = 10, .cell_h = 20 });
491 try std.testing.expectEqual(@as(usize, 3), tiny.len); 505 try std.testing.expectEqual(@as(usize, 3), tiny.len);
@@ -508,7 +522,9 @@ test "workspace refuses a nested leaf split even if global minima would fit" {
508 _ = w.focus(a); 522 _ = w.focus(a);
509 _ = try add(&w, 100, 100); 523 _ = try add(&w, 100, 100);
510 // Four leaves need83px globally, but the armed25px leaf needs41px. 524 // Four leaves need83px globally, but the armed25px leaf needs41px.
525 w.tab().fullscreen = true;
511 try std.testing.expectError(error.TooSmall, w.prepare(.{ .via = "cat" }, "x", 100, 100, .{ .cell_w = 10, .cell_h = 20 })); 526 try std.testing.expectError(error.TooSmall, w.prepare(.{ .via = "cat" }, "x", 100, 100, .{ .cell_w = 10, .cell_h = 20 }));
527 try std.testing.expect(w.tab().fullscreen);
512 } 528 }
513 fn allocationCase(alloc: std.mem.Allocator) !void { 529 fn allocationCase(alloc: std.mem.Allocator) !void {
514 var w = Workspace.init(alloc); 530 var w = Workspace.init(alloc);
@@ -578,6 +594,22 @@ test "weighted nested dividers share keyboard clamps and restore intended propor
578 try std.testing.expectEqual(@as(u32, 130), flat.get(b).?.outer.h); 594 try std.testing.expectEqual(@as(u32, 130), flat.get(b).?.outer.h);
579 595
580 const restored = flat; 596 const restored = flat;
597 w.tab().fullscreen = true;
598 const full = w.layout(1601, 1201, m);
599 try std.testing.expectEqual(@as(usize, 1), full.len);
600 try std.testing.expectEqual(c, full.items()[0].id);
601 try std.testing.expectEqual(full.viewport, full.get(c).?.outer);
602 try std.testing.expectEqual(@as(usize, 0), full.divider_len);
603 try std.testing.expect(full.get(a) == null and full.hit(0, 0) == c);
604 try std.testing.expect(!w.resizeFocused(.left, 1601, 1201, m));
605 const full_tiny = w.layout(10, 10, m);
606 try std.testing.expect(!full_tiny.fits and full_tiny.get(c).?.cols >= 2 and full_tiny.get(c).?.rows >= 2);
607 w.moveFocus(.up, 801, 601, m);
608 try std.testing.expect(w.tab().fullscreen and w.layout(801, 601, m).items()[0].id == b);
609 _ = w.focus(c);
610 w.tab().fullscreen = false;
611 try std.testing.expectEqualDeep(restored.items(), w.layout(801, 601, m).items());
612 try std.testing.expectEqualDeep(restored.boundaries(), w.layout(801, 601, m).boundaries());
581 const tiny = w.layout(10, 10, m); 613 const tiny = w.layout(10, 10, m);
582 try std.testing.expect(!tiny.fits); 614 try std.testing.expect(!tiny.fits);
583 try std.testing.expect(!w.resizeFocused(.right, 10, 10, m)); 615 try std.testing.expect(!w.resizeFocused(.right, 10, 10, m));
test/native_journey.py
Old New
@@ -124,6 +124,8 @@ def flood_while_responsive(rig, refs):
124 first_progress = progress.read_text() 124 first_progress = progress.read_text()
125 125
126 rig.focus(right) 126 rig.focus(right)
127 rig.chord('f')
128 rig.wait_state(lambda s: len(s['panes']) == 1 and s['focus'] == right)
127 started = time.monotonic() 129 started = time.monotonic()
128 rig.shell("printf '\\033[38;2;0;0;255mJOURNEY-%s\\033[0m\\n' RESPONSIVE") 130 rig.shell("printf '\\033[38;2;0;0;255mJOURNEY-%s\\033[0m\\n' RESPONSIVE")
129 rig.wait_marker(*refs[right], 'JOURNEY-RESPONSIVE') 131 rig.wait_marker(*refs[right], 'JOURNEY-RESPONSIVE')
@@ -140,7 +142,73 @@ def flood_while_responsive(rig, refs):
140 require(second_count > first_count, 'no frame completed while another pane flooded') 142 require(second_count > first_count, 'no frame completed while another pane flooded')
141 require(latency < DEADLINE and p99 < 20000, 143 require(latency < DEADLINE and p99 < 20000,
142 f'pane isolation exceeded budget: {latency}s / {p99}us') 144 f'pane isolation exceeded budget: {latency}s / {p99}us')
143 rig.ok(f'local flood leaves QUIC pane responsive ({latency * 1000:.0f} ms, {p99} us frame p99)') 145 rig.chord('f')
146 rig.wait_state(lambda s: len(s['panes']) == 3)
147 rig.ok(f'hidden local flood leaves fullscreen QUIC pane responsive ({latency * 1000:.0f} ms, {p99} us frame p99)')
148
149
150 def fullscreen_pane(rig, refs):
151 left, right, lower = refs
152 rig.focus(lower)
153 before = rig.state()
154 sizes = {p['id']: (p['rows'], p['cols']) for p in before['panes']}
155 original_geometry = [(p['id'], p['generation'], p['outer']) for p in before['panes']]
156 original_dividers = before['dividers']
157 for key, pane_id in (('f', lower), ('k', right), ('h', left)):
158 rig.chord(key)
159 state = rig.wait_state(lambda s: len(s['panes']) == 1 and s['focus'] == pane_id)
160 pane = state['panes'][0]
161 require(not state['dividers'] and pane['outer'] ==
162 dict(x=0, y=0, w=state['width'], h=state['height']),
163 'fullscreen did not give the focused pane the whole window')
164 require(pane['generation'] == by_id(before)[pane_id]['generation'],
165 'fullscreen reattached a pane')
166 rig.kernel_sizes()
167 sizes[pane_id] = (pane['rows'], pane['cols'])
168 for hidden_id, (sock, name) in refs.items():
169 status = rig.status(sock, name)
170 require((status['rows'], status['cols']) == sizes[hidden_id],
171 'fullscreen resized a hidden pane or lost the visible PTY size')
172 if pane_id == lower:
173 rig.shell("printf '\\033[0m\\033[2J\\033[H\\033[38;2;0;0;255mFULLSCREEN-%s\\033[0m\\n' INPUT")
174 rig.wait_marker(*refs[lower], 'FULLSCREEN-INPUT')
175 eventually(lambda: colour_counts(rig.last_pixels(), pane['content'], 'blue')[0] > 30 and
176 sum(colour_counts(rig.last_pixels(), pane['content'], 'green')) == 0,
177 'fullscreen left hidden-pane pixels or failed to paint its input')
178 require(all('FULLSCREEN-INPUT' not in rig.dump(*refs[p]) for p in (left, right)),
179 'fullscreen input leaked into a hidden pane')
180 new_size = (800, 500)
181 rig.send(f'resize:{new_size[0]}x{new_size[1]}')
182 rig.wait_state(lambda s: (s['width'], s['height']) == new_size)
183 rig.kernel_sizes()
184 rig.chord('f')
185 restored_new = rig.wait_state(lambda s: len(s['panes']) == 3 and
186 (s['width'], s['height']) == new_size)
187 rig.kernel_sizes()
188 require([(p['id'], p['generation']) for p in restored_new['panes']] ==
189 [(p['id'], p['generation']) for p in before['panes']] and
190 [(d['id'], d['direction']) for d in restored_new['dividers']] ==
191 [(d['id'], d['direction']) for d in original_dividers],
192 'restoring fullscreen at the new size changed pane or divider identities')
193 for old, current in zip(original_dividers, restored_new['dividers']):
194 axis = 'x' if old['direction'] == 'beside' else 'y'
195 extent = 'w' if old['direction'] == 'beside' else 'h'
196 old_ratio = (old['rect'][axis] - old['parent'][axis]) / old['parent'][extent]
197 new_ratio = (current['rect'][axis] - current['parent'][axis]) / current['parent'][extent]
198 require(abs(old_ratio - new_ratio) < 0.02,
199 'restoring fullscreen at the new size changed split proportions')
200 rig.send(f"resize:{before['logical_width']}x{before['logical_height']}")
201 restored = rig.wait_state(lambda s: len(s['panes']) == 3 and
202 (s['width'], s['height']) == (before['width'], before['height']))
203 require([(p['id'], p['generation'], p['outer']) for p in restored['panes']] ==
204 original_geometry and restored['dividers'] == original_dividers,
205 'fullscreen changed the saved split geometry after exact original restoration')
206 pty_sizes(rig, refs, [(p['id'], p['generation']) for p in before['panes']])
207 rig.chord('f')
208 rig.chord('r')
209 rig.wait_state(lambda s: s['resize_mode'] and len(s['panes']) == 3)
210 rig.key('escape')
211 rig.ok('fullscreen follows focus, preserves hidden PTYs, and restores weighted splits after window resizing')
144 212
145 213
146 def quick_panes(rig, refs): 214 def quick_panes(rig, refs):
@@ -154,6 +222,7 @@ def quick_panes(rig, refs):
154 names = sessions(sock) 222 names = sessions(sock)
155 other_sock = refs[left if origin == right else right][0] 223 other_sock = refs[left if origin == right else right][0]
156 other_names = sessions(other_sock) 224 other_names = sessions(other_sock)
225 rig.chord('f')
157 rig.split(direction) 226 rig.split(direction)
158 state = rig.wait_state(lambda s: len(s['panes']) == 4 and 227 state = rig.wait_state(lambda s: len(s['panes']) == 4 and
159 all(p['phase'] == 'attached' for p in s['panes'])) 228 all(p['phase'] == 'attached' for p in s['panes']))
@@ -172,6 +241,7 @@ def quick_panes(rig, refs):
172 require(pane['outer'][axis] >= source['outer'][axis] + source['outer'][extent], 241 require(pane['outer'][axis] >= source['outer'][axis] + source['outer'][extent],
173 'quick split inserted on the wrong side') 242 'quick split inserted on the wrong side')
174 rig.mark(pane['id'], sock, name, 'QUICK-' + str(pane['id'])) 243 rig.mark(pane['id'], sock, name, 'QUICK-' + str(pane['id']))
244 rig.chord('f')
175 rig.chord('d') 245 rig.chord('d')
176 state = rig.wait_state(lambda s: len(s['panes']) == 3) 246 state = rig.wait_state(lambda s: len(s['panes']) == 3)
177 require(stable_layout(state) == stable_layout(before), 'detaching quick pane changed existing layout') 247 require(stable_layout(state) == stable_layout(before), 'detaching quick pane changed existing layout')
@@ -180,13 +250,30 @@ def quick_panes(rig, refs):
180 # A split that cannot fit must fail before creating a remote shell. 250 # A split that cannot fit must fail before creating a remote shell.
181 rig.focus(right) 251 rig.focus(right)
182 names = sessions(refs[right][0]) 252 names = sessions(refs[right][0])
253 before_fullscreen = rig.state()
254 original = [(p['id'], p['generation'], p['label']) for p in before_fullscreen['panes']]
255 original_sessions = {pane_id: sessions(sock) for pane_id, (sock, _) in refs.items()}
256 rig.chord('f')
257 fullscreen = rig.wait_state(lambda s: len(s['panes']) == 1 and s['focus'] == right)
258 require([(p['id'], p['generation'], p['label']) for p in fullscreen['panes']] ==
259 [next(item for item in original if item[0] == right)],
260 'fullscreen did not retain the focused identity before undersized insertion')
183 rig.send('resize:8x8') 261 rig.send('resize:8x8')
184 rig.wait_state(lambda s: (s['width'], s['height']) == (8, 8)) 262 rig.wait_state(lambda s: (s['width'], s['height']) == (8, 8))
185 rig.split('stacked') 263 rig.split('stacked')
186 state = rig.wait_state(lambda s: bool(s['notice'])) 264 state = rig.wait_state(lambda s: bool(s['notice']))
187 require(state['picker'] is None and len(state['panes']) == 3 and state['pending'] is None, 265 require(state['picker'] is None and len(state['panes']) == 1 and state['focus'] == right and
188 'undersized quick split changed the workspace') 266 [(p['id'], p['generation'], p['label']) for p in state['panes']] ==
189 require(sessions(refs[right][0]) == names, 'undersized quick split created a session') 267 [next(item for item in original if item[0] == right)] and state['pending'] is None,
268 'fullscreen undersized quick split changed the visible workspace')
269 require(sessions(refs[right][0]) == names and
270 {pane_id: sessions(sock) for pane_id, (sock, _) in refs.items()} == original_sessions,
271 'fullscreen undersized quick split created or changed a session')
272 rig.chord('f')
273 restored = rig.wait_state(lambda s: len(s['panes']) == 3)
274 require(stable_layout(restored) == stable_layout(before_fullscreen) and
275 [(p['id'], p['generation'], p['label']) for p in restored['panes']] == original,
276 'fullscreen undersized quick split changed the underlying workspace')
190 rig.send('resize:960x600') 277 rig.send('resize:960x600')
191 rig.wait_state(lambda s: (s['width'], s['height']) == (960, 600)) 278 rig.wait_state(lambda s: (s['width'], s['height']) == (960, 600))
192 rig.ok('quick splits inherit local/QUIC targets, auto-name fresh sessions, and reject undersized panes') 279 rig.ok('quick splits inherit local/QUIC targets, auto-name fresh sessions, and reject undersized panes')
@@ -203,6 +290,8 @@ def raw_snapshot_and_terminal_peer(rig, refs):
203 eventually(lambda: colour_counts(rig.pixels(), by_id(state)[left]['content'], 'red')[0] > 30, 290 eventually(lambda: colour_counts(rig.pixels(), by_id(state)[left]['content'], 'red')[0] > 30,
204 'DEL-adjacent marker was not rendered before close') 291 'DEL-adjacent marker was not rendered before close')
205 before = stable_layout(state) 292 before = stable_layout(state)
293 rig.chord('f')
294 rig.wait_state(lambda s: len(s['panes']) == 1)
206 rig.quit() 295 rig.quit()
207 for target_sock, session in refs.values(): 296 for target_sock, session in refs.values():
208 require(session in sessions(target_sock), 'closing the journey ended a daemon session') 297 require(session in sessions(target_sock), 'closing the journey ended a daemon session')
@@ -224,6 +313,7 @@ def raw_snapshot_and_terminal_peer(rig, refs):
224 313
225 def independent_failures(rig, refs): 314 def independent_failures(rig, refs):
226 left, right, lower = list(refs) 315 left, right, lower = list(refs)
316 original_sessions = {pane_id: sessions(sock) for pane_id, (sock, _) in refs.items()}
227 remote_sock, remote_proc = next((sock, proc) for sock, proc in rig.daemons 317 remote_sock, remote_proc = next((sock, proc) for sock, proc in rig.daemons
228 if sock == refs[right][0]) 318 if sock == refs[right][0])
229 rig.stop_daemon(remote_sock, remote_proc) 319 rig.stop_daemon(remote_sock, remote_proc)
@@ -234,17 +324,35 @@ def independent_failures(rig, refs):
234 with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as silent: 324 with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as silent:
235 silent.bind(('127.0.0.1', port)) 325 silent.bind(('127.0.0.1', port))
236 rig.focus(right) 326 rig.focus(right)
327 before_fullscreen = rig.state()
328 original = [(p['id'], p['generation'], p['label']) for p in before_fullscreen['panes']]
329 rig.chord('f')
330 fullscreen = rig.wait_state(lambda s: len(s['panes']) == 1 and s['focus'] == right)
331 require([(p['id'], p['generation'], p['label']) for p in fullscreen['panes']] ==
332 [next(item for item in original if item[0] == right)],
333 'fullscreen did not retain the unavailable pane before opening')
237 rig.split('stacked') 334 rig.split('stacked')
238 pending = rig.wait_state(lambda s: s['opening']) 335 pending = rig.wait_state(lambda s: s['opening'])
239 require(pending['picker'] is None and 'Opening on' in pending['notice'], 336 require(len(pending['panes']) == 1 and pending['focus'] == right and
337 pending['picker'] is None and 'Opening on' in pending['notice'],
240 'pending remote opening hid the terminal behind a picker') 338 'pending remote opening hid the terminal behind a picker')
241 rig.mark(left, *refs[left], 'JOURNEY-SURVIVOR')
242 require(rig.state()['opening'], 'remote opening ended before the cancellation check') 339 require(rig.state()['opening'], 'remote opening ended before the cancellation check')
243 started = time.monotonic() 340 started = time.monotonic()
244 rig.key('escape') 341 rig.key('escape')
245 cancelled = rig.wait_state(lambda s: not s['opening']) 342 cancelled = rig.wait_state(lambda s: not s['opening'])
246 require(time.monotonic() - started < 1 and len(cancelled['panes']) == 3 and 343 require(time.monotonic() - started < 1 and len(cancelled['panes']) == 1 and
247 cancelled['pending'] is None, 'cancelling the remote opening blocked or changed panes') 344 cancelled['focus'] == right and
345 [(p['id'], p['generation'], p['label']) for p in cancelled['panes']] ==
346 [next(item for item in original if item[0] == right)] and
347 cancelled['pending'] is None and
348 {pane_id: sessions(sock) for pane_id, (sock, _) in refs.items() if pane_id != right} ==
349 {pane_id: original_sessions[pane_id] for pane_id in refs if pane_id != right},
350 'cancelling fullscreen remote opening blocked or changed workspace/session state')
351 rig.chord('f')
352 restored = rig.wait_state(lambda s: len(s['panes']) == 3)
353 require(stable_layout(restored) == stable_layout(before_fullscreen) and
354 [(p['id'], p['generation'], p['label']) for p in restored['panes']] == original,
355 'cancelling fullscreen remote opening changed the underlying workspace')
248 356
249 rig.focus(lower) 357 rig.focus(lower)
250 rig.shell('exit 7') 358 rig.shell('exit 7')
@@ -284,6 +392,7 @@ def main():
284 modal_leakage(rig, identities) 392 modal_leakage(rig, identities)
285 clamps(rig, identities, refs) 393 clamps(rig, identities, refs)
286 pixels_and_batch(rig, identities, refs) 394 pixels_and_batch(rig, identities, refs)
395 fullscreen_pane(rig, refs)
287 flood_while_responsive(rig, refs) 396 flood_while_responsive(rig, refs)
288 397
289 family = font_family() 398 family = font_family()