diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-10 17:32:43 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | 685b8c8e74c9edbdf701594f50ff1b70ac288ec9 (patch) | |
| tree | f303bd5e1f422030c2c645ea2d6c3a393990fb5e /src | |
| parent | ddc2eefb1e7cdd402593efb1297c25fc7ae4b110 (diff) | |
| download | pardes-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.zig | 401 | ||||
| -rw-r--r-- | src/look.zig | 79 | ||||
| -rw-r--r-- | src/syntax.zig | 18 | ||||
| -rw-r--r-- | src/tutor.txt | 24 |
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) |
