미리보기를 눌러 볼 수 있게 하고, Ctrl+드래그를 복사로 바꾼다
사용자 지적 두 건. 둘 다 라디오 배타 그룹이 걸린 문제였다.
## 미리보기 클릭이 안 먹었다
레거시 미리보기(TK_PREVIEW)는 정적 그림이 아니라 런타임 모드라 눌러 보며 확인한다.
우리 미리보기는 그림이었다 — 그래서 <b>라디오가 배타로 묶였는지 확인할 방법이 없었다</b>.
패널로 묶었다고 믿고 저장했다가 임상 화면에서 두 개가 동시에 켜지는 것이
이 서식에서 가장 흔한 사고인데, 저장 전에 그걸 볼 수단이 없었던 것이다.
PreviewCheckState — 컨트롤 Id → 체크 상태 사전. 문서는 건드리지 않는다.
확인하려고 누른 것이 서식 수정이 되면 미리보기를 열었다 닫은 것만으로
저장할 내용이 생긴다. 렌더 시점(WithTagValue)에만 덮고, 창을 닫으면 사라진다.
배타는 직계 부모 안에서만. 레거시 RadioButton 은 WinForms 를 그대로 상속해
GroupName 이 없고(RadioButton.vb:7-8) 배타 판정을 부모 컨테이너에 맡긴다.
운영 실측도 라디오 15,184개 중 15,026개(98.96%)가 패널 직계 자식 — 패널 1개 = 문항 1개다.
- PreventEditing=True 는 안 눌린다(RadioButton.vb:271-279 — 값 대입만 되고 클릭은 무시).
Locked 는 편집 보호일 뿐이라 눌린다.
- 라디오는 다시 눌러도 안 꺼진다. 끄는 것은 형제를 누르는 것뿐이다.
- 손으로 누른 값이 태그 값을 이긴다 — 그 칸을 직접 눌러 보는 중이므로 사용자의 손이 마지막이다.
- 환자를 바꾸거나 서식을 갈아타면 지운다. 안 지우면 손으로 누른 값이
새 환자의 실제 값을 가려, 확인하려고 만든 창이 확인을 막는다.
- 이 창에서 인쇄하면 화면과 같은 상태로 나간다(같은 checks 를 Print 까지 넘긴다).
히트테스트는 WPF 가 아니라 모델 트리로 한다. 종이 비주얼은 템플릿이 만든 그림이라
컨트롤 하나가 여러 요소로 쪼개져 있고 일부는 히트테스트에서 빠져 있다.
캔버스의 HitTestControl 과 두 군데만 다르다 — Locked 를 보지 않고,
그려지지 않은 것(숨김·데이터소스·인쇄 제외)을 뺀다.
## Ctrl+드래그가 복사가 아니었다
예전에는 '선택에 더하고 이동'이었다(Ctrl 이 선택 추가 수정자였으니까).
그래서 같은 컨트롤을 여러 개 놓는 방법이 복사·붙여넣기뿐이었는데,
붙여넣기는 <b>항상 최상위 +12,+12</b> 에 떨어진다.
패널 안 라디오를 그렇게 복사하면 사본이 패널 밖으로 나가 배타 그룹이 깨진다 —
눈으로는 안 보이고 임상 화면에서만 드러나는 고장이다.
DuplicateSelectionInPlace — 제자리 복제. 부모를 유지하므로 사본이 같은 문항 안에 남는다.
드래그 문턱을 넘은 지점에서 복제한다(누름 시점에 하면 Ctrl+클릭만으로도 사본이 생긴다).
복제가 스냅샷을 이미 찍었으므로 이어지는 이동은 추가로 찍지 않는다 —
안 그러면 "복사 후 이동"이 Undo 두 번이 되고, 한 번만 누른 사용자는
원본 자리에 사본이 겹쳐 남은 상태를 본다.
Ctrl 은 이 제스처에서 더하기가 아니다. 합집합으로 두면 아까 선택해 둔 컨트롤까지
함께 복제돼 하나 집어 끌었는데 화면 다른 곳에 사본이 여럿 생긴다. Shift 는 그대로 더하기다.
선택 단위가 패널이므로 자식을 그냥 집으면 <b>문항이 통째로</b> 복사되고,
더블클릭으로 들어간 뒤 집으면 <b>그 선택지만</b> 복사돼 같은 패널에 남는다.
둘 다 필요한 동작이라 둘 다 시험한다.
## 판정
- 단위 시험 368 (PreviewCheckState 10건 추가)
- edit-smoke 320건 전건 통과 (Ctrl+드래그 복사 9건, 미리보기 클릭 5건 추가)
· 20-5f 의 옛 단언 2건은 의미가 바뀌어 교체했다("합집합으로 함께 움직인다" → "복사된다")
· 20-5c(빈 선택 Ctrl+드래그)가 남기는 사본을 되돌린다 — 안 하면 뒤따르는 개수 검사가 흔들린다
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
- --db-patient "김" 39건 · --dialog-shots · --cleartype · --db-filecfg 전건 통과
시험을 처음 쓸 때 자식을 Children[0]/[1] 로 집었는데 자식 VM 은 z 순서로 온다.
이미 켜진 라디오를 누르는 시험이 되어 "문서가 바뀌었다"는 거짓 실패가 났다 — Id 로 집는다.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d965b3f217
commit
8cc2d68ac1
@@ -156,8 +156,28 @@ public sealed class InteractionController
|
||||
if (!downControl.IsSelected)
|
||||
{
|
||||
var mates = designer.GroupMatesOf(downControl);
|
||||
designer.Selection.Set(designer.Selection.Items.Union(mates).ToList(), downControl);
|
||||
// Shift 는 이 제스처에서도 '더하기'다 — 기존 선택과 합친다(업 토글과 같은 규칙).
|
||||
// Ctrl 은 '복사'라서 더하기가 아니다: 합집합으로 두면 아까 선택해 둔 컨트롤까지
|
||||
// 같이 복제돼, 하나 집어 끌었는데 화면 다른 곳에 사본이 여럿 생긴다.
|
||||
designer.Selection.Set(
|
||||
ctx.Ctrl ? mates : designer.Selection.Items.Union(mates).ToList(), downControl);
|
||||
}
|
||||
// Ctrl+드래그 = 복사(디자이너 관례). 원본은 자리에 남고 <b>복제본이 끌린다</b>.
|
||||
//
|
||||
// <b>왜 여기서 복제하나.</b> 누름 시점에 하면 Ctrl+클릭(선택 토글)만으로도 사본이 생긴다.
|
||||
// 드래그 문턱을 넘은 이 지점이 "복사할 뜻"이 확정되는 유일한 자리다.
|
||||
//
|
||||
// <b>왜 붙여넣기가 아닌가.</b> Paste 는 항상 최상위 +12,+12 에 놓는다 — 패널 안 라디오를
|
||||
// 그렇게 복사하면 사본이 패널 밖으로 나가 <b>배타 그룹이 깨진다</b>(레거시 라디오는
|
||||
// 직계 부모가 그룹이다). 제자리 복제는 부모를 유지하므로 같은 문항 안에 남는다.
|
||||
if (ctx.Ctrl && designer.DuplicateSelectionInPlace())
|
||||
{
|
||||
// 복제가 이미 스냅샷을 찍었다 — 이어지는 이동은 추가로 찍지 않는다.
|
||||
// 그러면 "복사 후 이동" 이 Undo 두 번이 되고, 한 번만 누른 사용자는
|
||||
// 원본 자리에 사본이 겹쳐 남은 상태를 보게 된다.
|
||||
undoCaptured = true;
|
||||
}
|
||||
// 복제가 거절되면(표 등) 그냥 이동한다 — 선택은 원본에 남아 있다
|
||||
BeginMove();
|
||||
DoMove(world, ctx); // 전환 프레임부터 즉시 추종
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ public static class PrintService
|
||||
/// <summary>인쇄 대화상자 → 전체 페이지 인쇄</summary>
|
||||
public static void Print(DesignerViewModel designer, string documentName,
|
||||
SheetMe.Core.Catalog.ITagValueResolver? tags = null,
|
||||
SheetMe.Core.Catalog.IDataFieldResolver? fields = null)
|
||||
SheetMe.Core.Catalog.IDataFieldResolver? fields = null,
|
||||
SheetMe.Core.Catalog.PreviewCheckState? checks = null)
|
||||
{
|
||||
var dialog = new System.Windows.Controls.PrintDialog();
|
||||
// 용지를 문서 크기로 미리 지정한다 — 지정하지 않으면 드라이버가 폭이 비슷한
|
||||
@@ -38,7 +39,7 @@ public static class PrintService
|
||||
return;
|
||||
}
|
||||
|
||||
var document = BuildFixedDocument(designer, tags, fields);
|
||||
var document = BuildFixedDocument(designer, tags, fields, checks);
|
||||
dialog.PrintDocument(document.DocumentPaginator, $"SheetMe — {documentName}");
|
||||
}
|
||||
|
||||
@@ -53,7 +54,8 @@ public static class PrintService
|
||||
/// </summary>
|
||||
public static FixedDocument BuildFixedDocument(DesignerViewModel designer,
|
||||
SheetMe.Core.Catalog.ITagValueResolver? tags = null,
|
||||
SheetMe.Core.Catalog.IDataFieldResolver? fields = null)
|
||||
SheetMe.Core.Catalog.IDataFieldResolver? fields = null,
|
||||
SheetMe.Core.Catalog.PreviewCheckState? checks = null)
|
||||
{
|
||||
var document = new FixedDocument();
|
||||
foreach (var page in designer.Pages)
|
||||
@@ -66,7 +68,7 @@ public static class PrintService
|
||||
};
|
||||
// 인쇄는 항상 '인쇄될 것만' 이다(레거시 인쇄가 PrintOutPut=False 를 뺀다) —
|
||||
// 미리보기의 보기 토글과 무관하게 printFilter 는 고정 true 다.
|
||||
fixedPage.Children.Add(BuildPageVisual(page, true, tags, fields));
|
||||
fixedPage.Children.Add(BuildPageVisual(page, true, tags, fields, checks));
|
||||
|
||||
var pageContent = new PageContent();
|
||||
((System.Windows.Markup.IAddChild)pageContent).AddChild(fixedPage);
|
||||
@@ -86,7 +88,8 @@ public static class PrintService
|
||||
/// </summary>
|
||||
public static UIElement BuildPageVisual(PageViewModel page, bool printFilter = true,
|
||||
SheetMe.Core.Catalog.ITagValueResolver? tags = null,
|
||||
SheetMe.Core.Catalog.IDataFieldResolver? fields = null)
|
||||
SheetMe.Core.Catalog.IDataFieldResolver? fields = null,
|
||||
SheetMe.Core.Catalog.PreviewCheckState? checks = null)
|
||||
{
|
||||
var canvas = new Canvas
|
||||
{
|
||||
@@ -118,7 +121,7 @@ public static class PrintService
|
||||
}
|
||||
var presenter = new ContentPresenter
|
||||
{
|
||||
Content = PrintProjection(control, printFilter, tags, fields),
|
||||
Content = PrintProjection(control, printFilter, tags, fields, checks),
|
||||
Width = Math.Max(1, control.Width),
|
||||
Height = Math.Max(1, control.Height),
|
||||
};
|
||||
@@ -149,11 +152,12 @@ public static class PrintService
|
||||
/// </summary>
|
||||
private static object PrintProjection(ControlViewModel control, bool printFilter,
|
||||
SheetMe.Core.Catalog.ITagValueResolver? tags,
|
||||
SheetMe.Core.Catalog.IDataFieldResolver? fields)
|
||||
SheetMe.Core.Catalog.IDataFieldResolver? fields,
|
||||
SheetMe.Core.Catalog.PreviewCheckState? checks = null)
|
||||
{
|
||||
if (control is not ContainerViewModel container)
|
||||
{
|
||||
return WithTagValue(control, tags, fields);
|
||||
return WithTagValue(control, tags, fields, checks);
|
||||
}
|
||||
ContainerViewModel? copy = container switch
|
||||
{
|
||||
@@ -173,7 +177,7 @@ public static class PrintService
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (PrintProjection(child, printFilter, tags, fields) is ControlViewModel projected)
|
||||
if (PrintProjection(child, printFilter, tags, fields, checks) is ControlViewModel projected)
|
||||
{
|
||||
copy.Children.Add(projected);
|
||||
}
|
||||
@@ -201,22 +205,31 @@ public static class PrintService
|
||||
/// 진단(--db-patient·edit-smoke)이 그것으로 판정한다.
|
||||
/// </summary>
|
||||
private static object WithTagValue(ControlViewModel control, SheetMe.Core.Catalog.ITagValueResolver? tags,
|
||||
SheetMe.Core.Catalog.IDataFieldResolver? fields)
|
||||
SheetMe.Core.Catalog.IDataFieldResolver? fields,
|
||||
SheetMe.Core.Catalog.PreviewCheckState? checks = null)
|
||||
{
|
||||
// 미리보기에서 눌러 본 체크·라디오는 <b>무엇보다 뒤다</b> — 태그가 만든 체크 상태까지 덮는다.
|
||||
// 그 칸이 어떻게 보이는지 직접 눌러 확인하는 중이므로 사용자의 손이 마지막이다.
|
||||
bool? clicked = null;
|
||||
if (control is CheckBoxViewModel or RadioButtonViewModel && checks is not null
|
||||
&& checks.Overrides.TryGetValue(control.Model.Id, out var state))
|
||||
{
|
||||
clicked = state;
|
||||
}
|
||||
// 두 관을 순서대로 본다. 태그가 먼저인 이유는 그것이 값을 만드는 더 단순한 경로이고,
|
||||
// 한 컨트롤에 둘이 다 걸려 있으면 어느 쪽이 이기는지 <b>정해져 있어야</b> 하기 때문이다
|
||||
// (정하지 않으면 같은 서식이 실행마다 다르게 보인다).
|
||||
var (label, value) = Bound(control, tags, fields);
|
||||
if (label is null)
|
||||
{
|
||||
return control;
|
||||
return clicked is null ? control : Rechecked(control, clicked.Value);
|
||||
}
|
||||
// 체크류에서 값을 못 만들면 컨트롤을 그대로 둔다 — 레거시도 SetValue 를 건너뛰고
|
||||
// ($NoCheck$ · 오류) 라벨과 기본 체크 상태를 유지한다. 라벨에 사유를 쓰면
|
||||
// "남 ☑" 이 "[태그 — 사유] ☑" 가 되어 서식이 깨진다.
|
||||
if (!value.Resolved && control is CheckBoxViewModel or RadioButtonViewModel)
|
||||
{
|
||||
return control;
|
||||
return clicked is null ? control : Rechecked(control, clicked.Value);
|
||||
}
|
||||
// 이미지류(싸인·직인·로고)의 값은 <b>파일 경로</b>다 — Text 가 아니라 경로 속성에 넣어
|
||||
// 템플릿이 그 파일을 그리게 한다(레거시도 경로 문자열을 SetValue 로 넘겨 그렸다).
|
||||
@@ -239,7 +252,7 @@ public static class PrintService
|
||||
// 않는다 — 레거시도 체크 상태만 바꾼다. Boolean 태그(입통원구분 등)의 ToString
|
||||
// "True" 가 이 규칙으로 체크가 된다. 값을 Text 에 넣으면 라벨이 "True" 로 바뀐다.
|
||||
var upper = value.Text.ToUpperInvariant();
|
||||
model.Props.SetText("Checked", upper is "Y" or "TRUE" ? "True" : "False");
|
||||
model.Props.SetText("Checked", (clicked ?? upper is "Y" or "TRUE") ? "True" : "False");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -249,6 +262,14 @@ public static class PrintService
|
||||
return DocumentMapper.CreateControl(model, control.EffectiveFont, control.Foreground);
|
||||
}
|
||||
|
||||
/// <summary>미리보기에서 눌러 본 체크 상태만 갈아 끼운 사본 — 원본 VM·모델은 그대로다</summary>
|
||||
private static object Rechecked(ControlViewModel control, bool state)
|
||||
{
|
||||
var model = control.Model.Clone();
|
||||
model.Props.SetText("Checked", state ? "True" : "False");
|
||||
return DocumentMapper.CreateControl(model, control.EffectiveFont, control.Foreground);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 이 컨트롤에 걸린 배선과 그 값 — 배선이 없으면 라벨이 null 이다.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user