mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 00:48:06 +00:00
fix(stage-tamagotchi): anchor tray resize to dominant display (#2012)
--- Co-authored-by-agent: Codex
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<Rectangle, 'width' | 'height'>
|
||||
/** 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
|
||||
|
||||
Reference in New Issue
Block a user