v0.7.1 (build 925)

Direct output (DRM/KMS) #

Use role: 'raw' to create a full-screen window on a physical display without a Wayland compositor. Each raw window owns one output and uses the same UI and input APIs as other Koya windows. Koya needs access to the display and input devices on the active Linux VT. The display must be available for Koya to take control.

import * as Compositor from 'Koya/Compositor';

const displays = await Compositor.listDisplays({backend: 'drm'});
if (!displays.length) throw new Error('No DRM output found');
const win = await Compositor.createWindow({
    role: 'raw',
    display: displays[0].display,
    position: {x: 0, y: 0},
    keyboardInteractivity: 'exclusive',
    acceptPointerEvents: true
});
await Compositor.setClearColor(win, 0.04, 0.05, 0.08, 1);
await Compositor.focusWindow(win);
// Attach UI through the same APIs used by Wayland windows.
// Window.create({role: 'raw', ...}) also works in the standard UI library.
  • One raw window owns one connector. Multiple outputs on one GPU and seat0 are supported. Omitting display selects an unclaimed output, preferring an internal panel; subsequent windows must use the same GPU. A connector cannot be reserved twice.
  • Raw windows are opaque and cover the selected output mode. The supported preferred mode is selected, falling back to greatest pixel area then highest refresh. There is no explicit mode or rotation option. size and resizeWindow do not select an output mode.
  • One logical unit equals one output pixel. Positions are integer desktop origins and may be negative; rectangles must fit int32 coordinates and must not overlap. Omitted positions place outputs horizontally to the right of existing windows. Repositioning changes input layout, not the physical scanout position.
  • Keyboard input and pointer/touch input default to enabled. Keyboard focus starts on the first eligible window, follows clicks/touch-downs, and can be changed with focusWindow. Both exclusive and on_demand make a raw window eligible for the session's single keyboard focus; none disables eligibility.
  • Relative mice share a desktop cursor with button capture. Unmapped touch and absolute-pointer devices target the first active window. Use setInputDeviceOutput to bind those devices to a particular output. Device/output loss cancels affected interactions. Keyboard, pointer and touch feed the existing Helix input/UI paths.
  • Raw keyboard layout uses XKB_DEFAULT_RULES, XKB_DEFAULT_MODEL, XKB_DEFAULT_LAYOUT, XKB_DEFAULT_VARIANT and XKB_DEFAULT_OPTIONS (default US); compose uses the locale. Repeat starts after 400 ms and runs at 25 Hz. External input methods are unavailable.
  • VT release and output disconnect suspend presentation while retaining window IDs and UI. The same connector resumes when available again. Newly attached outputs need explicit window creation. Ctrl+Alt+F1–F12 switches VTs. Normal exit, SIGINT and SIGTERM restore saved console state. GPU device loss requires restarting the session.
  • Raw and Wayland windows cannot coexist, including pending creations. After all live windows and pending creations have been released, another backend can be selected. Display discovery does not reserve a backend or retain DRM master.
  • Raw output does not provide Wayland window-manager operations, session locking, desktop clipboard/drag-and-drop, screencopy or external IMEs. Vulkan-source screenshots and bundled software cursors are available. Use setPointerEvents/setKeyboardInteractivity/focusWindow directly rather than elevateWindow/lowerWindow.

The examples/raw-output/index.js example creates a window on each available output with an editable text field. Click a field to type; press Escape to exit.

Normal windows (xdg_toplevel) #

Koya can create normal Wayland windows using xdg-shell. Use the unified factory with role: 'window':

import * as Compositor from 'Koya/Compositor';

const win = await Compositor.createWindow({
    role: 'window',
    title: 'My App',
    appId: 'org.example.myapp',
    size: { x: 800, y: 600 },
    state: { maximized: false, fullscreen: false },
    deviceSelector: ({ devices, preferredDeviceId }) =>
        devices.find(device => device.type === 'discrete' && device.suitable)?.id ?? preferredDeviceId
});

await Compositor.setTitle(win, 'New Title');
await Compositor.setAppId(win, 'org.example.newid');
await Compositor.setMaximized(win, true);
await Compositor.setFullscreen(win, false);
await Compositor.minimize(win);

Session locking #

