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()