레이어 패널에 [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
@@ -15,6 +15,8 @@ public abstract class ControlViewModel : ViewModelBase
#region Member Fields
private bool isSelected;
private bool isHovered;
private double layerIndent;
private bool isLayerExpanded = true;
#endregion
#region Properties
@@ -172,6 +174,39 @@ public abstract class ControlViewModel : ViewModelBase
/// <summary>레이어 숨김 토글 아이콘명</summary>
public string HiddenIcon => Model.Hidden ? "eye-off" : "eye";
/// <summary>
/// 레이어 목록 들여쓰기(px) — 그룹 멤버·컨테이너 자식의 계층을 나타낸다.
/// 목록이 평탄한 ListBox 라(TreeView 아님) 깊이를 여백으로만 표현한다 — 평탄화 시 대입된다.
/// </summary>
public double LayerIndent
{
get => layerIndent;
set => SetProperty(ref layerIndent, value);
}
/// <summary>펼침 토글 통지 — 목록을 다시 만들라는 신호(평탄화 때 디자이너가 배선한다)</summary>
public Action? LayerExpandedChanged { get; set; }
/// <summary>컨테이너(Panel/GroupBox) 자식 펼침 — 접으면 자식 행이 목록에서 빠진다</summary>
public bool IsLayerExpanded
{
get => isLayerExpanded;
set
{
if (SetProperty(ref isLayerExpanded, value))
{
OnPropertyChanged(nameof(LayerChevronIcon));
LayerExpandedChanged?.Invoke();
}
}
}
/// <summary>펼침 셰브론을 그릴지 — 자식이 있는 컨테이너에서만</summary>
public virtual bool HasLayerChildren => false;
/// <summary>펼침 셰브론 아이콘명</summary>
public string LayerChevronIcon => isLayerExpanded ? "chevron-down" : "chevron-right";
#endregion
#region Constructors
@@ -133,6 +133,9 @@ public abstract class ContainerViewModel : ControlViewModel
/// <summary>자식 컨트롤(그리기 순서 — 마지막이 최상위)</summary>
public ObservableCollection<ControlViewModel> Children { get; } = new();
/// <summary>레이어 목록에서 펼침 셰브론을 그릴지 — 자식이 있을 때만</summary>
public override bool HasLayerChildren => Children.Count > 0;
protected ContainerViewModel(ControlElement model) : base(model) { }
/// <summary>시각 재해석 — 자식에게 상속 컨텍스트 전파(WinForms Font/ForeColor 상속)</summary>
@@ -74,6 +74,10 @@ public sealed class DesignerViewModel : ViewModelBase
private bool isTabOrderMode;
private readonly List<ControlViewModel> tabOrderSequence = new();
private readonly Dictionary<string, SpreadGridInfo>? spreadDesigns;
// 레이어 목록 상태 — 접힌 그룹과 타입 필터는 문서가 아니라 편집 세션에 속한다(Undo 대상 아님)
private readonly HashSet<string> collapsedGroups = new(StringComparer.Ordinal);
private readonly HashSet<string> layerTypeFilter = new(StringComparer.Ordinal);
private System.Collections.Specialized.INotifyCollectionChanged? watchedControls;
#endregion
#region Properties
@@ -147,11 +151,33 @@ public sealed class DesignerViewModel : ViewModelBase
{
Selection.ActivePage = value;
}
OnPropertyChanged(nameof(VisibleLayers));
WatchLayerSource(value);
RebuildLayerRows();
}
}
}
/// <summary>
/// 활성 페이지의 최상위 컬렉션을 구독해 컨트롤 추가·삭제·순서 변경을 레이어 목록에 반영한다.
/// (예전에는 <c>page.Controls</c> 를 그대로 ItemsSource 로 줘서 공짜로 따라왔지만,
/// 그룹 폴더가 끼면서 목록이 평탄화 결과가 됐다 — 원본이 변할 때 다시 만들어야 한다.)
/// </summary>
private void WatchLayerSource(PageViewModel? page)
{
if (watchedControls is not null)
{
watchedControls.CollectionChanged -= OnLayerSourceChanged;
}
watchedControls = page?.Controls;
if (watchedControls is not null)
{
watchedControls.CollectionChanged += OnLayerSourceChanged;
}
}
private void OnLayerSourceChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
=> RebuildLayerRows();
/// <summary>
/// 레이어 검색어 — 이름(LayerName)·Id 부분일치. 서식당 컨트롤이 수백 개인 경우가 흔해
/// (운영 최대 1,067개) 스크롤만으로는 대상을 찾을 수 없다.
@@ -163,43 +189,36 @@ public sealed class DesignerViewModel : ViewModelBase
{
if (SetProperty(ref layerFilter, value ?? string.Empty))
{
OnPropertyChanged(nameof(VisibleLayers));
RebuildLayerRows();
}
}
}
/// <summary>
/// 레이어 목록에 실제로 표시할 항목활성 페이지의 컨트롤에 검색어를 적용한 결과.
/// 검색어가 비면 원본 컬렉션을 그대로 돌려준다(항목 추가·삭제가 즉시 반영되도록).
/// 레이어 목록에 실제로 표시할 그룹 폴더 행(<see cref="LayerGroupViewModel"/>)과
/// 컨트롤 행(<see cref="ControlViewModel"/>)이 섞인 <b>평탄화 결과</b>다.
///
/// TreeView 를 쓰지 않는 이유: 목록이 최대 1,067행이라 가상화가 필요하고, 컨트롤 행 템플릿
/// (눈·자물쇠·2줄 이름)을 이미 ListBox 기준으로 만들어 뒀다. 계층은 <c>LayerIndent</c> 여백으로만 표현한다
/// (인스펙터 섹션이 쓰는 방식과 같다).
/// </summary>
public System.Collections.IEnumerable? VisibleLayers
{
get
{
var source = SelectedPage?.Controls;
if (source is null || layerFilter.Length == 0)
{
return source;
}
return source
.Where(c => c.LayerName.Contains(layerFilter, StringComparison.OrdinalIgnoreCase)
|| c.Id.Contains(layerFilter, StringComparison.OrdinalIgnoreCase))
.ToList();
}
}
public ObservableCollection<object> LayerRows { get; } = new();
/// <summary>레이어 목록 선택 항목 — 캔버스 선택과 동기</summary>
public ControlViewModel? SelectedLayerItem
{
get => Selection.Primary;
set
{
if (value is not null && value != Selection.Primary)
{
Selection.SetSingle(value);
}
}
}
/// <summary>검색·필터 결과가 0행인지 — 빈 목록에 안내를 띄우기 위한 플래그</summary>
public bool IsLayerListEmpty => LayerRows.Count == 0;
/// <summary>레이어 목록이 비어 보이는 이유</summary>
public string LayerEmptyText =>
SelectedPage is null || SelectedPage.Controls.Count == 0
? "이 페이지에는 컨트롤이 없습니다"
: "검색·필터에 맞는 항목이 없습니다";
/// <summary>타입 필터가 걸려 있는지 — 헤더 버튼 강조/초기화 버튼 활성 판단</summary>
public bool HasLayerTypeFilter => layerTypeFilter.Count > 0;
/// <summary>타입 필터 상태 툴팁</summary>
public string LayerTypeFilterTip =>
layerTypeFilter.Count == 0 ? "타입으로 거르기" : $"{layerTypeFilter.Count}종만 표시 중";
/// <summary>줌 배율(0.25~3.0)</summary>
public double Zoom
@@ -248,7 +267,7 @@ public sealed class DesignerViewModel : ViewModelBase
Interaction = new InteractionController(this);
Inspector = new InspectorViewModel(this);
Selection.Changed += RefreshOverlay;
Selection.SetChanged += () => OnPropertyChanged(nameof(SelectedLayerItem));
Selection.SetChanged += LayerSelectionChanged;
UndoCommand = new Command((sender, e) => Undo.Undo());
RedoCommand = new Command((sender, e) => Undo.Redo());
@@ -363,6 +382,8 @@ public sealed class DesignerViewModel : ViewModelBase
OnPropertyChanged(nameof(WorldHeight));
RefreshOverlay();
Inspector.Rebuild();
// SelectedPage 대입이 이미 목록을 만들지만, 페이지가 0개면 setter 가 발화하지 않는다
RebuildLayerRows();
}
/// <summary>페이지 추가 — 문서 끝에 빈 페이지(720×856)</summary>
@@ -946,6 +967,8 @@ public sealed class DesignerViewModel : ViewModelBase
childViewModel.Parent = container;
container.Children.Add(childViewModel);
Selection.SetSingle(childViewModel);
// 컨테이너 자식은 page.Controls 밖이라 CollectionChanged 가 오지 않는다
RebuildLayerRows();
return;
}
@@ -996,6 +1019,8 @@ public sealed class DesignerViewModel : ViewModelBase
page.Model.Controls.Remove(vm.Model);
}
Selection.Clear();
// 컨테이너 자식 삭제는 page.Controls 를 건드리지 않아 CollectionChanged 가 오지 않는다
RebuildLayerRows();
}
/// <summary>선택 복사 — 내부 클립보드(딥클론, 모델 순서 유지)</summary>
@@ -1044,17 +1069,44 @@ public sealed class DesignerViewModel : ViewModelBase
Undo.Snapshot();
var pasted = new List<ControlViewModel>();
var usedIds = IdGenerator.CollectUsed(document);
// 그룹은 복제본끼리 다시 묶는다 — Clone 이 GroupId 를 그대로 복사하므로 그냥 두면
// 붙여넣은 컨트롤이 원본 그룹에 끼어들어 원본 하나만 클릭해도 복제본까지 함께 선택된다
var groupRemap = new Dictionary<string, string>(StringComparer.Ordinal);
// 모델 순서(0=최상위) 유지: 역순으로 index 0 삽입
foreach (var source in Enumerable.Reverse(pastable))
{
var clone = source.Clone();
AssignNewIds(clone, usedIds);
RemapGroupId(clone, groupRemap);
clone.Bounds.X += 12;
clone.Bounds.Y += 12;
page.Model.Controls.Insert(0, clone);
pasted.Add(CreateAndAttach(clone, page, attachToEnd: true));
}
Selection.Set(pasted, pasted.LastOrDefault());
RebuildLayerRows();
}
/// <summary>복제본의 그룹 식별자를 새 값으로 치환 — 같은 붙여넣기 안에서는 같은 그룹을 유지한다</summary>
private void RemapGroupId(ControlElement element, Dictionary<string, string> remap)
{
if (element.GroupId is { } original)
{
if (!remap.TryGetValue(original, out var replacement))
{
replacement = Guid.NewGuid().ToString("N");
remap[original] = replacement;
if (document.GroupNames.TryGetValue(original, out var name))
{
document.GroupNames[replacement] = name;
}
}
element.GroupId = replacement;
}
foreach (var child in element.Children)
{
RemapGroupId(child, remap);
}
}
/// <summary>
@@ -1169,7 +1221,10 @@ public sealed class DesignerViewModel : ViewModelBase
Selection.NotifyBoundsChanged();
}
/// <summary>선택 그룹화 — 동일 GroupId 부여(2개 이상, 최상위 컨트롤만)</summary>
/// <summary>
/// 선택 그룹화 — 동일 GroupId 부여(2개 이상, 최상위 컨트롤만) + 기본 이름 등록.
/// 잠긴 컨트롤은 애초에 선택에 들어오지 않는다(SelectionService 가 거른다).
/// </summary>
public void GroupSelection()
{
var targets = Selection.Items.Where(vm => vm.Parent is null && PageOf(vm) is not null).ToList();
@@ -1183,20 +1238,39 @@ public sealed class DesignerViewModel : ViewModelBase
{
vm.Model.GroupId = groupId;
}
// 이름이 없으면 목록에 "그룹"만 여러 개 뜬다 — 만들 때 번호를 붙여 준다
document.GroupNames[groupId] = $"그룹 {ActiveGroupIds().Count}";
RebuildLayerRows();
}
/// <summary>선택 그룹 해제</summary>
/// <summary>
/// 선택 그룹 해제 — 선택에 걸린 그룹의 <b>전 멤버</b>를 푼다(선택되지 않은 멤버 포함).
/// 일부만 풀면 멤버 1개짜리 반쪽 그룹이 남아 다음 선택부터 동작이 이상해진다.
/// </summary>
public void UngroupSelection()
{
if (Selection.Items.Count == 0)
var groupIds = Selection.Items
.Select(vm => vm.Model.GroupId)
.Where(id => id is not null)
.Distinct(StringComparer.Ordinal)
.ToList();
if (groupIds.Count == 0)
{
return;
}
Undo.Snapshot();
foreach (var vm in Selection.Items)
foreach (var control in AllControls())
{
vm.Model.GroupId = null;
if (control.Model.GroupId is { } id && groupIds.Contains(id, StringComparer.Ordinal))
{
control.Model.GroupId = null;
}
}
foreach (var id in groupIds)
{
document.GroupNames.Remove(id!);
}
RebuildLayerRows();
}
/// <summary>그룹 동료 확장 — GroupId 가 같은 페이지 내 컨트롤 전체(클릭 선택이 그룹 단위가 되도록)</summary>
@@ -1213,6 +1287,253 @@ public sealed class DesignerViewModel : ViewModelBase
}
return page.Controls.Where(c => c.Model.GroupId == groupId).ToList();
}
#endregion
#region Methods -
/// <summary>그룹 표시 이름 — 없으면 기본값("그룹")</summary>
public string GroupNameOf(string groupId)
=> document.GroupNames.TryGetValue(groupId, out var name) && name.Trim().Length > 0
? name
: LayerGroupViewModel.FallbackName;
/// <summary>그룹 이름 변경 — 비우면 키를 지워 기본 표시로 되돌린다</summary>
public void RenameGroup(string groupId, string? name)
{
var trimmed = (name ?? string.Empty).Trim();
if (trimmed.Length == 0 || trimmed == LayerGroupViewModel.FallbackName)
{
document.GroupNames.Remove(groupId);
return;
}
Undo.Snapshot();
document.GroupNames[groupId] = trimmed;
}
/// <summary>그룹 펼침 상태 — 기본은 펼침</summary>
public bool IsGroupExpanded(string groupId) => !collapsedGroups.Contains(groupId);
/// <summary>그룹 펼침/접기 — 목록을 다시 만든다</summary>
public void SetGroupExpanded(string groupId, bool expanded)
{
if (expanded ? collapsedGroups.Remove(groupId) : collapsedGroups.Add(groupId))
{
RequestLayerRebuild();
}
}
/// <summary>
/// 목록 재구성 예약 — 펼침 토글은 <b>목록 안의 버튼</b>이 일으킨다. 즉시 재구성하면 클릭 이벤트가
/// 끝나기도 전에 그 버튼이 목록에서 떨어져 나가므로 한 틱 미뤄 안전하게 갈아 끼운다.
/// (진단 모드처럼 Application 이 없으면 그냥 즉시 재구성한다)
/// </summary>
public void RequestLayerRebuild()
{
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher is null)
{
RebuildLayerRows();
return;
}
dispatcher.BeginInvoke(new Action(RebuildLayerRows));
}
/// <summary>현재 문서에 살아 있는 그룹 식별자</summary>
public HashSet<string> ActiveGroupIds()
{
var ids = new HashSet<string>(StringComparer.Ordinal);
foreach (var control in AllControls())
{
if (control.Model.GroupId is { } id)
{
ids.Add(id);
}
}
return ids;
}
/// <summary>
/// 그룹 자가치유 — 멤버가 1개 이하로 줄어든 그룹을 풀고, 주인 없는 이름을 지운다.
/// 삭제·Undo·페이지 이동 뒤에 홀로 남은 그룹은 클릭 확장이 무의미하고 폴더만 어지럽힌다.
/// 편집 의도가 아니라 정리라서 Undo 스냅샷을 찍지 않는다.
/// </summary>
public void CleanupGroups()
{
var counts = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var control in AllControls())
{
if (control.Model.GroupId is { } id)
{
counts[id] = counts.TryGetValue(id, out var n) ? n + 1 : 1;
}
}
foreach (var control in AllControls())
{
if (control.Model.GroupId is { } id && counts[id] < 2)
{
control.Model.GroupId = null;
}
}
foreach (var orphan in document.GroupNames.Keys
.Where(id => !counts.TryGetValue(id, out var n) || n < 2).ToList())
{
document.GroupNames.Remove(orphan);
}
collapsedGroups.RemoveWhere(id => !counts.ContainsKey(id));
}
/// <summary>타입 필터 — 비어 있으면 전체 표시</summary>
public IReadOnlyCollection<string> LayerTypeFilter => layerTypeFilter;
/// <summary>타입 필터 교체</summary>
public void SetLayerTypeFilter(IEnumerable<string> types)
{
layerTypeFilter.Clear();
foreach (var type in types)
{
layerTypeFilter.Add(type);
}
OnPropertyChanged(nameof(HasLayerTypeFilter));
OnPropertyChanged(nameof(LayerTypeFilterTip));
RebuildLayerRows();
}
/// <summary>활성 페이지에 실제로 존재하는 타입 목록(필터 후보) — 개수 많은 순</summary>
public List<(string Type, int Count)> LayerTypeCandidates()
{
var counts = new Dictionary<string, int>(StringComparer.Ordinal);
void Visit(IEnumerable<ControlViewModel> items)
{
foreach (var item in items)
{
counts[item.Type] = counts.TryGetValue(item.Type, out var n) ? n + 1 : 1;
if (item is ContainerViewModel container)
{
Visit(container.Children);
}
}
}
Visit(SelectedPage?.Controls ?? Enumerable.Empty<ControlViewModel>());
return counts.OrderByDescending(p => p.Value).ThenBy(p => p.Key, StringComparer.Ordinal)
.Select(p => (p.Key, p.Value)).ToList();
}
/// <summary>
/// 레이어 목록 재구성 — 그룹 폴더 + 들여쓴 멤버 + 컨테이너 자식을 한 줄 시퀀스로 평탄화한다.
/// 순서는 페이지의 <b>그리기 순서</b>를 그대로 따른다(캔버스에서 뒤에 있는 것이 목록 위).
/// </summary>
public void RebuildLayerRows()
{
CleanupGroups();
IsRebuildingLayers = true;
LayerRows.Clear();
var page = SelectedPage;
if (page is not null)
{
var rendered = new HashSet<string>(StringComparer.Ordinal);
foreach (var control in page.Controls)
{
if (control.Model.GroupId is { } groupId)
{
if (!rendered.Add(groupId))
{
continue;
}
var members = page.Controls.Where(c => c.Model.GroupId == groupId).ToList();
var shown = members.Where(PassesLayerFilter).ToList();
if (shown.Count == 0)
{
continue;
}
var group = new LayerGroupViewModel(this, groupId, members);
LayerRows.Add(group);
if (group.IsExpanded)
{
foreach (var member in shown)
{
AddLayerRow(member, 20);
}
}
continue;
}
if (PassesLayerFilter(control))
{
AddLayerRow(control, 0);
}
}
}
IsRebuildingLayers = false;
OnPropertyChanged(nameof(IsLayerListEmpty));
OnPropertyChanged(nameof(LayerEmptyText));
LayerRowsRebuilt?.Invoke();
}
/// <summary>
/// 목록 재구성 중 — 이때 ListBox 가 쏟아내는 선택 해제는 사용자의 조작이 아니다.
/// (<c>LayerRows.Clear()</c> 가 SelectedItems 를 비우면서 SelectionChanged 를 발화한다)
/// </summary>
public bool IsRebuildingLayers { get; private set; }
/// <summary>레이어 목록 재구성 완료 — 목록 강조를 되살리라는 신호</summary>
public event Action? LayerRowsRebuilt;
/// <summary>컨트롤 행 + (펼쳐져 있으면) 컨테이너 자식 행을 재귀로 추가</summary>
private void AddLayerRow(ControlViewModel viewModel, double indent)
{
viewModel.LayerIndent = indent;
viewModel.LayerExpandedChanged ??= RequestLayerRebuild;
LayerRows.Add(viewModel);
if (viewModel is not ContainerViewModel container || !viewModel.IsLayerExpanded)
{
return;
}
foreach (var child in container.Children.Where(PassesLayerFilter))
{
AddLayerRow(child, indent + 16);
}
}
/// <summary>
/// 검색어·타입 필터 통과 판정 — 자손이 걸리면 조상도 통과시킨다.
/// 그러지 않으면 패널 안의 컨트롤을 검색했을 때 부모가 빠져 자식이 통째로 사라진다.
/// </summary>
private bool PassesLayerFilter(ControlViewModel viewModel)
{
if (Matches(viewModel))
{
return true;
}
return viewModel is ContainerViewModel container && container.Children.Any(PassesLayerFilter);
bool Matches(ControlViewModel vm)
{
if (layerTypeFilter.Count > 0 && !layerTypeFilter.Contains(vm.Type))
{
return false;
}
return layerFilter.Length == 0
|| vm.LayerName.Contains(layerFilter, StringComparison.OrdinalIgnoreCase)
|| vm.Id.Contains(layerFilter, StringComparison.OrdinalIgnoreCase);
}
}
/// <summary>선택 집합이 바뀌었을 때 — 그룹 폴더의 눈·자물쇠 표시를 다시 계산</summary>
private void LayerSelectionChanged()
{
foreach (var group in LayerRows.OfType<LayerGroupViewModel>())
{
group.NotifyFlagsChanged();
}
}
/// <summary>레이어 행(컨트롤 또는 그룹 폴더) → 선택 대상 컨트롤 목록</summary>
public static IEnumerable<ControlViewModel> ControlsOfLayerRow(object? row) => row switch
{
ControlViewModel control => new[] { control },
LayerGroupViewModel group => group.Members,
_ => Enumerable.Empty<ControlViewModel>(),
};
/// <summary>잘라내기 — 복사 후 삭제</summary>
public void CutSelection()
@@ -0,0 +1,127 @@
using SheetMe.Designer.ViewModels.Controls;
namespace SheetMe.Designer.ViewModels;
/// <summary>
/// 레이어 목록의 그룹 폴더 행 1개 — <see cref="SheetMe.Core.Models.ControlElement.GroupId"/> 하나에 대응.
///
/// 그룹은 컨테이너가 아니라 <b>같은 GroupId 를 가진 최상위 컨트롤들에 붙은 태그</b>다.
/// 따라서 이 VM 은 문서 구조에 존재하지 않는 <b>표시 전용</b> 행이며, 목록을 다시 평탄화할 때마다
/// 새로 만들어진다 — 펼침 상태·이름은 여기에 두지 않고 각각
/// <see cref="DesignerViewModel"/> 의 접힌 그룹 집합과 <c>FormDocument.GroupNames</c> 가 소유한다.
/// </summary>
public sealed class LayerGroupViewModel : ViewModelBase
{
#region Member Fields
private const string DefaultName = "그룹";
private readonly DesignerViewModel designer;
private bool isEditing;
#endregion
#region Properties
/// <summary>그룹 식별자</summary>
public string GroupId { get; }
/// <summary>이 그룹에 속한 컨트롤(활성 페이지 기준, 검색 필터 적용 전)</summary>
public IReadOnlyList<ControlViewModel> Members { get; }
/// <summary>
/// 그룹 표시 이름 — 비우면 문서에서 키를 지워 기본값("그룹")으로 되돌아간다.
/// 값의 소유자는 문서(GroupNames)이고 이 프로퍼티는 통로일 뿐이다.
/// </summary>
public string Name
{
get => designer.GroupNameOf(GroupId);
set
{
designer.RenameGroup(GroupId, value);
OnPropertyChanged(nameof(Name));
}
}
/// <summary>멤버 수 표시 — "(3)"</summary>
public string MemberCountText => $"({Members.Count})";
/// <summary>펼침 상태 — 접으면 멤버 행이 목록에서 빠진다</summary>
public bool IsExpanded
{
get => designer.IsGroupExpanded(GroupId);
set
{
designer.SetGroupExpanded(GroupId, value);
OnPropertyChanged(nameof(IsExpanded));
OnPropertyChanged(nameof(ChevronIcon));
}
}
/// <summary>펼침 셰브론 아이콘명</summary>
public string ChevronIcon => IsExpanded ? "chevron-down" : "chevron-right";
/// <summary>
/// 그룹 일괄 숨김 — 하나라도 보이면 전부 숨기고, 전부 숨겨져 있으면 전부 되살린다.
/// 혼합 상태를 표현하는 3상태 배지는 두지 않는다([200] 과 동일 — 한 번 더 누르면 정리된다).
/// </summary>
public bool IsHiddenFlag
{
get => Members.Count > 0 && Members.All(m => m.IsHiddenFlag);
set
{
foreach (var member in Members)
{
member.IsHiddenFlag = value;
}
NotifyFlagsChanged();
}
}
/// <summary>그룹 일괄 잠금</summary>
public bool IsLockedFlag
{
get => Members.Count > 0 && Members.All(m => m.IsLockedFlag);
set
{
foreach (var member in Members)
{
member.IsLockedFlag = value;
}
NotifyFlagsChanged();
}
}
/// <summary>잠금 아이콘명 — 하나라도 잠겨 있으면 잠긴 모양</summary>
public string LockIcon => Members.Any(m => m.IsLockedFlag) ? "lock" : "unlock";
/// <summary>숨김 아이콘명 — 하나라도 숨겨져 있으면 숨긴 모양</summary>
public string HiddenIcon => Members.Any(m => m.IsHiddenFlag) ? "eye-off" : "eye";
/// <summary>이름 인라인 편집 중 — 더블클릭으로 켠다</summary>
public bool IsEditing
{
get => isEditing;
set => SetProperty(ref isEditing, value);
}
#endregion
#region Constructors
public LayerGroupViewModel(DesignerViewModel designer, string groupId, IReadOnlyList<ControlViewModel> members)
{
this.designer = designer;
GroupId = groupId;
Members = members;
}
#endregion
#region Methods
/// <summary>멤버 플래그가 밖에서 바뀌었을 때 아이콘·토글 상태 재계산</summary>
public void NotifyFlagsChanged()
{
OnPropertyChanged(nameof(IsHiddenFlag));
OnPropertyChanged(nameof(IsLockedFlag));
OnPropertyChanged(nameof(HiddenIcon));
OnPropertyChanged(nameof(LockIcon));
}
/// <summary>이름이 없을 때의 기본 표시</summary>
public static string FallbackName => DefaultName;
#endregion
}