Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions electron/main/ipc/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ import {
LensScreenshotArgsSchema,
LensSessionTargetArgsSchema,
} from "./schemas";
import { scaleLensBoundsWithinContainer } from "../../../src/lib/lens/lens-bounds";
import type {
LensBounds,
LensCdpApprovalResponse,
Expand Down Expand Up @@ -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 };
Expand Down
34 changes: 34 additions & 0 deletions src/components/panes/WorkspacePaneHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -744,6 +762,7 @@ export function WorkspacePaneHost() {
}
} finally {
reconcilingRef.current = false;
syncLensSplitBoundaryMarkers(api);
}
}, []);

Expand Down Expand Up @@ -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;
Expand Down
11 changes: 7 additions & 4 deletions src/components/panes/surfaces/LensSurfacePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions src/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions src/lib/lens/lens-bounds.ts
Original file line number Diff line number Diff line change
@@ -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),
};
}
27 changes: 27 additions & 0 deletions tests/e2e/lens-workspace-switch.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>(
".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(),
Expand All @@ -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 {
Expand Down
47 changes: 47 additions & 0 deletions tests/lens-bounds.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
Loading