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) 를 함께 걸어야 한다.
-->