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

"폼 기준으로 정렬"이 없었다. 정확히는 정렬 자체가 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
+70
View File
@@ -0,0 +1,70 @@
namespace SheetMe.Core.Layout;
/// <summary>정렬 방향 — 가로 3종, 세로 3종</summary>
public enum AlignMode
{
Left,
CenterH,
Right,
Top,
CenterV,
Bottom,
}
/// <summary>
/// 정렬 산술 — 항목 하나를 기준 사각형에 맞춘 새 자리를 낸다.
///
/// <b>왜 여기 있는가.</b> 계산 자체는 짧지만 경계가 잔소리 같은 규칙 덩어리다 —
/// 반올림 방향, 기준보다 넓은 항목, 용지가 아래 페이지라 원점이 큰 경우.
/// 이걸 디자이너 쪽에서 검사하려면 케이스마다 실행취소 스냅샷을 하나씩 먹는데(스모크의 예산이 있다),
/// 순수 함수로 두면 단위 테스트가 공짜다.
///
/// <see cref="SnapRect"/> 를 그대로 쓴다 — 새 타입을 만들 이유가 없고,
/// 스냅과 정렬이 같은 좌표 표현을 공유해야 결과가 갈라지지 않는다.
/// </summary>
public static class AlignSolver
{
#region Methods
/// <summary>
/// <paramref name="item"/> 을 <paramref name="reference"/> 에 맞춘 사각형(월드).
///
/// 기준보다 항목이 넓으면 가운데 맞춤이 음수를 낸다 — <b>그대로 둔다.</b>
/// 가운데에 두려면 그래야 하고, 여기서 잘라 붙이면 "가운데 맞춤을 눌렀는데 가운데가 아니다"가 된다.
/// </summary>
public static SnapRect Apply(SnapRect item, SnapRect reference, AlignMode mode) => mode switch
{
AlignMode.Left => item with { X = Round(reference.Left) },
AlignMode.Right => item with { X = Round(reference.Right - item.W) },
AlignMode.CenterH => item with { X = Round(reference.Left + (reference.W - item.W) / 2) },
AlignMode.Top => item with { Y = Round(reference.Top) },
AlignMode.Bottom => item with { Y = Round(reference.Bottom - item.H) },
AlignMode.CenterV => item with { Y = Round(reference.Top + (reference.H - item.H) / 2) },
_ => item,
};
/// <summary>문자열 명령을 방향으로 — 모르는 값이면 null(호출부가 아무 일도 하지 않는다)</summary>
public static AlignMode? Parse(string mode) => mode switch
{
"Left" => AlignMode.Left,
"Right" => AlignMode.Right,
"CenterH" => AlignMode.CenterH,
"Top" => AlignMode.Top,
"Bottom" => AlignMode.Bottom,
"CenterV" => AlignMode.CenterV,
_ => null,
};
/// <summary>세로 축 명령인가 — 여러 페이지에 걸친 선택에서 거부해야 할 것들</summary>
public static bool IsVertical(AlignMode mode)
=> mode is AlignMode.Top or AlignMode.CenterV or AlignMode.Bottom;
/// <summary>
/// 좌표는 정수로 떨어뜨린다 — 저장 포맷(LegacyFormat)이 어차피 정수로 쓰므로
/// 여기서 정하지 않으면 "화면에선 맞았는데 다시 열면 1px 어긋남"이 된다.
///
/// 기본 <c>ToEven</c> 을 쓰지 않는 이유: 322.5→322, 323.5→324 로 방향이 값에 따라 뒤집힌다.
/// 폭이 제각각인 라벨 다섯 개를 가운데 맞춤하면 어떤 건 322, 어떤 건 323 에 앉는다.
/// </summary>
private static double Round(double value) => Math.Round(value, MidpointRounding.AwayFromZero);
#endregion
}
+6 -6
View File
@@ -84,9 +84,9 @@ public static class LucideIcons
["moon"] = """<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />""",
["move-horizontal"] = """<polyline points="18 8 22 12 18 16" /> <polyline points="6 8 2 12 6 16" /> <line x1="2" x2="22" y1="12" y2="12" />""",
["move-vertical"] = """<polyline points="8 18 12 22 16 18" /> <polyline points="8 6 12 2 16 6" /> <line x1="12" x2="12" y1="2" y2="22" />""",
["align-obj-top"] = """<line x1="3" x2="21" y1="4" y2="4" /> <rect x="6" y="7" width="4" height="9" rx="1" /> <rect x="14" y="7" width="4" height="13" rx="1" />""",
["align-obj-middle"] = """<line x1="3" x2="21" y1="12" y2="12" /> <rect x="6" y="4" width="4" height="6" rx="1" /> <rect x="14" y="14" width="4" height="6" rx="1" />""",
["align-obj-bottom"] = """<line x1="3" x2="21" y1="20" y2="20" /> <rect x="6" y="8" width="4" height="9" rx="1" /> <rect x="14" y="4" width="4" height="13" rx="1" />""",
["align-obj-top"] = """<rect x="4.5" y="4.5" width="15" height="4.5" /> <line x1="3" x2="21" y1="4.5" y2="4.5" />""",
["align-obj-middle"] = """<rect x="4.5" y="7.5" width="15" height="9" /> <line x1="3" x2="21" y1="12" y2="12" />""",
["align-obj-bottom"] = """<rect x="4.5" y="15" width="15" height="4.5" /> <line x1="3" x2="21" y1="19.5" y2="19.5" />""",
["distribute-h"] = """<rect x="3" y="7" width="3.5" height="10" rx="1" /> <rect x="10.25" y="7" width="3.5" height="10" rx="1" /> <rect x="17.5" y="7" width="3.5" height="10" rx="1" />""",
["distribute-v"] = """<rect x="7" y="3" width="10" height="3.5" rx="1" /> <rect x="7" y="10.25" width="10" height="3.5" rx="1" /> <rect x="7" y="17.5" width="10" height="3.5" rx="1" />""",
["square"] = """<rect width="18" height="18" x="3" y="3" rx="2" />""",
@@ -94,9 +94,9 @@ public static class LucideIcons
["unlock"] = """<rect width="18" height="11" x="3" y="11" rx="2" ry="2" /> <path d="M7 11V7a5 5 0 0 1 9.9-1" />""",
["group"] = """<path d="M3 7V5a2 2 0 0 1 2-2h2" /> <path d="M17 3h2a2 2 0 0 1 2 2v2" /> <path d="M21 17v2a2 2 0 0 1-2 2h-2" /> <path d="M7 21H5a2 2 0 0 1-2-2v-2" /> <rect width="7" height="5" x="7" y="7" rx="1" /> <rect width="7" height="5" x="10" y="12" rx="1" />""",
["ungroup"] = """<rect width="8" height="6" x="5" y="4" rx="1" /> <rect width="8" height="6" x="11" y="14" rx="1" />""",
["align-obj-left"] = """<line x1="4" x2="4" y1="3" y2="21" /> <rect x="7" y="6" width="9" height="4" rx="1" /> <rect x="7" y="14" width="13" height="4" rx="1" />""",
["align-obj-center-h"] = """<line x1="12" x2="12" y1="3" y2="21" /> <rect x="6" y="6" width="12" height="4" rx="1" /> <rect x="3" y="14" width="18" height="4" rx="1" />""",
["align-obj-right"] = """<line x1="20" x2="20" y1="3" y2="21" /> <rect x="11" y="6" width="9" height="4" rx="1" /> <rect x="7" y="14" width="13" height="4" rx="1" />""",
["align-obj-left"] = """<rect x="4.5" y="4.5" width="4.5" height="15" /> <line x1="4.5" x2="4.5" y1="3" y2="21" />""",
["align-obj-center-h"] = """<rect x="7.5" y="4.5" width="9" height="15" /> <line x1="12" x2="12" y1="3" y2="21" />""",
["align-obj-right"] = """<rect x="15" y="4.5" width="4.5" height="15" /> <line x1="19.5" x2="19.5" y1="3" y2="21" />""",
["droplet"] = """<path d="M12 22a7 7 0 0 0 7-7c0-2-1-3.9-3-5.5s-3.5-4-4-6.5c-.5 2.5-2 4.9-4 6.5C6 11.1 5 13 5 15a7 7 0 0 0 7 7z" />""",
["rotate-cw"] = """<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /> <path d="M21 3v5h-5" />""",
["frame"] = """<line x1="22" x2="2" y1="6" y2="6" /> <line x1="22" x2="2" y1="18" y2="18" /> <line x1="6" x2="6" y1="2" y2="22" /> <line x1="18" x2="18" y1="2" y2="22" />""",
@@ -139,6 +139,12 @@ public static class DialogShots
("12-inspector-behavior", () => HostInspector(designer, "TextBox1", InspectorTab.Behavior)),
// 태그 제안이 타입별로 좁혀지는지 — 이미지는 서명·직인 6종만 떠야 한다
("12b-inspector-picture-data", () => HostInspector(designer, "MPictureBox1", InspectorTab.Data)),
// 정렬 바는 선택 상태에 따라 나타나야 한다 — 선택 0 과 페이지 속성 모드에서는 안 보여야 하는데
// 그건 수치로 못 잡는다(HasSelection 을 조건으로 쓰면 페이지 모드에서 뜬다).
// 15/16 이 같은 designer 에 GroupSelection 을 걸므로 반드시 그 앞에 둔다.
("12c-inspector-none", () => HostInspectorSelection(designer, InspectorShotMode.None)),
("12d-inspector-multi", () => HostInspectorSelection(designer, InspectorShotMode.Multi)),
("12e-inspector-page", () => HostInspectorSelection(designer, InspectorShotMode.Page)),
("13-layer-type-filter", () => new Views.LayerTypeFilterDialog(
designer.LayerTypeCandidates(), new[] { "Label" })),
// 레이어 아웃라인 — 선택 없음 / 컨트롤 하나 선택 / 그룹 전체 선택 세 상태를 각각 찍는다.
@@ -244,6 +250,38 @@ public static class DialogShots
};
}
/// <summary>정렬 바가 선택 상태에 따라 나타나고 사라지는지 — 그림으로만 확인되는 것들</summary>
private enum InspectorShotMode { None, Multi, Page }
private static Window HostInspectorSelection(DesignerViewModel designer, InspectorShotMode mode)
{
var page = designer.Pages[0];
switch (mode)
{
case InspectorShotMode.Multi:
designer.Selection.Set(page.Controls.Take(3).ToList(), page.Controls[0]);
break;
case InspectorShotMode.Page:
designer.Selection.Clear();
designer.ActivatePage(page);
break;
default:
designer.Selection.Clear();
break;
}
designer.Inspector.SelectedTab = InspectorTab.Design;
designer.Inspector.Rebuild();
return new Window
{
Title = $"속성 — {mode}",
Width = 300,
Height = 720,
Content = new Views.InspectorView { DataContext = designer.Inspector },
Background = Application.Current.TryFindResource("B.Panel") as Brush,
};
}
/// <summary>화면 밖에 띄워 레이아웃을 돌린 뒤 PNG 로 찍고 닫는다</summary>
private static void Capture(Window window, string pngPath)
{
@@ -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).
@@ -1463,35 +1463,138 @@ public sealed class DesignerViewModel : ViewModelBase
Selection.Set(page.Controls.Where(c => !c.Model.Hidden), page.Controls.LastOrDefault());
}
/// <summary>선택 정렬 — 선택 bbox 기준(Left/Right/Top/Bottom/CenterH/CenterV)</summary>
/// <summary>
/// 선택 정렬 — <b>기준은 언제나 '담고 있는 것'</b>이다.
///
/// 선택이 2개 이상이면 선택 영역이 그 자리를 대신하고, 1개면 그 컨트롤을 담고 있는 것 —
/// 컨테이너 안이면 컨테이너, 최상위면 페이지 — 이 기준이 된다. 피그마와 같은 규칙이다.
///
/// <b>컨테이너 자식을 페이지 기준으로 맞추지 않는 이유.</b> X/Y 는 부모 상대좌표라
/// "페이지 왼쪽"에 맞추면 음수가 되어 부모 밖으로 나간다. 디자이너 캔버스는 클리핑을 안 해서
/// 멀쩡히 보이지만 레거시 런타임에서는 부모가 잘라먹는다 — 화면과 인쇄가 갈라지고 눈으로 못 잡는다.
/// </summary>
public void Align(string mode)
{
if (Selection.Items.Count < 2)
if (Selection.Items.Count == 0 || SheetMe.Core.Layout.AlignSolver.Parse(mode) is not { } direction)
{
return;
}
var bbox = SelectionWorldBounds();
if (bbox is null)
// 부모와 그 자식을 함께 선택할 수 있다(레이어 목록). 부모를 옮기면 자식 월드 좌표가 따라 움직이는데
// 자식에게도 절대좌표를 대입하면 두 번 옮겨져 어긋난다 — 조상이 선택에 있는 것은 뺀다.
var targets = Selection.Items.Where(vm => !HasSelectedAncestor(vm)).ToList();
if (targets.Count == 0)
{
return;
}
Undo.Snapshot();
foreach (var vm in Selection.Items)
// 다른 종이의 '위 맞춤'에는 뜻이 없다. 예전에는 이 경우 컨트롤이 제 종이 위 허공으로 날아갔다
// (bbox 는 첫 페이지 월드 Y 인데 offset 은 자기 페이지 OffsetY 라 vm.Y 가 큰 음수가 됐다).
if (SheetMe.Core.Layout.AlignSolver.IsVertical(direction)
&& targets.Select(PageOf).Distinct().Count() > 1)
{
return;
}
if (ReferenceFor(targets) is not { } reference)
{
return;
}
// 버튼으로 노출되면 사람은 연타한다. 무조건 스냅샷을 찍으면 빈 실행취소 단계가 쌓이고
// '저장 안 한 변경'으로 표시된다 — 실제로 움직일 때만 찍는다.
var moves = new List<(ControlViewModel Vm, double X, double Y)>(targets.Count);
var changed = false;
foreach (var vm in targets)
{
var offset = ParentWorldOffset(vm);
switch (mode)
{
case "Left": vm.X = Math.Round(bbox.Value.Left - offset.X); break;
case "Right": vm.X = Math.Round(bbox.Value.Right - vm.Width - offset.X); break;
case "Top": vm.Y = Math.Round(bbox.Value.Top - offset.Y); break;
case "Bottom": vm.Y = Math.Round(bbox.Value.Bottom - vm.Height - offset.Y); break;
case "CenterH": vm.X = Math.Round(bbox.Value.Left + (bbox.Value.Width - vm.Width) / 2 - offset.X); break;
case "CenterV": vm.Y = Math.Round(bbox.Value.Top + (bbox.Value.Height - vm.Height) / 2 - offset.Y); break;
}
var world = new SheetMe.Core.Layout.SnapRect(
offset.X + vm.X, offset.Y + vm.Y, vm.Width, vm.Height);
var aligned = SheetMe.Core.Layout.AlignSolver.Apply(world, reference, direction);
var x = aligned.X - offset.X;
var y = aligned.Y - offset.Y;
changed |= x != vm.X || y != vm.Y;
moves.Add((vm, x, y));
}
if (!changed)
{
return;
}
Undo.Snapshot();
foreach (var (vm, x, y) in moves)
{
vm.X = x;
vm.Y = y;
}
// 페이지 소속은 바뀌지 않는다(세로 다중 페이지는 위에서 거부했다) — ReassignPagesAfterMove 는 부르지 않는다
Selection.NotifyBoundsChanged();
}
/// <summary>정렬 기준 사각형(월드) — 여럿이면 선택 영역, 하나면 그것을 담고 있는 것</summary>
private SheetMe.Core.Layout.SnapRect? ReferenceFor(IReadOnlyList<ControlViewModel> targets)
{
if (targets.Count >= 2)
{
if (SelectionWorldBounds() is not { } bbox)
{
return null;
}
return new SheetMe.Core.Layout.SnapRect(bbox.X, bbox.Y, bbox.Width, bbox.Height);
}
var only = targets[0];
if (only.Parent is { } parent)
{
var rect = WorldBoundsOf(parent);
return new SheetMe.Core.Layout.SnapRect(rect.X, rect.Y, rect.Width, rect.Height);
}
// 스냅(SnapEngine)이 쓰는 용지 사각형 표현과 글자 그대로 같아야 한다 —
// 갈라지면 가이드선이 가리키는 자리와 정렬 결과가 어긋난다
return PageOf(only) is { } page
? new SheetMe.Core.Layout.SnapRect(0, page.OffsetY, page.WidthDip, page.HeightDip)
: null;
}
/// <summary>부모 체인에 선택된 것이 있는가</summary>
private bool HasSelectedAncestor(ControlViewModel viewModel)
{
var parent = viewModel.Parent;
while (parent is not null)
{
if (Selection.Items.Contains(parent))
{
return true;
}
parent = parent.Parent;
}
return false;
}
/// <summary>정렬 기준을 사람 말로 — 인스펙터 툴팁이 이걸 물고 있다</summary>
public string AlignScopeText()
{
var items = Selection.Items;
if (items.Count == 0)
{
return string.Empty;
}
if (items.Count >= 2)
{
return items.Select(PageOf).Distinct().Count() > 1
? "선택 영역 기준 (여러 페이지)"
: "선택 영역 기준";
}
var only = items[0];
return only.Parent is { } parent
? $"{(parent.Id.Length > 0 ? parent.Id : "")} 기준"
: "페이지 기준";
}
/// <summary>세로 정렬이 가능한가 — 여러 페이지에 걸친 선택에서는 뜻이 없다</summary>
public bool CanAlignVertical()
=> Selection.Items.Count > 0 && Selection.Items.Select(PageOf).Distinct().Count() <= 1;
/// <summary>
/// 선택 그룹화 — 동일 GroupId 부여(2개 이상, 최상위 컨트롤만) + 기본 이름 등록.
/// 잠긴 컨트롤은 애초에 선택에 들어오지 않는다(SelectionService 가 거른다).
@@ -45,6 +45,31 @@ public sealed class InspectorViewModel : ViewModelBase
/// </summary>
public bool HasSelection => designer.Selection.Items.Count > 0 || designer.InspectedPage is not null;
/// <summary>
/// 정렬 바를 보일 것인가.
///
/// <see cref="HasSelection"/> 을 쓰면 안 된다 — 그건 페이지 속성 모드에서도 true 라
/// 정렬할 컨트롤이 없는데 바가 뜬다. 선택 0 에서 접히지 않으면 '컨트롤을 선택하면…' 안내문
/// 뒤에 버튼 줄이 그대로 남는다(안내문과 같은 Grid 에 겹쳐 그려진다).
/// </summary>
public bool ShowAlignBar => designer.Selection.Items.Count > 0 && selectedTab == InspectorTab.Design;
/// <summary>세로 정렬 가능 — 여러 페이지에 걸친 선택에서는 뜻이 없다</summary>
public bool CanAlignVertical => designer.CanAlignVertical();
/// <summary>지금 무엇을 기준으로 정렬하는가 — 버튼 툴팁이 이걸 물고 있다</summary>
public string AlignScopeText => designer.AlignScopeText();
/// <summary>세로 버튼 툴팁 — 못 쓰는 상태면 그 이유를 말한다</summary>
public string VerticalScopeText => CanAlignVertical
? AlignScopeText
: "여러 페이지에 걸친 선택은 세로 정렬을 할 수 없습니다";
/// <summary>
/// 정렬 실행 — 뷰의 DataContext 가 인스펙터라 디자이너 커맨드에 직접 닿을 수 없어 위임한다.
/// </summary>
public M.Framework.WPF.ICustomCommand AlignCommand { get; }
/// <summary>
/// 선택된 탭. 컨트롤을 바꿔도 유지된다 — 데이터 탭에서 여러 컨트롤의 태그를 잇달아 손보는
/// 작업이 흔한데, 선택할 때마다 디자인 탭으로 되돌아가면 매번 다시 눌러야 한다.
@@ -146,6 +171,8 @@ public sealed class InspectorViewModel : ViewModelBase
public InspectorViewModel(DesignerViewModel designer)
{
this.designer = designer;
AlignCommand = new M.Framework.WPF.Command(
(object parameter) => designer.Align(parameter as string ?? string.Empty));
designer.Selection.SetChanged += Rebuild;
designer.Selection.Changed += RefreshBoundsRows; // 드래그/리사이즈 중 X/Y/W/H 실시간 갱신
}
@@ -175,6 +202,10 @@ public sealed class InspectorViewModel : ViewModelBase
currentSection = null;
OnPropertyChanged(nameof(Summary));
OnPropertyChanged(nameof(HasSelection));
OnPropertyChanged(nameof(ShowAlignBar));
OnPropertyChanged(nameof(CanAlignVertical));
OnPropertyChanged(nameof(AlignScopeText));
OnPropertyChanged(nameof(VerticalScopeText));
var items = designer.Selection.Items;
if (items.Count == 0)
@@ -98,6 +98,40 @@
<Setter Property="MinWidth" Value="22"/>
</Style>
<!--
정렬 버튼 — 액션이지 상태가 아니다. SegmentToggle 처럼 채우면 '켜져 있다'로 읽힌다.
채움 없이 호버 반응만 남긴다. 30x26 / radius 6 / 사이 2px 은 [200] 와이어프레임 .icn 실측값.
-->
<Style x:Key="AlignBtn" TargetType="Button">
<Setter Property="Width" Value="30"/>
<Setter Property="Height" Value="26"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="Margin" Value="0,0,2,0"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="bd" Background="Transparent" CornerRadius="6"
BorderThickness="1" BorderBrush="Transparent" SnapsToDevicePixels="True">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Hover}"/>
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Accent}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="bd" Property="Opacity" Value="0.45"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 세그먼트 한 칸 — 붙어 있는 아이콘 토글(굵게/기울임/밑줄/취소선) -->
<!-- 폭 30 + 간격 2 = 4칸 128px. 라벨 92 와 합쳐 220px 라 패널 최소폭(250 → 행 가용 221)에 들어간다 -->
<Style x:Key="SegmentToggle" TargetType="ToggleButton">
@@ -614,6 +648,46 @@
</UniformGrid>
</Border>
<!--
정렬 — 기준은 언제나 '담고 있는 것'이다. 선택 1개면 페이지(컨테이너 안이면 그 컨테이너),
2개 이상이면 선택 영역. 툴팁이 지금 무엇이 기준인지 말해 준다.
행 VM(Rows)이 아니라 여기 두는 이유 둘: AlignRowViewModel 은 이미 '텍스트 정렬'로
이름과 템플릿을 선점했고, 정렬은 스크롤과 무관하게 늘 보이는 편이 조작에 맞다.
-->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="12,2,10,8"
ToolTipService.ShowOnDisabled="True"
Visibility="{Binding ShowAlignBar, Converter={StaticResource BoolToVisibility}}">
<Button Style="{StaticResource AlignBtn}" Command="{Binding AlignCommand}" CommandParameter="Left"
AutomationProperties.Name="왼쪽 맞춤"
ToolTip="{Binding AlignScopeText, StringFormat=왼쪽 맞춤 — {0}}"
Content="{Binding Source=align-obj-left, Converter={StaticResource IconName}, ConverterParameter=18|B.Muted}"/>
<Button Style="{StaticResource AlignBtn}" Command="{Binding AlignCommand}" CommandParameter="CenterH"
AutomationProperties.Name="가로 가운데 맞춤"
ToolTip="{Binding AlignScopeText, StringFormat=가로 가운데 — {0}}"
Content="{Binding Source=align-obj-center-h, Converter={StaticResource IconName}, ConverterParameter=18|B.Muted}"/>
<Button Style="{StaticResource AlignBtn}" Command="{Binding AlignCommand}" CommandParameter="Right"
AutomationProperties.Name="오른쪽 맞춤"
ToolTip="{Binding AlignScopeText, StringFormat=오른쪽 맞춤 — {0}}"
Content="{Binding Source=align-obj-right, Converter={StaticResource IconName}, ConverterParameter=18|B.Muted}"/>
<!-- 가로 축과 세로 축을 눈으로 가르는 틈 — 참조 .align .gap{width:8px} -->
<Border Width="8" Margin="0,0,2,0"/>
<Button Style="{StaticResource AlignBtn}" Command="{Binding AlignCommand}" CommandParameter="Top"
IsEnabled="{Binding CanAlignVertical}" AutomationProperties.Name="위 맞춤"
ToolTip="{Binding VerticalScopeText, StringFormat=위 맞춤 — {0}}"
Content="{Binding Source=align-obj-top, Converter={StaticResource IconName}, ConverterParameter=18|B.Muted}"/>
<Button Style="{StaticResource AlignBtn}" Command="{Binding AlignCommand}" CommandParameter="CenterV"
IsEnabled="{Binding CanAlignVertical}" AutomationProperties.Name="세로 가운데 맞춤"
ToolTip="{Binding VerticalScopeText, StringFormat=세로 가운데 — {0}}"
Content="{Binding Source=align-obj-middle, Converter={StaticResource IconName}, ConverterParameter=18|B.Muted}"/>
<Button Style="{StaticResource AlignBtn}" Command="{Binding AlignCommand}" CommandParameter="Bottom"
IsEnabled="{Binding CanAlignVertical}" AutomationProperties.Name="아래 맞춤"
ToolTip="{Binding VerticalScopeText, StringFormat=아래 맞춤 — {0}}"
Content="{Binding Source=align-obj-bottom, Converter={StaticResource IconName}, ConverterParameter=18|B.Muted}"/>
</StackPanel>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Rows}" Margin="10,0,10,10">
<!-- 접힌 섹션의 행은 컨테이너만 숨긴다 — 행 VM 을 재생성하지 않아 편집 상태가 유지된다 -->
+17 -8
View File
@@ -188,12 +188,19 @@
<MenuItem Header="그룹(_G)" Command="{Binding CurrentDesigner.GroupCommand}" InputGestureText="Ctrl+G"/>
<MenuItem Header="그룹 해제(_U)" Command="{Binding CurrentDesigner.UngroupCommand}" InputGestureText="Ctrl+Shift+G"/>
<Separator/>
<MenuItem Header="왼쪽 맞춤" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="Left"/>
<MenuItem Header="오른쪽 맞춤" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="Right"/>
<MenuItem Header="위 맞춤" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="Top"/>
<MenuItem Header="아래 맞춤" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="Bottom"/>
<MenuItem Header="가로 가운데" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="CenterH"/>
<MenuItem Header="세로 가운데" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="CenterV"/>
<!-- 기준: 선택 1개면 페이지(컨테이너 안이면 그 컨테이너), 2개 이상이면 선택 영역 -->
<MenuItem Header="쪽 맞춤" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="Left"
ToolTip="선택 1개면 페이지(또는 부모 컨테이너) 기준, 2개 이상이면 선택 영역 기준"/>
<MenuItem Header="오른쪽 맞춤" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="Right"
ToolTip="선택 1개면 페이지(또는 부모 컨테이너) 기준, 2개 이상이면 선택 영역 기준"/>
<MenuItem Header="위 맞춤" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="Top"
ToolTip="여러 페이지에 걸친 선택에서는 동작하지 않습니다"/>
<MenuItem Header="아래 맞춤" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="Bottom"
ToolTip="여러 페이지에 걸친 선택에서는 동작하지 않습니다"/>
<MenuItem Header="가로 가운데" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="CenterH"
ToolTip="선택 1개면 페이지(또는 부모 컨테이너) 기준, 2개 이상이면 선택 영역 기준"/>
<MenuItem Header="세로 가운데" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="CenterV"
ToolTip="여러 페이지에 걸친 선택에서는 동작하지 않습니다"/>
<Separator/>
<MenuItem Header="같은 크기로">
<MenuItem Header="너비/높이 모두(_B)" Command="{Binding CurrentDesigner.SizeToControlCommand}" CommandParameter="Both"/>
@@ -213,8 +220,10 @@
<MenuItem Header="붙이기(_C)" Command="{Binding CurrentDesigner.SpacingCommand}" CommandParameter="VertConcat"/>
</MenuItem>
<Separator/>
<MenuItem Header="페이지 가로 가운데(_H)" Command="{Binding CurrentDesigner.CenterInPageCommand}" CommandParameter="H"/>
<MenuItem Header="페이지 세로 가운데(_V)" Command="{Binding CurrentDesigner.CenterInPageCommand}" CommandParameter="V"/>
<MenuItem Header="페이지 가로 가운데 — 묶음째(_H)"
ToolTip="상대 배치를 유지한 채 선택 전체를 페이지 가운데로 옮깁니다" Command="{Binding CurrentDesigner.CenterInPageCommand}" CommandParameter="H"/>
<MenuItem Header="페이지 세로 가운데 — 묶음째(_V)"
ToolTip="상대 배치를 유지한 채 선택 전체를 페이지 가운데로 옮깁니다" Command="{Binding CurrentDesigner.CenterInPageCommand}" CommandParameter="V"/>
<Separator/>
<MenuItem Header="{Binding CurrentDesigner.TabOrderMenuHeader, FallbackValue=탭순서 편집 시작}"
Command="{Binding CurrentDesigner.TabOrderCommand}"
@@ -0,0 +1,117 @@
using SheetMe.Core.Layout;
namespace SheetMe.Core.Tests;
/// <summary>
/// 정렬 산술 고정 — 특히 반올림 방향.
///
/// 반올림은 눈에 안 띄는 채로 틀린다. 폭 75 짜리를 폭 720 용지 가운데 두면 목표가 정확히 322.5 인데,
/// .NET 기본 반올림(ToEven)은 322 로 내리고 323.5 는 324 로 올린다 — 폭이 제각각인 라벨 다섯 개를
/// 가운데 맞춤하면 어떤 건 322, 어떤 건 323 에 앉아 한 픽셀씩 어긋난다.
/// </summary>
[TestClass]
public sealed class AlignSolverTests
{
#region Member Fields
private static readonly SnapRect Page = new(0, 0, 720, 856);
/// <summary>둘째 장 — 월드 원점이 0 이 아닌 기준</summary>
private static readonly SnapRect SecondPage = new(0, 884, 720, 856);
#endregion
#region Methods
[TestMethod]
public void _모서리에_맞춘다()
{
var item = new SnapRect(10, 10, 50, 20);
Assert.AreEqual(0, AlignSolver.Apply(item, Page, AlignMode.Left).X, 1e-9);
Assert.AreEqual(670, AlignSolver.Apply(item, Page, AlignMode.Right).X, 1e-9);
Assert.AreEqual(335, AlignSolver.Apply(item, Page, AlignMode.CenterH).X, 1e-9);
}
[TestMethod]
public void _모서리에_맞춘다()
{
var item = new SnapRect(10, 10, 50, 20);
Assert.AreEqual(0, AlignSolver.Apply(item, Page, AlignMode.Top).Y, 1e-9);
Assert.AreEqual(836, AlignSolver.Apply(item, Page, AlignMode.Bottom).Y, 1e-9);
Assert.AreEqual(418, AlignSolver.Apply(item, Page, AlignMode.CenterV).Y, 1e-9);
}
[TestMethod]
public void _중심은_바깥쪽으로_올린다()
{
// (720 75) / 2 = 322.5 — 기본 반올림(ToEven)이면 322 가 나온다
var odd = new SnapRect(0, 0, 75, 20);
Assert.AreEqual(323, AlignSolver.Apply(odd, Page, AlignMode.CenterH).X, 1e-9);
// (100 51) / 2 = 24.5 — ToEven 이면 24
var narrow = new SnapRect(0, 0, 51, 20);
Assert.AreEqual(25, AlignSolver.Apply(narrow, new SnapRect(0, 0, 100, 100), AlignMode.CenterH).X, 1e-9);
}
[TestMethod]
public void _번_맞춰도_같은_자리다()
{
var item = new SnapRect(13, 7, 75, 20);
var once = AlignSolver.Apply(item, Page, AlignMode.CenterH);
var twice = AlignSolver.Apply(once, Page, AlignMode.CenterH);
Assert.AreEqual(once.X, twice.X, 1e-9, "기준이 고정이면 두 번째 호출이 자리를 바꿀 이유가 없다");
}
[TestMethod]
public void _넓으면_음수가_나오고_그게_맞다()
{
var wide = new SnapRect(0, 0, 800, 20);
Assert.AreEqual(-40, AlignSolver.Apply(wide, Page, AlignMode.CenterH).X, 1e-9,
"가운데에 두려면 그래야 한다 — 여기서 잘라 붙이면 가운데가 아니게 된다");
}
[TestMethod]
public void _0_이어도_터지지_않는다()
{
var empty = new SnapRect(300, 300, 0, 0);
Assert.AreEqual(0, AlignSolver.Apply(empty, Page, AlignMode.Left).X, 1e-9);
Assert.AreEqual(720, AlignSolver.Apply(empty, Page, AlignMode.Right).X, 1e-9);
}
[TestMethod]
public void _장에서도_그_장의_좌표로_맞춘다()
{
var item = new SnapRect(10, 1000, 50, 20);
Assert.AreEqual(884, AlignSolver.Apply(item, SecondPage, AlignMode.Top).Y, 1e-9);
Assert.AreEqual(1720, AlignSolver.Apply(item, SecondPage, AlignMode.Bottom).Y, 1e-9);
}
[TestMethod]
public void _않는_축은_건드리지_않는다()
{
var item = new SnapRect(13, 77, 50, 20);
var moved = AlignSolver.Apply(item, Page, AlignMode.Left);
Assert.AreEqual(77, moved.Y, 1e-9);
Assert.AreEqual(50, moved.W, 1e-9);
Assert.AreEqual(20, moved.H, 1e-9);
}
[TestMethod]
public void _문자열을_방향으로_읽는다()
{
Assert.AreEqual(AlignMode.Left, AlignSolver.Parse("Left"));
Assert.AreEqual(AlignMode.CenterV, AlignSolver.Parse("CenterV"));
Assert.IsNull(AlignSolver.Parse("Sideways"), "모르는 값은 null — 호출부가 아무 일도 하지 않는다");
Assert.IsNull(AlignSolver.Parse(string.Empty));
}
[TestMethod]
public void _축을_가려낸다()
{
// 여러 페이지에 걸친 선택에서 거부해야 할 것들 — 다른 종이의 '위 맞춤'에는 뜻이 없다
Assert.IsTrue(AlignSolver.IsVertical(AlignMode.Top));
Assert.IsTrue(AlignSolver.IsVertical(AlignMode.CenterV));
Assert.IsTrue(AlignSolver.IsVertical(AlignMode.Bottom));
Assert.IsFalse(AlignSolver.IsVertical(AlignMode.Left));
Assert.IsFalse(AlignSolver.IsVertical(AlignMode.CenterH));
Assert.IsFalse(AlignSolver.IsVertical(AlignMode.Right));
}
#endregion
}