diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-08 12:25:04 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | 172eb56fee0b6bde698e72be2bca55a90fe81ce9 (patch) | |
| tree | f2fd94709facf134c3670db4c1a20a5eaca1d53d /src | |
| parent | b91e5ebf8b529d0ced0bf18e83b17df0bd2374ce (diff) | |
| download | pardes-172eb56fee0b6bde698e72be2bca55a90fe81ce9.tar.gz pardes-172eb56fee0b6bde698e72be2bca55a90fe81ce9.zip | |
steam deck nice version
Diffstat (limited to 'src')
| -rw-r--r-- | src/crt.zig | 43 | ||||
| -rw-r--r-- | src/deck.zig | 157 | ||||
| -rw-r--r-- | src/gui.zig | 298 | ||||
| -rw-r--r-- | src/pardes.zig | 28 | ||||
| -rw-r--r-- | src/tty.zig | 1 |
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 => {}, }; } |