Use role: 'lock' with a compositor supporting ext-session-lock-v1. Create one opaque lock window for each display from listDisplays({backend: 'wayland'}), including newly attached displays. The compositor chooses its size. Wait for sessionLockState() to return 'locked' before reporting that the session is protected. Authenticate the user before calling unlockSession(). Destroying windows or exiting the process does not unlock the session.

sessionLockState
Returns the compositor-confirmed lock state. 'finished' means the compositor refused or ended the lock.
sessionLockState(): Promise<'unlocked' | 'locking' | 'locked' | 'finished'>
unlockSession
Releases a confirmed session lock. Throws if no confirmed lock exists or the Wayland connection fails.
unlockSession(): Promise<boolean>
Async

Compositor runtime calls are promise-based. Use await Compositor.*(...). The signatures below describe resolved values (Promise<...> at call site).

Notes #

  • The role is immutable. Switching from layer-shell to xdg (or vice versa) requires destroying and recreating the window.
  • Wayland resize hints use the same resizeWindow(id, w, h) API; the compositor may choose the final size. Compositor configure events resize the swapchain and update the Wayland viewport to the new logical dimensions for both xdg windows and layer surfaces, including rotation. The new viewport and configure acknowledgement remain pending until the resized buffer is committed, avoiding a stretched previous frame during configure processing.
  • Input delivery (pointer/keyboard/touch) is unified across roles.

Create and manage windows, query displays, and render. Window IDs are uint32 values. Functions throw on invalid IDs.

Import #

import * as Compositor from 'Koya/Compositor';

Types #

WindowOptions
Discriminated by role
Common
  • title string optional
  • msaaSamples number optional
  • keyboardInteractivity 'none' | 'exclusive' | 'on_demand' optional
  • acceptPointerEvents boolean optional
  • display string optional - Display ID from listDisplays({backend: 'wayland'}) for Wayland or listDisplays({backend: 'drm'}) for raw.
  • namespace string optional - Layer-shell namespace shown in hyprctl layers
  • exclusiveZone number optional - Layer-shell exclusive zone override. -1 to fill to anchored edges ignoring reserved areas, 0 for no reservation, or a positive number to reserve space.
  • deviceSelector (request: VulkanDeviceSelectionRequest) => string | PromiseLike<string> optional - Selects a suitable Vulkan device for this window. The callback has a five-second deadline. For raw outputs only the GPU owning the selected connector is eligible.
Bar
  • role 'bar'
  • edge 'top' | 'bottom' | 'left' | 'right'
  • thickness number optional - Default 24
  • reserveSpace boolean | number optional - Default true (uses thickness); number sets exclusive zone explicitly
Overlay
  • role 'overlay'
  • anchor 'top-left' | 'top' | 'top-right' | 'left' | 'center' | 'right' | 'bottom-left' | 'bottom' | 'bottom-right' | 'fill' - fill anchors all four edges and leaves both dimensions compositor-controlled. It follows output resizing and rotation. With exclusiveZone 0 it fills the area remaining between reserved bars; with -1 it covers the whole output.
  • size { x: number, y: number } - Requested logical size. Still required for fill overlays, but the compositor chooses both dimensions instead.
  • offset { x?: number, y?: number } optional
Background
  • role 'background'
Lock
  • role 'lock'
Window (xdg_toplevel)
  • role 'window'
  • title string optional
  • appId string optional
  • size { x: number, y: number } optional - Hint; compositor may ignore
  • state { maximized?: boolean, fullscreen?: boolean } optional
Raw
  • role 'raw'
  • display string optional - Opaque connector ID from listDisplays({backend: 'drm'}), containing GPU UUID and connector name. Omit to choose an unclaimed output, preferring the internal panel.
  • position {x: number, y: number} optional - Integer desktop origin; negative coordinates allowed. Defaults to horizontal placement. Rectangles must not overlap and must fit int32 coordinates.
VulkanDeviceSelectionRequest
Surface-specific Vulkan candidates supplied during window creation.
  • window { runtime, title, role, logicalWidth, logicalHeight, fullscreen, transparent, display }
  • preferredDeviceId string - Opaque ID chosen by Helix's discrete-first native policy.
  • devices VulkanDeviceCandidate[]
