summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-10 17:32:43 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commit685b8c8e74c9edbdf701594f50ff1b70ac288ec9 (patch)
treef303bd5e1f422030c2c645ea2d6c3a393990fb5e /src
parentddc2eefb1e7cdd402593efb1297c25fc7ae4b110 (diff)
downloadpardes-685b8c8e74c9edbdf701594f50ff1b70ac288ec9.tar.gz
pardes-685b8c8e74c9edbdf701594f50ff1b70ac288ec9.zip
web snapshots: add switch for saving/loading exports, update web version, web snapshot tooling
Diffstat (limited to 'src')
-rw-r--r--src/gui.zig401
-rw-r--r--src/look.zig79
-rw-r--r--src/syntax.zig18
-rw-r--r--src/tutor.txt24
4 files changed, 463 insertions, 59 deletions
diff --git a/src/gui.zig b/src/gui.zig
index a53dfa24..f170e1a6 100644
--- a/src/gui.zig
+++ b/src/gui.zig
@@ -100,10 +100,13 @@ const Slot = struct { u: u32, v: u32 };
const max_touch_points: usize = 16;
const max_touch_trail_points: usize = 8;
const overlay_circle_vertices: usize = 16 * 3;
-const touch_middle_flash_max_frames: u8 = 14;
-const touch_middle_flash_vertices: usize = 1400;
+const touch_click_flash_max_frames: u8 = 14;
+const touch_click_flash_vertices: usize = 1400;
const touch_scroll_tick: f32 = 0.02;
-const max_overlay_vertices: usize = touch_middle_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices);
+/// Web tap-vs-scroll heuristic, measured after normalized coordinates have
+/// been mapped into backing pixels (12px = 8 CSS px at web_render_scale).
+const touch_tap_slop_px: f32 = 12.0;
+const max_overlay_vertices: usize = touch_click_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices);
// One instance per cell; the vertex shader expands it to a 2-triangle quad
// with gl_VertexIndex. Coords are NDC (y up), uv into the atlas, colors 0..1.
@@ -127,7 +130,7 @@ const CellInstance = extern struct {
const OverlayVertex = extern struct { x: f32, y: f32, r: f32, g: f32, b: f32, a: f32 };
const OverlayColor = struct { r: f32, g: f32, b: f32, a: f32 };
-// ---- touch: per-finger tracking + the two-finger scroll/tap machine ----
+// ---- touch: per-finger tracking + shared scroll/tap machines ----
const TouchSample = struct { x: f32 = 0, y: f32 = 0, pressure: f32 = 1 };
@@ -157,8 +160,22 @@ const Touch = struct {
scrolled: bool = false,
last_center: TouchNormPoint = .{},
} = .{},
- middle_clicks: u32 = 0,
- middle_flash_frames: u8 = 0,
+ // Web policy: one primary finger is either a body tap/scroll or a latched
+ // left-mouse gesture when it begins on layout chrome. A second simultaneous
+ // finger cancels a body tap rather than producing a click.
+ single: struct {
+ active: bool = false,
+ id: u64 = 0,
+ start: TouchNormPoint = .{},
+ last: TouchNormPoint = .{},
+ accum_y: f32 = 0,
+ scrolling: bool = false,
+ mouse_drag: bool = false,
+ multitouch: bool = false,
+ } = .{},
+ click_count: u32 = 0,
+ click_button: pardes.Mouse.Button = .middle,
+ click_flash_frames: u8 = 0,
fn findSlot(t: *const Touch, id: u64) ?usize {
for (&t.points, 0..) |*tp, i| if (tp.active and tp.id == id) return i;
@@ -187,6 +204,12 @@ const Touch = struct {
}
}
+ fn activeCount(t: *const Touch) usize {
+ var count: usize = 0;
+ for (&t.points) |*tp| count += @intFromBool(tp.active);
+ return count;
+ }
+
fn pairCenter(t: *const Touch, ids: [2]u64) ?TouchNormPoint {
var sum: TouchNormPoint = .{};
for (ids) |id| {
@@ -250,20 +273,174 @@ const Touch = struct {
const delta_y = center.y - t.scroll.last_center.y;
t.scroll.last_center = center;
t.scroll.accum_y += delta_y;
- var ticks: i32 = 0;
- while (t.scroll.accum_y >= touch_scroll_tick) : (ticks += 1) t.scroll.accum_y -= touch_scroll_tick;
- while (t.scroll.accum_y <= -touch_scroll_tick) : (ticks -= 1) t.scroll.accum_y += touch_scroll_tick;
+ const ticks = takeScrollTicks(&t.scroll.accum_y);
if (ticks == 0) return null;
t.scroll.scrolled = true;
return .{ .center = center, .ticks = ticks, .delta_y = @as(f32, @floatFromInt(ticks)) * touch_scroll_tick };
}
+
+ fn beginSingle(t: *Touch, f: Finger, mouse_drag: bool) void {
+ if (t.single.active) {
+ if (t.single.id != f.id) t.single.multitouch = true;
+ return;
+ }
+ if (t.activeCount() != 1) return;
+ const point = TouchNormPoint{ .x = f.x, .y = f.y };
+ t.single = .{ .active = true, .id = f.id, .start = point, .last = point, .mouse_drag = mouse_drag };
+ }
+
+ fn noteSingleMotion(t: *Touch, f: Finger, win_w: f32, win_h: f32) ?struct { center: TouchNormPoint, ticks: i32, delta_y: f32 } {
+ if (!t.single.active or t.single.id != f.id or t.single.multitouch) return null;
+ const center = TouchNormPoint{ .x = f.x, .y = f.y };
+ var delta_y = center.y - t.single.last.y;
+ t.single.last = center;
+ if (!t.single.scrolling) {
+ const dx = (center.x - t.single.start.x) * win_w;
+ const dy = (center.y - t.single.start.y) * win_h;
+ if (dx * dx + dy * dy < touch_tap_slop_px * touch_tap_slop_px) return null;
+ // Once the pointer leaves the tap slop it can never click. Include
+ // all travel since finger-down so the first scroll tick is prompt.
+ t.single.scrolling = true;
+ delta_y = center.y - t.single.start.y;
+ }
+ t.single.accum_y += delta_y;
+ const ticks = takeScrollTicks(&t.single.accum_y);
+ if (ticks == 0) return null;
+ return .{ .center = center, .ticks = ticks, .delta_y = @as(f32, @floatFromInt(ticks)) * touch_scroll_tick };
+ }
+
+ fn finishSingle(t: *Touch, f: Finger, win_w: f32, win_h: f32) ?TouchNormPoint {
+ if (!t.single.active or t.single.id != f.id) return null;
+ const point = TouchNormPoint{ .x = f.x, .y = f.y };
+ const dx = (point.x - t.single.start.x) * win_w;
+ const dy = (point.y - t.single.start.y) * win_h;
+ const left_slop = dx * dx + dy * dy >= touch_tap_slop_px * touch_tap_slop_px;
+ const tap = f.kind == .up and !left_slop and !t.single.scrolling and !t.single.mouse_drag and !t.single.multitouch;
+ t.single = .{};
+ return if (tap) point else null;
+ }
};
-/// Two-finger drag → synthesized wheel presses + a touch_scroll event; a
-/// two-finger tap → synthesized middle press+release. win/cell dims map
-/// normalized coords to cells (grid test mode passes cols/rows with cell=1).
+fn takeScrollTicks(accum: *f32) i32 {
+ var ticks: i32 = 0;
+ while (accum.* >= touch_scroll_tick) : (ticks += 1) accum.* -= touch_scroll_tick;
+ while (accum.* <= -touch_scroll_tick) : (ticks -= 1) accum.* += touch_scroll_tick;
+ return ticks;
+}
+
+/// The SDL boundary owns touch policy: a web body uses one-finger natural
+/// scrolling and tap-as-LOOK, while layout chrome latches a left-mouse drag;
+/// native keeps two-finger scroll/tap-as-execute. Both feed the same core mouse
+/// events and coordinate mapping.
fn handleFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void {
std.debug.assert(cell_w > 0 and cell_h > 0);
+ if (is_emscripten)
+ return handleSingleFinger(t, core, f, win_w, win_h, cell_w, cell_h);
+ return handlePairFinger(t, core, f, win_w, win_h, cell_w, cell_h);
+}
+
+fn handleSingleFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void {
+ switch (f.kind) {
+ .down => {
+ t.updatePoint(f);
+ const point = TouchNormPoint{ .x = f.x, .y = f.y };
+ const col = normCell(point.x, win_w, cell_w);
+ const row = normCell(point.y, win_h, cell_h);
+ const chrome = webChromeTarget(core, col, row);
+ t.beginSingle(f, chrome != null);
+ if (chrome) |target|
+ core.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = target.col, .row = target.row } });
+ },
+ .motion => {
+ t.updatePoint(f);
+ if (t.single.active and t.single.id == f.id and t.single.mouse_drag) {
+ core.update(.{ .mouse = .{
+ .button = .left,
+ .kind = .drag,
+ .col = normCell(f.x, win_w, cell_w),
+ .row = normCell(f.y, win_h, cell_h),
+ } });
+ return;
+ }
+ const res = t.noteSingleMotion(f, win_w, win_h) orelse return;
+ emitTouchScroll(core, res.center, res.ticks, res.delta_y, win_w, win_h, cell_w, cell_h);
+ },
+ .up, .cancel => {
+ const mouse_drag = t.single.active and t.single.id == f.id and t.single.mouse_drag;
+ const tap = t.finishSingle(f, win_w, win_h);
+ t.updatePoint(f);
+ if (mouse_drag) {
+ core.update(.{ .mouse = .{
+ .button = .left,
+ .kind = .release,
+ .col = normCell(f.x, win_w, cell_w),
+ .row = normCell(f.y, win_h, cell_h),
+ } });
+ } else if (tap) |point| emitTouchClick(t, core, point, .right, win_w, win_h, cell_w, cell_h);
+ },
+ }
+}
+
+const WebChromeTarget = struct { col: u16, row: u16 };
+
+/// Fat-finger hit test for web layout chrome. A gesture is classified once at
+/// finger-down, then never changes into scrolling. Handle coordinates are
+/// snapped onto the core's one-cell resize edge; body rows remain untouched.
+fn webChromeTarget(core: *const pardes.Pardes, col: u16, row: u16) ?WebChromeTarget {
+ if (row < pardes.TOPBAR_H) return .{ .col = col, .row = row };
+
+ // The move box wins over a horizontal handle when a pane is tag-only,
+ // matching the core's own mouse hit-test priority.
+ for (core.panes, 0..) |slot, id| {
+ if (slot == null) continue;
+ const rect = core.rects[id];
+ if (row == rect.y and col >= rect.x and col < rect.x + rect.w and col < rect.x + pardes.GUTTER)
+ return .{ .col = col, .row = rect.y };
+ }
+
+ for (0..core.ncol -| 1) |column| {
+ const handle = core.col_x[column] + core.col_w[column] -| 1;
+ if (col == handle) return .{ .col = handle, .row = row };
+ }
+ for (0..core.ncol) |column| {
+ if (col < core.col_x[column] or col >= core.col_x[column] + core.col_w[column]) continue;
+ for (0..core.col_n[column] -| 1) |index| {
+ const rect = core.rects[core.col_terms[column][index]];
+ const handle = rect.y + rect.h -| 1;
+ if (row == handle) return .{ .col = col, .row = handle };
+ }
+ }
+
+ // The remainder of each tag row is tag editing, not a scrollable body.
+ for (core.panes, 0..) |slot, id| {
+ if (slot == null) continue;
+ const rect = core.rects[id];
+ if (row == rect.y and col >= rect.x and col < rect.x + rect.w)
+ return .{ .col = col, .row = rect.y };
+ }
+
+ // One-cell tolerance around thin separators, after exact tag rows have had
+ // first refusal so a fat-finger tag edit never turns into a resize.
+ for (0..core.ncol -| 1) |column| {
+ const handle = core.col_x[column] + core.col_w[column] -| 1;
+ if (cellDistance(col, handle) == 1) return .{ .col = handle, .row = row };
+ }
+ for (0..core.ncol) |column| {
+ if (col < core.col_x[column] or col >= core.col_x[column] + core.col_w[column]) continue;
+ for (0..core.col_n[column] -| 1) |index| {
+ const rect = core.rects[core.col_terms[column][index]];
+ const handle = rect.y + rect.h -| 1;
+ if (cellDistance(row, handle) == 1) return .{ .col = col, .row = handle };
+ }
+ }
+ return null;
+}
+
+fn cellDistance(a: u16, b: u16) u16 {
+ return if (a > b) a - b else b - a;
+}
+
+fn handlePairFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void {
switch (f.kind) {
.down => {
t.updatePoint(f);
@@ -275,30 +452,37 @@ fn handleFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f
t.syncPair();
if (!had_pair) return;
const res = t.noteMotion(f) orelse return;
- const col = normCell(res.center.x, win_w, cell_w);
- const row = normCell(res.center.y, win_h, cell_h);
- const button: pardes.Mouse.Button = if (res.ticks > 0) .wheel_up else .wheel_down;
- var remaining: u32 = @abs(res.ticks);
- while (remaining > 0) : (remaining -= 1)
- core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = col, .row = row } });
- core.update(.{ .touch_scroll = res.delta_y });
+ emitTouchScroll(core, res.center, res.ticks, res.delta_y, win_w, win_h, cell_w, cell_h);
},
.up, .cancel => {
const tap_center = t.finishPair(f);
t.updatePoint(f);
t.syncPair();
- if (tap_center) |center| {
- t.middle_clicks +%= 1;
- t.middle_flash_frames = touch_middle_flash_max_frames;
- const col = normCell(center.x, win_w, cell_w);
- const row = normCell(center.y, win_h, cell_h);
- core.update(.{ .mouse = .{ .button = .middle, .kind = .press, .col = col, .row = row } });
- core.update(.{ .mouse = .{ .button = .middle, .kind = .release, .col = col, .row = row } });
- }
+ if (tap_center) |center| emitTouchClick(t, core, center, .middle, win_w, win_h, cell_w, cell_h);
},
}
}
+fn emitTouchScroll(core: *pardes.Pardes, center: TouchNormPoint, ticks: i32, delta_y: f32, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void {
+ const col = normCell(center.x, win_w, cell_w);
+ const row = normCell(center.y, win_h, cell_h);
+ const button: pardes.Mouse.Button = if (ticks > 0) .wheel_up else .wheel_down;
+ var remaining: u32 = @abs(ticks);
+ while (remaining > 0) : (remaining -= 1)
+ core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = col, .row = row } });
+ core.update(.{ .touch_scroll = delta_y });
+}
+
+fn emitTouchClick(t: *Touch, core: *pardes.Pardes, point: TouchNormPoint, button: pardes.Mouse.Button, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void {
+ t.click_count +%= 1;
+ t.click_button = button;
+ t.click_flash_frames = touch_click_flash_max_frames;
+ const col = normCell(point.x, win_w, cell_w);
+ const row = normCell(point.y, win_h, cell_h);
+ core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = col, .row = row } });
+ core.update(.{ .mouse = .{ .button = button, .kind = .release, .col = col, .row = row } });
+}
+
fn normCell(norm: f32, win: f32, cell: f32) u16 {
const px = std.math.clamp(norm, 0.0, 1.0) * win;
return @intFromFloat(@max(0, @floor(px / cell)));
@@ -768,7 +952,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, core.crt_on) catch |err| log.err("render: {t}", .{err});
+ renderFrame(&g, gpa, surface, core.crt_on, core.show_debug) catch |err| log.err("render: {t}", .{err});
}
}
@@ -788,11 +972,130 @@ const WebState = struct {
gpa: std.mem.Allocator,
frame_arena: std.heap.ArenaAllocator,
};
+var live_web_state: ?*WebState = null;
+
+// Browser snapshot tests drive real DOM touch input, then read the exact same
+// rendered Surface that WebGL consumed. These tiny exports keep the test
+// contract textual (the existing .snap convention) without adding test state
+// or browser concepts to the core.
+comptime {
+ if (is_emscripten) {
+ @export(&webTestCols, .{ .name = "pardes_web_test_cols" });
+ @export(&webTestRows, .{ .name = "pardes_web_test_rows" });
+ @export(&webTestCursorX, .{ .name = "pardes_web_test_cursor_x" });
+ @export(&webTestCursorY, .{ .name = "pardes_web_test_cursor_y" });
+ @export(&webTestCell, .{ .name = "pardes_web_test_cell" });
+ @export(&webTestCellFg, .{ .name = "pardes_web_test_cell_fg" });
+ @export(&webTestCellBg, .{ .name = "pardes_web_test_cell_bg" });
+ @export(&webTestCellAttrs, .{ .name = "pardes_web_test_cell_attrs" });
+ @export(&webTestCellX, .{ .name = "pardes_web_test_cell_x" });
+ @export(&webTestCellY, .{ .name = "pardes_web_test_cell_y" });
+ }
+}
+
+fn webTestCols() callconv(.c) c_uint {
+ const st = live_web_state orelse return 0;
+ return st.core.surface.cols;
+}
+
+fn webTestRows() callconv(.c) c_uint {
+ const st = live_web_state orelse return 0;
+ return st.core.surface.rows;
+}
+
+fn webTestCursorX() callconv(.c) c_int {
+ const st = live_web_state orelse return -1;
+ return if (st.core.surface.cursor) |cursor| cursor.x else -1;
+}
+
+fn webTestCursorY() callconv(.c) c_int {
+ const st = live_web_state orelse return -1;
+ return if (st.core.surface.cursor) |cursor| cursor.y else -1;
+}
+
+fn webTestCell(col: c_uint, row: c_uint) callconv(.c) c_uint {
+ const cell = webTestCellAt(col, row) orelse return ' ';
+ const glyph = cell.grapheme();
+ if (glyph.len == 0) return ' ';
+ return std.unicode.utf8Decode(glyph) catch 0xfffd;
+}
+
+// RGB occupies 0x000000-0xffffff; the high byte distinguishes the two other
+// Color tags. The browser .snap harness renders these into d/pN/#rrggbb runs.
+fn webTestCellFg(col: c_uint, row: c_uint) callconv(.c) c_uint {
+ const cell = webTestCellAt(col, row) orelse return 0x01000000;
+ return webTestColor(cell.style.fg);
+}
+
+fn webTestCellBg(col: c_uint, row: c_uint) callconv(.c) c_uint {
+ const cell = webTestCellAt(col, row) orelse return 0x01000000;
+ return webTestColor(cell.style.bg);
+}
+
+fn webTestColor(color: pardes.Color) c_uint {
+ return switch (color) {
+ .default => 0x01000000,
+ .index => |idx| 0x02000000 | @as(c_uint, idx),
+ .rgb => |rgb| (@as(c_uint, rgb[0]) << 16) | (@as(c_uint, rgb[1]) << 8) | rgb[2],
+ };
+}
+
+fn webTestCellAttrs(col: c_uint, row: c_uint) callconv(.c) c_uint {
+ const cell = webTestCellAt(col, row) orelse return 0;
+ const style = cell.style;
+ var attrs: c_uint = 0;
+ if (style.bold) attrs |= 1 << 0;
+ if (style.dim) attrs |= 1 << 1;
+ if (style.italic) attrs |= 1 << 2;
+ if (style.blink) attrs |= 1 << 3;
+ if (style.reverse) attrs |= 1 << 4;
+ if (style.invisible) attrs |= 1 << 5;
+ if (style.strikethrough) attrs |= 1 << 6;
+ attrs |= @as(c_uint, @intFromEnum(style.ul)) << 8;
+ return attrs;
+}
+
+fn webTestCellAt(col: c_uint, row: c_uint) ?*const pardes.Cell {
+ const st = live_web_state orelse return null;
+ const surface = &st.core.surface;
+ if (col >= surface.cols or row >= surface.rows) return null;
+ return surface.at(@intCast(col), @intCast(row));
+}
+
+fn webTestCellX(col: f32) callconv(.c) f32 {
+ return webTestCellCenter(col, true);
+}
+
+fn webTestCellY(row: f32) callconv(.c) f32 {
+ return webTestCellCenter(row, false);
+}
+
+fn webTestCellCenter(cell: f32, horizontal: bool) f32 {
+ const st = live_web_state orelse return 0;
+ var pixel_w: c_int = 0;
+ var pixel_h: c_int = 0;
+ _ = c.SDL_GetWindowSizeInPixels(st.g.window, &pixel_w, &pixel_h);
+ const pw: u32 = @intCast(@max(pixel_w, 1));
+ const ph: u32 = @intCast(@max(pixel_h, 1));
+ var css_w: f64 = 0;
+ var css_h: f64 = 0;
+ if (emscripten_get_element_css_size("#canvas", &css_w, &css_h) != EMSCRIPTEN_RESULT_SUCCESS) return 0;
+ const layout = cellLayout(&st.g, st.core.surface.cols, st.core.surface.rows, pw, ph);
+ if (horizontal) {
+ const backing = layout.x_off + (cell + 0.5) * layout.w;
+ return backing * @as(f32, @floatCast(css_w)) / @as(f32, @floatFromInt(pw));
+ }
+ const backing = layout.y_off + (cell + 0.5) * layout.h;
+ return backing * @as(f32, @floatCast(css_h)) / @as(f32, @floatFromInt(ph));
+}
fn runWeb(opts_in: pardes.Options) !void {
web_fba = .init(&web_heap);
const gpa = web_fba.allocator();
+ // Our finger machine deliberately maps touch to scroll/LOOK. Suppress
+ // SDL's default synthetic left mouse event for the same browser touch.
+ _ = c.SDL_SetHint(c.SDL_HINT_TOUCH_MOUSE_EVENTS, "0");
if (!c.SDL_Init(c.SDL_INIT_VIDEO)) { // no gamepad in the browser
log.err("SDL_Init: {s}", .{c.SDL_GetError()});
return error.SdlInit;
@@ -900,6 +1203,7 @@ fn runWeb(opts_in: pardes.Options) !void {
opts.cols = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), cell_w)));
opts.rows = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), cell_h)));
st.core = try pardes.Pardes.initFromDump(gpa, opts, embedded_dump);
+ live_web_state = st;
_ = c.SDL_StartTextInput(window);
std.os.emscripten.emscripten_set_main_loop_arg(webFrame, st, 0, 0);
@@ -927,7 +1231,7 @@ fn webFrame(arg: ?*anyopaque) callconv(.c) void {
// 4. render
_ = st.frame_arena.reset(.retain_capacity);
const surface = core.render(st.frame_arena.allocator()) catch return;
- renderFrameGl(g, st.gpa, surface) catch return;
+ renderFrameGl(g, st.gpa, surface, core.show_debug) catch return;
}
/// the canvas follows the page (CSS); the SDL window follows the canvas,
@@ -1382,7 +1686,7 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
}
if (is_emscripten) {
// remap normalized coords into the letterboxed grid so the
- // shared two-finger machine's cell math stays exact
+ // shared touch machine's cell math stays exact
const layout = cellLayout(g, core.screen_w, core.screen_h, @intCast(@max(pw, 1)), @intCast(@max(ph, 1)));
const grid_w = @max(1.0, @as(f32, @floatFromInt(core.screen_w)) * layout.w);
const grid_h = @max(1.0, @as(f32, @floatFromInt(core.screen_h)) * layout.h);
@@ -1772,7 +2076,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, crt_on: bool) !void {
+fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, crt_on: bool, debug_on: bool) !void {
const cmd = c.SDL_AcquireGPUCommandBuffer(g.device) orelse return;
var sw: u32 = 0;
var sh: u32 = 0;
@@ -1806,7 +2110,7 @@ fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, crt_on
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);
+ const overlay_count = buildTouchOverlay(g, sw, sh, debug_on);
if (overlay_count != 0) uploadOverlayGpu(g, cmd, overlay_count);
const cells: u32 = @as(u32, surface.cols) * surface.rows;
@@ -2135,7 +2439,7 @@ fn makeShader(device: *c.SDL_GPUDevice, code: []const u8, stage: c.SDL_GPUShader
// re-upload each frame; attribs re-specified per draw (no VAO to manage).
// =====================================================================
-fn renderFrameGl(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface) !void {
+fn renderFrameGl(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, debug_on: bool) !void {
var pw: c_int = 0;
var ph: c_int = 0;
_ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph);
@@ -2150,7 +2454,7 @@ fn renderFrameGl(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface) !voi
);
c.glClear(c.GL_COLOR_BUFFER_BIT);
- const overlay_count = buildTouchOverlay(g, sw, sh);
+ const overlay_count = buildTouchOverlay(g, sw, sh, debug_on);
const cells: u32 = @as(u32, surface.cols) * surface.rows;
if (cells == 0) {
drawOverlayGl(g, overlay_count);
@@ -2376,20 +2680,24 @@ fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u
}
// =====================================================================
-// touch debug overlay: per-finger colored circles + trails + a middle-click
-// flash HUD, alpha-blended over the grid. Always active on touch input.
+// touch debug overlay: per-finger colored circles + trails + a click-action
+// flash HUD, alpha-blended over the grid only while the Debug builtin is on.
// =====================================================================
-fn buildTouchOverlay(g: *Gui, sw: u32, sh: u32) u32 {
+fn buildTouchOverlay(g: *Gui, sw: u32, sh: u32, debug_on: bool) u32 {
+ if (!debug_on) {
+ g.touch.click_flash_frames = 0;
+ return 0;
+ }
if (g.overlay_vertices.len == 0 or sw == 0 or sh == 0) return 0;
const win_w: f32 = @floatFromInt(sw);
const win_h: f32 = @floatFromInt(sh);
var builder = OverlayBuilder{ .vertices = g.overlay_vertices, .win_w = win_w, .win_h = win_h };
const t = &g.touch;
- if (t.middle_flash_frames != 0) {
- const flash = @as(f32, @floatFromInt(t.middle_flash_frames)) / @as(f32, @floatFromInt(touch_middle_flash_max_frames));
- const label = touchMiddleClickLabel(t.middle_clicks);
+ if (t.click_flash_frames != 0) {
+ const flash = @as(f32, @floatFromInt(t.click_flash_frames)) / @as(f32, @floatFromInt(touch_click_flash_max_frames));
+ const label = touchClickLabel(t.click_count, t.click_button);
const glyph_scale = 3.0;
const char_step = 4.0 * glyph_scale;
const pad = 5.0;
@@ -2400,7 +2708,7 @@ fn buildTouchOverlay(g: *Gui, sw: u32, sh: u32) u32 {
builder.addRect(8.0, 8.0, 8.0 + label_w, 8.0 + label_h, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.54 + 0.22 * flash });
builder.addRect(8.0 + pad, 8.0 + pad, 8.0 + pad + stripe_w, 8.0 + label_h - pad, .{ .r = 1.0, .g = 0.78, .b = 0.18, .a = 0.96 });
builder.addMiniText(label[0..], 8.0 + pad + stripe_w + 3.0, 8.0 + pad, glyph_scale, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.96 });
- t.middle_flash_frames -= 1;
+ t.click_flash_frames -= 1;
}
for (&t.points) |*tp| {
@@ -2573,12 +2881,12 @@ fn touchIdLabel(id: u64) [9]u8 {
return out;
}
-fn touchMiddleClickLabel(count: u32) [13]u8 {
- var out: [13]u8 = undefined;
- @memcpy(out[0..5], "MID #");
+fn touchClickLabel(count: u32, button: pardes.Mouse.Button) [14]u8 {
+ var out: [14]u8 = undefined;
+ @memcpy(out[0..6], if (button == .right) "LOOK #" else "MID #");
for (0..8) |i| {
const shift: u5 = @intCast((7 - i) * 4);
- out[i + 5] = hexDigit((count >> shift) & 0xf);
+ out[i + 6] = hexDigit((count >> shift) & 0xf);
}
return out;
}
@@ -2607,7 +2915,10 @@ fn miniGlyph(ch: u8) [5]u8 {
'E' => .{ 0b111, 0b100, 0b111, 0b100, 0b111 },
'F' => .{ 0b111, 0b100, 0b111, 0b100, 0b100 },
'I' => .{ 0b111, 0b010, 0b010, 0b010, 0b111 },
+ 'K' => .{ 0b101, 0b101, 0b110, 0b101, 0b101 },
+ 'L' => .{ 0b100, 0b100, 0b100, 0b100, 0b111 },
'M' => .{ 0b101, 0b111, 0b111, 0b101, 0b101 },
+ 'O' => .{ 0b111, 0b101, 0b101, 0b101, 0b111 },
else => .{ 0, 0, 0, 0, 0 },
};
}
diff --git a/src/look.zig b/src/look.zig
index 8744da24..fb74353f 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -5,12 +5,16 @@
//! os.tag, so every platform's behavior sits in the same screenful:
//! tty/gui — the word resolves through the real filesystem (realpath,
//! open(O_DIRECTORY)); dirs open shells, files open file panes.
-//! web — there is no filesystem; only URLs mean anything, and a URL
-//! opens in a new tab (the open_link effect).
+//! web — tracked Pardes .zig sources form a build-generated read-only
+//! filesystem; URLs still open in a new tab.
const std = @import("std");
const builtin = @import("builtin");
const libc = std.c;
const pardes = @import("pardes.zig");
+const embedded_sources = if (pardes.platform == .web) @import("embedded_sources") else struct {
+ pub const Source = struct { path: []const u8, contents: []const u8 };
+ pub const all = [_]Source{};
+};
extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*:0]u8;
@@ -55,7 +59,8 @@ pub fn isImagePath(path: []const u8) bool {
}
/// Resolve a looked-at word against the pane's directory. `realbuf` must
-/// outlive the returned Target (dir/file/image paths point into it).
+/// outlive the returned Target (native paths point into it; web paths are
+/// process-lifetime slices in the embedded source archive).
pub fn resolve(word_raw: []const u8, cwd: []const u8, realbuf: *[4096]u8) Target {
const trimmed = std.mem.trim(u8, word_raw, " \t\r\n");
const pl = parsePathLine(trimmed);
@@ -75,13 +80,68 @@ pub fn resolve(word_raw: []const u8, cwd: []const u8, realbuf: *[4096]u8) Target
if (isImagePath(resolved)) return .{ .image = .{ .path = resolved } };
return .{ .file = .{ .path = resolved, .line = pl.line } };
} else {
- // web: no filesystem. A URL opens in a new tab; everything else is inert.
+ // web: URLs leave the app; tracked Zig sources resolve inside the
+ // build-generated, read-only source filesystem.
if (std.mem.startsWith(u8, trimmed, "http://") or std.mem.startsWith(u8, trimmed, "https://"))
return .{ .url = trimmed };
+ if (resolveEmbedded(word, cwd, realbuf)) |source|
+ return .{ .file = .{ .path = source.path, .line = pl.line } };
return .none;
}
}
+/// Resolve a source path without teaching the core about a browser filesystem.
+/// Cwd-relative and absolute dump paths are normalized, with printed archive
+/// paths also accepted root-relative. The suffix match lets a dump made in
+/// `/host/repo` address names that deliberately remain relative to the root.
+fn resolveEmbedded(word: []const u8, cwd: []const u8, scratch: *[4096]u8) ?embedded_sources.Source {
+ var wordbuf: [4096]u8 = undefined;
+ const normalized_word = normalizeVirtualPath(word, &wordbuf) orelse return null;
+ if (word.len > 0 and word[0] == '/') return findEmbeddedSource(normalized_word, true);
+
+ var joined: [4096]u8 = undefined;
+ if (std.fmt.bufPrint(&joined, "{s}/{s}", .{ cwd, word }) catch null) |candidate|
+ if (normalizeVirtualPath(candidate, scratch)) |normalized|
+ if (findEmbeddedSource(normalized, true)) |source| return source;
+ // A printed archive path is root-relative even when its surrounding dump
+ // pane came from some unrelated cwd.
+ return findEmbeddedSource(normalized_word, false);
+}
+
+fn normalizeVirtualPath(path: []const u8, out: *[4096]u8) ?[]const u8 {
+ var len: usize = 0;
+ var parts = std.mem.tokenizeAny(u8, path, "/\\");
+ while (parts.next()) |part| {
+ if (std.mem.eql(u8, part, ".")) continue;
+ if (std.mem.eql(u8, part, "..")) {
+ while (len > 0 and out[len - 1] != '/') len -= 1;
+ if (len > 0) len -= 1;
+ continue;
+ }
+ const extra = part.len + @intFromBool(len != 0);
+ if (len + extra > out.len) return null;
+ if (len != 0) {
+ out[len] = '/';
+ len += 1;
+ }
+ @memcpy(out[len..][0..part.len], part);
+ len += part.len;
+ }
+ if (len == 0) return null;
+ return out[0..len];
+}
+
+fn findEmbeddedSource(path: []const u8, allow_root_suffix: bool) ?embedded_sources.Source {
+ for (embedded_sources.all) |source|
+ if (std.mem.eql(u8, source.path, path)) return source;
+ if (!allow_root_suffix) return null;
+ for (embedded_sources.all) |source| {
+ if (path.len <= source.path.len or path[path.len - source.path.len - 1] != '/') continue;
+ if (std.mem.endsWith(u8, path, source.path)) return source;
+ }
+ return null;
+}
+
const platform_has_fs = switch (pardes.platform) {
.tty, .gui => true,
.web => false,
@@ -95,10 +155,15 @@ fn isDir(path: [*:0]const u8) bool {
return true;
}
-/// read a whole file (gpa-owned) — the look side of opening a file pane.
-/// Compiled out on web (no fs); dumps provide file content there instead.
+/// Read a whole file (gpa-owned) — the look side of opening a file pane. Web
+/// reads from the generated source archive; native shells read the real fs.
pub fn readFile(gpa: std.mem.Allocator, path: []const u8) ![]u8 {
- if (!platform_has_fs) return error.NoFilesystem;
+ if (!platform_has_fs) {
+ var normalized_buf: [4096]u8 = undefined;
+ const normalized = normalizeVirtualPath(path, &normalized_buf) orelse return error.OpenFailed;
+ const source = findEmbeddedSource(normalized, true) orelse return error.OpenFailed;
+ return gpa.dupe(u8, source.contents);
+ }
var pathbuf: [4096]u8 = undefined;
const path_z = std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0) catch return error.PathTooLong;
const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY });
diff --git a/src/syntax.zig b/src/syntax.zig
index 0d2fa291..bbab17fa 100644
--- a/src/syntax.zig
+++ b/src/syntax.zig
@@ -1,12 +1,13 @@
//! Tree-sitter syntax highlighting: one style byte per content byte, filled by
//! running each grammar's highlights.scm query (slurped at build time into the
-//! ts_queries options module). Grammar set is tiered: `minimal` (c, cpp, zig)
-//! compiles queries at startup; `full` adds ~23 languages lazily on first use.
+//! ts_queries options module). Grammar set is tiered: `zig`; `minimal` (c, cpp,
+//! zig); and `full`, which adds ~23 languages lazily on first use.
const std = @import("std");
const config = @import("pardes_config");
const tracy = @import("tracy.zig");
pub const enabled = config.syntax_highlighting;
+const zig_grammar = config.syntax_zig_grammar;
const minimal_grammars = config.syntax_minimal_grammars;
const full_grammars = config.syntax_full_grammars;
@@ -52,10 +53,13 @@ const Spec = struct {
compiled_query: ?*ts.Query = null,
};
+var zig_specs = if (zig_grammar) [_]Spec{
+ .{ .exts = &.{ ".zig", ".zon" }, .language = tree_sitter_zig, .query_src = ts_queries.zig_highlights },
+} else [_]Spec{};
+
var minimal_specs = if (minimal_grammars) [_]Spec{
.{ .exts = &.{ ".c", ".h" }, .language = tree_sitter_c, .query_src = ts_queries.c_highlights },
.{ .exts = &.{ ".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx" }, .language = tree_sitter_cpp, .query_src = ts_queries.cpp_highlights },
- .{ .exts = &.{ ".zig", ".zon" }, .language = tree_sitter_zig, .query_src = ts_queries.zig_highlights },
} else [_]Spec{};
var full_specs = if (full_grammars) [_]Spec{
@@ -98,6 +102,11 @@ fn ensure(spec: *Spec) !Selected {
}
fn forExt(ext: []const u8) !?Selected {
+ for (&zig_specs) |*spec| {
+ for (spec.exts) |choice| {
+ if (std.ascii.eqlIgnoreCase(ext, choice)) return try ensure(spec);
+ }
+ }
for (&minimal_specs) |*spec| {
for (spec.exts) |choice| {
if (std.ascii.eqlIgnoreCase(ext, choice)) return try ensure(spec);
@@ -111,9 +120,10 @@ fn forExt(ext: []const u8) !?Selected {
return null;
}
-/// compile the minimal-tier queries eagerly so the first .zig/.c open is fast
+/// Compile the compact-tier queries eagerly so the first source open is fast.
pub fn compileStartupQueries() void {
if (!enabled) return;
+ for (&zig_specs) |*spec| _ = ensure(spec) catch {};
for (&minimal_specs) |*spec| _ = ensure(spec) catch {};
}
diff --git a/src/tutor.txt b/src/tutor.txt
index 7a8c7026..e2dfb123 100644
--- a/src/tutor.txt
+++ b/src/tutor.txt
@@ -66,6 +66,20 @@
opens/focuses a terminal there and ls's it; a file opens
a file pane (scrolled to a ":line" suffix if present).
+ WEB TOUCH (one finger): a tap is LOOK, the same action as R; a drag
+ past a small movement threshold is natural scrolling.
+ Once a gesture becomes a scroll it cannot also LOOK on
+ release. The browser has no host filesystem or ptys:
+ LOOK opens URLs or read-only Pardes .zig sources that
+ were Git-tracked and embedded when the web build was made.
+ The published launcher is a real Pardes terminal dump of
+ `git ls-files '*.zig'`: every listed path opens, and the
+ opened source uses tree-sitter syntax colors.
+ A touch beginning on a tagline or pane separator instead
+ latches a left-mouse gesture, so layout drags never scroll.
+ Touch circles/trails and the LOOK flash appear only while
+ the Debug builtin is on.
+
SELECT-THEN-ACT:
- MIDDLE-drag over text selects AND runs it on release (one gesture);
a no-drag middle click auto-expands to the word under the cursor.
@@ -119,9 +133,9 @@
- the scrollbar is the gutter below the box: left-click scrolls UP
to that point, right-click scrolls DOWN
- (Mouse actions are hard to unit-test from text; they're covered by
- the e2e suite: middle-click execute, right-click dir/file open, tag
- builtins, drag-rearrange. See tests.zig steps 6-8.)
+ (Mouse actions are hard to unit-test from text; native gestures are
+ covered by the e2e suite. Browser touch and embedded-source LOOK are
+ driven through real Chromium input by `zig build web-snap`.)
=================================================================
@@ -418,6 +432,10 @@
2-1 = middle-exec with the left selection as argument
file tag = mode + path + "Save Del"; other tags show Del
top bar = Kill Newcol Tutor Debug Colors NextColor
+ WEB TOUCH: one-finger tap = LOOK; drag = natural scroll
+ LOOK opens URLs or embedded tracked .zig source (read-only)
+ published TTY dump lists every source; tree-sitter colors it
+ tag/separator start = left drag; touch HUD requires Debug
TTY: a terminal IS a pane; Ctrl-b toggles the live shell by default,
landing its cursor where you navigated (prompt start if
the input is empty, mid-text otherwise)