정렬 — 기준은 언제나 '담고 있는 것'. 그리고 속성 패널에 정렬 행

"폼 기준으로 정렬"이 없었다. 정확히는 정렬 자체가 2개 이상에서만 동작했고 기준은 늘 선택 영역이라,
컨트롤 하나를 페이지 가운데 놓을 방법이 메뉴 어디에도 없었다.

규칙을 한 문장으로 바꿨다 — 기준은 그것을 담고 있는 것이다.
선택이 2개 이상이면 선택 영역이 그 자리를 대신하고, 1개면 담고 있는 것 —
컨테이너 안이면 컨테이너, 최상위면 페이지 — 이 기준이 된다. 피그마와 같다.

컨테이너 자식을 페이지 기준으로 맞추지 않는 이유. X/Y 는 부모 상대좌표라 '페이지 왼쪽'에 맞추면
음수가 되어 부모 밖으로 나간다. 디자이너 캔버스는 클리핑을 안 해서 멀쩡히 보이지만
레거시 런타임에서는 부모가 잘라먹는다 — 화면과 인쇄가 갈라지고 눈으로는 못 잡는 종류의 버그다.

같이 고친 것들:

· 페이지를 넘는 선택의 세로 정렬을 거부한다. 다른 종이의 '위 맞춤'에는 뜻이 없고,
  예전에는 실제로 깨졌다 — bbox 는 첫 페이지 월드 Y 인데 offset 은 자기 페이지 OffsetY 라
  vm.Y 가 큰 음수가 되어 컨트롤이 제 종이 위 허공으로 날아갔다. 가로는 모든 페이지의 X 원점이
  0 이라 그대로 허용한다.

· 부모와 자식을 함께 선택한 경우 자식을 대상에서 뺀다. 부모를 옮기면 자식 월드 좌표가 따라
  움직이는데 자식에게도 절대좌표를 대입하면 두 번 옮겨진다(레이어 목록에서 만들 수 있는 선택이다).

· 반올림을 AwayFromZero 로 고정했다. 기본 ToEven 은 322.5→322, 323.5→324 로 방향이 값에 따라
  뒤집힌다 — 폭이 제각각인 라벨 다섯 개를 가운데 맞춤하면 어떤 건 322, 어떤 건 323 에 앉는다.
  (스냅에서 반올림을 뺀 것과 반대로 보이지만 이유가 다르다. 거긴 가이드선이 모서리에서 뜨는 게
   보이기 때문이고, 정렬은 선을 긋지 않는다. 저장 포맷은 어차피 정수다.)

· 변경이 실제로 생길 때만 실행취소를 찍는다. 버튼으로 노출되면 연타되는데, 무조건 찍으면
  빈 되돌리기 단계가 쌓이고 '저장 안 한 변경'으로 표시된다.

산술은 Core/Layout/AlignSolver 로 뺐다. 짧지만 경계가 잔소리 같은 규칙 덩어리라(반올림 방향,
기준보다 넓은 항목, 원점이 큰 둘째 장) 디자이너 쪽에서 검사하면 케이스마다 실행취소 스냅샷을
하나씩 먹는다. 단위 테스트 10건이 그걸 공짜로 고정한다.

속성 패널 정렬 행. [200]SheetMe 의 inspector-wireframe.html 을 따랐다 —
30x26 버튼 6개, radius 6, 사이 2px, 가로 축과 세로 축 사이 8px 틈, 아이콘은 stroke 1.5.
LucideIcons 의 align-obj-* 6개는 등록만 되고 아무 데서도 안 쓰이던 죽은 항목이라 참조 도형으로
갈아 끼웠다(사각형 둘 + 기준선 → 사각형 하나 + 기준선). 좌표는 24격자다 — Build 가 Canvas 를
24x24 로 하드코딩해서, 참조의 16격자 좌표를 그대로 넣으면 아이콘이 좌상단 2/3 크기로 쏠린다.
크기 18 에서 stroke 2 × 18/24 = 1.5px 로 참조와 같은 굵기가 나온다.

버튼 스타일은 새로 만들었다. SegmentToggle 은 치수가 같지만 채워진 상태 토글 룩이고,
정렬은 상태가 아니라 액션이라 눌려 있는 것처럼 보이면 안 된다 — 채움 없이 호버만 남겼다.

