diff --git a/dev/mock.ts b/dev/mock.ts index bd72d79..5d4128d 100644 --- a/dev/mock.ts +++ b/dev/mock.ts @@ -42,6 +42,14 @@ export function mockScreen(options: { kitty?: boolean; W?: number; H?: number } } post?.(buf) }, + // Same pass, but the caller paints every cell itself, so a test can hand the engine a full UI (a popup) with its + // own per-cell colors and see what the engine gives back. + ui(paint: (i: number, x: number, y: number, buffers: typeof buffers) => void) { + const buf = { width: w, height: h, buffers } + layer?.(buf) + for (let i = 0; i < w * h; i++) paint(i, i % w, (i / w) | 0, buffers) + post?.(buf) + }, } } diff --git a/dev/panels.ts b/dev/panels.ts new file mode 100644 index 0000000..bb269ff --- /dev/null +++ b/dev/panels.ts @@ -0,0 +1,70 @@ +// Checks that the scene keeps out of a panel the UI draws over the page: with a dialog open, no cell of the dialog +// window may get scene colors or glyphs, on any frame, including the surface recount every 10 frames. +// bun dev/panels.ts [wallpaper] +import { seed } from "./seed" +import { H, W, mockScreen, session } from "./mock" +import { wallpapers } from "./wallpapers" + +const id = process.argv[2] ?? "ocean" +seed(1) +const screen = mockScreen() +screen.engine.start(wallpapers.find((w) => w.id === id)!, { activity: "calm", time: "day" }) + +// A dark theme's own colors, all near-neutral like the ones isSurface accepts: the page, the dialog window over it, and +// the dialog's dimmed backdrop around the window. +const PAGE = [6, 35, 29] +const DIALOG = [10, 45, 38] +const SCRIM = [3, 17, 15] +const win = { x0: Math.floor(W / 2) - 30, x1: Math.floor(W / 2) + 30, y0: Math.floor(H / 4), y1: H - Math.floor(H / 4) } +const swin = { x0: win.x0 - 4, x1: win.x1 + 4, y0: win.y0 - 2, y1: win.y1 + 2 } +const OPEN = 40 +const RUN = 40 +const cells = W * H +const inWindow = (i: number) => { + const x = i % W + const y = (i / W) | 0 + return x >= win.x0 && x < win.x1 && y >= win.y0 && y < win.y1 +} + +type Snap = { char: Uint32Array; bg: Uint16Array } +const snaps: Snap[] = [] +for (let f = 0; f < OPEN + RUN; f++) { + if (f >= OPEN) { + screen.ui((i, x, y, buffers) => { + const inside = x >= win.x0 && x < win.x1 && y >= win.y0 && y < win.y1 + const scrim = x >= swin.x0 && x < swin.x1 && y >= swin.y0 && y < swin.y1 + buffers.char[i] = 32 + buffers.bg.set([...(inside ? DIALOG : scrim ? SCRIM : PAGE), 255], i * 4) + buffers.fg.set([...PAGE, 255], i * 4) + }) + } else screen.frame(session(W, H)) + if (f >= OPEN) snaps.push({ char: new Uint32Array(screen.buffers.char), bg: new Uint16Array(screen.buffers.bg) }) +} +screen.engine.stop() + +let repainted = 0 +let glyphs = 0 +let recounted = 0 +for (let s = 1; s < snaps.length; s++) { + const a = snaps[s - 1] + const b = snaps[s] + let frameRepaint = 0 + for (let i = 0; i < cells; i++) { + if (!inWindow(i)) continue + let diff = a.char[i] !== b.char[i] + for (let c = 0; c < 3; c++) if (a.bg[i * 4 + c] !== b.bg[i * 4 + c]) diff = true + if (diff) frameRepaint++ + } + repainted += frameRepaint + if ((OPEN + s) % 10 === 0) recounted += frameRepaint +} +const last = snaps[snaps.length - 1] +for (let i = 0; i < cells; i++) if (inWindow(i) && last.char[i] !== 32) glyphs++ + +const size = (win.x1 - win.x0) * (win.y1 - win.y0) +console.log(`dialog window ${size} cells: ${glyphs} carry a scene glyph, ${repainted} cell repaints over the run (${recounted} of them on surface recounts)`) +if (glyphs || repainted) { + console.log(`FAIL: the scene paints into the dialog window`) + process.exit(1) +} +console.log("ok") diff --git a/src/engine.tsx b/src/engine.tsx index 7468470..b20863c 100644 --- a/src/engine.tsx +++ b/src/engine.tsx @@ -742,6 +742,10 @@ export function createEngine( dump?: string // Writes the first frame past the middle of a crossfade, likewise. dumpFade?: string + // The theme's own page background: the color the UI paints where nothing covers it. Passed in rather than guessed, + // because the most common color in a frame is a widget's surface whenever a screenful of dialogs, messages or code + // covers the page, and painting the scene there is what makes those widgets flicker. + background?: RGB layer?: (renderer: Context["renderer"], draw: (buffer: OptimizedBuffer) => void) => { dispose(): void } } = {}, ): Engine { @@ -767,6 +771,9 @@ export function createEngine( let dumped = false let fadeDumped = false let base = -2 + // The theme's page background, when the caller knows it: the scene paints only where the UI used it, so a dialog, + // a menu, a message or a code block keeps its own surface. -2 means "not known", and the most common color is used. + const pageBase = options.background ? ((options.background[0] & 255) << 16) | ((options.background[1] & 255) << 8) | (options.background[2] & 255) : -2 let mask = new Float32Array(0) const quad = new Float32Array(24) const order = new Uint8Array(8) @@ -1112,13 +1119,17 @@ export function createEngine( const k = key(bg, i * 4) counts.set(k, (counts.get(k) ?? 0) + 1) } - base = -2 - let best = 0 - for (const [k, n] of counts) if (n > best) ((best = n), (base = k)) + const known = pageBase !== -2 + base = known ? pageBase : -2 + if (!known) { + let best = 0 + for (const [k, n] of counts) if (n > best) ((best = n), (base = k)) + } surfaces.clear() tints.clear() surfaces.add(base) - for (const [k, n] of counts) if (k !== base && n > cells * 0.002 && isSurface(k, light)) surfaces.add(k) + // When the page color is known, every other surface is a widget's, not the page: none of them shows the scene. + if (!known) for (const [k, n] of counts) if (k !== base && n > cells * 0.002 && isSurface(k, light)) surfaces.add(k) } const look = LEVELS[settings!.brightness ?? "normal"] const curve = light ? look.light : look.dark @@ -1139,9 +1150,12 @@ export function createEngine( for (let i = 0; i < cells; i++) { const k = key(bg, i * 4) const tint = tintOf(k) - const on = tint !== NO_TINT + // The scene shows only where the UI painted the page's own background. A cell whose background is some other + // color - a dialog, a menu, a message or tool block, the prompt - belongs to the UI, and tinting it or fitting + // scene glyphs into it makes the wallpaper show through that widget and flicker as the scene moves. + const on = tint !== NO_TINT && k === base surface[i] = on ? 1 : 0 - cellTint[i] = tint + cellTint[i] = on ? tint : NO_TINT // 1 for text, 2 for a blank cell of a panel (a surface other than the base, such as a dialog), else 0. const shape = !on ? 0 : char[i] !== SPACE && !(isBlock(char[i]) && tintOf(fgKey(fg, i * 4)) !== NO_TINT) ? 1 : k !== base && tint === TINT_ZERO ? 2 : 0 if (shapes[i] !== shape) ((shapes[i] = shape), (reshaped = true)) diff --git a/tui.tsx b/tui.tsx index ab08bf6..46f004f 100644 --- a/tui.tsx +++ b/tui.tsx @@ -16,7 +16,14 @@ export default Plugin.define({ if (external.skipped.length) context.ui.toast.show({ message: `Skipped wallpapers:\n${external.skipped.join("\n")}`, variant: "error" }) const wallpapers = [...WALLPAPERS, ...external.wallpapers] const collections = [...new Set(wallpapers.map(collectionOf))] - const engine = createEngine(context, { dump: process.env.WALLPAPER_DUMP, dumpFade: process.env.WALLPAPER_DUMP_FADE }) + // The theme's own page color: what the UI paints where nothing covers it, so the scene can leave every other + // surface - a dialog, a menu, a message or a code block - to the UI. + const page = context.theme.background.base + const engine = createEngine(context, { + dump: process.env.WALLPAPER_DUMP, + dumpFade: process.env.WALLPAPER_DUMP_FADE, + background: page ? [page.r, page.g, page.b] : undefined, + }) // Environment overrides for development; they win over the stored choice. const pinned = process.env.WALLPAPER const pinnedActivity = process.env.WALLPAPER_ACTIVITY