정렬 — 기준은 언제나 '담고 있는 것'. 그리고 속성 패널에 정렬 행
"폼 기준으로 정렬"이 없었다. 정확히는 정렬 자체가 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:
co-authored by
Claude Opus 5
parent
ba838ae54a
commit
26f696ab00
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
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)
|
||||
{
|
||||
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;
|
||||
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 을 재생성하지 않아 편집 상태가 유지된다 -->
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user