diff --git a/src/workspaces/helpers/measure-zoomed.ts b/src/workspaces/helpers/measure-zoomed.ts new file mode 100644 index 00000000..2d2681f6 --- /dev/null +++ b/src/workspaces/helpers/measure-zoomed.ts @@ -0,0 +1,19 @@ +// Panel content is scaled with CSS `zoom` (see PanelSlot), and that splits measurement into two units. +// Layout — percentages, `transform: translateY(Npx)`, anything you write into a style — is in *layout* +// px, which zoom leaves alone. `getBoundingClientRect()` is in *rendered* px, already multiplied by the +// zoom. Feed one into the other and every offset is wrong by the zoom factor: at 0.7 a virtualiser +// measures each row as 70% of the height it will actually occupy, places the next row that much too +// early, and the list collapses into itself. +// +// `offsetHeight` is in layout px and would also fix it, but it rounds to whole pixels, which a +// virtualiser accumulates into visible drift. So keep the subpixel rect and divide the zoom back out. +// `currentCSSZoom` is the element's effective zoom including every zoomed ancestor; it is absent on +// older engines, where zoom is not applied either, so 1 is the right fallback. + +const effectiveZoom = (element: Element) => (element as Element & { currentCSSZoom?: number }).currentCSSZoom || 1; + +/** An element's height in layout px — the unit its own positioning styles are written in. */ +export const measureHeight = (element: Element) => element.getBoundingClientRect().height / effectiveZoom(element); + +/** An element's width in layout px. */ +export const measureWidth = (element: Element) => element.getBoundingClientRect().width / effectiveZoom(element); diff --git a/src/workspaces/officerdev/src/apps/Chat/components/MessageList.tsx b/src/workspaces/officerdev/src/apps/Chat/components/MessageList.tsx index 9c132819..796ad5f1 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/MessageList.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/MessageList.tsx @@ -1,6 +1,7 @@ import { useLayoutEffect, useRef } from 'react'; import { ArrowDown, Loader2 } from 'lucide-react'; import { useVirtualizer } from '@tanstack/react-virtual'; +import { measureHeight } from 'helpers/measure-zoomed'; import type { UseEmbeddableChatType } from '../EmbeddableChat/useEmbeddableChat'; import type { ChatMessage } from '../types'; import { MessageBubble, StreamingBubble } from './MessageBubble'; @@ -40,7 +41,9 @@ export const MessageList = ({ manager }: MessageListProps) => { getScrollElement: () => scrollViewportRef.current, estimateSize: () => 150, overscan: OVERSCAN, - measureElement: (element) => element.getBoundingClientRect().height, + // Measured height must be in the same unit as the translateY below — layout px, which is what the + // panel zoom leaves alone. A raw getBoundingClientRect is in rendered px and stacks the bubbles. + measureElement: measureHeight, }); // Keep the viewport anchored when older messages are prepended: the message that used to be first diff --git a/src/workspaces/officerdev/src/apps/Desktop/index.ts b/src/workspaces/officerdev/src/apps/Desktop/index.ts index fe5f203e..b344a9f3 100644 --- a/src/workspaces/officerdev/src/apps/Desktop/index.ts +++ b/src/workspaces/officerdev/src/apps/Desktop/index.ts @@ -13,5 +13,8 @@ export const appRegistryMetas: AppRegistryMeta[] = [ component: DesktopWrapper, header: DesktopHeader, availableOnPanel: false, + // noVNC scales the remote framebuffer itself and maps pointer coordinates against that; a wrapping + // CSS transform would leave every click landing somewhere other than where it was aimed. + zoomable: false, }, ]; diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekView.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekView.tsx index 2f6dbac4..4badd9fe 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekView.tsx +++ b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekView.tsx @@ -1,7 +1,6 @@ import { Construction } from 'lucide-react'; import { usePanelChannel } from 'hooks/usePanelChannel'; -import { useDashboardState } from 'state/useDashboardState'; -import { SOULSEEK_SECTION_CHANNEL, SOULSEEK_SECTIONS, soulseekZoomKey, type SoulseekSectionId } from './shared'; +import { SOULSEEK_SECTION_CHANNEL, SOULSEEK_SECTIONS, type SoulseekSectionId } from './shared'; import { SearchView } from './SearchView'; import { SoulseekTransfers } from './SoulseekTransfers'; import { SoulseekUploads } from './SoulseekUploads'; @@ -13,10 +12,8 @@ import { SoulseekSystem } from './SoulseekSystem'; import { SoulseekConnection } from './SoulseekConnection'; import { useServiceConnection } from '../../hooks/useServiceConnection'; -// Right panel of the /soulseek workspace — renders the UI for the section the nav selected. The panel -// header's +/- controls set a per-panel zoom factor, persisted via useDashboardState (same store as the -// layout config); we read it here and apply it as a CSS scale, with a compensating width/height so the -// scaled content still fills the panel and inner scroll keeps working. +// Right panel of the /soulseek workspace — renders the UI for the section the nav selected. Content +// zoom used to live here; it is now a framework-level per-panel control in PanelSlot. const Placeholder = ({ id }: { id: SoulseekSectionId }) => { const label = SOULSEEK_SECTIONS.find((s) => s.id === id)?.label ?? id; @@ -58,27 +55,13 @@ const sectionView = (section: SoulseekSectionId) => { } }; -type SoulseekViewProps = { panelId: string }; - -export const SoulseekView = ({ panelId }: SoulseekViewProps) => { +export const SoulseekView = () => { const [section] = usePanelChannel(SOULSEEK_SECTION_CHANNEL, 'dashboard'); - const { value: zoom } = useDashboardState(soulseekZoomKey(panelId), 1); const { data: connection, isLoading } = useServiceConnection('slskd'); - const z = zoom ?? 1; // Nothing connected yet: the setup form takes over every section, because none of them can do anything - // without a daemon. Zoom is skipped for it too — it is a form, not a dense slskd panel. + // without a daemon. if (!isLoading && !connection?.configured) return ; - if (z === 1) return
{sectionView(section)}
; - - // transform: scale doesn't reflow, so size the box to 1/z and let the scale bring it back to 100%. - const inv = `${100 / z}%`; - return ( -
-
- {sectionView(section)} -
-
- ); + return
{sectionView(section)}
; }; diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekViewHeader.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekViewHeader.tsx deleted file mode 100644 index 7b4b0985..00000000 --- a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekViewHeader.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { LayoutGrid, ZoomIn, ZoomOut } from 'lucide-react'; -import { useDashboardState } from 'state/useDashboardState'; -import { soulseekZoomKey, SOULSEEK_ZOOM_MIN, SOULSEEK_ZOOM_MAX, SOULSEEK_ZOOM_STEP } from './shared'; - -// Panel header for the right (soulseek-view) panel — icon + title plus +/- zoom controls. The zoom -// factor is persisted per-panel via useDashboardState (same store as the layout config) so it survives -// reloads; SoulseekView reads the same key to scale its content. This is the Workspace panel-header API: -// a `header` component (registered alongside `component`) rendered by PanelSlot with { panelId }. - -const clamp = (z: number) => Math.min(SOULSEEK_ZOOM_MAX, Math.max(SOULSEEK_ZOOM_MIN, Math.round(z * 10) / 10)); - -type SoulseekViewHeaderProps = { panelId: string }; - -export const SoulseekViewHeader = ({ panelId }: SoulseekViewHeaderProps) => { - const { value: zoom, setValue: setZoom } = useDashboardState(soulseekZoomKey(panelId), 1); - const level = zoom ?? 1; - - return ( - <> - - Soulseek -
- - - -
- - ); -}; diff --git a/src/workspaces/officerdev/src/apps/Soulseek/index.ts b/src/workspaces/officerdev/src/apps/Soulseek/index.ts index 2cfdc74e..147d607d 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/index.ts +++ b/src/workspaces/officerdev/src/apps/Soulseek/index.ts @@ -2,18 +2,10 @@ import type { AppRegistryMeta } from '../../AppRegistry'; import { PanelLeft, LayoutGrid } from 'lucide-react'; import { SoulseekNav } from './SoulseekNav'; import { SoulseekView } from './SoulseekView'; -import { SoulseekViewHeader } from './SoulseekViewHeader'; export { SoulseekNav, SoulseekView }; export const appRegistryMetas: AppRegistryMeta[] = [ { key: 'soulseek-nav', name: 'Soulseek', icon: PanelLeft, component: SoulseekNav, availableOnPanel: false }, - { - key: 'soulseek-view', - name: 'Soulseek', - icon: LayoutGrid, - component: SoulseekView, - header: SoulseekViewHeader, - availableOnPanel: false, - }, + { key: 'soulseek-view', name: 'Soulseek', icon: LayoutGrid, component: SoulseekView, availableOnPanel: false }, ]; diff --git a/src/workspaces/officerdev/src/apps/Soulseek/shared.ts b/src/workspaces/officerdev/src/apps/Soulseek/shared.ts index f095afab..f4aa558c 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/shared.ts +++ b/src/workspaces/officerdev/src/apps/Soulseek/shared.ts @@ -5,15 +5,6 @@ // panel refetches immediately instead of waiting for its next poll tick. export const SLSKD_REFRESH_CHANNEL = 'soulseek:refresh'; -// Per-panel content zoom for the soulseek-view (right) panel. The panel header (+/- buttons) writes the -// factor here; SoulseekView reads it and scales its content. Keyed by panelId so each panel instance -// zooms independently. Stored under the `screens/` namespace via useDashboardState — the same persisted -// dashboards store that holds the layout config — so the zoom level survives reloads. -export const soulseekZoomKey = (panelId: string) => `screens/soulseek-zoom/${panelId}`; -export const SOULSEEK_ZOOM_MIN = 0.7; -export const SOULSEEK_ZOOM_MAX = 1.6; -export const SOULSEEK_ZOOM_STEP = 0.1; - export type SlskdApplication = { version?: { current?: string; full?: string; latest?: string; isUpdateAvailable?: boolean }; server?: { state?: string; address?: string; isConnected?: boolean }; diff --git a/src/workspaces/officerdev/src/apps/Terminal/index.tsx b/src/workspaces/officerdev/src/apps/Terminal/index.tsx index 4fbfd573..2457bbb6 100644 --- a/src/workspaces/officerdev/src/apps/Terminal/index.tsx +++ b/src/workspaces/officerdev/src/apps/Terminal/index.tsx @@ -34,6 +34,9 @@ const ClaudeCodeWrapper = ({ panelId }: { panelId: string }) => ( ); +// zoomable: false on every real terminal — xterm sizes its own font and reflows to a measured cell grid, +// so a wrapping CSS scale both blurs the glyphs and desyncs the cols/rows it reports to the pty. The +// per-terminal font-size control in the panel header is the zoom for these. export const appRegistryMetas: AppRegistryMeta[] = [ { key: 'officerdev/terminal', @@ -41,6 +44,7 @@ export const appRegistryMetas: AppRegistryMeta[] = [ icon: TerminalSquare, component: TerminalWrapper, header: TerminalHeader, + zoomable: false, }, { key: 'officerdev/terminal-host', @@ -49,6 +53,7 @@ export const appRegistryMetas: AppRegistryMeta[] = [ component: HostTerminalWrapper, header: HostTerminalHeader, availableOnPanel: false, + zoomable: false, }, { key: 'officerdev/tmux', @@ -56,6 +61,7 @@ export const appRegistryMetas: AppRegistryMeta[] = [ icon: Columns2, component: TmuxWrapper, header: TmuxHeader, + zoomable: false, }, { key: 'officerdev/nvim', @@ -63,6 +69,7 @@ export const appRegistryMetas: AppRegistryMeta[] = [ icon: PenLine, component: NvimWrapper, header: NvimHeader, + zoomable: false, }, { key: 'officerdev/running-shells', @@ -76,5 +83,6 @@ export const appRegistryMetas: AppRegistryMeta[] = [ icon: Sparkles, component: ClaudeCodeWrapper, header: ClaudeCodeHeader, + zoomable: false, }, ]; diff --git a/src/workspaces/officerdev/src/components/Workspace/PanelSlot.tsx b/src/workspaces/officerdev/src/components/Workspace/PanelSlot.tsx index 7ff27161..b45c3639 100644 --- a/src/workspaces/officerdev/src/components/Workspace/PanelSlot.tsx +++ b/src/workspaces/officerdev/src/components/Workspace/PanelSlot.tsx @@ -1,8 +1,9 @@ import type { ComponentType } from 'react'; import { useCallback } from 'react'; -import { ArrowLeftRight, ChevronLeft, X, Minus } from 'lucide-react'; +import { ArrowLeftRight, ChevronLeft, X, Minus, ZoomIn, ZoomOut } from 'lucide-react'; import type { LayoutPanel, AppRegistryMap, PanelComponents, PanelComponentEntry } from './types'; import { useWorkspace } from './WorkspaceContext'; +import { ZOOM_MIN, ZOOM_MAX, ZOOM_STEP } from './layout-utils'; import { Card } from '@/components/Card'; import { AppPicker } from './AppPicker'; import { @@ -30,6 +31,8 @@ type PanelContextMenuProps = { panelId: string; hasApp: boolean; isLastPanel: boolean; + // Current content scale, or null when the app opted out of zoom (no zoom entries in the menu). + zoom: number | null; onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void; onRemove: (panelId: string) => void; onClearApp: () => void; @@ -43,6 +46,7 @@ const PanelContextMenu = ({ panelId, hasApp, isLastPanel, + zoom, onSplit, onRemove, onClearApp, @@ -73,6 +77,7 @@ const PanelContextMenu = ({ ) : ( setSwapSourceId(panelId)}>Swap with... )} + {zoom !== null && } ); @@ -221,7 +226,73 @@ const MaximizeButton = ({ panelId }: { panelId: string }) => { ); }; -const MaximizeContextMenu = ({ panelId, children }: { panelId: string; children: React.ReactNode }) => { +// Per-panel content scale. The percentage only appears once the panel is off 100%, so an untouched +// header looks exactly as it did before; the +/- buttons reveal on header hover. Clicking the +// percentage resets to 100%. +const ZoomControls = ({ panelId, zoom }: { panelId: string; zoom: number }) => { + const { onSetZoom } = useWorkspace(); + const atDefault = zoom === 1; + + return ( +
+ + + +
+ ); +}; + +const ZoomMenuItems = ({ panelId, zoom }: { panelId: string; zoom: number }) => { + const { onSetZoom } = useWorkspace(); + + return ( + <> + + = ZOOM_MAX} onClick={() => onSetZoom(panelId, zoom + ZOOM_STEP)}> + Zoom in + + onSetZoom(panelId, zoom - ZOOM_STEP)}> + Zoom out + + onSetZoom(panelId, 1)}> + Reset zoom + + + ); +}; + +const MaximizeContextMenu = ({ + panelId, + zoom, + children, +}: { + panelId: string; + zoom: number | null; + children: React.ReactNode; +}) => { const { maximizedPanelId, setMaximizedPanelId } = useWorkspace(); const isMaximized = maximizedPanelId === panelId; @@ -232,6 +303,7 @@ const MaximizeContextMenu = ({ panelId, children }: { panelId: string; children: setMaximizedPanelId(isMaximized ? null : panelId)}> {isMaximized ? 'Restore' : 'Maximize'} + {zoom !== null && } ); @@ -282,14 +354,22 @@ export const PanelSlot = ({ const ProviderComponent = panelEntry?.provider ?? entry?.provider; const onClose = panelEntry?.onClose; + // null = this app opted out of zoom, so no control and no menu entries. + const zoom = entry?.zoomable === false ? null : (panel.zoom ?? 1); + const contextMenu = interactive ? locked - ? (content: React.ReactNode) => {content} + ? (content: React.ReactNode) => ( + + {content} + + ) : (content: React.ReactNode) => ( onSetApp(panel.id, null)} @@ -375,8 +455,11 @@ export const PanelSlot = ({ ) : null; + const zoomControls = + interactive && !isMobile && zoom !== null ? : null; + const headerContent = ( -
+
{mobileBackButton} {ResolvedHeader && } {!mobileBackButton && onClose && ( @@ -384,18 +467,28 @@ export const PanelSlot = ({ )} - {trafficLights} + {(zoomControls || trafficLights) && ( + // One shrink-to-fit container so TrafficLights' own ml-auto has no free space left to claim — + // two competing auto margins would split the header's slack and strand the zoom control mid-row. +
+ {zoomControls} + {trafficLights} +
+ )}
); const headerBar = interactive ? ( locked ? ( - {headerContent} + + {headerContent} + ) : ( onSetApp(panel.id, null)} @@ -407,10 +500,23 @@ export const PanelSlot = ({ headerContent ); + // CSS `zoom`, not `transform: scale`. A transform paints the content at a different size without + // re-laying it out, so the panel keeps the geometry it had at 100% and anything sized against the + // viewport or a percentage lands in the wrong place — visible immediately in Chat, whose composer is + // pinned to the bottom of a box the transform no longer agrees with. `zoom` scales the used lengths + // instead: children reflow, `h-full` and percentages still resolve to the panel, and rem-based + // Tailwind text sizes scale too (a container font-size would not — rem is root-relative). Percentages + // resolve across the zoom boundary, so the wrapper still fills the panel exactly at any factor. + // + // @container sits on the zoomed element on purpose: at 1.4 a 700px panel measures 500 layout px, so + // container queries collapse the same way they would in a genuinely narrower panel. That is the point + // of the control — bigger content, same layout rules. const body = ( -
+
- +
+ +
); diff --git a/src/workspaces/officerdev/src/components/Workspace/WorkspaceContext.ts b/src/workspaces/officerdev/src/components/Workspace/WorkspaceContext.ts index 4849abea..df5d083b 100644 --- a/src/workspaces/officerdev/src/components/Workspace/WorkspaceContext.ts +++ b/src/workspaces/officerdev/src/components/Workspace/WorkspaceContext.ts @@ -20,6 +20,7 @@ type WorkspaceContextValue = { dragSourceId: string | null; setDragSourceId: (id: string | null) => void; onMove: (sourceId: string, targetId: string, position: DropPosition) => void; + onSetZoom: (panelId: string, zoom: number) => void; maximizedPanelId: string | null; setMaximizedPanelId: (id: string | null) => void; transitioningPanelId: string | null; @@ -38,6 +39,7 @@ const WorkspaceContext = createContext({ dragSourceId: null, setDragSourceId: noop, onMove: noop, + onSetZoom: noop, maximizedPanelId: null, setMaximizedPanelId: noop, transitioningPanelId: null, diff --git a/src/workspaces/officerdev/src/components/Workspace/WorkspaceLayout.tsx b/src/workspaces/officerdev/src/components/Workspace/WorkspaceLayout.tsx index b128c3d7..017f0d01 100644 --- a/src/workspaces/officerdev/src/components/Workspace/WorkspaceLayout.tsx +++ b/src/workspaces/officerdev/src/components/Workspace/WorkspaceLayout.tsx @@ -33,7 +33,7 @@ export const WorkspaceLayout = ({ layout, onLayoutChange, registry: registryProp ); return ( - + { + const next = setZoom(layout, panelId, zoom); + if (next !== layout) onLayoutChange(next); + }, + [layout, onLayoutChange], + ); + const handleSwap = useCallback( (sourceId: string, targetId: string) => { onLayoutChange(swapPanels(layout, sourceId, targetId)); @@ -155,6 +163,7 @@ export const WorkspaceView = ({ workspace, locked, cwd = '~', root, initialFileP dragSourceId, setDragSourceId: startDrag, onMove: handleMove, + onSetZoom: handleSetZoom, maximizedPanelId, setMaximizedPanelId: setMaximizedAnimated, transitioningPanelId, diff --git a/src/workspaces/officerdev/src/components/Workspace/layout-utils.ts b/src/workspaces/officerdev/src/components/Workspace/layout-utils.ts index 82e8c261..514e4700 100644 --- a/src/workspaces/officerdev/src/components/Workspace/layout-utils.ts +++ b/src/workspaces/officerdev/src/components/Workspace/layout-utils.ts @@ -102,6 +102,32 @@ export function setApp(root: LayoutNode, panelId: string, appType: string | null return { ...root, children: newChildren }; } +export const ZOOM_MIN = 0.7; +export const ZOOM_MAX = 1.6; +export const ZOOM_STEP = 0.1; + +export const clampZoom = (z: number) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(z * 10) / 10)); + +// Set a panel's content scale. Stores nothing at 1 — the default — so an untouched panel adds no key to +// the persisted layout and a reset returns the node to its original shape. +export function setZoom(root: LayoutNode, panelId: string, zoom: number): LayoutNode { + if (root.type === 'panel') { + if (root.id !== panelId) return root; + const next = clampZoom(zoom); + if ((root.zoom ?? 1) === next) return root; + if (next === 1) { + const { zoom: _drop, ...rest } = root; + return rest; + } + return { ...root, zoom: next }; + } + const newChildren = root.children.map((child) => { + const node = setZoom(child.node, panelId, zoom); + return node === child.node ? child : { ...child, node }; + }); + return newChildren.some((c, i) => c !== root.children[i]) ? { ...root, children: newChildren } : root; +} + export function updateSizes(root: LayoutNode, groupId: string, sizes: number[]): LayoutNode { if (root.type === 'panel') return root; if (root.id === groupId) { @@ -154,13 +180,25 @@ export function movePanel(root: LayoutNode, sourceId: string, targetId: string, return insertPanel(result, targetId, direction, before, sourceApp); } -function insertPanel(node: LayoutNode, targetId: string, direction: 'horizontal' | 'vertical', before: boolean, appType: string | null): LayoutNode { +function insertPanel( + node: LayoutNode, + targetId: string, + direction: 'horizontal' | 'vertical', + before: boolean, + appType: string | null, +): LayoutNode { if (node.type === 'panel') { if (node.id !== targetId) return node; const newPanel: LayoutPanel = { type: 'panel', id: uid(), appType }; const children = before - ? [{ node: newPanel, size: 50 }, { node, size: 50 }] - : [{ node, size: 50 }, { node: newPanel, size: 50 }]; + ? [ + { node: newPanel, size: 50 }, + { node, size: 50 }, + ] + : [ + { node, size: 50 }, + { node: newPanel, size: 50 }, + ]; return { type: 'group', id: uid(), direction, children }; } diff --git a/src/workspaces/officerdev/src/components/Workspace/types.ts b/src/workspaces/officerdev/src/components/Workspace/types.ts index 9eaf7a7e..27fbc16b 100644 --- a/src/workspaces/officerdev/src/components/Workspace/types.ts +++ b/src/workspaces/officerdev/src/components/Workspace/types.ts @@ -13,6 +13,10 @@ export type LayoutPanel = { id: string; appType: string | null; fitContent?: boolean; + // Per-panel content scale (1 = 100%). Lives on the node rather than in its own useDashboardState key + // because the layout is already persisted per panel — a separate key would seed a row per panel on + // mount (useDashboardState seeds defaults to the server), and would need its own dispatcher branch. + zoom?: number; }; export type LayoutNode = LayoutGroup | LayoutPanel; @@ -44,6 +48,10 @@ export type AppRegistryEntry = { transparent?: boolean; fixedHeight?: number; availableOnPanel?: boolean; + // Opt out of the panel zoom control. For apps that scale badly under a CSS transform: a terminal + // (its own font sizing, and scale blurs the glyph grid) or a remote-desktop canvas (noVNC does its + // own autoscale, and a wrapping transform desyncs its pointer mapping). + zoomable?: boolean; }; export type AppRegistryMap = Record;