표시 조건에 HasSelection 을 쓰지 않았다. 그건 페이지 속성 모드에서도 true 라 정렬할 컨트롤이
없는데 바가 뜬다. 선택 0 에서는 접어야 하는데, 안 접으면 '컨트롤을 선택하면…' 안내문 뒤에
버튼 줄이 그대로 남는다(같은 Grid 에 겹쳐 그려진다).

툴팁이 지금 무엇을 기준으로 정렬하는지 말한다 — "왼쪽 맞춤 — 페이지 기준" / "— Panel1 기준" /
"— 선택 영역 기준". 세로 3버튼은 페이지를 넘는 선택에서 비활성되고 그 이유를 툴팁에 적는다.
아이콘만 있는 버튼이라 AutomationProperties.Name 을 붙였다 — 이 저장소에 버튼 자동화 속성
전례가 없어 새로 여는 관례다.

메뉴는 그대로 두되 툴팁으로 전제를 적었고, '페이지 가로/세로 가운데'는 헤더에 '묶음째'를 붙였다.
선택 1개에서는 CenterH 와 결과가 같아지고, 다중에서만 갈리기 때문이다(그건 상대 배치를 보존한 채
묶음을 옮기고, CenterH 는 각자 가운데로 모은다).

분배(간격 균등) 버튼은 넣지 않았다. 참조 와이어프레임에 없고, 3개 이상에서만 살아서
실사용 대부분(1~2개 선택)에 두 칸이 죽은 채 남아 줄이 고장 난 것처럼 읽힌다.

--dialog-shots 에 선택 상태별 인스펙터 3장(12c/12d/12e)을 더했다. 정렬 바가 선택 0 과
페이지 속성 모드에서 사라지는지는 수치로 못 잡는다. 산출물 51 → 57.
기존 인스펙터 5장은 정렬 행만큼 아래로 밀린다 — 정상 diff 다.

알려진 별건(이번에 안 고침): 정렬 후 Ctrl+Z 하면 선택이 풀린다.
Undo 가 문서를 통째로 갈아끼우며 Selection.Clear 하기 때문이고, 정렬은 '왼쪽 → 아니네 → 가운데'로
여러 번 시도하는 조작이라 버튼으로 노출되면서 드러난다. Id 기반 선택 복원이 필요해 별도로 다룬다.

