레이어 패널에 [200]SheetMe 그룹 기능 이식 — 폴더 행·계층·타입 필터·다중선택

[200] 의 그룹도 컨테이너가 아니라 GroupId 태그 + 이름 사전이었다. SheetMe 가 이미
같은 구조(ControlElement.GroupId, JsonIgnore)를 갖고 있어 저장 포맷은 그대로 두고
표시·조작 계층만 얹는다 — 왕복 XML 에는 아무 흔적도 남지 않는다.

레이어 목록을 평탄화 결과(LayerRows)로 바꿨다. 그룹 폴더 행과 컨트롤 행이 한
시퀀스로 섞이고, 계층은 들여쓰기 여백으로만 표현한다(TreeView 를 쓰면 가상화와
기존 행 템플릿을 다 버려야 한다 — 인스펙터 섹션이 쓰는 방식과 같게 맞췄다).

새로 되는 것
- 그룹 폴더 행: 접기/펼치기, 멤버 수, 이름 인라인 편집(더블클릭), 그룹 전체 눈·자물쇠
- 그룹 이름 — FormDocument.GroupNames(편집 전용). 비우면 기본 그룹 으로 복귀
- 컨테이너(패널/그룹박스) 자식이 레이어에 보인다. 지금까지는 아예 접근할 수 없었다
- 목록 다중선택(Shift/Ctrl) — 캔버스 선택과 양방향 동기
- 타입 필터: 그 페이지에 실제로 있는 타입만 후보로 띄운다
- 검색 0건·빈 페이지 안내 문구

함께 고친 결함
- 붙여넣기가 원본 그룹에 끼어들었다(Clone 이 GroupId 를 그대로 복사) → 복제본끼리 새 그룹
- 그룹 해제가 선택된 멤버만 풀어 1개짜리 반쪽 그룹이 남았다 → 그룹 전 멤버를 푼다
- 멤버가 1개 이하로 줄어든 그룹이 계속 남았다 → CleanupGroups 자가치유
- 셰브론이 늘 눌린 것처럼 보였다(IsChecked 채움) → MiniChevron 스타일 분리

