@signal9/era-ui 1.24.0 → 1.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -0
- package/dist/os/desktop.svelte +4 -1
- package/dist/os/index.d.ts +1 -1
- package/dist/os/taskbar.svelte +22 -3
- package/dist/os/window.svelte +14 -14
- package/dist/os/wm.svelte.d.ts +90 -51
- package/dist/os/wm.svelte.js +100 -36
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export * from './ui/index.js';
|
|
2
2
|
export * as OS from './os/index.js';
|
|
3
|
+
export type { Point, Size, Rect, Insets, Frame, Workspace, WindowState, SnapZone, AppWindow, AppDefinition, AppComponent, AppProps } from './os/index.js';
|
|
3
4
|
export { cn, tv, type PartProps, type VariantProps } from './utils/index.js';
|
package/dist/os/desktop.svelte
CHANGED
|
@@ -51,7 +51,10 @@
|
|
|
51
51
|
over the chrome / menus / toasts / launcher that render below (outside it)
|
|
52
52
|
at the document root. This is the whole layer contract in one line. -->
|
|
53
53
|
<div class="absolute inset-0 isolate">
|
|
54
|
-
|
|
54
|
+
<!-- Only the active workspace paints; other workspaces' windows keep
|
|
55
|
+
their state untouched and simply aren't in the tree. Keyed by id, so
|
|
56
|
+
switching back remounts them exactly where they were. -->
|
|
57
|
+
{#each wm.activeWindows as win (win.id)}
|
|
55
58
|
<Window window={win} />
|
|
56
59
|
{/each}
|
|
57
60
|
</div>
|
package/dist/os/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { WindowManager, getWindowManager, setWindowManagerContext, type AppDefinition, type AppWindow, type AppComponent, type AppProps, type WindowState, type SnapZone } from './wm.svelte.js';
|
|
1
|
+
export { WindowManager, getWindowManager, setWindowManagerContext, type Point, type Size, type Rect, type Insets, type Frame, type AppDefinition, type AppWindow, type AppComponent, type AppProps, type WindowState, type SnapZone, type Workspace } from './wm.svelte.js';
|
|
2
2
|
export { NotificationService, getNotifications, setNotificationContext, type AppNotification, type NotifyOptions, type NotificationAction, type NotificationTone } from './notifications.svelte.js';
|
|
3
3
|
export { LAYER } from './layers.js';
|
|
4
4
|
export { default as Desktop } from './desktop.svelte';
|
package/dist/os/taskbar.svelte
CHANGED
|
@@ -107,6 +107,24 @@
|
|
|
107
107
|
<LayoutGrid class="size-(--era-h-xs)" />
|
|
108
108
|
</Button>
|
|
109
109
|
|
|
110
|
+
<!-- Workspace switcher — appears only once there's something to switch
|
|
111
|
+
between; a single-workspace desktop stays free of dead chrome. -->
|
|
112
|
+
{#if wm.workspaces.length > 1}
|
|
113
|
+
<div class="flex items-center gap-(--era-gap)">
|
|
114
|
+
{#each wm.workspaces as ws (ws.id)}
|
|
115
|
+
<Button
|
|
116
|
+
size="default"
|
|
117
|
+
active={wm.activeWorkspaceId === ws.id}
|
|
118
|
+
aria-label="Switch to workspace {ws.name}"
|
|
119
|
+
onclick={() => wm.switchWorkspace(ws.id)}
|
|
120
|
+
>
|
|
121
|
+
{ws.name}
|
|
122
|
+
</Button>
|
|
123
|
+
{/each}
|
|
124
|
+
</div>
|
|
125
|
+
<div class="h-1/2 w-px shrink-0 bg-divider-faded"></div>
|
|
126
|
+
{/if}
|
|
127
|
+
|
|
110
128
|
{#if pinned.length}
|
|
111
129
|
<div class="flex items-center gap-(--era-gap)">
|
|
112
130
|
{#each pinned as p (p.appId)}
|
|
@@ -118,10 +136,11 @@
|
|
|
118
136
|
<div class="h-1/2 w-px shrink-0 bg-divider-faded"></div>
|
|
119
137
|
{/if}
|
|
120
138
|
|
|
121
|
-
<!-- Running windows: real Buttons — `active` latches
|
|
122
|
-
pressed face (surface-aware for free), click
|
|
139
|
+
<!-- Running windows on the active workspace: real Buttons — `active` latches
|
|
140
|
+
the focused window's pressed face (surface-aware for free), click
|
|
141
|
+
focuses / minimises. -->
|
|
123
142
|
<div class="scrollbar-none flex min-w-0 flex-1 items-center gap-(--era-gap) overflow-x-auto">
|
|
124
|
-
{#each wm.
|
|
143
|
+
{#each wm.activeWindows as win (win.id)}
|
|
125
144
|
<Button
|
|
126
145
|
active={wm.focusedId === win.id && win.state !== 'minimized'}
|
|
127
146
|
onclick={() => wm.toggleMinimize(win.id)}
|
package/dist/os/window.svelte
CHANGED
|
@@ -13,27 +13,27 @@
|
|
|
13
13
|
const wm = getWindowManager();
|
|
14
14
|
const focused = $derived(wm.focusedId === win.id);
|
|
15
15
|
|
|
16
|
-
// Shell containment: a window can never become unrecoverable. The bar may
|
|
17
|
-
// leave the TOP of the
|
|
18
|
-
// at least wm.dragKeep px of the window must stay inside the
|
|
19
|
-
// excludes
|
|
20
|
-
// Clamped mid-drag the way neodrag's own bounds
|
|
21
|
-
// proposed delta → clamp → re-propose the delta,
|
|
22
|
-
// pulling at the fence instead of
|
|
23
|
-
// hook (width read live), and a
|
|
24
|
-
//
|
|
16
|
+
// Shell containment: a window can never become unrecoverable. The bar may
|
|
17
|
+
// not leave the TOP of the work area at all (y ≥ workArea.y), and on every
|
|
18
|
+
// other side at least wm.dragKeep px of the window must stay inside the
|
|
19
|
+
// work area (which excludes chrome — a strip hidden behind the taskbar
|
|
20
|
+
// would be just as lost). Clamped mid-drag the way neodrag's own bounds
|
|
21
|
+
// plugin does it: offset + proposed delta → clamp → re-propose the delta,
|
|
22
|
+
// so the cursor simply stops pulling at the fence instead of
|
|
23
|
+
// rubber-banding on release. Only a `drag` hook (width read live), and a
|
|
24
|
+
// STABLE plugin list — an inline array would change identity every render
|
|
25
|
+
// and make neodrag reconcile mid-session.
|
|
25
26
|
const shellClamp: Plugin = {
|
|
26
27
|
name: 'era:shell-clamp',
|
|
27
28
|
drag(ctx) {
|
|
28
|
-
const
|
|
29
|
-
if (!
|
|
30
|
-
const { top, right, bottom, left } = wm.insets;
|
|
29
|
+
const area = wm.workArea;
|
|
30
|
+
if (!area.width || !area.height) return;
|
|
31
31
|
const keep = wm.dragKeep;
|
|
32
32
|
const width = (ctx.rootNode as HTMLElement).offsetWidth;
|
|
33
33
|
const px = ctx.offset.x + (ctx.proposed.x ?? 0);
|
|
34
34
|
const py = ctx.offset.y + (ctx.proposed.y ?? 0);
|
|
35
|
-
const cx = Math.min(Math.max(px,
|
|
36
|
-
const cy = Math.min(Math.max(py,
|
|
35
|
+
const cx = Math.min(Math.max(px, area.x + keep - width), area.x + area.width - keep);
|
|
36
|
+
const cy = Math.min(Math.max(py, area.y), area.y + area.height - keep);
|
|
37
37
|
ctx.propose(cx - ctx.offset.x, cy - ctx.offset.y);
|
|
38
38
|
}
|
|
39
39
|
};
|
package/dist/os/wm.svelte.d.ts
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
import { type Component } from 'svelte';
|
|
2
2
|
import type { IconProps } from '@lucide/svelte';
|
|
3
|
+
/** A point on the desktop plane. */
|
|
4
|
+
export interface Point {
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
}
|
|
8
|
+
/** A box's dimensions. */
|
|
9
|
+
export interface Size {
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
}
|
|
13
|
+
/** A positioned box — a point plus its dimensions. */
|
|
14
|
+
export interface Rect extends Point, Size {
|
|
15
|
+
}
|
|
16
|
+
/** Space reserved along each desktop edge (by chrome like the taskbar). */
|
|
17
|
+
export interface Insets {
|
|
18
|
+
top: number;
|
|
19
|
+
right: number;
|
|
20
|
+
bottom: number;
|
|
21
|
+
left: number;
|
|
22
|
+
}
|
|
23
|
+
/** A window's placement: where it sits, and its explicit size once one has
|
|
24
|
+
* been set (`null` = auto — the window is content-sized until the user or a
|
|
25
|
+
* preset gives it real dimensions, so we never invent numbers). */
|
|
26
|
+
export interface Frame {
|
|
27
|
+
pos: Point;
|
|
28
|
+
size: Size | null;
|
|
29
|
+
}
|
|
3
30
|
export type WindowState = 'normal' | 'minimized' | 'maximized';
|
|
4
31
|
/** Snap targets: the four edges (halves) or four corners (quadrants). */
|
|
5
32
|
export type SnapZone = 'left' | 'right' | 'top' | 'bottom' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
|
@@ -18,10 +45,7 @@ export interface AppDefinition {
|
|
|
18
45
|
icon?: Component<IconProps> | null;
|
|
19
46
|
/** Body renderer. Omit for a chromeless/placeholder window. */
|
|
20
47
|
component?: AppComponent;
|
|
21
|
-
defaultSize?:
|
|
22
|
-
width: number;
|
|
23
|
-
height: number;
|
|
24
|
-
};
|
|
48
|
+
defaultSize?: Size;
|
|
25
49
|
/** Only one live instance; opening again focuses the existing one. */
|
|
26
50
|
singleton?: boolean;
|
|
27
51
|
/** Show a resize grip (default true). */
|
|
@@ -34,84 +58,99 @@ export interface AppDefinition {
|
|
|
34
58
|
export interface AppWindow {
|
|
35
59
|
id: string;
|
|
36
60
|
appId: string;
|
|
61
|
+
/** The workspace this window lives on. */
|
|
62
|
+
workspaceId: string;
|
|
37
63
|
title: string;
|
|
38
64
|
icon?: Component<IconProps> | null;
|
|
39
|
-
/**
|
|
40
|
-
pos:
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
};
|
|
44
|
-
/** Explicit size once set — bound to Pane.Root's `size`. */
|
|
45
|
-
size: {
|
|
46
|
-
width: number;
|
|
47
|
-
height: number;
|
|
48
|
-
} | null;
|
|
65
|
+
/** Placement when 'normal' — bound straight to Pane.Root's `position`. */
|
|
66
|
+
pos: Point;
|
|
67
|
+
/** Explicit size once set — bound to Pane.Root's `size` (null = auto). */
|
|
68
|
+
size: Size | null;
|
|
49
69
|
/** Stacking order within the desktop (higher = nearer the front). */
|
|
50
70
|
z: number;
|
|
51
71
|
state: WindowState;
|
|
52
|
-
/**
|
|
53
|
-
restore:
|
|
54
|
-
pos: {
|
|
55
|
-
x: number;
|
|
56
|
-
y: number;
|
|
57
|
-
};
|
|
58
|
-
size: {
|
|
59
|
-
width: number;
|
|
60
|
-
height: number;
|
|
61
|
-
} | null;
|
|
62
|
-
} | null;
|
|
72
|
+
/** Frame snapshot to restore to after un-maximize / un-snap. */
|
|
73
|
+
restore: Frame | null;
|
|
63
74
|
component?: AppComponent;
|
|
64
75
|
resizable: boolean;
|
|
65
76
|
bodyClass?: string;
|
|
66
77
|
}
|
|
78
|
+
/** A named virtual desktop. Every window belongs to exactly one; only the
|
|
79
|
+
* active workspace's windows are shown, the rest keep their geometry and
|
|
80
|
+
* stacking untouched until switched back to. */
|
|
81
|
+
export interface Workspace {
|
|
82
|
+
id: string;
|
|
83
|
+
name: string;
|
|
84
|
+
}
|
|
67
85
|
/**
|
|
68
|
-
* The source of truth for the desktop: which windows exist,
|
|
69
|
-
* their stacking / focus order. Everything visual
|
|
70
|
-
*
|
|
86
|
+
* The source of truth for the desktop: which workspaces and windows exist,
|
|
87
|
+
* their geometry, and their stacking / focus order. Everything visual
|
|
88
|
+
* (Desktop, Window, Taskbar, Launcher, …) is a pure view of this. Runes-based,
|
|
89
|
+
* shared via context.
|
|
71
90
|
*/
|
|
72
91
|
export declare class WindowManager {
|
|
73
92
|
#private;
|
|
74
|
-
/** Every live window. Order is spawn order; paint
|
|
93
|
+
/** Every live window, across all workspaces. Order is spawn order; paint
|
|
94
|
+
* order is by `z`; visibility is by workspace. */
|
|
75
95
|
windows: AppWindow[];
|
|
76
96
|
/** Registered app templates, keyed by id. */
|
|
77
97
|
apps: Record<string, AppDefinition>;
|
|
98
|
+
/** The virtual desktops. Always at least one. */
|
|
99
|
+
workspaces: Workspace[];
|
|
100
|
+
/** Which workspace is on screen. */
|
|
101
|
+
activeWorkspaceId: string;
|
|
78
102
|
/** Desktop viewport size — set by the Desktop host; drives maximize / snap. */
|
|
79
|
-
bounds:
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
right: number;
|
|
88
|
-
bottom: number;
|
|
89
|
-
left: number;
|
|
90
|
-
};
|
|
91
|
-
/** Drag containment: a window's bar may never leave the top of the shell, and
|
|
92
|
-
* at least this many px of it must stay reachable on every other side. */
|
|
103
|
+
bounds: Size;
|
|
104
|
+
/** Edges reserved by chrome (the Taskbar registers its footprint here).
|
|
105
|
+
* The work area = bounds − insets; maximize, snap and drag containment
|
|
106
|
+
* honour it. */
|
|
107
|
+
insets: Insets;
|
|
108
|
+
/** Drag containment: a window's bar may never leave the top of the work
|
|
109
|
+
* area, and at least this many px of it must stay reachable on every
|
|
110
|
+
* other side. */
|
|
93
111
|
dragKeep: number;
|
|
94
|
-
/** The
|
|
112
|
+
/** The usable desktop: bounds minus reserved chrome. Everything that places
|
|
113
|
+
* a window against "the screen" (maximize, snap, containment) uses this,
|
|
114
|
+
* never raw bounds — a strip hidden behind chrome is as lost as one
|
|
115
|
+
* off-screen. */
|
|
116
|
+
workArea: Rect;
|
|
117
|
+
/** The active workspace's windows — what the Desktop paints and the
|
|
118
|
+
* Taskbar lists. */
|
|
119
|
+
activeWindows: AppWindow[];
|
|
120
|
+
/** The focused window = the top-most non-minimized one on the active
|
|
121
|
+
* workspace. */
|
|
95
122
|
focusedId: string | null;
|
|
96
123
|
register(app: AppDefinition): void;
|
|
97
124
|
registerMany(apps: AppDefinition[]): void;
|
|
98
|
-
/** Spawn a window for an app (or focus the existing one if it's a
|
|
99
|
-
|
|
125
|
+
/** Spawn a window for an app (or focus the existing one if it's a
|
|
126
|
+
* singleton — from any workspace; it is brought to the active one). */
|
|
127
|
+
open(appId: string, overrides?: Partial<Pick<AppWindow, 'title' | 'pos' | 'size' | 'resizable' | 'workspaceId'>>): string | null;
|
|
100
128
|
close(id: string): void;
|
|
101
129
|
/** Bring a window to the front (and un-minimize it if needed). */
|
|
102
130
|
focus(id: string): void;
|
|
103
131
|
minimize(id: string): void;
|
|
104
132
|
/** Taskbar behaviour: focus if not focused, else minimize; restore if minimized. */
|
|
105
133
|
toggleMinimize(id: string): void;
|
|
106
|
-
/** Fill the
|
|
107
|
-
*
|
|
108
|
-
* first pre-maximize/pre-snap snapshot so restore returns to the true origin. */
|
|
134
|
+
/** Fill the work area (middle-click on the window's ×). Keeps the first
|
|
135
|
+
* pre-maximize/pre-snap frame so restore returns to the true origin. */
|
|
109
136
|
maximize(id: string): void;
|
|
110
137
|
toggleMaximize(id: string): void;
|
|
111
|
-
/** Un-maximize / un-snap back to the pre-change
|
|
138
|
+
/** Un-maximize / un-snap back to the pre-change frame (or just un-minimize). */
|
|
112
139
|
restore(id: string): void;
|
|
113
|
-
/** Half / quadrant tiling against the
|
|
140
|
+
/** Half / quadrant tiling against the work area. */
|
|
114
141
|
snap(id: string, zone: SnapZone): void;
|
|
142
|
+
/** Create a workspace (optionally switching to it) and return its id. */
|
|
143
|
+
addWorkspace(name?: string, { activate }?: {
|
|
144
|
+
activate?: boolean | undefined;
|
|
145
|
+
}): string;
|
|
146
|
+
/** Show a workspace. Windows on others keep their geometry and stacking. */
|
|
147
|
+
switchWorkspace(id: string): void;
|
|
148
|
+
/** Remove a workspace. Its windows migrate to the fallback (the active
|
|
149
|
+
* workspace, or the first remaining one) — closing a desktop never
|
|
150
|
+
* silently closes the work on it. The last workspace can't be removed. */
|
|
151
|
+
removeWorkspace(id: string): void;
|
|
152
|
+
/** Send a window to another workspace (it stays where it was on screen). */
|
|
153
|
+
moveToWorkspace(windowId: string, workspaceId: string): void;
|
|
115
154
|
}
|
|
116
155
|
export declare function setWindowManagerContext(wm: WindowManager): WindowManager;
|
|
117
156
|
export declare function getWindowManager(): WindowManager;
|
package/dist/os/wm.svelte.js
CHANGED
|
@@ -1,29 +1,55 @@
|
|
|
1
1
|
import { getContext, setContext } from 'svelte';
|
|
2
2
|
const DEFAULT_SIZE = { width: 480, height: 320 };
|
|
3
3
|
/**
|
|
4
|
-
* The source of truth for the desktop: which windows exist,
|
|
5
|
-
* their stacking / focus order. Everything visual
|
|
6
|
-
*
|
|
4
|
+
* The source of truth for the desktop: which workspaces and windows exist,
|
|
5
|
+
* their geometry, and their stacking / focus order. Everything visual
|
|
6
|
+
* (Desktop, Window, Taskbar, Launcher, …) is a pure view of this. Runes-based,
|
|
7
|
+
* shared via context.
|
|
7
8
|
*/
|
|
8
9
|
export class WindowManager {
|
|
9
|
-
/** Every live window. Order is spawn order; paint
|
|
10
|
+
/** Every live window, across all workspaces. Order is spawn order; paint
|
|
11
|
+
* order is by `z`; visibility is by workspace. */
|
|
10
12
|
windows = $state([]);
|
|
11
13
|
/** Registered app templates, keyed by id. */
|
|
12
14
|
apps = $state.raw({});
|
|
15
|
+
/** The virtual desktops. Always at least one. */
|
|
16
|
+
workspaces = $state([{ id: 'main', name: 'Main' }]);
|
|
17
|
+
/** Which workspace is on screen. */
|
|
18
|
+
activeWorkspaceId = $state('main');
|
|
13
19
|
/** Desktop viewport size — set by the Desktop host; drives maximize / snap. */
|
|
14
20
|
bounds = $state({ width: 0, height: 0 });
|
|
15
|
-
/** Edges reserved by chrome (the Taskbar registers its
|
|
16
|
-
* The work area = bounds − insets; maximize and drag containment
|
|
21
|
+
/** Edges reserved by chrome (the Taskbar registers its footprint here).
|
|
22
|
+
* The work area = bounds − insets; maximize, snap and drag containment
|
|
23
|
+
* honour it. */
|
|
17
24
|
insets = $state({ top: 0, right: 0, bottom: 0, left: 0 });
|
|
18
|
-
/** Drag containment: a window's bar may never leave the top of the
|
|
19
|
-
* at least this many px of it must stay reachable on every
|
|
25
|
+
/** Drag containment: a window's bar may never leave the top of the work
|
|
26
|
+
* area, and at least this many px of it must stay reachable on every
|
|
27
|
+
* other side. */
|
|
20
28
|
dragKeep = 25;
|
|
21
29
|
#z = 0;
|
|
22
30
|
#seq = 0;
|
|
23
|
-
|
|
31
|
+
#wsSeq = 0;
|
|
32
|
+
/** The usable desktop: bounds minus reserved chrome. Everything that places
|
|
33
|
+
* a window against "the screen" (maximize, snap, containment) uses this,
|
|
34
|
+
* never raw bounds — a strip hidden behind chrome is as lost as one
|
|
35
|
+
* off-screen. */
|
|
36
|
+
workArea = $derived.by(() => {
|
|
37
|
+
const { top, right, bottom, left } = this.insets;
|
|
38
|
+
return {
|
|
39
|
+
x: left,
|
|
40
|
+
y: top,
|
|
41
|
+
width: Math.max(0, this.bounds.width - left - right),
|
|
42
|
+
height: Math.max(0, this.bounds.height - top - bottom)
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
/** The active workspace's windows — what the Desktop paints and the
|
|
46
|
+
* Taskbar lists. */
|
|
47
|
+
activeWindows = $derived.by(() => this.windows.filter((w) => w.workspaceId === this.activeWorkspaceId));
|
|
48
|
+
/** The focused window = the top-most non-minimized one on the active
|
|
49
|
+
* workspace. */
|
|
24
50
|
focusedId = $derived.by(() => {
|
|
25
51
|
let top = null;
|
|
26
|
-
for (const w of this.
|
|
52
|
+
for (const w of this.activeWindows) {
|
|
27
53
|
if (w.state === 'minimized')
|
|
28
54
|
continue;
|
|
29
55
|
if (!top || w.z > top.z)
|
|
@@ -31,6 +57,7 @@ export class WindowManager {
|
|
|
31
57
|
}
|
|
32
58
|
return top?.id ?? null;
|
|
33
59
|
});
|
|
60
|
+
/* ---------------- apps ---------------- */
|
|
34
61
|
register(app) {
|
|
35
62
|
this.apps = { ...this.apps, [app.id]: app };
|
|
36
63
|
}
|
|
@@ -40,7 +67,9 @@ export class WindowManager {
|
|
|
40
67
|
#find(id) {
|
|
41
68
|
return this.windows.find((w) => w.id === id);
|
|
42
69
|
}
|
|
43
|
-
|
|
70
|
+
/* ---------------- windows ---------------- */
|
|
71
|
+
/** Spawn a window for an app (or focus the existing one if it's a
|
|
72
|
+
* singleton — from any workspace; it is brought to the active one). */
|
|
44
73
|
open(appId, overrides) {
|
|
45
74
|
const app = this.apps[appId];
|
|
46
75
|
if (!app)
|
|
@@ -48,19 +77,23 @@ export class WindowManager {
|
|
|
48
77
|
if (app.singleton) {
|
|
49
78
|
const existing = this.windows.find((w) => w.appId === appId);
|
|
50
79
|
if (existing) {
|
|
80
|
+
existing.workspaceId = overrides?.workspaceId ?? this.activeWorkspaceId;
|
|
51
81
|
this.restore(existing.id);
|
|
52
82
|
return existing.id;
|
|
53
83
|
}
|
|
54
84
|
}
|
|
55
85
|
const id = `${appId}#${++this.#seq}`;
|
|
56
|
-
// Cascade so stacked spawns don't land
|
|
86
|
+
// Cascade from the work area's origin so stacked spawns don't land
|
|
87
|
+
// exactly on top of each other (or under top chrome).
|
|
57
88
|
const n = this.windows.length % 8;
|
|
89
|
+
const { x, y } = this.workArea;
|
|
58
90
|
const win = {
|
|
59
91
|
id,
|
|
60
92
|
appId,
|
|
93
|
+
workspaceId: overrides?.workspaceId ?? this.activeWorkspaceId,
|
|
61
94
|
title: overrides?.title ?? app.title,
|
|
62
95
|
icon: app.icon,
|
|
63
|
-
pos: overrides?.pos ?? { x: 48 + n * 28, y:
|
|
96
|
+
pos: overrides?.pos ?? { x: x + 48 + n * 28, y: y + 24 + n * 28 },
|
|
64
97
|
size: overrides?.size ?? app.defaultSize ?? { ...DEFAULT_SIZE },
|
|
65
98
|
z: ++this.#z,
|
|
66
99
|
state: 'normal',
|
|
@@ -102,21 +135,17 @@ export class WindowManager {
|
|
|
102
135
|
else
|
|
103
136
|
this.focus(id);
|
|
104
137
|
}
|
|
105
|
-
/** Fill the
|
|
106
|
-
*
|
|
107
|
-
* first pre-maximize/pre-snap snapshot so restore returns to the true origin. */
|
|
138
|
+
/** Fill the work area (middle-click on the window's ×). Keeps the first
|
|
139
|
+
* pre-maximize/pre-snap frame so restore returns to the true origin. */
|
|
108
140
|
maximize(id) {
|
|
109
141
|
const w = this.#find(id);
|
|
110
142
|
if (!w)
|
|
111
143
|
return;
|
|
112
144
|
if (!w.restore)
|
|
113
145
|
w.restore = { pos: { ...w.pos }, size: w.size ? { ...w.size } : null };
|
|
114
|
-
const {
|
|
115
|
-
w.pos = { x
|
|
116
|
-
w.size = {
|
|
117
|
-
width: this.bounds.width - left - right,
|
|
118
|
-
height: this.bounds.height - top - bottom
|
|
119
|
-
};
|
|
146
|
+
const { x, y, width, height } = this.workArea;
|
|
147
|
+
w.pos = { x, y };
|
|
148
|
+
w.size = { width, height };
|
|
120
149
|
w.state = 'maximized';
|
|
121
150
|
this.focus(id);
|
|
122
151
|
}
|
|
@@ -129,7 +158,7 @@ export class WindowManager {
|
|
|
129
158
|
else
|
|
130
159
|
this.maximize(id);
|
|
131
160
|
}
|
|
132
|
-
/** Un-maximize / un-snap back to the pre-change
|
|
161
|
+
/** Un-maximize / un-snap back to the pre-change frame (or just un-minimize). */
|
|
133
162
|
restore(id) {
|
|
134
163
|
const w = this.#find(id);
|
|
135
164
|
if (!w)
|
|
@@ -142,33 +171,68 @@ export class WindowManager {
|
|
|
142
171
|
w.state = 'normal';
|
|
143
172
|
this.focus(id);
|
|
144
173
|
}
|
|
145
|
-
/** Half / quadrant tiling against the
|
|
174
|
+
/** Half / quadrant tiling against the work area. */
|
|
146
175
|
snap(id, zone) {
|
|
147
176
|
const w = this.#find(id);
|
|
148
177
|
if (!w)
|
|
149
178
|
return;
|
|
150
|
-
const { width: W, height: H } = this.
|
|
179
|
+
const { x, y, width: W, height: H } = this.workArea;
|
|
151
180
|
const halfW = Math.round(W / 2);
|
|
152
181
|
const halfH = Math.round(H / 2);
|
|
153
182
|
const rects = {
|
|
154
|
-
left:
|
|
155
|
-
right:
|
|
156
|
-
top:
|
|
157
|
-
bottom:
|
|
158
|
-
'top-left':
|
|
159
|
-
'top-right':
|
|
160
|
-
'bottom-left':
|
|
161
|
-
'bottom-right':
|
|
183
|
+
left: { x, y, width: halfW, height: H },
|
|
184
|
+
right: { x: x + halfW, y, width: W - halfW, height: H },
|
|
185
|
+
top: { x, y, width: W, height: halfH },
|
|
186
|
+
bottom: { x, y: y + halfH, width: W, height: H - halfH },
|
|
187
|
+
'top-left': { x, y, width: halfW, height: halfH },
|
|
188
|
+
'top-right': { x: x + halfW, y, width: W - halfW, height: halfH },
|
|
189
|
+
'bottom-left': { x, y: y + halfH, width: halfW, height: H - halfH },
|
|
190
|
+
'bottom-right': { x: x + halfW, y: y + halfH, width: W - halfW, height: H - halfH }
|
|
162
191
|
};
|
|
163
|
-
const
|
|
192
|
+
const rect = rects[zone];
|
|
164
193
|
if (!w.restore) {
|
|
165
194
|
w.restore = { pos: { ...w.pos }, size: w.size ? { ...w.size } : null };
|
|
166
195
|
}
|
|
167
|
-
w.pos = { x, y };
|
|
168
|
-
w.size = { width, height };
|
|
196
|
+
w.pos = { x: rect.x, y: rect.y };
|
|
197
|
+
w.size = { width: rect.width, height: rect.height };
|
|
169
198
|
w.state = 'normal';
|
|
170
199
|
this.focus(id);
|
|
171
200
|
}
|
|
201
|
+
/* ---------------- workspaces ---------------- */
|
|
202
|
+
/** Create a workspace (optionally switching to it) and return its id. */
|
|
203
|
+
addWorkspace(name, { activate = true } = {}) {
|
|
204
|
+
const id = `ws-${++this.#wsSeq}`;
|
|
205
|
+
this.workspaces.push({ id, name: name ?? `Workspace ${this.workspaces.length + 1}` });
|
|
206
|
+
if (activate)
|
|
207
|
+
this.activeWorkspaceId = id;
|
|
208
|
+
return id;
|
|
209
|
+
}
|
|
210
|
+
/** Show a workspace. Windows on others keep their geometry and stacking. */
|
|
211
|
+
switchWorkspace(id) {
|
|
212
|
+
if (this.workspaces.some((ws) => ws.id === id))
|
|
213
|
+
this.activeWorkspaceId = id;
|
|
214
|
+
}
|
|
215
|
+
/** Remove a workspace. Its windows migrate to the fallback (the active
|
|
216
|
+
* workspace, or the first remaining one) — closing a desktop never
|
|
217
|
+
* silently closes the work on it. The last workspace can't be removed. */
|
|
218
|
+
removeWorkspace(id) {
|
|
219
|
+
if (this.workspaces.length <= 1)
|
|
220
|
+
return;
|
|
221
|
+
this.workspaces = this.workspaces.filter((ws) => ws.id !== id);
|
|
222
|
+
if (this.activeWorkspaceId === id)
|
|
223
|
+
this.activeWorkspaceId = this.workspaces[0].id;
|
|
224
|
+
for (const w of this.windows) {
|
|
225
|
+
if (w.workspaceId === id)
|
|
226
|
+
w.workspaceId = this.activeWorkspaceId;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/** Send a window to another workspace (it stays where it was on screen). */
|
|
230
|
+
moveToWorkspace(windowId, workspaceId) {
|
|
231
|
+
const w = this.#find(windowId);
|
|
232
|
+
if (!w || !this.workspaces.some((ws) => ws.id === workspaceId))
|
|
233
|
+
return;
|
|
234
|
+
w.workspaceId = workspaceId;
|
|
235
|
+
}
|
|
172
236
|
}
|
|
173
237
|
const WM_KEY = Symbol('era-wm');
|
|
174
238
|
export function setWindowManagerContext(wm) {
|