여러 개를 골라 공통 속성을 한 번에 바꾼다

커밋 규약은 그대로다 — BindRow 는 이미 다중 대상이었고(스냅샷 1회 → 선택 전체 순회)
바뀐 것은 "어떤 행을 만드는가"뿐이다. 새 커밋 경로를 만들지 않았다.

## 막혀 있던 곳

타입이 하나라도 다르면 타입 전용 섹션이 통째로 사라졌다(RebuildCore 의 items.All(i => i.Type == type)).
그런데 여러 개를 함께 고쳐야 하는 속성 — 읽기 전용·인쇄 출력·필수 입력·재조회 — 이 정확히
거기 있었고, 없는 이유를 알려 주는 표시도 없어 "이 도구로는 안 되는 일"로 보였다.

더 나쁜 것은 <b>탭</b>이었다. 탭 가시성을 행을 만들기 전에 descriptor 하나로 정하는 바람에
타입이 섞이면 데이터·동작 탭이 접히고 selectedTab 이 디자인으로 강제 복귀했다.
공통 섹션을 완벽하게 만들어도 그 뒤에 갇힌다 — 순서를 바꾸지 않으면 어떤 병합 규칙으로도 못 고친다.
그래서 '① 공통 속성 계산 → ② 탭 → ③ 섹션' 으로 재배치했다.

## 무엇을 공통으로 보는가 — BatchEditPolicy

키 이름이 같은 것으로는 부족하다. Editor·Choices(원소와 순서)·Default 까지 같아야 한 칸에 묶는다.
이 규칙 하나가 예외 코드 없이 함정들을 잡는다.

- 정렬(TextAlign): 라벨은 ContentAlignment 9값, 텍스트박스는 HorizontalAlignment 3값이다.
  이름만 보고 묶으면 "MiddleCenter" 가 텍스트박스에 들어가고 레거시는 그것을 읽지 못한다.
- 테두리(BorderStyle): 기본값이 Fixed3D 대 None 이다. 기본값이 갈리면 키가 없는 대상이
  <b>남의 기본값</b>으로 보이고, 갈렸는지 판정부터 틀어진다.
- 표시(visible/Visible): 라벨만 소문자다(레거시가 그림자 속성을 쓴다). 키가 달라 자연히 탈락한다.
  <b>별칭으로 묶지 않았다</b> — 묶으면 라벨에 대문자 키가 생겨 EMR 에서는 사라지는데
  우리 미리보기는 계속 보여 준다.
- 스키마를 모르는 타입(표 등)이 섞이면 빈 집합이다. 무엇이 공통인지 말할 근거가 없다.

그 위에 대상 집합을 보는 차단을 건다. 근거 대부분이 키 이름이 아니라 <b>누가 선택됐는가</b>에 있다.

- 유일해야 하는 것: 이름·서명 슬롯·탭 순서·링크 대상. 서명 슬롯이 같으면 전자동의서가
  보호자 칸에 환자 서명을 찍는다 — 종이에는 서명이 다 있어 아무도 의심하지 않는다.
- 남을 이름으로 가리키는 배선: 액션 대상 컨트롤·합산식·이벤트 매핑. 같은 값을 넣으면
  5번 문항을 누를 때 1번 문항이 비워지는 식으로 <b>다른 곳</b>이 깨진다.
- 되돌릴 수 없는 것: 일련번호 발급은 서식을 여는 것만으로 DB 카운터를 올린다.
  스냅샷으로 덮이지 않는 유일한 부류라 커밋 자체를 막는다.
- 같은 부모의 라디오 Checked: 레거시 라디오는 GroupName 이 없어 직계 부모가 문항인데,
  로더가 자식을 부모에 붙이기 <b>전에</b> 값을 대입해 형제가 꺼지지 않는다.
  한 문항에 답이 둘 켜진 채로 저장되고 그대로 열린다. 부모를 모르면 막는 쪽으로 뒀다.
- 선의 방향·굵기: 전용 편집기는 컨트롤마다 크기까지 함께 고치므로 허용하고,
  고급(원문) 경로만 막는다 — 거기서는 속성만 바뀌어 XML 은 Vertical 인데 경계는 가로인 선이 된다.

이름 행은 items.Count == 1 을 <b>유지</b>했다. 푸는 것이 목표처럼 보였지만 반대다 —
풀면 두 번째 대상부터 이름이 충돌하는데, 그 거절이 모달을 띄우고도 커밋 루프를 멈추지 못한다.

## 고급(원문) 섹션을 다중 선택에도 열되, 판정을 대상 전체로

전에는 행의 종류·편집 여부·키 목록을 items[0] 하나로 정하면서 커밋은 선택 <b>전체</b>에 썼다.
그래서 이미지·중첩·참조 값을 지키던 읽기 전용 방어가 "처음 클릭한 컨트롤"에만 걸렸고,
클릭 순서에 따라 같은 조작이 안전해지거나 파괴적이 됐다.
게다가 GetText 가 문자열 아닌 값에 null 을 주므로 빈 값과 이미지 원문이 둘 다 "" 로 접혀
<b>갈렸다는 표시조차 나오지 않았다</b> — 경고 없이 base64 가 문자열로 교체된다.

이제 전부 갖고 전부 문자열일 때만 편집을 연다. 아니면 이유를 밝힌 읽기 전용 행이다
("3개 중 1개에만 있음", "대상마다 값의 형태가 다릅니다"). 없던 대상에 키를 만들면
레거시 서식생성기가 열 때마다 오류 모달을 띄우므로 만들지 않는다.
'속성 추가'는 단일 선택만 — 중복 검사·쓰기가 전부 대상 하나 기준이라 N개 중 1개에만 키가 생긴다.

## 함께 고친 것 — 다중 편집을 넓히기 전에 막아야 했던 것들

**갈린 토글을 한 번 누르면 꺼졌다.** 실측으로 확인했다(진짜 ToggleButton + 진짜 바인딩):
누르기 전 IsChecked=null → 커밋 "False" → 누른 뒤 False. 즉 굵게를 <b>켜려고</b> 누른 한 번이
선택 전체의 굵게를 껐다. WPF 는 불확정에서 IsThreeState 와 무관하게 false 로 간다.
게다가 세그먼트 토글에는 갈림 표시가 없어 '꺼짐'과 픽셀 단위로 같았다 — 무엇이 일어났는지
볼 수도 없었다. 갈린 상태의 클릭을 '켜기'로 읽고, {x:Null} 표시를 붙였다.

**갈린 값이 '없음'으로 보였다.** 쿼리·마스크·배선 행은 빈 칸이 아니라 "(쿼리 없음)" 같은
<b>단정</b>을 그린다. 서로 다른 쿼리를 든 둘을 골랐을 때 "쿼리 없음"이 나오면 빈 칸보다 나쁘다 —
없다고 믿고 새로 쓰면 양쪽 원본이 한꺼번에 사라진다. "(여러 값)" 으로 바꿨다.
정렬 격자도 갈리면 9칸이 전부 꺼져 '아직 안 고름'과 같았다 — 격자 오른쪽에 표식을 뒀다.

**안 바뀌었는데 문서가 '수정됨'이 됐다.** 갈린 숫자 칸에 "100px" 을 넣으면 스냅샷이 먼저
쌓이고 대상마다 파싱에 실패해 아무것도 안 바뀌었다. 그걸 지우려 누르는 Ctrl+Z 가 다음 문제를 밟는다.
RowBinding.Validate 를 두어 커밋 전에 거르고 칸을 되돌린다. 값이 이미 전부 같으면 스냅샷도 안 찍는다.

**Undo 한 번에 선택이 증발했다.** 문서 교체 후 재구성의 첫 줄이 Selection.Clear() 라,
'여럿 고르기 → 바꾸기 → 확인 → Ctrl+Z → 다시'라는 이 기능의 유일한 작업 흐름이
첫 되돌리기에서 끊겼다. Id 로 다시 찾아 선택을 복원한다.

**텍스트·항목 목록은 확인을 받는다.** 대상마다 다르던 고유값이 한 번에 사라지는 편집이다.
좌표·색·인쇄여부에는 붙이지 않았다 — 확인을 남발하면 정작 위험한 것도 습관적으로 넘긴다.

## 재현하지 못한 것

조사에서 P0로 지목된 ComboBox 코어스 되쓰기(목록에 없는 값을 null 로 바꿔 소스에 되써서
세로선을 회전시킨다)는 <b>재현되지 않았다</b>. 목록 밖 값을 넣은 양성 대조에서도 되쓰기가
관측되지 않아, 이 진단은 그 경로에 둔감하다. 그래서 '되쓰기가 없다'를 주장하지 않는다.
대신 갈렸을 때 빈 문자열 자리를 목록에 만들어(EnsureMixedPlaceholder) 코어스가 성립할
조건 자체를 없앴다 — 덤으로 빈 칸이 "여러 값"으로 보인다.

## 판정

