3c19644419
While a fullscreen app owns the screen, the HUD becomes an in-game chat frame: the idle bar steps back to a glanceable opacity, and the transcript is held open for as long as the game is there rather than fading on a timer — you look back at a chat log during a lull, not while the text happens to be fresh. Detection is a pure pass over the same front-to-back window enumeration read_window_below uses (electron/hud-game-overlay.ts); main polls it while the HUD is open and pushes changes to the renderer, which owns the treatment. Two details the enumeration forced: - Hysteresis. Entering needs the game to be what the user is actually looking at, so a windowed app on top vetoes it. Staying only needs the game to still exist: clicking the HUD to type de-foregrounds the game and floats every other open window above it, which otherwise dropped overlay mode at the moment the user engaged with it. - The last state is replayed on did-finish-load. The watch pushes only on change and its first tick fires at window creation, before the renderer has mounted its listener, so a HUD opened over an already-fullscreen game consumed its only message and sat at 'no game' forever. The band itself is reworked for living over someone else's window: - Light-on-dark unconditionally. The theme's near-black body ink is unreadable over a dark game, and every attempt to gate the light ink on some condition — focus, then the game flag — produced a state where it evaluated false and the words went black on black. The sheet is a dark scrim in every theme so white is always right; anything that paints its own light surface (a clarify question, an approval card, a code block, a form control) opts back into theme ink by re-pointing the ink variable, matched on the fill it paints rather than the feature it belongs to. - Your own lines are gold rather than bubbled. With no card the log otherwise reads as one voice; blue and purple are what most game UIs use for their own text, so they disappear into the background. - The scrollback ramps out at the top instead of being cut off, masked on the scroller (the band is a static box — its rows overflow the thread viewport nested inside it, so a mask on the band ramps over empty space). - The sheet is inset under the bar, so its square top corners no longer poke out past the bar's rounded ones.
308 lines
11 KiB
TypeScript
308 lines
11 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
|
|
import { test } from 'vitest'
|
|
|
|
import {
|
|
coversDisplay,
|
|
detectFullscreenApp,
|
|
findFullscreenAppAnywhere,
|
|
gameOverlayStateFor,
|
|
INACTIVE_GAME_OVERLAY,
|
|
isShellWindow,
|
|
startHudGameOverlayWatch
|
|
} from './hud-game-overlay'
|
|
import type { GameOverlayState } from './hud-game-overlay'
|
|
import type { EnumeratedWindow } from './window-below'
|
|
|
|
const DISPLAY = { x: 0, y: 0, width: 2560, height: 1440 }
|
|
const SELF_PID = 4242
|
|
|
|
const win = (over: Partial<EnumeratedWindow>): EnumeratedWindow => ({
|
|
app: 'App',
|
|
bounds: { x: 0, y: 0, width: 100, height: 100 },
|
|
id: 1,
|
|
pid: 1000,
|
|
title: '',
|
|
...over
|
|
})
|
|
|
|
const fullscreenGame = win({ app: 'Balatro', bounds: { ...DISPLAY }, id: 7, pid: 2000 })
|
|
const hudWindow = win({ app: 'Hermes', bounds: { x: 970, y: 1048, width: 620, height: 320 }, id: 2, pid: SELF_PID })
|
|
const desktopShell = win({ app: 'Windows Explorer', bounds: { ...DISPLAY }, id: 3, pid: 900 })
|
|
|
|
// ─── coversDisplay ───────────────────────────────────────────────────────
|
|
|
|
test('display-sized bounds cover the display', () => {
|
|
assert.equal(coversDisplay({ ...DISPLAY }, DISPLAY), true)
|
|
})
|
|
|
|
test('DPI rounding and the 1px-oversize trick stay within the epsilon', () => {
|
|
assert.equal(coversDisplay({ x: -1, y: -1, width: 2562, height: 1442 }, DISPLAY), true)
|
|
assert.equal(coversDisplay({ x: 1, y: 1, width: 2558, height: 1438 }, DISPLAY), true)
|
|
})
|
|
|
|
test('a maximized window stopping at the taskbar does not cover', () => {
|
|
assert.equal(coversDisplay({ x: 0, y: 0, width: 2560, height: 1392 }, DISPLAY), false)
|
|
})
|
|
|
|
test('a fullscreen window on ANOTHER display does not cover this one', () => {
|
|
assert.equal(coversDisplay({ x: 2560, y: 0, width: 2560, height: 1440 }, DISPLAY), false)
|
|
})
|
|
|
|
// ─── isShellWindow ───────────────────────────────────────────────────────
|
|
|
|
test('desktop shells are recognized, real apps are not', () => {
|
|
for (const app of ['Windows Explorer', 'explorer.exe', 'Program Manager', 'Finder', 'Dock', 'gnome-shell']) {
|
|
assert.equal(isShellWindow(app), true, app)
|
|
}
|
|
|
|
for (const app of ['Balatro', 'Explorer of the Deep', 'Firefox', '']) {
|
|
assert.equal(isShellWindow(app), false, app)
|
|
}
|
|
})
|
|
|
|
// ─── detectFullscreenApp ─────────────────────────────────────────────────
|
|
|
|
test('fullscreen game under the HUD is detected', () => {
|
|
const found = detectFullscreenApp([hudWindow, fullscreenGame, desktopShell], SELF_PID, DISPLAY)
|
|
|
|
assert.equal(found?.app, 'Balatro')
|
|
})
|
|
|
|
test('own windows never count, wherever they sit in the z-order', () => {
|
|
const selfFullscreen = win({ app: 'Hermes', bounds: { ...DISPLAY }, pid: SELF_PID })
|
|
|
|
assert.equal(detectFullscreenApp([selfFullscreen, desktopShell], SELF_PID, DISPLAY), null)
|
|
})
|
|
|
|
test('the desktop shell reporting display bounds is not a game', () => {
|
|
assert.equal(detectFullscreenApp([hudWindow, desktopShell], SELF_PID, DISPLAY), null)
|
|
})
|
|
|
|
test('a windowed app in FRONT of the game vetoes the overlay', () => {
|
|
const editor = win({ app: 'VS Code', bounds: { x: 200, y: 100, width: 1400, height: 900 }, id: 9, pid: 3000 })
|
|
|
|
assert.equal(detectFullscreenApp([hudWindow, editor, fullscreenGame], SELF_PID, DISPLAY), null)
|
|
})
|
|
|
|
test('zero-area rows (minimized) neither decide nor veto', () => {
|
|
const minimized = win({ app: 'Spotify', bounds: { x: 0, y: 0, width: 0, height: 0 }, id: 11, pid: 3100 })
|
|
|
|
assert.equal(detectFullscreenApp([minimized, fullscreenGame], SELF_PID, DISPLAY)?.app, 'Balatro')
|
|
})
|
|
|
|
test('a window intersecting only another display does not veto', () => {
|
|
const otherDisplayWin = win({ app: 'Slack', bounds: { x: 3000, y: 0, width: 800, height: 600 }, id: 12, pid: 3200 })
|
|
|
|
assert.equal(detectFullscreenApp([otherDisplayWin, fullscreenGame], SELF_PID, DISPLAY)?.app, 'Balatro')
|
|
})
|
|
|
|
test('empty desktop: nothing to detect', () => {
|
|
assert.equal(detectFullscreenApp([hudWindow], SELF_PID, DISPLAY), null)
|
|
})
|
|
|
|
test('gameOverlayStateFor maps detection to the pushed state shape', () => {
|
|
assert.deepEqual(gameOverlayStateFor([fullscreenGame], SELF_PID, DISPLAY), { active: true, app: 'Balatro' })
|
|
assert.deepEqual(gameOverlayStateFor([hudWindow], SELF_PID, DISPLAY), INACTIVE_GAME_OVERLAY)
|
|
})
|
|
|
|
// ─── hysteresis: enter strictly, stay while the game is still there ──────
|
|
|
|
test('a windowed app on top vetoes ENTERING but not STAYING', () => {
|
|
// Clicking the HUD to type de-foregrounds the game and floats every other
|
|
// open window above it. Entering must respect that veto; staying must not,
|
|
// or the treatment drops the moment the user engages with it.
|
|
const editor = win({ app: 'VS Code', bounds: { x: 200, y: 100, width: 1400, height: 900 }, id: 9, pid: 3000 })
|
|
const stack = [editor, fullscreenGame]
|
|
|
|
assert.deepEqual(gameOverlayStateFor(stack, SELF_PID, DISPLAY, false), INACTIVE_GAME_OVERLAY)
|
|
assert.deepEqual(gameOverlayStateFor(stack, SELF_PID, DISPLAY, true), { active: true, app: 'Balatro' })
|
|
})
|
|
|
|
test('the game actually closing ends overlay mode even when it was active', () => {
|
|
assert.deepEqual(gameOverlayStateFor([hudWindow, desktopShell], SELF_PID, DISPLAY, true), INACTIVE_GAME_OVERLAY)
|
|
})
|
|
|
|
test('findFullscreenAppAnywhere ignores z-order but keeps every other guard', () => {
|
|
const selfFullscreen = win({ app: 'Hermes', bounds: { ...DISPLAY }, pid: SELF_PID })
|
|
|
|
assert.equal(findFullscreenAppAnywhere([selfFullscreen], SELF_PID, DISPLAY), null)
|
|
assert.equal(findFullscreenAppAnywhere([desktopShell], SELF_PID, DISPLAY), null)
|
|
assert.equal(findFullscreenAppAnywhere([hudWindow, fullscreenGame], SELF_PID, DISPLAY)?.app, 'Balatro')
|
|
})
|
|
|
|
// ─── startHudGameOverlayWatch ────────────────────────────────────────────
|
|
|
|
interface FakeClock {
|
|
fire: () => Promise<void>
|
|
cleared: () => boolean
|
|
setIntervalFn: typeof setInterval
|
|
clearIntervalFn: typeof clearInterval
|
|
}
|
|
|
|
const fakeClock = (): FakeClock => {
|
|
let handler: (() => void) | null = null
|
|
let cleared = false
|
|
|
|
return {
|
|
fire: async () => {
|
|
handler?.()
|
|
// The tick is async; let its microtasks drain.
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
},
|
|
cleared: () => cleared,
|
|
setIntervalFn: ((fn: () => void) => {
|
|
handler = fn
|
|
|
|
return 1 as unknown as ReturnType<typeof setInterval>
|
|
}) as typeof setInterval,
|
|
clearIntervalFn: (() => {
|
|
cleared = true
|
|
}) as typeof clearInterval
|
|
}
|
|
}
|
|
|
|
const flush = async () => {
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
}
|
|
|
|
test('watch pushes on change only, never on a repeat answer', async () => {
|
|
const clock = fakeClock()
|
|
const pushed: Array<{ active: boolean; app: string }> = []
|
|
let windows: EnumeratedWindow[] = [hudWindow]
|
|
|
|
const dispose = startHudGameOverlayWatch({
|
|
enumerate: () => Promise.resolve(windows),
|
|
displayBounds: () => DISPLAY,
|
|
selfPid: SELF_PID,
|
|
send: state => pushed.push(state),
|
|
setIntervalFn: clock.setIntervalFn,
|
|
clearIntervalFn: clock.clearIntervalFn
|
|
})
|
|
|
|
await flush() // the immediate first tick
|
|
assert.deepEqual(pushed, [{ active: false, app: '' }])
|
|
|
|
await clock.fire() // same answer — no push
|
|
assert.equal(pushed.length, 1)
|
|
|
|
windows = [fullscreenGame]
|
|
await clock.fire()
|
|
assert.deepEqual(pushed[1], { active: true, app: 'Balatro' })
|
|
|
|
windows = [hudWindow]
|
|
await clock.fire()
|
|
assert.deepEqual(pushed[2], { active: false, app: '' })
|
|
|
|
dispose()
|
|
assert.equal(clock.cleared(), true)
|
|
})
|
|
|
|
test('watch settles inactive and stops after repeated enumeration failures', async () => {
|
|
const clock = fakeClock()
|
|
const pushed: Array<{ active: boolean }> = []
|
|
|
|
startHudGameOverlayWatch({
|
|
enumerate: () => Promise.resolve(null),
|
|
displayBounds: () => DISPLAY,
|
|
selfPid: SELF_PID,
|
|
send: state => pushed.push(state),
|
|
setIntervalFn: clock.setIntervalFn,
|
|
clearIntervalFn: clock.clearIntervalFn
|
|
})
|
|
|
|
await flush() // failure 1 — no conclusion yet
|
|
assert.equal(pushed.length, 0)
|
|
assert.equal(clock.cleared(), false)
|
|
|
|
await clock.fire() // failure 2 — settle and stop
|
|
assert.deepEqual(pushed, [{ active: false, app: '' }])
|
|
assert.equal(clock.cleared(), true)
|
|
})
|
|
|
|
test('a single transient failure does not give up the watch', async () => {
|
|
const clock = fakeClock()
|
|
const pushed: Array<{ active: boolean }> = []
|
|
let fail = true
|
|
|
|
startHudGameOverlayWatch({
|
|
enumerate: () => (fail ? Promise.resolve(null) : Promise.resolve([fullscreenGame])),
|
|
displayBounds: () => DISPLAY,
|
|
selfPid: SELF_PID,
|
|
send: state => pushed.push(state),
|
|
setIntervalFn: clock.setIntervalFn,
|
|
clearIntervalFn: clock.clearIntervalFn
|
|
})
|
|
|
|
await flush() // failure 1
|
|
fail = false
|
|
await clock.fire() // recovers
|
|
|
|
assert.deepEqual(pushed, [{ active: true, app: 'Balatro' }])
|
|
assert.equal(clock.cleared(), false)
|
|
|
|
// …and the failure counter reset: one more null is a transient again.
|
|
fail = true
|
|
await clock.fire()
|
|
assert.equal(clock.cleared(), false)
|
|
})
|
|
|
|
test('disposed watch stops pushing even with a tick in flight', async () => {
|
|
const clock = fakeClock()
|
|
const pushed: unknown[] = []
|
|
|
|
let release: (v: EnumeratedWindow[]) => void = () => {}
|
|
|
|
const dispose = startHudGameOverlayWatch({
|
|
enumerate: () =>
|
|
new Promise<EnumeratedWindow[]>(resolve => {
|
|
release = resolve
|
|
}),
|
|
displayBounds: () => DISPLAY,
|
|
selfPid: SELF_PID,
|
|
send: state => pushed.push(state),
|
|
setIntervalFn: clock.setIntervalFn,
|
|
clearIntervalFn: clock.clearIntervalFn
|
|
})
|
|
|
|
dispose()
|
|
release([fullscreenGame])
|
|
await flush()
|
|
|
|
assert.equal(pushed.length, 0)
|
|
})
|
|
|
|
test('the watch carries its own last answer into the next tick', async () => {
|
|
// The hysteresis is only correct if `wasActive` comes from what was last
|
|
// PUBLISHED; a watch that always passed false would re-veto every tick.
|
|
const clock = fakeClock()
|
|
const pushed: GameOverlayState[] = []
|
|
const editor = win({ app: 'VS Code', bounds: { x: 200, y: 100, width: 1400, height: 900 }, id: 9, pid: 3000 })
|
|
let windows: EnumeratedWindow[] = [fullscreenGame]
|
|
|
|
startHudGameOverlayWatch({
|
|
enumerate: () => Promise.resolve(windows),
|
|
displayBounds: () => DISPLAY,
|
|
selfPid: SELF_PID,
|
|
send: state => pushed.push(state),
|
|
setIntervalFn: clock.setIntervalFn,
|
|
clearIntervalFn: clock.clearIntervalFn
|
|
})
|
|
|
|
await flush()
|
|
assert.deepEqual(pushed, [{ active: true, app: 'Balatro' }])
|
|
|
|
// The user clicks the HUD: the editor is now above the game. Still active, so
|
|
// nothing new is published.
|
|
windows = [editor, fullscreenGame]
|
|
await clock.fire()
|
|
assert.equal(pushed.length, 1)
|
|
|
|
// The game closes for real.
|
|
windows = [editor]
|
|
await clock.fire()
|
|
assert.deepEqual(pushed[1], INACTIVE_GAME_OVERLAY)
|
|
})
|