그룹을 진짜 패널로 — 저장하면 살아남는다
지금까지 그룹은 GroupId 라는 편집 전용 표시였고, 저장 포맷에 담을 자리가 없어 다시 열면 풀려 있었다. 레거시도 같았다(clsDesignTimeGroupService.vb:3-4 — "디자인 시에만 쓰는 임시 그룹, 저장 시 Group 정의 없이 컨트롤만 저장됨"). 다만 우리는 레이어 폴더로, 이름까지 붙이는 1급 개념으로 보여 줘서 사라질 때의 배신감이 더 컸다. 묶으면 실제 Panel 을 만들어 멤버를 그 자식으로 옮긴다. 패널은 레거시에 실재하는 컨트롤이라 저장·재열기·레거시 디자이너 재저장 전부에서 그대로 살아남는다. 검증: EMR 런타임의 5개 소비 경로(태그 채움·MDataTable·저장·탭순서·인쇄)가 전부 컨테이너를 재귀하고, 운영 1,273건 중 492건이 이미 컨테이너 안에 컨트롤을 담고 동작 중이다. XML 스키마도 원래 구조다. 그 대신 그룹이 '문서를 바꾸는 조작'이 된다. 조사에서 나온 부작용 넷을 가드로 막는다. · 라디오는 묶지 않는다. 레거시 RadioButton 에는 GroupName 이 없어 <b>부모가 곧 배타 단위</b>다. '택1' 문항에서 일부만 묶으면 그 일부가 독립 집합이 되어 EMR 에서 답이 둘 선택된다. 캔버스에는 아무 변화도 안 보이고 어떤 게이트도 못 잡는 종류라 아예 막았다. · 멤버 사이에 남이 끼어 있으면 묶지 않는다. WinForms 에서 형제는 남의 자식 사이에 낄 수 없어 멤버가 반드시 연속 z 블록이 되는데, 사이에 낀 것이 앞뒤로 밀려 쌓임 순서가 바뀐다. 샘플 1장 실측에서 '이웃 둘 묶기' 35쌍 중 5쌍(14%)이 실제로 바뀌었다. · 패널은 멤버 bbox 에 정확히 맞춘다. WinForms 패널은 자식을 하드 클리핑하는데 인쇄 경로에는 클리핑이 0건이다 — 넘치면 화면에서만 사라지고 인쇄에는 나오는 컨트롤이 만들어진다. · 좌표를 감쌀 때 정수로 확정한다. 저장 포맷이 정수만 쓰는데 부모 원점과 자식 상대좌표를 따로 반올림하면 Location 과 LocationOnBase 가 축당 1px 어긋난 파일이 나온다. 탭 순서를 컨테이너 단위로 고쳤다. TabIndex 는 Windows 표준 탐색이라 컨테이너 범위인데, 문서 평면에 0,100,200… 을 뿌리고 있어서 패널 안의 것들이 저희끼리만 겨루고 탭이 그 패널을 통째로 지나친 뒤에야 들어갔다. 이제 부모별로 매기고 패널 자신에게는 제 자식 중 가장 이른 순번을 준다. Ctrl+Shift+G 는 패널 풀기가 된다 — 자식을 최상위로 되돌리고 좌표를 절대값으로 복원하며 패널이 있던 z 자리에 그대로 끼워 넣어 쌓임 순서를 보존한다. <b>레거시 디자이너에는 이 조작이 없다</b> — 거기서 패널을 지우면 자식까지 함께 지워진다. 기존 문서는 건드리지 않는다(새로 묶을 때만 패널이 생긴다). 그래서 왕복 검사와 렌더는 무사하다 — 직전 판단에서 "즉시 파괴"라고 적었던 것은 과장이었고, 여기서 정정한다. --edit-smoke 의 그룹 검사를 다시 썼다. 폴더 행(LayerGroupViewModel) 기반 단언 9건은 그 표현이 사라져 삭제했고, 패널 구조·가드 2종·풀기 복원으로 8건을 새로 넣었다. 그룹 검사는 별도 문서에서 돈다 — 메인 designer 의 최상위 구성이 바뀌면 뒤따르는 Find() 들이 전부 무너진다(실제로 그렇게 깨져서 분리했다). 게이트: 테스트 267/267, --edit-smoke 0실패, --query-popup 17/17, --maxrect 10/10, --db-smoke 3000 diff 0·예외 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일. 남은 것: 레이어 패널에서 '그룹용 패널'과 사용자가 만든 패널이 똑같이 보인다. 구분이 필요하면 별건으로 다룬다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d474937837
commit
0e30aa39c6
@@ -932,9 +932,38 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
index++;
|
||||
vm.Model.Props.SetText("TabIndex", (999999 + index).ToString());
|
||||
}
|
||||
// TabIndex 는 <b>컨테이너 범위</b>다(Windows 표준 탐색). 문서 평면에 0,100,200… 을 뿌리면
|
||||
// 패널 안의 것들이 저희끼리만 겨루게 되어 탭이 그 패널을 통째로 지나친 뒤에야 들어간다.
|
||||
// 그래서 부모별로 따로 매기고, 패널 자신에게는 제 자식 중 가장 이른 순번을 준다.
|
||||
// Dictionary 는 null 키를 받지 않는다 — 최상위는 따로 센다
|
||||
var topRank = 0;
|
||||
var byParent = new Dictionary<ControlViewModel, int>();
|
||||
var panelRank = new Dictionary<ControlViewModel, int>();
|
||||
for (var i = 0; i < tabOrderSequence.Count; i++)
|
||||
{
|
||||
tabOrderSequence[i].Model.Props.SetText("TabIndex", i == 0 ? "0" : (100 * i).ToString());
|
||||
var vm = tabOrderSequence[i];
|
||||
var parent = vm.Parent;
|
||||
int rank;
|
||||
if (parent is null)
|
||||
{
|
||||
rank = topRank++;
|
||||
}
|
||||
else
|
||||
{
|
||||
rank = byParent.TryGetValue(parent, out var next) ? next : 0;
|
||||
byParent[parent] = rank + 1;
|
||||
}
|
||||
vm.Model.Props.SetText("TabIndex", rank == 0 ? "0" : (100 * rank).ToString());
|
||||
|
||||
// 패널 자신의 순번은 그 안에서 가장 먼저 클릭된 것의 자리다
|
||||
if (parent is not null && !panelRank.ContainsKey(parent))
|
||||
{
|
||||
panelRank[parent] = i;
|
||||
}
|
||||
}
|
||||
foreach (var (panel, rank) in panelRank)
|
||||
{
|
||||
panel.Model.Props.SetText("TabIndex", rank == 0 ? "0" : (100 * rank).ToString());
|
||||
}
|
||||
Inspector.Rebuild();
|
||||
}
|
||||
@@ -1648,21 +1677,93 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
public void GroupSelection()
|
||||
{
|
||||
var targets = Selection.Items.Where(vm => vm.Parent is null && PageOf(vm) is not null).ToList();
|
||||
if (targets.Count < 2)
|
||||
if (targets.Count < 2 || PageOf(targets[0]) is not { } page)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Undo.Snapshot();
|
||||
var groupId = Guid.NewGuid().ToString("N");
|
||||
foreach (var vm in targets)
|
||||
if (GroupBlockReason(page, targets) is { } reason)
|
||||
{
|
||||
vm.Model.GroupId = groupId;
|
||||
DialogService.Notify(DialogKind.Warning, "그룹", reason);
|
||||
return;
|
||||
}
|
||||
|
||||
// z 순서상 멤버가 차지한 구간 — 패널은 그 자리에 들어간다.
|
||||
// WinForms 에서 형제는 남의 자식 사이에 낄 수 없어 멤버가 반드시 연속 블록이 되는데,
|
||||
// 사이에 남이 끼어 있으면 그 남이 앞뒤로 밀려 쌓임 순서가 바뀐다(위 가드가 그걸 막는다).
|
||||
var indices = targets.Select(vm => page.Model.Controls.IndexOf(vm.Model)).ToList();
|
||||
var insertAt = indices.Min();
|
||||
|
||||
var bounds = targets
|
||||
.Select(vm => new Rect(vm.X, vm.Y, Math.Max(1, vm.Width), Math.Max(1, vm.Height)))
|
||||
.Aggregate(Rect.Union);
|
||||
// 좌표는 여기서 정수로 확정한다. 부모 원점과 자식 상대좌표를 따로 반올림하면
|
||||
// 저장 시 Location 과 LocationOnBase 가 축당 1px 어긋난 파일이 나온다(포맷이 정수만 쓴다).
|
||||
var originX = Math.Round(bounds.X);
|
||||
var originY = Math.Round(bounds.Y);
|
||||
|
||||
Undo.Snapshot();
|
||||
var panel = new ControlElement
|
||||
{
|
||||
Type = "Panel",
|
||||
Id = IdGenerator.NextId(document, "Panel"),
|
||||
Bounds = new LayoutRect
|
||||
{
|
||||
X = originX,
|
||||
Y = originY,
|
||||
W = Math.Ceiling(bounds.Right - originX),
|
||||
H = Math.Ceiling(bounds.Bottom - originY),
|
||||
},
|
||||
};
|
||||
|
||||
foreach (var vm in targets.OrderBy(vm => page.Model.Controls.IndexOf(vm.Model)))
|
||||
{
|
||||
page.Model.Controls.Remove(vm.Model);
|
||||
vm.Model.Bounds.X = Math.Round(vm.X) - originX;
|
||||
vm.Model.Bounds.Y = Math.Round(vm.Y) - originY;
|
||||
panel.Children.Add(vm.Model);
|
||||
}
|
||||
page.Model.Controls.Insert(Math.Min(insertAt, page.Model.Controls.Count), panel);
|
||||
|
||||
RebuildFromDocument();
|
||||
if (PageOf2(page.Index)?.Controls.FirstOrDefault(c => ReferenceEquals(c.Model, panel)) is { } created)
|
||||
{
|
||||
Selection.SetSingle(created);
|
||||
}
|
||||
// 이름이 없으면 목록에 "그룹"만 여러 개 뜬다 — 만들 때 번호를 붙여 준다
|
||||
document.GroupNames[groupId] = $"그룹 {ActiveGroupIds().Count}";
|
||||
RebuildLayerRows();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 묶을 수 없는 이유 — 없으면 null.
|
||||
///
|
||||
/// 그룹이 진짜 Panel 이 되면서 레거시의 두 가지 성질이 따라온다.
|
||||
/// 여기서 막지 않으면 캔버스에는 아무 변화도 안 보이는 채로 EMR 동작이 달라진다.
|
||||
/// </summary>
|
||||
private string? GroupBlockReason(PageViewModel page, IReadOnlyList<ControlViewModel> targets)
|
||||
{
|
||||
// ① 라디오는 부모가 곧 배타 단위다(레거시 RadioButton 에 GroupName 이 없다).
|
||||
// '택1' 문항에서 일부만 묶으면 그 일부가 독립 집합이 되어 EMR 에서 답이 둘 선택된다.
|
||||
if (targets.Any(vm => vm.Type == "RadioButton"))
|
||||
{
|
||||
return "라디오 버튼은 패널이 곧 선택 묶음이라, 일부만 묶으면 '하나만 고르기'가 갈라집니다.\n"
|
||||
+ "라디오는 그룹으로 묶지 마세요.";
|
||||
}
|
||||
|
||||
// ② 멤버 사이에 남이 끼어 있으면 쌓임 순서가 바뀐다.
|
||||
var indices = targets.Select(vm => page.Model.Controls.IndexOf(vm.Model)).ToList();
|
||||
if (indices.Any(i => i < 0))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var span = indices.Max() - indices.Min() + 1;
|
||||
if (span != targets.Count)
|
||||
{
|
||||
return "묶으려는 것들 사이에 다른 컨트롤이 겹쳐 있어, 묶으면 앞뒤 순서가 바뀝니다.\n"
|
||||
+ "먼저 순서를 붙여 주세요(레이어 목록에서 나란히 오도록).";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private PageViewModel? PageOf2(int index) => index >= 0 && index < Pages.Count ? Pages[index] : null;
|
||||
|
||||
/// <summary>
|
||||
/// 선택 그룹 해제 — 선택에 걸린 그룹의 <b>전 멤버</b>를 푼다(선택되지 않은 멤버 포함).
|
||||
/// 일부만 풀면 멤버 1개짜리 반쪽 그룹이 남아 다음 선택부터 동작이 이상해진다.
|
||||
@@ -1674,6 +1775,16 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
.Where(id => id is not null)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
// 그룹은 이제 진짜 패널이다 — 고른 것이 패널이거나 패널 안의 것이면 그 패널을 푼다.
|
||||
var panel = Selection.Items.OfType<ContainerViewModel>().FirstOrDefault(c => c.Parent is null)
|
||||
?? Selection.Items.Select(vm => vm.Parent).OfType<ContainerViewModel>()
|
||||
.FirstOrDefault(c => c.Parent is null);
|
||||
if (panel is not null)
|
||||
{
|
||||
UnwrapPanel(panel);
|
||||
return;
|
||||
}
|
||||
|
||||
if (groupIds.Count == 0)
|
||||
{
|
||||
return;
|
||||
@@ -1693,6 +1804,51 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
RebuildLayerRows();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 패널 풀기 — 자식을 최상위로 되돌리고 패널을 지운다.
|
||||
///
|
||||
/// 자식 좌표를 절대값으로 되돌려야 한다. 안 그러면 패널 원점만큼 좌상단으로 튄다.
|
||||
/// 패널이 있던 z 자리에 자식들을 그대로 끼워 넣어 쌓임 순서를 보존한다.
|
||||
///
|
||||
/// <b>레거시 디자이너에는 이 조작이 없다</b> — 거기서 패널을 지우면 자식까지 함께 지워진다.
|
||||
/// </summary>
|
||||
public void UnwrapPanel(ControlViewModel viewModel)
|
||||
{
|
||||
if (viewModel is not ContainerViewModel container
|
||||
|| container.Parent is not null
|
||||
|| PageOf(container) is not { } page)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var at = page.Model.Controls.IndexOf(container.Model);
|
||||
if (at < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Undo.Snapshot();
|
||||
var originX = container.Model.Bounds.X;
|
||||
var originY = container.Model.Bounds.Y;
|
||||
var children = container.Model.Children.ToList();
|
||||
container.Model.Children.Clear();
|
||||
page.Model.Controls.RemoveAt(at);
|
||||
for (var i = 0; i < children.Count; i++)
|
||||
{
|
||||
children[i].Bounds.X = Math.Round(children[i].Bounds.X + originX);
|
||||
children[i].Bounds.Y = Math.Round(children[i].Bounds.Y + originY);
|
||||
page.Model.Controls.Insert(at + i, children[i]);
|
||||
}
|
||||
|
||||
RebuildFromDocument();
|
||||
var restored = Pages[page.Index].Controls
|
||||
.Where(c => children.Any(child => ReferenceEquals(child, c.Model)))
|
||||
.ToList();
|
||||
if (restored.Count > 0)
|
||||
{
|
||||
Selection.Set(restored, restored[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>그룹 동료 확장 — GroupId 가 같은 페이지 내 컨트롤 전체(클릭 선택이 그룹 단위가 되도록)</summary>
|
||||
public List<ControlViewModel> GroupMatesOf(ControlViewModel viewModel)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user