Files
SheetMe/src/SheetMe.Designer/Diagnostics/DialogShots.cs
T
MsystechandClaude Opus 5 76584e5004 레이어 패널을 [200]SheetMe 아웃라인으로 교체 — 페이지 노드·선택 밴드·드래그 재정렬
[200] 의 패널은 코드로 Border 를 쌓는 비가상화 렌더고 SheetMe 는 가상화 ListBox 다.
골격은 SheetMe 것을 지키고 겉모습과 조작 표면만 옮겼다 — 통째로 베끼면 1,067 컨트롤
서식에서 검색어 한 글자마다 수천 비주얼을 다시 만들게 되고, [200] 에 대응물이 없는
컨테이너 자식 재귀 표시가 사라진다.

구조
- 페이지를 별도 섹션이 아니라 아웃라인의 최상위 노드로. 접기·복제·삭제·컨트롤 수·현재 강조
- 맨 아래 '+ 페이지 추가' 행
- 목록 순서를 뒤집었다 — 이제 맨 위가 z-order 최상위(캔버스에서 위에 겹쳐 보이는 것이 목록 위)
- 들여쓰기 12 / 32 / +16
- 레이어 패널을 LayerPanelView 로 분리(MainView.xaml 이 1,000줄을 넘겼고, 진단 렌더러가
  이 패널만 따로 찍어 라이트·다크·선택 상태를 검사할 수 있어야 한다)

선택 표시
- IsSelected 가 아니라 Highlight 3상태(None/Blue/Navy)가 배경을 그린다. 그룹 전원 선택 시
  폴더는 파랑, 멤버는 서브트리 배경이 되고 맞닿는 모서리를 각지게 해 하나의 블록으로 이어진다
- 밴드 위에서는 아이콘·보조 텍스트 색도 함께 뒤집는다. 안 그러면 눈·자물쇠가 1.6:1 로 묻혀
  선택한 행에서만 잠금 상태가 안 보이는 상태가 된다
- 신설 토큰 3종(B.LayerSel / B.LayerSubtree / B.OnSelMuted) — [200] 원본값은 각각
  흰 글자 3.00:1, 다크 밴드 1.03:1, 라이트 밴드 1.14:1 로 이 저장소가 이미 기각한 값이다

기능
- 드래그로 z-order 재정렬(삽입선 어도너). 임의 index 이동 연산 MoveWithinParent 신설
- 페이지 복제 — 컨트롤 이름을 전부 새로 발급하고 그룹도 복제본끼리 다시 묶는다
  ([200] 은 GroupId 를 그대로 둬서 복제 그룹이 원본과 gid 를 공유한다)
- 페이지 헤더 클릭 → 캔버스가 그 페이지로 이동(ScrollToPage)
- 데이터 배선 배지 — 값이 조회·데이터소스에서 오는 컨트롤 표시
- 검색칩에 테두리(라이트에서 B.Input=B.Panel 이라 경계 대비가 1.00:1 이었다)

이식하지 않은 것: 컨트롤 행 인라인 개명. [200] 이 고치는 값은 표시 전용 별칭이지만
SheetMe 의 컨트롤 이름은 외부 참조 키라 목록에서 무심코 고치면 배선이 끊긴다.
더블클릭은 요청대로 캔버스 이동을 유지하고, 그룹 행에서만 이름 편집으로 간다.

동반 수정: 눈·자물쇠 토글에 되돌리기 지점 추가(400ms 코얼레스), 그룹 이름 비우기 경로가
스냅숏 없이 지워지던 것, 숨김 흐리기를 행 전체가 아니라 이름 줄에만.

편집 스모크의 레이어 검사 19 → 42건. 회귀: 테스트 124/124, 편집 스모크 실패 0,
DB 왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 바이트 동일.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 17:48:48 +09:00

312 lines
15 KiB
C#