VulkanDeviceCandidate
Complete device descriptor. IDs are opaque and valid only for the current request; 64-bit sizes are bigint and UUIDs are lowercase hexadecimal.
  • id, name, type, deviceUuid, driverUuid string
  • index, vendorId, deviceId, apiVersion, driverVersion, driverId number
  • suitable boolean
  • rejections { code: string, message: string }[]
  • extensions string[]
  • features { core, vulkan11, vulkan12, vulkan13, presentation }
  • limits, sparseProperties object
  • memory { heaps: VulkanMemoryHeap[], types: VulkanMemoryType[] }
  • queueFamilies VulkanQueueFamily[] - Includes decoded flags and presentationSupport for the new surface.
WindowInfo
  • exists boolean
  • id number
  • width number
  • height number
  • bufferScale number
  • fractionalScale number
  • role 'bar' | 'overlay' | 'background' | 'window' | 'lock' | 'raw'
  • edge 'top' | 'bottom' | 'left' | 'right' optional - Present for role bar
  • anchor string optional - Present for role overlay
  • x, y number - Requested logical margins/offset on the selected output. For raw windows this is the configured desktop origin in output pixels.
  • outputLogicalX, outputLogicalY number - Origin of the selected output in compositor logical space. For raw windows this is the configured desktop origin in output pixels.
  • onscreenX, onscreenY number - Approximate window top-left in logical space accounting for reserved bars. For raw windows this is the configured desktop origin in output pixels.
  • exclusiveTop, exclusiveRight, exclusiveBottom, exclusiveLeft number
RawInputDevice
Device discovered by the active raw seat0 input session.
  • deviceId string - Opaque device identity used for mappings; retained bindings survive device reconnects within the session.
  • name string - Human-readable device name.
  • display string - Explicit output binding, or an empty string when using default routing.
  • capabilities number - Bitmask: keyboard = 1, pointer = 2, touch = 4. A device can have several capabilities.

Functions #

setTextInput
Reports editable focus and text state to text-input-v3 input methods such as Squeekboard. Standard InputText controls call this automatically.
  • Options: enabled (default true), newFocus (default false; set when switching fields), inputId (field ID), contentPurpose (0 normal, 2 digits, 3 number, 4 phone, 6 email), contentHint (version 1 bitmask), surroundingText (optional), cursor and anchor (UTF-8 byte offsets; anchor defaults to cursor).
  • cursorRectangle is { x, y, width, height } in surface logical coordinates. cursorElement is a UI element ID whose frame is converted from canvas coordinates instead. changeCause is 'other' (default) or 'input_method'.
  • Set enabled:false on blur. Layer-shell surfaces must allow keyboard focus. State activates after the compositor sends text-input enter, and is resent on focus re-entry.
  • Returns false if the protocol is unavailable or metadata is invalid. Valid state is cached even if unavailable. Surrounding text is bounded to 4000 UTF-8 bytes with the selection preserved.
  • textInputMethod events contain id, inputId, text, preedit, beforeLength, afterLength, cursorBegin, cursorEnd. Apply deletions and committed text atomically, then show preedit separately. All lengths and preview indices are UTF-8 bytes; preview cursor -1 hides the caret. Empty batches clear composition on surface focus loss.
  • For raw windows, validates/caches editable state and resets compose on newFocus. Raw typing uses xkbcommon and the existing textInput/textRepeat events; no text-input-v3 input method is connected.
setTextInput(windowId: number, options: TextInputOptions): Promise<boolean>
createWindow
Creates a Wayland window or raw output window and returns its ID. Native GPU selection prefers a suitable discrete GPU for Wayland; raw output requires the connector-owning GPU.
  • Rejects if the requested backend is unavailable, initialization fails, or raw/Wayland windows would be mixed. Raw creation also rejects unavailable/already-reserved outputs, unsupported GPUs, invalid layouts and failure to acquire DRM/VT access.
  • A deviceSelector rejection, throw, timeout, unknown ID, or unsuitable selection rejects the Promise and releases pending backend/Vulkan resources.
createWindow(options: WindowOptions): Promise<number>
destroyWindow
destroyWindow(windowId: number): Promise<boolean>
setWindowRenderingEnabled
Gates per-frame rendering/present without changing Wayland visibility.
  • Useful for keeping the surface mapped but pausing GPU work.
setWindowRenderingEnabled(windowId: number, enabled: boolean): Promise<boolean>
elevateWindow
Enables pointer events and sets keyboard interactivity to on_demand.
  • For layer-shell windows (bar, overlay, background): the surface can receive keyboard focus when interacted with. Does not change stacking/layer.
  • For xdg_toplevel windows (role window): effectively a no-op for keyboard interactivity, only ensures pointer events are enabled.
  • Equivalent to setKeyboardInteractivity(win, 'on_demand') + setPointerEvents(win, true) on layer-shell.
  • Unsupported for raw output windows; use the explicit input and focus APIs.
