fix sizing issues

This commit is contained in:
Boof2015
2026-03-27 23:54:56 -04:00
parent 85c0cecfd2
commit f07d22cb33
3 changed files with 213 additions and 179 deletions
+112 -89
View File
@@ -13,7 +13,6 @@ import type { ProfileMenuRequest } from '../types/profileMenu'
import { SCOPE_KINDS, type ScopeKind } from '../types/scope'
let mainWindow: BrowserWindow | null = null
let currentSettingsHeight = 0
let moveInterval: ReturnType<typeof setInterval> | null = null
let moveStartCursor: { x: number; y: number } | null = null
let moveStartPosition: number[] | null = null
@@ -21,6 +20,8 @@ let moveStartPosition: number[] | null = null
const scopePopoutWindows = new Map<ScopeKind, BrowserWindow>()
const scopePopoutCloseAllowed = new Set<ScopeKind>()
const popoutBoundsTimers = new Map<ScopeKind, ReturnType<typeof setTimeout>>()
const windowSettingsHeights = new Map<number, number>()
const windowSettingsBottomAnchors = new Map<number, number>()
const WINDOW_DEFAULTS = {
width: 900,
@@ -48,6 +49,99 @@ function isMainRendererWindow(window: BrowserWindow | null): boolean {
return window !== null && window === mainWindow
}
function getBaseMinHeight(window: BrowserWindow): number {
return isMainRendererWindow(window) ? WINDOW_DEFAULTS.minHeight : POPOUT_DEFAULTS.minHeight
}
function getSettingsHeight(window: BrowserWindow | null): number {
if (!window) return 0
return windowSettingsHeights.get(window.id) ?? 0
}
function setSettingsHeightForWindow(window: BrowserWindow, height: number): void {
const nextHeight = Math.max(0, Math.round(height))
if (nextHeight === 0) {
windowSettingsHeights.delete(window.id)
windowSettingsBottomAnchors.delete(window.id)
return
}
windowSettingsHeights.set(window.id, nextHeight)
}
function toLogicalBounds(window: BrowserWindow, bounds = window.getBounds()): WindowBounds {
return {
...bounds,
height: Math.max(getBaseMinHeight(window), bounds.height - getSettingsHeight(window)),
}
}
function applyLogicalBounds(window: BrowserWindow, bounds: WindowBounds): void {
window.setBounds({
...bounds,
height: bounds.height + getSettingsHeight(window),
})
}
function setWindowHeight(window: BrowserWindow, bounds: WindowBounds, height: number, y = bounds.y): void {
window.setBounds({
x: bounds.x,
y,
width: bounds.width,
height,
})
}
function applySettingsHeight(window: BrowserWindow, rawNextHeight: number): void {
const currentSettingsHeight = getSettingsHeight(window)
const nextSettingsHeight = Math.max(0, Math.round(rawNextHeight))
const delta = nextSettingsHeight - currentSettingsHeight
const baseMinHeight = getBaseMinHeight(window)
const [minW] = window.getMinimumSize()
window.setMinimumSize(minW, baseMinHeight + nextSettingsHeight)
if (delta !== 0) {
const bounds = window.getBounds()
const newHeight = Math.max(baseMinHeight + nextSettingsHeight, bounds.height + delta)
const display = screen.getDisplayMatching(bounds)
const workArea = display.workArea
const workAreaBottom = workArea.y + workArea.height
const bottomEdge = bounds.y + newHeight
if (delta > 0 && bottomEdge > workAreaBottom) {
if (!windowSettingsBottomAnchors.has(window.id)) {
windowSettingsBottomAnchors.set(window.id, bounds.y + bounds.height)
}
const newY = Math.max(workArea.y, workAreaBottom - newHeight)
setWindowHeight(window, bounds, newHeight, newY)
} else if (delta < 0) {
const anchoredBottom = windowSettingsBottomAnchors.get(window.id)
if (anchoredBottom !== undefined) {
const targetY = Math.max(workArea.y, Math.min(anchoredBottom - newHeight, workAreaBottom - newHeight))
setWindowHeight(window, bounds, newHeight, targetY)
} else {
const baseHeight = newHeight - nextSettingsHeight
const naturalBottom = bounds.y + baseHeight
if (naturalBottom < workAreaBottom) {
const maxY = workAreaBottom - newHeight
if (bounds.y < maxY) {
setWindowHeight(window, bounds, newHeight)
} else {
setWindowHeight(window, bounds, newHeight, maxY)
}
} else {
setWindowHeight(window, bounds, newHeight)
}
}
} else {
setWindowHeight(window, bounds, newHeight)
}
}
setSettingsHeightForWindow(window, nextSettingsHeight)
}
function sendToRenderer(sender: WebContents, channel: string, ...args: unknown[]): void {
if (!sender.isDestroyed()) {
sender.send(channel, ...args)
@@ -189,8 +283,11 @@ function createMainWindow(): void {
})
mainWindow.on('closed', () => {
if (mainWindow) {
windowSettingsHeights.delete(mainWindow.id)
windowSettingsBottomAnchors.delete(mainWindow.id)
}
mainWindow = null
currentSettingsHeight = 0
for (const kind of SCOPE_KINDS) {
destroyScopePopoutWindow(kind)
@@ -219,7 +316,7 @@ function emitPopoutBoundsChanged(kind: ScopeKind, window: BrowserWindow): void {
popoutBoundsTimers.delete(kind)
if (!mainWindow || mainWindow.isDestroyed() || window.isDestroyed()) return
const bounds = window.getBounds()
const bounds = toLogicalBounds(window)
mainWindow.webContents.send('scope-popout:bounds-changed', kind, bounds)
}, 80)
@@ -293,6 +390,7 @@ function createScopePopoutWindow(kind: ScopeKind, rawBounds?: WindowBounds): Bro
}
const popoutWindow = new BrowserWindow(options)
setSettingsHeightForWindow(popoutWindow, 0)
scopePopoutWindows.set(kind, popoutWindow)
popoutWindow.once('ready-to-show', () => {
@@ -311,6 +409,8 @@ function createScopePopoutWindow(kind: ScopeKind, rawBounds?: WindowBounds): Bro
})
popoutWindow.on('closed', () => {
windowSettingsHeights.delete(popoutWindow.id)
windowSettingsBottomAnchors.delete(popoutWindow.id)
scopePopoutWindows.delete(kind)
scopePopoutCloseAllowed.delete(kind)
@@ -348,7 +448,7 @@ function syncScopePopouts(nextState: ScopePopoutSyncStateMap): void {
|| currentBounds.height !== nextBounds.height
if (hasBoundsDelta) {
popoutWindow.setBounds(nextBounds)
applyLogicalBounds(popoutWindow, nextBounds)
}
}
}
@@ -486,30 +586,14 @@ function setupIPC(): void {
const targetWindow = getWindowFromSender(event.sender)
if (!targetWindow) return
if (isMainRendererWindow(targetWindow)) {
targetWindow.setBounds({
...bounds,
height: bounds.height + currentSettingsHeight,
})
return
}
targetWindow.setBounds(bounds)
applyLogicalBounds(targetWindow, bounds)
})
ipcMain.handle('window:get-bounds', (event) => {
const targetWindow = getWindowFromSender(event.sender)
if (!targetWindow) return null
const bounds = targetWindow.getBounds()
if (!isMainRendererWindow(targetWindow)) {
return bounds
}
return {
...bounds,
height: bounds.height - currentSettingsHeight,
}
return toLogicalBounds(targetWindow)
})
ipcMain.on('window:reposition', (event, position: 'top' | 'bottom') => {
@@ -530,84 +614,23 @@ function setupIPC(): void {
ipcMain.on('window:expand-settings', (event, panelHeight: number) => {
const targetWindow = getWindowFromSender(event.sender)
if (!targetWindow || !isMainRendererWindow(targetWindow)) return
if (!targetWindow) return
const bounds = targetWindow.getBounds()
const [minW] = targetWindow.getMinimumSize()
const newHeight = bounds.height + panelHeight
targetWindow.setMinimumSize(minW, WINDOW_DEFAULTS.minHeight + panelHeight)
const display = screen.getDisplayMatching(bounds)
const workArea = display.workArea
const bottomEdge = bounds.y + newHeight
if (bottomEdge > workArea.y + workArea.height) {
const newY = Math.max(workArea.y, workArea.y + workArea.height - newHeight)
targetWindow.setBounds({ x: bounds.x, y: newY, width: bounds.width, height: newHeight })
} else {
targetWindow.setSize(bounds.width, newHeight, true)
}
currentSettingsHeight = Math.max(0, currentSettingsHeight + Math.round(panelHeight))
applySettingsHeight(targetWindow, getSettingsHeight(targetWindow) + panelHeight)
})
ipcMain.on('window:collapse-settings', (event, panelHeight: number) => {
const targetWindow = getWindowFromSender(event.sender)
if (!targetWindow || !isMainRendererWindow(targetWindow)) return
if (!targetWindow) return
const bounds = targetWindow.getBounds()
const [minW] = targetWindow.getMinimumSize()
const newHeight = Math.max(WINDOW_DEFAULTS.minHeight, bounds.height - panelHeight)
targetWindow.setMinimumSize(minW, WINDOW_DEFAULTS.minHeight)
const display = screen.getDisplayMatching(bounds)
const workArea = display.workArea
const wasAtBottom = bounds.y + bounds.height >= workArea.y + workArea.height - 10
if (wasAtBottom) {
const newY = Math.min(bounds.y + panelHeight, workArea.y + workArea.height - newHeight)
targetWindow.setBounds({ x: bounds.x, y: newY, width: bounds.width, height: newHeight })
} else {
targetWindow.setSize(bounds.width, newHeight, true)
}
currentSettingsHeight = Math.max(0, currentSettingsHeight - Math.round(panelHeight))
applySettingsHeight(targetWindow, getSettingsHeight(targetWindow) - panelHeight)
})
ipcMain.on('window:set-settings-height', (event, panelHeight: number) => {
const targetWindow = getWindowFromSender(event.sender)
if (!targetWindow || !isMainRendererWindow(targetWindow)) return
if (!targetWindow) return
const nextHeight = Math.max(0, Math.round(panelHeight))
const delta = nextHeight - currentSettingsHeight
const [width, height] = targetWindow.getSize()
const [minW] = targetWindow.getMinimumSize()
targetWindow.setMinimumSize(minW, WINDOW_DEFAULTS.minHeight + nextHeight)
if (delta !== 0) {
const newHeight = Math.max(WINDOW_DEFAULTS.minHeight, height + delta)
const bounds = targetWindow.getBounds()
const display = screen.getDisplayMatching(bounds)
const workArea = display.workArea
const bottomEdge = bounds.y + newHeight
if (delta > 0 && bottomEdge > workArea.y + workArea.height) {
const newY = Math.max(workArea.y, workArea.y + workArea.height - newHeight)
targetWindow.setBounds({ x: bounds.x, y: newY, width, height: newHeight })
} else if (delta < 0) {
const baseHeight = newHeight - nextHeight
const naturalBottom = bounds.y + baseHeight
if (naturalBottom < workArea.y + workArea.height) {
const maxY = workArea.y + workArea.height - newHeight
if (bounds.y < maxY) {
targetWindow.setSize(width, newHeight, true)
} else {
targetWindow.setBounds({ x: bounds.x, y: maxY, width, height: newHeight })
}
} else {
targetWindow.setSize(width, newHeight, true)
}
} else {
targetWindow.setSize(width, newHeight, true)
}
}
currentSettingsHeight = nextHeight
applySettingsHeight(targetWindow, panelHeight)
})
ipcMain.on('scope-popout:sync', (event, state: ScopePopoutSyncStateMap) => {
+83 -80
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState, type JSX, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type JSX, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent } from 'react'
import type { ScopePopoutSnapshot } from '../../types/popout'
import { SCOPE_LABELS, type ScopeKind } from '../../types/scope'
import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../../types/settings'
@@ -41,11 +41,12 @@ interface ScopePopoutWindowProps {
scopeKind: ScopeKind
}
const POPOUT_SETTINGS_EXPAND_HEIGHT = 260
export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps): JSX.Element {
const [snapshot, setSnapshot] = useState<ScopePopoutSnapshot<ScopeKind> | null>(null)
const [miniSettingsOpen, setMiniSettingsOpen] = useState(false)
const [chromeHeight, setChromeHeight] = useState(0)
const chromeRef = useRef<HTMLDivElement>(null)
const prevMiniSettingsOpenRef = useRef(false)
const dataSource = useMemo(() => new ScopePopoutDataSource(scopeKind), [scopeKind])
useEffect(() => {
@@ -74,6 +75,7 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps)
const effectiveAccent = snapshot?.accent ?? '#38bdf8'
const effectiveSettings = (snapshot?.settings ?? DEFAULT_SCOPE_SETTINGS[scopeKind]) as ScopeSettings[ScopeKind]
const settingsHeight = miniSettingsOpen ? POPOUT_SETTINGS_EXPAND_HEIGHT : 0
const handleUpdateScopeSettings = <K extends ScopeKind>(kind: K, partial: Partial<ScopeSettings[K]>): void => {
if (kind !== scopeKind) return
@@ -121,26 +123,21 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps)
window.electronAPI.stopWindowMove()
}, [])
useEffect(() => {
const chrome = chromeRef.current
if (!chrome) return
const updateHeight = (): void => {
setChromeHeight(chrome.scrollHeight)
useLayoutEffect(() => {
if (miniSettingsOpen && !prevMiniSettingsOpenRef.current) {
window.electronAPI.expandSettings(POPOUT_SETTINGS_EXPAND_HEIGHT)
} else if (!miniSettingsOpen && prevMiniSettingsOpenRef.current) {
window.electronAPI.collapseSettings(POPOUT_SETTINGS_EXPAND_HEIGHT)
}
updateHeight()
const observer = typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver(() => updateHeight())
observer?.observe(chrome)
return () => observer?.disconnect()
}, [miniSettingsOpen, snapshot])
prevMiniSettingsOpenRef.current = miniSettingsOpen
}, [miniSettingsOpen])
useEffect(() => {
return () => {
if (prevMiniSettingsOpenRef.current) {
window.electronAPI.setSettingsHeight(0)
}
window.electronAPI.stopWindowMove()
}
}, [])
@@ -152,57 +149,77 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps)
onMouseUp={handleAltDragEnd}
>
<div
ref={chromeRef}
className={[
'scope-popout__chrome',
miniSettingsOpen ? 'is-expanded' : '',
].join(' ').trim()}
className="scope-popout__viewport"
style={{ height: `calc(100vh - ${settingsHeight}px)` }}
>
<header className="scope-popout__header">
<div className="scope-popout__drag">
<button
type="button"
className="scope-popout__drag-handle"
onPointerDown={handleDragStart}
onPointerUp={handleDragEnd}
onPointerCancel={handleDragEnd}
onLostPointerCapture={handleDragEnd}
aria-label="Drag window"
title="Drag window"
>
<span className="scope-popout__drag-icon" aria-hidden="true">
<GripIcon />
</span>
</button>
<div className="scope-popout__title-group">
<span className="scope-popout__title">{snapshot?.label ?? SCOPE_LABELS[scopeKind]}</span>
<span className="scope-popout__subtitle">Detached Scope</span>
<div
className={[
'scope-popout__chrome',
miniSettingsOpen ? 'is-expanded' : '',
].join(' ').trim()}
>
<header className="scope-popout__header">
<div className="scope-popout__drag">
<button
type="button"
className="scope-popout__drag-handle"
onPointerDown={handleDragStart}
onPointerUp={handleDragEnd}
onPointerCancel={handleDragEnd}
onLostPointerCapture={handleDragEnd}
aria-label="Drag window"
title="Drag window"
>
<span className="scope-popout__drag-icon" aria-hidden="true">
<GripIcon />
</span>
</button>
<div className="scope-popout__title-group">
<span className="scope-popout__title">{snapshot?.label ?? SCOPE_LABELS[scopeKind]}</span>
<span className="scope-popout__subtitle">Detached Scope</span>
</div>
</div>
</div>
<div className="scope-popout__actions">
<button
type="button"
className={`scope-popout__button ${miniSettingsOpen ? 'is-active' : ''}`.trim()}
onClick={() => setMiniSettingsOpen((prev) => !prev)}
aria-label="Toggle mini settings"
title="Mini settings"
>
<SettingsIcon />
</button>
<button
type="button"
className="scope-popout__button"
onClick={() => window.electronAPI.requestScopePopIn(scopeKind)}
aria-label={`Pop in ${SCOPE_LABELS[scopeKind]}`}
title={`Pop in ${SCOPE_LABELS[scopeKind]}`}
>
<PopInIcon />
</button>
</div>
</header>
<div className="scope-popout__actions">
<button
type="button"
className={`scope-popout__button ${miniSettingsOpen ? 'is-active' : ''}`.trim()}
onClick={() => setMiniSettingsOpen((prev) => !prev)}
aria-label="Toggle mini settings"
title="Mini settings"
>
<SettingsIcon />
</button>
<button
type="button"
className="scope-popout__button"
onClick={() => window.electronAPI.requestScopePopIn(scopeKind)}
aria-label={`Pop in ${SCOPE_LABELS[scopeKind]}`}
title={`Pop in ${SCOPE_LABELS[scopeKind]}`}
>
<PopInIcon />
</button>
</div>
</header>
</div>
{miniSettingsOpen && (
<div className="scope-popout__content">
<div className="scope-popout__canvas-region">
<ScopeModule
scopeKind={scopeKind}
lineColor={effectiveAccent}
settings={effectiveSettings}
dataSource={dataSource}
/>
</div>
</div>
</div>
{miniSettingsOpen && (
<div
className="scope-popout__settings-region"
style={{ height: `${settingsHeight}px` }}
>
<div className="scope-popout__settings-panel">
<ScopeSettingsSection
kind={scopeKind}
@@ -210,22 +227,8 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps)
onUpdate={handleUpdateScopeSettings}
/>
</div>
)}
</div>
<div
className="scope-popout__content"
style={miniSettingsOpen ? { paddingTop: `${chromeHeight}px` } : undefined}
>
<div className="scope-popout__canvas-region">
<ScopeModule
scopeKind={scopeKind}
lineColor={effectiveAccent}
settings={effectiveSettings}
dataSource={dataSource}
/>
</div>
</div>
)}
</div>
)
}
+18 -10
View File
@@ -817,6 +817,13 @@ select {
color: var(--text-primary);
}
.scope-popout__viewport {
position: relative;
flex: 0 0 auto;
min-height: 0;
overflow: hidden;
}
.scope-popout__chrome {
position: absolute;
top: 0;
@@ -831,7 +838,7 @@ select {
transition: max-height 160ms ease, opacity 120ms ease, transform 160ms ease;
}
.scope-popout:hover .scope-popout__chrome,
.scope-popout__viewport:hover .scope-popout__chrome,
.scope-popout__chrome:hover,
.scope-popout__chrome.is-expanded {
max-height: 58px;
@@ -840,10 +847,6 @@ select {
transform: translateY(0);
}
.scope-popout__chrome.is-expanded {
max-height: 420px;
}
.scope-popout__header {
display: flex;
align-items: center;
@@ -951,12 +954,12 @@ select {
}
.scope-popout__settings-panel {
flex-shrink: 0;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
background: linear-gradient(180deg, rgba(6, 8, 11, 0.98), rgba(4, 5, 7, 0.98));
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
padding: 12px 0 8px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding: 12px 0 10px;
}
.scope-popout__settings-panel .settings-scope-section {
@@ -964,12 +967,17 @@ select {
padding: 0 14px 12px;
}
.scope-popout__settings-region {
flex: 0 0 auto;
min-height: 0;
overflow: hidden;
}
.scope-popout__content {
flex: 1;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
transition: padding-top 160ms ease;
}
.scope-popout__canvas-region {