From 1b215579d2c01a7719c4efe0514a410394d1b05a Mon Sep 17 00:00:00 2001 From: lacvet Date: Sat, 4 Apr 2026 09:00:15 +0900 Subject: [PATCH] =?UTF-8?q?[v2.0.0]=20Phase=20L3-7=20=EB=8B=A4=EC=A4=91=20?= =?UTF-8?q?=EB=94=94=EC=8A=A4=ED=94=8C=EB=A0=88=EC=9D=B4=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/LAUNCHER_ROADMAP.md | 2 +- src/AxCopilot/App.Settings.cs | 80 +++++++++++++++++-- src/AxCopilot/Models/AppSettings.cs | 8 ++ .../Views/LauncherWindow.Animations.cs | 49 ++++++++++-- 4 files changed, 124 insertions(+), 15 deletions(-) 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()