a73x

c49a06ed

feat: render native sessions with shared terminal input and reconnect

a73x   2026-09-04 19:38

Commit message
feat: render native sessions with shared terminal input and reconnect

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)"