c49a06ed
feat: render native sessions with shared terminal input and reconnect
a73x 2026-09-04 19:38
Commit message
Makefile
| Old | New | ||
|---|---|---|---|
| @@ -275,7 +275,7 @@ native: mac-sdk | |||
| 275 | $(ZIG) build native native-test | 275 | $(ZIG) build native native-test |
| 276 | 276 | ||
| 277 | native-e2e: mac-sdk | 277 | native-e2e: mac-sdk |
| 278 | $(ZIG) build native-e2e | 278 | $(ZIG) build native-e2e -Doptimize=ReleaseSafe |
| 279 | 279 | ||
| 280 | # Cross-version gate (test/xversion.sh): this tree's client against a | 280 | # Cross-version gate (test/xversion.sh): this tree's client against a |
| 281 | # previous version's daemon and back, each daemon in a container. | 281 | # previous version's daemon and back, each daemon in a container. |
src/cli/muxg.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,5 +1,11 @@ | |||
| 1 | //! `muxg`: native client entry point. During the first implementation sprint | 1 | //! `muxg`: the native client's entry. Parses ONE target the way `mux` |
| 2 | //! this is a real OpenGL probe over the system window library. | 2 | //! does (HOST, --sock PATH, --via CMD, quic://HOST[:PORT]) plus --session |
| 3 | //! and --font-px, resolves it to a client.Target, and hands it to the | ||
| 4 | //! painter. A session viewer: no wall, no hosts file, no layout. | ||
| 5 | //! | ||
| 6 | //! No daemon is started here. The self-exec rule (CLAUDE.md) says an | ||
| 7 | //! auto-start may only run the image already running, and this image is | ||
| 8 | //! not the daemon's. A silent socket is a refusal with the command to run. | ||
| 3 | const std = @import("std"); | 9 | const std = @import("std"); |
| 4 | const native = @import("native"); | 10 | const native = @import("native"); |
| 5 | const client = @import("client"); | 11 | const client = @import("client"); |
| @@ -7,62 +13,98 @@ const term = @import("term"); | |||
| 7 | const cliflags = @import("cliflags"); | 13 | const cliflags = @import("cliflags"); |
| 8 | const sockpath = @import("sockpath"); | 14 | const sockpath = @import("sockpath"); |
| 9 | 15 | ||
| 10 | const c = @cImport({ | 16 | const proto = term.protocol; |
| 11 | @cInclude("SDL3/SDL.h"); | 17 | const hosts = client.hosts; |
| 12 | }); | ||
| 13 | 18 | ||
| 14 | const gl_version = 0x1F02; | 19 | const usage = |
| 20 | \\usage: muxg [TARGET] [--session NAME] [--sock PATH] [--via CMD] [--key PATH] [--font-px N] | ||
| 21 | \\ | ||
| 22 | \\ TARGET HOST (ssh handoff) or quic://HOST[:PORT]; none means the local daemon | ||
| 23 | \\ --session the session name (default: the daemon's default session) | ||
| 24 | \\ --sock a local daemon's socket path | ||
| 25 | \\ --via a command whose stdio is the daemon | ||
| 26 | \\ --key the QUIC key file (or MUX_KEY_FILE) | ||
| 27 | \\ --font-px the face's pixel size (default 16) | ||
| 28 | \\ --help --version | ||
| 29 | \\ | ||
| 30 | ; | ||
| 15 | 31 | ||
| 16 | pub fn main() u8 { | 32 | const Arguments = struct { |
| 17 | if (!c.SDL_Init(c.SDL_INIT_VIDEO)) { | 33 | sock: ?[]const u8 = null, |
| 18 | std.debug.print("muxg: SDL_Init: {s}\n", .{std.mem.span(c.SDL_GetError())}); | 34 | via: ?[]const u8 = null, |
| 19 | return 2; | 35 | key: ?[]const u8 = null, |
| 20 | } | 36 | session: ?proto.SessionName = null, |
| 21 | defer c.SDL_Quit(); | 37 | font_px: u16 = 16, |
| 38 | _target: ?[]const u8 = null, | ||
| 39 | _targets: usize = 0, | ||
| 22 | 40 | ||
| 23 | if (!c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MAJOR_VERSION, 3) or | 41 | pub fn positional(self: *Arguments, word: []const u8) bool { |
| 24 | !c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MINOR_VERSION, 3) or | 42 | self._target = word; |
| 25 | !c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_PROFILE_MASK, c.SDL_GL_CONTEXT_PROFILE_CORE)) | 43 | self._targets += 1; |
| 26 | { | 44 | return true; |
| 27 | std.debug.print("muxg: SDL_GL_SetAttribute: {s}\n", .{std.mem.span(c.SDL_GetError())}); | ||
| 28 | return 2; | ||
| 29 | } | 45 | } |
| 46 | }; | ||
| 30 | 47 | ||
| 31 | const window = c.SDL_CreateWindow("muxg probe", 320, 200, c.SDL_WINDOW_OPENGL) orelse { | 48 | comptime { |
| 32 | std.debug.print("muxg: SDL_CreateWindow: {s}\n", .{std.mem.span(c.SDL_GetError())}); | 49 | cliflags.assertDocumented(Arguments, usage, &.{}); |
| 33 | return 2; | 50 | } |
| 34 | }; | ||
| 35 | defer c.SDL_DestroyWindow(window); | ||
| 36 | 51 | ||
| 37 | const context = c.SDL_GL_CreateContext(window) orelse { | 52 | pub fn main() !u8 { |
| 38 | std.debug.print("muxg: SDL_GL_CreateContext: {s}\n", .{std.mem.span(c.SDL_GetError())}); | 53 | var gpa: std.heap.DebugAllocator(.{}) = .init; |
| 39 | return 2; | 54 | defer if (gpa.deinit() == .leak) std.debug.print("muxg: LEAK: allocations outlived deinit\n", .{}); |
| 40 | }; | 55 | const alloc = gpa.allocator(); |
| 41 | defer _ = c.SDL_GL_DestroyContext(context); | 56 | var argv_arena = std.heap.ArenaAllocator.init(alloc); |
| 57 | defer argv_arena.deinit(); | ||
| 58 | const argv_alloc = argv_arena.allocator(); | ||
| 59 | const args = try std.process.argsAlloc(argv_alloc); | ||
| 42 | 60 | ||
| 43 | const get_string: ?*const fn (u32) callconv(.c) ?[*:0]const u8 = | 61 | var o: Arguments = .{}; |
| 44 | @ptrCast(c.SDL_GL_GetProcAddress("glGetString")); | 62 | cliflags.parseStrict(Arguments, &o, args[1..]) catch |e| return cliflags.exitFor(e, usage, "muxg", std.fmt.comptimePrint("0.0.1 ({s})", .{@tagName(@import("builtin").mode)})); |
| 45 | const get = get_string orelse { | 63 | const named: usize = @as(usize, @intFromBool(o.sock != null)) + @intFromBool(o.via != null) + o._targets; |
| 46 | std.debug.print("muxg: no glGetString: {s}\n", .{std.mem.span(c.SDL_GetError())}); | 64 | if (named > 1) { |
| 65 | std.debug.print("muxg: name one transport: HOST, --sock, --via or quic://\n{s}", .{usage}); | ||
| 47 | return 2; | 66 | return 2; |
| 48 | }; | 67 | } |
| 49 | const version = get(gl_version) orelse { | 68 | if (o.font_px == 0 or o.font_px > 256) { |
| 50 | std.debug.print("muxg: glGetString(GL_VERSION) returned null\n", .{}); | 69 | std.debug.print("muxg: --font-px must be between 1 and 256\n", .{}); |
| 51 | return 2; | ||
| 52 | }; | ||
| 53 | const driver = c.SDL_GetCurrentVideoDriver() orelse { | ||
| 54 | std.debug.print("muxg: no current video driver: {s}\n", .{std.mem.span(c.SDL_GetError())}); | ||
| 55 | return 2; | 70 | return 2; |
| 56 | }; | 71 | } |
| 57 | std.debug.print("muxg probe: driver={s} GL_VERSION={s}\n", .{ | 72 | const session = if (o.session) |n| n.name else ""; |
| 58 | std.mem.span(driver), | 73 | const key = std.posix.getenv("MUX_KEY_FILE"); |
| 59 | std.mem.span(version), | ||
| 60 | }); | ||
| 61 | 74 | ||
| 62 | _ = native; | 75 | var target: client.Target = undefined; |
| 63 | _ = client; | 76 | if (o.via) |cmd| { |
| 64 | _ = term; | 77 | target = .{ .via = cmd }; |
| 65 | _ = cliflags; | 78 | } else if (o._target) |word| { |
| 66 | _ = sockpath; | 79 | const spec = hosts.parse(word) catch |err| { |
| 67 | return 0; | 80 | std.debug.print("muxg: bad target {s}: {s}\n", .{ word, @errorName(err) }); |
| 81 | return 2; | ||
| 82 | }; | ||
| 83 | target = client.Target.fromSpec(argv_alloc, spec, o.key orelse key, client.quic_idle_ms_default, false) catch |err| switch (err) { | ||
| 84 | error.MissingKey => { | ||
| 85 | std.debug.print("muxg: no key: pass --key, set MUX_KEY_FILE, or run `mux d keygen`\n", .{}); | ||
| 86 | return 2; | ||
| 87 | }, | ||
| 88 | else => |e| return e, | ||
| 89 | }; | ||
| 90 | if (target == .hand) target.hand.narrate = true; | ||
| 91 | } else { | ||
| 92 | const path = if (o.sock) |s| try argv_alloc.dupe(u8, s) else (try sockpath.defaultOrExplain(argv_alloc, "muxg") orelse return 1); | ||
| 93 | if (!sockpath.answers(path)) { | ||
| 94 | std.debug.print("muxg: no daemon at {s} (run: mux d start -d --sock {s})\n", .{ path, path }); | ||
| 95 | return 2; | ||
| 96 | } | ||
| 97 | return native.run(alloc, .{ | ||
| 98 | .target = .{ .sock = path }, | ||
| 99 | .session = session, | ||
| 100 | .font_px = o.font_px, | ||
| 101 | .test_fifo = std.posix.getenv("MUXG_TEST_FIFO"), | ||
| 102 | }); | ||
| 103 | } | ||
| 104 | return native.run(alloc, .{ | ||
| 105 | .target = target, | ||
| 106 | .session = session, | ||
| 107 | .font_px = o.font_px, | ||
| 108 | .test_fifo = std.posix.getenv("MUXG_TEST_FIFO"), | ||
| 109 | }); | ||
| 68 | } | 110 | } |
src/client/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -34,6 +34,7 @@ pub const layoutfile = @import("layoutfile.zig"); | |||
| 34 | pub const keymap = @import("keymap.zig"); | 34 | pub const keymap = @import("keymap.zig"); |
| 35 | pub const askpass = @import("askpass.zig"); | 35 | pub const askpass = @import("askpass.zig"); |
| 36 | pub const core = @import("client_core.zig"); | 36 | pub const core = @import("client_core.zig"); |
| 37 | pub const session_pump = @import("session_pump.zig"); | ||
| 37 | 38 | ||
| 38 | /// A session name held by value. The names a switch travels on are decoded | 39 | /// A session name held by value. The names a switch travels on are decoded |
| 39 | /// out of a frame payload that is freed before the re-dial, so they cannot | 40 | /// out of a frame payload that is freed before the re-dial, so they cannot |
src/client/session_pump.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,757 @@ | |||
| 1 | //! One terminal-free session transport owner. The grid and semantic state | ||
| 2 | //! are shared under mu; the caller supplies a wake callback and a mailbox. | ||
| 3 | //! This is the third attach loop after the web hub and agent. The hub's | ||
| 4 | //! pumpTile is the first candidate to migrate here. | ||
| 5 | const std = @import("std"); | ||
| 6 | const client = @import("client.zig"); | ||
| 7 | const term = @import("term"); | ||
| 8 | const proto = term.protocol; | ||
| 9 | |||
| 10 | pub const Say = union(enum) { input: []const u8, resize: proto.Size, detach, quit }; | ||
| 11 | pub const Phase = enum { dialing, attached, reconnecting, exited, refused, taken, failed, dial_failed }; | ||
| 12 | pub const State = struct { | ||
| 13 | phase: Phase = .dialing, | ||
| 14 | exit_code: u8 = 0, | ||
| 15 | bell: bool = false, | ||
| 16 | reason: [1024]u8 = @splat(0), | ||
| 17 | reason_len: usize = 0, | ||
| 18 | |||
| 19 | pub fn reasonText(self: *const State) []const u8 { | ||
| 20 | return self.reason[0..self.reason_len]; | ||
| 21 | } | ||
| 22 | }; | ||
| 23 | pub const Options = struct { | ||
| 24 | // Borrowed until stop returns, including the slices inside target. | ||
| 25 | target: client.Target, | ||
| 26 | session: []const u8 = "0", | ||
| 27 | cols: u16, | ||
| 28 | rows: u16, | ||
| 29 | wake: ?*const fn (?*anyopaque) void = null, | ||
| 30 | wake_ctx: ?*anyopaque = null, | ||
| 31 | }; | ||
| 32 | |||
| 33 | pub const Pump = struct { | ||
| 34 | alloc: std.mem.Allocator, | ||
| 35 | opts: Options, | ||
| 36 | mu: std.Thread.Mutex = .{}, | ||
| 37 | grid: *term.grid.Grid, | ||
| 38 | replica: term.replica.Replica, | ||
| 39 | core: client.core.ClientCore = .{}, | ||
| 40 | last_apply_us: u32 = 0, | ||
| 41 | status: State = .{}, | ||
| 42 | mailbox_mu: std.Thread.Mutex = .{}, | ||
| 43 | mailbox: std.ArrayList(Say) = .empty, | ||
| 44 | wake_pipe: [2]std.posix.fd_t, | ||
| 45 | cancel_pipe: [2]std.posix.fd_t, | ||
| 46 | closing: std.atomic.Value(bool) = .init(false), | ||
| 47 | thread: ?std.Thread = null, | ||
| 48 | |||
| 49 | pub fn start(alloc: std.mem.Allocator, opts: Options) !*Pump { | ||
| 50 | if (opts.session.len != 0 and !proto.validSessionName(opts.session)) return error.InvalidSession; | ||
| 51 | if (opts.cols == 0 or opts.rows == 0 or opts.cols > proto.max_cols) return error.InvalidSize; | ||
| 52 | const g = try term.grid.Grid.init(alloc, opts.cols, opts.rows); | ||
| 53 | errdefer g.deinit(); | ||
| 54 | const wake_pipe = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); | ||
| 55 | errdefer closePipe(wake_pipe); | ||
| 56 | const cancel_pipe = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); | ||
| 57 | errdefer closePipe(cancel_pipe); | ||
| 58 | const self = try alloc.create(Pump); | ||
| 59 | errdefer alloc.destroy(self); | ||
| 60 | self.* = .{ .alloc = alloc, .opts = opts, .grid = g, .replica = .init(alloc, g), .wake_pipe = wake_pipe, .cancel_pipe = cancel_pipe }; | ||
| 61 | self.thread = try std.Thread.spawn(.{}, entry, .{self}); | ||
| 62 | return self; | ||
| 63 | } | ||
| 64 | |||
| 65 | /// Input is copied before returning. Quit and detach never allocate. | ||
| 66 | pub fn say(self: *Pump, msg: Say) !void { | ||
| 67 | self.mailbox_mu.lock(); | ||
| 68 | defer self.mailbox_mu.unlock(); | ||
| 69 | if (self.closing.load(.acquire)) return; | ||
| 70 | switch (msg) { | ||
| 71 | .quit, .detach => { | ||
| 72 | self.closing.store(true, .release); | ||
| 73 | ring(self.cancel_pipe[1], client.keymap.detach_key); | ||
| 74 | }, | ||
| 75 | else => { | ||
| 76 | var owned = msg; | ||
| 77 | if (msg == .input) owned = .{ .input = try self.alloc.dupe(u8, msg.input) }; | ||
| 78 | errdefer if (owned == .input) self.alloc.free(owned.input); | ||
| 79 | try self.mailbox.append(self.alloc, owned); | ||
| 80 | }, | ||
| 81 | } | ||
| 82 | ring(self.wake_pipe[1], 1); | ||
| 83 | } | ||
| 84 | |||
| 85 | pub fn state(self: *Pump) State { | ||
| 86 | self.mu.lock(); | ||
| 87 | defer self.mu.unlock(); | ||
| 88 | const result = self.status; | ||
| 89 | self.status.bell = false; | ||
| 90 | return result; | ||
| 91 | } | ||
| 92 | |||
| 93 | pub fn stop(self: *Pump) void { | ||
| 94 | self.say(.quit) catch unreachable; | ||
| 95 | if (self.thread) |thread| thread.join(); | ||
| 96 | for (self.mailbox.items) |msg| if (msg == .input) self.alloc.free(msg.input); | ||
| 97 | self.mailbox.deinit(self.alloc); | ||
| 98 | closePipe(self.wake_pipe); | ||
| 99 | closePipe(self.cancel_pipe); | ||
| 100 | self.grid.deinit(); | ||
| 101 | self.alloc.destroy(self); | ||
| 102 | } | ||
| 103 | |||
| 104 | fn wake(self: *Pump) void { | ||
| 105 | if (self.opts.wake) |f| f(self.opts.wake_ctx); | ||
| 106 | } | ||
| 107 | |||
| 108 | fn publish(self: *Pump, phase: Phase, code: u8, reason: []const u8) void { | ||
| 109 | self.mu.lock(); | ||
| 110 | self.setState(phase, code, reason); | ||
| 111 | self.mu.unlock(); | ||
| 112 | self.wake(); | ||
| 113 | } | ||
| 114 | |||
| 115 | fn setState(self: *Pump, phase: Phase, code: u8, reason: []const u8) void { | ||
| 116 | self.status.phase = phase; | ||
| 117 | self.status.exit_code = code; | ||
| 118 | self.status.reason_len = @min(reason.len, self.status.reason.len); | ||
| 119 | @memcpy(self.status.reason[0..self.status.reason_len], reason[0..self.status.reason_len]); | ||
| 120 | } | ||
| 121 | |||
| 122 | fn entry(self: *Pump) void { | ||
| 123 | self.run() catch |err| { | ||
| 124 | self.publish(.failed, 1, @errorName(err)); | ||
| 125 | return; | ||
| 126 | }; | ||
| 127 | self.mu.lock(); | ||
| 128 | const phase = self.status.phase; | ||
| 129 | self.mu.unlock(); | ||
| 130 | switch (phase) { | ||
| 131 | .dialing, .attached, .reconnecting => self.publish(if (self.closing.load(.acquire)) .exited else .failed, if (self.closing.load(.acquire)) 0 else 1, if (self.closing.load(.acquire)) "" else "session pump stopped unexpectedly"), | ||
| 132 | else => {}, | ||
| 133 | } | ||
| 134 | } | ||
| 135 | |||
| 136 | fn run(self: *Pump) !void { | ||
| 137 | var first = true; | ||
| 138 | var backoff: u64 = 0; | ||
| 139 | while (!self.closing.load(.acquire)) { | ||
| 140 | var dial: client.handoff.Dial = .{}; | ||
| 141 | var tr = client.Transport.open(self.alloc, self.opts.target, null, self.cancel_pipe[0], &dial) catch |err| { | ||
| 142 | if (self.closing.load(.acquire)) return; | ||
| 143 | if (first) { | ||
| 144 | var buf: [1024]u8 = undefined; | ||
| 145 | const failure = client.openFailure(&buf, self.opts.target, err, dial.reason.slice()); | ||
| 146 | self.publish(.dial_failed, 2, failure.msg); | ||
| 147 | return; | ||
| 148 | } | ||
| 149 | backoff = client.nextBackoffMs(backoff); | ||
| 150 | try self.waitRetry(backoff); | ||
| 151 | continue; | ||
| 152 | }; | ||
| 153 | first = false; | ||
| 154 | defer tr.close(); | ||
| 155 | if (self.closing.load(.acquire)) return; | ||
| 156 | var wire = try Wire.init(self.alloc, &tr); | ||
| 157 | defer wire.deinit(); | ||
| 158 | const ended = self.connected(&wire) catch |err| try connectionFailure(err); | ||
| 159 | if (ended or self.closing.load(.acquire)) return; | ||
| 160 | tr.close(); | ||
| 161 | self.publish(.reconnecting, 0, "connection lost"); | ||
| 162 | backoff = client.nextBackoffMs(backoff); | ||
| 163 | try self.waitRetry(backoff); | ||
| 164 | } | ||
| 165 | } | ||
| 166 | |||
| 167 | fn waitRetry(self: *Pump, ms: u64) !void { | ||
| 168 | var fds = [_]std.posix.pollfd{.{ .fd = self.cancel_pipe[0], .events = std.posix.POLL.IN, .revents = 0 }}; | ||
| 169 | _ = try std.posix.poll(&fds, @intCast(ms)); | ||
| 170 | } | ||
| 171 | |||
| 172 | fn attach(self: *Pump, wire: *Wire, fresh: bool) !void { | ||
| 173 | self.mu.lock(); | ||
| 174 | const args = self.replica.attachArgs(); | ||
| 175 | self.replica.state_since_attach = false; | ||
| 176 | self.mu.unlock(); | ||
| 177 | var buf: [proto.attach_max_len]u8 = undefined; | ||
| 178 | try wire.send(.attach, proto.encodeAttachNamed(&buf, self.opts.cols, self.opts.rows, if (fresh) 0 else args.have_seq, if (fresh) 0 else args.have_epoch, proto.wireName(self.opts.session))); | ||
| 179 | } | ||
| 180 | |||
| 181 | fn mail(self: *Pump, wire: *Wire) !void { | ||
| 182 | drain(self.wake_pipe[0]); | ||
| 183 | self.mailbox_mu.lock(); | ||
| 184 | var messages = self.mailbox; | ||
| 185 | self.mailbox = .empty; | ||
| 186 | self.mailbox_mu.unlock(); | ||
| 187 | defer { | ||
| 188 | for (messages.items) |msg| if (msg == .input) self.alloc.free(msg.input); | ||
| 189 | messages.deinit(self.alloc); | ||
| 190 | } | ||
| 191 | for (messages.items) |msg| switch (msg) { | ||
| 192 | .input => |bytes| try wire.send(.input, bytes), | ||
| 193 | .resize => |size| { | ||
| 194 | self.opts.cols = size.cols; | ||
| 195 | self.opts.rows = size.rows; | ||
| 196 | const buf = proto.encodeSize(size.cols, size.rows); | ||
| 197 | try wire.send(.resize, &buf); | ||
| 198 | }, | ||
| 199 | .quit, .detach => unreachable, | ||
| 200 | }; | ||
| 201 | } | ||
| 202 | |||
| 203 | fn connected(self: *Pump, wire: *Wire) !bool { | ||
| 204 | try self.attach(wire, false); | ||
| 205 | var eager = wire.tr.link == .quic; | ||
| 206 | while (true) { | ||
| 207 | try self.mail(wire); | ||
| 208 | if (self.closing.load(.acquire)) { | ||
| 209 | try wire.send(.detach, ""); | ||
| 210 | // A responsive peer receives detach; a stalled peer cannot | ||
| 211 | // prevent the owner from joining this thread. | ||
| 212 | const end = std.time.milliTimestamp() + 100; | ||
| 213 | while (wire.pending() and std.time.milliTimestamp() < end) { | ||
| 214 | try wire.flush(); | ||
| 215 | wire.tr.service(); | ||
| 216 | var fds = [_]std.posix.pollfd{.{ .fd = if (wire.tr.link == .quic) wire.tr.pollFd() else wire.writeFd(), .events = if (wire.tr.link == .quic) std.posix.POLL.IN else std.posix.POLL.OUT, .revents = 0 }}; | ||
| 217 | _ = try std.posix.poll(&fds, 5); | ||
| 218 | } | ||
| 219 | return true; | ||
| 220 | } | ||
| 221 | var fds = [_]std.posix.pollfd{ | ||
| 222 | .{ .fd = wire.tr.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 223 | .{ .fd = self.wake_pipe[0], .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 224 | .{ .fd = wire.tr.errFd() orelse -1, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 225 | .{ .fd = if (wire.tr.link != .quic and wire.pending()) wire.writeFd() else -1, .events = std.posix.POLL.OUT, .revents = 0 }, | ||
| 226 | }; | ||
| 227 | _ = try std.posix.poll(&fds, wire.tr.timeoutMs(if (eager) 0 else 1000)); | ||
| 228 | wire.tr.service(); | ||
| 229 | if (fds[2].revents != 0) wire.tr.drainErr(); | ||
| 230 | if (fds[3].revents != 0) try wire.flush(); | ||
| 231 | eager = false; | ||
| 232 | if (fds[0].revents != 0 or wire.tr.link == .quic) { | ||
| 233 | var changed = false; | ||
| 234 | defer if (changed) self.wake(); | ||
| 235 | const budget: usize = if (wire.tr.link == .quic) 64 else 1; | ||
| 236 | for (0..budget) |i| { | ||
| 237 | const incoming = try wire.read(); | ||
| 238 | switch (incoming) { | ||
| 239 | .closed => return false, | ||
| 240 | .incomplete => break, | ||
| 241 | .frame => |frame| { | ||
| 242 | defer frame.deinit(self.alloc); | ||
| 243 | const action = try self.onFrame(frame.type, frame.payload); | ||
| 244 | changed = changed or action != .skip; | ||
| 245 | switch (action) { | ||
| 246 | .resync => try self.attach(wire, true), | ||
| 247 | .end => return true, | ||
| 248 | else => {}, | ||
| 249 | } | ||
| 250 | if (i + 1 == budget and wire.tr.link == .quic) eager = true; | ||
| 251 | }, | ||
| 252 | } | ||
| 253 | } | ||
| 254 | } | ||
| 255 | } | ||
| 256 | } | ||
| 257 | |||
| 258 | const Action = enum { skip, changed, resync, end }; | ||
| 259 | fn onFrame(self: *Pump, kind: proto.MsgType, payload: []const u8) !Action { | ||
| 260 | self.mu.lock(); | ||
| 261 | defer self.mu.unlock(); | ||
| 262 | switch (kind) { | ||
| 263 | .snapshot, .delta => { | ||
| 264 | const begin = std.time.nanoTimestamp(); | ||
| 265 | const applied = self.replica.apply(kind, payload) catch |err| switch (err) { | ||
| 266 | error.BadPayload => return .skip, | ||
| 267 | else => return err, | ||
| 268 | }; | ||
| 269 | self.last_apply_us = @intCast(@min(std.math.maxInt(u32), @max(0, @divTrunc(std.time.nanoTimestamp() - begin, 1000)))); | ||
| 270 | if (applied == .resync) return .resync; | ||
| 271 | self.setState(.attached, 0, ""); | ||
| 272 | return .changed; | ||
| 273 | }, | ||
| 274 | .exit_status => { | ||
| 275 | const admitted = self.replica.state_since_attach; | ||
| 276 | self.setState(if (admitted) .exited else .refused, if (admitted and payload.len > 0) payload[0] else 1, if (admitted) "" else if (payload.len > 1) payload[1..] else "session refused"); | ||
| 277 | return .end; | ||
| 278 | }, | ||
| 279 | .taken_over => { | ||
| 280 | self.setState(.taken, 0, "session taken over"); | ||
| 281 | return .end; | ||
| 282 | }, | ||
| 283 | else => switch (self.core.receive(kind, payload)) { | ||
| 284 | .effect => |effect| switch (effect) { | ||
| 285 | .bell => self.status.bell = true, | ||
| 286 | .clipboard_set => return .skip, | ||
| 287 | }, | ||
| 288 | .state => return .changed, | ||
| 289 | else => return .skip, | ||
| 290 | }, | ||
| 291 | } | ||
| 292 | return .changed; | ||
| 293 | } | ||
| 294 | }; | ||
| 295 | |||
| 296 | // Incremental reads and queued writes keep partial stream frames and a | ||
| 297 | // peer which stops reading from blocking the mailbox or stop(). QUIC keeps | ||
| 298 | // its existing framing and outgoing queue in Link. | ||
| 299 | const Wire = struct { | ||
| 300 | alloc: std.mem.Allocator, | ||
| 301 | tr: *client.Transport, | ||
| 302 | input: std.ArrayList(u8) = .empty, | ||
| 303 | output: std.ArrayList(u8) = .empty, | ||
| 304 | |||
| 305 | fn init(alloc: std.mem.Allocator, tr: *client.Transport) !Wire { | ||
| 306 | switch (tr.link) { | ||
| 307 | .fd => |fd| try nonblocking(fd), | ||
| 308 | .pipe => |p| { | ||
| 309 | try nonblocking(p.r); | ||
| 310 | try nonblocking(p.w); | ||
| 311 | }, | ||
| 312 | .quic => {}, | ||
| 313 | } | ||
| 314 | return .{ .alloc = alloc, .tr = tr }; | ||
| 315 | } | ||
| 316 | fn deinit(self: *Wire) void { | ||
| 317 | self.input.deinit(self.alloc); | ||
| 318 | self.output.deinit(self.alloc); | ||
| 319 | } | ||
| 320 | fn writeFd(self: *Wire) std.posix.fd_t { | ||
| 321 | return switch (self.tr.link) { | ||
| 322 | .fd => |fd| fd, | ||
| 323 | .pipe => |p| p.w, | ||
| 324 | .quic => self.tr.pollFd(), | ||
| 325 | }; | ||
| 326 | } | ||
| 327 | fn pending(self: *Wire) bool { | ||
| 328 | return if (self.tr.link == .quic) self.tr.link.quic.qout.items.len > 0 else self.output.items.len > 0; | ||
| 329 | } | ||
| 330 | fn send(self: *Wire, kind: proto.MsgType, payload: []const u8) !void { | ||
| 331 | if (self.tr.link == .quic) return self.tr.writeFrame(kind, payload); | ||
| 332 | try proto.appendFrame(&self.output, self.alloc, kind, payload); | ||
| 333 | try self.flush(); | ||
| 334 | } | ||
| 335 | fn flush(self: *Wire) !void { | ||
| 336 | if (self.tr.link == .quic) return self.tr.flushQuic(); | ||
| 337 | if (self.output.items.len == 0) return; | ||
| 338 | const n = std.posix.write(self.writeFd(), self.output.items) catch |err| switch (err) { | ||
| 339 | error.WouldBlock => return, | ||
| 340 | else => return err, | ||
| 341 | }; | ||
| 342 | self.output.replaceRangeAssumeCapacity(0, n, &.{}); | ||
| 343 | } | ||
| 344 | fn read(self: *Wire) !client.Incoming { | ||
| 345 | if (self.tr.link == .quic) return self.tr.readFrame(self.alloc); | ||
| 346 | var need: usize = proto.frame_header_len; | ||
| 347 | if (self.input.items.len >= proto.frame_header_len) { | ||
| 348 | const len = std.mem.readInt(u32, self.input.items[1..5], .little); | ||
| 349 | if (len > proto.max_payload) return error.FrameTooLarge; | ||
| 350 | need += len; | ||
| 351 | } | ||
| 352 | var buf: [64 * 1024]u8 = undefined; | ||
| 353 | const n = std.posix.read(self.tr.pollFd(), buf[0..@min(buf.len, need - self.input.items.len)]) catch |err| switch (err) { | ||
| 354 | error.WouldBlock => return .incomplete, | ||
| 355 | else => return err, | ||
| 356 | }; | ||
| 357 | if (n == 0) return .closed; | ||
| 358 | try self.input.appendSlice(self.alloc, buf[0..n]); | ||
| 359 | if (try proto.takeFrame(self.alloc, &self.input)) |frame| return .{ .frame = frame }; | ||
| 360 | return .incomplete; | ||
| 361 | } | ||
| 362 | }; | ||
| 363 | |||
| 364 | // Only a lost connection earns a redial. Resource exhaustion, poll errors, | ||
| 365 | // and other local failures must reach entry's failure publication. | ||
| 366 | fn connectionFailure(err: anyerror) anyerror!bool { | ||
| 367 | return switch (err) { | ||
| 368 | error.Closed, error.ConnectionLost, error.BrokenPipe, error.ConnectionResetByPeer, error.ConnectionTimedOut, error.SocketNotConnected => false, | ||
| 369 | else => err, | ||
| 370 | }; | ||
| 371 | } | ||
| 372 | |||
| 373 | fn nonblocking(fd: std.posix.fd_t) !void { | ||
| 374 | const flags = try std.posix.fcntl(fd, std.posix.F.GETFL, 0); | ||
| 375 | const bits: u32 = @bitCast(std.posix.O{ .NONBLOCK = true }); | ||
| 376 | _ = try std.posix.fcntl(fd, std.posix.F.SETFL, flags | bits); | ||
| 377 | } | ||
| 378 | fn closePipe(fds: [2]std.posix.fd_t) void { | ||
| 379 | for (fds) |fd| std.posix.close(fd); | ||
| 380 | } | ||
| 381 | fn ring(fd: std.posix.fd_t, byte: u8) void { | ||
| 382 | _ = std.posix.write(fd, &.{byte}) catch {}; | ||
| 383 | } | ||
| 384 | fn drain(fd: std.posix.fd_t) void { | ||
| 385 | var buf: [128]u8 = undefined; | ||
| 386 | while ((std.posix.read(fd, &buf) catch return) > 0) {} | ||
| 387 | } | ||
| 388 | |||
| 389 | const TestPeer = struct { | ||
| 390 | tmp: @import("testtmp").TmpDir, | ||
| 391 | listener: std.net.Server, | ||
| 392 | path: []u8, | ||
| 393 | |||
| 394 | fn init() !TestPeer { | ||
| 395 | const alloc = std.testing.allocator; | ||
| 396 | var tmp = try @import("testtmp").TmpDir.make(); | ||
| 397 | errdefer tmp.cleanup(); | ||
| 398 | const path = try std.fmt.allocPrint(alloc, "{s}/native.sock", .{tmp.path()}); | ||
| 399 | errdefer alloc.free(path); | ||
| 400 | const addr = try std.net.Address.initUnix(path); | ||
| 401 | return .{ .tmp = tmp, .path = path, .listener = try addr.listen(.{}) }; | ||
| 402 | } | ||
| 403 | fn deinit(self: *TestPeer) void { | ||
| 404 | self.listener.deinit(); | ||
| 405 | std.testing.allocator.free(self.path); | ||
| 406 | self.tmp.cleanup(); | ||
| 407 | } | ||
| 408 | fn start(self: *TestPeer) !*Pump { | ||
| 409 | return Pump.start(std.testing.allocator, .{ .target = .{ .sock = self.path }, .session = "native-test", .cols = 11, .rows = 3 }); | ||
| 410 | } | ||
| 411 | fn accept(self: *TestPeer) !std.net.Stream { | ||
| 412 | var fds = [_]std.posix.pollfd{.{ .fd = self.listener.stream.handle, .events = std.posix.POLL.IN, .revents = 0 }}; | ||
| 413 | if (try std.posix.poll(&fds, 2000) == 0) return error.AcceptTimeout; | ||
| 414 | return (try self.listener.accept()).stream; | ||
| 415 | } | ||
| 416 | }; | ||
| 417 | |||
| 418 | fn testFrame(stream: std.net.Stream, kind: proto.MsgType) !proto.Frame { | ||
| 419 | var l = client.Link{ .fd = stream.handle }; | ||
| 420 | return (try l.awaitFrame(std.testing.allocator, kind, 2000, .{})) orelse error.FrameTimeout; | ||
| 421 | } | ||
| 422 | fn testPhase(pump: *Pump, expected: Phase) !State { | ||
| 423 | const end = std.time.milliTimestamp() + 2000; | ||
| 424 | while (std.time.milliTimestamp() < end) { | ||
| 425 | const s = pump.state(); | ||
| 426 | if (s.phase == expected) return s; | ||
| 427 | std.Thread.sleep(std.time.ns_per_ms); | ||
| 428 | } | ||
| 429 | const s = pump.state(); | ||
| 430 | std.debug.print("session pump expected {s}, got {s}: {s}\n", .{ @tagName(expected), @tagName(s.phase), s.reasonText() }); | ||
| 431 | return error.PhaseTimeout; | ||
| 432 | } | ||
| 433 | fn testSnapshot() [34]u8 { | ||
| 434 | var bytes: [34]u8 = @splat(0); | ||
| 435 | proto.writeSnapshotPrefix(bytes[0..proto.snapshot_prefix_len], .{ .seq = 37, .history_rows = 0, .cols = 11, .rows = 3, .epoch = 93 }); | ||
| 436 | proto.writeSnapshotCursor(bytes[proto.snapshot_prefix_len..][0..proto.snapshot_cursor_len], 4, 2); | ||
| 437 | return bytes; | ||
| 438 | } | ||
| 439 | |||
| 440 | test "session pump idle snapshot services copied input resize and detach in order" { | ||
| 441 | var peer = try TestPeer.init(); | ||
| 442 | defer peer.deinit(); | ||
| 443 | const pump = try peer.start(); | ||
| 444 | defer pump.stop(); | ||
| 445 | const stream = try peer.accept(); | ||
| 446 | defer stream.close(); | ||
| 447 | const attach_frame = try testFrame(stream, .attach); | ||
| 448 | defer attach_frame.deinit(std.testing.allocator); | ||
| 449 | const args = try proto.decodeAttach(attach_frame.payload); | ||
| 450 | try std.testing.expectEqualStrings("native-test", args.name); | ||
| 451 | try std.testing.expectEqual(@as(u16, 11), args.cols); | ||
| 452 | try proto.writeFrame(stream.handle, .snapshot, &testSnapshot()); | ||
| 453 | _ = try testPhase(pump, .attached); | ||
| 454 | // The peer is now silent; mailbox processing must not wait for another | ||
| 455 | // inbound frame. The input source can be reused as soon as say returns. | ||
| 456 | var input = [_]u8{ 'h', 'i' }; | ||
| 457 | try pump.say(.{ .input = &input }); | ||
| 458 | @memset(&input, 'x'); | ||
| 459 | try pump.say(.{ .resize = .{ .cols = 19, .rows = 7 } }); | ||
| 460 | try pump.say(.detach); | ||
| 461 | const one = (try proto.readFrame(std.testing.allocator, stream.handle)).?; | ||
| 462 | defer one.deinit(std.testing.allocator); | ||
| 463 | try std.testing.expectEqual(proto.MsgType.input, one.type); | ||
| 464 | try std.testing.expectEqualStrings("hi", one.payload); | ||
| 465 | const two = (try proto.readFrame(std.testing.allocator, stream.handle)).?; | ||
| 466 | defer two.deinit(std.testing.allocator); | ||
| 467 | try std.testing.expectEqual(proto.MsgType.resize, two.type); | ||
| 468 | try std.testing.expectEqual(proto.Size{ .cols = 19, .rows = 7 }, try proto.decodeSize(two.payload)); | ||
| 469 | const three = (try proto.readFrame(std.testing.allocator, stream.handle)).?; | ||
| 470 | defer three.deinit(std.testing.allocator); | ||
| 471 | try std.testing.expectEqual(proto.MsgType.detach, three.type); | ||
| 472 | _ = try testPhase(pump, .exited); | ||
| 473 | } | ||
| 474 | |||
| 475 | test "session pump stop interrupts a partial socket frame" { | ||
| 476 | var peer = try TestPeer.init(); | ||
| 477 | defer peer.deinit(); | ||
| 478 | const pump = try peer.start(); | ||
| 479 | var stopped = false; | ||
| 480 | defer if (!stopped) pump.stop(); | ||
| 481 | const stream = try peer.accept(); | ||
| 482 | defer stream.close(); | ||
| 483 | const attach_frame = try testFrame(stream, .attach); | ||
| 484 | attach_frame.deinit(std.testing.allocator); | ||
| 485 | const hdr = proto.encodeHeader(.snapshot, 999); | ||
| 486 | try proto.writeAllFd(stream.handle, hdr[0..2]); | ||
| 487 | std.Thread.sleep(10 * std.time.ns_per_ms); | ||
| 488 | const start = std.time.milliTimestamp(); | ||
| 489 | pump.stop(); | ||
| 490 | stopped = true; | ||
| 491 | try std.testing.expect(std.time.milliTimestamp() - start < 500); | ||
| 492 | } | ||
| 493 | |||
| 494 | test "session pump reconnect resumes coordinates but refuses before new replay" { | ||
| 495 | var peer = try TestPeer.init(); | ||
| 496 | defer peer.deinit(); | ||
| 497 | const pump = try peer.start(); | ||
| 498 | defer pump.stop(); | ||
| 499 | { | ||
| 500 | const stream = try peer.accept(); | ||
| 501 | defer stream.close(); | ||
| 502 | const attach_frame = try testFrame(stream, .attach); | ||
| 503 | attach_frame.deinit(std.testing.allocator); | ||
| 504 | try proto.writeFrame(stream.handle, .snapshot, &testSnapshot()); | ||
| 505 | _ = try testPhase(pump, .attached); | ||
| 506 | } | ||
| 507 | const stream = try peer.accept(); | ||
| 508 | defer stream.close(); | ||
| 509 | const attach_frame = try testFrame(stream, .attach); | ||
| 510 | defer attach_frame.deinit(std.testing.allocator); | ||
| 511 | const args = try proto.decodeAttach(attach_frame.payload); | ||
| 512 | try std.testing.expectEqual(@as(u64, 37), args.have_seq); | ||
| 513 | try std.testing.expectEqual(@as(u64, 93), args.have_epoch); | ||
| 514 | try proto.writeFrame(stream.handle, .exit_status, &.{1}); | ||
| 515 | const state = try testPhase(pump, .refused); | ||
| 516 | try std.testing.expectEqual(@as(u8, 1), state.exit_code); | ||
| 517 | } | ||
| 518 | |||
| 519 | test "session pump malformed short snapshot skips and destructive snapshot fails" { | ||
| 520 | for ([_]bool{ false, true }) |destructive| { | ||
| 521 | var peer = try TestPeer.init(); | ||
| 522 | defer peer.deinit(); | ||
| 523 | const pump = try peer.start(); | ||
| 524 | defer pump.stop(); | ||
| 525 | const stream = try peer.accept(); | ||
| 526 | defer stream.close(); | ||
| 527 | const attach_frame = try testFrame(stream, .attach); | ||
| 528 | attach_frame.deinit(std.testing.allocator); | ||
| 529 | if (destructive) { | ||
| 530 | const snapshot = testSnapshot(); | ||
| 531 | try proto.writeFrame(stream.handle, .snapshot, snapshot[0..28]); | ||
| 532 | const state = try testPhase(pump, .failed); | ||
| 533 | try std.testing.expectEqualStrings("SnapshotAborted", state.reasonText()); | ||
| 534 | } else { | ||
| 535 | try proto.writeFrame(stream.handle, .snapshot, "short"); | ||
| 536 | try proto.writeFrame(stream.handle, .exit_status, &.{1}); | ||
| 537 | _ = try testPhase(pump, .refused); | ||
| 538 | pump.mu.lock(); | ||
| 539 | defer pump.mu.unlock(); | ||
| 540 | try std.testing.expectEqual(@as(u64, 0), pump.replica.last_seq); | ||
| 541 | } | ||
| 542 | } | ||
| 543 | } | ||
| 544 | |||
| 545 | test "session pump resync requests fresh snapshot and preserves shell exit code" { | ||
| 546 | var peer = try TestPeer.init(); | ||
| 547 | defer peer.deinit(); | ||
| 548 | const pump = try peer.start(); | ||
| 549 | defer pump.stop(); | ||
| 550 | const stream = try peer.accept(); | ||
| 551 | defer stream.close(); | ||
| 552 | const first = try testFrame(stream, .attach); | ||
| 553 | first.deinit(std.testing.allocator); | ||
| 554 | try proto.writeFrame(stream.handle, .snapshot, &testSnapshot()); | ||
| 555 | _ = try testPhase(pump, .attached); | ||
| 556 | try proto.writeFrame(stream.handle, .delta, "bad"); | ||
| 557 | const fresh = try testFrame(stream, .attach); | ||
| 558 | defer fresh.deinit(std.testing.allocator); | ||
| 559 | const args = try proto.decodeAttach(fresh.payload); | ||
| 560 | try std.testing.expectEqual(@as(u64, 0), args.have_seq); | ||
| 561 | try std.testing.expectEqual(@as(u64, 0), args.have_epoch); | ||
| 562 | try proto.writeFrame(stream.handle, .snapshot, &testSnapshot()); | ||
| 563 | try proto.writeFrame(stream.handle, .exit_status, &.{42}); | ||
| 564 | const ended = try testPhase(pump, .exited); | ||
| 565 | try std.testing.expectEqual(@as(u8, 42), ended.exit_code); | ||
| 566 | } | ||
| 567 | |||
| 568 | test "session pump initial dial failure reports common diagnostic and code two" { | ||
| 569 | var peer = try TestPeer.init(); | ||
| 570 | defer peer.deinit(); | ||
| 571 | const missing = try std.fmt.allocPrint(std.testing.allocator, "{s}/missing", .{peer.tmp.path()}); | ||
| 572 | defer std.testing.allocator.free(missing); | ||
| 573 | const pump = try Pump.start(std.testing.allocator, .{ .target = .{ .sock = missing }, .cols = 11, .rows = 3 }); | ||
| 574 | defer pump.stop(); | ||
| 575 | const state = try testPhase(pump, .dial_failed); | ||
| 576 | try std.testing.expectEqual(@as(u8, 2), state.exit_code); | ||
| 577 | try std.testing.expect(std.mem.indexOf(u8, state.reasonText(), missing) != null); | ||
| 578 | } | ||
| 579 | |||
| 580 | test "session pump pipe framing remains interruptible while peer sends no frames" { | ||
| 581 | const pump = try Pump.start(std.testing.allocator, .{ .target = .{ .via = "cat" }, .cols = 11, .rows = 3 }); | ||
| 582 | // cat echoes the attach and input as unknown inbound frame types. A | ||
| 583 | // draining blocking read would wait forever after that first frame. | ||
| 584 | try pump.say(.{ .input = "hello" }); | ||
| 585 | std.Thread.sleep(20 * std.time.ns_per_ms); | ||
| 586 | const start = std.time.milliTimestamp(); | ||
| 587 | pump.stop(); | ||
| 588 | try std.testing.expect(std.time.milliTimestamp() - start < 500); | ||
| 589 | } | ||
| 590 | |||
| 591 | test "session pump stop cancels a silent handoff dial" { | ||
| 592 | const pump = try Pump.start(std.testing.allocator, .{ | ||
| 593 | .target = .{ .hand = .{ .host = "isolated-test", .ssh_argv = &.{ "sleep", "2" }, .cache_path = null } }, | ||
| 594 | .cols = 11, | ||
| 595 | .rows = 3, | ||
| 596 | }); | ||
| 597 | std.Thread.sleep(30 * std.time.ns_per_ms); | ||
| 598 | const start = std.time.milliTimestamp(); | ||
| 599 | pump.stop(); | ||
| 600 | try std.testing.expect(std.time.milliTimestamp() - start < 500); | ||
| 601 | } | ||
| 602 | |||
| 603 | test "session pump stop cancels reconnect backoff and oversized frame publishes failure" { | ||
| 604 | for ([_]bool{ false, true }) |oversized| { | ||
| 605 | var peer = try TestPeer.init(); | ||
| 606 | defer peer.deinit(); | ||
| 607 | const pump = try peer.start(); | ||
| 608 | var stopped = false; | ||
| 609 | defer if (!stopped) pump.stop(); | ||
| 610 | { | ||
| 611 | const stream = try peer.accept(); | ||
| 612 | defer stream.close(); | ||
| 613 | const attach_frame = try testFrame(stream, .attach); | ||
| 614 | attach_frame.deinit(std.testing.allocator); | ||
| 615 | if (oversized) { | ||
| 616 | try proto.writeAllFd(stream.handle, &proto.encodeHeader(.snapshot, proto.max_payload + 1)); | ||
| 617 | const state = try testPhase(pump, .failed); | ||
| 618 | try std.testing.expectEqualStrings("FrameTooLarge", state.reasonText()); | ||
| 619 | } | ||
| 620 | } | ||
| 621 | if (!oversized) _ = try testPhase(pump, .reconnecting); | ||
| 622 | const start = std.time.milliTimestamp(); | ||
| 623 | pump.stop(); | ||
| 624 | stopped = true; | ||
| 625 | try std.testing.expect(std.time.milliTimestamp() - start < 150); | ||
| 626 | } | ||
| 627 | } | ||
| 628 | |||
| 629 | test "session pump bell is consumed and takeover wakes outside the grid mutex" { | ||
| 630 | const Wake = struct { | ||
| 631 | calls: std.atomic.Value(u32) = .init(0), | ||
| 632 | fn fire(ctx: ?*anyopaque) void { | ||
| 633 | const self: *@This() = @ptrCast(@alignCast(ctx.?)); | ||
| 634 | _ = self.calls.fetchAdd(1, .monotonic); | ||
| 635 | } | ||
| 636 | }; | ||
| 637 | var peer = try TestPeer.init(); | ||
| 638 | defer peer.deinit(); | ||
| 639 | var callback: Wake = .{}; | ||
| 640 | const pump = try Pump.start(std.testing.allocator, .{ .target = .{ .sock = peer.path }, .cols = 11, .rows = 3, .wake = Wake.fire, .wake_ctx = &callback }); | ||
| 641 | defer pump.stop(); | ||
| 642 | const stream = try peer.accept(); | ||
| 643 | defer stream.close(); | ||
| 644 | const attach_frame = try testFrame(stream, .attach); | ||
| 645 | attach_frame.deinit(std.testing.allocator); | ||
| 646 | try proto.writeFrame(stream.handle, .term_event, &.{1}); | ||
| 647 | const end = std.time.milliTimestamp() + 2000; | ||
| 648 | while (callback.calls.load(.monotonic) == 0 and std.time.milliTimestamp() < end) std.Thread.sleep(std.time.ns_per_ms); | ||
| 649 | try std.testing.expect(pump.state().bell); | ||
| 650 | try std.testing.expect(!pump.state().bell); | ||
| 651 | try proto.writeFrame(stream.handle, .taken_over, ""); | ||
| 652 | _ = try testPhase(pump, .taken); | ||
| 653 | try std.testing.expect(callback.calls.load(.monotonic) >= 2); | ||
| 654 | } | ||
| 655 | |||
| 656 | test "session pump unexpected errors escape reconnect classification" { | ||
| 657 | try std.testing.expect(!try connectionFailure(error.ConnectionResetByPeer)); | ||
| 658 | try std.testing.expectError(error.SystemResources, connectionFailure(error.SystemResources)); | ||
| 659 | try std.testing.expectError(error.Unexpected, connectionFailure(error.Unexpected)); | ||
| 660 | try std.testing.expectError(error.NotOpenForReading, connectionFailure(error.NotOpenForReading)); | ||
| 661 | } | ||
| 662 | |||
| 663 | test "session pump QUIC wakes and services mailbox between bounded buffered batches" { | ||
| 664 | const alloc = std.testing.allocator; | ||
| 665 | // A real QUIC client with an isolated UDP destination. Frames already | ||
| 666 | // delivered into its stream buffer need no new datagram to be consumed. | ||
| 667 | var addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, 0); | ||
| 668 | const fd = try std.posix.socket(std.posix.AF.INET, std.posix.SOCK.DGRAM | std.posix.SOCK.CLOEXEC, 0); | ||
| 669 | defer std.posix.close(fd); | ||
| 670 | try std.posix.bind(fd, &addr.any, addr.getOsSockLen()); | ||
| 671 | var addr_len = addr.getOsSockLen(); | ||
| 672 | try std.posix.getsockname(fd, &addr.any, &addr_len); | ||
| 673 | const q = try @import("quic").Client.connect(alloc, addr, .{ .bytes = @splat(7) }, 5000); | ||
| 674 | var tr: client.Transport = .{ .link = .{ .quic = .{ .cl = q, .alloc = alloc } } }; | ||
| 675 | defer tr.close(); | ||
| 676 | for (1..131) |seq| { | ||
| 677 | var snapshot = testSnapshot(); | ||
| 678 | std.mem.writeInt(u64, snapshot[0..8], seq, .little); | ||
| 679 | try proto.appendFrame(&q.in, alloc, .snapshot, &snapshot); | ||
| 680 | } | ||
| 681 | try proto.appendFrame(&q.in, alloc, .exit_status, &.{42}); | ||
| 682 | |||
| 683 | const Callback = struct { | ||
| 684 | pump: *Pump, | ||
| 685 | seqs: [3]u64 = @splat(0), | ||
| 686 | cols: [3]u16 = @splat(0), | ||
| 687 | n: usize = 0, | ||
| 688 | err: ?anyerror = null, | ||
| 689 | fn fire(ctx: ?*anyopaque) void { | ||
| 690 | const self: *@This() = @ptrCast(@alignCast(ctx.?)); | ||
| 691 | // Acquiring mu here also pins that wakes never hold the grid. | ||
| 692 | self.pump.mu.lock(); | ||
| 693 | if (self.n < 3) { | ||
| 694 | self.seqs[self.n] = self.pump.replica.last_seq; | ||
| 695 | self.cols[self.n] = self.pump.opts.cols; | ||
| 696 | } | ||
| 697 | self.pump.mu.unlock(); | ||
| 698 | self.n += 1; | ||
| 699 | if (self.n == 1) self.pump.say(.{ .resize = .{ .cols = 19, .rows = 7 } }) catch |err| { | ||
| 700 | self.err = err; | ||
| 701 | }; | ||
| 702 | } | ||
| 703 | }; | ||
| 704 | const pump = setup: { | ||
| 705 | const g = try term.grid.Grid.init(alloc, 11, 3); | ||
| 706 | errdefer g.deinit(); | ||
| 707 | const wp = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); | ||
| 708 | errdefer closePipe(wp); | ||
| 709 | const cp = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); | ||
| 710 | errdefer closePipe(cp); | ||
| 711 | const p = try alloc.create(Pump); | ||
| 712 | p.* = .{ .alloc = alloc, .opts = .{ .target = .{ .sock = "" }, .cols = 11, .rows = 3 }, .grid = g, .replica = .init(alloc, g), .wake_pipe = wp, .cancel_pipe = cp }; | ||
| 713 | break :setup p; | ||
| 714 | }; | ||
| 715 | var callback: Callback = .{ .pump = pump }; | ||
| 716 | pump.opts.wake = Callback.fire; | ||
| 717 | pump.opts.wake_ctx = &callback; | ||
| 718 | defer pump.stop(); | ||
| 719 | var wire = try Wire.init(alloc, &tr); | ||
| 720 | defer wire.deinit(); | ||
| 721 | const start = std.time.milliTimestamp(); | ||
| 722 | try std.testing.expect(try pump.connected(&wire)); | ||
| 723 | try std.testing.expect(std.time.milliTimestamp() - start < 500); | ||
| 724 | try std.testing.expectEqual(@as(?anyerror, null), callback.err); | ||
| 725 | try std.testing.expectEqual(@as(usize, 3), callback.n); | ||
| 726 | try std.testing.expectEqualSlices(u64, &.{ 64, 128, 130 }, &callback.seqs); | ||
| 727 | try std.testing.expectEqualSlices(u16, &.{ 11, 19, 19 }, &callback.cols); | ||
| 728 | try std.testing.expectEqual(@as(u8, 42), pump.state().exit_code); | ||
| 729 | const sent_attach = (try proto.takeFrame(alloc, &tr.link.quic.qout)).?; | ||
| 730 | defer sent_attach.deinit(alloc); | ||
| 731 | try std.testing.expectEqual(proto.MsgType.attach, sent_attach.type); | ||
| 732 | const sent_resize = (try proto.takeFrame(alloc, &tr.link.quic.qout)).?; | ||
| 733 | defer sent_resize.deinit(alloc); | ||
| 734 | try std.testing.expectEqual(proto.MsgType.resize, sent_resize.type); | ||
| 735 | } | ||
| 736 | |||
| 737 | test "session pump pipe snapshot paints then remains responsive while idle" { | ||
| 738 | const alloc = std.testing.allocator; | ||
| 739 | var tmp = try @import("testtmp").TmpDir.make(); | ||
| 740 | defer tmp.cleanup(); | ||
| 741 | var bytes: std.ArrayList(u8) = .empty; | ||
| 742 | defer bytes.deinit(alloc); | ||
| 743 | try proto.appendFrame(&bytes, alloc, .snapshot, &testSnapshot()); | ||
| 744 | try tmp.dir.writeFile(.{ .sub_path = "snapshot", .data = bytes.items }); | ||
| 745 | const command = try std.fmt.allocPrint(alloc, "cat {s}/snapshot -", .{tmp.path()}); | ||
| 746 | defer alloc.free(command); | ||
| 747 | const pump = try Pump.start(alloc, .{ .target = .{ .via = command }, .cols = 11, .rows = 3 }); | ||
| 748 | var stopped = false; | ||
| 749 | defer if (!stopped) pump.stop(); | ||
| 750 | _ = try testPhase(pump, .attached); | ||
| 751 | try pump.say(.{ .input = "after snapshot" }); | ||
| 752 | try pump.say(.{ .resize = .{ .cols = 19, .rows = 7 } }); | ||
| 753 | const start = std.time.milliTimestamp(); | ||
| 754 | pump.stop(); | ||
| 755 | stopped = true; | ||
| 756 | try std.testing.expect(std.time.milliTimestamp() - start < 500); | ||
| 757 | } | ||
src/gui/atlas.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,73 @@ | |||
| 1 | //! Grow-only shelf-packed R8 glyph texture. Coordinates never move. | ||
| 2 | const std = @import("std"); | ||
| 3 | |||
| 4 | pub const Variant = enum(u2) { regular, bold, italic, bold_italic }; | ||
| 5 | pub const Key = struct { variant: Variant, glyph_id: u32 }; | ||
| 6 | pub const Entry = struct { x: u16, y: u16, w: u16, h: u16, left: i16, top: i16 }; | ||
| 7 | |||
| 8 | pub const Atlas = struct { | ||
| 9 | width: u16, | ||
| 10 | height: u16, | ||
| 11 | pixels: []u8, | ||
| 12 | dirty: bool = false, | ||
| 13 | entries: std.AutoHashMapUnmanaged(Key, Entry) = .empty, | ||
| 14 | shelf_x: u16 = 0, | ||
| 15 | shelf_y: u16 = 0, | ||
| 16 | shelf_h: u16 = 0, | ||
| 17 | pub fn init(alloc: std.mem.Allocator, width: u16, height: u16) !Atlas { | ||
| 18 | if (width == 0 or height == 0) return error.AtlasFull; | ||
| 19 | const pixels = try alloc.alloc(u8, @as(usize, width) * height); | ||
| 20 | @memset(pixels, 0); | ||
| 21 | return .{ .width = width, .height = height, .pixels = pixels }; | ||
| 22 | } | ||
| 23 | pub fn deinit(self: *Atlas, alloc: std.mem.Allocator) void { | ||
| 24 | alloc.free(self.pixels); | ||
| 25 | self.entries.deinit(alloc); | ||
| 26 | } | ||
| 27 | pub fn get(self: *const Atlas, key: Key) ?Entry { | ||
| 28 | return self.entries.get(key); | ||
| 29 | } | ||
| 30 | pub fn put(self: *Atlas, alloc: std.mem.Allocator, key: Key, w: u16, h: u16, left: i16, top: i16, bitmap: []const u8) !Entry { | ||
| 31 | if (self.get(key)) |entry| return entry; | ||
| 32 | if (w > self.width) return error.GlyphTooWide; | ||
| 33 | if (bitmap.len < @as(usize, w) * h) return error.BadBitmap; | ||
| 34 | if (@as(u32, self.shelf_x) + w > self.width) { | ||
| 35 | self.shelf_y = std.math.add(u16, self.shelf_y, self.shelf_h) catch return error.AtlasFull; | ||
| 36 | self.shelf_x = 0; | ||
| 37 | self.shelf_h = 0; | ||
| 38 | } | ||
| 39 | while (@as(u32, self.shelf_y) + @max(self.shelf_h, h) > self.height) try self.grow(alloc); | ||
| 40 | const e: Entry = .{ .x = self.shelf_x, .y = self.shelf_y, .w = w, .h = h, .left = left, .top = top }; | ||
| 41 | for (0..h) |row| { | ||
| 42 | const dst = (@as(usize, e.y) + row) * self.width + e.x; | ||
| 43 | @memcpy(self.pixels[dst .. dst + w], bitmap[row * w .. row * w + w]); | ||
| 44 | } | ||
| 45 | self.shelf_x = std.math.add(u16, self.shelf_x, w) catch return error.AtlasFull; | ||
| 46 | self.shelf_h = @max(self.shelf_h, h); | ||
| 47 | self.dirty = true; | ||
| 48 | try self.entries.put(alloc, key, e); | ||
| 49 | return e; | ||
| 50 | } | ||
| 51 | fn grow(self: *Atlas, alloc: std.mem.Allocator) !void { | ||
| 52 | const h = std.math.mul(u16, self.height, 2) catch return error.AtlasFull; | ||
| 53 | const p = try alloc.alloc(u8, @as(usize, self.width) * h); | ||
| 54 | @memset(p, 0); | ||
| 55 | @memcpy(p[0..self.pixels.len], self.pixels); | ||
| 56 | alloc.free(self.pixels); | ||
| 57 | self.pixels = p; | ||
| 58 | self.height = h; | ||
| 59 | self.dirty = true; | ||
| 60 | } | ||
| 61 | }; | ||
| 62 | |||
| 63 | test "variant key separation and growth preserve coordinates" { | ||
| 64 | const a = std.testing.allocator; | ||
| 65 | var at = try Atlas.init(a, 4, 2); | ||
| 66 | defer at.deinit(a); | ||
| 67 | const px = [_]u8{ 1, 2, 3, 4 }; | ||
| 68 | const r = try at.put(a, .{ .variant = .regular, .glyph_id = 7 }, 2, 2, 0, 0, &px); | ||
| 69 | _ = try at.put(a, .{ .variant = .bold, .glyph_id = 7 }, 2, 2, 0, 0, &px); | ||
| 70 | _ = try at.put(a, .{ .variant = .italic, .glyph_id = 8 }, 2, 2, 0, 0, &px); | ||
| 71 | try std.testing.expect(at.height > 2); | ||
| 72 | try std.testing.expectEqual(r, at.get(.{ .variant = .regular, .glyph_id = 7 }).?); | ||
| 73 | } | ||
src/gui/bench.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,101 @@ | |||
| 1 | //! Per-frame stage timings. `total` is window work only; pump apply time is | ||
| 2 | //! reported beside it rather than folded into window latency. | ||
| 3 | const std = @import("std"); | ||
| 4 | |||
| 5 | pub const Frame = struct { | ||
| 6 | apply_us: u32 = 0, | ||
| 7 | rebuild_us: u32 = 0, | ||
| 8 | atlas_us: u32 = 0, | ||
| 9 | upload_us: u32 = 0, | ||
| 10 | draw_us: u32 = 0, | ||
| 11 | fn windowTotal(f: Frame) u32 { | ||
| 12 | return f.rebuild_us +| f.atlas_us +| f.upload_us +| f.draw_us; | ||
| 13 | } | ||
| 14 | }; | ||
| 15 | |||
| 16 | pub const Ring = struct { | ||
| 17 | pub const capacity = 256; | ||
| 18 | frames: [capacity]Frame = undefined, | ||
| 19 | next: usize = 0, | ||
| 20 | recorded: u64 = 0, | ||
| 21 | pub fn record(self: *Ring, f: Frame) void { | ||
| 22 | self.frames[self.next] = f; | ||
| 23 | self.next = (self.next + 1) % capacity; | ||
| 24 | self.recorded += 1; | ||
| 25 | } | ||
| 26 | pub fn count(self: *const Ring) u64 { | ||
| 27 | return self.recorded; | ||
| 28 | } | ||
| 29 | fn held(self: *const Ring) usize { | ||
| 30 | return @intCast(@min(self.recorded, capacity)); | ||
| 31 | } | ||
| 32 | const Stats = struct { min: u32, avg: u32, p99: u32, max: u32 }; | ||
| 33 | fn stats(self: *const Ring, comptime pick: fn (Frame) u32) Stats { | ||
| 34 | const n = self.held(); | ||
| 35 | var vals: [capacity]u32 = undefined; | ||
| 36 | var sum: u64 = 0; | ||
| 37 | for (self.frames[0..n], 0..) |f, i| { | ||
| 38 | vals[i] = pick(f); | ||
| 39 | sum += vals[i]; | ||
| 40 | } | ||
| 41 | std.mem.sort(u32, vals[0..n], {}, std.sort.asc(u32)); | ||
| 42 | const rank = (n * 99 + 99) / 100; | ||
| 43 | return .{ .min = vals[0], .avg = @intCast(sum / n), .p99 = vals[rank - 1], .max = vals[n - 1] }; | ||
| 44 | } | ||
| 45 | pub fn report(self: *const Ring, buf: []u8) []const u8 { | ||
| 46 | var w = std.io.Writer.fixed(buf); | ||
| 47 | w.print("=== muxg frame timing ({d} frames) ===\n", .{self.recorded}) catch return w.buffered(); | ||
| 48 | w.print("{s:<16}{s:>6}{s:>7}{s:>7}{s:>7} (us)\n", .{ "stage", "min", "avg", "p99", "max" }) catch return w.buffered(); | ||
| 49 | if (self.held() == 0) { | ||
| 50 | w.print("{s:<16}{d:>6}{d:>7}{d:>7}{d:>7}\n", .{ "total", 0, 0, 0, 0 }) catch {}; | ||
| 51 | return w.buffered(); | ||
| 52 | } | ||
| 53 | const rows = .{ | ||
| 54 | .{ "apply", struct { | ||
| 55 | fn f(x: Frame) u32 { | ||
| 56 | return x.apply_us; | ||
| 57 | } | ||
| 58 | }.f }, | ||
| 59 | .{ "rebuild", struct { | ||
| 60 | fn f(x: Frame) u32 { | ||
| 61 | return x.rebuild_us; | ||
| 62 | } | ||
| 63 | }.f }, | ||
| 64 | .{ "atlas_upload", struct { | ||
| 65 | fn f(x: Frame) u32 { | ||
| 66 | return x.atlas_us; | ||
| 67 | } | ||
| 68 | }.f }, | ||
| 69 | .{ "instance_upload", struct { | ||
| 70 | fn f(x: Frame) u32 { | ||
| 71 | return x.upload_us; | ||
| 72 | } | ||
| 73 | }.f }, | ||
| 74 | .{ "draw_swap", struct { | ||
| 75 | fn f(x: Frame) u32 { | ||
| 76 | return x.draw_us; | ||
| 77 | } | ||
| 78 | }.f }, | ||
| 79 | .{ "total", Frame.windowTotal }, | ||
| 80 | }; | ||
| 81 | inline for (rows) |row| { | ||
| 82 | const s = self.stats(row[1]); | ||
| 83 | w.print("{s:<16}{d:>6}{d:>7}{d:>7}{d:>7}\n", .{ row[0], s.min, s.avg, s.p99, s.max }) catch return w.buffered(); | ||
| 84 | } | ||
| 85 | return w.buffered(); | ||
| 86 | } | ||
| 87 | }; | ||
| 88 | |||
| 89 | pub fn usSince(timer: *std.time.Timer) u32 { | ||
| 90 | return @intCast(@min(timer.lap() / std.time.ns_per_us, std.math.maxInt(u32))); | ||
| 91 | } | ||
| 92 | |||
| 93 | test "total excludes apply and ring retains newest samples" { | ||
| 94 | var r: Ring = .{}; | ||
| 95 | var i: u32 = 0; | ||
| 96 | while (i < Ring.capacity + 10) : (i += 1) r.record(.{ .apply_us = 999, .rebuild_us = i, .draw_us = 1 }); | ||
| 97 | var buf: [2048]u8 = undefined; | ||
| 98 | const out = r.report(&buf); | ||
| 99 | try std.testing.expect(std.mem.indexOf(u8, out, "total 11") != null); | ||
| 100 | try std.testing.expectEqual(@as(u64, 266), r.count()); | ||
| 101 | } | ||
src/gui/font.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,252 @@ | |||
| 1 | //! Fontconfig-selected monospace variants, full per-cell HarfBuzz shaping, | ||
| 2 | //! and FreeType rasterization by glyph ID. No fallback or colour faces. | ||
| 3 | const std = @import("std"); | ||
| 4 | const atlas = @import("atlas.zig"); | ||
| 5 | const quads = @import("quads.zig"); | ||
| 6 | const c = @cImport({ | ||
| 7 | @cInclude("fontconfig/fontconfig.h"); | ||
| 8 | @cInclude("freetype2/freetype/freetype.h"); | ||
| 9 | @cInclude("freetype2/freetype/ftsynth.h"); | ||
| 10 | @cInclude("harfbuzz/hb.h"); | ||
| 11 | @cInclude("harfbuzz/hb-ft.h"); | ||
| 12 | }); | ||
| 13 | |||
| 14 | pub const Variant = atlas.Variant; | ||
| 15 | pub const PositionedGlyph = struct { glyph_id: u32, x_advance: i32, y_advance: i32, x_offset: i32, y_offset: i32 }; | ||
| 16 | pub const Run = struct { | ||
| 17 | glyphs: []PositionedGlyph, | ||
| 18 | pub fn deinit(self: *Run, alloc: std.mem.Allocator) void { | ||
| 19 | alloc.free(self.glyphs); | ||
| 20 | } | ||
| 21 | }; | ||
| 22 | pub const Glyph = struct { | ||
| 23 | w: u16, | ||
| 24 | h: u16, | ||
| 25 | left: i16, | ||
| 26 | top: i16, | ||
| 27 | pixels: []u8, | ||
| 28 | pub fn deinit(self: *Glyph, alloc: std.mem.Allocator) void { | ||
| 29 | alloc.free(self.pixels); | ||
| 30 | } | ||
| 31 | }; | ||
| 32 | const Handle = struct { face: c.FT_Face, hb: *c.hb_font_t, synth_bold: bool, synth_italic: bool }; | ||
| 33 | |||
| 34 | pub const Face = struct { | ||
| 35 | lib: c.FT_Library, | ||
| 36 | handles: [4]Handle, | ||
| 37 | cell_w: u16, | ||
| 38 | cell_h: u16, | ||
| 39 | ascent: u16, | ||
| 40 | pub const Error = error{ NoFontconfig, NoMonospaceFace, FreetypeInit, FaceLoad, SizeSet, Shape, GlyphLoad, OutOfMemory }; | ||
| 41 | fn handle(self: *Face, v: Variant) *Handle { | ||
| 42 | return &self.handles[@intFromEnum(v)]; | ||
| 43 | } | ||
| 44 | fn match(buf: *[std.fs.max_path_bytes]u8, index: *c_int, want_bold: bool, want_italic: bool, synth_bold: *bool, synth_italic: *bool) Error![]const u8 { | ||
| 45 | const pat = c.FcPatternCreate() orelse return error.NoMonospaceFace; | ||
| 46 | defer c.FcPatternDestroy(pat); | ||
| 47 | _ = c.FcPatternAddString(pat, c.FC_FAMILY, "monospace"); | ||
| 48 | _ = c.FcPatternAddInteger(pat, c.FC_WEIGHT, if (want_bold) c.FC_WEIGHT_BOLD else c.FC_WEIGHT_REGULAR); | ||
| 49 | _ = c.FcPatternAddInteger(pat, c.FC_SLANT, if (want_italic) c.FC_SLANT_ITALIC else c.FC_SLANT_ROMAN); | ||
| 50 | _ = c.FcConfigSubstitute(null, pat, c.FcMatchPattern); | ||
| 51 | c.FcDefaultSubstitute(pat); | ||
| 52 | var result: c.FcResult = undefined; | ||
| 53 | const found = c.FcFontMatch(null, pat, &result) orelse return error.NoMonospaceFace; | ||
| 54 | defer c.FcPatternDestroy(found); | ||
| 55 | var file: [*c]c.FcChar8 = null; | ||
| 56 | if (c.FcPatternGetString(found, c.FC_FILE, 0, &file) != c.FcResultMatch) return error.NoMonospaceFace; | ||
| 57 | if (c.FcPatternGetInteger(found, c.FC_INDEX, 0, index) != c.FcResultMatch) index.* = 0; | ||
| 58 | var weight: c_int = c.FC_WEIGHT_REGULAR; | ||
| 59 | var slant: c_int = c.FC_SLANT_ROMAN; | ||
| 60 | _ = c.FcPatternGetInteger(found, c.FC_WEIGHT, 0, &weight); | ||
| 61 | _ = c.FcPatternGetInteger(found, c.FC_SLANT, 0, &slant); | ||
| 62 | synth_bold.* = want_bold and weight < c.FC_WEIGHT_DEMIBOLD; | ||
| 63 | synth_italic.* = want_italic and slant == c.FC_SLANT_ROMAN; | ||
| 64 | const path = std.mem.span(@as([*:0]const u8, @ptrCast(file))); | ||
| 65 | if (path.len >= buf.len) return error.NoMonospaceFace; | ||
| 66 | @memcpy(buf[0..path.len], path); | ||
| 67 | buf[path.len] = 0; | ||
| 68 | return buf[0..path.len]; | ||
| 69 | } | ||
| 70 | pub fn open(px: u16) Error!Face { | ||
| 71 | if (c.FcInit() == c.FcFalse) return error.NoFontconfig; | ||
| 72 | var lib: c.FT_Library = null; | ||
| 73 | if (c.FT_Init_FreeType(&lib) != 0) return error.FreetypeInit; | ||
| 74 | errdefer _ = c.FT_Done_FreeType(lib); | ||
| 75 | var hs: [4]Handle = undefined; | ||
| 76 | var made: usize = 0; | ||
| 77 | errdefer for (hs[0..made]) |h| { | ||
| 78 | c.hb_font_destroy(h.hb); | ||
| 79 | _ = c.FT_Done_Face(h.face); | ||
| 80 | }; | ||
| 81 | for (0..4) |i| { | ||
| 82 | const v: Variant = @enumFromInt(i); | ||
| 83 | const bold = v == .bold or v == .bold_italic; | ||
| 84 | const italic = v == .italic or v == .bold_italic; | ||
| 85 | var pathbuf: [std.fs.max_path_bytes]u8 = undefined; | ||
| 86 | var idx: c_int = 0; | ||
| 87 | var synth_bold = false; | ||
| 88 | var synth_italic = false; | ||
| 89 | const path = try match(&pathbuf, &idx, bold, italic, &synth_bold, &synth_italic); | ||
| 90 | var ft: c.FT_Face = null; | ||
| 91 | if (c.FT_New_Face(lib, @ptrCast(path.ptr), idx, &ft) != 0) return error.FaceLoad; | ||
| 92 | if (c.FT_Set_Pixel_Sizes(ft, 0, px) != 0) { | ||
| 93 | _ = c.FT_Done_Face(ft); | ||
| 94 | return error.SizeSet; | ||
| 95 | } | ||
| 96 | const hb = c.hb_ft_font_create_referenced(ft) orelse { | ||
| 97 | _ = c.FT_Done_Face(ft); | ||
| 98 | return error.FaceLoad; | ||
| 99 | }; | ||
| 100 | hs[i] = .{ .face = ft, .hb = hb, .synth_bold = synth_bold, .synth_italic = synth_italic }; | ||
| 101 | made += 1; | ||
| 102 | } | ||
| 103 | const m = hs[0].face.*.size.*.metrics; | ||
| 104 | if (c.FT_Load_Char(hs[0].face, 'M', c.FT_LOAD_DEFAULT) != 0) return error.GlyphLoad; | ||
| 105 | const w = @max(@as(i64, @intCast((hs[0].face.*.glyph.*.advance.x + 63) >> 6)), 1); | ||
| 106 | const h = @max(@as(i64, @intCast((m.height + 63) >> 6)), 1); | ||
| 107 | const asc = @as(i64, @intCast((m.ascender + 63) >> 6)); | ||
| 108 | return .{ .lib = lib, .handles = hs, .cell_w = @intCast(w), .cell_h = @intCast(h), .ascent = @intCast(std.math.clamp(asc, 1, h)) }; | ||
| 109 | } | ||
| 110 | pub fn deinit(self: *Face) void { | ||
| 111 | for (&self.handles) |*h| { | ||
| 112 | c.hb_font_destroy(h.hb); | ||
| 113 | _ = c.FT_Done_Face(h.face); | ||
| 114 | } | ||
| 115 | _ = c.FT_Done_FreeType(self.lib); | ||
| 116 | } | ||
| 117 | pub fn shape(self: *Face, alloc: std.mem.Allocator, text: []const u8, v: Variant) Error!Run { | ||
| 118 | const b = c.hb_buffer_create() orelse return error.Shape; | ||
| 119 | defer c.hb_buffer_destroy(b); | ||
| 120 | c.hb_buffer_add_utf8(b, text.ptr, @intCast(text.len), 0, @intCast(text.len)); | ||
| 121 | c.hb_buffer_guess_segment_properties(b); | ||
| 122 | c.hb_shape(self.handle(v).hb, b, null, 0); | ||
| 123 | var n: c_uint = 0; | ||
| 124 | const infos = c.hb_buffer_get_glyph_infos(b, &n); | ||
| 125 | const pos = c.hb_buffer_get_glyph_positions(b, &n); | ||
| 126 | const out = try alloc.alloc(PositionedGlyph, n); | ||
| 127 | for (out, 0..) |*g, i| g.* = .{ .glyph_id = infos[i].codepoint, .x_advance = pos[i].x_advance, .y_advance = pos[i].y_advance, .x_offset = pos[i].x_offset, .y_offset = pos[i].y_offset }; | ||
| 128 | return .{ .glyphs = out }; | ||
| 129 | } | ||
| 130 | pub fn renderGlyph(self: *Face, alloc: std.mem.Allocator, v: Variant, id: u32) Error!Glyph { | ||
| 131 | const h = self.handle(v); | ||
| 132 | if (c.FT_Load_Glyph(h.face, id, c.FT_LOAD_DEFAULT) != 0) return error.GlyphLoad; | ||
| 133 | if (h.synth_bold) c.FT_GlyphSlot_Embolden(h.face.*.glyph); | ||
| 134 | if (h.synth_italic) c.FT_GlyphSlot_Oblique(h.face.*.glyph); | ||
| 135 | if (c.FT_Render_Glyph(h.face.*.glyph, c.FT_RENDER_MODE_NORMAL) != 0) return error.GlyphLoad; | ||
| 136 | const s = h.face.*.glyph; | ||
| 137 | const bm = s.*.bitmap; | ||
| 138 | const w: u16 = @intCast(bm.width); | ||
| 139 | const rows: u16 = @intCast(bm.rows); | ||
| 140 | const pixels = try alloc.alloc(u8, @as(usize, w) * rows); | ||
| 141 | for (0..rows) |r| { | ||
| 142 | const pitch: usize = @intCast(@abs(bm.pitch)); | ||
| 143 | const source_row = if (bm.pitch >= 0) r else @as(usize, rows) - 1 - r; | ||
| 144 | const src: [*]const u8 = @ptrCast(bm.buffer + pitch * source_row); | ||
| 145 | @memcpy(pixels[r * w .. r * w + w], src[0..w]); | ||
| 146 | } | ||
| 147 | return .{ .w = w, .h = rows, .left = @intCast(s.*.bitmap_left), .top = @intCast(s.*.bitmap_top), .pixels = pixels }; | ||
| 148 | } | ||
| 149 | }; | ||
| 150 | |||
| 151 | /// Owns complete shaped-cluster keys and the positioned atlas runs they map | ||
| 152 | /// to. Call `prepare` for every visible cell before quad generation; after | ||
| 153 | /// that, `resolve` performs no insertion, so one frame uses one atlas size. | ||
| 154 | pub const GlyphCache = struct { | ||
| 155 | alloc: std.mem.Allocator, | ||
| 156 | face: *Face, | ||
| 157 | glyph_atlas: *atlas.Atlas, | ||
| 158 | runs: std.StringHashMapUnmanaged([]quads.PositionedGlyph) = .empty, | ||
| 159 | |||
| 160 | pub fn deinit(self: *GlyphCache) void { | ||
| 161 | var it = self.runs.iterator(); | ||
| 162 | while (it.next()) |entry| { | ||
| 163 | self.alloc.free(entry.key_ptr.*); | ||
| 164 | self.alloc.free(entry.value_ptr.*); | ||
| 165 | } | ||
| 166 | self.runs.deinit(self.alloc); | ||
| 167 | } | ||
| 168 | fn key(self: *GlyphCache, text: []const u8, variant: Variant) ![]u8 { | ||
| 169 | const out = try self.alloc.alloc(u8, text.len + 1); | ||
| 170 | out[0] = @intFromEnum(variant); | ||
| 171 | @memcpy(out[1..], text); | ||
| 172 | return out; | ||
| 173 | } | ||
| 174 | pub fn prepare(self: *GlyphCache, text: []const u8, variant: Variant) !void { | ||
| 175 | var lookup_buf: [256]u8 = undefined; | ||
| 176 | if (text.len + 1 > lookup_buf.len) return error.ClusterTooLong; | ||
| 177 | lookup_buf[0] = @intFromEnum(variant); | ||
| 178 | @memcpy(lookup_buf[1 .. text.len + 1], text); | ||
| 179 | if (self.runs.contains(lookup_buf[0 .. text.len + 1])) return; | ||
| 180 | var shaped = try self.face.shape(self.alloc, text, variant); | ||
| 181 | defer shaped.deinit(self.alloc); | ||
| 182 | const placed = try self.alloc.alloc(quads.PositionedGlyph, shaped.glyphs.len); | ||
| 183 | errdefer self.alloc.free(placed); | ||
| 184 | for (shaped.glyphs, placed) |g, *p| { | ||
| 185 | const glyph_key: atlas.Key = .{ .variant = variant, .glyph_id = g.glyph_id }; | ||
| 186 | const entry = self.glyph_atlas.get(glyph_key) orelse blk: { | ||
| 187 | var bitmap = try self.face.renderGlyph(self.alloc, variant, g.glyph_id); | ||
| 188 | defer bitmap.deinit(self.alloc); | ||
| 189 | break :blk try self.glyph_atlas.put(self.alloc, glyph_key, bitmap.w, bitmap.h, bitmap.left, bitmap.top, bitmap.pixels); | ||
| 190 | }; | ||
| 191 | p.* = .{ .entry = entry, .x_advance = g.x_advance, .y_advance = g.y_advance, .x_offset = g.x_offset, .y_offset = g.y_offset }; | ||
| 192 | } | ||
| 193 | const owned = try self.key(text, variant); | ||
| 194 | errdefer self.alloc.free(owned); | ||
| 195 | try self.runs.put(self.alloc, owned, placed); | ||
| 196 | } | ||
| 197 | pub fn resolve(ctx: *anyopaque, text: []const u8, variant: Variant) anyerror![]const quads.PositionedGlyph { | ||
| 198 | const self: *GlyphCache = @ptrCast(@alignCast(ctx)); | ||
| 199 | var lookup_buf: [256]u8 = undefined; | ||
| 200 | if (text.len + 1 > lookup_buf.len) return error.ClusterTooLong; | ||
| 201 | lookup_buf[0] = @intFromEnum(variant); | ||
| 202 | @memcpy(lookup_buf[1 .. text.len + 1], text); | ||
| 203 | return self.runs.get(lookup_buf[0 .. text.len + 1]) orelse error.RunNotPrepared; | ||
| 204 | } | ||
| 205 | }; | ||
| 206 | |||
| 207 | test "complete combining cluster shapes without truncation" { | ||
| 208 | var f = try Face.open(16); | ||
| 209 | defer f.deinit(); | ||
| 210 | var r = try f.shape(std.testing.allocator, "e\xcc\x81", .regular); | ||
| 211 | defer r.deinit(std.testing.allocator); | ||
| 212 | try std.testing.expect(r.glyphs.len > 0); | ||
| 213 | } | ||
| 214 | |||
| 215 | test "cell width is the shaped monospace M advance" { | ||
| 216 | var f = try Face.open(16); | ||
| 217 | defer f.deinit(); | ||
| 218 | var run = try f.shape(std.testing.allocator, "M", .regular); | ||
| 219 | defer run.deinit(std.testing.allocator); | ||
| 220 | try std.testing.expect(run.glyphs.len > 0); | ||
| 221 | var advance: i32 = 0; | ||
| 222 | for (run.glyphs) |g| advance += g.x_advance; | ||
| 223 | try std.testing.expectEqual(@as(i32, f.cell_w), @divTrunc(advance + 63, 64)); | ||
| 224 | } | ||
| 225 | |||
| 226 | test "cache owns complete styled cluster and preserves shaping through atlas growth" { | ||
| 227 | const alloc = std.testing.allocator; | ||
| 228 | var face = try Face.open(16); | ||
| 229 | defer face.deinit(); | ||
| 230 | var glyph_atlas = try atlas.Atlas.init(alloc, 128, 1); | ||
| 231 | defer glyph_atlas.deinit(alloc); | ||
| 232 | var cache: GlyphCache = .{ .alloc = alloc, .face = &face, .glyph_atlas = &glyph_atlas }; | ||
| 233 | defer cache.deinit(); | ||
| 234 | |||
| 235 | var source = [_]u8{ 'e', 0xcc, 0x81 }; | ||
| 236 | var expected = try face.shape(alloc, &source, .bold_italic); | ||
| 237 | defer expected.deinit(alloc); | ||
| 238 | try cache.prepare(&source, .bold_italic); | ||
| 239 | source[0] = 'x'; | ||
| 240 | const cached = try GlyphCache.resolve(@ptrCast(&cache), "e\xcc\x81", .bold_italic); | ||
| 241 | try std.testing.expectEqual(expected.glyphs.len, cached.len); | ||
| 242 | try std.testing.expect(glyph_atlas.height > 1); | ||
| 243 | for (expected.glyphs, cached) |want, got| { | ||
| 244 | try std.testing.expectEqual(want.x_advance, got.x_advance); | ||
| 245 | try std.testing.expectEqual(want.y_advance, got.y_advance); | ||
| 246 | try std.testing.expectEqual(want.x_offset, got.x_offset); | ||
| 247 | try std.testing.expectEqual(want.y_offset, got.y_offset); | ||
| 248 | try std.testing.expectEqual(got.entry, glyph_atlas.get(.{ .variant = .bold_italic, .glyph_id = want.glyph_id }).?); | ||
| 249 | const v1 = @as(f32, @floatFromInt(got.entry.y + got.entry.h)) / @as(f32, @floatFromInt(glyph_atlas.height)); | ||
| 250 | try std.testing.expect(v1 <= 1.0); | ||
| 251 | } | ||
| 252 | } | ||
src/gui/frame.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,4 +1,490 @@ | |||
| 1 | //! Window integration boundary for the native session painter. | 1 | //! The window thread: SDL owns the window and the GL context; this file |
| 2 | //! owns the loop. It waits on SDL's event queue; a wake from the pump or a | ||
| 3 | //! resize locks the replica, rebuilds every instance from the whole grid, | ||
| 4 | //! unlocks, uploads, draws and swaps. Keys become keymap events, text | ||
| 5 | //! becomes input bytes, and both go to the pump's mailbox. The ONE file | ||
| 6 | //! under src/gui/ that names SDL (folder rule 9). | ||
| 2 | //! | 7 | //! |
| 3 | //! The first sprint establishes this ownership boundary. The event loop is | 8 | //! Whole-grid rebuild every frame is deliberate for v1: a large window is |
| 4 | //! added with the painter; the executable currently performs the GL probe. | 9 | //! on the order of ten thousand cells, and the timing table is what will |
| 10 | //! say whether dirty rows ever matter. | ||
| 11 | const std = @import("std"); | ||
| 12 | const client = @import("client"); | ||
| 13 | const term = @import("term"); | ||
| 14 | const keymap = client.keymap; | ||
| 15 | const session_pump = client.session_pump; | ||
| 16 | const font = @import("font.zig"); | ||
| 17 | const atlas = @import("atlas.zig"); | ||
| 18 | const quads = @import("quads.zig"); | ||
| 19 | const gl = @import("gl.zig"); | ||
| 20 | const bench = @import("bench.zig"); | ||
| 21 | |||
| 22 | const c = @cImport({ | ||
| 23 | @cInclude("SDL3/SDL.h"); | ||
| 24 | }); | ||
| 25 | |||
| 26 | pub const Options = struct { | ||
| 27 | target: client.Target, | ||
| 28 | session: []const u8, | ||
| 29 | font_px: u16 = 16, | ||
| 30 | width: u32 = 960, | ||
| 31 | height: u32 = 600, | ||
| 32 | /// The e2e leg's hook: a FIFO of `text:`/`key:`/`resize:`/`quit` lines. | ||
| 33 | test_fifo: ?[]const u8 = null, | ||
| 34 | }; | ||
| 35 | |||
| 36 | pub const CellsOf = struct { cols: u16, rows: u16 }; | ||
| 37 | |||
| 38 | pub fn cellsOf(px_w: u32, px_h: u32, cell_w: u16, cell_h: u16) CellsOf { | ||
| 39 | return .{ | ||
| 40 | .cols = @intCast(@min(@max(px_w / @max(cell_w, 1), 1), term.protocol.max_cols)), | ||
| 41 | .rows = @intCast(@min(@max(px_h / @max(cell_h, 1), 1), std.math.maxInt(u16))), | ||
| 42 | }; | ||
| 43 | } | ||
| 44 | |||
| 45 | /// SDL keycode + mods → the keymap's event, or null for a key that types | ||
| 46 | /// (text input carries it) or means nothing to a session. | ||
| 47 | pub fn keyEvent(key: u32, mod: u16) ?keymap.Event { | ||
| 48 | const ctrl = mod & c.SDL_KMOD_CTRL != 0; | ||
| 49 | const alt = mod & c.SDL_KMOD_ALT != 0; | ||
| 50 | const shift = mod & c.SDL_KMOD_SHIFT != 0; | ||
| 51 | if (mod & c.SDL_KMOD_MODE != 0 or (ctrl and mod & c.SDL_KMOD_RALT != 0)) return null; | ||
| 52 | const mods: keymap.Mods = .{ .ctrl = ctrl, .alt = alt, .shift = shift }; | ||
| 53 | const named: ?keymap.Key = switch (key) { | ||
| 54 | c.SDLK_RETURN, c.SDLK_KP_ENTER => .enter, | ||
| 55 | c.SDLK_TAB => .tab, | ||
| 56 | c.SDLK_BACKSPACE => .backspace, | ||
| 57 | c.SDLK_ESCAPE => .escape, | ||
| 58 | c.SDLK_UP => .up, | ||
| 59 | c.SDLK_DOWN => .down, | ||
| 60 | c.SDLK_LEFT => .left, | ||
| 61 | c.SDLK_RIGHT => .right, | ||
| 62 | c.SDLK_HOME => .home, | ||
| 63 | c.SDLK_END => .end, | ||
| 64 | c.SDLK_INSERT => .insert, | ||
| 65 | c.SDLK_DELETE => .delete, | ||
| 66 | c.SDLK_PAGEUP => .page_up, | ||
| 67 | c.SDLK_PAGEDOWN => .page_down, | ||
| 68 | c.SDLK_F1 => .f1, | ||
| 69 | c.SDLK_F2 => .f2, | ||
| 70 | c.SDLK_F3 => .f3, | ||
| 71 | c.SDLK_F4 => .f4, | ||
| 72 | c.SDLK_F5 => .f5, | ||
| 73 | c.SDLK_F6 => .f6, | ||
| 74 | c.SDLK_F7 => .f7, | ||
| 75 | c.SDLK_F8 => .f8, | ||
| 76 | c.SDLK_F9 => .f9, | ||
| 77 | c.SDLK_F10 => .f10, | ||
| 78 | c.SDLK_F11 => .f11, | ||
| 79 | c.SDLK_F12 => .f12, | ||
| 80 | else => null, | ||
| 81 | }; | ||
| 82 | if (named) |k| return .{ .key = k, .mods = mods }; | ||
| 83 | // Modifier chords are encoded here; the event handler suppresses any | ||
| 84 | // matching text event to avoid sending the character twice. | ||
| 85 | if ((ctrl or alt) and key >= 0x20 and key < 0x7f) { | ||
| 86 | return .{ .key = .char, .cp = @intCast(key), .mods = mods }; | ||
| 87 | } | ||
| 88 | return null; | ||
| 89 | } | ||
| 90 | |||
| 91 | pub const Hook = union(enum) { | ||
| 92 | text: []const u8, | ||
| 93 | key: keymap.Key, | ||
| 94 | resize: struct { w: u32, h: u32 }, | ||
| 95 | capture: []const u8, | ||
| 96 | quit, | ||
| 97 | }; | ||
| 98 | |||
| 99 | pub fn parseHook(line: []const u8) ?Hook { | ||
| 100 | if (std.mem.eql(u8, line, "quit")) return .quit; | ||
| 101 | if (std.mem.startsWith(u8, line, "capture:")) return .{ .capture = line[8..] }; | ||
| 102 | if (std.mem.startsWith(u8, line, "text:")) return .{ .text = line["text:".len..] }; | ||
| 103 | if (std.mem.startsWith(u8, line, "key:")) { | ||
| 104 | const name = line["key:".len..]; | ||
| 105 | inline for (.{ "enter", "tab", "escape", "backspace", "up", "down", "left", "right" }) |n| { | ||
| 106 | if (std.mem.eql(u8, name, n)) return .{ .key = @field(keymap.Key, n) }; | ||
| 107 | } | ||
| 108 | return null; | ||
| 109 | } | ||
| 110 | if (std.mem.startsWith(u8, line, "resize:")) { | ||
| 111 | const rest = line["resize:".len..]; | ||
| 112 | const x = std.mem.indexOfScalar(u8, rest, 'x') orelse return null; | ||
| 113 | const w = std.fmt.parseInt(u32, rest[0..x], 10) catch return null; | ||
| 114 | const h = std.fmt.parseInt(u32, rest[x + 1 ..], 10) catch return null; | ||
| 115 | if (w == 0 or h == 0 or w > 16384 or h > 16384) return null; | ||
| 116 | return .{ .resize = .{ .w = w, .h = h } }; | ||
| 117 | } | ||
| 118 | return null; | ||
| 119 | } | ||
| 120 | |||
| 121 | var usr1_seen = std.atomic.Value(bool).init(false); | ||
| 122 | |||
| 123 | fn onUsr1(_: c_int) callconv(.c) void { | ||
| 124 | usr1_seen.store(true, .release); | ||
| 125 | } | ||
| 126 | |||
| 127 | const Wake = struct { | ||
| 128 | event_type: u32, | ||
| 129 | pending: std.atomic.Value(bool) = .init(false), | ||
| 130 | |||
| 131 | fn ring(ctx: ?*anyopaque) void { | ||
| 132 | const self: *Wake = @ptrCast(@alignCast(ctx.?)); | ||
| 133 | if (self.pending.swap(true, .acq_rel)) return; | ||
| 134 | var ev: c.SDL_Event = std.mem.zeroes(c.SDL_Event); | ||
| 135 | ev.type = self.event_type; | ||
| 136 | if (!c.SDL_PushEvent(&ev)) self.pending.store(false, .release); | ||
| 137 | } | ||
| 138 | }; | ||
| 139 | |||
| 140 | /// The optional test FIFO is read without blocking on the window thread. | ||
| 141 | /// It injects ordinary input events and calls the actual window resize API. | ||
| 142 | /// No detached worker can outlive the window or retain text-event pointers. | ||
| 143 | const HookReader = struct { | ||
| 144 | alloc: std.mem.Allocator, | ||
| 145 | fd: std.posix.fd_t, | ||
| 146 | bytes: [8192]u8 = undefined, | ||
| 147 | used: usize = 0, | ||
| 148 | text: std.ArrayListUnmanaged([:0]u8) = .empty, | ||
| 149 | capture: ?[]u8 = null, | ||
| 150 | |||
| 151 | fn init(alloc: std.mem.Allocator, path: []const u8) !HookReader { | ||
| 152 | return .{ .alloc = alloc, .fd = try std.posix.open(path, .{ .ACCMODE = .RDONLY, .NONBLOCK = true, .CLOEXEC = true }, 0) }; | ||
| 153 | } | ||
| 154 | |||
| 155 | fn deinit(self: *HookReader) void { | ||
| 156 | std.posix.close(self.fd); | ||
| 157 | for (self.text.items) |t| self.alloc.free(t); | ||
| 158 | self.text.deinit(self.alloc); | ||
| 159 | if (self.capture) |p| self.alloc.free(p); | ||
| 160 | } | ||
| 161 | |||
| 162 | fn releaseText(self: *HookReader, p: [*c]const u8) void { | ||
| 163 | for (self.text.items, 0..) |t, i| { | ||
| 164 | if (t.ptr == p) { | ||
| 165 | self.alloc.free(self.text.swapRemove(i)); | ||
| 166 | return; | ||
| 167 | } | ||
| 168 | } | ||
| 169 | } | ||
| 170 | |||
| 171 | fn read(self: *HookReader, win: *c.SDL_Window) !void { | ||
| 172 | // Bound hook traffic just like the normal event queue. | ||
| 173 | const n = std.posix.read(self.fd, self.bytes[self.used..]) catch |err| switch (err) { | ||
| 174 | error.WouldBlock => return, | ||
| 175 | else => return err, | ||
| 176 | }; | ||
| 177 | self.used += n; | ||
| 178 | var start: usize = 0; | ||
| 179 | while (std.mem.indexOfScalarPos(u8, self.bytes[0..self.used], start, '\n')) |end| { | ||
| 180 | if (parseHook(self.bytes[start..end])) |hook| try self.inject(win, hook); | ||
| 181 | start = end + 1; | ||
| 182 | } | ||
| 183 | std.mem.copyForwards(u8, &self.bytes, self.bytes[start..self.used]); | ||
| 184 | self.used -= start; | ||
| 185 | if (self.used == self.bytes.len) return error.TestHookLineTooLong; | ||
| 186 | } | ||
| 187 | |||
| 188 | fn inject(self: *HookReader, win: *c.SDL_Window, hook: Hook) !void { | ||
| 189 | var ev: c.SDL_Event = std.mem.zeroes(c.SDL_Event); | ||
| 190 | switch (hook) { | ||
| 191 | .text => |t| { | ||
| 192 | const z = try self.alloc.dupeZ(u8, t); | ||
| 193 | errdefer self.alloc.free(z); | ||
| 194 | try self.text.append(self.alloc, z); | ||
| 195 | ev.text.type = c.SDL_EVENT_TEXT_INPUT; | ||
| 196 | ev.text.windowID = c.SDL_GetWindowID(win); | ||
| 197 | ev.text.text = z.ptr; | ||
| 198 | }, | ||
| 199 | .key => |k| { | ||
| 200 | ev.key.type = c.SDL_EVENT_KEY_DOWN; | ||
| 201 | ev.key.windowID = c.SDL_GetWindowID(win); | ||
| 202 | ev.key.key = switch (k) { | ||
| 203 | .enter => c.SDLK_RETURN, | ||
| 204 | .tab => c.SDLK_TAB, | ||
| 205 | .escape => c.SDLK_ESCAPE, | ||
| 206 | .backspace => c.SDLK_BACKSPACE, | ||
| 207 | .up => c.SDLK_UP, | ||
| 208 | .down => c.SDLK_DOWN, | ||
| 209 | .left => c.SDLK_LEFT, | ||
| 210 | .right => c.SDLK_RIGHT, | ||
| 211 | else => return, | ||
| 212 | }; | ||
| 213 | }, | ||
| 214 | .resize => |r| { | ||
| 215 | if (!c.SDL_SetWindowSize(win, @intCast(r.w), @intCast(r.h))) return error.WindowResizeFailed; | ||
| 216 | return; | ||
| 217 | }, | ||
| 218 | .capture => |p| { | ||
| 219 | const copy = try self.alloc.dupe(u8, p); | ||
| 220 | if (self.capture) |old| self.alloc.free(old); | ||
| 221 | self.capture = copy; | ||
| 222 | return; | ||
| 223 | }, | ||
| 224 | .quit => ev.type = c.SDL_EVENT_QUIT, | ||
| 225 | } | ||
| 226 | if (!c.SDL_PushEvent(&ev)) { | ||
| 227 | if (hook == .text) self.releaseText(ev.text.text); | ||
| 228 | return error.EventInjectionFailed; | ||
| 229 | } | ||
| 230 | } | ||
| 231 | }; | ||
| 232 | |||
| 233 | fn setTitle(win: *c.SDL_Window, session: []const u8, reconnecting: bool, bell: bool) void { | ||
| 234 | var buf: [192]u8 = undefined; | ||
| 235 | const title = std.fmt.bufPrintZ(&buf, "muxg {s}{s}{s}", .{ session, if (reconnecting) " [reconnecting]" else "", if (bell) " [bell]" else "" }) catch "muxg"; | ||
| 236 | _ = c.SDL_SetWindowTitle(win, title.ptr); | ||
| 237 | } | ||
| 238 | |||
| 239 | fn sdlFail(op: []const u8) u8 { | ||
| 240 | std.debug.print("muxg: {s}: {s}\n", .{ op, std.mem.span(c.SDL_GetError()) }); | ||
| 241 | return 2; | ||
| 242 | } | ||
| 243 | |||
| 244 | const Events = struct { | ||
| 245 | pump: *session_pump.Pump, | ||
| 246 | win: *c.SDL_Window, | ||
| 247 | wake: *Wake, | ||
| 248 | hook: ?*HookReader, | ||
| 249 | cell_w: u16, | ||
| 250 | cell_h: u16, | ||
| 251 | cells: CellsOf, | ||
| 252 | fb_w: c_int, | ||
| 253 | fb_h: c_int, | ||
| 254 | dirty: bool = true, | ||
| 255 | suppress_text: bool = false, | ||
| 256 | |||
| 257 | fn handle(self: *Events, ev: c.SDL_Event) !bool { | ||
| 258 | switch (ev.type) { | ||
| 259 | c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => { | ||
| 260 | try self.pump.say(.detach); | ||
| 261 | return false; | ||
| 262 | }, | ||
| 263 | c.SDL_EVENT_TEXT_INPUT => { | ||
| 264 | defer if (self.hook) |h| h.releaseText(ev.text.text); | ||
| 265 | if (!self.suppress_text) try self.pump.say(.{ .input = std.mem.span(ev.text.text) }); | ||
| 266 | self.suppress_text = false; | ||
| 267 | }, | ||
| 268 | c.SDL_EVENT_KEY_DOWN => { | ||
| 269 | self.suppress_text = false; | ||
| 270 | // Event keycodes normally ignore Shift. Ask the active layout | ||
| 271 | // for its translated character before encoding a modifier chord. | ||
| 272 | const translated = if (ev.key.scancode != c.SDL_SCANCODE_UNKNOWN) | ||
| 273 | c.SDL_GetKeyFromScancode(ev.key.scancode, ev.key.mod, false) | ||
| 274 | else | ||
| 275 | ev.key.key; | ||
| 276 | if (keyEvent(translated, ev.key.mod)) |key| { | ||
| 277 | var buf: [keymap.max_seq_len]u8 = undefined; | ||
| 278 | const bytes = keymap.encode(key, &buf); | ||
| 279 | if (bytes.len != 0) try self.pump.say(.{ .input = bytes }); | ||
| 280 | self.suppress_text = key.key == .char; | ||
| 281 | } | ||
| 282 | }, | ||
| 283 | c.SDL_EVENT_KEY_UP => self.suppress_text = false, | ||
| 284 | c.SDL_EVENT_WINDOW_FOCUS_LOST => self.suppress_text = false, | ||
| 285 | c.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED, c.SDL_EVENT_WINDOW_RESIZED => { | ||
| 286 | if (!c.SDL_GetWindowSizeInPixels(self.win, &self.fb_w, &self.fb_h)) return error.WindowSizeFailed; | ||
| 287 | const now = cellsOf(@intCast(@max(self.fb_w, 1)), @intCast(@max(self.fb_h, 1)), self.cell_w, self.cell_h); | ||
| 288 | if (!std.meta.eql(now, self.cells)) { | ||
| 289 | self.cells = now; | ||
| 290 | try self.pump.say(.{ .resize = .{ .cols = now.cols, .rows = now.rows } }); | ||
| 291 | } | ||
| 292 | self.dirty = true; | ||
| 293 | }, | ||
| 294 | c.SDL_EVENT_WINDOW_EXPOSED => self.dirty = true, | ||
| 295 | else => if (ev.type == self.wake.event_type) { | ||
| 296 | self.dirty = true; | ||
| 297 | }, | ||
| 298 | } | ||
| 299 | return true; | ||
| 300 | } | ||
| 301 | }; | ||
| 302 | |||
| 303 | pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 { | ||
| 304 | var ring: bench.Ring = .{}; | ||
| 305 | defer report(&ring); | ||
| 306 | usr1_seen.store(false, .release); | ||
| 307 | const sa: std.posix.Sigaction = .{ .handler = .{ .handler = onUsr1 }, .mask = std.posix.sigemptyset(), .flags = 0 }; | ||
| 308 | var old_sa: std.posix.Sigaction = undefined; | ||
| 309 | std.posix.sigaction(std.posix.SIG.USR1, &sa, &old_sa); | ||
| 310 | defer std.posix.sigaction(std.posix.SIG.USR1, &old_sa, null); | ||
| 311 | |||
| 312 | var face = font.Face.open(opts.font_px) catch |err| { | ||
| 313 | std.debug.print("muxg: font: {s}\n", .{@errorName(err)}); | ||
| 314 | return 2; | ||
| 315 | }; | ||
| 316 | defer face.deinit(); | ||
| 317 | if (!c.SDL_Init(c.SDL_INIT_VIDEO)) return sdlFail("SDL_Init"); | ||
| 318 | defer c.SDL_Quit(); | ||
| 319 | _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MAJOR_VERSION, 3); | ||
| 320 | _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MINOR_VERSION, 3); | ||
| 321 | _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_PROFILE_MASK, c.SDL_GL_CONTEXT_PROFILE_CORE); | ||
| 322 | const win = c.SDL_CreateWindow("muxg", @intCast(opts.width), @intCast(opts.height), c.SDL_WINDOW_OPENGL | c.SDL_WINDOW_RESIZABLE | c.SDL_WINDOW_HIGH_PIXEL_DENSITY) orelse return sdlFail("SDL_CreateWindow"); | ||
| 323 | defer c.SDL_DestroyWindow(win); | ||
| 324 | const context = c.SDL_GL_CreateContext(win) orelse return sdlFail("SDL_GL_CreateContext"); | ||
| 325 | defer _ = c.SDL_GL_DestroyContext(context); | ||
| 326 | _ = c.SDL_GL_SetSwapInterval(1); | ||
| 327 | var renderer = gl.Renderer.init(@ptrCast(&c.SDL_GL_GetProcAddress)) catch |err| { | ||
| 328 | std.debug.print("muxg: OpenGL: {s}\n", .{@errorName(err)}); | ||
| 329 | return 2; | ||
| 330 | }; | ||
| 331 | defer renderer.deinit(); | ||
| 332 | if (!c.SDL_StartTextInput(win)) return sdlFail("SDL_StartTextInput"); | ||
| 333 | var glyph_atlas = try atlas.Atlas.init(alloc, 1024, 256); | ||
| 334 | defer glyph_atlas.deinit(alloc); | ||
| 335 | var cache: font.GlyphCache = .{ .alloc = alloc, .face = &face, .glyph_atlas = &glyph_atlas }; | ||
| 336 | defer cache.deinit(); | ||
| 337 | var lists: quads.Lists = .{}; | ||
| 338 | defer lists.deinit(alloc); | ||
| 339 | var instances: std.ArrayListUnmanaged(quads.Instance) = .empty; | ||
| 340 | defer instances.deinit(alloc); | ||
| 341 | |||
| 342 | var fb_w: c_int = 0; | ||
| 343 | var fb_h: c_int = 0; | ||
| 344 | if (!c.SDL_GetWindowSizeInPixels(win, &fb_w, &fb_h)) return sdlFail("SDL_GetWindowSizeInPixels"); | ||
| 345 | const cells = cellsOf(@intCast(@max(fb_w, 1)), @intCast(@max(fb_h, 1)), face.cell_w, face.cell_h); | ||
| 346 | var wake: Wake = .{ .event_type = c.SDL_RegisterEvents(1) }; | ||
| 347 | if (wake.event_type == 0) return sdlFail("SDL_RegisterEvents"); | ||
| 348 | const pump = try session_pump.Pump.start(alloc, .{ .target = opts.target, .session = opts.session, .cols = cells.cols, .rows = cells.rows, .wake = Wake.ring, .wake_ctx = &wake }); | ||
| 349 | defer pump.stop(); | ||
| 350 | var hook: ?HookReader = if (opts.test_fifo) |path| try HookReader.init(alloc, path) else null; | ||
| 351 | defer if (hook) |*h| h.deinit(); | ||
| 352 | var events: Events = .{ .pump = pump, .win = win, .wake = &wake, .hook = if (hook) |*h| h else null, .cell_w = face.cell_w, .cell_h = face.cell_h, .cells = cells, .fb_w = fb_w, .fb_h = fb_h }; | ||
| 353 | var last_phase: ?session_pump.Phase = null; | ||
| 354 | var bell_until: i64 = 0; | ||
| 355 | var visible_blink = false; | ||
| 356 | var blink_phase = true; | ||
| 357 | var blink_until: i64 = 0; | ||
| 358 | |||
| 359 | while (true) { | ||
| 360 | if (hook) |*h| { | ||
| 361 | try h.read(win); | ||
| 362 | if (h.capture != null) events.dirty = true; | ||
| 363 | } | ||
| 364 | var ev: c.SDL_Event = undefined; | ||
| 365 | const wait_ms: c_int = if (events.dirty) 0 else if (hook != null) 16 else 100; | ||
| 366 | if (c.SDL_WaitEventTimeout(&ev, wait_ms)) { | ||
| 367 | if (!try events.handle(ev)) return 0; | ||
| 368 | var consumed: usize = 1; | ||
| 369 | while (consumed < 128 and c.SDL_PollEvent(&ev)) : (consumed += 1) { | ||
| 370 | if (!try events.handle(ev)) return 0; | ||
| 371 | } | ||
| 372 | } | ||
| 373 | // Clearing before painting lets a concurrent apply queue the next wake. | ||
| 374 | if (wake.pending.swap(false, .acq_rel)) events.dirty = true; | ||
| 375 | if (usr1_seen.swap(false, .acq_rel)) report(&ring); | ||
| 376 | const now = std.time.milliTimestamp(); | ||
| 377 | const state = pump.state(); | ||
| 378 | if (last_phase == null or last_phase.? != state.phase) { | ||
| 379 | last_phase = state.phase; | ||
| 380 | switch (state.phase) { | ||
| 381 | .exited => return state.exit_code, | ||
| 382 | .refused, .failed, .dial_failed => { | ||
| 383 | std.debug.print("muxg: {s}\n", .{state.reasonText()}); | ||
| 384 | return if (state.phase == .dial_failed) 2 else 1; | ||
| 385 | }, | ||
| 386 | .taken => { | ||
| 387 | std.debug.print("muxg: the session was taken by another client\n", .{}); | ||
| 388 | return 0; | ||
| 389 | }, | ||
| 390 | else => setTitle(win, opts.session, state.phase == .reconnecting, bell_until != 0), | ||
| 391 | } | ||
| 392 | } | ||
| 393 | if (state.bell) { | ||
| 394 | bell_until = now + 200; | ||
| 395 | setTitle(win, opts.session, state.phase == .reconnecting, true); | ||
| 396 | } else if (bell_until != 0 and now >= bell_until) { | ||
| 397 | bell_until = 0; | ||
| 398 | setTitle(win, opts.session, state.phase == .reconnecting, false); | ||
| 399 | } | ||
| 400 | if (visible_blink and now >= blink_until) { | ||
| 401 | blink_phase = !blink_phase; | ||
| 402 | blink_until = now + 500; | ||
| 403 | events.dirty = true; | ||
| 404 | } | ||
| 405 | if (!events.dirty or events.fb_w <= 0 or events.fb_h <= 0) continue; | ||
| 406 | events.dirty = false; | ||
| 407 | var timer = try std.time.Timer.start(); | ||
| 408 | var timing: bench.Frame = .{}; | ||
| 409 | instances.clearRetainingCapacity(); | ||
| 410 | lists.backgrounds.clearRetainingCapacity(); | ||
| 411 | lists.foregrounds.clearRetainingCapacity(); | ||
| 412 | { | ||
| 413 | pump.mu.lock(); | ||
| 414 | defer pump.mu.unlock(); | ||
| 415 | timing.apply_us = @intCast(@min(pump.last_apply_us, std.math.maxInt(u32))); | ||
| 416 | const grid = pump.grid; | ||
| 417 | const rows = @min(grid.rows, events.cells.rows); | ||
| 418 | const cols = @min(grid.cols, events.cells.cols); | ||
| 419 | // Every glyph must be inserted before UV normalization for this frame. | ||
| 420 | for (0..rows) |y| { | ||
| 421 | const row = grid.row(@intCast(y)); | ||
| 422 | for (row.cells[0..cols]) |cell| { | ||
| 423 | if (cell.text_len == 0 or cell.wide == .spacer_tail) continue; | ||
| 424 | try cache.prepare(row.textOf(cell), @enumFromInt(cell.style.flags & 3)); | ||
| 425 | } | ||
| 426 | } | ||
| 427 | const qctx: quads.Ctx = .{ .cell_w = face.cell_w, .cell_h = face.cell_h, .ascent = face.ascent, .atlas_w = @floatFromInt(glyph_atlas.width), .atlas_h = @floatFromInt(glyph_atlas.height), .glyphs = .{ .ctx = &cache, .resolve = font.GlyphCache.resolve }, .blink_visible = blink_phase }; | ||
| 428 | const had_blink = visible_blink; | ||
| 429 | visible_blink = false; | ||
| 430 | for (0..rows) |y| { | ||
| 431 | const blinking = try quads.rowInstances(&lists, alloc, grid.row(@intCast(y)), cols, 0, @intCast(y), qctx); | ||
| 432 | visible_blink = visible_blink or blinking; | ||
| 433 | } | ||
| 434 | if (visible_blink and !had_blink) blink_until = now + 500; | ||
| 435 | if (!visible_blink) blink_phase = true; | ||
| 436 | if (grid.cursor.x < cols and grid.cursor.y < rows) try lists.foregrounds.append(alloc, quads.cursorInstance(grid.cursor.x, grid.cursor.y, qctx)); | ||
| 437 | } | ||
| 438 | try lists.flatten(&instances, alloc); | ||
| 439 | timing.rebuild_us = bench.usSince(&timer); | ||
| 440 | if (glyph_atlas.dirty) renderer.uploadAtlas(&glyph_atlas); | ||
| 441 | timing.atlas_us = bench.usSince(&timer); | ||
| 442 | renderer.uploadInstances(instances.items); | ||
| 443 | timing.upload_us = bench.usSince(&timer); | ||
| 444 | renderer.draw(instances.items.len, events.fb_w, events.fb_h, 0x101010ff); | ||
| 445 | if (hook) |*h| if (h.capture) |path| { | ||
| 446 | defer alloc.free(path); | ||
| 447 | h.capture = null; | ||
| 448 | const pixels = try renderer.readPixels(alloc, @intCast(events.fb_w), @intCast(events.fb_h)); | ||
| 449 | defer alloc.free(pixels); | ||
| 450 | const temporary = try std.fmt.allocPrint(alloc, "{s}.tmp", .{path}); | ||
| 451 | defer alloc.free(temporary); | ||
| 452 | const file = try std.fs.cwd().createFile(temporary, .{}); | ||
| 453 | defer file.close(); | ||
| 454 | var header: [64]u8 = undefined; | ||
| 455 | try file.writeAll(try std.fmt.bufPrint(&header, "P6\n{d} {d}\n255\n", .{ events.fb_w, events.fb_h })); | ||
| 456 | try file.writeAll(pixels); | ||
| 457 | try std.fs.cwd().rename(temporary, path); | ||
| 458 | }; | ||
| 459 | if (!c.SDL_GL_SwapWindow(win)) return sdlFail("SDL_GL_SwapWindow"); | ||
| 460 | timing.draw_us = bench.usSince(&timer); | ||
| 461 | ring.record(timing); | ||
| 462 | } | ||
| 463 | } | ||
| 464 | |||
| 465 | fn report(ring: *const bench.Ring) void { | ||
| 466 | var buf: [2048]u8 = undefined; | ||
| 467 | std.debug.print("{s}", .{ring.report(&buf)}); | ||
| 468 | } | ||
| 469 | |||
| 470 | test "key mapping sends modifier punctuation and space through the shared encoder" { | ||
| 471 | try std.testing.expect(keyEvent(c.SDLK_A, 0) == null); | ||
| 472 | try std.testing.expectEqual(keymap.Key.up, keyEvent(c.SDLK_UP, 0).?.key); | ||
| 473 | var buf: [keymap.max_seq_len]u8 = undefined; | ||
| 474 | const cases = .{ .{ c.SDLK_BACKSLASH, @as(u8, 28) }, .{ c.SDLK_LEFTBRACKET, @as(u8, 27) }, .{ c.SDLK_RIGHTBRACKET, @as(u8, 29) }, .{ c.SDLK_SPACE, @as(u8, 0) } }; | ||
| 475 | inline for (cases) |pair| { | ||
| 476 | try std.testing.expectEqualSlices(u8, &.{pair[1]}, keymap.encode(keyEvent(pair[0], c.SDL_KMOD_CTRL).?, &buf)); | ||
| 477 | } | ||
| 478 | try std.testing.expectEqualSlices(u8, &.{ 27, 'X' }, keymap.encode(keyEvent('X', c.SDL_KMOD_LALT | c.SDL_KMOD_LSHIFT).?, &buf)); | ||
| 479 | try std.testing.expectEqualSlices(u8, &.{ 27, '!' }, keymap.encode(keyEvent('!', c.SDL_KMOD_LALT | c.SDL_KMOD_LSHIFT).?, &buf)); | ||
| 480 | try std.testing.expect(keyEvent(c.SDLK_Q, c.SDL_KMOD_MODE | c.SDL_KMOD_RALT) == null); | ||
| 481 | try std.testing.expect(keyEvent(c.SDLK_Q, c.SDL_KMOD_LCTRL | c.SDL_KMOD_RALT) == null); | ||
| 482 | } | ||
| 483 | |||
| 484 | test "drawable dimensions floor, clamp small windows, and cap wire columns" { | ||
| 485 | try std.testing.expectEqual(CellsOf{ .cols = 120, .rows = 37 }, cellsOf(963, 601, 8, 16)); | ||
| 486 | try std.testing.expectEqual(CellsOf{ .cols = 1, .rows = 1 }, cellsOf(3, 5, 8, 16)); | ||
| 487 | try std.testing.expectEqual(term.protocol.max_cols, cellsOf(100000, 800, 1, 16).cols); | ||
| 488 | try std.testing.expect(parseHook("resize:0x400") == null); | ||
| 489 | try std.testing.expectEqualStrings("hi", parseHook("text:hi").?.text); | ||
| 490 | } | ||
src/gui/gl.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,201 @@ | |||
| 1 | //! OpenGL 3.3 instanced R8-atlas renderer, loaded entirely by function pointer. | ||
| 2 | const std = @import("std"); | ||
| 3 | const quads = @import("quads.zig"); | ||
| 4 | const atlas = @import("atlas.zig"); | ||
| 5 | const c = @cImport({ | ||
| 6 | @cInclude("GL/glcorearb.h"); | ||
| 7 | }); | ||
| 8 | pub const GetProc = *const fn ([*:0]const u8) callconv(.c) ?*anyopaque; | ||
| 9 | pub const Fns = struct { | ||
| 10 | glCreateShader: c.PFNGLCREATESHADERPROC, | ||
| 11 | glShaderSource: c.PFNGLSHADERSOURCEPROC, | ||
| 12 | glCompileShader: c.PFNGLCOMPILESHADERPROC, | ||
| 13 | glGetShaderiv: c.PFNGLGETSHADERIVPROC, | ||
| 14 | glGetShaderInfoLog: c.PFNGLGETSHADERINFOLOGPROC, | ||
| 15 | glDeleteShader: c.PFNGLDELETESHADERPROC, | ||
| 16 | glCreateProgram: c.PFNGLCREATEPROGRAMPROC, | ||
| 17 | glAttachShader: c.PFNGLATTACHSHADERPROC, | ||
| 18 | glLinkProgram: c.PFNGLLINKPROGRAMPROC, | ||
| 19 | glGetProgramiv: c.PFNGLGETPROGRAMIVPROC, | ||
| 20 | glGetProgramInfoLog: c.PFNGLGETPROGRAMINFOLOGPROC, | ||
| 21 | glDeleteProgram: c.PFNGLDELETEPROGRAMPROC, | ||
| 22 | glUseProgram: c.PFNGLUSEPROGRAMPROC, | ||
| 23 | glGetUniformLocation: c.PFNGLGETUNIFORMLOCATIONPROC, | ||
| 24 | glUniform2f: c.PFNGLUNIFORM2FPROC, | ||
| 25 | glUniform1i: c.PFNGLUNIFORM1IPROC, | ||
| 26 | glGenVertexArrays: c.PFNGLGENVERTEXARRAYSPROC, | ||
| 27 | glBindVertexArray: c.PFNGLBINDVERTEXARRAYPROC, | ||
| 28 | glDeleteVertexArrays: c.PFNGLDELETEVERTEXARRAYSPROC, | ||
| 29 | glGenBuffers: c.PFNGLGENBUFFERSPROC, | ||
| 30 | glBindBuffer: c.PFNGLBINDBUFFERPROC, | ||
| 31 | glBufferData: c.PFNGLBUFFERDATAPROC, | ||
| 32 | glDeleteBuffers: c.PFNGLDELETEBUFFERSPROC, | ||
| 33 | glEnableVertexAttribArray: c.PFNGLENABLEVERTEXATTRIBARRAYPROC, | ||
| 34 | glVertexAttribPointer: c.PFNGLVERTEXATTRIBPOINTERPROC, | ||
| 35 | glVertexAttribIPointer: c.PFNGLVERTEXATTRIBIPOINTERPROC, | ||
| 36 | glVertexAttribDivisor: c.PFNGLVERTEXATTRIBDIVISORPROC, | ||
| 37 | glGenTextures: c.PFNGLGENTEXTURESPROC, | ||
| 38 | glBindTexture: c.PFNGLBINDTEXTUREPROC, | ||
| 39 | glDeleteTextures: c.PFNGLDELETETEXTURESPROC, | ||
| 40 | glTexImage2D: c.PFNGLTEXIMAGE2DPROC, | ||
| 41 | glTexParameteri: c.PFNGLTEXPARAMETERIPROC, | ||
| 42 | glPixelStorei: c.PFNGLPIXELSTOREIPROC, | ||
| 43 | glActiveTexture: c.PFNGLACTIVETEXTUREPROC, | ||
| 44 | glViewport: c.PFNGLVIEWPORTPROC, | ||
| 45 | glClearColor: c.PFNGLCLEARCOLORPROC, | ||
| 46 | glClear: c.PFNGLCLEARPROC, | ||
| 47 | glEnable: c.PFNGLENABLEPROC, | ||
| 48 | glBlendFunc: c.PFNGLBLENDFUNCPROC, | ||
| 49 | glDrawArraysInstanced: c.PFNGLDRAWARRAYSINSTANCEDPROC, | ||
| 50 | glReadPixels: c.PFNGLREADPIXELSPROC, | ||
| 51 | pub fn load(get: GetProc) error{MissingGlFunction}!Fns { | ||
| 52 | var f: Fns = undefined; | ||
| 53 | inline for (std.meta.fields(Fns)) |field| { | ||
| 54 | @field(f, field.name) = @ptrCast(get(field.name ++ "") orelse { | ||
| 55 | std.debug.print("muxg: missing GL function {s}\n", .{field.name}); | ||
| 56 | return error.MissingGlFunction; | ||
| 57 | }); | ||
| 58 | } | ||
| 59 | return f; | ||
| 60 | } | ||
| 61 | }; | ||
| 62 | const vs: [*:0]const u8 = | ||
| 63 | \\#version 330 core | ||
| 64 | \\layout(location=0) in vec4 rect; | ||
| 65 | \\layout(location=1) in vec4 uv; | ||
| 66 | \\layout(location=2) in uint rgba; | ||
| 67 | \\layout(location=3) in uint kind; | ||
| 68 | \\uniform vec2 viewport; out vec2 v_uv; flat out vec4 v_color; flat out uint v_kind; | ||
| 69 | \\void main(){vec2 q=vec2(gl_VertexID&1,(gl_VertexID>>1)&1);vec2 p=rect.xy+q*rect.zw;gl_Position=vec4(p.x/viewport.x*2.-1.,1.-p.y/viewport.y*2.,0,1);v_uv=mix(uv.xy,uv.zw,q);v_color=vec4(float((rgba>>24u)&255u),float((rgba>>16u)&255u),float((rgba>>8u)&255u),float(rgba&255u))/255.;v_kind=kind;} | ||
| 70 | ; | ||
| 71 | const fs: [*:0]const u8 = | ||
| 72 | \\#version 330 core | ||
| 73 | \\in vec2 v_uv; flat in vec4 v_color; flat in uint v_kind; uniform sampler2D atlas; out vec4 frag; | ||
| 74 | \\void main(){float a=v_kind==1u?texture(atlas,v_uv).r:1.;frag=vec4(v_color.rgb,v_color.a*a);} | ||
| 75 | ; | ||
| 76 | pub const Renderer = struct { | ||
| 77 | f: Fns, | ||
| 78 | program: c.GLuint, | ||
| 79 | vao: c.GLuint, | ||
| 80 | vbo: c.GLuint, | ||
| 81 | tex: c.GLuint, | ||
| 82 | u_viewport: c.GLint, | ||
| 83 | pub const Error = error{ MissingGlFunction, ShaderCompile, ProgramLink }; | ||
| 84 | fn shader(f: *const Fns, kind: c.GLenum, src: [*:0]const u8) Error!c.GLuint { | ||
| 85 | const sh = f.glCreateShader.?(kind); | ||
| 86 | f.glShaderSource.?(sh, 1, @ptrCast(&src), null); | ||
| 87 | f.glCompileShader.?(sh); | ||
| 88 | var ok: c.GLint = 0; | ||
| 89 | f.glGetShaderiv.?(sh, c.GL_COMPILE_STATUS, &ok); | ||
| 90 | if (ok == 0) { | ||
| 91 | var log: [2048]u8 = undefined; | ||
| 92 | var n: c.GLsizei = 0; | ||
| 93 | f.glGetShaderInfoLog.?(sh, log.len, &n, &log); | ||
| 94 | std.debug.print("muxg: shader compile: {s}\n", .{log[0..@intCast(n)]}); | ||
| 95 | f.glDeleteShader.?(sh); | ||
| 96 | return error.ShaderCompile; | ||
| 97 | } | ||
| 98 | return sh; | ||
| 99 | } | ||
| 100 | pub fn init(get: GetProc) Error!Renderer { | ||
| 101 | const f = try Fns.load(get); | ||
| 102 | const vert = try shader(&f, c.GL_VERTEX_SHADER, vs); | ||
| 103 | defer f.glDeleteShader.?(vert); | ||
| 104 | const frag = try shader(&f, c.GL_FRAGMENT_SHADER, fs); | ||
| 105 | defer f.glDeleteShader.?(frag); | ||
| 106 | const p = f.glCreateProgram.?(); | ||
| 107 | f.glAttachShader.?(p, vert); | ||
| 108 | f.glAttachShader.?(p, frag); | ||
| 109 | f.glLinkProgram.?(p); | ||
| 110 | var ok: c.GLint = 0; | ||
| 111 | f.glGetProgramiv.?(p, c.GL_LINK_STATUS, &ok); | ||
| 112 | if (ok == 0) { | ||
| 113 | var log: [2048]u8 = undefined; | ||
| 114 | var n: c.GLsizei = 0; | ||
| 115 | f.glGetProgramInfoLog.?(p, log.len, &n, &log); | ||
| 116 | std.debug.print("muxg: program link: {s}\n", .{log[0..@intCast(n)]}); | ||
| 117 | f.glDeleteProgram.?(p); | ||
| 118 | return error.ProgramLink; | ||
| 119 | } | ||
| 120 | var vao: c.GLuint = 0; | ||
| 121 | var vbo: c.GLuint = 0; | ||
| 122 | var tex: c.GLuint = 0; | ||
| 123 | f.glGenVertexArrays.?(1, &vao); | ||
| 124 | f.glBindVertexArray.?(vao); | ||
| 125 | f.glGenBuffers.?(1, &vbo); | ||
| 126 | f.glBindBuffer.?(c.GL_ARRAY_BUFFER, vbo); | ||
| 127 | const stride: c.GLsizei = @sizeOf(quads.Instance); | ||
| 128 | f.glEnableVertexAttribArray.?(0); | ||
| 129 | f.glVertexAttribPointer.?(0, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(quads.Instance, "x"))); | ||
| 130 | f.glVertexAttribDivisor.?(0, 1); | ||
| 131 | f.glEnableVertexAttribArray.?(1); | ||
| 132 | f.glVertexAttribPointer.?(1, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(quads.Instance, "u0"))); | ||
| 133 | f.glVertexAttribDivisor.?(1, 1); | ||
| 134 | f.glEnableVertexAttribArray.?(2); | ||
| 135 | f.glVertexAttribIPointer.?(2, 1, c.GL_UNSIGNED_INT, stride, @ptrFromInt(@offsetOf(quads.Instance, "rgba"))); | ||
| 136 | f.glVertexAttribDivisor.?(2, 1); | ||
| 137 | f.glEnableVertexAttribArray.?(3); | ||
| 138 | f.glVertexAttribIPointer.?(3, 1, c.GL_UNSIGNED_INT, stride, @ptrFromInt(@offsetOf(quads.Instance, "kind"))); | ||
| 139 | f.glVertexAttribDivisor.?(3, 1); | ||
| 140 | f.glGenTextures.?(1, &tex); | ||
| 141 | f.glBindTexture.?(c.GL_TEXTURE_2D, tex); | ||
| 142 | f.glTexParameteri.?(c.GL_TEXTURE_2D, c.GL_TEXTURE_MIN_FILTER, c.GL_NEAREST); | ||
| 143 | f.glTexParameteri.?(c.GL_TEXTURE_2D, c.GL_TEXTURE_MAG_FILTER, c.GL_NEAREST); | ||
| 144 | f.glTexParameteri.?(c.GL_TEXTURE_2D, c.GL_TEXTURE_WRAP_S, c.GL_CLAMP_TO_EDGE); | ||
| 145 | f.glTexParameteri.?(c.GL_TEXTURE_2D, c.GL_TEXTURE_WRAP_T, c.GL_CLAMP_TO_EDGE); | ||
| 146 | f.glPixelStorei.?(c.GL_UNPACK_ALIGNMENT, 1); | ||
| 147 | f.glUseProgram.?(p); | ||
| 148 | f.glUniform1i.?(f.glGetUniformLocation.?(p, "atlas"), 0); | ||
| 149 | f.glEnable.?(c.GL_BLEND); | ||
| 150 | f.glBlendFunc.?(c.GL_SRC_ALPHA, c.GL_ONE_MINUS_SRC_ALPHA); | ||
| 151 | return .{ .f = f, .program = p, .vao = vao, .vbo = vbo, .tex = tex, .u_viewport = f.glGetUniformLocation.?(p, "viewport") }; | ||
| 152 | } | ||
| 153 | pub fn deinit(s: *Renderer) void { | ||
| 154 | s.f.glDeleteTextures.?(1, &s.tex); | ||
| 155 | s.f.glDeleteBuffers.?(1, &s.vbo); | ||
| 156 | s.f.glDeleteVertexArrays.?(1, &s.vao); | ||
| 157 | s.f.glDeleteProgram.?(s.program); | ||
| 158 | } | ||
| 159 | pub fn uploadAtlas(s: *Renderer, a: *atlas.Atlas) void { | ||
| 160 | if (!a.dirty) return; | ||
| 161 | s.f.glActiveTexture.?(c.GL_TEXTURE0); | ||
| 162 | s.f.glBindTexture.?(c.GL_TEXTURE_2D, s.tex); | ||
| 163 | s.f.glTexImage2D.?(c.GL_TEXTURE_2D, 0, c.GL_R8, a.width, a.height, 0, c.GL_RED, c.GL_UNSIGNED_BYTE, a.pixels.ptr); | ||
| 164 | a.dirty = false; | ||
| 165 | } | ||
| 166 | pub fn uploadInstances(s: *Renderer, items: []const quads.Instance) void { | ||
| 167 | s.f.glBindBuffer.?(c.GL_ARRAY_BUFFER, s.vbo); | ||
| 168 | s.f.glBufferData.?(c.GL_ARRAY_BUFFER, @intCast(items.len * @sizeOf(quads.Instance)), items.ptr, c.GL_STREAM_DRAW); | ||
| 169 | } | ||
| 170 | pub fn draw(s: *Renderer, count: usize, w: i32, h: i32, color: u32) void { | ||
| 171 | const cv = struct { | ||
| 172 | fn f(v: u32, shift: u5) f32 { | ||
| 173 | return @as(f32, @floatFromInt((v >> shift) & 255)) / 255; | ||
| 174 | } | ||
| 175 | }.f; | ||
| 176 | s.f.glViewport.?(0, 0, w, h); | ||
| 177 | s.f.glClearColor.?(cv(color, 24), cv(color, 16), cv(color, 8), cv(color, 0)); | ||
| 178 | s.f.glClear.?(c.GL_COLOR_BUFFER_BIT); | ||
| 179 | s.f.glUseProgram.?(s.program); | ||
| 180 | s.f.glUniform2f.?(s.u_viewport, @floatFromInt(w), @floatFromInt(h)); | ||
| 181 | s.f.glBindVertexArray.?(s.vao); | ||
| 182 | s.f.glDrawArraysInstanced.?(c.GL_TRIANGLE_STRIP, 0, 4, @intCast(count)); | ||
| 183 | } | ||
| 184 | pub fn readPixels(s: *Renderer, alloc: std.mem.Allocator, w: u32, h: u32) ![]u8 { | ||
| 185 | const row = @as(usize, w) * 3; | ||
| 186 | const out = try alloc.alloc(u8, row * h); | ||
| 187 | errdefer alloc.free(out); | ||
| 188 | s.f.glPixelStorei.?(c.GL_PACK_ALIGNMENT, 1); | ||
| 189 | s.f.glReadPixels.?(0, 0, @intCast(w), @intCast(h), c.GL_RGB, c.GL_UNSIGNED_BYTE, out.ptr); | ||
| 190 | const tmp = try alloc.alloc(u8, row); | ||
| 191 | defer alloc.free(tmp); | ||
| 192 | for (0..h / 2) |y| { | ||
| 193 | const a = out[y * row ..][0..row]; | ||
| 194 | const b = out[(@as(usize, h) - 1 - y) * row ..][0..row]; | ||
| 195 | @memcpy(tmp, a); | ||
| 196 | @memcpy(a, b); | ||
| 197 | @memcpy(b, tmp); | ||
| 198 | } | ||
| 199 | return out; | ||
| 200 | } | ||
| 201 | }; | ||
src/gui/native.zig
| Old | New | ||
|---|---|---|---|
| @@ -7,13 +7,28 @@ | |||
| 7 | //! remaining painter code stays usable in unit tests that open no window. | 7 | //! remaining painter code stays usable in unit tests that open no window. |
| 8 | //! | 8 | //! |
| 9 | //! Imports `client` and `term` and nothing else of ours. | 9 | //! Imports `client` and `term` and nothing else of ours. |
| 10 | const std = @import("std"); | ||
| 10 | const client = @import("client"); | 11 | const client = @import("client"); |
| 11 | const term = @import("term"); | 12 | const term = @import("term"); |
| 12 | 13 | ||
| 13 | pub const frame = @import("frame.zig"); | 14 | pub const frame = @import("frame.zig"); |
| 15 | pub const bench = @import("bench.zig"); | ||
| 16 | pub const atlas = @import("atlas.zig"); | ||
| 17 | pub const font = @import("font.zig"); | ||
| 18 | pub const quads = @import("quads.zig"); | ||
| 19 | pub const gl = @import("gl.zig"); | ||
| 20 | |||
| 21 | pub fn run(alloc: std.mem.Allocator, opts: frame.Options) !u8 { | ||
| 22 | return frame.run(alloc, opts); | ||
| 23 | } | ||
| 14 | 24 | ||
| 15 | test { | 25 | test { |
| 16 | _ = client; | 26 | _ = client; |
| 17 | _ = term; | 27 | _ = term; |
| 18 | _ = frame; | 28 | _ = frame; |
| 29 | _ = bench; | ||
| 30 | _ = atlas; | ||
| 31 | _ = font; | ||
| 32 | _ = quads; | ||
| 33 | _ = gl; | ||
| 19 | } | 34 | } |
src/gui/quads.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,243 @@ | |||
| 1 | //! Grid cells to ordered background and foreground instance streams. | ||
| 2 | const std = @import("std"); | ||
| 3 | const term = @import("term"); | ||
| 4 | const grid = term.grid; | ||
| 5 | const proto = term.protocol; | ||
| 6 | const atlas = @import("atlas.zig"); | ||
| 7 | pub const Instance = extern struct { | ||
| 8 | x: f32, | ||
| 9 | y: f32, | ||
| 10 | w: f32, | ||
| 11 | h: f32, | ||
| 12 | u0: f32, | ||
| 13 | v0: f32, | ||
| 14 | u1: f32, | ||
| 15 | v1: f32, | ||
| 16 | rgba: u32, | ||
| 17 | kind: u32, | ||
| 18 | pub const solid: u32 = 0; | ||
| 19 | pub const glyph: u32 = 1; | ||
| 20 | }; | ||
| 21 | pub const PositionedGlyph = struct { entry: atlas.Entry, x_advance: i32 = 0, y_advance: i32 = 0, x_offset: i32 = 0, y_offset: i32 = 0 }; | ||
| 22 | pub const Glyphs = struct { ctx: *anyopaque, resolve: *const fn (*anyopaque, []const u8, atlas.Variant) anyerror![]const PositionedGlyph }; | ||
| 23 | pub const Lists = struct { | ||
| 24 | backgrounds: std.ArrayListUnmanaged(Instance) = .empty, | ||
| 25 | foregrounds: std.ArrayListUnmanaged(Instance) = .empty, | ||
| 26 | pub fn deinit(s: *Lists, a: std.mem.Allocator) void { | ||
| 27 | s.backgrounds.deinit(a); | ||
| 28 | s.foregrounds.deinit(a); | ||
| 29 | } | ||
| 30 | pub fn flatten(s: *const Lists, out: *std.ArrayListUnmanaged(Instance), a: std.mem.Allocator) !void { | ||
| 31 | try out.appendSlice(a, s.backgrounds.items); | ||
| 32 | try out.appendSlice(a, s.foregrounds.items); | ||
| 33 | } | ||
| 34 | }; | ||
| 35 | pub const Ctx = struct { cell_w: u16, cell_h: u16, ascent: u16, x0: f32 = 0, y0: f32 = 0, atlas_w: f32, atlas_h: f32, glyphs: Glyphs, blink_visible: bool = true, default_fg: u32 = 0xd0d0d0ff, default_bg: u32 = 0x101010ff }; | ||
| 36 | const ansi16 = [16]u32{ 0x000000ff, 0xcc0000ff, 0x4e9a06ff, 0xc4a000ff, 0x0000eeff, 0x75507bff, 0x06989aff, 0xd3d7cfff, 0x555753ff, 0xef2929ff, 0x8ae234ff, 0xfce94fff, 0x729fcfff, 0xad7fa8ff, 0x34e2e2ff, 0xeeeeecff }; | ||
| 37 | fn rgb(r: u8, g: u8, b: u8) u32 { | ||
| 38 | return (@as(u32, r) << 24) | (@as(u32, g) << 16) | (@as(u32, b) << 8) | 0xff; | ||
| 39 | } | ||
| 40 | fn palette(i: u8) u32 { | ||
| 41 | if (i < 16) return ansi16[i]; | ||
| 42 | if (i < 232) { | ||
| 43 | const n = i - 16; | ||
| 44 | const s = [6]u8{ 0, 0x5f, 0x87, 0xaf, 0xd7, 0xff }; | ||
| 45 | return rgb(s[n / 36], s[(n / 6) % 6], s[n % 6]); | ||
| 46 | } | ||
| 47 | const g: u8 = 8 + 10 * (i - 232); | ||
| 48 | return rgb(g, g, g); | ||
| 49 | } | ||
| 50 | pub fn rgbaOf(c: u32, d: u32) u32 { | ||
| 51 | return switch (c >> 24) { | ||
| 52 | 1 => palette(@intCast(c & 0xff)), | ||
| 53 | 2 => (c << 8) | 0xff, | ||
| 54 | else => d, | ||
| 55 | }; | ||
| 56 | } | ||
| 57 | fn solid(x: f32, y: f32, w: f32, h: f32, color: u32) Instance { | ||
| 58 | return .{ .x = x, .y = y, .w = w, .h = h, .u0 = 0, .v0 = 0, .u1 = 0, .v1 = 0, .rgba = color, .kind = Instance.solid }; | ||
| 59 | } | ||
| 60 | pub fn variantOf(flags: u16) atlas.Variant { | ||
| 61 | return if (flags & 3 == 3) .bold_italic else if (flags & 1 != 0) .bold else if (flags & 2 != 0) .italic else .regular; | ||
| 62 | } | ||
| 63 | fn decoration(out: *std.ArrayListUnmanaged(Instance), a: std.mem.Allocator, x: f32, y: f32, w: f32, h: f32, color: u32, kind: u16) !void { | ||
| 64 | if (kind == 0) return; | ||
| 65 | const unit: @TypeOf(h) = @max(h / 16, 1); | ||
| 66 | switch (kind) { | ||
| 67 | 1 => try out.append(a, solid(x, y + h - unit * 2, w, unit, color)), | ||
| 68 | 2 => { | ||
| 69 | try out.append(a, solid(x, y + h - unit * 3, w, unit, color)); | ||
| 70 | try out.append(a, solid(x, y + h - unit, w, unit, color)); | ||
| 71 | }, | ||
| 72 | 3 => { | ||
| 73 | var p: f32 = 0; | ||
| 74 | var segment: usize = 0; | ||
| 75 | while (p < w) : ({ | ||
| 76 | p += unit * 2; | ||
| 77 | segment += 1; | ||
| 78 | }) try out.append(a, solid(x + p, y + h - unit * (1 + @as(f32, @floatFromInt(segment % 2))), @min(unit * 2, w - p), unit, color)); | ||
| 79 | }, | ||
| 80 | 4 => { | ||
| 81 | var p: f32 = 0; | ||
| 82 | while (p < w) : (p += unit * 2) try out.append(a, solid(x + p, y + h - unit, @min(unit, w - p), unit, color)); | ||
| 83 | }, | ||
| 84 | 5 => { | ||
| 85 | var p: f32 = 0; | ||
| 86 | while (p < w) : (p += unit * 4) try out.append(a, solid(x + p, y + h - unit, @min(unit * 3, w - p), unit, color)); | ||
| 87 | }, | ||
| 88 | else => {}, | ||
| 89 | } | ||
| 90 | } | ||
| 91 | pub fn rowInstances(out: *Lists, a: std.mem.Allocator, row: *const grid.Row, cols: u16, col_off: u16, y: u16, ctx: Ctx) !bool { | ||
| 92 | const n = @min(@as(usize, cols), row.cells.len); | ||
| 93 | const cw: f32 = @floatFromInt(ctx.cell_w); | ||
| 94 | const ch: f32 = @floatFromInt(ctx.cell_h); | ||
| 95 | const top = ctx.y0 + @as(f32, @floatFromInt(y)) * ch; | ||
| 96 | var has_blink = false; | ||
| 97 | for (row.cells[0..n], 0..) |cell, x| { | ||
| 98 | if (cell.wide == .spacer_tail) continue; | ||
| 99 | const span_cols: usize = @min(if (cell.wide == .wide) @as(usize, 2) else 1, n - x); | ||
| 100 | const inv = cell.style.flags & (1 << 4) != 0; | ||
| 101 | const bg = if (inv) rgbaOf(cell.style.fg, ctx.default_fg) else rgbaOf(cell.style.bg, ctx.default_bg); | ||
| 102 | if (inv or cell.style.bg != proto.color_none) try out.backgrounds.append(a, solid(ctx.x0 + @as(f32, @floatFromInt(col_off + x)) * cw, top, cw * @as(f32, @floatFromInt(span_cols)), ch, bg)); | ||
| 103 | } | ||
| 104 | for (row.cells[0..n], 0..) |cell, x| { | ||
| 105 | if (cell.wide == .spacer_tail) continue; | ||
| 106 | const flags = cell.style.flags; | ||
| 107 | const decorated = flags & ((1 << 6) | (1 << 7) | (7 << 8)) != 0; | ||
| 108 | if (flags & (1 << 3) != 0 and flags & (1 << 5) == 0 and (cell.text_len > 0 or decorated)) has_blink = true; | ||
| 109 | if (flags & (1 << 5) != 0 or (flags & (1 << 3) != 0 and !ctx.blink_visible)) continue; | ||
| 110 | const inv = flags & (1 << 4) != 0; | ||
| 111 | var fg = if (inv) rgbaOf(cell.style.bg, ctx.default_bg) else rgbaOf(cell.style.fg, ctx.default_fg); | ||
| 112 | if (flags & (1 << 2) != 0) fg = (fg & 0xffffff00) | 0x80; | ||
| 113 | const left = ctx.x0 + @as(f32, @floatFromInt(col_off + x)) * cw; | ||
| 114 | const span_cols: usize = @min(if (cell.wide == .wide) @as(usize, 2) else 1, n - x); | ||
| 115 | const span = cw * @as(f32, @floatFromInt(span_cols)); | ||
| 116 | if (cell.text_len > 0) { | ||
| 117 | const run = try ctx.glyphs.resolve(ctx.glyphs.ctx, row.textOf(cell), variantOf(flags)); | ||
| 118 | var pen_x: i32 = 0; | ||
| 119 | var pen_y: i32 = 0; | ||
| 120 | for (run) |g| { | ||
| 121 | const e = g.entry; | ||
| 122 | const gx = left + @as(f32, @floatFromInt(e.left)) + @as(f32, @floatFromInt(pen_x + g.x_offset)) / 64; | ||
| 123 | const gy = top + @as(f32, @floatFromInt(@as(i32, ctx.ascent) - e.top)) - @as(f32, @floatFromInt(pen_y + g.y_offset)) / 64; | ||
| 124 | const gw: f32 = @floatFromInt(e.w); | ||
| 125 | const gh: f32 = @floatFromInt(e.h); | ||
| 126 | const x0 = @max(gx, left); | ||
| 127 | const y0 = @max(gy, top); | ||
| 128 | const x1 = @min(gx + gw, left + span); | ||
| 129 | const y1 = @min(gy + gh, top + ch); | ||
| 130 | if (x1 > x0 and y1 > y0 and gw > 0 and gh > 0) { | ||
| 131 | const eu0: f32 = @floatFromInt(e.x); | ||
| 132 | const ev0: f32 = @floatFromInt(e.y); | ||
| 133 | try out.foregrounds.append(a, .{ .x = x0, .y = y0, .w = x1 - x0, .h = y1 - y0, .u0 = (eu0 + (x0 - gx)) / ctx.atlas_w, .v0 = (ev0 + (y0 - gy)) / ctx.atlas_h, .u1 = (eu0 + (x1 - gx)) / ctx.atlas_w, .v1 = (ev0 + (y1 - gy)) / ctx.atlas_h, .rgba = fg, .kind = Instance.glyph }); | ||
| 134 | } | ||
| 135 | pen_x += g.x_advance; | ||
| 136 | pen_y += g.y_advance; | ||
| 137 | } | ||
| 138 | } | ||
| 139 | if (flags & (1 << 6) != 0) try out.foregrounds.append(a, solid(left, top + ch / 2, span, @max(ch / 16, 1), fg)); | ||
| 140 | if (flags & (1 << 7) != 0) try out.foregrounds.append(a, solid(left, top, span, @max(ch / 16, 1), fg)); | ||
| 141 | try decoration(&out.foregrounds, a, left, top, span, ch, rgbaOf(cell.style.ul, fg), (flags >> 8) & 7); | ||
| 142 | } | ||
| 143 | return has_blink; | ||
| 144 | } | ||
| 145 | pub fn cursorInstance(x: u16, y: u16, ctx: Ctx) Instance { | ||
| 146 | return solid(ctx.x0 + @as(f32, @floatFromInt(x)) * @as(f32, @floatFromInt(ctx.cell_w)), ctx.y0 + @as(f32, @floatFromInt(y)) * @as(f32, @floatFromInt(ctx.cell_h)), @floatFromInt(ctx.cell_w), @floatFromInt(ctx.cell_h), ctx.default_fg); | ||
| 147 | } | ||
| 148 | |||
| 149 | test "palette and cursor preserve origin" { | ||
| 150 | try std.testing.expectEqual(@as(u32, 0xcc0000ff), rgbaOf(proto.colorPalette(1), 0)); | ||
| 151 | const dummy = Ctx{ .cell_w = 8, .cell_h = 16, .ascent = 12, .x0 = 3, .y0 = 4, .atlas_w = 1, .atlas_h = 1, .glyphs = undefined }; | ||
| 152 | const q = cursorInstance(2, 1, dummy); | ||
| 153 | try std.testing.expectEqual(@as(f32, 19), q.x); | ||
| 154 | try std.testing.expectEqual(@as(f32, 20), q.y); | ||
| 155 | } | ||
| 156 | |||
| 157 | const Fake = struct { | ||
| 158 | var last_variant: atlas.Variant = .regular; | ||
| 159 | var run = [_]PositionedGlyph{ | ||
| 160 | .{ .entry = .{ .x = 0, .y = 0, .w = 4, .h = 8, .left = 1, .top = 7 }, .x_advance = 6 * 64 }, | ||
| 161 | .{ .entry = .{ .x = 4, .y = 0, .w = 2, .h = 3, .left = -1, .top = 9 }, .x_offset = -2 * 64, .y_offset = 2 * 64 }, | ||
| 162 | }; | ||
| 163 | fn resolve(_: *anyopaque, _: []const u8, v: atlas.Variant) anyerror![]const PositionedGlyph { | ||
| 164 | last_variant = v; | ||
| 165 | return &run; | ||
| 166 | } | ||
| 167 | }; | ||
| 168 | |||
| 169 | const Oversize = struct { | ||
| 170 | var run = [_]PositionedGlyph{.{ .entry = .{ .x = 2, .y = 3, .w = 20, .h = 24, .left = -4, .top = 20 } }}; | ||
| 171 | fn resolve(_: *anyopaque, _: []const u8, _: atlas.Variant) anyerror![]const PositionedGlyph { | ||
| 172 | return &run; | ||
| 173 | } | ||
| 174 | }; | ||
| 175 | |||
| 176 | fn testCtx(blink_visible: bool) Ctx { | ||
| 177 | return .{ .cell_w = 8, .cell_h = 16, .ascent = 12, .x0 = 5, .atlas_w = 16, .atlas_h = 16, .blink_visible = blink_visible, .glyphs = .{ .ctx = undefined, .resolve = Fake.resolve } }; | ||
| 178 | } | ||
| 179 | |||
| 180 | test "full cluster, wide origin, styles, underline variants and blink" { | ||
| 181 | var cells = [_]grid.Cell{ .{ .wide = .wide, .text_len = 3 }, .{ .wide = .spacer_tail } }; | ||
| 182 | var row: grid.Row = .{ .cells = &cells, .text = .{ .items = @constCast("e\xcc\x81"), .capacity = 3 } }; | ||
| 183 | const a = std.testing.allocator; | ||
| 184 | for (1..6) |underline| { | ||
| 185 | cells[0].style.flags = @as(u16, @intCast(underline)) << 8 | 1 | 2 | (1 << 6) | (1 << 7); | ||
| 186 | cells[0].style.ul = proto.colorRgb(1, 2, 3); | ||
| 187 | var lists: Lists = .{}; | ||
| 188 | defer lists.deinit(a); | ||
| 189 | _ = try rowInstances(&lists, a, &row, 2, 3, 0, testCtx(true)); | ||
| 190 | try std.testing.expect(lists.foregrounds.items.len >= 5); | ||
| 191 | try std.testing.expectEqual(@as(f32, 29 + 1), lists.foregrounds.items[0].x); | ||
| 192 | try std.testing.expectEqual(@as(f32, 32), lists.foregrounds.items[1].x); | ||
| 193 | try std.testing.expectEqual(atlas.Variant.bold_italic, Fake.last_variant); | ||
| 194 | try std.testing.expectEqual(@as(u32, 0x010203ff), lists.foregrounds.items[lists.foregrounds.items.len - 1].rgba); | ||
| 195 | if (underline == 3) { | ||
| 196 | const tail = lists.foregrounds.items[lists.foregrounds.items.len - 4 ..]; | ||
| 197 | try std.testing.expect(tail[0].y != tail[1].y); | ||
| 198 | } | ||
| 199 | } | ||
| 200 | cells[0].style.flags = (1 << 5) | (1 << 6) | (1 << 8); | ||
| 201 | var hidden: Lists = .{}; | ||
| 202 | defer hidden.deinit(a); | ||
| 203 | _ = try rowInstances(&hidden, a, &row, 2, 0, 0, testCtx(true)); | ||
| 204 | try std.testing.expectEqual(@as(usize, 0), hidden.foregrounds.items.len); | ||
| 205 | cells[0].style.flags = 1 << 3; | ||
| 206 | var blink: Lists = .{}; | ||
| 207 | defer blink.deinit(a); | ||
| 208 | try std.testing.expect(try rowInstances(&blink, a, &row, 2, 0, 0, testCtx(false))); | ||
| 209 | try std.testing.expectEqual(@as(usize, 0), blink.foregrounds.items.len); | ||
| 210 | } | ||
| 211 | |||
| 212 | test "each non-underline style flag has a visible deterministic effect" { | ||
| 213 | var cells = [_]grid.Cell{.{ .text_len = 1 }}; | ||
| 214 | var row: grid.Row = .{ .cells = &cells, .text = .{ .items = @constCast("x"), .capacity = 1 } }; | ||
| 215 | const a = std.testing.allocator; | ||
| 216 | for ([_]u16{ 1 << 0, 1 << 1, 1 << 2, 1 << 4, 1 << 6, 1 << 7 }) |flag| { | ||
| 217 | cells[0].style = .{ .flags = flag, .fg = proto.colorRgb(100, 80, 60), .bg = proto.colorRgb(5, 6, 7) }; | ||
| 218 | var lists: Lists = .{}; | ||
| 219 | defer lists.deinit(a); | ||
| 220 | _ = try rowInstances(&lists, a, &row, 1, 0, 0, testCtx(true)); | ||
| 221 | try std.testing.expect(lists.foregrounds.items.len >= 2); | ||
| 222 | if (flag == 1 << 0) try std.testing.expectEqual(atlas.Variant.bold, Fake.last_variant); | ||
| 223 | if (flag == 1 << 1) try std.testing.expectEqual(atlas.Variant.italic, Fake.last_variant); | ||
| 224 | if (flag == 1 << 2) try std.testing.expectEqual(@as(u8, 0x80), @as(u8, @truncate(lists.foregrounds.items[0].rgba))); | ||
| 225 | if (flag == 1 << 4) try std.testing.expectEqual(@as(u32, 0x050607ff), lists.foregrounds.items[0].rgba); | ||
| 226 | } | ||
| 227 | } | ||
| 228 | |||
| 229 | test "glyphs and a clipped wide edge stay inside the authoritative span" { | ||
| 230 | var cells = [_]grid.Cell{.{ .wide = .wide, .text_len = 1, .style = .{ .bg = proto.colorRgb(1, 1, 1) } }}; | ||
| 231 | var row: grid.Row = .{ .cells = &cells, .text = .{ .items = @constCast("x"), .capacity = 1 } }; | ||
| 232 | var lists: Lists = .{}; | ||
| 233 | defer lists.deinit(std.testing.allocator); | ||
| 234 | var ctx = testCtx(true); | ||
| 235 | ctx.glyphs.resolve = Oversize.resolve; | ||
| 236 | _ = try rowInstances(&lists, std.testing.allocator, &row, 1, 2, 0, ctx); | ||
| 237 | try std.testing.expectEqual(@as(f32, 8), lists.backgrounds.items[0].w); | ||
| 238 | const glyph = lists.foregrounds.items[0]; | ||
| 239 | try std.testing.expect(glyph.x >= 21 and glyph.x + glyph.w <= 29); | ||
| 240 | try std.testing.expect(glyph.y >= 0 and glyph.y + glyph.h <= 16); | ||
| 241 | try std.testing.expect(glyph.u0 > @as(f32, 2) / ctx.atlas_w); | ||
| 242 | try std.testing.expect(glyph.u1 < @as(f32, 22) / ctx.atlas_w); | ||
| 243 | } | ||
test/native.sh
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,148 @@ | |||
| 1 | #!/bin/sh | ||
| 2 | # The native client's end-to-end leg: a real daemon, a real muxg on SDL's | ||
| 3 | # offscreen driver, keys through the real event path, a bounded flood, | ||
| 4 | # and the frame table read while the flood ran. Opt-in (`make native-e2e`); | ||
| 5 | # not part of ci, because it needs SDL3 and a GL-capable offscreen driver. | ||
| 6 | # | ||
| 7 | # The one pin that matters is the waystty failure: frames were painted | ||
| 8 | # WHILE output flooded the session. A painter that is fast per frame and | ||
| 9 | # never asked to paint reports a fine p99 and zero frames; this leg reads | ||
| 10 | # the frame count twice during the flood and asserts it grew. | ||
| 11 | set -eu | ||
| 12 | MUX="$1" | ||
| 13 | MUXG="$2" | ||
| 14 | [ -x "$MUX" ] && [ -x "$MUXG" ] || { echo "native FAIL: need mux and muxg (run: make native)"; exit 1; } | ||
| 15 | E2E_DIR=$(dirname "$0") | ||
| 16 | . "$E2E_DIR/e2e_lib.sh" | ||
| 17 | |||
| 18 | # A Debug muxg measures a Debug replica; the numbers would mean nothing. | ||
| 19 | # The budget below is stated for ReleaseSafe/ReleaseFast and the leg | ||
| 20 | # refuses to grade anything else. `zig build native -Doptimize=ReleaseSafe`. | ||
| 21 | case "$("$MUXG" --version 2>&1)" in | ||
| 22 | *ReleaseSafe*|*ReleaseFast*) ;; | ||
| 23 | *) echo "native FAIL: build muxg with -Doptimize=ReleaseSafe or ReleaseFast"; exit 1 ;; | ||
| 24 | esac | ||
| 25 | |||
| 26 | SOCK="${TMPDIR:-/tmp}/muxd-native-$$.sock" | ||
| 27 | LOG="${TMPDIR:-/tmp}/muxd-native-$$.log" | ||
| 28 | defer_rm "$LOG" | ||
| 29 | start_daemon "$SOCK" "$LOG" "native daemon" | ||
| 30 | |||
| 31 | FIFO="${TMPDIR:-/tmp}/muxg-hook-$$" | ||
| 32 | defer_rm "$FIFO" | ||
| 33 | mkfifo "$FIFO" | ||
| 34 | GLOG="${TMPDIR:-/tmp}/muxg-native-$$.log" | ||
| 35 | defer_rm "$GLOG" | ||
| 36 | |||
| 37 | SDL_VIDEODRIVER="${MUXG_VIDEODRIVER:-offscreen}" MUXG_TEST_FIFO="$FIFO" \ | ||
| 38 | "$MUXG" --sock "$SOCK" 2>"$GLOG" & | ||
| 39 | GPID=$! | ||
| 40 | defer_kill "$GPID" | ||
| 41 | # Hold the FIFO's write end open for the whole leg; each hook line is one echo. | ||
| 42 | exec 8<>"$FIFO" | ||
| 43 | |||
| 44 | # 1. The window attached: the daemon counts a client. | ||
| 45 | # wait_until TENTHS LABEL PREDICATE — the predicate is eval'd per tick. | ||
| 46 | wait_until 100 "muxg attached" '[ "$(attaches_now "$SOCK")" -ge 1 ]' | ||
| 47 | ok "muxg attached to a real daemon on the offscreen driver" | ||
| 48 | |||
| 49 | # 2. Keys through the real event path reach the shell. | ||
| 50 | printf '%s\n' "text:printf 'native-%s\\n' 'ok-$$'" >&8 | ||
| 51 | printf 'key:enter\n' >&8 | ||
| 52 | wait_grid "$SOCK" "native-ok-$$" "typed text landed on the daemon's grid" | ||
| 53 | ok "text and Enter cross the keymap, the mailbox, the pump and the daemon" | ||
| 54 | |||
| 55 | # 3. A bounded producer with explicit readiness, progress and completion. | ||
| 56 | # Fixed /tmp template yields a shell-safe path for the session command. | ||
| 57 | FLOOD_DIR=$(mktemp -d /tmp/mux-native-flood.XXXXXXXX) | ||
| 58 | defer_rm "$FLOOD_DIR" | ||
| 59 | reports_now() { grep -c '^=== muxg frame timing (' "$GLOG" || true; } | ||
| 60 | frames_now() { | ||
| 61 | frame_reports_before=$(reports_now) | ||
| 62 | kill -USR1 "$GPID" || return 1 | ||
| 63 | # Wait for a NEW, COMPLETE report, rather than rereading an old table. | ||
| 64 | # The total row is printed last, so its count acknowledges the report. | ||
| 65 | wait_until 50 "fresh frame report" '[ "$(grep -c "^total " "$GLOG" || true)" -gt "$frame_reports_before" ]' >&2 | ||
| 66 | grep -o 'timing ([0-9]* frames)' "$GLOG" | tail -1 | tr -dc '0-9' | ||
| 67 | } | ||
| 68 | # At most 4096 x 256 KiB of source bytes; normal completion is requested | ||
| 69 | # after the two samples. The producer writes progress only after output. | ||
| 70 | "$MUX" a run --sock "$SOCK" --timeout 60000 "i=0; : > '$FLOOD_DIR/ready'; while [ \"\$i\" -lt 4096 ] && [ ! -e '$FLOOD_DIR/stop' ]; do head -c 262144 /dev/urandom | base64; i=\$((i + 1)); printf '%s\\n' \"\$i\" > '$FLOOD_DIR/progress.tmp'; mv '$FLOOD_DIR/progress.tmp' '$FLOOD_DIR/progress'; done; : > '$FLOOD_DIR/done'; echo flood-done" >/dev/null 2>&1 & | ||
| 71 | RUNPID=$! | ||
| 72 | defer_kill "$RUNPID" | ||
| 73 | wait_until 100 "flood producer ready" '[ -s "$FLOOD_DIR/progress" ]' | ||
| 74 | [ ! -e "$FLOOD_DIR/done" ] || { echo "native FAIL: producer finished before sampling"; exit 1; } | ||
| 75 | before=$(frames_now) | ||
| 76 | progress_before=$(cat "$FLOOD_DIR/progress") | ||
| 77 | sleep 1 | ||
| 78 | wait_until 50 "producer output advanced" '[ -s "$FLOOD_DIR/progress" ] && [ "$(cat "$FLOOD_DIR/progress")" != "$progress_before" ]' | ||
| 79 | mid=$(frames_now) | ||
| 80 | [ ! -e "$FLOOD_DIR/done" ] || { echo "native FAIL: producer finished during sampling"; exit 1; } | ||
| 81 | [ "${mid:-0}" -gt "${before:-0}" ] || { echo "native FAIL: no frame painted during active output (before=$before mid=$mid)"; exit 1; } | ||
| 82 | : > "$FLOOD_DIR/stop" | ||
| 83 | wait "$RUNPID" || { echo "native FAIL: producer command failed"; exit 1; } | ||
| 84 | wait_grid "$SOCK" "flood-done" "the flood ended" | ||
| 85 | after=$(frames_now) | ||
| 86 | ok "frames were painted while the session flooded: before=$before mid=$mid after=$after" | ||
| 87 | |||
| 88 | # 4. The window-side p99 sits under the budget (ReleaseSafe/Fast only). | ||
| 89 | # Budget: 20 ms window total including swap, for a 960x600 | ||
| 90 | # window of ~4,500 cells; the row to read is `total`. | ||
| 91 | p99=$(grep -E '^total ' "$GLOG" | tail -1 | awk '{print $4}') | ||
| 92 | [ -n "$p99" ] && [ "$p99" -lt 20000 ] || { echo "native FAIL: total p99 ${p99:-?} us over the 20000 us budget"; tail -12 "$GLOG"; exit 1; } | ||
| 93 | ok "window-side total p99 ${p99} us under 20000 us" | ||
| 94 | |||
| 95 | # Read back real pixels after asking the shell to draw coloured text. The | ||
| 96 | # shell's command echo cannot satisfy the red-pixel assertion. | ||
| 97 | CAPTURE="$OUT.native.ppm" | ||
| 98 | printf '%s\n' "text:printf '\033[2J\033[H\033[31mNATIVE-%s\033[0m\n' RENDER" >&8 | ||
| 99 | printf 'key:enter\n' >&8 | ||
| 100 | wait_grid "$SOCK" "NATIVE-RENDER" "render marker reached the daemon" | ||
| 101 | printf 'capture:%s\n' "$CAPTURE" >&8 | ||
| 102 | wait_until 50 "framebuffer capture completed" '[ -s "$CAPTURE" ]' | ||
| 103 | tail -n +4 "$CAPTURE" | od -An -v -tu1 | awk ' | ||
| 104 | { for (i=1; i<=NF; i++) { channel=(n++ % 3); if(channel==0) r=$i; else if(channel==1) g=$i; else if(r>80 && r>g*2 && r>$i*2) red++; } } | ||
| 105 | END { if(red<30) exit 1; } | ||
| 106 | ' || { echo "native FAIL: framebuffer contains no rendered red text"; exit 1; } | ||
| 107 | ok "the OpenGL framebuffer contains the session's coloured glyphs" | ||
| 108 | |||
| 109 | # 5. A resize through the hook is followed by the daemon: cols shrink from | ||
| 110 | # whatever the 960 px window gave (the face decides the number) to | ||
| 111 | # fewer at 640 px. Read before, then wait for after to differ. | ||
| 112 | cols_of() { "$MUX" a status --sock "$SOCK" --timeout 2000 | sed 's/.*"cols":\([0-9]*\).*/\1/'; } | ||
| 113 | cols_before=$(cols_of) | ||
| 114 | printf 'resize:640x400\n' >&8 | ||
| 115 | wait_until 100 "the daemon's cols changed after the resize" '[ "$(cols_of)" != "$cols_before" ]' | ||
| 116 | cols_after=$(cols_of) | ||
| 117 | [ "$cols_after" -lt "$cols_before" ] || { echo "native FAIL: cols $cols_before -> $cols_after did not shrink with the 640 px window"; exit 1; } | ||
| 118 | ok "the daemon followed the window's resize (cols $cols_before -> $cols_after)" | ||
| 119 | |||
| 120 | # 6. Close the window: muxg exits and the session survives on the daemon. | ||
| 121 | # wait_pid_gone PID LABEL — it allows 2 s. | ||
| 122 | printf 'quit\n' >&8 | ||
| 123 | wait_pid_gone "$GPID" "muxg exits on quit" | ||
| 124 | wait "$GPID" || { echo "native FAIL: muxg did not exit 0"; cat "$GLOG"; exit 1; } | ||
| 125 | "$MUX" a status --sock "$SOCK" --timeout 2000 >/dev/null 2>&1 || { echo "native FAIL: the session did not survive the window closing"; exit 1; } | ||
| 126 | ok "closing the window detaches and leaves the session on its daemon" | ||
| 127 | |||
| 128 | pipe_mux "$OUT.terminal" "$OUT.terminal.err" timeout 15 "$MUX" --sock "$SOCK" | ||
| 129 | await_out "$OUT.terminal" "NATIVE-RENDER" "terminal attach sees the GUI session" | ||
| 130 | pipe_send "printf 'terminal-%%s\\n' attached\n" | ||
| 131 | wait_grid "$SOCK" "terminal-attached" "terminal input reaches the same session" | ||
| 132 | pipe_detach "terminal client detached" | ||
| 133 | ok "a terminal client can attach to and type into the GUI's session" | ||
| 134 | |||
| 135 | attaches_before=$(attaches_now "$SOCK") | ||
| 136 | SDL_VIDEODRIVER="${MUXG_VIDEODRIVER:-offscreen}" MUXG_TEST_FIFO="$FIFO" \ | ||
| 137 | "$MUXG" --sock "$SOCK" --session native-exit 2>"$OUT.native-exit.log" & | ||
| 138 | EXITPID=$! | ||
| 139 | defer_kill "$EXITPID" | ||
| 140 | wait_until 100 "named native session attached" '[ "$(attaches_now "$SOCK")" -gt "$attaches_before" ]' | ||
| 141 | printf '%s\n' 'text:exit 7' 'key:enter' >&8 | ||
| 142 | wait_pid_gone "$EXITPID" "native window follows shell exit" | ||
| 143 | exit_rc=0 | ||
| 144 | wait "$EXITPID" || exit_rc=$? | ||
| 145 | [ "$exit_rc" -eq 7 ] || { echo "native FAIL: shell exit 7 became $exit_rc"; cat "$OUT.native-exit.log"; exit 1; } | ||
| 146 | ok "a named native session returns the shell's exit code" | ||
| 147 | |||
| 148 | echo "native OK ($OK_COUNT checkpoints)" | ||