cf196f16
feat: fullscreen native panes
a73x 2026-09-07 16:16
Commit message
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() |