elevateWindow(windowId: number): Promise<boolean>
lowerWindow
Disables pointer events and sets keyboard interactivity to none.
  • For layer-shell windows: makes the surface non-focusable for keyboard input. Useful for dismissing overlays/menus.
  • For xdg_toplevel windows: effectively a no-op for keyboard interactivity, only disables pointer events.
  • Equivalent to setKeyboardInteractivity(win, 'none') + setPointerEvents(win, false) on layer-shell.
  • Unsupported for raw output windows; use the explicit input and focus APIs.
lowerWindow(windowId: number): Promise<boolean>
setClearColor
setClearColor(windowId: number, r: number, g: number, b: number, a: number): Promise<boolean>
getWindowInfo
getWindowInfo(windowId: number): Promise<WindowInfo>
getScaleStatus
Returns a debug string describing the current scale state.
getScaleStatus(windowId: number): Promise<string>
setUiScaling
Authors the window's UI against a reference resolution (default 1920x1080) and scales it to the output. 'expand' keeps the whole reference area on screen; 'matchWidth'/'matchHeight' pin one axis to the reference; 'fixed' (the default for every window) uses one UI unit per logical unit. Resolves to the canvas the UI now lays out in. Wayland windows are already in logical units, so this is mainly for raw outputs, which have no compositor scale. Pointer input, text and composites follow the scale.
setUiScaling(windowId: number, options: { referenceWidth?: number, referenceHeight?: number, mode?: 'fixed' | 'expand' | 'matchWidth' | 'matchHeight' }): Promise<{ width: number, height: number, scale: number }>
resizeWindow
Requests a Wayland window size; unsupported for raw output windows.
resizeWindow(windowId: number, width: number, height: number): Promise<boolean>
listWindows
listWindows(): Promise<number[]>
listDisplays
Lists outputs for the requested backend; omission defaults to Wayland, even when raw windows exist.
  • Pass {backend: 'drm'} for raw output discovery. Rejects if discovery or backend initialization fails; an empty array means no usable outputs were discovered.
  • Descriptors contain display (opaque selection ID), name, and available metadata such as width, height, logical_width, logical_height, refresh_rate (millihertz), physical_width_mm and physical_height_mm. Numeric metadata is returned as strings: use Number(value). Optional fields can be absent.
  • For raw layout positions use getWindowInfo(windowId). Discovery describes connectors, not the configured layout of live windows.
listDisplays(options: { backend?: 'wayland' | 'drm' }): Promise<Array<Record<string, string>>>
cleanup
cleanup(): Promise<boolean>
setKeyboardInteractivity
For raw windows, 'none' disables keyboard eligibility; 'exclusive' and 'on_demand' both enable it. Use focusWindow to select an active eligible window.
setKeyboardInteractivity(windowId: number, interactivity: 'none' | 'exclusive' | 'on_demand'): Promise<boolean>
setPointerEvents
Enables or disables pointer/touch delivery. Disabling raw pointer input cancels active interactions on that window.
setPointerEvents(windowId: number, accept: boolean): Promise<boolean>
setCursor
Changes the pointer cursor for the given window.
  • Common shapes: 'default', 'arrow', 'ibeam', 'text', 'hand', 'pointer', 'crosshair', 'ew-resize', 'ns-resize', 'nwse-resize', 'nesw-resize', 'wait', 'progress'.
  • Wayland also accepts cursor theme names such as 'left_ptr' and 'hand2' and requires wayland-cursor plus an installed theme.
  • Raw windows support only the listed common shapes, drawn as a software overlay above UI/post-processing; no cursor theme is needed. Touch input hides the cursor until pointer input resumes.
setCursor(windowId: number, shape: string): Promise<boolean>
setClipboardText
Writes plain text to the system clipboard.
  • Uses native Wayland selection APIs (wl_data_device/wl_data_source) with no external helper process.
setClipboardText(text: string): Promise<boolean>
getClipboardText
Reads plain text from the system clipboard.
  • Reads from the active Wayland selection offer (wl_data_offer) with MIME negotiation (text/plain;charset=utf-8, text/plain, UTF8_STRING).
