diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md index e78abc1..a41ccd7 100644 --- a/docs/LAUNCHER_ROADMAP.md +++ b/docs/LAUNCHER_ROADMAP.md @@ -101,7 +101,7 @@ | ✅ L3-4 | **파라미터 퀵링크** | `jira {티켓번호}` → URL 템플릿 변수 치환 (사내 JIRA/Confluence 등) | 중간 | → Agent 18-4 | | ✅ L3-5 | **파일 태그 시스템** | 파일에 사용자 태그 부여, `tag` 프리픽스로 태그 기반 검색. `file_tags.json` 로컬 저장 | 중간 | — | | L3-6 | **오프라인 AI (로컬 SLM)** | ONNX Runtime + phi-3, 서버 없이 번역/요약 | 낮음 | → Agent 18-5 | -| L3-7 | **다중 디스플레이** | 모니터별 런처/독 바 위치 기억 | 낮음 | — | +| ✅ L3-7 | **다중 디스플레이** | 마우스 커서 위치 모니터에 런처 표시, 독 바 per-monitor 위치 저장·유효성 검증 | 낮음 | — | | L3-8 | **알림 센터 통합** | Windows 알림과 연동 | 낮음 | — | | L3-9 | **런처 미니 위젯** | 날씨/일정/할일을 런처 하단에 카드형으로 표시. 로컬 데이터 기반 | 낮음 | — | diff --git a/src/AxCopilot/App.Settings.cs b/src/AxCopilot/App.Settings.cs index 68859bc..652aa89 100644 --- a/src/AxCopilot/App.Settings.cs +++ b/src/AxCopilot/App.Settings.cs @@ -154,23 +154,87 @@ public partial class App 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) - { - _settings.Settings.Launcher.DockBarLeft = left; - _settings.Settings.Launcher.DockBarTop = top; - _settings.Save(); - } + 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, - launcher?.DockBarLeft ?? -1, - launcher?.DockBarTop ?? -1, + savedLeft, + savedTop, launcher?.DockBarRainbowGlow ?? false); } + // ─── Phase L3-7: 다중 모니터 헬퍼 ───────────────────────────────────── + + /// + /// WPF 논리 좌표를 물리적 좌표로 변환하여 해당 모니터의 디바이스 이름을 반환합니다. + /// + 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; } + } + + /// + /// 저장된 독 바 위치(WPF 논리 좌표)가 현재 연결된 모니터 중 하나에 있는지 검사합니다. + /// 모니터가 분리되거나 해상도가 변경되어 위치가 화면 밖에 있으면 false를 반환합니다. + /// + 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 (위치 유지) + } + /// 독 바를 현재 설정으로 즉시 새로고침합니다. public void RefreshDockBar() { diff --git a/src/AxCopilot/Models/AppSettings.cs b/src/AxCopilot/Models/AppSettings.cs index b78bd9a..1b4d53a 100644 --- a/src/AxCopilot/Models/AppSettings.cs +++ b/src/AxCopilot/Models/AppSettings.cs @@ -247,6 +247,14 @@ public class LauncherSettings /// 독 바 마지막 위치 Y. -1이면 하단. [JsonPropertyName("dockBarTop")] public double DockBarTop { get; set; } = -1; + + /// + /// Phase L3-7: 모니터별 독 바 위치. + /// key = 모니터 디바이스 이름 (예: \\.\DISPLAY1), value = [WPF Left, WPF Top]. + /// 모니터가 재연결되면 해당 모니터의 저장 위치로 자동 복원됩니다. + /// + [JsonPropertyName("monitorDockPositions")] + public Dictionary MonitorDockPositions { get; set; } = new(); } /// diff --git a/src/AxCopilot/Views/LauncherWindow.Animations.cs b/src/AxCopilot/Views/LauncherWindow.Animations.cs index d3a8d17..e24dccd 100644 --- a/src/AxCopilot/Views/LauncherWindow.Animations.cs +++ b/src/AxCopilot/Views/LauncherWindow.Animations.cs @@ -1,6 +1,7 @@ using System.Windows; using System.Windows.Media; using System.Windows.Media.Animation; +using AxCopilot.Services; namespace AxCopilot.Views; @@ -107,23 +108,59 @@ public partial class LauncherWindow return a; } - // ─── 화면 배치 ──────────────────────────────────────────────────────────── + // ─── 화면 배치 (Phase L3-7: 다중 디스플레이 지원) ──────────────────────── + /// + /// 마우스 커서가 위치한 모니터 중앙에 런처를 배치합니다. + /// 듀얼 모니터 환경에서도 핫키를 누른 순간 마우스가 있는 화면에 나타납니다. + /// private void CenterOnScreen() { - var screen = SystemParameters.WorkArea; + var area = GetCurrentMonitorWorkArea(); // ActualHeight/ActualWidth는 첫 Show() 전 레이아웃 패스 이전에 0일 수 있음 → 기본값으로 보호 var w = ActualWidth > 0 ? ActualWidth : 640; var h = ActualHeight > 0 ? ActualHeight : 80; - Left = (screen.Width - w) / 2 + screen.Left; + Left = (area.Width - w) / 2 + area.Left; Top = _vm.WindowPosition switch { - "center" => (screen.Height - h) / 2 + screen.Top, - "bottom" => screen.Height * 0.75 + screen.Top, - _ => screen.Height * 0.2 + screen.Top, // "center-top" (기본) + "center" => (area.Height - h) / 2 + area.Top, + "bottom" => area.Height * 0.75 + area.Top, + _ => area.Height * 0.2 + area.Top, // "center-top" (기본) }; } + /// + /// 마우스 커서가 있는 모니터의 작업 영역을 WPF 논리 좌표(DIP)로 반환합니다. + /// PresentationSource를 통해 DPI 스케일을 정확하게 보정합니다. + /// + private System.Windows.Rect GetCurrentMonitorWorkArea() + { + try + { + // 마우스 커서 물리적 좌표 → 해당 모니터 탐색 + var cursorPos = System.Windows.Forms.Cursor.Position; + var screen = System.Windows.Forms.Screen.FromPoint(cursorPos); + var wa = screen.WorkingArea; // 물리적 픽셀 단위 + + // 물리적 픽셀 → WPF DIP 변환 (DPI 보정) + var src = PresentationSource.FromVisual(this); + if (src?.CompositionTarget != null) + { + var m = src.CompositionTarget.TransformFromDevice; // physical→logical + var tl = m.Transform(new System.Windows.Point(wa.Left, wa.Top)); + var br = m.Transform(new System.Windows.Point(wa.Right, wa.Bottom)); + return new System.Windows.Rect(tl, br); + } + } + catch (Exception ex) + { + LogService.Warn($"[MultiMonitor] 모니터 감지 실패, 기본 모니터 사용: {ex.Message}"); + } + + // 폴백: WPF 기본 주 모니터 + return SystemParameters.WorkArea; + } + // ─── 등장 애니메이션 ────────────────────────────────────────────────────── private void AnimateIn()