From 10ad8aaeb88179b5383766e8f11a7768a2a649e5 Mon Sep 17 00:00:00 2001 From: Jacob Kim Date: Thu, 30 Jul 2026 14:19:37 +0900 Subject: [PATCH] fix(lens): prevent resize sash border overlap --- electron/main/ipc/browser.ts | 11 ++--- src/components/panes/WorkspacePaneHost.tsx | 34 ++++++++++++++ .../panes/surfaces/LensSurfacePanel.tsx | 11 +++-- src/globals.css | 12 +++++ src/lib/lens/lens-bounds.ts | 25 ++++++++++ tests/e2e/lens-workspace-switch.e2e.ts | 27 +++++++++++ tests/lens-bounds.test.ts | 47 +++++++++++++++++++ 7 files changed, 157 insertions(+), 10 deletions(-) create mode 100644 src/lib/lens/lens-bounds.ts create mode 100644 tests/lens-bounds.test.ts diff --git a/electron/main/ipc/browser.ts b/electron/main/ipc/browser.ts index 9e60fef..ff98bfb 100644 --- a/electron/main/ipc/browser.ts +++ b/electron/main/ipc/browser.ts @@ -85,6 +85,7 @@ import { LensScreenshotArgsSchema, LensSessionTargetArgsSchema, } from "./schemas"; +import { scaleLensBoundsWithinContainer } from "../../../src/lib/lens/lens-bounds"; import type { LensBounds, LensCdpApprovalResponse, @@ -373,12 +374,10 @@ export function registerBrowserHandlers() { ); } const zoomFactor = win?.webContents.getZoomFactor() ?? 1; - const scaled: LensBounds = { - x: Math.round(args.bounds.x * zoomFactor), - y: Math.round(args.bounds.y * zoomFactor), - width: Math.round(args.bounds.width * zoomFactor), - height: Math.round(args.bounds.height * zoomFactor), - }; + const scaled = scaleLensBoundsWithinContainer({ + bounds: args.bounds, + zoomFactor, + }); setViewBounds(args.workspaceId, scaled, args.lensSessionId); return { ok: true }; diff --git a/src/components/panes/WorkspacePaneHost.tsx b/src/components/panes/WorkspacePaneHost.tsx index 5ec16e5..0fd1433 100644 --- a/src/components/panes/WorkspacePaneHost.tsx +++ b/src/components/panes/WorkspacePaneHost.tsx @@ -76,6 +76,24 @@ const STAVE_DOCKVIEW_THEME: DockviewTheme = { tabGroupIndicator: "none", }; +function syncLensSplitBoundaryMarkers(api: DockviewApi) { + for (const group of api.groups) { + const view = group.element.parentElement; + if ( + !(view instanceof HTMLElement) || + !view.classList.contains("dv-view") + ) { + continue; + } + view.toggleAttribute( + "data-lens-split-boundary", + group.panels.some( + (panel) => parsePanePanelId(panel.id)?.kind === "lens", + ), + ); + } +} + function PaneIconPicker(props: IContextMenuItemComponentProps) { const options = props.componentProps as { panelId?: string; selectedIcon?: string | null } | undefined; @@ -744,6 +762,7 @@ export function WorkspacePaneHost() { } } finally { reconcilingRef.current = false; + syncLensSplitBoundaryMarkers(api); } }, []); @@ -827,6 +846,21 @@ export function WorkspacePaneHost() { } }); + let lensBoundarySyncRaf = 0; + const scheduleLensBoundarySync = () => { + cancelAnimationFrame(lensBoundarySyncRaf); + lensBoundarySyncRaf = requestAnimationFrame(() => { + lensBoundarySyncRaf = 0; + if (apiRef.current === api) { + syncLensSplitBoundaryMarkers(api); + } + }); + }; + api.onDidAddPanel(scheduleLensBoundarySync); + api.onDidRemovePanel(scheduleLensBoundarySync); + api.onDidMovePanel(scheduleLensBoundarySync); + api.onDidLayoutFromJSON(scheduleLensBoundarySync); + api.onDidActivePanelChange(({ panel }) => { if (!panel) { return; diff --git a/src/components/panes/surfaces/LensSurfacePanel.tsx b/src/components/panes/surfaces/LensSurfacePanel.tsx index 43eef6b..e41f9ea 100644 --- a/src/components/panes/surfaces/LensSurfacePanel.tsx +++ b/src/components/panes/surfaces/LensSurfacePanel.tsx @@ -1424,11 +1424,14 @@ function LensSessionSurface(args: { return; } + // Keep the measured CSS-pixel rectangle intact. The main process + // converts its scaled edges inward so the native view cannot overlap + // Dockview's renderer-owned resize sash by a rounding pixel. pendingBoundsRef.current = { - x: Math.round(rect.left), - y: Math.round(rect.top), - width: Math.round(rect.width), - height: Math.round(rect.height), + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, }; cancelAnimationFrame(flushRafRef.current); diff --git a/src/globals.css b/src/globals.css index 83245c4..8104438 100644 --- a/src/globals.css +++ b/src/globals.css @@ -434,6 +434,18 @@ --dv-tab-group-line-opacity: 0.6; } +/* + * A Lens group uses the adjacent Dockview sash as its resize affordance. Its + * one-pixel split separator otherwise continues through the address bar and + * competes with the wider sash hover treatment. + */ +.dockview-theme-stave + .dv-split-view-container.dv-separator-border + > .dv-view-container + > .dv-view[data-lens-split-boundary]::before { + background-color: transparent; +} + /* * Keep the task rail itself borderless and attach the primary indicator to the * selected tab. The inset line follows the tab bounds without drawing a diff --git a/src/lib/lens/lens-bounds.ts b/src/lib/lens/lens-bounds.ts new file mode 100644 index 0000000..b2a7e2d --- /dev/null +++ b/src/lib/lens/lens-bounds.ts @@ -0,0 +1,25 @@ +import type { LensBounds } from "./lens.types"; + +/** + * Convert renderer CSS-pixel bounds into native-view bounds without allowing + * the native view to spill into a neighbouring Dockview sash. Rounding the + * origin and size independently can expand a fractional rectangle by a pixel; + * rounding its edges inward preserves the renderer-owned resize hit target. + */ +export function scaleLensBoundsWithinContainer(args: { + bounds: LensBounds; + zoomFactor: number; +}): LensBounds { + const { bounds, zoomFactor } = args; + const left = Math.ceil(bounds.x * zoomFactor); + const top = Math.ceil(bounds.y * zoomFactor); + const right = Math.floor((bounds.x + bounds.width) * zoomFactor); + const bottom = Math.floor((bounds.y + bounds.height) * zoomFactor); + + return { + x: left, + y: top, + width: Math.max(0, right - left), + height: Math.max(0, bottom - top), + }; +} diff --git a/tests/e2e/lens-workspace-switch.e2e.ts b/tests/e2e/lens-workspace-switch.e2e.ts index 549e438..2ac9ceb 100644 --- a/tests/e2e/lens-workspace-switch.e2e.ts +++ b/tests/e2e/lens-workspace-switch.e2e.ts @@ -256,6 +256,30 @@ test("workspace switch with an open Lens keeps the active task surface visible", const automaticLensSurface = page.getByTestId("lens-surface-panel"); await expect(automaticLensSurface).toBeVisible(); await expect(sessionArea).toBeVisible(); + const lensSplitBorderColor = await automaticLensSurface.evaluate( + (element) => { + const lensRect = element.getBoundingClientRect(); + const view = Array.from(document.querySelectorAll( + ".dv-view", + )).find((candidate) => { + const candidateRect = candidate.getBoundingClientRect(); + return ( + Math.abs(candidateRect.left - lensRect.left) < 1 && + Math.abs(candidateRect.width - lensRect.width) < 1 + ); + }); + return view + ? { + isLensBoundary: view.hasAttribute("data-lens-split-boundary"), + separatorColor: getComputedStyle(view, "::before").backgroundColor, + } + : null; + }, + ); + expect(lensSplitBorderColor).toEqual({ + isLensBoundary: true, + separatorColor: "rgba(0, 0, 0, 0)", + }); const [taskBounds, lensBounds, taskGroupIsActive] = await Promise.all([ sessionArea.boundingBox(), automaticLensSurface.boundingBox(), @@ -273,6 +297,9 @@ test("workspace switch with an open Lens keeps the active task surface visible", .getByRole("button", { name: "close-pane-lens:automatic-lens" }) .click(); await expect(automaticLensSurface).toHaveCount(0); + await expect( + page.locator(".dv-view[data-lens-split-boundary]"), + ).toHaveCount(0); await page.evaluate(() => { const target = window as unknown as { diff --git a/tests/lens-bounds.test.ts b/tests/lens-bounds.test.ts new file mode 100644 index 0000000..490e4b8 --- /dev/null +++ b/tests/lens-bounds.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "bun:test"; +import { scaleLensBoundsWithinContainer } from "@/lib/lens/lens-bounds"; + +describe("scaleLensBoundsWithinContainer", () => { + it("preserves an integer-aligned bounds rectangle", () => { + expect( + scaleLensBoundsWithinContainer({ + bounds: { x: 320, y: 144, width: 640, height: 480 }, + zoomFactor: 1, + }), + ).toEqual({ x: 320, y: 144, width: 640, height: 480 }); + }); + + it("rounds fractional edges inward instead of expanding into a sash", () => { + const bounds = { x: 320.6, y: 144.2, width: 640.6, height: 480.4 }; + const result = scaleLensBoundsWithinContainer({ bounds, zoomFactor: 1 }); + + expect(result).toEqual({ x: 321, y: 145, width: 640, height: 479 }); + expect(Math.round(bounds.x) + Math.round(bounds.width)).toBeGreaterThan( + bounds.x + bounds.width, + ); + expect(result.x).toBeGreaterThanOrEqual(bounds.x); + expect(result.y).toBeGreaterThanOrEqual(bounds.y); + expect(result.x + result.width).toBeLessThanOrEqual( + bounds.x + bounds.width, + ); + expect(result.y + result.height).toBeLessThanOrEqual( + bounds.y + bounds.height, + ); + }); + + it("contains the scaled rectangle at a non-default zoom factor", () => { + const bounds = { x: 320.6, y: 144.2, width: 640.6, height: 480.4 }; + const zoomFactor = 1.1; + const result = scaleLensBoundsWithinContainer({ bounds, zoomFactor }); + + expect(result).toEqual({ x: 353, y: 159, width: 704, height: 528 }); + expect(result.x).toBeGreaterThanOrEqual(bounds.x * zoomFactor); + expect(result.y).toBeGreaterThanOrEqual(bounds.y * zoomFactor); + expect(result.x + result.width).toBeLessThanOrEqual( + (bounds.x + bounds.width) * zoomFactor, + ); + expect(result.y + result.height).toBeLessThanOrEqual( + (bounds.y + bounds.height) * zoomFactor, + ); + }); +});