diff --git a/aec b/aec index f00bb1f..9e11f4f 160000 --- a/aec +++ b/aec @@ -1 +1 @@ -Subproject commit f00bb1fb948053c752b916adfee19f90644a0b2f +Subproject commit 9e11f4f95707714464194bdfc9db0222ec5c6163 diff --git a/src/bridge/featureBridge.js b/src/bridge/featureBridge.js index 7df4a83..9c56a32 100644 --- a/src/bridge/featureBridge.js +++ b/src/bridge/featureBridge.js @@ -76,7 +76,8 @@ module.exports = { ipcMain.handle('ask:sendQuestionFromAsk', async (event, userPrompt) => await askService.sendMessage(userPrompt)); ipcMain.handle('ask:sendQuestionFromSummary', async (event, userPrompt) => await askService.sendMessage(userPrompt)); ipcMain.handle('ask:toggleAskButton', async () => await askService.toggleAskButton()); - + ipcMain.handle('ask:closeAskWindow', async () => await askService.closeAskWindow()); + // Listen ipcMain.handle('listen:sendMicAudio', async (event, { data, mimeType }) => await listenService.handleSendMicAudioContent(data, mimeType)); ipcMain.handle('listen:sendSystemAudio', async (event, { data, mimeType }) => { diff --git a/src/bridge/windowBridge.js b/src/bridge/windowBridge.js index c298d93..5531952 100644 --- a/src/bridge/windowBridge.js +++ b/src/bridge/windowBridge.js @@ -24,7 +24,7 @@ module.exports = { ipcMain.handle('adjust-window-height', (event, targetHeight) => windowManager.adjustWindowHeight(event.sender, targetHeight)); ipcMain.handle('toggle-all-windows-visibility', () => windowManager.toggleAllWindowsVisibility()); ipcMain.on('animation-finished', (event) => windowManager.handleAnimationFinished(event.sender)); - ipcMain.handle('ask:closeAskWindow', () => windowManager.closeAskWindow()); + // ipcMain.handle('ask:closeAskWindow', () => windowManager.closeAskWindow()); }, notifyFocusChange(win, isFocused) { diff --git a/src/features/ask/askService.js b/src/features/ask/askService.js index 13653b4..c61f465 100644 --- a/src/features/ask/askService.js +++ b/src/features/ask/askService.js @@ -2,6 +2,8 @@ const { BrowserWindow } = require('electron'); const { createStreamingLLM } = require('../common/ai/factory'); // Lazy require helper to avoid circular dependency issues const getWindowManager = () => require('../../window/windowManager'); +const internalBridge = require('../../bridge/internalBridge'); +const { EVENTS } = internalBridge; const getWindowPool = () => { try { @@ -10,8 +12,6 @@ const getWindowPool = () => { return null; } }; -const updateLayout = () => getWindowManager().updateLayout(); -const ensureAskWindowVisible = () => getWindowManager().ensureAskWindowVisible(); const sessionRepository = require('../common/repositories/session'); const askRepository = require('./repositories'); @@ -148,32 +148,47 @@ class AskService { async toggleAskButton() { const askWindow = getWindowPool()?.get('ask'); - // 답변이 있거나 스트리밍 중일 때 const hasContent = this.state.isStreaming || (this.state.currentResponse && this.state.currentResponse.length > 0); if (askWindow && askWindow.isVisible() && hasContent) { - // 창을 닫는 대신, 텍스트 입력창만 토글합니다. this.state.showTextInput = !this.state.showTextInput; - this._broadcastState(); // 변경된 상태 전파 + this._broadcastState(); } else { - // 기존의 창 보이기/숨기기 로직 if (askWindow && askWindow.isVisible()) { - askWindow.webContents.send('window-hide-animation'); + internalBridge.emit('request-window-visibility', { name: 'ask', visible: false }); this.state.isVisible = false; } else { console.log('[AskService] Showing hidden Ask window'); + internalBridge.emit('request-window-visibility', { name: 'ask', visible: true }); this.state.isVisible = true; - askWindow?.show(); - updateLayout(); - askWindow?.webContents.send('window-show-animation'); } - // 창이 다시 열릴 때를 대비해 상태를 초기화하고 전파합니다. if (this.state.isVisible) { this.state.showTextInput = true; this._broadcastState(); } } } + + async closeAskWindow () { + if (this.abortController) { + this.abortController.abort('Window closed by user'); + this.abortController = null; + } + + this.state = { + isVisible : false, + isLoading : false, + isStreaming : false, + currentQuestion: '', + currentResponse: '', + showTextInput : true, + }; + this._broadcastState(); + + internalBridge.emit('request-window-visibility', { name: 'ask', visible: false }); + + return { success: true }; + } /** @@ -195,7 +210,7 @@ class AskService { * @returns {Promise<{success: boolean, response?: string, error?: string}>} */ async sendMessage(userPrompt, conversationHistoryRaw=[]) { - ensureAskWindowVisible(); + // ensureAskWindowVisible(); if (this.abortController) { this.abortController.abort('New request received.'); diff --git a/src/features/listen/listenService.js b/src/features/listen/listenService.js index 2137d57..3f62fb7 100644 --- a/src/features/listen/listenService.js +++ b/src/features/listen/listenService.js @@ -4,6 +4,8 @@ const SummaryService = require('./summary/summaryService'); const authService = require('../common/services/authService'); const sessionRepository = require('../common/repositories/session'); const sttRepository = require('./stt/repositories'); +const internalBridge = require('../../bridge/internalBridge'); +const { EVENTS } = internalBridge; class ListenService { constructor() { @@ -60,9 +62,7 @@ class ListenService { switch (listenButtonText) { case 'Listen': console.log('[ListenService] changeSession to "Listen"'); - listenWindow.show(); - updateLayout(); - listenWindow.webContents.send('window-show-animation'); + internalBridge.emit('request-window-visibility', { name: 'listen', visible: true }); await this.initializeSession(); listenWindow.webContents.send('session-state-changed', { isActive: true }); break; @@ -75,7 +75,7 @@ class ListenService { case 'Done': console.log('[ListenService] changeSession to "Done"'); - listenWindow.webContents.send('window-hide-animation'); + internalBridge.emit('request-window-visibility', { name: 'listen', visible: false }); listenWindow.webContents.send('session-state-changed', { isActive: false }); break; diff --git a/src/preload.js b/src/preload.js index 762a382..9b2584c 100644 --- a/src/preload.js +++ b/src/preload.js @@ -256,16 +256,8 @@ contextBridge.exposeInMainWorld('api', { sendAnimationFinished: () => ipcRenderer.send('animation-finished'), // Listeners - onWindowShowAnimation: (callback) => ipcRenderer.on('window-show-animation', callback), - removeOnWindowShowAnimation: (callback) => ipcRenderer.removeListener('window-show-animation', callback), - onWindowHideAnimation: (callback) => ipcRenderer.on('window-hide-animation', callback), - removeOnWindowHideAnimation: (callback) => ipcRenderer.removeListener('window-hide-animation', callback), onSettingsWindowHideAnimation: (callback) => ipcRenderer.on('settings-window-hide-animation', callback), - removeOnSettingsWindowHideAnimation: (callback) => ipcRenderer.removeListener('settings-window-hide-animation', callback), - onListenWindowMoveToCenter: (callback) => ipcRenderer.on('listen-window-move-to-center', callback), - removeOnListenWindowMoveToCenter: (callback) => ipcRenderer.removeListener('listen-window-move-to-center', callback), - onListenWindowMoveToLeft: (callback) => ipcRenderer.on('listen-window-move-to-left', callback), - removeOnListenWindowMoveToLeft: (callback) => ipcRenderer.removeListener('listen-window-move-to-left', callback) + removeOnSettingsWindowHideAnimation: (callback) => ipcRenderer.removeListener('settings-window-hide-animation', callback), }, // src/ui/listen/audioCore/listenCapture.js diff --git a/src/ui/app/content.html b/src/ui/app/content.html index 8757011..af65de0 100644 --- a/src/ui/app/content.html +++ b/src/ui/app/content.html @@ -99,62 +99,6 @@ transition: transform 0.25s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.25s ease-out; } - .window-sliding-down { - animation: slideDownFromHeader 0.12s cubic-bezier(0.23, 1, 0.32, 1) forwards; - will-change: transform, opacity; - transform-style: preserve-3d; - } - - .window-sliding-up { - animation: slideUpToHeader 0.10s cubic-bezier(0.55, 0.085, 0.68, 0.53) forwards; - will-change: transform, opacity; - transform-style: preserve-3d; - } - - .window-hidden { - opacity: 0; - transform: translate3d(0, -18px, 0) scale3d(0.96, 0.96, 1); - pointer-events: none; - will-change: auto; - contain: layout style paint; - } - - .listen-window-moving { - transition: transform 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94); - will-change: transform; - } - - .listen-window-center { - transform: translate3d(0, 0, 0); - } - - .listen-window-left { - transform: translate3d(-110px, 0, 0); - } - - @keyframes slideDownFromHeader { - 0% { - opacity: 0; - transform: translate3d(0, -18px, 0) scale3d(0.96, 0.96, 1); - } - 25% { - opacity: 0.4; - transform: translate3d(0, -10px, 0) scale3d(0.98, 0.98, 1); - } - 50% { - opacity: 0.7; - transform: translate3d(0, -3px, 0) scale3d(1.01, 1.01, 1); - } - 75% { - opacity: 0.9; - transform: translate3d(0, -0.5px, 0) scale3d(1.005, 1.005, 1); - } - 100% { - opacity: 1; - transform: translate3d(0, 0, 0) scale3d(1, 1, 1); - } - } - .settings-window-show { animation: settingsPopFromButton 0.12s cubic-bezier(0.23, 1, 0.32, 1) forwards; transform-origin: 85% 0%; @@ -206,25 +150,6 @@ transform: translate3d(0, -8px, 0) scale3d(0.5, 0.5, 1); } } - - @keyframes slideUpToHeader { - 0% { - opacity: 1; - transform: translate3d(0, 0, 0) scale3d(1, 1, 1); - } - 30% { - opacity: 0.6; - transform: translate3d(0, -6px, 0) scale3d(0.98, 0.98, 1); - } - 65% { - opacity: 0.2; - transform: translate3d(0, -14px, 0) scale3d(0.95, 0.95, 1); - } - 100% { - opacity: 0; - transform: translate3d(0, -18px, 0) scale3d(0.93, 0.93, 1); - } - } @@ -239,62 +164,24 @@ const app = document.getElementById('pickle-glass'); if (window.api) { - // --- REFACTORED: Event-driven animation handling --- app.addEventListener('animationend', (event) => { - // 숨김 애니메이션이 끝나면 main 프로세스에 알려 창을 실제로 숨깁니다. - if (event.animationName === 'slideUpToHeader' || event.animationName === 'settingsCollapseToButton') { - console.log(`Animation finished: ${event.animationName}. Notifying main process.`); + if (event.animationName === 'settingsCollapseToButton') { + console.log('Settings hide animation finished. Notifying main process.'); window.api.content.sendAnimationFinished(); - - // 완료 후 애니메이션 클래스 정리 - app.classList.remove('window-sliding-up', 'settings-window-hide'); - app.classList.add('window-hidden'); - } else if (event.animationName === 'slideDownFromHeader' || event.animationName === 'settingsPopFromButton') { - // 보이기 애니메이션 완료 후 클래스 정리 - app.classList.remove('window-sliding-down', 'settings-window-show'); + + app.classList.remove('settings-window-hide'); + app.classList.add('hidden'); + } + else if (event.animationName === 'settingsPopFromButton') { + app.classList.remove('settings-window-show'); } }); - window.api.content.onWindowShowAnimation(() => { - console.log('Starting window show animation'); - app.classList.remove('window-hidden', 'window-sliding-up', 'settings-window-hide'); - app.classList.add('window-sliding-down'); - }); - - window.api.content.onWindowHideAnimation(() => { - console.log('Starting window hide animation'); - app.classList.remove('window-sliding-down', 'settings-window-show'); - app.classList.add('window-sliding-up'); - }); - window.api.content.onSettingsWindowHideAnimation(() => { console.log('Starting settings window hide animation'); - app.classList.remove('window-sliding-down', 'settings-window-show'); + app.classList.remove('settings-window-show'); app.classList.add('settings-window-hide'); }); - - // --- UNCHANGED: Existing logic for listen window movement --- - window.api.content.onListenWindowMoveToCenter(() => { - console.log('Moving listen window to center'); - app.classList.add('listen-window-moving'); - app.classList.remove('listen-window-left'); - app.classList.add('listen-window-center'); - - setTimeout(() => { - app.classList.remove('listen-window-moving'); - }, 350); - }); - - window.api.content.onListenWindowMoveToLeft(() => { - console.log('Moving listen window to left'); - app.classList.add('listen-window-moving'); - app.classList.remove('listen-window-center'); - app.classList.add('listen-window-left'); - - setTimeout(() => { - app.classList.remove('listen-window-moving'); - }, 350); - }); } }); diff --git a/src/ui/ask/AskView.js b/src/ui/ask/AskView.js index 964c46b..1b0b862 100644 --- a/src/ui/ask/AskView.js +++ b/src/ui/ask/AskView.js @@ -878,7 +878,7 @@ export class AskView extends LitElement { } handleCloseAskWindow() { - this.clearResponseContent(); + // this.clearResponseContent(); window.api.askView.closeAskWindow(); } diff --git a/src/window/smoothMovementManager.js b/src/window/smoothMovementManager.js index ba9eec0..c81f0a4 100644 --- a/src/window/smoothMovementManager.js +++ b/src/window/smoothMovementManager.js @@ -170,6 +170,56 @@ class SmoothMovementManager { this.animateToPosition(header, clampedX, clampedY, windowSize); } + /** + * [수정됨] 창을 목표 지점으로 부드럽게 애니메이션합니다. + * 완료 콜백 및 기타 옵션을 지원합니다. + * @param {BrowserWindow} win - 애니메이션할 창 + * @param {number} targetX - 목표 X 좌표 + * @param {number} targetY - 목표 Y 좌표 + * @param {object} [options] - 추가 옵션 + * @param {object} [options.sizeOverride] - 애니메이션 중 사용할 창 크기 + * @param {function} [options.onComplete] - 애니메이션 완료 후 실행할 콜백 + * @param {number} [options.duration] - 애니메이션 지속 시간 (ms) + */ + animateWindow(win, targetX, targetY, options = {}) { + if (!this._isWindowValid(win)) { + if (options.onComplete) options.onComplete(); + return; + } + + const { sizeOverride, onComplete, duration: animDuration } = options; + const start = win.getBounds(); + const startTime = Date.now(); + const duration = animDuration || this.animationDuration; + const { width, height } = sizeOverride || start; + + const step = () => { + // 애니메이션 중간에 창이 파괴될 경우 콜백을 실행하고 중단 + if (!this._isWindowValid(win)) { + if (onComplete) onComplete(); + return; + } + + const p = Math.min((Date.now() - startTime) / duration, 1); + const eased = 1 - Math.pow(1 - p, 3); // ease-out-cubic + const x = start.x + (targetX - start.x) * eased; + const y = start.y + (targetY - start.y) * eased; + + win.setBounds({ x: Math.round(x), y: Math.round(y), width, height }); + + if (p < 1) { + setTimeout(step, 8); // requestAnimationFrame 대신 setTimeout으로 간결하게 처리 + } else { + // 애니메이션 종료 + this.updateLayout(); // 레이아웃 재정렬 + if (onComplete) { + onComplete(); // 완료 콜백 실행 + } + } + }; + step(); + } + animateToPosition(header, targetX, targetY, windowSize) { if (!this._isWindowValid(header)) return; diff --git a/src/window/windowLayoutManager.js b/src/window/windowLayoutManager.js index 7893822..0ce34dc 100644 --- a/src/window/windowLayoutManager.js +++ b/src/window/windowLayoutManager.js @@ -1,9 +1,9 @@ const { screen } = require('electron'); /** - * 주어진 창이 현재 어느 디스플레이에 속해 있는지 반환합니다. - * @param {BrowserWindow} window - 확인할 창 객체 - * @returns {Display} Electron의 Display 객체 + * + * @param {BrowserWindow} window + * @returns {Display} */ function getCurrentDisplay(window) { if (!window || window.isDestroyed()) return screen.getPrimaryDisplay(); @@ -27,10 +27,6 @@ class WindowLayoutManager { this.PADDING = 80; } - /** - * 모든 창의 레이아웃 업데이트를 요청합니다. - * 중복 실행을 방지하기 위해 isUpdating 플래그를 사용합니다. - */ updateLayout() { if (this.isUpdating) return; this.isUpdating = true; @@ -42,8 +38,112 @@ class WindowLayoutManager { } /** - * 헤더 창을 기준으로 모든 기능 창들의 위치를 계산하고 배치합니다. + * + * @param {object} [visibilityOverride] - { listen: true, ask: true } + * @returns {{listen: {x:number, y:number}|null, ask: {x:number, y:number}|null}} */ + getTargetBoundsForFeatureWindows(visibilityOverride = {}) { + const header = this.windowPool.get('header'); + if (!header?.getBounds) return {}; + + const headerBounds = header.getBounds(); + const display = getCurrentDisplay(header); + const { width: screenWidth, height: screenHeight } = display.workAreaSize; + const { x: workAreaX, y: workAreaY } = display.workArea; + + const ask = this.windowPool.get('ask'); + const listen = this.windowPool.get('listen'); + + const askVis = visibilityOverride.ask !== undefined ? + visibilityOverride.ask : + (ask && ask.isVisible() && !ask.isDestroyed()); + const listenVis = visibilityOverride.listen !== undefined ? + visibilityOverride.listen : + (listen && listen.isVisible() && !listen.isDestroyed()); + + if (!askVis && !listenVis) return {}; + + const PAD = 8; + const headerCenterXRel = headerBounds.x - workAreaX + headerBounds.width / 2; + + const relativeX = headerCenterXRel / screenWidth; + const relativeY = (headerBounds.y - workAreaY) / screenHeight; + const strategy = this.determineLayoutStrategy(headerBounds, screenWidth, screenHeight, relativeX, relativeY, workAreaX, workAreaY); + + const askB = ask ? ask.getBounds() : null; + const listenB = listen ? listen.getBounds() : null; + + const result = { listen: null, ask: null }; + + if (askVis && listenVis) { + let askXRel = headerCenterXRel - (askB.width / 2); + let listenXRel = askXRel - listenB.width - PAD; + + if (listenXRel < PAD) { + listenXRel = PAD; + askXRel = listenXRel + listenB.width + PAD; + } + if (askXRel + askB.width > screenWidth - PAD) { + askXRel = screenWidth - PAD - askB.width; + listenXRel = askXRel - listenB.width - PAD; + } + + const yPos = (strategy.primary === 'above') ? + (headerBounds.y - workAreaY) - Math.max(askB.height, listenB.height) - PAD : + (headerBounds.y - workAreaY) + headerBounds.height + PAD; + const yAbs = yPos + workAreaY; + + result.listen = { x: Math.round(listenXRel + workAreaX), y: Math.round(yAbs) }; + result.ask = { x: Math.round(askXRel + workAreaX), y: Math.round(yAbs) }; + + } else { + const winB = askVis ? askB : listenB; + let xRel = headerCenterXRel - winB.width / 2; + + let yPos = (strategy.primary === 'above') ? + (headerBounds.y - workAreaY) - winB.height - PAD : + (headerBounds.y - workAreaY) + headerBounds.height + PAD; + + xRel = Math.max(PAD, Math.min(screenWidth - winB.width - PAD, xRel)); + + const abs = { x: Math.round(xRel + workAreaX), y: Math.round(yPos + workAreaY) }; + if (askVis) result.ask = abs; + if (listenVis) result.listen = abs; + } + return result; + } + + /** + * + * @returns {{listen: {x:number, y:number}}} + */ + getTargetBoundsForListenNextToAsk() { + const ask = this.windowPool.get('ask'); + const listen = this.windowPool.get('listen'); + const header = this.windowPool.get('header'); + + if (!ask || !listen || !header || !ask.isVisible() || ask.isDestroyed() || listen.isDestroyed()) { + return {}; + } + + const askB = ask.getBounds(); + const listenB = listen.getBounds(); + const PAD = 8; + + const listenX = askB.x - listenB.width - PAD; + const listenY = askB.y; + + const display = getCurrentDisplay(header); + const { x: workAreaX } = display.workArea; + + return { + listen: { + x: Math.max(workAreaX + PAD, listenX), + y: listenY + } + }; + } + positionWindows() { const header = this.windowPool.get('header'); if (!header?.getBounds) return; @@ -59,21 +159,24 @@ class WindowLayoutManager { const relativeX = headerCenterX / screenWidth; const relativeY = headerCenterY / screenHeight; - const strategy = this.determineLayoutStrategy(headerBounds, screenWidth, screenHeight, relativeX, relativeY); + const strategy = this.determineLayoutStrategy(headerBounds, screenWidth, screenHeight, relativeX, relativeY, workAreaX, workAreaY); this.positionFeatureWindows(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY); this.positionSettingsWindow(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY); } /** - * 헤더 창의 위치에 따라 기능 창들을 배치할 최적의 전략을 결정합니다. - * @returns {{name: string, primary: string, secondary: string}} 레이아웃 전략 + * + * @returns {{name: string, primary: string, secondary: string}} */ - determineLayoutStrategy(headerBounds, screenWidth, screenHeight, relativeX, relativeY) { - const spaceBelow = screenHeight - (headerBounds.y + headerBounds.height); - const spaceAbove = headerBounds.y; - const spaceLeft = headerBounds.x; - const spaceRight = screenWidth - (headerBounds.x + headerBounds.width); + determineLayoutStrategy(headerBounds, screenWidth, screenHeight, relativeX, relativeY, workAreaX, workAreaY) { + const headerRelX = headerBounds.x - workAreaX; + const headerRelY = headerBounds.y - workAreaY; + + const spaceBelow = screenHeight - (headerRelY + headerBounds.height); + const spaceAbove = headerRelY; + const spaceLeft = headerRelX; + const spaceRight = screenWidth - (headerRelX + headerBounds.width); if (spaceBelow >= 400) { return { name: 'below', primary: 'below', secondary: relativeX < 0.5 ? 'right' : 'left' }; @@ -88,9 +191,7 @@ class WindowLayoutManager { } } - /** - * 'ask'와 'listen' 창의 위치를 조정합니다. - */ + positionFeatureWindows(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY) { const ask = this.windowPool.get('ask'); const listen = this.windowPool.get('listen'); @@ -105,10 +206,8 @@ class WindowLayoutManager { let listenBounds = listenVisible ? listen.getBounds() : null; if (askVisible && listenVisible) { - const combinedWidth = listenBounds.width + PAD + askBounds.width; - let groupStartXRel = headerCenterXRel - combinedWidth / 2; - let listenXRel = groupStartXRel; - let askXRel = groupStartXRel + listenBounds.width + PAD; + let askXRel = headerCenterXRel - (askBounds.width / 2); + let listenXRel = askXRel - listenBounds.width - PAD; if (listenXRel < PAD) { listenXRel = PAD; @@ -119,30 +218,28 @@ class WindowLayoutManager { listenXRel = askXRel - listenBounds.width - PAD; } - let yRel = (strategy.primary === 'above') - ? headerBounds.y - workAreaY - Math.max(askBounds.height, listenBounds.height) - PAD - : headerBounds.y - workAreaY + headerBounds.height + PAD; + const yPos = (strategy.primary === 'above') + ? (headerBounds.y - workAreaY) - Math.max(askBounds.height, listenBounds.height) - PAD + : (headerBounds.y - workAreaY) + headerBounds.height + PAD; + const yAbs = yPos + workAreaY; - listen.setBounds({ x: Math.round(listenXRel + workAreaX), y: Math.round(yRel + workAreaY), width: listenBounds.width, height: listenBounds.height }); - ask.setBounds({ x: Math.round(askXRel + workAreaX), y: Math.round(yRel + workAreaY), width: askBounds.width, height: askBounds.height }); + listen.setBounds({ x: Math.round(listenXRel + workAreaX), y: Math.round(yAbs), width: listenBounds.width, height: listenBounds.height }); + ask.setBounds({ x: Math.round(askXRel + workAreaX), y: Math.round(yAbs), width: askBounds.width, height: askBounds.height }); } else { const win = askVisible ? ask : listen; const winBounds = askVisible ? askBounds : listenBounds; let xRel = headerCenterXRel - winBounds.width / 2; - let yRel = (strategy.primary === 'above') - ? headerBounds.y - workAreaY - winBounds.height - PAD - : headerBounds.y - workAreaY + headerBounds.height + PAD; + let yPos = (strategy.primary === 'above') + ? (headerBounds.y - workAreaY) - winBounds.height - PAD + : (headerBounds.y - workAreaY) + headerBounds.height + PAD; xRel = Math.max(PAD, Math.min(screenWidth - winBounds.width - PAD, xRel)); - yRel = Math.max(PAD, Math.min(screenHeight - winBounds.height - PAD, yRel)); + const yAbs = yPos + workAreaY; - win.setBounds({ x: Math.round(xRel + workAreaX), y: Math.round(yRel + workAreaY), width: winBounds.width, height: winBounds.height }); + win.setBounds({ x: Math.round(xRel + workAreaX), y: Math.round(yAbs), width: winBounds.width, height: winBounds.height }); } } - /** - * 'settings' 창의 위치를 조정합니다. - */ positionSettingsWindow(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY) { const settings = this.windowPool.get('settings'); if (!settings?.getBounds || !settings.isVisible()) return; @@ -198,10 +295,9 @@ class WindowLayoutManager { } /** - * 두 사각형 영역이 겹치는지 확인합니다. * @param {Rectangle} bounds1 * @param {Rectangle} bounds2 - * @returns {boolean} 겹침 여부 + * @returns {boolean} */ boundsOverlap(bounds1, bounds2) { const margin = 10; diff --git a/src/window/windowManager.js b/src/window/windowManager.js index eacf4da..634a83e 100644 --- a/src/window/windowManager.js +++ b/src/window/windowManager.js @@ -5,6 +5,7 @@ const path = require('node:path'); const os = require('os'); const shortcutsService = require('../features/shortcuts/shortcutsService'); const internalBridge = require('../bridge/internalBridge'); +const { EVENTS } = internalBridge; const permissionRepository = require('../features/common/repositories/permission'); /* ────────────────[ GLASS BYPASS ]─────────────── */ @@ -54,6 +55,145 @@ function updateLayout() { let movementManager = null; + + +function setupAnimationController(windowPool, layoutManager, movementManager) { + internalBridge.on('request-window-visibility', ({ name, visible }) => { + handleWindowVisibilityRequest(windowPool, layoutManager, movementManager, name, visible); + }); +} + + +/** + * + * @param {Map} windowPool + * @param {WindowLayoutManager} layoutManager + * @param {SmoothMovementManager} movementManager + * @param {'listen' | 'ask'} name + * @param {boolean} shouldBeVisible + */ +async function handleWindowVisibilityRequest(windowPool, layoutManager, movementManager, name, shouldBeVisible) { + console.log(`[WindowManager] Request: set '${name}' visibility to ${shouldBeVisible}`); + const win = windowPool.get(name); + + if (!win || win.isDestroyed()) { + console.warn(`[WindowManager] Window '${name}' not found or destroyed.`); + return; + } + + const isCurrentlyVisible = win.isVisible(); + if (isCurrentlyVisible === shouldBeVisible) { + console.log(`[WindowManager] Window '${name}' is already in the desired state.`); + return; + } + + const otherName = name === 'listen' ? 'ask' : 'listen'; + const otherWin = windowPool.get(otherName); + const isOtherWinVisible = otherWin && !otherWin.isDestroyed() && otherWin.isVisible(); + + const ANIM_OFFSET_X = 100; + const ANIM_OFFSET_Y = 100; + + if (shouldBeVisible) { + win.setOpacity(0); + + if (name === 'listen') { + if (!isOtherWinVisible) { + const targets = layoutManager.getTargetBoundsForFeatureWindows({ listen: true, ask: false }); + if (!targets.listen) return; + + const startPos = { x: targets.listen.x - ANIM_OFFSET_X, y: targets.listen.y }; + win.setBounds(startPos); + win.show(); + win.setOpacity(1); + movementManager.animateWindow(win, targets.listen.x, targets.listen.y); + + } else { + const targets = layoutManager.getTargetBoundsForListenNextToAsk(); + if (!targets.listen) return; + + const startPos = { x: targets.listen.x - ANIM_OFFSET_X, y: targets.listen.y }; + win.setBounds(startPos); + win.show(); + win.setOpacity(1); + movementManager.animateWindow(win, targets.listen.x, targets.listen.y); + } + } else if (name === 'ask') { + if (!isOtherWinVisible) { + const targets = layoutManager.getTargetBoundsForFeatureWindows({ listen: false, ask: true }); + if (!targets.ask) return; + + const startPos = { x: targets.ask.x, y: targets.ask.y - ANIM_OFFSET_Y }; + win.setBounds(startPos); + win.show(); + win.setOpacity(1); + movementManager.animateWindow(win, targets.ask.x, targets.ask.y); + + } else { + const targets = layoutManager.getTargetBoundsForFeatureWindows({ listen: true, ask: true }); + if (!targets.listen || !targets.ask) return; + + const startAskPos = { x: targets.ask.x, y: targets.ask.y - ANIM_OFFSET_Y }; + win.setBounds(startAskPos); + + win.show(); + win.setOpacity(1); + movementManager.animateWindow(otherWin, targets.listen.x, targets.listen.y); + movementManager.animateWindow(win, targets.ask.x, targets.ask.y); + } + } + } else { + const currentBounds = win.getBounds(); + + if (name === 'listen') { + if (!isOtherWinVisible) { + const targetX = currentBounds.x - ANIM_OFFSET_X; + movementManager.animateWindow(win, targetX, currentBounds.y, { + onComplete: () => win.hide() + }); + } else { + const targetX = currentBounds.x - currentBounds.width; + movementManager.animateWindow(win, targetX, currentBounds.y, { + onComplete: () => win.hide() + }); + } + } else if (name === 'ask') { + if (!isOtherWinVisible) { + const targetY = currentBounds.y - ANIM_OFFSET_Y; + movementManager.animateWindow(win, currentBounds.x, targetY, { + onComplete: () => win.hide() + }); + } else { + const targetAskY = currentBounds.y - ANIM_OFFSET_Y; + movementManager.animateWindow(win, currentBounds.x, targetAskY, { + onComplete: () => win.hide() + }); + + const targets = layoutManager.getTargetBoundsForFeatureWindows({ listen: true, ask: false }); + if (targets.listen) { + movementManager.animateWindow(otherWin, targets.listen.x, targets.listen.y); + } + } + } + } +} + +const handleAnimationFinished = (sender) => { + const win = BrowserWindow.fromWebContents(sender); + if (win && !win.isDestroyed()) { + console.log(`[WindowManager] Hiding window after animation.`); + win.hide(); + const listenWin = windowPool.get('listen'); + const askWin = windowPool.get('ask'); + + if (win === askWin && listenWin && !listenWin.isDestroyed() && listenWin.isVisible()) { + console.log('[WindowManager] Ask window hidden, moving listen window to center.'); + listenWin.webContents.send('listen-window-move-to-center'); + updateLayout(); + } + } +}; + const setContentProtection = (status) => { isContentProtectionOn = status; console.log(`[Protection] Content protection toggled to: ${isContentProtectionOn}`); @@ -531,6 +671,7 @@ function createWindows() { }); setupIpcHandlers(movementManager); + setupAnimationController(windowPool, layoutManager, movementManager); if (currentHeaderState === 'main') { createFeatureWindows(header, ['listen', 'ask', 'settings', 'shortcut-settings']); @@ -689,45 +830,6 @@ const adjustWindowHeight = (sender, targetHeight) => { } }; -const handleAnimationFinished = (sender) => { - const win = BrowserWindow.fromWebContents(sender); - if (win && !win.isDestroyed()) { - console.log(`[WindowManager] Hiding window after animation.`); - win.hide(); - } -}; - -const closeAskWindow = () => { - const askWindow = windowPool.get('ask'); - if (askWindow) { - askWindow.webContents.send('window-hide-animation'); - } -}; - -async function ensureAskWindowVisible() { - if (currentHeaderState !== 'main') { - console.log('[WindowManager] Not in main state, skipping ensureAskWindowVisible'); - return; - } - - let askWindow = windowPool.get('ask'); - - if (!askWindow || askWindow.isDestroyed()) { - console.log('[WindowManager] Ask window not found, creating new one'); - createFeatureWindows(windowPool.get('header'), 'ask'); - askWindow = windowPool.get('ask'); - } - - if (!askWindow.isVisible()) { - console.log('[WindowManager] Showing hidden Ask window'); - askWindow.show(); - updateLayout(); - askWindow.webContents.send('window-show-animation'); - } -} - - -//////// after_modelStateService //////// const closeWindow = (windowName) => { const win = windowPool.get(windowName); @@ -759,6 +861,4 @@ module.exports = { moveHeaderTo, adjustWindowHeight, handleAnimationFinished, - closeAskWindow, - ensureAskWindowVisible, }; \ No newline at end of file