Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 96 additions & 15 deletions src/tui-opentui/mark-anim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@ import { describe, expect, test } from "bun:test"

import {
MARK_PERIOD_SECONDS,
SNOW_CHAR,
markFrame,
markText,
renderMark,
smooth,
} from "./mark-anim"
import { MARK_COLS, MARK_COVERAGE, MARK_LARGE, MARK_ROWS } from "./mark-shape"
import { MARK_COLS, MARK_LARGE, MARK_ROWS, MARK_SMALL } from "./mark-shape"
import { UI } from "./theme"

const MOUNTAIN_CHARS = "▁▂▃▄▅▆▇█"

const isMountain = (char: string): boolean => MOUNTAIN_CHARS.includes(char)
const isSnow = (char: string): boolean => char === SNOW_CHAR

describe("smooth", () => {
test("clamps outside [0, 1] and eases inside it", () => {
expect(smooth(-3)).toBe(0)
Expand Down Expand Up @@ -62,20 +68,36 @@ describe("markFrame", () => {
})

describe("renderMark", () => {
const emptyCells = (grid: readonly (readonly { char: string }[])[]): number =>
grid.flat().filter((cell) => cell.char === " ").length
/** Mountain-block weight only — snow must not pollute silhouette metrics. */
const mountainWeight = (
grid: readonly (readonly { char: string }[])[],
): number =>
grid
.flat()
.reduce((sum, cell) => sum + Math.max(0, MOUNTAIN_CHARS.indexOf(cell.char) + 1), 0)

const mountainCells = (
grid: readonly (readonly { char: string }[])[],
): number => grid.flat().filter((cell) => isMountain(cell.char)).length

test("is the mark's cell dimensions", () => {
const grid = renderMark({ nowMs: 0, still: true })
expect(grid).toHaveLength(MARK_ROWS)
for (const row of grid) expect(row).toHaveLength(MARK_COLS)
})

test("never paints outside the silhouette", () => {
const grid = renderMark({ nowMs: 2000, still: false })
test("sky is empty or snow; mountain cells never hold snow", () => {
const grid = renderMark({ nowMs: 2000, still: false, grid: MARK_LARGE })
grid.forEach((row, y) => {
row.forEach((cell, x) => {
if ((MARK_COVERAGE[y]?.[x] ?? 0) === 0) expect(cell.char).toBe(" ")
const coverage = MARK_LARGE.coverage[y]?.[x] ?? 0
if (coverage === 0) {
expect(cell.char === " " || isSnow(cell.char)).toBe(true)
if (isSnow(cell.char)) expect(cell.fg).toBe(UI.textFaint)
} else if (isMountain(cell.char)) {
expect(cell.fg).toBe(UI.action)
expect(isSnow(cell.char)).toBe(false)
}
})
})
})
Expand All @@ -84,17 +106,19 @@ describe("renderMark", () => {
const grid = renderMark({ nowMs: 0, still: true, grid: MARK_LARGE })
grid.forEach((row, y) => {
row.forEach((cell, x) => {
expect(" ▁▂▃▄▅▆▇█").toContain(cell.char)
// Still mode has no snow — only space or mountain blocks.
expect(` ${MOUNTAIN_CHARS}`).toContain(cell.char)
if ((MARK_LARGE.coverage[y]?.[x] ?? 0) === 1) expect(cell.char).toBe("█")
})
})
})

test("the still frame is clock-independent", () => {
test("the still frame is clock-independent and has no snow", () => {
const a = markText(renderMark({ nowMs: 0, still: true }))
const b = markText(renderMark({ nowMs: 987_654, still: true }))
expect(b).toBe(a)
expect(a.replace(/[\s\n]/g, "").length).toBeGreaterThan(0)
expect(a.includes(SNOW_CHAR)).toBe(false)
})

test("the animated frame advances with the injected clock", () => {
Expand All @@ -105,14 +129,14 @@ describe("renderMark", () => {
})

test("the outline reveals left to right", () => {
// Early in the draw phase only the leftmost columns may be lit.
// Early in the draw phase only the leftmost mountain columns may be lit.
const grid = renderMark({ nowMs: 0.06 * MARK_PERIOD_SECONDS * 1000, still: false })
const lit = grid.flatMap((row) =>
row.flatMap((cell, col) => (cell.char === " " ? [] : [col])),
row.flatMap((cell, col) => (isMountain(cell.char) ? [col] : [])),
)
expect(Math.max(...lit, -1)).toBeLessThan(MARK_COLS)
const full = renderMark({ nowMs: 0.4 * MARK_PERIOD_SECONDS * 1000, still: false })
expect(emptyCells(grid)).toBeGreaterThan(emptyCells(full))
expect(mountainCells(full)).toBeGreaterThan(mountainCells(grid))
})

test("the fade thins the mark out toward empty", () => {
Expand All @@ -121,12 +145,10 @@ describe("renderMark", () => {
nowMs: 0.995 * MARK_PERIOD_SECONDS * 1000,
still: false,
})
expect(emptyCells(fading)).toBeGreaterThan(emptyCells(held))
expect(mountainCells(held)).toBeGreaterThan(mountainCells(fading))
})

test("filling makes the mark denser than its outline alone", () => {
const weight = (grid: readonly (readonly { char: string }[])[]): number =>
grid.flat().reduce((sum, cell) => sum + " ▁▂▃▄▅▆▇█".indexOf(cell.char), 0)
const outlineOnly = renderMark({
nowMs: 0.42 * MARK_PERIOD_SECONDS * 1000,
still: false,
Expand All @@ -135,6 +157,65 @@ describe("renderMark", () => {
nowMs: 0.8 * MARK_PERIOD_SECONDS * 1000,
still: false,
})
expect(weight(filled)).toBeGreaterThan(weight(outlineOnly))
expect(mountainWeight(filled)).toBeGreaterThan(mountainWeight(outlineOnly))
})

test("snow drifts over time without overwriting the silhouette", () => {
// Sample across several seconds so flakes advance even at a slow fall rate.
const times = [0, 1500, 3000, 4500, 6000, 7500]
const snowSets = times.map((nowMs) => {
const grid = renderMark({ nowMs, still: false, grid: MARK_LARGE })
const snow: string[] = []
grid.forEach((row, y) => {
row.forEach((cell, x) => {
if (isSnow(cell.char)) {
snow.push(`${y},${x}`)
// Flakes live only in sky cells — never on mountain coverage.
expect(MARK_LARGE.coverage[y]?.[x] ?? 0).toBe(0)
}
})
})
return snow.join("|")
})

const withSnow = snowSets.filter((s) => s.length > 0)
expect(withSnow.length).toBeGreaterThan(1)
expect(new Set(withSnow).size).toBeGreaterThan(1)

// During the full-hold phase the ridgeline dominates the flake field.
const held = renderMark({
nowMs: 0.82 * MARK_PERIOD_SECONDS * 1000,
still: false,
grid: MARK_LARGE,
})
let flakes = 0
let mountains = 0
held.forEach((row, y) => {
row.forEach((cell, x) => {
if (isSnow(cell.char)) {
flakes += 1
expect(MARK_LARGE.coverage[y]?.[x] ?? 0).toBe(0)
}
if (isMountain(cell.char)) mountains += 1
})
})
expect(mountains).toBeGreaterThan(20)
expect(mountains).toBeGreaterThan(flakes)
})

test("still mode freezes the mark with no snow motion", () => {
const a = renderMark({ nowMs: 0, still: true, grid: MARK_SMALL })
const b = renderMark({ nowMs: 50_000, still: true, grid: MARK_SMALL })
expect(markText(b)).toBe(markText(a))
expect(a.flat().some((cell) => isSnow(cell.char))).toBe(false)
})

test("snow drops out during the fade-out phase, matching the mark", () => {
const fading = renderMark({
nowMs: 0.995 * MARK_PERIOD_SECONDS * 1000,
still: false,
grid: MARK_LARGE,
})
expect(fading.flat().some((cell) => isSnow(cell.char))).toBe(false)
})
})
64 changes: 63 additions & 1 deletion src/tui-opentui/mark-anim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
* wave; at hero size a terminal renders that as visible noise rather than as
* shimmer, so the terminal mark is opaque instead.
*
* Over the sky (zero-coverage cells) a sparse field of pixel snow falls on the
* same injected clock. Density and speed stay low so the ridgeline keeps its
* silhouette; `still` (idle or reduced motion) freezes the mark and drops the
* snow entirely. Mountain cells always win over flakes.
*
* Everything here is pure and clock-injected: `nowMs` is the only time source,
* so the caller's existing 250 ms status tick drives the animation and tests
* drive it deterministically. There is no timer in this module.
Expand Down Expand Up @@ -70,6 +75,18 @@ const EIGHTHS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] as cons
*/
const FILL_GAMMA = 0.6

/** One snowflake pixel. Exported so tests can distinguish sky from mountain. */
export const SNOW_CHAR = "·"

/**
* Fraction of columns that host a flake. Kept low so the sky reads as empty
* with occasional drift rather than a storm.
*/
const SNOW_COLUMN_FRACTION = 0.18

/** Baseline rows-per-second fall rate. Slow enough to feel like drift. */
const SNOW_FALL_SPEED = 0.55

export type MarkCell = {
readonly char: string
readonly fg: string
Expand All @@ -83,6 +100,35 @@ export type MarkInput = {
readonly grid?: MarkGrid
}

/**
* Stable unit hash in [0, 1) from integer seeds. Pure and clock-independent so
* flake columns and phases never jitter between frames.
*/
function unitHash(a: number, b = 0): number {
const n = Math.imul(a + 1, 374761393) ^ Math.imul(b + 1, 668265263)
const x = Math.imul(n ^ (n >>> 13), 1274126177)
return ((x >>> 0) % 10_000) / 10_000
}

/**
* Whether a sky cell at (row, col) holds a flake at `seconds`. Sparse columns
* only; each active column carries one flake with a private phase and a slight
* speed variation so the field does not march as a rigid lattice.
*/
function snowflakeAt(
row: number,
col: number,
seconds: number,
rows: number,
): boolean {
if (rows <= 0) return false
if (unitHash(col, 1) > SNOW_COLUMN_FRACTION) return false
const phase = unitHash(col, 2) * rows
const speed = SNOW_FALL_SPEED * (0.75 + unitHash(col, 3) * 0.5)
const wrapped = (((seconds * speed + phase) % rows) + rows) % rows
return Math.floor(wrapped) === row
}

/**
* Composite one frame into a row-major cell grid.
*
Expand All @@ -91,6 +137,9 @@ export type MarkInput = {
* slopes instead of staircasing. No dither texture survives inside the shape —
* the mark is a mountain, and a mountain is opaque.
*
* Sky cells (zero coverage) may hold a single falling snow pixel. Flakes never
* overwrite mountain coverage, and `still` suppresses them entirely.
*
* `alpha` has no terminal equivalent, so it scales the block height instead:
* the mark sinks toward empty rather than blending to black.
*/
Expand All @@ -100,6 +149,9 @@ export function renderMark(input: MarkInput): readonly (readonly MarkCell[])[] {
const { drawProg, fillProg, alpha } = markFrame(seconds, input.still)
const revealed = drawProg * shape.cols
const fillLine = shape.rows * (1 - fillProg)
// Fade out drops the snow too so the decoration doesn't outlast the mark
// it drifts over.
const snowOn = !input.still && alpha === 1

const grid: MarkCell[][] = []
for (let row = 0; row < shape.rows; row++) {
Expand All @@ -110,7 +162,17 @@ export function renderMark(input: MarkInput): readonly (readonly MarkCell[])[] {
const coverage = shape.coverage[row]?.[col] ?? 0
const reveal = clamp01(revealed - col)
if (coverage === 0 || reveal === 0) {
cells.push({ char: " ", fg: UI.action })
// Snow only in true sky. Unrevealed mountain cells stay empty so the
// left-to-right draw still reads as a clean silhouette edge.
if (
snowOn &&
coverage === 0 &&
snowflakeAt(row, col, seconds, shape.rows)
) {
cells.push({ char: SNOW_CHAR, fg: UI.textFaint })
} else {
cells.push({ char: " ", fg: UI.action })
}
continue
}
// The outline states the shape at its true coverage; filling lifts it
Expand Down
Loading