diff --git a/src/SheetMe.Designer/App.xaml.cs b/src/SheetMe.Designer/App.xaml.cs index a655471..a34d533 100644 --- a/src/SheetMe.Designer/App.xaml.cs +++ b/src/SheetMe.Designer/App.xaml.cs @@ -77,6 +77,8 @@ public partial class App : Application if (sender is Window window) { Services.WindowChromeTheme.Apply(window, !Services.ThemeManager.IsLight); + // 모달이면 뒤를 어둡게 — 여기서 한 번 걸어야 새 대화상자에서 빠지지 않는다 + Services.ModalScrim.Attach(window); } })); diff --git a/src/SheetMe.Designer/Services/ModalScrim.cs b/src/SheetMe.Designer/Services/ModalScrim.cs new file mode 100644 index 0000000..88c636b --- /dev/null +++ b/src/SheetMe.Designer/Services/ModalScrim.cs @@ -0,0 +1,108 @@ +using System.Windows; +using System.Windows.Media; + + +namespace SheetMe.Designer.Services; + +/// +/// 모달 대화상자 뒤를 어둡게 덮는다. +/// +/// 왜 필요한가. WPF 의 ShowDialog() 는 입력만 막고 화면은 그대로 둔다. +/// 대화상자가 앱과 같은 밝기로 떠 있으면 "이게 떠 있는 동안은 뒤를 못 만진다"가 안 읽혀서, +/// 뒤를 클릭했다가 아무 반응이 없으면 멈춘 것으로 오해한다. +/// +/// 왜 호출부마다 안 고쳤나. ShowDialog 호출부가 17곳이고 앞으로 더 는다. +/// 한 곳이라도 빠지면 그 대화상자만 다르게 동작하는데, 그건 눈으로 전수 확인해야만 알 수 있다. +/// 창이 뜨는 순간을 전역으로 잡아 모달일 때만 건다(WindowChromeTheme 이 제목 표시줄 색을 +/// 같은 방식으로 거는 것과 같은 자리다). +/// +/// 진단 모드는 창을 모달이 아니라 화면 밖에 그냥 띄우므로 이 경로를 타지 않는다 — +/// --dialog-shots 산출물은 영향받지 않는다. +/// +public static class ModalScrim +{ + #region Member Fields + /// 지금 떠 있는 가림막 — 대화상자가 겹쳐 뜨면 그만큼 쌓인다 + private static readonly Stack Layers = new(); + #endregion + + #region Methods + /// + /// 이 창이 모달이면 뒤에 가림막을 깐다 — 창이 보이기 시작할 때 불러야 한다. + /// + public static void Attach(Window window) + { + // 모달이 아닌 창(메인 셸, 진단 렌더용 오프스크린 창)은 대상이 아니다 + if (!System.Windows.Interop.ComponentDispatcher.IsThreadModal) + { + return; + } + + var behind = window.Owner ?? Application.Current?.MainWindow; + if (behind is null || ReferenceEquals(behind, window) || !behind.IsVisible) + { + return; + } + + var scrim = new Window + { + WindowStyle = WindowStyle.None, + ResizeMode = ResizeMode.NoResize, + AllowsTransparency = true, + Background = new SolidColorBrush(Color.FromArgb(0x66, 0, 0, 0)), + ShowInTaskbar = false, + ShowActivated = false, + IsHitTestVisible = false, + Focusable = false, + WindowStartupLocation = WindowStartupLocation.Manual, + Owner = behind, + }; + Cover(scrim, behind); + // 뒤 창이 움직이거나 크기가 바뀌면 가림막도 따라가야 한다 — 안 그러면 어두운 사각형만 남는다 + void Follow(object? sender, EventArgs e) => Cover(scrim, behind); + behind.LocationChanged += Follow; + behind.SizeChanged += Follow; + behind.StateChanged += Follow; + + scrim.Show(); + // 가림막을 나중에 띄웠으니 대화상자를 다시 앞으로 올린다 + window.Activate(); + Layers.Push(scrim); + + window.Closed += (_, _) => + { + behind.LocationChanged -= Follow; + behind.SizeChanged -= Follow; + behind.StateChanged -= Follow; + if (Layers.Count > 0) + { + Layers.Pop(); + } + scrim.Close(); + }; + } + + /// 뒤 창을 정확히 덮는다 — 최대화 상태도 그 사각형 그대로다 + private static void Cover(Window scrim, Window behind) + { + if (behind.WindowState == WindowState.Minimized) + { + scrim.Visibility = Visibility.Collapsed; + return; + } + scrim.Visibility = Visibility.Visible; + + // 최대화된 창은 Left/Top 이 '복원했을 때의 값'이라 그대로 쓰면 가림막이 엉뚱한 데 깔린다. + // 화면 좌표를 직접 물어 DIP 로 되돌린다. + var origin = new Point(behind.Left, behind.Top); + if (PresentationSource.FromVisual(behind) is { CompositionTarget: { } target }) + { + origin = target.TransformFromDevice.Transform(behind.PointToScreen(new Point(0, 0))); + } + scrim.Left = origin.X; + scrim.Top = origin.Y; + scrim.Width = behind.ActualWidth > 0 ? behind.ActualWidth : behind.Width; + scrim.Height = behind.ActualHeight > 0 ? behind.ActualHeight : behind.Height; + } + #endregion +}