Koya/Compositor
Create and manage Linux Wayland windows and direct DRM outputs.
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
displayselects 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.
sizeandresizeWindowdo 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. Bothexclusiveandon_demandmake a raw window eligible for the session's single keyboard focus;nonedisables eligibility. - Relative mice share a desktop cursor with button capture. Unmapped touch and absolute-pointer devices target the first active window. Use
setInputDeviceOutputto 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(): Promise<'unlocked' | 'locking' | 'locked' | 'finished'>unlockSession(): Promise<boolean>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 #
roletitlestring optionalmsaaSamplesnumber optionalkeyboardInteractivity'none' | 'exclusive' | 'on_demand' optionalacceptPointerEventsboolean optionaldisplaystring optional - Display ID from listDisplays({backend: 'wayland'}) for Wayland or listDisplays({backend: 'drm'}) for raw.namespacestring optional - Layer-shell namespace shown inhyprctl layersexclusiveZonenumber optional - Layer-shell exclusive zone override.-1to fill to anchored edges ignoring reserved areas,0for 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.
role'bar'edge'top' | 'bottom' | 'left' | 'right'thicknessnumber optional - Default 24reserveSpaceboolean | number optional - Default true (usesthickness); number sets exclusive zone explicitly
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
role'background'
role'lock'
role'window'titlestring optionalappIdstring optionalsize{ x: number, y: number } optional - Hint; compositor may ignorestate{ maximized?: boolean, fullscreen?: boolean } optional
role'raw'displaystring 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.
window{ runtime, title, role, logicalWidth, logicalHeight, fullscreen, transparent, display }preferredDeviceIdstring - Opaque ID chosen by Helix's discrete-first native policy.devicesVulkanDeviceCandidate[]
id, name, type, deviceUuid, driverUuidstringindex, vendorId, deviceId, apiVersion, driverVersion, driverIdnumbersuitablebooleanrejections{ code: string, message: string }[]extensionsstring[]features{ core, vulkan11, vulkan12, vulkan13, presentation }limits, sparsePropertiesobjectmemory{ heaps: VulkanMemoryHeap[], types: VulkanMemoryType[] }queueFamiliesVulkanQueueFamily[] - Includes decoded flags and presentationSupport for the new surface.
existsbooleanidnumberwidthnumberheightnumberbufferScalenumberfractionalScalenumberrole'bar' | 'overlay' | 'background' | 'window' | 'lock' | 'raw'edge'top' | 'bottom' | 'left' | 'right' optional - Present for rolebaranchorstring optional - Present for roleoverlayx, ynumber - Requested logical margins/offset on the selected output. For raw windows this is the configured desktop origin in output pixels.outputLogicalX, outputLogicalYnumber - Origin of the selected output in compositor logical space. For raw windows this is the configured desktop origin in output pixels.onscreenX, onscreenYnumber - 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, exclusiveLeftnumber
deviceIdstring - Opaque device identity used for mappings; retained bindings survive device reconnects within the session.namestring - Human-readable device name.displaystring - Explicit output binding, or an empty string when using default routing.capabilitiesnumber - Bitmask: keyboard = 1, pointer = 2, touch = 4. A device can have several capabilities.
Functions #
- 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>- 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(windowId: number): Promise<boolean>- Useful for keeping the surface mapped but pausing GPU work.
setWindowRenderingEnabled(windowId: number, enabled: boolean): Promise<boolean>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_toplevelwindows (rolewindow): 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>none.- For layer-shell windows: makes the surface non-focusable for keyboard input. Useful for dismissing overlays/menus.
- For
xdg_toplevelwindows: 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(windowId: number, r: number, g: number, b: number, a: number): Promise<boolean>getWindowInfo(windowId: number): Promise<WindowInfo>getScaleStatus(windowId: number): Promise<string>setUiScaling(windowId: number, options: { referenceWidth?: number, referenceHeight?: number, mode?: 'fixed' | 'expand' | 'matchWidth' | 'matchHeight' }): Promise<{ width: number, height: number, scale: number }>resizeWindow(windowId: number, width: number, height: number): Promise<boolean>listWindows(): Promise<number[]>- 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(): Promise<boolean>setKeyboardInteractivity(windowId: number, interactivity: 'none' | 'exclusive' | 'on_demand'): Promise<boolean>setPointerEvents(windowId: number, accept: boolean): Promise<boolean>- 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>- Uses native Wayland selection APIs (
wl_data_device/wl_data_source) with no external helper process.
setClipboardText(text: string): Promise<boolean>- 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>- 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(windowId: number): Promise<boolean>- 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>- Resolves true on success; rejects for a missing, suspended, non-raw or keyboard-disabled window.
focusWindow(windowId: number): Promise<boolean>- 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>- 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[]>- 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)).