- 단위 시험 388 (BatchEditPolicy 20건 추가)
- edit-smoke 339건 전건 통과 (일괄 편집 12건 · 혼합 표시 4건 추가)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
- --db-patient "김" · --dialog-shots · --cleartype · --db-filecfg 전건 통과

## 남는 것

- 혼합 상태에서 '선택 전체를 빈 값으로' 는 못 한다 — ""→"" 가 커밋되지 않아서다.
  하려면 커밋을 우회하는 새 경로가 필요하고 그건 별건이다.
- 갈린 숫자 칸의 ↑/↓ 는 무동작이다(빈칸을 0으로 뭉개지 않으려는 기존 방어).
  다중 선택은 좌표가 갈리는 것이 기본에 가까워 자주 보이지만 이번 범위 밖으로 뒀다.
- 라벨의 대문자 Visible 을 PrintFilter 가 레거시와 다르게 읽는 문제는 <b>지금도</b> 있다.
  이번 설계는 병합에서 라벨을 빼 새 사례를 만들지 않을 뿐, 기존 어긋남은 고치지 않았다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-20 16:24:16 +09:00
co-authored by Claude Fable 5
parent 8cc2d68ac1
commit d45b674e29
8 changed files with 1106 additions and 30 deletions
+327
View File
@@ -0,0 +1,327 @@
using SheetMe.Core.Models;
namespace SheetMe.Core.Catalog;
/// <summary>
/// 여러 컨트롤을 함께 골랐을 때 <b>무엇을 한 번에 바꿔도 되는가</b> — 판정 한 곳.
///
/// <b>왜 순수 함수 한 곳인가.</b> 차단 근거의 대부분이 키 이름이 아니라 <b>대상 집합</b>에 달려 있다
/// (타입이 섞였는가, 같은 패널의 라디오가 둘인가, 값의 형태가 대상마다 다른가).
/// 이 판정을 UI 조건문으로 흩어 놓으면 큐레이션 경로에서 막은 것이 고급(원문) 경로로 그대로 새고,
/// 어느 쪽이 진짜 규칙인지 아무도 모르게 된다. 여기 모아 두면 DB 없이 검사할 수 있고,
/// 실사용 서식에서 반례가 0인 항목은 기대값을 0으로 고정할 수 있다.
///
/// <b>기준은 레거시가 열 수 있는 XML 이다.</b> 레거시 서식생성기는 타입에 없는 속성이나 변환 불가 값을
/// 만나면 서식을 열 때마다 오류 모달을 띄운다. 그래서 "우리 화면에서 그럴듯한가"가 아니라
/// "레거시가 이 XML 을 조용히 열 수 있는가"로 자른다.
/// </summary>
public static class BatchEditPolicy
{
#region Member Fields
/// <summary>
/// 대상이 둘 이상이면 <b>언제나</b> 막는 키 — 값이 대상마다 달라야 의미가 있는 것들.
///
/// 같은 값을 여럿에 복사하는 순간 뜻이 무너지는 세 부류다.
/// ① <b>유일해야 하는 식별자</b> — 이름, 서명 슬롯 번호, 탭 순서, 링크 대상.
/// ② <b>남을 이름으로 가리키는 배선</b> — 액션 대상 컨트롤, 합산식. 같은 값을 넣으면
/// 5번 문항을 누를 때 1번 문항이 초기화되는 식으로 <b>다른 곳</b>이 깨진다.
/// ③ <b>되돌릴 수 없는 것</b> — 일련번호 발급은 서식을 여는 것만으로 DB 카운터를 올린다.
/// Undo 스냅샷으로 덮이지 않는 유일한 부류다.
/// </summary>
private static readonly HashSet<string> AlwaysBlocked = new(StringComparer.Ordinal)
{
// ① 유일해야 하는 것
"Name",
"SignatureIndex", // 전자동의서가 이 값으로 서명 슬롯을 배정한다 — 같으면 보호자 칸에 환자 서명이 간다
"DisplaySequence",
"TabIndex",
"LinkToNewDesign",
"SMSWebControlName",
// 저장 시 writer 가 Bounds/Id 에서 다시 만들어 덮는 키 — 고쳐도 저장하면 되돌아간다
"Location",
"Size",
"LocationOnBase",
// ② 남을 이름으로 가리키는 배선
"DataActionTagControl",
"GetDataActionTagControl",
"GetDataActionTagControlChange",
"AssociatedControl",
"Formula",
"FormulaResultCheck",
"EventHandlerMappingTag",
// ③ 되돌릴 수 없는 것 — 일련번호 발급
"SequenceCode",
"SequenceType",
"SequenceNumber",
"CheckSequenceAtSaving",
};
/// <summary>
/// 타입이 섞였을 때만 막는 키 — 같은 타입끼리는 정상 작업이다.
///
/// <c>TextAlign</c> 이 대표다. 같은 이름이지만 어휘가 다르다 — 라벨·체크·라디오·버튼은
/// <c>ContentAlignment</c>(TopLeft…BottomRight 9값), 텍스트박스·마스크박스는
/// <c>HorizontalAlignment</c>(Left/Center/Right 3값)다. 섞어서 "MiddleCenter" 를 주면
/// 텍스트박스 쪽에는 <b>레거시가 읽을 수 없는 값</b>이 들어간다.
/// </summary>
private static readonly HashSet<string> BlockedWhenTypesDiffer = new(StringComparer.Ordinal)
{
"TextAlign",
// 태그는 타입마다 채우는 방식이 다르다(체크류는 Y/TRUE 판정, 글자류는 문자열) —
// 섞어서 같은 태그를 주면 한쪽은 값이, 다른 쪽은 체크가 되어 뜻이 갈린다
"DataInterfaceTag",
};
/// <summary>
/// 고급(원문) 경로에서만 막는 키 — 큐레이션 경로는 <b>속성과 크기를 함께</b> 고치므로 안전하다.
///
/// 선의 방향·굵기가 그렇다. 인스펙터의 전용 행은 컨트롤마다 <c>LineGeometry</c> 를 태워
/// 각 선의 길이를 보존한 채 축을 바꾼다. 반면 원문 텍스트 행은 속성만 쓰고 크기를 그대로 두어,
/// XML 은 Vertical 인데 경계는 200×1 인 선이 된다 — EMR 은 그것을 1px 점으로 그린다.
/// </summary>
private static readonly HashSet<string> BlockedInRawOnly = new(StringComparer.Ordinal)
{
"Orientation",
"BorderWidth",
};
/// <summary>
/// 타입마다 라벨이 갈리는 키의 <b>중립 라벨</b> — 섞였을 때 한쪽 타입의 말을 쓰지 않는다.
/// 예를 들어 체크박스의 "기본 체크"를 라디오까지 포함한 선택에 그대로 쓰면 어색하다.
/// </summary>
private static readonly Dictionary<string, string> NeutralLabels = new(StringComparer.Ordinal)
{
["Checked"] = "기본값",
["Text"] = "텍스트",
["Items"] = "항목(줄바꿈 구분)",
};
#endregion
#region Types
/// <summary>일괄 편집 허용 여부와, 막았다면 사람이 읽을 이유</summary>
public readonly record struct BatchVerdict(bool Allowed, string? Reason)
{
/// <summary>허용</summary>
public static BatchVerdict Ok => new(true, null);
/// <summary>차단 — 이유는 인스펙터에 그대로 보인다</summary>
public static BatchVerdict No(string reason) => new(false, reason);
}
#endregion
#region Methods
/// <summary>
/// 선택된 타입들이 <b>모두</b> 갖는 속성 — 이것이 "공통 속성"이다.
///
/// <b>키 이름만 같으면 안 된다.</b> 편집기 종류·선택지·기본값까지 같아야 한 칸으로 묶을 수 있다.
/// 하나라도 다르면 그 칸의 값이 대상마다 다른 뜻이 되고, 사용자는 그 사실을 알 방법이 없다.
/// · <c>TextAlign</c> — 선택지가 9값 대 3값이라 탈락(섞으면 한쪽에 없는 값이 들어간다).
/// · <c>BorderStyle</c> — 기본값이 "Fixed3D" 대 "None" 이라 탈락. 기본값이 갈리면
/// 키가 없는 대상이 <b>남의 기본값</b>으로 보이고, 갈렸는지 여부 판정부터 틀어진다.
/// · <c>visible</c>/<c>Visible</c> — 라벨만 소문자다(레거시가 그림자 속성을 쓴다).
/// 키 문자열이 다르므로 자연히 탈락한다. <b>별칭으로 묶지 않는다</b> — 묶으면 라벨에
/// 대문자 키가 생겨 EMR 에서는 사라지는데 우리 미리보기는 계속 보여 준다.
///
/// 스키마를 모르는 타입(표·격자 등 descriptor 가 없는 것)이 하나라도 섞이면 <b>빈 집합</b>이다.
/// 무엇이 공통인지 말할 근거가 없는데 추측해서 열면 그게 손상 경로가 된다.
/// </summary>
public static IReadOnlyList<PropertyDef> MergeCommonDefs(IReadOnlyList<string> types)
{
if (types.Count == 0)
{
return Array.Empty<PropertyDef>();
}
var descriptors = new List<ControlDescriptor>(types.Count);
foreach (var type in types)
{
if (ControlRegistry.Find(type) is not { } descriptor)
{
return Array.Empty<PropertyDef>();
}
descriptors.Add(descriptor);
}
if (descriptors.Count == 1)
{
return descriptors[0].Properties;
}
var merged = new List<PropertyDef>();
// 기준은 첫 타입의 선언 순서 — 인스펙터 행 순서가 실행마다 달라지면 안 된다
foreach (var def in descriptors[0].Properties)
{
var common = true;
var labelDiffers = false;
for (var i = 1; i < descriptors.Count && common; i++)
{
var other = descriptors[i].Properties.FirstOrDefault(p => string.Equals(p.Key, def.Key, StringComparison.Ordinal));
if (other is null || other.Editor != def.Editor
|| !SameChoices(def.Choices, other.Choices)
|| !string.Equals(def.Default, other.Default, StringComparison.Ordinal))
{
common = false;
break;
}
labelDiffers |= !string.Equals(def.Label, other.Label, StringComparison.Ordinal);
}
if (!common)
{
continue;
}
merged.Add(labelDiffers && NeutralLabels.TryGetValue(def.Key, out var neutral)
? Relabel(def, neutral)
: def);
}
return merged;
}
/// <summary>
/// 어느 타입엔가 있지만 공통으로 올리지 못한 키의 수 — 인스펙터가 "함께 못 바꾸는 것이 있다"고 알린다.
/// 조용히 빼면 사용자는 그 속성이 <b>존재하지 않는다</b>고 읽는다.
/// </summary>
public static int ExcludedCount(IReadOnlyList<string> types)
{
if (types.Count < 2)
{
return 0;
}
var all = new HashSet<string>(StringComparer.Ordinal);
foreach (var type in types)
{
if (ControlRegistry.Find(type) is not { } descriptor)
{
continue;
}
foreach (var def in descriptor.Properties)
{
all.Add(def.Key);
}
}
foreach (var def in MergeCommonDefs(types))
{
all.Remove(def.Key);
}
return all.Count;
}
/// <summary>
/// 이 키를 <b>이 대상들에</b> 한 번에 써도 되는가.
/// </summary>
/// <param name="key">PropBag 키</param>
/// <param name="targets">선택된 컨트롤 모델</param>
/// <param name="viaAdvancedRaw">고급(원문 텍스트) 경로인가 — 전용 편집기의 보정을 타지 않는다</param>
/// <param name="parentOf">컨트롤 → 직계 부모(최상위면 null). 라디오 배타 판정에 쓴다.</param>
public static BatchVerdict IsBatchable(string key, IReadOnlyList<ControlElement> targets,
bool viaAdvancedRaw = false, Func<ControlElement, ControlElement?>? parentOf = null)
{
// 대상이 하나면 단일 선택과 똑같이 둔다 — 이 기능이 기존 동작을 좁히면 안 된다
if (targets.Count <= 1)
{
return BatchVerdict.Ok;
}
if (AlwaysBlocked.Contains(key))
{
return BatchVerdict.No("대상마다 값이 달라야 하는 속성입니다");
}
if (viaAdvancedRaw && BlockedInRawOnly.Contains(key))
{
return BatchVerdict.No("원문으로 바꾸면 크기가 따라오지 않습니다 — 전용 편집기를 쓰세요");
}
var types = targets.Select(t => t.Type).Distinct(StringComparer.Ordinal).Count();
if (types > 1 && BlockedWhenTypesDiffer.Contains(key))
{
return BatchVerdict.No("타입마다 값의 뜻이 다릅니다 — 타입별로 따로 고르세요");
}
// 라디오의 기본 선택 — 같은 부모에 둘 이상이면 <b>한 문항의 답이 둘</b>이 된다.
// 레거시 라디오는 GroupName 이 없어 배타를 직계 부모에 맡기는데, 로더가 자식 속성을
// 부모에 붙이기 <b>전에</b> 대입하므로 형제가 꺼지지 않는다 — 저장도 되고 그대로 열린다.
// 캔버스에는 아무 신호가 없고, 종이에는 ● 가 둘 찍힌다.
if (string.Equals(key, "Checked", StringComparison.Ordinal) && HasRadioSiblings(targets, parentOf))
{
return BatchVerdict.No("같은 묶음의 라디오는 하나만 선택될 수 있습니다");
}
return BatchVerdict.Ok;
}
/// <summary>
/// 대상 <b>전부</b>가 이 키를 갖고, 전부 문자열 형태인가 — 고급(원문) 행을 열어도 되는 조건.
///
/// 둘 다 필요하다.
/// · <b>전부 갖고 있어야</b> 한다. 없던 대상에 키가 생기면 레거시 서식생성기가
/// "이 타입에 없는 속성"이라며 열 때마다 모달을 띄운다.
/// · <b>전부 문자열이어야</b> 한다. 이미지·중첩·참조 값은 텍스트로 바꾸는 순간 원문이 사라진다.
/// <c>GetText</c> 가 문자열이 아닌 값에 null 을 돌려주므로 빈 값과 이미지가 둘 다 "" 로 접혀
/// <b>갈렸다는 표시조차 나오지 않는다</b> — 경고 없이 base64 원문이 교체된다.
/// </summary>
public static bool AllShareTextShape(string key, IReadOnlyList<ControlElement> targets)
{
foreach (var target in targets)
{
if (!target.Props.Contains(key))
{
return false;
}
if (target.Props.Get(key) is not (LegacyPropValue.TextValue or LegacyPropValue.NullValue))
{
return false;
}
}
return true;
}
/// <summary>이 키를 가진 대상 수 — "3개 중 1개에만 있음" 안내용</summary>
public static int CountHaving(string key, IReadOnlyList<ControlElement> targets)
=> targets.Count(t => t.Props.Contains(key));
/// <summary>선택지 동일성 — 원소와 순서까지. 참조 비교로는 절대 판정할 수 없다.</summary>
private static bool SameChoices(string[]? left, string[]? right)
{
if (left is null || right is null)
{
return left is null && right is null;
}
return left.SequenceEqual(right, StringComparer.Ordinal);
}
/// <summary>
/// 라벨만 바꾼 복제 — <see cref="PropertyDef"/> 는 record 가 아니라 <c>with</c> 를 쓸 수 없다.
/// 선택지 배열은 <b>복사하지 않고 참조만</b> 넘긴다(레지스트리의 공개 값이라 절대 수정하지 않는다).
/// </summary>
private static PropertyDef Relabel(PropertyDef def, string label) => new()
{
Key = def.Key,
Label = label,
Editor = def.Editor,
Choices = def.Choices,
Default = def.Default,
};
/// <summary>같은 직계 부모를 공유하는 라디오가 둘 이상 들어 있는가</summary>
private static bool HasRadioSiblings(IReadOnlyList<ControlElement> targets,
Func<ControlElement, ControlElement?>? parentOf)
{
var seen = new List<(ControlElement? Parent, int Count)>();
foreach (var target in targets)
{
if (!string.Equals(target.Type, "RadioButton", StringComparison.Ordinal))
{
continue;
}
// 부모를 알 수 없으면(맵 미제공) 전부 같은 묶음으로 본다 — 모르면 막는 쪽이다.
// 여기서 틀리면 한 문항에 답이 둘 켜진 서식이 나가고, 그건 화면으로 확인할 수 없다.
var parent = parentOf?.Invoke(target);
var index = seen.FindIndex(entry => ReferenceEquals(entry.Parent, parent));
if (index < 0)
{
seen.Add((parent, 1));
continue;
}
if (seen[index].Count + 1 >= 2)
{
return true;
}
seen[index] = (parent, seen[index].Count + 1);
}
return false;
}
#endregion
}
@@ -0,0 +1,25 @@
using System.Globalization;
using System.Windows.Data;
namespace SheetMe.Designer.Controls;
/// <summary>
/// 선택지 목록의 표시 글자 — 빈 항목만 "여러 값"으로 바꾼다.
///
/// 값이 갈린 다중 선택에서 <see cref="ViewModels.Inspector.ChoiceRowViewModel.EnsureMixedPlaceholder"/> 가
/// 목록 맨 앞에 빈 문자열 자리를 만든다. 그대로 그리면 목록에 <b>빈 줄</b>이 하나 보일 뿐이라
/// 무엇인지 알 수 없고, 실수로 고르면 "값을 지우는 것"으로 읽힌다.
/// 나머지 값은 레거시 원문 열거값 그대로 둔다 — 저장되는 문자열과 화면 글자가 같아야
/// 사용자가 XML 을 열어 봤을 때 대조할 수 있다.
/// </summary>
public sealed class ChoiceLabelConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var text = value as string ?? string.Empty;
return text.Length == 0 ? "여러 값" : text;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
@@ -1026,6 +1026,176 @@ public static class EditSmoke
Check("인스펙터: 커밋이 Undo 스텝", designer.Undo.CanUndo);
}
// 15-b) 값이 갈린 행이 <b>사용자가 건드리기 전에</b> 커밋을 내지 않는가 — 진짜 컨트롤·진짜 바인딩으로.
//
// <b>여기서 실제로 확인한 것.</b> 갈린 토글을 한 번 누르면 무엇이 되는지가 핵심이다.
// WPF ToggleButton 은 IsChecked 가 null 이면 IsThreeState 와 무관하게 <b>false</b> 로 간다.
// 고치기 전 실측: 누르기 전 IsChecked=null → 커밋 "False" → 누른 뒤 False.
// 즉 굵게를 <b>켜려고</b> 누른 한 번이 선택 전체의 굵게를 껐다. 세그먼트 토글에는
// 갈림 표시도 없어(꺼짐과 픽셀 단위로 동일) 무엇이 일어났는지 볼 수도 없었다.
//
// <b>확인하지 못한 것.</b> ComboBox.SelectedItem 의 코어스 되쓰기(목록에 없는 값을 null 로
// 바꿔 소스에 되쓰는 것)는 이 진단에서 <b>재현되지 않았다</b> — 목록 밖 값을 넣은
// 양성 대조에서도 되쓰기가 관측되지 않아, 이 검사는 그 경로에 대해 둔감하다.
// 그래서 '되쓰기가 없다'를 여기서 주장하지 않는다. 대신 ChoiceRow 는 갈렸을 때
// 빈 문자열 자리를 목록에 만들어(EnsureMixedPlaceholder) 코어스가 성립할 조건 자체를 없앤다.
{
var choiceRow = new ViewModels.Inspector.ChoiceRowViewModel("정렬",
new[] { "Horizontal", "Vertical" });
var fired = new List<string>();
choiceRow.Commit = value => fired.Add(value ?? "(null)");
// BindRow 가 갈린 값에 대해 하는 것과 같은 순서로 준비한다
choiceRow.EnsureMixedPlaceholder();
choiceRow.Initialize(null, isMixed: true); // 값이 갈린 상태 = 다중 선택의 기본형
var combo = new System.Windows.Controls.ComboBox { DataContext = choiceRow };
combo.SetBinding(System.Windows.Controls.ComboBox.ItemsSourceProperty,
new System.Windows.Data.Binding(nameof(ViewModels.Inspector.ChoiceRowViewModel.Choices)));
combo.SetBinding(System.Windows.Controls.Primitives.Selector.SelectedItemProperty,
new System.Windows.Data.Binding(nameof(ViewModels.Inspector.PropertyRowViewModel.ValueText)));
// 바인딩을 실제로 활성화한다 — 붙이기만 하면 Selector 의 코어스가 아직 안 돈다
combo.Measure(new System.Windows.Size(200, 30));
combo.Arrange(new Rect(0, 0, 200, 30));
combo.UpdateLayout();
Check("혼합: 갈린 선택지 행이 스스로 커밋하지 않는다", fired.Count == 0,
fired.Count == 0 ? "커밋 0회" : $"커밋 {fired.Count}회 — 값 [{string.Join(", ", fired)}]");
// 갈린 선택지 행에 '여러 값' 자리가 생겼는가 — 빈 칸이 '값 없음'과 구별되어야 한다
Check("혼합: 갈린 선택지에 '여러 값' 자리가 생긴다",
choiceRow.Choices.Count == 3 && choiceRow.Choices[0].Length == 0,
$"[{string.Join(",", choiceRow.Choices.Select(c => c.Length == 0 ? "()" : c))}]");
// 갈린 토글을 한 번 누르면 <b>켜져야</b> 한다.
// WPF ToggleButton.OnToggle 은 IsChecked==null 이면 IsThreeState 와 무관하게 false 로 간다
// (isChecked.HasValue → null 이면 false). 그대로 두면 굵게를 <b>켜려고</b> 누른 한 번이
// 선택 전체를 '굵게 끔' 으로 커밋한다 — 고치기 전 실측이 정확히 그랬다.
var styleToggle = new ViewModels.Inspector.ToggleRowViewModel("굵게");
var styleFired = new List<string>();
styleToggle.Commit = value => styleFired.Add(value);
styleToggle.Initialize(null, isMixed: true);
var button = new System.Windows.Controls.Primitives.ToggleButton { DataContext = styleToggle };
button.SetBinding(System.Windows.Controls.Primitives.ToggleButton.IsCheckedProperty,
new System.Windows.Data.Binding(nameof(ViewModels.Inspector.ToggleRowViewModel.IsOn)));
button.SetBinding(System.Windows.Controls.Primitives.ToggleButton.IsThreeStateProperty,
new System.Windows.Data.Binding(nameof(ViewModels.Inspector.PropertyRowViewModel.IsMixed))
{ Mode = System.Windows.Data.BindingMode.OneWay });
button.Measure(new System.Windows.Size(40, 26));
button.Arrange(new Rect(0, 0, 40, 26));
var beforeClick = button.IsChecked;
// 실제 클릭과 같은 경로 — 자동화 피어의 Toggle 이 ToggleButton.OnToggle 을 부른다
((System.Windows.Automation.Provider.IToggleProvider)
new System.Windows.Automation.Peers.ToggleButtonAutomationPeer(button)).Toggle();
Check("혼합: 갈린 토글을 한 번 누르면 켜진다(끄지 않는다)",
styleFired.Count == 1 && styleFired[0] == "True",
$"누르기 전 IsChecked={beforeClick?.ToString() ?? "null"}, "
+ $"커밋 [{string.Join(",", styleFired)}], 누른 뒤 IsChecked={button.IsChecked?.ToString() ?? "null"}");
Check("혼합: 갈린 선택지 행의 값이 되밀리지 않는다", choiceRow.ValueText.Length == 0
&& choiceRow.IsMixed,
$"ValueText='{choiceRow.ValueText}' IsMixed={choiceRow.IsMixed}");
}
// 15-c) 여러 개를 함께 고르고 <b>공통 속성을 한 번에</b> 바꾼다.
//
// 전에는 타입이 하나라도 다르면 타입 전용 섹션이 통째로 사라졌다. 그런데 여러 개를
// 함께 고쳐야 하는 속성(읽기 전용·인쇄 출력·필수 입력)이 정확히 거기 있었고,
// 없는 이유를 알려 주는 표시도 없어 '이 도구로는 안 되는 일'로 보였다.
{
var bulk = new DesignerViewModel(new FormDesignDataBusiness().CreateNew());
bulk.AddControlAt("Label", new Point(60, 60));
bulk.AddControlAt("TextBox", new Point(60, 120));
bulk.AddControlAt("CheckBox", new Point(60, 180));
var all = bulk.Pages[0].Controls.ToList();
bulk.Selection.Set(all, all[0]);
var inspector = bulk.Inspector;
Check("일괄: 타입이 섞여도 공통 섹션이 나온다",
inspector.Rows.OfType<ViewModels.Inspector.SectionRowViewModel>()
.Any(s => s.Label.StartsWith("공통 속성", StringComparison.Ordinal)),
string.Join(" / ", inspector.Rows.OfType<ViewModels.Inspector.SectionRowViewModel>().Select(s => s.Label)));
// 라벨에는 '읽기 전용'이 없다(입력 컨트롤이 아니라 레거시도 노출하지 않는다) —
// 교집합이 제대로 좁혀졌는지부터 본다
Check("일괄: 한쪽에만 있는 속성은 공통에 오지 않는다",
!inspector.Rows.Any(r => r.Label == "읽기 전용"),
string.Join(" / ", inspector.Rows.Select(r => r.Label)));
// 정렬은 타입마다 어휘가 다르다(라벨 9값 ContentAlignment vs 텍스트박스 3값 HorizontalAlignment).
// 함께 올리면 텍스트박스에 레거시가 못 읽는 값이 들어간다.
inspector.SelectedTab = ViewModels.Inspector.InspectorTab.Design;
Check("일괄: 뜻이 다른 정렬은 공통에 올리지 않는다",
!inspector.Rows.Any(r => r.Label == "정렬"),
string.Join(" / ", inspector.Rows.Select(r => r.Label)));
Check("일괄: 함께 못 바꾸는 것이 있다고 알린다",
inspector.Rows.OfType<ViewModels.Inspector.ReadOnlyRowViewModel>()
.Any(r => r.Label == "함께 바꿀 수 없음"));
// 이름은 유일해야 한다 — 풀면 두 번째 대상부터 충돌 모달이 뜨고
// 그 거절이 커밋 루프를 멈추지 못한다
Check("일괄: 다중 선택에는 이름 행이 없다",
!inspector.Rows.Any(r => r.Label == "이름"));
// 이 기능이 존재하는 이유 — 읽기 전용·필수 입력·재조회는 동작/데이터 탭에 있다.
// 전에는 타입이 섞이면 그 탭이 접혀, 공통 섹션을 아무리 잘 만들어도 닿을 수 없었다.
// 셋 다 '읽기 전용'을 갖는 조합으로 확인한다.
bulk.AddControlAt("ComboBox", new Point(60, 240));
bulk.AddControlAt("DateTimePicker", new Point(60, 300));
var editable = bulk.Pages[0].Controls
.Where(c => c.Type is "TextBox" or "ComboBox" or "DateTimePicker").ToList();
bulk.Selection.Set(editable, editable[0]);
Check("일괄: 타입이 섞여도 동작 탭이 남는다", inspector.ShowBehaviorTab,
$"동작 {inspector.BehaviorCount}개");
inspector.SelectedTab = ViewModels.Inspector.InspectorTab.Behavior;
var preventRow = inspector.Rows.OfType<ViewModels.Inspector.ToggleRowViewModel>()
.FirstOrDefault(r => r.Label == "읽기 전용");
Check("일괄: 동작 탭에 공통 행이 실제로 있다", preventRow is not null,
string.Join(" / ", inspector.Rows.Select(r => r.Label)));
if (preventRow is not null)
{
preventRow.ValueText = "True";
Check("일괄: 한 번 바꾸면 선택 전체에 들어간다",
editable.All(c => c.Model.Props.GetText("PreventEditing") == "True"),
string.Join(",", editable.Select(c => $"{c.Type}={c.Model.Props.GetText("PreventEditing")}")));
Check("일괄: 대상이 몇 개든 Undo 는 한 스텝", bulk.Undo.CanUndo);
bulk.Undo.Undo();
Check("일괄: Undo 한 번으로 전부 원복된다",
bulk.Pages[0].Controls.All(c => c.Model.Props.GetText("PreventEditing") is null),
string.Join(",", bulk.Pages[0].Controls.Select(c => c.Model.Props.GetText("PreventEditing") ?? "없음")));
// 되돌린 뒤에도 선택이 살아 있어야 '바꾸기 → 확인 → 되돌리기 → 다시'가 이어진다.
// 전에는 문서 교체가 선택을 통째로 지워, 첫 되돌리기에서 흐름이 끊겼다.
Check("일괄: Undo 후에도 선택이 남는다", bulk.Selection.Items.Count == editable.Count,
$"{bulk.Selection.Items.Count}개 (기대 {editable.Count})");
}
inspector.SelectedTab = ViewModels.Inspector.InspectorTab.Design;
// 같은 타입만 고르면 그 타입의 전용 섹션이 그대로 나와야 한다(기존 동작 유지)
bulk.AddControlAt("TextBox", new Point(60, 360));
var boxes = bulk.Pages[0].Controls.Where(c => c.Type == "TextBox").ToList();
bulk.Selection.Set(boxes, boxes[0]);
Check("일괄: 같은 타입끼리는 전용 섹션 이름이 유지된다",
inspector.Rows.OfType<ViewModels.Inspector.SectionRowViewModel>()
.Any(s => s.Label == "텍스트박스"),
string.Join(" / ", inspector.Rows.OfType<ViewModels.Inspector.SectionRowViewModel>().Select(s => s.Label)));
Check("일괄: 같은 타입끼리는 정렬을 함께 바꿀 수 있다",
inspector.Rows.Any(r => r.Label == "정렬"));
// 바뀐 것이 없으면 문서를 '수정됨'으로 만들지 않는다 —
// 전에는 스냅샷만 쌓여, 그것을 지우려 누른 Ctrl+Z 가 선택까지 지웠다
var fresh = new DesignerViewModel(new FormDesignDataBusiness().CreateNew());
fresh.AddControlAt("Label", new Point(60, 60));
fresh.AddControlAt("Label", new Point(60, 120));
fresh.Selection.Set(fresh.Pages[0].Controls.ToList(), fresh.Pages[0].Controls[0]);
fresh.Undo.MarkSaved();
var xRow = (ViewModels.Inspector.NumberRowViewModel)fresh.Inspector.Rows
.OfType<ViewModels.Inspector.PairRowViewModel>().First(p => p.LeftLabel == "X").Left;
xRow.ValueText = "100px"; // 숫자가 아니다
Check("일괄: 숫자가 아니면 커밋하지 않는다(문서가 수정되지 않는다)",
!fresh.Undo.IsDirty, $"dirty={fresh.Undo.IsDirty}");
Check("일괄: 거절한 값은 칸에 남지 않는다", xRow.ValueText != "100px",
$"칸='{xRow.ValueText}'");
}
// 16) 인스펙터 — 이름 변경
var nameRow = designer.Inspector.Rows
.OfType<ViewModels.Inspector.TextRowViewModel>()
@@ -577,11 +577,58 @@ public sealed class DesignerViewModel : ViewModelBase
RefreshOverlay();
}
/// <summary>문서 교체 후 재구성 — Undo 복원 경로</summary>
/// <summary>
/// 문서 교체 후 재구성 — Undo 복원 경로.
///
/// <b>선택을 되살린다.</b> 재구성의 첫 줄이 <c>Selection.Clear()</c> 라, 전에는 되돌리기 한 번마다
/// 선택이 통째로 증발했다. 여러 개를 함께 고쳐 보는 작업의 흐름이
/// '여럿 고르기 → 바꾸기 → 눈으로 확인 → Ctrl+Z → 다른 값으로 다시' 인데
/// 첫 되돌리기에서 끊기고, 마퀴로 만든 열두 개 선택을 매번 다시 만들어야 했다.
///
/// 기준은 Id 다 — 문서 안에서 유일하고(<c>IdGenerator.CollectUsed</c>) 문서를 갈아끼워도 같다.
/// 못 찾은 것은 조용히 뺀다(삭제를 다시 실행한 경우처럼 정말 없어졌을 수 있다).
/// </summary>
public void ReplaceDocument(FormDocument newDocument)
{
var keepIds = Selection.Items.Select(vm => vm.Id).ToList();
var primaryId = Selection.Primary?.Id;
document = newDocument;
RebuildFromDocument();
if (keepIds.Count == 0)
{
return;
}
var wanted = new HashSet<string>(keepIds, StringComparer.OrdinalIgnoreCase);
var found = new List<ControlViewModel>();
ControlViewModel? primary = null;
void Visit(IEnumerable<ControlViewModel> controls)
{
foreach (var vm in controls)
{
if (wanted.Contains(vm.Id))
{
found.Add(vm);
if (primaryId is not null && string.Equals(vm.Id, primaryId, StringComparison.OrdinalIgnoreCase))
{
primary = vm;
}
}
if (vm is ContainerViewModel container)
{
Visit(container.Children);
}
}
}
foreach (var page in Pages)
{
Visit(page.Controls);
}
if (found.Count > 0)
{
Selection.Set(found, primary ?? found[0]);
}
}
/// <summary>페이지 세로 오프셋 재계산</summary>
@@ -388,7 +388,7 @@ public sealed class InspectorViewModel : ViewModelBase
var items = designer.Selection.Items;
if (items.Count == 0)
{
RefreshTabCounts(null);
RefreshTabCounts(Array.Empty<PropertyDef>(), 0);
if (designer.InspectedPage is { } page)
{
BuildPageRows(page);
@@ -397,10 +397,22 @@ public sealed class InspectorViewModel : ViewModelBase
return;
}
// 같은 타입만 타입 전용 섹션을 낸다 — 탭 개수 계산도 이 조건을 따라야 어긋나지 않는다
var type = items[0].Type;
var descriptor = items.All(i => i.Type == type) ? ControlRegistry.Find(type) : null;
RefreshTabCounts(descriptor);
// 편집할 속성 집합을 <b>먼저</b> 정하고, 그 결과로 탭을 정한다.
//
// 순서가 중요하다. 전에는 "타입이 전부 같은가"만 보고 탭 가시성을 정했는데,
// 타입이 섞이면 descriptor 가 null 이라 데이터·동작 탭이 통째로 접혔다.
// 그런데 여러 개를 함께 고쳐야 하는 속성(읽기 전용·필수 입력·재조회·데이터 배선)이
// 정확히 그 두 탭에 있다 — 공통 섹션을 아무리 잘 만들어도 화면에는 글꼴과 좌표만 남고
// 왜 없는지 알려 주는 표시도 없었다.
var models = items.Select(i => i.Model).ToList();
var types = items.Select(i => i.Type).Distinct(StringComparer.Ordinal).ToList();
var descriptor = types.Count == 1 ? ControlRegistry.Find(types[0]) : null;
// MergeCommonDefs 는 타입이 하나면 그 타입의 속성을 그대로, 스키마를 모르는 타입이
// 섞이면 빈 집합을 돌려준다 — 여기서 갈래를 나눌 필요가 없다.
var merged = BatchEditPolicy.MergeCommonDefs(types)
.Where(def => BatchEditPolicy.IsBatchable(def.Key, models, viaAdvancedRaw: false, ParentModelOf).Allowed)
.ToList();
RefreshTabCounts(merged, items.Count);
if (selectedTab == InspectorTab.Design)
{
@@ -429,33 +441,46 @@ public sealed class InspectorViewModel : ViewModelBase
AddFontStyleSegment();
}
// 타입 전용 — 선택 탭에 해당하는 속성만
// 타입 전용(또는 타입이 섞였으면 공통) — 선택 탭에 해당하는 속성만
var curatedKeys = new HashSet<string>(HandledKeys, StringComparer.Ordinal);
if (descriptor is not null)
if (merged.Count > 0)
{
// 큐레이션 키는 탭과 무관하게 전부 모아야 한다 — 고급 목록에서 중복 노출을 막는 용도라
// 탭으로 걸러 버리면 다른 탭 속성이 고급에 다시 나온다.
foreach (var def in descriptor.Properties)
foreach (var def in merged)
{
curatedKeys.Add(def.Key);
}
var tabDefs = descriptor.Properties.Where(d => InspectorTabCatalog.TabOf(d) == selectedTab).ToList();
var tabDefs = merged.Where(d => InspectorTabCatalog.TabOf(d) == selectedTab).ToList();
if (tabDefs.Count > 0)
{
AddSection(descriptor.DisplayName);
// 섹션 제목에 한 타입의 이름을 쓰면 안 된다 — 라벨과 텍스트박스를 함께 고른
// 선택에 '라벨'이라는 제목이 붙으면 텍스트박스는 안 바뀐다고 읽는다.
AddSection(types.Count == 1 && descriptor is not null
? descriptor.DisplayName
: $"공통 속성 ({types.Count}종 타입)");
foreach (var def in tabDefs)
{
AddDefRow(def, descriptor.Type);
// 태그 제안은 타입별로 후보가 갈린다 — 섞였으면 한쪽으로 편향시키지 않는다
AddDefRow(def, types.Count == 1 ? types[0] : string.Empty, models);
}
// 함께 못 바꾸는 것이 있으면 <b>말한다</b>. 조용히 빼면 그 속성이 없다고 읽는다.
var excluded = BatchEditPolicy.ExcludedCount(types);
if (excluded > 0)
{
AddPlain(new ReadOnlyRowViewModel("함께 바꿀 수 없음",
$"타입이 달라 값의 뜻이 다른 속성 {excluded}개 — 타입별로 따로 고르세요"));
}
}
}
// 전체 속성(고급) — 단일 선택 시 PropBag 의 나머지 레거시 속성 전부(레거시 PropertyGrid 등가).
// 전체 속성(고급) — PropBag 의 나머지 레거시 원문 속성(레거시 PropertyGrid 등가).
// 레거시 원문 키는 분류 근거가 없어 어느 탭에도 속하지 않는다 — 탈출구이므로 모든 탭 아래에 둔다.
if (items.Count == 1)
// 표(Spread)가 섞이면 열지 않는다 — 격자 디자인이 별도 테이블에 있어 원문 편집의 파장을 모른다.
if (!items.Any(i => DesignerViewModel.IsSpread(i.Model)))
{
BuildAdvancedRows(items[0], curatedKeys);
BuildAdvancedRows(items, curatedKeys);
}
OnPropertyChanged(nameof(IsTabEmpty));
@@ -469,11 +494,13 @@ public sealed class InspectorViewModel : ViewModelBase
/// (데이터 탭도 6타입에서 빈다). 빈 탭을 남겨 두면 대부분의 컨트롤에서 "여긴 없습니다"만
/// 보게 되고, 사용자는 속성 하나를 찾아 빈 탭을 계속 눌러보게 된다 — 탭이 없느니만 못하다.
/// </summary>
private void RefreshTabCounts(ControlDescriptor? descriptor)
private void RefreshTabCounts(IReadOnlyList<PropertyDef> typed, int selectionCount)
{
var typed = descriptor?.Properties ?? (IReadOnlyList<PropertyDef>)Array.Empty<PropertyDef>();
// 디자인 탭에는 공통 5 + 글꼴 6 이 항상 붙는다 → 선택이 있으면 절대 비지 않는다
DesignCount = 11 + typed.Count(d => InspectorTabCatalog.TabOf(d) == InspectorTab.Design);
// 디자인 탭에는 공통 + 글꼴이 항상 붙는다 → 선택이 있으면 절대 비지 않는다.
// 다중 선택에는 이름 행이 없으므로(이름은 유일해야 한다) 하나 적다 —
// 전에는 11 로 굳어 있어 다중 선택에서 실제 행 수와 어긋났다.
DesignCount = (selectionCount == 1 ? 11 : 10)
+ typed.Count(d => InspectorTabCatalog.TabOf(d) == InspectorTab.Design);
DataCount = typed.Count(d => InspectorTabCatalog.TabOf(d) == InspectorTab.Data);
BehaviorCount = typed.Count(d => InspectorTabCatalog.TabOf(d) == InspectorTab.Behavior);
@@ -665,12 +692,41 @@ public sealed class InspectorViewModel : ViewModelBase
}, () => designer.RefreshPageInheritance(page));
}
/// <summary>고급 섹션 구성 — 접이식, raw 문자열 편집 + 속성 추가</summary>
private void BuildAdvancedRows(ControlViewModel target, HashSet<string> curatedKeys)
/// <summary>
/// 고급 섹션 구성 — 접이식, raw 문자열 편집 + 속성 추가.
///
/// <b>다중 선택에서 위험한 곳이 여기다.</b> 전에는 행의 종류·편집 여부·키 목록을 전부
/// <c>items[0]</c> 하나로 정하면서 커밋은 선택 <b>전체</b>에 썼다. 그래서 이미지·중첩·참조 값을
/// 지키던 읽기 전용 방어가 "처음 클릭한 컨트롤"에만 걸렸고, 클릭 순서에 따라 같은 조작이
/// 안전해지거나 파괴적이 됐다. 게다가 <c>GetText</c> 는 문자열이 아닌 값에 null 을 돌려주므로
/// 빈 값과 이미지 원문이 둘 다 "" 로 접혀 <b>갈렸다는 표시조차 나오지 않았다</b>.
///
/// 그래서 판정을 대상 <b>전체</b>로 바꾼다 — 전부 갖고 있고 전부 문자열일 때만 편집을 연다.
/// 레거시 PropertyGrid 의 병합 규칙(이름과 타입이 모든 대상에 공통일 때만 행이 생긴다)과 같은 결이다.
/// </summary>
private void BuildAdvancedRows(IReadOnlyList<ControlViewModel> targets, HashSet<string> curatedKeys)
{
var advancedKeys = target.Model.Props.Keys
.Where(k => !curatedKeys.Contains(k))
.ToList();
if (targets.Count == 0)
{
return;
}
var target = targets[0];
var models = targets.Select(t => t.Model).ToList();
var single = targets.Count == 1;
// 키 목록은 <b>합집합</b>이다(선언 순서 보존) — 대상마다 가진 키가 다를 수 있고,
// 첫 컨트롤만 훑으면 나머지가 가진 속성은 존재조차 보이지 않는다.
var advancedKeys = new List<string>();
var seenKeys = new HashSet<string>(StringComparer.Ordinal);
foreach (var model in models)
{
foreach (var key in model.Props.Keys)
{
if (!curatedKeys.Contains(key) && seenKeys.Add(key))
{
advancedKeys.Add(key);
}
}
}
if (!showAdvanced)
{
@@ -692,6 +748,36 @@ public sealed class InspectorViewModel : ViewModelBase
foreach (var key in advancedKeys)
{
// 다중 선택은 대상 <b>전체</b>를 보고 판정한다 — 셋 다 통과해야 편집을 연다.
if (!single)
{
if (BatchEditPolicy.IsBatchable(key, models, viaAdvancedRaw: true, ParentModelOf) is { Allowed: false } verdict)
{
AddPlain(new ReadOnlyRowViewModel(key, $"(함께 바꿀 수 없음 — {verdict.Reason})"));
continue;
}
var having = BatchEditPolicy.CountHaving(key, models);
if (having < models.Count)
{
// 없던 대상에 키를 만들면 레거시 서식생성기가 열 때마다 '이 타입에 없는 속성'
// 이라며 모달을 띄운다 — 만들지 않는다
AddPlain(new ReadOnlyRowViewModel(key,
$"({models.Count}개 중 {having}개에만 있음 — 함께 바꿀 수 없음)"));
continue;
}
if (!BatchEditPolicy.AllShareTextShape(key, models))
{
AddPlain(new ReadOnlyRowViewModel(key, "(대상마다 값의 형태가 다릅니다 — 함께 바꿀 수 없음)"));
continue;
}
AddRow(new TextRowViewModel(key), new RowBinding
{
Get = vm => vm.Model.Props.GetText(key) ?? string.Empty,
Set = (vm, newValue) => vm.Model.Props.SetText(key, newValue),
});
continue;
}
var value = target.Model.Props.Get(key);
switch (value)
{
@@ -736,6 +822,13 @@ public sealed class InspectorViewModel : ViewModelBase
}
}
// 속성 추가는 단일 선택만. 중복 검사·쓰기·제안이 전부 대상 <b>하나</b> 기준이라
// 다중 선택에서 누르면 N개 중 1개에만 키가 생기고, 나머지는 아무 표시 없이 그대로다.
if (!single)
{
return;
}
// 속성 추가 — 타입별 실사용 키를 제안한다(자유 입력도 그대로 열어 둔다).
// 제안이 없으면 사용자가 철자를 외워 맞혀야 하고, 틀려도 경고가 없다 —
// 라벨의 소문자 visible, PrintOutPut 의 대문자 P 같은 함정이 실재한다.
@@ -752,7 +845,16 @@ public sealed class InspectorViewModel : ViewModelBase
}, LegacyPropertyCatalog.SuggestFor(target.Type, target.Model.Props.Keys)));
}
private void AddDefRow(PropertyDef def, string controlType)
/// <summary>
/// 컨트롤 모델 → 직계 부모 모델(최상위면 null).
///
/// 라디오 배타 판정에 쓴다 — 레거시 라디오는 <c>GroupName</c> 이 없어 <b>직계 부모가 곧 문항</b>이다.
/// 선택 목록을 뒤져 같은 모델의 VM 을 찾고 그 <c>Parent</c> 를 돌려준다.
/// </summary>
private ControlElement? ParentModelOf(ControlElement model)
=> designer.Selection.Items.FirstOrDefault(vm => ReferenceEquals(vm.Model, model))?.Parent?.Model;
private void AddDefRow(PropertyDef def, string controlType, IReadOnlyList<ControlElement> targets)
{
var binding = def.Editor == PropEditorKind.StringList
? new RowBinding
@@ -769,6 +871,7 @@ public sealed class InspectorViewModel : ViewModelBase
}
vm.Model.Props.Set(def.Key, itemsValue);
},
ConfirmBatch = ConfirmDestructive(def.Key),
}
: new RowBinding
{
@@ -793,6 +896,7 @@ public sealed class InspectorViewModel : ViewModelBase
vm.Model.Props.SetText(def.Key, value);
}
},
ConfirmBatch = ConfirmDestructive(def.Key),
};
PropertyRowViewModel row = def.Editor switch
@@ -818,9 +922,25 @@ public sealed class InspectorViewModel : ViewModelBase
PropEditorKind.DataTableField => new DataTableFieldRowViewModel(def.Label, DataTableNames),
_ => new TextRowViewModel(def.Label),
};
AddRow(row, binding);
// 선의 방향·굵기는 값만 바꾸는 것이 아니라 <b>크기까지</b> 바꾼다(ApplyLineChange).
// 알리지 않으면 선택 핸들이 옛 사각형에 남아, 화면에서는 선이 돌았는데 테두리는 그대로다.
AddRow(row, binding, def.Key is "Orientation" or "BorderWidth"
? () => designer.Selection.NotifyBoundsChanged()
: null);
}
/// <summary>
/// 여러 대상의 <b>고유값을 한꺼번에 덮는</b> 편집에만 붙이는 확인 — 텍스트·항목 목록.
///
/// 좌표·색·인쇄여부처럼 되돌리기 쉬운 것에는 붙이지 않는다. 확인을 남발하면 아무도 읽지 않고,
/// 정작 위험한 것을 물었을 때도 습관적으로 넘긴다.
/// </summary>
private static Func<int, bool>? ConfirmDestructive(string key) => key is "Text" or "Items"
? count => Services.DialogService.Confirm("일괄 변경",
$"{count}개 컨트롤의 기존 값이 모두 같은 값으로 바뀝니다.",
"각자 다르게 들어 있던 값은 되살릴 수 없습니다(되돌리기로만 복구됩니다).")
: null;
/// <summary>
/// 이 서식의 데이터소스(MDataTable) 이름 — 배선 편집기의 선택지.
///
@@ -905,6 +1025,8 @@ public sealed class InspectorViewModel : ViewModelBase
set(vm, Math.Round(number));
}
},
// 숫자가 아니면 커밋 자체를 막는다 — 전에는 스냅샷만 쌓이고 값은 하나도 안 바뀌었다
Validate = text => double.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out _),
AffectsVisual = false,
};
BindRow(row, binding, afterCommit: () => designer.Selection.NotifyBoundsChanged());
@@ -941,6 +1063,10 @@ public sealed class InspectorViewModel : ViewModelBase
set(font, value);
vm.Model.Props.SetText("Font", LegacyFormat.FormatFont(font));
},
// 글꼴 크기는 양수만 — 0 이나 음수를 넣으면 Set 이 조용히 옛 크기를 유지해
// "바꿨는데 안 바뀐다"가 된다. 커밋 전에 막고 칸을 되돌린다.
Validate = text => double.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out var size)
&& size > 0,
});
}
@@ -1030,6 +1156,12 @@ public sealed class InspectorViewModel : ViewModelBase
// 목록 밖 값이 들어오면 ComboBox 가 null 로 코어스해 되쓰면서 속성을 지운다 — 표시 전에 편입시킨다.
if (row is ChoiceRowViewModel choice)
{
// 값이 갈리면 '여러 값' 자리를 먼저 만든다 — 그러면 빈 칸이 '값 없음'과 구별되고,
// 목록에 없는 값(빈 문자열)을 SelectedItem 에 물리는 상태 자체가 사라진다.
if (values.Count > 1)
{
choice.EnsureMixedPlaceholder();
}
choice.EnsureChoice(initial);
}
@@ -1042,6 +1174,27 @@ public sealed class InspectorViewModel : ViewModelBase
{
return;
}
// ── 스냅샷 <b>앞</b>의 관문 셋. 순서가 중요하다: 여기서 걸러야 '바뀐 것이 없는데
// 문서가 수정됨' 이 생기지 않고, 그래야 사용자가 그걸 지우려 Ctrl+Z 를 누를 일도 없다.
if (binding.Validate is { } validate && !validate(value))
{
// 값으로 인정하지 않았으므로 칸을 원래 표시로 되돌린다 — 잘못된 문자열이
// 다음 재구성까지 남아 값인 척하는 것을 막는다
var current = designer.Selection.Items.Select(binding.Get).Distinct(StringComparer.Ordinal).ToList();
row.Initialize(current.Count == 1 ? current[0] : null, isMixed: current.Count > 1);
return;
}
// 이미 전부 그 값이면 아무 일도 하지 않는다(스냅샷도 찍지 않는다)
var before = targets.Select(binding.Get).Distinct(StringComparer.Ordinal).ToList();
if (before.Count == 1 && string.Equals(before[0], value, StringComparison.Ordinal))
{
return;
}
if (targets.Count > 1 && binding.ConfirmBatch is { } confirm && !confirm(targets.Count))
{
row.Initialize(before.Count == 1 ? before[0] : null, isMixed: before.Count > 1);
return;
}
// 화살표 연타는 400ms 코얼레스로 한 스텝에 묶는다 — 커밋 1회가 문서 딥클론 1회라
// 그대로 두면 ↑ 스무 번에 스냅샷 스무 개가 쌓여 편집 이력이 밀려 나간다.
if (row.CoalesceUndo)
@@ -30,6 +30,15 @@ public abstract class PropertyRowViewModel : ViewModelBase
/// <summary>선택 대상들의 값이 서로 다른지 — "여러 값" 표시</summary>
public bool IsMixed { get; private set; }
/// <summary>
/// 값이 갈렸을 때 <b>요약 칸</b>에 쓰는 글귀 — 쿼리·마스크·배선처럼 값 대신 요약을 그리는 행용.
///
/// 이 행들은 빈 칸이 아니라 "(쿼리 없음)"·"(마스크 없음)"·"(배선 없음)" 같은 <b>단정</b>을 그린다.
/// 서로 다른 쿼리를 든 컨트롤 둘을 골랐을 때 "쿼리 없음"이 나오면 그건 빈 칸보다 나쁘다 —
/// 없다고 믿고 새로 쓰면 양쪽의 원래 쿼리가 한꺼번에 사라진다.
/// </summary>
protected const string MixedSummary = "(여러 값)";
/// <summary>
/// 이 행이 품고 있는 '속성' 개수 — 섹션 헤더의 건수 표시용.
/// 쌍 행·세그먼트 행은 한 줄이지만 속성은 여럿이라, 줄 수를 세면 헤더가 "공통 3"처럼
@@ -52,6 +61,11 @@ public abstract class PropertyRowViewModel : ViewModelBase
get => valueText;
set
{
// null 을 문자열로 정규화한다. 뷰가 null 을 되밀 수 있는 경로가 있고
// (ComboBox.SelectedItem 은 TwoWay 기본이라 목록에 없는 값을 null 로 코어스한다)
// 그 null 이 커밋까지 흘러가면 AddDefRow 의 value.Length 에서 NullReferenceException 이다.
// 아래 SetProperty 도 "" 와 null 을 다른 값으로 보아 무의미한 커밋을 한 번 내보낸다.
value ??= string.Empty;
if (!SetProperty(ref valueText, value) || building)
{
return;
@@ -365,10 +379,21 @@ public sealed class ToggleRowViewModel : PropertyRowViewModel
get => IsMixed ? null : ValueText == "True";
set
{
if (value is bool on)
if (value is not bool on)
{
ValueText = on ? "True" : "False";
return;
}
// <b>갈린 상태에서 누르면 켠다.</b> WPF ToggleButton.OnToggle 은 IsChecked 가 null 이면
// IsThreeState 와 무관하게 false 로 간다(내부적으로 isChecked.HasValue — null 이면 false).
// 그대로 두면 굵게를 <b>켜려고</b> 누른 한 번이 선택 전체를 '굵게 끔'으로 커밋한다.
// 실측(edit-smoke): 누르기 전 IsChecked=null → 커밋 "False" → 누른 뒤 False.
// 갈린 상태에서 setter 에 false 가 오는 경로는 이 클릭뿐이므로(사용자가 '꺼짐'을 본 적이
// 없다) 켜는 것으로 읽는다 — 디자이너 관례도 '혼합을 누르면 전부 켜기'다.
if (IsMixed && !on)
{
on = true;
}
ValueText = on ? "True" : "False";
}
}
@@ -402,6 +427,27 @@ public sealed class ChoiceRowViewModel : PropertyRowViewModel
choices.Add(value);
}
}
/// <summary>
/// 값이 갈린 다중 선택에서 <b>'여러 값' 자리를 목록 맨 앞에 만든다</b>(빈 문자열).
///
/// 두 가지를 동시에 해결한다.
/// ① <b>보이게 한다.</b> 갈린 값은 ValueText 가 "" 라 콤보가 빈 칸으로 보이는데,
/// 그건 '값 없음'과 구별되지 않는다. 목록에 자리가 있으면 "여러 값"으로 그릴 수 있다.
/// ② <b>코어스를 막는다.</b> <see cref="EnsureChoice"/> 는 IsNullOrEmpty 에서 빠져나가므로
/// "" 를 편입하지 않는다. 목록에 없는 값을 SelectedItem 에 물리면 WPF Selector 가
/// null 로 코어스하고 TwoWay 로 되쓸 수 있다 — 그 경로는 실측(edit-smoke)에서 재현되지
/// 않았지만, 자리를 만들어 두면 코어스 자체가 성립하지 않으므로 따질 일이 없어진다.
///
/// 사용자가 이 빈 항목을 다시 골라도 ""→"" 라 SetProperty 가 false 를 돌려주고 커밋이 없다.
/// </summary>
public void EnsureMixedPlaceholder()
{
if (!choices.Contains(string.Empty, StringComparer.Ordinal))
{
choices.Insert(0, string.Empty);
}
}
}
/// <summary>
@@ -613,7 +659,9 @@ public sealed class TagPickerRowViewModel : PropertyRowViewModel
public sealed class MaskRowViewModel : PropertyRowViewModel
{
/// <summary>이 마스크가 화면에 어떻게 보이는지 — 인스펙터에서 바로 확인</summary>
public string Preview => ValueText.Length == 0
public string Preview => IsMixed
? MixedSummary
: ValueText.Length == 0
? "(마스크 없음)"
: SheetMe.Core.Serialization.LegacyMask.ToPromptDisplay(ValueText);
@@ -648,6 +696,10 @@ public sealed class QueryRowViewModel : PropertyRowViewModel
{
get
{
if (IsMixed)
{
return MixedSummary;
}
var oneLine = ValueText.Replace("\r", " ").Replace("\n", " ").Trim();
return oneLine.Length == 0 ? "(쿼리 없음)" : oneLine.Length > 48 ? oneLine[..48] + "…" : oneLine;
}
@@ -843,6 +895,10 @@ public sealed class DataTableFieldRowViewModel : PropertyRowViewModel
{
get
{
if (IsMixed)
{
return MixedSummary;
}
if (ValueText.Length == 0)
{
return "(배선 없음)";
@@ -987,4 +1043,22 @@ public sealed class RowBinding
/// <summary>커밋 후 시각 재해석 필요 여부</summary>
public bool AffectsVisual { get; init; } = true;
/// <summary>
/// 커밋 전 값 검사 — false 면 <b>아무것도 하지 않고</b> 칸을 원래 표시로 되돌린다.
///
/// 숫자 칸이 그 예다. 전에는 "100px" 같은 문자열도 스냅샷을 먼저 찍은 뒤 대상마다
/// TryParse 에 실패해 아무것도 안 바뀌었다 — <b>바뀐 것이 없는데 문서는 '수정됨'</b>이 되고
/// 잘못된 문자열은 다음 재구성까지 칸에 남아 값인 척했다. 다중 선택은 좌표·크기가
/// 갈리는 것이 기본에 가까워 이 경로를 훨씬 자주 밟는다.
/// </summary>
public Func<string, bool>? Validate { get; init; }
/// <summary>
/// 대상이 둘 이상일 때 커밋 전에 받는 확인 — false 면 커밋하지 않는다(인자는 대상 수).
///
/// 대상마다 <b>서로 다른 고유값</b>이 한 번에 사라지는 편집에만 붙인다(텍스트·항목 목록).
/// 좌표·색·인쇄여부처럼 되돌리기 쉬운 것에는 붙이지 않는다 — 확인을 남발하면 아무도 안 읽는다.
/// </summary>
public Func<int, bool>? ConfirmBatch { get; init; }
}
+44 -1
View File
@@ -11,6 +11,7 @@
<UserControl.Resources>
<ctl:IconNameToVisualConverter x:Key="IconName"/>
<ctl:ChoiceLabelConverter x:Key="ChoiceLabel"/>
<BooleanToVisibilityConverter x:Key="BoolToVisibility"/>
<!--
@@ -299,6 +300,18 @@
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Accent2}"/>
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Accent}"/>
</Trigger>
<!--
갈림(다중선택에서 값이 서로 다름) — 표시가 없으면 '꺼짐'과 픽셀 단위로 같다.
그러면 굵게를 켜려고 누른 한 번이 선택 전체를 '굵게 끔'으로 만들어도
(WPF 는 불확정에서 false 로 간다) 사용자는 무엇이 일어났는지 알 수 없다.
동작 자체는 ToggleRowViewModel.IsOn 이 '갈림에서 누르면 켠다'로 고쳤고,
여기서는 <b>갈렸다는 사실</b>을 보이게 한다. SwitchToggle(:105-108)과 같은 표현.
-->
<Trigger Property="IsChecked" Value="{x:Null}">
<Setter TargetName="bd" Property="Opacity" Value="0.55"/>
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Line2}"/>
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Line2}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
@@ -425,6 +438,15 @@
<DataTemplate DataType="{x:Type ins:AlignRowViewModel}">
<DockPanel Margin="0,2">
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}" VerticalAlignment="Top" Margin="0,4,6,0"/>
<!--
갈린 값이면 9칸이 전부 꺼져 '아직 아무것도 안 고른 격자'와 똑같이 보인다.
다른 편집기는 MixedHintOverlay 로 알리는데 이 격자만 알릴 자리가 없었다 —
칸 위가 아니라 격자 오른쪽에 붙여 어느 칸도 가리지 않게 한다.
-->
<TextBlock DockPanel.Dock="Right" Text="여러 값" FontStyle="Italic" FontSize="11"
VerticalAlignment="Top" Margin="6,6,0,0"
Foreground="{DynamicResource B.Muted}"
Visibility="{Binding IsMixed, Converter={StaticResource BoolToVisibility}}"/>
<ItemsControl ItemsSource="{Binding Cells}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><UniformGrid Columns="3"/></ItemsPanelTemplate>
@@ -533,7 +555,28 @@
<DockPanel Margin="0,2">
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}" ToolTip="{Binding Label}"/>
<Grid>
<ComboBox Style="{StaticResource RowCombo}" ItemsSource="{Binding Choices}" SelectedItem="{Binding ValueText}"/>
<!--
빈 항목은 '여러 값' 자리다(ChoiceRowViewModel.EnsureMixedPlaceholder).
그냥 두면 목록에 빈 줄로 보여 무엇인지 알 수 없다.
-->
<ComboBox Style="{StaticResource RowCombo}" ItemsSource="{Binding Choices}" SelectedItem="{Binding ValueText}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource ChoiceLabel}}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Style.Triggers>
<DataTrigger Binding="{Binding}" Value="">
<Setter Property="FontStyle" Value="Italic"/>
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Style="{StaticResource MixedHintOverlay}" Margin="9,0,0,0"/>
</Grid>
</DockPanel>