mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-08 00:50:06 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
124cc3ad9c | ||
|
|
306bdfc151 |
No files matched your search
@@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { AsidePanel } from './ui/aside-panel';
|
||||
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from './test-support/renderReactDom';
|
||||
|
||||
async function fixture(t: test.TestContext, layout: 'inline' | 'overlay' = 'inline') {
|
||||
const env = installDomEnvironment();
|
||||
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
|
||||
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: env.window.localStorage });
|
||||
env.window.localStorage.setItem('desired-width', '720');
|
||||
const renderer = await createDomRenderer(env.document);
|
||||
const writes: number[] = [];
|
||||
const setItem = env.window.Storage.prototype.setItem;
|
||||
t.mock.method(env.window.Storage.prototype, 'setItem', function (this: Storage, key: string, value: string) {
|
||||
if (key === 'desired-width') writes.push(Number(value));
|
||||
setItem.call(this, key, value);
|
||||
});
|
||||
const render = (open = true) => renderer.render(<AsidePanel open={open} onClose={() => {}} title="Editor"
|
||||
layout={layout} resizable persistWidthStorageKey="desired-width" resizeAriaLabel="Resize details"
|
||||
dataSection="test-panel">Content</AsidePanel>);
|
||||
await render();
|
||||
const panel = env.document.querySelector<HTMLElement>('[data-section="test-panel"]')!;
|
||||
// Browser layout is covered by native-matrix.mjs. Here the actual rendered
|
||||
// border box is narrower than the remembered 720px preference.
|
||||
panel.getBoundingClientRect = () => new env.window.DOMRect(320, 36, 378, 494);
|
||||
const separator = panel.querySelector('[role="separator"]')!;
|
||||
const pointer = (target: EventTarget, type: string, x: number) => dispatchDomEvent(target,
|
||||
new env.window.MouseEvent(type, { bubbles: true, clientX: x }));
|
||||
t.after(async () => {
|
||||
await pointer(env.window, 'pointerup', 100);
|
||||
await renderer.unmount();
|
||||
if (previousStorage) Object.defineProperty(globalThis, 'localStorage', previousStorage);
|
||||
else Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
env.cleanup();
|
||||
});
|
||||
return { writes, render, unmount: () => renderer.unmount(),
|
||||
start: () => pointer(separator, 'pointerdown', 100),
|
||||
move: (x: number) => pointer(env.window, 'pointermove', x),
|
||||
up: (x: number) => pointer(env.window, 'pointerup', x),
|
||||
cancel: (x: number) => pointer(env.window, 'pointercancel', x), env };
|
||||
}
|
||||
|
||||
test('dragging a fitted inline panel starts at its visible edge, then remembers the explicit size', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.start(); await f.move(120); await f.up(120);
|
||||
assert.deepEqual(f.writes, [358], '20px inward from visible 378px must remember 358px, without a jump from 720px');
|
||||
});
|
||||
|
||||
test('pressing a fitted resize handle without dragging preserves the larger desired width', async (t) => {
|
||||
const f = await fixture(t);
|
||||
assert.deepEqual(f.writes, [], 'mount must not persist');
|
||||
await f.start(); await f.up(100);
|
||||
assert.deepEqual(f.writes, [], 'a click must not replace the remembered desired width');
|
||||
});
|
||||
|
||||
test('canceling a resize leaves the remembered width unchanged and restores pointer styles', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.start(); await f.move(120); await f.cancel(120); await f.up(120);
|
||||
assert.deepEqual(f.writes, []);
|
||||
assert.equal(f.env.document.body.style.cursor, '');
|
||||
assert.equal(f.env.document.body.style.userSelect, '');
|
||||
});
|
||||
|
||||
for (const mode of ['hidden', 'unmounted'] as const) {
|
||||
test(`${mode} panels do not persist a viewport clamp or a stale drag`, async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.start(); await f.move(120);
|
||||
if (mode === 'hidden') await f.render(false); else await f.unmount();
|
||||
await f.up(120);
|
||||
assert.deepEqual(f.writes, []);
|
||||
assert.equal(f.env.document.body.style.cursor, '');
|
||||
assert.equal(f.env.document.body.style.userSelect, '');
|
||||
});
|
||||
}
|
||||
|
||||
test('overlay resizing retains its existing desired-width behavior', async (t) => {
|
||||
const f = await fixture(t, 'overlay');
|
||||
await f.start(); await f.move(120); await f.up(120);
|
||||
assert.deepEqual(f.writes, [700]);
|
||||
});
|
||||
@@ -370,14 +370,21 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
|
||||
resizable ? readPersistedAsideWidth(persistWidthStorageKey, fallbackWidthPx) : fallbackWidthPx,
|
||||
);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const resizeCleanupRef = React.useRef<(() => void) | null>(null);
|
||||
React.useEffect(() => () => resizeCleanupRef.current?.(), [open]);
|
||||
const effectivePanelWidthPx = resizable ? panelWidthPx : fallbackWidthPx;
|
||||
// Only containers opting into this constraint limit the rendered width.
|
||||
// Keep the animation's final minimum on the same cap as its width.
|
||||
const renderedWidth = resizable && layout === 'inline'
|
||||
? `min(${effectivePanelWidthPx}px, var(--aside-inline-max-width, ${effectivePanelWidthPx}px))`
|
||||
: `${effectivePanelWidthPx}px`;
|
||||
// Resizable panels always use pixel width so overlay and inline share the same drag path.
|
||||
const usesPixelWidth = resizable || layout === 'inline';
|
||||
|
||||
const panelStyle = usesPixelWidth
|
||||
? ({
|
||||
width: `${effectivePanelWidthPx}px`,
|
||||
['--aside-inline-width' as string]: `${effectivePanelWidthPx}px`,
|
||||
width: renderedWidth,
|
||||
['--aside-inline-width' as string]: renderedWidth,
|
||||
} as React.CSSProperties)
|
||||
: undefined;
|
||||
|
||||
@@ -386,9 +393,12 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
resizeCleanupRef.current?.();
|
||||
|
||||
const startX = event.clientX;
|
||||
const startWidth = panelWidthPx;
|
||||
const startWidth = layout === 'inline'
|
||||
? event.currentTarget.parentElement?.getBoundingClientRect().width || panelWidthPx
|
||||
: panelWidthPx;
|
||||
const previousCursor = document.body.style.cursor;
|
||||
const previousUserSelect = document.body.style.userSelect;
|
||||
|
||||
@@ -397,26 +407,35 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
setPanelWidthPx(clampAsideInlineWidth(startWidth + startX - moveEvent.clientX));
|
||||
setPanelWidthPx(moveEvent.clientX === startX ? panelWidthPx
|
||||
: clampAsideInlineWidth(startWidth + startX - moveEvent.clientX));
|
||||
};
|
||||
const handlePointerUp = (upEvent: PointerEvent) => {
|
||||
cleanup();
|
||||
// A handle click is not a request to forget a larger preference.
|
||||
if (upEvent.clientX === startX) return;
|
||||
const nextWidth = clampAsideInlineWidth(startWidth + startX - upEvent.clientX);
|
||||
setPanelWidthPx(nextWidth);
|
||||
if (persistWidthStorageKey) {
|
||||
localStorageAdapter.writeNumber(persistWidthStorageKey, nextWidth);
|
||||
}
|
||||
};
|
||||
const cleanup = () => {
|
||||
resizeCleanupRef.current = null;
|
||||
setPanelWidthPx(panelWidthPx);
|
||||
setIsResizing(false);
|
||||
document.body.style.cursor = previousCursor;
|
||||
document.body.style.userSelect = previousUserSelect;
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
window.removeEventListener('pointercancel', handlePointerUp);
|
||||
window.removeEventListener('pointercancel', cleanup);
|
||||
};
|
||||
|
||||
resizeCleanupRef.current = cleanup;
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
window.addEventListener('pointercancel', handlePointerUp);
|
||||
}, [panelWidthPx, persistWidthStorageKey, resizable]);
|
||||
window.addEventListener('pointercancel', cleanup);
|
||||
}, [layout, panelWidthPx, persistWidthStorageKey, resizable]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "../../domain/vaultGroupSelection";
|
||||
import { STORAGE_KEY_VAULT_HOST_PANEL_WIDTH } from "@/infrastructure/config/storageKeys.ts";
|
||||
import { VaultHostListSection } from "./VaultHostListSection";
|
||||
import { VAULT_HOST_GRID_MIN_CARD_WIDTH } from "./vaultHostGridLayout";
|
||||
import { LocalShellHostDetailsPanel } from "../LocalShellHostDetailsPanel";
|
||||
import { VaultImportProgressPanel } from "./ImportVaultDialog";
|
||||
import {
|
||||
@@ -860,7 +861,13 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
data-section="vault-stage"
|
||||
>
|
||||
<div
|
||||
className="relative flex min-h-0 flex-1 overflow-hidden rounded-xl border border-border/60 bg-background shadow-sm"
|
||||
className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden rounded-xl border border-border/60 bg-background shadow-sm"
|
||||
// Keep one host card and its existing px-4 padding beside an editor.
|
||||
// CSS resolves this against the current surface, including when the
|
||||
// retained vault is shown again; remembered widths remain untouched.
|
||||
style={{
|
||||
["--aside-inline-max-width" as string]: `max(0px, calc(100% - ${VAULT_HOST_GRID_MIN_CARD_WIDTH + 32}px))`,
|
||||
}}
|
||||
data-section="vault-surface"
|
||||
>
|
||||
{/* Main Area */}
|
||||
|
||||
@@ -84,8 +84,8 @@ const DEFAULT_WINDOW_HEIGHT = 900;
|
||||
// Small displays shrink this fit to their work area at window-creation time
|
||||
// (see resolveWindowMinimumSize in windowManager/mainWindow.cjs, #3647);
|
||||
// the minimum tracks the display the window currently occupies.
|
||||
const MIN_WINDOW_WIDTH = 1100;
|
||||
const MIN_WINDOW_HEIGHT = 640;
|
||||
const MIN_WINDOW_WIDTH = 960;
|
||||
const MIN_WINDOW_HEIGHT = 540;
|
||||
|
||||
function debugLog(...args) {
|
||||
if (!DEBUG_WINDOWS) return;
|
||||
|
||||
@@ -7,7 +7,7 @@ const perfTrace = require("../../perfTrace.cjs");
|
||||
|
||||
const TERMINAL_KEYBOARD_FOCUS = Symbol("netcattyTerminalKeyboardFocus");
|
||||
|
||||
// #3647: the 1100×640 layout minimum is larger than the usable workspace on
|
||||
// #3647: the layout minimum can be larger than the usable workspace on
|
||||
// small laptops (e.g. 1366×768 at 125% Windows scaling leaves only ~1093×574
|
||||
// logical px), which made those windows impossible to fit — let alone shrink.
|
||||
// Keep the layout minimum on capable screens, but shrink it to the work area
|
||||
@@ -33,14 +33,19 @@ function pickTargetDisplay(screenModule, bounds) {
|
||||
}
|
||||
|
||||
function resolveWindowMinimumSize(screenModule, minWidth, minHeight, bounds) {
|
||||
let display;
|
||||
try {
|
||||
display = bounds ? pickTargetDisplay(screenModule, bounds) : screenModule?.getPrimaryDisplay?.();
|
||||
} catch {
|
||||
// screen unavailable (e.g. before app ready): keep the static minimums
|
||||
}
|
||||
return minimumSizeForDisplay(display, minWidth, minHeight);
|
||||
}
|
||||
|
||||
function minimumSizeForDisplay(display, minWidth, minHeight) {
|
||||
let effectiveWidth = minWidth;
|
||||
let effectiveHeight = minHeight;
|
||||
try {
|
||||
// Size for the display the window actually lives on (saved position or
|
||||
// current placement), falling back to the primary display.
|
||||
const display = bounds
|
||||
? pickTargetDisplay(screenModule, bounds)
|
||||
: screenModule?.getPrimaryDisplay?.();
|
||||
const workArea = display?.workArea;
|
||||
if (Number.isFinite(workArea?.width) && workArea.width > 0) {
|
||||
effectiveWidth = Math.min(effectiveWidth, Math.floor(workArea.width * MIN_SIZE_FIT_FACTOR));
|
||||
@@ -57,6 +62,52 @@ function resolveWindowMinimumSize(screenModule, minWidth, minHeight, bounds) {
|
||||
};
|
||||
}
|
||||
|
||||
function intersectsBounds(a, b) {
|
||||
return b && a.x < b.x + b.width && a.x + a.width > b.x
|
||||
&& a.y < b.y + b.height && a.y + a.height > b.y;
|
||||
}
|
||||
|
||||
// Initial normal bounds only: live display recovery and manual placement keep
|
||||
// their existing lifecycle. Electron screen coordinates are already DIPs.
|
||||
function resolveStartupBounds(screenModule, savedState, defaults, minWidth, minHeight) {
|
||||
const savedPosition = savedState && Number.isFinite(savedState.x) && Number.isFinite(savedState.y)
|
||||
? savedState : null;
|
||||
let display;
|
||||
try {
|
||||
display = screenModule?.getPrimaryDisplay?.();
|
||||
if (savedPosition) {
|
||||
const matched = pickTargetDisplay(screenModule, savedPosition);
|
||||
// getDisplayMatching returns the nearest screen even for removed displays.
|
||||
// Only select it when the saved rectangle actually intersects that screen.
|
||||
if (intersectsBounds(savedPosition, matched?.bounds)) display = matched;
|
||||
}
|
||||
} catch {
|
||||
// Keep static sizes when Electron cannot supply a display.
|
||||
}
|
||||
const minimum = minimumSizeForDisplay(display, minWidth, minHeight);
|
||||
const workArea = display?.workArea;
|
||||
const bounds = {};
|
||||
for (const axis of ["width", "height"]) {
|
||||
const intended = savedState?.[axis] ?? defaults[axis];
|
||||
const available = workArea?.[axis];
|
||||
bounds[axis] = Math.max(minimum[axis], Number.isFinite(available) && available > 0 && intended > available
|
||||
? Math.floor(available * MIN_SIZE_FIT_FACTOR)
|
||||
: intended);
|
||||
}
|
||||
if (savedPosition && workArea) {
|
||||
bounds.x = savedPosition.x;
|
||||
bounds.y = savedPosition.y;
|
||||
// Preserve all usable placements, including negative monitor origins and
|
||||
// partial overlap. Resizing can make a previously visible position unusable.
|
||||
if (!intersectsBounds(bounds, workArea)) {
|
||||
for (const [axis, size] of [["x", "width"], ["y", "height"]]) {
|
||||
bounds[axis] = Math.max(workArea[axis], Math.min(bounds[axis], workArea[axis] + workArea[size] - bounds[size]));
|
||||
}
|
||||
}
|
||||
}
|
||||
return { bounds, minimum };
|
||||
}
|
||||
|
||||
// Keep the BrowserWindow minimum in sync with the display it currently lives
|
||||
// on: docking, unplugging a screen, or display-scaling changes can move the
|
||||
// window onto a display with a different work area.
|
||||
@@ -207,56 +258,14 @@ function createMainWindowApi(ctx) {
|
||||
|
||||
// Load saved window state
|
||||
const savedState = persistWindowState ? loadWindowState() : null;
|
||||
let windowBounds = {
|
||||
width: DEFAULT_WINDOW_WIDTH,
|
||||
height: DEFAULT_WINDOW_HEIGHT,
|
||||
};
|
||||
|
||||
// Size the minimum to the display the restored window will live on
|
||||
// (a saved position may target a smaller secondary display), falling
|
||||
// back to the primary display when there is no usable saved position.
|
||||
const savedTargetBounds = savedState && Number.isFinite(savedState.x) && Number.isFinite(savedState.y)
|
||||
? { x: savedState.x, y: savedState.y, width: savedState.width, height: savedState.height }
|
||||
: null;
|
||||
const windowMinimumSize = resolveWindowMinimumSize(
|
||||
const { bounds: windowBounds, minimum: windowMinimumSize } = resolveStartupBounds(
|
||||
screen,
|
||||
savedState,
|
||||
{ width: DEFAULT_WINDOW_WIDTH, height: DEFAULT_WINDOW_HEIGHT },
|
||||
MIN_WINDOW_WIDTH,
|
||||
MIN_WINDOW_HEIGHT,
|
||||
savedTargetBounds,
|
||||
);
|
||||
|
||||
if (savedState) {
|
||||
// Use saved dimensions, but clamp to the minimum so a previously
|
||||
// shrunk window from an older build cannot start below the minimum.
|
||||
windowBounds.width = Math.max(savedState.width, windowMinimumSize.width);
|
||||
windowBounds.height = Math.max(savedState.height, windowMinimumSize.height);
|
||||
|
||||
// Only use saved position if the screen is available at that location
|
||||
if (typeof savedState.x === "number" && typeof savedState.y === "number") {
|
||||
try {
|
||||
// Check if the saved position is within any available display
|
||||
const displays = screen?.getAllDisplays?.() || [];
|
||||
const isPositionVisible = displays.some((display) => {
|
||||
const { x, y, width, height } = display.bounds;
|
||||
// Check if at least part of the window would be visible on this display
|
||||
return (
|
||||
savedState.x < x + width &&
|
||||
savedState.x + savedState.width > x &&
|
||||
savedState.y < y + height &&
|
||||
savedState.y + savedState.height > y
|
||||
);
|
||||
});
|
||||
|
||||
if (isPositionVisible) {
|
||||
windowBounds.x = savedState.x;
|
||||
windowBounds.y = savedState.y;
|
||||
}
|
||||
} catch {
|
||||
// Ignore screen check errors, just don't set position
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const windowsChrome = !isMac ? windowsFramelessContentChromeOptions() : {};
|
||||
|
||||
perfTrace.mark("main:create-window");
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { createMainWindowApi } = require("./mainWindow.cjs");
|
||||
|
||||
const large = {
|
||||
id: 1, bounds: { x: 0, y: 0, width: 1920, height: 1080 },
|
||||
workArea: { x: 0, y: 0, width: 1920, height: 1040 },
|
||||
};
|
||||
const small = {
|
||||
id: 2, bounds: { x: 0, y: 0, width: 1093, height: 614 },
|
||||
workArea: { x: 0, y: 0, width: 1093, height: 585 },
|
||||
};
|
||||
const intersects = (a, b) => a.x < b.x + b.width && a.x + a.width > b.x
|
||||
&& a.y < b.y + b.height && a.y + a.height > b.y;
|
||||
|
||||
// Exercise the real constructor and lifecycle with recorded native calls.
|
||||
async function create(saved, primary = large, displays = [primary], matching) {
|
||||
const writes = [];
|
||||
const screen = new EventEmitter();
|
||||
screen.getPrimaryDisplay = () => primary;
|
||||
screen.getAllDisplays = () => displays;
|
||||
screen.getDisplayMatching = matching || ((bounds) => displays.find((d) => intersects(bounds, d.bounds)) || displays.at(-1));
|
||||
let options;
|
||||
class Window extends EventEmitter {
|
||||
constructor(opts) {
|
||||
super();
|
||||
options = opts;
|
||||
this.bounds = { x: opts.x ?? primary?.workArea.x ?? 0, y: opts.y ?? primary?.workArea.y ?? 0, width: opts.width, height: opts.height };
|
||||
this.minimum = [opts.minWidth, opts.minHeight];
|
||||
this.maximized = false;
|
||||
this.fullscreen = false;
|
||||
this.webContents = Object.assign(new EventEmitter(), {
|
||||
id: 71, isDestroyed: () => false, setWindowOpenHandler() {}, send() {},
|
||||
});
|
||||
}
|
||||
isDestroyed() { return false; }
|
||||
isMaximized() { return this.maximized; }
|
||||
isFullScreen() { return this.fullscreen; }
|
||||
isMinimized() { return this.minimized === true; }
|
||||
getBounds() { return { ...this.bounds }; }
|
||||
getMinimumSize() { return this.minimum; }
|
||||
setMinimumSize(w, h) { this.minimum = [w, h]; }
|
||||
maximize() { this.maximized = true; }
|
||||
setFullScreen(value) { this.fullscreen = value; }
|
||||
setBackgroundColor() {}
|
||||
loadURL() { return Promise.resolve(); }
|
||||
close() {}
|
||||
}
|
||||
const api = createMainWindowApi({
|
||||
mainWindow: null, electronApp: null, currentTheme: "light", isQuitting: true,
|
||||
pendingWindowStateWrite: null, queuedWindowState: null,
|
||||
DEFAULT_WINDOW_WIDTH: 1400, DEFAULT_WINDOW_HEIGHT: 900, MIN_WINDOW_WIDTH: 960, MIN_WINDOW_HEIGHT: 540,
|
||||
V8_CACHE_OPTIONS: "code", THEME_COLORS: { light: {} },
|
||||
unhealthyWebContentsIds: new Set(), rendererReadySeenByWebContentsId: new Set(),
|
||||
__dirname, URL, require, console, setTimeout, clearTimeout,
|
||||
debugLog() {}, resolveFrontendBackgroundColor() { return null; }, loadWindowState() { return saved; },
|
||||
getWindowBoundsState(win, normal) {
|
||||
return { ...(normal || win.getBounds()), isMaximized: win.isMaximized(), isFullScreen: win.isFullScreen() };
|
||||
},
|
||||
queueWindowStateSave: (state) => writes.push(state), saveWindowStateSync: (state) => writes.push(state),
|
||||
setupDeferredShow() {}, createExternalOnlyWindowOpenHandler() { return {}; }, createAppWindowOpenHandler() { return {}; },
|
||||
attachOAuthLoadingOverlay() {}, registerWindowHandlers() {}, registerMainWindow() {}, unregisterMainWindow() {},
|
||||
closeSettingsWindow() {}, applyWindowOpacityToWindow() {},
|
||||
});
|
||||
const win = await api.createWindow(
|
||||
{ BrowserWindow: Window, nativeTheme: {}, app: {}, screen, shell: {}, ipcMain: {} },
|
||||
{ preload: "/unused/preload.cjs", isDev: false, isMac: true, electronDir: __dirname },
|
||||
);
|
||||
assert.deepEqual(writes, [], "calculating startup bounds must not persist settings");
|
||||
return { win, options, screen, writes };
|
||||
}
|
||||
const geometry = (o) => [o.width, o.height, o.minWidth, o.minHeight];
|
||||
|
||||
test("ordinary large-display default stays 1400x900 with 960x540 minimum", async () => {
|
||||
assert.deepEqual(geometry((await create(null)).options), [1400, 900, 960, 540]);
|
||||
});
|
||||
|
||||
test("fresh small-display default fits each overflowing axis", async () => {
|
||||
assert.deepEqual(geometry((await create(null, small)).options), [983, 526, 960, 526]);
|
||||
});
|
||||
|
||||
test("huge saved bounds fit the target work area without mutating saved settings", async () => {
|
||||
const saved = Object.freeze({ x: 10, y: 20, width: 4000, height: 3000 });
|
||||
const { options } = await create(saved, small);
|
||||
assert.deepEqual(geometry(options), [983, 526, 960, 526]);
|
||||
assert.deepEqual([options.x, options.y], [10, 20]);
|
||||
});
|
||||
|
||||
test("fitting dimensions and usable partial placements remain exact", async () => {
|
||||
for (const [x, y] of [[30, 15], [-20, -10], [1000, 500]]) {
|
||||
const { options } = await create({ x, y, width: 1000, height: 560 }, small);
|
||||
assert.deepEqual([options.x, options.y, options.width, options.height], [x, y, 1000, 560]);
|
||||
}
|
||||
});
|
||||
|
||||
test("only overflowing axes shrink; undersized axes rise to the effective minimum", async () => {
|
||||
for (const [width, height, expected] of [
|
||||
[1600, 560, [983, 560]], [1000, 1000, [1000, 526]],
|
||||
[800, 400, [960, 526]], [1093, 585, [1093, 585]],
|
||||
]) {
|
||||
const { options } = await create({ x: 0, y: 0, width, height }, small);
|
||||
assert.deepEqual([options.width, options.height], expected);
|
||||
}
|
||||
});
|
||||
|
||||
test("workArea offsets/taskbars recover inaccessible positions only", async () => {
|
||||
const display = { ...small, workArea: { x: 80, y: 40, width: 1013, height: 545 } };
|
||||
const { options } = await create({ x: -950, y: 0, width: 1000, height: 540 }, display);
|
||||
assert.deepEqual([options.x, options.y, options.width, options.height], [80, 40, 1000, 540]);
|
||||
const usable = (await create({ x: 90, y: 42, width: 1000, height: 540 }, display)).options;
|
||||
assert.deepEqual([usable.x, usable.y, usable.width, usable.height], [90, 42, 1000, 540]);
|
||||
});
|
||||
|
||||
test("negative secondary origin selects that display and preserves valid position", async () => {
|
||||
const secondary = { ...small, bounds: { ...small.bounds, x: -1093 }, workArea: { ...small.workArea, x: -1093 } };
|
||||
const { options } = await create({ x: -1080, y: 15, width: 1000, height: 560 }, large, [large, secondary]);
|
||||
assert.deepEqual([options.x, options.y, ...geometry(options)], [-1080, 15, 1000, 560, 960, 526]);
|
||||
});
|
||||
|
||||
test("removed saved screen falls back to primary even when Electron matches the nearest secondary", async () => {
|
||||
const secondary = { ...large, id: 3, bounds: { ...large.bounds, x: 1920 }, workArea: { ...large.workArea, x: 1920 } };
|
||||
const { options } = await create({ x: 8000, y: 100, width: 4000, height: 3000 }, small, [small, secondary], () => secondary);
|
||||
assert.deepEqual(geometry(options), [983, 526, 960, 526]);
|
||||
assert.ok(intersects({ ...options }, small.workArea));
|
||||
});
|
||||
|
||||
test("DIP startup sizing does not rescale at 1, 1.25, 1.5 or 2", async () => {
|
||||
for (const scaleFactor of [1, 1.25, 1.5, 2]) {
|
||||
const { options } = await create(null, { ...small, scaleFactor });
|
||||
assert.deepEqual(geometry(options), [983, 526, 960, 526]);
|
||||
}
|
||||
});
|
||||
|
||||
test("representative work areas retain the adaptive 90 percent minimum and emergency floor", async () => {
|
||||
for (const [width, height, expected] of [
|
||||
[1366, 728, [1229, 655, 960, 540]], [1280, 680, [1152, 612, 960, 540]],
|
||||
[960, 540, [864, 486, 864, 486]], [300, 200, [320, 240, 320, 240]],
|
||||
]) {
|
||||
const display = { bounds: { x: 0, y: 0, width, height }, workArea: { x: 0, y: 0, width, height } };
|
||||
assert.deepEqual(geometry((await create(null, display)).options), expected);
|
||||
}
|
||||
});
|
||||
|
||||
test("legacy dimension-only state and unavailable screen keep usable dimensions", async () => {
|
||||
assert.deepEqual(geometry((await create({ width: 1000, height: 560 }, small)).options), [1000, 560, 960, 526]);
|
||||
const { options } = await create({ width: 800, height: 400 }, null, []);
|
||||
assert.deepEqual(geometry(options), [960, 540, 960, 540]);
|
||||
});
|
||||
|
||||
test("saved flags retain existing startup semantics with fitted normal bounds and no startup write", async () => {
|
||||
for (const [isMaximized, isFullScreen] of [[true, false], [false, true], [true, true]]) {
|
||||
const { win, options, writes } = await create({ x: 10, y: 20, width: 4000, height: 3000, isMaximized, isFullScreen }, small);
|
||||
assert.deepEqual(geometry(options), [983, 526, 960, 526]);
|
||||
win.emit("ready-to-show");
|
||||
assert.equal(win.isMaximized(), isMaximized && !isFullScreen);
|
||||
// Existing startup deliberately does not re-enter fullscreen.
|
||||
assert.equal(win.isFullScreen(), false);
|
||||
assert.deepEqual(writes, []);
|
||||
win.emit("close", { preventDefault() {} });
|
||||
win.emit("closed");
|
||||
}
|
||||
});
|
||||
|
||||
test("manual normal bounds persist across maximized/fullscreen/minimized close", async () => {
|
||||
for (const state of ["maximized", "fullscreen", "minimized"]) {
|
||||
const { win, writes } = await create(null);
|
||||
const normal = { x: 75, y: 80, width: 1040, height: 620 };
|
||||
win.bounds = normal;
|
||||
win.emit("move");
|
||||
win[state] = true;
|
||||
// A minimized window retains its normal native bounds; max/fullscreen do not.
|
||||
if (state !== "minimized") win.bounds = { x: 0, y: 0, width: 1920, height: 1080 };
|
||||
win.emit("resize");
|
||||
win.emit("close", { preventDefault() {} });
|
||||
assert.deepEqual(writes.at(-1), { ...normal, isMaximized: state === "maximized", isFullScreen: state === "fullscreen" });
|
||||
win.emit("closed");
|
||||
}
|
||||
});
|
||||
|
||||
test("live minimum sync moves between displays, recovers after removal, and detaches on close", async () => {
|
||||
const secondary = { ...small, bounds: { ...small.bounds, x: -1093 }, workArea: { ...small.workArea, x: -1093 } };
|
||||
const { win, screen } = await create(null, large, [large, secondary]);
|
||||
win.bounds = { x: -1080, y: 0, width: 1000, height: 560 };
|
||||
win.emit("move");
|
||||
assert.deepEqual(win.minimum, [960, 526]);
|
||||
screen.getDisplayMatching = () => large;
|
||||
screen.emit("display-removed");
|
||||
assert.deepEqual(win.minimum, [960, 540]);
|
||||
screen.getDisplayMatching = () => small;
|
||||
screen.emit("display-metrics-changed");
|
||||
assert.deepEqual(win.minimum, [960, 526]);
|
||||
win.emit("close", { preventDefault() {} });
|
||||
win.emit("closed");
|
||||
assert.equal(screen.listenerCount("display-removed"), 0);
|
||||
assert.equal(screen.listenerCount("display-metrics-changed"), 0);
|
||||
});
|
||||
Reference in new issue
Block a user