summaryrefslogtreecommitdiff
path: root/src/gui
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-26 01:42:15 -0300
committerGabriel Schneider <[email protected]>2026-08-26 01:42:16 -0300
commit2d3148247e6555b7b33bd532ae538d7a4358e160 (patch)
tree0b9d40d116de8e70a979f92d718b01d4b7e9dfb9 /src/gui
parent4ab24352873ed7bf8db93ef6bfec36a34b0357e8 (diff)
downloadpardes-2d3148247e6555b7b33bd532ae538d7a4358e160.tar.gz
pardes-2d3148247e6555b7b33bd532ae538d7a4358e160.zip
Hold a lone ESC: every escape sequence on this wire was being shredded
The mouse did not work. Chasing that found something much larger: NO escape sequence worked on this transport, and had not since the port began. `vaxis.Parser` resolves a buffer containing nothing but 0x1b as the Escape KEY. That is deliberate and correct for a terminal, where the kernel hands over a whole escape sequence in a single read, so a solitary ESC really does mean somebody pressed Escape. A 115200 serial line hands over ONE BYTE AT A TIME - 87 us apart, an eternity to a loop running at 360 MHz - so the first byte of every sequence arrived alone and was resolved as Escape, and the remaining bytes arrived as ordinary keys. A mouse click therefore came through as TEN key presses: Escape, `[`, `<`, `0`, `;`, `1`, `8`, `;`, `3`, `M`. The `0` among them is "go to column zero" in normal mode, which is exactly where the cursor kept landing, and why the first attempt at this looked like a coordinate bug. Arrow keys, function keys, and the host bridge's in-band resize reports were all being taken apart the same way. Longer partial sequences were never affected: the CSI scanner returns `n == 0` for "no final byte yet" and the shell already keeps those bytes. Only the one-byte case needed an answer, because it is the only one the parser answers WRONGLY instead of declining. So the shell holds a buffer that is exactly one ESC and lets `pardes_p4_tick` release it after 10 ms - two orders of magnitude longer than the 87 us until the next byte of a real sequence, and imperceptible to a person pressing Escape. The same trade every terminal editor makes, for the same reason. Finding it took instrumenting the ABI: printing `@tagName` of every event the shell applied. Ten `key_press` where one `mouse` belonged is not a thing any amount of reading the coordinate arithmetic would have shown, and I had already read it twice. ## Mouse reporting, and the 1003 that is not requested With the sequences intact, `apply` already handled `.mouse` - it mirrors the tty shell - so enabling reporting was the only missing piece. Spelled out here rather than taken from `vx.setMouseMode`, which asks for `1002;1003;1004;1006`: 1003 is ANY-MOTION tracking, a report per cell the pointer crosses with no button held. On a 115200 line that is dozens of 15-byte reports for one sweep, arriving as input the editor must parse while it paints, and arriving whether or not anyone wants it - moving the mouse over the window would starve typing. 1002 reports presses, releases and motion while a button is held, which is exactly what a click and a drag-select need. Verified on the die: a click at column 12 puts the cursor at column 12 and one at column 22 puts it at column 22, a drag paints a selection, and the wheel scrolls. A press alone paints the new position and then reverts - the caret does not move until the gesture ends - so the release is what commits it, which cost an hour of believing a working click was broken. Screen byte-identical to the vaxis reference, round trip median 3682 us against 3682, snap 95/95, hxdiff 481/0, hxparity 561/0, unit-test, tty/p4/gui all build.
Diffstat (limited to 'src/gui')
-rw-r--r--src/gui/gui.zig174
1 files changed, 169 insertions, 5 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) {