--edit-smoke 에 레이어 검사 19건 추가(그룹 위생·평탄화·필터·XML 무영향).
회귀: 테스트 124/124, edit-smoke 실패 0, db-smoke 1,271건 diff 0/예외 0,
종이 렌더 P062 바이트 동일.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-12 15:43:37 +09:00
co-authored by Claude Opus 5
parent d09bd90c8e
commit a30421f9b5
14 changed files with 1154 additions and 89 deletions
@@ -126,6 +126,8 @@ public static class DialogShots
("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" })),
};
}
@@ -261,6 +261,118 @@ public static class EditSmoke
Check("폰트 일괄 적용: Undo 1스텝 원복",
Find("Label").Model.Props.GetText("Font") != "돋움, 12pt, style=Bold");
// 20-2-1) 레이어 목록 — 그룹 폴더/계층 평탄화/검색·타입 필터/그룹 위생.
// 뒤 단계들이 designer 의 컨트롤 개수를 세므로 별도 문서에서 검사한다.
{
var lay = new DesignerViewModel(business.CreateNew());
lay.AddControlAt("TextBox", new Point(100, 100));
lay.AddControlAt("Label", new Point(100, 200));
lay.AddControlAt("CheckBox", new Point(100, 300));
ControlViewModel Pick(string type) => lay.Pages[0].Controls.First(c => c.Type == type);
lay.Selection.Set(new[] { Pick("TextBox"), Pick("Label") });
lay.GroupSelection();
var groupRow = lay.LayerRows.OfType<LayerGroupViewModel>().FirstOrDefault();
Check("레이어: 그룹 폴더 행 1개", lay.LayerRows.OfType<LayerGroupViewModel>().Count() == 1);
Check("레이어: 폴더 멤버 2 + 기본 이름",
groupRow is { Members.Count: 2 } && groupRow.Name.StartsWith("그룹"),
$"실제 {groupRow?.Members.Count} / {groupRow?.Name}");
// 폴더 바로 뒤에 멤버가 들여쓰기된 채 이어져야 한다(평탄화 시퀀스)
var folderIndex = lay.LayerRows.IndexOf(groupRow!);
Check("레이어: 폴더 뒤에 멤버가 들여쓰여 이어짐",
folderIndex >= 0
&& lay.LayerRows.Skip(folderIndex + 1).Take(2)
.OfType<ControlViewModel>().Count(c => c.LayerIndent == 20) == 2);
// 접으면 멤버 행이 빠지고, 펴면 되돌아온다
var expandedCount = lay.LayerRows.Count;
groupRow!.IsExpanded = false;
lay.RebuildLayerRows();
Check("레이어: 접으면 멤버 행 제거", lay.LayerRows.Count == expandedCount - 2,
$"실제 {lay.LayerRows.Count} 기대 {expandedCount - 2}");
groupRow = lay.LayerRows.OfType<LayerGroupViewModel>().First();
groupRow.IsExpanded = true;
lay.RebuildLayerRows();
Check("레이어: 펴면 멤버 행 복귀", lay.LayerRows.Count == expandedCount);
// 그룹 일괄 숨김이 전 멤버에 퍼진다
groupRow = lay.LayerRows.OfType<LayerGroupViewModel>().First();
groupRow.IsHiddenFlag = true;
Check("레이어: 그룹 숨김 → 전 멤버", groupRow.Members.All(m => m.IsHiddenFlag));
groupRow.IsHiddenFlag = false;
Check("레이어: 그룹 표시 복귀", groupRow.Members.All(m => !m.IsHiddenFlag));
// 그룹 이름은 문서가 소유 — 비우면 기본값으로 되돌아간다
groupRow.Name = "머리글 묶음";
Check("레이어: 그룹 개명", lay.GroupNameOf(groupRow.GroupId) == "머리글 묶음");
groupRow.Name = string.Empty;
Check("레이어: 이름 비우면 기본값", lay.GroupNameOf(groupRow.GroupId) == "그룹");
// 컨테이너 자식이 계층으로 보이고, 접으면 목록에서 빠진다
lay.AddControlAt("Panel", new Point(300, 600));
var layerPanel = lay.Pages[0].Controls.OfType<PanelViewModel>().First();
lay.AddControlAt("Label", new Point(layerPanel.X + 20, layerPanel.Y + 20));
lay.RebuildLayerRows();
Check("레이어: 컨테이너 자식이 목록에 나옴",
lay.LayerRows.OfType<ControlViewModel>().Any(c => ReferenceEquals(c.Parent, layerPanel)));
Check("레이어: 자식 들여쓰기 = 부모+16",
lay.LayerRows.OfType<ControlViewModel>()
.Where(c => ReferenceEquals(c.Parent, layerPanel))
.All(c => c.LayerIndent == layerPanel.LayerIndent + 16));
var withChild = lay.LayerRows.Count;
layerPanel.IsLayerExpanded = false;
lay.RebuildLayerRows();
Check("레이어: 컨테이너 접으면 자식 제거", lay.LayerRows.Count == withChild - 1);
layerPanel.IsLayerExpanded = true;
lay.RebuildLayerRows();
// 검색은 자손이 걸리면 조상도 남긴다 — 부모가 빠지면 자식이 통째로 사라진다
var childId = layerPanel.Children[0].Id;
lay.LayerFilter = childId;
Check("레이어: 검색 시 조상 보존",
lay.LayerRows.Contains(layerPanel)
&& lay.LayerRows.OfType<ControlViewModel>().Any(c => c.Id == childId));
lay.LayerFilter = "존재하지않는이름____";
Check("레이어: 0건이면 빈 목록 안내", lay.IsLayerListEmpty && lay.LayerEmptyText.Contains("검색"));
lay.LayerFilter = string.Empty;
// 타입 필터
lay.SetLayerTypeFilter(new[] { "CheckBox" });
Check("레이어: 타입 필터 적용",
lay.HasLayerTypeFilter
&& lay.LayerRows.OfType<ControlViewModel>().Any()
&& lay.LayerRows.OfType<ControlViewModel>().All(c => c.Type == "CheckBox"));
lay.SetLayerTypeFilter(Array.Empty<string>());
Check("레이어: 타입 필터 해제", !lay.HasLayerTypeFilter);
// 붙여넣은 복제본은 원본 그룹에 끼지 않는다(GroupId 재매핑)
groupRow = lay.LayerRows.OfType<LayerGroupViewModel>().First();
var sourceGroupId = groupRow.Members[0].Model.GroupId;
lay.Selection.SetSingle(groupRow.Members[0]);
lay.CopySelection();
lay.Paste();
Check("레이어: 붙여넣기가 원본 그룹에 끼지 않음",
lay.Selection.Items[0].Model.GroupId != sourceGroupId,
$"원본 {sourceGroupId} 복제 {lay.Selection.Items[0].Model.GroupId}");
// 멤버가 1개로 줄면 그룹은 자동 해제되고 이름도 정리된다
groupRow = lay.LayerRows.OfType<LayerGroupViewModel>().First();
lay.Selection.SetSingle(groupRow.Members[1]);
lay.DeleteSelection();
Check("레이어: 멤버 1개 남으면 그룹 자동 해제",
!lay.LayerRows.OfType<LayerGroupViewModel>().Any()
&& lay.Pages[0].Controls.All(c => c.Model.GroupId is null)
&& lay.Document.GroupNames.Count == 0);
// 그룹은 편집 전용 상태다 — 저장 XML 에 흔적이 남으면 왕복 불변식이 깨진다
lay.Selection.Set(lay.Pages[0].Controls.Take(2).ToList());
lay.GroupSelection();
Check("레이어: 그룹은 XML 에 나가지 않음",
!serializer.Write(lay.Document).Contains("GroupId", StringComparison.OrdinalIgnoreCase));
}
// 20-3) 문서 간 복사/붙여넣기 — A 서식에서 복사 → B 서식에 붙여넣기(클립보드 공유)
var designerB = new DesignerViewModel(business.CreateNew());
designer.Selection.SetSingle(Find("TextBox"));