diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-05 19:42:46 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | 7988d9bc6e31210ff13994f18d5622dd57ba9341 (patch) | |
| tree | 62138868db52638cbefb18086dc226810fad761a | |
| parent | e36e946f9a56c4ab1d10cef3aace20fe7d274dfa (diff) | |
| download | pardes-7988d9bc6e31210ff13994f18d5622dd57ba9341.tar.gz pardes-7988d9bc6e31210ff13994f18d5622dd57ba9341.zip | |
sdl: ui_sdl rework (380+ lines) + replay changes
| -rw-r--r-- | main.zig | 21 | ||||
| -rw-r--r-- | replay.zig | 97 | ||||
| -rw-r--r-- | tests_sdl.zig | 20 | ||||
| -rw-r--r-- | ui_sdl.zig | 380 |
4 files changed, 459 insertions, 59 deletions
@@ -151,6 +151,7 @@ pub const Command = struct { mouse: vaxis.Mouse, paste: []const u8, // OSC 52 system-clipboard read reply (the loop decodes it) pinch: f32, + touch_scroll_delta: f32, } = .nop; }; @@ -311,6 +312,7 @@ var colors_on: bool = true; // central command dispatcher can flip it; the render reads it. var show_debug: bool = false; var ov_pinch_scale: f32 = 1.0; +var ov_touch_scroll_delta: f32 = 0.0; // Builtins. Executing the name (middle-click / Tab) runs the action through the ONE // dispatcher (onSelect + actOnSelection), no matter where the name is shown — a pane @@ -3393,6 +3395,9 @@ pub fn main(init: std.process.Init) !void { .pinch => |scale| { ov_pinch_scale = scale; }, + .touch_scroll_delta => |delta| { + ov_touch_scroll_delta = delta; + }, .quit => break, else => {}, } @@ -3431,6 +3436,10 @@ pub fn main(init: std.process.Init) !void { .mouse => |next_mouse| { handled = handleWheelMouse(&terms, &rects, screen_w, screen_h, &hover_col, &hover_row, next_mouse); }, + .touch_scroll_delta => |delta| { + ov_touch_scroll_delta = delta; + handled = true; + }, else => {}, } if (!handled) { @@ -3577,12 +3586,12 @@ pub fn main(init: std.process.Init) !void { sb_total = sb.total; } const s = std.fmt.bufPrint( - &ov_buf, - "pardes resizes={d}\nterms {d} cols {d}\nactive #{d} {d}x{d}\nscroll {d}/{d}\npinch {d:6.4}", - .{ resize_count, nlive, layout.ncol, active, at.cols, at.rows, sb_off, sb_total, ov_pinch_scale }, - ) catch ""; - const ow: u16 = 30; - const oh: u16 = 7; + &ov_buf, + "pardes resizes={d}\nterms {d} cols {d}\nactive #{d} {d}x{d}\nscroll {d}/{d}\npinch {d:6.4}\ntscroll {d:7.4}", + .{ resize_count, nlive, layout.ncol, active, at.cols, at.rows, sb_off, sb_total, ov_pinch_scale, ov_touch_scroll_delta }, + ) catch ""; + const ow: u16 = 30; + const oh: u16 = 8; const ox: u16 = if (screen_w > ow) screen_w - ow else 0; const ov = win.child(.{ .x_off = ox, .y_off = TOPBAR_H, .width = ow, .height = oh, .border = .{ .where = .all } }); ov.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = gray.bar } } }); @@ -83,6 +83,7 @@ pub const Command = struct { mouse: ui.Mouse, paste: []const u8, pinch: f32, + touch_scroll_delta: f32, } = .nop; }; @@ -97,6 +98,36 @@ const synStyle = paint.synStyle; const highlightFile = paint.highlightFile; const cellStyle = paint.cellStyle; +const SelectAction = enum { none, colors, nextcolor }; +const Builtin = struct { name: []const u8, action: SelectAction }; +const builtins = [_]Builtin{ + .{ .name = "Colors", .action = .colors }, + .{ .name = "NextColor", .action = .nextcolor }, +}; + +fn topbarText(state: dump.State) []const u8 { + return if (state.topbar.len > 0) state.topbar else "pardes replay"; +} + +fn wordAtCol(str: []const u8, col: u16) []const u8 { + const idx: usize = col; + if (idx >= str.len or str[idx] == ' ') return ""; + var lo: usize = idx; + while (lo > 0 and str[lo - 1] != ' ') lo -= 1; + var hi: usize = idx; + while (hi < str.len and str[hi] != ' ') hi += 1; + return str[lo..hi]; +} + +fn onSelect(text: []const u8) SelectAction { + const cmd = std.mem.trim(u8, text, " \t\r\n"); + if (cmd.len == 0) return .none; + inline for (builtins) |b| { + if (std.mem.eql(u8, cmd, b.name)) return b.action; + } + return .none; +} + fn printUsage(io: std.Io) !void { try std.Io.File.stdout().writeStreamingAll(io, "Usage: pardes-replay <dump.zon>\n"); } @@ -170,7 +201,7 @@ fn setPaneVWeight(panes: []Pane, id: usize, weight: f32) void { panes[id].vweight = weight; } -fn renderPane(alloc: std.mem.Allocator, root: ui.Window, p: *Pane, r: Rect, active: bool) !void { +fn renderPane(alloc: std.mem.Allocator, root: ui.Window, p: *Pane, r: Rect, active: bool, colors_on: bool) !void { if (r.w <= GUTTER or r.h == 0) return; const text = root.child(.{ .x_off = r.x + GUTTER, .y_off = r.y, .width = r.w - GUTTER, .height = r.h }); text.clear(); @@ -199,18 +230,20 @@ fn renderPane(alloc: std.mem.Allocator, root: ui.Window, p: *Pane, r: Rect, acti _ = text.printSegment(.{ .text = line, .style = body_style }, .{ .row_offset = BOX_H + vr, .wrap = .none }); } - const rt = &p.terminal.?; - vr = 0; - while (vr < body_h and vr < p.rows) : (vr += 1) { - var c: u16 = 0; - while (c < body_w) : (c += 1) { - const ci = rt.term.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(c), .y = @intCast(vr) } }) orelse continue; - if (ci.cell.wide == .spacer_tail) continue; - if (text.readCell(c, BOX_H + vr)) |cell| { - var nc = cell; - nc.default = false; - nc.style = cellStyle(ci); - text.writeCell(c, BOX_H + vr, nc); + if (colors_on) { + const rt = &p.terminal.?; + vr = 0; + while (vr < body_h and vr < p.rows) : (vr += 1) { + var c: u16 = 0; + while (c < body_w) : (c += 1) { + const ci = rt.term.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(c), .y = @intCast(vr) } }) orelse continue; + if (ci.cell.wide == .spacer_tail) continue; + if (text.readCell(c, BOX_H + vr)) |cell| { + var nc = cell; + nc.default = false; + nc.style = cellStyle(ci); + text.writeCell(c, BOX_H + vr, nc); + } } } } @@ -237,7 +270,7 @@ fn renderPane(alloc: std.mem.Allocator, root: ui.Window, p: *Pane, r: Rect, acti } } - if (p.highlights.len > 0) { + if (colors_on and p.highlights.len > 0) { var lines = std.mem.splitScalar(u8, p.content, '\n'); var skip: usize = 0; while (skip < p.scroll) : (skip += 1) if (lines.next() == null) break; @@ -295,6 +328,8 @@ fn render( hover_col: u16, hover_row: u16, ov_pinch_scale: f32, + ov_touch_scroll_delta: f32, + colors_on: bool, ) !void { var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); @@ -309,23 +344,23 @@ fn render( image.ensureLoadedFromMemory(iv, p.image_bytes, vx, gpa, writer); image.ensureGrid(iv, gpa, p.cols, p.rows); } - try renderPane(alloc, win, p, rects[id], id == active); + try renderPane(alloc, win, p, rects[id], id == active, colors_on); } const bar = win.child(.{ .x_off = 0, .y_off = 0, .width = screen_w, .height = TOPBAR_H }); bar.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = rgbColor(T().tag_bg) } }); - const topbar = if (state.topbar.len > 0) state.topbar else "pardes replay"; + const topbar = topbarText(state); _ = bar.printSegment(.{ .text = topbar, .style = .{ .fg = rgbColor(T().tag_fg), .bg = rgbColor(T().tag_bg) } }, .{ .wrap = .none }); pane_ui.drawInteractionPreview(win, layout, rects, col_x, col_w, screen_h, drag, hover_col, hover_row, T().scroll_thumb); - // debug overlay with pinch scale - if (ov_pinch_scale != 1.0) { - var ov_buf: [64]u8 = undefined; - const s = std.fmt.bufPrint(&ov_buf, "pinch {d:6.4}", .{ov_pinch_scale}) catch ""; + // debug overlay with gesture telemetry + if (ov_pinch_scale != 1.0 or ov_touch_scroll_delta != 0.0) { + var ov_buf: [96]u8 = undefined; + const s = std.fmt.bufPrint(&ov_buf, "pinch {d:6.4}\ntscroll {d:7.4}", .{ ov_pinch_scale, ov_touch_scroll_delta }) catch ""; if (s.len > 0) { - const ow: u16 = 16; - const oh: u16 = 2; + const ow: u16 = 18; + const oh: u16 = 3; const ox: u16 = if (screen_w > ow) screen_w - ow else 0; const ov = win.child(.{ .x_off = ox, .y_off = TOPBAR_H, .width = ow, .height = oh, .border = .{ .where = .all } }); ov.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = rgbColor(T().scroll_track) } }); @@ -441,6 +476,8 @@ const App = struct { hover_col: u16 = 0, hover_row: u16 = 0, ov_pinch_scale: f32 = 1.0, + ov_touch_scroll_delta: f32 = 0.0, + colors_on: bool = true, fn init( self: *App, @@ -486,6 +523,8 @@ const App = struct { self.drag = .none; self.hover_col = 0; self.hover_row = 0; + self.ov_touch_scroll_delta = 0.0; + self.colors_on = true; self.tty = try ui.Tty.init(io, &self.tty_buf); self.vx = try ui.init(io, gpa, env_map, .{}); @@ -549,6 +588,8 @@ const App = struct { if (hovered) |id| scrollBy(&self.panes[id], 1); } else if (mouse.button == .wheel_up and mouse.type == .press) { if (hovered) |id| scrollBy(&self.panes[id], -1); + } else if (mouse.type == .press and mouse.button == .middle and mrow < TOPBAR_H) { + self.actOnTopbarWord(mcol); } else if (mouse.type == .press and mouse.button == .left) { var handled = false; var c: usize = 0; @@ -658,9 +699,11 @@ const App = struct { }, .key_press => |key| { if (key.matches('q', .{}) or key.matches(ui.Key.escape, .{}) or key.matches('c', .{ .ctrl = true })) return false; + if (key.matches(ui.Key.tab, .{}) and self.hover_row < TOPBAR_H) self.actOnTopbarWord(self.hover_col); }, .paste => |text| self.gpa.free(text), .pinch => |scale| self.ov_pinch_scale = scale, + .touch_scroll_delta => |delta| self.ov_touch_scroll_delta = delta, .quit, .close => return false, else => {}, } @@ -677,9 +720,17 @@ const App = struct { p.rows = rows; clampScroll(p); } - try render(&self.vx, self.gpa, self.tty.writer(), self.state, self.panes, &self.rects, &self.layout, &self.col_x, &self.col_w, self.screen_w, self.screen_h, self.active, self.drag, self.hover_col, self.hover_row, self.ov_pinch_scale); + try render(&self.vx, self.gpa, self.tty.writer(), self.state, self.panes, &self.rects, &self.layout, &self.col_x, &self.col_w, self.screen_w, self.screen_h, self.active, self.drag, self.hover_col, self.hover_row, self.ov_pinch_scale, self.ov_touch_scroll_delta, self.colors_on); return true; } + + fn actOnTopbarWord(self: *App, col: u16) void { + switch (onSelect(wordAtCol(topbarText(self.state), col))) { + .none => {}, + .colors => self.colors_on = !self.colors_on, + .nextcolor => paint.nextTheme(), + } + } }; fn wasmFrame(arg: ?*anyopaque) callconv(.c) void { diff --git a/tests_sdl.zig b/tests_sdl.zig index 857fc0a2..4bb8ab96 100644 --- a/tests_sdl.zig +++ b/tests_sdl.zig @@ -94,6 +94,26 @@ pub fn main(init: std.process.Init) !void { try expectChangedPixels(&hs, before, 1000, "SDL: Tutor visual delta was too small"); try hs.expectBrightRows(1, 8, 300, "SDL: Tutor pane did not render bright text in the top rows"); + // Touch-gesture parity at the app boundary: in PARDES_TEST the SDL app is + // driven by vaxis.Parser reading stdin, so SDL_EVENT_FINGER_* is not part of + // this e2e path. Simulate the gesture result instead: a two-finger tap should + // become a middle-click, and middle-clicking the global "Tutor" word should + // execute the builtin and render the tutor pane. + { + var hs_mid = try Harness.init(gpa, io, exe.ptr, 24, 80); + defer hs_mid.deinit(); + hs_mid.trace = config.trace; + try hs_mid.expectWaitForImage(8000, "SDL middle-click result: no initial frame"); + try hs_mid.pump(1000); + + var mid_before = try hs_mid.imageCopy(); + defer mid_before.deinit(gpa); + try hs_mid.send("\x1b[<1;14;1M\x1b[<1;14;1m"); + try hs_mid.expectWaitForChange(mid_before.hash(), 10000, "SDL middle-click result: Tutor did not visibly change the render"); + try expectChangedPixels(&hs_mid, mid_before, 1000, "SDL middle-click result: Tutor visual delta was too small"); + try hs_mid.expectBrightRows(1, 8, 300, "SDL middle-click result: Tutor pane did not render bright text in the top rows"); + } + // Fresh app: terminal char-mode overlay is visible, can be edited, hides in // raw tty, and returns after leaving tty. Every step is asserted through // rendered pixels. @@ -139,6 +139,9 @@ gl_u_atlas: c_int = -1, gl_instances: []CellInstance = &.{}, overlay_vertices: []OverlayVertex = &.{}, touches: [max_touch_points]TouchPoint = [_]TouchPoint{.{}} ** max_touch_points, +touch_scroll: TouchScrollState = .{}, +touch_middle_clicks: u32 = 0, +touch_middle_flash_frames: u8 = 0, // font + cell metrics font: ?*c.UIFont, @@ -192,7 +195,10 @@ pub const DirtyRange = struct { start: u32, count: u32 }; pub const max_touch_points: usize = 16; pub const max_touch_trail_points: usize = 8; pub const overlay_circle_vertices: usize = 16 * 3; -pub const max_overlay_vertices: usize = max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices); +pub const touch_middle_flash_max_frames: u8 = 14; +pub const touch_middle_flash_vertices: usize = 1400; +pub const touch_scroll_tick: f32 = 0.02; +pub const max_overlay_vertices: usize = touch_middle_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices); // One instance per cell. The vertex shader expands each instance into a 2-triangle // quad using gl_VertexIndex, so dirty uploads move 56 B/cell instead of 240 B/cell. @@ -245,6 +251,25 @@ const TouchPoint = struct { } }; +const TouchNormPoint = struct { + x: f32 = 0, + y: f32 = 0, +}; + +const TouchScrollState = struct { + active: bool = false, + ids: [2]u64 = .{ 0, 0 }, + accum_y: f32 = 0, + scrolled: bool = false, + last_center: TouchNormPoint = .{}, +}; + +const TouchScrollResult = struct { + center: TouchNormPoint, + ticks: i32, + delta_y: f32, +}; + const OverlayColor = struct { r: f32, g: f32, @@ -425,8 +450,9 @@ pub fn init( // ---- test modes ------------------------------------------------------- // PARDES_TEST=1 keeps the real SDL/GPU/window renderer active, but drives - // input from stdin (raw terminal escape sequences parsed by vaxis.Parser) - // and writes rendered-frame captures into PARDES_TEST_CAPTURE_DIR. + // input from stdin (raw terminal escape sequences parsed by vaxis.Parser, + // plus private synthetic SDL event OSCs) and writes rendered-frame captures + // into PARDES_TEST_CAPTURE_DIR. // // PARDES_TEST_GRID=1 is retained as a debug escape hatch: no SDL/GPU/window, // stdin→events, retained cell grid→stdout. The real SDL e2e suite does not @@ -1151,7 +1177,7 @@ fn clampF32(value: f32, lo: f32, hi: f32) f32 { return value; } -fn findTouchSlot(self: *SdlBackend, id: u64) ?usize { +fn findTouchSlot(self: *const SdlBackend, id: u64) ?usize { var i: usize = 0; while (i < self.touches.len) : (i += 1) { if (self.touches[i].active and self.touches[i].id == id) return i; @@ -1167,6 +1193,128 @@ fn inactiveTouchSlot(self: *SdlBackend) ?usize { return null; } +fn sortedTouchPair(a: u64, b: u64) [2]u64 { + return if (a <= b) .{ a, b } else .{ b, a }; +} + +fn sameTouchPair(a: [2]u64, b: [2]u64) bool { + return a[0] == b[0] and a[1] == b[1]; +} + +fn touchPairHas(ids: [2]u64, id: u64) bool { + return ids[0] == id or ids[1] == id; +} + +fn activeTouchPair(self: *const SdlBackend) ?[2]u64 { + var ids: [2]u64 = .{ 0, 0 }; + var count: usize = 0; + for (self.touches) |tp| { + if (!tp.active) continue; + if (count >= 2) return null; + ids[count] = tp.id; + count += 1; + } + return if (count == 2) sortedTouchPair(ids[0], ids[1]) else null; +} + +fn touchPairCenter(self: *const SdlBackend, ids: [2]u64) ?TouchNormPoint { + var sum_x: f32 = 0; + var sum_y: f32 = 0; + var count: usize = 0; + for (ids) |id| { + const idx = self.findTouchSlot(id) orelse return null; + sum_x += self.touches[idx].x; + sum_y += self.touches[idx].y; + count += 1; + } + if (count != 2) return null; + return .{ .x = sum_x * 0.5, .y = sum_y * 0.5 }; +} + +fn touchPairCenterWithEvent(self: *const SdlBackend, ids: [2]u64, tfinger: c.SDL_TouchFingerEvent) ?TouchNormPoint { + const event_id: u64 = @intCast(tfinger.fingerID); + var sum_x: f32 = 0; + var sum_y: f32 = 0; + var count: usize = 0; + for (ids) |id| { + if (id == event_id) { + sum_x += tfinger.x; + sum_y += tfinger.y; + } else { + const idx = self.findTouchSlot(id) orelse return null; + sum_x += self.touches[idx].x; + sum_y += self.touches[idx].y; + } + count += 1; + } + if (count != 2) return null; + return .{ .x = sum_x * 0.5, .y = sum_y * 0.5 }; +} + +fn resetTouchScroll(self: *SdlBackend) void { + self.touch_scroll = .{}; +} + +fn beginTouchScrollPair(self: *SdlBackend, ids: [2]u64) void { + self.touch_scroll = .{ + .active = true, + .ids = ids, + .last_center = self.touchPairCenter(ids) orelse .{}, + }; +} + +fn syncTouchScrollPair(self: *SdlBackend) void { + const ids = self.activeTouchPair() orelse { + self.resetTouchScroll(); + return; + }; + if (!self.touch_scroll.active or !sameTouchPair(self.touch_scroll.ids, ids)) { + self.beginTouchScrollPair(ids); + } +} + +fn finishTouchScrollPair(self: *SdlBackend, tfinger: c.SDL_TouchFingerEvent) ?TouchNormPoint { + const id: u64 = @intCast(tfinger.fingerID); + if (!self.touch_scroll.active or !touchPairHas(self.touch_scroll.ids, id)) return null; + + const tap = tfinger.type == c.SDL_EVENT_FINGER_UP and !self.touch_scroll.scrolled; + const center = if (tap) + self.touchPairCenterWithEvent(self.touch_scroll.ids, tfinger) orelse self.touch_scroll.last_center + else + null; + self.resetTouchScroll(); + return center; +} + +fn noteTouchScrollMotion(self: *SdlBackend, tfinger: c.SDL_TouchFingerEvent) ?TouchScrollResult { + const id: u64 = @intCast(tfinger.fingerID); + if (!self.touch_scroll.active or !touchPairHas(self.touch_scroll.ids, id)) return null; + + const prev_center = self.touch_scroll.last_center; + const center = self.touchPairCenter(self.touch_scroll.ids) orelse self.touch_scroll.last_center; + self.touch_scroll.last_center = center; + const delta_y = center.y - prev_center.y; + self.touch_scroll.accum_y += delta_y; + + var ticks: i32 = 0; + while (self.touch_scroll.accum_y >= touch_scroll_tick) { + self.touch_scroll.accum_y -= touch_scroll_tick; + ticks += 1; + } + while (self.touch_scroll.accum_y <= -touch_scroll_tick) { + self.touch_scroll.accum_y += touch_scroll_tick; + ticks -= 1; + } + if (ticks == 0) return null; + + self.touch_scroll.scrolled = true; + return .{ + .center = center, + .ticks = ticks, + .delta_y = @as(f32, @floatFromInt(ticks)) * touch_scroll_tick, + }; +} + fn updateTouch(self: *SdlBackend, tfinger: c.SDL_TouchFingerEvent) void { const id: u64 = @intCast(tfinger.fingerID); switch (tfinger.type) { @@ -1189,6 +1337,27 @@ fn updateTouch(self: *SdlBackend, tfinger: c.SDL_TouchFingerEvent) void { } } +fn noteTouchMiddleClick(self: *SdlBackend, is_press: bool) void { + if (is_press) self.touch_middle_clicks +%= 1; + self.touch_middle_flash_frames = touch_middle_flash_max_frames; +} + +fn fingerMousePoint(self: *const SdlBackend, x_norm: f32, y_norm: f32) struct { x: f32, y: f32 } { + var ww: c_int = 0; + var wh: c_int = 0; + if (is_emscripten) { + _ = c.SDL_GetWindowSize(self.sdl_window, &ww, &wh); + } else { + _ = c.SDL_GetWindowSizeInPixels(self.sdl_window, &ww, &wh); + } + const w: f32 = @floatFromInt(if (ww > 0) ww else 1); + const h: f32 = @floatFromInt(if (wh > 0) wh else 1); + return .{ + .x = clampF32(x_norm, 0.0, 1.0) * w, + .y = clampF32(y_norm, 0.0, 1.0) * h, + }; +} + fn touchColor(id: u64) OverlayColor { const palette = [_]OverlayColor{ .{ .r = 0.10, .g = 0.72, .b = 1.00, .a = 0.88 }, @@ -1216,6 +1385,25 @@ fn buildTouchOverlay(self: *SdlBackend, sw: u32, sh: u32) u32 { .win_h = win_h, }; + if (self.touch_middle_flash_frames != 0) { + const flash = @as(f32, @floatFromInt(self.touch_middle_flash_frames)) / @as(f32, @floatFromInt(touch_middle_flash_max_frames)); + const label = touchMiddleClickLabel(self.touch_middle_clicks); + const glyph_scale = 3.0 * ui_scale; + const char_step = 4.0 * glyph_scale; + const pad = 5.0 * ui_scale; + const stripe_w = 4.0 * ui_scale; + const label_w = pad * 2.0 + stripe_w + 3.0 * ui_scale + @as(f32, @floatFromInt(label.len)) * char_step - glyph_scale; + const label_h = pad * 2.0 + 5.0 * glyph_scale; + const x = 8.0 * ui_scale; + const y = 8.0 * ui_scale; + + builder.addRect(0.0, 0.0, win_w, win_h, .{ .r = 1.0, .g = 0.78, .b = 0.18, .a = 0.055 * flash }); + builder.addRect(x, y, x + label_w, y + label_h, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.54 + 0.22 * flash }); + builder.addRect(x + pad, y + pad, x + pad + stripe_w, y + label_h - pad, .{ .r = 1.0, .g = 0.78, .b = 0.18, .a = 0.96 }); + builder.addMiniText(label[0..], x + pad + stripe_w + 3.0 * ui_scale, y + pad, glyph_scale, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.96 }); + self.touch_middle_flash_frames -= 1; + } + for (self.touches) |tp| { if (!tp.active) continue; const cx = clampF32(tp.x, 0.0, 1.0) * win_w; @@ -1427,6 +1615,21 @@ fn touchIdLabel(id: u64) [9]u8 { return out; } +fn touchMiddleClickLabel(count: u32) [13]u8 { + var out: [13]u8 = undefined; + out[0] = 'M'; + out[1] = 'I'; + out[2] = 'D'; + out[3] = ' '; + out[4] = '#'; + var i: usize = 0; + while (i < 8) : (i += 1) { + const shift: u5 = @intCast((7 - i) * 4); + out[i + 5] = hexDigit((count >> shift) & 0xf); + } + return out; +} + fn hexDigit(n: u32) u8 { return if (n < 10) @intCast('0' + n) else @intCast('A' + (n - 10)); } @@ -1450,6 +1653,8 @@ fn miniGlyph(ch: u8) [5]u8 { 'D' => .{ 0b110, 0b101, 0b101, 0b101, 0b110 }, 'E' => .{ 0b111, 0b100, 0b111, 0b100, 0b111 }, 'F' => .{ 0b111, 0b100, 0b111, 0b100, 0b100 }, + 'I' => .{ 0b111, 0b010, 0b010, 0b010, 0b111 }, + 'M' => .{ 0b101, 0b111, 0b111, 0b101, 0b101 }, else => .{ 0, 0, 0, 0, 0 }, }; } @@ -2242,6 +2447,7 @@ fn SdlLoop(comptime EventType: type) type { var sev = std.mem.zeroes(c.SDL_Event); while (c.SDL_PollEvent(&sev)) { if (try self.dispatch(&sev)) |ev| return ev; + if (try self.queue.tryPop()) |ev| return ev; } if (is_emscripten) { self.postWinsize(); @@ -2306,6 +2512,16 @@ fn SdlLoop(comptime EventType: type) type { } }) catch {}; } + fn mouseEvent(self: *Self, cell: MouseCell, button: vaxis.Mouse.Button, t: vaxis.Mouse.Type) EventType { + return EventType{ .mouse = .{ + .col = cell.col, + .row = cell.row, + .button = button, + .mods = sdlMouseMods(self.live_mods), + .type = t, + } }; + } + fn dispatch(self: *Self, sev: *const c.SDL_Event) !?EventType { switch (sev.type) { c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => return .quit, @@ -2344,13 +2560,7 @@ fn SdlLoop(comptime EventType: type) type { const t: vaxis.Mouse.Type = if (b.down) .press else .release; const button = mouseButton(b.button) orelse return null; const cell = self.vx.mouseCell(b.x, b.y); - return EventType{ .mouse = .{ - .col = cell.col, - .row = cell.row, - .button = button, - .mods = sdlMouseMods(self.live_mods), - .type = t, - } }; + return self.mouseEvent(cell, button, t); }, c.SDL_EVENT_MOUSE_MOTION => { const m = sev.motion; @@ -2360,30 +2570,59 @@ fn SdlLoop(comptime EventType: type) type { const t: vaxis.Mouse.Type = if (held != null) .drag else .motion; const button = held orelse .none; const cell = self.vx.mouseCell(m.x, m.y); - return EventType{ .mouse = .{ - .col = cell.col, - .row = cell.row, - .button = button, - .mods = sdlMouseMods(self.live_mods), - .type = t, - } }; + return self.mouseEvent(cell, button, t); }, c.SDL_EVENT_MOUSE_WHEEL => { const w = sev.wheel; const dy = w.y; const button: vaxis.Mouse.Button = if (dy > 0) .wheel_up else if (dy < 0) .wheel_down else return null; const cell = self.vx.mouseCell(w.mouse_x, w.mouse_y); - return EventType{ .mouse = .{ - .col = cell.col, - .row = cell.row, - .button = button, - .mods = sdlMouseMods(self.live_mods), - .type = .press, - } }; + return self.mouseEvent(cell, button, .press); }, c.SDL_EVENT_FINGER_DOWN, c.SDL_EVENT_FINGER_MOTION, c.SDL_EVENT_FINGER_UP, c.SDL_EVENT_FINGER_CANCELED => { - self.vx.updateTouch(sev.tfinger); - return .tick; + const f = sev.tfinger; + switch (f.type) { + c.SDL_EVENT_FINGER_DOWN => { + self.vx.updateTouch(f); + self.vx.syncTouchScrollPair(); + return .tick; + }, + c.SDL_EVENT_FINGER_MOTION => { + const had_pair = self.vx.touch_scroll.active; + self.vx.updateTouch(f); + self.vx.syncTouchScrollPair(); + if (had_pair) { + if (self.vx.noteTouchScrollMotion(f)) |scroll| { + const point = self.vx.fingerMousePoint(scroll.center.x, scroll.center.y); + const cell = self.vx.mouseCell(point.x, point.y); + const button: vaxis.Mouse.Button = if (scroll.ticks > 0) .wheel_up else .wheel_down; + var remaining: u32 = @intCast(if (scroll.ticks > 0) scroll.ticks else -scroll.ticks); + while (remaining > 0) : (remaining -= 1) { + try self.postEvent(self.mouseEvent(cell, button, .press)); + } + try self.postEvent(.{ .touch_scroll_delta = scroll.delta_y }); + return null; + } + } + return .tick; + }, + c.SDL_EVENT_FINGER_UP, c.SDL_EVENT_FINGER_CANCELED => { + const tap_center = self.vx.finishTouchScrollPair(f); + self.vx.updateTouch(f); + self.vx.syncTouchScrollPair(); + if (tap_center) |center| { + self.vx.noteTouchMiddleClick(true); + const point = self.vx.fingerMousePoint(center.x, center.y); + const cell = self.vx.mouseCell(point.x, point.y); + try self.postEvent(self.mouseEvent(cell, .middle, .press)); + self.vx.noteTouchMiddleClick(false); + try self.postEvent(self.mouseEvent(cell, .middle, .release)); + return null; + } + return .tick; + }, + else => return .tick, + } }, c.SDL_EVENT_PINCH_BEGIN, c.SDL_EVENT_PINCH_UPDATE, c.SDL_EVENT_PINCH_END => { return .{ .pinch = sev.pinch.scale }; @@ -2443,9 +2682,8 @@ fn SdlLoop(comptime EventType: type) type { // ---- test-mode stdin reader ---- // Parses terminal escape sequences from fd 0 (the pty slave) into the - // app's Event union and posts them to the queue. Identical to vaxis's - // ttyRun but reading from stdin instead of /dev/tty, and checking a - // SIGWINCH flag for resizes. + // app's Event union and posts them to the queue. This mirrors vaxis's + // ttyRun while also recognizing private PARDES_TEST SDL event OSCs. fn stdinRun(self: *Self) void { self.stdinRunTyped() catch {}; } @@ -2475,6 +2713,23 @@ fn SdlLoop(comptime EventType: type) type { const len: usize = read_start + n; var seq_start: usize = 0; while (seq_start < len) { + switch (try self.parseTestSdlEvent(buf[seq_start..len])) { + .no_match => {}, + .need_more => { + const initial = seq_start; + while (seq_start < len) : (seq_start += 1) { + buf[seq_start - initial] = buf[seq_start]; + } + read_start = len - initial; + break; + }, + .consumed => |consumed| { + read_start = 0; + seq_start += consumed; + continue; + }, + } + const result = parser.parse(buf[seq_start..len], null) catch break; if (result.n == 0) { // partial sequence — shift to front of buffer @@ -2505,6 +2760,71 @@ fn SdlLoop(comptime EventType: type) type { } } } + + const TestSdlParse = union(enum) { + no_match, + need_more, + consumed: usize, + }; + + // Private OSC used only by PARDES_TEST: + // ESC ] 777;finger;SDL_EVENT_FINGER_DOWN;<id>;<x>;<y>;<pressure> BEL + // x/y/pressure use SDL's normalized 0..1 touch coordinates. + fn parseTestSdlEvent(self: *Self, bytes: []const u8) !TestSdlParse { + const prefix = "\x1b]777;finger;"; + if (!std.mem.startsWith(u8, bytes, prefix)) { + if (bytes.len >= 2 and bytes.len < prefix.len and std.mem.eql(u8, bytes, prefix[0..bytes.len])) { + return .need_more; + } + return .no_match; + } + + const body = bytes[prefix.len..]; + const end = std.mem.indexOfScalar(u8, body, '\x07') orelse return .need_more; + try self.postTestFingerEvent(body[0..end]); + return .{ .consumed = prefix.len + end + 1 }; + } + + fn postTestFingerEvent(self: *Self, payload: []const u8) !void { + var parts = std.mem.splitScalar(u8, payload, ';'); + const type_name = parts.next() orelse return error.BadTestSdlEvent; + const finger_id_s = parts.next() orelse return error.BadTestSdlEvent; + const x_s = parts.next() orelse return error.BadTestSdlEvent; + const y_s = parts.next() orelse return error.BadTestSdlEvent; + const pressure_s = parts.next() orelse return error.BadTestSdlEvent; + if (parts.next() != null) return error.BadTestSdlEvent; + + const event_type = testFingerEventType(type_name) orelse return error.BadTestSdlEvent; + const finger_id = try std.fmt.parseInt(u64, finger_id_s, 10); + const x = try std.fmt.parseFloat(f32, x_s); + const y = try std.fmt.parseFloat(f32, y_s); + const pressure = try std.fmt.parseFloat(f32, pressure_s); + + var sev = std.mem.zeroes(c.SDL_Event); + sev.type = event_type; + sev.tfinger = .{ + .type = event_type, + .reserved = 0, + .timestamp = 0, + .touchID = 1, + .fingerID = finger_id, + .x = x, + .y = y, + .dx = 0, + .dy = 0, + .pressure = pressure, + .windowID = c.SDL_GetWindowID(self.vx.sdl_window), + }; + if (try self.dispatch(&sev)) |event| try self.postEvent(event); + } + + fn testFingerEventType(name: []const u8) ?c.SDL_EventType { + if (std.mem.eql(u8, name, "SDL_EVENT_FINGER_DOWN") or std.mem.eql(u8, name, "down")) return c.SDL_EVENT_FINGER_DOWN; + if (std.mem.eql(u8, name, "SDL_EVENT_FINGER_MOTION") or std.mem.eql(u8, name, "motion")) return c.SDL_EVENT_FINGER_MOTION; + if (std.mem.eql(u8, name, "SDL_EVENT_FINGER_UP") or std.mem.eql(u8, name, "up")) return c.SDL_EVENT_FINGER_UP; + if (std.mem.eql(u8, name, "SDL_EVENT_FINGER_CANCELED") or std.mem.eql(u8, name, "cancel")) return c.SDL_EVENT_FINGER_CANCELED; + return null; + } }; } |
