89cc9067
refactor: the host picker's popup is wall_picker.zig
a73x 2026-08-28 20:49
Commit message
docscheck.budget
| Old | New | ||
|---|---|---|---|
| @@ -49,3 +49,4 @@ server_test_upgrade.zig 0 | |||
| 49 | server_agent.zig 0 | 49 | server_agent.zig 0 |
| 50 | server_sessions.zig 0 | 50 | server_sessions.zig 0 |
| 51 | wall_host.zig 0 | 51 | wall_host.zig 0 |
| 52 | wall_picker.zig 0 | ||
src/tui/wall_picker.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,474 @@ | |||
| 1 | //! `Ctrl-\ s`: the host picker's popup. A MODE of the wall's prefix | ||
| 2 | //! filter, so every byte typed here is the popup's and none reaches a | ||
| 3 | //! session. Rows are the hosts file's daemons in file order with the | ||
| 4 | //! poller's last answer beside each; Enter births, `x` forgets, `a` is the | ||
| 5 | //! spelling editor. Paints under `paint_mu` while `Shared.picker_open` | ||
| 6 | //! holds every tile off the terminal. | ||
| 7 | const std = @import("std"); | ||
| 8 | const proto = @import("protocol"); | ||
| 9 | const client = @import("client"); | ||
| 10 | const hosts = @import("hosts"); | ||
| 11 | const interact = @import("interact"); | ||
| 12 | const wall_host = @import("wall_host.zig"); | ||
| 13 | const wv = @import("wallview.zig"); | ||
| 14 | const Host = wall_host.Host; | ||
| 15 | const Shared = wv.Shared; | ||
| 16 | const Tile = wv.Tile; | ||
| 17 | |||
| 18 | /// The widest row the picker draws. A spelling past it is cut, never | ||
| 19 | /// wrapped: a host list that reflows is one a digit cannot address. | ||
| 20 | pub const picker_row_max: usize = 128; | ||
| 21 | |||
| 22 | /// Under either, the popup is one row: a box that does not fit is worse | ||
| 23 | /// than a header saying which one it is. | ||
| 24 | const picker_min_cols: u16 = 24; | ||
| 25 | const picker_min_rows: u16 = 4; | ||
| 26 | |||
| 27 | /// One whole popup, written in one go: every row plus its cursor address | ||
| 28 | /// and its two SGRs, at the table's cap of rows. | ||
| 29 | const picker_frame_max: usize = (wv.max_tiles + 2) * (picker_row_max + 32) + 8; | ||
| 30 | |||
| 31 | /// The picker's rendered rows. Rendered once per paint and read twice — | ||
| 32 | /// by the writer and by the tests — so the box on the screen is the box | ||
| 33 | /// the claims are made about. | ||
| 34 | pub const PickerBody = struct { | ||
| 35 | text: [wv.max_tiles][picker_row_max]u8 = undefined, | ||
| 36 | lens: [wv.max_tiles]usize = [_]usize{0} ** wv.max_tiles, | ||
| 37 | /// Which host each row is, so a digit answers in host indices. | ||
| 38 | host: [wv.max_tiles]usize = [_]usize{0} ** wv.max_tiles, | ||
| 39 | n: usize = 0, | ||
| 40 | |||
| 41 | pub fn row(self: *const PickerBody, i: usize) []const u8 { | ||
| 42 | return self.text[i][0..self.lens[i]]; | ||
| 43 | } | ||
| 44 | }; | ||
| 45 | |||
| 46 | /// What the state column says: the POLLER's last answer, never the wall's | ||
| 47 | /// tiles. A host with no tiles is exactly the one the user is about to | ||
| 48 | /// birth on, and it has to say whether the daemon is answering at all. | ||
| 49 | pub fn hostState(buf: []u8, h: *Host) []const u8 { | ||
| 50 | // `applied` is set by the first list to reach the wall, well or badly, | ||
| 51 | // so a host that has never reported is `connecting` — not `no | ||
| 52 | // sessions`, which would send the user to birth on a dead machine. | ||
| 53 | if (!h.applied) return "connecting"; | ||
| 54 | if (!h.reachable.load(.acquire)) return "unreachable"; | ||
| 55 | var n: usize = 0; | ||
| 56 | h.list_mu.lock(); | ||
| 57 | var it = std.mem.splitScalar(u8, h.list[0..h.list_len], '\n'); | ||
| 58 | while (it.next()) |name| { | ||
| 59 | // The same filter `planHostDiff` births through, so the count a row | ||
| 60 | // advertises is the number of tiles that host can actually put on | ||
| 61 | // the wall. | ||
| 62 | if (proto.validSessionName(name)) n += 1; | ||
| 63 | } | ||
| 64 | h.list_mu.unlock(); | ||
| 65 | if (n == 0) return "no sessions"; | ||
| 66 | if (n == 1) return "1 session"; | ||
| 67 | return std.fmt.bufPrint(buf, "{d} sessions", .{n}) catch "sessions"; | ||
| 68 | } | ||
| 69 | |||
| 70 | /// One row: ` N> SPELLING STATE `, padded to `cols` so the box has an | ||
| 71 | /// edge. The spelling is cut from the LEFT, because the head of a socket | ||
| 72 | /// path is what every daemon on one machine has in common. | ||
| 73 | pub fn pickerRow(out: []u8, n: usize, wide: bool, spelling: []const u8, state: []const u8, selected: bool, cols: u16) []const u8 { | ||
| 74 | // The same two-wide marker a label bar wears, so the eye reads the | ||
| 75 | // popup and the wall the same way and rows do not shift as it moves. | ||
| 76 | // `wide` is the number's half of that: an unpadded 10 puts its spelling | ||
| 77 | // a column right of row 9's. | ||
| 78 | const marker: []const u8 = if (selected) "> " else " "; | ||
| 79 | var head_buf: [16]u8 = undefined; | ||
| 80 | const head = (if (wide) | ||
| 81 | std.fmt.bufPrint(&head_buf, " {d: >2}{s}", .{ n, marker }) | ||
| 82 | else | ||
| 83 | std.fmt.bufPrint(&head_buf, " {d}{s}", .{ n, marker })) catch return out[0..0]; | ||
| 84 | const room = @min(@as(usize, cols), out.len); | ||
| 85 | const field = room -| head.len -| state.len -| 2; | ||
| 86 | const cut = if (spelling.len > field) spelling[spelling.len - field ..] else spelling; | ||
| 87 | var w: usize = 0; | ||
| 88 | for ([_][]const u8{ head, cut }) |part| { | ||
| 89 | const take = @min(part.len, room - w); | ||
| 90 | @memcpy(out[w..][0..take], part[0..take]); | ||
| 91 | w += take; | ||
| 92 | } | ||
| 93 | while (w < room -| state.len -| 1 and w < room) : (w += 1) out[w] = ' '; | ||
| 94 | for ([_][]const u8{ state, " " }) |part| { | ||
| 95 | const take = @min(part.len, room - w); | ||
| 96 | @memcpy(out[w..][0..take], part[0..take]); | ||
| 97 | w += take; | ||
| 98 | } | ||
| 99 | return out[0..w]; | ||
| 100 | } | ||
| 101 | |||
| 102 | /// Whether the popup on screen is one the WALL opened rather than the user, | ||
| 103 | /// and what the next pass through the keyboard loop owes it. | ||
| 104 | pub const PickerAuto = struct { | ||
| 105 | /// The popup currently on screen is the WALL's, not the user's. | ||
| 106 | on: bool = false, | ||
| 107 | /// This spell of emptiness has already had its one auto-open. Distinct | ||
| 108 | /// from `on`, and that is the whole of it: the Esc that closes the | ||
| 109 | /// wall's popup leaves the wall STILL EMPTY, and a single flag would | ||
| 110 | /// reopen it over the one-line text the Esc asked for. | ||
| 111 | spent: bool = false, | ||
| 112 | |||
| 113 | pub const Step = enum { open, close, leave }; | ||
| 114 | |||
| 115 | /// What an empty (or newly un-empty) wall owes the popup. | ||
| 116 | pub fn step(self: *PickerAuto, is_tty: bool, empty: bool, picking: bool, prompting: bool) Step { | ||
| 117 | if (empty) { | ||
| 118 | if (is_tty and !picking and !self.spent) { | ||
| 119 | self.spent = true; | ||
| 120 | self.on = true; | ||
| 121 | return .open; | ||
| 122 | } | ||
| 123 | // A popup already up on an empty wall has had this emptiness's | ||
| 124 | // one open, whoever opened it. Left unspent, the Esc that closes | ||
| 125 | // a picker the USER opened is answered by the wall opening it | ||
| 126 | // straight back, and the one-line text costs two Escs. | ||
| 127 | if (is_tty and picking) self.spent = true; | ||
| 128 | return .leave; | ||
| 129 | } | ||
| 130 | // The wall has tiles again, so the next emptiness earns its own open. | ||
| 131 | self.spent = false; | ||
| 132 | // A tile arriving takes the screen back from a popup nobody asked | ||
| 133 | // for — but never out from under a spelling in progress, because | ||
| 134 | // `prompting` layers over `picking` and clearing the lower one | ||
| 135 | // alone leaves an editor eating every key with nothing on screen. | ||
| 136 | if (self.on and !prompting) { | ||
| 137 | self.on = false; | ||
| 138 | return .close; | ||
| 139 | } | ||
| 140 | return .leave; | ||
| 141 | } | ||
| 142 | |||
| 143 | /// The user closed it by hand. | ||
| 144 | pub fn taken(self: *PickerAuto) void { | ||
| 145 | // The next tile to arrive must not force-close a popup they then | ||
| 146 | // open themselves. `spent` is untouched: an Esc on an empty wall | ||
| 147 | // asked for the one-line text, not for the popup again. | ||
| 148 | self.on = false; | ||
| 149 | } | ||
| 150 | }; | ||
| 151 | |||
| 152 | /// Whether this action is the picker's. The popup answers every one of | ||
| 153 | /// them, so the arms below it in `run` name them only to stay exhaustive. | ||
| 154 | pub fn isPickAction(a: interact.PrefixFilter.Action) bool { | ||
| 155 | return switch (a) { | ||
| 156 | .pick_open, | ||
| 157 | .pick_move, | ||
| 158 | .pick_select, | ||
| 159 | .pick_birth, | ||
| 160 | .pick_forget, | ||
| 161 | .pick_add_open, | ||
| 162 | .pick_close, | ||
| 163 | => true, | ||
| 164 | else => false, | ||
| 165 | }; | ||
| 166 | } | ||
| 167 | |||
| 168 | /// Enter or `c`: a new session on the SELECTED host, tile or no tile. | ||
| 169 | /// Null when nothing was made. | ||
| 170 | pub fn pickBirth( | ||
| 171 | alloc: std.mem.Allocator, | ||
| 172 | tiles: []Tile, | ||
| 173 | present: []bool, | ||
| 174 | live: *usize, | ||
| 175 | shared: *Shared, | ||
| 176 | host_table: []Host, | ||
| 177 | sel: usize, | ||
| 178 | ) ?usize { | ||
| 179 | // The tile creates on attach exactly as a chord-born one does: no side | ||
| 180 | // connection, and no second road onto the wall to keep in step. | ||
| 181 | // A forgotten host is off the rows and out of the file, and its poller | ||
| 182 | // has exited: born on one, a tile no list can ever confirm or vanish | ||
| 183 | // would sit on the wall naming a machine the user has just removed. | ||
| 184 | // `pickerNearest` cannot save this — with every row gone it returns the | ||
| 185 | // selection unchanged. | ||
| 186 | if (sel >= host_table.len or host_table[sel].forgotten.load(.acquire)) { | ||
| 187 | // The one key the footer advertises, on a wall with nothing to | ||
| 188 | // birth on: an Enter that closes the popup and does nothing reads | ||
| 189 | // as a broken key rather than as an empty hosts file. | ||
| 190 | wv.setNotice(shared, "[no hosts to start a session on - a adds one]"); | ||
| 191 | return null; | ||
| 192 | } | ||
| 193 | const h = &host_table[sel]; | ||
| 194 | // Enter IS the ask, and this copy is where that is written down: the | ||
| 195 | // row it lands on is often the one the poller calls unreachable, and | ||
| 196 | // starting that machine's daemon is what choosing it means. The SPEC | ||
| 197 | // is untouched — the poller re-dials off it every second, and a wall | ||
| 198 | // must not resurrect a daemon whose owner just stopped it. | ||
| 199 | var target = h.spec.target; | ||
| 200 | if (target == .hand) { | ||
| 201 | target.hand.asked = true; | ||
| 202 | // ...and quietly. This dial happens on a tile thread, under the | ||
| 203 | // wall's alternate screen: `muxd start`'s progress would land over | ||
| 204 | // tiles and rails, where the tile's own `connecting` label is | ||
| 205 | // already saying the only thing there is to say. | ||
| 206 | target.hand.quiet = true; | ||
| 207 | } | ||
| 208 | var list_buf: [proto.sessions_text_max]u8 = undefined; | ||
| 209 | h.list_mu.lock(); | ||
| 210 | @memcpy(list_buf[0..h.list_len], h.list[0..h.list_len]); | ||
| 211 | const list_len = h.list_len; | ||
| 212 | h.list_mu.unlock(); | ||
| 213 | // The daemon's own naming, off the daemon's own list: the name the | ||
| 214 | // `c` chord would have landed on, reached without a pump to ask. | ||
| 215 | var name_buf: [proto.session_name_max]u8 = undefined; | ||
| 216 | const name = client.nextFreeName(&name_buf, list_buf[0..list_len]); | ||
| 217 | const session = alloc.dupe(u8, name) catch return null; | ||
| 218 | const label = wv.tileLabel(alloc, target, session) catch { | ||
| 219 | alloc.free(session); | ||
| 220 | return null; | ||
| 221 | }; | ||
| 222 | const anchor = wv.anchorTile(present[0..live.*], shared.sel); | ||
| 223 | const has_anchor = wv.presentCount(present[0..live.*]) > 0; | ||
| 224 | // `-A` is inherited only within one host. A chord inherits it because | ||
| 225 | // the new session is on the machine the offer was already made to; the | ||
| 226 | // picker can cross to a host the user never offered an agent, and a | ||
| 227 | // popup must not be the thing that hands a stranger the keys. | ||
| 228 | const agent = has_anchor and tiles[anchor].host != null and | ||
| 229 | tiles[anchor].host.? == sel and tiles[anchor].r.agent; | ||
| 230 | const at = wv.birthTile(alloc, tiles, present, live, shared, .{ | ||
| 231 | .r = .{ .target = target, .label = label, .session = session, .agent = agent }, | ||
| 232 | .from = anchor, | ||
| 233 | .place = .beside_focus, | ||
| 234 | .creates = true, | ||
| 235 | // Nowhere to hand a refusal back to on an empty wall, so `keeps_wall` | ||
| 236 | // is what tells `endAction` the wall outlives this tile. | ||
| 237 | .born_from = if (has_anchor) anchor else null, | ||
| 238 | .keeps_wall = true, | ||
| 239 | .host = sel, | ||
| 240 | }) orelse { | ||
| 241 | alloc.free(session); | ||
| 242 | alloc.free(label); | ||
| 243 | wv.setNotice(shared, "[no room on the wall for another session]"); | ||
| 244 | return null; | ||
| 245 | }; | ||
| 246 | wv.spawnPump(&tiles[at]); | ||
| 247 | // The new session must not wait out a poll to be confirmed by the list | ||
| 248 | // that will also stop the diff from vanishing it. | ||
| 249 | h.poke.store(true, .release); | ||
| 250 | return at; | ||
| 251 | } | ||
| 252 | |||
| 253 | /// `x`: the host leaves the file, its poller stops and its tiles go. | ||
| 254 | pub fn pickForget( | ||
| 255 | alloc: std.mem.Allocator, | ||
| 256 | tiles: []Tile, | ||
| 257 | present: []bool, | ||
| 258 | live: usize, | ||
| 259 | shared: *Shared, | ||
| 260 | host_table: []Host, | ||
| 261 | sel: usize, | ||
| 262 | path: ?[]const u8, | ||
| 263 | ) void { | ||
| 264 | // The SESSIONS keep running: forgetting a daemon is `mux hosts rm` | ||
| 265 | // typed from inside, and that ends nothing. | ||
| 266 | if (sel >= host_table.len) return; | ||
| 267 | const h = &host_table[sel]; | ||
| 268 | if (h.forgotten.load(.acquire)) return; | ||
| 269 | // The file first, for `addHost`'s reason: the wall the user is looking | ||
| 270 | // at and the wall they get back next time are the same wall. | ||
| 271 | // | ||
| 272 | // What the file said is what the notice says. A `false` is a line that | ||
| 273 | // was not there to remove — a hand-edited file, or a host this wall took | ||
| 274 | // off argv — and the tiles go either way, so a flat `[forgot ...]` would | ||
| 275 | // leave the wall and the file disagreeing with nobody told. | ||
| 276 | var gone_from_file = true; | ||
| 277 | var why: ?anyerror = null; | ||
| 278 | if (path) |p| { | ||
| 279 | gone_from_file = hosts.forget(alloc, p, h.spec.spelling) catch |e| blk: { | ||
| 280 | why = e; | ||
| 281 | break :blk false; | ||
| 282 | }; | ||
| 283 | } | ||
| 284 | h.forgotten.store(true, .release); | ||
| 285 | for (0..live) |i| { | ||
| 286 | if (present[i] and wall_host.ownedBy(&tiles[i], sel)) | ||
| 287 | wv.vanishTile(tiles[0..live], present[0..live], shared, i, null); | ||
| 288 | } | ||
| 289 | var buf: [96]u8 = undefined; | ||
| 290 | const said = if (why) |e| | ||
| 291 | std.fmt.bufPrint(&buf, "[hosts file not updated: {s}]", .{hosts.reason(e)}) catch "[hosts file not updated]" | ||
| 292 | else if (!gone_from_file) | ||
| 293 | std.fmt.bufPrint(&buf, "[{s} was not on the wall]", .{h.spec.spelling}) catch "[that host was not on the wall]" | ||
| 294 | else | ||
| 295 | std.fmt.bufPrint(&buf, "[forgot {s}]", .{h.spec.spelling}) catch "[forgot the host]"; | ||
| 296 | wv.setNotice(shared, said); | ||
| 297 | } | ||
| 298 | |||
| 299 | /// The popup. Painted by the KEYBOARD thread, which is the only one that | ||
| 300 | /// knows the picker exists: on open, on every key, and on every list that | ||
| 301 | /// lands under it, so the state column is live while the user reads it. | ||
| 302 | /// | ||
| 303 | /// `line` is the spelling editor's text when `a` has it open. | ||
| 304 | pub fn paintPicker(shared: *Shared, host_table: []Host, sel: usize, line: ?[]const u8) void { | ||
| 305 | if (!shared.is_tty) return; | ||
| 306 | // The flag is set before the lock and READ under it (`tilePaintBegin`), | ||
| 307 | // which is what orders the two: a pump either takes `paint_mu` first and | ||
| 308 | // finishes its rect before this paint starts, or takes it after and sees | ||
| 309 | // the flag already set. | ||
| 310 | shared.picker_open.store(true, .release); | ||
| 311 | shared.paint_mu.lock(); | ||
| 312 | defer shared.paint_mu.unlock(); | ||
| 313 | const cols = shared.size.cols; | ||
| 314 | const rows = shared.size.rows; | ||
| 315 | const w: u16 = @min(cols, @as(u16, picker_row_max)); | ||
| 316 | const left: u16 = (cols -| w) / 2; | ||
| 317 | var body: PickerBody = .{}; | ||
| 318 | pickerRows(&body, host_table, sel, w); | ||
| 319 | // Too small for a box is not too small for an answer: the header alone | ||
| 320 | // still says which popup has the keyboard. | ||
| 321 | const cramped = cols < picker_min_cols or rows < picker_min_rows; | ||
| 322 | const want: u16 = @intCast(@min(@as(usize, rows), body.n + 2)); | ||
| 323 | const height: u16 = if (cramped) 1 else want; | ||
| 324 | const top: u16 = (rows -| height) / 2; | ||
| 325 | // The window onto the rows when the terminal cannot hold them all: the | ||
| 326 | // selection stays visible, because it is what every other key acts on. | ||
| 327 | const shown: usize = height -| 2; | ||
| 328 | var first: usize = 0; | ||
| 329 | var sel_row: usize = 0; | ||
| 330 | for (body.host[0..body.n], 0..) |hi, i| { | ||
| 331 | if (hi == sel) sel_row = i; | ||
| 332 | } | ||
| 333 | if (shown > 0 and sel_row >= shown) first = sel_row - shown + 1; | ||
| 334 | // The notice wins the footer over the legend and over the editor's own | ||
| 335 | // line: a refusal the user just earned is the one sentence that cannot | ||
| 336 | // wait for the next keystroke. | ||
| 337 | // PEEKED: the notice is part of the frame, and the stamp below can | ||
| 338 | // still refuse to write it. Taken only once the bytes are out. | ||
| 339 | var notice_buf: [96]u8 = undefined; | ||
| 340 | const notice = peekNoticeLocked(shared, ¬ice_buf); | ||
| 341 | const foot: []const u8 = if (notice.len > 0) | ||
| 342 | notice | ||
| 343 | else if (line) |l| | ||
| 344 | l | ||
| 345 | else | ||
| 346 | " Enter/c new session x forget a add Esc"; | ||
| 347 | |||
| 348 | var out: [picker_frame_max]u8 = undefined; | ||
| 349 | var fbs = std.io.fixedBufferStream(&out); | ||
| 350 | const wr = fbs.writer(); | ||
| 351 | // The cursor is hidden for as long as the popup owns the screen: a | ||
| 352 | // caret left blinking in a tile says the keys are going there. | ||
| 353 | wr.writeAll("\x1b[?25l") catch return; | ||
| 354 | pickerLine(wr, top, left, w, " hosts"); | ||
| 355 | if (!cramped) { | ||
| 356 | var r: u16 = 0; | ||
| 357 | while (r < shown and first + r < body.n) : (r += 1) | ||
| 358 | pickerLine(wr, top + 1 + r, left, w, body.row(first + r)); | ||
| 359 | pickerLine(wr, top + height - 1, left, w, foot); | ||
| 360 | } | ||
| 361 | const frame = fbs.getWritten(); | ||
| 362 | // Nothing changed, nothing written. The pollers report once a second | ||
| 363 | // per host and every one of them repaints this box; rewriting an | ||
| 364 | // identical screen at that rate is a terminal that never goes quiet. | ||
| 365 | const stamp = std.hash.Wyhash.hash(0, frame); | ||
| 366 | if (stamp == shared.picker_stamp) return; | ||
| 367 | shared.picker_stamp = stamp; | ||
| 368 | shared.notice_len = 0; | ||
| 369 | proto.writeAllFd(shared.out_fd, frame) catch {}; | ||
| 370 | } | ||
| 371 | |||
| 372 | /// What the popup owes the screen on a pass with no keystroke: whether to | ||
| 373 | /// paint at all, and the footer to paint with. | ||
| 374 | const PickerRepaint = struct { due: bool, line: ?[]const u8 }; | ||
| 375 | |||
| 376 | /// `prompting` is not a term: `relayout` clears the whole screen on any | ||
| 377 | /// poll's birth or vanish while the box is up, and a paint skipped for the | ||
| 378 | /// spelling editor leaves the user typing into an editor the screen no | ||
| 379 | /// longer shows — every key still reaching it. | ||
| 380 | pub fn pickerRepaint(buf: []u8, prefix: *const interact.PrefixFilter, trigger: bool) PickerRepaint { | ||
| 381 | if (!prefix.picking) return .{ .due = false, .line = null }; | ||
| 382 | const line: ?[]const u8 = if (prefix.prompting) | ||
| 383 | (std.fmt.bufPrint(buf, ": {s}_", .{prefix.promptLine()}) catch "") | ||
| 384 | else | ||
| 385 | null; | ||
| 386 | return .{ .due = trigger, .line = line }; | ||
| 387 | } | ||
| 388 | |||
| 389 | /// One row of the box: reverse video, padded to the box width. The same | ||
| 390 | /// chrome a label bar and a rail wear, so the popup never reads as a | ||
| 391 | /// session's own output. | ||
| 392 | fn pickerLine(wr: anytype, row: u16, left: u16, w: u16, text: []const u8) void { | ||
| 393 | wr.print("\x1b[{d};{d}H\x1b[7m", .{ row + 1, left + 1 }) catch return; | ||
| 394 | const take = @min(text.len, @as(usize, w)); | ||
| 395 | wr.writeAll(text[0..take]) catch return; | ||
| 396 | var i: usize = take; | ||
| 397 | while (i < w) : (i += 1) wr.writeByte(' ') catch break; | ||
| 398 | wr.writeAll("\x1b[0m") catch return; | ||
| 399 | } | ||
| 400 | |||
| 401 | pub fn peekNoticeLocked(shared: *const Shared, out: []u8) []const u8 { | ||
| 402 | const n = @min(shared.notice_len, out.len); | ||
| 403 | @memcpy(out[0..n], shared.notice[0..n]); | ||
| 404 | return out[0..n]; | ||
| 405 | } | ||
| 406 | |||
| 407 | /// The hosts still on the wall, in file order — the picker's rows, as | ||
| 408 | /// indices into the table a forgotten host never leaves. | ||
| 409 | fn pickerVisible(host_table: []Host, out: *[wv.max_tiles]usize) []const usize { | ||
| 410 | var n: usize = 0; | ||
| 411 | for (host_table, 0..) |*h, hi| { | ||
| 412 | if (h.forgotten.load(.acquire)) continue; | ||
| 413 | out[n] = hi; | ||
| 414 | n += 1; | ||
| 415 | } | ||
| 416 | return out[0..n]; | ||
| 417 | } | ||
| 418 | |||
| 419 | /// The host `d` rows away. CLAMPED, not wrapped: a held key that rolled | ||
| 420 | /// the list round would birth on whatever it stopped on. | ||
| 421 | pub fn pickerStep(host_table: []Host, sel: usize, d: i8) usize { | ||
| 422 | var buf: [wv.max_tiles]usize = undefined; | ||
| 423 | const vis = pickerVisible(host_table, &buf); | ||
| 424 | if (vis.len == 0) return sel; | ||
| 425 | var row: usize = 0; | ||
| 426 | for (vis, 0..) |hi, i| { | ||
| 427 | if (hi == sel) row = i; | ||
| 428 | } | ||
| 429 | const moved = @as(isize, @intCast(row)) + d; | ||
| 430 | const clamped: usize = if (moved < 0) 0 else @min(@as(usize, @intCast(moved)), vis.len - 1); | ||
| 431 | return vis[clamped]; | ||
| 432 | } | ||
| 433 | |||
| 434 | /// The host a picker row number names, or null when the list is shorter. | ||
| 435 | pub fn pickerAt(host_table: []Host, row: usize) ?usize { | ||
| 436 | var buf: [wv.max_tiles]usize = undefined; | ||
| 437 | const vis = pickerVisible(host_table, &buf); | ||
| 438 | return if (row < vis.len) vis[row] else null; | ||
| 439 | } | ||
| 440 | |||
| 441 | /// Where the selection rests when the picker opens: the wanted host if it | ||
| 442 | /// is still on the wall, else the first row — never a forgotten slot. | ||
| 443 | pub fn pickerNearest(host_table: []Host, want: usize) usize { | ||
| 444 | var buf: [wv.max_tiles]usize = undefined; | ||
| 445 | const vis = pickerVisible(host_table, &buf); | ||
| 446 | for (vis) |hi| { | ||
| 447 | if (hi == want) return want; | ||
| 448 | } | ||
| 449 | return if (vis.len > 0) vis[0] else want; | ||
| 450 | } | ||
| 451 | |||
| 452 | /// The picker's body: one row per host still on the wall, in file order. | ||
| 453 | /// `sel` is a HOST index, not a row — a forgotten host keeps its slot | ||
| 454 | /// (a poller holds the pointer and `Tile.host` indexes it) and only | ||
| 455 | /// leaves the list. | ||
| 456 | pub fn pickerRows(body: *PickerBody, host_table: []Host, sel: usize, cols: u16) void { | ||
| 457 | body.n = 0; | ||
| 458 | // Counted before anything is spelled: the padding is the TABLE's, and a | ||
| 459 | // row cannot know from its own number whether a 10 is coming. | ||
| 460 | var listed: usize = 0; | ||
| 461 | for (host_table) |*h| { | ||
| 462 | if (!h.forgotten.load(.acquire)) listed += 1; | ||
| 463 | } | ||
| 464 | const wide = listed >= 10; | ||
| 465 | for (host_table, 0..) |*h, hi| { | ||
| 466 | if (h.forgotten.load(.acquire)) continue; | ||
| 467 | var state_buf: [24]u8 = undefined; | ||
| 468 | const state = hostState(&state_buf, h); | ||
| 469 | const at = body.n; | ||
| 470 | body.host[at] = hi; | ||
| 471 | body.lens[at] = pickerRow(&body.text[at], at + 1, wide, h.spec.spelling, state, hi == sel, cols).len; | ||
| 472 | body.n += 1; | ||
| 473 | } | ||
| 474 | } | ||
src/tui/wallview.zig
| Old | New | ||
|---|---|---|---|
| @@ -37,9 +37,12 @@ const interact = @import("interact"); | |||
| 37 | const layout = @import("layout"); | 37 | const layout = @import("layout"); |
| 38 | const TmpDir = @import("testtmp").TmpDir; | 38 | const TmpDir = @import("testtmp").TmpDir; |
| 39 | const wall_host = @import("wall_host.zig"); | 39 | const wall_host = @import("wall_host.zig"); |
| 40 | const wall_picker = @import("wall_picker.zig"); | ||
| 40 | const AddHost = wall_host.AddHost; | 41 | const AddHost = wall_host.AddHost; |
| 41 | const BirthNames = wall_host.BirthNames; | 42 | const BirthNames = wall_host.BirthNames; |
| 42 | const Host = wall_host.Host; | 43 | const Host = wall_host.Host; |
| 44 | const PickerAuto = wall_picker.PickerAuto; | ||
| 45 | const PickerBody = wall_picker.PickerBody; | ||
| 43 | const Resolved = wall_host.Resolved; | 46 | const Resolved = wall_host.Resolved; |
| 44 | const TileIdxs = wall_host.TileIdxs; | 47 | const TileIdxs = wall_host.TileIdxs; |
| 45 | 48 | ||
| @@ -2534,7 +2537,7 @@ fn closeNotice(tiles: []Tile, present: []const bool, shared: *Shared) void { | |||
| 2534 | /// address — so it gets a label that is honest on a bar and is not a | 2537 | /// address — so it gets a label that is honest on a bar and is not a |
| 2535 | /// spelling: `recordHost` refuses it for the same reason. Naming it anyway | 2538 | /// spelling: `recordHost` refuses it for the same reason. Naming it anyway |
| 2536 | /// is better than a blank bar over a session that is really there. | 2539 | /// is better than a blank bar over a session that is really there. |
| 2537 | fn tileLabel(alloc: std.mem.Allocator, target: client.Target, name: []const u8) ![]const u8 { | 2540 | pub fn tileLabel(alloc: std.mem.Allocator, target: client.Target, name: []const u8) ![]const u8 { |
| 2538 | // Sized for the form it will actually take. `spellingCap` bounds the | 2541 | // Sized for the form it will actually take. `spellingCap` bounds the |
| 2539 | // wall grammar's three forms and deliberately counts a `--via` | 2542 | // wall grammar's three forms and deliberately counts a `--via` |
| 2540 | // command's operand as ZERO, because that grammar has no place to put | 2543 | // command's operand as ZERO, because that grammar has no place to put |
| @@ -2824,360 +2827,6 @@ pub fn selfSession(target: client.Target, env_sock: ?[]const u8, env_session: ?[ | |||
| 2824 | return proto.resolveName(es); | 2827 | return proto.resolveName(es); |
| 2825 | } | 2828 | } |
| 2826 | 2829 | ||
| 2827 | /// The widest row the picker draws. A spelling past it is cut, never | ||
| 2828 | /// wrapped: a host list that reflows is one a digit cannot address. | ||
| 2829 | const picker_row_max: usize = 128; | ||
| 2830 | |||
| 2831 | /// Under either, the popup is one row: a box that does not fit is worse | ||
| 2832 | /// than a header saying which one it is. | ||
| 2833 | const picker_min_cols: u16 = 24; | ||
| 2834 | const picker_min_rows: u16 = 4; | ||
| 2835 | |||
| 2836 | /// One whole popup, written in one go: every row plus its cursor address | ||
| 2837 | /// and its two SGRs, at the table's cap of rows. | ||
| 2838 | const picker_frame_max: usize = (max_tiles + 2) * (picker_row_max + 32) + 8; | ||
| 2839 | |||
| 2840 | /// The picker's rendered rows. Rendered once per paint and read twice — | ||
| 2841 | /// by the writer and by the tests — so the box on the screen is the box | ||
| 2842 | /// the claims are made about. | ||
| 2843 | const PickerBody = struct { | ||
| 2844 | text: [max_tiles][picker_row_max]u8 = undefined, | ||
| 2845 | lens: [max_tiles]usize = [_]usize{0} ** max_tiles, | ||
| 2846 | /// Which host each row is, so a digit answers in host indices. | ||
| 2847 | host: [max_tiles]usize = [_]usize{0} ** max_tiles, | ||
| 2848 | n: usize = 0, | ||
| 2849 | |||
| 2850 | fn row(self: *const PickerBody, i: usize) []const u8 { | ||
| 2851 | return self.text[i][0..self.lens[i]]; | ||
| 2852 | } | ||
| 2853 | }; | ||
| 2854 | |||
| 2855 | /// What the state column says: the POLLER's last answer, never the wall's | ||
| 2856 | /// tiles. A host with no tiles is exactly the one the user is about to | ||
| 2857 | /// birth on, and it has to say whether the daemon is answering at all. | ||
| 2858 | fn hostState(buf: []u8, h: *Host) []const u8 { | ||
| 2859 | // `applied` is set by the first list to reach the wall, well or badly, | ||
| 2860 | // so a host that has never reported is `connecting` — not `no | ||
| 2861 | // sessions`, which would send the user to birth on a dead machine. | ||
| 2862 | if (!h.applied) return "connecting"; | ||
| 2863 | if (!h.reachable.load(.acquire)) return "unreachable"; | ||
| 2864 | var n: usize = 0; | ||
| 2865 | h.list_mu.lock(); | ||
| 2866 | var it = std.mem.splitScalar(u8, h.list[0..h.list_len], '\n'); | ||
| 2867 | while (it.next()) |name| { | ||
| 2868 | // The same filter `planHostDiff` births through, so the count a row | ||
| 2869 | // advertises is the number of tiles that host can actually put on | ||
| 2870 | // the wall. | ||
| 2871 | if (proto.validSessionName(name)) n += 1; | ||
| 2872 | } | ||
| 2873 | h.list_mu.unlock(); | ||
| 2874 | if (n == 0) return "no sessions"; | ||
| 2875 | if (n == 1) return "1 session"; | ||
| 2876 | return std.fmt.bufPrint(buf, "{d} sessions", .{n}) catch "sessions"; | ||
| 2877 | } | ||
| 2878 | |||
| 2879 | /// One row: ` N> SPELLING STATE `, padded to `cols` so the box has an | ||
| 2880 | /// edge. The spelling is cut from the LEFT, because the head of a socket | ||
| 2881 | /// path is what every daemon on one machine has in common. | ||
| 2882 | fn pickerRow(out: []u8, n: usize, wide: bool, spelling: []const u8, state: []const u8, selected: bool, cols: u16) []const u8 { | ||
| 2883 | // The same two-wide marker a label bar wears, so the eye reads the | ||
| 2884 | // popup and the wall the same way and rows do not shift as it moves. | ||
| 2885 | // `wide` is the number's half of that: an unpadded 10 puts its spelling | ||
| 2886 | // a column right of row 9's. | ||
| 2887 | const marker: []const u8 = if (selected) "> " else " "; | ||
| 2888 | var head_buf: [16]u8 = undefined; | ||
| 2889 | const head = (if (wide) | ||
| 2890 | std.fmt.bufPrint(&head_buf, " {d: >2}{s}", .{ n, marker }) | ||
| 2891 | else | ||
| 2892 | std.fmt.bufPrint(&head_buf, " {d}{s}", .{ n, marker })) catch return out[0..0]; | ||
| 2893 | const room = @min(@as(usize, cols), out.len); | ||
| 2894 | const field = room -| head.len -| state.len -| 2; | ||
| 2895 | const cut = if (spelling.len > field) spelling[spelling.len - field ..] else spelling; | ||
| 2896 | var w: usize = 0; | ||
| 2897 | for ([_][]const u8{ head, cut }) |part| { | ||
| 2898 | const take = @min(part.len, room - w); | ||
| 2899 | @memcpy(out[w..][0..take], part[0..take]); | ||
| 2900 | w += take; | ||
| 2901 | } | ||
| 2902 | while (w < room -| state.len -| 1 and w < room) : (w += 1) out[w] = ' '; | ||
| 2903 | for ([_][]const u8{ state, " " }) |part| { | ||
| 2904 | const take = @min(part.len, room - w); | ||
| 2905 | @memcpy(out[w..][0..take], part[0..take]); | ||
| 2906 | w += take; | ||
| 2907 | } | ||
| 2908 | return out[0..w]; | ||
| 2909 | } | ||
| 2910 | |||
| 2911 | /// Whether the popup on screen is one the WALL opened rather than the user, | ||
| 2912 | /// and what the next pass through the keyboard loop owes it. | ||
| 2913 | const PickerAuto = struct { | ||
| 2914 | /// The popup currently on screen is the WALL's, not the user's. | ||
| 2915 | on: bool = false, | ||
| 2916 | /// This spell of emptiness has already had its one auto-open. Distinct | ||
| 2917 | /// from `on`, and that is the whole of it: the Esc that closes the | ||
| 2918 | /// wall's popup leaves the wall STILL EMPTY, and a single flag would | ||
| 2919 | /// reopen it over the one-line text the Esc asked for. | ||
| 2920 | spent: bool = false, | ||
| 2921 | |||
| 2922 | const Step = enum { open, close, leave }; | ||
| 2923 | |||
| 2924 | /// What an empty (or newly un-empty) wall owes the popup. | ||
| 2925 | fn step(self: *PickerAuto, is_tty: bool, empty: bool, picking: bool, prompting: bool) Step { | ||
| 2926 | if (empty) { | ||
| 2927 | if (is_tty and !picking and !self.spent) { | ||
| 2928 | self.spent = true; | ||
| 2929 | self.on = true; | ||
| 2930 | return .open; | ||
| 2931 | } | ||
| 2932 | // A popup already up on an empty wall has had this emptiness's | ||
| 2933 | // one open, whoever opened it. Left unspent, the Esc that closes | ||
| 2934 | // a picker the USER opened is answered by the wall opening it | ||
| 2935 | // straight back, and the one-line text costs two Escs. | ||
| 2936 | if (is_tty and picking) self.spent = true; | ||
| 2937 | return .leave; | ||
| 2938 | } | ||
| 2939 | // The wall has tiles again, so the next emptiness earns its own open. | ||
| 2940 | self.spent = false; | ||
| 2941 | // A tile arriving takes the screen back from a popup nobody asked | ||
| 2942 | // for — but never out from under a spelling in progress, because | ||
| 2943 | // `prompting` layers over `picking` and clearing the lower one | ||
| 2944 | // alone leaves an editor eating every key with nothing on screen. | ||
| 2945 | if (self.on and !prompting) { | ||
| 2946 | self.on = false; | ||
| 2947 | return .close; | ||
| 2948 | } | ||
| 2949 | return .leave; | ||
| 2950 | } | ||
| 2951 | |||
| 2952 | /// The user closed it by hand. | ||
| 2953 | fn taken(self: *PickerAuto) void { | ||
| 2954 | // The next tile to arrive must not force-close a popup they then | ||
| 2955 | // open themselves. `spent` is untouched: an Esc on an empty wall | ||
| 2956 | // asked for the one-line text, not for the popup again. | ||
| 2957 | self.on = false; | ||
| 2958 | } | ||
| 2959 | }; | ||
| 2960 | |||
| 2961 | /// Whether this action is the picker's. The popup answers every one of | ||
| 2962 | /// them, so the arms below it in `run` name them only to stay exhaustive. | ||
| 2963 | fn isPickAction(a: interact.PrefixFilter.Action) bool { | ||
| 2964 | return switch (a) { | ||
| 2965 | .pick_open, | ||
| 2966 | .pick_move, | ||
| 2967 | .pick_select, | ||
| 2968 | .pick_birth, | ||
| 2969 | .pick_forget, | ||
| 2970 | .pick_add_open, | ||
| 2971 | .pick_close, | ||
| 2972 | => true, | ||
| 2973 | else => false, | ||
| 2974 | }; | ||
| 2975 | } | ||
| 2976 | |||
| 2977 | /// Enter or `c`: a new session on the SELECTED host, tile or no tile. | ||
| 2978 | /// Null when nothing was made. | ||
| 2979 | fn pickBirth( | ||
| 2980 | alloc: std.mem.Allocator, | ||
| 2981 | tiles: []Tile, | ||
| 2982 | present: []bool, | ||
| 2983 | live: *usize, | ||
| 2984 | shared: *Shared, | ||
| 2985 | host_table: []Host, | ||
| 2986 | sel: usize, | ||
| 2987 | ) ?usize { | ||
| 2988 | // The tile creates on attach exactly as a chord-born one does: no side | ||
| 2989 | // connection, and no second road onto the wall to keep in step. | ||
| 2990 | // A forgotten host is off the rows and out of the file, and its poller | ||
| 2991 | // has exited: born on one, a tile no list can ever confirm or vanish | ||
| 2992 | // would sit on the wall naming a machine the user has just removed. | ||
| 2993 | // `pickerNearest` cannot save this — with every row gone it returns the | ||
| 2994 | // selection unchanged. | ||
| 2995 | if (sel >= host_table.len or host_table[sel].forgotten.load(.acquire)) { | ||
| 2996 | // The one key the footer advertises, on a wall with nothing to | ||
| 2997 | // birth on: an Enter that closes the popup and does nothing reads | ||
| 2998 | // as a broken key rather than as an empty hosts file. | ||
| 2999 | setNotice(shared, "[no hosts to start a session on - a adds one]"); | ||
| 3000 | return null; | ||
| 3001 | } | ||
| 3002 | const h = &host_table[sel]; | ||
| 3003 | // Enter IS the ask, and this copy is where that is written down: the | ||
| 3004 | // row it lands on is often the one the poller calls unreachable, and | ||
| 3005 | // starting that machine's daemon is what choosing it means. The SPEC | ||
| 3006 | // is untouched — the poller re-dials off it every second, and a wall | ||
| 3007 | // must not resurrect a daemon whose owner just stopped it. | ||
| 3008 | var target = h.spec.target; | ||
| 3009 | if (target == .hand) { | ||
| 3010 | target.hand.asked = true; | ||
| 3011 | // ...and quietly. This dial happens on a tile thread, under the | ||
| 3012 | // wall's alternate screen: `muxd start`'s progress would land over | ||
| 3013 | // tiles and rails, where the tile's own `connecting` label is | ||
| 3014 | // already saying the only thing there is to say. | ||
| 3015 | target.hand.quiet = true; | ||
| 3016 | } | ||
| 3017 | var list_buf: [proto.sessions_text_max]u8 = undefined; | ||
| 3018 | h.list_mu.lock(); | ||
| 3019 | @memcpy(list_buf[0..h.list_len], h.list[0..h.list_len]); | ||
| 3020 | const list_len = h.list_len; | ||
| 3021 | h.list_mu.unlock(); | ||
| 3022 | // The daemon's own naming, off the daemon's own list: the name the | ||
| 3023 | // `c` chord would have landed on, reached without a pump to ask. | ||
| 3024 | var name_buf: [proto.session_name_max]u8 = undefined; | ||
| 3025 | const name = client.nextFreeName(&name_buf, list_buf[0..list_len]); | ||
| 3026 | const session = alloc.dupe(u8, name) catch return null; | ||
| 3027 | const label = tileLabel(alloc, target, session) catch { | ||
| 3028 | alloc.free(session); | ||
| 3029 | return null; | ||
| 3030 | }; | ||
| 3031 | const anchor = anchorTile(present[0..live.*], shared.sel); | ||
| 3032 | const has_anchor = presentCount(present[0..live.*]) > 0; | ||
| 3033 | // `-A` is inherited only within one host. A chord inherits it because | ||
| 3034 | // the new session is on the machine the offer was already made to; the | ||
| 3035 | // picker can cross to a host the user never offered an agent, and a | ||
| 3036 | // popup must not be the thing that hands a stranger the keys. | ||
| 3037 | const agent = has_anchor and tiles[anchor].host != null and | ||
| 3038 | tiles[anchor].host.? == sel and tiles[anchor].r.agent; | ||
| 3039 | const at = birthTile(alloc, tiles, present, live, shared, .{ | ||
| 3040 | .r = .{ .target = target, .label = label, .session = session, .agent = agent }, | ||
| 3041 | .from = anchor, | ||
| 3042 | .place = .beside_focus, | ||
| 3043 | .creates = true, | ||
| 3044 | // Nowhere to hand a refusal back to on an empty wall, so `keeps_wall` | ||
| 3045 | // is what tells `endAction` the wall outlives this tile. | ||
| 3046 | .born_from = if (has_anchor) anchor else null, | ||
| 3047 | .keeps_wall = true, | ||
| 3048 | .host = sel, | ||
| 3049 | }) orelse { | ||
| 3050 | alloc.free(session); | ||
| 3051 | alloc.free(label); | ||
| 3052 | setNotice(shared, "[no room on the wall for another session]"); | ||
| 3053 | return null; | ||
| 3054 | }; | ||
| 3055 | spawnPump(&tiles[at]); | ||
| 3056 | // The new session must not wait out a poll to be confirmed by the list | ||
| 3057 | // that will also stop the diff from vanishing it. | ||
| 3058 | h.poke.store(true, .release); | ||
| 3059 | return at; | ||
| 3060 | } | ||
| 3061 | |||
| 3062 | /// `x`: the host leaves the file, its poller stops and its tiles go. | ||
| 3063 | fn pickForget( | ||
| 3064 | alloc: std.mem.Allocator, | ||
| 3065 | tiles: []Tile, | ||
| 3066 | present: []bool, | ||
| 3067 | live: usize, | ||
| 3068 | shared: *Shared, | ||
| 3069 | host_table: []Host, | ||
| 3070 | sel: usize, | ||
| 3071 | path: ?[]const u8, | ||
| 3072 | ) void { | ||
| 3073 | // The SESSIONS keep running: forgetting a daemon is `mux hosts rm` | ||
| 3074 | // typed from inside, and that ends nothing. | ||
| 3075 | if (sel >= host_table.len) return; | ||
| 3076 | const h = &host_table[sel]; | ||
| 3077 | if (h.forgotten.load(.acquire)) return; | ||
| 3078 | // The file first, for `addHost`'s reason: the wall the user is looking | ||
| 3079 | // at and the wall they get back next time are the same wall. | ||
| 3080 | // | ||
| 3081 | // What the file said is what the notice says. A `false` is a line that | ||
| 3082 | // was not there to remove — a hand-edited file, or a host this wall took | ||
| 3083 | // off argv — and the tiles go either way, so a flat `[forgot ...]` would | ||
| 3084 | // leave the wall and the file disagreeing with nobody told. | ||
| 3085 | var gone_from_file = true; | ||
| 3086 | var why: ?anyerror = null; | ||
| 3087 | if (path) |p| { | ||
| 3088 | gone_from_file = hosts.forget(alloc, p, h.spec.spelling) catch |e| blk: { | ||
| 3089 | why = e; | ||
| 3090 | break :blk false; | ||
| 3091 | }; | ||
| 3092 | } | ||
| 3093 | h.forgotten.store(true, .release); | ||
| 3094 | for (0..live) |i| { | ||
| 3095 | if (present[i] and wall_host.ownedBy(&tiles[i], sel)) | ||
| 3096 | vanishTile(tiles[0..live], present[0..live], shared, i, null); | ||
| 3097 | } | ||
| 3098 | var buf: [96]u8 = undefined; | ||
| 3099 | const said = if (why) |e| | ||
| 3100 | std.fmt.bufPrint(&buf, "[hosts file not updated: {s}]", .{hosts.reason(e)}) catch "[hosts file not updated]" | ||
| 3101 | else if (!gone_from_file) | ||
| 3102 | std.fmt.bufPrint(&buf, "[{s} was not on the wall]", .{h.spec.spelling}) catch "[that host was not on the wall]" | ||
| 3103 | else | ||
| 3104 | std.fmt.bufPrint(&buf, "[forgot {s}]", .{h.spec.spelling}) catch "[forgot the host]"; | ||
| 3105 | setNotice(shared, said); | ||
| 3106 | } | ||
| 3107 | |||
| 3108 | /// The popup. Painted by the KEYBOARD thread, which is the only one that | ||
| 3109 | /// knows the picker exists: on open, on every key, and on every list that | ||
| 3110 | /// lands under it, so the state column is live while the user reads it. | ||
| 3111 | /// | ||
| 3112 | /// `line` is the spelling editor's text when `a` has it open. | ||
| 3113 | fn paintPicker(shared: *Shared, host_table: []Host, sel: usize, line: ?[]const u8) void { | ||
| 3114 | if (!shared.is_tty) return; | ||
| 3115 | // The flag is set before the lock and READ under it (`tilePaintBegin`), | ||
| 3116 | // which is what orders the two: a pump either takes `paint_mu` first and | ||
| 3117 | // finishes its rect before this paint starts, or takes it after and sees | ||
| 3118 | // the flag already set. | ||
| 3119 | shared.picker_open.store(true, .release); | ||
| 3120 | shared.paint_mu.lock(); | ||
| 3121 | defer shared.paint_mu.unlock(); | ||
| 3122 | const cols = shared.size.cols; | ||
| 3123 | const rows = shared.size.rows; | ||
| 3124 | const w: u16 = @min(cols, @as(u16, picker_row_max)); | ||
| 3125 | const left: u16 = (cols -| w) / 2; | ||
| 3126 | var body: PickerBody = .{}; | ||
| 3127 | pickerRows(&body, host_table, sel, w); | ||
| 3128 | // Too small for a box is not too small for an answer: the header alone | ||
| 3129 | // still says which popup has the keyboard. | ||
| 3130 | const cramped = cols < picker_min_cols or rows < picker_min_rows; | ||
| 3131 | const want: u16 = @intCast(@min(@as(usize, rows), body.n + 2)); | ||
| 3132 | const height: u16 = if (cramped) 1 else want; | ||
| 3133 | const top: u16 = (rows -| height) / 2; | ||
| 3134 | // The window onto the rows when the terminal cannot hold them all: the | ||
| 3135 | // selection stays visible, because it is what every other key acts on. | ||
| 3136 | const shown: usize = height -| 2; | ||
| 3137 | var first: usize = 0; | ||
| 3138 | var sel_row: usize = 0; | ||
| 3139 | for (body.host[0..body.n], 0..) |hi, i| { | ||
| 3140 | if (hi == sel) sel_row = i; | ||
| 3141 | } | ||
| 3142 | if (shown > 0 and sel_row >= shown) first = sel_row - shown + 1; | ||
| 3143 | // The notice wins the footer over the legend and over the editor's own | ||
| 3144 | // line: a refusal the user just earned is the one sentence that cannot | ||
| 3145 | // wait for the next keystroke. | ||
| 3146 | // PEEKED: the notice is part of the frame, and the stamp below can | ||
| 3147 | // still refuse to write it. Taken only once the bytes are out. | ||
| 3148 | var notice_buf: [96]u8 = undefined; | ||
| 3149 | const notice = peekNoticeLocked(shared, ¬ice_buf); | ||
| 3150 | const foot: []const u8 = if (notice.len > 0) | ||
| 3151 | notice | ||
| 3152 | else if (line) |l| | ||
| 3153 | l | ||
| 3154 | else | ||
| 3155 | " Enter/c new session x forget a add Esc"; | ||
| 3156 | |||
| 3157 | var out: [picker_frame_max]u8 = undefined; | ||
| 3158 | var fbs = std.io.fixedBufferStream(&out); | ||
| 3159 | const wr = fbs.writer(); | ||
| 3160 | // The cursor is hidden for as long as the popup owns the screen: a | ||
| 3161 | // caret left blinking in a tile says the keys are going there. | ||
| 3162 | wr.writeAll("\x1b[?25l") catch return; | ||
| 3163 | pickerLine(wr, top, left, w, " hosts"); | ||
| 3164 | if (!cramped) { | ||
| 3165 | var r: u16 = 0; | ||
| 3166 | while (r < shown and first + r < body.n) : (r += 1) | ||
| 3167 | pickerLine(wr, top + 1 + r, left, w, body.row(first + r)); | ||
| 3168 | pickerLine(wr, top + height - 1, left, w, foot); | ||
| 3169 | } | ||
| 3170 | const frame = fbs.getWritten(); | ||
| 3171 | // Nothing changed, nothing written. The pollers report once a second | ||
| 3172 | // per host and every one of them repaints this box; rewriting an | ||
| 3173 | // identical screen at that rate is a terminal that never goes quiet. | ||
| 3174 | const stamp = std.hash.Wyhash.hash(0, frame); | ||
| 3175 | if (stamp == shared.picker_stamp) return; | ||
| 3176 | shared.picker_stamp = stamp; | ||
| 3177 | shared.notice_len = 0; | ||
| 3178 | proto.writeAllFd(shared.out_fd, frame) catch {}; | ||
| 3179 | } | ||
| 3180 | |||
| 3181 | /// A refusal in THIS client's words. `parseEndReply` hands back the frame's | 2830 | /// A refusal in THIS client's words. `parseEndReply` hands back the frame's |
| 3182 | /// tail unfiltered and `paintBanner` writes it verbatim, so a peer's | 2831 | /// tail unfiltered and `paintBanner` writes it verbatim, so a peer's |
| 3183 | /// `\x1b]0;..\x07` would run outside the replica. The reasons are constants. | 2832 | /// `\x1b]0;..\x07` would run outside the replica. The reasons are constants. |
| @@ -3189,117 +2838,13 @@ fn endRefusal(reason: []const u8) []const u8 { | |||
| 3189 | return "[the daemon refused to end this session]"; | 2838 | return "[the daemon refused to end this session]"; |
| 3190 | } | 2839 | } |
| 3191 | 2840 | ||
| 3192 | /// What the popup owes the screen on a pass with no keystroke: whether to | ||
| 3193 | /// paint at all, and the footer to paint with. | ||
| 3194 | const PickerRepaint = struct { due: bool, line: ?[]const u8 }; | ||
| 3195 | |||
| 3196 | /// `prompting` is not a term: `relayout` clears the whole screen on any | ||
| 3197 | /// poll's birth or vanish while the box is up, and a paint skipped for the | ||
| 3198 | /// spelling editor leaves the user typing into an editor the screen no | ||
| 3199 | /// longer shows — every key still reaching it. | ||
| 3200 | fn pickerRepaint(buf: []u8, prefix: *const interact.PrefixFilter, trigger: bool) PickerRepaint { | ||
| 3201 | if (!prefix.picking) return .{ .due = false, .line = null }; | ||
| 3202 | const line: ?[]const u8 = if (prefix.prompting) | ||
| 3203 | (std.fmt.bufPrint(buf, ": {s}_", .{prefix.promptLine()}) catch "") | ||
| 3204 | else | ||
| 3205 | null; | ||
| 3206 | return .{ .due = trigger, .line = line }; | ||
| 3207 | } | ||
| 3208 | |||
| 3209 | /// One row of the box: reverse video, padded to the box width. The same | ||
| 3210 | /// chrome a label bar and a rail wear, so the popup never reads as a | ||
| 3211 | /// session's own output. | ||
| 3212 | fn pickerLine(wr: anytype, row: u16, left: u16, w: u16, text: []const u8) void { | ||
| 3213 | wr.print("\x1b[{d};{d}H\x1b[7m", .{ row + 1, left + 1 }) catch return; | ||
| 3214 | const take = @min(text.len, @as(usize, w)); | ||
| 3215 | wr.writeAll(text[0..take]) catch return; | ||
| 3216 | var i: usize = take; | ||
| 3217 | while (i < w) : (i += 1) wr.writeByte(' ') catch break; | ||
| 3218 | wr.writeAll("\x1b[0m") catch return; | ||
| 3219 | } | ||
| 3220 | |||
| 3221 | /// For a caller already holding `paint_mu`. | 2841 | /// For a caller already holding `paint_mu`. |
| 3222 | fn takeNoticeLocked(shared: *Shared, out: []u8) []const u8 { | 2842 | fn takeNoticeLocked(shared: *Shared, out: []u8) []const u8 { |
| 3223 | const said = peekNoticeLocked(shared, out); | 2843 | const said = wall_picker.peekNoticeLocked(shared, out); |
| 3224 | shared.notice_len = 0; | 2844 | shared.notice_len = 0; |
| 3225 | return said; | 2845 | return said; |
| 3226 | } | 2846 | } |
| 3227 | 2847 | ||
| 3228 | fn peekNoticeLocked(shared: *const Shared, out: []u8) []const u8 { | ||
| 3229 | const n = @min(shared.notice_len, out.len); | ||
| 3230 | @memcpy(out[0..n], shared.notice[0..n]); | ||
| 3231 | return out[0..n]; | ||
| 3232 | } | ||
| 3233 | |||
| 3234 | /// The hosts still on the wall, in file order — the picker's rows, as | ||
| 3235 | /// indices into the table a forgotten host never leaves. | ||
| 3236 | fn pickerVisible(host_table: []Host, out: *[max_tiles]usize) []const usize { | ||
| 3237 | var n: usize = 0; | ||
| 3238 | for (host_table, 0..) |*h, hi| { | ||
| 3239 | if (h.forgotten.load(.acquire)) continue; | ||
| 3240 | out[n] = hi; | ||
| 3241 | n += 1; | ||
| 3242 | } | ||
| 3243 | return out[0..n]; | ||
| 3244 | } | ||
| 3245 | |||
| 3246 | /// The host `d` rows away. CLAMPED, not wrapped: a held key that rolled | ||
| 3247 | /// the list round would birth on whatever it stopped on. | ||
| 3248 | fn pickerStep(host_table: []Host, sel: usize, d: i8) usize { | ||
| 3249 | var buf: [max_tiles]usize = undefined; | ||
| 3250 | const vis = pickerVisible(host_table, &buf); | ||
| 3251 | if (vis.len == 0) return sel; | ||
| 3252 | var row: usize = 0; | ||
| 3253 | for (vis, 0..) |hi, i| { | ||
| 3254 | if (hi == sel) row = i; | ||
| 3255 | } | ||
| 3256 | const moved = @as(isize, @intCast(row)) + d; | ||
| 3257 | const clamped: usize = if (moved < 0) 0 else @min(@as(usize, @intCast(moved)), vis.len - 1); | ||
| 3258 | return vis[clamped]; | ||
| 3259 | } | ||
| 3260 | |||
| 3261 | /// The host a picker row number names, or null when the list is shorter. | ||
| 3262 | fn pickerAt(host_table: []Host, row: usize) ?usize { | ||
| 3263 | var buf: [max_tiles]usize = undefined; | ||
| 3264 | const vis = pickerVisible(host_table, &buf); | ||
| 3265 | return if (row < vis.len) vis[row] else null; | ||
| 3266 | } | ||
| 3267 | |||
| 3268 | /// Where the selection rests when the picker opens: the wanted host if it | ||
| 3269 | /// is still on the wall, else the first row — never a forgotten slot. | ||
| 3270 | fn pickerNearest(host_table: []Host, want: usize) usize { | ||
| 3271 | var buf: [max_tiles]usize = undefined; | ||
| 3272 | const vis = pickerVisible(host_table, &buf); | ||
| 3273 | for (vis) |hi| { | ||
| 3274 | if (hi == want) return want; | ||
| 3275 | } | ||
| 3276 | return if (vis.len > 0) vis[0] else want; | ||
| 3277 | } | ||
| 3278 | |||
| 3279 | /// The picker's body: one row per host still on the wall, in file order. | ||
| 3280 | /// `sel` is a HOST index, not a row — a forgotten host keeps its slot | ||
| 3281 | /// (a poller holds the pointer and `Tile.host` indexes it) and only | ||
| 3282 | /// leaves the list. | ||
| 3283 | fn pickerRows(body: *PickerBody, host_table: []Host, sel: usize, cols: u16) void { | ||
| 3284 | body.n = 0; | ||
| 3285 | // Counted before anything is spelled: the padding is the TABLE's, and a | ||
| 3286 | // row cannot know from its own number whether a 10 is coming. | ||
| 3287 | var listed: usize = 0; | ||
| 3288 | for (host_table) |*h| { | ||
| 3289 | if (!h.forgotten.load(.acquire)) listed += 1; | ||
| 3290 | } | ||
| 3291 | const wide = listed >= 10; | ||
| 3292 | for (host_table, 0..) |*h, hi| { | ||
| 3293 | if (h.forgotten.load(.acquire)) continue; | ||
| 3294 | var state_buf: [24]u8 = undefined; | ||
| 3295 | const state = hostState(&state_buf, h); | ||
| 3296 | const at = body.n; | ||
| 3297 | body.host[at] = hi; | ||
| 3298 | body.lens[at] = pickerRow(&body.text[at], at + 1, wide, h.spec.spelling, state, hi == sel, cols).len; | ||
| 3299 | body.n += 1; | ||
| 3300 | } | ||
| 3301 | } | ||
| 3302 | |||
| 3303 | /// The DIAL is on the main thread, before any wall: ssh can want the tty. | 2848 | /// The DIAL is on the main thread, before any wall: ssh can want the tty. |
| 3304 | /// The pump ADOPTS a link that is already up. | 2849 | /// The pump ADOPTS a link that is already up. |
| 3305 | pub fn runAttach( | 2850 | pub fn runAttach( |
| @@ -3809,7 +3354,7 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 3809 | picker_opened = true; | 3354 | picker_opened = true; |
| 3810 | picker_shown = true; | 3355 | picker_shown = true; |
| 3811 | input.prefix.picking = true; | 3356 | input.prefix.picking = true; |
| 3812 | picker_sel = pickerNearest(host_table[0..hosts_live], picker_sel); | 3357 | picker_sel = wall_picker.pickerNearest(host_table[0..hosts_live], picker_sel); |
| 3813 | }, | 3358 | }, |
| 3814 | .close => { | 3359 | .close => { |
| 3815 | input.prefix.picking = false; | 3360 | input.prefix.picking = false; |
| @@ -3831,9 +3376,9 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 3831 | // a pass with no bell, no news and no key would otherwise leave the | 3376 | // a pass with no bell, no news and no key would otherwise leave the |
| 3832 | // popup wiped until the next list. | 3377 | // popup wiped until the next list. |
| 3833 | var foot_buf: [interact.PrefixFilter.prompt_max + 4]u8 = undefined; | 3378 | var foot_buf: [interact.PrefixFilter.prompt_max + 4]u8 = undefined; |
| 3834 | const repair = pickerRepaint(&foot_buf, &input.prefix, picker_opened or host_news or | 3379 | const repair = wall_picker.pickerRepaint(&foot_buf, &input.prefix, picker_opened or host_news or |
| 3835 | winch or fds[1].revents != 0 or shared.picker_stamp == 0); | 3380 | winch or fds[1].revents != 0 or shared.picker_stamp == 0); |
| 3836 | if (repair.due) paintPicker(&shared, host_table[0..hosts_live], picker_sel, repair.line); | 3381 | if (repair.due) wall_picker.paintPicker(&shared, host_table[0..hosts_live], picker_sel, repair.line); |
| 3837 | if (fds[0].revents == 0) continue; | 3382 | if (fds[0].revents == 0) continue; |
| 3838 | 3383 | ||
| 3839 | const n = std.posix.read(stdin_fd, &b) catch break; | 3384 | const n = std.posix.read(stdin_fd, &b) catch break; |
| @@ -3850,7 +3395,7 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 3850 | // so neither the empty wall's arms below nor the tiles' ever see | 3395 | // so neither the empty wall's arms below nor the tiles' ever see |
| 3851 | // one — and the popup is the one thing that works on a wall with | 3396 | // one — and the popup is the one thing that works on a wall with |
| 3852 | // no tile at all, which is why it is answered ahead of both. | 3397 | // no tile at all, which is why it is answered ahead of both. |
| 3853 | if (input.prefix.picking or isPickAction(cmd.action)) { | 3398 | if (input.prefix.picking or wall_picker.isPickAction(cmd.action)) { |
| 3854 | // Typed AT the session, ahead of the chord, in the same read. | 3399 | // Typed AT the session, ahead of the chord, in the same read. |
| 3855 | if (cmd.forward.len > 0 and z < live and present[z]) sendKeys(&tiles[z], cmd.forward); | 3400 | if (cmd.forward.len > 0 and z < live and present[z]) sendKeys(&tiles[z], cmd.forward); |
| 3856 | var birth_at: ?usize = null; | 3401 | var birth_at: ?usize = null; |
| @@ -3862,14 +3407,14 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 3862 | if (z < live and present[z]) { | 3407 | if (z < live and present[z]) { |
| 3863 | if (tiles[z].host) |hi| picker_sel = hi; | 3408 | if (tiles[z].host) |hi| picker_sel = hi; |
| 3864 | } | 3409 | } |
| 3865 | picker_sel = pickerNearest(host_table[0..hosts_live], picker_sel); | 3410 | picker_sel = wall_picker.pickerNearest(host_table[0..hosts_live], picker_sel); |
| 3866 | } | 3411 | } |
| 3867 | switch (cmd.action) { | 3412 | switch (cmd.action) { |
| 3868 | .pick_move => |d| picker_sel = pickerStep(host_table[0..hosts_live], picker_sel, d), | 3413 | .pick_move => |d| picker_sel = wall_picker.pickerStep(host_table[0..hosts_live], picker_sel, d), |
| 3869 | .pick_select => |row| { | 3414 | .pick_select => |row| { |
| 3870 | if (pickerAt(host_table[0..hosts_live], row - 1)) |hi| picker_sel = hi; | 3415 | if (wall_picker.pickerAt(host_table[0..hosts_live], row - 1)) |hi| picker_sel = hi; |
| 3871 | }, | 3416 | }, |
| 3872 | .pick_birth => birth_at = pickBirth( | 3417 | .pick_birth => birth_at = wall_picker.pickBirth( |
| 3873 | alloc, | 3418 | alloc, |
| 3874 | tiles, | 3419 | tiles, |
| 3875 | present, | 3420 | present, |
| @@ -3878,7 +3423,7 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 3878 | host_table[0..hosts_live], | 3423 | host_table[0..hosts_live], |
| 3879 | picker_sel, | 3424 | picker_sel, |
| 3880 | ), | 3425 | ), |
| 3881 | .pick_forget => pickForget( | 3426 | .pick_forget => wall_picker.pickForget( |
| 3882 | alloc, | 3427 | alloc, |
| 3883 | tiles, | 3428 | tiles, |
| 3884 | present, | 3429 | present, |
| @@ -3916,8 +3461,8 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 3916 | picker_shown = input.prefix.picking; | 3461 | picker_shown = input.prefix.picking; |
| 3917 | if (input.prefix.picking) { | 3462 | if (input.prefix.picking) { |
| 3918 | var line_buf: [interact.PrefixFilter.prompt_max + 4]u8 = undefined; | 3463 | var line_buf: [interact.PrefixFilter.prompt_max + 4]u8 = undefined; |
| 3919 | const keyed = pickerRepaint(&line_buf, &input.prefix, true); | 3464 | const keyed = wall_picker.pickerRepaint(&line_buf, &input.prefix, true); |
| 3920 | paintPicker(&shared, host_table[0..hosts_live], picker_sel, keyed.line); | 3465 | wall_picker.paintPicker(&shared, host_table[0..hosts_live], picker_sel, keyed.line); |
| 3921 | } else { | 3466 | } else { |
| 3922 | // The close gives the terminal back: `relayout` clears it | 3467 | // The close gives the terminal back: `relayout` clears it |
| 3923 | // and bumps `repaint_gen`, which is the only thing that | 3468 | // and bumps `repaint_gen`, which is the only thing that |
| @@ -4499,7 +4044,7 @@ test "applyReadyLists: a host added after the wall opened gets its sessions, and | |||
| 4499 | // A picker painted into a pipe, drained. Non-blocking on both ends so a | 4044 | // A picker painted into a pipe, drained. Non-blocking on both ends so a |
| 4500 | // frame that outgrew the pipe FAILS here rather than parking the suite. | 4045 | // frame that outgrew the pipe FAILS here rather than parking the suite. |
| 4501 | fn pickerFrame(shared: *Shared, r: std.posix.fd_t, host_table: []Host, sel: usize, out: []u8) []const u8 { | 4046 | fn pickerFrame(shared: *Shared, r: std.posix.fd_t, host_table: []Host, sel: usize, out: []u8) []const u8 { |
| 4502 | paintPicker(shared, host_table, sel, null); | 4047 | wall_picker.paintPicker(shared, host_table, sel, null); |
| 4503 | const n = std.posix.read(r, out) catch 0; | 4048 | const n = std.posix.read(r, out) catch 0; |
| 4504 | return out[0..n]; | 4049 | return out[0..n]; |
| 4505 | } | 4050 | } |
| @@ -4606,13 +4151,13 @@ test "paintPicker: a frame the stamp refuses to write does not eat the notice wi | |||
| 4606 | var table = [_]Host{testHost(&shared, "--sock /tmp/a.sock", "/tmp/a.sock")}; | 4151 | var table = [_]Host{testHost(&shared, "--sock /tmp/a.sock", "/tmp/a.sock")}; |
| 4607 | setList(&table[0], ""); | 4152 | setList(&table[0], ""); |
| 4608 | table[0].applied = true; | 4153 | table[0].applied = true; |
| 4609 | paintPicker(&shared, &table, 0, null); | 4154 | wall_picker.paintPicker(&shared, &table, 0, null); |
| 4610 | 4155 | ||
| 4611 | // A notice whose footer is byte-for-byte the legend already on the | 4156 | // A notice whose footer is byte-for-byte the legend already on the |
| 4612 | // screen: the stamp refuses the write, and taking the notice ahead of | 4157 | // screen: the stamp refuses the write, and taking the notice ahead of |
| 4613 | // that check is a sentence consumed by a frame nobody was sent. | 4158 | // that check is a sentence consumed by a frame nobody was sent. |
| 4614 | setNotice(&shared, " Enter/c new session x forget a add Esc"); | 4159 | setNotice(&shared, " Enter/c new session x forget a add Esc"); |
| 4615 | paintPicker(&shared, &table, 0, null); | 4160 | wall_picker.paintPicker(&shared, &table, 0, null); |
| 4616 | var buf: [96]u8 = undefined; | 4161 | var buf: [96]u8 = undefined; |
| 4617 | try std.testing.expectEqualStrings( | 4162 | try std.testing.expectEqualStrings( |
| 4618 | " Enter/c new session x forget a add Esc", | 4163 | " Enter/c new session x forget a add Esc", |
| @@ -4631,7 +4176,7 @@ test "paintPicker: replayed into an engine, the popup covers its box and NOT one | |||
| 4631 | // top = (20-5)/2. A grep over the frame's CUP sequences cannot see a | 4176 | // top = (20-5)/2. A grep over the frame's CUP sequences cannot see a |
| 4632 | // paint that reaches a cell by another route — a different CUP form, a | 4177 | // paint that reaches a cell by another route — a different CUP form, a |
| 4633 | // wider pad — so this one judges the GRID. | 4178 | // wider pad — so this one judges the GRID. |
| 4634 | const w: u16 = @intCast(picker_row_max); | 4179 | const w: u16 = @intCast(wall_picker.picker_row_max); |
| 4635 | const left: u16 = (cols - w) / 2; | 4180 | const left: u16 = (cols - w) / 2; |
| 4636 | const height: u16 = 5; | 4181 | const height: u16 = 5; |
| 4637 | const top: u16 = (rows - height) / 2; | 4182 | const top: u16 = (rows - height) / 2; |
| @@ -4657,7 +4202,7 @@ test "paintPicker: replayed into an engine, the popup covers its box and NOT one | |||
| 4657 | setList(h, ""); | 4202 | setList(h, ""); |
| 4658 | h.applied = true; | 4203 | h.applied = true; |
| 4659 | } | 4204 | } |
| 4660 | paintPicker(&shared, &table, 1, null); | 4205 | wall_picker.paintPicker(&shared, &table, 1, null); |
| 4661 | screen.drain(); | 4206 | screen.drain(); |
| 4662 | const dump = try screen.eng.dumpPlain(alloc); | 4207 | const dump = try screen.eng.dumpPlain(alloc); |
| 4663 | defer alloc.free(dump); | 4208 | defer alloc.free(dump); |
| @@ -4907,8 +4452,8 @@ test "tilePaintBegin: no tile paints while the picker owns the screen" { | |||
| 4907 | test "pickerRow: a table with a 10 in it pads every number, so no spelling shifts a column" { | 4452 | test "pickerRow: a table with a 10 in it pads every number, so no spelling shifts a column" { |
| 4908 | var nine_buf: [96]u8 = undefined; | 4453 | var nine_buf: [96]u8 = undefined; |
| 4909 | var ten_buf: [96]u8 = undefined; | 4454 | var ten_buf: [96]u8 = undefined; |
| 4910 | const nine = pickerRow(&nine_buf, 9, true, "--sock /a", "1 session", false, 40); | 4455 | const nine = wall_picker.pickerRow(&nine_buf, 9, true, "--sock /a", "1 session", false, 40); |
| 4911 | const ten = pickerRow(&ten_buf, 10, true, "--sock /a", "1 session", false, 40); | 4456 | const ten = wall_picker.pickerRow(&ten_buf, 10, true, "--sock /a", "1 session", false, 40); |
| 4912 | try std.testing.expectEqual( | 4457 | try std.testing.expectEqual( |
| 4913 | std.mem.indexOf(u8, nine, "--sock").?, | 4458 | std.mem.indexOf(u8, nine, "--sock").?, |
| 4914 | std.mem.indexOf(u8, ten, "--sock").?, | 4459 | std.mem.indexOf(u8, ten, "--sock").?, |
| @@ -4917,7 +4462,7 @@ test "pickerRow: a table with a 10 in it pads every number, so no spelling shift | |||
| 4917 | // A table that never reaches 10 spends no column on a digit it has not | 4462 | // A table that never reaches 10 spends no column on a digit it has not |
| 4918 | // got: the marker is already two wide for the same reason. | 4463 | // got: the marker is already two wide for the same reason. |
| 4919 | var narrow_buf: [96]u8 = undefined; | 4464 | var narrow_buf: [96]u8 = undefined; |
| 4920 | const narrow = pickerRow(&narrow_buf, 9, false, "--sock /a", "1 session", false, 40); | 4465 | const narrow = wall_picker.pickerRow(&narrow_buf, 9, false, "--sock /a", "1 session", false, 40); |
| 4921 | try std.testing.expectEqualStrings(" 9 --sock", narrow[0..10]); | 4466 | try std.testing.expectEqualStrings(" 9 --sock", narrow[0..10]); |
| 4922 | } | 4467 | } |
| 4923 | 4468 | ||
| @@ -4943,7 +4488,7 @@ test "pickerRows: every host says what its poller last answered" { | |||
| 4943 | // two send the user to opposite places. | 4488 | // two send the user to opposite places. |
| 4944 | 4489 | ||
| 4945 | var body: PickerBody = .{}; | 4490 | var body: PickerBody = .{}; |
| 4946 | pickerRows(&body, &table, 2, 48); | 4491 | wall_picker.pickerRows(&body, &table, 2, 48); |
| 4947 | 4492 | ||
| 4948 | try std.testing.expectEqual(@as(usize, 4), body.n); | 4493 | try std.testing.expectEqual(@as(usize, 4), body.n); |
| 4949 | try std.testing.expectEqualStrings(" 1 --sock /tmp/a.sock 3 sessions ", body.row(0)); | 4494 | try std.testing.expectEqualStrings(" 1 --sock /tmp/a.sock 3 sessions ", body.row(0)); |
| @@ -4959,7 +4504,7 @@ test "pickerRows: one session is not 1 sessions" { | |||
| 4959 | setList(&table[0], "0\n"); | 4504 | setList(&table[0], "0\n"); |
| 4960 | table[0].applied = true; | 4505 | table[0].applied = true; |
| 4961 | var body: PickerBody = .{}; | 4506 | var body: PickerBody = .{}; |
| 4962 | pickerRows(&body, &table, 0, 32); | 4507 | wall_picker.pickerRows(&body, &table, 0, 32); |
| 4963 | try std.testing.expectEqualStrings(" 1> box 1 session ", body.row(0)); | 4508 | try std.testing.expectEqualStrings(" 1> box 1 session ", body.row(0)); |
| 4964 | } | 4509 | } |
| 4965 | 4510 | ||
| @@ -4973,7 +4518,7 @@ test "pickerRows: a spelling wider than the box keeps its tail" { | |||
| 4973 | setList(&table[0], "0\n"); | 4518 | setList(&table[0], "0\n"); |
| 4974 | table[0].applied = true; | 4519 | table[0].applied = true; |
| 4975 | var body: PickerBody = .{}; | 4520 | var body: PickerBody = .{}; |
| 4976 | pickerRows(&body, &table, 0, 32); | 4521 | wall_picker.pickerRows(&body, &table, 0, 32); |
| 4977 | // Cut from the LEFT: the head of a socket path is what every host on | 4522 | // Cut from the LEFT: the head of a socket path is what every host on |
| 4978 | // one machine has in common, and the tail is what tells them apart. | 4523 | // one machine has in common, and the tail is what tells them apart. |
| 4979 | try std.testing.expectEqualStrings(" 1> path/to/muxd.sock 1 session ", body.row(0)); | 4524 | try std.testing.expectEqualStrings(" 1> path/to/muxd.sock 1 session ", body.row(0)); |
| @@ -5002,7 +4547,7 @@ test "pickBirth: Enter on an emptied popup births nothing, not a session on a ho | |||
| 5002 | h.forgotten.store(true, .release); | 4547 | h.forgotten.store(true, .release); |
| 5003 | } | 4548 | } |
| 5004 | 4549 | ||
| 5005 | const at = pickBirth(alloc, &tiles, &present, &live, &shared, &table, 1); | 4550 | const at = wall_picker.pickBirth(alloc, &tiles, &present, &live, &shared, &table, 1); |
| 5006 | 4551 | ||
| 5007 | if (at != null) return error.BornOnAForgottenHost; | 4552 | if (at != null) return error.BornOnAForgottenHost; |
| 5008 | if (live != 0) return error.AForgottenHostTookASlot; | 4553 | if (live != 0) return error.AForgottenHostTookASlot; |
| @@ -5040,7 +4585,7 @@ test "pickBirth: Enter is an ask — the tile it births may start a daemon, the | |||
| 5040 | h.applied = true; | 4585 | h.applied = true; |
| 5041 | } | 4586 | } |
| 5042 | 4587 | ||
| 5043 | const at = pickBirth(alloc, &tiles, &present, &live, &shared, &table, 1) orelse | 4588 | const at = wall_picker.pickBirth(alloc, &tiles, &present, &live, &shared, &table, 1) orelse |
| 5044 | return error.EnterBornNothing; | 4589 | return error.EnterBornNothing; |
| 5045 | 4590 | ||
| 5046 | // The row Enter lands on is often exactly the one the poller calls | 4591 | // The row Enter lands on is often exactly the one the poller calls |
| @@ -5067,7 +4612,7 @@ test "pickerRows: a forgotten host is off the list, and the numbers close up" { | |||
| 5067 | // indexes it — so only the list closes up. | 4612 | // indexes it — so only the list closes up. |
| 5068 | table[1].forgotten.store(true, .release); | 4613 | table[1].forgotten.store(true, .release); |
| 5069 | var body: PickerBody = .{}; | 4614 | var body: PickerBody = .{}; |
| 5070 | pickerRows(&body, &table, 2, 32); | 4615 | wall_picker.pickerRows(&body, &table, 2, 32); |
| 5071 | try std.testing.expectEqual(@as(usize, 2), body.n); | 4616 | try std.testing.expectEqual(@as(usize, 2), body.n); |
| 5072 | try std.testing.expectEqualStrings(" 1 a no sessions ", body.row(0)); | 4617 | try std.testing.expectEqualStrings(" 1 a no sessions ", body.row(0)); |
| 5073 | try std.testing.expectEqualStrings(" 2> c no sessions ", body.row(1)); | 4618 | try std.testing.expectEqualStrings(" 2> c no sessions ", body.row(1)); |
| @@ -5497,10 +5042,10 @@ test "pickerRepaint: a screen cleared under the spelling editor still owes a pai | |||
| 5497 | var buf: [interact.PrefixFilter.prompt_max + 4]u8 = undefined; | 5042 | var buf: [interact.PrefixFilter.prompt_max + 4]u8 = undefined; |
| 5498 | 5043 | ||
| 5499 | // Nothing is owed while the popup is not up, whatever else happened. | 5044 | // Nothing is owed while the popup is not up, whatever else happened. |
| 5500 | try std.testing.expect(!pickerRepaint(&buf, &f, true).due); | 5045 | try std.testing.expect(!wall_picker.pickerRepaint(&buf, &f, true).due); |
| 5501 | 5046 | ||
| 5502 | f.picking = true; | 5047 | f.picking = true; |
| 5503 | const closed = pickerRepaint(&buf, &f, false); | 5048 | const closed = wall_picker.pickerRepaint(&buf, &f, false); |
| 5504 | try std.testing.expect(!closed.due); | 5049 | try std.testing.expect(!closed.due); |
| 5505 | try std.testing.expectEqual(@as(?[]const u8, null), closed.line); | 5050 | try std.testing.expectEqual(@as(?[]const u8, null), closed.line); |
| 5506 | 5051 | ||
| @@ -5511,7 +5056,7 @@ test "pickerRepaint: a screen cleared under the spelling editor still owes a pai | |||
| 5511 | f.prompting = true; | 5056 | f.prompting = true; |
| 5512 | var typed = [_]u8{ 'b', 'o', 'x' }; | 5057 | var typed = [_]u8{ 'b', 'o', 'x' }; |
| 5513 | for (&typed) |*c| _ = f.feed(c[0..1]); | 5058 | for (&typed) |*c| _ = f.feed(c[0..1]); |
| 5514 | const repair = pickerRepaint(&buf, &f, true); | 5059 | const repair = wall_picker.pickerRepaint(&buf, &f, true); |
| 5515 | try std.testing.expect(repair.due); | 5060 | try std.testing.expect(repair.due); |
| 5516 | try std.testing.expectEqualStrings(": box_", repair.line orelse ""); | 5061 | try std.testing.expectEqualStrings(": box_", repair.line orelse ""); |
| 5517 | } | 5062 | } |
| @@ -5526,13 +5071,13 @@ test "hostState: the row counts the sessions the wall could show, not the runs i | |||
| 5526 | 5071 | ||
| 5527 | // Never polled, so nothing is known: `no sessions` here would send the | 5072 | // Never polled, so nothing is known: `no sessions` here would send the |
| 5528 | // user to birth on a machine that is not answering. | 5073 | // user to birth on a machine that is not answering. |
| 5529 | try std.testing.expectEqualStrings("connecting", hostState(&buf, &h)); | 5074 | try std.testing.expectEqualStrings("connecting", wall_picker.hostState(&buf, &h)); |
| 5530 | 5075 | ||
| 5531 | h.applied = true; | 5076 | h.applied = true; |
| 5532 | const listed = "a\nb\nc\n"; | 5077 | const listed = "a\nb\nc\n"; |
| 5533 | @memcpy(h.list[0..listed.len], listed); | 5078 | @memcpy(h.list[0..listed.len], listed); |
| 5534 | h.list_len = listed.len; | 5079 | h.list_len = listed.len; |
| 5535 | try std.testing.expectEqualStrings("3 sessions", hostState(&buf, &h)); | 5080 | try std.testing.expectEqualStrings("3 sessions", wall_picker.hostState(&buf, &h)); |
| 5536 | 5081 | ||
| 5537 | // A row saying `4 sessions` beside three tiles is the row lying about | 5082 | // A row saying `4 sessions` beside three tiles is the row lying about |
| 5538 | // the wall: `planHostDiff` births through `validSessionName`, so the | 5083 | // the wall: `planHostDiff` births through `validSessionName`, so the |
| @@ -5540,10 +5085,10 @@ test "hostState: the row counts the sessions the wall could show, not the runs i | |||
| 5540 | const with_junk = "a\nb\n" ++ ("x" ** (proto.session_name_max + 1)) ++ "\nc\n"; | 5085 | const with_junk = "a\nb\n" ++ ("x" ** (proto.session_name_max + 1)) ++ "\nc\n"; |
| 5541 | @memcpy(h.list[0..with_junk.len], with_junk); | 5086 | @memcpy(h.list[0..with_junk.len], with_junk); |
| 5542 | h.list_len = with_junk.len; | 5087 | h.list_len = with_junk.len; |
| 5543 | try std.testing.expectEqualStrings("3 sessions", hostState(&buf, &h)); | 5088 | try std.testing.expectEqualStrings("3 sessions", wall_picker.hostState(&buf, &h)); |
| 5544 | 5089 | ||
| 5545 | h.reachable.store(false, .release); | 5090 | h.reachable.store(false, .release); |
| 5546 | try std.testing.expectEqualStrings("unreachable", hostState(&buf, &h)); | 5091 | try std.testing.expectEqualStrings("unreachable", wall_picker.hostState(&buf, &h)); |
| 5547 | } | 5092 | } |
| 5548 | 5093 | ||
| 5549 | test "addHost: a forgotten host's slot comes back, so a and x cannot fill the table between them" { | 5094 | test "addHost: a forgotten host's slot comes back, so a and x cannot fill the table between them" { |