패널이 선택 단위 — 자식을 집어도 묶음째 움직인다

패널로 감싸 놓고 정작 자식을 클릭하면 자식만 잡혀서 그 하나만 끌려갔다. 그룹처럼 안 움직인다.

이제 컨트롤을 클릭하면 최상위 컨테이너까지 거슬러 올라가 그것을 고른다.
패널이 잡히므로 끌면 자식들이 부모를 따라 함께 움직이고, 자식의 상대좌표는 그대로 남는다.
마퀴로 자식 하나가 걸려도 같은 규칙이라 선택 단위가 한 가지로 유지된다.

안으로 들어가려면 더블클릭한다. 한 번 들어가면 그 자식이 선택된 상태이므로
다시 더블클릭하면 예전처럼 글자 편집이다. 최상위 컨트롤은 들어갈 곳이 없으니
첫 더블클릭이 바로 글자 편집으로 간다(기존 동작 그대로).

이 규칙은 그룹용 패널뿐 아니라 사용자가 직접 만든 패널에도 똑같이 걸린다.
둘을 구분할 방법이 없기도 하고, 구분하면 같은 모양이 다르게 동작해 더 헷갈린다.
디자인 도구의 관례(프레임을 클릭하면 프레임, 들어가려면 더블클릭)와도 맞는다.

--edit-smoke 에 3건 추가: 자식을 집으면 패널이 잡히는가, 끌면 패널째 움직이고 자식 상대좌표가
그대로인가, 더블클릭이 안으로 들어가는가.

