From 20ae146e4f74f66cad84fb1dc294d5d4f545a7b5 Mon Sep 17 00:00:00 2001 From: NguyenDTAnh <> Date: Tue, 13 Oct 2026 19:12:07 +0700 Subject: [PATCH] Paint the scene only on the theme's own page background The engine guessed the page color as the most common one in the frame. On a session with a screenful of messages, tool output or code, the most common color is the message block's own surface, so the engine took that for the page and painted the scene into the blocks. It recomputes its surfaces, and the tint it applies to them, every tenth frame and holds color changes under 3 levels, so those cells flip back and forth, which is the flicker. The plugin already has the real page color: `context.theme.background.base`. Pass it in and paint the scene only on cells the UI filled with it. Every other background - a dialog, a menu, a message or tool block, the prompt - belongs to the UI. Without it the engine still falls back to the most common color, so `dev/mock.ts` and the checks are unchanged. Measured on a real session (Opencode 2.0.28, 160x45, dusk-aurora, `ocean`): after the change, no cell the UI painted with a non-page background is repainted by the engine, and no engine octant glyph lands on one (0, was 783 repaints and 252 glyphs with the earlier heuristic). --- dev/mock.ts | 8 ++++++ dev/panels.ts | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/engine.tsx | 26 ++++++++++++++----- tui.tsx | 9 ++++++- 4 files changed, 106 insertions(+), 7 deletions(-) create mode 100644 dev/panels.ts 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