9c199243
feat: Enter revives a gone pane in place, and x still drops it
a73x 2026-09-01 10:18
Commit message
src/tui/wall_test_host.zig
| Old | New | ||
|---|---|---|---|
| @@ -965,3 +965,68 @@ test "setFocus: landing on a gone pane says what the two keys are" { | |||
| 965 | wv.setFocus(tiles[0..live], &shared, 1); | 965 | wv.setFocus(tiles[0..live], &shared, 1); |
| 966 | try std.testing.expectEqualStrings(wv.gone_notice, shared.notice[0..shared.notice_len]); | 966 | try std.testing.expectEqualStrings(wv.gone_notice, shared.notice[0..shared.notice_len]); |
| 967 | } | 967 | } |
| 968 | |||
| 969 | test "armRevive: Enter turns a gone pane into a creating attach in the same rect" { | ||
| 970 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); | ||
| 971 | defer arena.deinit(); | ||
| 972 | const alloc = arena.allocator(); | ||
| 973 | var shared: Shared = undefined; | ||
| 974 | fixture.stoppedWall(alloc, &shared); | ||
| 975 | var tiles: [2]Tile = undefined; | ||
| 976 | try wv.seedTile(&tiles[0], .{ .target = .{ .sock = "/tmp/box.sock" }, .label = "box#a", .session = "a" }, .{ .top = 1, .left = 41, .rows = 23, .cols = 39 }, &shared, 0, 0); | ||
| 977 | tiles[0].state = .gone; | ||
| 978 | |||
| 979 | wv.armRevive(&tiles[0]); | ||
| 980 | |||
| 981 | // The tile is no longer a placeholder: it CREATES (the daemon has no | ||
| 982 | // such session - that is what gone means), and it does so exactly | ||
| 983 | // where it stood. | ||
| 984 | try std.testing.expect(!tiles[0].pending); | ||
| 985 | try std.testing.expect(tiles[0].creates); | ||
| 986 | try std.testing.expectEqual(wv.State.connecting, tiles[0].state); | ||
| 987 | try std.testing.expect(tiles[0].alive.load(.acquire)); | ||
| 988 | try std.testing.expect(!tiles[0].pump_done.load(.acquire)); | ||
| 989 | try std.testing.expectEqual(@as(u16, 41), tiles[0].rect.left); | ||
| 990 | try std.testing.expectEqualStrings("a", tiles[0].r.session); | ||
| 991 | } | ||
| 992 | |||
| 993 | test "keysToFocused: a gone pane eats every byte except Enter" { | ||
| 994 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); | ||
| 995 | defer arena.deinit(); | ||
| 996 | const alloc = arena.allocator(); | ||
| 997 | var shared: Shared = undefined; | ||
| 998 | fixture.stoppedWall(alloc, &shared); | ||
| 999 | var tiles: [2]Tile = undefined; | ||
| 1000 | // The pump the Enter below spawns holds `*Tile` into this frame, and | ||
| 1001 | // `shared` is this frame too: the test may not return until that thread | ||
| 1002 | // has stored `alive = false`, or the next test's stack is what it reads. | ||
| 1003 | defer fixture.endPumps(tiles[0..1]); | ||
| 1004 | try wv.seedTile(&tiles[0], .{ .target = .{ .sock = "/tmp/box.sock" }, .label = "box#a", .session = "a" }, .{ .top = 1, .left = 0, .rows = 23, .cols = 40 }, &shared, 0, 0); | ||
| 1005 | tiles[0].state = .gone; | ||
| 1006 | |||
| 1007 | // Ordinary typing reaches no session and queues nowhere. | ||
| 1008 | wv.keysToFocused(&tiles[0], "ls -la"); | ||
| 1009 | try std.testing.expectEqual(@as(usize, 0), tiles[0].in_len); | ||
| 1010 | try std.testing.expect(tiles[0].pending); | ||
| 1011 | |||
| 1012 | // Enter revives. (`running` is false in this fixture, so the spawned | ||
| 1013 | // pump exits at its gate; the transitions are armRevive's, pinned above.) | ||
| 1014 | wv.keysToFocused(&tiles[0], "\r"); | ||
| 1015 | try std.testing.expect(!tiles[0].pending); | ||
| 1016 | tiles[0].pump_done.store(true, .release); | ||
| 1017 | } | ||
| 1018 | |||
| 1019 | test "endKey: x on a gone pane drops the tile locally" { | ||
| 1020 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); | ||
| 1021 | defer arena.deinit(); | ||
| 1022 | const alloc = arena.allocator(); | ||
| 1023 | var shared: Shared = undefined; | ||
| 1024 | fixture.stoppedWall(alloc, &shared); | ||
| 1025 | var tiles: [2]Tile = undefined; | ||
| 1026 | try wv.seedTile(&tiles[0], .{ .target = .{ .sock = "/tmp/box.sock" }, .label = "box#a", .session = "a" }, .{ .top = 1, .left = 0, .rows = 23, .cols = 40 }, &shared, 0, 0); | ||
| 1027 | tiles[0].state = .gone; | ||
| 1028 | // A PIN, green from the day it was written: `.drop` already covers | ||
| 1029 | // `!alive`, and a gone pane has no pump. The spec now depends on that | ||
| 1030 | // coincidence, so it is asserted rather than assumed. | ||
| 1031 | try std.testing.expectEqual(wv.EndKey.drop, wv.endKey(&tiles[0], 0)); | ||
| 1032 | } | ||
src/tui/wallview.zig
| Old | New | ||
|---|---|---|---|
| @@ -974,6 +974,45 @@ pub fn bindTile(w: Wall, i: usize) void { | |||
| 974 | spawnPump(t); | 974 | spawnPump(t); |
| 975 | } | 975 | } |
| 976 | 976 | ||
| 977 | /// The state half of a revive, split from the thread so a test can pin the | ||
| 978 | /// transitions without racing a pump. `reviveTile` is the only production | ||
| 979 | /// caller. | ||
| 980 | pub fn armRevive(t: *Tile) void { | ||
| 981 | t.shared.paint_mu.lock(); | ||
| 982 | t.pending = false; | ||
| 983 | t.creates = true; | ||
| 984 | t.state = .connecting; | ||
| 985 | t.shared.paint_mu.unlock(); | ||
| 986 | t.end_seen = false; | ||
| 987 | // The user is looking at this pane — Enter was pressed IN it — so the | ||
| 988 | // claim is armed the way bindTile arms the saved focus. | ||
| 989 | if (t.idx == t.shared.sel) t.claim_pending.store(true, .release); | ||
| 990 | t.pump_done.store(false, .release); | ||
| 991 | t.alive.store(true, .release); | ||
| 992 | } | ||
| 993 | |||
| 994 | /// A gone pane, revived on the user's Enter: the same tile re-arms as a | ||
| 995 | /// CREATING attach — same host, same session name, same rect — so the | ||
| 996 | /// daemon makes the session anew where the old one stood. No tree edit and | ||
| 997 | /// no flatten: reviving re-cuts nothing, which is the promise gone panes | ||
| 998 | /// exist to keep. | ||
| 999 | pub fn reviveTile(t: *Tile) void { | ||
| 1000 | armRevive(t); | ||
| 1001 | spawnPump(t); | ||
| 1002 | } | ||
| 1003 | |||
| 1004 | /// The one door for bytes aimed at the focused tile. A gone pane has no | ||
| 1005 | /// pump, so no byte could reach a session — eaten here rather than queued | ||
| 1006 | /// into `in` where they would greet the NEXT session this tile carries. | ||
| 1007 | /// Enter is the pane's one verb. | ||
| 1008 | pub fn keysToFocused(t: *Tile, keys: []const u8) void { | ||
| 1009 | if (t.pending and t.state == .gone) { | ||
| 1010 | if (std.mem.indexOfAny(u8, keys, "\r\n") != null) reviveTile(t); | ||
| 1011 | return; | ||
| 1012 | } | ||
| 1013 | sendKeys(t, keys); | ||
| 1014 | } | ||
| 1015 | |||
| 977 | /// Every road onto a running wall — chord, fold, prompt — one body. | 1016 | /// Every road onto a running wall — chord, fold, prompt — one body. |
| 978 | pub fn birthTile(w: Wall, b: Birth) ?usize { | 1017 | pub fn birthTile(w: Wall, b: Birth) ?usize { |
| 979 | return birthTileOrRefuse(w, b) catch null; | 1018 | return birthTileOrRefuse(w, b) catch null; |
| @@ -1928,7 +1967,7 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 1928 | // no tile at all, which is why it is answered ahead of both. | 1967 | // no tile at all, which is why it is answered ahead of both. |
| 1929 | if (input.prefix.picking or wall_picker.isPickAction(cmd.action)) { | 1968 | if (input.prefix.picking or wall_picker.isPickAction(cmd.action)) { |
| 1930 | // Typed AT the session, ahead of the chord, in the same read. | 1969 | // Typed AT the session, ahead of the chord, in the same read. |
| 1931 | if (cmd.forward.len > 0 and z < w.live.* and present[z]) sendKeys(&tiles[z], cmd.forward); | 1970 | if (cmd.forward.len > 0 and z < w.live.* and present[z]) keysToFocused(&tiles[z], cmd.forward); |
| 1932 | var birth_at: ?usize = null; | 1971 | var birth_at: ?usize = null; |
| 1933 | // Opening on a focused tile pre-selects that tile's host: the | 1972 | // Opening on a focused tile pre-selects that tile's host: the |
| 1934 | // machine the user is already on is the one they mean. Judged | 1973 | // machine the user is already on is the one they mean. Judged |
| @@ -2050,13 +2089,13 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 2050 | if (rectHit(tiles[0..live], present[0..live], &shared, ev.row, ev.col)) |hit| { | 2089 | if (rectHit(tiles[0..live], present[0..live], &shared, ev.row, ev.col)) |hit| { |
| 2051 | if (hit != shared.sel) { | 2090 | if (hit != shared.sel) { |
| 2052 | if (ev.at > seg_start) | 2091 | if (ev.at > seg_start) |
| 2053 | sendKeys(&tiles[shared.sel], cmd.forward[seg_start..ev.at]); | 2092 | keysToFocused(&tiles[shared.sel], cmd.forward[seg_start..ev.at]); |
| 2054 | seg_start = ev.at; | 2093 | seg_start = ev.at; |
| 2055 | setFocus(tiles[0..live], &shared, hit); | 2094 | setFocus(tiles[0..live], &shared, hit); |
| 2056 | } | 2095 | } |
| 2057 | } | 2096 | } |
| 2058 | } | 2097 | } |
| 2059 | sendKeys(&tiles[shared.sel], cmd.forward[seg_start..]); | 2098 | keysToFocused(&tiles[shared.sel], cmd.forward[seg_start..]); |
| 2060 | } | 2099 | } |
| 2061 | switch (cmd.action) { | 2100 | switch (cmd.action) { |
| 2062 | .none => {}, | 2101 | .none => {}, |