fix window animation

This commit is contained in:
sanio 2025-07-14 00:23:46 +09:00
parent 7d33ea9ca8
commit 8da13dcb27
11 changed files with 371 additions and 230 deletions

2
aec

@ -1 +1 @@
Subproject commit f00bb1fb948053c752b916adfee19f90644a0b2f Subproject commit 9e11f4f95707714464194bdfc9db0222ec5c6163

View File

@ -76,7 +76,8 @@ module.exports = {
ipcMain.handle('ask:sendQuestionFromAsk', async (event, userPrompt) => await askService.sendMessage(userPrompt)); 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:sendQuestionFromSummary', async (event, userPrompt) => await askService.sendMessage(userPrompt));
ipcMain.handle('ask:toggleAskButton', async () => await askService.toggleAskButton()); ipcMain.handle('ask:toggleAskButton', async () => await askService.toggleAskButton());
ipcMain.handle('ask:closeAskWindow', async () => await askService.closeAskWindow());
// Listen // Listen
ipcMain.handle('listen:sendMicAudio', async (event, { data, mimeType }) => await listenService.handleSendMicAudioContent(data, mimeType)); ipcMain.handle('listen:sendMicAudio', async (event, { data, mimeType }) => await listenService.handleSendMicAudioContent(data, mimeType));
ipcMain.handle('listen:sendSystemAudio', async (event, { data, mimeType }) => { ipcMain.handle('listen:sendSystemAudio', async (event, { data, mimeType }) => {

View File

@ -24,7 +24,7 @@ module.exports = {
ipcMain.handle('adjust-window-height', (event, targetHeight) => windowManager.adjustWindowHeight(event.sender, targetHeight)); ipcMain.handle('adjust-window-height', (event, targetHeight) => windowManager.adjustWindowHeight(event.sender, targetHeight));
ipcMain.handle('toggle-all-windows-visibility', () => windowManager.toggleAllWindowsVisibility()); ipcMain.handle('toggle-all-windows-visibility', () => windowManager.toggleAllWindowsVisibility());
ipcMain.on('animation-finished', (event) => windowManager.handleAnimationFinished(event.sender)); ipcMain.on('animation-finished', (event) => windowManager.handleAnimationFinished(event.sender));
ipcMain.handle('ask:closeAskWindow', () => windowManager.closeAskWindow()); // ipcMain.handle('ask:closeAskWindow', () => windowManager.closeAskWindow());
}, },
notifyFocusChange(win, isFocused) { notifyFocusChange(win, isFocused) {

View File

@ -2,6 +2,8 @@ const { BrowserWindow } = require('electron');
const { createStreamingLLM } = require('../common/ai/factory'); const { createStreamingLLM } = require('../common/ai/factory');
// Lazy require helper to avoid circular dependency issues // Lazy require helper to avoid circular dependency issues
const getWindowManager = () => require('../../window/windowManager'); const getWindowManager = () => require('../../window/windowManager');
const internalBridge = require('../../bridge/internalBridge');
const { EVENTS } = internalBridge;
const getWindowPool = () => { const getWindowPool = () => {
try { try {
@ -10,8 +12,6 @@ const getWindowPool = () => {
return null; return null;
} }
}; };
const updateLayout = () => getWindowManager().updateLayout();
const ensureAskWindowVisible = () => getWindowManager().ensureAskWindowVisible();
const sessionRepository = require('../common/repositories/session'); const sessionRepository = require('../common/repositories/session');
const askRepository = require('./repositories'); const askRepository = require('./repositories');
@ -148,32 +148,47 @@ class AskService {
async toggleAskButton() { async toggleAskButton() {
const askWindow = getWindowPool()?.get('ask'); const askWindow = getWindowPool()?.get('ask');
// 답변이 있거나 스트리밍 중일 때
const hasContent = this.state.isStreaming || (this.state.currentResponse && this.state.currentResponse.length > 0); const hasContent = this.state.isStreaming || (this.state.currentResponse && this.state.currentResponse.length > 0);
if (askWindow && askWindow.isVisible() && hasContent) { if (askWindow && askWindow.isVisible() && hasContent) {
// 창을 닫는 대신, 텍스트 입력창만 토글합니다.
this.state.showTextInput = !this.state.showTextInput; this.state.showTextInput = !this.state.showTextInput;
this._broadcastState(); // 변경된 상태 전파 this._broadcastState();
} else { } else {
// 기존의 창 보이기/숨기기 로직
if (askWindow && askWindow.isVisible()) { if (askWindow && askWindow.isVisible()) {
askWindow.webContents.send('window-hide-animation'); internalBridge.emit('request-window-visibility', { name: 'ask', visible: false });
this.state.isVisible = false; this.state.isVisible = false;
} else { } else {
console.log('[AskService] Showing hidden Ask window'); console.log('[AskService] Showing hidden Ask window');
internalBridge.emit('request-window-visibility', { name: 'ask', visible: true });
this.state.isVisible = true; this.state.isVisible = true;
askWindow?.show();
updateLayout();
askWindow?.webContents.send('window-show-animation');
} }
// 창이 다시 열릴 때를 대비해 상태를 초기화하고 전파합니다.
if (this.state.isVisible) { if (this.state.isVisible) {
this.state.showTextInput = true; this.state.showTextInput = true;
this._broadcastState(); 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}>} * @returns {Promise<{success: boolean, response?: string, error?: string}>}
*/ */
async sendMessage(userPrompt, conversationHistoryRaw=[]) { async sendMessage(userPrompt, conversationHistoryRaw=[]) {
ensureAskWindowVisible(); // ensureAskWindowVisible();
if (this.abortController) { if (this.abortController) {
this.abortController.abort('New request received.'); this.abortController.abort('New request received.');

View File

@ -4,6 +4,8 @@ const SummaryService = require('./summary/summaryService');
const authService = require('../common/services/authService'); const authService = require('../common/services/authService');
const sessionRepository = require('../common/repositories/session'); const sessionRepository = require('../common/repositories/session');
const sttRepository = require('./stt/repositories'); const sttRepository = require('./stt/repositories');
const internalBridge = require('../../bridge/internalBridge');
const { EVENTS } = internalBridge;
class ListenService { class ListenService {
constructor() { constructor() {
@ -60,9 +62,7 @@ class ListenService {
switch (listenButtonText) { switch (listenButtonText) {
case 'Listen': case 'Listen':
console.log('[ListenService] changeSession to "Listen"'); console.log('[ListenService] changeSession to "Listen"');
listenWindow.show(); internalBridge.emit('request-window-visibility', { name: 'listen', visible: true });
updateLayout();
listenWindow.webContents.send('window-show-animation');
await this.initializeSession(); await this.initializeSession();
listenWindow.webContents.send('session-state-changed', { isActive: true }); listenWindow.webContents.send('session-state-changed', { isActive: true });
break; break;
@ -75,7 +75,7 @@ class ListenService {
case 'Done': case 'Done':
console.log('[ListenService] changeSession to "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 }); listenWindow.webContents.send('session-state-changed', { isActive: false });
break; break;

View File

@ -256,16 +256,8 @@ contextBridge.exposeInMainWorld('api', {
sendAnimationFinished: () => ipcRenderer.send('animation-finished'), sendAnimationFinished: () => ipcRenderer.send('animation-finished'),
// Listeners // 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), onSettingsWindowHideAnimation: (callback) => ipcRenderer.on('settings-window-hide-animation', callback),
removeOnSettingsWindowHideAnimation: (callback) => ipcRenderer.removeListener('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)
}, },
// src/ui/listen/audioCore/listenCapture.js // src/ui/listen/audioCore/listenCapture.js

View File

@ -99,62 +99,6 @@
transition: transform 0.25s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.25s ease-out; 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 { .settings-window-show {
animation: settingsPopFromButton 0.12s cubic-bezier(0.23, 1, 0.32, 1) forwards; animation: settingsPopFromButton 0.12s cubic-bezier(0.23, 1, 0.32, 1) forwards;
transform-origin: 85% 0%; transform-origin: 85% 0%;
@ -206,25 +150,6 @@
transform: translate3d(0, -8px, 0) scale3d(0.5, 0.5, 1); 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);
}
}
</style> </style>
</head> </head>
<body> <body>
@ -239,62 +164,24 @@
const app = document.getElementById('pickle-glass'); const app = document.getElementById('pickle-glass');
if (window.api) { if (window.api) {
// --- REFACTORED: Event-driven animation handling ---
app.addEventListener('animationend', (event) => { app.addEventListener('animationend', (event) => {
// 숨김 애니메이션이 끝나면 main 프로세스에 알려 창을 실제로 숨깁니다. if (event.animationName === 'settingsCollapseToButton') {
if (event.animationName === 'slideUpToHeader' || event.animationName === 'settingsCollapseToButton') { console.log('Settings hide animation finished. Notifying main process.');
console.log(`Animation finished: ${event.animationName}. Notifying main process.`);
window.api.content.sendAnimationFinished(); window.api.content.sendAnimationFinished();
// 완료 후 애니메이션 클래스 정리 app.classList.remove('settings-window-hide');
app.classList.remove('window-sliding-up', 'settings-window-hide'); app.classList.add('hidden');
app.classList.add('window-hidden'); }
} else if (event.animationName === 'slideDownFromHeader' || event.animationName === 'settingsPopFromButton') { else if (event.animationName === 'settingsPopFromButton') {
// 보이기 애니메이션 완료 후 클래스 정리 app.classList.remove('settings-window-show');
app.classList.remove('window-sliding-down', '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(() => { window.api.content.onSettingsWindowHideAnimation(() => {
console.log('Starting settings window hide animation'); 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'); 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);
});
} }
}); });
</script> </script>

View File

@ -878,7 +878,7 @@ export class AskView extends LitElement {
} }
handleCloseAskWindow() { handleCloseAskWindow() {
this.clearResponseContent(); // this.clearResponseContent();
window.api.askView.closeAskWindow(); window.api.askView.closeAskWindow();
} }

View File

@ -170,6 +170,56 @@ class SmoothMovementManager {
this.animateToPosition(header, clampedX, clampedY, windowSize); 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) { animateToPosition(header, targetX, targetY, windowSize) {
if (!this._isWindowValid(header)) return; if (!this._isWindowValid(header)) return;

View File

@ -1,9 +1,9 @@
const { screen } = require('electron'); const { screen } = require('electron');
/** /**
* 주어진 창이 현재 어느 디스플레이에 속해 있는지 반환합니다. *
* @param {BrowserWindow} window - 확인할 객체 * @param {BrowserWindow} window
* @returns {Display} Electron의 Display 객체 * @returns {Display}
*/ */
function getCurrentDisplay(window) { function getCurrentDisplay(window) {
if (!window || window.isDestroyed()) return screen.getPrimaryDisplay(); if (!window || window.isDestroyed()) return screen.getPrimaryDisplay();
@ -27,10 +27,6 @@ class WindowLayoutManager {
this.PADDING = 80; this.PADDING = 80;
} }
/**
* 모든 창의 레이아웃 업데이트를 요청합니다.
* 중복 실행을 방지하기 위해 isUpdating 플래그를 사용합니다.
*/
updateLayout() { updateLayout() {
if (this.isUpdating) return; if (this.isUpdating) return;
this.isUpdating = true; 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() { positionWindows() {
const header = this.windowPool.get('header'); const header = this.windowPool.get('header');
if (!header?.getBounds) return; if (!header?.getBounds) return;
@ -59,21 +159,24 @@ class WindowLayoutManager {
const relativeX = headerCenterX / screenWidth; const relativeX = headerCenterX / screenWidth;
const relativeY = headerCenterY / screenHeight; 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.positionFeatureWindows(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY);
this.positionSettingsWindow(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) { determineLayoutStrategy(headerBounds, screenWidth, screenHeight, relativeX, relativeY, workAreaX, workAreaY) {
const spaceBelow = screenHeight - (headerBounds.y + headerBounds.height); const headerRelX = headerBounds.x - workAreaX;
const spaceAbove = headerBounds.y; const headerRelY = headerBounds.y - workAreaY;
const spaceLeft = headerBounds.x;
const spaceRight = screenWidth - (headerBounds.x + headerBounds.width); const spaceBelow = screenHeight - (headerRelY + headerBounds.height);
const spaceAbove = headerRelY;
const spaceLeft = headerRelX;
const spaceRight = screenWidth - (headerRelX + headerBounds.width);
if (spaceBelow >= 400) { if (spaceBelow >= 400) {
return { name: 'below', primary: 'below', secondary: relativeX < 0.5 ? 'right' : 'left' }; 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) { positionFeatureWindows(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY) {
const ask = this.windowPool.get('ask'); const ask = this.windowPool.get('ask');
const listen = this.windowPool.get('listen'); const listen = this.windowPool.get('listen');
@ -105,10 +206,8 @@ class WindowLayoutManager {
let listenBounds = listenVisible ? listen.getBounds() : null; let listenBounds = listenVisible ? listen.getBounds() : null;
if (askVisible && listenVisible) { if (askVisible && listenVisible) {
const combinedWidth = listenBounds.width + PAD + askBounds.width; let askXRel = headerCenterXRel - (askBounds.width / 2);
let groupStartXRel = headerCenterXRel - combinedWidth / 2; let listenXRel = askXRel - listenBounds.width - PAD;
let listenXRel = groupStartXRel;
let askXRel = groupStartXRel + listenBounds.width + PAD;
if (listenXRel < PAD) { if (listenXRel < PAD) {
listenXRel = PAD; listenXRel = PAD;
@ -119,30 +218,28 @@ class WindowLayoutManager {
listenXRel = askXRel - listenBounds.width - PAD; listenXRel = askXRel - listenBounds.width - PAD;
} }
let yRel = (strategy.primary === 'above') const yPos = (strategy.primary === 'above')
? headerBounds.y - workAreaY - Math.max(askBounds.height, listenBounds.height) - PAD ? (headerBounds.y - workAreaY) - Math.max(askBounds.height, listenBounds.height) - PAD
: headerBounds.y - workAreaY + headerBounds.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 }); 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(yRel + workAreaY), width: askBounds.width, height: askBounds.height }); ask.setBounds({ x: Math.round(askXRel + workAreaX), y: Math.round(yAbs), width: askBounds.width, height: askBounds.height });
} else { } else {
const win = askVisible ? ask : listen; const win = askVisible ? ask : listen;
const winBounds = askVisible ? askBounds : listenBounds; const winBounds = askVisible ? askBounds : listenBounds;
let xRel = headerCenterXRel - winBounds.width / 2; let xRel = headerCenterXRel - winBounds.width / 2;
let yRel = (strategy.primary === 'above') let yPos = (strategy.primary === 'above')
? headerBounds.y - workAreaY - winBounds.height - PAD ? (headerBounds.y - workAreaY) - winBounds.height - PAD
: headerBounds.y - workAreaY + headerBounds.height + PAD; : (headerBounds.y - workAreaY) + headerBounds.height + PAD;
xRel = Math.max(PAD, Math.min(screenWidth - winBounds.width - PAD, xRel)); 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) { positionSettingsWindow(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY) {
const settings = this.windowPool.get('settings'); const settings = this.windowPool.get('settings');
if (!settings?.getBounds || !settings.isVisible()) return; if (!settings?.getBounds || !settings.isVisible()) return;
@ -198,10 +295,9 @@ class WindowLayoutManager {
} }
/** /**
* 사각형 영역이 겹치는지 확인합니다.
* @param {Rectangle} bounds1 * @param {Rectangle} bounds1
* @param {Rectangle} bounds2 * @param {Rectangle} bounds2
* @returns {boolean} 겹침 여부 * @returns {boolean}
*/ */
boundsOverlap(bounds1, bounds2) { boundsOverlap(bounds1, bounds2) {
const margin = 10; const margin = 10;

View File

@ -5,6 +5,7 @@ const path = require('node:path');
const os = require('os'); const os = require('os');
const shortcutsService = require('../features/shortcuts/shortcutsService'); const shortcutsService = require('../features/shortcuts/shortcutsService');
const internalBridge = require('../bridge/internalBridge'); const internalBridge = require('../bridge/internalBridge');
const { EVENTS } = internalBridge;
const permissionRepository = require('../features/common/repositories/permission'); const permissionRepository = require('../features/common/repositories/permission');
/* ────────────────[ GLASS BYPASS ]─────────────── */ /* ────────────────[ GLASS BYPASS ]─────────────── */
@ -54,6 +55,145 @@ function updateLayout() {
let movementManager = null; let movementManager = null;
function setupAnimationController(windowPool, layoutManager, movementManager) {
internalBridge.on('request-window-visibility', ({ name, visible }) => {
handleWindowVisibilityRequest(windowPool, layoutManager, movementManager, name, visible);
});
}
/**
*
* @param {Map<string, BrowserWindow>} 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) => { const setContentProtection = (status) => {
isContentProtectionOn = status; isContentProtectionOn = status;
console.log(`[Protection] Content protection toggled to: ${isContentProtectionOn}`); console.log(`[Protection] Content protection toggled to: ${isContentProtectionOn}`);
@ -531,6 +671,7 @@ function createWindows() {
}); });
setupIpcHandlers(movementManager); setupIpcHandlers(movementManager);
setupAnimationController(windowPool, layoutManager, movementManager);
if (currentHeaderState === 'main') { if (currentHeaderState === 'main') {
createFeatureWindows(header, ['listen', 'ask', 'settings', 'shortcut-settings']); 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 closeWindow = (windowName) => {
const win = windowPool.get(windowName); const win = windowPool.get(windowName);
@ -759,6 +861,4 @@ module.exports = {
moveHeaderTo, moveHeaderTo,
adjustWindowHeight, adjustWindowHeight,
handleAnimationFinished, handleAnimationFinished,
closeAskWindow,
ensureAskWindowVisible,
}; };