using System.IO;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using SheetMe.Core.Models;
using SheetMe.Data.Stores;
using SheetMe.Designer.Services;
using SheetMe.Designer.ViewModels;
using SheetMe.Designer.ViewModels.Inspector;
namespace SheetMe.Designer.Diagnostics;
/// <summary>
/// 별도 창(대화상자) 오프스크린 스냅샷 — <c>--dialog-shots &lt;출력폴더&gt;</c>.
///
/// 창 10종을 다크·라이트 양 테마로 열어 PNG 로 남긴다. 목적은 하나다:
/// <b>테마 대비 결함을 사람 손 없이 재현 가능하게 확인한다.</b>
/// 실입력(마우스·키보드) 주입은 화면 잠금·세션 격리 상태에서 OS 가 거부하지만
/// 이 경로는 창을 화면 밖에 띄워 RenderTargetBitmap 으로 찍으므로 그 제약을 받지 않는다.
///
/// 창은 화면 밖(-10000)에 <c>Show()</c> 로 띄운다 — 레이아웃이 실제로 돌아야 템플릿·트리거가
/// 적용된 상태로 찍힌다(Measure/Arrange 만으로는 Window 크롬이 구성되지 않는다).
/// 모든 창은 찍은 뒤 즉시 닫는다.
/// </summary>
public static class DialogShots
{
#region Methods
public static int Run(string outputDirectory)
{
try
{
Directory.CreateDirectory(outputDirectory);
// 기본 ShutdownMode 는 OnLastWindowClose 라, 찍고 닫는 순간 마지막 창이 사라져 앱이 종료된다
// (그 뒤 Application.Current 가 null 이 되어 테마 교체에서 NRE). 명시 종료로 바꾼다.
Application.Current.ShutdownMode = ShutdownMode.OnExplicitShutdown;
var lines = new List<string>();
foreach (var light in new[] { false, true })
{
ThemeManager.Apply(light);
var suffix = light ? "light" : "dark";
foreach (var (name, factory) in Factories())
{
try
{
var window = factory();
Capture(window, Path.Combine(outputDirectory, $"{name}-{suffix}.png"));
// 창 배경이 실제로 무엇으로 해석됐는지 함께 남긴다 — 픽셀만 보면 원인을 못 가린다.
// WPF 는 암시 스타일을 요소의 '정확한 타입'으로만 찾으므로, Window 를 상속한
// 대화상자에는 <Style TargetType="Window"> 가 적용되지 않는다(기본 흰 배경이 된다).
var background = window.Background is SolidColorBrush solid
? solid.Color.ToString()
: window.Background?.ToString() ?? "(null)";
var styled = window.Style is not null ? "스타일적용" : "스타일없음";
lines.Add($"OK {name}-{suffix} 배경={background} {styled}");
}
catch (Exception ex)
{
lines.Add($"FAIL {name}-{suffix} — {ex.GetType().Name}: {ex.Message}");
}
}
}
lines.Add(string.Empty);
lines.AddRange(StyleSelfCheck());
File.WriteAllText(Path.Combine(outputDirectory, "_report.txt"),
string.Join(Environment.NewLine, lines));
Console.WriteLine(string.Join(Environment.NewLine, lines));
return lines.Any(l => l.StartsWith("FAIL", StringComparison.Ordinal)) ? 1 : 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"대화상자 스냅샷 실패: {ex}");
return 1;
}
}
/// <summary>
/// 암시 스타일 자가 점검 — 스크린샷에 안 잡히는 표면(툴팁·포커스링·툴바 자식·스크롤 코너)이
/// 실제로 테마 스타일을 받는지 값으로 확인한다.
/// 이들은 팝업이거나 프레임워크 템플릿 소속이라 창을 찍어도 보이지 않는데, 스타일이 없으면
/// WPF 기본(Aero2) 밝은 크롬으로 떨어져 다크에서 글자가 사라진다.
/// </summary>
private static List<string> StyleSelfCheck()
{
var lines = new List<string> { "[암시 스타일 자가 점검 — 라이트 테마 기준]" };
// 떼어 놓은 인스턴스는 트리에 붙기 전이라 Style 이 아직 null 이다 — 사전에 있는지로 판정한다
foreach (var (label, key) in new (string, object)[]
{
("ToolTip", typeof(System.Windows.Controls.ToolTip)),
("ListView", typeof(System.Windows.Controls.ListView)),
("ToolBar", typeof(System.Windows.Controls.ToolBar)),
("ToolBar.Button", System.Windows.Controls.ToolBar.ButtonStyleKey),
("ToolBar.Separator", System.Windows.Controls.ToolBar.SeparatorStyleKey),
("ToolBar.TextBox", System.Windows.Controls.ToolBar.TextBoxStyleKey),
("FocusVisual", SystemParameters.FocusVisualStyleKey),
("ScrollViewer코너", SystemColors.ControlBrushKey),
})
{
var found = Application.Current.TryFindResource(key);
lines.Add($" {label,-18} {(found is null ? " 미정의(Aero2 폴백)" : "정의됨")}");
}
return lines;
}
/// <summary>창 생성기 목록 — 실제 사용 시와 같은 인자로 만든다(빈 껍데기를 찍으면 의미가 없다)</summary>
private static List<(string Name, Func<Window> Factory)> Factories()
{
var designer = SampleDesigner();
return new List<(string, Func<Window>)>
{
("01-sheet-open", () => new Views.SheetOpenDialogView(_ => SampleSheets())),
("02-sheet-history", () => new Views.SheetHistoryDialogView("S999", "표본 서식", SampleVersions())),
("03-query-editor", () => new Views.QueryEditorWindow("데이터소스",
"SELECT ChtNum, PatNam FROM P_PatMst\nWHERE ChtNum = <<M.CMM.HISOperatingInfo.bzPatientInfo.ChtNum>>")),
("04-tag-picker", () => new Views.TagPickerDialogView("동작 태그", SampleTags(), SampleTags()[1])),
("05-mask-picker", () => new Views.MaskPickerDialogView("0000년 90월 90일")),
("06-font-manager", () => new Views.FontManagerDialogView(designer)),
("07-register-sheet", () => new Views.RegisterSheetDialogView("S999", "표본 서식")),
("08-preview", () => new Views.PreviewWindow(designer)),
// 인스펙터는 UserControl 이라 창이 없다 — 실폭 300px 호스트 창에 담아 실제 배치를 찍는다.
// 속성 패널 레이아웃 회귀를 눈으로 확인할 수 있는 유일한 자동 경로다.
("09-inspector-label", () => HostInspector(designer, "Label1")),
("10-inspector-textbox", () => HostInspector(designer, "TextBox1")),
("11-inspector-data", () => HostInspector(designer, "TextBox1", InspectorTab.Data)),
("12-inspector-behavior", () => HostInspector(designer, "TextBox1", InspectorTab.Behavior)),
("13-layer-type-filter", () => new Views.LayerTypeFilterDialog(
designer.LayerTypeCandidates(), new[] { "Label" })),
// 레이어 아웃라인 — 선택 없음 / 컨트롤 하나 선택 / 그룹 전체 선택 세 상태를 각각 찍는다.
// 강조는 IsSelected 가 아니라 Highlight 3상태가 그리므로 눈으로 확인할 경로가 이것뿐이다.
("14-layer-plain", () => HostLayerPanel(designer, LayerShotMode.None)),
("15-layer-selected", () => HostLayerPanel(designer, LayerShotMode.Single)),
("16-layer-group", () => HostLayerPanel(designer, LayerShotMode.Group)),
};
}
/// <summary>레이어 패널 스냅숏의 선택 상태</summary>
private enum LayerShotMode
{
/// <summary>선택 없음</summary>
None,
/// <summary>컨트롤 하나 선택 — 파란 밴드 하나</summary>
Single,
/// <summary>그룹 전체 선택 — 폴더 파랑 + 멤버 서브트리, 이어진 블록</summary>
Group,
}
/// <summary>
/// 레이어 패널을 실제 패널 폭(255px)의 창에 담는다.
/// MainViewModel 을 통째로 만들면 DB 조회가 딸려 오므로, 패널이 읽는 <c>CurrentDesigner</c> 하나만
/// 들고 있는 최소 DataContext 를 세워 준다.
/// </summary>
private static Window HostLayerPanel(DesignerViewModel designer, LayerShotMode mode)
{
designer.Selection.Clear();
designer.SetLayerTypeFilter(Array.Empty<string>());
designer.LayerFilter = string.Empty;
var page = designer.Pages[0];
if (mode != LayerShotMode.None && page.Controls.Count >= 3)
{
designer.Selection.Set(new[] { page.Controls[^1], page.Controls[^2] });
designer.GroupSelection();
if (mode == LayerShotMode.Single)
{
designer.Selection.SetSingle(page.Controls[0]);
}
}
designer.RebuildLayerRows();
return new Window
{
Title = $"레이어 — {mode}",
Width = 255,
Height = 620,
Content = new Views.LayerPanelView { DataContext = new LayerShotContext(designer) },
Background = Application.Current.TryFindResource("B.Panel") as Brush,
};
}
/// <summary>패널이 바인딩하는 <c>CurrentDesigner</c> 만 노출하는 스냅숏 전용 DataContext</summary>
private sealed class LayerShotContext
{
public LayerShotContext(DesignerViewModel designer) => CurrentDesigner = designer;
public DesignerViewModel CurrentDesigner { get; }
}
/// <summary>인스펙터를 실제 패널 폭(300px)의 창에 담는다 — 컨트롤 하나를 선택한 상태로</summary>
private static Window HostInspector(DesignerViewModel designer, string controlId,
InspectorTab tab = InspectorTab.Design)
{
var page = designer.Pages[0];
var target = page.Controls.FirstOrDefault(c => c.Id == controlId) ?? page.Controls[0];
designer.Selection.SetSingle(target);
designer.Inspector.SelectedTab = tab;
designer.Inspector.Rebuild();
return new Window
{
Title = $"속성 — {controlId}",
Width = 300,
Height = 720,
Content = new Views.InspectorView { DataContext = designer.Inspector },
Background = Application.Current.TryFindResource("B.Panel") as Brush,
};
}
/// <summary>화면 밖에 띄워 레이아웃을 돌린 뒤 PNG 로 찍고 닫는다</summary>
private static void Capture(Window window, string pngPath)
{
window.WindowStartupLocation = WindowStartupLocation.Manual;
window.Left = -10000;
window.Top = -10000;
window.ShowInTaskbar = false;
window.Show();
window.UpdateLayout();
// 레이아웃·비동기 로딩(Loaded 핸들러)이 한 바퀴 돌게 한다
Pump();
// 창 자체를 렌더한다 — Content 만 찍으면 Window.Background(B.AppBg)가 빠져 투명이 되고,
// PNG 에서 검정으로 저장돼 "다크처럼 보이는" 착시가 생긴다(라이트 결함이 가려진다).
var content = (FrameworkElement)window.Content;
var width = (int)Math.Ceiling(content.ActualWidth > 0 ? content.ActualWidth : window.Width);
var height = (int)Math.Ceiling(content.ActualHeight > 0 ? content.ActualHeight : window.Height);
var bitmap = new RenderTargetBitmap(Math.Max(1, width), Math.Max(1, height), 96, 96, PixelFormats.Pbgra32);
var drawing = new DrawingVisual();
using (var context = drawing.RenderOpen())
{
context.DrawRectangle(window.Background ?? Brushes.Transparent, null, new Rect(0, 0, width, height));
context.DrawRectangle(new VisualBrush(content) { Stretch = Stretch.None, AlignmentX = AlignmentX.Left, AlignmentY = AlignmentY.Top },
null, new Rect(0, 0, width, height));
}
bitmap.Render(drawing);
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(bitmap));
using (var stream = File.Create(pngPath))
{
encoder.Save(stream);
}
window.Close();
}
/// <summary>디스패처 큐를 비운다(Loaded/바인딩 갱신 반영)</summary>
private static void Pump()
{
for (var i = 0; i < 3; i++)
{
System.Windows.Threading.Dispatcher.CurrentDispatcher.Invoke(
() => { }, System.Windows.Threading.DispatcherPriority.ContextIdle);
}
}
#endregion
#region Methods - 표본 데이터
private static List<SheetSummary> SampleSheets() => new()
{
new SheetSummary("C020", "협진기록지", true, true, "A", "의사기록"),
new SheetSummary("C030", "Doctor's Order List", false, true, "A", "의사기록"),
new SheetSummary("P001", "간호 기록지", true, true, "E", "간호기록"),
new SheetSummary("F004", "임상생리신경검사", true, false, "D", "검사결과"),
new SheetSummary("Z001", "분류 없는 서식", false, true, string.Empty, "(미분류)"),
};
private static List<DesignVersionInfo> SampleVersions() => new()
{
new DesignVersionInfo(52443, "202608121030", "011825", false),
new DesignVersionInfo(52380, "202607161422", "011825", true),
new DesignVersionInfo(52241, "202607160901", "MSYS", true),
};
private static string[] SampleTags() => new[]
{
"SetPatientName", "SetChartNumber", "EnableControl", "SetVisitDate", "ClearValue",
};
/// <summary>폰트 관리자·미리보기용 표본 문서 — 컨트롤이 있어야 목록이 비지 않는다</summary>
private static DesignerViewModel SampleDesigner()
{
var document = new FormDocument { FormId = "S999", Title = "표본 서식" };
var page = Core.Serialization.LegacyXmlSerializer.CreateEmptyPage(1);
page.Controls.Add(NewControl("Label", "Label1", "환자명", 40, 40, 120, 24));
page.Controls.Add(NewControl("TextBox", "TextBox1", string.Empty, 170, 40, 200, 26));
page.Controls.Add(NewControl("Label", "Label2", "생년월일", 40, 80, 120, 24));
// 이름만 있고 문구가 없는 컨트롤 — 레이어 목록에서 이름 칸이 중복 표시되지 않는지 확인용
page.Controls.Add(NewControl("TextBox", "TextBox2", string.Empty, 170, 80, 200, 26));
document.Pages.Add(page);
return new DesignerViewModel(document);
}
private static ControlElement NewControl(string type, string id, string text, double x, double y, double w, double h)
{
var element = new ControlElement { Type = type, Id = id };
element.Bounds.X = x;
element.Bounds.Y = y;
element.Bounds.W = w;
element.Bounds.H = h;
if (text.Length > 0)
{
element.Props.SetText("Text", text);
}
return element;
}
#endregion
}