LauncherWindow.Animations.cs:
- CenterOnScreen(): SystemParameters.WorkArea(주 모니터 고정) → 마우스 커서 위치 모니터 기반으로 변경
- GetCurrentMonitorWorkArea() 신규: Screen.FromPoint(Cursor.Position)으로 현재 모니터 탐색
· PresentationSource.TransformFromDevice로 물리 픽셀 → WPF DIP 정확 변환
· PresentationSource 미사용 가능 시 SystemParameters.WorkArea 폴백
- using AxCopilot.Services 추가
AppSettings.cs:
- LauncherSettings에 MonitorDockPositions 딕셔너리 추가
· key=모니터 디바이스 이름 (\.\DISPLAY1 등), value=[Left, Top]
· JSON 직렬화 지원
App.Settings.cs:
- ToggleDockBar(): 독 바 위치 변경 시 per-monitor 위치 저장 (GetMonitorDeviceNameAt)
- 독 바 복원 시 IsDockPositionOnAnyMonitor로 유효성 검사
· 연결 끊긴 모니터 위치면 중앙 하단(-1,-1)으로 자동 초기화
- GetMonitorDeviceNameAt(): WPF DIP → 물리 픽셀 변환 후 모니터 디바이스명 반환
- IsDockPositionOnAnyMonitor(): Screen.AllScreens 범위 검사
docs/LAUNCHER_ROADMAP.md: L3-7 ✅ 완료 표시
효과:
- 듀얼 모니터에서 핫키 → 마우스가 있는 모니터에 런처 표시
- 독 바 드래그 시 해당 모니터 이름으로 위치 기억
- 모니터 분리 후 재실행 시 독 바 위치 자동 복원
빌드: 경고 0, 오류 0
317 lines
12 KiB
C#
317 lines
12 KiB
C#
using System.Windows;
|
|
using System.Windows.Forms;
|
|
using AxCopilot.Core;
|
|
using AxCopilot.Services;
|
|
using AxCopilot.ViewModels;
|
|
using AxCopilot.Views;
|
|
|
|
namespace AxCopilot;
|
|
|
|
public partial class App
|
|
{
|
|
// ─── 설정창 / ChatWindow ─────────────────────────────────────────────
|
|
|
|
private void SetupTrayIcon(PluginHost pluginHost, SettingsService settings)
|
|
{
|
|
_trayIcon = new NotifyIcon
|
|
{
|
|
Text = "AX Copilot",
|
|
Visible = true,
|
|
Icon = LoadAppIcon()
|
|
};
|
|
|
|
// ─── WPF 커스텀 트레이 메뉴 구성 ──────────────────────────────────
|
|
_trayMenu = new Views.TrayMenuWindow();
|
|
_trayMenu
|
|
.AddItem("\uE7C5", "AX Commander 호출하기", () =>
|
|
Dispatcher.Invoke(() => _launcher?.Show()))
|
|
.AddItem("\uE8BD", "AX Agent 대화하기", () =>
|
|
Dispatcher.Invoke(() => OpenAiChat()), out var aiTrayItem)
|
|
.AddItem("\uE8A7", "독 바 표시", () =>
|
|
Dispatcher.Invoke(() => ToggleDockBar()))
|
|
.AddSeparator()
|
|
.AddItem("\uE72C", "플러그인 재로드", () =>
|
|
{
|
|
pluginHost.Reload();
|
|
_trayIcon!.ShowBalloonTip(2000, "AX Copilot", "플러그인이 재로드되었습니다.", ToolTipIcon.None);
|
|
})
|
|
.AddItem("\uE838", "로그 폴더 열기", () =>
|
|
{
|
|
var logDir = System.IO.Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
"AxCopilot", "logs");
|
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("explorer.exe", logDir)
|
|
{ UseShellExecute = true });
|
|
})
|
|
.AddItem("\uE9D9", "사용 통계", () =>
|
|
Dispatcher.Invoke(() => new StatisticsWindow().Show()))
|
|
.AddItem("\uE736", "사용 가이드 문서보기", () =>
|
|
{
|
|
try { Dispatcher.Invoke(() => new Views.GuideViewerWindow().Show()); }
|
|
catch (Exception ex) { LogService.Error($"사용 가이드 열기 실패: {ex.Message}"); }
|
|
})
|
|
.AddSeparator()
|
|
.AddToggleItem("\uE82F", "Windows 시작 시 자동 실행", IsAutoStartEnabled(),
|
|
isChecked => SetAutoStart(isChecked), out _, out _)
|
|
.AddSeparator()
|
|
.AddItem("\uE713", "설정", () =>
|
|
Dispatcher.Invoke(OpenSettings))
|
|
.AddItem("\uE946", "개발 정보", () =>
|
|
Dispatcher.Invoke(() => new AboutWindow().Show()))
|
|
.AddItem("\uE711", "종료", () =>
|
|
{
|
|
_inputListener?.Dispose();
|
|
_trayIcon?.Dispose();
|
|
Shutdown();
|
|
});
|
|
|
|
// AI 기능 활성화 여부에 따라 메뉴 항목 가시성 동적 업데이트
|
|
_trayMenu.Opening += () =>
|
|
{
|
|
aiTrayItem.Visibility = settings.Settings.AiEnabled
|
|
? System.Windows.Visibility.Visible
|
|
: System.Windows.Visibility.Collapsed;
|
|
};
|
|
|
|
// 우클릭 → WPF 메뉴 표시, 좌클릭 → 런처 토글
|
|
_trayIcon.MouseClick += (_, e) =>
|
|
{
|
|
if (e.Button == System.Windows.Forms.MouseButtons.Left)
|
|
Dispatcher.Invoke(() => _launcher?.Show());
|
|
else if (e.Button == System.Windows.Forms.MouseButtons.Right)
|
|
Dispatcher.Invoke(() => _trayMenu?.ShowWithUpdate());
|
|
};
|
|
|
|
// 타이머/알람 풍선 알림 서비스 연결
|
|
NotificationService.Initialize((title, msg) =>
|
|
{
|
|
Dispatcher.Invoke(() =>
|
|
_trayIcon?.ShowBalloonTip(4000, title, msg, ToolTipIcon.None));
|
|
});
|
|
}
|
|
|
|
/// <summary>ChatWindow 등 외부에서 설정 창을 여는 공개 메서드.</summary>
|
|
public void OpenSettingsFromChat() => Dispatcher.Invoke(OpenSettings);
|
|
|
|
/// <summary>AX Agent 창 열기 (트레이 메뉴 등에서 호출).</summary>
|
|
private Views.ChatWindow? _chatWindow;
|
|
|
|
/// <summary>
|
|
/// ChatWindow를 백그라운드에서 미리 생성합니다 (앱 시작 후 저우선순위로 호출).
|
|
/// 이후 OpenAiChat() 시 창 생성 비용 없이 즉시 Show/Activate만 수행합니다.
|
|
/// </summary>
|
|
internal void PrewarmChatWindow()
|
|
{
|
|
if (_chatWindow != null || _settings == null) return;
|
|
_chatWindow = new Views.ChatWindow(_settings);
|
|
}
|
|
|
|
private void OpenAiChat()
|
|
{
|
|
if (_settings == null) return;
|
|
if (_chatWindow == null)
|
|
{
|
|
_chatWindow = new Views.ChatWindow(_settings);
|
|
}
|
|
_chatWindow.Show();
|
|
_chatWindow.Activate();
|
|
}
|
|
|
|
public void ToggleDockBar()
|
|
{
|
|
if (_dockBar != null && _dockBar.IsVisible)
|
|
{
|
|
_dockBar.Hide();
|
|
return;
|
|
}
|
|
|
|
if (_dockBar == null)
|
|
{
|
|
_dockBar = new DockBarWindow();
|
|
_dockBar.OnQuickSearch = query =>
|
|
{
|
|
if (_launcher == null) return;
|
|
_launcher.Show();
|
|
_launcher.Activate(); // 독 바 뒤가 아닌 전면에 표시
|
|
if (!string.IsNullOrEmpty(query))
|
|
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input,
|
|
() => _launcher.SetInputText(query));
|
|
};
|
|
_dockBar.OnCapture = async () =>
|
|
{
|
|
WindowTracker.Capture();
|
|
if (_captureHandler != null)
|
|
await _captureHandler.CaptureDirectAsync("region");
|
|
};
|
|
_dockBar.OnOpenAgent = () =>
|
|
{
|
|
if (_launcher == null) return;
|
|
_launcher.Show();
|
|
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input,
|
|
() => _launcher.SetInputText("!"));
|
|
};
|
|
}
|
|
var launcher = _settings?.Settings.Launcher;
|
|
var dockItems = launcher?.DockBarItems ?? new() { "launcher", "clipboard", "capture", "agent", "clock", "cpu" };
|
|
_dockBar.BuildFromSettings(dockItems);
|
|
|
|
// Phase L3-7: 독 바 위치 변경 시 모니터별로 저장
|
|
_dockBar.OnPositionChanged = (left, top) =>
|
|
{
|
|
if (_settings == null) return;
|
|
var lnch = _settings.Settings.Launcher;
|
|
lnch.DockBarLeft = left;
|
|
lnch.DockBarTop = top;
|
|
|
|
// 현재 독 바가 속한 모니터 디바이스 이름으로 위치 기억
|
|
var deviceName = GetMonitorDeviceNameAt(_dockBar, left, top);
|
|
if (deviceName != null)
|
|
lnch.MonitorDockPositions[deviceName] = new[] { left, top };
|
|
|
|
_settings.Save();
|
|
};
|
|
|
|
_dockBar.Show();
|
|
|
|
// Phase L3-7: 저장 위치 유효성 검사 — 연결이 끊긴 모니터면 중앙 하단으로 리셋
|
|
var savedLeft = launcher?.DockBarLeft ?? -1;
|
|
var savedTop = launcher?.DockBarTop ?? -1;
|
|
if (!IsDockPositionOnAnyMonitor(savedLeft, savedTop))
|
|
{
|
|
LogService.Info("[MultiMonitor] 독 바 저장 위치가 연결된 모니터 밖 → 중앙 하단으로 초기화");
|
|
savedLeft = -1;
|
|
savedTop = -1;
|
|
}
|
|
|
|
_dockBar.ApplySettings(
|
|
launcher?.DockBarOpacity ?? 0.92,
|
|
savedLeft,
|
|
savedTop,
|
|
launcher?.DockBarRainbowGlow ?? false);
|
|
}
|
|
|
|
// ─── Phase L3-7: 다중 모니터 헬퍼 ─────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// WPF 논리 좌표를 물리적 좌표로 변환하여 해당 모니터의 디바이스 이름을 반환합니다.
|
|
/// </summary>
|
|
private static string? GetMonitorDeviceNameAt(
|
|
System.Windows.Media.Visual? visual, double wpfLeft, double wpfTop)
|
|
{
|
|
try
|
|
{
|
|
System.Drawing.Point physPt;
|
|
var src = visual != null ? PresentationSource.FromVisual(visual) : null;
|
|
if (src?.CompositionTarget != null)
|
|
{
|
|
var m = src.CompositionTarget.TransformToDevice; // logical→physical
|
|
var pt = m.Transform(new System.Windows.Point(wpfLeft + 10, wpfTop + 10));
|
|
physPt = new System.Drawing.Point((int)pt.X, (int)pt.Y);
|
|
}
|
|
else
|
|
{
|
|
physPt = new System.Drawing.Point((int)wpfLeft + 10, (int)wpfTop + 10);
|
|
}
|
|
return System.Windows.Forms.Screen.FromPoint(physPt).DeviceName;
|
|
}
|
|
catch { return null; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// 저장된 독 바 위치(WPF 논리 좌표)가 현재 연결된 모니터 중 하나에 있는지 검사합니다.
|
|
/// 모니터가 분리되거나 해상도가 변경되어 위치가 화면 밖에 있으면 false를 반환합니다.
|
|
/// </summary>
|
|
private static bool IsDockPositionOnAnyMonitor(double left, double top)
|
|
{
|
|
if (left < 0 || top < 0) return false; // -1 기본값은 항상 재계산 대상
|
|
try
|
|
{
|
|
// 참고: WPF 논리 좌표와 물리적 픽셀의 차이는 DPI에 따라 다르나
|
|
// "화면 밖인지" 판단에는 근사 비교로 충분합니다.
|
|
return System.Windows.Forms.Screen.AllScreens
|
|
.Any(s => left >= s.Bounds.Left && left < s.Bounds.Right
|
|
&& top >= s.Bounds.Top && top < s.Bounds.Bottom);
|
|
}
|
|
catch { return true; } // 오류 시 보수적으로 true (위치 유지)
|
|
}
|
|
|
|
/// <summary>독 바를 현재 설정으로 즉시 새로고침합니다.</summary>
|
|
public void RefreshDockBar()
|
|
{
|
|
if (_dockBar == null || !_dockBar.IsVisible) return;
|
|
var launcher = _settings?.Settings.Launcher;
|
|
var dockItems = launcher?.DockBarItems ?? new() { "launcher", "clipboard", "capture", "agent", "clock", "cpu" };
|
|
_dockBar.BuildFromSettings(dockItems);
|
|
_dockBar.ApplySettings(
|
|
launcher?.DockBarOpacity ?? 0.92,
|
|
launcher?.DockBarLeft ?? -1,
|
|
launcher?.DockBarTop ?? -1,
|
|
launcher?.DockBarRainbowGlow ?? false);
|
|
}
|
|
|
|
private void OpenSettings()
|
|
{
|
|
if (_settingsWindow != null && _settingsWindow.IsVisible)
|
|
{
|
|
_settingsWindow.Activate();
|
|
return;
|
|
}
|
|
|
|
if (_settings == null || _launcher == null) return;
|
|
|
|
var vm = new ViewModels.SettingsViewModel(_settings);
|
|
|
|
// 미리보기 콜백: 현재 편집 중인 색상(vm.ColorRows)으로 런처에 즉시 반영
|
|
void PreviewCallback(string themeKey)
|
|
{
|
|
if (themeKey == "custom")
|
|
{
|
|
var tempColors = new AxCopilot.Models.CustomThemeColors();
|
|
foreach (var row in vm.ColorRows)
|
|
{
|
|
var prop = typeof(AxCopilot.Models.CustomThemeColors).GetProperty(row.Property);
|
|
prop?.SetValue(tempColors, row.Hex);
|
|
}
|
|
_launcher.ApplyTheme(themeKey, tempColors);
|
|
}
|
|
else
|
|
{
|
|
_launcher.ApplyTheme(themeKey, _settings.Settings.Launcher.CustomTheme);
|
|
}
|
|
}
|
|
|
|
// 취소/X 닫기 콜백: 파일에 저장된 원본 설정으로 복원
|
|
void RevertCallback()
|
|
{
|
|
_launcher.ApplyTheme(
|
|
_settings.Settings.Launcher.Theme ?? "system",
|
|
_settings.Settings.Launcher.CustomTheme);
|
|
}
|
|
|
|
_settingsWindow = new Views.SettingsWindow(vm, PreviewCallback, RevertCallback)
|
|
{
|
|
// 핫키 녹화 중 글로벌 핫키 일시 정지
|
|
SuspendHotkeyCallback = suspend =>
|
|
{
|
|
if (_inputListener != null)
|
|
_inputListener.SuspendHotkey = suspend;
|
|
}
|
|
};
|
|
|
|
// 저장 완료 시 InputListener 핫키 갱신 + 알림 타이머 재시작
|
|
vm.SaveCompleted += (_, _) =>
|
|
{
|
|
if (_inputListener != null && _settings != null)
|
|
{
|
|
_inputListener.UpdateHotkey(_settings.Settings.Hotkey);
|
|
_inputListener.UpdateCaptureHotkey(
|
|
_settings.Settings.ScreenCapture.GlobalHotkey,
|
|
_settings.Settings.ScreenCapture.GlobalHotkeyEnabled);
|
|
}
|
|
_worktimeReminder?.RestartTimer();
|
|
};
|
|
|
|
_settingsWindow.Show();
|
|
}
|
|
}
|