모달 인구를 줄이고 배치 명령에 손잡이를 붙인다

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>
This commit is contained in:
Msystech
2026-08-14 17:18:57 +09:00
co-authored by Claude Opus 5
parent d6052610b9
commit 7e38ba654a
7 changed files with 162 additions and 6 deletions
@@ -40,10 +40,15 @@ public sealed class CanvasKeyboardBehavior : Behavior<UIElement>
var ctrl = Keyboard.Modifiers.HasFlag(ModifierKeys.Control);
var shift = Keyboard.Modifiers.HasFlag(ModifierKeys.Shift);
var alt = Keyboard.Modifiers.HasFlag(ModifierKeys.Alt);
var step = shift ? designer.Snap.GridSize * 2 : 1;
var handled = true;
switch (e.Key)
// Alt 조합은 e.Key 로 오지 않는다 — WPF 가 Key.System 으로 싸서 넘기고 실제 키는 SystemKey 에 있다.
// e.Key 만 보면 Alt 단축키가 하나도 안 걸리는데, 그 실패는 "눌러도 아무 일이 없다"로만 보인다.
var key = e.Key == Key.System ? e.SystemKey : e.Key;
switch (key)
{
case Key.Left: designer.NudgeSelection(-step, 0); break;
case Key.Right: designer.NudgeSelection(step, 0); break;
@@ -67,6 +72,31 @@ public sealed class CanvasKeyboardBehavior : Behavior<UIElement>
case Key.Y when ctrl: designer.Undo.Redo(); break;
case Key.G when ctrl && shift: designer.UngroupSelection(); break;
case Key.G when ctrl: designer.GroupSelection(); break;
// 배치 명령 — 전에는 단축키가 <b>0개</b>였고 전부 로고 아래 4단 메뉴 안에만 있었다.
// 20px→32px 간격 조정이 4px 씩 12왕복이고 매번 마우스가 좌상단까지 갔다.
// 자리는 Figma 와 같게 둔다(Alt+A/D/W/S/H/V) — 이미 그 손버릇을 가진 사람이 있다.
case Key.A when alt: designer.Align("Left"); break;
case Key.D when alt: designer.Align("Right"); break;
case Key.W when alt && !shift: designer.Align("Top"); break;
case Key.S when alt && !shift: designer.Align("Bottom"); break;
case Key.H when alt && !shift: designer.Align("CenterH"); break;
case Key.V when alt && !shift: designer.Align("CenterV"); break;
// 균등 간격 — 3개 이상일 때만 뜻이 있다
case Key.H when alt && shift: designer.AdjustSpacing("HorizEqual"); break;
case Key.V when alt && shift: designer.AdjustSpacing("VertEqual"); break;
// 같은 크기로
case Key.W when alt && shift: designer.SizeToControl("Width"); break;
case Key.S when alt && shift: designer.SizeToControl("Height"); break;
// 간격 넓게/좁게 — 세로가 기본(기록지는 줄이 세로로 쌓인다), Shift 로 가로
case Key.OemCloseBrackets when alt && !shift: designer.AdjustSpacing("VertIncrease"); break;
case Key.OemOpenBrackets when alt && !shift: designer.AdjustSpacing("VertDecrease"); break;
case Key.OemCloseBrackets when alt && shift: designer.AdjustSpacing("HorizIncrease"); break;
case Key.OemOpenBrackets when alt && shift: designer.AdjustSpacing("HorizDecrease"); break;
default: handled = false; break;
}
@@ -69,6 +69,8 @@ public static class ModalCheck
ShowInTaskbar = false,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
};
// 자격을 밝힌 창만 어두워진다 — 아래 ⑤ 가 밝히지 않은 창을 따로 확인한다
Services.ModalScrim.SetDimBehind(dialog, true);
var scrimsWhileOpen = -1;
var dialogAbove = false;
@@ -103,6 +105,32 @@ public static class ModalCheck
Check("가림막이 대화상자 아래에 있다", dialogAbove);
Check("닫으면 가림막이 걷힌다", CountScrims() == 0, $"실제 {CountScrims()}개");
// ⑤ 자격을 밝히지 않은 창은 어두워지지 않는다.
//
// 이것이 이 검사에서 가장 중요한 항목이다. 전에는 모든 ShowDialog 가 무조건 어두워져서
// 태그 하나 고르는 데도 앱이 암전했다 — 하루 수백 번이면 신호가 아니라 소음이다.
// 위 ①~④ 만 있으면 "깔린다"는 확인되지만 "안 깔려야 할 때 안 깔린다"는 확인되지 않는다.
var plainScrims = -1;
var plain = new Window
{
Title = "가림막 검사 — 자격 없는 대화상자",
Width = 300, Height = 200, Owner = owner,
ShowInTaskbar = false,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
};
var plainTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(400) };
plainTimer.Tick += (_, _) =>
{
plainTimer.Stop();
plainScrims = CountScrims();
plain.Close();
};
plainTimer.Start();
plain.ShowDialog();
lines.Add($"자격 없는 대화상자가 떠 있는 동안의 가림막 수 = {plainScrims}");
Check("자격을 밝히지 않으면 어두워지지 않는다", plainScrims == 0, $"실제 {plainScrims}개");
owner.Close();
}
catch (Exception ex)
+29 -2
View File
@@ -30,6 +30,31 @@ public static class ModalScrim
/// <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 가 온다)
@@ -42,8 +67,10 @@ public static class ModalScrim
private static void AttachIfModal(Window window)
{
// 모달이 아닌 창(메인 셸, 진단 렌더용 오프스크린 창)은 대상이 아니다
if (!System.Windows.Interop.ComponentDispatcher.IsThreadModal || !window.IsVisible)
// 모달이 아닌 창(메인 셸, 진단 렌더용 오프스크린 창)은 대상이 아니다.
// 그리고 스스로 자격을 밝힌 창만 깐다 — 값을 고르는 대화상자는 뒤를 가리면 안 된다.
if (!System.Windows.Interop.ComponentDispatcher.IsThreadModal || !window.IsVisible
|| !GetDimBehind(window))
{
return;
}
@@ -189,7 +189,19 @@ internal sealed class MainViewModel : ViewModelBase
LoadedCommand = new Command(async (sender, e) => await OnLoadedAsync());
NewFileCommand = new Command((sender, e) => OnNewFile());
OpenFileCommand = new Command((sender, e) => OnOpenFile());
SaveFileCommand = new Command((sender, e) => OnSaveFile(saveAs: false));
// Ctrl+S 가 이 커맨드에 걸려 있다. 문서 출처로 갈라야 한다 —
// DB 에서 연 서식에서 Ctrl+S 를 누르면 OS 저장 대화상자가 뜨고 디스크에 XML 이 쓰였다.
// 사용자는 저장했다고 믿지만 <b>DB 에는 아무것도 안 들어간다</b>.
// 종료 가드는 이미 IsFromDb 로 갈라 준다(:365) — Ctrl+S 만 안 갈라 주고 있었다.
SaveFileCommand = new Command((sender, e) =>
{
if (CurrentDesigner?.IsFromDb == true)
{
OnSaveToDb();
return;
}
OnSaveFile(saveAs: false);
});
SaveAsFileCommand = new Command((sender, e) => OnSaveFile(saveAs: true));
OpenFromDbCommand = new Command((sender, e) => OnOpenFromDb());
SaveToDbCommand = new Command((sender, e) => OnSaveToDb());
@@ -697,9 +709,12 @@ internal sealed class MainViewModel : ViewModelBase
$"서식 [{document.FormId}] 이(가) 다른 탭에도 열려 있습니다.",
"그 탭은 방금 저장하기 전의 내용입니다. 거기서 저장하면 이번 저장이 덮어써집니다 — 닫고 다시 여세요.");
}
// 완료 알림 모달을 띄우지 않는다. 정보량이 0 이었다 — SdgKey 는 바로 위에서 상태바에 넣고,
// 같은 값을 모달로 한 번 더 보여 주는 것이었다.
// 하루 30회 저장하는 사람에게 그건 Enter 를 30번 치는 의식이고,
// 그때마다 화면 전체가 어두워졌다(스크림). 실패는 계속 모달로 알린다 —
// 그건 진행이 정의되지 않는 상태이고, 조용히 넘기면 저장된 줄 알고 넘어간다.
StatusText = $"DB 저장 완료: {document.FormId} → SdgKey {sdgKey}";
DialogService.Notify(DialogKind.Info, "DB 저장",
$"저장되었습니다. (SdgKey {sdgKey})", "레거시 뷰어/디자이너에서 열어 확인하세요.");
return true;
}
catch (Exception ex)
@@ -17,6 +17,55 @@
<bh:CanvasKeyboardBehavior/>
</b:Interaction.Behaviors>
<!--
캔버스 우클릭 메뉴 — 전에는 앱 전체에 우클릭 메뉴가 하나도 없었다.
배치·정렬·같은 크기·간격·앞뒤·그룹이 전부 로고 아래 4단 메뉴 안에만 있어서,
선택한 것을 정렬하려면 캔버스에서 손을 떼고 화면 좌상단까지 마우스를 옮겨야 했다.
단축키(Alt+A/D/W/S/H/V …)와 같은 명령을 같은 자리에서 보여 준다 —
단축키를 모르는 사람도 여기서 배운다.
-->
<UserControl.ContextMenu>
<ContextMenu>
<MenuItem Header="왼쪽 맞춤" InputGestureText="Alt+A"
Command="{Binding AlignCommand}" CommandParameter="Left"/>
<MenuItem Header="오른쪽 맞춤" InputGestureText="Alt+D"
Command="{Binding AlignCommand}" CommandParameter="Right"/>
<MenuItem Header="위 맞춤" InputGestureText="Alt+W"
Command="{Binding AlignCommand}" CommandParameter="Top"/>
<MenuItem Header="아래 맞춤" InputGestureText="Alt+S"
Command="{Binding AlignCommand}" CommandParameter="Bottom"/>
<MenuItem Header="가로 가운데" InputGestureText="Alt+H"
Command="{Binding AlignCommand}" CommandParameter="CenterH"/>
<MenuItem Header="세로 가운데" InputGestureText="Alt+V"
Command="{Binding AlignCommand}" CommandParameter="CenterV"/>
<Separator/>
<MenuItem Header="가로 간격 균등" InputGestureText="Alt+Shift+H"
Command="{Binding SpacingCommand}" CommandParameter="HorizEqual"/>
<MenuItem Header="세로 간격 균등" InputGestureText="Alt+Shift+V"
Command="{Binding SpacingCommand}" CommandParameter="VertEqual"/>
<MenuItem Header="세로 간격 넓게" InputGestureText="Alt+]"
Command="{Binding SpacingCommand}" CommandParameter="VertIncrease"/>
<MenuItem Header="세로 간격 좁게" InputGestureText="Alt+["
Command="{Binding SpacingCommand}" CommandParameter="VertDecrease"/>
<Separator/>
<MenuItem Header="너비 같게" InputGestureText="Alt+Shift+W"
Command="{Binding SizeToControlCommand}" CommandParameter="Width"/>
<MenuItem Header="높이 같게" InputGestureText="Alt+Shift+S"
Command="{Binding SizeToControlCommand}" CommandParameter="Height"/>
<Separator/>
<MenuItem Header="그룹으로 묶기" InputGestureText="Ctrl+G"
Command="{Binding GroupCommand}"/>
<MenuItem Header="그룹 풀기" InputGestureText="Ctrl+Shift+G"
Command="{Binding UngroupCommand}"/>
<Separator/>
<MenuItem Header="맨 앞으로" Command="{Binding BringToFrontCommand}"/>
<MenuItem Header="맨 뒤로" Command="{Binding SendToBackCommand}"/>
<Separator/>
<MenuItem Header="복제" InputGestureText="Ctrl+D" Command="{Binding DuplicateCommand}"/>
<MenuItem Header="삭제" InputGestureText="Del" Command="{Binding DeleteCommand}"/>
</ContextMenu>
</UserControl.ContextMenu>
<!-- 디자인 캔버스: 스크롤 + 줌(LayoutTransform) 월드. 입력은 World 1곳에서 수신 -->
<ScrollViewer x:Name="Scroll"
HorizontalScrollBarVisibility="Auto"
@@ -47,6 +47,11 @@ public partial class MessageDialogView : Window
// 노트북 해상도에서 창이 화면을 덮지 않도록 한 번 더 조인다
MaxHeight = Math.Min(620, SystemParameters.WorkArea.Height * 0.85);
// 뒤를 어둡게 할 자격 — 지금 멈추고 결정해야 하는 것만.
// 물음(되돌릴 수 없는 쓰기의 확인)과 오류(진행이 정의되지 않는 차단)가 그것이다.
// 알림·경고는 읽고 지나가는 것이라 뒤를 가리지 않는다.
Services.ModalScrim.SetDimBehind(this, kind is DialogKind.Question or DialogKind.Error);
CaptionText.Text = caption;
HeadText.Text = head;
DetailText.Text = detail ?? string.Empty;
@@ -21,6 +21,8 @@ public partial class RegisterSheetDialogView : Window
public RegisterSheetDialogView(string initialCode, string initialName)
{
InitializeComponent();
// 되돌릴 수 없는 신규 등록 — 지금 멈추고 결정해야 하는 자리라 뒤를 어둡게 한다
Services.ModalScrim.SetDimBehind(this, true);
CodeBox.Text = initialCode;
NameBox.Text = initialName;
}