From 78fc63f86f00c840f68ac956eba76d371bacebd3 Mon Sep 17 00:00:00 2001 From: Khiem Nguyen Date: Thu, 16 Jul 2026 14:33:31 +0700 Subject: [PATCH 1/6] feat: add configurable stick positions (left/right/top) with multi-monitor support --- .gitignore | 2 + electron/main/geometry.ts | 86 +++++++++++++++++ electron/main/ipc.ts | 5 +- electron/main/tray.ts | 21 ++++- electron/main/window.ts | 129 +++++++++++++++++++------ electron/preload/index.ts | 2 +- package.json | 3 +- shared/bridge.ts | 10 +- shared/ipc.ts | 10 +- shared/types.ts | 8 +- src/components/Panel.tsx | 140 ++++++++++++++++++++-------- src/components/Settings.tsx | 25 +++++ src/hooks/useEdgeHover.ts | 181 +++++++++++++++++++++++++++--------- src/styles/panel.css | 37 ++++++++ tests/geometry.test.ts | 93 ++++++++++++++++++ vitest.config.ts | 7 ++ 16 files changed, 636 insertions(+), 123 deletions(-) create mode 100644 electron/main/geometry.ts create mode 100644 tests/geometry.test.ts create mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore index 23c64fd..232ceab 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ dist .DS_Store .vscode/* !.vscode/extensions.json + +docs/ diff --git a/electron/main/geometry.ts b/electron/main/geometry.ts new file mode 100644 index 0000000..99655e8 --- /dev/null +++ b/electron/main/geometry.ts @@ -0,0 +1,86 @@ +export type StickPosition = 'left' | 'right' | 'top' + +export interface DisplayInfo { + id: number + workArea: { x: number; y: number; width: number; height: number } +} + +export interface StickBoundsParams { + position: StickPosition + displays: DisplayInfo[] + displayId?: number + windowWidth: number + windowHeight: number + currentBounds?: { x: number; y: number } +} + +export interface StickBoundsResult { + x: number + y: number + width: number + height: number + displayId: number +} + +export function computeStickBounds(params: StickBoundsParams): StickBoundsResult { + const { position, displays, displayId, windowWidth, windowHeight, currentBounds } = params + + let display: DisplayInfo | undefined + + if (displayId !== undefined) { + display = displays.find(d => d.id === displayId) + } + + if (!display && currentBounds) { + const nearest = findNearestDisplay(displays, currentBounds) + if (nearest) display = nearest + } + + if (!display) { + display = displays[0] + } + + const wa = display.workArea + + let x: number + let y: number + let width = windowWidth + let height: number + + switch (position) { + case 'left': + x = wa.x + y = wa.y + height = wa.height + break + case 'right': + x = wa.x + wa.width - windowWidth + y = wa.y + height = wa.height + break + case 'top': + x = wa.x + Math.floor((wa.width - windowWidth) / 2) + y = wa.y + height = Math.min(Math.max(windowHeight, 320), wa.height) + break + } + + return { x, y, width, height, displayId: display.id } +} + +function findNearestDisplay(displays: DisplayInfo[], point: { x: number; y: number }): DisplayInfo | undefined { + let nearest: DisplayInfo | undefined + let minDist = Infinity + for (const d of displays) { + const cx = d.workArea.x + d.workArea.width / 2 + const cy = d.workArea.y + d.workArea.height / 2 + const dx = cx - point.x + const dy = cy - point.y + const dist = dx * dx + dy * dy + if (dist < minDist) { + minDist = dist + nearest = d + } + } + return nearest +} diff --git a/electron/main/ipc.ts b/electron/main/ipc.ts index efaba45..450fb8d 100644 --- a/electron/main/ipc.ts +++ b/electron/main/ipc.ts @@ -12,7 +12,7 @@ import { psHost } from './powershell' import { type InvokeMap, type InvokeChannel, type SendMap, type SendChannel } from '../../shared/ipc' import { getStore, loadSettings, saveSettings, pushState, addFiles, getWatcher } from './state' import { getMainWindow } from './window' -import { setInteractive, setHeartbeatPaused, setHotZoneWidth } from './window' +import { setInteractive, setHeartbeatPaused, setHotZoneWidth, repositionWindow } from './window' import { getOnboardingWindow } from './onboardingWindow' import { startDragOut, resolveDragData } from './drag' import { clipboardSignature } from '../clipboard/formats' @@ -437,6 +437,9 @@ export function registerIpc(): void { if (patch.hotZoneWidth !== undefined) { setHotZoneWidth(patch.hotZoneWidth) } + if (patch.stickPosition !== undefined) { + repositionWindow() + } pushState.settings(next) return next }) diff --git a/electron/main/tray.ts b/electron/main/tray.ts index 4f9f5b6..d3a6da9 100644 --- a/electron/main/tray.ts +++ b/electron/main/tray.ts @@ -10,7 +10,8 @@ import { Menu, Tray, app, nativeImage, Notification } from 'electron' import { existsSync } from 'node:fs' import { PATHS } from '../store/paths' import { loadSettings, saveSettings } from '../store/settings' -import { getMainWindow, setVisible } from './window' +import { getMainWindow, setVisible, repositionWindow } from './window' +import type { StickPosition } from '../../shared/types' import { pushState } from './state' let tray: Tray | null = null @@ -56,6 +57,19 @@ export function createTray(): Tray { } catch { /* ignore */ } } + function buildStickSubmenu(current: StickPosition): Electron.MenuItemConstructorOptions[] { + return (['left', 'right', 'top'] as StickPosition[]).map((pos) => ({ + label: pos.charAt(0).toUpperCase() + pos.slice(1), + type: 'radio' as const, + checked: current === pos, + click: () => { + const next = saveSettings({ stickPosition: pos }) + pushState.settings(next) + repositionWindow() + } + })) + } + const rebuild = () => { const settings = loadSettings() const menu = Menu.buildFromTemplate([ @@ -89,6 +103,11 @@ export function createTray(): Tray { } }, { type: 'separator' }, + { + label: 'Stick to', + submenu: buildStickSubmenu(settings.stickPosition) + }, + { type: 'separator' }, { label: 'Quit Edge-Drop', click: () => { diff --git a/electron/main/window.ts b/electron/main/window.ts index b3afba9..494a316 100644 --- a/electron/main/window.ts +++ b/electron/main/window.ts @@ -27,6 +27,8 @@ import { join } from 'node:path' import { APP_CONFIG } from './config' import { runtime } from './config' import { PATHS } from '../store/paths' +import { computeStickBounds } from './geometry' +import { loadSettings, saveSettings } from '../store/settings' export const PANEL_WIDTH = 384 /** Visual width of the blade when collapsed (only used by the renderer). */ @@ -134,24 +136,41 @@ export function startCursorPoll(): void { // Guard against garbage values that Windows occasionally sends. if (clientX < -1000 || clientX > 10000 || clientY < -1000 || clientY > 10000) return - const inEdge = clientX <= currentHotZoneWidth + const settings = loadSettings() + + let inEdge = false + switch (settings.stickPosition) { + case 'left': + inEdge = clientX <= currentHotZoneWidth + break + case 'right': + inEdge = (wa.width - clientX) <= currentHotZoneWidth + break + case 'top': + inEdge = clientY <= currentHotZoneWidth && Math.abs(clientX - wa.width / 2) <= PANEL_WIDTH / 2 + break + } + const newState = inEdge - // Keep streaming the cursor position to the renderer while it is near the - // edge (so opening works) OR while the panel is open (so closing works in - // EVERY direction). Previously we only streamed while clientX <= 60, which - // froze the renderer's last-known position the moment the cursor moved to - // the right past 60px — so it never learned the cursor had left and the - // blade refused to retract horizontally. When the panel is closed and the - // cursor is away from the edge we stop streaming to avoid needless IPC. - if (clientX <= 450 || interactive || newState !== lastEdgeState) { + // Stream cursor position while near the edge (opening), while open (closing), + // or when edge state changes. The near-edge check adapts to stick position. + const nearEdge = settings.stickPosition === 'top' + ? clientY <= 450 + : settings.stickPosition === 'right' + ? (wa.width - clientX) <= 450 + : clientX <= 450 + if (nearEdge || interactive || newState !== lastEdgeState) { lastEdgeState = newState if (!mainWindow.webContents.isDestroyed()) { mainWindow.webContents.send('window:cursor-edge', { x: clientX, y: clientY, inEdge, - inZone: true + inZone: true, + stickPosition: settings.stickPosition, + displayWidth: wa.width, + displayHeight: wa.height }) } } @@ -165,20 +184,36 @@ export function stopCursorPoll(): void { } } -/** Compute geometry anchored to the left edge of the primary display. */ -function edgeGeometry(): { x: number; y: number; width: number; height: number } { - const display = screen.getPrimaryDisplay() - const workArea = display.workArea - return { - x: workArea.x, - y: workArea.y, - width: PANEL_WIDTH, - height: workArea.height +function getStickGeometry(): { x: number; y: number; width: number; height: number } { + const settings = loadSettings() + const allDisplays = screen.getAllDisplays().map(d => ({ + id: d.id, + workArea: { ...d.workArea } + })) + + const primaryHeight = screen.getPrimaryDisplay().workArea.height + const windowHeight = settings.stickPosition === 'top' + ? Math.max(320, Math.round(settings.panelHeight * primaryHeight)) + : primaryHeight + + const result = computeStickBounds({ + position: settings.stickPosition, + displays: allDisplays, + displayId: settings.stickDisplayId, + windowWidth: PANEL_WIDTH, + windowHeight, + currentBounds: getMainWindow()?.getBounds() + }) + + if (result.displayId !== settings.stickDisplayId) { + saveSettings({ stickDisplayId: result.displayId }) } + + return { x: result.x, y: result.y, width: result.width, height: result.height } } export function createWindow(): BrowserWindow { - const { x, y, width, height } = edgeGeometry() + const { x, y, width, height } = getStickGeometry() mainWindow = new BrowserWindow({ icon: PATHS.icon(), @@ -216,10 +251,28 @@ export function createWindow(): BrowserWindow { // Keep the panel glued to the primary display if the work area changes. screen.on('display-metrics-changed', () => { if (!mainWindow?.isVisible()) return - const g = edgeGeometry() + const g = getStickGeometry() + mainWindow.setBounds({ ...g }) + if (detectorWindow && !detectorWindow.isDestroyed()) { + detectorWindow.setBounds(getDetectorBounds(g, loadSettings().stickPosition)) + } + }) + + screen.on('display-added', () => { + if (!mainWindow?.isVisible()) return + const g = getStickGeometry() mainWindow.setBounds({ ...g }) if (detectorWindow && !detectorWindow.isDestroyed()) { - detectorWindow.setBounds({ x: g.x, y: g.y, width: 1, height: g.height }) + detectorWindow.setBounds(getDetectorBounds(g, loadSettings().stickPosition)) + } + }) + + screen.on('display-removed', () => { + if (!mainWindow?.isVisible()) return + const g = getStickGeometry() + mainWindow.setBounds({ ...g }) + if (detectorWindow && !detectorWindow.isDestroyed()) { + detectorWindow.setBounds(getDetectorBounds(g, loadSettings().stickPosition)) } }) @@ -285,6 +338,15 @@ export function stopHeartbeat(): void { } } +export function repositionWindow(): void { + if (!mainWindow || mainWindow.isDestroyed()) return + const g = getStickGeometry() + mainWindow.setBounds({ ...g }) + if (detectorWindow && !detectorWindow.isDestroyed()) { + detectorWindow.setBounds(getDetectorBounds(g, loadSettings().stickPosition)) + } +} + /** Toggle the panel between shown (always on top) and fully hidden. */ export function setVisible(visible: boolean): void { if (!mainWindow) return @@ -305,6 +367,17 @@ export function setVisible(visible: boolean): void { * drag-event absorber; its inline script still forwards file drags to the main * window via `window.edge.setInteractive(true)` should it ever receive one. */ +function getDetectorBounds(g: { x: number; y: number; width: number; height: number }, stickPosition: string): { x: number; y: number; width: number; height: number } { + if (stickPosition === 'top') { + const detWidth = Math.floor(g.width * 0.3) + return { x: g.x + Math.floor((g.width - detWidth) / 2), y: g.y, width: detWidth, height: 1 } + } + const h = g.height + const detHeight = Math.floor(h * 0.3) + const detY = g.y + Math.floor((h - detHeight) / 2) + return { x: g.x, y: detY, width: 1, height: detHeight } +} + function createDetectorWindow(x: number, y: number, _w: number, h: number): void { // Minimal HTML: the detector uses the preload bridge (window.edge) to send IPC. // It listens for dragenter/dragover/drop on the document and sends a signal @@ -332,15 +405,13 @@ function createDetectorWindow(x: number, y: number, _w: number, h: number): void ` - // Center vertically, 30% height so we don't block the Start menu / taskbar clicks - const detHeight = Math.floor(h * 0.3) - const detY = y + Math.floor((h - detHeight) / 2) + const detBounds = getDetectorBounds({ x, y, width: _w, height: h }, loadSettings().stickPosition) detectorWindow = new BrowserWindow({ - x, - y: detY, - width: 1, - height: detHeight, + x: detBounds.x, + y: detBounds.y, + width: detBounds.width, + height: detBounds.height, show: false, frame: false, fullscreenable: false, diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 8245d26..5f3d40d 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -119,7 +119,7 @@ const api = { onOpenSettings: (cb: () => void) => on('window:open-settings', cb), onDragEnd: (cb: () => void) => on('item:drag-end', cb), onInternalDrop: (cb: (pos: { x: number; y: number }) => void) => on('item:internal-drop', cb), - onCursorEdge: (cb: (data: { x: number; y: number; inEdge: boolean; inZone: boolean }) => void) => on('window:cursor-edge', cb), + onCursorEdge: (cb: (data: EventArgs<'window:cursor-edge'>[0]) => void) => on('window:cursor-edge', cb), onToast: (cb: (toast: { id: string; message: string; tone: 'info' | 'error' }) => void) => on('ui:toast', cb), onTutorialStep: (cb: (step: number) => void) => on('tutorial:step', cb), diff --git a/package.json b/package.json index 4b9e879..78fe4f0 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,8 @@ "electron-builder": "^26.15.3", "electron-vite": "^2.1.0", "typescript": "^5.4.3", - "vite": "^5.2.6" + "vite": "^5.2.6", + "vitest": "^4.1.10" }, "dependencies": { "@radix-ui/react-scroll-area": "^1.2.13", diff --git a/shared/bridge.ts b/shared/bridge.ts index 10499a1..97fbc5d 100644 --- a/shared/bridge.ts +++ b/shared/bridge.ts @@ -41,7 +41,15 @@ export interface EdgeApi { onOpenSettings: (cb: () => void) => () => void onDragEnd: (cb: () => void) => () => void onInternalDrop: (cb: (pos: { x: number; y: number }) => void) => () => void - onCursorEdge: (cb: (data: { x: number; y: number; inEdge: boolean; inZone: boolean }) => void) => () => void + onCursorEdge: (cb: (data: { + x: number + y: number + inEdge: boolean + inZone: boolean + stickPosition: import('./types').StickPosition + displayWidth: number + displayHeight: number + }) => void) => () => void onToast: (cb: (toast: { id: string; message: string; tone: 'info' | 'error' }) => void) => () => void onTutorialStep: (cb: (step: number) => void) => () => void } diff --git a/shared/ipc.ts b/shared/ipc.ts index 3fd72d6..1905181 100644 --- a/shared/ipc.ts +++ b/shared/ipc.ts @@ -97,7 +97,15 @@ export interface EventMap { * Windows transparent windows). * payload: { x, y, inEdge, inZone } */ - 'window:cursor-edge': [data: { x: number; y: number; inEdge: boolean; inZone: boolean }] + 'window:cursor-edge': [data: { + x: number + y: number + inEdge: boolean + inZone: boolean + stickPosition: import('./types').StickPosition + displayWidth: number + displayHeight: number + }] } /* ------------------------------------------------------------------ */ diff --git a/shared/types.ts b/shared/types.ts index c0f5076..f37f548 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -61,6 +61,8 @@ export interface ClipboardItemDto extends Omit { /** Section the renderer groups items into. */ export type ItemSection = 'pinned' | 'shelf' +export type StickPosition = 'left' | 'right' | 'top' + /** * Request to begin a native OS drag-out of one item. * @@ -108,6 +110,8 @@ export interface Settings { uiStyle: 'modern' | 'compact' /** Flag to track if the onboarding tutorial is completed. */ tutorialCompleted: boolean + stickPosition: StickPosition + stickDisplayId?: number } export const DEFAULT_SETTINGS: Settings = { @@ -121,7 +125,9 @@ export const DEFAULT_SETTINGS: Settings = { clearUnpinnedOnRestart: false, autoDeleteHours: 0, uiStyle: 'modern', - tutorialCompleted: false + tutorialCompleted: false, + stickPosition: 'left', + stickDisplayId: undefined } diff --git a/src/components/Panel.tsx b/src/components/Panel.tsx index fccfc43..ff23e0f 100644 --- a/src/components/Panel.tsx +++ b/src/components/Panel.tsx @@ -158,29 +158,73 @@ export function Panel() { setDragActive(false) } + const isRight = settings.stickPosition === 'right' + const isTop = settings.stickPosition === 'top' + + let containerClass = 'blade-container' + if (isRight) containerClass += ' blade-right' + if (isTop) containerClass += ' blade-top' + + const containerStyle: Record = { + position: 'absolute', + zIndex: 10, + pointerEvents: open ? 'auto' : 'none' + } + + let originX = 0 + let originY = 0.5 + if (isTop) { + containerStyle.top = 0 + containerStyle.left = 0 + containerStyle.right = 0 + containerStyle.y = 0 + originX = 0.5 + originY = 0 + } else if (isRight) { + containerStyle.top = topOffset + containerStyle.y = '-50%' + containerStyle.right = 0 + originX = 1 + } else { + containerStyle.top = topOffset + containerStyle.y = '-50%' + containerStyle.left = 0 + } + containerStyle.originX = originX + containerStyle.originY = originY + + const PANEL_WIDE = 270 + const panelHalfW = PANEL_WIDE / 2 + + // Set clipPath via style (not animate) to avoid Framer Motion's broken + // calc() interpolation — CSS transitions handle it correctly. + let clipPath: string + if (isTop) { + clipPath = open + ? 'inset(calc(0% - 100px) calc(0% - 100px) calc(0% - 100px) calc(0% - 100px) round 0px 0px 24px 24px)' + : `inset(0px calc(50% - ${panelHalfW}px) calc(100% - ${settings.hotZoneWidth || 3}px) calc(50% - ${panelHalfW}px) round 0px 0px 24px 24px)` + } else if (isRight) { + clipPath = open + ? 'inset(calc(0% - 100px) 0px calc(0% - 100px) calc(0% - 100px) round 24px 0px 0px 24px)' + : `inset(calc(50% - ${halfTrigger}px) 0px calc(50% - ${halfTrigger}px) calc(100% - ${settings.hotZoneWidth || 3}px) round 24px 0px 0px 24px)` + } else { + clipPath = open + ? 'inset(calc(0% - 100px) calc(0% - 100px) calc(0% - 100px) 0px round 0px 24px 24px 0px)' + : `inset(calc(50% - ${halfTrigger}px) calc(100% - ${settings.hotZoneWidth || 3}px) calc(50% - ${halfTrigger}px) 0px round 0px 24px 24px 0px)` + } + containerStyle.clipPath = clipPath + return (
-
- - - -
-
- - - -
+ {!isTop ? ( + isRight ? ( + <> +
+ + + +
+
+ + + +
+ + ) : ( + <> +
+ + + +
+
+ + + +
+ + ) + ) : null}
@@ -235,9 +293,9 @@ export function Panel() { ) : ( @@ -265,7 +323,7 @@ export function Panel() { )} - +
@@ -353,7 +411,7 @@ function DropOverlay() { ) } -function SplitDropZone() { +function SplitDropZone({ isRight = false, isTop = false }: { isRight?: boolean; isTop?: boolean }) { const internalDragReq = useStore((s) => s.internalDragReq) const isSubitemDragging = !!( internalDragReq && @@ -379,11 +437,11 @@ function SplitDropZone() { onDragOver={(e) => e.preventDefault()} onDragEnter={handleDragEnter} onDragLeave={handleDragLeave} - initial={{ opacity: 0, x: -15 }} - animate={{ opacity: 1, x: 0 }} - exit={{ opacity: 0, x: -15 }} + initial={{ opacity: 0, [isTop ? 'y' : 'x']: isTop ? -15 : (isRight ? 15 : -15) }} + animate={{ opacity: 1, x: 0, y: 0 }} + exit={{ opacity: 0, [isTop ? 'y' : 'x']: isTop ? -15 : (isRight ? 15 : -15) }} transition={{ type: 'spring', stiffness: 350, damping: 25 }} - style={{ y: '-50%' }} + style={isTop ? {} : { y: '-50%' }} >
diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 4d7ee6d..93f6b6e 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -168,6 +168,31 @@ export function Settings() {
+ {/* Stick position */} +
+
+
Stick position
+
Screen edge to attach the panel
+
+
+ {[ + { label: 'Left', val: 'left' as const }, + { label: 'Right', val: 'right' as const }, + { label: 'Top', val: 'top' as const } + ].map((opt) => ( + + ))} +
+
+ +
+ {/* Incognito mode */}
diff --git a/src/hooks/useEdgeHover.ts b/src/hooks/useEdgeHover.ts index b6ee840..40e4964 100644 --- a/src/hooks/useEdgeHover.ts +++ b/src/hooks/useEdgeHover.ts @@ -150,10 +150,18 @@ export function useEdgeHover(): void { // expand). So we treat `panel:leave` as a *hint* and only actually close // if the last known pointer position is genuinely outside the blade. const isInsideBlade = () => { - const { midY, panelHalfH } = zone.current const { x, y } = lastClient.current - if (x < 0) return true // unknown — be conservative, don't close - return x <= PANEL_WIDE && y >= midY - panelHalfH && y <= midY + panelHalfH + if (x < 0 || y < 0) return true // unknown — be conservative, don't close + const s = settingsRef.current + if (s.stickPosition === 'top') { + const bladeH = (s.panelHeight || 0.5) * window.innerHeight + return y <= bladeH && x >= (window.innerWidth - PANEL_WIDE) / 2 && x <= (window.innerWidth + PANEL_WIDE) / 2 + } + if (s.stickPosition === 'right') { + return x >= window.innerWidth - PANEL_WIDE + } + // left + return x <= PANEL_WIDE } const onPanelLeave = () => { @@ -174,59 +182,140 @@ export function useEdgeHover(): void { // here with the renderer's own settings so everything stays in sync. const unsubCursorEdge = window.edge.onCursorEdge((data) => { lastClient.current = { x: data.x, y: data.y } + const { stickPosition, displayWidth } = data const { top, bottom, midY, panelHalfH } = zone.current - const inEdge = data.x <= TRIGGER_PX - const inZone = data.y >= top && data.y <= bottom + const centerX = displayWidth / 2 + const panelHalfW = PANEL_WIDE / 2 - // Edge trigger: only start dwell to OPEN when panel is closed. - // When panel is already open, cursor at x=0 is treated like any other - // inside-blade position (falls through to insideX check below). - if (inEdge && inZone && !openRef.current) { - cancelClose() - if (dwellTimer === undefined) { - dwellTimer = window.setTimeout(() => { + switch (stickPosition) { + case 'right': { + const distFromRight = displayWidth - data.x + const inEdge = distFromRight <= TRIGGER_PX + const inZone = data.y >= top && data.y <= bottom + + if (inEdge && inZone && !openRef.current) { + cancelClose() + if (dwellTimer === undefined) { + dwellTimer = window.setTimeout(() => { + dwellTimer = undefined + openPanel() + }, DWELL_MS) + } + return + } + + if (dwellTimer !== undefined) { + window.clearTimeout(dwellTimer) dwellTimer = undefined - openPanel() - }, DWELL_MS) + } + + if (!openRef.current) return + + const now = Date.now() + if (now - lastSetInteractiveRef.current > 2000) { + lastSetInteractiveRef.current = now + edge.setInteractive(true) + } + + const insideY = data.y >= midY - panelHalfH && data.y <= midY + panelHalfH + + if (distFromRight <= KEEP_OPEN_PX && insideY) { + cancelClose() + return + } + + if (distFromRight > START_CLOSE_PX || !insideY) { + scheduleClose() + } + break } - return - } - if (dwellTimer !== undefined) { - window.clearTimeout(dwellTimer) - dwellTimer = undefined - } + case 'top': { + const inEdge = data.y <= TRIGGER_PX + const inZone = data.x >= centerX - panelHalfW && data.x <= centerX + panelHalfW - if (!openRef.current) return + if (inEdge && inZone && !openRef.current) { + cancelClose() + if (dwellTimer === undefined) { + dwellTimer = window.setTimeout(() => { + dwellTimer = undefined + openPanel() + }, DWELL_MS) + } + return + } - // Self-healing safety: if panel is open, enforce interactivity so it can never be stuck click-through! - // Throttled to fire at most once every 2 seconds to prevent flooding the IPC queue. - const now = Date.now() - if (now - lastSetInteractiveRef.current > 2000) { - lastSetInteractiveRef.current = now - edge.setInteractive(true) - } + if (dwellTimer !== undefined) { + window.clearTimeout(dwellTimer) + dwellTimer = undefined + } - // Hysteresis-based close detection. - // Using two separate thresholds prevents the oscillation that occurs when - // the cursor hovers right at the blade edge (x ≈ 280px): a single threshold - // causes rapid cancel/schedule cycles every 16ms that prevent the grace - // timer from ever counting down. - // - // x ≤ KEEP_OPEN_PX (250): cursor clearly inside blade → cancel close - // x > START_CLOSE_PX (340): cursor clearly outside window → schedule close - // 250 < x ≤ 340 (dead band): neither action — let existing timer run - const insideY = data.y >= midY - panelHalfH && data.y <= midY + panelHalfH - - if (data.x <= KEEP_OPEN_PX && insideY) { - cancelClose() - return - } + if (!openRef.current) return + + const now = Date.now() + if (now - lastSetInteractiveRef.current > 2000) { + lastSetInteractiveRef.current = now + edge.setInteractive(true) + } + + const insideX = data.x >= centerX - panelHalfW && data.x <= centerX + panelHalfW + const bladeH = (settingsRef.current.panelHeight || 0.5) * data.displayHeight + const keepOpenY = bladeH - 15 + const startCloseY = bladeH + 20 + + if (data.y <= keepOpenY && insideX) { + cancelClose() + return + } + + if (data.y > startCloseY || !insideX) { + scheduleClose() + } + break + } - if (data.x > START_CLOSE_PX || !insideY) { - scheduleClose() + // left + default: { + const inEdge = data.x <= TRIGGER_PX + const inZone = data.y >= top && data.y <= bottom + + if (inEdge && inZone && !openRef.current) { + cancelClose() + if (dwellTimer === undefined) { + dwellTimer = window.setTimeout(() => { + dwellTimer = undefined + openPanel() + }, DWELL_MS) + } + return + } + + if (dwellTimer !== undefined) { + window.clearTimeout(dwellTimer) + dwellTimer = undefined + } + + if (!openRef.current) return + + const now = Date.now() + if (now - lastSetInteractiveRef.current > 2000) { + lastSetInteractiveRef.current = now + edge.setInteractive(true) + } + + const insideY = data.y >= midY - panelHalfH && data.y <= midY + panelHalfH + + if (data.x <= KEEP_OPEN_PX && insideY) { + cancelClose() + return + } + + if (data.x > START_CLOSE_PX || !insideY) { + scheduleClose() + } + break + } } - // else: dead band — do nothing, let existing timer expire naturally }) // ── keyboard ─────────────────────────────────────────────────────────── diff --git a/src/styles/panel.css b/src/styles/panel.css index 520f9a8..8f3d3f4 100644 --- a/src/styles/panel.css +++ b/src/styles/panel.css @@ -33,6 +33,25 @@ border: none; backdrop-filter: blur(20px); } +.blade-container.blade-right .blade { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-top-left-radius: 24px; + border-bottom-left-radius: 24px; +} + +/* Top-position blade: centered at top, bottom-rounded corners */ +.blade-container.blade-top { + top: 0 !important; + left: 0 !important; + right: 0 !important; + bottom: auto !important; + y: 0 !important; +} +.blade-container.blade-top .blade { + margin: 0 auto; + border-radius: 0 0 24px 24px; +} /* Reverse curves (flares) connecting blade to screen edge */ .flare-top, .flare-bottom { @@ -46,6 +65,12 @@ background: linear-gradient(to right, #000000 30px, transparent 30px); justify-content: flex-end; } +.flare-top.flare-right, .flare-bottom.flare-right { + left: auto; + right: -30px; + background: linear-gradient(to left, #000000 30px, transparent 30px); + justify-content: flex-start; +} .flare-top { top: -30px; } @@ -58,6 +83,11 @@ display: block; } +/* CSS transition for clip-path (Framer Motion can't interpolate calc() properly) */ +.blade-container { + transition: clip-path 0.6s cubic-bezier(0.34, 1.56, 0.64, 1); +} + /* Parent container solid block extension to prevent screen edge blur transparency */ .blade-container::before { content: ''; @@ -70,6 +100,13 @@ pointer-events: none; z-index: -2; } +.blade-container.blade-right::before { + left: auto; + right: -30px; +} +.blade-container.blade-top::before { + display: none; +} diff --git a/tests/geometry.test.ts b/tests/geometry.test.ts new file mode 100644 index 0000000..78e55d8 --- /dev/null +++ b/tests/geometry.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest' +import { computeStickBounds, type DisplayInfo } from '../electron/main/geometry' + +function makeDisplay(id: number, x: number, y: number, w: number, h: number): DisplayInfo { + return { id, workArea: { x, y, width: w, height: h } } +} + +const primary = makeDisplay(1, 0, 0, 1920, 1080) +const secondaryRight = makeDisplay(2, 1920, 0, 1920, 1080) +const secondaryNegX = makeDisplay(3, -1920, 0, 1920, 1080) +const secondaryNegY = makeDisplay(4, 0, -1080, 1920, 1080) + +describe('computeStickBounds', () => { + it('sticks to left edge of primary display', () => { + const result = computeStickBounds({ + position: 'left', + displays: [primary], + windowWidth: 384, + windowHeight: 1080 + }) + expect(result).toEqual({ x: 0, y: 0, width: 384, height: 1080, displayId: 1 }) + }) + + it('sticks to right edge of secondary display to the right', () => { + const result = computeStickBounds({ + position: 'right', + displays: [primary, secondaryRight], + displayId: 2, + windowWidth: 384, + windowHeight: 1080 + }) + expect(result).toEqual({ x: 3456, y: 0, width: 384, height: 1080, displayId: 2 }) + }) + + it('sticks to right edge of display with negative x', () => { + const result = computeStickBounds({ + position: 'right', + displays: [primary, secondaryNegX], + displayId: 3, + windowWidth: 384, + windowHeight: 1080 + }) + expect(result).toEqual({ x: -384, y: 0, width: 384, height: 1080, displayId: 3 }) + }) + + it('sticks to top edge of display with negative y', () => { + const result = computeStickBounds({ + position: 'top', + displays: [primary, secondaryNegY], + displayId: 4, + windowWidth: 384, + windowHeight: 600 + }) + expect(result).toEqual({ x: 768, y: -1080, width: 384, height: 600, displayId: 4 }) + }) + + it('falls back to nearest display when saved display is missing', () => { + const result = computeStickBounds({ + position: 'left', + displays: [primary, secondaryRight], + displayId: 99, + currentBounds: { x: 2000, y: 100 }, + windowWidth: 384, + windowHeight: 1080 + }) + expect(result.displayId).toBe(2) + }) + + it('falls back to primary when saved display missing and no current bounds', () => { + const result = computeStickBounds({ + position: 'left', + displays: [primary, secondaryRight], + displayId: 99, + windowWidth: 384, + windowHeight: 1080 + }) + expect(result.displayId).toBe(1) + }) + + it('clamps window larger than work area', () => { + const small = makeDisplay(5, 0, 0, 800, 600) + const result = computeStickBounds({ + position: 'left', + displays: [small], + windowWidth: 1000, + windowHeight: 800 + }) + expect(result.x).toBe(0) + expect(result.y).toBe(0) + expect(result.width).toBe(1000) + expect(result.height).toBe(600) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..adfd635 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'] + } +}) From e6dc402daffb73b4cff7768ad8d51fa39684d77d Mon Sep 17 00:00:00 2001 From: Khiem Nguyen Date: Thu, 16 Jul 2026 14:40:15 +0700 Subject: [PATCH 2/6] add display picker in settings and tray --- electron/main/ipc.ts | 19 +++++++++++++++++-- electron/main/tray.ts | 25 ++++++++++++++++++++++++- electron/preload/index.ts | 1 + shared/bridge.ts | 1 + shared/ipc.ts | 3 +++ shared/types.ts | 7 +++++++ src/components/Settings.tsx | 29 +++++++++++++++++++++++++++++ 7 files changed, 82 insertions(+), 3 deletions(-) diff --git a/electron/main/ipc.ts b/electron/main/ipc.ts index 450fb8d..ee56dba 100644 --- a/electron/main/ipc.ts +++ b/electron/main/ipc.ts @@ -5,7 +5,7 @@ * renderer calls them through the typed preload bridge, so a signature mismatch * is a compile-time error rather than a runtime one. */ -import { app, ipcMain, clipboard, nativeImage } from 'electron' +import { app, ipcMain, clipboard, nativeImage, screen } from 'electron' import { existsSync } from 'node:fs' import { execFile } from 'node:child_process' import { psHost } from './powershell' @@ -437,7 +437,7 @@ export function registerIpc(): void { if (patch.hotZoneWidth !== undefined) { setHotZoneWidth(patch.hotZoneWidth) } - if (patch.stickPosition !== undefined) { + if (patch.stickPosition !== undefined || patch.stickDisplayId !== undefined) { repositionWindow() } pushState.settings(next) @@ -454,6 +454,21 @@ export function registerIpc(): void { win.minimize() } }) + + handle('displays:list', () => { + const all = screen.getAllDisplays() + const primary = screen.getPrimaryDisplay() + return all.map((d) => { + const isLeft = d.bounds.x < 0 + const positionLabel = d.id === primary.id ? 'Primary' : (isLeft ? 'Left' : 'Right') + return { + id: d.id, + bounds: { x: d.bounds.x, y: d.bounds.y, width: d.bounds.width, height: d.bounds.height }, + isPrimary: d.id === primary.id, + label: `Display ${positionLabel} (${d.bounds.width}×${d.bounds.height})` + } + }) + }) } /** diff --git a/electron/main/tray.ts b/electron/main/tray.ts index d3a6da9..af38c59 100644 --- a/electron/main/tray.ts +++ b/electron/main/tray.ts @@ -6,7 +6,7 @@ * state (checkmarks) is rebuilt every time the menu opens so it always reflects * current settings. */ -import { Menu, Tray, app, nativeImage, Notification } from 'electron' +import { Menu, Tray, app, nativeImage, Notification, screen } from 'electron' import { existsSync } from 'node:fs' import { PATHS } from '../store/paths' import { loadSettings, saveSettings } from '../store/settings' @@ -57,6 +57,25 @@ export function createTray(): Tray { } catch { /* ignore */ } } + function buildDisplaySubmenu(currentId: number | undefined): Electron.MenuItemConstructorOptions[] { + const all = screen.getAllDisplays() + const primary = screen.getPrimaryDisplay() + return all.map((d) => { + const isLeft = d.bounds.x < 0 + const posLabel = d.id === primary.id ? 'Primary' : (isLeft ? 'Left' : 'Right') + return { + label: `Display ${posLabel} (${d.bounds.width}×${d.bounds.height})`, + type: 'radio' as const, + checked: currentId === d.id, + click: () => { + const next = saveSettings({ stickDisplayId: d.id }) + pushState.settings(next) + repositionWindow() + } + } + }) + } + function buildStickSubmenu(current: StickPosition): Electron.MenuItemConstructorOptions[] { return (['left', 'right', 'top'] as StickPosition[]).map((pos) => ({ label: pos.charAt(0).toUpperCase() + pos.slice(1), @@ -107,6 +126,10 @@ export function createTray(): Tray { label: 'Stick to', submenu: buildStickSubmenu(settings.stickPosition) }, + { + label: 'Display', + submenu: buildDisplaySubmenu(settings.stickDisplayId) + }, { type: 'separator' }, { label: 'Quit Edge-Drop', diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 5f3d40d..f354024 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -105,6 +105,7 @@ const api = { removeSubitem: (req: import('../../shared/types').DragRequest) => invoke('item:remove-subitem', req), mergeItems: (sourceId: string, targetId: string) => invoke('item:merge', sourceId, targetId), splitItem: (req: import('../../shared/types').DragRequest) => invoke('item:split', req), + getDisplays: () => invoke('displays:list'), updateSettings: (patch: Partial>) => invoke('settings:update', patch), setInteractive: (value: boolean) => invoke('window:set-interactive', value), diff --git a/shared/bridge.ts b/shared/bridge.ts index 97fbc5d..25802a2 100644 --- a/shared/bridge.ts +++ b/shared/bridge.ts @@ -31,6 +31,7 @@ export interface EdgeApi { updateSettings: (patch: Partial) => Promise setInteractive: (value: boolean) => Promise minimizeWindow: () => Promise + getDisplays: () => Promise setInternalDrag: (active: boolean) => void broadcastTutorialStep: (step: number) => void diff --git a/shared/ipc.ts b/shared/ipc.ts index 1905181..c6de104 100644 --- a/shared/ipc.ts +++ b/shared/ipc.ts @@ -64,6 +64,9 @@ export interface InvokeMap { /** Check for application updates on GitHub releases. */ 'app:check-update': { args: []; result: { latestVersion: string; downloadUrl: string } | null } + + /** Get the list of connected displays. */ + 'displays:list': { args: []; result: import('./types').DisplayInfo[] } } /* ------------------------------------------------------------------ */ diff --git a/shared/types.ts b/shared/types.ts index f37f548..93dad60 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -63,6 +63,13 @@ export type ItemSection = 'pinned' | 'shelf' export type StickPosition = 'left' | 'right' | 'top' +export interface DisplayInfo { + id: number + bounds: { x: number; y: number; width: number; height: number } + isPrimary: boolean + label: string +} + /** * Request to begin a native OS drag-out of one item. * diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 93f6b6e..7ae595e 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -1,4 +1,6 @@ +import { useEffect, useState } from 'react' import { useStore } from '../store/appStore' +import type { DisplayInfo } from '../../shared/types' import '../styles/settings.css' export function Settings() { @@ -7,6 +9,11 @@ export function Settings() { const updateInfo = useStore((s) => s.updateInfo) const currentVersion = useStore((s) => s.currentVersion) + const [displays, setDisplays] = useState([]) + useEffect(() => { + window.edge.getDisplays().then(setDisplays).catch(() => {}) + }, []) + return (
{updateInfo?.hasUpdate && ( @@ -193,6 +200,28 @@ export function Settings() {
+ {/* Display picker */} +
+
+
Display
+
Monitor to stick the panel to
+
+
+ {displays.length === 0 &&
Loading...
} + {displays.map((d) => ( + + ))} +
+
+ +
+ {/* Incognito mode */}
From 6a38f614a215a381d020f9778276e55fb1fc5cf1 Mon Sep 17 00:00:00 2001 From: Khiem Nguyen Date: Thu, 16 Jul 2026 14:43:31 +0700 Subject: [PATCH 3/6] hover only responds on configured stick display --- drag_debug.txt | 6 ++++++ electron/main/window.ts | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/drag_debug.txt b/drag_debug.txt index a24e464..4ffe3d4 100644 --- a/drag_debug.txt +++ b/drag_debug.txt @@ -3463,3 +3463,9 @@ [2026-07-15T12:00:36.093Z] createFileStackDragIcon resvg result: isEmpty=false, size={"width":96,"height":96} [2026-07-15T12:00:36.094Z] dragIcon result: isEmpty=false, size={"width":96,"height":96} [2026-07-15T12:00:36.094Z] calling sender.startDrag with item.file=C:\Users\yadav\Downloads\test_project.py, item.files=["C:\\Users\\yadav\\Downloads\\test_project.py"] +[2026-07-16T07:41:02.483Z] createFileStackDragIcon calling Resvg for count=1 +[2026-07-16T07:41:03.399Z] createFileStackDragIcon resvg result: isEmpty=false, size={"width":96,"height":96} +[2026-07-16T07:41:03.400Z] createFileStackDragIcon calling Resvg for count=1 +[2026-07-16T07:41:04.268Z] createFileStackDragIcon resvg result: isEmpty=false, size={"width":96,"height":96} +[2026-07-16T07:41:04.268Z] createFileStackDragIcon calling Resvg for count=1 +[2026-07-16T07:41:05.123Z] createFileStackDragIcon resvg result: isEmpty=false, size={"width":96,"height":96} diff --git a/electron/main/window.ts b/electron/main/window.ts index 494a316..847f21c 100644 --- a/electron/main/window.ts +++ b/electron/main/window.ts @@ -39,11 +39,16 @@ let detectorWindow: BrowserWindow | null = null let interactive = false export let currentHotZoneWidth = 3 +export let currentStickDisplayId: number | undefined export function setHotZoneWidth(width: number): void { currentHotZoneWidth = width } +export function setStickDisplayId(id: number | undefined): void { + currentStickDisplayId = id +} + export function getMainWindow(): BrowserWindow | null { return mainWindow } @@ -123,8 +128,14 @@ export function startCursorPoll(): void { cursorPollTimer = setInterval(() => { if (runtime.quitting || !mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) return + const settings = loadSettings() + const pt = screen.getCursorScreenPoint() const display = screen.getDisplayNearestPoint(pt) + + // Only respond on the configured stick display (or any if none set). + if (currentStickDisplayId !== undefined && display.id !== currentStickDisplayId) return + const wa = display.workArea // Translate screen coords → window-client coords. @@ -136,8 +147,6 @@ export function startCursorPoll(): void { // Guard against garbage values that Windows occasionally sends. if (clientX < -1000 || clientX > 10000 || clientY < -1000 || clientY > 10000) return - const settings = loadSettings() - let inEdge = false switch (settings.stickPosition) { case 'left': @@ -209,6 +218,7 @@ function getStickGeometry(): { x: number; y: number; width: number; height: numb saveSettings({ stickDisplayId: result.displayId }) } + currentStickDisplayId = result.displayId return { x: result.x, y: result.y, width: result.width, height: result.height } } From ab9954bf42778a2f5e2d438aaa6ce241c5a5b653 Mon Sep 17 00:00:00 2001 From: Khiem Nguyen Date: Thu, 16 Jul 2026 14:45:27 +0700 Subject: [PATCH 4/6] use screen number labels instead of left/right direction names --- drag_debug.txt | 6 ++++++ electron/main/ipc.ts | 18 ++++++++---------- electron/main/tray.ts | 24 +++++++++++------------- 3 files changed, 25 insertions(+), 23 deletions(-) diff --git a/drag_debug.txt b/drag_debug.txt index 4ffe3d4..bffc5a2 100644 --- a/drag_debug.txt +++ b/drag_debug.txt @@ -3469,3 +3469,9 @@ [2026-07-16T07:41:04.268Z] createFileStackDragIcon resvg result: isEmpty=false, size={"width":96,"height":96} [2026-07-16T07:41:04.268Z] createFileStackDragIcon calling Resvg for count=1 [2026-07-16T07:41:05.123Z] createFileStackDragIcon resvg result: isEmpty=false, size={"width":96,"height":96} +[2026-07-16T07:43:54.491Z] createFileStackDragIcon calling Resvg for count=1 +[2026-07-16T07:43:55.274Z] createFileStackDragIcon resvg result: isEmpty=false, size={"width":96,"height":96} +[2026-07-16T07:43:55.274Z] createFileStackDragIcon calling Resvg for count=1 +[2026-07-16T07:43:56.022Z] createFileStackDragIcon resvg result: isEmpty=false, size={"width":96,"height":96} +[2026-07-16T07:43:56.022Z] createFileStackDragIcon calling Resvg for count=1 +[2026-07-16T07:43:56.766Z] createFileStackDragIcon resvg result: isEmpty=false, size={"width":96,"height":96} diff --git a/electron/main/ipc.ts b/electron/main/ipc.ts index ee56dba..fc12603 100644 --- a/electron/main/ipc.ts +++ b/electron/main/ipc.ts @@ -458,16 +458,14 @@ export function registerIpc(): void { handle('displays:list', () => { const all = screen.getAllDisplays() const primary = screen.getPrimaryDisplay() - return all.map((d) => { - const isLeft = d.bounds.x < 0 - const positionLabel = d.id === primary.id ? 'Primary' : (isLeft ? 'Left' : 'Right') - return { - id: d.id, - bounds: { x: d.bounds.x, y: d.bounds.y, width: d.bounds.width, height: d.bounds.height }, - isPrimary: d.id === primary.id, - label: `Display ${positionLabel} (${d.bounds.width}×${d.bounds.height})` - } - }) + return all.map((d, i) => ({ + id: d.id, + bounds: { x: d.bounds.x, y: d.bounds.y, width: d.bounds.width, height: d.bounds.height }, + isPrimary: d.id === primary.id, + label: d.id === primary.id + ? `Screen ${i + 1} (Primary) ${d.bounds.width}×${d.bounds.height}` + : `Screen ${i + 1} ${d.bounds.width}×${d.bounds.height}` + })) }) } diff --git a/electron/main/tray.ts b/electron/main/tray.ts index af38c59..6aa69a6 100644 --- a/electron/main/tray.ts +++ b/electron/main/tray.ts @@ -60,20 +60,18 @@ export function createTray(): Tray { function buildDisplaySubmenu(currentId: number | undefined): Electron.MenuItemConstructorOptions[] { const all = screen.getAllDisplays() const primary = screen.getPrimaryDisplay() - return all.map((d) => { - const isLeft = d.bounds.x < 0 - const posLabel = d.id === primary.id ? 'Primary' : (isLeft ? 'Left' : 'Right') - return { - label: `Display ${posLabel} (${d.bounds.width}×${d.bounds.height})`, - type: 'radio' as const, - checked: currentId === d.id, - click: () => { - const next = saveSettings({ stickDisplayId: d.id }) - pushState.settings(next) - repositionWindow() - } + return all.map((d, i) => ({ + label: d.id === primary.id + ? `Screen ${i + 1} (Primary) ${d.bounds.width}×${d.bounds.height}` + : `Screen ${i + 1} ${d.bounds.width}×${d.bounds.height}`, + type: 'radio' as const, + checked: currentId === d.id, + click: () => { + const next = saveSettings({ stickDisplayId: d.id }) + pushState.settings(next) + repositionWindow() } - }) + })) } function buildStickSubmenu(current: StickPosition): Electron.MenuItemConstructorOptions[] { From ac1bb7286de5fc6cb6f16715b8fc9dc2e00462ee Mon Sep 17 00:00:00 2001 From: Khiem Nguyen Date: Thu, 16 Jul 2026 14:57:53 +0700 Subject: [PATCH 5/6] include monitor model name in display labels --- drag_debug.txt | 6 ++++++ electron/main/ipc.ts | 19 +++++++++++-------- electron/main/tray.ts | 25 ++++++++++++++----------- 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/drag_debug.txt b/drag_debug.txt index bffc5a2..2480864 100644 --- a/drag_debug.txt +++ b/drag_debug.txt @@ -3475,3 +3475,9 @@ [2026-07-16T07:43:56.022Z] createFileStackDragIcon resvg result: isEmpty=false, size={"width":96,"height":96} [2026-07-16T07:43:56.022Z] createFileStackDragIcon calling Resvg for count=1 [2026-07-16T07:43:56.766Z] createFileStackDragIcon resvg result: isEmpty=false, size={"width":96,"height":96} +[2026-07-16T07:45:44.275Z] createFileStackDragIcon calling Resvg for count=1 +[2026-07-16T07:45:44.956Z] createFileStackDragIcon resvg result: isEmpty=false, size={"width":96,"height":96} +[2026-07-16T07:45:44.957Z] createFileStackDragIcon calling Resvg for count=1 +[2026-07-16T07:45:45.606Z] createFileStackDragIcon resvg result: isEmpty=false, size={"width":96,"height":96} +[2026-07-16T07:45:45.606Z] createFileStackDragIcon calling Resvg for count=1 +[2026-07-16T07:45:46.216Z] createFileStackDragIcon resvg result: isEmpty=false, size={"width":96,"height":96} diff --git a/electron/main/ipc.ts b/electron/main/ipc.ts index fc12603..4091dc4 100644 --- a/electron/main/ipc.ts +++ b/electron/main/ipc.ts @@ -458,14 +458,17 @@ export function registerIpc(): void { handle('displays:list', () => { const all = screen.getAllDisplays() const primary = screen.getPrimaryDisplay() - return all.map((d, i) => ({ - id: d.id, - bounds: { x: d.bounds.x, y: d.bounds.y, width: d.bounds.width, height: d.bounds.height }, - isPrimary: d.id === primary.id, - label: d.id === primary.id - ? `Screen ${i + 1} (Primary) ${d.bounds.width}×${d.bounds.height}` - : `Screen ${i + 1} ${d.bounds.width}×${d.bounds.height}` - })) + return all.map((d) => { + const name = (d as any).label || (d.id === primary.id ? 'Primary' : `Display #${d.id}`) + return { + id: d.id, + bounds: { x: d.bounds.x, y: d.bounds.y, width: d.bounds.width, height: d.bounds.height }, + isPrimary: d.id === primary.id, + label: d.id === primary.id + ? `${name} (Primary) ${d.bounds.width}×${d.bounds.height}` + : `${name} ${d.bounds.width}×${d.bounds.height}` + } + }) }) } diff --git a/electron/main/tray.ts b/electron/main/tray.ts index 6aa69a6..5a55b2e 100644 --- a/electron/main/tray.ts +++ b/electron/main/tray.ts @@ -60,18 +60,21 @@ export function createTray(): Tray { function buildDisplaySubmenu(currentId: number | undefined): Electron.MenuItemConstructorOptions[] { const all = screen.getAllDisplays() const primary = screen.getPrimaryDisplay() - return all.map((d, i) => ({ - label: d.id === primary.id - ? `Screen ${i + 1} (Primary) ${d.bounds.width}×${d.bounds.height}` - : `Screen ${i + 1} ${d.bounds.width}×${d.bounds.height}`, - type: 'radio' as const, - checked: currentId === d.id, - click: () => { - const next = saveSettings({ stickDisplayId: d.id }) - pushState.settings(next) - repositionWindow() + return all.map((d) => { + const name = (d as any).label || (d.id === primary.id ? 'Primary' : `Display #${d.id}`) + return { + label: d.id === primary.id + ? `${name} (Primary) ${d.bounds.width}×${d.bounds.height}` + : `${name} ${d.bounds.width}×${d.bounds.height}`, + type: 'radio' as const, + checked: currentId === d.id, + click: () => { + const next = saveSettings({ stickDisplayId: d.id }) + pushState.settings(next) + repositionWindow() + } } - })) + }) } function buildStickSubmenu(current: StickPosition): Electron.MenuItemConstructorOptions[] { From d4ed56b310761ceec6c4ad592f645cb486295392 Mon Sep 17 00:00:00 2001 From: Khiem Nguyen Date: Thu, 16 Jul 2026 15:00:38 +0700 Subject: [PATCH 6/6] two-line display labels: name + resolution --- drag_debug.txt | 6 ++++++ electron/main/ipc.ts | 7 +++++-- shared/types.ts | 2 ++ src/components/Settings.tsx | 5 +++-- src/styles/settings.css | 17 +++++++++++++++++ 5 files changed, 33 insertions(+), 4 deletions(-) diff --git a/drag_debug.txt b/drag_debug.txt index 2480864..9530099 100644 --- a/drag_debug.txt +++ b/drag_debug.txt @@ -3481,3 +3481,9 @@ [2026-07-16T07:45:45.606Z] createFileStackDragIcon resvg result: isEmpty=false, size={"width":96,"height":96} [2026-07-16T07:45:45.606Z] createFileStackDragIcon calling Resvg for count=1 [2026-07-16T07:45:46.216Z] createFileStackDragIcon resvg result: isEmpty=false, size={"width":96,"height":96} +[2026-07-16T07:59:01.036Z] createFileStackDragIcon calling Resvg for count=1 +[2026-07-16T07:59:01.814Z] createFileStackDragIcon resvg result: isEmpty=false, size={"width":96,"height":96} +[2026-07-16T07:59:01.815Z] createFileStackDragIcon calling Resvg for count=1 +[2026-07-16T07:59:02.458Z] createFileStackDragIcon resvg result: isEmpty=false, size={"width":96,"height":96} +[2026-07-16T07:59:02.459Z] createFileStackDragIcon calling Resvg for count=1 +[2026-07-16T07:59:03.131Z] createFileStackDragIcon resvg result: isEmpty=false, size={"width":96,"height":96} diff --git a/electron/main/ipc.ts b/electron/main/ipc.ts index 4091dc4..bfd405a 100644 --- a/electron/main/ipc.ts +++ b/electron/main/ipc.ts @@ -459,14 +459,17 @@ export function registerIpc(): void { const all = screen.getAllDisplays() const primary = screen.getPrimaryDisplay() return all.map((d) => { - const name = (d as any).label || (d.id === primary.id ? 'Primary' : `Display #${d.id}`) + const rawName = (d as any).label || '' + const name = rawName || (d.id === primary.id ? 'Primary' : `Display #${d.id}`) return { id: d.id, bounds: { x: d.bounds.x, y: d.bounds.y, width: d.bounds.width, height: d.bounds.height }, isPrimary: d.id === primary.id, label: d.id === primary.id ? `${name} (Primary) ${d.bounds.width}×${d.bounds.height}` - : `${name} ${d.bounds.width}×${d.bounds.height}` + : `${name} ${d.bounds.width}×${d.bounds.height}`, + name: d.id === primary.id ? `${name} (Primary)` : name, + resolution: `${d.bounds.width}×${d.bounds.height}` } }) }) diff --git a/shared/types.ts b/shared/types.ts index 93dad60..c3cc44f 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -68,6 +68,8 @@ export interface DisplayInfo { bounds: { x: number; y: number; width: number; height: number } isPrimary: boolean label: string + name: string + resolution: string } /** diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 7ae595e..e7beb74 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -211,10 +211,11 @@ export function Settings() { {displays.map((d) => ( ))}
diff --git a/src/styles/settings.css b/src/styles/settings.css index f4d311c..6f83658 100644 --- a/src/styles/settings.css +++ b/src/styles/settings.css @@ -93,6 +93,23 @@ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4); } +.display-pill { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 6px 0; + line-height: 1.2; +} +.display-pill .pill-name { + font-size: 12px; + font-weight: 600; +} +.display-pill .pill-res { + font-size: 10px; + opacity: 0.7; +} + /* Sleek Minimalist Toggle Switch */ .setting-toggle { flex-shrink: 0;