게이트: 테스트 267/267(신규 10), --edit-smoke 234건 0실패(정렬 검사 11건 신규),
--query-popup 17/17, --maxrect 10/10, --snap-shots 7종 0실패,
--db-smoke 3000 diff 0·예외 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일,
--dialog-shots 57파일.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-14 10:17:02 +09:00
co-authored by Claude Opus 5
parent ba838ae54a
commit 26f696ab00
9 changed files with 592 additions and 29 deletions
@@ -1093,6 +1093,127 @@ public static class EditSmoke
dirtyDoc.Undo.Undo();
Check("더티: 분기 후에는 저장 지점 소실로 계속 dirty", dirtyDoc.Undo.IsDirty);
// 24-1) 정렬 — 기준이 무엇이 되는가. 산술 자체는 단위 테스트(AlignSolverTests)가 보고,
// 여기서는 배선을 본다: 어느 사각형을 기준으로 잡았는가.
// 별도 문서에 붙인다 — 메인 designer 에 붙이면 아래 '전체 Undo' 의 guard(80)를 갉아먹는다.
{
var alignDoc = new DesignerViewModel(business.CreateNew());
ControlViewModel Put(double x, double y, double w, double h)
{
alignDoc.AddControlAt("Label", new Point(x, y));
var vm = alignDoc.Pages[0].Controls[^1];
vm.X = x;
vm.Y = y;
vm.Width = w;
vm.Height = h;
return vm;
}
var page0 = alignDoc.Pages[0];
var solo = Put(137, 211, 75, 20);
alignDoc.Selection.SetSingle(solo);
alignDoc.Align("Left");
Check("정렬: 1개는 페이지 기준 — 왼쪽", solo.X == 0, $"실제 {solo.X}");
alignDoc.Align("Right");
Check("정렬: 1개는 페이지 기준 — 오른쪽", solo.X == page0.WidthDip - solo.Width,
$"실제 {solo.X} 기대 {page0.WidthDip - solo.Width}");
alignDoc.Align("CenterV");
Check("정렬: 1개는 페이지 기준 — 세로 가운데", solo.Y > 0 && solo.Y == Math.Round((page0.HeightDip - solo.Height) / 2, MidpointRounding.AwayFromZero),
$"실제 {solo.Y}");
var beforeIdle = alignDoc.Undo.CanUndo;
var idleX = solo.X;
alignDoc.Align("Right");
Check("정렬: 이미 맞은 상태를 다시 눌러도 실행취소가 쌓이지 않는다",
solo.X == idleX && alignDoc.Undo.CanUndo == beforeIdle);
// 실행취소 1회로 완전 복원 — 정렬 1회 = 1스텝
var beforeUndoX = solo.X;
alignDoc.Align("Left");
alignDoc.Undo.Undo();
var restored = alignDoc.Pages[0].Controls.FirstOrDefault(c => c.Width == 75);
Check("정렬: 실행취소 1회로 되돌아온다", restored is not null && restored.X == beforeUndoX,
$"실제 {restored?.X}");
// 컨테이너 자식은 페이지가 아니라 부모가 기준 — 페이지 기준이면 X 가 음수가 된다
var nestDoc = new DesignerViewModel(business.CreateNew());
nestDoc.AddControlAt("Panel", new Point(120, 90));
var holder = nestDoc.Pages[0].Controls[^1] as ContainerViewModel;
if (holder is not null)
{
holder.X = 120;
holder.Y = 90;
holder.Width = 300;
holder.Height = 200;
nestDoc.Selection.SetSingle(holder);
nestDoc.AddControlAt("Label", new Point(180, 140));
var inner = holder.Children.Count > 0 ? holder.Children[^1] : null;
if (inner is not null)
{
inner.X = 60;
inner.Y = 50;
inner.Width = 80;
inner.Height = 20;
nestDoc.Selection.SetSingle(inner);
nestDoc.Align("Left");
Check("정렬: 컨테이너 자식은 부모 기준(음수로 튀지 않는다)", inner.X == 0, $"실제 {inner.X}");
nestDoc.Align("Right");
Check("정렬: 컨테이너 자식 오른쪽도 부모 기준",
inner.X == holder.Width - inner.Width, $"실제 {inner.X}");
// 부모와 자식을 함께 선택하면 자식은 대상에서 빠진다(두 번 옮겨지지 않게)
var innerBefore = inner.X;
nestDoc.Selection.Set(new List<ControlViewModel> { holder, inner }, holder);
nestDoc.Align("Left");
Check("정렬: 조상이 선택돼 있으면 자식은 건드리지 않는다", inner.X == innerBefore,
$"실제 {inner.X} (시작 {innerBefore})");
}
}
// 2페이지 컨트롤 1개 — 그 페이지 사각형을 기준으로 잡아야 한다
var twoPage = new DesignerViewModel(business.CreateNew());
twoPage.AddControlAt("Label", new Point(80, 120));
twoPage.AddPage();
if (twoPage.Pages.Count >= 2)
{
var second = twoPage.Pages[1];
twoPage.AddControlAt("Label", new Point(200, second.OffsetY + 300));
var onSecond = second.Controls.Count > 0 ? second.Controls[^1] : null;
if (onSecond is not null)
{
onSecond.X = 200;
onSecond.Y = 300;
onSecond.Width = 60;
onSecond.Height = 20;
twoPage.Selection.SetSingle(onSecond);
twoPage.Align("Top");
Check("정렬: 2페이지 컨트롤은 그 페이지 기준", onSecond.Y == 0, $"실제 {onSecond.Y}");
// 페이지를 넘는 선택 — 세로는 거부, 가로는 허용
var onFirst = twoPage.Pages[0].Controls.Count > 0 ? twoPage.Pages[0].Controls[0] : null;
if (onFirst is not null)
{
var mixedY = onSecond.Y;
var canUndoBefore = twoPage.Undo.CanUndo;
twoPage.Selection.Set(new List<ControlViewModel> { onFirst, onSecond }, onFirst);
twoPage.Align("Top");
Check("정렬: 페이지를 넘는 선택은 세로 정렬을 거부한다",
onSecond.Y == mixedY && twoPage.Undo.CanUndo == canUndoBefore,
$"실제 {onSecond.Y} (시작 {mixedY})");
twoPage.Align("Left");
Check("정렬: 페이지를 넘어도 가로는 맞는다", onFirst.X == onSecond.X,
$"실제 {onFirst.X} / {onSecond.X}");
}
}
}
}
// 24-2) 스냅 — 정렬 가이드와 간격 균등.
// 별도 문서에 붙인다. 메인 designer 에 붙이면 아래 '전체 Undo' 의 guard(80)를
// 드래그마다 잡아먹어 그 검사가 조용히 깨진다(UndoService 용량 100).