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>
167 lines
6.7 KiB
C#
167 lines
6.7 KiB
C#
using System.Windows;
|
|
using System.Windows.Input;
|
|
using SheetMe.Designer.Controls;
|
|
using SheetMe.Designer.Services;
|
|
|
|
namespace SheetMe.Designer.Views;
|
|
|
|
/// <summary>
|
|
/// 알림·확인 대화상자 — 순정 Win32 MessageBox 대체.
|
|
///
|
|
/// 기각 의미론은 MessageBox 를 그대로 따른다: 닫기(X)·Alt+F4·Esc 는
|
|
/// 확인 1개면 '확인', 예/아니오면 '아니오', 예/아니오/취소면 '취소'로 돌아간다.
|
|
///
|
|
/// <b><see cref="Window.DialogResult"/> 를 쓰지 않는다.</b> Show() 로 띄운 창에 대입하면
|
|
/// InvalidOperationException 이고, 진단 렌더러(DialogShots)가 정확히 그 경로로 이 창을 찍는다.
|
|
/// 같은 이유로 <c>Closing</c> 에서 <c>e.Cancel</c> 로 닫기를 막지도 않는다 —
|
|
/// 렌더러가 <c>Close()</c> 로만 창을 정리하므로 취소되면 화면 밖 창이 살아남는다.
|
|
/// </summary>
|
|
public partial class MessageDialogView : Window
|
|
{
|
|
#region Member Fields
|
|
private readonly string caption;
|
|
private readonly string head;
|
|
private readonly string? detail;
|
|
private bool? answer;
|
|
#endregion
|
|
|
|
#region Properties
|
|
/// <summary>사용자 응답 — 예=true / 아니오=false / 취소=null</summary>
|
|
public bool? Answer => answer;
|
|
#endregion
|
|
|
|
#region Constructors
|
|
/// <summary>
|
|
/// 공개 생성자 — 정적 헬퍼로 감싸지 말고 이 형태를 유지할 것.
|
|
/// 진단 렌더러가 팩토리로 <c>new</c> 해서 라이트·다크 스냅샷을 찍는다(private 생성자면 편입 불가).
|
|
/// </summary>
|
|
public MessageDialogView(DialogKind kind, string caption, string head, string? detail,
|
|
DialogButtons buttons, string yes = "예", string no = "아니오", bool destructive = false)
|
|
{
|
|
InitializeComponent();
|
|
|
|
this.caption = caption;
|
|
this.head = head;
|
|
this.detail = detail;
|
|
|
|
// 노트북 해상도에서 창이 화면을 덮지 않도록 한 번 더 조인다
|
|
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;
|
|
DetailText.Visibility = string.IsNullOrEmpty(detail) ? Visibility.Collapsed : Visibility.Visible;
|
|
|
|
var (icon, brushKey) = Visual(kind);
|
|
// 색은 반드시 '키'로 넘긴다 — Brush 인스턴스를 대입하면 그 시점 값으로 굳어 테마 전환을 못 따라간다
|
|
SeverityIcon.Content = LucideIcons.Icon(icon, 22, brushKey);
|
|
// 오류만 복사 버튼을 노출한다 — 오류 코드를 전화로 불러 주던 실사용 경로
|
|
CopyButton.Visibility = kind == DialogKind.Error ? Visibility.Visible : Visibility.Collapsed;
|
|
|
|
Configure(buttons, yes, no, destructive);
|
|
}
|
|
#endregion
|
|
|
|
#region Methods
|
|
/// <summary>성격 → (아이콘, 색 토큰)</summary>
|
|
private static (string Icon, string BrushKey) Visual(DialogKind kind) => kind switch
|
|
{
|
|
DialogKind.Error => ("circle-alert", "B.Danger"),
|
|
DialogKind.Warning => ("triangle-alert", "B.Warning"),
|
|
DialogKind.Question => ("circle-help", "B.AccentText"),
|
|
_ => ("info", "B.AccentText"),
|
|
};
|
|
|
|
/// <summary>버튼 구성·기본 응답·초기 포커스</summary>
|
|
private void Configure(DialogButtons buttons, string yes, string no, bool destructive)
|
|
{
|
|
switch (buttons)
|
|
{
|
|
case DialogButtons.YesNo:
|
|
answer = false;
|
|
AffirmButton.Content = yes;
|
|
DenyButton.Content = no;
|
|
DenyButton.Visibility = Visibility.Visible;
|
|
DenyButton.IsCancel = true;
|
|
break;
|
|
|
|
case DialogButtons.YesNoCancel:
|
|
answer = null;
|
|
AffirmButton.Content = yes;
|
|
DenyButton.Content = no;
|
|
DenyButton.Visibility = Visibility.Visible;
|
|
CancelButton.Visibility = Visibility.Visible;
|
|
CancelButton.IsCancel = true;
|
|
break;
|
|
|
|
default:
|
|
answer = true;
|
|
AffirmButton.Content = "확인";
|
|
AffirmButton.IsCancel = true;
|
|
break;
|
|
}
|
|
|
|
// 되돌리기 어려운 확인에서는 기본 버튼을 부정 쪽에 둔다 — Enter 한 번에 페이지가 지워지면 안 된다
|
|
var defaultButton = destructive && DenyButton.Visibility == Visibility.Visible
|
|
? DenyButton
|
|
: AffirmButton;
|
|
defaultButton.IsDefault = true;
|
|
Loaded += (_, _) => defaultButton.Focus();
|
|
}
|
|
|
|
private void OnAffirmClick(object sender, RoutedEventArgs e)
|
|
{
|
|
answer = true;
|
|
Close();
|
|
}
|
|
|
|
private void OnDenyClick(object sender, RoutedEventArgs e)
|
|
{
|
|
answer = false;
|
|
Close();
|
|
}
|
|
|
|
/// <summary>닫기(X)·취소 — 생성자에서 정한 기각 기본값을 그대로 두고 닫는다</summary>
|
|
private void OnDismissClick(object sender, RoutedEventArgs e) => Close();
|
|
|
|
/// <summary>본문이 넘칠 때만 버튼 줄 위에 구분선 — 창이 최대 높이에 걸렸다는 유일한 신호</summary>
|
|
private void OnBodyScrollChanged(object sender, System.Windows.Controls.ScrollChangedEventArgs e)
|
|
=> Footer.BorderThickness = new Thickness(0, BodyScroll.ScrollableHeight > 0.5 ? 1 : 0, 0, 0);
|
|
|
|
/// <summary>내용 복사 — MessageBox 의 Ctrl+C 동작 보존(오류 코드를 옮겨 적던 경로)</summary>
|
|
private void OnCopyClick(object sender, RoutedEventArgs e) => CopyToClipboard();
|
|
|
|
protected override void OnKeyDown(KeyEventArgs e)
|
|
{
|
|
if (e.Key == Key.C && Keyboard.Modifiers == ModifierKeys.Control)
|
|
{
|
|
CopyToClipboard();
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
base.OnKeyDown(e);
|
|
}
|
|
|
|
private void CopyToClipboard()
|
|
{
|
|
var text = string.IsNullOrEmpty(detail)
|
|
? $"{caption}\n---\n{head}"
|
|
: $"{caption}\n---\n{head}\n\n{detail}";
|
|
try
|
|
{
|
|
Clipboard.SetText(text);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// 클립보드는 다른 프로세스가 잠글 수 있다 — 복사 실패가 알림 자체를 막으면 안 된다
|
|
AppLog.Warn($"알림 내용 복사 실패: {ex.Message}");
|
|
}
|
|
}
|
|
#endregion
|
|
}
|