Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions apps/browser-demos/pages/kandelo/panes/Display.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import * as React from "react";
import { useWebPreview } from "../kernel-host/react";
import { Framebuffer, type FramebufferProps } from "./Framebuffer";
import { Modeset } from "./Modeset";
import type { PrimarySurface } from "../../../../../web-libs/kandelo-session/src/kernel-host";
import type {
DemoPresentation,
PrimarySurface,
} from "../../../../../web-libs/kandelo-session/src/kernel-host";

export interface WordPressLoginOptions {
username: string;
Expand All @@ -23,13 +26,28 @@ export interface DisplayProps extends FramebufferProps {
* framebuffer) for callers that don't yet pass a surface.
*/
surface?: PrimarySurface;
presentation?: DemoPresentation;
onDockControlsChange?: (controls: React.ReactNode | null) => void;
}

export const Display = React.forwardRef<DisplayHandle, DisplayProps>(({ surface, onDockControlsChange, ...props }, ref) => {
export const Display = React.forwardRef<DisplayHandle, DisplayProps>(({
surface,
presentation,
onDockControlsChange,
...props
}, ref) => {
const preview = useWebPreview();

if (surface === "kms") return <Modeset {...props} onDockControlsChange={onDockControlsChange} />;
if (surface === "kms") {
return (
<Modeset
{...props}
connectorMode={presentation?.kms?.connectorMode}
fit={presentation?.kms?.fit}
onDockControlsChange={onDockControlsChange}
/>
);
}
if (surface === "framebuffer") return <Framebuffer {...props} onDockControlsChange={onDockControlsChange} />;
if (surface === "web" && preview) {
return <WebPreviewPane ref={ref} preview={preview} onDockControlsChange={onDockControlsChange} {...props} />;
Expand Down
87 changes: 59 additions & 28 deletions apps/browser-demos/pages/kandelo/panes/Modeset.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@

import * as React from "react";
import { useKernelHost, useStatus } from "../kernel-host/react";
import type { KmsDisplayHandle } from "../../../../../web-libs/kandelo-session/src/kernel-host";
import type {
KmsConnectorMode,
KmsDisplayHandle,
KmsSurfaceFit,
} from "../../../../../web-libs/kandelo-session/src/kernel-host";
import {
attachLinuxMediumRawKeyboard,
injectChunkedMouseMotion,
Expand All @@ -13,15 +17,10 @@ import {
import { DemoSurfaceDockControls } from "./Framebuffer";
import { useFittedCanvasStyle } from "./canvasFit";

// modeset.c hardcodes 1920×1080 (CANVAS_W/CANVAS_H). The kernel-side
// auto-attach resizes the OffscreenCanvas drawing buffer to match the
// FB before `getContext("webgl2")`, but the placeholder HTMLCanvas in
// the main thread keeps whatever `width`/`height` we set BEFORE
// `transferControlToOffscreen()`. We need correct attribute dims here
// so the pointer scaling math (`canvas.width / rect.width`) matches
// the framebuffer the wasm program actually paints into.
const MODESET_FB_W = 1920;
const MODESET_FB_H = 1080;
// Initial connector mode before any process binds a KMS FB. Once a process
// calls MODE_SETCRTC/PAGE_FLIP, stats slots 2/3 become the authoritative
// scanout size and drive input scaling + CSS fitting.
const DEFAULT_KMS_CONNECTOR_MODE: KmsConnectorMode = { width: 1920, height: 1080 };

export interface ModesetProps {
dragProps?: import("./PaneHead").PaneHeadDragProps;
Expand All @@ -34,6 +33,10 @@ export interface ModesetProps {
/** CRTC to bind the canvas to. Defaults to 1 (the single CRTC the
* kernel currently advertises via MODE_GETRESOURCES). */
crtcId?: number;
/** Virtual mode advertised by the browser KMS connector before page flips. */
connectorMode?: KmsConnectorMode;
/** Browser presentation fit for the scanout canvas. */
fit?: KmsSurfaceFit;
}

interface KmsStats {
Expand All @@ -50,9 +53,20 @@ const ZERO_STATS: KmsStats = {
lastFrameUs: 0,
};

export const Modeset: React.FC<ModesetProps> = ({ autoFocus = false, focusToken = 0, crtcId = 1, onDockControlsChange }) => {
export const Modeset: React.FC<ModesetProps> = ({
autoFocus = false,
focusToken = 0,
crtcId = 1,
connectorMode: configuredConnectorMode,
fit = "contain",
onDockControlsChange,
}) => {
const host = useKernelHost();
const status = useStatus();
const connectorMode = React.useMemo(
() => sanitizeConnectorMode(configuredConnectorMode),
[configuredConnectorMode?.height, configuredConnectorMode?.width],
);
const stageRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const handleRef = React.useRef<KmsDisplayHandle | null>(null);
Expand All @@ -73,21 +87,15 @@ export const Modeset: React.FC<ModesetProps> = ({ autoFocus = false, focusToken
if (!canvas) return;
if (handleRef.current) return;

// Match the wasm program's framebuffer dims BEFORE
// `transferControlToOffscreen()`. The placeholder HTMLCanvas keeps
// these as its `.width`/`.height` attribute values after transfer;
// the OffscreenCanvas inherits them too. Both matter:
// - The pointer scaler reads `canvas.width / rect.width` to map
// CSS deltas to framebuffer pixels. Default 300/150 would mean
// the cursor crawls at ~1/6 speed and Pavel's splats clump.
// - The OffscreenCanvas drawing buffer must be 1920×1080 so
// `glViewport(0, 0, 1920, 1080)` covers the full surface.
if (canvas.width !== MODESET_FB_W) canvas.width = MODESET_FB_W;
if (canvas.height !== MODESET_FB_H) canvas.height = MODESET_FB_H;
// Seed the connector mode before transfer. The worker resizes the
// OffscreenCanvas to the kernel's current FB before WebGL2 attaches,
// and the stats SAB reports that real scanout size back to this pane.
if (canvas.width !== connectorMode.width) canvas.width = connectorMode.width;
if (canvas.height !== connectorMode.height) canvas.height = connectorMode.height;

let offBound: (() => void) | null = null;
try {
const handle = host.attachKmsDisplay(canvas, crtcId);
const handle = host.attachKmsDisplay(canvas, crtcId, { mode: "webgl2", connectorMode });
if (!handle) {
setError("Kernel does not expose kmsAttachCanvas (older ABI?)");
return;
Expand All @@ -106,7 +114,7 @@ export const Modeset: React.FC<ModesetProps> = ({ autoFocus = false, focusToken
handleRef.current = null;
setBoundPid(null);
};
}, [host, status, crtcId]);
}, [connectorMode, host, status, crtcId]);

// Forward mouse motion + buttons into the kernel's `/dev/input/mice`.
// The wasm side has no absolute-cursor input — it integrates int8
Expand All @@ -125,8 +133,8 @@ export const Modeset: React.FC<ModesetProps> = ({ autoFocus = false, focusToken

const activeFrameSize = () => {
const s = statsRef.current;
const width = s.width > 0 ? s.width : (canvas.width || MODESET_FB_W);
const height = s.height > 0 ? s.height : (canvas.height || MODESET_FB_H);
const width = s.width > 0 ? s.width : (canvas.width || connectorMode.width);
const height = s.height > 0 ? s.height : (canvas.height || connectorMode.height);
return { width, height };
};
const initialFrame = activeFrameSize();
Expand Down Expand Up @@ -230,7 +238,7 @@ export const Modeset: React.FC<ModesetProps> = ({ autoFocus = false, focusToken
canvas.removeEventListener("contextmenu", onContextMenu);
doc.removeEventListener("mouseup", onMouseUp);
};
}, [boundPid, stats.height, stats.width, status]);
}, [boundPid, connectorMode.height, connectorMode.width, stats.height, stats.width, status]);

React.useEffect(() => {
if (status !== "running") return;
Expand Down Expand Up @@ -292,7 +300,14 @@ export const Modeset: React.FC<ModesetProps> = ({ autoFocus = false, focusToken
const captureLabel = focused
? boundPid !== null ? `captured · pid ${boundPid}` : "captured"
: boundPid !== null ? "click to type" : "waiting for KMS process";
const canvasStyle = useFittedCanvasStyle(stageRef, canvasRef, MODESET_FB_W / MODESET_FB_H);
const scanoutAspect = hasFrame ? stats.width / stats.height : undefined;
const canvasStyle = useFittedCanvasStyle(
stageRef,
canvasRef,
connectorMode.width / connectorMode.height,
scanoutAspect,
fit,
);
const statusLabel = hasFrame
? `${stats.width}×${stats.height} · ${stats.commitCount} flips · ${stats.lastFrameUs}µs · ${captureLabel}`
: "waiting for PAGE_FLIP";
Expand Down Expand Up @@ -346,3 +361,19 @@ export const Modeset: React.FC<ModesetProps> = ({ autoFocus = false, focusToken
</div>
);
};

function sanitizeConnectorMode(mode: KmsConnectorMode | undefined): KmsConnectorMode {
if (
mode &&
Number.isFinite(mode.width) &&
Number.isFinite(mode.height) &&
mode.width > 0 &&
mode.height > 0
) {
return {
width: Math.floor(mode.width),
height: Math.floor(mode.height),
};
}
return DEFAULT_KMS_CONNECTOR_MODE;
}
24 changes: 22 additions & 2 deletions apps/browser-demos/pages/kandelo/panes/canvasFit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export function useFittedCanvasStyle(
containerRef: React.RefObject<HTMLElement | null>,
canvasRef: React.RefObject<HTMLCanvasElement | null>,
fallbackAspect: number,
aspectOverride?: number,
fit: "contain" | "stretch" = "contain",
): React.CSSProperties {
const [style, setStyle] = React.useState<React.CSSProperties>({});

Expand All @@ -15,7 +17,21 @@ export function useFittedCanvasStyle(
const update = () => {
const rect = container.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
const aspect = canvasAspect(canvas, fallbackAspect);
if (fit === "stretch") {
const nextWidth = Math.max(1, Math.floor(rect.width));
const nextHeight = Math.max(1, Math.floor(rect.height));
setStyle((current) => {
if (current.width === `${nextWidth}px` && current.height === `${nextHeight}px`) {
return current;
}
return {
width: `${nextWidth}px`,
height: `${nextHeight}px`,
};
});
return;
}
const aspect = validAspect(aspectOverride) ? aspectOverride : canvasAspect(canvas, fallbackAspect);
const containerAspect = rect.width / rect.height;
const width = containerAspect > aspect ? rect.height * aspect : rect.width;
const height = width / aspect;
Expand Down Expand Up @@ -47,7 +63,7 @@ export function useFittedCanvasStyle(
mutationObserver.disconnect();
window.removeEventListener("resize", update);
};
}, [canvasRef, containerRef, fallbackAspect]);
}, [aspectOverride, canvasRef, containerRef, fallbackAspect, fit]);

return style;
}
Expand All @@ -60,3 +76,7 @@ function canvasAspect(canvas: HTMLCanvasElement, fallbackAspect: number): number
}
return fallbackAspect;
}

function validAspect(value: number | undefined): value is number {
return value !== undefined && Number.isFinite(value) && value > 0;
}
2 changes: 2 additions & 0 deletions apps/browser-demos/pages/kandelo/views/MachineView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ export const MachineView: React.FC<MachineViewProps> = ({
const {
activePrimary,
demoSurface,
presentation,
canUseTerminal,
shouldMountDemoSurface,
followDemoSurface,
Expand Down Expand Up @@ -250,6 +251,7 @@ export const MachineView: React.FC<MachineViewProps> = ({
autoFocus={activePrimary === demoSurface}
focusToken={demoFocusToken}
surface={demoSurface ?? undefined}
presentation={presentation}
onDockControlsChange={onDemoDockControlsChange}
/>
</PrimarySurfaceSlot>
Expand Down
Loading
Loading