@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 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';
@@ -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
- {#each wm.windows as win (win.id)}
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>
@@ -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';
@@ -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 the focused window's
122
- pressed face (surface-aware for free), click focuses / minimises. -->
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.windows as win (win.id)}
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)}
@@ -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 not
17
- // leave the TOP of the shell at all (y ≥ top inset), and on every other side
18
- // at least wm.dragKeep px of the window must stay inside the work area (which
19
- // excludes the taskbar — a strip hidden behind chrome would be just as lost).
20
- // Clamped mid-drag the way neodrag's own bounds plugin does it: offset +
21
- // proposed delta → clamp → re-propose the delta, so the cursor simply stops
22
- // pulling at the fence instead of rubber-banding on release. Only a `drag`
23
- // hook (width read live), and a STABLE plugin list — an inline array would
24
- // change identity every render and make neodrag reconcile mid-session.
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 { width: W, height: H } = wm.bounds;
29
- if (!W || !H) return;
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, left + keep - width), W - right - keep);
36
- const cy = Math.min(Math.max(py, top), H - bottom - keep);
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
  };
@@ -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
- /** neodrag offset — bound straight to Pane.Root's `position`. */
40
- pos: {
41
- x: number;
42
- y: number;
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
- /** Geometry snapshot to restore to after un-maximize / un-snap. */
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, their geometry, and
69
- * their stacking / focus order. Everything visual (Desktop, Window, Taskbar,
70
- * Alt-Tab, …) is a pure view of this. Runes-based, shared via context.
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 order is by `z`. */
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
- width: number;
81
- height: number;
82
- };
83
- /** Edges reserved by chrome (the Taskbar registers its height as `bottom`).
84
- * The work area = bounds insets; maximize and drag containment honour it. */
85
- insets: {
86
- top: number;
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 focused window = the top-most non-minimized one. */
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 singleton). */
99
- open(appId: string, overrides?: Partial<Pick<AppWindow, 'title' | 'pos' | 'size' | 'resizable'>>): string | null;
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 WORK AREA bounds minus chrome insets, so a maximized window
107
- * doesn't hide under the taskbar (middle-click on the window's ×). Keeps the
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 geometry (or just un-minimize). */
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 desktop bounds. */
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;
@@ -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, their geometry, and
5
- * their stacking / focus order. Everything visual (Desktop, Window, Taskbar,
6
- * Alt-Tab, …) is a pure view of this. Runes-based, shared via context.
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 order is by `z`. */
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 height as `bottom`).
16
- * The work area = bounds − insets; maximize and drag containment honour it. */
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 shell, and
19
- * at least this many px of it must stay reachable on every other side. */
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
- /** The focused window = the top-most non-minimized one. */
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.windows) {
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
- /** Spawn a window for an app (or focus the existing one if it's a singleton). */
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 exactly on top of each other.
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: 48 + n * 28 },
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 WORK AREA bounds minus chrome insets, so a maximized window
106
- * doesn't hide under the taskbar (middle-click on the window's ×). Keeps the
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 { top, right, bottom, left } = this.insets;
115
- w.pos = { x: left, y: top };
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 geometry (or just un-minimize). */
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 desktop bounds. */
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.bounds;
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: [0, 0, halfW, H],
155
- right: [halfW, 0, W - halfW, H],
156
- top: [0, 0, W, halfH],
157
- bottom: [0, halfH, W, H - halfH],
158
- 'top-left': [0, 0, halfW, halfH],
159
- 'top-right': [halfW, 0, W - halfW, halfH],
160
- 'bottom-left': [0, halfH, halfW, H - halfH],
161
- 'bottom-right': [halfW, halfH, W - halfW, H - halfH]
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 [x, y, width, height] = rects[zone];
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signal9/era-ui",
3
- "version": "1.24.0",
3
+ "version": "1.25.0",
4
4
  "scripts": {
5
5
  "dev": "vite dev --host",
6
6
  "build": "vite build && npm run prepack",