diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/config.zig | 12 | ||||
| -rw-r--r-- | src/effect_sources.zig | 24 | ||||
| -rw-r--r-- | src/gui/gui.zig | 151 | ||||
| -rw-r--r-- | src/macos.zig | 20 | ||||
| -rw-r--r-- | src/macos/Sources/PardesView.swift | 1 | ||||
| -rw-r--r-- | src/macos/pardes.h | 2 | ||||
| -rw-r--r-- | src/panel_animation.zig | 101 | ||||
| -rw-r--r-- | src/pardes.zig | 411 | ||||
| -rw-r--r-- | src/tty/panel_compositor.zig | 140 | ||||
| -rw-r--r-- | src/tutor.txt | 10 |
10 files changed, 638 insertions, 234 deletions
diff --git a/src/config.zig b/src/config.zig index c8a8f8cf..9f18f75e 100644 --- a/src/config.zig +++ b/src/config.zig @@ -465,6 +465,18 @@ comptime { @compileError("config.gui_tagline_font_percent must be in 1...100"); } +/// Physical-pixel rule between the global topbar and pane taglines in the +/// SDL GUI. Their smaller font bands retain body-sized grid rows; without +/// an explicit join, centering both bands leaves the two unused half-bands +/// touching and makes a wide strip of the window background show through. +/// Zero disables the rule and joins the two bands directly. +pub const gui_topbar_pane_border_px: u8 = 1; + +/// Fixed RGB for that rule, or null to follow the active theme's scrollbar +/// track. The themed default stays quiet across light and dark themes while a +/// build that wants a deliberate accent can pin one here. +pub const gui_topbar_pane_border_rgb: ?[3]u8 = null; + /// Touchpad drift guard, in ticks. A two-finger swipe that is MEANT to be /// vertical carries a little sideways drift, and the pad faithfully turns that /// drift into wheel_left/wheel_right — so a plain scroll slides the view diff --git a/src/effect_sources.zig b/src/effect_sources.zig index 87026278..6f1c6bb1 100644 --- a/src/effect_sources.zig +++ b/src/effect_sources.zig @@ -9,6 +9,7 @@ const build_config = @import("pardes_config"); const runtime_config = @import("runtime_config.zig"); const panel_math = @embedFile("panel_animation.zig"); +const pardes_core = @embedFile("pardes.zig"); const tty_compositor = @embedFile("tty/panel_compositor.zig"); const panel_vertex = @embedFile("effect-source-ui.vert.glsl"); const panel_fragment = @embedFile("effect-source-ui.frag.glsl"); @@ -59,6 +60,16 @@ const gui_cell_instance = sourceSection( "// EFFECT_CODE_CELL_INSTANCE_BEGIN\n", "// EFFECT_CODE_CELL_INSTANCE_END", ); +const ascii_diff = sourceSection( + pardes_core, + "// EFFECT_CODE_ASCII_DIFF_BEGIN\n", + "// EFFECT_CODE_ASCII_DIFF_END", +); +const ascii_compositor = sourceSection( + pardes_core, + " // EFFECT_CODE_ASCII_COMPOSITOR_BEGIN\n", + " // EFFECT_CODE_ASCII_COMPOSITOR_END", +); const ui_vertex_path = if (build_config.gui_shader_sources_prebuilt) "shaders/prebuilt/ui.vert.glsl" @@ -117,10 +128,14 @@ pub const Segment = struct { const tty_panel = [_]Segment{ .{ .path = "src/panel_animation.zig", .source = panel_math }, + .{ .path = "src/pardes.zig#ascii-diff", .source = ascii_diff }, + .{ .path = "src/pardes.zig#ascii-compositor", .source = ascii_compositor }, .{ .path = "src/tty/panel_compositor.zig", .source = tty_compositor }, }; const gui_panel = [_]Segment{ .{ .path = "src/panel_animation.zig", .source = panel_math }, + .{ .path = "src/pardes.zig#ascii-diff", .source = ascii_diff }, + .{ .path = "src/pardes.zig#ascii-compositor", .source = ascii_compositor }, .{ .path = "src/gui/gui.zig#panel-host", .source = gui_panel_host }, .{ .path = "src/gui/gui.zig#native-panel", .source = gui_native_panel }, .{ .path = "src/gui/gui.zig#frame-submission", .source = gui_frame_submission }, @@ -138,6 +153,8 @@ const gui_scene = [_]Segment{ }; const mac_panel = [_]Segment{ .{ .path = "src/panel_animation.zig", .source = panel_math }, + .{ .path = "src/pardes.zig#ascii-diff", .source = ascii_diff }, + .{ .path = "src/pardes.zig#ascii-compositor", .source = ascii_compositor }, .{ .path = "src/macos/Sources/PardesView.swift", .source = mac_view }, .{ .path = "src/macos/Sources/ScenePostprocessor.swift", .source = mac_postprocessor }, .{ .path = "shaders/crt.ci.metal", .source = mac_scene }, @@ -205,6 +222,8 @@ test "GUI scene EffectCode includes both runtime shader stages" { test "GUI panel EffectCode includes the actual host paint and submission path" { if (comptime backend == .gui) { + try std.testing.expect(std.mem.indexOf(u8, ascii_diff, "pub const PanelCellDiff") != null); + try std.testing.expect(std.mem.indexOf(u8, ascii_compositor, "fn composeAsciiTransitions") != null); try std.testing.expect(std.mem.indexOf(u8, gui_panel_host, "fn makePaintPlan") != null); try std.testing.expect(std.mem.indexOf(u8, gui_panel_host, "fn setTransitionFields") != null); try std.testing.expect(std.mem.indexOf(u8, gui_native_panel, "fn prepareNativeImages") != null); @@ -213,7 +232,8 @@ test "GUI panel EffectCode includes the actual host paint and submission path" { try std.testing.expect(std.mem.indexOf(u8, gui_frame_submission, "for (paint_plan.batches") != null); try std.testing.expect(std.mem.indexOf(u8, gui_cell_instance, "fn emitInstance") != null); try std.testing.expect(std.mem.indexOf(u8, gui_cell_instance, "setTransitionFields") != null); - try std.testing.expect(std.mem.indexOf(u8, image_fragment, "vec3 contrastInk") != null); + try std.testing.expect(std.mem.indexOf(u8, panel_math, "frame_count") != null); + try std.testing.expect(std.mem.indexOf(u8, gui_frame_submission, "active.effect == .dissolve") != null); } } @@ -233,7 +253,7 @@ test "mac EffectCode includes the runtime panel and scene wiring" { try std.testing.expect(std.mem.indexOf(u8, mac_scene, "extern \"C\" float4 pardesPanelClear") != null); try std.testing.expect(std.mem.indexOf(u8, mac_scene, "extern \"C\" float4 pardesPanel") != null); try std.testing.expect(std.mem.indexOf(u8, mac_scene, "extern \"C\" float4 pardesScene") != null); - try std.testing.expect(std.mem.indexOf(u8, mac_scene, "float3 contrastInk") != null); + try std.testing.expect(std.mem.indexOf(u8, mac_scene, "already composed in Pardes core") != null); try std.testing.expect(std.mem.indexOf(u8, mac_view, "hideCursor: !tracks.isEmpty") != null); try std.testing.expect(std.mem.indexOf(u8, mac_postprocessor, "panelClearKernel.apply") != null); try std.testing.expect(std.mem.indexOf(u8, mac_postprocessor, "panelKernel.apply") != null); diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 2c556895..d0757d7f 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -239,7 +239,7 @@ test "a persistent scene presents a new panel's frame zero before advancing it" for (second.panelTracks()) |track| try std.testing.expectEqual(@as(u16, 1), track.frame); } -const max_overlay_vertices: usize = 6 + touch_click_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices); +const max_overlay_vertices: usize = 18 + touch_click_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices); // Ctrl+ / Ctrl-: how far one press moves g.px, and the two sizes it stops at. // ONE size for the whole window, not one per pane: the core lays every pane @@ -2014,6 +2014,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { gpa, surface, core.theme().bg, + config.gui_topbar_pane_border_rgb orelse core.chromeTheme().scroll_track, + core.chromeTheme().tag_bg, core.settings.scene_effects, core.settings.debug, ) catch |err| blk: { @@ -3710,7 +3712,7 @@ fn prepareNativeImages( if (saved.serial != active.serial) continue; appendPreparedImage(g, gpa, saved, active, null, false); } - } else if (active.effect == .dissolve or active.effect == .ascii) { + } else if (active.effect == .dissolve) { // Old layer first. Its shader half disappears at the same // per-cell threshold at which the current half appears. for (g.presented_images.items) |saved| { @@ -3729,8 +3731,8 @@ fn prepareNativeImages( const current_track = if (track) |active| switch (active.effect) { // An identical cached placement is semantically unchanged // and must bypass a data effect exactly like an unchanged - // cell in Surface.changed_cells. - .dissolve, .ascii => if (snapshotContainsPlacement(g, current)) null else active, + // cell in Surface.cell_diffs. + .dissolve => if (snapshotContainsPlacement(g, current)) null else active, else => active, } else null; appendPreparedImage(g, gpa, saved, current_track, null, false); @@ -3860,6 +3862,8 @@ fn renderFrame( gpa: std.mem.Allocator, surface: *pardes.Surface, theme_bg: ?[3]u8, + topbar_pane_border_rgb: [3]u8, + tagline_rgb: [3]u8, scene_effects: pardes.panel_animation.SceneEffect, debug_on: bool, ) !bool { @@ -3920,14 +3924,22 @@ fn renderFrame( const layout = fixedCellLayout(g); var paint_plan = makePaintPlan(surface.panelTracks(), surface.hasPanelDiff()); - // Cursors/debug overlays do not carry pane ownership. Hide them for the + // Cursors/debug overlays do not carry pane ownership. Hide those for the // short interval in which panel geometry differs from logical geometry; // otherwise a cursor could remain pinned at the final cell, or paint over - // a later opening pane, while its own pane moves underneath it. - var overlay_count = if (paint_plan.len == 1) - buildOverlay(g, surface, layout, sw, sh, debug_on) - else - 0; + // a later opening pane, while its own pane moves underneath it. The + // topbar/pane rule is anchored window chrome and remains present. + var overlay_count = buildOverlay( + g, + surface, + layout, + sw, + sh, + topbar_pane_border_rgb, + tagline_rgb, + paint_plan.len == 1, + debug_on, + ); if (overlay_count != 0 and !uploadOverlayGpu(g, cmd, overlay_count)) overlay_count = 0; if (!prepareNativeImages(g, gpa, cmd, surface, &paint_plan, sw, sh)) { @@ -3962,7 +3974,7 @@ fn renderFrame( const batch_index = paintBatchAt(&paint_plan, col, row); const track = paint_plan.batches[batch_index].track; const data_effect = if (track) |active| - active.effect == .dissolve or active.effect == .ascii + active.effect == .dissolve else false; const data_diff = data_effect and surface.panelCellChanged(col, row); @@ -4022,7 +4034,7 @@ fn renderFrame( const track = paint_plan.batches[batch_index].track; const logical_idx: u32 = @as(u32, row) * surface.cols + col; const data_effect = if (track) |active| - active.effect == .dissolve or active.effect == .ascii + active.effect == .dissolve else false; const data_diff = data_effect and surface.panelCellChanged(col, row); @@ -4219,6 +4231,55 @@ const ResolvedCell = struct { role: pardes.FontRole, }; +fn topbarPaneBorderPixels(cell_h: u32, tagline_h: u32) u32 { + const spare = cell_h -| tagline_h; + return @min(@as(u32, config.gui_topbar_pane_border_px), spare * 2); +} + +/// Vertical offset of a tagline band inside its body-sized logical row. Row +/// zero and the first pane-tag row face a shared rule instead of centering two +/// independent bands. A Tagbottom band on the final grid row faces the window +/// edge, eliminating the matching unused half-band at the bottom. +fn taglineBandOffset(row: u16, canvas_h: f32, cell_h: u32, tagline_h: u32) u32 { + const spare = cell_h -| tagline_h; + const border = topbarPaneBorderPixels(cell_h, tagline_h); + if (row == 0) return spare - border / 2; + if (row == pardes.TOPBAR_H) return border - border / 2; + const rows: u32 = @max(1, @as(u32, @intFromFloat(@floor(canvas_h / @as(f32, @floatFromInt(cell_h)))))); + if (@as(u32, row) + 1 >= rows) return spare; + return spare / 2; +} + +fn topbarPaneBorderHeight(surface: *const pardes.Surface, cell_h: u32, tagline_h: u32) ?u32 { + if (surface.rows <= pardes.TOPBAR_H) return null; + const height = topbarPaneBorderPixels(cell_h, tagline_h); + if (height == 0) return null; + const base = @as(usize, pardes.TOPBAR_H) * surface.cols; + for (surface.cells[base..][0..surface.cols]) |cell| + if (!cell.default and cell.style.font_role == .tagline) return height; + return null; +} + +fn bottomTaglinePresent(surface: *const pardes.Surface) bool { + if (surface.rows == 0) return false; + const base = @as(usize, surface.rows - 1) * surface.cols; + for (surface.cells[base..][0..surface.cols]) |cell| + if (!cell.default and cell.style.font_role == .tagline) return true; + return false; +} + +test "tagline bands face the topbar rule and Tagbottom faces the window edge" { + const cell_h: u32 = 20; + const tagline_h: u32 = 16; + const canvas_h: f32 = 200; + const border = topbarPaneBorderPixels(cell_h, tagline_h); + try std.testing.expectEqual(@as(u32, 1), border); + try std.testing.expectEqual(@as(u32, 4), taglineBandOffset(0, canvas_h, cell_h, tagline_h)); + try std.testing.expectEqual(@as(u32, 1), taglineBandOffset(1, canvas_h, cell_h, tagline_h)); + try std.testing.expectEqual(@as(u32, 2), taglineBandOffset(5, canvas_h, cell_h, tagline_h)); + try std.testing.expectEqual(@as(u32, 4), taglineBandOffset(9, canvas_h, cell_h, tagline_h)); +} + fn resolveCell(g: *Gui, cell: *const pardes.Cell, is_cursor: bool, page: [3]u8) ResolvedCell { var fg = fg_default; var bg = page; @@ -4283,8 +4344,11 @@ fn emitInstance( @floatFromInt(g.tagline_height) else layout.h; - const py0 = layout.y_off + @as(f32, @floatFromInt(row)) * layout.h + - @max(0.0, (layout.h - visual_h) * 0.5); + const band_offset: f32 = if (resolved.role == .tagline) + @floatFromInt(taglineBandOffset(row, win_h, g.cell_h, g.tagline_height)) + else + 0; + const py0 = layout.y_off + @as(f32, @floatFromInt(row)) * layout.h + band_offset; const x0 = (px0 / win_w) * 2.0 - 1.0; const x1 = ((px0 + layout.w) / win_w) * 2.0 - 1.0; const y0 = 1.0 - (py0 / win_h) * 2.0; @@ -4812,7 +4876,7 @@ fn addCursorBar( const x0 = layout.x_off + @as(f32, @floatFromInt(x)) * layout.w; const height = std.math.clamp(visual_height, 1.0, layout.h); const y0 = layout.y_off + @as(f32, @floatFromInt(y)) * layout.h + - (layout.h - height) * 0.5; + @as(f32, @floatFromInt(taglineBandOffset(y, builder.win_h, @intFromFloat(layout.h), @intFromFloat(height)))); builder.addRect( x0, y0, @@ -4827,23 +4891,58 @@ fn addCursorBar( ); } -fn buildOverlay(g: *Gui, surface: *const pardes.Surface, layout: CellLayout, sw: u32, sh: u32, debug_on: bool) u32 { +fn buildOverlay( + g: *Gui, + surface: *const pardes.Surface, + layout: CellLayout, + sw: u32, + sh: u32, + topbar_pane_border_rgb: [3]u8, + tagline_rgb: [3]u8, + transient_on: bool, + debug_on: bool, +) u32 { 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 }; - if (surface.cursor) |cursor| { - if (cursor.x < surface.cols and cursor.y < surface.rows) { - const cell = surface.cells[@as(usize, cursor.y) * surface.cols + cursor.x]; - const role = cell.style.font_role; - const visual_height: f32 = if (role == .tagline) - @floatFromInt(g.tagline_height) - else - layout.h; - addCursorBar(&builder, cursor.x, cursor.y, cursor.bar, visual_height, layout); + if (topbarPaneBorderHeight(surface, g.cell_h, g.tagline_height)) |height| { + const y0: f32 = @floatFromInt(g.cell_h - height / 2); + const rgb = topbar_pane_border_rgb; + builder.addRect(0, y0, win_w, y0 + @as(f32, @floatFromInt(height)), .{ + .r = @as(f32, @floatFromInt(rgb[0])) / 255.0, + .g = @as(f32, @floatFromInt(rgb[1])) / 255.0, + .b = @as(f32, @floatFromInt(rgb[2])) / 255.0, + .a = 1.0, + }); + } + // The core grid contains only complete cells. Extend a bottommost + // Tagbottom band through the swapchain remainder so an arbitrary window + // height cannot reintroduce a page-colored strip below the final row. + const grid_bottom = @as(f32, @floatFromInt(surface.rows)) * layout.h; + if (grid_bottom < win_h and bottomTaglinePresent(surface)) { + const rgb = tagline_rgb; + builder.addRect(0, grid_bottom, win_w, win_h, .{ + .r = @as(f32, @floatFromInt(rgb[0])) / 255.0, + .g = @as(f32, @floatFromInt(rgb[1])) / 255.0, + .b = @as(f32, @floatFromInt(rgb[2])) / 255.0, + .a = 1.0, + }); + } + if (transient_on) { + if (surface.cursor) |cursor| { + if (cursor.x < surface.cols and cursor.y < surface.rows) { + const cell = surface.cells[@as(usize, cursor.y) * surface.cols + cursor.x]; + const role = cell.style.font_role; + const visual_height: f32 = if (role == .tagline) + @floatFromInt(g.tagline_height) + else + layout.h; + addCursorBar(&builder, cursor.x, cursor.y, cursor.bar, visual_height, layout); + } } } - appendTouchOverlay(g, &builder, debug_on); + if (transient_on) appendTouchOverlay(g, &builder, debug_on); return @intCast(builder.len); } diff --git a/src/macos.zig b/src/macos.zig index 9f971e5f..3c1458a3 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -498,7 +498,7 @@ const State = struct { arena: std.heap.ArenaAllocator, runtime: Runtime, cells: []Cell = &.{}, - /// Frozen canonical grid paired with `changed_cells` while a content or + /// Frozen canonical grid paired with an encoded change mask while a content or /// lifecycle transition is active. Both are encoded at frame time so the /// native renderer never borrows core-owned Cell layout across the ABI. previous_cells: []Cell = &.{}, @@ -1397,13 +1397,13 @@ fn collectPanelDiff(st: *State, surface: *const pardes.Surface, count: usize) vo } for (surface.previous_cells, st.previous_cells[0..count]) |cell, *out| out.* = encodeCell(cell); - for (surface.changed_cells, st.changed_cells[0..count]) |changed, *out| - out.* = encodeChanged(changed); + for (surface.cell_diffs, st.changed_cells[0..count]) |diff, *out| + out.* = encodeChanged(diff); st.panel_diff_len = count; } -fn encodeChanged(changed: u8) u8 { - return if (changed == 0) 0 else 255; +fn encodeChanged(diff: pardes.PanelCellDiff) u8 { + return if (diff.changed()) 255 else 0; } /// Flatten Surface.images into the flat C array the host walks. @@ -2089,8 +2089,6 @@ test "pardes.h matches the Zig boundary" { inline for (@typeInfo(PanelTrack).@"struct".fields) |field| { const c_name = comptime if (std.mem.eql(u8, field.name, "_padding")) "reserved0" - else if (std.mem.eql(u8, field.name, "_frame_padding")) - "reserved1" else field.name; try expectEqual(@offsetOf(c.pardes_panel_track_s, c_name), @offsetOf(PanelTrack, field.name)); @@ -2217,9 +2215,9 @@ test "mac panel ABI paint order includes closing tombstones after live panes" { } test "mac panel mask is a literal normalized grayscale texture" { - try std.testing.expectEqual(@as(u8, 0), encodeChanged(0)); - try std.testing.expectEqual(@as(u8, 255), encodeChanged(1)); - try std.testing.expectEqual(@as(u8, 255), encodeChanged(255)); + try std.testing.expectEqual(@as(u8, 0), encodeChanged(.unchanged)); + try std.testing.expectEqual(@as(u8, 255), encodeChanged(.visual)); + try std.testing.expectEqual(@as(u8, 255), encodeChanged(.{ .ascii = .{ .from = 'a', .to = 'z' } })); } test "mac panel ABI has the documented compact byte layout" { @@ -2231,7 +2229,7 @@ test "mac panel ABI has the documented compact byte layout" { try std.testing.expectEqual(@as(usize, 6), @offsetOf(PanelTrack, "effect")); try std.testing.expectEqual(@as(usize, 7), @offsetOf(PanelTrack, "_padding")); try std.testing.expectEqual(@as(usize, 8), @offsetOf(PanelTrack, "frame")); - try std.testing.expectEqual(@as(usize, 10), @offsetOf(PanelTrack, "_frame_padding")); + try std.testing.expectEqual(@as(usize, 10), @offsetOf(PanelTrack, "frame_count")); try std.testing.expectEqual(@as(usize, 12), @offsetOf(PanelTrack, "from")); try std.testing.expectEqual(@as(usize, 28), @offsetOf(PanelTrack, "to")); } diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift index a22d4d55..aa8ac425 100644 --- a/src/macos/Sources/PardesView.swift +++ b/src/macos/Sources/PardesView.swift @@ -648,7 +648,6 @@ final class PardesView: NSView { drawFrame(sceneContext, count: count, hideCursor: !tracks.isEmpty) let needsPrevious = tracks.contains { $0.effect == UInt8(PARDES_PANEL_DISSOLVE) - || $0.effect == UInt8(PARDES_PANEL_ASCII) || $0.effect == UInt8(PARDES_PANEL_VERTICAL) } var oldReady = !needsPrevious diff --git a/src/macos/pardes.h b/src/macos/pardes.h index cfe83ffa..a506423d 100644 --- a/src/macos/pardes.h +++ b/src/macos/pardes.h @@ -108,7 +108,7 @@ typedef struct { uint8_t effect; uint8_t reserved0; uint16_t frame; - uint16_t reserved1; + uint16_t frame_count; pardes_panel_box_s from; pardes_panel_box_s to; } pardes_panel_track_s; diff --git a/src/panel_animation.zig b/src/panel_animation.zig index 045d2a87..0451213d 100644 --- a/src/panel_animation.zig +++ b/src/panel_animation.zig @@ -1,13 +1,17 @@ //! Backend-neutral vocabulary and math for pane transitions. //! -//! The core publishes plain transition data. GUI shells evaluate that data in -//! shaders; the TTY shell evaluates the same progress over cells in its grid. -//! There are deliberately no callbacks or backend objects here. +//! The core publishes plain transition data and composes semantic PanelAscii +//! bytes itself. GUI shells evaluate geometry/dissolve data in shaders; the +//! TTY shell evaluates the same records over cells in its grid. There are +//! deliberately no callbacks or backend objects here. const std = @import("std"); +pub const ascii_max_movement_frames: u16 = 12; + pub const Easing = enum(u8) { linear, smooth, + in_cubic, out_cubic, out_back, }; @@ -38,7 +42,10 @@ pub const Transition = enum(u8) { .slide => 12, .zoom => 14, .dissolve => 10, - .ascii => 16, + // Frame zero is the exact old byte. Core's AsciiDiff caps a long + // byte walk at twelve eased movement samples, including the exact + // destination; nearby bytes still move one value at a time. + .ascii => ascii_max_movement_frames + 1, .vertical => 12, }; } @@ -89,16 +96,27 @@ pub const Track = extern struct { effect: Transition = .off, _padding: u8 = 0, frame: u16 = 0, - _frame_padding: u16 = 0, + /// Core-computed duration for data-dependent effects. Zero selects the + /// effect preset; PanelAscii fills this from the longest eased byte walk + /// in the pane's semantic cell diff. + frame_count: u16 = 0, from: Box = .{}, to: Box = .{}, pub fn active(track: Track) bool { - return track.effect != .off and track.frame < track.effect.frames(); + return track.effect != .off and track.frame < track.frames(); + } + + pub fn frames(track: Track) u16 { + return if (track.frame_count != 0) track.frame_count else track.effect.frames(); } pub fn amount(track: Track) f32 { - return progress(track.effect, track.frame); + // Opening rises quickly and settles; closing reverses that motion and + // accelerates down out of the fixed clip. + if (track.phase == .closing and track.effect == .vertical) + return progressEased(.in_cubic, track.frames(), track.frame); + return progressEased(track.effect.easing(), track.frames(), track.frame); } pub fn presented(track: Track) Box { @@ -153,14 +171,15 @@ pub fn openingBox(effect: Transition, target: Box, screen_width: u16) Box { }; } -/// Destination for a lifecycle-only closing track. The old panel rises out -/// through its own fixed clip; surviving panes are already at canonical -/// geometry underneath it and never receive tracks for this effect. +/// Destination for a lifecycle-only closing track. The old panel drops out +/// through its own fixed clip, reversing the opening path; surviving panes +/// are already at canonical geometry underneath it and never receive tracks +/// for this effect. pub fn closingBox(effect: Transition, source: Box) Box { return switch (effect) { .vertical => blk: { var to = source; - to.y -= source.h; + to.y += source.h; break :blk to; }, else => source, @@ -172,6 +191,7 @@ pub fn sample(easing: Easing, raw: f32) f32 { return switch (easing) { .linear => t, .smooth => t * t * (3.0 - 2.0 * t), + .in_cubic => t * t * t, .out_cubic => 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t), // Robert Penner's ease-out-back polynomial. It intentionally travels // a little past one before settling exactly on the endpoint. @@ -185,12 +205,15 @@ pub fn sample(easing: Easing, raw: f32) f32 { } pub fn progress(effect: Transition, frame: u16) f32 { - const frames = effect.frames(); + return progressEased(effect.easing(), effect.frames(), frame); +} + +fn progressEased(easing: Easing, frames: u16, frame: u16) f32 { if (frames <= 1 or frame >= frames - 1) return 1.0; // `frames` is the number of presented samples, including both exact // endpoints. This makes the last active frame the real final image rather // than 15/16 followed by an unrendered snap to canonical content. - return sample(effect.easing(), @as(f32, @floatFromInt(frame)) / @as(f32, @floatFromInt(frames - 1))); + return sample(easing, @as(f32, @floatFromInt(frame)) / @as(f32, @floatFromInt(frames - 1))); } pub fn lerpBox(from: Box, to: Box, t: f32) Box { @@ -227,39 +250,13 @@ pub fn dissolveRevealed(serial: u32, col: u16, row: u16, raw_progress: f32) bool return cellNoise(serial, col, row) < t; } -pub const AsciiCell = struct { - /// When true the pane's real cell has arrived. - reveal: bool, - /// A transient one-byte glyph while `reveal` is false. Space means the - /// cell has not entered the animation yet. - glyph: u8, -}; - -/// ASCII-materialization preset: a diagonal wave perturbed by stable noise. -/// It is content animation rather than geometry; TTY replaces the cell glyph, -/// while GUI shaders use the same threshold to dissolve glyph coverage. -pub fn asciiCell(serial: u32, col: u16, row: u16, raw_progress: f32) AsciiCell { - const t = std.math.clamp(raw_progress, 0.0, 1.0); - // These endpoints are real presented frames: even a cell whose stable - // threshold is zero starts as old data, and every cell ends as new data. - if (t <= 0) return .{ .reveal = false, .glyph = ' ' }; - if (t >= 1) return .{ .reveal = true, .glyph = ' ' }; - const noise = cellNoise(serial, col, row); - const diagonal = @as(f32, @floatFromInt((@as(u32, col) + @as(u32, row) * 2) % 17)) / 17.0; - const threshold = noise * 0.72 + diagonal * 0.28; - if (t >= threshold) return .{ .reveal = true, .glyph = ' ' }; - if (t + 0.22 < threshold) return .{ .reveal = false, .glyph = ' ' }; - const glyphs = ".:+*#%@"; - const index: usize = @intFromFloat(@floor(noise * @as(f32, @floatFromInt(glyphs.len)))); - return .{ .reveal = false, .glyph = glyphs[@min(index, glyphs.len - 1)] }; -} - test "easing presets have exact endpoints and intended shapes" { inline for (std.enums.values(Easing)) |easing| { try std.testing.expectEqual(@as(f32, 0), sample(easing, 0)); try std.testing.expectEqual(@as(f32, 1), sample(easing, 1)); } try std.testing.expectEqual(@as(f32, 0.5), sample(.linear, 0.5)); + try std.testing.expect(sample(.in_cubic, 0.5) < sample(.linear, 0.5)); try std.testing.expect(sample(.out_cubic, 0.5) > sample(.linear, 0.5)); try std.testing.expect(sample(.out_back, 0.8) > 1.0); } @@ -287,7 +284,7 @@ test "opening presets separate geometry and content transitions" { try std.testing.expectEqual(@as(f32, 0), openingBox(.zoom, target, 80).w); try std.testing.expectEqual(@as(f32, 80), openingBox(.slide, target, 80).x); try std.testing.expectEqual(@as(f32, target.y + target.h), openingBox(.vertical, target, 80).y); - try std.testing.expectEqual(@as(f32, target.y - target.h), closingBox(.vertical, target).y); + try std.testing.expectEqual(@as(f32, target.y + target.h), closingBox(.vertical, target).y); var track: Track = .{ .effect = .slide, .from = target, .to = target }; try std.testing.expect(track.active()); @@ -297,31 +294,15 @@ test "opening presets separate geometry and content transitions" { track = .{ .effect = .dissolve, .frame = 3, .from = .{}, .to = target }; try std.testing.expectEqual(target, track.visualBox()); - const closing: Track = .{ + var closing: Track = .{ .phase = .closing, .effect = .vertical, .from = target, .to = closingBox(.vertical, target), }; try std.testing.expectEqual(target, closing.contentBox()); -} - -test "ASCII materialization is stable and finishes as real content" { - const a = asciiCell(42, 11, 7, 0.4); - const b = asciiCell(42, 11, 7, 0.4); - try std.testing.expectEqual(a, b); - try std.testing.expect(!asciiCell(42, 11, 7, 0).reveal); - try std.testing.expectEqual(@as(u8, ' '), asciiCell(42, 11, 7, 0).glyph); - try std.testing.expect(asciiCell(42, 11, 7, 1).reveal); - - var changed = false; - for (0..32) |col| { - if (!std.meta.eql(asciiCell(42, @intCast(col), 3, 0.35), asciiCell(43, @intCast(col), 3, 0.35))) { - changed = true; - break; - } - } - try std.testing.expect(changed); + closing.frame = 2; + try std.testing.expect(closing.amount() < progress(.vertical, closing.frame)); } test "dissolve has exact stable endpoints" { diff --git a/src/pardes.zig b/src/pardes.zig index c5d06a43..38e317c4 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -482,6 +482,34 @@ test "render uses target content colors while chrome is in flight" { try std.testing.expect(p.animationActive()); } +test "topbar hover highlights exactly the executable word" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + var frame = std.heap.ArenaAllocator.init(std.testing.allocator); + defer frame.deinit(); + + _ = try p.render(frame.allocator()); + p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = 1, .row = 0 } }); + _ = frame.reset(.retain_capacity); + const hovered = try p.render(frame.allocator()); + for (0.."New".len) |col| { + const cell = hovered.at(@intCast(col), 0); + try std.testing.expectEqual(Color{ .rgb = p.theme().sel_bg }, cell.style.bg); + try std.testing.expectEqual(Color{ .rgb = p.theme().sel_fg }, cell.style.fg); + } + try std.testing.expectEqual(Color{ .rgb = p.chromeTheme().tag_bg }, hovered.at(3, 0).style.bg); + + p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = 3, .row = 0 } }); + _ = frame.reset(.retain_capacity); + const whitespace = try p.render(frame.allocator()); + try std.testing.expectEqual(Color{ .rgb = p.chromeTheme().tag_bg }, whitespace.at(1, 0).style.bg); + + p.update(.pointer_leave); + _ = frame.reset(.retain_capacity); + const left = try p.render(frame.allocator()); + try std.testing.expectEqual(Color{ .rgb = p.chromeTheme().tag_bg }, left.at(1, 0).style.bg); +} + test "pane-tag Exec prefers New and argument builtins before shell fallback" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 30 }); @@ -1521,14 +1549,24 @@ comptime { } } -/// the whitespace-delimited word covering `col` in `str` (topbar dispatch) -fn wordAtCol(str: []const u8, col: u16) []const u8 { - if (col >= str.len or str[col] == ' ') return ""; +const WordBounds = struct { lo: usize, hi: usize }; + +/// The whitespace-delimited word covering `col` in `str`. The topbar uses the +/// same bounds for pointer feedback and dispatch, so the word that lights up +/// is necessarily the word a middle click will execute. +fn wordBoundsAtCol(str: []const u8, col: u16) ?WordBounds { + if (col >= str.len or str[col] == ' ') return null; var lo: usize = col; while (lo > 0 and str[lo - 1] != ' ') lo -= 1; var hi: usize = col; while (hi < str.len and str[hi] != ' ') hi += 1; - return str[lo..hi]; + return .{ .lo = lo, .hi = hi }; +} + +/// the whitespace-delimited word covering `col` in `str` (topbar dispatch) +fn wordAtCol(str: []const u8, col: u16) []const u8 { + const bounds = wordBoundsAtCol(str, col) orelse return ""; + return str[bounds.lo..bounds.hi]; } /// `a` IS `b` or lives under it — how Grep drops a pane's directory when @@ -1716,6 +1754,7 @@ pub const CellStyle = struct { /// One surface cell. `default = true` means "never painted this frame": the /// shell renders it as the terminal's default cell (vaxis clear semantics). +// EFFECT_CODE_ASCII_DIFF_BEGIN pub const Cell = struct { text: [7]u8 = @splat(' '), len: u8 = 1, @@ -1735,8 +1774,98 @@ pub const Cell = struct { std.mem.eql(u8, a.grapheme(), b.grapheme()) and std.meta.eql(a.style, b.style); } + + /// The byte an ASCII transition may walk. Default cells are visibly + /// spaces; painted cells opt in only when their complete grapheme is one + /// printable byte. This keeps an intermediate frame valid UTF-8 and + /// prevents a style-only or multi-byte change from churning its glyph. + pub fn printableAscii(c: *const Cell) ?u8 { + if (c.default) return ' '; + if (c.len != 1) return null; + const byte = c.text[0]; + return if (byte >= ' ' and byte <= '~') byte else null; + } +}; + +/// The semantic character part of one old/new panel-cell diff. It lives in +/// the core because every renderer must present the same byte at a given +/// frame. Backends receive the already-composed Cell; none implements this +/// walk or chooses its own punctuation/noise threshold. +pub const AsciiDiff = struct { + from: u8, + to: u8, + + /// Long printable-byte walks complete in about the same time as the other + /// panel effects. Extra distance is crossed by eased character skips. + pub const max_movement_frames = panel_animation.ascii_max_movement_frames; + + pub fn between(old: *const Cell, new: *const Cell) ?AsciiDiff { + const from = old.printableAscii() orelse return null; + const to = new.printableAscii() orelse return null; + if (from == to) return null; + return .{ .from = from, .to = to }; + } + + pub fn distance(diff: AsciiDiff) u8 { + return if (diff.from < diff.to) diff.to - diff.from else diff.from - diff.to; + } + + pub fn movementFrames(diff: AsciiDiff) u16 { + return @min(@as(u16, diff.distance()), max_movement_frames); + } + + pub fn frameCount(diff: AsciiDiff) u16 { + return diff.movementFrames() + 1; + } + + /// Move through the u8 range with integer ease-out. Every frame advances + /// at least one byte; distances beyond max_movement_frames are distributed + /// as larger skips near the start and taper to an exact final step. Keeping + /// this integer-only makes every backend receive the same character. + pub fn byteAt(diff: AsciiDiff, frame: u16) u8 { + const byte_distance: u32 = diff.distance(); + const movements: u32 = diff.movementFrames(); + const at: u32 = @min(@as(u32, frame), movements); + const extra = byte_distance - movements; + const remaining = movements - at; + const denominator = movements * movements; + const eased_numerator = denominator - remaining * remaining; + // Ceiling division consumes all skipped distance before the last + // movement, so a long walk always settles onto its target by one. + const skipped = if (eased_numerator == 0) + 0 + else + (extra * eased_numerator + denominator - 1) / denominator; + const delta: u8 = @intCast(at + skipped); + return if (diff.from < diff.to) diff.from + delta else diff.from - delta; + } + + pub fn complete(diff: AsciiDiff, frame: u16) bool { + return diff.byteAt(frame) == diff.to; + } }; +/// One core-owned classification per cell in the frozen old/new grid. A +/// visual-only diff still matters to dissolve, but PanelAscii only walks the +/// `.ascii` case. That distinction fixes the old effect's habit of replacing +/// unchanged glyphs merely because their colour or other style changed. +pub const PanelCellDiff = union(enum) { + unchanged, + visual, + ascii: AsciiDiff, + + pub fn between(old: *const Cell, new: *const Cell) PanelCellDiff { + if (old.visuallyEqual(new)) return .unchanged; + if (AsciiDiff.between(old, new)) |diff| return .{ .ascii = diff }; + return .visual; + } + + pub fn changed(diff: PanelCellDiff) bool { + return diff != .unchanged; + } +}; +// EFFECT_CODE_ASCII_DIFF_END + test "cell visual equality ignores dead grapheme tail bytes" { var a: Cell = .{ .default = false }; var b = a; @@ -1752,6 +1881,54 @@ test "cell visual equality ignores dead grapheme tail bytes" { try std.testing.expect(a.visuallyEqual(&b)); } +test "ASCII cell diffs ease long byte walks in both directions" { + var low: Cell = .{ .default = false }; + low.text[0] = 'A'; + var high: Cell = .{ .default = false }; + high.text[0] = 'F'; + + const rising = AsciiDiff.between(&low, &high).?; + try std.testing.expectEqual(@as(u8, 'A'), rising.byteAt(0)); + try std.testing.expectEqual(@as(u8, 'B'), rising.byteAt(1)); + try std.testing.expectEqual(@as(u8, 'F'), rising.byteAt(5)); + try std.testing.expectEqual(@as(u8, 'F'), rising.byteAt(500)); + + const falling = AsciiDiff.between(&high, &low).?; + try std.testing.expectEqual(@as(u8, 'E'), falling.byteAt(1)); + try std.testing.expectEqual(@as(u8, 'A'), falling.byteAt(5)); + + low.text[0] = ' '; + high.text[0] = '~'; + const long_rising = AsciiDiff.between(&low, &high).?; + try std.testing.expectEqual(@as(u16, 13), long_rising.frameCount()); + try std.testing.expectEqual(@as(u8, ' '), long_rising.byteAt(0)); + try std.testing.expectEqual(@as(u8, '/'), long_rising.byteAt(1)); + try std.testing.expectEqual(@as(u8, '}'), long_rising.byteAt(11)); + try std.testing.expectEqual(@as(u8, '~'), long_rising.byteAt(12)); + + const long_falling = AsciiDiff.between(&high, &low).?; + try std.testing.expectEqual(@as(u8, 'o'), long_falling.byteAt(1)); + try std.testing.expectEqual(@as(u8, '!'), long_falling.byteAt(11)); + try std.testing.expectEqual(@as(u8, ' '), long_falling.byteAt(12)); +} + +test "ASCII diff classification skips stable and non-ASCII glyphs" { + var old: Cell = .{ .default = false }; + old.text[0] = 'x'; + var style_only = old; + style_only.style.bold = true; + try std.testing.expectEqual(PanelCellDiff.visual, PanelCellDiff.between(&old, &style_only)); + + var changed = old; + changed.text[0] = 'z'; + try std.testing.expectEqual(AsciiDiff{ .from = 'x', .to = 'z' }, PanelCellDiff.between(&old, &changed).ascii); + + var unicode = old; + unicode.text[0..2].* = .{ 0xc3, 0xa9 }; + unicode.len = 2; + try std.testing.expectEqual(PanelCellDiff.visual, PanelCellDiff.between(&old, &unicode)); +} + /// One generation of a pixel attachment. `serial` identifies the pane for its /// whole lifetime; `revision` identifies pixels rendered later by that same /// pane (for example, a different PDF page or zoom level). Backends must use @@ -1882,10 +2059,10 @@ pub const Surface = struct { panel_tracks: [MAX_PANES * 2]panel_animation.Track = undefined, npanel_tracks: usize = 0, /// Frozen canonical cells from before the current content/lifecycle - /// transition, plus its semantic old/new mask. Both are core-owned and - /// remain stable until every associated track has finished. + /// transition, plus the core's semantic old/new classifications. Both are + /// core-owned and remain stable until every associated track has finished. previous_cells: []const Cell = &.{}, - changed_cells: []const u8 = &.{}, + cell_diffs: []const PanelCellDiff = &.{}, pub fn panelTracks(s: *const Surface) []const panel_animation.Track { return s.panel_tracks[0..s.npanel_tracks]; @@ -1893,12 +2070,17 @@ pub const Surface = struct { pub fn hasPanelDiff(s: *const Surface) bool { return s.previous_cells.len == s.cells.len and - s.changed_cells.len == s.cells.len; + s.cell_diffs.len == s.cells.len; } pub fn panelCellChanged(s: *const Surface, x: u16, y: u16) bool { if (!s.hasPanelDiff() or x >= s.cols or y >= s.rows) return false; - return s.changed_cells[@as(usize, y) * s.cols + x] != 0; + return s.cell_diffs[@as(usize, y) * s.cols + x].changed(); + } + + pub fn panelCellDiff(s: *const Surface, x: u16, y: u16) PanelCellDiff { + if (!s.hasPanelDiff() or x >= s.cols or y >= s.rows) return .unchanged; + return s.cell_diffs[@as(usize, y) * s.cols + x]; } pub fn at(s: *Surface, x: u16, y: u16) *Cell { @@ -3547,16 +3729,17 @@ test "previous-grid animation refreshes its mask and snaps overlapping layout ch p.sync(); _ = frame.reset(.retain_capacity); const opening = try p.render(frame.allocator()); - const changed_index = std.mem.indexOfScalar(u8, opening.changed_cells, 1) orelse - return error.MissingPanelDiff; + const changed_index = for (opening.cell_diffs, 0..) |diff, index| { + if (diff.changed()) break index; + } else return error.MissingPanelDiff; p.acknowledgePanelPresentation(opening.panelTracks()); - // Corrupt one cached bit to prove a later live frame derives it again + // Corrupt one cached classification to prove a later live frame derives it again // from the frozen old cells and freshly rendered new cells. - p.panel_changed_cells[changed_index] = 0; + p.panel_cell_diffs[changed_index] = .unchanged; _ = frame.reset(.retain_capacity); const refreshed = try p.render(frame.allocator()); - try std.testing.expectEqual(@as(u8, 1), refreshed.changed_cells[changed_index]); + try std.testing.expect(refreshed.cell_diffs[changed_index].changed()); p.acknowledgePanelPresentation(refreshed.panelTracks()); // A second opener before the first canonical endpoint has no truthful @@ -3788,16 +3971,64 @@ test "an unpresented opening pane remains in the opening paint phase" { try std.testing.expect(retargeted.from.eql(first.from)); } +test "core composes ASCII bytes while visual-only cells pass through" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 8, .rows = 4, .tty_only = true }); + defer p.deinit(); + var current: [4]Cell = @splat(.{ .default = false }); + var previous = current; + for (¤t, "Dwxq") |*cell, byte| cell.text[0] = byte; + for (&previous, "Azxq") |*cell, byte| cell.text[0] = byte; + current[2].style.bold = true; // visual-only: same glyph, new style + current[3].text[0..2].* = .{ 0xc3, 0xa9 }; + current[3].len = 2; // non-ASCII: canonical immediately + const diffs = [_]PanelCellDiff{ + .{ .ascii = .{ .from = 'A', .to = 'D' } }, + .{ .ascii = .{ .from = 'z', .to = 'w' } }, + .visual, + .visual, + }; + var surface: Surface = .{ + .cols = 4, + .rows = 1, + .cells = ¤t, + .previous_cells = &previous, + .cell_diffs = &diffs, + }; + surface.panel_tracks[0] = .{ + .effect = .ascii, + .frame = 2, + .frame_count = 4, + .from = .{ .w = 4, .h = 1 }, + .to = .{ .w = 4, .h = 1 }, + }; + surface.npanel_tracks = 1; + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + const presented = try p.composeAsciiTransitions(arena.allocator(), &surface); + try std.testing.expect(presented != &surface); + try std.testing.expectEqualStrings("C", presented.at(0, 0).grapheme()); + try std.testing.expectEqualStrings("x", presented.at(1, 0).grapheme()); + try std.testing.expectEqual(current[2], presented.at(2, 0).*); + try std.testing.expectEqual(current[3], presented.at(3, 0).*); + try std.testing.expectEqualStrings("D", surface.at(0, 0).grapheme()); +} + test "pointer rejects panel content cells which have not materialized" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 12, .tty_only = true }); defer p.deinit(); const pane = p.panes[0].?; const target: panel_animation.Box = .{ .x = 4, .y = 2, .w = 32, .h = 4 }; - p.panel_changed_cells = try p.gpa.alloc(u8, @as(usize, p.screen_w) * p.screen_h); - @memset(p.panel_changed_cells, 0); + p.panel_cell_diffs = try p.gpa.alloc(PanelCellDiff, @as(usize, p.screen_w) * p.screen_h); + @memset(p.panel_cell_diffs, .unchanged); for (2..6) |row| { for (4..36) |col| { - p.panel_changed_cells[row * p.screen_w + col] = 1; + // Distances on both sides of frame five prove that finished ASCII + // bytes are clickable while bytes still walking are not. + p.panel_cell_diffs[row * p.screen_w + col] = .{ .ascii = .{ + .from = ' ', + .to = @intCast(' ' + (col - 4) % 12 + 1), + } }; } } p.panel_diff_ready = true; @@ -3820,6 +4051,8 @@ test "pointer rejects panel content cells which have not materialized" { for (0..4) |relative_row| for (0..32) |relative_col| { const rcol: u16 = @intCast(relative_col); const rrow: u16 = @intCast(relative_row); + const col: u16 = @intCast(4 + relative_col); + const row: u16 = @intCast(2 + relative_row); const visible = switch (effect) { .dissolve => panel_animation.dissolveRevealed( pane.serial, @@ -3827,16 +4060,12 @@ test "pointer rejects panel content cells which have not materialized" { rrow, track.amount(), ), - .ascii => panel_animation.asciiCell( - pane.serial, - rcol, - rrow, - track.amount(), - ).reveal, + .ascii => switch (p.panelCellDiff(col, row)) { + .ascii => |diff| diff.complete(track.frame), + .unchanged, .visual => true, + }, else => unreachable, }; - const col: u16 = @intCast(4 + relative_col); - const row: u16 = @intCast(2 + relative_row); const mapped = p.presentedPointer(col, row); try std.testing.expectEqual(visible, mapped != null); if (mapped) |point| @@ -3853,8 +4082,8 @@ test "unchanged content cells remain clickable through data effects" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 40, .rows = 10, .tty_only = true }); defer p.deinit(); const pane = p.panes[0].?; - p.panel_changed_cells = try p.gpa.alloc(u8, @as(usize, p.screen_w) * p.screen_h); - @memset(p.panel_changed_cells, 0); + p.panel_cell_diffs = try p.gpa.alloc(PanelCellDiff, @as(usize, p.screen_w) * p.screen_h); + @memset(p.panel_cell_diffs, .unchanged); p.panel_diff_ready = true; const box: panel_animation.Box = .{ .x = 2, .y = 2, .w = 20, .h = 4 }; @@ -4191,7 +4420,7 @@ pub const Pardes = struct { /// partially animated pixels. A closing tombstone may sample the frozen /// grid only when that grid actually contains the exact pane box. presented_cells_layout: [MAX_PANES]?LayoutSnapshot = @splat(null), - panel_changed_cells: []u8 = &.{}, + panel_cell_diffs: []PanelCellDiff = &.{}, panel_diff_pending: bool = false, panel_diff_ready: bool = false, /// Canonical pane lifetimes/boxes captured by render, and the visual boxes @@ -4470,7 +4699,7 @@ pub const Pardes = struct { p.scratch.deinit(); gpa.free(p.surface.cells); if (p.presented_cells.len > 0) gpa.free(p.presented_cells); - if (p.panel_changed_cells.len > 0) gpa.free(p.panel_changed_cells); + if (p.panel_cell_diffs.len > 0) gpa.free(p.panel_cell_diffs); if (p.surface.images.len > 0) gpa.free(p.surface.images); gpa.destroy(p); } @@ -9875,12 +10104,10 @@ pub const Pardes = struct { relative_row, track.amount(), ), - .ascii => panel_animation.asciiCell( - track.serial, - relative_col, - relative_row, - track.amount(), - ).reveal, + .ascii => switch (p.panelCellDiff(col, row)) { + .ascii => |diff| diff.complete(track.frame), + .unchanged, .visual => true, + }, else => unreachable, }; return if (visible) .{ .col = col, .row = row } else null; @@ -10001,8 +10228,14 @@ pub const Pardes = struct { fn panelCellChanged(p: *const Pardes, col: u16, row: u16) bool { if (!p.panel_diff_ready or col >= p.screen_w or row >= p.screen_h or - p.panel_changed_cells.len != @as(usize, p.screen_w) * p.screen_h) return false; - return p.panel_changed_cells[@as(usize, row) * p.screen_w + col] != 0; + p.panel_cell_diffs.len != @as(usize, p.screen_w) * p.screen_h) return false; + return p.panel_cell_diffs[@as(usize, row) * p.screen_w + col].changed(); + } + + fn panelCellDiff(p: *const Pardes, col: u16, row: u16) PanelCellDiff { + if (!p.panel_diff_ready or col >= p.screen_w or row >= p.screen_h or + p.panel_cell_diffs.len != @as(usize, p.screen_w) * p.screen_h) return .unchanged; + return p.panel_cell_diffs[@as(usize, row) * p.screen_w + col]; } /// A backend is about to replace an unpresentable animated frame with the @@ -12254,7 +12487,7 @@ pub const Pardes = struct { s.nimages = 0; s.npanel_tracks = 0; s.previous_cells = &.{}; - s.changed_cells = &.{}; + s.cell_diffs = &.{}; const chrome = p.chromeTheme(); const th = p.theme(); // the message row paints in the editor's colours // gaps between panes read as chrome (scrollbar track), not raw default @@ -12378,6 +12611,22 @@ pub const Pardes = struct { .bg = .{ .rgb = chrome.tag_bg }, .font_role = .tagline, }); + // The topbar is executable chrome, just like a button row. Pane text + // already previews the exact operand a Look would use; row zero has + // no Pane and used to fall through that machinery without any pointer + // feedback at all. Paint the same word the click dispatcher resolves, + // immediately, while leaving whitespace inert. + if (p.pointer_inside and p.hover_row < TOPBAR_H) { + if (wordBoundsAtCol(p.topbar(&tb_buf), p.hover_col)) |bounds| { + var col: usize = bounds.lo; + while (col < bounds.hi and col < s.cols) : (col += 1) { + const cell = s.at(@intCast(col), 0); + cell.default = false; + cell.style.bg = .{ .rgb = th.sel_bg }; + cell.style.fg = .{ .rgb = th.sel_fg }; + } + } + } // the topbar's cursor, if it has the keyboard. AFTER the pane loop on // purpose: there is exactly one Surface cursor and the bar's must beat // the active pane's. Always a block — the bar has no insert mode. @@ -12543,7 +12792,7 @@ pub const Pardes = struct { } if (p.panel_diff_ready) { s.previous_cells = p.presented_cells; - s.changed_cells = p.panel_changed_cells; + s.cell_diffs = p.panel_cell_diffs; } // Moving panes first, then new panes, then inert closing tombstones on // top. Native GUI paint planners may regroup by phase, but every host @@ -12559,7 +12808,54 @@ pub const Pardes = struct { s.panel_tracks[s.npanel_tracks] = track; s.npanel_tracks += 1; } - return s; + return p.composeAsciiTransitions(arena, s); + } + + // EFFECT_CODE_ASCII_COMPOSITOR_BEGIN + /// Lazily copy the canonical grid only when an active PanelAscii track has + /// a character which has not reached its target yet. The returned Surface + /// is the sole backend boundary, so every shell rasterizes the exact same + /// intermediate bytes and style-only/non-ASCII changes pass through once. + fn composeAsciiTransitions(p: *Pardes, arena: std.mem.Allocator, canonical: *Surface) !*Surface { + _ = p; + if (!canonical.hasPanelDiff()) return canonical; + var presented: ?*Surface = null; + for (canonical.panelTracks()) |track| { + if (track.effect != .ascii or track.phase == .closing) continue; + var row: u16 = 0; + while (row < canonical.rows) : (row += 1) { + var col: u16 = 0; + while (col < canonical.cols) : (col += 1) { + if (!boxContainsCell(track.to, col, row)) continue; + const index = @as(usize, row) * canonical.cols + col; + const diff = switch (canonical.cell_diffs[index]) { + .ascii => |diff| diff, + .unchanged, .visual => continue, + }; + const byte = diff.byteAt(track.frame); + if (byte == diff.to) continue; + if (presented == null) { + const next = try arena.create(Surface); + next.* = canonical.*; + next.cells = try arena.dupe(Cell, canonical.cells); + presented = next; + } + const cell = &presented.?.cells[index]; + if (track.frame == 0) { + cell.* = canonical.previous_cells[index]; + } else { + // The semantic destination owns presentation style as + // soon as its byte begins walking. At the endpoint the + // untouched canonical cell wins bit-for-bit instead. + cell.* = canonical.cells[index]; + cell.text[0] = byte; + cell.len = 1; + cell.default = false; + } + } + } + } + return presented orelse canonical; } fn preparePanelDiff(p: *Pardes) !bool { @@ -12568,17 +12864,40 @@ pub const Pardes = struct { p.presented_cells_cols != p.surface.cols or p.presented_cells_rows != p.surface.rows or p.presented_cells.len != count) return false; - if (p.panel_changed_cells.len != count) { - const next = try p.gpa.alloc(u8, count); - if (p.panel_changed_cells.len > 0) p.gpa.free(p.panel_changed_cells); - p.panel_changed_cells = next; + if (p.panel_cell_diffs.len != count) { + const next = try p.gpa.alloc(PanelCellDiff, count); + if (p.panel_cell_diffs.len > 0) p.gpa.free(p.panel_cell_diffs); + p.panel_cell_diffs = next; + } + for (p.panel_cell_diffs, p.presented_cells, p.surface.cells) |*diff, *old, *new| + diff.* = PanelCellDiff.between(old, new); + // The fixed Track ABI already carried two padding bytes after frame. + // They now hold the core-computed ASCII duration: exactly one sample + // beyond the longest eased walk in this pane, so there is neither a + // forced endpoint jump nor a long invisible tail for nearby glyphs. + for (&p.panel_tracks) |*slot| { + const track = if (slot.*) |*track| track else continue; + if (track.effect != .ascii) continue; + var longest: u16 = 1; + var row: u16 = 0; + while (row < p.surface.rows) : (row += 1) { + var col: u16 = 0; + while (col < p.surface.cols) : (col += 1) { + if (!boxContainsCell(track.to, col, row)) continue; + const index = @as(usize, row) * p.surface.cols + col; + switch (p.panel_cell_diffs[index]) { + .ascii => |diff| longest = @max(longest, diff.frameCount()), + .unchanged, .visual => {}, + } + } + } + track.frame_count = @max(track.frame_count, longest); } - for (p.panel_changed_cells, p.presented_cells, p.surface.cells) |*changed, *old, *new| - changed.* = @intFromBool(!old.visuallyEqual(new)); p.panel_diff_pending = false; p.panel_diff_ready = true; return true; } + // EFFECT_CODE_ASCII_COMPOSITOR_END /// Paint a selection expressed in the coordinate space used by pointer /// gestures. Both the persistent mouse selections and the delayed Look diff --git a/src/tty/panel_compositor.zig b/src/tty/panel_compositor.zig index 647c36ff..1891c623 100644 --- a/src/tty/panel_compositor.zig +++ b/src/tty/panel_compositor.zig @@ -1,8 +1,9 @@ //! TTY implementation of panel transitions. //! -//! The core publishes final cells plus POD tracks. This backend copies that -//! grid and performs the transition in cell space; no terminal escape trick -//! or pixel assumption leaks into the core. +//! The core publishes presentation cells plus POD tracks. It has already +//! composed PanelAscii because byte stepping is semantic, while this backend +//! copies the grid for geometry and dissolve operations. No terminal escape +//! trick or pixel assumption leaks into the core. const std = @import("std"); const pardes = @import("../pardes.zig"); @@ -22,7 +23,7 @@ pub fn hidesAttachment(tracks: []const Track, serial: u32) bool { if (track.effect != .slide and track.effect != .zoom and track.effect != .vertical) continue; // The last active sample is already the exact canonical frame. Place // the image there too, rather than making it pop in one tick later. - if (track.frame + 1 < track.effect.frames()) return true; + if (track.frame + 1 < track.frames()) return true; } return false; } @@ -83,7 +84,9 @@ pub fn compose( .off => {}, .slide, .zoom => transform(out, source, track), .dissolve => dissolve(out, source, track), - .ascii => ascii(out, source, track), + // Pardes core has already composed the byte-wise ASCII diff + // into source.cells. TTY only owns cursor suppression here. + .ascii => {}, .vertical => vertical(out, source, track), } } @@ -146,33 +149,6 @@ fn dissolve(out: *pardes.Surface, source: *const pardes.Surface, track: Track) v } } -fn ascii(out: *pardes.Surface, source: *const pardes.Surface, track: Track) void { - const area = clipped(track.to, out.cols, out.rows) orelse return; - const amount = track.amount(); - var y = area.y0; - while (y < area.y1) : (y += 1) { - var x = area.x0; - while (x < area.x1) : (x += 1) { - if (!source.panelCellChanged(x, y)) continue; - const material = panel_animation.asciiCell(track.serial, x - area.x0, y - area.y0, amount); - if (material.reveal) continue; - const cell = out.at(x, y); - if (material.glyph == ' ') { - cell.* = previousCell(source, x, y).*; - continue; - } - // The wave itself wears the new cell's colors, then yields to its - // real glyph. Before the wave arrives the complete old cell—not a - // synthetic blank—remains visible above. - cell.* = source.cells[@as(usize, y) * source.cols + x]; - cell.text[0] = material.glyph; - cell.len = 1; - cell.default = false; - cell.style.dim = true; - } - } -} - fn previousCell(source: *const pardes.Surface, x: u16, y: u16) *const pardes.Cell { std.debug.assert(source.hasPanelDiff()); return &source.previous_cells[@as(usize, y) * source.cols + x]; @@ -258,10 +234,10 @@ fn clipped(box: Box, cols: u16, rows: u16) ?GridBox { return if (area.x0 < area.x1 and area.y0 < area.y1) area else null; } -test "ASCII materialization happens in copied TTY cells" { +test "TTY consumes core-composed ASCII cells without reinterpreting glyphs" { var cells: [8]pardes.Cell = @splat(.{}); var previous: [8]pardes.Cell = @splat(.{}); - const changed: [8]u8 = @splat(1); + const diffs: [8]pardes.PanelCellDiff = @splat(.visual); for (&cells) |*cell| { cell.text[0] = 'X'; cell.len = 1; @@ -277,7 +253,7 @@ test "ASCII materialization happens in copied TTY cells" { .rows = 2, .cells = &cells, .previous_cells = &previous, - .changed_cells = &changed, + .cell_diffs = &diffs, }; var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); @@ -291,9 +267,7 @@ test "ASCII materialization happens in copied TTY cells" { const animated = try compose(arena.allocator(), &surface, &.{track}, null); try std.testing.expect(animated != &surface); try std.testing.expectEqualStrings("X", surface.at(0, 0).grapheme()); - var differs = false; - for (animated.cells) |cell| differs = differs or !std.mem.eql(u8, cell.grapheme(), "X"); - try std.testing.expect(differs); + for (animated.cells) |cell| try std.testing.expectEqualStrings("X", cell.grapheme()); } test "dissolve changes only diff cells from old to new" { @@ -309,13 +283,13 @@ test "dissolve changes only diff cells from old to new" { cell.len = 1; cell.default = false; } - const changed = [_]u8{ 1, 0 }; + const diffs = [_]pardes.PanelCellDiff{ .{ .ascii = .{ .from = 'O', .to = 'N' } }, .unchanged }; var surface: pardes.Surface = .{ .cols = 2, .rows = 1, .cells = &cells, .previous_cells = &previous, - .changed_cells = &changed, + .cell_diffs = &diffs, }; var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); @@ -337,45 +311,45 @@ test "dissolve changes only diff cells from old to new" { try std.testing.expectEqual(cells[1], final.at(1, 0).*); } -test "ASCII diff travels old through punctuation to new" { - var cells = [_]pardes.Cell{.{}}; - var previous = [_]pardes.Cell{.{}}; - cells[0].text[0] = 'N'; - cells[0].default = false; - previous[0].text[0] = 'O'; - previous[0].default = false; - const changed = [_]u8{1}; +test "TTY content effects never touch cells outside the published diff" { + var cells: [4]pardes.Cell = @splat(.{}); + var previous: [4]pardes.Cell = @splat(.{}); + for (&cells, "NUQV") |*cell, glyph| { + cell.text[0] = glyph; + cell.len = 1; + cell.default = false; + } + // Deliberately disagree even where the mask says unchanged. This makes + // the test prove that the compositor obeys the core cell diff rather than + // merely appearing correct because old and new happened to match there. + for (&previous, "OXRY") |*cell, glyph| { + cell.text[0] = glyph; + cell.len = 1; + cell.default = false; + } + const diffs = [_]pardes.PanelCellDiff{ .visual, .unchanged, .visual, .unchanged }; var surface: pardes.Surface = .{ - .cols = 1, + .cols = 4, .rows = 1, .cells = &cells, .previous_cells = &previous, - .changed_cells = &changed, + .cell_diffs = &diffs, }; var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); - var track: Track = .{ - .serial = 9, - .effect = .ascii, - .from = .{ .w = 1, .h = 1 }, - .to = .{ .w = 1, .h = 1 }, - }; - const old_frame = try compose(arena.allocator(), &surface, &.{track}, null); - try std.testing.expectEqualStrings("O", old_frame.at(0, 0).grapheme()); - - _ = arena.reset(.retain_capacity); - track.frame = 7; - const wave = panel_animation.asciiCell(track.serial, 0, 0, track.amount()); - try std.testing.expect(!wave.reveal and wave.glyph != ' '); - const punctuation = try compose(arena.allocator(), &surface, &.{track}, null); - try std.testing.expectEqualSlices(u8, &.{wave.glyph}, punctuation.at(0, 0).grapheme()); - try std.testing.expect(punctuation.at(0, 0).style.dim); - - _ = arena.reset(.retain_capacity); - track.frame = track.effect.frames() - 1; - const new_frame = try compose(arena.allocator(), &surface, &.{track}, null); - try std.testing.expectEqual(cells[0], new_frame.at(0, 0).*); + for ([_]panel_animation.Transition{ .ascii, .dissolve }) |effect| { + const track: Track = .{ + .serial = 17, + .effect = effect, + .from = .{ .w = 4, .h = 1 }, + .to = .{ .w = 4, .h = 1 }, + }; + const animated = try compose(arena.allocator(), &surface, &.{track}, null); + try std.testing.expectEqual(cells[1], animated.at(1, 0).*); + try std.testing.expectEqual(cells[3], animated.at(3, 0).*); + _ = arena.reset(.retain_capacity); + } } test "content transition without a diff snaps to canonical surface" { @@ -405,14 +379,14 @@ test "content transition without a diff snaps to canonical surface" { test "exact transition endpoint preserves the canonical cursor" { var cells = [_]pardes.Cell{.{}}; var previous = cells; - const changed = [_]u8{1}; + const diffs = [_]pardes.PanelCellDiff{.visual}; var surface: pardes.Surface = .{ .cols = 1, .rows = 1, .cells = &cells, .cursor = .{ .x = 0, .y = 0 }, .previous_cells = &previous, - .changed_cells = &changed, + .cell_diffs = &diffs, }; const track: Track = .{ .effect = .ascii, @@ -441,13 +415,13 @@ test "vertical opening rises through a fixed old-grid clip" { cell.len = 1; cell.default = false; } - const changed: [12]u8 = @splat(1); + const diffs: [12]pardes.PanelCellDiff = @splat(.visual); var surface: pardes.Surface = .{ .cols = 3, .rows = 4, .cells = &cells, .previous_cells = &previous, - .changed_cells = &changed, + .cell_diffs = &diffs, }; var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); @@ -481,7 +455,7 @@ test "vertical opening rises through a fixed old-grid clip" { try std.testing.expectEqualStrings("c", final.at(0, 2).grapheme()); } -test "vertical closing slides frozen content upward over canonical cells" { +test "vertical closing drops frozen content over canonical cells" { var cells: [12]pardes.Cell = @splat(.{}); var previous: [12]pardes.Cell = @splat(.{}); for (&cells, "aaabbbcccddd") |*cell, glyph| { @@ -494,13 +468,13 @@ test "vertical closing slides frozen content upward over canonical cells" { cell.len = 1; cell.default = false; } - const changed: [12]u8 = @splat(1); + const diffs: [12]pardes.PanelCellDiff = @splat(.visual); var surface: pardes.Surface = .{ .cols = 3, .rows = 4, .cells = &cells, .previous_cells = &previous, - .changed_cells = &changed, + .cell_diffs = &diffs, }; var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); @@ -518,10 +492,10 @@ test "vertical closing slides frozen content upward over canonical cells" { try std.testing.expectEqualStrings("2", first.at(0, 2).grapheme()); _ = arena.reset(.retain_capacity); - track.frame = 2; + track.frame = 8; const leaving = try compose(arena.allocator(), &surface, &.{track}, null); - try std.testing.expectEqualStrings("2", leaving.at(0, 1).grapheme()); - try std.testing.expectEqualStrings("c", leaving.at(0, 2).grapheme()); + try std.testing.expectEqualStrings("b", leaving.at(0, 1).grapheme()); + try std.testing.expectEqualStrings("1", leaving.at(0, 2).grapheme()); try std.testing.expectEqualStrings("a", leaving.at(0, 0).grapheme()); try std.testing.expectEqualStrings("d", leaving.at(0, 3).grapheme()); @@ -539,13 +513,13 @@ test "closing content paints after opening content regardless of track order" { cells[0].default = false; previous[0].text[0] = 'O'; previous[0].default = false; - const changed = [_]u8{1}; + const diffs = [_]pardes.PanelCellDiff{.visual}; var surface: pardes.Surface = .{ .cols = 1, .rows = 1, .cells = &cells, .previous_cells = &previous, - .changed_cells = &changed, + .cell_diffs = &diffs, }; var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); diff --git a/src/tutor.txt b/src/tutor.txt index 7c4c2733..f9ee1932 100644 --- a/src/tutor.txt +++ b/src/tutor.txt @@ -1079,10 +1079,12 @@ abc Native panel transitions are the five SPC a choices listed in 3.13. They start off. Only one is selected; running it again turns it off. Slide and - zoom animate geometry. Dissolve and ASCII animate only the old/new cell - diff, leaving unchanged content alone. Vertical raises only the pane being - added or deleted inside its own box, leaving every survivor still. The - native GUI scene switches Crt, Ripple + zoom animate geometry. Dissolve switches changed cells at stable thresholds. + ASCII increments or decrements each changed printable byte toward its new + value, using fast ease-out skips for long distances and exact steps nearby; + unchanged glyphs and non-ASCII graphemes pass through. Vertical raises a pane being added + and drops a deleted pane back down inside its own box; every survivor stays + still. The native GUI scene switches Crt, Ripple and Glitch may be combined. `EffectCode PanelAscii` (or any other effect builtin as its argument) opens the exact backend sources that were embedded when this binary was built. |
