fix header name, modulize windowmanager, fix ui size bug

This commit is contained in:
sanio 2025-07-07 08:04:05 +09:00
parent a18e93583f
commit 12a07b8607
14 changed files with 992 additions and 1006 deletions

View File

@ -70,6 +70,7 @@
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2",
"@img/sharp-libvips-darwin-x64": "^1.1.0" "@img/sharp-libvips-darwin-x64": "^1.1.0",
"electron-liquid-glass": "^1.0.1"
} }
} }

View File

@ -248,6 +248,30 @@ export class ApiKeyHeader extends LitElement {
width: 100%; width: 100%;
text-align: left; text-align: left;
} }
:host-context(body.has-glass) .container,
:host-context(body.has-glass) .api-input,
:host-context(body.has-glass) .provider-select,
:host-context(body.has-glass) .action-button,
:host-context(body.has-glass) .close-button {
background: transparent !important;
border: none !important;
box-shadow: none !important;
filter: none !important;
backdrop-filter: none !important;
}
/* 가상 레이어·그라데이션 테두리 제거 */
:host-context(body.has-glass) .container::after,
:host-context(body.has-glass) .action-button::after {
display: none !important;
}
/* hover/active 때 다시 생기는 배경도 차단 */
:host-context(body.has-glass) .action-button:hover,
:host-context(body.has-glass) .provider-select:hover,
:host-context(body.has-glass) .close-button:hover {
background: transparent !important;
}
`; `;
constructor() { constructor() {

View File

@ -1,18 +1,18 @@
import './AppHeader.js'; import './MainHeader.js';
import './ApiKeyHeader.js'; import './ApiKeyHeader.js';
import './PermissionSetup.js'; import './PermissionHeader.js';
class HeaderTransitionManager { class HeaderTransitionManager {
constructor() { constructor() {
this.headerContainer = document.getElementById('header-container'); this.headerContainer = document.getElementById('header-container');
this.currentHeaderType = null; // 'apikey' | 'app' | 'permission' this.currentHeaderType = null; // 'apikey' | 'main' | 'permission'
this.apiKeyHeader = null; this.apiKeyHeader = null;
this.appHeader = null; this.mainHeader = null;
this.permissionSetup = null; this.permissionHeader = null;
/** /**
* only one header window is allowed * only one header window is allowed
* @param {'apikey'|'app'|'permission'} type * @param {'apikey'|'main'|'permission'} type
*/ */
this.ensureHeader = (type) => { this.ensureHeader = (type) => {
if (this.currentHeaderType === type) return; if (this.currentHeaderType === type) return;
@ -20,21 +20,21 @@ class HeaderTransitionManager {
this.headerContainer.innerHTML = ''; this.headerContainer.innerHTML = '';
this.apiKeyHeader = null; this.apiKeyHeader = null;
this.appHeader = null; this.mainHeader = null;
this.permissionSetup = null; this.permissionHeader = null;
// Create new header element // Create new header element
if (type === 'apikey') { if (type === 'apikey') {
this.apiKeyHeader = document.createElement('apikey-header'); this.apiKeyHeader = document.createElement('apikey-header');
this.headerContainer.appendChild(this.apiKeyHeader); this.headerContainer.appendChild(this.apiKeyHeader);
} else if (type === 'permission') { } else if (type === 'permission') {
this.permissionSetup = document.createElement('permission-setup'); this.permissionHeader = document.createElement('permission-setup');
this.permissionSetup.continueCallback = () => this.transitionToAppHeader(); this.permissionHeader.continueCallback = () => this.transitionToMainHeader();
this.headerContainer.appendChild(this.permissionSetup); this.headerContainer.appendChild(this.permissionHeader);
} else { } else {
this.appHeader = document.createElement('app-header'); this.mainHeader = document.createElement('main-header');
this.headerContainer.appendChild(this.appHeader); this.headerContainer.appendChild(this.mainHeader);
this.appHeader.startSlideInAnimation?.(); this.mainHeader.startSlideInAnimation?.();
} }
this.currentHeaderType = type; this.currentHeaderType = type;
@ -87,16 +87,16 @@ class HeaderTransitionManager {
const { isLoggedIn, hasApiKey } = userState; const { isLoggedIn, hasApiKey } = userState;
if (isLoggedIn) { if (isLoggedIn) {
// Firebase user: Check permissions, then show App or Permission Setup // Firebase user: Check permissions, then show Main or Permission header
const permissionResult = await this.checkPermissions(); const permissionResult = await this.checkPermissions();
if (permissionResult.success) { if (permissionResult.success) {
this.transitionToAppHeader(); this.transitionToMainHeader();
} else { } else {
this.transitionToPermissionSetup(); this.transitionToPermissionHeader();
} }
} else if (hasApiKey) { } else if (hasApiKey) {
// API Key only user: Skip permission check, go directly to App // API Key only user: Skip permission check, go directly to Main
this.transitionToAppHeader(); this.transitionToMainHeader();
} else { } else {
// No auth at all // No auth at all
await this._resizeForApiKey(); await this._resizeForApiKey();
@ -104,7 +104,7 @@ class HeaderTransitionManager {
} }
} }
async transitionToPermissionSetup() { async transitionToPermissionHeader() {
// Prevent duplicate transitions // Prevent duplicate transitions
if (this.currentHeaderType === 'permission') { if (this.currentHeaderType === 'permission') {
console.log('[HeaderController] Already showing permission setup, skipping transition'); console.log('[HeaderController] Already showing permission setup, skipping transition');
@ -123,7 +123,7 @@ class HeaderTransitionManager {
const permissionResult = await this.checkPermissions(); const permissionResult = await this.checkPermissions();
if (permissionResult.success) { if (permissionResult.success) {
// Skip permission setup if already granted // Skip permission setup if already granted
this.transitionToAppHeader(); this.transitionToMainHeader();
return; return;
} }
@ -134,24 +134,24 @@ class HeaderTransitionManager {
} }
} }
await this._resizeForPermissionSetup(); await this._resizeForPermissionHeader();
this.ensureHeader('permission'); this.ensureHeader('permission');
} }
async transitionToAppHeader(animate = true) { async transitionToMainHeader(animate = true) {
if (this.currentHeaderType === 'app') { if (this.currentHeaderType === 'main') {
return this._resizeForApp(); return this._resizeForMain();
} }
await this._resizeForApp(); await this._resizeForMain();
this.ensureHeader('app'); this.ensureHeader('main');
} }
_resizeForApp() { _resizeForMain() {
if (!window.require) return; if (!window.require) return;
return window return window
.require('electron') .require('electron')
.ipcRenderer.invoke('resize-header-window', { width: 353, height: 60 }) .ipcRenderer.invoke('resize-header-window', { width: 353, height: 47 })
.catch(() => {}); .catch(() => {});
} }
@ -163,7 +163,7 @@ class HeaderTransitionManager {
.catch(() => {}); .catch(() => {});
} }
async _resizeForPermissionSetup() { async _resizeForPermissionHeader() {
if (!window.require) return; if (!window.require) return;
return window return window
.require('electron') .require('electron')

View File

@ -1,6 +1,6 @@
import { html, css, LitElement } from '../assets/lit-core-2.7.4.min.js'; import { html, css, LitElement } from '../assets/lit-core-2.7.4.min.js';
export class AppHeader extends LitElement { export class MainHeader extends LitElement {
static properties = { static properties = {
isSessionActive: { type: Boolean, state: true }, isSessionActive: { type: Boolean, state: true },
}; };
@ -292,6 +292,58 @@ export class AppHeader extends LitElement {
width: 16px; width: 16px;
height: 16px; height: 16px;
} }
:host-context(body.has-glass) .header,
:host-context(body.has-glass) .listen-button,
:host-context(body.has-glass) .header-actions,
:host-context(body.has-glass) .settings-button {
/* 배경·블러·그림자 전부 제거 */
background: transparent !important;
filter: none !important;
box-shadow: none !important;
backdrop-filter: none !important;
}
:host-context(body.has-glass) .icon-box {
background: transparent !important;
border: none !important;
}
/* 장식용 before/after 레이어와 버튼 오버레이 비활성화 */
:host-context(body.has-glass) .header::before,
:host-context(body.has-glass) .header::after,
:host-context(body.has-glass) .listen-button::before,
:host-context(body.has-glass) .listen-button::after {
display: none !important;
}
/* hover 때 의도치 않게 생기는 배경도 차단 */
:host-context(body.has-glass) .header-actions:hover,
:host-context(body.has-glass) .settings-button:hover,
:host-context(body.has-glass) .listen-button:hover::before {
background: transparent !important;
}
:host-context(body.has-glass) * {
animation: none !important;
transition: none !important;
transform: none !important;
filter: none !important;
backdrop-filter: none !important;
box-shadow: none !important;
}
/* 2) pill 형태·아이콘 박스 둥근 모서리 평면화 (선택) */
:host-context(body.has-glass) .header,
:host-context(body.has-glass) .listen-button,
:host-context(body.has-glass) .header-actions,
:host-context(body.has-glass) .settings-button,
:host-context(body.has-glass) .icon-box {
border-radius: 0 !important;
}
:host-context(body.has-glass) {
animation: none !important;
transition: none !important;
transform: none !important;
will-change: auto !important;
}
`; `;
constructor() { constructor() {
@ -362,7 +414,7 @@ export class AppHeader extends LitElement {
toggleVisibility() { toggleVisibility() {
if (this.isAnimating) { if (this.isAnimating) {
console.log('[AppHeader] Animation already in progress, ignoring toggle'); console.log('[MainHeader] Animation already in progress, ignoring toggle');
return; return;
} }
@ -432,7 +484,7 @@ export class AppHeader extends LitElement {
} else if (this.classList.contains('sliding-in')) { } else if (this.classList.contains('sliding-in')) {
this.classList.remove('sliding-in'); this.classList.remove('sliding-in');
this.hasSlidIn = true; this.hasSlidIn = true;
console.log('[AppHeader] Slide-in animation completed'); console.log('[MainHeader] Slide-in animation completed');
} }
} }
@ -484,7 +536,7 @@ export class AppHeader extends LitElement {
if (this.wasJustDragged) return; if (this.wasJustDragged) return;
if (window.require) { if (window.require) {
const { ipcRenderer } = window.require('electron'); const { ipcRenderer } = window.require('electron');
console.log(`[AppHeader] showWindow('${name}') called at ${Date.now()}`); console.log(`[MainHeader] showWindow('${name}') called at ${Date.now()}`);
ipcRenderer.send('cancel-hide-window', name); ipcRenderer.send('cancel-hide-window', name);
@ -508,7 +560,7 @@ export class AppHeader extends LitElement {
hideWindow(name) { hideWindow(name) {
if (this.wasJustDragged) return; if (this.wasJustDragged) return;
if (window.require) { if (window.require) {
console.log(`[AppHeader] hideWindow('${name}') called at ${Date.now()}`); console.log(`[MainHeader] hideWindow('${name}') called at ${Date.now()}`);
window.require('electron').ipcRenderer.send('hide-window', name); window.require('electron').ipcRenderer.send('hide-window', name);
} }
} }
@ -590,4 +642,4 @@ export class AppHeader extends LitElement {
} }
} }
customElements.define('app-header', AppHeader); customElements.define('main-header', MainHeader);

View File

@ -1,6 +1,6 @@
import { LitElement, html, css } from '../assets/lit-core-2.7.4.min.js'; import { LitElement, html, css } from '../assets/lit-core-2.7.4.min.js';
export class PermissionSetup extends LitElement { export class PermissionHeader extends LitElement {
static styles = css` static styles = css`
:host { :host {
display: block; display: block;
@ -237,6 +237,30 @@ export class PermissionSetup extends LitElement {
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.2);
cursor: not-allowed; cursor: not-allowed;
} }
:host-context(body.has-glass) .container,
:host-context(body.has-glass) .action-button,
:host-context(body.has-glass) .continue-button,
:host-context(body.has-glass) .close-button {
background: transparent !important;
border: none !important;
box-shadow: none !important;
filter: none !important;
backdrop-filter: none !important;
}
/* Remove gradient borders / pseudo layers */
:host-context(body.has-glass) .container::after,
:host-context(body.has-glass) .action-button::after,
:host-context(body.has-glass) .continue-button::after {
display: none !important;
}
/* Prevent background reappearing on hover/active */
:host-context(body.has-glass) .action-button:hover,
:host-context(body.has-glass) .continue-button:hover,
:host-context(body.has-glass) .close-button:hover {
background: transparent !important;
}
`; `;
static properties = { static properties = {
@ -337,7 +361,7 @@ export class PermissionSetup extends LitElement {
try { try {
const permissions = await ipcRenderer.invoke('check-system-permissions'); const permissions = await ipcRenderer.invoke('check-system-permissions');
console.log('[PermissionSetup] Permission check result:', permissions); console.log('[PermissionHeader] Permission check result:', permissions);
const prevMic = this.microphoneGranted; const prevMic = this.microphoneGranted;
const prevScreen = this.screenGranted; const prevScreen = this.screenGranted;
@ -347,7 +371,7 @@ export class PermissionSetup extends LitElement {
// if permissions changed == UI update // if permissions changed == UI update
if (prevMic !== this.microphoneGranted || prevScreen !== this.screenGranted) { if (prevMic !== this.microphoneGranted || prevScreen !== this.screenGranted) {
console.log('[PermissionSetup] Permission status changed, updating UI'); console.log('[PermissionHeader] Permission status changed, updating UI');
this.requestUpdate(); this.requestUpdate();
} }
@ -355,11 +379,11 @@ export class PermissionSetup extends LitElement {
if (this.microphoneGranted === 'granted' && if (this.microphoneGranted === 'granted' &&
this.screenGranted === 'granted' && this.screenGranted === 'granted' &&
this.continueCallback) { this.continueCallback) {
console.log('[PermissionSetup] All permissions granted, proceeding automatically'); console.log('[PermissionHeader] All permissions granted, proceeding automatically');
setTimeout(() => this.handleContinue(), 500); setTimeout(() => this.handleContinue(), 500);
} }
} catch (error) { } catch (error) {
console.error('[PermissionSetup] Error checking permissions:', error); console.error('[PermissionHeader] Error checking permissions:', error);
} finally { } finally {
this.isChecking = false; this.isChecking = false;
} }
@ -368,12 +392,12 @@ export class PermissionSetup extends LitElement {
async handleMicrophoneClick() { async handleMicrophoneClick() {
if (!window.require || this.microphoneGranted === 'granted' || this.wasJustDragged) return; if (!window.require || this.microphoneGranted === 'granted' || this.wasJustDragged) return;
console.log('[PermissionSetup] Requesting microphone permission...'); console.log('[PermissionHeader] Requesting microphone permission...');
const { ipcRenderer } = window.require('electron'); const { ipcRenderer } = window.require('electron');
try { try {
const result = await ipcRenderer.invoke('check-system-permissions'); const result = await ipcRenderer.invoke('check-system-permissions');
console.log('[PermissionSetup] Microphone permission result:', result); console.log('[PermissionHeader] Microphone permission result:', result);
if (result.microphone === 'granted') { if (result.microphone === 'granted') {
this.microphoneGranted = 'granted'; this.microphoneGranted = 'granted';
@ -394,19 +418,19 @@ export class PermissionSetup extends LitElement {
// Check permissions again after a delay // Check permissions again after a delay
// setTimeout(() => this.checkPermissions(), 1000); // setTimeout(() => this.checkPermissions(), 1000);
} catch (error) { } catch (error) {
console.error('[PermissionSetup] Error requesting microphone permission:', error); console.error('[PermissionHeader] Error requesting microphone permission:', error);
} }
} }
async handleScreenClick() { async handleScreenClick() {
if (!window.require || this.screenGranted === 'granted' || this.wasJustDragged) return; if (!window.require || this.screenGranted === 'granted' || this.wasJustDragged) return;
console.log('[PermissionSetup] Checking screen recording permission...'); console.log('[PermissionHeader] Checking screen recording permission...');
const { ipcRenderer } = window.require('electron'); const { ipcRenderer } = window.require('electron');
try { try {
const permissions = await ipcRenderer.invoke('check-system-permissions'); const permissions = await ipcRenderer.invoke('check-system-permissions');
console.log('[PermissionSetup] Screen permission check result:', permissions); console.log('[PermissionHeader] Screen permission check result:', permissions);
if (permissions.screen === 'granted') { if (permissions.screen === 'granted') {
this.screenGranted = 'granted'; this.screenGranted = 'granted';
@ -414,7 +438,7 @@ export class PermissionSetup extends LitElement {
return; return;
} }
if (permissions.screen === 'not-determined' || permissions.screen === 'denied' || permissions.screen === 'unknown' || permissions.screen === 'restricted') { if (permissions.screen === 'not-determined' || permissions.screen === 'denied' || permissions.screen === 'unknown' || permissions.screen === 'restricted') {
console.log('[PermissionSetup] Opening screen recording preferences...'); console.log('[PermissionHeader] Opening screen recording preferences...');
await ipcRenderer.invoke('open-system-preferences', 'screen-recording'); await ipcRenderer.invoke('open-system-preferences', 'screen-recording');
} }
@ -422,7 +446,7 @@ export class PermissionSetup extends LitElement {
// (This may not execute if app restarts after permission grant) // (This may not execute if app restarts after permission grant)
// setTimeout(() => this.checkPermissions(), 2000); // setTimeout(() => this.checkPermissions(), 2000);
} catch (error) { } catch (error) {
console.error('[PermissionSetup] Error opening screen recording preferences:', error); console.error('[PermissionHeader] Error opening screen recording preferences:', error);
} }
} }
@ -436,9 +460,9 @@ export class PermissionSetup extends LitElement {
const { ipcRenderer } = window.require('electron'); const { ipcRenderer } = window.require('electron');
try { try {
await ipcRenderer.invoke('mark-permissions-completed'); await ipcRenderer.invoke('mark-permissions-completed');
console.log('[PermissionSetup] Marked permissions as completed'); console.log('[PermissionHeader] Marked permissions as completed');
} catch (error) { } catch (error) {
console.error('[PermissionSetup] Error marking permissions as completed:', error); console.error('[PermissionHeader] Error marking permissions as completed:', error);
} }
} }
@ -530,4 +554,4 @@ export class PermissionSetup extends LitElement {
} }
} }
customElements.define('permission-setup', PermissionSetup); customElements.define('permission-setup', PermissionHeader);

View File

@ -1,7 +1,6 @@
import { html, css, LitElement } from '../assets/lit-core-2.7.4.min.js'; import { html, css, LitElement } from '../assets/lit-core-2.7.4.min.js';
import { CustomizeView } from '../features/customize/CustomizeView.js'; import { CustomizeView } from '../features/customize/CustomizeView.js';
import { AssistantView } from '../features/listen/AssistantView.js'; import { AssistantView } from '../features/listen/AssistantView.js';
import { OnboardingView } from '../features/onboarding/OnboardingView.js';
import { AskView } from '../features/ask/AskView.js'; import { AskView } from '../features/ask/AskView.js';
import '../features/listen/renderer.js'; import '../features/listen/renderer.js';
@ -11,6 +10,7 @@ export class PickleGlassApp extends LitElement {
:host { :host {
display: block; display: block;
width: 100%; width: 100%;
height: 100%;
color: var(--text-color); color: var(--text-color);
background: transparent; background: transparent;
border-radius: 7px; border-radius: 7px;
@ -19,11 +19,13 @@ export class PickleGlassApp extends LitElement {
assistant-view { assistant-view {
display: block; display: block;
width: 100%; width: 100%;
height: 100%;
} }
ask-view, customize-view, history-view, help-view, onboarding-view, setup-view { ask-view, customize-view, history-view, help-view, onboarding-view, setup-view {
display: block; display: block;
width: 100%; width: 100%;
height: 100%;
} }
`; `;

View File

@ -301,5 +301,11 @@
} }
}); });
</script> </script>
<script>
const params = new URLSearchParams(window.location.search);
if (params.get('glass') === 'true') {
document.body.classList.add('has-glass');
}
</script>
</body> </body>
</html> </html>

View File

@ -15,10 +15,14 @@
</head> </head>
<body> <body>
<div id="header-container" tabindex="0" style="outline: none;"> <div id="header-container" tabindex="0" style="outline: none;">
<!-- <apikey-header id="apikey-header" style="display: none;"></apikey-header>
<app-header id="app-header" style="display: none;"></app-header> -->
</div> </div>
<script type="module" src="../../public/build/header.js"></script> <script type="module" src="../../public/build/header.js"></script>
<script>
const params = new URLSearchParams(window.location.search);
if (params.get('glass') === 'true') {
document.body.classList.add('has-glass');
}
</script>
</body> </body>
</html> </html>

View File

@ -0,0 +1,312 @@
const { screen } = require('electron');
class SmoothMovementManager {
constructor(windowPool, getDisplayById, getCurrentDisplay, updateLayout) {
this.windowPool = windowPool;
this.getDisplayById = getDisplayById;
this.getCurrentDisplay = getCurrentDisplay;
this.updateLayout = updateLayout;
this.stepSize = 80;
this.animationDuration = 300;
this.headerPosition = { x: 0, y: 0 };
this.isAnimating = false;
this.hiddenPosition = null;
this.lastVisiblePosition = null;
this.currentDisplayId = null;
this.animationFrameId = null;
}
/**
* @param {BrowserWindow} win
* @returns {boolean}
*/
_isWindowValid(win) {
if (!win || win.isDestroyed()) {
if (this.isAnimating) {
console.warn('[MovementManager] Window destroyed mid-animation. Halting.');
this.isAnimating = false;
if (this.animationFrameId) {
clearTimeout(this.animationFrameId);
this.animationFrameId = null;
}
}
return false;
}
return true;
}
moveToDisplay(displayId) {
const header = this.windowPool.get('header');
if (!this._isWindowValid(header) || !header.isVisible() || this.isAnimating) return;
const targetDisplay = this.getDisplayById(displayId);
if (!targetDisplay) return;
const currentBounds = header.getBounds();
const currentDisplay = this.getCurrentDisplay(header);
if (currentDisplay.id === targetDisplay.id) return;
const relativeX = (currentBounds.x - currentDisplay.workArea.x) / currentDisplay.workAreaSize.width;
const relativeY = (currentBounds.y - currentDisplay.workArea.y) / currentDisplay.workAreaSize.height;
const targetX = targetDisplay.workArea.x + targetDisplay.workAreaSize.width * relativeX;
const targetY = targetDisplay.workArea.y + targetDisplay.workAreaSize.height * relativeY;
const finalX = Math.max(targetDisplay.workArea.x, Math.min(targetDisplay.workArea.x + targetDisplay.workAreaSize.width - currentBounds.width, targetX));
const finalY = Math.max(targetDisplay.workArea.y, Math.min(targetDisplay.workArea.y + targetDisplay.workAreaSize.height - currentBounds.height, targetY));
this.headerPosition = { x: currentBounds.x, y: currentBounds.y };
this.animateToPosition(header, finalX, finalY);
this.currentDisplayId = targetDisplay.id;
}
hideToEdge(edge, callback) {
const header = this.windowPool.get('header');
if (!this._isWindowValid(header) || !header.isVisible() || this.isAnimating) return;
const currentBounds = header.getBounds();
const display = this.getCurrentDisplay(header);
if (
!currentBounds || typeof currentBounds.x !== 'number' || typeof currentBounds.y !== 'number' ||
!display || !display.workArea || !display.workAreaSize ||
typeof display.workArea.x !== 'number' || typeof display.workArea.y !== 'number' ||
typeof display.workAreaSize.width !== 'number' || typeof display.workAreaSize.height !== 'number'
) {
console.error('[MovementManager] Invalid bounds or display info for hideToEdge. Aborting.');
return;
}
this.lastVisiblePosition = { x: currentBounds.x, y: currentBounds.y };
this.headerPosition = { x: currentBounds.x, y: currentBounds.y };
const { width: screenWidth, height: screenHeight } = display.workAreaSize;
const { x: workAreaX, y: workAreaY } = display.workArea;
let targetX = this.headerPosition.x;
let targetY = this.headerPosition.y;
switch (edge) {
case 'top': targetY = workAreaY - currentBounds.height - 20; break;
case 'bottom': targetY = workAreaY + screenHeight + 20; break;
case 'left': targetX = workAreaX - currentBounds.width - 20; break;
case 'right': targetX = workAreaX + screenWidth + 20; break;
}
this.hiddenPosition = { x: targetX, y: targetY, edge };
this.isAnimating = true;
const startX = this.headerPosition.x;
const startY = this.headerPosition.y;
const duration = 400;
const startTime = Date.now();
const animate = () => {
if (!this._isWindowValid(header)) return;
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = progress * progress * progress;
const currentX = startX + (targetX - startX) * eased;
const currentY = startY + (targetY - startY) * eased;
if (!Number.isFinite(currentX) || !Number.isFinite(currentY)) {
this.isAnimating = false;
return;
}
if (!this._isWindowValid(header)) return;
header.setPosition(Math.round(currentX), Math.round(currentY));
if (progress < 1) {
this.animationFrameId = setTimeout(animate, 8);
} else {
this.animationFrameId = null;
this.headerPosition = { x: targetX, y: targetY };
if (Number.isFinite(targetX) && Number.isFinite(targetY)) {
if (!this._isWindowValid(header)) return;
header.setPosition(Math.round(targetX), Math.round(targetY));
}
this.isAnimating = false;
if (typeof callback === 'function') callback();
}
};
animate();
}
showFromEdge(callback) {
const header = this.windowPool.get('header');
if (
!this._isWindowValid(header) || this.isAnimating ||
!this.hiddenPosition || !this.lastVisiblePosition ||
typeof this.hiddenPosition.x !== 'number' || typeof this.hiddenPosition.y !== 'number' ||
typeof this.lastVisiblePosition.x !== 'number' || typeof this.lastVisiblePosition.y !== 'number'
) {
console.error('[MovementManager] Invalid state for showFromEdge. Aborting.');
this.isAnimating = false;
this.hiddenPosition = null;
this.lastVisiblePosition = null;
return;
}
if (!this._isWindowValid(header)) return;
header.setPosition(this.hiddenPosition.x, this.hiddenPosition.y);
this.headerPosition = { x: this.hiddenPosition.x, y: this.hiddenPosition.y };
const targetX = this.lastVisiblePosition.x;
const targetY = this.lastVisiblePosition.y;
this.isAnimating = true;
const startX = this.headerPosition.x;
const startY = this.headerPosition.y;
const duration = 500;
const startTime = Date.now();
const animate = () => {
if (!this._isWindowValid(header)) return;
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
const c1 = 1.70158;
const c3 = c1 + 1;
const eased = 1 + c3 * Math.pow(progress - 1, 3) + c1 * Math.pow(progress - 1, 2);
const currentX = startX + (targetX - startX) * eased;
const currentY = startY + (targetY - startY) * eased;
if (!Number.isFinite(currentX) || !Number.isFinite(currentY)) {
this.isAnimating = false;
return;
}
if (!this._isWindowValid(header)) return;
header.setPosition(Math.round(currentX), Math.round(currentY));
if (progress < 1) {
this.animationFrameId = setTimeout(animate, 8);
} else {
this.animationFrameId = null;
this.headerPosition = { x: targetX, y: targetY };
if (Number.isFinite(targetX) && Number.isFinite(targetY)) {
if (!this._isWindowValid(header)) return;
header.setPosition(Math.round(targetX), Math.round(targetY));
}
this.isAnimating = false;
this.hiddenPosition = null;
this.lastVisiblePosition = null;
if (callback) callback();
}
};
animate();
}
moveStep(direction) {
const header = this.windowPool.get('header');
if (!this._isWindowValid(header) || !header.isVisible() || this.isAnimating) return;
const currentBounds = header.getBounds();
this.headerPosition = { x: currentBounds.x, y: currentBounds.y };
let targetX = this.headerPosition.x;
let targetY = this.headerPosition.y;
switch (direction) {
case 'left': targetX -= this.stepSize; break;
case 'right': targetX += this.stepSize; break;
case 'up': targetY -= this.stepSize; break;
case 'down': targetY += this.stepSize; break;
default: return;
}
const displays = screen.getAllDisplays();
let validPosition = displays.some(d => (
targetX >= d.workArea.x && targetX + currentBounds.width <= d.workArea.x + d.workArea.width &&
targetY >= d.workArea.y && targetY + currentBounds.height <= d.workArea.y + d.workArea.height
));
if (!validPosition) {
const nearestDisplay = screen.getDisplayNearestPoint({ x: targetX, y: targetY });
const { x, y, width, height } = nearestDisplay.workArea;
targetX = Math.max(x, Math.min(x + width - currentBounds.width, targetX));
targetY = Math.max(y, Math.min(y + height - currentBounds.height, targetY));
}
if (targetX === this.headerPosition.x && targetY === this.headerPosition.y) return;
this.animateToPosition(header, targetX, targetY);
}
animateToPosition(header, targetX, targetY) {
if (!this._isWindowValid(header)) return;
this.isAnimating = true;
const startX = this.headerPosition.x;
const startY = this.headerPosition.y;
const startTime = Date.now();
if (!Number.isFinite(targetX) || !Number.isFinite(targetY) || !Number.isFinite(startX) || !Number.isFinite(startY)) {
this.isAnimating = false;
return;
}
const animate = () => {
if (!this._isWindowValid(header)) return;
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / this.animationDuration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
const currentX = startX + (targetX - startX) * eased;
const currentY = startY + (targetY - startY) * eased;
if (!Number.isFinite(currentX) || !Number.isFinite(currentY)) {
this.isAnimating = false;
return;
}
if (!this._isWindowValid(header)) return;
header.setPosition(Math.round(currentX), Math.round(currentY));
if (progress < 1) {
this.animationFrameId = setTimeout(animate, 8);
} else {
this.animationFrameId = null;
this.headerPosition = { x: targetX, y: targetY };
if (Number.isFinite(targetX) && Number.isFinite(targetY)) {
if (!this._isWindowValid(header)) return;
header.setPosition(Math.round(targetX), Math.round(targetY));
}
this.isAnimating = false;
this.updateLayout();
}
};
animate();
}
moveToEdge(direction) {
const header = this.windowPool.get('header');
if (!this._isWindowValid(header) || !header.isVisible() || this.isAnimating) return;
const display = this.getCurrentDisplay(header);
const { width, height } = display.workAreaSize;
const { x: workAreaX, y: workAreaY } = display.workArea;
const headerBounds = header.getBounds();
const currentBounds = header.getBounds();
let targetX = currentBounds.x;
let targetY = currentBounds.y;
switch (direction) {
case 'left': targetX = workAreaX; break;
case 'right': targetX = workAreaX + width - headerBounds.width; break;
case 'up': targetY = workAreaY; break;
case 'down': targetY = workAreaY + height - headerBounds.height; break;
}
this.headerPosition = { x: currentBounds.x, y: currentBounds.y };
this.animateToPosition(header, targetX, targetY);
}
destroy() {
if (this.animationFrameId) {
clearTimeout(this.animationFrameId);
this.animationFrameId = null;
}
this.isAnimating = false;
console.log('[Movement] Manager destroyed');
}
}
module.exports = SmoothMovementManager;

View File

@ -0,0 +1,217 @@
const { screen } = require('electron');
/**
* 주어진 창이 현재 어느 디스플레이에 속해 있는지 반환합니다.
* @param {BrowserWindow} window - 확인할 객체
* @returns {Display} Electron의 Display 객체
*/
function getCurrentDisplay(window) {
if (!window || window.isDestroyed()) return screen.getPrimaryDisplay();
const windowBounds = window.getBounds();
const windowCenter = {
x: windowBounds.x + windowBounds.width / 2,
y: windowBounds.y + windowBounds.height / 2,
};
return screen.getDisplayNearestPoint(windowCenter);
}
class WindowLayoutManager {
/**
* @param {Map<string, BrowserWindow>} windowPool - 관리할 창들의
*/
constructor(windowPool) {
this.windowPool = windowPool;
this.isUpdating = false;
this.PADDING = 80;
}
/**
* 모든 창의 레이아웃 업데이트를 요청합니다.
* 중복 실행을 방지하기 위해 isUpdating 플래그를 사용합니다.
*/
updateLayout() {
if (this.isUpdating) return;
this.isUpdating = true;
setImmediate(() => {
this.positionWindows();
this.isUpdating = false;
});
}
/**
* 헤더 창을 기준으로 모든 기능 창들의 위치를 계산하고 배치합니다.
*/
positionWindows() {
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 headerCenterX = headerBounds.x - workAreaX + headerBounds.width / 2;
const headerCenterY = headerBounds.y - workAreaY + headerBounds.height / 2;
const relativeX = headerCenterX / screenWidth;
const relativeY = headerCenterY / screenHeight;
const strategy = this.determineLayoutStrategy(headerBounds, screenWidth, screenHeight, relativeX, relativeY);
this.positionFeatureWindows(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY);
this.positionSettingsWindow(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY);
}
/**
* 헤더 창의 위치에 따라 기능 창들을 배치할 최적의 전략을 결정합니다.
* @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);
if (spaceBelow >= 400) {
return { name: 'below', primary: 'below', secondary: relativeX < 0.5 ? 'right' : 'left' };
} else if (spaceAbove >= 400) {
return { name: 'above', primary: 'above', secondary: relativeX < 0.5 ? 'right' : 'left' };
} else if (relativeX < 0.3 && spaceRight >= 800) {
return { name: 'right-side', primary: 'right', secondary: spaceBelow > spaceAbove ? 'below' : 'above' };
} else if (relativeX > 0.7 && spaceLeft >= 800) {
return { name: 'left-side', primary: 'left', secondary: spaceBelow > spaceAbove ? 'below' : 'above' };
} else {
return { name: 'adaptive', primary: spaceBelow > spaceAbove ? 'below' : 'above', secondary: spaceRight > spaceLeft ? 'right' : 'left' };
}
}
/**
* 'ask' 'listen' 창의 위치를 조정합니다.
*/
positionFeatureWindows(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY) {
const ask = this.windowPool.get('ask');
const listen = this.windowPool.get('listen');
const askVisible = ask && ask.isVisible() && !ask.isDestroyed();
const listenVisible = listen && listen.isVisible() && !listen.isDestroyed();
if (!askVisible && !listenVisible) return;
const PAD = 8;
const headerCenterXRel = headerBounds.x - workAreaX + headerBounds.width / 2;
let askBounds = askVisible ? ask.getBounds() : null;
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;
if (listenXRel < PAD) {
listenXRel = PAD;
askXRel = listenXRel + listenBounds.width + PAD;
}
if (askXRel + askBounds.width > screenWidth - PAD) {
askXRel = screenWidth - PAD - askBounds.width;
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;
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 });
} 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;
xRel = Math.max(PAD, Math.min(screenWidth - winBounds.width - PAD, xRel));
yRel = Math.max(PAD, Math.min(screenHeight - winBounds.height - PAD, yRel));
win.setBounds({ x: Math.round(xRel + workAreaX), y: Math.round(yRel + workAreaY), 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;
if (settings.__lockedByButton) {
const headerDisplay = getCurrentDisplay(this.windowPool.get('header'));
const settingsDisplay = getCurrentDisplay(settings);
if (headerDisplay.id !== settingsDisplay.id) {
settings.__lockedByButton = false;
} else {
return;
}
}
const settingsBounds = settings.getBounds();
const PAD = 5;
const buttonPadding = 17;
let x = headerBounds.x + headerBounds.width - settingsBounds.width - buttonPadding;
let y = headerBounds.y + headerBounds.height + PAD;
const otherVisibleWindows = [];
['listen', 'ask'].forEach(name => {
const win = this.windowPool.get(name);
if (win && win.isVisible() && !win.isDestroyed()) {
otherVisibleWindows.push({ name, bounds: win.getBounds() });
}
});
const settingsNewBounds = { x, y, width: settingsBounds.width, height: settingsBounds.height };
let hasOverlap = otherVisibleWindows.some(otherWin => this.boundsOverlap(settingsNewBounds, otherWin.bounds));
if (hasOverlap) {
x = headerBounds.x + headerBounds.width + PAD;
y = headerBounds.y;
if (x + settingsBounds.width > screenWidth - 10) {
x = headerBounds.x - settingsBounds.width - PAD;
}
if (x < 10) {
x = headerBounds.x + headerBounds.width - settingsBounds.width - buttonPadding;
y = headerBounds.y - settingsBounds.height - PAD;
if (y < 10) {
x = headerBounds.x + headerBounds.width - settingsBounds.width;
y = headerBounds.y + headerBounds.height + PAD;
}
}
}
x = Math.max(workAreaX + 10, Math.min(workAreaX + screenWidth - settingsBounds.width - 10, x));
y = Math.max(workAreaY + 10, Math.min(workAreaY + screenHeight - settingsBounds.height - 10, y));
settings.setBounds({ x: Math.round(x), y: Math.round(y) });
settings.moveTop();
}
/**
* 사각형 영역이 겹치는지 확인합니다.
* @param {Rectangle} bounds1
* @param {Rectangle} bounds2
* @returns {boolean} 겹침 여부
*/
boundsOverlap(bounds1, bounds2) {
const margin = 10;
return !(
bounds1.x + bounds1.width + margin < bounds2.x ||
bounds2.x + bounds2.width + margin < bounds1.x ||
bounds1.y + bounds1.height + margin < bounds2.y ||
bounds2.y + bounds2.height + margin < bounds1.y
);
}
}
module.exports = WindowLayoutManager;

File diff suppressed because it is too large Load Diff

View File

@ -590,6 +590,43 @@ export class AskView extends LitElement {
color: rgba(255, 255, 255, 0.5); color: rgba(255, 255, 255, 0.5);
font-size: 14px; font-size: 14px;
} }
:host-context(body.has-glass) .ask-container,
:host-context(body.has-glass) .response-header,
:host-context(body.has-glass) .response-icon,
:host-context(body.has-glass) .copy-button,
:host-context(body.has-glass) .close-button,
:host-context(body.has-glass) .line-copy-button,
:host-context(body.has-glass) .text-input-container,
:host-context(body.has-glass) .response-container pre,
:host-context(body.has-glass) .response-container p code,
:host-context(body.has-glass) .response-container pre code {
background: transparent !important;
border: none !important;
outline: none !important;
box-shadow: none !important;
filter: none !important;
backdrop-filter: none !important;
}
/* ask-container 의 블러·그림자 레이어 제거 */
:host-context(body.has-glass) .ask-container::before {
display: none !important;
}
/* hover/active 때 다시 생기는 배경도 차단 */
:host-context(body.has-glass) .copy-button:hover,
:host-context(body.has-glass) .close-button:hover,
:host-context(body.has-glass) .line-copy-button,
:host-context(body.has-glass) .line-copy-button:hover,
:host-context(body.has-glass) .response-line:hover {
background: transparent !important;
}
/* 스크롤바 트랙·썸 마저 투명화 (원할 경우) */
:host-context(body.has-glass) .response-container::-webkit-scrollbar-track,
:host-context(body.has-glass) .response-container::-webkit-scrollbar-thumb {
background: transparent !important;
}
`; `;
constructor() { constructor() {
@ -1378,9 +1415,9 @@ export class AskView extends LitElement {
const responseHeight = responseEl.scrollHeight; const responseHeight = responseEl.scrollHeight;
const inputHeight = (inputEl && !inputEl.classList.contains('hidden')) ? inputEl.offsetHeight : 0; const inputHeight = (inputEl && !inputEl.classList.contains('hidden')) ? inputEl.offsetHeight : 0;
const idealHeight = headerHeight + responseHeight + inputHeight + 20; // padding const idealHeight = headerHeight + responseHeight + inputHeight;
const targetHeight = Math.min(700, Math.max(200, idealHeight)); const targetHeight = Math.min(700, idealHeight);
const { ipcRenderer } = window.require('electron'); const { ipcRenderer } = window.require('electron');
ipcRenderer.invoke('adjust-window-height', targetHeight); ipcRenderer.invoke('adjust-window-height', targetHeight);

View File

@ -11,7 +11,7 @@ export class CustomizeView extends LitElement {
:host { :host {
display: block; display: block;
width: 180px; width: 180px;
min-height: 180px; height: 100%;
color: white; color: white;
} }
@ -234,6 +234,38 @@ export class CustomizeView extends LitElement {
font-size: 11px; font-size: 11px;
margin-bottom: 4px; margin-bottom: 4px;
} }
:host-context(body.has-glass) .settings-container,
:host-context(body.has-glass) .settings-button,
:host-context(body.has-glass) .cmd-key,
:host-context(body.has-glass) .shortcut-key,
:host-context(body.has-glass) .api-key-section input {
background: transparent !important;
border: none !important;
outline: none !important;
box-shadow: none !important;
filter: none !important;
backdrop-filter: none !important;
}
/* 블러·그림자·gradient 레이어 제거 */
:host-context(body.has-glass) .settings-container::before {
display: none !important;
}
/* hover/active 시 다시 생기는 배경도 차단 */
:host-context(body.has-glass) .settings-button:hover,
:host-context(body.has-glass) .shortcut-item:hover,
:host-context(body.has-glass) .settings-button.danger:hover {
background: transparent !important;
border-color: transparent !important;
transform: none !important;
}
/* 스크롤바 트랙·썸 투명화(선택 사항) */
:host-context(body.has-glass) .settings-container::-webkit-scrollbar-track,
:host-context(body.has-glass) .settings-container::-webkit-scrollbar-thumb {
background: transparent !important;
}
`; `;
@ -390,9 +422,7 @@ export class CustomizeView extends LitElement {
updateScrollHeight() { updateScrollHeight() {
const windowHeight = window.innerHeight; const windowHeight = window.innerHeight;
const headerHeight = 60; const maxHeight = windowHeight;
const padding = 40;
const maxHeight = windowHeight - headerHeight - padding;
this.style.maxHeight = `${maxHeight}px`; this.style.maxHeight = `${maxHeight}px`;
} }

View File

@ -161,7 +161,7 @@ export class AssistantView extends LitElement {
/* outline: 0.5px rgba(255, 255, 255, 0.5) solid; */ /* outline: 0.5px rgba(255, 255, 255, 0.5) solid; */
/* outline-offset: -1px; */ /* outline-offset: -1px; */
width: 100%; width: 100%;
min-height: 200px; height: 100%;
} }
.assistant-container::after { .assistant-container::after {
@ -542,6 +542,73 @@ export class AssistantView extends LitElement {
font-size: 10px; font-size: 10px;
color: rgba(255, 255, 255, 0.7); color: rgba(255, 255, 255, 0.7);
} }
:host-context(body.has-glass) .assistant-container,
:host-context(body.has-glass) .top-bar,
:host-context(body.has-glass) .toggle-button,
:host-context(body.has-glass) .copy-button,
:host-context(body.has-glass) .transcription-container,
:host-context(body.has-glass) .insights-container,
:host-context(body.has-glass) .stt-message,
:host-context(body.has-glass) .outline-item,
:host-context(body.has-glass) .request-item,
:host-context(body.has-glass) .markdown-content,
:host-context(body.has-glass) .insights-container pre,
:host-context(body.has-glass) .insights-container p code,
:host-context(body.has-glass) .insights-container pre code {
background: transparent !important;
border: none !important;
outline: none !important;
box-shadow: none !important;
filter: none !important;
backdrop-filter: none !important;
}
/* 가상 레이어·gradient 테두리 제거 */
:host-context(body.has-glass) .assistant-container::before,
:host-context(body.has-glass) .assistant-container::after {
display: none !important;
}
/* hover 상태에서 생기는 배경도 차단 */
:host-context(body.has-glass) .toggle-button:hover,
:host-context(body.has-glass) .copy-button:hover,
:host-context(body.has-glass) .outline-item:hover,
:host-context(body.has-glass) .request-item.clickable:hover,
:host-context(body.has-glass) .markdown-content:hover {
background: transparent !important;
transform: none !important;
}
/* 스크롤바 트랙·썸도 투명화(선택) */
:host-context(body.has-glass) .transcription-container::-webkit-scrollbar-track,
:host-context(body.has-glass) .transcription-container::-webkit-scrollbar-thumb,
:host-context(body.has-glass) .insights-container::-webkit-scrollbar-track,
:host-context(body.has-glass) .insights-container::-webkit-scrollbar-thumb {
background: transparent !important;
}
:host-context(body.has-glass) * {
animation: none !important;
transition: none !important;
transform: none !important;
filter: none !important;
backdrop-filter: none !important;
box-shadow: none !important;
}
/* 추가: 둥근 모서리와 스크롤바도 평면화하려면 */
:host-context(body.has-glass) .assistant-container,
:host-context(body.has-glass) .stt-message,
:host-context(body.has-glass) .toggle-button,
:host-context(body.has-glass) .copy-button {
border-radius: 0 !important;
}
:host-context(body.has-glass) ::-webkit-scrollbar,
:host-context(body.has-glass) ::-webkit-scrollbar-track,
:host-context(body.has-glass) ::-webkit-scrollbar-thumb {
background: transparent !important;
width: 0 !important; /* 스크롤바 자체 숨기기 */
}
`; `;
static properties = { static properties = {
@ -777,9 +844,9 @@ export class AssistantView extends LitElement {
const contentHeight = activeContent.scrollHeight; const contentHeight = activeContent.scrollHeight;
const idealHeight = topBarHeight + contentHeight + 20; const idealHeight = topBarHeight + contentHeight;
const targetHeight = Math.min(700, Math.max(200, idealHeight)); const targetHeight = Math.min(700, idealHeight);
console.log( console.log(
`[Height Adjusted] Mode: ${this.viewMode}, TopBar: ${topBarHeight}px, Content: ${contentHeight}px, Ideal: ${idealHeight}px, Target: ${targetHeight}px` `[Height Adjusted] Mode: ${this.viewMode}, TopBar: ${topBarHeight}px, Content: ${contentHeight}px, Ideal: ${idealHeight}px, Target: ${targetHeight}px`