다크 테마에서 흰 시스템 창이 튀어나오던 알림을 자체 창(MessageDialogView)으로 바꿨다. 앱 셸과 같은 커스텀 타이틀바(36px) + B.Surface 본문 + 성격 아이콘 + 우측 버튼 줄. 폭 440 고정, 내용에 따라 높이 자동, 긴 예외는 스크롤로 흡수한다. 진입점은 DialogService 정적 메서드 4개로 모았다 — Notify / Confirm / ConfirmWithCancel / ShowError. MessageBox 는 Win32 호출이라 아무 데서나 되지만 WPF 창은 아니다. 교체로 새로 생기는 제약 셋을 DialogService 한곳에서 막는다. - 진단 모드에서는 창을 만들지 않고 기각 기본값을 즉시 돌려준다. 스모크는 무인 실행이라 모달이 하나라도 뜨면 타임아웃 없이 영원히 멈춘다 — 실제로 편집 스모크가 지나는 경로에 붙여넣기·개명 가드가 있다. 규약을 스모크 검사 3건으로 고정했다. - UI 스레드가 아니면 Dispatcher 로 넘긴다. - 소유 창은 '살아 있는 것'만 건다. 아직 안 보였거나 이미 닫힌 창을 Owner 로 주면 예외이고, 진단 렌더러가 도는 동안 MainWindow 가 닫힌 창을 가리킬 수 있다. App.xaml.cs 4곳은 다르게 처리했다. - 기동 실패(:49)·예외 폭주(:82)·복구 안내(:89) 3곳은 순정 유지. 창이 없는 시점이라 자체 창을 띄우면 종료코드가 유실되고, 이미 예외가 터진 자리에서 WPF 창을 새로 만들면 같은 핸들러로 재진입한다. 이유를 각 자리에 주석으로 남겼다. 덤으로 :89 는 e.Handled 를 알림보다 먼저 세우도록 순서를 바로잡았다 — 알림이 던지면 '복구 가능한 예외'가 하드 크래시로 바뀐다. - 알 수 없는 진단 옵션(:179)은 알림을 없앴다. 옵션 오타 하나로 무인 실행이 멈추던 자리다. 함께 고친 것 — Primary 버튼 스타일. 공유 버튼 템플릿의 호버 트리거가 TargetName 으로 채움을 회색으로 덮는데(TargetName 트리거는 TemplateBinding 을 이긴다) 글자는 흰색 그대로라, 라이트에서 마우스를 올리면 #F3F3F3 위 흰 글자 1.08:1 로 사라진다. 지금까지 Primary 사용처가 0건이라 드러난 적이 없었고 이 창이 첫 사용이다. 전용 템플릿 + 채움 호버·누름 토큰 2종 신설. 신설 토큰: B.Warning / B.Danger(라이트는 다크값을 못 쓴다 — 앰버 #E0A33A 는 흰 면 위 2.22:1 로 아이콘 기준 3:1 도 미달), B.AccentFillHover / B.AccentFillPressed. 전부 양 테마에 동시 추가. Lucide 아이콘 4종(info·circle-alert·triangle-alert·circle-help) 추가. 바꾸지 않은 것: 예외 원문. 18곳을 ShowError 로 수렴시키면 화면에서 ex.Message 가 오류코드+로그로 대체되는 동작 변경이 된다 — 요청은 시각 변경이라 문구·정보량을 그대로 뒀다. 남는 시스템 대화상자: 인쇄(PrintDialog)와 파일 열기/저장 — OS 셸 대화상자라 대상이 아니다. 진단 렌더러에 알림 5종(오류·경고·확인·저장확인·정보)을 등록해 라이트/다크 10장이 자동으로 남는다. 회귀: 테스트 124/124, 편집 스모크 실패 0, DB 왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 바이트 동일. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
167 lines
7.2 KiB
C#
167 lines
7.2 KiB
C#
using System.Windows;
|
|
using Microsoft.Win32;
|
|
|
|
namespace SheetMe.Designer.Services;
|
|
|
|
/// <summary>알림 성격 — 아이콘과 강조색만 결정한다(버튼 구성과 무관)</summary>
|
|
public enum DialogKind
|
|
{
|
|
/// <summary>정보 — 성공·빈 결과처럼 실패가 아닌 안내</summary>
|
|
Info,
|
|
|
|
/// <summary>경고 — 거부·미설정·입력 오류</summary>
|
|
Warning,
|
|
|
|
/// <summary>오류 — 예외</summary>
|
|
Error,
|
|
|
|
/// <summary>확인 — 사용자에게 묻는 자리</summary>
|
|
Question,
|
|
}
|
|
|
|
/// <summary>버튼 구성 — 실제로 쓰이는 3종이 전부다</summary>
|
|
public enum DialogButtons
|
|
{
|
|
/// <summary>확인 1개</summary>
|
|
Ok,
|
|
|
|
/// <summary>예 / 아니오</summary>
|
|
YesNo,
|
|
|
|
/// <summary>예 / 아니오 / 취소</summary>
|
|
YesNoCancel,
|
|
}
|
|
|
|
/// <summary>
|
|
/// 대화상자 진입점 — 파일 대화상자 래퍼(ViewModel 에서 View 기술 의존 격리)와
|
|
/// 앱 테마를 따르는 알림·확인 창.
|
|
///
|
|
/// 알림을 순정 <c>MessageBox</c> 대신 자체 창으로 띄우면서 새로 생긴 제약이 셋 있고,
|
|
/// 그 셋을 이 클래스가 한곳에서 막는다 — 진단 모드에서는 창을 만들지 않고, UI 스레드가 아니면
|
|
/// 넘겨 주며, 소유 창은 살아 있는 것만 건다. 호출부는 이걸 몰라도 된다.
|
|
/// </summary>
|
|
public sealed class DialogService
|
|
{
|
|
#region Methods
|
|
/// <summary>열기 대화상자 — 취소 시 null</summary>
|
|
public string? ShowOpenFile(string filter, string title)
|
|
{
|
|
var dialog = new OpenFileDialog
|
|
{
|
|
Filter = filter,
|
|
Title = title,
|
|
};
|
|
return dialog.ShowDialog() == true ? dialog.FileName : null;
|
|
}
|
|
|
|
/// <summary>저장 대화상자 — 취소 시 null</summary>
|
|
public string? ShowSaveFile(string filter, string title, string defaultFileName)
|
|
{
|
|
var dialog = new SaveFileDialog
|
|
{
|
|
Filter = filter,
|
|
Title = title,
|
|
FileName = defaultFileName,
|
|
};
|
|
return dialog.ShowDialog() == true ? dialog.FileName : null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 오류 안내 — 상세는 로그로, 화면에는 조치 가능한 내용만.
|
|
///
|
|
/// 우리가 던진 안내성 예외(<see cref="InvalidOperationException"/> 등)는 메시지 자체에 조치가
|
|
/// 담겨 있으므로 그대로 보여준다. 그 외(Oracle 오류·NRE 등)는 SQL 조각이나 접속 단서가 섞일 수 있어
|
|
/// 일반화 문구 + 오류 코드만 노출한다 — 코드는 로그 줄머리와 같아서 전화 한 통으로 특정된다.
|
|
/// </summary>
|
|
public static void ShowError(string action, Exception exception, Window? owner = null)
|
|
{
|
|
var code = AppLog.Error(action, exception);
|
|
var detail = exception is InvalidOperationException or ArgumentException
|
|
? exception.Message
|
|
: $"{exception.GetType().Name} — 자세한 내용은 로그를 확인해 주세요.\n오류 코드: {code}";
|
|
|
|
Notify(DialogKind.Error, "오류", $"{action} 중 오류가 발생했습니다.",
|
|
AppLog.Redact(detail), owner);
|
|
}
|
|
|
|
/// <summary>알림(확인 1개). <paramref name="detail"/> 은 머리말 아래에 보조 글씨로 붙는다.</summary>
|
|
public static void Notify(DialogKind kind, string caption, string message,
|
|
string? detail = null, Window? owner = null)
|
|
=> Ask(kind, caption, message, detail, DialogButtons.Ok, owner: owner);
|
|
|
|
/// <summary>확인(예/아니오) — 예=true. 닫기·Esc 는 아니오로 친다.</summary>
|
|
/// <param name="destructive">되돌리기 어려운 동작이면 기본 버튼을 부정 쪽에 둔다</param>
|
|
public static bool Confirm(string caption, string message, string? detail = null,
|
|
string yes = "예", string no = "아니오", bool destructive = false, Window? owner = null)
|
|
=> Ask(DialogKind.Question, caption, message, detail, DialogButtons.YesNo,
|
|
yes, no, destructive, owner) == true;
|
|
|
|
/// <summary>확인(예/아니오/취소) — 취소=null. 닫기·Esc 는 취소로 친다.</summary>
|
|
public static bool? ConfirmWithCancel(string caption, string message, string? detail = null,
|
|
string yes = "예", string no = "아니오", Window? owner = null)
|
|
=> Ask(DialogKind.Question, caption, message, detail, DialogButtons.YesNoCancel,
|
|
yes, no, destructive: false, owner);
|
|
|
|
/// <summary>
|
|
/// 실제 표시 — 세 가지 안전장치를 여기서 한 번에 건다.
|
|
/// </summary>
|
|
private static bool? Ask(DialogKind kind, string caption, string message, string? detail,
|
|
DialogButtons buttons, string yes = "예", string no = "아니오",
|
|
bool destructive = false, Window? owner = null)
|
|
{
|
|
// ① 진단 모드에서는 창을 만들지 않는다.
|
|
// 스모크는 무인 실행이라 모달이 하나라도 뜨면 타임아웃 없이 영원히 멈춘다.
|
|
// (실제로 편집 스모크가 지나는 경로에 붙여넣기·개명 가드가 있다)
|
|
if (UserSession.IsDiagnostic)
|
|
{
|
|
AppLog.Warn($"[진단] {caption}: {message}{(detail is null ? "" : " / " + detail)}");
|
|
return DismissValue(buttons);
|
|
}
|
|
|
|
var app = Application.Current;
|
|
if (app is null)
|
|
{
|
|
// 앱이 없는 호스트(단위 테스트 등) — 창을 만들 수 없다
|
|
AppLog.Warn($"[창 없음] {caption}: {message}");
|
|
return DismissValue(buttons);
|
|
}
|
|
|
|
// ② 스레드 친화성. MessageBox 는 Win32 호출이라 아무 스레드에서나 됐지만 Window 는 아니다.
|
|
if (!app.Dispatcher.CheckAccess())
|
|
{
|
|
return app.Dispatcher.Invoke(
|
|
() => Ask(kind, caption, message, detail, buttons, yes, no, destructive, owner));
|
|
}
|
|
|
|
var dialog = new Views.MessageDialogView(kind, caption, message, detail, buttons, yes, no, destructive);
|
|
|
|
// ③ 소유 창은 '살아 있는 것'만 건다. 아직 보이지 않았거나 이미 닫힌 창을 Owner 로 주면
|
|
// WPF 가 예외를 던진다 — 진단 렌더러가 도는 동안 MainWindow 가 닫힌 창을 가리킬 수 있다.
|
|
var target = owner ?? app.MainWindow;
|
|
if (target is { IsLoaded: true, IsVisible: true } && !ReferenceEquals(target, dialog))
|
|
{
|
|
dialog.Owner = target;
|
|
dialog.WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
|
}
|
|
else
|
|
{
|
|
// 소유 창이 없으면 화면 가운데에 띄우고 작업 표시줄에도 노출한다 —
|
|
// 안 그러면 다른 창 뒤로 숨어 '앱이 멈춘' 것처럼 보인다
|
|
dialog.WindowStartupLocation = WindowStartupLocation.CenterScreen;
|
|
dialog.ShowInTaskbar = true;
|
|
}
|
|
|
|
dialog.ShowDialog();
|
|
return dialog.Answer;
|
|
}
|
|
|
|
/// <summary>창을 띄우지 못했을 때의 안전 기본값 — MessageBox 의 기각 의미론과 같다</summary>
|
|
private static bool? DismissValue(DialogButtons buttons) => buttons switch
|
|
{
|
|
DialogButtons.YesNo => false,
|
|
DialogButtons.YesNoCancel => null,
|
|
_ => true,
|
|
};
|
|
#endregion
|
|
}
|