@wonderlandlabs-pixi-ux/window
Repository: https://github.com/wonderlandlabs-pixi-ux/wonderlandlabs-pixi-ux/tree/main/packages/window
window packages desktop-style behaviors like selection, drag, resize, and titlebars into reusable stores.
It is designed for multi-panel Pixi tools where users manipulate independent UI surfaces.
A window management system for PixiJS applications using Forestry state management.
Installation
yarn add @wonderlandlabs-pixi-ux/window
Quick Start
import { Application, Container } from 'pixi.js';
import { WindowsManager } from '@wonderlandlabs-pixi-ux/window';
const app = new Application();
await app.init({ width: 1200, height: 800 });
const root = new Container();
app.stage.addChild(root);
const windows = new WindowsManager({
app,
container: root,
});
windows.addWindow('notes', {
x: 120,
y: 100,
width: 420,
height: 280,
isDraggable: true,
isResizeable: true,
titlebar: { title: 'Notes' },
});
Resize Transform Passthrough
window forwards rectTransform to the underlying @wonderlandlabs-pixi-ux/resizer instance.
Use this to apply snapping or coordinate transforms during resize drags.
windows.addWindow('snapped', {
x: 120,
y: 100,
width: 420,
height: 280,
isResizeable: true,
resizeMode: 'EDGE_AND_CORNER',
rectTransform: ({ rect, phase, handle }) => {
const snap = (n: number) => Math.round(n / 16) * 16;
const min = 64;
// Keep drag preview responsive; snap on release.
if (phase === 'drag') return rect;
return {
x: snap(rect.x),
y: snap(rect.y),
width: Math.max(min, snap(rect.width)),
height: Math.max(min, snap(rect.height)),
};
},
});
Callback params:
rect: current rectangle candidate (Rectangle)phase:'drag' | 'release'handle: active resize handle id, ornull
Overview
This package provides draggable, resizable windows with titlebars, managed through a centralized WindowsManager. Each window is a WindowStore that extends TickerForest for synchronized PixiJS rendering.
Container Hierarchy
The window system uses a nested container structure to manage rendering, events, and z-index ordering:
app.stage (or your root container)
└── container (WindowsManager.container)
│ Label: "WindowsManager"
│ The main container passed to WindowsManager config
│
├── windowsContainer (WindowsManager.windowsContainer)
│ │ Label: "windows"
│ │ Holds all window guardContainers, manages z-index ordering
│ │
│ ├── guardContainer (WindowStore.guardContainer) [per window]
│ │ │ Label: none
│ │ │ Outer wrapper that protects event listeners from being
│ │ │ purged when event models change on rootContainer.
│ │ │ No offset/mask/scale - purely structural.
│ │ │ Used for z-index management in windowsContainer.
│ │ │
│ │ └── rootContainer (WindowStore.rootContainer)
│ │ │ Label: none
│ │ │ eventMode: "static"
│ │ │ sortableChildren: true
│ │ │ Main window container with drag/event handling
│ │ │ Position set to window's (x, y)
│ │ │
│ │ ├── background (Graphics, zIndex: 0)
│ │ │ Window background with rounded corners
│ │ │
│ │ ├── contentContainer (zIndex: 1)
│ │ │ │ Container for user content
│ │ │ │ Masked to window bounds
│ │ │ │
│ │ │ └── contentMask (Graphics)
│ │ │ Clips content to window area
│ │ │
│ │ ├── selectionBorder (Graphics, zIndex: 3)
│ │ │ Visible when window is selected
│ │ │
│ │ └── titlebarContainer (zIndex: 2)
│ │ │ Managed by TitlebarStore
│ │ │
│ │ ├── titlebarBackground (Graphics)
│ │ ├── titleText (Text)
│ │ └── [optional icons/buttons]
│ │
│ └── ... (more windows)
│
└── handlesContainer (WindowsManager.handlesContainer)
Label: "handles"
Sibling to windowsContainer, renders on top
Contains resize handles for all windows
Ensures handles are always visible regardless of window z-index
Zoom-Independent Titlebar Sizing
Titlebar visual height is kept stable under zoom by enabling TickerForest scale dirty tracking with the
dirtyOnScale parameter in TitlebarStore (Y-axis only).
In the built-in implementation, this is enabled automatically when WindowStore creates TitlebarStore.
There is no extra addWindow(...) flag required.
If you implement a custom titlebar store, keep this setting on:
dirtyOnScale: {
enabled: true,
watchX: false,
watchY: true,
relativeToRootParent: true,
}
Content Placement Rules (Important)
- Put window body content in
WindowStore.contentContainer(orWindowsManager.getContentContainer(id)). - Put titlebar controls that must remain zoom-independent in the
titlebarContentRenderer({ ..., contentContainer })callback container. - Use
windowContentRenderer({ ..., contentContainer })for generated body content. - Do not attach zoom-independent titlebar controls directly to the raw
titlebarContainer; use the provided render callback container so they stay in the counter-scaled layer.
State-First Content Updates (Required Pattern)
For runtime content changes (toolbar clicks, async results, external events), use this flow:
- Mutate store state (including custom fields) on the relevant
WindowStore/TitlebarStore. - Set dirty state (
isDirty = true), usually by callingmarkDirty()on the store. - Use
windowContentRendererand/ortitlebarContentRendererto upsertGraphics/Container/Textnodes into the providedcontentContainerduring the refresh cycle.
Do not directly add/remove Pixi content from event handlers as your primary update path.
Renderer timing in the current implementation:
onResolve(state)runs first inWindowStore.resolveComponents(...)for window-level pre-render work.WindowStore.resolve()callsresolveComponents(...), which calls#refreshContentContainer(), which calls#applyWindowContentRenderer().TitlebarStore.resolve()callsresolveComponents(), which invokestitlebarContentRenderer(...)when set.
Why this pattern is required:
- Pixi mutations outside the ticker-driven refresh path can cause visual artifacts.
- Multiple state changes between ticks are naturally coalesced to one final render snapshot.
- If content is added then removed before the next tick, the renderer can resolve to "no-op" instead of doing unnecessary add/remove churn.
Titlebar Content Hook Pattern
Use addWindow(..., { titlebarContentRenderer }) as the hook for zoom-independent titlebar UI.
Keep the renderer idempotent (upsert by label), then call markDirty() + queueResolve() whenever external
state changes. Use configureTitlebar for one-time setup after the titlebar store is created.
Use modifyInitialTitlebarParams for a startup-only functional parameter transform:
modifyInitialTitlebarParams: ({ state, config }) => ({ state, config }).
Use onResolve for general window-level pre-render logic that should run before content renderers:
onResolve: (state) => { ... }.
Renderer signature:
titlebarContentRenderer: ({
titlebarStore,
titlebarValue,
windowStore,
windowValue,
contentContainer,
localRect,
localScale,
}) => {
// add/update contentContainer children
}
windowContentRenderer: ({
windowStore,
windowValue,
contentContainer,
localRect,
localScale,
}) => {
// add/update contentContainer children
}
onResolve: (state) => {
// window-level pre-render hook
}
localRectis the known render bounds in the renderer container's local coordinate space.localScaleis the renderer container's effective local scale (x,y) for scale-aware layout.
import { Graphics, Text } from 'pixi.js';
windows.addWindow('notes', {
x: 120,
y: 100,
width: 420,
height: 280,
isDraggable: true,
titlebar: { title: 'Notes', height: 30 },
titlebarContentRenderer: ({ windowValue, contentContainer }) => {
const countLabel = 'titlebar-count';
let countText = contentContainer.getChildByLabel(countLabel) as Text | null;
if (!countText) {
countText = new Text({
text: '',
style: { fontSize: 12, fill: 0xffffff },
});
countText.label = countLabel;
countText.position.set(340, -6);
contentContainer.addChild(countText);
}
const pinLabel = 'titlebar-pin';
let pin = contentContainer.getChildByLabel(pinLabel) as Graphics | null;
if (!pin) {
pin = new Graphics({ label: pinLabel });
pin.circle(0, 0, 5).fill(0xffcc00);
pin.position.set(320, 0);
contentContainer.addChild(pin);
}
// Update values each render.
countText.text = `W:${Math.round(windowValue.width)}`;
},
});
windows.addWindow('notes', {
x: 120,
y: 100,
width: 420,
height: 280,
modifyInitialTitlebarParams: ({ state, config }) => ({
state: {
...state,
title: `${config.id.toUpperCase()} (${config.width}x${config.height})`,
},
}),
});
Content Generator Function Example
Use a generator/factory to produce a renderer that writes multiple items into the titlebar
contentContainer in one place.
import { Text } from 'pixi.js';
type TitlebarItem = {
id: string;
text: string;
x: number;
y: number;
};
function makeTitlebarContentRenderer(getItems: () => TitlebarItem[]) {
return ({ contentContainer }) => {
const active = new Set<string>();
for (const item of getItems()) {
const label = `tb-item-${item.id}`;
active.add(label);
let node = contentContainer.getChildByLabel(label) as Text | null;
if (!node) {
node = new Text({
text: '',
style: { fontSize: 11, fill: 0xffffff },
});
node.label = label;
contentContainer.addChild(node);
}
node.text = item.text;
node.position.set(item.x, item.y);
}
// Optional cleanup for removed items.
for (const child of contentContainer.children.slice()) {
const label = child.label ?? '';
if (label.startsWith('tb-item-') && !active.has(label)) {
contentContainer.removeChild(child);
child.destroy();
}
}
};
}
const getTitlebarItems = () => [
{ id: 'status', text: 'READY', x: 300, y: -6 },
{ id: 'count', text: '3', x: 370, y: -6 },
];
windows.addWindow('notes', {
x: 120,
y: 100,
width: 420,
height: 280,
modifyInitialTitlebarParams: ({ state, config }) => ({
state: { ...state, title: `Doc: ${config.id}` },
}),
titlebarContentRenderer: makeTitlebarContentRenderer(getTitlebarItems),
});
Window-Level Misc Hook (onResolve)
Use onResolve for window-level custom logic that should run before content renderers, without requiring a custom
WindowStore subclass or post-construction branch mutation.
import { Text } from 'pixi.js';
const snapshot = { renderCount: 0, width: 0, height: 0 };
windows.addWindow('notes', {
x: 120,
y: 100,
width: 420,
height: 280,
onResolve: (state) => {
snapshot.renderCount += 1;
snapshot.width = Math.round(state.width);
snapshot.height = Math.round(state.height);
},
titlebarContentRenderer: ({ contentContainer }) => {
const label = 'tb-size';
let text = contentContainer.getChildByLabel(label) as Text | null;
if (!text) {
text = new Text({ text: '', style: { fontSize: 11, fill: 0xffffff } });
text.label = label;
text.position.set(300, -6);
contentContainer.addChild(text);
}
text.text = `${snapshot.width}x${snapshot.height}`;
},
});
Complete Example: addWindow + zoom-independent titlebar
addWindow(...) does not need a special "scale titlebar" flag. The built-in TitlebarStore already enables
dirtyOnScale for Y-axis counter-scaling.
import { Application, Container, Graphics, Text } from 'pixi.js';
import { WindowsManager } from '@wonderlandlabs-pixi-ux/window';
const app = new Application();
await app.init({ width: 1200, height: 800 });
// Simulate editor zoom by scaling the parent container.
const zoomLayer = new Container();
zoomLayer.scale.set(1.75, 1.75);
app.stage.addChild(zoomLayer);
const windows = new WindowsManager({
app,
container: zoomLayer,
});
windows.addWindow('notes', {
x: 120,
y: 100,
width: 420,
height: 280,
isDraggable: true,
isResizeable: true,
titlebar: {
title: 'Notes',
height: 30,
},
titlebarContentRenderer: ({ contentContainer }) => {
const pin = contentContainer.getChildByLabel('pin-btn') as Graphics | null;
if (!pin) {
const nextPin = new Graphics({ label: 'pin-btn' });
nextPin.circle(0, 0, 6).fill(0xffcc00);
nextPin.position.set(380, 0);
contentContainer.addChild(nextPin);
}
},
windowContentRenderer: ({ contentContainer }) => {
const body = contentContainer.getChildByLabel('body-title') as Text | null;
if (!body) {
const text = new Text({
text: 'Body content via windowContentRenderer',
style: { fontSize: 14, fill: 0xffffff },
});
text.label = 'body-title';
text.position.set(12, 42);
contentContainer.addChild(text);
}
},
});
Why guardContainer?
The guardContainer exists to protect event listeners on rootContainer from being purged when PixiJS event models are changed. By wrapping rootContainer in a plain container with no event configuration, we ensure that:
- Event model changes on
rootContainerdon't affect parent containers - Child event listeners remain intact when parent event modes change
- Z-index management operates on
guardContainer(what's inwindowsContainer)
Key Classes
WindowsManager
Central manager for all windows. Extends Forest for state management.
const manager = new WindowsManager({
app: pixiApp,
container: parentContainer,
textures: [{ id: 'icon', url: '/icon.png' }]
});
manager.addWindow('myWindow', {
x: 100, y: 100,
width: 400, height: 300,
titlebar: { title: 'My Window' }
});
WindowStore
Individual window state and rendering. Extends TickerForest.
- Manages window position, size, and appearance
- Handles drag behavior (optional)
- Creates and manages titlebar via
TitlebarStore - Integrates with
ResizerStorefor resize handles
TitlebarStore
Titlebar state and rendering. Extends TickerForest.
- Renders title text and background
- Supports custom render functions
- Can include icons and buttons
Texture Management
WindowsManager provides centralized texture loading:
// Add textures to state
manager.mutate(draft => {
draft.textures.push({ id: 'myIcon', url: '/icon.png' });
});
// Load pending textures
manager.loadTextures();
// Check status
const status = manager.getTextureStatus('myIcon');
// Returns: 'missing' | 'pending' | 'loading' | 'loaded' | 'error'
// Use loaded texture
if (status === 'loaded') {
const texture = Assets.get('myIcon');
}
Z-Index Management
Windows support z-index ordering via the zIndex property:
manager.addWindow('back', { zIndex: 0, ... });
manager.addWindow('front', { zIndex: 10, ... });
The updateZIndices() method reorders guardContainers within windowsContainer.
Selection
Windows can be selected/deselected:
manager.selectWindow('myWindow');
manager.deselectWindow('myWindow');
manager.setSelectedWindow('myWindow'); // Exclusive selection
manager.toggleWindowSelection('myWindow');
const isSelected = manager.isWindowSelected('myWindow');
const selected = manager.getSelectedWindows(); // ReadonlySet<string>
Selected windows show a selection border and their resize handles become visible.