summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/gui/gui.zig306
-rw-r--r--src/main.zig8
2 files changed, 301 insertions, 13 deletions
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,
@@ -2325,6 +2390,187 @@ fn openLinkWeb(gpa: std.mem.Allocator, url: []const u8) void {
}
// =====================================================================
+// 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");
+}