Skip to content
Merged
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
19 changes: 17 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,20 @@ each time of day at the top, middle and bottom of the screen. Darker, more satur
at those depths read better than black: dark sky blue at the top and dark green over grass by day, deep navy at night.
Check that text stays readable with `bun dev/preview.ts` at every time of day.

## Composition

OpenCode's text sits in the middle of the screen: the home screen has a centered logo and a 76-column prompt across
the middle third of the height, and a session has a column of messages, a prompt at the bottom and, from 120 columns,
a sidebar on the right. Put the things people should see (a barn, a volcano, a waterfall) toward the left and right
edges, in the top band or along the bottom, measured in screen heights from the nearest edge (`A - 0.3`, not `0.8 * A`)
so they stay clear on wide and narrow terminals alike. Keep the middle band to calm scenery such as sky, water or open
field. `bun dev/preview.ts <id> home 100x30` shows a real layout at any size.

The engine also tells scenes where text actually is, about once a second. `this.openSpot(x0, x1, y0, y1)` picks a
random point in a box, the least covered of a few tries, for a creature's next stop or a visitor's spot, and
`this.openRow(y0, y1)` picks a height for something crossing the whole screen. Both are plain random picks until the
engine has sent its map, and in `dev/snap.ts`.

## Activity

- **calm** is scenery with something rare now and then: water, wind, clouds, one creature passing every minute or so.
Expand Down Expand Up @@ -232,7 +246,7 @@ a lot with machine load, so compare against an existing wallpaper measured back
bun run typecheck
bun dev/check.ts [id...] [season] [weather] # every activity and time: errors, ms/frame, flicker
bun dev/snap.ts <id> [activity] [time] [season] [weather] [seconds...] # scene PNGs in dev/out/, and ms per render
bun dev/preview.ts <id> [activity] [time] [season] [weather] [seconds] # terminal cells behind text, as a PNG
bun dev/preview.ts <id> [activity] [time] [season] [weather] [home|session] [WxH] [seconds] # terminal cells behind text, as a PNG
bun dev/pixel-check.ts [id] [activity] [time] # real-pixel mode against a mock kitty terminal
bun dev/poke.ts <id> [activity] [time] <fx> <fy> [seconds...] # click at a point (fractions of the screen)
bun dev/fade.ts <id>:day <id>:night 50 # a frame halfway through a crossfade, and its cost
Expand All @@ -247,7 +261,8 @@ bun dev/gif.ts all # re-renders the README's GIFs
than `--max-ms`; CI runs `--quiet --max-ms=60`. Dev renders seed `Math.random`, so they are reproducible.
Errors thrown in `step` or `render` show up here; inside OpenCode they are only logged to `WALLPAPER_DEBUG`.
- `snap.ts` renders the scene directly, before terminal conversion, for judging the art.
- `preview.ts` shows what the terminal will show, scrim included.
- `preview.ts` shows what the terminal will show, scrim included. `home` and `session` lay the text out like OpenCode's
home screen or a busy session, and a size such as `240x60` or `100x30` replaces 160x45.
- `dev/pty.ts [seconds] [keys...]` runs a real OpenCode in a pseudo-terminal and types each key argument, pausing
1.5 s after each; `\r` is Enter and `\e` is Escape. `dev/pty-kitty.ts [id] [seconds]` does the same as a kitty
terminal in pixel mode. Run them with a throwaway profile (fresh `XDG_DATA_HOME`, `XDG_CONFIG_HOME`,
Expand Down
49 changes: 41 additions & 8 deletions dev/mock.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,30 @@
// A mock 160x45 OpenCode screen driving the real engine, for headless checks.
// A mock OpenCode screen (160x45 unless sized) driving the real engine, for headless checks.
import { createEngine } from "../src/engine"

export const W = 160
export const H = 45
export const FPS = 15

export function mockScreen(options: { kitty?: boolean } = {}) {
export function mockScreen(options: { kitty?: boolean; W?: number; H?: number } = {}) {
const w = options.W ?? W
const h = options.H ?? H
// The engine advances on wall-clock time; each performance.now() call moves one engine frame forward.
let clock = 0
performance.now = () => (clock += 1000 / FPS)
const buffers = { char: new Uint32Array(W * H), fg: new Uint16Array(W * H * 4), bg: new Uint16Array(W * H * 4), attributes: new Uint32Array(W * H) }
const buffers = { char: new Uint32Array(w * h), fg: new Uint16Array(w * h * 4), bg: new Uint16Array(w * h * 4), attributes: new Uint32Array(w * h) }
let post: ((buffer: unknown) => void) | undefined
let layer: ((buffer: unknown) => void) | undefined
const renderer = {
addPostProcessFn: (fn: typeof post) => (post = fn),
removePostProcessFn: () => (post = undefined),
requestRender() {},
getCursorState: () => ({ x: 10, y: 40, visible: true }),
getCursorState: () => ({ x: 10, y: h - 5, visible: true }),
on() {},
off() {},
stdin: { on() {}, off() {} },
kittyImageTransport: "raw",
root: {} as { onMouse?: unknown },
...(options.kitty ? { resolution: { width: W * 8, height: H * 16 }, capabilities: { kitty_graphics: true } } : {}),
...(options.kitty ? { resolution: { width: w * 8, height: h * 16 }, capabilities: { kitty_graphics: true } } : {}),
}
const engine = createEngine({ renderer, data: { listen: () => () => {} } } as never, { layer: (_renderer, draw) => ((layer = draw as typeof layer), { dispose() {} }) })
return {
Expand All @@ -31,10 +33,10 @@ export function mockScreen(options: { kitty?: boolean } = {}) {
renderer,
// OpenCode's render pass: the back layer places its image first, then the UI paints every cell, then post-processing.
frame(paint: (x: number, y: number) => number = () => 32, buffer: Record<string, unknown> = {}) {
const buf = { width: W, height: H, buffers, ...buffer }
const buf = { width: w, height: h, buffers, ...buffer }
layer?.(buf)
for (let i = 0; i < W * H; i++) {
buffers.char[i] = paint(i % W, (i / W) | 0)
for (let i = 0; i < w * h; i++) {
buffers.char[i] = paint(i % w, (i / w) | 0)
buffers.bg.set([10, 10, 12, 255], i * 4)
buffers.fg.set([200, 200, 200, 255], i * 4)
}
Expand All @@ -45,3 +47,34 @@ export function mockScreen(options: { kitty?: boolean } = {}) {

// Some text-like cells so the scrim and text handling get exercised.
export const text = (x: number, y: number) => (y % 4 === 1 && x > 40 && x < 120 && x % 9 !== 0 ? 65 : 32)

// Where text sits on OpenCode's home screen, measured in a real one: a centered logo, a 76-column prompt below it and
// the version in the bottom-right corner.
export function home(w: number, h: number) {
const c = Math.floor(h / 2)
const left = Math.floor(w / 2) - 37
const spans: [number, number, number][] = [
...[0, 1, 2, 3].map((r): [number, number, number] => [c - 6 + r, left + 18, left + 58]),
[c - 1, left + 20, left + 56],
[c + 2, left + 3, left + 58],
[c + 4, left + 3, left + 32],
[c + 6, left + 42, left + 75],
...[1, 2, 3, 4, 5].map((r): [number, number, number] => [c + r, left, left + 1]),
[h - 2, w - 17, w - 2],
]
return (x: number, y: number) => (spans.some((s) => s[0] === y && x >= s[1] && x < s[2]) ? 65 : 32)
}

// A session: a column of messages above the prompt, a status line, and from 120 columns a sidebar on the right.
export function session(w: number, h: number) {
const right = w >= 120 ? w - 42 : w - 2
return (x: number, y: number) => {
if (y === h - 2) return x >= 2 && x < w - 2 && x % 23 < 15 ? 65 : 32
if (x >= right + 2 && x < w - 2) return (y >= 2 && y < 4) || (y >= 6 && y < 15 && y % 3 === 0 && x < w - 16 - ((y * 7) % 12)) ? 65 : 32
if (x < 2 || x >= right) return 32
if (y >= h - 7 && y < h - 3) return y === h - 6 || (y === h - 4 && x < 30) ? 65 : 32
if (y < 1 || y >= h - 8 || y % 6 === 5) return 32
// Paragraphs of prose: every line fills the column except each paragraph's last.
return y % 6 !== 4 || x < 2 + (right - 2) * (0.3 + ((y * 37) % 50) / 100) ? 65 : 32
}
}
20 changes: 13 additions & 7 deletions dev/preview.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,31 @@
// Renders what a wallpaper looks like in the terminal (octant cells behind some text) on a mock 160x45 screen, to a PNG:
// bun dev/preview.ts <wallpaper> [calm|lively|teeming] [day|sunset|night] [spring|summer|autumn|winter] [clear|overcast|rain|snow|fog] [seconds]
// Renders what a wallpaper looks like in the terminal (octant cells behind some text) on a mock screen, to a PNG. home
// and session lay the text out like OpenCode's home screen or a session; a size such as 240x60 replaces 160x45:
// bun dev/preview.ts <wallpaper> [calm|lively|teeming] [day|sunset|night] [spring|summer|autumn|winter] [clear|overcast|rain|snow|fog] [home|session] [WxH] [seconds]
import "./seed"
import { mkdirSync } from "node:fs"
import { ACTIVITIES, SEASONS, TIMES, WEATHERS } from "../src/engine"
import { cellsToPng } from "./cells"
import { FPS, H, W, mockScreen, text } from "./mock"
import { FPS, H, W, home, mockScreen, session, text } from "./mock"
import { wallpapers } from "./wallpapers"

const [id, ...rest] = process.argv.slice(2)
const activity = ACTIVITIES.find((a) => rest.includes(a)) ?? "calm"
const time = TIMES.find((t) => rest.includes(t)) ?? "day"
const season = SEASONS.find((s) => rest.includes(s))
const weather = WEATHERS.find((w) => rest.includes(w))
const layout = (["home", "session"] as const).find((l) => rest.includes(l))
const size = rest.find((arg) => /^\d+x\d+$/.test(arg))
const [w, h] = size ? size.split("x").map(Number) : [W, H]
const seconds = rest.find((arg) => /^[\d.]+$/.test(arg)) ?? "20"
const wallpaper = wallpapers.find((w) => w.id === id)
if (!wallpaper) throw new Error(`usage: bun dev/preview.ts <${wallpapers.map((w) => w.id).join("|")}> [seconds]`)
process.env.WALLPAPER_OCTANTS = "1"
mkdirSync("dev/out", { recursive: true })
const screen = mockScreen()
const screen = mockScreen({ W: w, H: h })
const paint = layout === "home" ? home(w, h) : layout === "session" ? session(w, h) : text
screen.engine.start(wallpaper, { activity, time, season, weather })
for (let f = 0; f < Number(seconds) * FPS; f++) screen.frame(text)
for (let f = 0; f < Number(seconds) * FPS; f++) screen.frame(paint)
screen.engine.stop()
const path = `dev/out/${[wallpaper.id, activity, time, season, weather, "preview", `${seconds}s`].filter(Boolean).join("-")}.png`
await Bun.write(path, cellsToPng({ W, H, ...screen.buffers }))
const path = `dev/out/${[wallpaper.id, activity, time, season, weather, layout, size, "preview", `${seconds}s`].filter(Boolean).join("-")}.png`
await Bun.write(path, cellsToPng({ W: w, H: h, ...screen.buffers }))
console.log(path)
1 change: 1 addition & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export {
type Scrim,
type Season,
type Settings,
type TextMap,
type Time,
type Wallpaper,
type Weather,
Expand Down
55 changes: 53 additions & 2 deletions src/canvas.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { AgentEvent } from "./wallpaper"
import { clamp, type RGB } from "./math"
import type { AgentEvent, TextMap } from "./wallpaper"
import { clamp, rand, type RGB } from "./math"

// Bloom is computed at 1/BLOOM resolution, then again at half that for the wide halo.
const BLOOM = 4
Expand Down Expand Up @@ -72,6 +72,57 @@ export abstract class Canvas {
this.gloom = clamp(this.gloom + (this.gloomy ? dt : -dt) * 0.3, 0, 1)
}

// Where OpenCode's text sits; unknown until the engine says, and in dev/snap.ts.
protected text: TextMap | undefined

textMap(map: TextMap) {
this.text = map
}

// How hidden by text the point (x, y) in screen heights is, from 0 (open) to 1.
protected covered(x: number, y: number) {
const t = this.text
if (!t) return 0
return t.cover[clamp(Math.floor(y * t.rows), 0, t.rows - 1) * t.cols + clamp(Math.floor((x / this.A) * t.cols), 0, t.cols - 1)]
}

// A random point in the box from (x0, y0) to (x1, y1) in screen heights, the most open of a few tries once the
// engine has said where text is: creatures wander toward open ground and visitors come on where they'll be seen.
protected openSpot(x0: number, x1: number, y0: number, y1: number): [number, number] {
let best: [number, number] = [rand(x0, x1), rand(y0, y1)]
let least = this.covered(best[0], best[1])
for (let i = 0; i < 5 && least > 0.05; i++) {
const spot: [number, number] = [rand(x0, x1), rand(y0, y1)]
const c = this.covered(spot[0], spot[1])
if (c >= least) continue
best = spot
least = c
}
return best
}

// A height between y0 and y1 for a visitor crossing the whole screen: the least covered row of a few tries.
protected openRow(y0: number, y1: number) {
let best = rand(y0, y1)
const t = this.text
if (!t) return best
const cover = (y: number) => {
const row = clamp(Math.floor(y * t.rows), 0, t.rows - 1) * t.cols
let sum = 0
for (let c = 0; c < t.cols; c++) sum += t.cover[row + c]
return sum
}
let least = cover(best)
for (let i = 0; i < 4; i++) {
const y = rand(y0, y1)
const c = cover(y)
if (c >= least) continue
best = y
least = c
}
return best
}

// Called after every size change, once the buffers match the new size.
protected layout() {}

Expand Down
30 changes: 29 additions & 1 deletion src/engine.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { appendFileSync } from "node:fs"
import { watchEgg } from "./egg"
import { lerp, smoothstep, type RGB } from "./math"
import { OCTANTS } from "./octants"
import { AGENT_EVENTS, type AgentEvent, type Brightness, type Power, type Scene, type Scrim, type Settings, type Wallpaper } from "./wallpaper"
import { AGENT_EVENTS, type AgentEvent, type Brightness, type Power, type Scene, type Scrim, type Settings, type TextMap, type Wallpaper } from "./wallpaper"

export {
ACTIVITIES,
Expand All @@ -22,6 +22,7 @@ export {
type Scrim,
type Season,
type Settings,
type TextMap,
type Time,
type Wallpaper,
type Weather,
Expand Down Expand Up @@ -73,6 +74,10 @@ const LEVELS: Record<Brightness, ReturnType<typeof level>> = {
const DESATURATE = { day: 0.22, sunset: 0.22, night: 0.04 }
// Seconds a change of wallpaper, activity or time of day crossfades over by default.
export const FADE = 1.2
// The map of where text sits that scenes get (see Scene.textMap): its grid, and how many frames apart it is rebuilt.
const MAP_COLS = 16
const MAP_ROWS = 9
const MAP_EVERY = 15

// Crossfades two equally sized RGBA frames into out.
function mix(a: Uint8Array, b: Uint8Array, out: Uint8Array, t: number) {
Expand Down Expand Up @@ -466,6 +471,10 @@ export function createEngine(
let grid = new Float32Array(0)
let gridBlur = new Float32Array(0)
let light = false
const textMap: TextMap = { cols: MAP_COLS, rows: MAP_ROWS, cover: new Float32Array(MAP_COLS * MAP_ROWS) }
const mapCount = new Float32Array(MAP_COLS * MAP_ROWS)
let mapped = false
let mapFresh = false

// Box-filters the scene down to the 2x4-per-cell octant grid, then applies an unsharp mask.
const buildGrid = (px: Uint8Array, PW: number, PH: number, GW: number, GH: number) => {
Expand Down Expand Up @@ -727,6 +736,11 @@ export function createEngine(
// Blending in the previous frame fades at the same speed per second whatever the frame rate.
const persistence = interval > 1000 / FPS ? PERSISTENCE ** (interval / (1000 / FPS)) : PERSISTENCE
paint(buf, from ? mixed : scene.pixels, scene.W, scene.H, advanced, pixels, desaturate, shade, persistence, scrim)
if (mapFresh) {
mapFresh = false
scene.textMap?.(textMap)
from?.scene.textMap?.(textMap)
}
if (options.dump && !dumped && frames > 120) {
dumped = true
write(buf, options.dump)
Expand Down Expand Up @@ -816,6 +830,19 @@ export function createEngine(
if (open.length !== cells) open = new Uint8Array(cells)
openW = W
for (let i = 0; i < cells; i++) open[i] = surface[i] && char[i] === SPACE && mask[((i / W) | 0) * 2 * W + (i % W)] < OPEN ? 1 : 0
if (frames % MAP_EVERY === 1) {
const cover = textMap.cover
cover.fill(0)
mapCount.fill(0)
for (let i = 0; i < cells; i++) {
const g = Math.floor((((i / W) | 0) * MAP_ROWS) / H) * MAP_COLS + Math.floor(((i % W) * MAP_COLS) / W)
cover[g] += surface[i] ? Math.min(1, mask[((i / W) | 0) * 2 * W + (i % W)] * look.reach) : 1
mapCount[g]++
}
for (let g = 0; g < cover.length; g++) cover[g] /= Math.max(1, mapCount[g])
mapped = true
mapFresh = true
}

const cursor = renderer.getCursorState()
const cursorX = cursor.visible ? cursor.x - 1 : -1
Expand Down Expand Up @@ -909,6 +936,7 @@ export function createEngine(
const created = next.create(chosen)
if (busy) created.react?.("busy")
if (failed) created.react?.("error")
if (mapped) created.textMap?.(textMap)
return created
}

Expand Down
11 changes: 11 additions & 0 deletions src/wallpaper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ export interface Scene {
react?(event: AgentEvent): void
// Optional reaction to a click on open background, in screen heights: x runs 0..W/H across and y 0..1 down.
poke?(x: number, y: number): void
// Optional: where OpenCode's text sits, sent when the scene starts and about once a second after that, so creatures
// can keep to open ground and visitors can come on where they'll be seen.
textMap?(map: TextMap): void
}

// A coarse grid over the whole screen, row-major: how much each cell is covered by text, its scrim or a panel, from 0
// (open) to 1 (hidden).
export interface TextMap {
readonly cols: number
readonly rows: number
readonly cover: Float32Array
}

export const AGENT_EVENTS = ["busy", "idle", "done", "error"] as const
Expand Down
8 changes: 4 additions & 4 deletions wallpapers/beach.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,9 +305,10 @@ class Beach extends Canvas {
if (season === "summer") for (const [i, [x, y, stripe]] of UMBRELLAS.slice(0, { calm: 2, lively: 4, teeming: 6 }[this.activity]).entries()) this.drawUmbrella(x * A, y, stripe, i % 3 === 2 ? undefined : SKIN[i % SKIN.length])
if (season === "autumn" || season === "winter") this.drawDriftwood(0.56 * A, 0.9)
if (this.activity === "teeming" && season !== "autumn" && season !== "winter") this.drawBeachThings()
// The palms lean in from the edges, their crowns high and to the sides of the text in the middle.
this.palms = [
{ base: [0.07 * A, 1.03], crown: [0.2 * A, 0.36], lean: 1 },
{ base: [0.94 * A, 1.0], crown: [0.85 * A, 0.44], lean: -1 },
{ base: [0.12, 1.03], crown: [0.35, 0.33], lean: 1 },
{ base: [A - 0.1, 1.0], crown: [A - 0.27, 0.39], lean: -1 },
]
this.background = this.hdr.slice()
}
Expand Down Expand Up @@ -534,8 +535,7 @@ class Beach extends Canvas {
if (d.wait > 0) return
d.t = 0
d.dir = Math.random() < 0.5 ? 1 : -1
d.x = rand(0.3 * this.A, 0.7 * this.A)
d.y = rand(0.56, 0.66)
;[d.x, d.y] = this.openSpot(0.2 * this.A, 0.8 * this.A, 0.56, 0.66)
this.splash(d.x, d.y)
return
}
Expand Down
8 changes: 5 additions & 3 deletions wallpapers/city.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,8 @@ class City extends Canvas {
this.beacons = []
this.flickers = []
this.sign = undefined
// Hazy towers on the far side of the city, then the downtown skyline, tallest toward the middle.
// Hazy towers on the far side of the city, then the skyline, tallest in two districts toward the sides where text
// rarely covers them, and lower across the middle.
for (let x = -0.03, i = 0; x < A + 0.03; i++) {
const w = 0.035 + hash(i * 1.7 + 0.3) * 0.05
this.paintTower(this.tower(x, w, 0.06 + hash(i * 3.1 + 0.7) ** 1.5 * 0.17, i + 500, true))
Expand All @@ -344,8 +345,9 @@ class City extends Canvas {
const near: Tower[] = []
for (let x = -0.02, i = 0; x < A + 0.02; i++) {
const w = 0.05 + hash(i * 2.3 + 0.1) * 0.055
const downtown = 0.13 * Math.exp(-(((x + w / 2 - 0.55 * A) / 0.4) ** 2))
near.push(this.tower(x, w, 0.08 + hash(i * 4.7 + 0.2) ** 1.6 * 0.23 + downtown, i, false))
const c = x + w / 2
const downtown = 0.16 * Math.max(Math.exp(-(((c - 0.36) / 0.26) ** 2)), Math.exp(-(((c - A + 0.36) / 0.26) ** 2)))
near.push(this.tower(x, w, 0.08 + hash(i * 4.7 + 0.2) ** 1.6 * (0.16 + downtown * 0.45) + downtown, i, false))
x += w + (hash(i * 6.1) - 0.35) * 0.03
}
// The tallest two get a spire and an antenna.
Expand Down
Loading
Loading