summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-08 12:25:04 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commit172eb56fee0b6bde698e72be2bca55a90fe81ce9 (patch)
treef2fd94709facf134c3670db4c1a20a5eaca1d53d /src
parentb91e5ebf8b529d0ced0bf18e83b17df0bd2374ce (diff)
downloadpardes-172eb56fee0b6bde698e72be2bca55a90fe81ce9.tar.gz
pardes-172eb56fee0b6bde698e72be2bca55a90fe81ce9.zip
steam deck nice version
Diffstat (limited to 'src')
-rw-r--r--src/crt.zig43
-rw-r--r--src/deck.zig157
-rw-r--r--src/gui.zig298
-rw-r--r--src/pardes.zig28
-rw-r--r--src/tty.zig1
5 files changed, 465 insertions, 62 deletions
diff --git a/src/crt.zig b/src/crt.zig
new file mode 100644
index 00000000..9cf81593
--- /dev/null
+++ b/src/crt.zig
@@ -0,0 +1,43 @@
+//! The Crt builtin's screen geometry, shared between the gui shell's input
+//! mapping and its shader: crt.frag.glsl shows the scene point
+//! uv' = cc*(1 + barrel*r2) at screen point cc, so a physical mouse position
+//! must pass through the SAME forward transform before it becomes a cell.
+//! Pure std, so the unit-test step can pin it against the shader formula.
+
+const std = @import("std");
+
+/// MUST match BARREL in shaders/crt.frag.glsl
+pub const barrel: f32 = 0.012;
+
+pub const Point = struct { x: f32, y: f32 };
+
+/// physical window pixels -> the scene pixels the shader displays there,
+/// clamped to the window. The shader's phosphor quantization and chromatic
+/// offset displace at most a few pixels — well under a cell — and are
+/// ignored here.
+pub fn map(x: f32, y: f32, w: f32, h: f32) Point {
+ const ccx = x / w * 2.0 - 1.0;
+ const ccy = y / h * 2.0 - 1.0;
+ const s = 1.0 + barrel * (ccx * ccx + ccy * ccy);
+ return .{
+ .x = std.math.clamp((ccx * s + 1.0) * 0.5 * w, 0, w - 1),
+ .y = std.math.clamp((ccy * s + 1.0) * 0.5 * h, 0, h - 1),
+ };
+}
+
+test "map matches the shader's barrel formula" {
+ // center is a fixed point
+ try std.testing.expectApproxEqAbs(@as(f32, 500), map(500, 250, 1000, 500).x, 0.001);
+ try std.testing.expectApproxEqAbs(@as(f32, 250), map(500, 250, 1000, 500).y, 0.001);
+ // corners: cc=(±1,±1), r2=2, scale 1.024 pushes past the tube -> clamped
+ try std.testing.expectEqual(@as(f32, 0), map(0, 0, 1000, 500).x);
+ try std.testing.expectEqual(@as(f32, 0), map(0, 0, 1000, 500).y);
+ try std.testing.expectEqual(@as(f32, 999), map(1000, 500, 1000, 500).x);
+ try std.testing.expectEqual(@as(f32, 499), map(1000, 500, 1000, 500).y);
+ // (100,100) of 1000x500: cc=(-0.8,-0.6), r2=1.0, s=1.012 ->
+ // cc'=(-0.8096,-0.6072) -> (95.2, 98.2)
+ try std.testing.expectApproxEqAbs(@as(f32, 95.2), map(100, 100, 1000, 500).x, 0.01);
+ try std.testing.expectApproxEqAbs(@as(f32, 98.2), map(100, 100, 1000, 500).y, 0.01);
+ // (900,250): cc=(0.8,0), r2=0.64, s=1.00768 -> x'=903.072
+ try std.testing.expectApproxEqAbs(@as(f32, 903.072), map(900, 250, 1000, 500).x, 0.01);
+}
diff --git a/src/deck.zig b/src/deck.zig
new file mode 100644
index 00000000..365c420c
--- /dev/null
+++ b/src/deck.zig
@@ -0,0 +1,157 @@
+//! Steam Deck controls -> editor input, as a pure state machine: gui.zig
+//! feeds SDL gamepad events in and applies the returned actions (cursor
+//! moves, synthetic clicks, wheel ticks, keys, rumble), so the whole
+//! mapping replays off-device from deckcap recordings (test/deck/).
+//!
+//! Layout: right pad moves the pointer (relative, laptop-touchpad-style),
+//! left pad scrolls, R2 = select (button 1), L2 = execute (button 2),
+//! L1 = look (button 3), dpad down/up = n/N, A = Enter, Y = Tab; execute
+//! and look presses also request a brief rumble ack.
+
+const std = @import("std");
+
+// SDL_GamepadButton / SDL_GamepadAxis / touchpad indices (SDL3), verified
+// against real deck recordings (test/deck/*.zon) and the pinned SDL's
+// hidapi deck driver (touchpad 0 = left pad, 1 = right pad).
+pub const BTN_A = 0; // SOUTH
+pub const BTN_Y = 3; // NORTH
+pub const BTN_L1 = 9; // LEFT_SHOULDER
+pub const BTN_DPAD_UP = 11;
+pub const BTN_DPAD_DOWN = 12;
+pub const AXIS_L2 = 4; // LEFT_TRIGGER, analog 0..32767
+pub const AXIS_R2 = 5; // RIGHT_TRIGGER
+pub const PAD_LEFT = 0;
+pub const PAD_RIGHT = 1;
+
+/// feel knobs: a full-pad finger sweep covers this many screen pixels
+/// (the deck's screen is 1280x800) / this many wheel ticks
+pub const POINTER_SCALE: f32 = 600.0;
+pub const SCROLL_SCALE: f32 = 24.0;
+/// the pinned SDL 3.4.4 deck driver skips upstream's pad-Y negation, so
+/// touch y arrives 0 = pad bottom; flip this when the sdl dep is bumped
+pub const PAD_Y_SIGN: f32 = -1.0;
+// analog triggers click at 30% and release at 20%: the gap keeps a value
+// hovering near one threshold from chattering press/release pairs
+pub const TRIG_PRESS: i16 = 9830;
+pub const TRIG_RELEASE: i16 = 6553;
+
+pub const Input = union(enum) {
+ button: struct { idx: u8, down: bool },
+ axis: struct { idx: u8, value: i16 },
+ touch: struct { pad: u8, phase: enum { down, motion, up }, x: f32, y: f32 },
+};
+
+pub const Click = enum { select, execute, look }; // mouse buttons 1/2/3
+pub const Keytap = enum { n, cap_n, enter, tab };
+
+pub const Action = union(enum) {
+ move: struct { dx: f32, dy: f32 }, // pointer delta in screen pixels
+ click: struct { which: Click, down: bool },
+ wheel: i32, // whole ticks, positive = wheel_down
+ key: Keytap,
+ rumble,
+};
+
+pub const Deck = struct {
+ rpad_on: bool = false,
+ rpad_x: f32 = 0,
+ rpad_y: f32 = 0,
+ lpad_on: bool = false,
+ lpad_y: f32 = 0,
+ scroll: f32 = 0, // fractional wheel ticks carried between motions
+ l2_held: bool = false,
+ r2_held: bool = false,
+ look_held: bool = false,
+
+ pub fn feed(d: *Deck, in: Input, out: *[2]Action) usize {
+ var n: usize = 0;
+ switch (in) {
+ .button => |b| switch (b.idx) {
+ BTN_A => if (b.down) {
+ out[0] = .{ .key = .enter };
+ n = 1;
+ },
+ BTN_Y => if (b.down) {
+ out[0] = .{ .key = .tab };
+ n = 1;
+ },
+ BTN_DPAD_UP => if (b.down) {
+ out[0] = .{ .key = .cap_n };
+ n = 1;
+ },
+ BTN_DPAD_DOWN => if (b.down) {
+ out[0] = .{ .key = .n };
+ n = 1;
+ },
+ BTN_L1 => {
+ d.look_held = b.down;
+ out[0] = .{ .click = .{ .which = .look, .down = b.down } };
+ n = 1;
+ if (b.down) {
+ out[1] = .rumble;
+ n = 2;
+ }
+ },
+ else => {},
+ },
+ .axis => |a| {
+ if (a.idx != AXIS_L2 and a.idx != AXIS_R2) return 0;
+ const held = if (a.idx == AXIS_L2) &d.l2_held else &d.r2_held;
+ const which: Click = if (a.idx == AXIS_L2) .execute else .select;
+ if (!held.* and a.value >= TRIG_PRESS) {
+ held.* = true;
+ out[0] = .{ .click = .{ .which = which, .down = true } };
+ n = 1;
+ if (which == .execute) {
+ out[1] = .rumble;
+ n = 2;
+ }
+ } else if (held.* and a.value <= TRIG_RELEASE) {
+ held.* = false;
+ out[0] = .{ .click = .{ .which = which, .down = false } };
+ n = 1;
+ }
+ },
+ .touch => |t| switch (t.pad) {
+ PAD_RIGHT => switch (t.phase) {
+ // a fresh touch only re-anchors — deltas start from the
+ // landing point, so the pointer never jumps to it
+ .down => {
+ d.rpad_on = true;
+ d.rpad_x = t.x;
+ d.rpad_y = t.y;
+ },
+ .motion => if (d.rpad_on) {
+ out[0] = .{ .move = .{
+ .dx = (t.x - d.rpad_x) * POINTER_SCALE,
+ .dy = (t.y - d.rpad_y) * PAD_Y_SIGN * POINTER_SCALE,
+ } };
+ n = 1;
+ d.rpad_x = t.x;
+ d.rpad_y = t.y;
+ },
+ .up => d.rpad_on = false,
+ },
+ PAD_LEFT => switch (t.phase) {
+ .down => {
+ d.lpad_on = true;
+ d.lpad_y = t.y;
+ },
+ .motion => if (d.lpad_on) {
+ d.scroll += (t.y - d.lpad_y) * PAD_Y_SIGN * SCROLL_SCALE;
+ d.lpad_y = t.y;
+ const ticks: i32 = @intFromFloat(d.scroll);
+ if (ticks != 0) {
+ d.scroll -= @floatFromInt(ticks);
+ out[0] = .{ .wheel = ticks };
+ n = 1;
+ }
+ },
+ .up => d.lpad_on = false,
+ },
+ else => {},
+ },
+ }
+ return n;
+ }
+};
diff --git a/src/gui.zig b/src/gui.zig
index d9ddb280..aba4f7a2 100644
--- a/src/gui.zig
+++ b/src/gui.zig
@@ -18,6 +18,8 @@ const vaxis = @import("vaxis"); // test modes only: the stdin escape-seq parser
const ghostty_vt = @import("ghostty-vt"); // 256-color palette for .index cells
const pardes = @import("pardes.zig");
const look = @import("look.zig");
+const deck = @import("deck.zig");
+const crt = @import("crt.zig");
const is_emscripten = builtin.os.tag == .emscripten;
@@ -48,6 +50,8 @@ const vert_spv = if (is_emscripten) "" else @embedFile("ui.vert.spv");
const frag_spv = if (is_emscripten) "" else @embedFile("ui.frag.spv");
const overlay_vert_spv = if (is_emscripten) "" else @embedFile("overlay.vert.spv");
const overlay_frag_spv = if (is_emscripten) "" else @embedFile("overlay.frag.spv");
+const crt_vert_spv = if (is_emscripten) "" else @embedFile("crt.vert.spv");
+const crt_frag_spv = if (is_emscripten) "" else @embedFile("crt.frag.spv");
// web: GL ES shader SOURCES, compiled by WebGL at init (no SPIR-V there)
const vert_glsl_es = if (is_emscripten) @embedFile("ui.vert.es.glsl") else "";
const frag_glsl_es = if (is_emscripten) @embedFile("ui.frag.es.glsl") else "";
@@ -60,13 +64,37 @@ const embedded_dump = if (is_emscripten) @embedFile("embedded.dump.zon") else ""
const bg_default = [3]u8{ 18, 18, 18 };
const fg_default = [3]u8{ 204, 204, 204 };
+// the plan9 arrow cursor, bytes verbatim from 9front /sys/src/9/port/
+// devmouse.c (Cursor arrow, 16x16 MSB-first): clr is the white outline, set
+// the black ink, offset {-1,-1} puts the hot point at (1,1) in the bitmap.
+// SDL_CreateCursor's scheme: data=1,mask=1 black; data=0,mask=1 white;
+// mask=0 transparent — so data = set and mask = set|clr.
+const p9_arrow_clr = [32]u8{
+ 0xFF, 0xFF, 0x80, 0x01, 0x80, 0x02, 0x80, 0x0C,
+ 0x80, 0x10, 0x80, 0x10, 0x80, 0x08, 0x80, 0x04,
+ 0x80, 0x02, 0x80, 0x01, 0x80, 0x02, 0x8C, 0x04,
+ 0x92, 0x08, 0x91, 0x10, 0xA0, 0xA0, 0xC0, 0x40,
+};
+const p9_arrow_set = [32]u8{
+ 0x00, 0x00, 0x7F, 0xFE, 0x7F, 0xFC, 0x7F, 0xF0,
+ 0x7F, 0xE0, 0x7F, 0xE0, 0x7F, 0xF0, 0x7F, 0xF8,
+ 0x7F, 0xFC, 0x7F, 0xFE, 0x7F, 0xFC, 0x73, 0xF8,
+ 0x61, 0xF0, 0x60, 0xE0, 0x40, 0x40, 0x00, 0x00,
+};
+const p9_arrow_mask = blk: {
+ var m: [32]u8 = undefined;
+ for (&m, p9_arrow_set, p9_arrow_clr) |*b, s, cl| b.* = s | cl;
+ break :blk m;
+};
+
// web: glyphs raster at 1.5x and the canvas backs at CSS×1.5 so text stays
// sharp on scaled canvases (the prototype's -Dweb-render-scale, which never
// shipped non-default — hardcoded here instead of a build option).
const web_render_scale: f32 = 1.5;
-const atlas_w: u32 = if (is_emscripten) 2048 else 1024;
-const atlas_h: u32 = if (is_emscripten) 2048 else 1024;
+// 2048 fits ~2500 glyphs at the 2x native cell (~20x40); web needs it too
+const atlas_w: u32 = 2048;
+const atlas_h: u32 = 2048;
const Slot = struct { u: u32, v: u32 };
const max_touch_points: usize = 16;
@@ -370,6 +398,7 @@ const Gui = struct {
swapchain_format: c.SDL_GPUTextureFormat,
pipeline: *c.SDL_GPUGraphicsPipeline,
overlay_pipeline: *c.SDL_GPUGraphicsPipeline,
+ crt_pipeline: *c.SDL_GPUGraphicsPipeline,
atlas_tex: *c.SDL_GPUTexture,
atlas_sampler: *c.SDL_GPUSampler,
atlas_xfer: *c.SDL_GPUTransferBuffer,
@@ -400,6 +429,12 @@ const Gui = struct {
live_ctrl: bool = false,
live_alt: bool = false,
+ // 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,
+ scene_tex_w: u32 = 0,
+ scene_tex_h: u32 = 0,
+
// PARDES_TEST frame capture (render into an offscreen target, dump PPM)
capture: bool = false,
capture_dir: []const u8 = "",
@@ -414,6 +449,7 @@ const Gui = struct {
pad_x: f32 = 0,
pad_y: f32 = 0,
pad_scroll: f32 = 0,
+ deck: deck.Deck = .{},
// web: WebGL2 handles (plain GL ints) + the CPU-side instance staging
gl_program: c_uint = 0,
@@ -480,6 +516,12 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
log.err("SDL_CreateWindow: {s}", .{c.SDL_GetError()});
return error.SdlInit;
};
+ if (c.SDL_CreateCursor(&p9_arrow_set, &p9_arrow_mask, 16, 16, 1, 1)) |cur| {
+ _ = c.SDL_SetCursor(cur);
+ } else log.err("SDL_CreateCursor: {s}", .{c.SDL_GetError()});
+ // fullscreen deck use: keep the pointer inside the window. Absolute
+ // coords stay (no relative mode — clicks need positions).
+ if (!test_mode) _ = c.SDL_SetWindowMouseGrab(window, true);
const device = c.SDL_CreateGPUDevice(c.SDL_GPU_SHADERFORMAT_SPIRV, true, null) orelse {
log.err("SDL_CreateGPUDevice: {s}", .{c.SDL_GetError()});
return error.SdlInit;
@@ -515,7 +557,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
log.err("ui_font_new failed", .{});
return error.FontInit;
};
- const px: f32 = 18.0;
+ const px: f32 = 27.0;
const scale = c.ui_font_scale_for_height(font, px);
var cw: c_int = 10;
var chh: c_int = 20;
@@ -556,6 +598,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
const pipeline = try makePipeline(device, swapchain_format);
const overlay_pipeline = try makeOverlayPipeline(device, swapchain_format);
+ const crt_pipeline = try makeCrtPipeline(device, swapchain_format);
const overlay_buffer_size: u32 = @intCast(max_overlay_vertices * @sizeOf(OverlayVertex));
var ovb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = overlay_buffer_size, .props = 0 };
@@ -575,6 +618,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
.swapchain_format = swapchain_format,
.pipeline = pipeline,
.overlay_pipeline = overlay_pipeline,
+ .crt_pipeline = crt_pipeline,
.atlas_tex = atlas_tex,
.atlas_sampler = atlas_sampler,
.atlas_xfer = atlas_xfer,
@@ -713,7 +757,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
// 7. render
_ = frame_arena.reset(.retain_capacity);
const surface = try core.render(frame_arena.allocator());
- renderFrame(&g, gpa, surface) catch |err| log.err("render: {t}", .{err});
+ renderFrame(&g, gpa, surface, core.crt_on) catch |err| log.err("render: {t}", .{err});
}
}
@@ -805,6 +849,7 @@ fn runWeb(opts_in: pardes.Options) !void {
.swapchain_format = undefined,
.pipeline = undefined,
.overlay_pipeline = undefined,
+ .crt_pipeline = undefined,
.atlas_tex = undefined,
.atlas_sampler = undefined,
.atlas_xfer = undefined,
@@ -1033,12 +1078,21 @@ const StdinFeed = struct {
f.fill = len - seq_start;
break;
};
- if (parseFinger(body[0..end])) |finger| {
+ if (parseFinger(body[0..end])) |finger_in| {
+ var finger = finger_in;
if (g) |gp| {
var wpw: c_int = 0;
var wph: c_int = 0;
_ = c.SDL_GetWindowSizeInPixels(gp.window, &wpw, &wph);
- handleFinger(&gp.touch, core, finger, @floatFromInt(@max(wpw, 1)), @floatFromInt(@max(wph, 1)), @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h));
+ const win_w: f32 = @floatFromInt(@max(wpw, 1));
+ const win_h: f32 = @floatFromInt(@max(wph, 1));
+ if (core.crt_on) {
+ // touch points are physical: same CRT mapping as the mouse
+ const m = crt.map(finger.x * win_w, finger.y * win_h, win_w, win_h);
+ finger.x = m.x / win_w;
+ finger.y = m.y / win_h;
+ }
+ handleFinger(&gp.touch, core, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h));
} else {
handleFinger(&grid_touch, core, finger, @floatFromInt(core.screen_w), @floatFromInt(core.screen_h), 1, 1);
}
@@ -1249,6 +1303,9 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
const m = sev.motion;
g.mouse_x = m.x;
g.mouse_y = m.y;
+ // pad cursor continues from wherever the pointer last was
+ g.pad_x = m.x;
+ g.pad_y = m.y;
// drag = motion with a button held (selection extension keys off it)
const held: ?pardes.Mouse.Button = if ((m.state & c.SDL_BUTTON_LMASK) != 0)
.left
@@ -1306,6 +1363,12 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
var win_h: f32 = @floatFromInt(@max(ph, 1));
var fcw: f32 = @floatFromInt(g.cell_w);
var fch: f32 = @floatFromInt(g.cell_h);
+ if (!is_emscripten and core.crt_on) {
+ // touch points are physical too: same CRT mapping as the mouse
+ const m = crt.map(finger.x * win_w, finger.y * win_h, win_w, win_h);
+ finger.x = m.x / win_w;
+ finger.y = m.y / win_h;
+ }
if (is_emscripten) {
// remap normalized coords into the letterboxed grid so the
// shared two-finger machine's cell math stays exact
@@ -1333,22 +1396,104 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
g.gamepad = null;
}
},
- c.SDL_EVENT_GAMEPAD_BUTTON_DOWN, c.SDL_EVENT_GAMEPAD_BUTTON_UP => {
- const down = sev.type == c.SDL_EVENT_GAMEPAD_BUTTON_DOWN;
- const col = pixelCell(g.pad_x, g.cell_w);
- const row = pixelCell(g.pad_y, g.cell_h);
- const button: pardes.Mouse.Button = switch (sev.gbutton.button) {
- c.SDL_GAMEPAD_BUTTON_SOUTH => .left, // A
- c.SDL_GAMEPAD_BUTTON_EAST => .right, // B
- c.SDL_GAMEPAD_BUTTON_NORTH => .middle, // Y
- c.SDL_GAMEPAD_BUTTON_LEFT_SHOULDER => .wheel_up,
- c.SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER => .wheel_down,
- else => return,
+ // deck.zig maps buttons/triggers/trackpads to pointer, clicks,
+ // wheel, keys and rumble; this arm just applies its actions
+ c.SDL_EVENT_GAMEPAD_BUTTON_DOWN,
+ c.SDL_EVENT_GAMEPAD_BUTTON_UP,
+ c.SDL_EVENT_GAMEPAD_AXIS_MOTION,
+ c.SDL_EVENT_GAMEPAD_TOUCHPAD_DOWN,
+ c.SDL_EVENT_GAMEPAD_TOUCHPAD_MOTION,
+ c.SDL_EVENT_GAMEPAD_TOUCHPAD_UP,
+ => {
+ const in: deck.Input = switch (sev.type) {
+ c.SDL_EVENT_GAMEPAD_BUTTON_DOWN, c.SDL_EVENT_GAMEPAD_BUTTON_UP => .{ .button = .{
+ .idx = sev.gbutton.button,
+ .down = sev.gbutton.down,
+ } },
+ c.SDL_EVENT_GAMEPAD_AXIS_MOTION => .{ .axis = .{
+ .idx = sev.gaxis.axis,
+ .value = sev.gaxis.value,
+ } },
+ else => .{ .touch = .{
+ .pad = @intCast(std.math.clamp(sev.gtouchpad.touchpad, 0, 255)),
+ .phase = switch (sev.type) {
+ c.SDL_EVENT_GAMEPAD_TOUCHPAD_DOWN => .down,
+ c.SDL_EVENT_GAMEPAD_TOUCHPAD_UP => .up,
+ else => .motion,
+ },
+ .x = sev.gtouchpad.x,
+ .y = sev.gtouchpad.y,
+ } },
+ };
+ var acts: [2]deck.Action = undefined;
+ for (acts[0..g.deck.feed(in, &acts)]) |act| switch (act) {
+ .move => |mv| {
+ var pw: c_int = 0;
+ var ph: c_int = 0;
+ _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph);
+ // pad_x/pad_y stay PHYSICAL; mouseCell applies the CRT
+ // mapping once at cell conversion, never into the pad
+ const old = mouseCell(g, core, g.pad_x, g.pad_y);
+ g.pad_x = std.math.clamp(g.pad_x + mv.dx, 0, @as(f32, @floatFromInt(@max(pw, 1))) - 1);
+ g.pad_y = std.math.clamp(g.pad_y + mv.dy, 0, @as(f32, @floatFromInt(@max(ph, 1))) - 1);
+ // the SDL cursor (plan9 arrow) rides along, so the pad
+ // cursor and a hardware mouse are one visible pointer
+ c.SDL_WarpMouseInWindow(g.window, g.pad_x, g.pad_y);
+ const mc = mouseCell(g, core, g.pad_x, g.pad_y);
+ if (mc.col == old.col and mc.row == old.row) continue;
+ // moving with a click held drags, so selections stretch
+ const held: ?pardes.Mouse.Button = if (g.deck.r2_held)
+ .left
+ else if (g.deck.l2_held)
+ .middle
+ else if (g.deck.look_held)
+ .right
+ else
+ null;
+ core.update(.{ .mouse = .{
+ .button = held orelse .none,
+ .kind = if (held != null) .drag else .motion,
+ .col = mc.col,
+ .row = mc.row,
+ } });
+ },
+ .click => |ck| {
+ const mc = mouseCell(g, core, g.pad_x, g.pad_y);
+ core.update(.{ .mouse = .{
+ .button = switch (ck.which) {
+ .select => .left,
+ .execute => .middle,
+ .look => .right,
+ },
+ .kind = if (ck.down) .press else .release,
+ .col = mc.col,
+ .row = mc.row,
+ } });
+ },
+ .wheel => |ticks| {
+ const mc = mouseCell(g, core, g.pad_x, g.pad_y);
+ var left = ticks;
+ while (left != 0) {
+ left += if (ticks > 0) -1 else 1;
+ core.update(.{ .mouse = .{
+ .button = if (ticks > 0) .wheel_down else .wheel_up,
+ .kind = .press,
+ .col = mc.col,
+ .row = mc.row,
+ } });
+ }
+ },
+ .key => |k| core.update(.{ .key = switch (k) {
+ .n => .{ .cp = 'n', .text = "n" },
+ .cap_n => .{ .cp = 'N', .text = "N" },
+ .enter => .{ .cp = pardes.Key.enter },
+ .tab => .{ .cp = pardes.Key.tab },
+ } }),
+ // a brief gentle ack for execute/look, not a buzz
+ .rumble => if (g.gamepad) |pad| {
+ _ = c.SDL_RumbleGamepad(pad, 0x4000, 0x4000, 80);
+ },
};
- switch (button) {
- .wheel_up, .wheel_down => if (down) core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = col, .row = row } }),
- else => core.update(.{ .mouse = .{ .button = button, .kind = if (down) .press else .release, .col = col, .row = row } }),
- }
},
else => {},
}
@@ -1396,9 +1541,24 @@ fn pixelCellF(px: f32, cell: f32) u16 {
}
/// mouse coords → cell. Native windows are pixel-exact; web SDL reports CSS
-/// points into a letterboxed canvas backed at web_render_scale.
+/// points into a letterboxed canvas backed at web_render_scale. With the Crt
+/// builtin on, the physical point first maps to the scene point the shader
+/// displays there (crt.map) — this is the one physical→logical spot shared
+/// by the real mouse and the deck's virtual cursor.
fn mouseCell(g: *const Gui, core: *const pardes.Pardes, x: f32, y: f32) struct { col: u16, row: u16 } {
- if (!is_emscripten) return .{ .col = pixelCell(x, g.cell_w), .row = pixelCell(y, g.cell_h) };
+ if (!is_emscripten) {
+ var mx = x;
+ var my = y;
+ if (core.crt_on) {
+ var pw: c_int = 0;
+ var ph: c_int = 0;
+ _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph);
+ const m = crt.map(mx, my, @floatFromInt(@max(pw, 1)), @floatFromInt(@max(ph, 1)));
+ mx = m.x;
+ my = m.y;
+ }
+ return .{ .col = pixelCell(mx, g.cell_w), .row = pixelCell(my, g.cell_h) };
+ }
var pw: c_int = 0;
var ph: c_int = 0;
_ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph);
@@ -1427,22 +1587,23 @@ fn pollGamepad(g: *Gui, core: *pardes.Pardes) void {
const deadzone: f32 = 8000;
const ax: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_LEFTX));
const ay: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_LEFTY));
- const old_col = pixelCell(g.pad_x, g.cell_w);
- const old_row = pixelCell(g.pad_y, g.cell_h);
- // full tilt ≈ one cell-height per frame; ~60fps makes that a brisk glide
- const speed: f32 = @floatFromInt(g.cell_h);
+ const old = mouseCell(g, core, g.pad_x, g.pad_y);
+ // full tilt ≈ 0.4 cell-heights per frame: a gentle, aimable glide
+ const speed: f32 = @as(f32, @floatFromInt(g.cell_h)) * 0.4;
if (@abs(ax) > deadzone) g.pad_x = std.math.clamp(g.pad_x + ax / 32767.0 * speed, 0, win_w - 1);
if (@abs(ay) > deadzone) g.pad_y = std.math.clamp(g.pad_y + ay / 32767.0 * speed, 0, win_h - 1);
- const col = pixelCell(g.pad_x, g.cell_w);
- const row = pixelCell(g.pad_y, g.cell_h);
- if (col != old_col or row != old_row)
- core.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = col, .row = row } });
+ // only on actual stick movement — an unconditional per-frame warp would
+ // pin the pointer and fight any hardware mouse
+ if (@abs(ax) > deadzone or @abs(ay) > deadzone) c.SDL_WarpMouseInWindow(g.window, g.pad_x, g.pad_y);
+ const mc = mouseCell(g, core, g.pad_x, g.pad_y);
+ if (mc.col != old.col or mc.row != old.row)
+ core.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = mc.col, .row = mc.row } });
const ry: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_RIGHTY));
if (@abs(ry) > deadzone) g.pad_scroll += ry / 32767.0 * 0.15;
while (@abs(g.pad_scroll) >= 1.0) {
const button: pardes.Mouse.Button = if (g.pad_scroll < 0) .wheel_up else .wheel_down;
g.pad_scroll += if (g.pad_scroll < 0) 1.0 else -1.0;
- core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = col, .row = row } });
+ core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = mc.col, .row = mc.row } });
}
}
@@ -1524,15 +1685,6 @@ fn drainEffects(
defer gpa.free(z);
_ = c.SDL_SetClipboardText(z.ptr);
},
- .get_clipboard => {
- if (g == null) continue;
- // synchronous, unlike the tty's OSC 52 round-trip: reply is a paste
- const t = c.SDL_GetClipboardText();
- defer c.SDL_free(t);
- var tlen: usize = 0;
- while (t[tlen] != 0) : (tlen += 1) {}
- if (tlen > 0) core.update(.{ .paste = t[0..tlen] });
- },
.quit => {},
};
}
@@ -1570,9 +1722,8 @@ fn drainEffectsWeb(core: *pardes.Pardes, gpa: std.mem.Allocator, g: *Gui) void {
},
// look on a URL → a new tab
.open_link => |url| openLinkWeb(gpa, url.slice()),
- // nothing to spawn/write/resize/save/dump into; get_clipboard would
- // need an async permission round-trip — all no-ops
- .spawn, .write, .resize_pty, .save_file, .write_dump, .get_clipboard, .quit => {},
+ // nothing to spawn/write/resize/save/dump into — all no-ops
+ .spawn, .write, .resize_pty, .save_file, .write_dump, .quit => {},
};
}
@@ -1595,7 +1746,7 @@ fn openLinkWeb(gpa: std.mem.Allocator, url: []const u8) void {
// render: Surface → instanced quads → SDL GPU
// =====================================================================
-fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface) !void {
+fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, crt_on: bool) !void {
const cmd = c.SDL_AcquireGPUCommandBuffer(g.device) orelse return;
var sw: u32 = 0;
var sh: u32 = 0;
@@ -1624,6 +1775,11 @@ fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface) !void
const win_w: f32 = @floatFromInt(sw);
const win_h: f32 = @floatFromInt(sh);
+ // Crt on: the scene pass lands in an offscreen texture, then a second
+ // pass draws it through the CRT shader onto the real target
+ if (crt_on) try ensureSceneTexture(g, sw, sh);
+ const scene: *c.SDL_GPUTexture = if (crt_on) g.scene_tex.? else target;
+
const overlay_count = buildTouchOverlay(g, sw, sh);
if (overlay_count != 0) uploadOverlayGpu(g, cmd, overlay_count);
@@ -1631,7 +1787,7 @@ fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface) !void
if (cells != 0) try ensureVbuf(g, cells);
var color_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo);
- color_target.texture = target;
+ color_target.texture = scene;
color_target.clear_color = .{
.r = @as(f32, @floatFromInt(bg_default[0])) / 255.0,
.g = @as(f32, @floatFromInt(bg_default[1])) / 255.0,
@@ -1688,6 +1844,19 @@ fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface) !void
c.SDL_EndGPURenderPass(rp);
}
+ if (crt_on) {
+ var crt_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo);
+ crt_target.texture = target;
+ crt_target.load_op = c.SDL_GPU_LOADOP_DONT_CARE;
+ crt_target.store_op = c.SDL_GPU_STOREOP_STORE;
+ const rp = c.SDL_BeginGPURenderPass(cmd, &crt_target, 1, null);
+ c.SDL_BindGPUGraphicsPipeline(rp, g.crt_pipeline);
+ const samp_binding = c.SDL_GPUTextureSamplerBinding{ .texture = g.scene_tex.?, .sampler = g.atlas_sampler };
+ c.SDL_BindGPUFragmentSamplers(rp, 0, &samp_binding, 1);
+ c.SDL_DrawGPUPrimitives(rp, 3, 1, 0, 0);
+ c.SDL_EndGPURenderPass(rp);
+ }
+
if (g.capture) return captureFrame(g, gpa, cmd, target, sw, sh);
_ = c.SDL_SubmitGPUCommandBuffer(cmd);
}
@@ -1904,6 +2073,26 @@ fn makeOverlayPipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureF
return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate;
}
+// the CRT pass: a fullscreen triangle sampling the scene texture, no vertex
+// buffers at all (positions from gl_VertexIndex)
+fn makeCrtPipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline {
+ const vs = try makeShader(device, crt_vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0);
+ defer c.SDL_ReleaseGPUShader(device, vs);
+ const fs = try makeShader(device, crt_frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1);
+ defer c.SDL_ReleaseGPUShader(device, fs);
+
+ var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = std.mem.zeroes(c.SDL_GPUColorTargetBlendState) };
+ var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo);
+ info.vertex_shader = vs;
+ info.fragment_shader = fs;
+ info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST;
+ info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL;
+ info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE;
+ info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1;
+ info.target_info = .{ .color_target_descriptions = &col_desc, .num_color_targets = 1 };
+ return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate;
+}
+
fn makeShader(device: *c.SDL_GPUDevice, code: []const u8, stage: c.SDL_GPUShaderStage, n_samplers: u32) !*c.SDL_GPUShader {
var info = std.mem.zeroes(c.SDL_GPUShaderCreateInfo);
info.code = code.ptr;
@@ -2054,6 +2243,23 @@ fn makeGlShader(kind: c_uint, source: []const u8) !c_uint {
// PARDES_TEST frame capture: download the render target, write latest.ppm
// =====================================================================
+fn ensureSceneTexture(g: *Gui, width: u32, height: u32) !void {
+ if (g.scene_tex != null and g.scene_tex_w == width and g.scene_tex_h == height) return;
+ if (g.scene_tex) |t| c.SDL_ReleaseGPUTexture(g.device, t);
+ var info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo);
+ info.type = c.SDL_GPU_TEXTURETYPE_2D;
+ info.format = g.swapchain_format;
+ info.usage = c.SDL_GPU_TEXTUREUSAGE_COLOR_TARGET | c.SDL_GPU_TEXTUREUSAGE_SAMPLER;
+ info.width = width;
+ info.height = height;
+ info.layer_count_or_depth = 1;
+ info.num_levels = 1;
+ info.sample_count = c.SDL_GPU_SAMPLECOUNT_1;
+ g.scene_tex = c.SDL_CreateGPUTexture(g.device, &info) orelse return error.GpuCreate;
+ g.scene_tex_w = width;
+ g.scene_tex_h = height;
+}
+
fn ensureCaptureTexture(g: *Gui, width: u32, height: u32) !void {
if (g.capture_tex != null and g.capture_tex_w == width and g.capture_tex_h == height) return;
if (g.capture_tex) |t| c.SDL_ReleaseGPUTexture(g.device, t);
diff --git a/src/pardes.zig b/src/pardes.zig
index 994b0785..f6c0a09c 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -36,7 +36,7 @@ pub const PREFIX_W: u16 = 5;
/// above/below the cursor (clamped at file boundaries and short panes)
const scroll_off = 3;
-pub const topbar_str = "Kill Newcol Tutor Debug Colors NextColor Dump";
+pub const topbar_str = "Kill Newcol Tutor Debug Colors NextColor Crt Dump";
const pane_builtins_str = "Del";
const file_pane_builtins_str = "Save Del";
const pane_tail = " " ++ pane_builtins_str;
@@ -44,7 +44,7 @@ const file_pane_tail = " " ++ file_pane_builtins_str;
// Builtins: executing the name (middle-click / Tab) runs the action through the
// ONE dispatcher (onSelect + actOnSelection), no matter where the name appears.
-const SelectAction = enum { none, send, newcol, delcol, del, save, tutor, kill, debug, colors, nextcolor, dump, restore };
+const SelectAction = enum { none, send, newcol, delcol, del, save, tutor, kill, debug, colors, nextcolor, crt, dump, restore };
const Builtin = struct { name: []const u8, action: SelectAction };
const builtins = [_]Builtin{
.{ .name = "Kill", .action = .kill },
@@ -53,6 +53,7 @@ const builtins = [_]Builtin{
.{ .name = "Debug", .action = .debug },
.{ .name = "Colors", .action = .colors },
.{ .name = "NextColor", .action = .nextcolor },
+ .{ .name = "Crt", .action = .crt },
.{ .name = "Dump", .action = .dump },
.{ .name = "Restore", .action = .restore },
.{ .name = "Save", .action = .save },
@@ -355,9 +356,6 @@ pub const Effect = union(enum) {
write_dump,
/// the yank register changed; the shell reads it off the core (OSC 52 out)
set_clipboard,
- /// a normal-mode `p` wants the system clipboard; the shell requests it and
- /// the reply comes back as Event.paste
- get_clipboard,
quit,
fn Buf(comptime n: usize) type {
@@ -709,6 +707,9 @@ pub const Pardes = struct {
theme_idx: usize = 0,
colors_on: bool = true,
show_debug: bool = false,
+ /// the Crt builtin's CRT post-process, rendered by the gui shell only
+ /// (the tty and web shells never read it)
+ crt_on: bool = false,
/// kitty graphics support, reported by the shell (image panes fall back
/// to the petscii matcher without it)
kitty_ok: bool = false,
@@ -732,9 +733,6 @@ pub const Pardes = struct {
/// modal yank register (gpa-owned); a yank also mirrors out to the system
/// clipboard via the set_clipboard effect.
yank: ?[]u8 = null,
- /// a normal-mode `P` is in flight: the next Event.paste pastes BEFORE
- /// (the get_clipboard round trip carries no argument)
- paste_before: bool = false,
/// the last serialized dump (gpa-owned), read by the write_dump effect
dump_out: ?[]u8 = null,
/// where the shell wrote the last dump (shell reports back after
@@ -971,10 +969,9 @@ pub const Pardes = struct {
.key => |key| p.handleKey(key),
.mouse => |m| p.handleMouse(m),
.paste => |bytes| {
- // the OSC 52 system-clipboard reply for a normal-mode `p`/`P`
+ // a shell-level paste (bracketed/SDL): load the register, paste
p.setYank(bytes);
- if (p.panes[p.active]) |pane| p.normalPaste(pane, p.paste_before);
- p.paste_before = false;
+ if (p.panes[p.active]) |pane| p.normalPaste(pane, false);
},
.pinch => |scale| p.ov_pinch_scale = scale,
.touch_scroll => |delta| p.ov_touch_scroll_delta = delta,
@@ -1285,13 +1282,13 @@ pub const Pardes = struct {
p.tagInsertKey(pane, key);
return;
}
- // normal-mode `p`/`P` paste the SYSTEM clipboard: an async OSC 52
- // round trip — request here, the reply arrives later as Event.paste.
+ // normal-mode `p`/`P` paste the core's yank register directly (helix:
+ // the default register, NOT the system clipboard — most terminals
+ // refuse the OSC 52 read, so a round trip would never come back).
// Skipped while a prefix waits for its char argument (f/r/m and
// friends take `p` literally).
if (pane.mode == .normal and pane.pending == 0 and (is(key, 'p') or is(key, 'P'))) {
- p.paste_before = is(key, 'P');
- p.emit(.get_clipboard);
+ p.normalPaste(pane, is(key, 'P'));
return;
}
switch (pane.mode) {
@@ -4350,6 +4347,7 @@ pub const Pardes = struct {
.debug => p.show_debug = !p.show_debug,
.colors => p.colors_on = !p.colors_on,
.nextcolor => p.theme_idx = (p.theme_idx + 1) % themes.len,
+ .crt => p.crt_on = !p.crt_on,
.dump => p.dumpState() catch {},
.restore => {
var it = std.mem.tokenizeAny(u8, std.mem.trim(u8, txt, " \t\r\n"), " \t");
diff --git a/src/tty.zig b/src/tty.zig
index a2ea6462..1342ef6f 100644
--- a/src/tty.zig
+++ b/src/tty.zig
@@ -365,7 +365,6 @@ fn drainEffects(
// mirror the core's yank register out via OSC 52
if (core.yank) |y| vx.copyToSystemClipboard(tty.writer(), y, gpa) catch {};
},
- .get_clipboard => vx.requestSystemClipboard(tty.writer()) catch {},
.quit => {},
};
}