게이트: 테스트 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:
Msystech
2026-08-14 13:33:31 +09:00
co-authored by Claude Opus 5
parent 0e30aa39c6
commit 893d81a40e
3 changed files with 68 additions and 7 deletions
+29 -2
View File
@@ -37,6 +37,7 @@ public static class EditSmoke
var designer = new DesignerViewModel(business.CreateNew()); var designer = new DesignerViewModel(business.CreateNew());
var none = new PointerContext(false, false, false, 1); var none = new PointerContext(false, false, false, 1);
var alt = new PointerContext(false, false, true, 1); var alt = new PointerContext(false, false, true, 1);
var doubleClick = new PointerContext(false, false, false, 2);
var ctrl = new PointerContext(true, false, false, 1); var ctrl = new PointerContext(true, false, false, 1);
ControlViewModel Find(string type) ControlViewModel Find(string type)
@@ -251,6 +252,32 @@ public static class EditSmoke
&& wrap.Children.Any(c => c.X == 100 && c.Y == 40), && wrap.Children.Any(c => c.X == 100 && c.Y == 40),
string.Join(" ", wrap?.Children.Select(c => $"({c.X},{c.Y})") ?? Array.Empty<string>())); string.Join(" ", wrap?.Children.Select(c => $"({c.X},{c.Y})") ?? Array.Empty<string>()));
// 자식을 집어도 패널이 잡히고, 끌면 묶음째 움직인다 — 이게 '그룹처럼'의 알맹이다
var kid = wrap!.Children[0];
var kidWorld = grp.WorldBoundsOf(kid);
var grab = new Point(kidWorld.X + kidWorld.Width / 2, kidWorld.Y + kidWorld.Height / 2);
grp.Selection.Clear();
grp.Interaction.PointerDown(grab, none);
Check("그룹: 자식을 집으면 패널이 잡힌다",
grp.Selection.Items.Count == 1 && ReferenceEquals(grp.Selection.Items[0], wrap),
$"실제 {grp.Selection.Items.Count}개 / {grp.Selection.Items.FirstOrDefault()?.Type}");
var panelBefore = wrap.X;
var kidLocalBefore = kid.X;
grp.Interaction.PointerMove(new Point(grab.X + 40, grab.Y), alt);
grp.Interaction.PointerUp(new Point(grab.X + 40, grab.Y), alt);
Check("그룹: 자식을 끌면 패널째 움직인다",
wrap.X == panelBefore + 40 && kid.X == kidLocalBefore,
$"패널 {wrap.X}(시작 {panelBefore}) 자식상대 {kid.X}(시작 {kidLocalBefore})");
// 안으로 들어가려면 더블클릭
grp.Selection.Clear();
grp.Interaction.PointerDown(new Point(grab.X + 40, grab.Y), doubleClick);
grp.Interaction.PointerUp(new Point(grab.X + 40, grab.Y), doubleClick);
Check("그룹: 더블클릭이면 안으로 들어간다",
grp.Selection.Items.Count == 1 && ReferenceEquals(grp.Selection.Items[0], kid),
$"실제 {grp.Selection.Items.FirstOrDefault()?.Type}");
// 풀기 — 절대좌표 복원 // 풀기 — 절대좌표 복원
grp.Selection.SetSingle(wrap!); grp.Selection.SetSingle(wrap!);
grp.UngroupSelection(); grp.UngroupSelection();
@@ -259,8 +286,8 @@ public static class EditSmoke
&& !grp.Pages[0].Controls.OfType<ContainerViewModel>().Any(), && !grp.Pages[0].Controls.OfType<ContainerViewModel>().Any(),
$"최상위 {grp.Pages[0].Controls.Count}"); $"최상위 {grp.Pages[0].Controls.Count}");
Check("그룹 풀기: 좌표가 절대값으로 복원된다", Check("그룹 풀기: 좌표가 절대값으로 복원된다",
grp.Pages[0].Controls.Any(c => c.X == 100 && c.Y == 100) grp.Pages[0].Controls.Any(c => c.X == 140 && c.Y == 100)
&& grp.Pages[0].Controls.Any(c => c.X == 200 && c.Y == 140), && grp.Pages[0].Controls.Any(c => c.X == 240 && c.Y == 140),
string.Join(" ", grp.Pages[0].Controls.Select(c => $"({c.X},{c.Y})"))); string.Join(" ", grp.Pages[0].Controls.Select(c => $"({c.X},{c.Y})")));
// 가드 ① 라디오는 부모가 곧 배타 단위 — 일부만 묶으면 '택1'이 갈라진다 // 가드 ① 라디오는 부모가 곧 배타 단위 — 일부만 묶으면 '택1'이 갈라진다
@@ -89,13 +89,25 @@ public sealed class InteractionController
var hit = designer.HitTestControl(world); var hit = designer.HitTestControl(world);
if (hit is not null) if (hit is not null)
{ {
// 더블클릭 → 인라인 텍스트 편집(드래그 시작 안 함) // 더블클릭 — 두 뜻이 겹친다. 먼저 '안으로 들어가기'다.
if (ctx.ClickCount >= 2 && DesignerViewModel.IsTextEditable(hit)) // 패널 안의 것을 집으면 평소에는 패널이 잡히는데(묶음째 움직이도록),
// 그 안의 하나만 손보려면 들어갈 길이 있어야 한다. 이미 들어와 있으면 그때 글자 편집이다.
if (ctx.ClickCount >= 2)
{
// 최상위 컨트롤은 들어갈 곳이 없다 — 예전처럼 바로 글자 편집이다
if (hit.Parent is not null && !hit.IsSelected)
{
mode = Mode.None;
designer.Selection.SetSingle(hit);
return;
}
if (DesignerViewModel.IsTextEditable(hit))
{ {
mode = Mode.None; mode = Mode.None;
designer.RequestInlineEdit(hit); designer.RequestInlineEdit(hit);
return; return;
} }
}
downControl = hit; downControl = hit;
if (ctx.Ctrl || ctx.Shift) if (ctx.Ctrl || ctx.Shift)
@@ -1849,9 +1849,31 @@ public sealed class DesignerViewModel : ViewModelBase
} }
} }
/// <summary>
/// 이 컨트롤을 클릭했을 때 실제로 잡히는 것 — 최상위 컨테이너까지 거슬러 올라간다.
/// 그룹이 진짜 패널이 되면서, 자식을 집으면 그 하나만 끌려가던 문제를 여기서 막는다.
/// </summary>
public ControlViewModel SelectionUnitOf(ControlViewModel viewModel)
{
var unit = viewModel;
while (unit.Parent is { } parent)
{
unit = parent;
}
return unit;
}
/// <summary>그룹 동료 확장 — GroupId 가 같은 페이지 내 컨트롤 전체(클릭 선택이 그룹 단위가 되도록)</summary> /// <summary>그룹 동료 확장 — GroupId 가 같은 페이지 내 컨트롤 전체(클릭 선택이 그룹 단위가 되도록)</summary>
public List<ControlViewModel> GroupMatesOf(ControlViewModel viewModel) public List<ControlViewModel> GroupMatesOf(ControlViewModel viewModel)
{ {
// 패널 안의 것은 패널이 곧 선택 단위다 — 자식 하나를 집어도 묶음 전체가 함께 움직여야 한다.
// 안으로 들어가려면 더블클릭한다(InteractionController 가 처리).
var unit = SelectionUnitOf(viewModel);
if (!ReferenceEquals(unit, viewModel))
{
return new List<ControlViewModel> { unit };
}
if (viewModel.Model.GroupId is not { } groupId) if (viewModel.Model.GroupId is not { } groupId)
{ {
return new List<ControlViewModel> { viewModel }; return new List<ControlViewModel> { viewModel };