From 41847fce244fdd0d01971c302b89d1252f6497ed Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Fri, 31 Jul 2026 19:01:28 -0300 Subject: a wheel tick slides the pane instead of jumping it --- build.zig | 9 ++ src/gui/gui.zig | 306 +++++++++++++++++++++++++++++++++++++++++++++++++++++--- src/main.zig | 8 ++ 3 files changed, 310 insertions(+), 13 deletions(-) diff --git a/build.zig b/build.zig index 59eb6e87..781cc848 100644 --- a/build.zig +++ b/build.zig @@ -724,6 +724,15 @@ pub fn build(b: *std.Build) void { // is a second thing to keep in step. const core_test = b.addTest(.{ .root_module = hx_core_mod }); unit_step.dependOn(&b.addRunArtifact(core_test).step); + // gui.zig's own inline tests (the smooth-scroll ease). Only reachable + // with -Dplatform=gui, and it links the SAME module the gui exe does + // for the same reason core_test does: SDL, the shaders and the font + // are already wired onto it, and a second wiring is a second thing to + // keep in step. + if (platform == .gui) { + const gui_test = b.addTest(.{ .root_module = root_mod }); + unit_step.dependOn(&b.addRunArtifact(gui_test).step); + } } } diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 8aaeb4ec..6280ab00 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -111,6 +111,13 @@ const touch_scroll_tick: f32 = 0.02; const touch_tap_slop_px: f32 = 12.0; const max_overlay_vertices: usize = touch_click_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices); +// Smooth scroll: the fraction of the distance still to travel that the +// picture covers each frame (the loop runs at ~60fps, so a wheel row lands in +// about six), and the distance under which it simply arrives instead of +// halving forever. See stepScroll. +const scroll_ease: f32 = 0.35; +const scroll_ease_min: f32 = 0.05; + // One instance per cell; the vertex shader expands it to a 2-triangle quad // with gl_VertexIndex. Coords are NDC (y up), uv into the atlas, colors 0..1. const CellInstance = extern struct { @@ -792,6 +799,26 @@ const Gui = struct { live_ctrl: bool = false, live_alt: bool = false, + // Smooth scroll, one pane at a time. The core still scrolls in whole + // rows and knows nothing about any of this — it just hears about them a + // frame or two late while the renderer draws the pane at the sub-row + // offset in between. See stepScroll. + // + // ponytail: one queue, so exactly one pane can be sliding and only the + // MOUSE wheel fills it — the deck's left stick and a two-finger touch + // scroll still hand the core their whole rows on the spot (they have + // their own sub-tick accumulators, and neither aims well enough to miss + // the animation). Both are one call site each: point them at + // scroll_pending the way the wheel arm of dispatch does. + scroll_pane: ?usize = null, + scroll_rect: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 }, + scroll_col: u16 = 0, // where the wheel turned: the owed rows are delivered + scroll_row: u16 = 0, // THERE, not wherever the pointer has drifted to since + scroll_pending: i32 = 0, // whole rows the core has not been told about yet + scroll_lag: f32 = 0, // picture position - core position, in rows + scroll_edge: []pardes.Cell = &.{}, // the row that just left the pane + scroll_edge_len: u16 = 0, + // Crt builtin: the scene renders into this texture, then a fullscreen // CRT pass warps it onto the real target scene_tex: ?*c.SDL_GPUTexture = null, @@ -1010,6 +1037,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { .capture_dir = capture_dir orelse "", }; defer g.glyphs.deinit(); + defer gpa.free(g.scroll_edge); // grown on demand by stepScroll // slot (0,0) is the space glyph (blank cells sample alpha=0 → bg only) _ = c.ui_font_raster(font, scale, ' ', atlas_stage.ptr, @intCast(atlas_w), @intCast(cell_w), @intCast(cell_h), asc); g.pen_x = cell_w; @@ -1159,7 +1187,9 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), cell_h))); if (cols != core.screen_w or rows != core.screen_h) core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); - // 7. render + // 7. render — the scroll animation steps LAST before it, while + // core.surface still holds the frame the last pass drew + stepScroll(&g, core, gpa); _ = frame_arena.reset(.retain_capacity); const surface = try core.render(frame_arena.allocator()); renderFrame(&g, gpa, surface, core.crt_on, core.show_debug) catch |err| log.err("render: {t}", .{err}); @@ -1438,7 +1468,8 @@ fn webFrame(arg: ?*anyopaque) callconv(.c) void { const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h))); if (cols != core.screen_w or rows != core.screen_h) core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); - // 4. render + // 4. render — scroll animation last before it, see runNative + stepScroll(g, core, st.gpa); _ = st.frame_arena.reset(.retain_capacity); const surface = core.render(st.frame_arena.allocator()) catch return; renderFrameGl(g, st.gpa, surface, core.show_debug) catch return; @@ -1875,12 +1906,46 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void { g.mouse_x = w.mouse_x; g.mouse_y = w.mouse_y; const mc = mouseCell(g, core, w.mouse_x, w.mouse_y); - if (w.y != 0) core.update(.{ .mouse = .{ - .button = if (w.y > 0) .wheel_up else .wheel_down, - .kind = .press, - .col = mc.col, - .row = mc.row, - } }); + if (w.y != 0) { + // Still exactly one core row per tick, whatever the wheel's + // resolution — only queued, so stepScroll can hand the rows + // over across the next few frames and slide the pane in + // between. The pane is hit-tested here (the core repeats the + // test for the event it eventually gets) because the queue + // belongs to ONE pane: rows already owed are flushed the + // moment the wheel turns over another one, and a tick that + // hits no pane at all is not an animation, just an event. + const hit: ?usize = for (core.panes, 0..) |slot, i| { + if (slot == null) continue; + const r = core.rects[i]; + if (mc.col >= r.x and mc.col < r.x + r.w and mc.row >= r.y and mc.row < r.y + r.h) break i; + } else null; + if (g.scroll_pane) |old| if (hit == null or hit.? != old) { + while (g.scroll_pending != 0) { + const down = g.scroll_pending > 0; + g.scroll_pending += if (down) -1 else 1; + core.update(.{ .mouse = .{ + .button = if (down) .wheel_down else .wheel_up, + .kind = .press, + .col = g.scroll_col, + .row = g.scroll_row, + } }); + } + g.scroll_lag = 0; + g.scroll_pane = null; + }; + if (hit) |id| { + g.scroll_pane = id; + g.scroll_col = mc.col; + g.scroll_row = mc.row; + g.scroll_pending += if (w.y > 0) -1 else 1; + } else core.update(.{ .mouse = .{ + .button = if (w.y > 0) .wheel_up else .wheel_down, + .kind = .press, + .col = mc.col, + .row = mc.row, + } }); + } if (w.x != 0) core.update(.{ .mouse = .{ .button = if (w.x > 0) .wheel_right else .wheel_left, .kind = .press, @@ -2324,6 +2389,187 @@ fn openLinkWeb(gpa: std.mem.Allocator, url: []const u8) void { std.os.emscripten.emscripten_run_script(script.ptr); } +// ===================================================================== +// smooth scroll: whole rows for the core, sub-row offsets for the picture +// ===================================================================== + +/// One frame of the ease, as pure arithmetic: where the picture lands and how +/// many whole rows that owes the core (positive = down). Split out of +/// stepScroll only because it is the half a test can hold still. +/// +/// The rule that makes the whole thing work is in the loop: every row the +/// picture crosses goes to the core IMMEDIATELY, so the core is always +/// rounded one step AHEAD of the picture, in the direction of travel, and the +/// lag it comes back with always points backwards. That is what makes the +/// strip of content the offset exposes a row from the PAST — the surface the +/// last frame painted still has it — instead of one from the future, which +/// nothing anywhere has. +fn scrollStep(pending: i32, lag: f32) struct { lag: f32, rows: i32 } { + const remaining = @as(f32, @floatFromInt(pending)) - lag; + const travel = if (@abs(remaining) <= scroll_ease_min) remaining else remaining * scroll_ease; + var l = lag + travel; + var rows: i32 = 0; + while ((travel > 0 and l > 0) or (travel < 0 and l < 0)) { + const down = l > 0; + l += if (down) -1 else 1; + rows += if (down) 1 else -1; + } + return .{ .lag = l, .rows = rows }; +} + +test "smooth scroll eases in, and the core is always a whole row ahead" { + // one wheel row: the core gets its row on the very first frame and the + // picture trails behind by less than a row, then closes on it + var s = scrollStep(1, 0); + try std.testing.expectEqual(@as(i32, 1), s.rows); + try std.testing.expect(s.lag > -1 and s.lag < 0); + var frames: u32 = 0; + while (s.lag != 0 and frames < 30) : (frames += 1) { + const next = scrollStep(0, s.lag); + try std.testing.expectEqual(@as(i32, 0), next.rows); // no second crossing + try std.testing.expect(@abs(next.lag) < @abs(s.lag)); // always closing + s = next; + } + try std.testing.expectEqual(@as(f32, 0), s.lag); // and it arrives, exactly + // a fast spin hands over several rows at once and still parks the picture + // inside one row of the core — the exposed strip is never further back + // than the surface the last frame painted + s = scrollStep(-4, 0); + try std.testing.expect(s.rows <= -1 and s.rows >= -4); + try std.testing.expect(s.lag >= 0 and s.lag < 1); + // reversing mid-slide re-rounds the core the other way instead of letting + // the lag point forwards + s = scrollStep(-1, -0.65); + try std.testing.expectEqual(@as(i32, -1), s.rows); + try std.testing.expect(s.lag > 0 and s.lag < 1); +} + +/// Advance the animation one frame: ease the picture, hand the core the whole +/// rows the picture crossed, and keep the row those rows pushed out of the +/// pane. Called from both shells right before core.render() — which is the +/// only moment core.surface still holds what the LAST frame drew. +fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { + const id = g.scroll_pane orelse return; + const pane = core.panes[id] orelse { + g.scroll_pane = null; + g.scroll_pending = 0; + g.scroll_lag = 0; + return; + }; + // the rect the last frame was painted through — what the snapshot below + // indexes. Nothing between here and render() moves it. + const r = core.rects[id]; + const st = scrollStep(g.scroll_pending, g.scroll_lag); + var left = st.rows; + while (left != 0) { + const down = left > 0; + left += if (down) -1 else 1; + const was = pane.scroll(); + core.update(.{ .mouse = .{ + .button = if (down) .wheel_down else .wheel_up, + .kind = .press, + .col = g.scroll_col, + .row = g.scroll_row, + } }); + // the document ran out under us (top of a file, bottom of a live + // terminal): there is no travel left to draw, so stop dead rather + // than slide the pane against a view that is not moving + if (pane.scroll() == was) { + g.scroll_pane = null; + g.scroll_pending = 0; + g.scroll_lag = 0; + return; + } + } + g.scroll_pending -= st.rows; + g.scroll_lag = st.lag; + g.scroll_rect = r; + if (st.rows != 0) { + // The offset opens a gap at the trailing edge of the travel, and what + // belongs in it is the row that just left the pane: already gone from + // the surface the core is about to paint, still in the one it painted + // last frame. k rows in, that row is the k-1'th body row from the top + // going down, the k'th from the bottom going up. + const s = &core.surface; + g.scroll_edge_len = 0; + if (r.w > config.GUTTER and r.h > pardes.BOX_H and r.x + r.w <= s.cols) { + const bw = r.w - config.GUTTER; + const bh = r.h - pardes.BOX_H; + const k: u16 = @intCast(@min(@abs(st.rows), @as(i32, bh))); + const srow = r.y + pardes.BOX_H + (if (st.rows > 0) k - 1 else bh - k); + if (srow < s.rows) { + if (g.scroll_edge.len < bw) { + gpa.free(g.scroll_edge); + g.scroll_edge = gpa.alloc(pardes.Cell, bw) catch &.{}; + } + if (g.scroll_edge.len >= bw) { + @memcpy(g.scroll_edge[0..bw], s.cells[@as(usize, srow) * s.cols + r.x + config.GUTTER ..][0..bw]); + g.scroll_edge_len = bw; + } + } + } + } + if (g.scroll_pending == 0 and g.scroll_lag == 0) g.scroll_pane = null; +} + +/// The animated pane's body, emitted a SECOND time at its sub-row offset, +/// plus the one row of history that fills the gap the offset opens. Writes +/// instances at `base` and returns how many; the caller draws them scissored +/// to the body, which is the whole of the clipping — the shell draws the grid +/// in one flat pass, so without it the overhanging rows would land on the +/// pane's own tag and on whatever is below it. +fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pardes.Surface, layout: CellLayout, win_w: f32, win_h: f32) u32 { + if (g.scroll_pane == null or g.scroll_lag == 0) return 0; + const r = g.scroll_rect; + if (r.w <= config.GUTTER or r.h <= pardes.BOX_H) return 0; + const x0 = r.x + config.GUTTER; + const y0 = r.y + pardes.BOX_H; + const bw = r.w - config.GUTTER; + const bh = r.h - pardes.BOX_H; + if (x0 + bw > surface.cols or y0 + bh > surface.rows) return 0; // resized under us + // the same layout, one sub-row up: emitInstance needs to know nothing + var shifted = layout; + shifted.y_off -= g.scroll_lag * layout.h; + const cursor_idx: u32 = if (surface.cursor) |cu| @as(u32, cu.y) * surface.cols + cu.x else std.math.maxInt(u32); + const cursor_bar = if (surface.cursor) |cu| cu.bar else false; + var n: u32 = 0; + var row = y0; + while (row < y0 + bh) : (row += 1) { + var col = x0; + while (col < x0 + bw) : (col += 1) { + const sidx: u32 = @as(u32, row) * surface.cols + col; + emitInstance(g, instances, base + n, col, row, shifted, win_w, win_h, surface.at(col, row), sidx == cursor_idx, cursor_bar); + n += 1; + } + } + // ...and the row that just left, one row outside the body on the side the + // travel came from. The scissor keeps all of it but the exposed strip. + if (g.scroll_edge_len >= bw) { + const erow: u16 = if (g.scroll_lag > 0) y0 + bh else y0 - 1; + var i: u16 = 0; + while (i < bw) : (i += 1) { + emitInstance(g, instances, base + n, x0 + i, erow, shifted, win_w, win_h, &g.scroll_edge[i], false, false); + n += 1; + } + } + return n; +} + +/// That body rect in target pixels, clamped to the target — the scissor both +/// shells set around the draw above. +fn scrollScissor(g: *const Gui, layout: CellLayout, sw: u32, sh: u32) c.SDL_Rect { + const r = g.scroll_rect; + const px = layout.x_off + @as(f32, @floatFromInt(r.x + config.GUTTER)) * layout.w; + const py = layout.y_off + @as(f32, @floatFromInt(r.y + pardes.BOX_H)) * layout.h; + const pw = @as(f32, @floatFromInt(r.w - config.GUTTER)) * layout.w; + const ph = @as(f32, @floatFromInt(r.h - pardes.BOX_H)) * layout.h; + const x0 = std.math.clamp(@as(i32, @intFromFloat(@floor(px))), 0, @as(i32, @intCast(sw))); + const y0 = std.math.clamp(@as(i32, @intFromFloat(@floor(py))), 0, @as(i32, @intCast(sh))); + const x1 = std.math.clamp(@as(i32, @intFromFloat(@ceil(px + pw))), x0, @as(i32, @intCast(sw))); + const y1 = std.math.clamp(@as(i32, @intFromFloat(@ceil(py + ph))), y0, @as(i32, @intCast(sh))); + return .{ .x = x0, .y = y0, .w = x1 - x0, .h = y1 - y0 }; +} + // ===================================================================== // render: Surface → instanced quads → SDL GPU // ===================================================================== @@ -2366,7 +2612,12 @@ fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, crt_on if (overlay_count != 0) uploadOverlayGpu(g, cmd, overlay_count); const cells: u32 = @as(u32, surface.cols) * surface.rows; - if (cells != 0) try ensureVbuf(g, cells); + // ponytail: ×2, because the smooth-scroll pane is drawn a second time, + // offset, out of the tail of the same buffer — and a pane is never bigger + // than the grid. The ceiling is one grid's worth of vertex memory nobody + // uses when nothing is sliding; sizing it to the actual pane instead means + // re-creating the buffer on every frame that starts or ends a slide. + if (cells != 0) try ensureVbuf(g, cells * 2); var color_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo); color_target.texture = scene; @@ -2398,6 +2649,7 @@ fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, crt_on emitInstance(g, instances, idx, col, row, layout, win_w, win_h, surface.at(col, row), idx == cursor_idx, cursor_bar); } } + const shifted = emitScrollRows(g, instances, cells, surface, layout, win_w, win_h); c.SDL_UnmapGPUTransferBuffer(g.device, g.vxfer.?); // emitInstance may have rasterized new glyphs into the staging atlas; @@ -2406,7 +2658,7 @@ fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, crt_on const copy = c.SDL_BeginGPUCopyPass(cmd); const src = c.SDL_GPUTransferBufferLocation{ .transfer_buffer = g.vxfer.?, .offset = 0 }; - const dst = c.SDL_GPUBufferRegion{ .buffer = g.vbuf.?, .offset = 0, .size = cells * @sizeOf(CellInstance) }; + const dst = c.SDL_GPUBufferRegion{ .buffer = g.vbuf.?, .offset = 0, .size = (cells + shifted) * @sizeOf(CellInstance) }; c.SDL_UploadToGPUBuffer(copy, &src, &dst, false); c.SDL_EndGPUCopyPass(copy); @@ -2417,6 +2669,17 @@ fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, crt_on const vbinding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = 0 }; c.SDL_BindGPUVertexBuffers(rp, 0, &vbinding, 1); c.SDL_DrawGPUPrimitives(rp, 6, cells, 0, 0); + if (shifted != 0) { + // second pass, clipped to the pane body: it overdraws the same + // pane's un-offset cells, and its overhang stops at the body edge + const clip = scrollScissor(g, layout, sw, sh); + c.SDL_SetGPUScissor(rp, &clip); + const sbinding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = cells * @sizeOf(CellInstance) }; + c.SDL_BindGPUVertexBuffers(rp, 0, &sbinding, 1); + c.SDL_DrawGPUPrimitives(rp, 6, shifted, 0, 0); + const whole = c.SDL_Rect{ .x = 0, .y = 0, .w = @intCast(sw), .h = @intCast(sh) }; + c.SDL_SetGPUScissor(rp, &whole); + } drawOverlayGpu(g, rp, overlay_count); c.SDL_EndGPURenderPass(rp); } else { @@ -2713,9 +2976,10 @@ fn renderFrameGl(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, debu _ = c.SDL_GL_SwapWindow(g.window); return; } - if (g.gl_instances.len < cells) { + // ×2 for the smooth-scroll pane's offset copy, as in renderFrame + if (g.gl_instances.len < cells * 2) { gpa.free(g.gl_instances); - g.gl_instances = try gpa.alloc(CellInstance, cells); + g.gl_instances = try gpa.alloc(CellInstance, cells * 2); } const win_w: f32 = @floatFromInt(sw); @@ -2731,6 +2995,7 @@ fn renderFrameGl(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, debu emitInstance(g, g.gl_instances.ptr, idx, col, row, layout, win_w, win_h, surface.at(col, row), idx == cursor_idx, cursor_bar); } } + const shifted = emitScrollRows(g, g.gl_instances.ptr, cells, surface, layout, win_w, win_h); if (g.atlas_dirty) uploadAtlasGl(g); c.glUseProgram(g.gl_program); @@ -2738,7 +3003,7 @@ fn renderFrameGl(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, debu c.glBindTexture(c.GL_TEXTURE_2D, g.gl_atlas_tex); c.glUniform1i(g.gl_u_atlas, 0); c.glBindBuffer(c.GL_ARRAY_BUFFER, g.gl_vbo); - c.glBufferData(c.GL_ARRAY_BUFFER, @intCast(cells * @sizeOf(CellInstance)), g.gl_instances.ptr, c.GL_STREAM_DRAW); + c.glBufferData(c.GL_ARRAY_BUFFER, @intCast((cells + shifted) * @sizeOf(CellInstance)), g.gl_instances.ptr, c.GL_STREAM_DRAW); const stride: c_int = @intCast(@sizeOf(CellInstance)); c.glEnableVertexAttribArray(0); c.glVertexAttribPointer(0, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "x0"))); @@ -2753,6 +3018,21 @@ fn renderFrameGl(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, debu c.glVertexAttribPointer(3, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "br"))); c.glVertexAttribDivisor(3, 1); c.glDrawArraysInstanced(c.GL_TRIANGLES, 0, 6, @intCast(cells)); + if (shifted != 0) { + // the same second pass renderFrame does: re-point the attributes at + // the tail of the buffer, clip to the pane body (GL counts scissor + // rows from the BOTTOM), draw the offset copy over the flat one + const tail: usize = cells * @sizeOf(CellInstance); + c.glVertexAttribPointer(0, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(tail + @offsetOf(CellInstance, "x0"))); + c.glVertexAttribPointer(1, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(tail + @offsetOf(CellInstance, "u0"))); + c.glVertexAttribPointer(2, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(tail + @offsetOf(CellInstance, "fr"))); + c.glVertexAttribPointer(3, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(tail + @offsetOf(CellInstance, "br"))); + const clip = scrollScissor(g, layout, sw, sh); + c.glEnable(c.GL_SCISSOR_TEST); + c.glScissor(clip.x, @as(c_int, @intCast(sh)) - (clip.y + clip.h), clip.w, clip.h); + c.glDrawArraysInstanced(c.GL_TRIANGLES, 0, 6, @intCast(shifted)); + c.glDisable(c.GL_SCISSOR_TEST); + } drawOverlayGl(g, overlay_count); _ = c.SDL_GL_SwapWindow(g.window); } diff --git a/src/main.zig b/src/main.zig index 1a2510de..3808e1f8 100644 --- a/src/main.zig +++ b/src/main.zig @@ -173,3 +173,11 @@ fn parseCtrlKey(raw: []const u8) ?u21 { if (c < 'a' or c > 'z' or c == 'c') return null; return c; } + +// The shells are imported inside main(), which a test build never analyses — +// so their inline tests need naming here to exist at all. gui.zig only +// compiles when it IS the shell (it @cImports SDL), hence the comptime gate; +// under -Dplatform=tty this block analyses to nothing. +test { + if (comptime pardes.platform == .gui) _ = @import("gui/gui.zig"); +} -- cgit v1.3