chore: init clean tree
This commit is contained in:
15
src/renderer/assets.ts
Normal file
15
src/renderer/assets.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import path from "path";
|
||||
|
||||
import { loadImage, type Image } from "@napi-rs/canvas";
|
||||
|
||||
const ASSET_ROOT = path.resolve(__dirname, "..", "..", "asset");
|
||||
|
||||
export function resolveAssetPath(relativePath: string): string {
|
||||
return path.join(ASSET_ROOT, relativePath);
|
||||
}
|
||||
|
||||
export async function loadImageAsset(relativePath: string): Promise<Image> {
|
||||
console.debug("[debug] [renderer/assets] loadImageAsset " + relativePath)
|
||||
const assetPath = resolveAssetPath(relativePath);
|
||||
return loadImage(assetPath);
|
||||
}
|
||||
104
src/renderer/cli.ts
Normal file
104
src/renderer/cli.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
export type CliConfig = {
|
||||
rendererId?: string;
|
||||
debugGlobalHud: boolean;
|
||||
debugRendererHud: boolean;
|
||||
crashRecoverySession?: string | true;
|
||||
errorScreenRequested?: boolean;
|
||||
errorScreenMessage?: string;
|
||||
errorScreenTitle?: string;
|
||||
errorScreenHint?: string;
|
||||
debugLogFile?: string;
|
||||
helpRequested: boolean;
|
||||
};
|
||||
|
||||
export function parseCli(argv: string[]): CliConfig {
|
||||
// Bun passes: [bunPath, scriptPath, ...]
|
||||
const args = argv.slice(2);
|
||||
const config: CliConfig = {
|
||||
debugGlobalHud: false,
|
||||
debugRendererHud: false,
|
||||
helpRequested: false
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i] ?? "";
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
config.helpRequested = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--renderer" && args[i + 1] && !args[i + 1]!.startsWith("--")) {
|
||||
config.rendererId = args[i + 1]!;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--renderer=")) {
|
||||
config.rendererId = arg.split("=")[1];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--debug") {
|
||||
config.debugGlobalHud = true;
|
||||
config.debugRendererHud = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--debug-global") {
|
||||
config.debugGlobalHud = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--debug-renderer") {
|
||||
config.debugRendererHud = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--error-screen") {
|
||||
config.errorScreenRequested = true;
|
||||
if (args[i + 1] && !args[i + 1]!.startsWith("--")) {
|
||||
config.errorScreenMessage = args[i + 1]!;
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--error-screen=")) {
|
||||
config.errorScreenRequested = true;
|
||||
config.errorScreenMessage = arg.split("=").slice(1).join("=");
|
||||
continue;
|
||||
}
|
||||
if (arg === "--error-title" && args[i + 1] && !args[i + 1]!.startsWith("--")) {
|
||||
config.errorScreenTitle = args[i + 1]!;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--error-title=")) {
|
||||
config.errorScreenTitle = arg.split("=").slice(1).join("=");
|
||||
continue;
|
||||
}
|
||||
if (arg === "--error-hint" && args[i + 1] && !args[i + 1]!.startsWith("--")) {
|
||||
config.errorScreenHint = args[i + 1]!;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--error-hint=")) {
|
||||
config.errorScreenHint = arg.split("=").slice(1).join("=");
|
||||
continue;
|
||||
}
|
||||
if (arg === "--debug-log-file" && args[i + 1] && !args[i + 1]!.startsWith("--")) {
|
||||
config.debugLogFile = args[i + 1]!;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--debug-log-file=")) {
|
||||
config.debugLogFile = arg.split("=").slice(1).join("=");
|
||||
continue;
|
||||
}
|
||||
if (arg === "--crash-recovery") {
|
||||
const maybeSession = args[i + 1];
|
||||
if (maybeSession && !maybeSession.startsWith("--")) {
|
||||
config.crashRecoverySession = maybeSession;
|
||||
i += 1;
|
||||
} else {
|
||||
config.crashRecoverySession = "Hyprland";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
103
src/renderer/debug-hud.ts
Normal file
103
src/renderer/debug-hud.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { CanvasRenderingContext2D } from "@napi-rs/canvas";
|
||||
|
||||
import type { Layout } from "./layout";
|
||||
|
||||
export type DebugStats = Record<string, string | number | boolean | undefined>;
|
||||
|
||||
export type DebugHudOptions = {
|
||||
showGlobal: boolean;
|
||||
showRenderer: boolean;
|
||||
showCustom?: boolean;
|
||||
};
|
||||
|
||||
export type DebugHudData = {
|
||||
global: DebugStats | string[];
|
||||
renderer: {
|
||||
id: string;
|
||||
label: string;
|
||||
stats: DebugStats;
|
||||
fps: number;
|
||||
};
|
||||
custom?: DebugStats;
|
||||
};
|
||||
|
||||
export type DebugHud = {
|
||||
draw: (ctx: CanvasRenderingContext2D, layout: Layout, data: DebugHudData) => void;
|
||||
};
|
||||
|
||||
export function createDebugHud(options: DebugHudOptions): DebugHud {
|
||||
const padding = 8;
|
||||
const lineHeight = 16;
|
||||
const bg = "rgba(0, 0, 0, 0.65)";
|
||||
const fg = "yellow"; // global
|
||||
const rendererFg = "#ff66cc"; // renderer
|
||||
const customFg = "#00c6ff"; // custom
|
||||
|
||||
const drawBlock = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
title: string,
|
||||
stats: DebugStats | string[],
|
||||
color: string
|
||||
): { width: number; height: number } => {
|
||||
let lines: string[] = [];
|
||||
if (Array.isArray(stats)) {
|
||||
lines = stats
|
||||
} else {
|
||||
const keys = Object.keys(stats);
|
||||
lines = [title, ...keys.map((k) => `${k}: ${String(stats[k])}`)];
|
||||
}
|
||||
ctx.font = "14px \"JetBrains Mono\", monospace";
|
||||
const textWidth = Math.max(...lines.map((l) => ctx.measureText(l).width));
|
||||
const height = lines.length * lineHeight + padding * 2;
|
||||
const width = textWidth + padding * 2;
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(x, y, width, height);
|
||||
ctx.fillStyle = color;
|
||||
ctx.textBaseline = "top";
|
||||
lines.forEach((line, i) => {
|
||||
ctx.fillText(line, x + padding, y + padding + i * lineHeight);
|
||||
});
|
||||
ctx.restore();
|
||||
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
const draw = (ctx: CanvasRenderingContext2D, layout: Layout, data: DebugHudData) => {
|
||||
if (!options.showGlobal && !options.showRenderer && !options.showCustom) return;
|
||||
ctx.save();
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.globalAlpha = 0.9;
|
||||
|
||||
let cursorY = padding;
|
||||
const originX = padding;
|
||||
|
||||
if (options.showGlobal) {
|
||||
const { height } = drawBlock(ctx, originX, cursorY, "Global", data.global, fg);
|
||||
cursorY += height + padding;
|
||||
}
|
||||
|
||||
if (options.showRenderer) {
|
||||
const { height } = drawBlock(
|
||||
ctx,
|
||||
originX,
|
||||
cursorY,
|
||||
`Renderer: ${data.renderer.label}`,
|
||||
{ fps: data.renderer.fps.toFixed(2), ...data.renderer.stats },
|
||||
rendererFg
|
||||
);
|
||||
cursorY += height + padding;
|
||||
}
|
||||
|
||||
if (options.showCustom && data.custom) {
|
||||
drawBlock(ctx, originX, cursorY, "Custom", data.custom, customFg);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
return { draw };
|
||||
}
|
||||
27
src/renderer/fps.ts
Normal file
27
src/renderer/fps.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export type FpsCounter = {
|
||||
tick: (nowMs: number) => void;
|
||||
value: number;
|
||||
};
|
||||
|
||||
export function createFpsCounter(sampleWindowMs = 500): FpsCounter {
|
||||
let lastSampleStart = Date.now();
|
||||
let frameCount = 0;
|
||||
let currentFps = 0;
|
||||
|
||||
const tick = (nowMs: number) => {
|
||||
frameCount += 1;
|
||||
const elapsed = nowMs - lastSampleStart;
|
||||
if (elapsed >= sampleWindowMs) {
|
||||
currentFps = (frameCount * 1000) / elapsed;
|
||||
frameCount = 0;
|
||||
lastSampleStart = nowMs;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
tick,
|
||||
get value() {
|
||||
return currentFps;
|
||||
}
|
||||
};
|
||||
}
|
||||
155
src/renderer/index.ts
Normal file
155
src/renderer/index.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { Events } from "@kmamal/sdl";
|
||||
import {
|
||||
createCanvas,
|
||||
type Canvas,
|
||||
type CanvasRenderingContext2D
|
||||
} from "@napi-rs/canvas";
|
||||
|
||||
import { SDLWindow, type WindowProps } from "./window";
|
||||
|
||||
type RenderFrame = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
size: { width: number; height: number }
|
||||
) => void | Promise<void>;
|
||||
type RendererOptions = WindowProps & { window?: SDLWindow };
|
||||
|
||||
export class Renderer {
|
||||
readonly window: SDLWindow;
|
||||
readonly canvas: Canvas;
|
||||
readonly ctx: CanvasRenderingContext2D;
|
||||
#animation: ReturnType<typeof setInterval> | undefined;
|
||||
#pixelBuffer: Buffer | undefined;
|
||||
#stride = 0;
|
||||
#size: { width: number; height: number };
|
||||
#stop: (() => void) | undefined;
|
||||
|
||||
constructor(options: RendererOptions = {}) {
|
||||
console.debug("[debug] [renderer] new Renderer")
|
||||
const { window: providedWindow, ...windowProps } = options;
|
||||
this.window = providedWindow ?? new SDLWindow(windowProps);
|
||||
|
||||
const { width, height } = this.window.size;
|
||||
this.#size = { width, height };
|
||||
this.canvas = createCanvas(width, height);
|
||||
this.ctx = this.canvas.getContext("2d");
|
||||
this.ctx.imageSmoothingEnabled = false;
|
||||
this.#syncPixelBuffer();
|
||||
}
|
||||
|
||||
get size(): { width: number; height: number } {
|
||||
return this.#size;
|
||||
}
|
||||
|
||||
resize(width: number, height: number): void {
|
||||
this.#size = { width, height };
|
||||
if (this.canvas.width === width && this.canvas.height === height) {
|
||||
return;
|
||||
}
|
||||
this.canvas.width = width;
|
||||
this.canvas.height = height;
|
||||
this.ctx.imageSmoothingEnabled = false;
|
||||
this.#syncPixelBuffer();
|
||||
}
|
||||
|
||||
present(): void {
|
||||
if (!this.#pixelBuffer) {
|
||||
this.#syncPixelBuffer();
|
||||
}
|
||||
this.window.renderFromBuffer(
|
||||
this.canvas.width,
|
||||
this.canvas.height,
|
||||
this.#stride,
|
||||
this.#pixelBuffer!
|
||||
);
|
||||
}
|
||||
|
||||
#syncPixelBuffer(): void {
|
||||
this.#pixelBuffer = this.canvas.data();
|
||||
this.#stride = Math.floor(
|
||||
this.#pixelBuffer.byteLength / Math.max(1, this.canvas.height)
|
||||
);
|
||||
}
|
||||
|
||||
requestStop(): void {
|
||||
this.#stop?.();
|
||||
}
|
||||
|
||||
async run(renderFrame: RenderFrame): Promise<void> {
|
||||
console.debug("[debug] [renderer] starting render")
|
||||
|
||||
const listeners: Array<() => void> = [];
|
||||
let rendering = false;
|
||||
const addListener = <E extends Events.Window.Any["type"]>(
|
||||
event: E,
|
||||
handler: (event: Extract<Events.Window.Any, { type: E }>) => void
|
||||
) => {
|
||||
listeners.push(this.window.on(event, handler));
|
||||
};
|
||||
|
||||
const renderOnce = async () => {
|
||||
await renderFrame(this.ctx, this.size);
|
||||
this.present();
|
||||
};
|
||||
|
||||
await renderOnce();
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let stopped = false;
|
||||
|
||||
const cleanup = () => {
|
||||
if (this.#animation) {
|
||||
clearInterval(this.#animation);
|
||||
this.#animation = undefined;
|
||||
}
|
||||
this.#stop = undefined;
|
||||
listeners.splice(0).forEach((off) => off());
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
cleanup();
|
||||
this.window.destroy();
|
||||
resolve();
|
||||
};
|
||||
this.#stop = stop;
|
||||
|
||||
const tick = () => {
|
||||
if (rendering) return;
|
||||
rendering = true;
|
||||
void renderOnce().finally(() => {
|
||||
rendering = false;
|
||||
});
|
||||
};
|
||||
|
||||
this.#animation = setInterval(tick, 1000 / 60);
|
||||
this.#animation.unref?.();
|
||||
|
||||
addListener("resize", async (event) => {
|
||||
this.resize(event.pixelWidth, event.pixelHeight);
|
||||
tick();
|
||||
});
|
||||
|
||||
addListener("expose", () => {
|
||||
tick();
|
||||
});
|
||||
|
||||
addListener("keyDown", (event) => {
|
||||
if (event.key === "Escape" || event.key === "Q") {
|
||||
stop();
|
||||
}
|
||||
});
|
||||
|
||||
addListener("beforeClose", (event) => {
|
||||
event.prevent();
|
||||
stop();
|
||||
});
|
||||
|
||||
addListener("close", () => stop());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createRenderer(options: RendererOptions = {}): Renderer {
|
||||
return new Renderer(options);
|
||||
}
|
||||
65
src/renderer/layout.ts
Normal file
65
src/renderer/layout.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
export type Layout = {
|
||||
width: number;
|
||||
height: number;
|
||||
viewScale: number;
|
||||
boxWidth: number;
|
||||
boxHeight: number;
|
||||
boxX: number;
|
||||
boxY: number;
|
||||
contentScale: number;
|
||||
drawWidth: number;
|
||||
drawHeight: number;
|
||||
x: number;
|
||||
y: number;
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
};
|
||||
|
||||
export function createLayoutCalculator(options: {
|
||||
baseWidth: number;
|
||||
baseHeight: number;
|
||||
viewWidth: number;
|
||||
viewHeight: number;
|
||||
}): (size: { width: number; height: number }) => Layout {
|
||||
let cachedLayout: Layout | undefined;
|
||||
|
||||
return (size: { width: number; height: number }): Layout => {
|
||||
const { width, height } = size;
|
||||
if (cachedLayout && cachedLayout.width === width && cachedLayout.height === height) {
|
||||
return cachedLayout;
|
||||
}
|
||||
|
||||
const viewScale = Math.min(width / options.viewWidth, height / options.viewHeight);
|
||||
const boxWidth = options.viewWidth * viewScale;
|
||||
const boxHeight = options.viewHeight * viewScale;
|
||||
const boxX = (width - boxWidth) / 2;
|
||||
const boxY = (height - boxHeight) / 2;
|
||||
|
||||
const contentScale = Math.min(boxWidth / options.baseWidth, boxHeight / options.baseHeight);
|
||||
const drawWidth = options.baseWidth * contentScale;
|
||||
const drawHeight = options.baseHeight * contentScale;
|
||||
const x = boxX + (boxWidth - drawWidth) / 2;
|
||||
const y = boxY + (boxHeight - drawHeight) / 2;
|
||||
const centerX = boxX + boxWidth / 2;
|
||||
const centerY = boxY + boxHeight / 2;
|
||||
|
||||
cachedLayout = {
|
||||
width,
|
||||
height,
|
||||
viewScale,
|
||||
boxWidth,
|
||||
boxHeight,
|
||||
boxX,
|
||||
boxY,
|
||||
contentScale,
|
||||
drawWidth,
|
||||
drawHeight,
|
||||
x,
|
||||
y,
|
||||
centerX,
|
||||
centerY
|
||||
};
|
||||
|
||||
return cachedLayout;
|
||||
};
|
||||
}
|
||||
45
src/renderer/lazy-resource.ts
Normal file
45
src/renderer/lazy-resource.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export type LazyResource<T> = {
|
||||
load: () => Promise<T>;
|
||||
unload: () => void;
|
||||
isLoaded: () => boolean;
|
||||
};
|
||||
|
||||
export function createLazyResource<T>(
|
||||
loader: () => Promise<T>,
|
||||
dispose?: (value: T) => void
|
||||
): LazyResource<T> {
|
||||
let cached: T | null = null;
|
||||
let inflight: Promise<T> | null = null;
|
||||
|
||||
const load = async (): Promise<T> => {
|
||||
if (cached) return cached;
|
||||
if (inflight) return inflight;
|
||||
|
||||
inflight = (async () => {
|
||||
const value = await loader();
|
||||
cached = value;
|
||||
inflight = null;
|
||||
return value;
|
||||
})();
|
||||
|
||||
return inflight;
|
||||
};
|
||||
|
||||
const unload = () => {
|
||||
if (cached && dispose) {
|
||||
try {
|
||||
dispose(cached);
|
||||
} catch (error) {
|
||||
console.error("[lazy-resource] failed to dispose resource", error);
|
||||
}
|
||||
}
|
||||
cached = null;
|
||||
inflight = null;
|
||||
};
|
||||
|
||||
return {
|
||||
load,
|
||||
unload,
|
||||
isLoaded: () => cached !== null
|
||||
};
|
||||
}
|
||||
94
src/renderer/video.ts
Normal file
94
src/renderer/video.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { ImageData } from "@napi-rs/canvas";
|
||||
|
||||
import { resolveAssetPath } from "./assets";
|
||||
|
||||
type VideoLoaderOptions = {
|
||||
width: number;
|
||||
height: number;
|
||||
fps?: number;
|
||||
maxFramesInMemory?: number;
|
||||
frameSampleStep?: number;
|
||||
};
|
||||
|
||||
export type VideoFrameSequence = {
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
durationMs: number;
|
||||
frames: ImageData[];
|
||||
};
|
||||
|
||||
export async function loadVideoFrames(
|
||||
relativePath: string,
|
||||
options: VideoLoaderOptions
|
||||
): Promise<VideoFrameSequence> {
|
||||
const targetFps = options.fps ?? 30;
|
||||
const assetPath = resolveAssetPath(relativePath);
|
||||
const maxFrames = options.maxFramesInMemory ?? 0;
|
||||
const sampleStep = Math.max(1, options.frameSampleStep ?? 1);
|
||||
|
||||
const ffmpeg = Bun.spawn(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-v", "error",
|
||||
"-i", assetPath,
|
||||
"-an",
|
||||
"-vf", `scale=${options.width}:${options.height}`,
|
||||
"-r", `${targetFps}`,
|
||||
"-f", "rawvideo",
|
||||
"-pix_fmt", "rgba",
|
||||
"-"
|
||||
],
|
||||
{ stdout: "pipe", stderr: "pipe" }
|
||||
);
|
||||
|
||||
const frameSize = options.width * options.height * 4;
|
||||
const frames: ImageData[] = [];
|
||||
let residual = new Uint8Array(0);
|
||||
let decodedFrameCount = 0;
|
||||
|
||||
const stderrPromise = ffmpeg.stderr ? Bun.readableStreamToText(ffmpeg.stderr) : Promise.resolve("");
|
||||
|
||||
for await (const chunk of ffmpeg.stdout) {
|
||||
const merged = new Uint8Array(residual.length + chunk.length);
|
||||
merged.set(residual, 0);
|
||||
merged.set(chunk, residual.length);
|
||||
residual = merged;
|
||||
|
||||
while (residual.length >= frameSize) {
|
||||
const frameBytes = residual.slice(0, frameSize);
|
||||
residual = residual.slice(frameSize);
|
||||
decodedFrameCount += 1;
|
||||
|
||||
if (decodedFrameCount % sampleStep !== 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const clamped = new Uint8ClampedArray(frameBytes.buffer, frameBytes.byteOffset, frameBytes.byteLength);
|
||||
const image = new ImageData(clamped, options.width, options.height);
|
||||
if (maxFrames > 0 && frames.length >= maxFrames) {
|
||||
frames.shift();
|
||||
}
|
||||
frames.push(image);
|
||||
}
|
||||
}
|
||||
|
||||
const exitCode = await ffmpeg.exited;
|
||||
const stderr = await stderrPromise;
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`ffmpeg exited with code ${exitCode}${stderr ? `: ${stderr}` : ""}`);
|
||||
}
|
||||
|
||||
if (frames.length === 0) {
|
||||
throw new Error("No frames decoded from video");
|
||||
}
|
||||
|
||||
const effectiveFrameCount = decodedFrameCount;
|
||||
return {
|
||||
width: options.width,
|
||||
height: options.height,
|
||||
fps: targetFps,
|
||||
durationMs: (effectiveFrameCount / targetFps) * 1000,
|
||||
frames
|
||||
};
|
||||
}
|
||||
98
src/renderer/window.ts
Normal file
98
src/renderer/window.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import assert from "assert";
|
||||
|
||||
import sdl, { type Events, type Sdl } from "@kmamal/sdl";
|
||||
import { createCanvas, Image, type CanvasRenderingContext2D } from "@napi-rs/canvas";
|
||||
|
||||
export type WindowProps = {
|
||||
title?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
visible?: boolean;
|
||||
fullscreen?: boolean;
|
||||
resizable?: boolean;
|
||||
borderless?: boolean;
|
||||
alwaysOnTop?: boolean;
|
||||
};
|
||||
|
||||
export class SDLWindow {
|
||||
#window: Sdl.Video.Window | undefined;
|
||||
|
||||
constructor(props: WindowProps = {}) {
|
||||
console.debug("[debug] [renderer/window] new SDLWindow", props)
|
||||
this.#window = sdl.video.createWindow({
|
||||
...props,
|
||||
title: props.title ?? "SDL Application"
|
||||
});
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
this.#window.on("resize", (e) => {
|
||||
this.#window?.setTitle(`${props.title ?? "SDL Application"} [${e.pixelWidth}x${e.pixelHeight}]`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
get size(): { width: number; height: number } {
|
||||
const { pixelWidth, pixelHeight } = this.Window;
|
||||
return { width: pixelWidth, height: pixelHeight };
|
||||
}
|
||||
|
||||
get Window(): Sdl.Video.Window {
|
||||
if (!this.#window) throw "Window not present";
|
||||
return this.#window;
|
||||
}
|
||||
|
||||
on<EventName extends Events.Window.Any["type"]>(
|
||||
event: EventName,
|
||||
handler: (
|
||||
event: Extract<Events.Window.Any, { type: EventName }>
|
||||
) => void
|
||||
): () => void {
|
||||
const target = this.Window as unknown as {
|
||||
on: (event: Events.Window.Any["type"], listener: (event: Events.Window.Any) => void) => void;
|
||||
off?: (
|
||||
event: Events.Window.Any["type"],
|
||||
listener: (event: Events.Window.Any) => void
|
||||
) => void;
|
||||
removeListener?: (
|
||||
event: Events.Window.Any["type"],
|
||||
listener: (event: Events.Window.Any) => void
|
||||
) => void;
|
||||
};
|
||||
|
||||
target.on(event, handler as (event: Events.Window.Any) => void);
|
||||
return () => {
|
||||
if (typeof target.off === "function") {
|
||||
target.off(event, handler as (event: Events.Window.Any) => void);
|
||||
return;
|
||||
}
|
||||
if (typeof target.removeListener === "function") {
|
||||
target.removeListener(
|
||||
event,
|
||||
handler as (event: Events.Window.Any) => void
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
renderFromBuffer(width: number, height: number, stride: number, buffer: Buffer): void {
|
||||
this.Window.render(width, height, stride, "rgba32", buffer);
|
||||
}
|
||||
|
||||
renderFromContext(ctx: CanvasRenderingContext2D): void {
|
||||
const { width, height } = this.size;
|
||||
const buffer = Buffer.from(ctx.getImageData(0, 0, width, height).data);
|
||||
this.renderFromBuffer(width, height, width * 4, buffer);
|
||||
}
|
||||
|
||||
setIconFromImage(image: Image): void {
|
||||
const canvas = createCanvas(image.width, image.height);
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.drawImage(image as any, 0, 0);
|
||||
const data = ctx.getImageData(0, 0, image.width, image.height).data;
|
||||
this.Window.setIcon(image.width, image.height, image.width * 4, "rgba32", Buffer.from(data));
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.Window.destroy();
|
||||
this.#window = undefined;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user