From 31fece62f56aa2311e2325de83659edbc9e641db Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 17 Aug 2026 16:12:56 -0300 Subject: animation: core publishes transition records; gui evaluates via shaders, tty over grid cells --- docs/config.md | 25 ++- docs/design.typ | 18 +- docs/macos.md | 9 +- shaders/crt.ci.metal | 78 ++----- shaders/image.frag.glsl | 60 ------ shaders/prebuilt/image.frag.glsl | 60 ------ shaders/prebuilt/image.frag.spv | Bin 10372 -> 3684 bytes shaders/prebuilt/ui.frag.glsl | 60 ------ shaders/prebuilt/ui.frag.spv | Bin 9712 -> 3908 bytes shaders/ui.frag.glsl | 60 ------ src/config.zig | 12 ++ src/effect_sources.zig | 24 ++- src/gui/gui.zig | 151 +++++++++++--- src/macos.zig | 20 +- src/macos/Sources/PardesView.swift | 1 - src/macos/pardes.h | 2 +- src/panel_animation.zig | 101 ++++----- src/pardes.zig | 411 ++++++++++++++++++++++++++++++++----- src/tty/panel_compositor.zig | 140 +++++-------- src/tutor.txt | 10 +- test/snapshots/theme.golden | 8 +- test/snapshots/windowops.golden | 2 +- 22 files changed, 695 insertions(+), 557 deletions(-) diff --git a/docs/config.md b/docs/config.md index 7e27216b..6485d46a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -100,6 +100,13 @@ neighbour instead of leaving the body grid stable. `Config` reports the active percentage. The browser applies the same compiled percentage to its DOM glyphs but has no runtime setter. +The SDL GUI joins the reduced-height global and pane tagline bands with +`gui_topbar_pane_border_px` physical pixels. Set it to zero for a direct join. +`gui_topbar_pane_border_rgb` can pin an RGB color; its default `null` follows +the active theme's scrollbar-track color. With `Tagbottom` enabled, a tagline +on the final grid row is bottom-aligned so the same unused half-band does not +show beneath it. + What happens to a codepoint the chosen face has no glyph for differs by shell. The SDL GUI falls back through a chain it builds itself: embedded Adwaita Mono, then installed `NotoSansMono-Regular`, `DejaVuSansMono`, @@ -132,12 +139,20 @@ PanelVertical All panel transitions start off. Slide uses cubic ease-out and zoom uses an overshooting ease-out-back. Dissolve and ASCII compare the last successfully -presented grid with the new one: cells which did not change are immediately -canonical, while changed cells cross from old to new through smoothstep or -stable themed punctuation. Vertical is a pane-lifecycle effect: a newly added +presented grid with the new one. Dissolve switches visually changed cells at +stable noise thresholds. ASCII walks every changed single-byte printable glyph +from its old `u8` value to its new one. Nearby values increment or decrement +once per frame; longer distances use ease-out character skips, moving quickly +at first and settling exactly on the destination. Glyph-stable style changes +and non-ASCII graphemes become canonical immediately. A walk is capped at +twelve movement frames, and each pane lasts only as long as its longest walk. +The core computes and composes that semantic diff once for every backend; +pixel attachments, which have no character value, pass through unchanged. +Vertical is a pane-lifecycle effect: a newly added pane rises from below inside its own fixed box, and a deleted pane's frozen -content rises out; surviving panes are never animated. The TTY implementation -performs those operations directly on a copy of the canonical cell grid. +content drops back down; surviving panes are never animated. The TTY +implementation performs its remaining geometry/dissolve operations directly +on a copy of the core-composed presentation grid. The SDL and native macOS GUI implementations pass plain panel tracks to their GPU shaders, including native image/PDF pixels; layout itself commits immediately and remains the one authoritative geometry. DOM web intentionally diff --git a/docs/design.typ b/docs/design.typ index 8dd14cdf..1c50cfde 100644 --- a/docs/design.typ +++ b/docs/design.typ @@ -178,24 +178,30 @@ weight. their easing, exact endpoint progress, stable per-cell noise, and a POD track. The core detects opening/moving rectangles when it commits layout and publishes only active tracks. It retains the last successfully presented canonical grid -and a semantic old/new cell mask; unused grapheme bytes do not manufacture a +and a typed old/new cell diff; unused grapheme bytes do not manufacture a change. A separate dense closing-track list is presentation-only state for a pane whose functional lifetime has already ended. Pointer input inverts the presented slide/zoom/vertical rectangle back to the canonical grid, lets unchanged dissolve/ASCII cells through immediately, and rejects closing pixels, so pixels and gestures cannot disagree during a transition. -TTY copies the canonical `Surface` grid into a compositor scratch grid, clears +Pardes core first composes each ASCII-qualified diff by incrementing or +decrementing its printable byte. Short walks move once per frame; longer walks +use integer ease-out skips and finish within twelve movements. Style-only and +non-ASCII changes pass through, and every backend receives the same +presentation cells. +TTY copies that `Surface` grid into a compositor scratch grid, clears slide/zoom destinations, then paints moving, opening, and closing panels in order. Cleared geometry uses the theme page color when it is explicit and the host terminal default only for transparent themes, so a light theme cannot flash a dark gap. Slide/zoom change the copied rectangle. Dissolve changes only diff -cells from their old value to their new value; ASCII takes the same cells -through punctuation; vertical raises only an opening or frozen closing pane +cells from their old value to their new value; vertical raises only an opening +or frozen closing pane inside its own clip. SDL supplies old/new glyph data, diff flags, final/presented boxes, and effect parameters to the glyph and native-image -shaders. macOS passes the same records across its plain C ABI and composites +shaders. Pixel attachments bypass ASCII because they have no character byte. +macOS passes the same records across its plain C ABI and composites old/new panel images in Metal/Core Image. Scene `Crt`, `Ripple`, and `Glitch` bits share one full-window pass in each native GUI. DOM web is a separate platform, not a shader GUI: retaining selectable HTML/CSS is more @@ -218,7 +224,7 @@ Pardes rects: [16]Rect // where each pane landed, this frame panel_tracks: [16]?Track // live panes, serial-guarded closing_panel_tracks: [16]Track // dense visual tombstones, no pane owner - presented_cells + changed_cells // acknowledged baseline + semantic diff + presented_cells + panel_cell_diffs // acknowledged baseline + semantic diff effects: [4096]Effect + head/len // a fixed ring Pane diff --git a/docs/macos.md b/docs/macos.md index b800513f..5e5f13e3 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -655,13 +655,16 @@ against the Zig extern struct on Linux. The exported list is already stable paint order—moving panes by slot, then opening panes by slot—so Swift only consumes it. -CoreText still renders one canonical complete frame. When tracks exist, the +CoreText renders the complete frame supplied by the core. PanelAscii bytes are +already interpolated there, identically to the TTY and SDL paths. When tracks +exist, the same retained bitmap used by scene effects is fed through two kernels in `shaders/crt.ci.metal`: `pardesPanelClear` removes every final target first, then `pardesPanel` samples each target into its eased presented box. Slide uses ease-out cubic, zoom uses ease-out-back, dissolve uses stable pane/cell noise, -and ASCII materialization replaces not-yet-revealed cells with procedural -punctuation. Because the input is the finished bitmap rather than a glyph-only +while ASCII only clips the already-composed core cells; pixel attachments have +no ASCII value and pass through unchanged. Because the input is the finished +bitmap rather than a glyph-only batch, backgrounds, glyphs, taglines, rules, the caret, PDF pages, and image panes move and dissolve together. Scene CRT/ripple/glitch runs once after the panel composition. diff --git a/shaders/crt.ci.metal b/shaders/crt.ci.metal index 32e26b21..e1db5770 100644 --- a/shaders/crt.ci.metal +++ b/shaders/crt.ci.metal @@ -106,7 +106,6 @@ float pardesPanelProgress(float effect, float frame) { float frames = effect == 1.0 ? 12.0 : effect == 2.0 ? 14.0 : effect == 3.0 ? 10.0 : - effect == 4.0 ? 16.0 : effect == 5.0 ? 12.0 : 1.0; if (frames <= 1.0 || frame >= frames - 1.0) return 1.0; float t = clamp(frame / (frames - 1.0), 0.0, 1.0); @@ -121,33 +120,7 @@ float pardesPanelProgress(float effect, float frame) { return 1.0 + c3 * u * u * u + c1 * u * u; } if (effect == 3.0) return t * t * (3.0 - 2.0 * t); // dissolve - return t; // ASCII materialization -} - -float pardesAsciiGlyph(float2 uv, int glyph) { - float2 p = uv * 2.0 - 1.0; - const float thin = 0.12; - float dotShape = 1.0 - smoothstep(0.10, 0.20, length(p - float2(0.0, 0.45))); - float barH = 1.0 - smoothstep(thin, thin + 0.08, abs(p.y)); - float barV = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x)); - if (glyph == 0) return dotShape; - if (glyph == 1) return max(dotShape, - 1.0 - smoothstep(0.10, 0.20, length(p + float2(0.0, 0.45)))); - if (glyph == 2) return max(barH, barV); - if (glyph == 3) { - float d0 = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x - p.y)); - float d1 = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x + p.y)); - return max(max(d0, d1), max(barH, barV)); - } - if (glyph == 4) { - float rails = max( - 1.0 - smoothstep(thin, thin + 0.08, abs(abs(p.x) - 0.34)), - 1.0 - smoothstep(thin, thin + 0.08, abs(abs(p.y) - 0.34))); - return rails; - } - float ring = 1.0 - smoothstep(thin, thin + 0.08, - abs(length(p * float2(0.8, 1.0)) - 0.48)); - return max(ring, glyph == 6 ? dotShape : 0.0); + return t; // ASCII cells are already composed; retained for ABI completeness } // Prepare every geometric target before any translated panel is drawn. A @@ -195,10 +168,18 @@ extern "C" float4 pardesPanel( float progress = pardesPanelProgress(effect, frame); float2 safeCell = max(cellSize, float2(1.0)); - // Content transitions stay at canonical geometry. Unchanged cells bypass - // the effect completely; changed cells retain the exact old raster until - // their deterministic wave reaches them. - if (effect == 3.0 || effect == 4.0) { + // ASCII bytes were already composed in Pardes core before CoreText built + // `current`. Pixel attachments have no character value and intentionally + // pass through unchanged, so this kernel only clips the pane batch. + if (effect == 4.0) { + if (!pardesInside(coord, target)) return underneath; + return current.sample(current.transform(coord)); + } + + // Dissolve stays at canonical geometry. Unchanged cells bypass the effect + // completely; changed cells retain the exact old raster until their + // deterministic threshold is crossed. + if (effect == 3.0) { if (!pardesInside(coord, target)) return underneath; // During a live resize the grid is still anchored at the view's top, // leaving a partial strip below its final logical row. Account for @@ -223,38 +204,7 @@ extern "C" float4 pardesPanel( float2 cellCount = max(round(target.zw / safeCell), float2(1.0)); cell = clamp(cell, float2(0.0), cellCount - 1.0); float noise = pardesCellNoise(serialParts, cell.x, cell.y); - if (effect == 3.0) - return noise < progress ? newPixel : oldPixel; - - float diagonal = fmod(cell.x + cell.y * 2.0, 17.0) / 17.0; - float threshold = noise * 0.72 + diagonal * 0.28; - if (progress >= threshold) return newPixel; - if (progress + 0.22 < threshold) return oldPixel; - - float2 cellUV = fract(float2( - (coord.x - target.x) / safeCell.x, - (target.y + target.w - coord.y) / safeCell.y)); - int glyph = min(int(floor(noise * 7.0)), 6); - float ink = pardesAsciiGlyph(cellUV, glyph); - float2 cellOrigin = float2( - target.x + cell.x * safeCell.x, - target.y + target.w - (cell.y + 1.0) * safeCell.y); - float4 bgSample = current.sample(current.transform( - cellOrigin + safeCell * float2(0.12, 0.12))); - float4 bg = coreimage::unpremultiply(bgSample); - float4 fg = coreimage::unpremultiply(newPixel); - // A procedural mark usually crosses pixels where the target glyph has - // no ink, so sampling `newPixel` alone can make it background-on- - // background and erase the ASCII phase. Preserve target ink where it - // is present; otherwise derive restrained contrast from the cell's - // own background instead of imposing a fixed palette. - float3 contrastInk = dot(bg.rgb, float3(0.2126, 0.7152, 0.0722)) > 0.5 - ? float3(0.0) : float3(1.0); - float sourceContrast = smoothstep(0.025, 0.14, length(fg.rgb - bg.rgb)); - float3 inkColor = mix(contrastInk, fg.rgb, sourceContrast); - float3 dimInk = mix(bg.rgb, inkColor, 0.62); - return coreimage::premultiply(float4( - mix(bg.rgb, dimInk, ink), max(bg.a, newPixel.a))); + return noise < progress ? newPixel : oldPixel; } float4 shown = mix(from, to, progress); diff --git a/shaders/image.frag.glsl b/shaders/image.frag.glsl index 747ceef7..2a3c2038 100644 --- a/shaders/image.frag.glsl +++ b/shaders/image.frag.glsl @@ -17,35 +17,6 @@ float cellNoise(uint serial, uint col, uint row) { return float(x & 0xffffu) / 65535.0; } -// The same seven procedural ASCII marks as ui.frag.glsl and the macOS Metal -// panel kernel. Native attachments are content in the panel, so they should -// not switch to a smaller two-symbol animation when the glyph grid over them -// is using `. : + * # % @`. -float asciiGlyph(vec2 uv, int glyph) { - vec2 p = uv * 2.0 - 1.0; - float thin = 0.12; - float dotShape = 1.0 - smoothstep(0.10, 0.20, length(p - vec2(0.0, 0.45))); - float barH = 1.0 - smoothstep(thin, thin + 0.08, abs(p.y)); - float barV = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x)); - if (glyph == 0) return dotShape; - if (glyph == 1) return max(dotShape, 1.0 - smoothstep(0.10, 0.20, length(p + vec2(0.0, 0.45)))); - if (glyph == 2) return max(barH, barV); - if (glyph == 3) { - float d0 = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x - p.y)); - float d1 = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x + p.y)); - return max(max(d0, d1), max(barH, barV)); - } - if (glyph == 4) { - float rails = max( - 1.0 - smoothstep(thin, thin + 0.08, abs(abs(p.x) - 0.34)), - 1.0 - smoothstep(thin, thin + 0.08, abs(abs(p.y) - 0.34)) - ); - return rails; - } - float ring = 1.0 - smoothstep(thin, thin + 0.08, abs(length(p * vec2(0.8, 1.0)) - 0.48)); - return max(ring, glyph == 6 ? dotShape : 0.0); -} - void main() { vec4 source = texture(u_image, v_uv); uint effect = v_effect.x & 0x7fffffffu; @@ -64,36 +35,5 @@ void main() { o_col = source; return; } - if (effect == 4u) { - float diagonal = float((cellCoord.x + cellCoord.y * 2u) % 17u) / 17.0; - float threshold = noise * 0.72 + diagonal * 0.28; - bool reveal = progress >= 1.0 || (progress > 0.0 && progress >= threshold); - bool wave = !reveal && progress > 0.0 && progress + 0.22 >= threshold; - if (oldLayer) { - if (wave || reveal) discard; - o_col = source; - return; - } - if (reveal) { - o_col = source; - return; - } - if (!wave) discard; - vec2 cellUV = fract(v_panel_uv * vec2(dimensions)); - int glyph = min(int(floor(noise * 7.0)), 6); - float mark = asciiGlyph(cellUV, glyph); - float luma = dot(source.rgb, vec3(0.2126, 0.7152, 0.0722)); - vec3 paper = source.rgb * 0.10; - // Preserve source colour when it already separates from the dimmed - // paper. Solid black pixels have no such separation, so derive a - // readable mark from their own luminance instead of letting the ASCII - // phase disappear on dark images/PDF regions. - vec3 contrastInk = luma > 0.5 ? vec3(0.0) : vec3(1.0); - float sourceContrast = smoothstep(0.025, 0.14, length(source.rgb - paper)); - vec3 ink = mix(contrastInk, source.rgb, sourceContrast); - vec3 dimInk = mix(paper, ink, 0.62); - o_col = vec4(mix(paper, dimInk, mark), source.a); - return; - } o_col = source; } diff --git a/shaders/prebuilt/image.frag.glsl b/shaders/prebuilt/image.frag.glsl index 747ceef7..2a3c2038 100644 --- a/shaders/prebuilt/image.frag.glsl +++ b/shaders/prebuilt/image.frag.glsl @@ -17,35 +17,6 @@ float cellNoise(uint serial, uint col, uint row) { return float(x & 0xffffu) / 65535.0; } -// The same seven procedural ASCII marks as ui.frag.glsl and the macOS Metal -// panel kernel. Native attachments are content in the panel, so they should -// not switch to a smaller two-symbol animation when the glyph grid over them -// is using `. : + * # % @`. -float asciiGlyph(vec2 uv, int glyph) { - vec2 p = uv * 2.0 - 1.0; - float thin = 0.12; - float dotShape = 1.0 - smoothstep(0.10, 0.20, length(p - vec2(0.0, 0.45))); - float barH = 1.0 - smoothstep(thin, thin + 0.08, abs(p.y)); - float barV = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x)); - if (glyph == 0) return dotShape; - if (glyph == 1) return max(dotShape, 1.0 - smoothstep(0.10, 0.20, length(p + vec2(0.0, 0.45)))); - if (glyph == 2) return max(barH, barV); - if (glyph == 3) { - float d0 = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x - p.y)); - float d1 = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x + p.y)); - return max(max(d0, d1), max(barH, barV)); - } - if (glyph == 4) { - float rails = max( - 1.0 - smoothstep(thin, thin + 0.08, abs(abs(p.x) - 0.34)), - 1.0 - smoothstep(thin, thin + 0.08, abs(abs(p.y) - 0.34)) - ); - return rails; - } - float ring = 1.0 - smoothstep(thin, thin + 0.08, abs(length(p * vec2(0.8, 1.0)) - 0.48)); - return max(ring, glyph == 6 ? dotShape : 0.0); -} - void main() { vec4 source = texture(u_image, v_uv); uint effect = v_effect.x & 0x7fffffffu; @@ -64,36 +35,5 @@ void main() { o_col = source; return; } - if (effect == 4u) { - float diagonal = float((cellCoord.x + cellCoord.y * 2u) % 17u) / 17.0; - float threshold = noise * 0.72 + diagonal * 0.28; - bool reveal = progress >= 1.0 || (progress > 0.0 && progress >= threshold); - bool wave = !reveal && progress > 0.0 && progress + 0.22 >= threshold; - if (oldLayer) { - if (wave || reveal) discard; - o_col = source; - return; - } - if (reveal) { - o_col = source; - return; - } - if (!wave) discard; - vec2 cellUV = fract(v_panel_uv * vec2(dimensions)); - int glyph = min(int(floor(noise * 7.0)), 6); - float mark = asciiGlyph(cellUV, glyph); - float luma = dot(source.rgb, vec3(0.2126, 0.7152, 0.0722)); - vec3 paper = source.rgb * 0.10; - // Preserve source colour when it already separates from the dimmed - // paper. Solid black pixels have no such separation, so derive a - // readable mark from their own luminance instead of letting the ASCII - // phase disappear on dark images/PDF regions. - vec3 contrastInk = luma > 0.5 ? vec3(0.0) : vec3(1.0); - float sourceContrast = smoothstep(0.025, 0.14, length(source.rgb - paper)); - vec3 ink = mix(contrastInk, source.rgb, sourceContrast); - vec3 dimInk = mix(paper, ink, 0.62); - o_col = vec4(mix(paper, dimInk, mark), source.a); - return; - } o_col = source; } diff --git a/shaders/prebuilt/image.frag.spv b/shaders/prebuilt/image.frag.spv index c3d87e58..ac6a2682 100644 Binary files a/shaders/prebuilt/image.frag.spv and b/shaders/prebuilt/image.frag.spv differ diff --git a/shaders/prebuilt/ui.frag.glsl b/shaders/prebuilt/ui.frag.glsl index 2113f6ae..ddc1cde0 100644 --- a/shaders/prebuilt/ui.frag.glsl +++ b/shaders/prebuilt/ui.frag.glsl @@ -23,31 +23,6 @@ float cellNoise(uint serial, uint col, uint row) { return float(x & 0xffffu) / 65535.0; } -float asciiGlyph(vec2 uv, int glyph) { - vec2 p = uv * 2.0 - 1.0; - float thin = 0.12; - float dotShape = 1.0 - smoothstep(0.10, 0.20, length(p - vec2(0.0, 0.45))); - float barH = 1.0 - smoothstep(thin, thin + 0.08, abs(p.y)); - float barV = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x)); - if (glyph == 0) return dotShape; - if (glyph == 1) return max(dotShape, 1.0 - smoothstep(0.10, 0.20, length(p + vec2(0.0, 0.45)))); - if (glyph == 2) return max(barH, barV); - if (glyph == 3) { - float d0 = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x - p.y)); - float d1 = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x + p.y)); - return max(max(d0, d1), max(barH, barV)); - } - if (glyph == 4) { - float rails = max( - 1.0 - smoothstep(thin, thin + 0.08, abs(abs(p.x) - 0.34)), - 1.0 - smoothstep(thin, thin + 0.08, abs(abs(p.y) - 0.34)) - ); - return rails; - } - float ring = 1.0 - smoothstep(thin, thin + 0.08, abs(length(p * vec2(0.8, 1.0)) - 0.48)); - return max(ring, glyph == 6 ? dotShape : 0.0); -} - void main() { float a = texture(u_atlas, v_uv).r; bool oldLayer = (v_effect.x & 0x80000000u) != 0u; @@ -69,41 +44,6 @@ void main() { return; } - // 4 = ASCII materialization. A diagonal/noise threshold matches the TTY - // compositor's idea: cells briefly become punctuation, then their real - // atlas glyph arrives. The procedural marks keep the GUI implementation - // entirely shader-side. - if (effect == 4u) { - if (progress <= 0.0) { - if (!oldLayer) discard; - o_col = vec4(cell, 1.0); - return; - } - if (progress >= 1.0) { - if (oldLayer) discard; - o_col = vec4(cell, 1.0); - return; - } - float diagonal = float((cellCol + cellRow * 2u) % 17u) / 17.0; - float threshold = noise * 0.72 + diagonal * 0.28; - if (progress >= threshold) { - if (oldLayer) discard; - o_col = vec4(cell, 1.0); - return; - } - if (progress + 0.22 >= threshold) { - if (oldLayer) discard; - int glyph = int(floor(noise * 7.0)); - float ink = asciiGlyph(v_cell_uv, min(glyph, 6)); - vec3 dimInk = mix(v_bg, v_fg, 0.62); - o_col = vec4(mix(v_bg, dimInk, ink), 1.0); - return; - } - if (!oldLayer) discard; - o_col = vec4(cell, 1.0); - return; - } - // FreeType's hinted grayscale bitmap is already the intended coverage. // Preserve it exactly; weight-changing gamma hacks blur small stems. o_col = vec4(cell, 1.0); diff --git a/shaders/prebuilt/ui.frag.spv b/shaders/prebuilt/ui.frag.spv index b97495b3..6b8a88e0 100644 Binary files a/shaders/prebuilt/ui.frag.spv and b/shaders/prebuilt/ui.frag.spv differ diff --git a/shaders/ui.frag.glsl b/shaders/ui.frag.glsl index 2113f6ae..ddc1cde0 100644 --- a/shaders/ui.frag.glsl +++ b/shaders/ui.frag.glsl @@ -23,31 +23,6 @@ float cellNoise(uint serial, uint col, uint row) { return float(x & 0xffffu) / 65535.0; } -float asciiGlyph(vec2 uv, int glyph) { - vec2 p = uv * 2.0 - 1.0; - float thin = 0.12; - float dotShape = 1.0 - smoothstep(0.10, 0.20, length(p - vec2(0.0, 0.45))); - float barH = 1.0 - smoothstep(thin, thin + 0.08, abs(p.y)); - float barV = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x)); - if (glyph == 0) return dotShape; - if (glyph == 1) return max(dotShape, 1.0 - smoothstep(0.10, 0.20, length(p + vec2(0.0, 0.45)))); - if (glyph == 2) return max(barH, barV); - if (glyph == 3) { - float d0 = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x - p.y)); - float d1 = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x + p.y)); - return max(max(d0, d1), max(barH, barV)); - } - if (glyph == 4) { - float rails = max( - 1.0 - smoothstep(thin, thin + 0.08, abs(abs(p.x) - 0.34)), - 1.0 - smoothstep(thin, thin + 0.08, abs(abs(p.y) - 0.34)) - ); - return rails; - } - float ring = 1.0 - smoothstep(thin, thin + 0.08, abs(length(p * vec2(0.8, 1.0)) - 0.48)); - return max(ring, glyph == 6 ? dotShape : 0.0); -} - void main() { float a = texture(u_atlas, v_uv).r; bool oldLayer = (v_effect.x & 0x80000000u) != 0u; @@ -69,41 +44,6 @@ void main() { return; } - // 4 = ASCII materialization. A diagonal/noise threshold matches the TTY - // compositor's idea: cells briefly become punctuation, then their real - // atlas glyph arrives. The procedural marks keep the GUI implementation - // entirely shader-side. - if (effect == 4u) { - if (progress <= 0.0) { - if (!oldLayer) discard; - o_col = vec4(cell, 1.0); - return; - } - if (progress >= 1.0) { - if (oldLayer) discard; - o_col = vec4(cell, 1.0); - return; - } - float diagonal = float((cellCol + cellRow * 2u) % 17u) / 17.0; - float threshold = noise * 0.72 + diagonal * 0.28; - if (progress >= threshold) { - if (oldLayer) discard; - o_col = vec4(cell, 1.0); - return; - } - if (progress + 0.22 >= threshold) { - if (oldLayer) discard; - int glyph = int(floor(noise * 7.0)); - float ink = asciiGlyph(v_cell_uv, min(glyph, 6)); - vec3 dimInk = mix(v_bg, v_fg, 0.62); - o_col = vec4(mix(v_bg, dimInk, ink), 1.0); - return; - } - if (!oldLayer) discard; - o_col = vec4(cell, 1.0); - return; - } - // FreeType's hinted grayscale bitmap is already the intended coverage. // Preserve it exactly; weight-changing gamma hacks blur small stems. o_col = vec4(cell, 1.0); 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. diff --git a/test/snapshots/theme.golden b/test/snapshots/theme.golden index d1941f9b..be97a268 100644 --- a/test/snapshots/theme.golden +++ b/test/snapshots/theme.golden @@ -92,7 +92,7 @@ | | == style dark -|0: 0-56 #969696,#2c2a3e, 57-99 d,#2c2a3e, +|0: 0-36 #969696,#2c2a3e, 37-45 #d8c8a8,#4a3a1e, 46-56 #969696,#2c2a3e, 57-99 d,#2c2a3e, |1: 0-1 #ffffff,#6e4fd0, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 #ffffff,#383c54, 52-85 #969696,#2c2a3e, 86-99 d,#2c2a3e, |2: 0-0 d,#525252, 1-49 d,d, 50-50 d,#525252, 51-99 d,d, |3: 0-0 d,#525252, 1-49 d,d, 50-50 d,#525252, 51-99 d,d, @@ -154,7 +154,7 @@ | | == style acme -|0: 0-56 #000000,#eaffff, 57-99 d,#eaffff, +|0: 0-36 #000000,#eaffff, 37-45 #000000,#eeee9e, 46-56 #000000,#eaffff, 57-99 d,#eaffff, |1: 0-1 #ffffff,#6e4fd0, 2-35 #000000,#eaffff, 36-49 d,#eaffff, 50-51 #ffffff,#383c54, 52-85 #000000,#eaffff, 86-99 d,#eaffff, |2: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, |3: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, @@ -216,7 +216,7 @@ | | == style colors-off -|0: 0-56 #000000,#eaffff, 57-99 d,#eaffff, +|0: 0-36 #000000,#eaffff, 37-45 #000000,#eeee9e, 46-56 #000000,#eaffff, 57-99 d,#eaffff, |1: 0-1 #ffffff,#6e4fd0, 2-35 #000000,#eaffff, 36-49 d,#eaffff, 50-51 #ffffff,#383c54, 52-85 #000000,#eaffff, 86-99 d,#eaffff, |2: 0-1 d,#ffffea, 2-3 #000000,#ffffea, 4-51 d,#ffffea, 52-53 #000000,#ffffea, 54-99 d,#ffffea, |3: 0-1 d,#ffffea, 2-6 #000000,#ffffea, 7-51 d,#ffffea, 52-56 #000000,#ffffea, 57-99 d,#ffffea, @@ -278,7 +278,7 @@ | | == style generated -|0: 0-56 #e0e0e0,#1e262e, 57-99 d,#1e262e, +|0: 0-36 #e0e0e0,#1e262e, 37-45 #e0e0e0,#303840, 46-56 #e0e0e0,#1e262e, 57-99 d,#1e262e, |1: 0-1 #000000,#ffa060, 2-35 #e0e0e0,#1e262e, 36-49 d,#1e262e, 50-51 #ffffff,#303840, 52-85 #e0e0e0,#1e262e, 86-99 d,#1e262e, |2: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, |3: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, diff --git a/test/snapshots/windowops.golden b/test/snapshots/windowops.golden index 2ec3e46f..187c6ada 100644 --- a/test/snapshots/windowops.golden +++ b/test/snapshots/windowops.golden @@ -309,7 +309,7 @@ | | == style debug-on -|0: 0-56 #949494,#121212, 57-99 d,#121212, +|0: 0-46 #949494,#121212, 47-51 #080808,#80a0ff, 52-56 #949494,#121212, 57-99 d,#121212, |1: 0-1 #ffffff,#262626, 2-39 #949494,#121212, 40-41 #ffffff,#626262, 42-61 #949494,#121212, 62-99 d,d, |2: 0-0 d,#444444, 1-1 d,#080808, 2-39 #bdbdbd,#080808, 40-40 d,#444444, 41-41 d,#080808, 42-61 #bdbdbd,#080808, 62-62 d,d, 63-79 #949494,d, 80-98 d,#121212, 99-99 d,d, |3: 0-0 d,#444444, 1-1 d,#080808, 2-39 #bdbdbd,#080808, 40-40 d,#444444, 41-41 d,#080808, 42-61 #bdbdbd,#080808, 62-62 d,d, 63-74 #949494,d, 75-98 d,#121212, 99-99 d,d, -- cgit v1.3