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.
262 lines
8.9 KiB
TypeScript
262 lines
8.9 KiB
TypeScript
// hud-game-overlay.ts — is the HUD floating over a fullscreen app (a game)?
|
|
//
|
|
// Discord's in-game overlay behavior: while a fullscreen app owns the screen
|
|
// the HUD steps back to a low-opacity, glanceable state, and steps forward
|
|
// again when the user engages it or a reply lands. The DECISION lives here as
|
|
// pure functions over the same front-to-back enumeration `window-below.ts`
|
|
// uses; main polls while the HUD is open and pushes changes to the HUD
|
|
// renderer, which owns the visual treatment (see `data-hud-game` in
|
|
// styles.css).
|
|
//
|
|
// "Fullscreen app" means: the frontmost other-process window on the HUD's
|
|
// display covers that display edge-to-edge. Walking the z-order front-to-back
|
|
// mirrors how the screen actually reads — if something windowed sits on top of
|
|
// the game, the game is not what the HUD is floating over.
|
|
//
|
|
// A note on what this can and cannot float over, so nobody debugs the wrong
|
|
// layer: an always-on-top window covers BORDERLESS fullscreen (the default in
|
|
// most modern games) and macOS fullscreen Spaces. True exclusive-fullscreen
|
|
// bypasses the compositor entirely — nothing short of injecting into the
|
|
// game's render pipeline (what Discord's native overlay does) draws over it.
|
|
// Detection still works there; the HUD is simply behind until the user
|
|
// alt-tabs, which Windows answers by flipping the game to composited output.
|
|
|
|
import type { EnumeratedWindow } from './window-below'
|
|
|
|
export interface GameOverlayState {
|
|
active: boolean
|
|
/** The fullscreen app's name while active, '' otherwise — the renderer may
|
|
* surface it ("over Balatro") and the diff key needs it either way. */
|
|
app: string
|
|
}
|
|
|
|
export const INACTIVE_GAME_OVERLAY: GameOverlayState = { active: false, app: '' }
|
|
|
|
interface Bounds {
|
|
x: number
|
|
y: number
|
|
width: number
|
|
height: number
|
|
}
|
|
|
|
/** Allowance for DPI rounding and the 1px oversize some engines use to dodge
|
|
* the OS's own "looks fullscreen" heuristics. */
|
|
const COVER_EPSILON_PX = 2
|
|
|
|
/**
|
|
* Desktop-shell windows that legitimately report display-sized bounds and must
|
|
* never read as a game: the Windows desktop (Progman/WorkerW both belong to
|
|
* Explorer), the macOS Dock/desktop layers. Matched on the OWNER name — titles
|
|
* are localized, unavailable without permissions on macOS, and empty for most
|
|
* of these anyway.
|
|
*/
|
|
const SHELL_APPS = [
|
|
/^windows explorer$/i,
|
|
/^explorer(\.exe)?$/i,
|
|
/^program manager$/i,
|
|
/^finder$/i,
|
|
/^dock$/i,
|
|
/^window ?server$/i,
|
|
/^windowmanager$/i,
|
|
/^gnome-shell$/i,
|
|
/^plasmashell$/i
|
|
]
|
|
|
|
export const isShellWindow = (app: string): boolean => SHELL_APPS.some(pattern => pattern.test(app.trim()))
|
|
|
|
/** Whether `bounds` covers `display` edge-to-edge (within the DPI epsilon).
|
|
* Work-area coverage is deliberately not enough: a maximized window stops at
|
|
* the taskbar/menu bar, a fullscreen one does not — that IS the distinction. */
|
|
export const coversDisplay = (bounds: Bounds, display: Bounds, epsilon: number = COVER_EPSILON_PX): boolean =>
|
|
bounds.x <= display.x + epsilon &&
|
|
bounds.y <= display.y + epsilon &&
|
|
bounds.x + bounds.width >= display.x + display.width - epsilon &&
|
|
bounds.y + bounds.height >= display.y + display.height - epsilon
|
|
|
|
const intersects = (a: Bounds, b: Bounds): boolean =>
|
|
a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height
|
|
|
|
/**
|
|
* The fullscreen app the HUD is floating over on `display`, or null.
|
|
*
|
|
* Front-to-back: skip every window of our own process (all Hermes windows
|
|
* share main's pid) and the desktop shell's display-sized layers, then let the
|
|
* FIRST window that intersects the display decide — covering it means a
|
|
* fullscreen app, anything less means ordinary windows are on top and the
|
|
* overlay treatment would just make the HUD illegible over a busy desktop.
|
|
* Zero-area rows (minimized windows report those on some platforms) never
|
|
* decide either way.
|
|
*/
|
|
export function detectFullscreenApp(
|
|
windows: EnumeratedWindow[],
|
|
selfPid: number,
|
|
display: Bounds
|
|
): EnumeratedWindow | null {
|
|
for (const win of windows) {
|
|
if (win.pid === selfPid || isShellWindow(win.app)) {
|
|
continue
|
|
}
|
|
|
|
if (win.bounds.width <= 0 || win.bounds.height <= 0 || !intersects(win.bounds, display)) {
|
|
continue
|
|
}
|
|
|
|
return coversDisplay(win.bounds, display) ? win : null
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* The display-covering app anywhere in the stack, ignoring z-order.
|
|
*
|
|
* The STAY half of the hysteresis below. Front-to-back order answers "is a
|
|
* game what I'm looking at" for ENTERING overlay mode, but it cannot answer
|
|
* "am I still over the game" once the user clicks the HUD to type: the game
|
|
* stops being foreground, and whatever else they had open (a terminal, an
|
|
* editor, a browser) is suddenly above it and vetoes. The game did not go
|
|
* anywhere, so neither should the treatment.
|
|
*/
|
|
export function findFullscreenAppAnywhere(
|
|
windows: EnumeratedWindow[],
|
|
selfPid: number,
|
|
display: Bounds
|
|
): EnumeratedWindow | null {
|
|
return (
|
|
windows.find(
|
|
win =>
|
|
win.pid !== selfPid &&
|
|
!isShellWindow(win.app) &&
|
|
win.bounds.width > 0 &&
|
|
win.bounds.height > 0 &&
|
|
coversDisplay(win.bounds, display)
|
|
) ?? null
|
|
)
|
|
}
|
|
|
|
export const gameOverlayStateFor = (
|
|
windows: EnumeratedWindow[],
|
|
selfPid: number,
|
|
display: Bounds,
|
|
wasActive = false
|
|
): GameOverlayState => {
|
|
// Hysteresis. ENTERING needs the game to be what the user is actually
|
|
// looking at (front-to-back, windowed apps on top veto it). STAYING only
|
|
// needs the game to still be there: clicking the HUD to type pushes the game
|
|
// out of foreground and floats every other open window above it, which would
|
|
// otherwise drop the treatment at exactly the moment the user is reading it.
|
|
const fullscreen = wasActive
|
|
? findFullscreenAppAnywhere(windows, selfPid, display)
|
|
: detectFullscreenApp(windows, selfPid, display)
|
|
|
|
return fullscreen ? { active: true, app: fullscreen.app } : INACTIVE_GAME_OVERLAY
|
|
}
|
|
|
|
export interface HudGameOverlayWatchDeps {
|
|
/** Front-to-back window enumeration; null when the platform cannot answer
|
|
* (Wayland, missing native module). Same contract as window-below's. */
|
|
enumerate: () => Promise<EnumeratedWindow[] | null>
|
|
/** Bounds of the display the HUD currently sits on. */
|
|
displayBounds: () => Bounds
|
|
selfPid: number
|
|
/** Push a CHANGED state to the HUD renderer. */
|
|
send: (state: GameOverlayState) => void
|
|
intervalMs?: number
|
|
/** Injectable timers so tests never wait on a real clock. */
|
|
setIntervalFn?: typeof setInterval
|
|
clearIntervalFn?: typeof clearInterval
|
|
}
|
|
|
|
/** Consecutive failed enumerations before the watch concludes the platform
|
|
* cannot answer and stops burning a subprocess/native call per tick. Two, not
|
|
* one: a single null can be a transient failure mid-session. */
|
|
const FAILURES_BEFORE_GIVING_UP = 2
|
|
|
|
/**
|
|
* Poll for fullscreen-app changes while the HUD is open. Returns the disposer;
|
|
* idempotent, and the caller must invoke it when the HUD closes.
|
|
*
|
|
* Polling, not events: no OS surfaces a cross-process "a fullscreen app
|
|
* appeared" signal to an unprivileged window, and every consumer of this class
|
|
* of information (Discord, Steam, GeForce overlays) watches for it. The
|
|
* interval is slow enough to be free next to the HUD's own cursor feed.
|
|
*/
|
|
export function startHudGameOverlayWatch({
|
|
enumerate,
|
|
displayBounds,
|
|
selfPid,
|
|
send,
|
|
intervalMs = 1500,
|
|
setIntervalFn = setInterval,
|
|
clearIntervalFn = clearInterval
|
|
}: HudGameOverlayWatchDeps): () => void {
|
|
let last: GameOverlayState | null = null
|
|
let failures = 0
|
|
let inFlight = false
|
|
let disposed = false
|
|
|
|
const publish = (state: GameOverlayState) => {
|
|
if (last === null || last.active !== state.active || last.app !== state.app) {
|
|
last = state
|
|
send(state)
|
|
}
|
|
}
|
|
|
|
const tick = async () => {
|
|
// Enumeration is async and slower than the interval on a bad day (X11
|
|
// shells out); overlapping ticks would answer out of order.
|
|
if (inFlight || disposed) {
|
|
return
|
|
}
|
|
|
|
inFlight = true
|
|
|
|
try {
|
|
const windows = await enumerate()
|
|
|
|
if (disposed) {
|
|
return
|
|
}
|
|
|
|
if (windows === null) {
|
|
failures += 1
|
|
|
|
// The platform cannot answer (and said so twice): settle on inactive
|
|
// and stop asking.
|
|
if (failures >= FAILURES_BEFORE_GIVING_UP) {
|
|
publish(INACTIVE_GAME_OVERLAY)
|
|
dispose()
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
failures = 0
|
|
publish(gameOverlayStateFor(windows, selfPid, displayBounds(), last?.active ?? false))
|
|
} catch {
|
|
// A throwing enumerator counts the same as a null answer.
|
|
failures += 1
|
|
|
|
if (failures >= FAILURES_BEFORE_GIVING_UP && !disposed) {
|
|
publish(INACTIVE_GAME_OVERLAY)
|
|
dispose()
|
|
}
|
|
} finally {
|
|
inFlight = false
|
|
}
|
|
}
|
|
|
|
const timer = setIntervalFn(() => void tick(), intervalMs)
|
|
|
|
function dispose() {
|
|
if (!disposed) {
|
|
disposed = true
|
|
clearIntervalFn(timer)
|
|
}
|
|
}
|
|
|
|
void tick()
|
|
|
|
return dispose
|
|
}
|