@backbay/glia-desktop 0.2.0-alpha.1
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/package.json +37 -0
- package/src/components/GliaErrorBoundary/GliaErrorBoundary.tsx +202 -0
- package/src/components/GliaErrorBoundary/index.ts +2 -0
- package/src/components/GliaErrorBoundary/useErrorBoundary.tsx +61 -0
- package/src/components/desktop/Desktop.tsx +204 -0
- package/src/components/desktop/DesktopIcon.tsx +293 -0
- package/src/components/desktop/FileBrowser.stories.tsx +287 -0
- package/src/components/desktop/FileBrowser.tsx +981 -0
- package/src/components/desktop/SnapZoneOverlay.tsx +230 -0
- package/src/components/desktop/index.ts +15 -0
- package/src/components/index.ts +16 -0
- package/src/components/shell/Clock.tsx +212 -0
- package/src/components/shell/ContextMenu.tsx +249 -0
- package/src/components/shell/GlassMenubar.stories.tsx +382 -0
- package/src/components/shell/GlassMenubar.tsx +632 -0
- package/src/components/shell/NotificationCenter.stories.tsx +515 -0
- package/src/components/shell/NotificationCenter.tsx +545 -0
- package/src/components/shell/NotificationToast.tsx +319 -0
- package/src/components/shell/StartMenu.stories.tsx +249 -0
- package/src/components/shell/StartMenu.tsx +568 -0
- package/src/components/shell/SystemTray.stories.tsx +492 -0
- package/src/components/shell/SystemTray.tsx +457 -0
- package/src/components/shell/Taskbar.tsx +387 -0
- package/src/components/shell/TaskbarButton.tsx +208 -0
- package/src/components/shell/index.ts +37 -0
- package/src/components/window/Window.tsx +751 -0
- package/src/components/window/WindowTitlebar.tsx +359 -0
- package/src/components/window/index.ts +10 -0
- package/src/core/desktop/fileBrowserTypes.ts +112 -0
- package/src/core/desktop/index.ts +8 -0
- package/src/core/desktop/types.ts +185 -0
- package/src/core/desktop/useFileBrowser.tsx +405 -0
- package/src/core/desktop/useSnapZones.tsx +203 -0
- package/src/core/index.ts +11 -0
- package/src/core/shell/__tests__/useNotifications.test.ts +155 -0
- package/src/core/shell/__tests__/useTaskbar.test.ts +99 -0
- package/src/core/shell/index.ts +10 -0
- package/src/core/shell/notificationTypes.ts +110 -0
- package/src/core/shell/types.ts +194 -0
- package/src/core/shell/useNotifications.tsx +259 -0
- package/src/core/shell/useStartMenu.tsx +242 -0
- package/src/core/shell/useSystemTray.tsx +175 -0
- package/src/core/shell/useTaskbar.tsx +320 -0
- package/src/core/useKeyboardNavigation.ts +41 -0
- package/src/core/window/__tests__/useWindowManager.test.ts +269 -0
- package/src/core/window/index.ts +6 -0
- package/src/core/window/types.ts +149 -0
- package/src/core/window/useWindowManager.tsx +1154 -0
- package/src/index.ts +146 -0
- package/src/lib/utils.ts +6 -0
- package/src/providers/DesktopOSProvider.tsx +391 -0
- package/src/providers/ThemeProvider.tsx +162 -0
- package/src/providers/index.ts +6 -0
- package/src/themes/default.ts +107 -0
- package/src/themes/index.ts +6 -0
- package/src/themes/types.ts +230 -0
- package/tsconfig.json +20 -0
- package/tsup.config.ts +16 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @backbay/glia Desktop OS - useTaskbar Hook
|
|
3
|
+
*
|
|
4
|
+
* Taskbar state management for button positions and hover previews.
|
|
5
|
+
* Used for minimize/restore animations and window preview tooltips.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useMemo, createContext, useContext, useState, type ReactNode } from 'react';
|
|
9
|
+
import { create, createStore, useStore } from 'zustand';
|
|
10
|
+
import { useShallow } from 'zustand/react/shallow';
|
|
11
|
+
import type { WindowId } from '../window/types';
|
|
12
|
+
import type {
|
|
13
|
+
TaskbarButtonPosition,
|
|
14
|
+
TaskbarPreviewState,
|
|
15
|
+
TaskbarItem,
|
|
16
|
+
UseTaskbarReturn,
|
|
17
|
+
} from './types';
|
|
18
|
+
|
|
19
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
20
|
+
// Constants
|
|
21
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
22
|
+
|
|
23
|
+
/** Delay before showing preview (ms) */
|
|
24
|
+
const PREVIEW_SHOW_DELAY = 200;
|
|
25
|
+
|
|
26
|
+
/** Delay before hiding preview - allows moving to preview (ms) */
|
|
27
|
+
const PREVIEW_HIDE_DELAY = 100;
|
|
28
|
+
|
|
29
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
30
|
+
// Store Interface
|
|
31
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
32
|
+
|
|
33
|
+
interface TaskbarStore {
|
|
34
|
+
buttonPositions: Map<string, TaskbarButtonPosition>;
|
|
35
|
+
previewState: TaskbarPreviewState | null;
|
|
36
|
+
pinnedApps: string[];
|
|
37
|
+
_showPreviewTimeout: ReturnType<typeof setTimeout> | null;
|
|
38
|
+
_hidePreviewTimeout: ReturnType<typeof setTimeout> | null;
|
|
39
|
+
|
|
40
|
+
registerButtonPosition: (id: string, position: TaskbarButtonPosition) => void;
|
|
41
|
+
unregisterButtonPosition: (id: string) => void;
|
|
42
|
+
getButtonPosition: (id: string) => TaskbarButtonPosition | undefined;
|
|
43
|
+
getButtonCenter: (id: string) => { x: number; y: number } | undefined;
|
|
44
|
+
|
|
45
|
+
startPreviewHover: (windowId: WindowId, icon?: string) => void;
|
|
46
|
+
endPreviewHover: () => void;
|
|
47
|
+
cancelHidePreview: () => void;
|
|
48
|
+
hidePreview: () => void;
|
|
49
|
+
|
|
50
|
+
pinApp: (appId: string) => void;
|
|
51
|
+
unpinApp: (appId: string) => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
55
|
+
// Store Factory
|
|
56
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
57
|
+
|
|
58
|
+
function createTaskbarStoreImpl(set: any, get: any): TaskbarStore {
|
|
59
|
+
return {
|
|
60
|
+
buttonPositions: new Map(),
|
|
61
|
+
previewState: null,
|
|
62
|
+
pinnedApps: [],
|
|
63
|
+
_showPreviewTimeout: null,
|
|
64
|
+
_hidePreviewTimeout: null,
|
|
65
|
+
|
|
66
|
+
registerButtonPosition: (id, position) => {
|
|
67
|
+
set((state: TaskbarStore) => {
|
|
68
|
+
const newPositions = new Map(state.buttonPositions);
|
|
69
|
+
newPositions.set(id, position);
|
|
70
|
+
return { buttonPositions: newPositions };
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
unregisterButtonPosition: (id) => {
|
|
75
|
+
set((state: TaskbarStore) => {
|
|
76
|
+
const newPositions = new Map(state.buttonPositions);
|
|
77
|
+
newPositions.delete(id);
|
|
78
|
+
return { buttonPositions: newPositions };
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
getButtonPosition: (id) => {
|
|
83
|
+
return get().buttonPositions.get(id);
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
getButtonCenter: (id) => {
|
|
87
|
+
const pos = get().buttonPositions.get(id);
|
|
88
|
+
if (!pos) return undefined;
|
|
89
|
+
return {
|
|
90
|
+
x: pos.x + pos.width / 2,
|
|
91
|
+
y: pos.y + pos.height / 2,
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
startPreviewHover: (windowId, icon) => {
|
|
96
|
+
const state = get();
|
|
97
|
+
|
|
98
|
+
if (state._hidePreviewTimeout) {
|
|
99
|
+
clearTimeout(state._hidePreviewTimeout);
|
|
100
|
+
set({ _hidePreviewTimeout: null });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (state._showPreviewTimeout) {
|
|
104
|
+
clearTimeout(state._showPreviewTimeout);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const timeout = setTimeout(() => {
|
|
108
|
+
const currentState = get();
|
|
109
|
+
const anchorRect = currentState.buttonPositions.get(windowId);
|
|
110
|
+
if (anchorRect) {
|
|
111
|
+
set({
|
|
112
|
+
previewState: { windowId, anchorRect, icon },
|
|
113
|
+
_showPreviewTimeout: null,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}, PREVIEW_SHOW_DELAY);
|
|
117
|
+
|
|
118
|
+
set({ _showPreviewTimeout: timeout });
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
endPreviewHover: () => {
|
|
122
|
+
const state = get();
|
|
123
|
+
|
|
124
|
+
if (state._showPreviewTimeout) {
|
|
125
|
+
clearTimeout(state._showPreviewTimeout);
|
|
126
|
+
set({ _showPreviewTimeout: null });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (state._hidePreviewTimeout) {
|
|
130
|
+
clearTimeout(state._hidePreviewTimeout);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const timeout = setTimeout(() => {
|
|
134
|
+
set({ previewState: null, _hidePreviewTimeout: null });
|
|
135
|
+
}, PREVIEW_HIDE_DELAY);
|
|
136
|
+
|
|
137
|
+
set({ _hidePreviewTimeout: timeout });
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
cancelHidePreview: () => {
|
|
141
|
+
const state = get();
|
|
142
|
+
|
|
143
|
+
if (state._hidePreviewTimeout) {
|
|
144
|
+
clearTimeout(state._hidePreviewTimeout);
|
|
145
|
+
set({ _hidePreviewTimeout: null });
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
hidePreview: () => {
|
|
150
|
+
const state = get();
|
|
151
|
+
|
|
152
|
+
if (state._showPreviewTimeout) {
|
|
153
|
+
clearTimeout(state._showPreviewTimeout);
|
|
154
|
+
}
|
|
155
|
+
if (state._hidePreviewTimeout) {
|
|
156
|
+
clearTimeout(state._hidePreviewTimeout);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
set({
|
|
160
|
+
previewState: null,
|
|
161
|
+
_showPreviewTimeout: null,
|
|
162
|
+
_hidePreviewTimeout: null,
|
|
163
|
+
});
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
pinApp: (appId) => {
|
|
167
|
+
set((state: TaskbarStore) => ({
|
|
168
|
+
pinnedApps: state.pinnedApps.includes(appId)
|
|
169
|
+
? state.pinnedApps
|
|
170
|
+
: [...state.pinnedApps, appId],
|
|
171
|
+
}));
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
unpinApp: (appId) => {
|
|
175
|
+
set((state: TaskbarStore) => ({
|
|
176
|
+
pinnedApps: state.pinnedApps.filter((id: string) => id !== appId),
|
|
177
|
+
}));
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Factory: creates an isolated Taskbar store instance. */
|
|
183
|
+
export function createTaskbarStore() {
|
|
184
|
+
return createStore<TaskbarStore>((set, get) => createTaskbarStoreImpl(set, get));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export type TaskbarStoreApi = ReturnType<typeof createTaskbarStore>;
|
|
188
|
+
|
|
189
|
+
const TaskbarStoreContext = createContext<TaskbarStoreApi | null>(null);
|
|
190
|
+
|
|
191
|
+
/** Provider that creates an isolated Taskbar store for its subtree. */
|
|
192
|
+
export function TaskbarStoreProvider({ children }: { children: ReactNode }) {
|
|
193
|
+
const [store] = useState(() => createTaskbarStore());
|
|
194
|
+
return (
|
|
195
|
+
<TaskbarStoreContext.Provider value={store}>
|
|
196
|
+
{children}
|
|
197
|
+
</TaskbarStoreContext.Provider>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Legacy singleton
|
|
202
|
+
export const useTaskbarStore = create<TaskbarStore>((set, get) => createTaskbarStoreImpl(set, get));
|
|
203
|
+
|
|
204
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
205
|
+
// Context-aware store resolver
|
|
206
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
207
|
+
|
|
208
|
+
/** Returns the context-provided store if available, otherwise the singleton. */
|
|
209
|
+
function useResolvedTaskbarStore(): import('zustand').StoreApi<TaskbarStore> {
|
|
210
|
+
const contextStore = useContext(TaskbarStoreContext);
|
|
211
|
+
return contextStore ?? useTaskbarStore;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
215
|
+
// Public Hook
|
|
216
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Hook for taskbar state management.
|
|
220
|
+
*
|
|
221
|
+
* Handles button position tracking for animations and hover preview state.
|
|
222
|
+
*
|
|
223
|
+
* @example
|
|
224
|
+
* ```tsx
|
|
225
|
+
* const { registerButtonPosition, startPreviewHover, endPreviewHover } = useTaskbar();
|
|
226
|
+
*
|
|
227
|
+
* // Register button position on mount
|
|
228
|
+
* useEffect(() => {
|
|
229
|
+
* const rect = buttonRef.current?.getBoundingClientRect();
|
|
230
|
+
* if (rect) {
|
|
231
|
+
* registerButtonPosition(windowId, {
|
|
232
|
+
* x: rect.x, y: rect.y, width: rect.width, height: rect.height
|
|
233
|
+
* });
|
|
234
|
+
* }
|
|
235
|
+
* }, []);
|
|
236
|
+
*
|
|
237
|
+
* // Handle hover
|
|
238
|
+
* <button
|
|
239
|
+
* onMouseEnter={() => startPreviewHover(windowId)}
|
|
240
|
+
* onMouseLeave={endPreviewHover}
|
|
241
|
+
* >
|
|
242
|
+
* {title}
|
|
243
|
+
* </button>
|
|
244
|
+
* ```
|
|
245
|
+
*/
|
|
246
|
+
export function useTaskbar(
|
|
247
|
+
windows: Array<{ id: string; appId: string; title: string; icon?: string }> = [],
|
|
248
|
+
activeWindowId: WindowId | null = null
|
|
249
|
+
): UseTaskbarReturn {
|
|
250
|
+
const resolvedStore = useResolvedTaskbarStore();
|
|
251
|
+
const store = useStore(
|
|
252
|
+
resolvedStore,
|
|
253
|
+
useShallow((state) => ({
|
|
254
|
+
buttonPositions: state.buttonPositions,
|
|
255
|
+
previewState: state.previewState,
|
|
256
|
+
pinnedApps: state.pinnedApps,
|
|
257
|
+
registerButtonPosition: state.registerButtonPosition,
|
|
258
|
+
unregisterButtonPosition: state.unregisterButtonPosition,
|
|
259
|
+
getButtonCenter: state.getButtonCenter,
|
|
260
|
+
startPreviewHover: state.startPreviewHover,
|
|
261
|
+
endPreviewHover: state.endPreviewHover,
|
|
262
|
+
cancelHidePreview: state.cancelHidePreview,
|
|
263
|
+
hidePreview: state.hidePreview,
|
|
264
|
+
pinApp: state.pinApp,
|
|
265
|
+
unpinApp: state.unpinApp,
|
|
266
|
+
})),
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
// Build taskbar items from windows and pinned apps (memoized to prevent unnecessary re-renders)
|
|
270
|
+
const items = useMemo(() => {
|
|
271
|
+
const result: TaskbarItem[] = [];
|
|
272
|
+
|
|
273
|
+
// Add pinned apps (even if not running)
|
|
274
|
+
for (const appId of store.pinnedApps) {
|
|
275
|
+
const runningWindow = windows.find((w) => w.appId === appId);
|
|
276
|
+
result.push({
|
|
277
|
+
id: runningWindow?.id ?? `pinned-${appId}`,
|
|
278
|
+
windowId: runningWindow?.id,
|
|
279
|
+
appId,
|
|
280
|
+
title: runningWindow?.title ?? appId,
|
|
281
|
+
icon: runningWindow?.icon,
|
|
282
|
+
isPinned: true,
|
|
283
|
+
isActive: runningWindow?.id === activeWindowId,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Add running windows that aren't pinned
|
|
288
|
+
for (const window of windows) {
|
|
289
|
+
if (!store.pinnedApps.includes(window.appId)) {
|
|
290
|
+
result.push({
|
|
291
|
+
id: window.id,
|
|
292
|
+
windowId: window.id,
|
|
293
|
+
appId: window.appId,
|
|
294
|
+
title: window.title,
|
|
295
|
+
icon: window.icon,
|
|
296
|
+
isPinned: false,
|
|
297
|
+
isActive: window.id === activeWindowId,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return result;
|
|
303
|
+
}, [windows, store.pinnedApps, activeWindowId]);
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
items,
|
|
307
|
+
activeWindowId,
|
|
308
|
+
previewState: store.previewState,
|
|
309
|
+
buttonPositions: store.buttonPositions,
|
|
310
|
+
registerButtonPosition: store.registerButtonPosition,
|
|
311
|
+
unregisterButtonPosition: store.unregisterButtonPosition,
|
|
312
|
+
getButtonCenter: store.getButtonCenter,
|
|
313
|
+
pin: store.pinApp,
|
|
314
|
+
unpin: store.unpinApp,
|
|
315
|
+
startPreviewHover: store.startPreviewHover,
|
|
316
|
+
endPreviewHover: store.endPreviewHover,
|
|
317
|
+
cancelHidePreview: store.cancelHidePreview,
|
|
318
|
+
hidePreview: store.hidePreview,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
import { useWindowManagerStore, type WindowManagerStoreApi } from "./window/useWindowManager";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Global keyboard navigation hook for the desktop shell.
|
|
8
|
+
*
|
|
9
|
+
* Listens for:
|
|
10
|
+
* - Ctrl+Tab: Cycle focus to the next open window
|
|
11
|
+
* - Escape: Close the current overlay / exit fullscreen
|
|
12
|
+
*
|
|
13
|
+
* Call this once from the desktop shell root component.
|
|
14
|
+
*
|
|
15
|
+
* @param storeApi - Optional store API; defaults to the legacy singleton.
|
|
16
|
+
*/
|
|
17
|
+
export function useKeyboardNavigation(storeApi?: WindowManagerStoreApi) {
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const api = storeApi ?? useWindowManagerStore;
|
|
20
|
+
|
|
21
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
22
|
+
const store = api.getState();
|
|
23
|
+
|
|
24
|
+
// Ctrl+Tab — cycle to next window
|
|
25
|
+
if (e.key === "Tab" && e.ctrlKey && !e.altKey && !e.metaKey) {
|
|
26
|
+
e.preventDefault();
|
|
27
|
+
store._cycleFocusNext();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Escape — exit fullscreen if active
|
|
32
|
+
if (e.key === "Escape" && store.fullscreenId) {
|
|
33
|
+
store._exitFullscreen();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
globalThis.addEventListener("keydown", handleKeyDown);
|
|
39
|
+
return () => globalThis.removeEventListener("keydown", handleKeyDown);
|
|
40
|
+
}, [storeApi]);
|
|
41
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { useWindowManagerStore } from '../useWindowManager';
|
|
3
|
+
|
|
4
|
+
function resetStore() {
|
|
5
|
+
useWindowManagerStore.setState({
|
|
6
|
+
windows: new Map(),
|
|
7
|
+
groups: new Map(),
|
|
8
|
+
focusedId: null,
|
|
9
|
+
fullscreenId: null,
|
|
10
|
+
nextZIndex: 1,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('WindowManager store', () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
resetStore();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('open', () => {
|
|
20
|
+
it('creates a window entry', () => {
|
|
21
|
+
const store = useWindowManagerStore.getState();
|
|
22
|
+
const id = store._open({ title: 'Test Window' });
|
|
23
|
+
|
|
24
|
+
const state = useWindowManagerStore.getState();
|
|
25
|
+
expect(state.windows.size).toBe(1);
|
|
26
|
+
expect(state.windows.get(id)).toBeDefined();
|
|
27
|
+
expect(state.windows.get(id)!.title).toBe('Test Window');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('sets the new window as focused', () => {
|
|
31
|
+
const store = useWindowManagerStore.getState();
|
|
32
|
+
const id = store._open({ title: 'Test' });
|
|
33
|
+
|
|
34
|
+
const state = useWindowManagerStore.getState();
|
|
35
|
+
expect(state.focusedId).toBe(id);
|
|
36
|
+
expect(state.windows.get(id)!.isFocused).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('uses default size when none provided', () => {
|
|
40
|
+
const store = useWindowManagerStore.getState();
|
|
41
|
+
const id = store._open({ title: 'Test' });
|
|
42
|
+
|
|
43
|
+
const w = useWindowManagerStore.getState().windows.get(id)!;
|
|
44
|
+
expect(w.size).toEqual({ width: 640, height: 480 });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('uses provided size', () => {
|
|
48
|
+
const store = useWindowManagerStore.getState();
|
|
49
|
+
const id = store._open({ title: 'Test', size: { width: 800, height: 600 } });
|
|
50
|
+
|
|
51
|
+
const w = useWindowManagerStore.getState().windows.get(id)!;
|
|
52
|
+
expect(w.size).toEqual({ width: 800, height: 600 });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('increments z-index for each new window', () => {
|
|
56
|
+
const store = useWindowManagerStore.getState();
|
|
57
|
+
const id1 = store._open({ title: 'W1' });
|
|
58
|
+
const id2 = useWindowManagerStore.getState()._open({ title: 'W2' });
|
|
59
|
+
|
|
60
|
+
const state = useWindowManagerStore.getState();
|
|
61
|
+
const w1 = state.windows.get(id1)!;
|
|
62
|
+
const w2 = state.windows.get(id2)!;
|
|
63
|
+
expect(w2.zIndex).toBeGreaterThan(w1.zIndex);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('close', () => {
|
|
68
|
+
it('removes the window', () => {
|
|
69
|
+
const store = useWindowManagerStore.getState();
|
|
70
|
+
const id = store._open({ title: 'Test' });
|
|
71
|
+
expect(useWindowManagerStore.getState().windows.size).toBe(1);
|
|
72
|
+
|
|
73
|
+
useWindowManagerStore.getState()._close(id);
|
|
74
|
+
expect(useWindowManagerStore.getState().windows.size).toBe(0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('focuses next topmost window after closing focused window', () => {
|
|
78
|
+
const store = useWindowManagerStore.getState();
|
|
79
|
+
const id1 = store._open({ title: 'W1' });
|
|
80
|
+
const id2 = useWindowManagerStore.getState()._open({ title: 'W2' });
|
|
81
|
+
|
|
82
|
+
expect(useWindowManagerStore.getState().focusedId).toBe(id2);
|
|
83
|
+
|
|
84
|
+
useWindowManagerStore.getState()._close(id2);
|
|
85
|
+
expect(useWindowManagerStore.getState().focusedId).toBe(id1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('sets focusedId to null when last window is closed', () => {
|
|
89
|
+
const store = useWindowManagerStore.getState();
|
|
90
|
+
const id = store._open({ title: 'Test' });
|
|
91
|
+
|
|
92
|
+
useWindowManagerStore.getState()._close(id);
|
|
93
|
+
expect(useWindowManagerStore.getState().focusedId).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('does nothing for non-existent window', () => {
|
|
97
|
+
const store = useWindowManagerStore.getState();
|
|
98
|
+
store._open({ title: 'Test' });
|
|
99
|
+
|
|
100
|
+
useWindowManagerStore.getState()._close('non-existent');
|
|
101
|
+
expect(useWindowManagerStore.getState().windows.size).toBe(1);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('focus', () => {
|
|
106
|
+
it('updates z-index and focused state', () => {
|
|
107
|
+
const store = useWindowManagerStore.getState();
|
|
108
|
+
const id1 = store._open({ title: 'W1' });
|
|
109
|
+
const id2 = useWindowManagerStore.getState()._open({ title: 'W2' });
|
|
110
|
+
|
|
111
|
+
// id2 is focused, focus id1
|
|
112
|
+
useWindowManagerStore.getState()._focus(id1);
|
|
113
|
+
|
|
114
|
+
const state = useWindowManagerStore.getState();
|
|
115
|
+
expect(state.focusedId).toBe(id1);
|
|
116
|
+
expect(state.windows.get(id1)!.isFocused).toBe(true);
|
|
117
|
+
expect(state.windows.get(id2)!.isFocused).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('does nothing if window is already focused', () => {
|
|
121
|
+
const store = useWindowManagerStore.getState();
|
|
122
|
+
const id = store._open({ title: 'Test' });
|
|
123
|
+
|
|
124
|
+
const zBefore = useWindowManagerStore.getState().nextZIndex;
|
|
125
|
+
useWindowManagerStore.getState()._focus(id);
|
|
126
|
+
const zAfter = useWindowManagerStore.getState().nextZIndex;
|
|
127
|
+
|
|
128
|
+
// nextZIndex should not change since it was already focused
|
|
129
|
+
expect(zAfter).toBe(zBefore);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('minimize', () => {
|
|
134
|
+
it('sets isMinimized to true', () => {
|
|
135
|
+
const store = useWindowManagerStore.getState();
|
|
136
|
+
const id = store._open({ title: 'Test' });
|
|
137
|
+
|
|
138
|
+
useWindowManagerStore.getState()._minimize(id);
|
|
139
|
+
|
|
140
|
+
const w = useWindowManagerStore.getState().windows.get(id)!;
|
|
141
|
+
expect(w.isMinimized).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('focuses next topmost non-minimized window', () => {
|
|
145
|
+
const store = useWindowManagerStore.getState();
|
|
146
|
+
const id1 = store._open({ title: 'W1' });
|
|
147
|
+
const id2 = useWindowManagerStore.getState()._open({ title: 'W2' });
|
|
148
|
+
|
|
149
|
+
useWindowManagerStore.getState()._minimize(id2);
|
|
150
|
+
|
|
151
|
+
expect(useWindowManagerStore.getState().focusedId).toBe(id1);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('sets focusedId to null when all windows are minimized', () => {
|
|
155
|
+
const store = useWindowManagerStore.getState();
|
|
156
|
+
const id = store._open({ title: 'Test' });
|
|
157
|
+
|
|
158
|
+
useWindowManagerStore.getState()._minimize(id);
|
|
159
|
+
|
|
160
|
+
expect(useWindowManagerStore.getState().focusedId).toBeNull();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('maximize', () => {
|
|
165
|
+
it('sets isMaximized to true and saves pre-maximize state', () => {
|
|
166
|
+
const store = useWindowManagerStore.getState();
|
|
167
|
+
const id = store._open({ title: 'Test', position: { x: 100, y: 100 }, size: { width: 400, height: 300 } });
|
|
168
|
+
|
|
169
|
+
useWindowManagerStore.getState()._maximize(id);
|
|
170
|
+
|
|
171
|
+
const w = useWindowManagerStore.getState().windows.get(id)!;
|
|
172
|
+
expect(w.isMaximized).toBe(true);
|
|
173
|
+
expect(w.preMaximize).toEqual({ x: 100, y: 100, width: 400, height: 300 });
|
|
174
|
+
expect(w.position).toEqual({ x: 0, y: 0 });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('does nothing if already maximized', () => {
|
|
178
|
+
const store = useWindowManagerStore.getState();
|
|
179
|
+
const id = store._open({ title: 'Test' });
|
|
180
|
+
|
|
181
|
+
useWindowManagerStore.getState()._maximize(id);
|
|
182
|
+
const firstSize = useWindowManagerStore.getState().windows.get(id)!.size;
|
|
183
|
+
|
|
184
|
+
useWindowManagerStore.getState()._maximize(id);
|
|
185
|
+
const secondSize = useWindowManagerStore.getState().windows.get(id)!.size;
|
|
186
|
+
|
|
187
|
+
expect(firstSize).toEqual(secondSize);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('restore', () => {
|
|
192
|
+
it('restores from minimized', () => {
|
|
193
|
+
const store = useWindowManagerStore.getState();
|
|
194
|
+
const id = store._open({ title: 'Test' });
|
|
195
|
+
|
|
196
|
+
useWindowManagerStore.getState()._minimize(id);
|
|
197
|
+
expect(useWindowManagerStore.getState().windows.get(id)!.isMinimized).toBe(true);
|
|
198
|
+
|
|
199
|
+
useWindowManagerStore.getState()._restore(id);
|
|
200
|
+
|
|
201
|
+
const w = useWindowManagerStore.getState().windows.get(id)!;
|
|
202
|
+
expect(w.isMinimized).toBe(false);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('restores from maximized to pre-maximize position', () => {
|
|
206
|
+
const store = useWindowManagerStore.getState();
|
|
207
|
+
const id = store._open({ title: 'Test', position: { x: 100, y: 200 }, size: { width: 400, height: 300 } });
|
|
208
|
+
|
|
209
|
+
useWindowManagerStore.getState()._maximize(id);
|
|
210
|
+
useWindowManagerStore.getState()._restore(id);
|
|
211
|
+
|
|
212
|
+
const w = useWindowManagerStore.getState().windows.get(id)!;
|
|
213
|
+
expect(w.isMaximized).toBe(false);
|
|
214
|
+
expect(w.position).toEqual({ x: 100, y: 200 });
|
|
215
|
+
expect(w.size).toEqual({ width: 400, height: 300 });
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('stacking order', () => {
|
|
220
|
+
it('maintains correct z-index order with multiple windows', () => {
|
|
221
|
+
const store = useWindowManagerStore.getState();
|
|
222
|
+
const id1 = store._open({ title: 'W1' });
|
|
223
|
+
const id2 = useWindowManagerStore.getState()._open({ title: 'W2' });
|
|
224
|
+
const id3 = useWindowManagerStore.getState()._open({ title: 'W3' });
|
|
225
|
+
|
|
226
|
+
// Focus id1, bringing it to top
|
|
227
|
+
useWindowManagerStore.getState()._focus(id1);
|
|
228
|
+
|
|
229
|
+
const state = useWindowManagerStore.getState();
|
|
230
|
+
const z1 = state.windows.get(id1)!.zIndex;
|
|
231
|
+
const z2 = state.windows.get(id2)!.zIndex;
|
|
232
|
+
const z3 = state.windows.get(id3)!.zIndex;
|
|
233
|
+
|
|
234
|
+
// id1 should be on top (highest z-index)
|
|
235
|
+
expect(z1).toBeGreaterThan(z2);
|
|
236
|
+
expect(z1).toBeGreaterThan(z3);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe('minimizeAll', () => {
|
|
241
|
+
it('minimizes all windows and clears focus', () => {
|
|
242
|
+
const store = useWindowManagerStore.getState();
|
|
243
|
+
store._open({ title: 'W1' });
|
|
244
|
+
useWindowManagerStore.getState()._open({ title: 'W2' });
|
|
245
|
+
|
|
246
|
+
useWindowManagerStore.getState()._minimizeAll();
|
|
247
|
+
|
|
248
|
+
const state = useWindowManagerStore.getState();
|
|
249
|
+
for (const [, w] of state.windows) {
|
|
250
|
+
expect(w.isMinimized).toBe(true);
|
|
251
|
+
}
|
|
252
|
+
expect(state.focusedId).toBeNull();
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe('closeAll', () => {
|
|
257
|
+
it('removes all windows', () => {
|
|
258
|
+
const store = useWindowManagerStore.getState();
|
|
259
|
+
store._open({ title: 'W1' });
|
|
260
|
+
useWindowManagerStore.getState()._open({ title: 'W2' });
|
|
261
|
+
|
|
262
|
+
useWindowManagerStore.getState()._closeAll();
|
|
263
|
+
|
|
264
|
+
const state = useWindowManagerStore.getState();
|
|
265
|
+
expect(state.windows.size).toBe(0);
|
|
266
|
+
expect(state.focusedId).toBeNull();
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
});
|