From f9ac76c4f80bf5524ce03e70396043551ffc6fdb Mon Sep 17 00:00:00 2001 From: David Date: Mon, 6 Oct 2025 14:26:05 -0700 Subject: [PATCH 1/2] Clamp windows to desktop bounds --- src/components/Desktop.tsx | 8 +- src/contexts/WindowManagerContext.test.tsx | 29 +++++ src/contexts/WindowManagerContext.tsx | 139 ++++++++++++++++++--- 3 files changed, 158 insertions(+), 18 deletions(-) create mode 100644 src/contexts/WindowManagerContext.test.tsx diff --git a/src/components/Desktop.tsx b/src/components/Desktop.tsx index 454000a..373e565 100644 --- a/src/components/Desktop.tsx +++ b/src/components/Desktop.tsx @@ -45,7 +45,7 @@ export default function Desktop() { const containerRef = useRef(null) const [containerSize, setContainerSize] = useState(DEFAULT_SIZE) const [iconSize, setIconSize] = useState(DEFAULT_ICON_SIZE) - const { windows, openWindow } = useWindowManager() + const { windows, openWindow, setDesktopBounds } = useWindowManager() const initializedRef = useRef(false) const firstIconRef = useRef(null) @@ -62,7 +62,9 @@ export default function Desktop() { const updateSize = () => { const rect = element.getBoundingClientRect() - setContainerSize({ width: rect.width, height: rect.height }) + const bounds = { width: rect.width, height: rect.height } + setContainerSize(bounds) + setDesktopBounds(bounds) } updateSize() @@ -76,7 +78,7 @@ export default function Desktop() { const handleResize = () => updateSize() window.addEventListener('resize', handleResize) return () => window.removeEventListener('resize', handleResize) - }, []) + }, [setDesktopBounds]) useEffect(() => { const element = firstIconRef.current diff --git a/src/contexts/WindowManagerContext.test.tsx b/src/contexts/WindowManagerContext.test.tsx new file mode 100644 index 0000000..f6e6742 --- /dev/null +++ b/src/contexts/WindowManagerContext.test.tsx @@ -0,0 +1,29 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' + +import { + WindowManagerProvider, + useWindowManager, +} from './WindowManagerContext.tsx' +import type { WindowSize } from '../types/window.ts' + +const h = React.createElement + +type CaptureComponent = (() => null) & { + lastValue?: (bounds: WindowSize) => void +} + +const Capture: CaptureComponent = () => { + const manager = useWindowManager() + Capture.lastValue = manager.setDesktopBounds + return null +} + +test('WindowManagerProvider exposes setDesktopBounds', () => { + Capture.lastValue = undefined + renderToStaticMarkup(h(WindowManagerProvider, null, h(Capture))) + + assert.equal(typeof Capture.lastValue, 'function') +}) diff --git a/src/contexts/WindowManagerContext.tsx b/src/contexts/WindowManagerContext.tsx index 53f27c8..138c1dd 100644 --- a/src/contexts/WindowManagerContext.tsx +++ b/src/contexts/WindowManagerContext.tsx @@ -35,6 +35,7 @@ type WindowManagerValue = { setWindowPosition: (id: string, position: WindowPoint) => void setWindowSize: (id: string, size: WindowSize, position?: WindowPoint) => void setWindowTitle: (id: string, title: string) => void + setDesktopBounds: (bounds: WindowSize) => void apps: typeof WINDOW_APPS } @@ -43,6 +44,10 @@ const randomId = () => Math.random().toString(36).slice(2, 7) export function WindowManagerProvider({ children }: PropsWithChildren) { const [windows, setWindows] = useState([]) const [activeWindowId, setActiveWindowIdState] = useState(null) + const [desktopBounds, setDesktopBoundsState] = useState({ + width: Number.POSITIVE_INFINITY, + height: Number.POSITIVE_INFINITY, + }) const activeIdRef = useRef(null) const zIndexRef = useRef(100) @@ -105,15 +110,17 @@ export function WindowManagerProvider({ children }: PropsWithChildren) { y: 80 + prev.length * 24, } + const requestedWidth = + options?.size?.width ?? definition.defaultSize.width + const requestedHeight = + options?.size?.height ?? definition.defaultSize.height + + const boundedWidth = Math.min(requestedWidth, desktopBounds.width) + const boundedHeight = Math.min(requestedHeight, desktopBounds.height) + const size = { - width: Math.max( - MIN_WIDTH, - options?.size?.width ?? definition.defaultSize.width - ), - height: Math.max( - MIN_HEIGHT, - options?.size?.height ?? definition.defaultSize.height - ), + width: Math.max(MIN_WIDTH, boundedWidth), + height: Math.max(MIN_HEIGHT, boundedHeight), } const id = `${appId}-${Date.now()}-${randomId()}` @@ -139,7 +146,12 @@ export function WindowManagerProvider({ children }: PropsWithChildren) { setActiveWindowId(createdId) return createdId }, - [getNextZIndex, setActiveWindowId] + [ + desktopBounds.height, + desktopBounds.width, + getNextZIndex, + setActiveWindowId, + ] ) const toggleMinimize = useCallback( @@ -236,20 +248,115 @@ export function WindowManagerProvider({ children }: PropsWithChildren) { return window } + const limitedWidth = Math.min(size.width, desktopBounds.width) + const limitedHeight = Math.min(size.height, desktopBounds.height) + + const nextSize = { + width: Math.max(MIN_WIDTH, limitedWidth), + height: Math.max(MIN_HEIGHT, limitedHeight), + } + + const nextPosition = position ?? window.position + const maxX = Math.max(0, desktopBounds.width - nextSize.width) + const maxY = Math.max(0, desktopBounds.height - nextSize.height) + + const clampedPosition = { + x: Math.min(Math.max(0, nextPosition.x), maxX), + y: Math.min(Math.max(0, nextPosition.y), maxY), + } + return { ...window, - size: { - width: Math.max(MIN_WIDTH, size.width), - height: Math.max(MIN_HEIGHT, size.height), - }, - position: position ?? window.position, + size: nextSize, + position: clampedPosition, } }) ) }, - [] + [desktopBounds.height, desktopBounds.width] ) + const setDesktopBounds = useCallback((bounds: WindowSize) => { + setDesktopBoundsState(prevBounds => { + const hasWidthChange = bounds.width !== prevBounds.width + const hasHeightChange = bounds.height !== prevBounds.height + + if (!hasWidthChange && !hasHeightChange) { + return prevBounds + } + + const widthShrunk = bounds.width < prevBounds.width + const heightShrunk = bounds.height < prevBounds.height + + setWindows(prevWindows => { + let changed = false + + const updated = prevWindows.map(window => { + if (window.maximized) { + const maximizeSize = { + width: Math.max(MIN_WIDTH, bounds.width), + height: Math.max(MIN_HEIGHT, bounds.height), + } + if ( + window.position.x !== 0 || + window.position.y !== 0 || + window.size.width !== maximizeSize.width || + window.size.height !== maximizeSize.height + ) { + changed = true + return { + ...window, + position: { x: 0, y: 0 }, + size: maximizeSize, + } + } + return window + } + + if (!widthShrunk && !heightShrunk) { + return window + } + + const limitedWidth = Math.min(window.size.width, bounds.width) + const limitedHeight = Math.min(window.size.height, bounds.height) + + const nextSize = { + width: Math.max(MIN_WIDTH, limitedWidth), + height: Math.max(MIN_HEIGHT, limitedHeight), + } + + const maxX = Math.max(0, bounds.width - nextSize.width) + const maxY = Math.max(0, bounds.height - nextSize.height) + + const nextPosition = { + x: Math.min(Math.max(0, window.position.x), maxX), + y: Math.min(Math.max(0, window.position.y), maxY), + } + + if ( + nextSize.width !== window.size.width || + nextSize.height !== window.size.height || + nextPosition.x !== window.position.x || + nextPosition.y !== window.position.y + ) { + changed = true + return { + ...window, + size: nextSize, + position: nextPosition, + } + } + + return window + }) + + return changed ? updated : prevWindows + }) + + return bounds + }) + }, []) + const closeWindow = useCallback( (id: string) => { let nextActiveId: string | null = activeIdRef.current @@ -288,6 +395,7 @@ export function WindowManagerProvider({ children }: PropsWithChildren) { setWindowPosition, setWindowSize, setWindowTitle, + setDesktopBounds, apps: WINDOW_APPS, }), [ @@ -301,6 +409,7 @@ export function WindowManagerProvider({ children }: PropsWithChildren) { setWindowPosition, setWindowSize, setWindowTitle, + setDesktopBounds, ] ) From 22bd2bc1dc6de6d4bcac9012a1e8187a9c74fc01 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 6 Oct 2025 14:35:47 -0700 Subject: [PATCH 2/2] Clamp maximized window restore state --- src/contexts/WindowManagerContext.tsx | 53 ++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/src/contexts/WindowManagerContext.tsx b/src/contexts/WindowManagerContext.tsx index 138c1dd..3b98c60 100644 --- a/src/contexts/WindowManagerContext.tsx +++ b/src/contexts/WindowManagerContext.tsx @@ -297,17 +297,66 @@ export function WindowManagerProvider({ children }: PropsWithChildren) { width: Math.max(MIN_WIDTH, bounds.width), height: Math.max(MIN_HEIGHT, bounds.height), } - if ( + + let nextPrevious = window.previous + let previousChanged = false + + if (window.previous) { + const limitedPrevWidth = Math.min( + window.previous.size.width, + bounds.width + ) + const limitedPrevHeight = Math.min( + window.previous.size.height, + bounds.height + ) + + const nextPreviousSize = { + width: Math.max(MIN_WIDTH, limitedPrevWidth), + height: Math.max(MIN_HEIGHT, limitedPrevHeight), + } + + const maxPrevX = Math.max( + 0, + bounds.width - nextPreviousSize.width + ) + const maxPrevY = Math.max( + 0, + bounds.height - nextPreviousSize.height + ) + + const nextPreviousPosition = { + x: Math.min(Math.max(0, window.previous.position.x), maxPrevX), + y: Math.min(Math.max(0, window.previous.position.y), maxPrevY), + } + + previousChanged = + nextPreviousSize.width !== window.previous.size.width || + nextPreviousSize.height !== window.previous.size.height || + nextPreviousPosition.x !== window.previous.position.x || + nextPreviousPosition.y !== window.previous.position.y + + if (previousChanged) { + nextPrevious = { + position: nextPreviousPosition, + size: nextPreviousSize, + } + } + } + + const frameChanged = window.position.x !== 0 || window.position.y !== 0 || window.size.width !== maximizeSize.width || window.size.height !== maximizeSize.height - ) { + + if (frameChanged || previousChanged) { changed = true return { ...window, position: { x: 0, y: 0 }, size: maximizeSize, + previous: nextPrevious, } } return window