diff --git a/.github/workflows/build-loremaster.yml b/.github/workflows/build-loremaster.yml index 06bd60f..cc7b602 100644 --- a/.github/workflows/build-loremaster.yml +++ b/.github/workflows/build-loremaster.yml @@ -250,6 +250,8 @@ jobs: pnpm test:updates pnpm test:skin-updates pnpm test:rc-backup + pnpm test:alert-placement + pnpm test:updater-startup - name: Fetch stock-layout reference commit shell: bash @@ -390,6 +392,8 @@ jobs: pnpm build pnpm test:gear pnpm test:rc-backup + pnpm test:alert-placement + pnpm test:updater-startup - name: Verify installer selftest run: python3 installer/spinui_installer.py --selftest diff --git a/UI_Spin_qeynos_LO1.ini b/UI_Spin_qeynos_LO1.ini index de5c35f..127a1c2 100644 --- a/UI_Spin_qeynos_LO1.ini +++ b/UI_Spin_qeynos_LO1.ini @@ -716,7 +716,7 @@ ClickThrough=0 Escapable=0 Show=1 Width=360 -Height=193 +Height=207 [PlayerWindow_1] Show=0 [PlayerWindow_2] diff --git a/docs/UIfix/Screenshot_20260815_150209.png b/docs/UIfix/Screenshot_20260815_150209.png new file mode 100644 index 0000000..0e982f4 Binary files /dev/null and b/docs/UIfix/Screenshot_20260815_150209.png differ diff --git a/layouts/combat-focus/UI_Spin_qeynos_LO1.ini b/layouts/combat-focus/UI_Spin_qeynos_LO1.ini index de5c35f..127a1c2 100644 --- a/layouts/combat-focus/UI_Spin_qeynos_LO1.ini +++ b/layouts/combat-focus/UI_Spin_qeynos_LO1.ini @@ -716,7 +716,7 @@ ClickThrough=0 Escapable=0 Show=1 Width=360 -Height=193 +Height=207 [PlayerWindow_1] Show=0 [PlayerWindow_2] diff --git a/layouts/hybrid/UI_Spin_qeynos_LO1.ini b/layouts/hybrid/UI_Spin_qeynos_LO1.ini index 76008fe..472c0a2 100644 --- a/layouts/hybrid/UI_Spin_qeynos_LO1.ini +++ b/layouts/hybrid/UI_Spin_qeynos_LO1.ini @@ -716,7 +716,7 @@ ClickThrough=0 Escapable=0 Show=1 Width=360 -Height=193 +Height=207 [PlayerWindow_1] Show=0 [PlayerWindow_2] diff --git a/layouts/profiles/1920x1080/combat-focus/UI_Spin_qeynos_LO1.ini b/layouts/profiles/1920x1080/combat-focus/UI_Spin_qeynos_LO1.ini index 68bc0c6..47810e8 100644 --- a/layouts/profiles/1920x1080/combat-focus/UI_Spin_qeynos_LO1.ini +++ b/layouts/profiles/1920x1080/combat-focus/UI_Spin_qeynos_LO1.ini @@ -716,7 +716,7 @@ ClickThrough=0 Escapable=0 Show=1 Width=360 -Height=193 +Height=207 [PlayerWindow_1] Show=0 [PlayerWindow_2] diff --git a/layouts/profiles/1920x1080/hybrid/UI_Spin_qeynos_LO1.ini b/layouts/profiles/1920x1080/hybrid/UI_Spin_qeynos_LO1.ini index 27d92d4..7726d58 100644 --- a/layouts/profiles/1920x1080/hybrid/UI_Spin_qeynos_LO1.ini +++ b/layouts/profiles/1920x1080/hybrid/UI_Spin_qeynos_LO1.ini @@ -716,7 +716,7 @@ ClickThrough=0 Escapable=0 Show=1 Width=360 -Height=193 +Height=207 [PlayerWindow_1] Show=0 [PlayerWindow_2] diff --git a/layouts/profiles/1920x1080/social-focus/UI_Spin_qeynos_LO1.ini b/layouts/profiles/1920x1080/social-focus/UI_Spin_qeynos_LO1.ini index 6ec97e4..116b9f8 100644 --- a/layouts/profiles/1920x1080/social-focus/UI_Spin_qeynos_LO1.ini +++ b/layouts/profiles/1920x1080/social-focus/UI_Spin_qeynos_LO1.ini @@ -716,7 +716,7 @@ ClickThrough=0 Escapable=0 Show=1 Width=360 -Height=193 +Height=207 [PlayerWindow_1] Show=0 [PlayerWindow_2] diff --git a/layouts/profiles/2048x1080/combat-focus/UI_Spin_qeynos_LO1.ini b/layouts/profiles/2048x1080/combat-focus/UI_Spin_qeynos_LO1.ini index 0d343c3..e79a20d 100644 --- a/layouts/profiles/2048x1080/combat-focus/UI_Spin_qeynos_LO1.ini +++ b/layouts/profiles/2048x1080/combat-focus/UI_Spin_qeynos_LO1.ini @@ -716,7 +716,7 @@ ClickThrough=0 Escapable=0 Show=1 Width=360 -Height=193 +Height=207 [PlayerWindow_1] Show=0 [PlayerWindow_2] diff --git a/layouts/profiles/2048x1080/hybrid/UI_Spin_qeynos_LO1.ini b/layouts/profiles/2048x1080/hybrid/UI_Spin_qeynos_LO1.ini index dafcda5..f186993 100644 --- a/layouts/profiles/2048x1080/hybrid/UI_Spin_qeynos_LO1.ini +++ b/layouts/profiles/2048x1080/hybrid/UI_Spin_qeynos_LO1.ini @@ -716,7 +716,7 @@ ClickThrough=0 Escapable=0 Show=1 Width=360 -Height=193 +Height=207 [PlayerWindow_1] Show=0 [PlayerWindow_2] diff --git a/layouts/profiles/2048x1080/social-focus/UI_Spin_qeynos_LO1.ini b/layouts/profiles/2048x1080/social-focus/UI_Spin_qeynos_LO1.ini index d173c86..3eceb7e 100644 --- a/layouts/profiles/2048x1080/social-focus/UI_Spin_qeynos_LO1.ini +++ b/layouts/profiles/2048x1080/social-focus/UI_Spin_qeynos_LO1.ini @@ -716,7 +716,7 @@ ClickThrough=0 Escapable=0 Show=1 Width=360 -Height=193 +Height=207 [PlayerWindow_1] Show=0 [PlayerWindow_2] diff --git a/layouts/profiles/2560x1080/combat-focus/UI_Spin_qeynos_LO1.ini b/layouts/profiles/2560x1080/combat-focus/UI_Spin_qeynos_LO1.ini index ddbed80..4000069 100644 --- a/layouts/profiles/2560x1080/combat-focus/UI_Spin_qeynos_LO1.ini +++ b/layouts/profiles/2560x1080/combat-focus/UI_Spin_qeynos_LO1.ini @@ -716,7 +716,7 @@ ClickThrough=0 Escapable=0 Show=1 Width=360 -Height=193 +Height=207 [PlayerWindow_1] Show=0 [PlayerWindow_2] diff --git a/layouts/profiles/2560x1080/hybrid/UI_Spin_qeynos_LO1.ini b/layouts/profiles/2560x1080/hybrid/UI_Spin_qeynos_LO1.ini index e5c3dcd..429e62a 100644 --- a/layouts/profiles/2560x1080/hybrid/UI_Spin_qeynos_LO1.ini +++ b/layouts/profiles/2560x1080/hybrid/UI_Spin_qeynos_LO1.ini @@ -716,7 +716,7 @@ ClickThrough=0 Escapable=0 Show=1 Width=360 -Height=193 +Height=207 [PlayerWindow_1] Show=0 [PlayerWindow_2] diff --git a/layouts/profiles/2560x1080/social-focus/UI_Spin_qeynos_LO1.ini b/layouts/profiles/2560x1080/social-focus/UI_Spin_qeynos_LO1.ini index b6dc15d..bf23427 100644 --- a/layouts/profiles/2560x1080/social-focus/UI_Spin_qeynos_LO1.ini +++ b/layouts/profiles/2560x1080/social-focus/UI_Spin_qeynos_LO1.ini @@ -716,7 +716,7 @@ ClickThrough=0 Escapable=0 Show=1 Width=360 -Height=193 +Height=207 [PlayerWindow_1] Show=0 [PlayerWindow_2] diff --git a/layouts/profiles/2560x1440/combat-focus/UI_Spin_qeynos_LO1.ini b/layouts/profiles/2560x1440/combat-focus/UI_Spin_qeynos_LO1.ini index 1d05ed7..161aedc 100644 --- a/layouts/profiles/2560x1440/combat-focus/UI_Spin_qeynos_LO1.ini +++ b/layouts/profiles/2560x1440/combat-focus/UI_Spin_qeynos_LO1.ini @@ -716,7 +716,7 @@ ClickThrough=0 Escapable=0 Show=1 Width=360 -Height=193 +Height=207 [PlayerWindow_1] Show=0 [PlayerWindow_2] diff --git a/layouts/profiles/2560x1440/hybrid/UI_Spin_qeynos_LO1.ini b/layouts/profiles/2560x1440/hybrid/UI_Spin_qeynos_LO1.ini index 08e40a7..e2805fd 100644 --- a/layouts/profiles/2560x1440/hybrid/UI_Spin_qeynos_LO1.ini +++ b/layouts/profiles/2560x1440/hybrid/UI_Spin_qeynos_LO1.ini @@ -716,7 +716,7 @@ ClickThrough=0 Escapable=0 Show=1 Width=360 -Height=193 +Height=207 [PlayerWindow_1] Show=0 [PlayerWindow_2] diff --git a/layouts/profiles/2560x1440/social-focus/UI_Spin_qeynos_LO1.ini b/layouts/profiles/2560x1440/social-focus/UI_Spin_qeynos_LO1.ini index c6ead53..67e825b 100644 --- a/layouts/profiles/2560x1440/social-focus/UI_Spin_qeynos_LO1.ini +++ b/layouts/profiles/2560x1440/social-focus/UI_Spin_qeynos_LO1.ini @@ -716,7 +716,7 @@ ClickThrough=0 Escapable=0 Show=1 Width=360 -Height=193 +Height=207 [PlayerWindow_1] Show=0 [PlayerWindow_2] diff --git a/layouts/profiles/3440x1440/combat-focus/UI_Spin_qeynos_LO1.ini b/layouts/profiles/3440x1440/combat-focus/UI_Spin_qeynos_LO1.ini index de5c35f..127a1c2 100644 --- a/layouts/profiles/3440x1440/combat-focus/UI_Spin_qeynos_LO1.ini +++ b/layouts/profiles/3440x1440/combat-focus/UI_Spin_qeynos_LO1.ini @@ -716,7 +716,7 @@ ClickThrough=0 Escapable=0 Show=1 Width=360 -Height=193 +Height=207 [PlayerWindow_1] Show=0 [PlayerWindow_2] diff --git a/layouts/profiles/3440x1440/hybrid/UI_Spin_qeynos_LO1.ini b/layouts/profiles/3440x1440/hybrid/UI_Spin_qeynos_LO1.ini index 76008fe..472c0a2 100644 --- a/layouts/profiles/3440x1440/hybrid/UI_Spin_qeynos_LO1.ini +++ b/layouts/profiles/3440x1440/hybrid/UI_Spin_qeynos_LO1.ini @@ -716,7 +716,7 @@ ClickThrough=0 Escapable=0 Show=1 Width=360 -Height=193 +Height=207 [PlayerWindow_1] Show=0 [PlayerWindow_2] diff --git a/layouts/profiles/3440x1440/social-focus/UI_Spin_qeynos_LO1.ini b/layouts/profiles/3440x1440/social-focus/UI_Spin_qeynos_LO1.ini index fdd9b3e..5f9e038 100644 --- a/layouts/profiles/3440x1440/social-focus/UI_Spin_qeynos_LO1.ini +++ b/layouts/profiles/3440x1440/social-focus/UI_Spin_qeynos_LO1.ini @@ -716,7 +716,7 @@ ClickThrough=0 Escapable=0 Show=1 Width=360 -Height=193 +Height=207 [PlayerWindow_1] Show=0 [PlayerWindow_2] diff --git a/layouts/profiles/3840x1600/combat-focus/UI_Spin_qeynos_LO1.ini b/layouts/profiles/3840x1600/combat-focus/UI_Spin_qeynos_LO1.ini index b7b173c..aee4ed6 100644 --- a/layouts/profiles/3840x1600/combat-focus/UI_Spin_qeynos_LO1.ini +++ b/layouts/profiles/3840x1600/combat-focus/UI_Spin_qeynos_LO1.ini @@ -716,7 +716,7 @@ ClickThrough=0 Escapable=0 Show=1 Width=360 -Height=193 +Height=207 [PlayerWindow_1] Show=0 [PlayerWindow_2] diff --git a/layouts/profiles/3840x1600/hybrid/UI_Spin_qeynos_LO1.ini b/layouts/profiles/3840x1600/hybrid/UI_Spin_qeynos_LO1.ini index 5fccfa1..6a531fe 100644 --- a/layouts/profiles/3840x1600/hybrid/UI_Spin_qeynos_LO1.ini +++ b/layouts/profiles/3840x1600/hybrid/UI_Spin_qeynos_LO1.ini @@ -716,7 +716,7 @@ ClickThrough=0 Escapable=0 Show=1 Width=360 -Height=193 +Height=207 [PlayerWindow_1] Show=0 [PlayerWindow_2] diff --git a/layouts/profiles/3840x1600/social-focus/UI_Spin_qeynos_LO1.ini b/layouts/profiles/3840x1600/social-focus/UI_Spin_qeynos_LO1.ini index 0b68590..9bf62ed 100644 --- a/layouts/profiles/3840x1600/social-focus/UI_Spin_qeynos_LO1.ini +++ b/layouts/profiles/3840x1600/social-focus/UI_Spin_qeynos_LO1.ini @@ -716,7 +716,7 @@ ClickThrough=0 Escapable=0 Show=1 Width=360 -Height=193 +Height=207 [PlayerWindow_1] Show=0 [PlayerWindow_2] diff --git a/layouts/profiles/3840x2160/combat-focus/UI_Spin_qeynos_LO1.ini b/layouts/profiles/3840x2160/combat-focus/UI_Spin_qeynos_LO1.ini index 57a931b..e20bfc6 100644 --- a/layouts/profiles/3840x2160/combat-focus/UI_Spin_qeynos_LO1.ini +++ b/layouts/profiles/3840x2160/combat-focus/UI_Spin_qeynos_LO1.ini @@ -716,7 +716,7 @@ ClickThrough=0 Escapable=0 Show=1 Width=360 -Height=193 +Height=207 [PlayerWindow_1] Show=0 [PlayerWindow_2] diff --git a/layouts/profiles/3840x2160/hybrid/UI_Spin_qeynos_LO1.ini b/layouts/profiles/3840x2160/hybrid/UI_Spin_qeynos_LO1.ini index 020389a..7ec72bf 100644 --- a/layouts/profiles/3840x2160/hybrid/UI_Spin_qeynos_LO1.ini +++ b/layouts/profiles/3840x2160/hybrid/UI_Spin_qeynos_LO1.ini @@ -716,7 +716,7 @@ ClickThrough=0 Escapable=0 Show=1 Width=360 -Height=193 +Height=207 [PlayerWindow_1] Show=0 [PlayerWindow_2] diff --git a/layouts/profiles/3840x2160/social-focus/UI_Spin_qeynos_LO1.ini b/layouts/profiles/3840x2160/social-focus/UI_Spin_qeynos_LO1.ini index b2f5098..cf93315 100644 --- a/layouts/profiles/3840x2160/social-focus/UI_Spin_qeynos_LO1.ini +++ b/layouts/profiles/3840x2160/social-focus/UI_Spin_qeynos_LO1.ini @@ -716,7 +716,7 @@ ClickThrough=0 Escapable=0 Show=1 Width=360 -Height=193 +Height=207 [PlayerWindow_1] Show=0 [PlayerWindow_2] diff --git a/layouts/social-focus/UI_Spin_qeynos_LO1.ini b/layouts/social-focus/UI_Spin_qeynos_LO1.ini index fdd9b3e..5f9e038 100644 --- a/layouts/social-focus/UI_Spin_qeynos_LO1.ini +++ b/layouts/social-focus/UI_Spin_qeynos_LO1.ini @@ -716,7 +716,7 @@ ClickThrough=0 Escapable=0 Show=1 Width=360 -Height=193 +Height=207 [PlayerWindow_1] Show=0 [PlayerWindow_2] diff --git a/loremaster-desktop/electron/alert-placement.ts b/loremaster-desktop/electron/alert-placement.ts new file mode 100644 index 0000000..7b78a85 --- /dev/null +++ b/loremaster-desktop/electron/alert-placement.ts @@ -0,0 +1,161 @@ +export interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +export type AlertSide = "above" | "below" | "left" | "right"; +export type AlertAnchorSetting = "auto" | AlertSide; + +// Preference order auto has always used: above first, then right, then below, +// then left as the last resort. +export const ALERT_SIDES: readonly AlertSide[] = ["above", "right", "below", "left"]; + +export interface AlertPlacementRequest { + anchor: AlertAnchorSetting; + /** The seed/expanded window the alerts belong to. */ + main: Rect; + alert: { width: number; height: number }; + /** The crowd-control panel when it is on screen, otherwise null. */ + control: Rect | null; + workArea: Rect; + gap: number; +} + +export interface AlertPlacement { + x: number; + y: number; + side: AlertSide; + anchor: AlertAnchorSetting; +} + +export function intersects(a: Rect, b: Rect | null | undefined): boolean { + if (!b) return false; + return (a.x < b.x + b.width + && a.x + a.width > b.x + && a.y < b.y + b.height + && a.y + a.height > b.y); +} + +function clamp(value: number, low: number, high: number): number { + return Math.min(Math.max(value, low), Math.max(low, high)); +} + +/** Where the alert sits on a given side before anything is dodged. */ +function baseRect(side: AlertSide, request: AlertPlacementRequest): Rect { + const { main, alert, gap } = request; + const centredX = main.x + Math.round((main.width - alert.width) / 2); + const centredY = main.y + Math.round((main.height - alert.height) / 2); + if (side === "above") { + return { x: centredX, y: main.y - alert.height - gap, ...alert }; + } + if (side === "below") { + return { x: centredX, y: main.y + main.height + gap, ...alert }; + } + if (side === "left") { + return { x: main.x - alert.width - gap, y: centredY, ...alert }; + } + return { x: main.x + main.width + gap, y: centredY, ...alert }; +} + +// Push the alert further out along the side it is on, so it clears the control +// panel instead of landing on top of it. This generalises what the old code +// did only for a panel sitting above the window. +function clearControl(rect: Rect, side: AlertSide, request: AlertPlacementRequest): Rect { + const { control, gap } = request; + if (!intersects(rect, control) || !control) return rect; + if (side === "above") { + return { ...rect, y: Math.min(rect.y, control.y - rect.height - gap) }; + } + if (side === "below") { + return { ...rect, y: Math.max(rect.y, control.y + control.height + gap) }; + } + if (side === "left") { + return { ...rect, x: Math.min(rect.x, control.x - rect.width - gap) }; + } + return { ...rect, x: Math.max(rect.x, control.x + control.width + gap) }; +} + +function clampToWorkArea(rect: Rect, workArea: Rect): Rect { + return { + ...rect, + x: clamp(rect.x, workArea.x, workArea.x + workArea.width - rect.width), + y: clamp(rect.y, workArea.y, workArea.y + workArea.height - rect.height), + }; +} + +// Base position for a side, with the control panel dodged, but NOT clamped. +// Clamping is deliberately withheld here: a clamped rectangle always fits the +// work area, so testing fit after clamping is a tautology that makes every +// side look viable. +function candidate(side: AlertSide, request: AlertPlacementRequest): Rect { + return clearControl(baseRect(side, request), side, request); +} + +function insideWorkArea(rect: Rect, workArea: Rect): boolean { + return (rect.x >= workArea.x + && rect.y >= workArea.y + && rect.x + rect.width <= workArea.x + workArea.width + && rect.y + rect.height <= workArea.y + workArea.height); +} + +// The seed window is the only way to reach analyze and settings, and the only +// thing a player can drag. An alert card parked on top of it makes the whole +// application unreachable, so overlapping it disqualifies a side outright -- +// ahead of honouring a preferred anchor. +function usable(rect: Rect, request: AlertPlacementRequest): boolean { + return (insideWorkArea(rect, request.workArea) + && !intersects(rect, request.control) + && !intersects(rect, request.main)); +} + +// Mirrors the historic preference: above first, but only while it has at +// least as much room as below; otherwise try the sides before dropping below. +function preferenceOrder(request: AlertPlacementRequest): readonly AlertSide[] { + const { main, workArea } = request; + const spaceAbove = main.y - workArea.y; + const spaceBelow = workArea.y + workArea.height - (main.y + main.height); + return spaceAbove >= spaceBelow + ? (["above", "right", "below", "left"] as const) + : (["right", "below", "left", "above"] as const); +} + +/** + * Choose where the alert stack goes. + * + * The control panel is auto-placed on whichever side of the window has room, + * and it is click-through, so a player cannot drag it out of the way. The + * alerts therefore have to be the ones that move: they dodge the panel on + * every side, and they never cover the seed window. + * + * Every side yields two candidates -- its natural position, and that position + * pulled back inside the work area. Near a screen edge the natural one falls + * off screen while the clamped one can land on the seed, so both are offered + * and ranked together rather than clamping blindly at the end. + */ +export function placeAlertWindow(request: AlertPlacementRequest): AlertPlacement { + const { workArea, main } = request; + const sides = preferenceOrder(request); + const optionsFor = (side: AlertSide) => { + const raw = candidate(side, request); + return [{ side, rect: raw }, { side, rect: clampToWorkArea(raw, workArea) }]; + }; + const preferred = request.anchor === "auto" ? [] : optionsFor(request.anchor); + const options = [...preferred, ...sides.flatMap(optionsFor)]; + + const chosen = options.find(({ rect }) => usable(rect, request)) + // Nothing ideal: staying off the seed matters more than the panel, and + // both matter more than honouring the requested side. + ?? options.find(({ rect }) => insideWorkArea(rect, workArea) + && !intersects(rect, main)) + ?? options.find(({ rect }) => !intersects(rect, main)) + ?? options[0]; + + return { + x: chosen.rect.x, + y: chosen.rect.y, + side: chosen.side, + anchor: request.anchor, + }; +} diff --git a/loremaster-desktop/electron/main.ts b/loremaster-desktop/electron/main.ts index f230745..c3dc8a2 100644 --- a/loremaster-desktop/electron/main.ts +++ b/loremaster-desktop/electron/main.ts @@ -14,6 +14,7 @@ import { type InventoryEntry, } from "./gear-plan"; import { ItemIntelligenceService } from "./item-intelligence"; +import { placeAlertWindow } from "./alert-placement"; import { acknowledgePortableUpdateRelaunch, PortableUpdateService, @@ -1608,39 +1609,21 @@ function positionAlertWindow(): void { const settings = engine?.getState().settings ?? defaultSettings; const alertSize = scaledSize(ALERT_SIZE, settings.fontScale); const anchorBounds = mainWindow.getBounds(); - const companionBounds = controlWindow?.isVisible() ? controlWindow.getBounds() : null; - const companionAbove = Boolean( - companionBounds && companionBounds.y + companionBounds.height <= anchorBounds.y); - const workArea = screen.getDisplayMatching(anchorBounds).workArea; - const gap = 10; - let anchor = settings.alerts.alertAnchor; - if (anchor === "auto") { - const aboveEdge = companionAbove && companionBounds - ? companionBounds.y - : anchorBounds.y; - const above = aboveEdge - workArea.y; - const below = workArea.y + workArea.height - (anchorBounds.y + anchorBounds.height); - const right = workArea.x + workArea.width - (anchorBounds.x + anchorBounds.width); - anchor = above >= alertSize.height + gap && above >= below - ? "above" - : right >= alertSize.width + gap ? "right" - : below >= alertSize.height + gap ? "below" : "left"; - } - let x = anchorBounds.x + Math.round((anchorBounds.width - alertSize.width) / 2); - let y = (companionAbove && companionBounds ? companionBounds.y : anchorBounds.y) - - alertSize.height - gap; - if (anchor === "below") y = anchorBounds.y + anchorBounds.height + gap; - if (anchor === "left") { - x = anchorBounds.x - alertSize.width - gap; - y = anchorBounds.y + Math.round((anchorBounds.height - alertSize.height) / 2); - } - if (anchor === "right") { - x = anchorBounds.x + anchorBounds.width + gap; - y = anchorBounds.y + Math.round((anchorBounds.height - alertSize.height) / 2); - } - x = clamp(x, workArea.x, workArea.x + workArea.width - alertSize.width); - y = clamp(y, workArea.y, workArea.y + workArea.height - alertSize.height); - alertWindow.setBounds({ x, y, ...alertSize }, false); + // The control panel auto-places itself on whichever side has room and is + // click-through, so a player cannot drag it clear. The alerts are the ones + // that have to move out of its way -- on every side, not just above it. + const control = controlWindow && !controlWindow.isDestroyed() && controlWindow.isVisible() + ? controlWindow.getBounds() + : null; + const placement = placeAlertWindow({ + anchor: settings.alerts.alertAnchor, + main: anchorBounds, + alert: alertSize, + control, + workArea: screen.getDisplayMatching(anchorBounds).workArea, + gap: 10, + }); + alertWindow.setBounds({ x: placement.x, y: placement.y, ...alertSize }, false); } function setWindowMode(expanded: boolean, preserveAnchor = false): void { diff --git a/loremaster-desktop/electron/portable-updater.ts b/loremaster-desktop/electron/portable-updater.ts index 38dc802..d155101 100644 --- a/loremaster-desktop/electron/portable-updater.ts +++ b/loremaster-desktop/electron/portable-updater.ts @@ -488,7 +488,7 @@ export class PortableUpdateService { private readonly spawnImpl: typeof spawn; private readonly minExecutableBytes: number; private readonly maxExecutableBytes: number; - private readonly powershellPath: string; + private readonly configuredPowershellPath: string | null; private readonly listeners = new Set(); private progress: UpdateProgress = { phase: "idle", percent: 0, detail: "Ready to check for updates." }; private activeDownload: Promise | null = null; @@ -505,7 +505,16 @@ export class PortableUpdateService { this.spawnImpl = options.spawnImpl ?? spawn; this.minExecutableBytes = options.minExecutableBytes ?? MIN_EXECUTABLE_BYTES; this.maxExecutableBytes = options.maxExecutableBytes ?? MAX_EXECUTABLE_BYTES; - this.powershellPath = options.powershellPath ?? resolveWindowsPowerShell(); + this.configuredPowershellPath = options.powershellPath ?? null; + } + + // Resolved at install time, not construction time. The constructor runs + // during app startup on every platform -- main.ts calls it synchronously + // inside app.whenReady(), ahead of createWindow() and ensureTray() -- so a + // throw here silently costs the window and the tray icon. Only the Windows + // portable-replacement path actually needs PowerShell. + private resolvePowershellPath(): string { + return this.configuredPowershellPath ?? resolveWindowsPowerShell(); } subscribe(listener: ProgressListener): () => void { @@ -669,7 +678,7 @@ export class PortableUpdateService { } this.emit({ phase: "installing", percent: 100, detail: "Closing Loremaster, installing the update, and reopening it.", version: update.version }); const child: ChildProcess = this.spawnImpl( - this.powershellPath, + this.resolvePowershellPath(), [ "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", update.helperPath, diff --git a/loremaster-desktop/package.json b/loremaster-desktop/package.json index f59093e..7cd0b3c 100644 --- a/loremaster-desktop/package.json +++ b/loremaster-desktop/package.json @@ -17,6 +17,8 @@ "test:items": "node scripts/test-item-intelligence.cjs", "test:updates": "node scripts/test-portable-updater.cjs", "test:skin-updates": "node scripts/test-spinui-updater.cjs", + "test:alert-placement": "node scripts/test-alert-placement.cjs", + "test:updater-startup": "node scripts/test-updater-startup.cjs", "test:rc-backup": "node scripts/test-rc-backup.cjs" }, "dependencies": { diff --git a/loremaster-desktop/scripts/test-alert-placement.cjs b/loremaster-desktop/scripts/test-alert-placement.cjs new file mode 100644 index 0000000..95aafb5 --- /dev/null +++ b/loremaster-desktop/scripts/test-alert-placement.cjs @@ -0,0 +1,215 @@ +const assert = require("node:assert/strict"); + +const { + ALERT_SIDES, + intersects, + placeAlertWindow, +} = require("../dist-electron/alert-placement.js"); + +const WORK = { x: 0, y: 0, width: 1920, height: 1080 }; +const MAIN = { x: 800, y: 500, width: 320, height: 120 }; +const ALERT = { width: 360, height: 140 }; +const GAP = 10; + +function place(overrides = {}) { + return placeAlertWindow({ + anchor: "auto", + main: MAIN, + alert: ALERT, + control: null, + workArea: WORK, + gap: GAP, + ...overrides, + }); +} + +function rectOf(result) { + return { x: result.x, y: result.y, ...ALERT }; +} + +// A control panel placed hard against each side of the main window, matching +// what syncControlWindow produces for that side. +function controlOn(side, size = { width: 300, height: 90 }) { + if (side === "above") { + return { x: MAIN.x, y: MAIN.y - size.height - 6, ...size }; + } + if (side === "below") { + return { x: MAIN.x, y: MAIN.y + MAIN.height + 6, ...size }; + } + if (side === "left") { + return { x: MAIN.x - size.width - 6, y: MAIN.y, ...size }; + } + return { x: MAIN.x + MAIN.width + 6, y: MAIN.y, ...size }; +} + +function testStaysOnScreen() { + for (const side of ALERT_SIDES) { + for (const control of [null, ...ALERT_SIDES.map(controlOn)]) { + const r = rectOf(place({ anchor: side, control })); + assert.ok(r.x >= WORK.x && r.y >= WORK.y, `${side} escaped top/left`); + assert.ok(r.x + r.width <= WORK.x + WORK.width, `${side} escaped right`); + assert.ok(r.y + r.height <= WORK.y + WORK.height, `${side} escaped bottom`); + } + } + console.log(" always inside the work area: PASS"); +} + +function testNoControlKeepsHistoricPreference() { + // With room everywhere and no panel, auto has always preferred above. + const result = place(); + assert.equal(result.side, "above"); + assert.equal(result.y, MAIN.y - ALERT.height - GAP); + console.log(" no panel -> unchanged 'above' preference: PASS"); +} + +function testControlAboveStillClears() { + // The one case the old code handled: alerts sit above the panel, not on it. + const control = controlOn("above"); + const result = place({ control }); + const r = rectOf(result); + assert.equal(intersects(r, control), false); + assert.ok(r.y + r.height <= control.y, "alert must sit above the panel"); + console.log(" panel above -> alert clears it (regression guard): PASS"); +} + +function testAutoAvoidsPanelOnEverySide() { + for (const side of ALERT_SIDES) { + const control = controlOn(side); + const result = place({ control }); + const r = rectOf(result); + assert.equal( + intersects(r, control), false, + `auto placed the alert on top of a panel sitting ${side}`, + ); + } + console.log(" auto avoids the panel on all four sides: PASS"); +} + +function testExplicitAnchorIsNudgedClear() { + // The user pinned alerts to a side; honour the side but clear the panel. + for (const side of ALERT_SIDES) { + const control = controlOn(side); + const result = place({ anchor: side, control }); + const r = rectOf(result); + assert.equal(result.side, side, "explicit anchor must be preserved"); + assert.equal( + intersects(r, control), false, + `explicit '${side}' still overlapped the panel`, + ); + } + console.log(" explicit anchor kept, nudged clear of the panel: PASS"); +} + +function testHiddenPanelChangesNothing() { + const withPanel = place({ control: null }); + const same = place({ control: null }); + assert.deepEqual(withPanel, same); + console.log(" hidden panel is a no-op: PASS"); +} + +function testTallPanelFallsBackWithoutOverlap() { + // A panel that leaves no clear room on its own side must not trap the alert + // underneath it -- auto has to pick a different side. + const control = { x: MAIN.x - 40, y: 0, width: 420, height: MAIN.y + MAIN.height }; + const r = rectOf(place({ control })); + assert.equal(intersects(r, control), false); + console.log(" no room above -> auto picks a clear side: PASS"); +} + +function testIntersectsHelper() { + const a = { x: 0, y: 0, width: 10, height: 10 }; + assert.equal(intersects(a, { x: 10, y: 0, width: 10, height: 10 }), false); + assert.equal(intersects(a, { x: 9, y: 9, width: 10, height: 10 }), true); + assert.equal(intersects(a, null), false); + console.log(" intersects helper: PASS"); +} + + +// --- regression: the alert must never cover the seed window ----------------- +// A clamped rectangle always "fits" the work area, so checking fit after +// clamping is a tautology: auto then picks "above" no matter how little room +// is there, and near a screen edge that lands on top of the seed. The seed is +// the only way to reach analyze/settings and to drag the app, so covering it +// makes the application unusable. +const SEED = { width: 128, height: 74 }; +const REAL_ALERT = { width: 420, height: 112 }; + +function placeSeed(main, overrides = {}) { + return placeAlertWindow({ + anchor: "auto", + main, + alert: REAL_ALERT, + control: null, + workArea: WORK, + gap: 10, + ...overrides, + }); +} + +function testNeverCoversSeedAnywhereOnScreen() { + const xs = [WORK.x, 400, WORK.x + WORK.width - SEED.width]; + const ys = [WORK.y, 300, WORK.y + WORK.height - SEED.height]; + let checked = 0; + for (const x of xs) { + for (const y of ys) { + const main = { x, y, ...SEED }; + for (const anchor of ["auto", ...ALERT_SIDES]) { + const r = placeSeed(main, { anchor }); + const rect = { x: r.x, y: r.y, ...REAL_ALERT }; + assert.equal( + intersects(rect, main), false, + `alert covered the seed at (${x},${y}) with anchor ${anchor}`, + ); + checked += 1; + } + } + } + console.log(` never covers the seed (${checked} positions): PASS`); +} + +function testSeedAtTopDoesNotChooseAbove() { + const main = { x: 900, y: WORK.y, ...SEED }; + const r = placeSeed(main); + assert.notEqual(r.side, "above", "no room above a seed pinned to the top edge"); + const rect = { x: r.x, y: r.y, ...REAL_ALERT }; + assert.equal(intersects(rect, main), false); + console.log(" seed at the top edge -> auto avoids 'above': PASS"); +} + +function testSeedAtBottomDoesNotChooseBelow() { + const main = { x: 900, y: WORK.y + WORK.height - SEED.height, ...SEED }; + const r = placeSeed(main); + assert.notEqual(r.side, "below", "no room below a seed pinned to the bottom edge"); + const rect = { x: r.x, y: r.y, ...REAL_ALERT }; + assert.equal(intersects(rect, main), false); + console.log(" seed at the bottom edge -> auto avoids 'below': PASS"); +} + +function testAvoidsSeedAndPanelTogether() { + const main = { x: 900, y: WORK.y, ...SEED }; + const control = { x: main.x + main.width + 6, y: main.y, width: 300, height: 90 }; + const r = placeSeed(main, { control }); + const rect = { x: r.x, y: r.y, ...REAL_ALERT }; + assert.equal(intersects(rect, main), false, "covered the seed"); + assert.equal(intersects(rect, control), false, "covered the control panel"); + console.log(" avoids the seed and the panel at once: PASS"); +} + +function main() { + console.log("alert placement:"); + testIntersectsHelper(); + testNoControlKeepsHistoricPreference(); + testHiddenPanelChangesNothing(); + testControlAboveStillClears(); + testAutoAvoidsPanelOnEverySide(); + testExplicitAnchorIsNudgedClear(); + testTallPanelFallsBackWithoutOverlap(); + testStaysOnScreen(); + testSeedAtTopDoesNotChooseAbove(); + testSeedAtBottomDoesNotChooseBelow(); + testAvoidsSeedAndPanelTogether(); + testNeverCoversSeedAnywhereOnScreen(); + console.log("alert placement: ALL PASS"); +} + +main(); diff --git a/loremaster-desktop/scripts/test-updater-startup.cjs b/loremaster-desktop/scripts/test-updater-startup.cjs new file mode 100644 index 0000000..a449855 --- /dev/null +++ b/loremaster-desktop/scripts/test-updater-startup.cjs @@ -0,0 +1,96 @@ +// Startup guard: constructing the update services must never throw, on any +// platform. main.ts calls initializeUpdateServices() synchronously inside +// app.whenReady(), before createWindow() and ensureTray(). A constructor that +// throws there takes the window and the tray icon down with it, and the only +// symptom is an UnhandledPromiseRejectionWarning in a log nobody reads. +const assert = require("node:assert/strict"); +const { mkdtemp, rm } = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); + +const { PortableUpdateService } = require("../dist-electron/portable-updater.js"); +const { SpinUISkinUpdateService } = require("../dist-electron/spinui-updater.js"); + +async function withTempDir(run) { + const dir = await mkdtemp(path.join(os.tmpdir(), "updater-startup-")); + try { + await run(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +// A Linux/macOS process has neither of the variables the Windows PowerShell +// lookup needs. Clear them explicitly so this test proves the same thing when +// it runs on a Windows CI runner. +function withoutWindowsEnvironment(run) { + const saved = { SystemRoot: process.env.SystemRoot, WINDIR: process.env.WINDIR }; + delete process.env.SystemRoot; + delete process.env.WINDIR; + try { + return run(); + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + +async function testPortableUpdaterConstructs() { + await withTempDir(async (dir) => { + withoutWindowsEnvironment(() => { + const service = new PortableUpdateService({ + currentVersion: "0.4.0", + userDataDir: dir, + executablePath: null, + }); + assert.ok(service, "the portable updater must construct without Windows"); + assert.equal(typeof service.subscribe, "function"); + }); + }); + console.log(" portable updater constructs without Windows PowerShell: PASS"); +} + +async function testSkinUpdaterConstructs() { + await withTempDir(async (dir) => { + withoutWindowsEnvironment(() => { + const service = new SpinUISkinUpdateService({ userDataDir: dir, eqRoot: null }); + assert.ok(service, "the skin updater must construct without Windows"); + }); + }); + console.log(" skin updater constructs without Windows PowerShell: PASS"); +} + +async function testStartupSequenceSurvives() { + // The exact shape main.ts uses: build both services back to back and let any + // throw escape, the way it would inside app.whenReady(). + await withTempDir(async (dir) => { + withoutWindowsEnvironment(() => { + assert.doesNotThrow(() => { + const portable = new PortableUpdateService({ + currentVersion: "0.4.0", + userDataDir: dir, + executablePath: null, + }); + portable.subscribe(() => {}); + const skins = new SpinUISkinUpdateService({ userDataDir: dir, eqRoot: null }); + skins.subscribe(() => {}); + }, "initializeUpdateServices must not throw during startup"); + }); + }); + console.log(" full startup sequence does not throw: PASS"); +} + +async function main() { + console.log("updater startup:"); + await testPortableUpdaterConstructs(); + await testSkinUpdaterConstructs(); + await testStartupSequenceSurvives(); + console.log("updater startup: ALL PASS"); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/spinui_glass/EQUI_PlayerWindow.xml b/spinui_glass/EQUI_PlayerWindow.xml index 4984b33..2ef21ed 100644 --- a/spinui_glass/EQUI_PlayerWindow.xml +++ b/spinui_glass/EQUI_PlayerWindow.xml @@ -517,12 +517,13 @@ true true false - 96 - 112 + 18 + 2 6 132 false - true + false + false true @@ -550,12 +551,13 @@ true true true - 96 - 112 + 18 + 2 232 6 false - true + false + false true @@ -1358,10 +1360,10 @@ 360 - 193 + 207 280 - 193 + 207 false false WDT_RoundedTransparentNoArrow diff --git a/spinui_glass/EQUI_PlayerWindow1.xml b/spinui_glass/EQUI_PlayerWindow1.xml index 4f370c8..c06343b 100644 --- a/spinui_glass/EQUI_PlayerWindow1.xml +++ b/spinui_glass/EQUI_PlayerWindow1.xml @@ -519,12 +519,13 @@ true true false - 96 - 112 + 18 + 2 6 132 false - true + false + false true @@ -552,12 +553,13 @@ true true true - 96 - 112 + 18 + 2 232 6 false - true + false + false true @@ -1359,10 +1361,10 @@ 360 - 193 + 207 280 - 193 + 207 false false WDT_RoundedTransparentNoArrow diff --git a/spinui_glass/EQUI_PlayerWindow2.xml b/spinui_glass/EQUI_PlayerWindow2.xml index 4f370c8..c06343b 100644 --- a/spinui_glass/EQUI_PlayerWindow2.xml +++ b/spinui_glass/EQUI_PlayerWindow2.xml @@ -519,12 +519,13 @@ true true false - 96 - 112 + 18 + 2 6 132 false - true + false + false true @@ -552,12 +553,13 @@ true true true - 96 - 112 + 18 + 2 232 6 false - true + false + false true @@ -1359,10 +1361,10 @@ 360 - 193 + 207 280 - 193 + 207 false false WDT_RoundedTransparentNoArrow diff --git a/spinui_glass/EQUI_PlayerWindow3.xml b/spinui_glass/EQUI_PlayerWindow3.xml index 4f370c8..c06343b 100644 --- a/spinui_glass/EQUI_PlayerWindow3.xml +++ b/spinui_glass/EQUI_PlayerWindow3.xml @@ -519,12 +519,13 @@ true true false - 96 - 112 + 18 + 2 6 132 false - true + false + false true @@ -552,12 +553,13 @@ true true true - 96 - 112 + 18 + 2 232 6 false - true + false + false true @@ -1359,10 +1361,10 @@ 360 - 193 + 207 280 - 193 + 207 false false WDT_RoundedTransparentNoArrow diff --git a/spinui_glass/EQUI_PlayerWindow4.xml b/spinui_glass/EQUI_PlayerWindow4.xml index 4f370c8..c06343b 100644 --- a/spinui_glass/EQUI_PlayerWindow4.xml +++ b/spinui_glass/EQUI_PlayerWindow4.xml @@ -519,12 +519,13 @@ true true false - 96 - 112 + 18 + 2 6 132 false - true + false + false true @@ -552,12 +553,13 @@ true true true - 96 - 112 + 18 + 2 232 6 false - true + false + false true @@ -1359,10 +1361,10 @@ 360 - 193 + 207 280 - 193 + 207 false false WDT_RoundedTransparentNoArrow diff --git a/spinui_glass/EQUI_PlayerWindow5.xml b/spinui_glass/EQUI_PlayerWindow5.xml index 4f370c8..c06343b 100644 --- a/spinui_glass/EQUI_PlayerWindow5.xml +++ b/spinui_glass/EQUI_PlayerWindow5.xml @@ -519,12 +519,13 @@ true true false - 96 - 112 + 18 + 2 6 132 false - true + false + false true @@ -552,12 +553,13 @@ true true true - 96 - 112 + 18 + 2 232 6 false - true + false + false true @@ -1359,10 +1361,10 @@ 360 - 193 + 207 280 - 193 + 207 false false WDT_RoundedTransparentNoArrow diff --git a/spinui_glass/EQUI_PlayerWindow6.xml b/spinui_glass/EQUI_PlayerWindow6.xml index 4f370c8..c06343b 100644 --- a/spinui_glass/EQUI_PlayerWindow6.xml +++ b/spinui_glass/EQUI_PlayerWindow6.xml @@ -519,12 +519,13 @@ true true false - 96 - 112 + 18 + 2 6 132 false - true + false + false true @@ -552,12 +553,13 @@ true true true - 96 - 112 + 18 + 2 232 6 false - true + false + false true @@ -1359,10 +1361,10 @@ 360 - 193 + 207 280 - 193 + 207 false false WDT_RoundedTransparentNoArrow diff --git a/spinui_glass/default1080.ini b/spinui_glass/default1080.ini index 7da6655..ec94932 100644 --- a/spinui_glass/default1080.ini +++ b/spinui_glass/default1080.ini @@ -336,7 +336,7 @@ YRef=top XPos=20.416667% YPos=46.018519% Width=360 -Height=193 +Height=207 TickTimer=1 AttackTimer=1 [PlayerWindow_1] diff --git a/spinui_glass/default1440.ini b/spinui_glass/default1440.ini index 4401687..b6983d6 100644 --- a/spinui_glass/default1440.ini +++ b/spinui_glass/default1440.ini @@ -336,7 +336,7 @@ YRef=top XPos=28.515625% YPos=53.541667% Width=360 -Height=193 +Height=207 TickTimer=1 AttackTimer=1 [PlayerWindow_1] diff --git a/spinui_glass/default4k.ini b/spinui_glass/default4k.ini index 709d185..1a92103 100644 --- a/spinui_glass/default4k.ini +++ b/spinui_glass/default4k.ini @@ -336,7 +336,7 @@ YRef=top XPos=35.677083% YPos=68.379630% Width=360 -Height=193 +Height=207 TickTimer=1 AttackTimer=1 [PlayerWindow_1] diff --git a/spinui_reloaded/EQUI_PlayerWindow.xml b/spinui_reloaded/EQUI_PlayerWindow.xml index 72b974b..7941f8b 100644 --- a/spinui_reloaded/EQUI_PlayerWindow.xml +++ b/spinui_reloaded/EQUI_PlayerWindow.xml @@ -517,12 +517,13 @@ true true false - 96 - 112 + 18 + 2 6 132 false - true + false + false true @@ -550,12 +551,13 @@ true true true - 96 - 112 + 18 + 2 232 6 false - true + false + false true @@ -1358,10 +1360,10 @@ 360 - 193 + 207 280 - 193 + 207 false false WDT_RoundedTransparentNoArrow diff --git a/spinui_reloaded/EQUI_PlayerWindow1.xml b/spinui_reloaded/EQUI_PlayerWindow1.xml index e6a0869..580b277 100644 --- a/spinui_reloaded/EQUI_PlayerWindow1.xml +++ b/spinui_reloaded/EQUI_PlayerWindow1.xml @@ -519,12 +519,13 @@ true true false - 96 - 112 + 18 + 2 6 132 false - true + false + false true @@ -552,12 +553,13 @@ true true true - 96 - 112 + 18 + 2 232 6 false - true + false + false true @@ -1359,10 +1361,10 @@ 360 - 193 + 207 280 - 193 + 207 false false WDT_RoundedTransparentNoArrow diff --git a/spinui_reloaded/EQUI_PlayerWindow2.xml b/spinui_reloaded/EQUI_PlayerWindow2.xml index e6a0869..580b277 100644 --- a/spinui_reloaded/EQUI_PlayerWindow2.xml +++ b/spinui_reloaded/EQUI_PlayerWindow2.xml @@ -519,12 +519,13 @@ true true false - 96 - 112 + 18 + 2 6 132 false - true + false + false true @@ -552,12 +553,13 @@ true true true - 96 - 112 + 18 + 2 232 6 false - true + false + false true @@ -1359,10 +1361,10 @@ 360 - 193 + 207 280 - 193 + 207 false false WDT_RoundedTransparentNoArrow diff --git a/spinui_reloaded/EQUI_PlayerWindow3.xml b/spinui_reloaded/EQUI_PlayerWindow3.xml index e6a0869..580b277 100644 --- a/spinui_reloaded/EQUI_PlayerWindow3.xml +++ b/spinui_reloaded/EQUI_PlayerWindow3.xml @@ -519,12 +519,13 @@ true true false - 96 - 112 + 18 + 2 6 132 false - true + false + false true @@ -552,12 +553,13 @@ true true true - 96 - 112 + 18 + 2 232 6 false - true + false + false true @@ -1359,10 +1361,10 @@ 360 - 193 + 207 280 - 193 + 207 false false WDT_RoundedTransparentNoArrow diff --git a/spinui_reloaded/EQUI_PlayerWindow4.xml b/spinui_reloaded/EQUI_PlayerWindow4.xml index e6a0869..580b277 100644 --- a/spinui_reloaded/EQUI_PlayerWindow4.xml +++ b/spinui_reloaded/EQUI_PlayerWindow4.xml @@ -519,12 +519,13 @@ true true false - 96 - 112 + 18 + 2 6 132 false - true + false + false true @@ -552,12 +553,13 @@ true true true - 96 - 112 + 18 + 2 232 6 false - true + false + false true @@ -1359,10 +1361,10 @@ 360 - 193 + 207 280 - 193 + 207 false false WDT_RoundedTransparentNoArrow diff --git a/spinui_reloaded/EQUI_PlayerWindow5.xml b/spinui_reloaded/EQUI_PlayerWindow5.xml index e6a0869..580b277 100644 --- a/spinui_reloaded/EQUI_PlayerWindow5.xml +++ b/spinui_reloaded/EQUI_PlayerWindow5.xml @@ -519,12 +519,13 @@ true true false - 96 - 112 + 18 + 2 6 132 false - true + false + false true @@ -552,12 +553,13 @@ true true true - 96 - 112 + 18 + 2 232 6 false - true + false + false true @@ -1359,10 +1361,10 @@ 360 - 193 + 207 280 - 193 + 207 false false WDT_RoundedTransparentNoArrow diff --git a/spinui_reloaded/EQUI_PlayerWindow6.xml b/spinui_reloaded/EQUI_PlayerWindow6.xml index e6a0869..580b277 100644 --- a/spinui_reloaded/EQUI_PlayerWindow6.xml +++ b/spinui_reloaded/EQUI_PlayerWindow6.xml @@ -519,12 +519,13 @@ true true false - 96 - 112 + 18 + 2 6 132 false - true + false + false true @@ -552,12 +553,13 @@ true true true - 96 - 112 + 18 + 2 232 6 false - true + false + false true @@ -1359,10 +1361,10 @@ 360 - 193 + 207 280 - 193 + 207 false false WDT_RoundedTransparentNoArrow diff --git a/spinui_reloaded/default1080.ini b/spinui_reloaded/default1080.ini index 7da6655..ec94932 100644 --- a/spinui_reloaded/default1080.ini +++ b/spinui_reloaded/default1080.ini @@ -336,7 +336,7 @@ YRef=top XPos=20.416667% YPos=46.018519% Width=360 -Height=193 +Height=207 TickTimer=1 AttackTimer=1 [PlayerWindow_1] diff --git a/spinui_reloaded/default1440.ini b/spinui_reloaded/default1440.ini index 4401687..b6983d6 100644 --- a/spinui_reloaded/default1440.ini +++ b/spinui_reloaded/default1440.ini @@ -336,7 +336,7 @@ YRef=top XPos=28.515625% YPos=53.541667% Width=360 -Height=193 +Height=207 TickTimer=1 AttackTimer=1 [PlayerWindow_1] diff --git a/spinui_reloaded/default4k.ini b/spinui_reloaded/default4k.ini index 709d185..1a92103 100644 --- a/spinui_reloaded/default4k.ini +++ b/spinui_reloaded/default4k.ini @@ -336,7 +336,7 @@ YRef=top XPos=35.677083% YPos=68.379630% Width=360 -Height=193 +Height=207 TickTimer=1 AttackTimer=1 [PlayerWindow_1] diff --git a/tools/audit_combat_ui.py b/tools/audit_combat_ui.py index e3a55f0..5ee41e7 100644 --- a/tools/audit_combat_ui.py +++ b/tools/audit_combat_ui.py @@ -287,7 +287,7 @@ def audit_attack_indicator_contract() -> None: fail(f"{label} attack fill lost its topmost perimeter binding") window = item(root, "Screen", "PlayerWindow") - if dimensions(window) != (360, 193): + if dimensions(window) != (360, 207): fail(f"{label} changed the canonical player-window size") minimum = ( child_int(window, "MinHSize"), child_int(window, "MinVSize") @@ -341,7 +341,7 @@ def audit_attack_indicator_contract() -> None: # Resolve real rectangles at both supported extremes. This catches # gaps, clipped corners, and one-sided anchor regressions that an # XML symbol-table check cannot see. - for size in ((360, 193), PLAYER_MIN_SIZE): + for size in ((360, 207), PLAYER_MIN_SIZE): frame_width, frame_height = size expected_rects = { "A_AttackIndicatorAnimTop": @@ -428,8 +428,11 @@ def audit_player_and_target() -> None: if child_text(player_subwindow, "Style_Border") != "true": fail("PlayerSubWindow must remain the visible compact command frame") player_window = item(player, "Screen", "PlayerWindow") - if dimensions(player_window) != (360, 193): - fail("PlayerWindow must remain 360x193") + # 207, not 193: the bordered sub-window's client area is ~12px shorter + # than its box, so the stance/invocation row had no room and was clipped. + # TargetWindow has no such row and stays 193. + if dimensions(player_window) != (360, 207): + fail("PlayerWindow must remain 360x207") if (child_text(player_window, "Style_Border") != "false" or child_text(player_window, "Style_Transparent") != "true"): fail("PlayerWindow reintroduced the faint maximum-canvas perimeter") @@ -444,9 +447,12 @@ def audit_player_and_target() -> None: ) if player_min_size != PLAYER_MIN_SIZE: fail(f"PlayerWindow resize bounds changed: {player_min_size}") + # Offsets measure upward from PlayerSubWindow's bottom edge: the row is + # 16px tall (18 -> 2) riding 2px above that edge, so it stays on screen at + # any height rather than being clipped by the pinned 193px window. for label, expected, alignment in ( - (stance, (96, 112, 6, 132), "false"), - (invocation, (96, 112, 232, 6), "true"), + (stance, (18, 2, 6, 132), "false"), + (invocation, (18, 2, 232, 6), "true"), ): actual = tuple( child_int(label, tag) for tag in ( @@ -1323,6 +1329,49 @@ def semantic_signature(node: ET.Element): fail(f"variant audit coverage changed unexpectedly: {checked}") +def audit_stance_row_visibility() -> None: + """Prove the stance/invocation row cannot fall outside the sub-window. + + PlayerSubWindow is pinned to (70, 9, 3, 3) so the themed frame never + covers the native attack perimeter, which leaves it 114px tall inside a + 193px window -- minus whatever the bordered draw template insets. The + stance row is the bottom-most content, so anchoring it a fixed distance + from the TOP put its lower edge past the client area and clipped it. Both + edges anchor to the BOTTOM instead, so the row rides the sub-window's + lower edge at any height the player resizes to. + """ + checked = 0 + for skin in COMBAT_SKINS: + for name in PLAYER_WINDOW_FILES: + root = root_for_path(skin / name) + label = f"{skin.name}/{name}" + for element in ("PW_StanceLabel", "PW_InvocationInfo"): + node = item(root, "Label", element) + # An absent anchor tag means EQ defaults it to the top edge, + # which is the clipping bug this guards -- so read it without + # failing on absence and treat the default as top-anchored. + anchors = tuple((node.findtext(tag) or "true").strip() + for tag in ("TopAnchorToTop", + "BottomAnchorToTop")) + if anchors != ("false", "false"): + fail( + f"{label} {element} anchors to the top, so a fixed " + "window height can clip it out of view" + ) + top = child_int(node, "TopAnchorOffset") + bottom = child_int(node, "BottomAnchorOffset") + # Offsets measure upward from the sub-window's bottom edge, so + # the top offset is the larger of the two. + if top <= bottom: + fail(f"{label} {element} has an inverted bottom anchor") + if top - bottom < 14: + fail(f"{label} {element} row is too short to read: " + f"{top - bottom}px") + checked += 1 + if checked != 28: + fail(f"stance row coverage changed unexpectedly: {checked}") + + def main() -> int: audit_attack_indicator_contract() audit_player_and_target() @@ -1332,6 +1381,7 @@ def main() -> int: audit_accessibility() audit_default_visibility() audit_variant_safety() + audit_stance_row_visibility() stock_checked = audit_optional_stock_parity() print("Combat Command Center audit: ALL PASS") print(" Player/Target/ToT | Group 1..11 | XTarget 0..22 | Raid groups 1..12") diff --git a/tools/generate_spinui_layout.py b/tools/generate_spinui_layout.py index b4003e2..016eae6 100644 --- a/tools/generate_spinui_layout.py +++ b/tools/generate_spinui_layout.py @@ -163,7 +163,7 @@ def P_for(screen_w, screen_h, x, y, w=None, h=None, show=None, extra=None): # --- center combat cluster (above chat) --------------------------------- # Player and Target plates share the hotbar block's outer edges, so the # pair is perfectly centered over the rows beneath (block midpoint 1720). - "PlayerWindow": P(1170, 771, 360, 193, show=1), + "PlayerWindow": P(1170, 771, 360, 207, show=1), "TargetWindow": P(1898, 771, 360, 193, show=1), # Every pet layout preserves the historical x=1180 right edge and y=965 # bottom baseline: 8px before PlayerWindow and 7px above the hotbar row. @@ -299,7 +299,7 @@ def q(x, y, w=None, h=None, show=None, extra=None): # plate instead of inside it (seen at 2560x1080). tot_x, tot_y = max(8, sw - 1080), 536 plate_clash = ( - tot_y < player_y + 193 and tot_y + 100 > player_y and ( + tot_y < player_y + 207 and tot_y + 100 > player_y and ( (tot_x < player_x + 360 and tot_x + 232 > player_x) or (tot_x < target_x + 360 and tot_x + 232 > target_x))) if plate_clash: @@ -323,7 +323,7 @@ def q(x, y, w=None, h=None, show=None, extra=None): "HotButtonWnd": q(center - 588, chat_top - 98, 272, 93, show=1), "HotButtonWnd2": q(center + 289, chat_top - 98, 272, 93, show=1), "HotButtonWnd11": q(64, max(8, chat_top - 273), 98, 271, show=0), - "PlayerWindow": q(player_x, player_y, 360, 193, show=1), + "PlayerWindow": q(player_x, player_y, 360, 207, show=1), "TargetWindow": q(target_x, player_y, 360, 193, show=1), "PetInfoWindow": q(pet_x, pet_y, 356, 210, show=0), "PetInfoWindow_1": q(pet_x + 157, pet_y - 24, @@ -570,7 +570,7 @@ def rebuild_chat_manager(lines: list[str], font: int = CHAT_FONT_1440) -> list[s XML_SIZES = { # Player/Target keep their full transparent interaction and buff hosts for # placement math even though only the compact lower subframes are painted. - "PlayerWindow": (360, 193), "TargetWindow": (360, 193), + "PlayerWindow": (360, 207), "TargetWindow": (360, 193), "PetInfoWindow": (356, 210), "BuffWindow": (216, 640), "PetInfoWindow_1": (356, 210), "PetInfoWindow_2": (356, 210), "PetInfoWindow_3": (441, 181), diff --git a/tools/restyle_combat.py b/tools/restyle_combat.py index 631a0f2..4c6b057 100644 --- a/tools/restyle_combat.py +++ b/tools/restyle_combat.py @@ -62,7 +62,7 @@ # The authored 360x193 command frames remain unchanged on first load. Their # anchored subwindows can safely contract to these Legends-compatible bounds, # so users may resize them without clipping the command rows. -PLAYER_MIN_SIZE = (280, 193) +PLAYER_MIN_SIZE = (280, 207) TARGET_MIN_SIZE = (260, 174) # EverQuest recognizes the attack indicator by exact animation and ScreenID # names, then owns its attack-on visibility, red tint, and flashing cadence.