diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7ef7da63..24c2cf4d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -115,7 +115,7 @@ When session restore is on, startup loads validated records before the renderer `SessionMetadata` owns both world-space position and card size. `App` reconciles those bounds, while `TerminalCard` may hold transient pointer-move geometry until pointer-up. The main process validates and clamps committed sizes before emitting a session snapshot. Camera wheel handling is limited to empty canvas; interactive surfaces keep their native scroll/input ownership. -The camera is not React state. `App` owns a `cameraStore` (`features/workspace/cameraStore.ts`); `WorkspaceCanvas` writes the scene transform from the store's listener, synchronously and before anything that measures the scene renders. Only the minimap and `BrowserCard` subscribe to every move; cards subscribe to derived values (`useCameraSelector`: summary scale, WebGL eligibility) and drag handlers read `camera.get().zoom` when they move, so a pan or zoom renders no card. +The camera is not React state. `App` owns a `cameraStore` (`features/workspace/cameraStore.ts`); `WorkspaceCanvas` writes the scene transform from the store's listener, synchronously and before anything that measures the scene renders. Only the minimap's camera rectangle and `BrowserCard` subscribe to every move; cards subscribe to derived values (`useCameraSelector`: summary scale, WebGL eligibility) and drag handlers read `camera.get().zoom` when they move, so a pan or zoom renders no card. The minimap fits HOME and occupied canvas bounds independently of the camera. Its workspace layer is memoized by geometry, provider, region color and layer order, ignoring session output, titles and activity; camera updates project only one viewport rectangle in constant time, without rebuilding the object list or provider icons. A live `TerminalCard` owns one xterm instance for the lifetime of its session ID. Palette changes update `terminal.options.theme` in place; title and settings changes must never dispose the terminal or its renderer-side scrollback. Window titles are updated as session metadata through `terminal:rename`. PTY input and resize events that race with process exit are contained at the main-process boundary and never surface as uncaught Electron errors. diff --git a/docs/UI_CONTRACT.md b/docs/UI_CONTRACT.md index e2780c2e..235d6372 100644 --- a/docs/UI_CONTRACT.md +++ b/docs/UI_CONTRACT.md @@ -56,7 +56,7 @@ This contract preserves the approved MVP concept and prevents feature ownership - Settings uses the approved two-pane modal grammar: a persistent icon-labelled sidebar for all seven sections, a separate current-section header, and a scrollable content pane. Settings must not fall back to the legacy light slide-over with one horizontal segmented tab strip. Simple settings use compact copy/control rows; all Settings chrome reads the active system tokens, official Lucide assets, and root `--ui-scale`. - General Settings places the macOS/Windows/Linux/Custom keyboard preset after language, followed by an expandable Keyboard shortcuts section for canvas, terminal, Codex editor and canvas navigation bindings. Recording a shortcut selects Custom; choosing a named preset replaces bindings. Optional commands can be disabled, while submit, newline and whole-draft selection remain bound. Canvas and terminal bindings update immediately; native Codex editor bindings apply to new or restarted cards. Focused terminal and text inputs retain ownership of canvas keyboard shortcuts on every platform. - Empty-canvas drag and middle-button drag pan the canvas. On fresh profiles ordinary scroll over empty canvas pans on both axes; pinch and `Cmd/Ctrl + scroll` zoom around the pointer. **Use scroll wheel to zoom** restores historical ordinary-wheel zoom and is enabled by migration for existing profiles. Only a focused input widget interrupts canvas wheel/pinch, except the HOME session list keeps ordinary local scrolling without a focus precondition; unfocused or non-focusable widgets otherwise leave those events to the canvas. **Wheel/pinch capture over widgets** has Off, On, and Key modes: Off preserves local ownership, On always transfers only wheel/pinch to the canvas, and Key transfers only wheel/pinch while its binding is held. Key and full-navigation bindings accept modifier chords or Mouse3/Mouse4/Mouse5. A focused live Browser page therefore scrolls normally in Off or released-Key mode; unfocused Browser content, On, an active Key binding, the full override, pinch, and `Cmd/Ctrl + scroll` are canvas-owned. The owner selected for a Browser wheel sequence is held until 250 ms of wheel inactivity. Browser summaries and placeholders are always canvas-owned. Fresh profiles use Key with `Command` on macOS or `Ctrl` elsewhere. A separate configurable **Canvas navigation override** (default `Option` on macOS and `Alt` elsewhere) transfers wheel/pinch and drag while held. While full override is held all canvas surfaces show `grab`; an owned drag shows `grabbing` until pointer up/cancel even after the binding is released. -- The minimap is an RTS-style camera-centred radar over the infinite Canvas. Its projection uses one uniform scale and never auto-fits around HOME or visible objects, so pan moves HOME, regions, and fixed-size window markers through the frame without stretching them. The white camera rectangle stays centred at `46 × 30px`; HOME and windows may cross and leave the radar. Controls chooses between two mutually exclusive modes: **Click** (the default, which recentres without changing zoom) and **Drag**. Drag mirrors empty-canvas grab semantics: pointer-down never jumps the camera, moving right/down pulls the scene right/down from the gesture's starting camera, and pointer capture continues outside the radar. Pressing and releasing without movement in Drag mode never recentres the camera. Arrow keys always pan it from keyboard focus. Minimap, passive shortcut hints, and Home/zoom controls share four corner slots and stack instead of overlapping when assigned to the same corner. +- The minimap fits the occupied workspace: HOME, regions, terminal/agent windows, renderable plugin cards, the visible Browser card, and sticky notes. It uses one uniform scale with padding, so every rectangle preserves its real proportions and relative size. Window stacking follows the Canvas. Sessions have no text labels; their existing provider icons appear only when they fit inside the projected rectangle, without enlarging it. Pan and zoom leave the overview fixed and move/resize only the white camera rectangle; a white edge marker locates the camera when its viewport is entirely outside the overview. Moving or resizing an individual window or HOME updates the layout after pointer release. Adding, closing, or repositioning objects refits the overview, and a distant object can therefore make all rectangles smaller. Controls chooses between two mutually exclusive modes: **Click** (the default, which recentres without changing zoom) and **Drag**. Drag mirrors empty-canvas grab semantics: pointer-down never jumps the camera, moving right/down pulls the scene right/down from the gesture's starting camera, and pointer capture continues outside the minimap. Pressing and releasing without movement in Drag mode never recentres the camera. Arrow keys always pan it from keyboard focus. Minimap, passive shortcut hints, and Home/zoom controls share four corner slots and stack instead of overlapping when assigned to the same corner. - The canvas edge-pans RTS-style while the pointer rests within `56px` of a viewport edge over empty canvas; speed ramps linearly up to `900px/s` at the edge itself. Edge panning is off by default and enabled in Settings. Motion pauses over interactive surfaces (terminal cards, Home, controls) and while drag-panning. - Dialog close actions stay inside their own header/control row with a consistent inset; they never overlap a field, outline, or panel boundary. diff --git a/src/renderer/src/features/workspace/CanvasMinimap.tsx b/src/renderer/src/features/workspace/CanvasMinimap.tsx index 030a4279..90a04a36 100644 --- a/src/renderer/src/features/workspace/CanvasMinimap.tsx +++ b/src/renderer/src/features/workspace/CanvasMinimap.tsx @@ -1,37 +1,31 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { memo, useEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties, RefObject } from "react"; import type { - BrowserCanvasState, CameraState, - CanvasRegion, LocaleId, MinimapInteractionMode, - PluginCanvasInstance, + ProviderId, SessionBounds, - SessionSnapshot, - Size, - StickyNote + Size } from "../../../../shared/contracts"; +import { ProviderIcon } from "../../components/ProviderIcon"; import { t } from "../../lib/i18n"; +import { useCameraSelector, type CameraStore } from "./cameraStore"; +import { browserLayerId, noteLayerId, pluginLayerId, terminalLayerId } from "./canvasSelectionGesture"; +import { minimapContentEqual, type MinimapContent } from "./minimapContent"; import { cameraWorldViewport, minimapCameraForPointerDrag, minimapAreaForBounds, minimapEdgePointForBounds, - minimapPointForBounds, + MINIMAP_SURFACE_SIZE, minimapWorldBounds, minimapWorldPoint } from "./minimapGeometry"; -interface CanvasMinimapProps { +interface CanvasMinimapProps extends MinimapContent { viewport: RefObject; - camera: CameraState; - homeBounds: SessionBounds; - canvasRegions: readonly CanvasRegion[]; - sessions: readonly SessionSnapshot[]; - stickyNotes: readonly StickyNote[]; - pluginCanvas: readonly PluginCanvasInstance[]; - browserCanvas: BrowserCanvasState | null; + camera: CameraStore; locale: LocaleId; interactionMode: MinimapInteractionMode; onCameraChange(camera: CameraState): void; @@ -41,6 +35,7 @@ interface MinimapEntity { id: string; kind: "terminal" | "plugin" | "browser" | "note"; bounds: SessionBounds; + provider?: ProviderId; } interface MinimapDragState { @@ -50,7 +45,16 @@ interface MinimapDragState { moved: boolean; } -export function CanvasMinimap({ +export const CanvasMinimap = memo(CanvasMinimapView, (previous, next) => ( + previous.viewport === next.viewport + && previous.camera === next.camera + && previous.locale === next.locale + && previous.interactionMode === next.interactionMode + && previous.onCameraChange === next.onCameraChange + && minimapContentEqual(previous, next) +)); + +function CanvasMinimapView({ viewport, camera, homeBounds, @@ -59,15 +63,14 @@ export function CanvasMinimap({ stickyNotes, pluginCanvas, browserCanvas, + layerOrder, locale, interactionMode, onCameraChange }: CanvasMinimapProps): React.JSX.Element { const surface = useRef(null); - const cameraRef = useRef(camera); const dragState = useRef(null); const [viewportSize, setViewportSize] = useState({ width: 1, height: 1 }); - cameraRef.current = camera; useEffect(() => { const element = viewport.current; @@ -85,34 +88,26 @@ export function CanvasMinimap({ return () => observer.disconnect(); }, [viewport]); - const worldViewport = useMemo( - () => cameraWorldViewport(camera, viewportSize), - [camera, viewportSize] - ); - const worldBounds = useMemo(() => minimapWorldBounds(worldViewport), [worldViewport]); - const viewportPoint = useMemo( - () => minimapPointForBounds(worldViewport, worldBounds), - [worldBounds, worldViewport] + const entities = useMemo(() => [ + ...sessions.map((session) => ({ + id: terminalLayerId(session.id), kind: "terminal" as const, bounds: session, provider: session.provider + })), + ...stickyNotes.map((note) => ({ id: noteLayerId(note.id), kind: "note" as const, bounds: note })), + ...pluginCanvas.map((instance) => ({ id: pluginLayerId(instance.id), kind: "plugin" as const, bounds: instance })), + ...(browserCanvas ? [{ id: browserLayerId, kind: "browser" as const, bounds: browserCanvas }] : []) + ], [browserCanvas, pluginCanvas, sessions, stickyNotes]); + const layerIndices = useMemo( + () => new Map(layerOrder.map((id, index) => [id, index + 1])), + [layerOrder] ); - const homeEdge = useMemo( - () => minimapEdgePointForBounds(homeBounds, worldBounds), - [homeBounds, worldBounds] + const worldBounds = useMemo( + () => minimapWorldBounds([homeBounds, ...canvasRegions, ...entities.map((entity) => entity.bounds)]), + [canvasRegions, entities, homeBounds] ); const homeArea = useMemo( () => minimapAreaForBounds(homeBounds, worldBounds), [homeBounds, worldBounds] ); - const entities = useMemo(() => [ - ...sessions.map((session) => ({ id: session.id, kind: "terminal" as const, bounds: session })), - ...stickyNotes.map((note) => ({ id: note.id, kind: "note" as const, bounds: note })), - ...pluginCanvas.map((instance) => ({ id: instance.id, kind: "plugin" as const, bounds: instance })), - ...(browserCanvas ? [{ id: "browser", kind: "browser" as const, bounds: browserCanvas }] : []) - ], [browserCanvas, pluginCanvas, sessions, stickyNotes]); - - const applyCamera = (next: CameraState): void => { - cameraRef.current = next; - onCameraChange(next); - }; const navigate = (clientX: number, clientY: number): void => { const element = surface.current; @@ -123,8 +118,8 @@ export function CanvasMinimap({ x: (clientX - bounds.left) / bounds.width, y: (clientY - bounds.top) / bounds.height }, worldBounds); - const current = cameraRef.current; - applyCamera({ + const current = camera.get(); + onCameraChange({ zoom: current.zoom, x: viewportSize.width / 2 - target.x * current.zoom, y: viewportSize.height / 2 - target.y * current.zoom @@ -153,7 +148,7 @@ export function CanvasMinimap({ worldBounds ); if (!next) return; - applyCamera(next); + onCameraChange(next); }; return ( @@ -173,7 +168,7 @@ export function CanvasMinimap({ dragState.current = { pointerId: event.pointerId, startClient: { x: event.clientX, y: event.clientY }, - startCamera: cameraRef.current, + startCamera: camera.get(), moved: false }; } else { @@ -210,8 +205,8 @@ export function CanvasMinimap({ : null; if (!delta) return; event.preventDefault(); - const current = cameraRef.current; - applyCamera({ ...current, x: current.x + delta.x, y: current.y + delta.y }); + const current = camera.get(); + onCameraChange({ ...current, x: current.x + delta.x, y: current.y + delta.y }); }} > ); } +// Only this layer subscribes to pan/zoom; the workspace rectangles and icons do not render per camera event. +function MinimapViewport({ camera, viewportSize, worldBounds }: { + camera: CameraStore; + viewportSize: Size; + worldBounds: SessionBounds; +}): React.JSX.Element { + const current = useCameraSelector(camera, (value) => value); + const worldViewport = cameraWorldViewport(current, viewportSize); + const area = minimapAreaForBounds(worldViewport, worldBounds); + const edge = minimapEdgePointForBounds(worldViewport, worldBounds); + return ( + <> + {area && } + {edge && } + + ); +} + function pointStyle(point: { x: number; y: number }): CSSProperties { return { left: `${point.x * 100}%`, top: `${point.y * 100}%` }; } diff --git a/src/renderer/src/features/workspace/WorkspaceCanvas.tsx b/src/renderer/src/features/workspace/WorkspaceCanvas.tsx index 54d2da7e..f0b0c99b 100644 --- a/src/renderer/src/features/workspace/WorkspaceCanvas.tsx +++ b/src/renderer/src/features/workspace/WorkspaceCanvas.tsx @@ -362,6 +362,10 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element candidate.id === instance.contributionId && candidate.kind === "canvas-app" )); }).map((instance) => instance.id)), [plugins, settings.pluginCanvas]); + const minimapPluginCanvas = useMemo( + () => renderedPluginCanvas.filter((instance) => renderablePluginIds.has(instance.id)), + [renderedPluginCanvas, renderablePluginIds] + ); const activeLayerIds = useMemo(() => [ ...renderedSessions.map((session) => terminalLayerId(session.id)), ...renderedPluginCanvas.filter((instance) => renderablePluginIds.has(instance.id)).map((instance) => pluginLayerId(instance.id)), @@ -1328,9 +1332,10 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element )} {settings.minimapPlacement === placement && ( - )} @@ -1391,9 +1396,3 @@ function shouldKeepCanvasContextMenu(target: EventTarget | null): boolean { "textarea, input, select, [contenteditable='true'], .terminal-card, .plugin-canvas-card, .browser-card, .home-zone, .canvas-overlays, .canvas-menu, .canvas-region-editor, [data-canvas-region-id], [data-sticky-note-id], [data-interactive='true']" )); } - -/** The minimap draws the camera's viewport, so it is the one part of the HUD that renders on every move. */ -function LiveCanvasMinimap(props: Omit, "camera"> & { camera: CameraStore }): React.JSX.Element { - const camera = useCameraSelector(props.camera, (current) => current); - return ; -} diff --git a/src/renderer/src/features/workspace/cameraStore.ts b/src/renderer/src/features/workspace/cameraStore.ts index 85fd5e40..c0ec5a3e 100644 --- a/src/renderer/src/features/workspace/cameraStore.ts +++ b/src/renderer/src/features/workspace/cameraStore.ts @@ -5,7 +5,7 @@ import type { CameraState } from "../../../../shared/contracts"; * The canvas camera outside React state. A pan or zoom changes it on every pointer or wheel event; kept in * App state it rendered the whole application tree per event. The store applies the scene transform * directly (see `sceneTransform`), and only the parts that really depend on the camera subscribe: - * the minimap and the browser card (its native view follows the card) take every change, cards take + * the minimap's camera rectangle and the browser card (its native view follows the card) take every change, cards take * derived values that change rarely (summary mode, WebGL eligibility), and pointer handlers read `get()`. */ export interface CameraStore { diff --git a/src/renderer/src/features/workspace/minimapContent.ts b/src/renderer/src/features/workspace/minimapContent.ts new file mode 100644 index 00000000..7ad218de --- /dev/null +++ b/src/renderer/src/features/workspace/minimapContent.ts @@ -0,0 +1,47 @@ +import type { + BrowserCanvasState, + CanvasRegion, + PluginCanvasInstance, + SessionBounds, + SessionSnapshot, + StickyNote +} from "../../../../shared/contracts"; +import { boundsEqual } from "./canvasStacking.ts"; + +export interface MinimapContent { + homeBounds: SessionBounds; + canvasRegions: readonly CanvasRegion[]; + sessions: readonly SessionSnapshot[]; + stickyNotes: readonly StickyNote[]; + pluginCanvas: readonly PluginCanvasInstance[]; + browserCanvas: BrowserCanvasState | null; + layerOrder: readonly string[]; +} + +/** Session output, titles and activity do not change the workspace projection. */ +export function minimapContentEqual(previous: MinimapContent, next: MinimapContent): boolean { + return boundsEqual(previous.homeBounds, next.homeBounds) + && sameItems(previous.layerOrder, next.layerOrder, (left, right) => left === right) + && sameItems(previous.canvasRegions, next.canvasRegions, (left, right) => ( + left.id === right.id && left.color === right.color && boundsEqual(left, right) + )) + && sameItems(previous.sessions, next.sessions, (left, right) => ( + left.id === right.id && left.provider === right.provider && boundsEqual(left, right) + )) + && sameItems(previous.stickyNotes, next.stickyNotes, sameWindow) + && sameItems(previous.pluginCanvas, next.pluginCanvas, sameWindow) + && (previous.browserCanvas === next.browserCanvas || ( + previous.browserCanvas !== null && next.browserCanvas !== null + && boundsEqual(previous.browserCanvas, next.browserCanvas) + )); +} + +function sameWindow(left: SessionBounds & { id: string }, right: SessionBounds & { id: string }): boolean { + return left.id === right.id && boundsEqual(left, right); +} + +function sameItems(previous: readonly T[], next: readonly T[], equal: (left: T, right: T) => boolean): boolean { + return previous === next || ( + previous.length === next.length && previous.every((item, index) => equal(item, next[index])) + ); +} diff --git a/src/renderer/src/features/workspace/minimapGeometry.ts b/src/renderer/src/features/workspace/minimapGeometry.ts index 852cdddc..6703b727 100644 --- a/src/renderer/src/features/workspace/minimapGeometry.ts +++ b/src/renderer/src/features/workspace/minimapGeometry.ts @@ -5,10 +5,11 @@ import type { SessionBounds, Size } from "../../../../shared/contracts"; +import { boundsUnion } from "./canvasCameraGeometry.ts"; export const MINIMAP_SURFACE_SIZE = { width: 172, height: 104 } as const; -export const MINIMAP_VIEWPORT_MARKER_SIZE = { width: 46, height: 30 } as const; -const MINIMAP_HOME_EDGE_MARKER_SIZE = { width: 14, height: 14 } as const; +export const MINIMAP_CONTENT_PADDING = 8; +const MINIMAP_EDGE_MARKER_SIZE = { width: 14, height: 14 } as const; export interface NormalizedMinimapPoint { x: number; @@ -21,20 +22,23 @@ export interface NormalizedMinimapArea extends NormalizedMinimapPoint { } /** - * A camera-centred world window with one uniform world-units-per-pixel scale. - * Its scale changes with zoom, but never with pan or with the number/position of - * canvas objects, so moving away cannot auto-fit or stretch the map contents. + * Fit the occupied workspace with one scale for both axes. Camera pan and zoom + * never enter this calculation, so only layout changes can rescale the overview. */ -export function minimapWorldBounds(worldViewport: SessionBounds): SessionBounds { +export function minimapWorldBounds(content: readonly SessionBounds[]): SessionBounds { + const contentBounds = boundsUnion(content) ?? { + position: { x: 0, y: 0 }, + size: { width: 1, height: 1 } + }; const worldUnitsPerPixel = Math.max( - worldViewport.size.width / MINIMAP_VIEWPORT_MARKER_SIZE.width, - worldViewport.size.height / MINIMAP_VIEWPORT_MARKER_SIZE.height + Math.max(1, contentBounds.size.width) / (MINIMAP_SURFACE_SIZE.width - MINIMAP_CONTENT_PADDING * 2), + Math.max(1, contentBounds.size.height) / (MINIMAP_SURFACE_SIZE.height - MINIMAP_CONTENT_PADDING * 2) ); const size = { width: MINIMAP_SURFACE_SIZE.width * worldUnitsPerPixel, height: MINIMAP_SURFACE_SIZE.height * worldUnitsPerPixel }; - const center = boundsCenter(worldViewport); + const center = boundsCenter(contentBounds); return { position: { x: center.x - size.width / 2, y: center.y - size.height / 2 }, size @@ -72,23 +76,15 @@ export function minimapAreaForBounds( bounds: SessionBounds, worldBounds: SessionBounds ): NormalizedMinimapArea | null { - const left = Math.max(bounds.position.x, worldBounds.position.x); - const top = Math.max(bounds.position.y, worldBounds.position.y); - const right = Math.min( - bounds.position.x + bounds.size.width, - worldBounds.position.x + worldBounds.size.width - ); - const bottom = Math.min( - bounds.position.y + bounds.size.height, - worldBounds.position.y + worldBounds.size.height - ); - if (right <= left || bottom <= top) return null; + if (!boundsIntersect(bounds, worldBounds)) return null; + // The surface clips the original rectangle; clipping its bounds here would + // draw a false viewport border along the map edge. return { - x: (left - worldBounds.position.x) / worldBounds.size.width, - y: (top - worldBounds.position.y) / worldBounds.size.height, - width: (right - left) / worldBounds.size.width, - height: (bottom - top) / worldBounds.size.height + x: (bounds.position.x - worldBounds.position.x) / worldBounds.size.width, + y: (bounds.position.y - worldBounds.position.y) / worldBounds.size.height, + width: bounds.size.width / worldBounds.size.width, + height: bounds.size.height / worldBounds.size.height }; } @@ -102,8 +98,8 @@ export function minimapEdgePointForBounds( const delta = { x: target.x - 0.5, y: target.y - 0.5 }; if (delta.x === 0 && delta.y === 0) return null; - const radiusX = 0.5 - MINIMAP_HOME_EDGE_MARKER_SIZE.width / 2 / MINIMAP_SURFACE_SIZE.width; - const radiusY = 0.5 - MINIMAP_HOME_EDGE_MARKER_SIZE.height / 2 / MINIMAP_SURFACE_SIZE.height; + const radiusX = 0.5 - MINIMAP_EDGE_MARKER_SIZE.width / 2 / MINIMAP_SURFACE_SIZE.width; + const radiusY = 0.5 - MINIMAP_EDGE_MARKER_SIZE.height / 2 / MINIMAP_SURFACE_SIZE.height; const scaleX = delta.x === 0 ? Number.POSITIVE_INFINITY : radiusX / Math.abs(delta.x); const scaleY = delta.y === 0 ? Number.POSITIVE_INFINITY : radiusY / Math.abs(delta.y); const scale = Math.min(scaleX, scaleY); diff --git a/src/renderer/src/styles/app.css b/src/renderer/src/styles/app.css index c5073304..fc9081f1 100644 --- a/src/renderer/src/styles/app.css +++ b/src/renderer/src/styles/app.css @@ -343,16 +343,18 @@ button { border: 0; } .canvas-minimap:hover, .canvas-minimap:focus-visible, .agent-chat-history:hover, .agent-chat-history:focus-within { border-color: rgba(255,255,255,.32); background: rgba(40,41,52,.97); } .canvas-minimap:focus-visible { box-shadow: 0 0 0 2px var(--primary), var(--shadow-sm); } .canvas-minimap__surface { position: relative; width: 100%; height: 100%; display: block; overflow: hidden; border-radius: 7px; background-color: rgba(255,255,255,.035); background-image: radial-gradient(rgba(255,255,255,.16) .7px, transparent .7px); background-size: 8px 8px; } -.canvas-minimap__home, .canvas-minimap__region, .canvas-minimap__entity, .canvas-minimap__viewport, .canvas-minimap__home-edge { position: absolute; display: block; pointer-events: none; } -.canvas-minimap__home { border: 1px solid rgba(184,207,153,.82); border-radius: 2px; background: rgba(184,207,153,.17); } -.canvas-minimap__region { border: 1px solid color-mix(in srgb, var(--minimap-region-color) 72%, white); border-radius: 2px; background: color-mix(in srgb, var(--minimap-region-color) 25%, transparent); } -.canvas-minimap__entity { z-index: 2; width: 9px; height: 7px; border-radius: 2px; transform: translate(-50%, -50%); box-shadow: 0 0 0 1px rgba(31,32,42,.34); } -.canvas-minimap__entity--terminal { background: rgba(248,247,241,.82); } -.canvas-minimap__entity--plugin { background: rgba(213,162,201,.9); } -.canvas-minimap__entity--browser { background: rgba(156,199,220,.94); } -.canvas-minimap__entity--note { background: var(--yellow); } -.canvas-minimap__viewport { z-index: 3; width: 46px; height: 30px; border: 2px solid white; border-radius: 3px; background: rgba(255,255,255,.045); box-shadow: 0 0 0 1px rgba(31,32,42,.52); transform: translate(-50%, -50%); } -.canvas-minimap__home-edge { z-index: 4; width: 14px; height: 14px; border: 3px solid #d8e1c5; border-radius: 2px; background: #30313d; box-shadow: 0 0 0 2px rgba(48,49,61,.82); transform: translate(-50%, -50%); } +.canvas-minimap__home, .canvas-minimap__region, .canvas-minimap__entity, .canvas-minimap__viewport, .canvas-minimap__viewport-edge { position: absolute; display: block; pointer-events: none; } +.canvas-minimap__home { border-radius: 2px; background: rgba(184,207,153,.17); box-shadow: inset 0 0 0 1px rgba(184,207,153,.82); } +.canvas-minimap__region { border-radius: 2px; background: color-mix(in srgb, var(--minimap-region-color) 25%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--minimap-region-color) 72%, white); } +.canvas-minimap__windows { position: absolute; inset: 0; z-index: 2; pointer-events: none; } +.canvas-minimap__entity { --minimap-entity-color: rgba(248,247,241,.82); overflow: hidden; border-radius: 2px; background: color-mix(in srgb, var(--minimap-entity-color) 25%, transparent); box-shadow: inset 0 0 0 1px var(--minimap-entity-color); } +.canvas-minimap__entity--plugin { --minimap-entity-color: rgba(213,162,201,.9); } +.canvas-minimap__entity--browser { --minimap-entity-color: rgba(156,199,220,.94); } +.canvas-minimap__entity--note { --minimap-entity-color: var(--yellow); } +.canvas-minimap__entity .provider-icon { position: absolute; left: 50%; top: 50%; width: var(--minimap-icon-size); height: var(--minimap-icon-size); transform: translate(-50%, -50%); } +.canvas-minimap__entity .provider-icon img, .canvas-minimap__entity .provider-icon__system { width: 100%; height: 100%; } +.canvas-minimap__viewport { z-index: 3; border-radius: 3px; background: rgba(255,255,255,.045); box-shadow: inset 0 0 0 2px white, 0 0 0 1px rgba(31,32,42,.52); } +.canvas-minimap__viewport-edge { z-index: 4; width: 14px; height: 14px; border: 2px solid white; border-radius: 2px; background: #30313d; box-shadow: 0 0 0 2px rgba(48,49,61,.82); transform: translate(-50%, -50%); } .canvas-region { position: absolute; left: 0; top: 0; box-sizing: border-box; border: 2px solid color-mix(in srgb, var(--canvas-region-color) 72%, #30313d); border-radius: 15px; background: color-mix(in srgb, var(--canvas-region-color) 27%, transparent); box-shadow: inset 0 0 0 1px rgba(255,255,255,.16); } .canvas-region__title { position: absolute; left: 14px; top: 14px; max-width: calc(100% - 28px); min-height: 34px; padding: 8px 12px; overflow: hidden; border: 1px solid color-mix(in srgb, var(--canvas-region-color) 72%, #30313d); border-radius: 9px; color: #30313d; background: color-mix(in srgb, var(--canvas-region-color) 82%, white); box-shadow: var(--shadow-sm); cursor: move; font: 850 12px/1 var(--font-mono); text-overflow: ellipsis; white-space: nowrap; touch-action: none; } .canvas-region__resize-handle { position: absolute; z-index: 2; touch-action: none; } diff --git a/tests/minimap-geometry.test.mjs b/tests/minimap-geometry.test.mjs index a48acc12..afecac55 100644 --- a/tests/minimap-geometry.test.mjs +++ b/tests/minimap-geometry.test.mjs @@ -2,14 +2,16 @@ import assert from "node:assert/strict"; import test from "node:test"; import { MINIMAP_SURFACE_SIZE, - MINIMAP_VIEWPORT_MARKER_SIZE, + MINIMAP_CONTENT_PADDING, cameraWorldViewport, minimapCameraForPointerDrag, minimapAreaForBounds, minimapEdgePointForBounds, minimapPointForBounds, - minimapWorldBounds + minimapWorldBounds, + minimapWorldPoint } from "../src/renderer/src/features/workspace/minimapGeometry.ts"; +import { minimapContentEqual } from "../src/renderer/src/features/workspace/minimapContent.ts"; const home = { position: { x: 0, y: 0 }, @@ -17,25 +19,25 @@ const home = { }; const viewportSize = { width: 1_400, height: 820 }; -test("camera pan moves HOME through a fixed-scale minimap window", () => { +test("camera pan and zoom change the viewport without rescaling the workspace overview", () => { const initialViewport = cameraWorldViewport({ x: 0, y: 0, zoom: 1 }, viewportSize); - const movedViewport = cameraWorldViewport({ x: -2_400, y: -600, zoom: 1 }, viewportSize); - const initialWorld = minimapWorldBounds(initialViewport); - const movedWorld = minimapWorldBounds(movedViewport); - const initialHomePoint = minimapPointForBounds(home, initialWorld); - const movedHomePoint = minimapPointForBounds(home, movedWorld); - - assert.deepEqual(movedWorld.size, initialWorld.size); - assert.equal(movedHomePoint.x < initialHomePoint.x, true); - assert.equal(movedHomePoint.y < initialHomePoint.y, true); - assert.deepEqual(MINIMAP_VIEWPORT_MARKER_SIZE, { width: 46, height: 30 }); - assert.deepEqual(minimapPointForBounds(initialViewport, initialWorld), { x: 0.5, y: 0.5 }); - assert.deepEqual(minimapPointForBounds(movedViewport, movedWorld), { x: 0.5, y: 0.5 }); + const movedViewport = cameraWorldViewport({ x: -400, y: -200, zoom: 0.75 }, viewportSize); + const world = minimapWorldBounds([home]); + const homeArea = minimapAreaForBounds(home, world); + const initialArea = minimapAreaForBounds(initialViewport, world); + const movedArea = minimapAreaForBounds(movedViewport, world); + + assert.ok(initialArea && movedArea); + assert.ok(movedArea.x > initialArea.x); + assert.ok(movedArea.y > initialArea.y); + assert.ok(movedArea.width > initialArea.width); + assert.deepEqual(minimapAreaForBounds(home, world), homeArea); + assert.ok(Math.abs(initialArea.width * MINIMAP_SURFACE_SIZE.width + / (initialArea.height * MINIMAP_SURFACE_SIZE.height) - viewportSize.width / viewportSize.height) < 1e-9); }); test("minimap projection uses one scale for both axes and never stretches objects", () => { - const viewport = cameraWorldViewport({ x: 0, y: 0, zoom: 0.75 }, viewportSize); - const world = minimapWorldBounds(viewport); + const world = minimapWorldBounds([home]); const worldUnitsPerPixelX = world.size.width / MINIMAP_SURFACE_SIZE.width; const worldUnitsPerPixelY = world.size.height / MINIMAP_SURFACE_SIZE.height; const square = { @@ -44,7 +46,7 @@ test("minimap projection uses one scale for both axes and never stretches object }; const projected = minimapAreaForBounds(square, world); - assert.equal(worldUnitsPerPixelX, worldUnitsPerPixelY); + assert.ok(Math.abs(worldUnitsPerPixelX - worldUnitsPerPixelY) < 1e-9); assert.ok(projected); assert.equal( Math.round(projected.width * MINIMAP_SURFACE_SIZE.width * 1_000), @@ -52,37 +54,80 @@ test("minimap projection uses one scale for both axes and never stretches object ); }); -test("HOME edge marker exists only after HOME has left the minimap", () => { - const viewport = cameraWorldViewport({ x: 0, y: 0, zoom: 1 }, viewportSize); - const world = minimapWorldBounds(viewport); - const partlyVisibleHome = { +test("the viewport edge marker appears only when the camera leaves the overview", () => { + const world = minimapWorldBounds([home]); + const partlyVisibleViewport = { position: { x: world.position.x + world.size.width - 40, y: 200 }, size: { width: 200, height: 300 } }; - const outsideHome = { + const outsideViewport = { position: { x: world.position.x + world.size.width + 80, y: 200 }, size: { width: 200, height: 300 } }; assert.ok(minimapAreaForBounds(home, world)); assert.equal(minimapEdgePointForBounds(home, world), null); - assert.ok(minimapAreaForBounds(partlyVisibleHome, world)); - assert.equal(minimapEdgePointForBounds(partlyVisibleHome, world), null); - assert.equal(minimapAreaForBounds(outsideHome, world), null); + assert.ok(minimapAreaForBounds(partlyVisibleViewport, world)); + assert.equal(minimapEdgePointForBounds(partlyVisibleViewport, world), null); + assert.equal(minimapAreaForBounds(outsideViewport, world), null); - const edge = minimapEdgePointForBounds(outsideHome, world); + const edge = minimapEdgePointForBounds(outsideViewport, world); assert.ok(edge); assert.equal(edge.x > 0.9, true); assert.equal(edge.x < 1, true); }); -test("HOME and windows are allowed to leave instead of sticking to a clamped edge", () => { - const viewport = cameraWorldViewport({ x: -8_000, y: 0, zoom: 1 }, viewportSize); - const world = minimapWorldBounds(viewport); - const point = minimapPointForBounds(home, world); +test("the overview fits every object including distant windows and negative coordinates", () => { + const windows = [home, + { position: { x: -8_000, y: -3_000 }, size: { width: 1_600, height: 900 } }, + { position: { x: 12_000, y: 4_000 }, size: { width: 800, height: 450 } } + ]; + const world = minimapWorldBounds(windows); + const marginX = MINIMAP_CONTENT_PADDING / MINIMAP_SURFACE_SIZE.width; + const marginY = MINIMAP_CONTENT_PADDING / MINIMAP_SURFACE_SIZE.height; + for (const bounds of windows) { + const area = minimapAreaForBounds(bounds, world); + assert.ok(area); + assert.ok(area.x >= marginX - 1e-9 && area.y >= marginY - 1e-9); + assert.ok(area.x + area.width <= 1 - marginX + 1e-9); + assert.ok(area.y + area.height <= 1 - marginY + 1e-9); + } + const large = minimapAreaForBounds(windows[1], world); + const small = minimapAreaForBounds(windows[2], world); + assert.equal(large.width, small.width * 2); + assert.equal(large.height, small.height * 2); +}); + +test("layout changes expand and shrink the overview around occupied bounds", () => { + const distant = { position: { x: 10_000, y: 0 }, size: { width: 1_000, height: 600 } }; + const expanded = minimapWorldBounds([home, distant]); + const restored = minimapWorldBounds([home]); + assert.ok(expanded.size.width > restored.size.width); + assert.deepEqual(restored, minimapWorldBounds([home])); + const resized = minimapWorldBounds([{ ...home, size: { width: 4_000, height: 3_000 } }]); + assert.ok(resized.size.height > restored.size.height); +}); + +test("viewport rectangles retain their true geometry when clipped by the map surface", () => { + const world = minimapWorldBounds([home]); + const viewport = { + position: { x: world.position.x - 100, y: world.position.y + 100 }, + size: { width: 400, height: 300 } + }; + const area = minimapAreaForBounds(viewport, world); + assert.ok(area); + assert.ok(area.x < 0); + assert.equal(area.width, 400 / world.size.width); +}); - assert.equal(point.x < 0, true); - assert.equal(minimapAreaForBounds(home, world), null); +test("an empty overview has a finite projection and map clicks recover world coordinates", () => { + const empty = minimapWorldBounds([]); + assert.ok(empty.size.width > 0 && Number.isFinite(empty.size.width)); + assert.ok(empty.size.height > 0 && Number.isFinite(empty.size.height)); + const world = minimapWorldBounds([home]); + const center = minimapWorldPoint(minimapPointForBounds(home, world), world); + assert.ok(Math.abs(center.x - home.size.width / 2) < 1e-9); + assert.ok(Math.abs(center.y - home.size.height / 2) < 1e-9); }); test("minimap drag follows the same grab direction as empty-canvas drag", () => { @@ -104,3 +149,57 @@ test("minimap drag follows the same grab direction as empty-canvas drag", () => assert.equal(Math.abs(dragged.y - 252) < 1e-9, true); assert.equal(dragged.zoom, 0.5); }); + +test("session activity and text changes do not invalidate the workspace layer", () => { + const scene = { + homeBounds: home, + canvasRegions: [], + sessions: [{ ...home, id: "session", provider: "codex", title: "Original", buffer: "", status: "idle" }], + stickyNotes: [{ ...home, id: "note", text: "Original note" }], + pluginCanvas: [], + browserCanvas: null, + layerOrder: ["terminal:session", "note:note"] + }; + const updated = { + ...scene, + homeBounds: { position: { ...home.position }, size: { ...home.size } }, + sessions: [{ ...scene.sessions[0], title: "Renamed", buffer: "New output", status: "working" }], + stickyNotes: [{ ...scene.stickyNotes[0], text: "Edited note" }], + layerOrder: [...scene.layerOrder] + }; + assert.equal(minimapContentEqual(scene, updated), true); + + for (const change of [ + { provider: "hermes" }, + { id: "replacement" }, + { position: { x: 100, y: 0 } }, + { size: { width: 2_000, height: 1_062 } } + ]) { + assert.equal(minimapContentEqual(scene, { ...scene, sessions: [{ ...scene.sessions[0], ...change }] }), false); + } + assert.equal(minimapContentEqual(scene, { ...scene, sessions: [] }), false); + assert.equal(minimapContentEqual(scene, { ...scene, layerOrder: [...scene.layerOrder].reverse() }), false); + assert.equal(minimapContentEqual(scene, { ...scene, homeBounds: { ...home, size: { width: 2_000, height: 1_062 } } }), false); +}); + +test("region appearance, plugin geometry and Browser visibility invalidate the workspace layer", () => { + const scene = { + homeBounds: home, + canvasRegions: [{ ...home, id: "region", color: "#B8CF99", title: "Original" }], + sessions: [], + stickyNotes: [], + pluginCanvas: [{ ...home, id: "plugin" }], + browserCanvas: { ...home }, + layerOrder: ["plugin:plugin", "browser"] + }; + assert.equal(minimapContentEqual(scene, { + ...scene, canvasRegions: [{ ...scene.canvasRegions[0], title: "Renamed" }] + }), true); + assert.equal(minimapContentEqual(scene, { + ...scene, canvasRegions: [{ ...scene.canvasRegions[0], color: "#9CC7DC" }] + }), false); + assert.equal(minimapContentEqual(scene, { + ...scene, pluginCanvas: [{ ...scene.pluginCanvas[0], position: { x: 100, y: 0 } }] + }), false); + assert.equal(minimapContentEqual(scene, { ...scene, browserCanvas: null }), false); +}); diff --git a/tests/settings-ui.test.mjs b/tests/settings-ui.test.mjs index 58ced30c..d98a166a 100644 --- a/tests/settings-ui.test.mjs +++ b/tests/settings-ui.test.mjs @@ -165,12 +165,12 @@ test("canvas overlays share configurable collision-safe corner slots", async () assert.match(workspace, / {