diff options
| -rw-r--r-- | src/gui/gui.zig | 174 | ||||
| -rw-r--r-- | src/p4.zig | 69 |
2 files changed, 236 insertions, 7 deletions
diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 092ac2e4..05008d0b 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -1335,6 +1335,18 @@ const Gui = struct { capture_xfer: ?*c.SDL_GPUTransferBuffer = null, capture_xfer_size: u32 = 0, + // Software present. A Vulkan swapchain needs a presentable surface, which + // a compositor without linux-dmabuf cannot provide (p9wl and other + // software/remote Wayland stacks: the driver reports "this surface does + // not support presenting"). The GPU still renders, so render offscreen + // exactly like capture does and blit the readback through SDL_Renderer, + // which goes out over wl_shm. + soft_present: bool = false, + soft_renderer: ?*c.SDL_Renderer = null, + soft_texture: ?*c.SDL_Texture = null, + soft_tex_w: u32 = 0, + soft_tex_h: u32 = 0, + // Steam Deck: gamepad-driven virtual cursor in SDL window coordinates. // Conversion to physical render pixels happens once in mouseCell. gamepad: ?*c.SDL_Gamepad = null, @@ -1671,12 +1683,32 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { log.err("SDL_CreateGPUDevice: {s}", .{c.SDL_GetError()}); return error.SdlInit; }; - if (!c.SDL_ClaimWindowForGPUDevice(device, window)) { - log.err("ClaimWindowForGPUDevice: {s}", .{c.SDL_GetError()}); - return error.SdlInit; + // A failed claim is not fatal: it means the compositor has no presentable + // Vulkan surface (no linux-dmabuf), which is the normal case under p9wl and + // other software/remote Wayland compositors. Rendering still works, so keep + // the device and present the readback through SDL_Renderer instead. + // PARDES_SOFT_PRESENT=1 takes that path on a compositor that could present, + // which is how the path is exercised without a remote display. + var soft_present = false; + var soft_renderer: ?*c.SDL_Renderer = null; + const force_soft = if (env.get("PARDES_SOFT_PRESENT")) |raw| + !(std.mem.eql(u8, raw, "0") or raw.len == 0) + else + false; + if (force_soft or !c.SDL_ClaimWindowForGPUDevice(device, window)) { + if (force_soft) + log.info("PARDES_SOFT_PRESENT set: presenting in software", .{}) + else + log.warn("ClaimWindowForGPUDevice: {s}; presenting in software", .{c.SDL_GetError()}); + soft_renderer = c.SDL_CreateRenderer(window, null) orelse { + log.err("SDL_CreateRenderer: {s}", .{c.SDL_GetError()}); + return error.SdlInit; + }; + soft_present = true; } // present mode: PARDES_SDL_PRESENT env override, else immediate → mailbox → vsync const present_mode: c.SDL_GPUPresentMode = blk: { + if (soft_present) break :blk c.SDL_GPU_PRESENTMODE_VSYNC; if (env.get("PARDES_SDL_PRESENT")) |raw| { const want: ?c.SDL_GPUPresentMode = if (std.ascii.eqlIgnoreCase(raw, "immediate")) c.SDL_GPU_PRESENTMODE_IMMEDIATE @@ -1694,8 +1726,16 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { break :blk c.SDL_GPU_PRESENTMODE_VSYNC; }; if (!test_mode) _ = c.SDL_SetGPUAllowedFramesInFlight(device, 1); - _ = c.SDL_SetGPUSwapchainParameters(device, window, c.SDL_GPU_SWAPCHAINCOMPOSITION_SDR, present_mode); - const swapchain_format = c.SDL_GetGPUSwapchainTextureFormat(device, window); + if (!soft_present) { + _ = c.SDL_SetGPUSwapchainParameters(device, window, c.SDL_GPU_SWAPCHAINCOMPOSITION_SDR, present_mode); + } + // Without a claimed window there is no swapchain format to ask for, so + // pick a colour-target format the device does support; the readback and + // the SDL_Texture agree on it below. + const swapchain_format = if (soft_present) + softTargetFormat(device) + else + c.SDL_GetGPUSwapchainTextureFormat(device, window); // ---- font + cell metrics ---- const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)) orelse { @@ -1802,6 +1842,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { .glyphs = std.AutoHashMap(GlyphKey, Slot).init(gpa), .capture = test_mode, .capture_dir = capture_dir orelse "", + .soft_present = soft_present, + .soft_renderer = soft_renderer, }; setGuiFontName(&g, "Adwaita Mono"); loadFallbackFonts(&g, gpa); @@ -4311,6 +4353,20 @@ fn renderFrame( } try ensureCaptureTexture(g, sw, sh); target = g.capture_tex.?; + } else if (g.soft_present) { + // No swapchain to acquire: render the window-sized frame offscreen and + // blit it in softPresentFrame below. + var pw: c_int = 0; + var ph: c_int = 0; + if (!c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph) or pw <= 0 or ph <= 0) { + command_consumed = true; + _ = c.SDL_SubmitGPUCommandBuffer(cmd); + return false; + } + sw = @intCast(pw); + sh = @intCast(ph); + try ensureCaptureTexture(g, sw, sh); + target = g.capture_tex.?; } else { var swap_tex: ?*c.SDL_GPUTexture = null; if (!c.SDL_AcquireGPUSwapchainTexture(cmd, g.window, &swap_tex, &sw, &sh)) { @@ -4648,6 +4704,14 @@ fn renderFrame( if (paint_plan.len == 1) rememberPresentedImages(g, gpa, surface); return true; } + if (g.soft_present) { + // Same contract as captureFrame: the readback submits `cmd` itself. + command_consumed = true; + try softPresentFrame(g, cmd, target, sw, sh); + g.presented_scene = rendered_scene; + if (paint_plan.len == 1) rememberPresentedImages(g, gpa, surface); + return true; + } command_consumed = true; const submitted = c.SDL_SubmitGPUCommandBuffer(cmd); if (submitted) { @@ -5266,6 +5330,106 @@ fn ensureCaptureTexture(g: *Gui, width: u32, height: u32) !void { g.capture_tex_h = height; } +// ===================================================================== +// Software present: readback + SDL_Renderer blit, for compositors that +// cannot back a Vulkan swapchain (no linux-dmabuf; p9wl, remote stacks) +// ===================================================================== + +/// A colour-target format the device supports and writeCapturePpm/softPresentFrame +/// can both interpret. BGRA first because it is the usual swapchain layout. +fn softTargetFormat(device: *c.SDL_GPUDevice) c.SDL_GPUTextureFormat { + const candidates = [_]c.SDL_GPUTextureFormat{ + c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM, + c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM, + }; + for (candidates) |format| { + if (c.SDL_GPUTextureSupportsFormat( + device, + format, + c.SDL_GPU_TEXTURETYPE_2D, + c.SDL_GPU_TEXTUREUSAGE_COLOR_TARGET | c.SDL_GPU_TEXTUREUSAGE_SAMPLER, + )) return format; + } + return c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM; +} + +/// SDL pixel format matching the byte order of a 32-bit GPU format. +fn softPixelFormat(format: c.SDL_GPUTextureFormat) ?c.SDL_PixelFormat { + return switch (format) { + c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM, + c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM_SRGB, + => c.SDL_PIXELFORMAT_ARGB8888, + c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM, + c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM_SRGB, + => c.SDL_PIXELFORMAT_ABGR8888, + else => null, + }; +} + +fn ensureSoftTexture(g: *Gui, width: u32, height: u32) !*c.SDL_Texture { + if (g.soft_texture) |texture| { + if (g.soft_tex_w == width and g.soft_tex_h == height) return texture; + c.SDL_DestroyTexture(texture); + g.soft_texture = null; + } + const renderer = g.soft_renderer orelse return error.GpuCreate; + const pixel_format = softPixelFormat(g.swapchain_format) orelse return error.UnsupportedCaptureFormat; + const next = c.SDL_CreateTexture( + renderer, + pixel_format, + c.SDL_TEXTUREACCESS_STREAMING, + @intCast(width), + @intCast(height), + ) orelse return error.GpuCreate; + _ = c.SDL_SetTextureScaleMode(next, c.SDL_SCALEMODE_NEAREST); + g.soft_texture = next; + g.soft_tex_w = width; + g.soft_tex_h = height; + return next; +} + +/// Download the finished frame and blit it with SDL_Renderer. Consumes `cmd` +/// on every path, exactly like captureFrame. +fn softPresentFrame(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, target: *c.SDL_GPUTexture, sw: u32, sh: u32) !void { + const bpp = c.SDL_GPUTextureFormatTexelBlockSize(g.swapchain_format); + const size = c.SDL_CalculateGPUTextureFormatSize(g.swapchain_format, sw, sh, 1); + if (size == 0 or bpp != 4) { + _ = c.SDL_SubmitGPUCommandBuffer(cmd); + return error.UnsupportedCaptureFormat; + } + if (g.capture_xfer == null or g.capture_xfer_size < size) { + var info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_DOWNLOAD, .size = size, .props = 0 }; + const next = c.SDL_CreateGPUTransferBuffer(g.device, &info) orelse { + _ = c.SDL_SubmitGPUCommandBuffer(cmd); + return error.GpuCreate; + }; + if (g.capture_xfer) |transfer| c.SDL_ReleaseGPUTransferBuffer(g.device, transfer); + g.capture_xfer = next; + g.capture_xfer_size = size; + } + const xfer = g.capture_xfer.?; + const copy = c.SDL_BeginGPUCopyPass(cmd); + const src = c.SDL_GPUTextureRegion{ .texture = target, .mip_level = 0, .layer = 0, .x = 0, .y = 0, .z = 0, .w = sw, .h = sh, .d = 1 }; + const dst = c.SDL_GPUTextureTransferInfo{ .transfer_buffer = xfer, .offset = 0, .pixels_per_row = sw, .rows_per_layer = sh }; + c.SDL_DownloadFromGPUTexture(copy, &src, &dst); + c.SDL_EndGPUCopyPass(copy); + + const fence = c.SDL_SubmitGPUCommandBufferAndAcquireFence(cmd) orelse return error.GpuSubmit; + defer c.SDL_ReleaseGPUFence(g.device, fence); + var fences = [_]*c.SDL_GPUFence{fence}; + if (!c.SDL_WaitForGPUFences(g.device, true, &fences, 1)) return error.GpuSubmit; + + const mapped = c.SDL_MapGPUTransferBuffer(g.device, xfer, false) orelse return error.GpuMap; + defer c.SDL_UnmapGPUTransferBuffer(g.device, xfer); + + const texture = try ensureSoftTexture(g, sw, sh); + const renderer = g.soft_renderer orelse return error.GpuCreate; + if (!c.SDL_UpdateTexture(texture, null, mapped, @intCast(sw * bpp))) return error.GpuMap; + _ = c.SDL_RenderClear(renderer); + _ = c.SDL_RenderTexture(renderer, texture, null, null); + _ = c.SDL_RenderPresent(renderer); +} + fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, target: *c.SDL_GPUTexture, sw: u32, sh: u32) !void { const size = c.SDL_CalculateGPUTextureFormatSize(g.swapchain_format, sw, sh, 1); if (size == 0) { @@ -259,6 +259,21 @@ export fn pardes_p4_init( // works over a serial line: the replies arrive as ordinary input and are parsed like any key. vx.enterAltScreen(&out) catch |err| return errCode(err); vx.queryTerminalSend(&out) catch |err| return errCode(err); + + // MOUSE REPORTING, spelled out here rather than taken from `vx.setMouseMode`. + // + // vaxis enables `1002;1003;1004;1006`, and 1003 is ANY-MOTION tracking: the terminal reports + // every cell the pointer crosses with no button held. On a 115200 line that is unaffordable - + // one sweep across this grid is dozens of reports of ~15 bytes each, and each one arrives as + // input that the editor must parse while it is trying to paint. Worse, it arrives whether or not + // anybody wants it, so moving the mouse over the window would starve typing. + // + // 1002 reports presses, releases and motion WHILE A BUTTON IS HELD, which is exactly the set a + // click and a drag-select need. 1004 is focus in/out, which `apply` already handles. 1006 is the + // SGR encoding: unlike the original X10 form it is not limited to column 223, which a grid this + // small does not need today but costs nothing to have and cannot be added later without the + // terminal disagreeing with the editor about where the pointer is. + out.writeAll("\x1b[?1002;1004;1006h") catch |err| return errCode(err); out.flush() catch |err| return errCode(err); // The CLAMPED geometry, because the core and vaxis must agree on the grid and vaxis was just @@ -293,8 +308,33 @@ export fn pardes_p4_input(ptr: [*]const u8, len: usize) callconv(.c) void { in_len += take; } + drainInput(c, false); +} + +/// Parse what has accumulated, applying every event it yields. +/// +/// THE LONE ESCAPE IS AMBIGUOUS, and on this transport it is ambiguous constantly. `vaxis.Parser` +/// resolves a buffer containing nothing but `0x1b` as the Escape KEY - deliberately, and correctly +/// for a real terminal, where the kernel hands over a whole escape sequence in one read so a solitary +/// ESC really does mean the key. A 115200 serial line hands over one byte at a time: 87 us apart, +/// which is an eternity to this loop. So the first byte of EVERY escape sequence arrived alone and +/// was resolved as Escape, and the rest arrived as ordinary keys. +/// +/// That is not a mouse bug, though it is why the mouse did not work: a click report came through as +/// ten key presses - Escape, `[`, `<`, `0`, ... - and the `0` among them is "go to column zero" in +/// normal mode, which is exactly where the cursor kept landing. Arrow keys, function keys and the +/// host's in-band resize reports were all being shredded the same way. +/// +/// Longer partial sequences were never affected: the CSI scanner returns `n == 0` for "no final byte +/// yet", and the loop below keeps those bytes. Only the one-byte case needed an answer, because it is +/// the only one the parser answers wrongly instead of declining. +/// +/// `force` is how a real Escape keypress still works: `pardes_p4_tick` calls with it set once the +/// hold has lasted longer than any serial line would take to deliver the next byte. +fn drainInput(c: *pardes.Pardes, force: bool) void { var off: usize = 0; while (off < in_len) { + if (!force and in_len - off == 1 and in_buf[off] == 0x1b) break; const res = parser.parse(in_buf[off..in_len], gpa()) catch break; if (res.n == 0) break; // incomplete: wait for more bytes off += res.n; @@ -305,6 +345,11 @@ export fn pardes_p4_input(ptr: [*]const u8, len: usize) callconv(.c) void { std.mem.copyForwards(u8, in_buf[0 .. in_len - off], in_buf[off..in_len]); in_len -= off; } + // Start or clear the hold. `esc_held_at` is only ever set for a buffer that is exactly one ESC, + // so a partial CSI - which the parser already declines - does not start a timer it does not need. + if (in_len == 1 and in_buf[0] == 0x1b) { + if (esc_held_at == null) esc_held_at = last_now_ms; + } else esc_held_at = null; } /// One parsed vaxis event applied to the core. Mirrors the tty shell's `apply` @@ -475,16 +520,36 @@ fn mapKey(cp: u21) u21 { else => cp, }; } - export fn pardes_p4_tick(now_ms: u64) callconv(.c) void { const c = core orelse return; - _ = now_ms; + last_now_ms = now_ms; + // The held Escape, released. Anything still waiting after this long is a key the human pressed, + // not the head of a sequence: the next byte of a real sequence is 87 us behind on this line, and + // even a slow terminal emulator answers a query in well under a millisecond. Ten is generous by + // two orders of magnitude and imperceptible to the person pressing it - the same trade every + // terminal editor makes for the same reason. + if (esc_held_at) |at| { + if (now_ms -% at >= esc_hold_ms) { + drainInput(c, true); + dirty = true; + } + } if (c.animationActive()) { c.update(.tick); dirty = true; } } +/// How long a lone ESC waits for a second byte before it counts as the Escape key. +const esc_hold_ms = 10; + +/// The last timestamp `pardes_p4_tick` was given, so `drainInput` can date a hold without needing a +/// clock of its own - there is no clock on this side of the ABI. +var last_now_ms: u64 = 0; + +/// When the buffer became a lone ESC, or null when it is not holding one. +var esc_held_at: ?u64 = null; + export fn pardes_p4_wants_frame() callconv(.c) bool { const c = core orelse return false; return dirty or c.animationActive(); |
