summaryrefslogtreecommitdiff
path: root/ui_sdl.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-05 19:42:46 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commit7988d9bc6e31210ff13994f18d5622dd57ba9341 (patch)
tree62138868db52638cbefb18086dc226810fad761a /ui_sdl.zig
parente36e946f9a56c4ab1d10cef3aace20fe7d274dfa (diff)
downloadpardes-7988d9bc6e31210ff13994f18d5622dd57ba9341.tar.gz
pardes-7988d9bc6e31210ff13994f18d5622dd57ba9341.zip
sdl: ui_sdl rework (380+ lines) + replay changes
Diffstat (limited to 'ui_sdl.zig')
-rw-r--r--ui_sdl.zig380
1 files changed, 350 insertions, 30 deletions
diff --git a/ui_sdl.zig b/ui_sdl.zig
index ea95f707..2e967fb8 100644
--- a/ui_sdl.zig
+++ b/ui_sdl.zig
@@ -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;
+ }
};
}