getClipboardText(): Promise<string>
startDrag
Starts a Wayland drag using MIME-keyed bytes during an active pointer button or touch contact on the source window.
  • Actions default to copy. Each MIME value is limited to 16 MiB and the combined payload to 64 MiB. Move reports the selected action; the application decides when to remove the original.
  • Requires a Wayland data device. Data-device versions older than 3 support copy only.
startDrag(windowId: number, options: { data: Record<string, Uint8Array>, actions?: ('copy' | 'move')[] }): Promise<boolean>
cancelDrag
Cancels a drag started by this window.
cancelDrag(windowId: number): Promise<boolean>
setDropTarget
Accepts selected MIME types on a UI element, or on the window when elementId is -1. Pass null to unregister.
  • The deepest registered element under the pointer wins, then a registered ancestor, then the window fallback. Registration enables element hit testing.
  • Listen for dragEvent on Helix/Event. Destination phases are enter, over, leave and drop; source phases are action, performed, finished and cancelled. A drop has an ArrayBuffer data field, MIME type, action, window and element IDs, and window/local coordinates.
setDropTarget(windowId: number, elementId: number, options: { mimeTypes: string[], actions?: ('copy' | 'move')[] } | null): Promise<boolean>
focusWindow
Gives keyboard focus to an active, keyboard-eligible raw window.
  • Resolves true on success; rejects for a missing, suspended, non-raw or keyboard-disabled window.
focusWindow(windowId: number): Promise<boolean>
setOutputPosition
Moves a raw output rectangle in the shared input desktop.
  • Coordinates must be finite int32 integers. The complete rectangle must fit int32 and must not overlap any other retained raw window. Resolves true on success; rejects invalid windows or layouts. Cancels active pointer/touch interactions on the moved output.
  • A suspended output can be repositioned. This can allow resumption after a mode-size change would otherwise overlap another output.
setOutputPosition(windowId: number, position: { x: number, y: number }): Promise<boolean>
listInputDevices
Lists devices currently discovered by the active raw input session.
  • Create a raw window first. Rejects when no raw input session exists. Device discovery is asynchronous; query again after the event loop has pumped if the initial list is empty.
listInputDevices(): Promise<RawInputDevice[]>
setInputDeviceOutput
Binds touch/absolute-pointer input to a retained raw output; pass an empty displayId to clear the binding.
  • Use deviceId from listInputDevices() and the display ID used to create the target raw window. Resolves true on success; rejects unknown devices or outputs.
  • Bindings survive device reconnects within the session. A suspended target receives no input; its device is not redirected to another output. Remapping cancels that device’s active pointer/touch interactions. Relative pointer motion still uses the shared desktop; keyboard focus is controlled separately.
setInputDeviceOutput(deviceId: string, displayId: string): Promise<boolean>

Drag and drop #

import * as Compositor from 'Koya/Compositor';
import * as Event from 'Helix/Event';

await Compositor.setDropTarget(win, elementId, { mimeTypes: ['text/plain'], actions: ['copy'] });
Event.on('dragEvent', event => {
    if (event.windowId === win && event.elementId === elementId && event.phase === 'drop') {
        const bytes = new Uint8Array(event.data);
        // Use event.mimeType, event.action, event.x/y or event.localX/localY.
    }
});
// Call from a pointer-down or touch-down handler while the contact is held.
await Compositor.startDrag(win, { data: { 'text/plain': Uint8Array.from([72, 105]) } });

For a runnable example, use koya -m assets -i examples/drag-drop-test.js. Drag payloads are separate from clipboard selection. Custom drag icons are not yet provided.

Compositor notes #

  • Some compositors (e.g., Hyprland) do not reliably remap a hidden window when unmapping/remapping a layer surface. To "hide" a window, prefer to hide your UI (e.g., animate to alpha 0) and then call setWindowRenderingEnabled(windowId, false) to pause GPU work.

Events #

Raw lifecycle events also use Helix/Event: windowFocusChanged carries {id: number, focused: boolean}; windowOutputStateChanged carries {id: number, state: 'active' | 'suspended'}; displaysChanged carries {} and prompts a fresh listDisplays({backend: 'drm'}) query. An active output state means presentation is eligible, not confirmation that the first frame has reached the display. Display notifications may repeat, so reconcile by display ID instead of creating duplicate windows.

