diff --git a/src/SheetMe.Designer/App.xaml.cs b/src/SheetMe.Designer/App.xaml.cs index e02f95c..1cfcdcd 100644 --- a/src/SheetMe.Designer/App.xaml.cs +++ b/src/SheetMe.Designer/App.xaml.cs @@ -20,6 +20,9 @@ public partial class App : Application /// Dispatcher 예외 폭주 차단용 — 10초 내 5회면 강제 종료(무한 팝업 루프 방지) private readonly Queue recentCrashes = new(); + /// 진단 플래그로 기동했는가 — 이 모드에서는 어떤 경로로도 모달을 띄우지 않는다 + private bool diagnosticMode; + protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); @@ -37,6 +40,13 @@ public partial class App : Application if (Services.StartupArguments.IsDiagnostic(e.Args)) { + diagnosticMode = true; + // 기본 ShutdownMode 는 OnLastWindowClose 다. 창을 띄웠다 전부 닫는 진단 + // (--maxrect, --edit-smoke, --dialog-shots …)은 마지막 창을 닫는 순간 WPF 가 먼저 + // Shutdown(0) 을 걸어 버리고, 뒤이은 Shutdown(RunDiagnostic(...)) 인자는 무시된다 — + // 리포트에 '실패 3건' 이 찍혀도 프로세스는 0 을 돌려준다(실제로 그 상태였다). + // 스크립트가 종료코드로 판정하면 실패를 성공으로 읽는다. + ShutdownMode = ShutdownMode.OnExplicitShutdown; Services.UserSession.InitializeDiagnostic(); Shutdown(RunDiagnostic(e.Args)); return; @@ -89,6 +99,17 @@ public partial class App : Application { var code = Services.AppLog.Error("처리되지 않은 예외(UI)", e.Exception); + // 진단 모드에서는 절대 모달을 띄우지 않는다 — 아래 MessageBox 는 사람이 누르기 전까지 돌아오지 않아 + // 무인 실행이 타임아웃까지 매달린다. 이 핸들러가 진단 분기보다 먼저 등록되므로(로그를 남기려고) + // RunDiagnostic 의 "모달 금지" 규칙을 여기서도 지켜야 앞뒤가 맞는다. + if (diagnosticMode) + { + e.Handled = true; + Console.Error.WriteLine($"진단 중 UI 예외 — 오류 코드: {code}"); + Shutdown(3); + return; + } + var now = DateTime.UtcNow; recentCrashes.Enqueue(now); while (recentCrashes.Count > 0 && (now - recentCrashes.Peek()).TotalSeconds > 10) @@ -249,6 +270,11 @@ public partial class App : Application return Diagnostics.DialogShots.Run(args[1]); } + if (args.Length >= 1 && args[0] == "--maxrect") + { + return Diagnostics.MaximizeCheck.Run(args.Length >= 2 ? args[1] : null); + } + // 진단 모드에서는 모달을 띄우지 않는다 — 스크립트 옵션 오타 하나로 무인 실행이 여기서 // 영원히 멈춘다. WinExe 라 콘솔이 없어 stderr 는 호출자가 리다이렉트할 때만 보이므로 // 로그를 함께 남긴다(--db-render 가 .err.txt 로 남기는 것과 같은 이유). diff --git a/src/SheetMe.Designer/Diagnostics/MaximizeCheck.cs b/src/SheetMe.Designer/Diagnostics/MaximizeCheck.cs new file mode 100644 index 0000000..6aeee43 --- /dev/null +++ b/src/SheetMe.Designer/Diagnostics/MaximizeCheck.cs @@ -0,0 +1,390 @@ +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Windows; +using System.Windows.Interop; +using System.Windows.Shell; +using System.Windows.Threading; + +namespace SheetMe.Designer.Diagnostics; + +/// +/// 최대화 사각형 자동 검증 — --maxrect [출력파일]. +/// +/// 무엇을 재는가. 를 건 창에 +/// ① WM_GETMINMAXINFO 를 직접 보내 답을 뜯어보고, +/// ② 실제로 창을 띄워 최대화해 OS 가 그 답을 지켰는지 창 사각형으로 확인한다. +/// +/// 기대값을 전사(轉寫)하지 않는다. 구현의 계산을 이 파일에 베껴 적으면 같은 착각을 양쪽이 +/// 공유해 아무것도 검증하지 못하고, 구현이 조금만 달라져도(예: 양보 폭 1→2) 멀쩡한 동작에서 +/// 거짓 실패가 난다. 대신 성질을 단언한다 — 모니터 안에 들어가는가, 자동 숨김이 없는 변은 +/// 작업영역과 같은가, 있는 변은 안쪽으로 물러섰는가, 작업표시줄과 겹치지 않는가. +/// +/// 화면이 한 번 번쩍인다. ⑧⑨ 는 창을 실제로 띄워 최대화·복원한다 — 그 경로에서만 +/// StateChanged 배선과 OS 의 실제 동작을 잡을 수 있다. 진단을 사람이 직접 돌리므로 감수한다. +/// +/// 재지 못하는 것. 자동 숨김 작업표시줄이 정말 다시 튀어나오는지, 다른 배율·다중 모니터에서 +/// 어떤지는 그 환경에서 이 진단을 돌려 봐야 안다. +/// +public static class MaximizeCheck +{ + #region Member Fields + private const int WmGetMinMaxInfo = 0x0024; + private const uint MonitorinfofPrimary = 0x00000001; + private const uint AbmGetState = 0x00000004; + private const uint AbmGetTaskbarPos = 0x00000005; + private const uint AbmGetAutoHideBarEx = 0x0000000B; + private const int AbsAutoHide = 0x00000001; + + private const int ProbeMinWidth = 940; + private const int ProbeMinHeight = 640; + private const double ProbeBorder = 6; + + [StructLayout(LayoutKind.Sequential)] + private struct RECT + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + [StructLayout(LayoutKind.Sequential)] + private struct POINT + { + public int X; + public int Y; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MONITORINFO + { + public int cbSize; + public RECT rcMonitor; + public RECT rcWork; + public uint dwFlags; + } + + [StructLayout(LayoutKind.Sequential)] + private struct APPBARDATA + { + public uint cbSize; + public IntPtr hWnd; + public uint uCallbackMessage; + public uint uEdge; + public RECT rc; + public IntPtr lParam; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MINMAXINFO + { + public POINT ptReserved; + public POINT ptMaxSize; + public POINT ptMaxPosition; + public POINT ptMinTrackSize; + public POINT ptMaxTrackSize; + } + + /// 훅이 아무것도 안 써도 검사가 통과하지 않도록 보내기 전에 채워 두는 값 + private static readonly MINMAXINFO Sentinel = new() + { + ptMaxSize = new POINT { X = 12345, Y = 23456 }, + ptMaxPosition = new POINT { X = -4321, Y = -8765 }, + }; + + private delegate bool MonitorEnumProc(IntPtr monitor, IntPtr dc, ref RECT rect, IntPtr data); + + private sealed record MonitorFacts(string Name, RECT Bounds, RECT Work, bool Primary, bool[] AutoHide); + #endregion + + #region Methods + [DllImport("user32.dll")] + private static extern bool EnumDisplayMonitors(IntPtr dc, IntPtr clip, MonitorEnumProc callback, IntPtr data); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetMonitorInfo(IntPtr monitor, ref MONITORINFO info); + + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr SHAppBarMessage(uint message, ref APPBARDATA data); + + [DllImport("user32.dll")] + private static extern IntPtr SendMessage(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll")] + private static extern bool SetWindowPos(IntPtr hwnd, IntPtr after, int x, int y, int cx, int cy, uint flags); + + [DllImport("user32.dll")] + private static extern bool GetWindowRect(IntPtr hwnd, out RECT rect); + + public static int Run(string? outputPath) + { + var report = new StringBuilder(); + int failures; + try + { + failures = Measure(report); + } + catch (Exception ex) + { + // 꼬리에서 터져도 여기까지 모은 리포트는 남긴다 — 원인을 찾는 유일한 단서다 + report.AppendLine($"진단 자체가 예외로 중단됨 — {ex.GetType().Name}: {ex.Message}"); + failures = -1; + } + report.AppendLine(); + report.AppendLine(failures < 0 ? "결과: 진단 중단" : $"결과: 실패 {failures}건"); + return Finish(report, outputPath, failures switch { 0 => 0, < 0 => 2, _ => 1 }); + } + + private static int Measure(StringBuilder report) + { + var monitors = ListMonitors(); + if (monitors.Count == 0) + { + report.AppendLine("모니터를 하나도 조회하지 못했습니다."); + return -1; + } + + var taskbar = TaskbarRect(); + var state = Empty(); + var taskbarAutoHide = (SHAppBarMessage(AbmGetState, ref state).ToInt64() & AbsAutoHide) != 0; + var failures = 0; + + // 프로브는 WindowStyle=None 이어야 한다 — 기본값이면 Attach 의 가드에 걸려 아무 일도 안 일어난다 + var probe = NewProbe(ProbeMinWidth, ProbeMinHeight); + var handle = new WindowInteropHelper(probe).EnsureHandle(); + Services.MaximizeToWorkArea.Attach(probe); + + foreach (var monitor in monitors) + { + report.AppendLine($"모니터 {monitor.Name}{(monitor.Primary ? " (주)" : string.Empty)}"); + report.AppendLine($" rcMonitor {Show(monitor.Bounds)} rcWork {Show(monitor.Work)}"); + report.AppendLine($" 자동숨김 감지 좌={monitor.AutoHide[0]} 상={monitor.AutoHide[1]} 우={monitor.AutoHide[2]} 하={monitor.AutoHide[3]}"); + + // 그 모니터 안으로 옮긴다 — MonitorFromWindow 가 고르는 모니터가 곧 검사 대상이다. + // SWP_NOSIZE|SWP_NOZORDER|SWP_NOACTIVATE + SetWindowPos(handle, IntPtr.Zero, monitor.Bounds.Left + 20, monitor.Bounds.Top + 20, 0, 0, 0x0001 | 0x0004 | 0x0010); + + var answer = Ask(handle); + var max = Absolute(answer, monitor.Bounds); + report.AppendLine($" 답 ptMaxPosition=({answer.ptMaxPosition.X},{answer.ptMaxPosition.Y}) ptMaxSize=({answer.ptMaxSize.X},{answer.ptMaxSize.Y}) → 창 {Show(max)}"); + report.AppendLine($" 답 ptMinTrackSize=({answer.ptMinTrackSize.X},{answer.ptMinTrackSize.Y})"); + + failures += Check(report, "① 최대화 창이 모니터 안에 들어간다", + max.Left >= monitor.Bounds.Left && max.Top >= monitor.Bounds.Top + && max.Right <= monitor.Bounds.Right && max.Bottom <= monitor.Bounds.Bottom); + + failures += Check(report, "② 자동 숨김이 없는 변은 작업영역 그대로다", + (monitor.AutoHide[0] || max.Left == monitor.Work.Left) + && (monitor.AutoHide[1] || max.Top == monitor.Work.Top) + && (monitor.AutoHide[2] || max.Right == monitor.Work.Right) + && (monitor.AutoHide[3] || max.Bottom == monitor.Work.Bottom)); + + // 값이 아니라 방향만 본다 — 양보 폭을 1→2 로 바꿔도 진단이 깨지지 않아야 한다 + failures += Check(report, "③ 자동 숨김이 있는 변은 모니터 가장자리에서 물러선다", + (!monitor.AutoHide[0] || max.Left > monitor.Bounds.Left) + && (!monitor.AutoHide[1] || max.Top > monitor.Bounds.Top) + && (!monitor.AutoHide[2] || max.Right < monitor.Bounds.Right) + && (!monitor.AutoHide[3] || max.Bottom < monitor.Bounds.Bottom)); + + // 원래 결함을 그 언어 그대로 표현한 검사. 구현이 1차 판정에 쓰지 않는 API 라 독립적이다. + // 자동 숨김이면 작업표시줄이 자리를 예약하지 않아 겹치는 것이 정상이므로 건너뛴다. + if (taskbar is { } bar && !taskbarAutoHide && Intersects(bar, monitor.Bounds)) + { + failures += Check(report, "④ 작업표시줄을 덮지 않는다", !Intersects(max, bar)); + } + + // OS 는 최대화 크기를 최소 트래킹 크기까지 되밀어 올린다 — 이게 크면 ①②가 무의미해진다 + failures += Check(report, "⑤ 최소 크기가 최대화 크기를 넘지 않는다", + answer.ptMinTrackSize.X <= answer.ptMaxSize.X && answer.ptMinTrackSize.Y <= answer.ptMaxSize.Y); + report.AppendLine(); + } + + var primary = monitors.Find(m => m.Primary) ?? monitors[0]; + + // ⑦ 표준 크롬 창에는 걸리지 않아야 한다 — 걸면 클라이언트가 프레임 두께만큼 줄어든다 + var normal = new Window { WindowStyle = WindowStyle.SingleBorderWindow, ShowInTaskbar = false }; + var normalHandle = new WindowInteropHelper(normal).EnsureHandle(); + Services.MaximizeToWorkArea.Attach(normal); + var untouched = Ask(normalHandle); + failures += Check(report, "⑦ 표준 크롬 창은 건드리지 않는다", + untouched.ptMaxSize.X == Sentinel.ptMaxSize.X && untouched.ptMaxSize.Y == Sentinel.ptMaxSize.Y); + normal.Close(); + probe.Close(); + + report.AppendLine(); + failures += Live(report, primary); + return failures; + } + + /// + /// 실제로 띄워서 최대화·복원해 본다 — 여기서만 잡히는 것 두 가지. + /// ⑧ OS 가 우리 답을 지키는가(메시지 응답만 보면 알 수 없다). + /// ⑨ StateChanged 배선이 살아 있는가(Attach 시점 스냅샷만 보면 그 한 줄을 지워도 통과한다). + /// + private static int Live(StringBuilder report, MonitorFacts monitor) + { + var failures = 0; + var chrome = new WindowChrome { CaptionHeight = 40, ResizeBorderThickness = new Thickness(ProbeBorder) }; + var probe = NewProbe(ProbeMinWidth, ProbeMinHeight); + WindowChrome.SetWindowChrome(probe, chrome); + probe.WindowStartupLocation = WindowStartupLocation.Manual; + probe.Left = monitor.Bounds.Left + 40; + probe.Top = monitor.Bounds.Top + 40; + probe.ShowActivated = false; + probe.Show(); + Pump(); + Services.MaximizeToWorkArea.Attach(probe); + + var restoredBorder = chrome.ResizeBorderThickness; + probe.WindowState = WindowState.Maximized; + Pump(); + GetWindowRect(new WindowInteropHelper(probe).Handle, out var maximized); + var maximizedBorder = chrome.ResizeBorderThickness; + + probe.WindowState = WindowState.Normal; + Pump(); + var afterRestore = chrome.ResizeBorderThickness; + + // 두 번 붙여도 복원 두께를 잃지 않아야 한다 — 최대화 중에 캡처한 0 을 기억하면 창이 못 커진다 + Services.MaximizeToWorkArea.Attach(probe); + probe.WindowState = WindowState.Maximized; + Pump(); + probe.WindowState = WindowState.Normal; + Pump(); + var afterReattach = chrome.ResizeBorderThickness; + + // ⑥ 좁은 화면 흉내 — 작업영역보다 큰 최소 크기를 요구해 본다. + // 눌러 두지 않으면 OS 가 최대화 창을 그 크기까지 도로 부풀려 작업표시줄을 다시 덮는다. + // 띄운 창에서 재야 한다 — MinWidth/MinHeight 는 창 객체에 있고, 띄우지 않은 HWND 에는 + // RootVisual 이 없어 훅이 창을 찾지 못한다(그 상태로 재다가 한 번 헛다리를 짚었다). + var workHeight = monitor.Work.Bottom - monitor.Work.Top; + var handle = new WindowInteropHelper(probe).Handle; + probe.MinHeight = workHeight + 200; + var cramped = Ask(handle); + probe.MinHeight = ProbeMinHeight; + probe.Close(); + + report.AppendLine($"좁은 화면 흉내 — 최소 높이 {workHeight + 200} 요구, 작업영역 높이 {workHeight}"); + report.AppendLine($" 답 ptMinTrackSize=({cramped.ptMinTrackSize.X},{cramped.ptMinTrackSize.Y}) ptMaxSize=({cramped.ptMaxSize.X},{cramped.ptMaxSize.Y})"); + failures += Check(report, "⑥ 작업영역보다 큰 최소 크기는 눌린다", + cramped.ptMaxSize.Y > 0 && cramped.ptMinTrackSize.Y <= cramped.ptMaxSize.Y); + + report.AppendLine($"실제 최대화 창 사각형 {Show(maximized)} 작업영역 {Show(monitor.Work)}"); + report.AppendLine($"리사이즈 띠 복원={restoredBorder.Top} 최대화={maximizedBorder.Top} 복원후={afterRestore.Top} 재부착후={afterReattach.Top}"); + + failures += Check(report, "⑧ OS 가 답을 지킨다 — 실제 최대화 창이 작업영역 안이다", + maximized.Left >= monitor.Work.Left && maximized.Top >= monitor.Work.Top + && maximized.Right <= monitor.Work.Right && maximized.Bottom <= monitor.Work.Bottom); + failures += Check(report, "⑨ 최대화하면 리사이즈 띠가 걷히고 복원하면 돌아온다", + restoredBorder.Top > 0 && maximizedBorder == new Thickness(0) && afterRestore == restoredBorder); + failures += Check(report, "⑩ 두 번 붙여도 복원 두께를 잃지 않는다", afterReattach == restoredBorder); + return failures; + } + + private static Window NewProbe(double minWidth, double minHeight) => new() + { + WindowStyle = WindowStyle.None, + ShowInTaskbar = false, + MinWidth = minWidth, + MinHeight = minHeight, + Width = minWidth, + Height = Math.Min(minHeight, 400), + }; + + /// 메시지를 실제로 보내고 되읽는다 — 창의 WndProc 사슬(WPF + 우리 훅)이 전부 돈다 + private static MINMAXINFO Ask(IntPtr handle) + { + var buffer = Marshal.AllocHGlobal(Marshal.SizeOf()); + try + { + // 0 이 아니라 센티넬을 채워 보낸다. 0 으로 두면 기대값이 (0,0) 인 모니터에서 + // 훅이 아무것도 안 써도 검사가 통과해 버린다 — 실제로 그 상태를 한 번 놓칠 뻔했다. + Marshal.StructureToPtr(Sentinel, buffer, fDeleteOld: false); + SendMessage(handle, WmGetMinMaxInfo, IntPtr.Zero, buffer); + return Marshal.PtrToStructure(buffer); + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + /// 모니터 상대 답을 절대 좌표 사각형으로 + private static RECT Absolute(MINMAXINFO answer, RECT bounds) => new() + { + Left = bounds.Left + answer.ptMaxPosition.X, + Top = bounds.Top + answer.ptMaxPosition.Y, + Right = bounds.Left + answer.ptMaxPosition.X + answer.ptMaxSize.X, + Bottom = bounds.Top + answer.ptMaxPosition.Y + answer.ptMaxSize.Y, + }; + + private static RECT? TaskbarRect() + { + var data = new APPBARDATA { cbSize = (uint)Marshal.SizeOf() }; + return SHAppBarMessage(AbmGetTaskbarPos, ref data) == IntPtr.Zero ? null : data.rc; + } + + private static APPBARDATA Empty() => new() { cbSize = (uint)Marshal.SizeOf() }; + + private static List ListMonitors() + { + var list = new List(); + var index = 0; + EnumDisplayMonitors(IntPtr.Zero, IntPtr.Zero, (IntPtr monitor, IntPtr _, ref RECT _, IntPtr _) => + { + var info = new MONITORINFO { cbSize = Marshal.SizeOf() }; + if (GetMonitorInfo(monitor, ref info)) + { + var bars = new bool[4]; + for (uint edge = 0; edge < 4; edge++) + { + var data = new APPBARDATA + { + cbSize = (uint)Marshal.SizeOf(), + uEdge = edge, + rc = info.rcMonitor, + }; + bars[edge] = SHAppBarMessage(AbmGetAutoHideBarEx, ref data) != IntPtr.Zero; + } + list.Add(new MonitorFacts($"#{++index}", info.rcMonitor, info.rcWork, + (info.dwFlags & MonitorinfofPrimary) != 0, bars)); + } + return true; + }, IntPtr.Zero); + return list; + } + + private static bool Intersects(RECT a, RECT b) + => a.Left < b.Right && a.Right > b.Left && a.Top < b.Bottom && a.Bottom > b.Top; + + /// 레이아웃과 창 상태 전이가 한 바퀴 돌게 한다 + private static void Pump() + { + for (var i = 0; i < 3; i++) + { + Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.ContextIdle); + } + } + + private static string Show(RECT r) => $"({r.Left},{r.Top})~({r.Right},{r.Bottom})"; + + private static int Check(StringBuilder report, string label, bool ok) + { + report.AppendLine($" {(ok ? "PASS" : "FAIL")} {label}"); + return ok ? 0 : 1; + } + + private static int Finish(StringBuilder report, string? outputPath, int code) + { + var text = report.ToString(); + if (!string.IsNullOrWhiteSpace(outputPath)) + { + File.WriteAllText(outputPath, text, Encoding.UTF8); + } + Console.Error.Write(text); + return code; + } + #endregion +} diff --git a/src/SheetMe.Designer/Services/MaximizeToWorkArea.cs b/src/SheetMe.Designer/Services/MaximizeToWorkArea.cs new file mode 100644 index 0000000..c17065e --- /dev/null +++ b/src/SheetMe.Designer/Services/MaximizeToWorkArea.cs @@ -0,0 +1,405 @@ +using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Interop; +using System.Windows.Shell; + +namespace SheetMe.Designer.Services; + +/// +/// 최대화 크기를 작업영역으로 제한한다 — 작업표시줄을 덮지 않게. +/// +/// 왜 필요한가. 메인 창은 WindowStyle="None" + WindowChrome 으로 크롬을 직접 그린다. +/// 그 조합에서 OS 는 최대화 사각형을 작업영역(rcWork)이 아니라 모니터 전체(rcMonitor)를 기준으로 +/// 잡고, 거기에 리사이즈 프레임 두께만큼 더 부풀린다. 실측으로 창이 (-7,-7)~(1927,1087) 이 되어 +/// 작업영역 (0,0)~(1920,1032) 를 아래로 55px 넘겼다 — 작업표시줄이 통째로 가려졌다. +/// 표준 크롬 창에는 이 문제가 없다(OS 가 알아서 작업영역에 맞춘다). 그래서 이 보정은 +/// WindowStyle=None 창에만 건다. +/// +/// 고치는 방법은 WM_GETMINMAXINFO 한 통이다. OS 가 "최대화하면 어디에 얼마만큼"을 물어볼 때 +/// 작업영역을 그대로 답한다. 레이아웃·XAML·테마는 건드리지 않는다. +/// +/// 전제 — 이 프로세스는 System DPI aware 다. 매니페스트에 DPI 선언이 없어 WPF 가 기동 중 +/// SetProcessDPIAware() 로 올린 상태다. 그래서 MINMAXINFO·MONITORINFO·APPBARDATA 가 모두 +/// 같은 좌표 공간(시스템 DPI 기준 물리 픽셀)에 있고, rcWork 를 변환 없이 넣을 수 있다. +/// 누군가 app.manifest 로 PerMonitorV2 를 선언하면 이 전제가 무너진다 — 좌표가 모니터별 실제 픽셀이 되고 +/// WM_DPICHANGED 가 들어오기 시작하므로 이 파일을 다시 검토해야 한다. +/// PixelsPerDip 이나 TransformToDevice 를 곱하지 말 것. 지금 곱하면 125%/150% 에서 이중 스케일이 된다. +/// +/// 조회에 실패하면 아무것도 쓰지 않는다 — 예전처럼 작업표시줄을 덮을 뿐 크래시는 없다. +/// +public static class MaximizeToWorkArea +{ + #region Member Fields + private const int WmGetMinMaxInfo = 0x0024; + + /// 가장 가까운 모니터 — PRIMARY 를 쓰면 보조 모니터에서 최대화할 때 주 모니터 사각형을 쓴다 + private const uint MonitorDefaultToNearest = 0x00000002; + + private const uint AbmGetState = 0x00000004; + private const uint AbmGetTaskbarPos = 0x00000005; + private const uint AbmGetAutoHideBarEx = 0x0000000B; + private const int AbsAutoHide = 0x00000001; + + private const uint AbeLeft = 0; + private const uint AbeTop = 1; + private const uint AbeRight = 2; + private const uint AbeBottom = 3; + + /// + /// 자동 숨김 작업표시줄에게 양보하는 두께. + /// + /// 자동 숨김이면 작업영역이 모니터 전체와 같아진다. 그대로 꽉 채우면 셸이 이 창을 + /// '전체 화면 앱'으로 보고 가장자리 호버에 반응하지 않는다 — 작업표시줄이 영영 안 나온다. + /// 1px 로 안 나오는 셸 버전이 있으면 이 값만 2 로 올린다. + /// + private const int AutoHideReserve = 1; + + /// 이 창에 이미 붙였는가 — 중복 부착 표식(창과 함께 사라진다) + private static readonly DependencyProperty AttachedProperty = DependencyProperty.RegisterAttached( + "Attached", typeof(bool), typeof(MaximizeToWorkArea), new PropertyMetadata(false)); + + [StructLayout(LayoutKind.Sequential)] + private struct RECT + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + [StructLayout(LayoutKind.Sequential)] + private struct POINT + { + public int X; + public int Y; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MONITORINFO + { + public int cbSize; + public RECT rcMonitor; + public RECT rcWork; + public uint dwFlags; + } + + [StructLayout(LayoutKind.Sequential)] + private struct APPBARDATA + { + public uint cbSize; + public IntPtr hWnd; + public uint uCallbackMessage; + public uint uEdge; + public RECT rc; + public IntPtr lParam; + } + + /// + /// OS 가 최대화 크기를 물어보는 구조체. + /// 필드 순서를 바꾸면 조용히 오작동한다 — 네이티브 레이아웃 그대로여야 한다. + /// + [StructLayout(LayoutKind.Sequential)] + private struct MINMAXINFO + { + public POINT ptReserved; + public POINT ptMaxSize; + public POINT ptMaxPosition; + public POINT ptMinTrackSize; + public POINT ptMaxTrackSize; + } + #endregion + + #region Methods + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = false)] + private static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint flags); + + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = false)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetMonitorInfo(IntPtr monitor, ref MONITORINFO info); + + [DllImport("shell32.dll", CharSet = CharSet.Unicode, SetLastError = false)] + private static extern IntPtr SHAppBarMessage(uint message, ref APPBARDATA data); + + /// + /// 이 창의 최대화 크기를 작업영역으로 제한한다 — 창 핸들이 생긴 뒤(SourceInitialized)에 불러야 한다. + /// + /// WindowChrome 은 창마다 따로 만든 인스턴스여야 한다. Style Setter 로 공유되면 + /// 그 인스턴스가 봉인(Freezable)돼 최대화 시 리사이즈 띠를 끄는 쓰기가 예외를 던진다. + /// + public static void Attach(Window window) + { + // 표준 크롬 창은 이미 작업영역 기준으로 최대화된다. 거기에 창 사각형을 rcWork 로 고정하면 + // 프레임 두께만큼 클라이언트가 줄고 테두리가 안쪽에 드러난다. + if (window.WindowStyle != WindowStyle.None) + { + return; + } + + // 두 번 붙으면 최대화 중에 캡처한 0 을 '복원 시 두께'로 기억해 버려, + // 복원해도 리사이즈 띠가 영영 0 인 창이 된다(가장자리를 끌어 크기를 못 바꾼다). + if ((bool)window.GetValue(AttachedProperty)) + { + return; + } + window.SetValue(AttachedProperty, true); + + // HWND 로 찾는다. PresentationSource.FromVisual 은 창이 아직 보이기 전에 null 을 돌려줘 + // 조용히 아무것도 안 붙는다(진단 --maxrect 가 이 상태를 잡았다). + var handle = new WindowInteropHelper(window).Handle; + if (handle == IntPtr.Zero || HwndSource.FromHwnd(handle) is not { } source) + { + return; + } + + // AddHook 과 RemoveHook 은 같은 델리게이트 인스턴스여야 한다 + HwndSourceHook hook = OnMessage; + source.AddHook(hook); + // Closing 이 아니라 Closed 다. 닫기 확인에서 취소되면(MainView.OnWindowClosing) 창이 살아남는데, + // 그때 훅을 이미 떼었으면 버그가 조용히 재발한다. + window.Closed += (_, _) => source.RemoveHook(hook); + + TrackResizeBorder(window); + } + + /// + /// 최대화 중에는 리사이즈 띠를 걷는다. + /// + /// 창을 작업영역에 정확히 맞추고 나서 드러난 증상이다. 전에는 창이 화면 밖으로 7px 밀려 있어 + /// WindowChrome 의 상단 6px 리사이즈 띠가 화면 밖에 있었다. 이제 그 띠가 타이틀바 맨 위로 올라와 + /// 최대화 상태에서 타이틀바 위쪽 6px 이 리사이즈 커서가 되고 그 부분 클릭이 먹지 않는다 + /// (실측: 최대화 시 y=1·3·5 에서 HTTOP, y=7 부터 HTCAPTION). 오른쪽 위 닫기 버튼의 윗변이 + /// 그 띠에 걸리므로 "마우스를 구석으로 던져 닫기"가 안 된다. + /// + /// 어차피 최대화된 창은 가장자리를 끌어 크기를 바꿀 수 없다 — 띠가 있을 이유가 없다. + /// 여백(Margin) 보정이 아니라 히트테스트 영역 보정이다. 창 사각형은 그대로 작업영역이다. + /// + private static void TrackResizeBorder(Window window) + { + if (WindowChrome.GetWindowChrome(window) is not { } chrome) + { + return; + } + var restored = chrome.ResizeBorderThickness; + void Sync() => chrome.ResizeBorderThickness = + window.WindowState == WindowState.Maximized ? default : restored; + window.StateChanged += (_, _) => Sync(); + Sync(); + } + + private static IntPtr OnMessage(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) + { + if (msg != WmGetMinMaxInfo || lParam == IntPtr.Zero) + { + return IntPtr.Zero; + } + + try + { + // 보통은 handled 를 false 로 둔다. true 로 막으면 WPF 자신의 처리가 잘려 + // MinWidth=940 / MinHeight=640 이 사라진다(창을 그 아래로 줄일 수 있게 된다). + // WPF 는 우리 뒤에 돌면서 트래킹 크기만 손대므로 우리 값은 그대로 살아남는다. + // 예외는 하나 — 최소 크기가 화면에 안 들어가는 경우다(Fill 안 설명 참조). + handled = Fill(hwnd, lParam); + } + catch (DllNotFoundException) + { + // user32/shell32 가 없는 환경은 사실상 없지만, 여기서 터지면 창이 안 뜬다. + // 아무것도 안 쓰면 예전 동작(작업표시줄을 덮음)으로 남을 뿐이다. + } + catch (EntryPointNotFoundException) + { + } + + return IntPtr.Zero; + } + + /// WPF 의 처리를 막아야 하면 true + private static bool Fill(IntPtr hwnd, IntPtr lParam) + { + var monitor = MonitorFromWindow(hwnd, MonitorDefaultToNearest); + if (monitor == IntPtr.Zero) + { + return false; + } + + var info = new MONITORINFO { cbSize = Marshal.SizeOf() }; + if (!GetMonitorInfo(monitor, ref info)) + { + return false; + } + + var max = Reserve(info.rcWork, info.rcMonitor); + var width = max.Right - max.Left; + var height = max.Bottom - max.Top; + if (width <= 0 || height <= 0) + { + return false; + } + + // 구조체 전체를 읽어 두 필드만 바꾸고 되쓴다. 새로 만들어 덮으면 OS 가 미리 채워 보낸 + // 트래킹 크기 기본값이 지워진다 — lParam 은 기본값이 이미 들어찬 채로 온다. + var mmi = Marshal.PtrToStructure(lParam); + // ptMaxPosition 은 절대 좌표가 아니라 rcMonitor 좌상단 기준 상대 좌표다. + // rcWork 는 항상 rcMonitor 안쪽이라 이 차는 음수가 될 수 없다(Abs 를 씌우지 말 것). + mmi.ptMaxPosition = new POINT { X = max.Left - info.rcMonitor.Left, Y = max.Top - info.rcMonitor.Top }; + mmi.ptMaxSize = new POINT { X = width, Y = height }; + + // 여기서 멈추면 좁은 화면에서 보정이 조용히 무효가 된다. + // + // OS 는 최대화 크기를 최소 트래킹 크기까지 되밀어 올린다. 그 값은 우리 뒤에 도는 WPF 가 + // MinWidth/MinHeight(940x640 DIP)로 채운다. 작업영역이 그보다 작으면 최대화 창이 도로 부풀어 + // 작업표시줄을 다시 덮는다 — 1366x768 을 125% 로 쓰면 최소 높이가 800px 인데 작업영역은 720px 다. + // 우리가 먼저 눌러 봐야 WPF 가 도로 덮어쓴다(진단 --maxrect 가 그 상태를 잡았다). + // + // 그래서 그때만 마지막 말을 한다: WPF 의 처리를 막고 트래킹 크기를 직접 채운다. + // 화면에 들어가지도 않는 최소 크기는 최소 크기 구실을 못 한다 — 그 화면에서는 최소 크기가 양보한다. + // 보통(작업영역이 최소 크기보다 큰 경우)에는 이 가지를 타지 않으므로 + // MinWidth/MinHeight 는 WPF 가 계산한 그대로 살아 있다. + var window = SourceWindow(hwnd); + if (MinimumInPixels(window, hwnd) is not { } min || (min.X <= width && min.Y <= height)) + { + Marshal.StructureToPtr(mmi, lParam, fDeleteOld: false); + return false; + } + + mmi.ptMinTrackSize = new POINT { X = Math.Min(min.X, width), Y = Math.Min(min.Y, height) }; + // 이 가지에서는 WPF 가 안 도므로 MaxWidth/MaxHeight 도 여기서 반영해 준다. + // (MainView 에는 없다 — 있으면 ptMaxTrackSize 가 ptMaxSize 를 잘라 최대화가 작아진다.) + if (window is not null) + { + if (ToPixels(window.MaxWidth, hwnd, horizontal: true) is { } maxWidth) + { + mmi.ptMaxTrackSize.X = maxWidth; + } + if (ToPixels(window.MaxHeight, hwnd, horizontal: false) is { } maxHeight) + { + mmi.ptMaxTrackSize.Y = maxHeight; + } + } + Marshal.StructureToPtr(mmi, lParam, fDeleteOld: false); + return true; + } + + private static Window? SourceWindow(IntPtr hwnd) + => HwndSource.FromHwnd(hwnd)?.RootVisual as Window; + + /// MinWidth/MinHeight 를 픽셀로 — 창을 못 찾거나 제약이 없으면 null + private static POINT? MinimumInPixels(Window? window, IntPtr hwnd) + { + if (window is null) + { + return null; + } + var x = ToPixels(window.MinWidth, hwnd, horizontal: true); + var y = ToPixels(window.MinHeight, hwnd, horizontal: false); + return x is null && y is null ? null : new POINT { X = x ?? 0, Y = y ?? 0 }; + } + + /// + /// DIP → 픽셀. 변환 배율은 WPF 에게 묻는다 — 우리가 DPI 를 직접 읽어 곱하면 + /// System DPI aware 전제가 깨진 날 이중 스케일이 된다. + /// + private static int? ToPixels(double dip, IntPtr hwnd, bool horizontal) + { + if (double.IsNaN(dip) || double.IsInfinity(dip) || dip <= 0) + { + return null; + } + var transform = HwndSource.FromHwnd(hwnd)?.CompositionTarget?.TransformToDevice; + var scale = horizontal ? transform?.M11 : transform?.M22; + return (int)Math.Ceiling(dip * (scale is > 0 ? scale.Value : 1.0)); + } + + /// + /// 자동 숨김 작업표시줄이 붙은 변에서 만큼 물러선다. + /// + /// 판정은 변 단위다. "rcWork == rcMonitor 일 때만"으로 묶으면 + /// 하단 자동 숨김 + 우측 도킹 앱바 조합에서 감지에 실패한다. + /// + private static RECT Reserve(RECT work, RECT monitor) + { + var bars = new[] + { + HasAutoHideBar(monitor, AbeLeft), + HasAutoHideBar(monitor, AbeTop), + HasAutoHideBar(monitor, AbeRight), + HasAutoHideBar(monitor, AbeBottom), + }; + + // 폴백은 '한 변도 못 찾았을 때'가 아니라 '그 변을 못 찾았을 때' 돈다. + // 전자로 두면 좌측의 서드파티 자동 숨김 바 하나를 찾은 것 때문에 + // EX 가 놓친 하단 작업표시줄이 구제받지 못한다. + if (FallbackEdge(monitor) is { } edge && !bars[edge]) + { + bars[edge] = true; + } + + // 변마다 '그 변이 모니터 가장자리에 닿아 있을 때'만 물러선다. + // 닿아 있지 않다면 다른 앱바가 이미 자리를 예약해 창이 가장자리에 닿지 않는다 — 물러서면 틈만 생긴다. + // 좌·상은 크기만 줄이면 안 되고 위치까지 밀어야 한다. Fill 이 이 사각형에서 ptMaxPosition 을 + // 파생시키므로 여기서 left/top 을 옮기는 것으로 충분하다 — 순서를 바꾸지 말 것. + if (bars[AbeLeft] && work.Left == monitor.Left) + { + work.Left += AutoHideReserve; + } + if (bars[AbeTop] && work.Top == monitor.Top) + { + work.Top += AutoHideReserve; + } + if (bars[AbeRight] && work.Right == monitor.Right) + { + work.Right -= AutoHideReserve; + } + if (bars[AbeBottom] && work.Bottom == monitor.Bottom) + { + work.Bottom -= AutoHideReserve; + } + return work; + } + + /// + /// ABM_GETAUTOHIDEBAREX 가 놓쳤을 때의 2차 판정 — 자동 숨김 바가 붙은 변, 없으면 null. + /// + /// 주 작업표시줄만 본다. ABM_GETSTATE 도 ABM_GETTASKBARPOS 도 주 작업표시줄 전용이라 + /// 보조 모니터의 작업표시줄은 여기서도 안 잡힌다(교차 검사에서 걸러진다). 그러니 이 폴백이 실제로 + /// 덮는 것은 "주 모니터에서 EX 조회가 실패한 경우"뿐이다 — 보조 모니터까지 구제하려면 + /// 모니터별 앱바 열거라는 다른 수단이 필요하다. + /// + /// 끝내 못 정하면 양보하지 않는다. "모르면 하단에 1px" 로 두면 + /// 자동 숨김을 쓰지 않는 모든 사용자에게 최대화 창 아래로 바탕화면 한 줄이 상시 보인다. + /// + private static uint? FallbackEdge(RECT monitor) + { + var data = new APPBARDATA { cbSize = (uint)Marshal.SizeOf() }; + if ((SHAppBarMessage(AbmGetState, ref data).ToInt64() & AbsAutoHide) == 0) + { + return null; + } + + var pos = new APPBARDATA { cbSize = (uint)Marshal.SizeOf() }; + if (SHAppBarMessage(AbmGetTaskbarPos, ref pos) == IntPtr.Zero || !Intersects(pos.rc, monitor)) + { + return null; + } + return pos.uEdge <= AbeBottom ? pos.uEdge : null; + } + + /// + /// 이 모니터의 그 변에 자동 숨김 바가 있는가. + /// rc 로 모니터를 지목할 수 있는 EX 판을 쓴다 — 구판(ABM_GETAUTOHIDEBAR)은 주 모니터만 본다. + /// + private static bool HasAutoHideBar(RECT monitor, uint edge) + { + var data = new APPBARDATA + { + cbSize = (uint)Marshal.SizeOf(), + uEdge = edge, + rc = monitor, + }; + return SHAppBarMessage(AbmGetAutoHideBarEx, ref data) != IntPtr.Zero; + } + + private static bool Intersects(RECT a, RECT b) + => a.Left < b.Right && a.Right > b.Left && a.Top < b.Bottom && a.Bottom > b.Top; + #endregion +} diff --git a/src/SheetMe.Designer/Views/MainView.xaml.cs b/src/SheetMe.Designer/Views/MainView.xaml.cs index f3209be..9ebc3a4 100644 --- a/src/SheetMe.Designer/Views/MainView.xaml.cs +++ b/src/SheetMe.Designer/Views/MainView.xaml.cs @@ -19,6 +19,19 @@ public partial class MainView : Window ZoomPopup.Closed += (_, _) => zoomPopupClosedAt = Environment.TickCount; } + /// + /// 창 핸들이 막 생긴 시점 — 최대화 보정을 건다. + /// + /// Loaded 가 아니라 여기다. Loaded 는 창이 이미 보인 뒤라 창 생성 시점의 첫 WM_GETMINMAXINFO 를 놓친다. + /// 지금은 기동 시 최대화하지 않아 결과가 같지만, '마지막 창 상태 기억'이 생기면 + /// 기동 순간 잘못된 크기가 한 프레임 번쩍인다. + /// + protected override void OnSourceInitialized(EventArgs e) + { + base.OnSourceInitialized(e); + MaximizeToWorkArea.Attach(this); + } + /// 기동 — 테마 메뉴 체크 동기화 + 뷰모델 Loaded 커맨드 실행(구 CustomWindow.LoadedCommand 대체) private void OnWindowLoaded(object sender, RoutedEventArgs e) { diff --git a/src/SheetMe.Designer/Views/MessageDialogView.xaml b/src/SheetMe.Designer/Views/MessageDialogView.xaml index b3a0502..1553101 100644 --- a/src/SheetMe.Designer/Views/MessageDialogView.xaml +++ b/src/SheetMe.Designer/Views/MessageDialogView.xaml @@ -17,6 +17,10 @@ AllowsTransparency 는 켜지 않는다: 레이어드 윈도우가 되면 ClearType 이 꺼져 13px 한글이 뭉개지고, 진단 렌더러가 찍는 PNG 배경이 알파가 되어 라이트 결함이 가려진다. + + ResizeMode="NoResize" 를 CanResize 로 바꾸지 말 것. 이 창은 WindowStyle=None 이라 + 최대화가 열리는 순간 작업표시줄을 덮는다(메인 창에서 실제로 겪은 결함이다). + 꼭 바꿔야 한다면 SourceInitialized 에서 Services.MaximizeToWorkArea.Attach(this) 를 함께 걸어야 한다. -->