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 /// /// 이 창이 모달이면 뒤에 가림막을 깐다 — 창이 보이기 시작할 때 불러야 한다. /// /// /// 이 창이 뒤를 어둡게 할 자격이 있는가 — 기본값은 아니다. /// /// 전에는 모든 ShowDialog 에 무조건 붙었다. 그래서 태그 하나 고르는 데도 앱이 암전했고, /// 하루 수백 번 그러면 그건 신호가 아니라 소음이다. /// /// 암전이 옳은 자리는 지금 멈추고 결정해야 하는 곳뿐이다 — /// 되돌릴 수 없는 쓰기의 확인(운영 EMR 테이블에 버전을 만든다), 진행이 정의되지 않는 차단 통보. /// 값 하나를 고르는 일은 Undo 로 되돌아가고 판단 근거가 뒤 화면에 있으므로 가리면 안 된다. /// /// 전역 후크는 그대로 둔다 — 호출부 17곳 중 하나만 빠져도 그 대화상자만 다르게 동작하고, /// 그건 눈으로 전수 확인해야만 알 수 있다. 대신 기본을 뒤집었다: /// 후크는 항상 돌지만, 창이 스스로 자격을 밝혀야 깔린다. /// 새 대화상자가 이것을 잊으면 암전이 안 되는데, 이제 그쪽이 안전한 기본값이다. /// public static readonly DependencyProperty DimBehindProperty = DependencyProperty.RegisterAttached("DimBehind", typeof(bool), typeof(ModalScrim), new PropertyMetadata(false)); public static void SetDimBehind(DependencyObject target, bool value) => target.SetValue(DimBehindProperty, value); public static bool GetDimBehind(DependencyObject target) => (bool)target.GetValue(DimBehindProperty); public static void Attach(Window window) { // 모달 판정을 여기서 바로 하면 안 된다. ShowDialog() 는 창을 먼저 보이고(그때 Loaded 가 온다) // 그다음에 모달 루프에 들어가므로, 이 시점의 IsThreadModal 은 아직 false 다. // 실제로 그렇게 만들어서 가림막이 한 번도 안 깔렸다(--modal-check 가 그 상태를 잡았다). // 모달 프레임이 돌기 시작한 뒤에 다시 본다 — 한 프레임 늦게 깔리는 것은 눈에 띄지 않는다. window.Dispatcher.BeginInvoke(new Action(() => AttachIfModal(window)), System.Windows.Threading.DispatcherPriority.Background); } private static void AttachIfModal(Window window) { // 모달이 아닌 창(메인 셸, 진단 렌더용 오프스크린 창)은 대상이 아니다. // 그리고 스스로 자격을 밝힌 창만 깐다 — 값을 고르는 대화상자는 뒤를 가리면 안 된다. if (!System.Windows.Interop.ComponentDispatcher.IsThreadModal || !window.IsVisible || !GetDimBehind(window)) { 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(); // 가림막을 대화상자 바로 아래로 내린다. // // Activate() 로는 안 된다. 둘 다 같은 창을 소유자로 갖는 형제라 나중에 띄운 쪽이 위에 오고, // 활성 창을 바꾸는 것과 z순서를 바꾸는 것은 다른 일이다 — // 실제로 대화상자까지 같이 흐려졌다. PlaceBehind(scrim, window); Layers.Push(scrim); window.Closed += (_, _) => { behind.LocationChanged -= Follow; behind.SizeChanged -= Follow; behind.StateChanged -= Follow; if (Layers.Count > 0) { Layers.Pop(); } scrim.Close(); }; } [System.Runtime.InteropServices.DllImport("user32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)] private static extern bool SetWindowPos(IntPtr hwnd, IntPtr insertAfter, int x, int y, int cx, int cy, uint flags); /// /// 바로 아래로 놓는다. /// SetWindowPos 의 insertAfter 는 "이 창 다음"이라는 뜻이라, 대화상자를 넘기면 그 아래가 된다. /// private static void PlaceBehind(Window scrim, Window above) { var scrimHandle = new System.Windows.Interop.WindowInteropHelper(scrim).Handle; var aboveHandle = new System.Windows.Interop.WindowInteropHelper(above).Handle; if (scrimHandle == IntPtr.Zero || aboveHandle == IntPtr.Zero) { return; } // SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE SetWindowPos(scrimHandle, aboveHandle, 0, 0, 0, 0, 0x0002 | 0x0001 | 0x0010); } /// 뒤 창을 정확히 덮는다 — 최대화 상태도 그 사각형 그대로다 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 }