import * as Event from 'Helix/Event';

Event.on('windowFocusChanged', ({id, focused}) => {
    // Update application focus indication.
});
Event.on('windowOutputStateChanged', ({id, state}) => {
    // Retain this window/UI while state is 'suspended'.
});
Event.on('displaysChanged', async () => {
    const outputs = await Compositor.listDisplays({backend: 'drm'});
    // Reconcile output IDs with windows already created.
});

Key, text, pointer button and windowResized events are emitted on the global event bus (Helix/Event). windowResized carries { id, width, height } in logical pixels after a compositor configure changes the window size. Repeated configures with unchanged dimensions do not emit it. Handlers run on the script thread after the renderer accepts the size. UI layouts reflow automatically; use this event to recalculate application-specific pagination, grid capacity or artwork crops. No event is replayed for initial creation: read getWindowInfo(id) for the starting size.

import * as Event from 'Helix/Event';

Event.on('keyDown', ({ key, time, id }) => { /* one-shot physical key down */ });
Event.on('keyUp',   ({ key, time, id }) => { /* ... */ });
Event.on('keyRepeat', ({ key, time, id }) => { /* held-key repeats */ });
Event.on('textInput', ({ text, id }) => { /* composed text typed once */ });
Event.on('textRepeat', ({ text, id }) => { /* composed text repeats while held */ });
Event.on('keyModifiers', ({ depressed, latched, locked, group, id }) => { /* modifier state */ });
Event.on('windowResized', ({ id, width, height }) => {
    if (id !== win) return;
    // width and height are logical pixels, including for layer-shell windows.
    updatePageCapacity(width, height);
});

key is the Linux evdev code. id is the emitting window ID. keyDown remains one-shot for raw keystate consumers, while keyRepeat/textRepeat provide environment-driven repeat behavior. textInput/textRepeat are produced via xkbcommon and reflect layout/compose sequences.

Examples #

Layer-shell examples on the primary display: a top-edge bar that reserves space, a floating overlay with input and a timed teardown, a full-screen background, and a background that ignores exclusive zones. Together these demonstrate the typical layer-shell setup for a desktop shell.

import * as Compositor from 'Koya/Compositor';
import * as Log from 'Helix/Log';

const displays = await Compositor.listDisplays({backend: 'wayland'});
const display = displays.length ? displays[0].display : '';

// Bar attached to the top, reserves space equal to thickness
const topBar = await Compositor.createWindow({
    role: 'bar', edge: 'top', thickness: 26,
    reserveSpace: true, namespace: 'koya-top-bar', display
});

// Floating overlay at top-left with offset
const win = await Compositor.createWindow({
    role: 'overlay', anchor: 'top-left',
    size: { x: 400, y: 200 }, offset: { x: 100, y: 100 },
    namespace: 'koya-overlay', display
});
await Compositor.setClearColor(win, 0.1, 0.1, 0.12, 1.0);
Log.info(JSON.stringify(await Compositor.getWindowInfo(win)));

await Compositor.setKeyboardInteractivity(win, 'on_demand');
await Compositor.setPointerEvents(win, true);
await Compositor.setCursor(win, 'hand');
await Compositor.elevateWindow(win);
setTimeout(async () => {
    await Compositor.lowerWindow(win);
    await Compositor.destroyWindow(win);
}, 3000);

// Full-screen background layer
const bg = await Compositor.createWindow({
    role: 'background', namespace: 'koya-background', display
});
await Compositor.setClearColor(bg, 0.0, 0.0, 0.0, 1.0);

// Hyprland: fill under bars regardless of exclusive zones
const bgFull = await Compositor.createWindow({
    role: 'background', display,
    exclusiveZone: -1, namespace: 'koya-background-full'
});
// An overlay that follows the output, including live rotation.
const sheet = await Compositor.createWindow({
    role: 'overlay', anchor: 'fill', display,
    size: { x: 540, y: 1140 }, exclusiveZone: -1,
    namespace: 'rotation-aware-sheet'
});
Event.on('windowResized', ({ id, width, height }) => {
    if (id === sheet) updateArtworkCrop(width, height);
});

Input routing #

Pointer/mouse/touch/keyboard events are pumped automatically each frame and dispatched to the window's UI tree. To receive pointer events, ensure the window was created with acceptPointerEvents: true (or call setPointerEvents(win, true)).