From 1065ed565b56c778750e56971547911a2ab82347 Mon Sep 17 00:00:00 2001 From: Neko Date: Sat, 27 Jun 2026 01:54:39 +0800 Subject: [PATCH] fix(stage-tamagotchi): anchor tray resize to dominant display (#2012) --- Co-authored-by-agent: Codex --- apps/stage-tamagotchi/src/main/tray/index.ts | 32 ++--- .../src/main/windows/shared/display.test.ts | 56 ++++++++- .../src/main/windows/shared/display.ts | 114 ++++++++++++++++++ 3 files changed, 186 insertions(+), 16 deletions(-) diff --git a/apps/stage-tamagotchi/src/main/tray/index.ts b/apps/stage-tamagotchi/src/main/tray/index.ts index 950ac29fa..66e12e005 100644 --- a/apps/stage-tamagotchi/src/main/tray/index.ts +++ b/apps/stage-tamagotchi/src/main/tray/index.ts @@ -22,6 +22,7 @@ import macOSTrayIcon from '../../../resources/tray-icon-macos.png?asset' import { onAppBeforeQuit } from '../libs/bootkit/lifecycle' import { setupInlayWindow } from '../windows/inlay' +import { computeResizedBoundsAnchoredToDominantDisplay, findDominantDisplayArea } from '../windows/shared/display' import { toggleWindowShow } from '../windows/shared/window' const RECOMMENDED_WIDTH = 450 @@ -35,21 +36,20 @@ function applyWindowSize(window: BrowserWindow, width: number, height: number, x window.setResizable(true) - const bounds = { - width: Math.round(width), - height: Math.round(height), - } as Electron.Rectangle - - if (x !== undefined && y !== undefined) { - bounds.x = Math.round(x) - bounds.y = Math.round(y) - } + const bounds = x !== undefined && y !== undefined + ? { + x: Math.round(x), + y: Math.round(y), + width: Math.round(width), + height: Math.round(height), + } + : computeResizedBoundsAnchoredToDominantDisplay({ + currentBounds: window.getBounds(), + targetSize: { width, height }, + displays: screen.getAllDisplays(), + }) window.setBounds(bounds) - if (x === undefined || y === undefined) { - window.center() - } - window.show() } @@ -109,8 +109,10 @@ export function setupTray(params: { return } - const { x: areaX, y: areaY, width: areaWidth, height: areaHeight } = screen.getPrimaryDisplay().workArea - const { width: windowWidth, height: windowHeight } = params.mainWindow.getBounds() + const mainWindowBounds = params.mainWindow.getBounds() + const currentDisplay = findDominantDisplayArea(mainWindowBounds, screen.getAllDisplays()) ?? screen.getDisplayMatching(mainWindowBounds) + const { x: areaX, y: areaY, width: areaWidth, height: areaHeight } = currentDisplay.workArea + const { width: windowWidth, height: windowHeight } = mainWindowBounds const fullHeightTarget = areaHeight const fullWidthTarget = Math.floor(areaHeight * ASPECT_RATIO) diff --git a/apps/stage-tamagotchi/src/main/windows/shared/display.test.ts b/apps/stage-tamagotchi/src/main/windows/shared/display.test.ts index 13a8d8077..6da6145d3 100644 --- a/apps/stage-tamagotchi/src/main/windows/shared/display.test.ts +++ b/apps/stage-tamagotchi/src/main/windows/shared/display.test.ts @@ -2,7 +2,7 @@ import type { Rectangle } from 'electron' import { describe, expect, it, vi } from 'vitest' -import { heightFrom, mapForBreakpoints, widthFrom } from './display' +import { computeResizedBoundsAnchoredToDominantDisplay, heightFrom, mapForBreakpoints, widthFrom } from './display' // NOTICE: // Mocking 'electron' is needed to prevent Vitest from attempting to resolve/load the real Electron binary during tests. @@ -78,3 +78,57 @@ describe('heightFrom', () => { expect(heightFrom({ height: 1000 } as Rectangle, { actual: 350, max: 400 })).toBe(350) }) }) + +describe('computeResizedBoundsAnchoredToDominantDisplay', () => { + const primaryDisplay = { + bounds: { x: 0, y: 0, width: 1920, height: 1080 }, + workArea: { x: 0, y: 25, width: 1920, height: 1055 }, + } + const secondaryDisplay = { + bounds: { x: 1920, y: 0, width: 1920, height: 1080 }, + workArea: { x: 1920, y: 0, width: 1920, height: 1040 }, + } + const topDisplay = { + bounds: { x: 0, y: -900, width: 1600, height: 900 }, + workArea: { x: 0, y: -900, width: 1600, height: 860 }, + } + + it('uses the display with the largest overlap when resizing a window across two displays', () => { + const bounds = computeResizedBoundsAnchoredToDominantDisplay({ + currentBounds: { x: 1700, y: 220, width: 500, height: 600 }, + targetSize: { width: 450, height: 600 }, + displays: [primaryDisplay, secondaryDisplay], + }) + + expect(bounds.x).toBe(1920) + expect(bounds.y).toBe(220) + expect(bounds.width).toBe(450) + expect(bounds.height).toBe(600) + }) + + it('uses the display with the largest overlap across three displays', () => { + const bounds = computeResizedBoundsAnchoredToDominantDisplay({ + currentBounds: { x: 1100, y: -700, width: 380, height: 620 }, + targetSize: { width: 450, height: 600 }, + displays: [primaryDisplay, secondaryDisplay, topDisplay], + }) + + expect(bounds.x).toBe(1030) + expect(bounds.y).toBe(-680) + expect(bounds.width).toBe(450) + expect(bounds.height).toBe(600) + }) + + it('keeps the matching display bottom-right corner anchored when resizing in the bottom-right quadrant', () => { + const bounds = computeResizedBoundsAnchoredToDominantDisplay({ + currentBounds: { x: 3420, y: 740, width: 300, height: 250 }, + targetSize: { width: 450, height: 600 }, + displays: [primaryDisplay, secondaryDisplay], + }) + + expect(bounds.x).toBe(3270) + expect(bounds.y).toBe(390) + expect(bounds.width).toBe(450) + expect(bounds.height).toBe(600) + }) +}) diff --git a/apps/stage-tamagotchi/src/main/windows/shared/display.ts b/apps/stage-tamagotchi/src/main/windows/shared/display.ts index 9501ae70d..4701f1865 100644 --- a/apps/stage-tamagotchi/src/main/windows/shared/display.ts +++ b/apps/stage-tamagotchi/src/main/windows/shared/display.ts @@ -9,6 +9,120 @@ export function currentDisplayBounds(window: BrowserWindow) { return nearbyDisplay.bounds } +export interface ResizableDisplayArea { + /** Full display bounds used to decide which physical display owns most of a window. */ + bounds: Rectangle + /** Usable display area used for quadrant anchoring and final window clamping. */ + workArea: Rectangle +} + +export interface DominantDisplayResizeOptions { + /** Current window bounds in Electron display coordinates. */ + currentBounds: Rectangle + /** Desired size before display work-area clamping. */ + targetSize: Pick + /** Displays from Electron screen APIs. */ + displays: readonly ResizableDisplayArea[] +} + +/** + * Computes resize bounds from the display that owns most of the current window. + */ +export function computeResizedBoundsAnchoredToDominantDisplay(options: DominantDisplayResizeOptions): Rectangle { + const targetWidth = Math.round(options.targetSize.width) + const targetHeight = Math.round(options.targetSize.height) + const display = findDominantDisplayArea(options.currentBounds, options.displays) + + if (!display) { + return { + ...options.currentBounds, + width: targetWidth, + height: targetHeight, + } + } + + const workArea = display.workArea + + // Target sizes may come from a larger display preset. Clamp them before + // deriving anchors so the right/bottom edge math never asks for coordinates + // outside the selected display's usable area. + const width = Math.min(targetWidth, workArea.width) + const height = Math.min(targetHeight, workArea.height) + const workAreaRight = workArea.x + workArea.width + const workAreaBottom = workArea.y + workArea.height + const currentRight = options.currentBounds.x + options.currentBounds.width + const currentBottom = options.currentBounds.y + options.currentBounds.height + + // The quadrant is based on the current window center, not the top-left + // corner, so a window crossing displays behaves according to where most of + // the visible window lives inside the selected work area. + const currentCenterX = options.currentBounds.x + options.currentBounds.width / 2 + const currentCenterY = options.currentBounds.y + options.currentBounds.height / 2 + const workAreaCenterX = workArea.x + workArea.width / 2 + const workAreaCenterY = workArea.y + workArea.height / 2 + + // Left/top quadrants keep the original x/y. Right/bottom quadrants keep the + // opposite edge visually fixed by subtracting the new size from the current + // right/bottom edge. + const x = currentCenterX > workAreaCenterX + ? currentRight - width + : options.currentBounds.x + const y = currentCenterY > workAreaCenterY + ? currentBottom - height + : options.currentBounds.y + + // The anchor can still land just outside the work area when the previous + // window crossed a screen boundary. Clamp after anchoring so resize intent + // wins first, then display safety. + return { + x: Math.round(clamp(x, workArea.x, workAreaRight - width)), + y: Math.round(clamp(y, workArea.y, workAreaBottom - height)), + width, + height, + } +} + +/** + * Finds the display that owns the largest visible share of `bounds`. + */ +export function findDominantDisplayArea(bounds: Rectangle, displays: readonly ResizableDisplayArea[]): ResizableDisplayArea | undefined { + let dominantDisplay: ResizableDisplayArea | undefined + let dominantArea = -1 + + for (const display of displays) { + // Use full display bounds, not workArea. Menu bars and docks shrink + // workArea, but they should not change which physical display owns a + // cross-screen window. + const area = intersectionArea(bounds, display.bounds) + if (area > dominantArea) { + dominantDisplay = display + dominantArea = area + } + } + + return dominantDisplay +} + +function intersectionArea(a: Rectangle, b: Rectangle): number { + // Each side of the overlap rectangle is the inner edge from the two source + // rectangles. If the right edge crosses the left edge, or bottom crosses top, + // the rectangles do not overlap. + const left = Math.max(a.x, b.x) + const top = Math.max(a.y, b.y) + const right = Math.min(a.x + a.width, b.x + b.width) + const bottom = Math.min(a.y + a.height, b.y + b.height) + + if (right <= left || bottom <= top) { + return 0 + } + + return (right - left) * (bottom - top) +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max) +} + interface SizeActual { actual: number } interface SizePercentage { percentage: number } type Size = SizeActual | SizePercentage | number