3단계 계속. <b>① Ctrl+S 가 DB 문서를 디스크에 쓰고 있었다.</b> Ctrl+S 는 SaveFileCommand 에 걸려 있고 그건 항상 파일 저장이다. DB 에서 연 서식에서 누르면 OS 저장 대화상자가 뜨고 XML 이 디스크에 쓰였다 — 사용자는 저장했다고 믿지만 <b>DB 에는 아무것도 안 들어간다</b>. 종료 가드는 이미 IsFromDb 로 갈라 준다(MainViewModel.cs:365). Ctrl+S 만 안 갈라 주고 있었다. <b>② 저장 완료 알림 모달을 없앤다.</b> 정보량이 0 이었다 — SdgKey 를 바로 위에서 상태바에 넣고 같은 값을 모달로 한 번 더 보여 주는 것이었다. 하루 30회 저장하는 사람에게 그건 Enter 를 30번 치는 의식이고, 그때마다 화면이 어두워졌다. 실패는 계속 모달로 알린다 — 조용히 넘기면 저장된 줄 알고 넘어간다. <b>③ 스크림의 기본을 뒤집었다.</b> 전에는 모든 ShowDialog 에 무조건 붙어서 태그 하나 고르는 데도 앱이 암전했다. 하루 수백 번이면 신호가 아니라 소음이다. 전역 후크는 그대로 둔다 — 호출부 17곳 중 하나만 빠져도 그 대화상자만 다르게 동작하고 그건 눈으로 전수 확인해야만 안다. 대신 창이 스스로 자격을 밝혀야 깔리게 했다 (ModalScrim.DimBehind). 새 대화상자가 잊으면 암전이 안 되는데, 이제 그쪽이 안전한 기본값이다. 자격을 밝힌 것: 물음(되돌릴 수 없는 쓰기의 확인)·오류(진행이 정의되지 않는 차단)·서식 신규 등록. --modal-check 에 ⑤ 를 더했다: <b>자격을 밝히지 않은 창은 어두워지지 않는다</b>. ①~④ 만으로는 "깔린다"만 확인되고 "안 깔려야 할 때 안 깔린다"는 확인되지 않는다. <b>④ 배치 명령에 단축키와 우클릭 메뉴를 붙였다.</b> 전에는 단축키가 0개였고 우클릭 메뉴가 앱 전체에 하나도 없었다. 정렬 6종·같은 크기·간격이 전부 로고 아래 4단 메뉴 안에만 있어서, 20px→32px 간격 조정이 4px 씩 12왕복이고 매번 마우스가 화면 좌상단까지 갔다. 자리는 Figma 와 같게 둔다(Alt+A/D/W/S/H/V) — 이미 그 손버릇을 가진 사람이 있다. 균등 Alt+Shift+H/V, 같은 크기 Alt+Shift+W/S, 간격 Alt+[ / Alt+]. <b>Alt 조합에 함정이 있다.</b> WPF 는 Alt+문자를 Key.System 으로 싸서 넘기고 실제 키는 SystemKey 에 있다. e.Key 만 보면 Alt 단축키가 하나도 안 걸리는데, 그 실패는 "눌러도 아무 일이 없다"로만 보여서 원인을 찾기 어렵다. 우클릭 메뉴에 InputGestureText 를 적었다 — 단축키를 모르는 사람이 거기서 배운다. 게이트: 테스트 285/285, --edit-smoke 0실패, --modal-check 0실패(신규 1), --dialog-shots 넘침 0(대조군 4/4), --scale-budget 5/5, --maxrect 0실패, --cleartype 11/11, 빌드 경고 0, --db-smoke 1271건 diff 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
170 lines
8.0 KiB
C#
170 lines
8.0 KiB
C#
using System.Windows;
|
|
using System.Windows.Media;
|
|
|
|
|
|
namespace SheetMe.Designer.Services;
|
|
|
|
/// <summary>
|
|
/// 모달 대화상자 뒤를 어둡게 덮는다.
|
|
///
|
|
/// <b>왜 필요한가.</b> WPF 의 <c>ShowDialog()</c> 는 입력만 막고 화면은 그대로 둔다.
|
|
/// 대화상자가 앱과 같은 밝기로 떠 있으면 "이게 떠 있는 동안은 뒤를 못 만진다"가 안 읽혀서,
|
|
/// 뒤를 클릭했다가 아무 반응이 없으면 멈춘 것으로 오해한다.
|
|
///
|
|
/// <b>왜 호출부마다 안 고쳤나.</b> ShowDialog 호출부가 17곳이고 앞으로 더 는다.
|
|
/// 한 곳이라도 빠지면 그 대화상자만 다르게 동작하는데, 그건 눈으로 전수 확인해야만 알 수 있다.
|
|
/// 창이 뜨는 순간을 전역으로 잡아 <b>모달일 때만</b> 건다(WindowChromeTheme 이 제목 표시줄 색을
|
|
/// 같은 방식으로 거는 것과 같은 자리다).
|
|
///
|
|
/// 진단 모드는 창을 모달이 아니라 화면 밖에 그냥 띄우므로 이 경로를 타지 않는다 —
|
|
/// --dialog-shots 산출물은 영향받지 않는다.
|
|
/// </summary>
|
|
public static class ModalScrim
|
|
{
|
|
#region Member Fields
|
|
/// <summary>지금 떠 있는 가림막 — 대화상자가 겹쳐 뜨면 그만큼 쌓인다</summary>
|
|
private static readonly Stack<Window> Layers = new();
|
|
#endregion
|
|
|
|
#region Methods
|
|
/// <summary>
|
|
/// 이 창이 모달이면 뒤에 가림막을 깐다 — 창이 <b>보이기 시작할 때</b> 불러야 한다.
|
|
/// </summary>
|
|
/// <summary>
|
|
/// 이 창이 뒤를 어둡게 할 자격이 있는가 — <b>기본값은 아니다</b>.
|
|
///
|
|
/// 전에는 모든 <c>ShowDialog</c> 에 무조건 붙었다. 그래서 태그 하나 고르는 데도 앱이 암전했고,
|
|
/// 하루 수백 번 그러면 그건 신호가 아니라 소음이다.
|
|
///
|
|
/// 암전이 옳은 자리는 <b>지금 멈추고 결정해야 하는 곳</b>뿐이다 —
|
|
/// 되돌릴 수 없는 쓰기의 확인(운영 EMR 테이블에 버전을 만든다), 진행이 정의되지 않는 차단 통보.
|
|
/// 값 하나를 고르는 일은 Undo 로 되돌아가고 판단 근거가 뒤 화면에 있으므로 가리면 안 된다.
|
|
///
|
|
/// 전역 후크는 그대로 둔다 — 호출부 17곳 중 하나만 빠져도 그 대화상자만 다르게 동작하고,
|
|
/// 그건 눈으로 전수 확인해야만 알 수 있다. 대신 <b>기본을 뒤집었다</b>:
|
|
/// 후크는 항상 돌지만, 창이 스스로 자격을 밝혀야 깔린다.
|
|
/// 새 대화상자가 이것을 잊으면 암전이 안 되는데, 이제 그쪽이 안전한 기본값이다.
|
|
/// </summary>
|
|
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 가 온다)
|
|
// <b>그다음에</b> 모달 루프에 들어가므로, 이 시점의 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();
|
|
// 가림막을 대화상자 <b>바로 아래</b>로 내린다.
|
|
//
|
|
// 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);
|
|
|
|
/// <summary>
|
|
/// <paramref name="scrim"/> 을 <paramref name="above"/> 바로 아래로 놓는다.
|
|
/// SetWindowPos 의 insertAfter 는 "이 창 다음"이라는 뜻이라, 대화상자를 넘기면 그 아래가 된다.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>뒤 창을 정확히 덮는다 — 최대화 상태도 그 사각형 그대로다</summary>
|
|
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
|
|
}
|