Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
893d81a40e | ||
|
|
0e30aa39c6 | ||
|
|
d474937837 | ||
|
|
6ee4435575 | ||
|
|
e82d520de6 | ||
|
|
a5083fd0bb | ||
|
|
7f8808eb44 | ||
|
|
26f696ab00 | ||
|
|
ba838ae54a | ||
|
|
8a7cc65099 |
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,481 @@
|
|||||||
|
namespace SheetMe.Core.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 스냅 계산 — 순수 기하. 화면도 뷰모델도 모른다.
|
||||||
|
///
|
||||||
|
/// <b>왜 후보를 좌표 목록으로 평탄화하지 않는가.</b> 예전에는 후보를 <c>List<double></c> 로 모아
|
||||||
|
/// 최근접 하나를 골랐다. 그러면 "이 좌표가 <i>어느 객체</i>에서 왔는가"를 잃는다 — 그래서 가이드를
|
||||||
|
/// 화면 끝에서 끝까지 긋는 무한선으로밖에 못 그렸고, 간격 균등은 아예 계산할 수 없었다.
|
||||||
|
/// 여기서는 <see cref="SnapRect"/> 원본을 끝까지 들고 간다.
|
||||||
|
///
|
||||||
|
/// <b>결정성.</b> 같은 입력이면 항상 같은 답이어야 한다(단위 테스트가 그걸 고정한다).
|
||||||
|
/// 동률 후보의 승자는 <see cref="SnapGuideKind"/> → 좌표 → 모서리 순서로 완전히 정해진다 —
|
||||||
|
/// 예전에는 컨트롤 열거 순서가 승자를 갈랐다.
|
||||||
|
///
|
||||||
|
/// <b>할당.</b> 드래그 중 매 프레임 불린다. 스크래치 버퍼를 재사용하고 이 경로에서 LINQ 를 쓰지 않는다.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SnapSolver
|
||||||
|
{
|
||||||
|
#region Member Fields
|
||||||
|
/// <summary>교차축이 겹치는 형제가 이보다 많으면 간격 균등을 포기한다 — 정렬 스냅은 그대로 돈다</summary>
|
||||||
|
private const int SpacingRowLimit = 64;
|
||||||
|
|
||||||
|
/// <summary>형제가 이보다 많으면 간격 균등을 아예 끈다</summary>
|
||||||
|
private const int SpacingTotalLimit = 2000;
|
||||||
|
|
||||||
|
/// <summary>막대가 화면을 덮지 않게 — 균등 구간이 아주 길어도 여기서 끊는다</summary>
|
||||||
|
private const int SpacingMarkLimit = 8;
|
||||||
|
|
||||||
|
private const double Epsilon = 1e-6;
|
||||||
|
|
||||||
|
private SnapContext context = new();
|
||||||
|
|
||||||
|
/// <summary>Left 오름차순 형제 — BeginDrag 에서 한 번 정렬한다(프레임당 정렬 금지)</summary>
|
||||||
|
private SnapRect[] byLeft = Array.Empty<SnapRect>();
|
||||||
|
|
||||||
|
/// <summary>Top 오름차순 형제</summary>
|
||||||
|
private SnapRect[] byTop = Array.Empty<SnapRect>();
|
||||||
|
|
||||||
|
private readonly List<Match> matches = new();
|
||||||
|
private readonly List<SnapGuide> guides = new();
|
||||||
|
private readonly List<SpacingMark> spacings = new();
|
||||||
|
private readonly List<SnapRect> row = new();
|
||||||
|
private readonly List<SnapRect> matched = new();
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Methods
|
||||||
|
/// <summary>드래그 시작 — 후보를 한 번만 모으고 정렬해 둔다</summary>
|
||||||
|
public void Prepare(SnapContext value)
|
||||||
|
{
|
||||||
|
context = value;
|
||||||
|
byLeft = new SnapRect[value.Siblings.Count];
|
||||||
|
byTop = new SnapRect[value.Siblings.Count];
|
||||||
|
for (var i = 0; i < value.Siblings.Count; i++)
|
||||||
|
{
|
||||||
|
byLeft[i] = value.Siblings[i];
|
||||||
|
byTop[i] = value.Siblings[i];
|
||||||
|
}
|
||||||
|
Array.Sort(byLeft, static (a, b) => a.Left.CompareTo(b.Left));
|
||||||
|
Array.Sort(byTop, static (a, b) => a.Top.CompareTo(b.Top));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>드래그 끝 — 후보를 버린다. 드래그 밖에서 낡은 후보가 살아 있으면 유령 스냅이 된다</summary>
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
context = new SnapContext();
|
||||||
|
byLeft = Array.Empty<SnapRect>();
|
||||||
|
byTop = Array.Empty<SnapRect>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>이동 스냅 — bbox 에 (dx,dy) 를 적용한 자리에서 정렬·간격·격자 순으로 붙인다</summary>
|
||||||
|
public SnapOutcome SolveMove(SnapRect bbox, double dx, double dy, SnapOptions options, bool free)
|
||||||
|
{
|
||||||
|
if (free)
|
||||||
|
{
|
||||||
|
return SnapOutcome.Free(dx, dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
guides.Clear();
|
||||||
|
spacings.Clear();
|
||||||
|
|
||||||
|
var moved = new SnapRect(bbox.X + dx, bbox.Y + dy, bbox.W, bbox.H);
|
||||||
|
var (adjustX, snappedX) = SolveAxis(moved, vertical: true, options);
|
||||||
|
var (adjustY, snappedY) = SolveAxis(moved, vertical: false, options);
|
||||||
|
|
||||||
|
// 정렬이 못 잡은 축만 간격 균등을 본다 — 같은 축에서 둘 다 사정권이면 정렬이 이긴다.
|
||||||
|
// 거리로 겨루게 하면 한 픽셀 차이로 표시가 정렬선↔간격막대로 튀어 종잡을 수 없다.
|
||||||
|
var canSpace = options.EnableSpacing && context.Siblings.Count <= SpacingTotalLimit;
|
||||||
|
if (canSpace && !snappedX)
|
||||||
|
{
|
||||||
|
(adjustX, snappedX) = SolveSpacing(moved, horizontal: true, options);
|
||||||
|
}
|
||||||
|
if (canSpace && !snappedY)
|
||||||
|
{
|
||||||
|
(adjustY, snappedY) = SolveSpacing(moved, horizontal: false, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
var resultDx = snappedX ? dx + adjustX : Grid(moved.Left, options.GridSize) - bbox.X;
|
||||||
|
var resultDy = snappedY ? dy + adjustY : Grid(moved.Top, options.GridSize) - bbox.Y;
|
||||||
|
|
||||||
|
return new SnapOutcome(resultDx, resultDy, snappedX, snappedY,
|
||||||
|
guides.ToArray(), spacings.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 리사이즈 모서리 스냅 — 움직이는 모서리 하나를 후보에 맞춘다.
|
||||||
|
/// 이동과 <b>같은 최근접 규칙</b>을 쓴다(예전에는 여기만 '첫 매치'라 둘이 어긋났다).
|
||||||
|
/// </summary>
|
||||||
|
public SnapEdgeOutcome SolveEdge(double value, bool isXAxis, SnapOptions options, bool free)
|
||||||
|
{
|
||||||
|
if (free)
|
||||||
|
{
|
||||||
|
return SnapEdgeOutcome.None(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
matches.Clear();
|
||||||
|
CollectCandidates(isXAxis, value, value, value, options.Tolerance, singleEdge: true);
|
||||||
|
if (matches.Count == 0)
|
||||||
|
{
|
||||||
|
return SnapEdgeOutcome.None(Grid(value, options.GridSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
var winner = Winner();
|
||||||
|
matched.Clear();
|
||||||
|
foreach (var match in matches)
|
||||||
|
{
|
||||||
|
if (Math.Abs(match.Distance - winner.Distance) < Epsilon
|
||||||
|
&& Math.Abs(match.Position - winner.Position) < Epsilon
|
||||||
|
&& match.Source is { } source)
|
||||||
|
{
|
||||||
|
matched.Add(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new SnapEdgeOutcome(winner.Position, true, matched.ToArray(), winner.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Grid(double value, double grid)
|
||||||
|
=> grid <= 0 ? value : Math.Round(value / grid) * grid;
|
||||||
|
|
||||||
|
/// <summary>한 축의 정렬 스냅 — 보정량은 승자 하나, 가이드는 '실제로 맞은 것' 전부</summary>
|
||||||
|
private (double Adjust, bool Snapped) SolveAxis(SnapRect moved, bool vertical, SnapOptions options)
|
||||||
|
{
|
||||||
|
var (a, b, c) = vertical
|
||||||
|
? (moved.Left, moved.CenterX, moved.Right)
|
||||||
|
: (moved.Top, moved.CenterY, moved.Bottom);
|
||||||
|
|
||||||
|
matches.Clear();
|
||||||
|
CollectCandidates(vertical, a, b, c, options.Tolerance, singleEdge: false);
|
||||||
|
if (matches.Count == 0)
|
||||||
|
{
|
||||||
|
return (0, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var winner = Winner();
|
||||||
|
EmitGuides(moved, vertical, winner.Distance);
|
||||||
|
return (winner.Distance, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 승자 — |거리| 최소 → 종류 → 좌표 → 모서리 순.
|
||||||
|
/// 네 단계를 모두 두는 이유는 어느 하나라도 빠지면 입력 순서가 답을 가르기 때문이다.
|
||||||
|
/// </summary>
|
||||||
|
private Match Winner()
|
||||||
|
{
|
||||||
|
var best = matches[0];
|
||||||
|
for (var i = 1; i < matches.Count; i++)
|
||||||
|
{
|
||||||
|
if (Better(matches[i], best))
|
||||||
|
{
|
||||||
|
best = matches[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Better(Match candidate, Match current)
|
||||||
|
{
|
||||||
|
var da = Math.Abs(candidate.Distance);
|
||||||
|
var db = Math.Abs(current.Distance);
|
||||||
|
if (Math.Abs(da - db) > Epsilon)
|
||||||
|
{
|
||||||
|
return da < db;
|
||||||
|
}
|
||||||
|
if (candidate.Kind != current.Kind)
|
||||||
|
{
|
||||||
|
return candidate.Kind < current.Kind;
|
||||||
|
}
|
||||||
|
if (Math.Abs(candidate.Position - current.Position) > Epsilon)
|
||||||
|
{
|
||||||
|
return candidate.Position < current.Position;
|
||||||
|
}
|
||||||
|
return candidate.EdgeIndex < current.EdgeIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 보정을 적용했을 때 <b>실제로 맞는</b> 매치만 선으로 낸다.
|
||||||
|
///
|
||||||
|
/// |거리| 만 같고 부호가 반대인 후보(+3 과 −3)를 함께 그리면 '붙었다고 그려 놓고 안 붙은' 선이 된다.
|
||||||
|
/// 그래서 부호까지 같은 것만 남긴다.
|
||||||
|
/// </summary>
|
||||||
|
private void EmitGuides(SnapRect moved, bool vertical, double adjust)
|
||||||
|
{
|
||||||
|
var settled = vertical
|
||||||
|
? new SnapRect(moved.X + adjust, moved.Y, moved.W, moved.H)
|
||||||
|
: new SnapRect(moved.X, moved.Y + adjust, moved.W, moved.H);
|
||||||
|
|
||||||
|
foreach (var match in matches)
|
||||||
|
{
|
||||||
|
if (Math.Abs(match.Distance - adjust) > Epsilon)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
double start, end;
|
||||||
|
if (match.Source is { } source)
|
||||||
|
{
|
||||||
|
// 형제 가이드는 관련된 것들의 교차축 합집합만큼만 — 관계가 눈에 보이는 길이다
|
||||||
|
start = vertical ? Math.Min(source.Top, settled.Top) : Math.Min(source.Left, settled.Left);
|
||||||
|
end = vertical ? Math.Max(source.Bottom, settled.Bottom) : Math.Max(source.Right, settled.Right);
|
||||||
|
}
|
||||||
|
else if (match.Page is { } page)
|
||||||
|
{
|
||||||
|
// 용지 가이드는 그 용지 전체. 종이 밖으로 나가면 회색 배경 위에서 대비가 죽는다
|
||||||
|
start = vertical ? page.Top : page.Left;
|
||||||
|
end = vertical ? page.Bottom : page.Right;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Merge(new SnapGuide(vertical, match.Position, start, end, match.Kind));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>같은 축·같은 좌표의 가이드는 하나로 합치고 범위를 넓힌다 — 같은 자리에 선을 겹쳐 긋지 않는다</summary>
|
||||||
|
private void Merge(SnapGuide guide)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < guides.Count; i++)
|
||||||
|
{
|
||||||
|
var existing = guides[i];
|
||||||
|
if (existing.IsVertical != guide.IsVertical
|
||||||
|
|| Math.Abs(existing.Position - guide.Position) > Epsilon)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
guides[i] = new SnapGuide(
|
||||||
|
existing.IsVertical, existing.Position,
|
||||||
|
Math.Min(existing.Start, guide.Start),
|
||||||
|
Math.Max(existing.End, guide.End),
|
||||||
|
existing.Kind <= guide.Kind ? existing.Kind : guide.Kind);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
guides.Add(guide);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <param name="singleEdge">리사이즈처럼 모서리가 하나뿐이면 a 만 본다</param>
|
||||||
|
private void CollectCandidates(bool vertical, double a, double b, double c,
|
||||||
|
double tolerance, bool singleEdge)
|
||||||
|
{
|
||||||
|
var limit = singleEdge ? 1 : 3;
|
||||||
|
foreach (var sibling in context.Siblings)
|
||||||
|
{
|
||||||
|
var (p0, p1, p2) = vertical
|
||||||
|
? (sibling.Left, sibling.CenterX, sibling.Right)
|
||||||
|
: (sibling.Top, sibling.CenterY, sibling.Bottom);
|
||||||
|
for (var e = 0; e < limit; e++)
|
||||||
|
{
|
||||||
|
var edge = e == 0 ? a : e == 1 ? b : c;
|
||||||
|
Consider(edge, p0, e, SnapGuideKind.SiblingEdge, sibling, null, tolerance);
|
||||||
|
Consider(edge, p1, e, SnapGuideKind.SiblingCenter, sibling, null, tolerance);
|
||||||
|
Consider(edge, p2, e, SnapGuideKind.SiblingEdge, sibling, null, tolerance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var page in context.Pages)
|
||||||
|
{
|
||||||
|
var (p0, p1, p2) = vertical
|
||||||
|
? (page.Left, page.CenterX, page.Right)
|
||||||
|
: (page.Top, page.CenterY, page.Bottom);
|
||||||
|
for (var e = 0; e < limit; e++)
|
||||||
|
{
|
||||||
|
var edge = e == 0 ? a : e == 1 ? b : c;
|
||||||
|
Consider(edge, p0, e, SnapGuideKind.PageEdge, null, page, tolerance);
|
||||||
|
Consider(edge, p1, e, SnapGuideKind.PageCenter, null, page, tolerance);
|
||||||
|
Consider(edge, p2, e, SnapGuideKind.PageEdge, null, page, tolerance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Consider(double edge, double candidate, int edgeIndex, SnapGuideKind kind,
|
||||||
|
SnapRect? source, SnapRect? page, double tolerance)
|
||||||
|
{
|
||||||
|
var distance = candidate - edge;
|
||||||
|
if (Math.Abs(distance) <= tolerance)
|
||||||
|
{
|
||||||
|
matches.Add(new Match(distance, candidate, kind, edgeIndex, source, page));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 간격 균등 — 형제들이 이미 같은 간격으로 늘어서 있으면 그 리듬에 끼거나 이어 붙는 자리에 스냅한다.
|
||||||
|
///
|
||||||
|
/// 가로(X)로 설명한다. 세로는 축만 바꾼 대칭이다.
|
||||||
|
/// ① 이동체와 교차축이 겹치는 형제만 한 줄로 본다(겹치지 않으면 '나란히'가 아니다).
|
||||||
|
/// ② 줄의 끝(또는 앞)에서부터 같은 간격이 이어지는 최대 구간을 찾아 그 값을 g 로 쓴다.
|
||||||
|
/// ③ 붙을 자리는 셋 — 두 형제 사이 가운데, 줄 뒤에 g 만큼, 줄 앞에 g 만큼.
|
||||||
|
/// </summary>
|
||||||
|
private (double Adjust, bool Snapped) SolveSpacing(SnapRect moved, bool horizontal, SnapOptions options)
|
||||||
|
{
|
||||||
|
if (context.SpacingPage is not { } page)
|
||||||
|
{
|
||||||
|
return (0, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
BuildRow(moved, horizontal);
|
||||||
|
if (row.Count < 2 || row.Count > SpacingRowLimit)
|
||||||
|
{
|
||||||
|
return (0, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var size = horizontal ? moved.W : moved.H;
|
||||||
|
var start = horizontal ? moved.Left : moved.Top;
|
||||||
|
var lower = horizontal ? page.Left : page.Top;
|
||||||
|
var upper = horizontal ? page.Right : page.Bottom;
|
||||||
|
|
||||||
|
var bestDistance = double.MaxValue;
|
||||||
|
var bestStart = 0.0;
|
||||||
|
var bestRank = int.MaxValue;
|
||||||
|
var found = false;
|
||||||
|
|
||||||
|
void Offer(double candidateStart, int rank)
|
||||||
|
{
|
||||||
|
if (candidateStart < lower - Epsilon || candidateStart + size > upper + Epsilon)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var distance = candidateStart - start;
|
||||||
|
if (Math.Abs(distance) > options.Tolerance)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var better = !found
|
||||||
|
|| Math.Abs(distance) < Math.Abs(bestDistance) - Epsilon
|
||||||
|
|| (Math.Abs(Math.Abs(distance) - Math.Abs(bestDistance)) <= Epsilon
|
||||||
|
&& (rank < bestRank || (rank == bestRank && candidateStart < bestStart)));
|
||||||
|
if (better)
|
||||||
|
{
|
||||||
|
found = true;
|
||||||
|
bestDistance = distance;
|
||||||
|
bestStart = candidateStart;
|
||||||
|
bestRank = rank;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③-1 두 형제 사이에 끼우기 — 남는 틈을 반씩 나눈다
|
||||||
|
for (var i = 0; i < row.Count - 1; i++)
|
||||||
|
{
|
||||||
|
var gapStart = horizontal ? row[i].Right : row[i].Bottom;
|
||||||
|
var gapEnd = horizontal ? row[i + 1].Left : row[i + 1].Top;
|
||||||
|
var gap = gapEnd - gapStart;
|
||||||
|
if (gap - size >= -Epsilon)
|
||||||
|
{
|
||||||
|
Offer(gapStart + (gap - size) / 2, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③-2 뒤에 이어 붙이기 / ③-3 앞에 이어 붙이기
|
||||||
|
var tail = RunGap(horizontal, fromEnd: true);
|
||||||
|
if (tail is { } tailGap && tailGap >= -Epsilon)
|
||||||
|
{
|
||||||
|
Offer((horizontal ? row[^1].Right : row[^1].Bottom) + tailGap, 1);
|
||||||
|
}
|
||||||
|
var head = RunGap(horizontal, fromEnd: false);
|
||||||
|
if (head is { } headGap && headGap >= -Epsilon)
|
||||||
|
{
|
||||||
|
Offer((horizontal ? row[0].Left : row[0].Top) - headGap - size, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found)
|
||||||
|
{
|
||||||
|
return (0, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var settled = horizontal
|
||||||
|
? new SnapRect(bestStart, moved.Y, moved.W, moved.H)
|
||||||
|
: new SnapRect(moved.X, bestStart, moved.W, moved.H);
|
||||||
|
EmitSpacings(settled, horizontal);
|
||||||
|
return (bestDistance, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>이동체와 교차축이 겹치는 형제만, 주축 오름차순으로</summary>
|
||||||
|
private void BuildRow(SnapRect moved, bool horizontal)
|
||||||
|
{
|
||||||
|
row.Clear();
|
||||||
|
var source = horizontal ? byLeft : byTop;
|
||||||
|
foreach (var sibling in source)
|
||||||
|
{
|
||||||
|
var overlaps = horizontal
|
||||||
|
? sibling.Top < moved.Bottom && moved.Top < sibling.Bottom
|
||||||
|
: sibling.Left < moved.Right && moved.Left < sibling.Right;
|
||||||
|
if (overlaps)
|
||||||
|
{
|
||||||
|
row.Add(sibling);
|
||||||
|
if (row.Count > SpacingRowLimit)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>줄의 끝(또는 앞)에서부터 같은 값이 이어지는 간격 — 형제가 둘뿐이면 그 하나가 곧 답이다</summary>
|
||||||
|
private double? RunGap(bool horizontal, bool fromEnd)
|
||||||
|
{
|
||||||
|
if (row.Count < 2)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
double Gap(int i) => horizontal
|
||||||
|
? row[i + 1].Left - row[i].Right
|
||||||
|
: row[i + 1].Top - row[i].Bottom;
|
||||||
|
|
||||||
|
var index = fromEnd ? row.Count - 2 : 0;
|
||||||
|
var value = Gap(index);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 확정된 자리를 기준으로 <b>같은 값이 된 빈틈 전부</b>를 막대로 낸다.
|
||||||
|
///
|
||||||
|
/// 이동체 양옆만 재면 "30"이 한 번 뜰 뿐이라 무엇과 같아졌는지 안 보인다.
|
||||||
|
/// 같은 간격에 전부 막대를 그어야 '균등해졌다'가 한눈에 읽힌다.
|
||||||
|
/// </summary>
|
||||||
|
private void EmitSpacings(SnapRect settled, bool horizontal)
|
||||||
|
{
|
||||||
|
row.Add(settled);
|
||||||
|
row.Sort((a, b) => horizontal ? a.Left.CompareTo(b.Left) : a.Top.CompareTo(b.Top));
|
||||||
|
|
||||||
|
var at = -1;
|
||||||
|
for (var i = 0; i < row.Count; i++)
|
||||||
|
{
|
||||||
|
if (row[i].Equals(settled))
|
||||||
|
{
|
||||||
|
at = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (at < 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기준 간격 = 이동체에 붙은 빈틈. 앞에 끼었으면 오른쪽 것을 쓴다
|
||||||
|
var target = at > 0 ? GapAt(at - 1) : GapAt(at);
|
||||||
|
for (var i = 0; i < row.Count - 1 && spacings.Count < SpacingMarkLimit; i++)
|
||||||
|
{
|
||||||
|
var gap = GapAt(i);
|
||||||
|
if (gap < -Epsilon || Math.Abs(gap - target) > Epsilon)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var first = row[i];
|
||||||
|
var second = row[i + 1];
|
||||||
|
var from = horizontal ? first.Right : first.Bottom;
|
||||||
|
var to = horizontal ? second.Left : second.Top;
|
||||||
|
// 막대는 두 사각형이 교차축에서 겹치는 구간의 한가운데에 놓는다
|
||||||
|
var lo = horizontal ? Math.Max(first.Top, second.Top) : Math.Max(first.Left, second.Left);
|
||||||
|
var hi = horizontal ? Math.Min(first.Bottom, second.Bottom) : Math.Min(first.Right, second.Right);
|
||||||
|
spacings.Add(new SpacingMark(horizontal, from, to, (lo + hi) / 2, gap));
|
||||||
|
}
|
||||||
|
|
||||||
|
double GapAt(int i) => horizontal
|
||||||
|
? row[i + 1].Left - row[i].Right
|
||||||
|
: row[i + 1].Top - row[i].Bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct Match(
|
||||||
|
double Distance, double Position, SnapGuideKind Kind, int EdgeIndex,
|
||||||
|
SnapRect? Source, SnapRect? Page);
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
namespace SheetMe.Core.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 스냅 계산용 사각형 — 월드 좌표(px).
|
||||||
|
///
|
||||||
|
/// <b>왜 System.Windows.Rect 를 안 쓰는가.</b> 이 프로젝트(SheetMe.Core)는 net10.0 이고
|
||||||
|
/// System.Windows 참조가 0건이다. Rect 를 들이면 Core 가 Windows 전용이 되고
|
||||||
|
/// 단위 테스트 프로젝트까지 같이 끌려간다 — 스냅 계산을 여기로 옮긴 이유가 사라진다.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct SnapRect(double X, double Y, double W, double H)
|
||||||
|
{
|
||||||
|
public double Left => X;
|
||||||
|
public double Top => Y;
|
||||||
|
public double Right => X + W;
|
||||||
|
public double Bottom => Y + H;
|
||||||
|
public double CenterX => X + W / 2;
|
||||||
|
public double CenterY => Y + H / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 가이드선 종류 — 동률 후보의 승자를 결정적으로 가르고, 선분 범위 규칙도 이걸로 갈린다.
|
||||||
|
/// 값의 <b>순서가 곧 우선순위</b>다(작을수록 이긴다).
|
||||||
|
/// </summary>
|
||||||
|
public enum SnapGuideKind
|
||||||
|
{
|
||||||
|
/// <summary>형제의 좌/우/상/하 모서리</summary>
|
||||||
|
SiblingEdge = 0,
|
||||||
|
|
||||||
|
/// <summary>형제의 중심</summary>
|
||||||
|
SiblingCenter = 1,
|
||||||
|
|
||||||
|
/// <summary>용지 경계</summary>
|
||||||
|
PageEdge = 2,
|
||||||
|
|
||||||
|
/// <summary>용지 중앙</summary>
|
||||||
|
PageCenter = 3,
|
||||||
|
|
||||||
|
/// <summary>간격 균등으로 정해진 자리 — 선은 긋지 않고 간격 막대로만 보인다</summary>
|
||||||
|
Spacing = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>가이드선 1개 — 월드 좌표 선분</summary>
|
||||||
|
/// <param name="IsVertical">true = 세로선(X 정합), false = 가로선(Y 정합)</param>
|
||||||
|
/// <param name="Position">정합된 좌표 — 세로선이면 X</param>
|
||||||
|
/// <param name="Start">교차축 시작 — 세로선이면 Y 최소</param>
|
||||||
|
/// <param name="End">교차축 끝</param>
|
||||||
|
public readonly record struct SnapGuide(
|
||||||
|
bool IsVertical, double Position, double Start, double End, SnapGuideKind Kind);
|
||||||
|
|
||||||
|
/// <summary>간격 균등 표시 1개 — 빈틈 하나를 재는 막대(월드 좌표)</summary>
|
||||||
|
/// <param name="IsHorizontal">true = 가로 간격(막대가 수평)</param>
|
||||||
|
/// <param name="Start">주축 시작 — 가로면 X</param>
|
||||||
|
/// <param name="End">주축 끝</param>
|
||||||
|
/// <param name="Cross">교차축 위치 — 막대가 놓이는 선</param>
|
||||||
|
/// <param name="Gap">간격값 — 표시 텍스트용</param>
|
||||||
|
public readonly record struct SpacingMark(
|
||||||
|
bool IsHorizontal, double Start, double End, double Cross, double Gap);
|
||||||
|
|
||||||
|
/// <summary>스냅 계산 결과</summary>
|
||||||
|
/// <param name="SnappedX">X 가 정렬/간격으로 붙었는가 — 그리드로 떨어진 것은 false</param>
|
||||||
|
public readonly record struct SnapOutcome(
|
||||||
|
double Dx, double Dy, bool SnappedX, bool SnappedY,
|
||||||
|
IReadOnlyList<SnapGuide> Guides, IReadOnlyList<SpacingMark> Spacings)
|
||||||
|
{
|
||||||
|
public static SnapOutcome Free(double dx, double dy)
|
||||||
|
=> new(dx, dy, false, false, Array.Empty<SnapGuide>(), Array.Empty<SpacingMark>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>리사이즈 모서리 스냅 결과 — 선분 범위는 호출부가 최종 사각형을 확정한 뒤 조립한다</summary>
|
||||||
|
public readonly record struct SnapEdgeOutcome(
|
||||||
|
double Value, bool Snapped, IReadOnlyList<SnapRect> Matched, SnapGuideKind Kind)
|
||||||
|
{
|
||||||
|
public static SnapEdgeOutcome None(double value)
|
||||||
|
=> new(value, false, Array.Empty<SnapRect>(), SnapGuideKind.SiblingEdge);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>프레임 단위 스냅 파라미터 — 허용 오차는 줌에 따라 매 프레임 달라진다</summary>
|
||||||
|
/// <param name="Tolerance">월드 단위 허용 오차</param>
|
||||||
|
/// <param name="GridSize">정렬이 실패한 축의 폴백 격자(월드)</param>
|
||||||
|
public readonly record struct SnapOptions(double Tolerance, double GridSize, bool EnableSpacing)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 화면 px 허용 오차를 월드 허용 오차로.
|
||||||
|
///
|
||||||
|
/// 월드 고정이면 줌 50%에서 두 배로 끈적이고 200%에서 절반으로 미끄러진다 —
|
||||||
|
/// 손끝 감각은 화면 거리로 정해지므로 화면 기준이 맞다.
|
||||||
|
/// 줌은 0.25~3.0 으로 묶는다(디자이너가 허용하는 범위 밖 값이 들어와도 오차가 폭주하지 않게).
|
||||||
|
/// </summary>
|
||||||
|
public static SnapOptions ForZoom(double screenTolerance, double gridSize, double zoom, bool enableSpacing)
|
||||||
|
=> new(screenTolerance / Math.Clamp(zoom, 0.25, 3.0), gridSize, enableSpacing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>드래그 한 번 동안 고정되는 후보 집합 — 프레임마다 다시 모으지 않는다</summary>
|
||||||
|
public sealed class SnapContext
|
||||||
|
{
|
||||||
|
/// <summary>용지 사각형(월드)</summary>
|
||||||
|
public IReadOnlyList<SnapRect> Pages { get; init; } = Array.Empty<SnapRect>();
|
||||||
|
|
||||||
|
/// <summary>움직이지 않는 형제(월드) — 컨테이너 자식까지 평탄화해서 넘어온다</summary>
|
||||||
|
public IReadOnlyList<SnapRect> Siblings { get; init; } = Array.Empty<SnapRect>();
|
||||||
|
|
||||||
|
/// <summary>간격 균등 계산의 기준 용지 — 페이지를 넘는 '균등 간격'은 뜻이 없다</summary>
|
||||||
|
public SnapRect? SpacingPage { get; init; }
|
||||||
|
}
|
||||||
@@ -20,6 +20,9 @@ public partial class App : Application
|
|||||||
/// <summary>Dispatcher 예외 폭주 차단용 — 10초 내 5회면 강제 종료(무한 팝업 루프 방지)</summary>
|
/// <summary>Dispatcher 예외 폭주 차단용 — 10초 내 5회면 강제 종료(무한 팝업 루프 방지)</summary>
|
||||||
private readonly Queue<DateTime> recentCrashes = new();
|
private readonly Queue<DateTime> recentCrashes = new();
|
||||||
|
|
||||||
|
/// <summary>진단 플래그로 기동했는가 — 이 모드에서는 어떤 경로로도 모달을 띄우지 않는다</summary>
|
||||||
|
private bool diagnosticMode;
|
||||||
|
|
||||||
protected override void OnStartup(StartupEventArgs e)
|
protected override void OnStartup(StartupEventArgs e)
|
||||||
{
|
{
|
||||||
base.OnStartup(e);
|
base.OnStartup(e);
|
||||||
@@ -37,6 +40,13 @@ public partial class App : Application
|
|||||||
|
|
||||||
if (Services.StartupArguments.IsDiagnostic(e.Args))
|
if (Services.StartupArguments.IsDiagnostic(e.Args))
|
||||||
{
|
{
|
||||||
|
diagnosticMode = true;
|
||||||
|
// 기본 ShutdownMode 는 OnLastWindowClose 다. 창을 띄웠다 전부 닫는 진단
|
||||||
|
// (--maxrect, --edit-smoke, --dialog-shots …)은 마지막 창을 닫는 순간 WPF 가 먼저
|
||||||
|
// Shutdown(0) 을 걸어 버리고, 뒤이은 Shutdown(RunDiagnostic(...)) 인자는 무시된다 —
|
||||||
|
// 리포트에 '실패 3건' 이 찍혀도 프로세스는 0 을 돌려준다(실제로 그 상태였다).
|
||||||
|
// 스크립트가 종료코드로 판정하면 실패를 성공으로 읽는다.
|
||||||
|
ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
||||||
Services.UserSession.InitializeDiagnostic();
|
Services.UserSession.InitializeDiagnostic();
|
||||||
Shutdown(RunDiagnostic(e.Args));
|
Shutdown(RunDiagnostic(e.Args));
|
||||||
return;
|
return;
|
||||||
@@ -89,6 +99,17 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
var code = Services.AppLog.Error("처리되지 않은 예외(UI)", e.Exception);
|
var code = Services.AppLog.Error("처리되지 않은 예외(UI)", e.Exception);
|
||||||
|
|
||||||
|
// 진단 모드에서는 절대 모달을 띄우지 않는다 — 아래 MessageBox 는 사람이 누르기 전까지 돌아오지 않아
|
||||||
|
// 무인 실행이 타임아웃까지 매달린다. 이 핸들러가 진단 분기보다 먼저 등록되므로(로그를 남기려고)
|
||||||
|
// RunDiagnostic 의 "모달 금지" 규칙을 여기서도 지켜야 앞뒤가 맞는다.
|
||||||
|
if (diagnosticMode)
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
Console.Error.WriteLine($"진단 중 UI 예외 — 오류 코드: {code}");
|
||||||
|
Shutdown(3);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
recentCrashes.Enqueue(now);
|
recentCrashes.Enqueue(now);
|
||||||
while (recentCrashes.Count > 0 && (now - recentCrashes.Peek()).TotalSeconds > 10)
|
while (recentCrashes.Count > 0 && (now - recentCrashes.Peek()).TotalSeconds > 10)
|
||||||
@@ -249,6 +270,16 @@ public partial class App : Application
|
|||||||
return Diagnostics.DialogShots.Run(args[1]);
|
return Diagnostics.DialogShots.Run(args[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args.Length >= 2 && args[0] == "--snap-shots")
|
||||||
|
{
|
||||||
|
return Diagnostics.SnapShots.Run(args[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.Length >= 1 && args[0] == "--maxrect")
|
||||||
|
{
|
||||||
|
return Diagnostics.MaximizeCheck.Run(args.Length >= 2 ? args[1] : null);
|
||||||
|
}
|
||||||
|
|
||||||
// 진단 모드에서는 모달을 띄우지 않는다 — 스크립트 옵션 오타 하나로 무인 실행이 여기서
|
// 진단 모드에서는 모달을 띄우지 않는다 — 스크립트 옵션 오타 하나로 무인 실행이 여기서
|
||||||
// 영원히 멈춘다. WinExe 라 콘솔이 없어 stderr 는 호출자가 리다이렉트할 때만 보이므로
|
// 영원히 멈춘다. WinExe 라 콘솔이 없어 stderr 는 호출자가 리다이렉트할 때만 보이므로
|
||||||
// 로그를 함께 남긴다(--db-render 가 .err.txt 로 남기는 것과 같은 이유).
|
// 로그를 함께 남긴다(--db-render 가 .err.txt 로 남기는 것과 같은 이유).
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ public static class LucideIcons
|
|||||||
["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-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" />""",
|
||||||
["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-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" />""",
|
["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" />""",
|
||||||
|
["ellipsis-vertical"] = """<circle cx="12" cy="5" r="1" /> <circle cx="12" cy="12" r="1" /> <circle cx="12" cy="19" r="1" />""",
|
||||||
|
["file-text"] = """<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /> <path d="M14 2v5h5" /> <line x1="8" x2="16" y1="13" y2="13" /> <line x1="8" x2="16" y1="17" y2="17" />""",
|
||||||
["square"] = """<rect width="18" height="18" x="3" y="3" rx="2" />""",
|
["square"] = """<rect width="18" height="18" x="3" y="3" rx="2" />""",
|
||||||
["lock"] = """<rect width="18" height="11" x="3" y="11" rx="2" ry="2" /> <path d="M7 11V7a5 5 0 0 1 10 0v4" />""",
|
["lock"] = """<rect width="18" height="11" x="3" y="11" rx="2" ry="2" /> <path d="M7 11V7a5 5 0 0 1 10 0v4" />""",
|
||||||
["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" />""",
|
["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" />""",
|
||||||
|
|||||||
@@ -139,6 +139,12 @@ public static class DialogShots
|
|||||||
("12-inspector-behavior", () => HostInspector(designer, "TextBox1", InspectorTab.Behavior)),
|
("12-inspector-behavior", () => HostInspector(designer, "TextBox1", InspectorTab.Behavior)),
|
||||||
// 태그 제안이 타입별로 좁혀지는지 — 이미지는 서명·직인 6종만 떠야 한다
|
// 태그 제안이 타입별로 좁혀지는지 — 이미지는 서명·직인 6종만 떠야 한다
|
||||||
("12b-inspector-picture-data", () => HostInspector(designer, "MPictureBox1", InspectorTab.Data)),
|
("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(
|
("13-layer-type-filter", () => new Views.LayerTypeFilterDialog(
|
||||||
designer.LayerTypeCandidates(), new[] { "Label" })),
|
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>
|
/// <summary>화면 밖에 띄워 레이아웃을 돌린 뒤 PNG 로 찍고 닫는다</summary>
|
||||||
private static void Capture(Window window, string pngPath)
|
private static void Capture(Window window, string pngPath)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -216,21 +217,104 @@ public static class EditSmoke
|
|||||||
designer.DeleteSelection();
|
designer.DeleteSelection();
|
||||||
Check("자식 삭제", panelVm.Children.Count == 0 && panelVm.Model.Children.Count == 0);
|
Check("자식 삭제", panelVm.Children.Count == 0 && panelVm.Model.Children.Count == 0);
|
||||||
|
|
||||||
// 20) 그룹/해제 — 그룹 단위 클릭 선택
|
// 20) 그룹 — 이제 진짜 Panel 로 감싼다. 별도 문서에서 본다(메인 designer 의 최상위 구성이 바뀌면
|
||||||
designer.Selection.Set(new[] { Find("TextBox"), Find("Label") });
|
// 뒤따르는 Find("TextBox") 들이 전부 무너진다 — 실제로 그렇게 깨져서 분리했다).
|
||||||
designer.GroupSelection();
|
{
|
||||||
designer.Selection.Clear();
|
var grp = new DesignerViewModel(business.CreateNew());
|
||||||
var member = Find("TextBox");
|
ControlViewModel Put(string type, double x, double y, double w, double h)
|
||||||
var memberPoint = new Point(member.X + member.Width / 2, member.Y + member.Height / 2);
|
{
|
||||||
designer.Interaction.PointerDown(memberPoint, none);
|
grp.AddControlAt(type, new Point(x, y));
|
||||||
designer.Interaction.PointerUp(memberPoint, none);
|
var vm = grp.Pages[0].Controls[^1];
|
||||||
Check("그룹 클릭 → 그룹 전체 선택", designer.Selection.Items.Count == 2,
|
vm.X = x;
|
||||||
$"실제 {designer.Selection.Items.Count}");
|
vm.Y = y;
|
||||||
designer.UngroupSelection();
|
vm.Width = w;
|
||||||
designer.Selection.Clear();
|
vm.Height = h;
|
||||||
designer.Interaction.PointerDown(memberPoint, none);
|
return vm;
|
||||||
designer.Interaction.PointerUp(memberPoint, none);
|
}
|
||||||
Check("그룹 해제 후 단일 선택", designer.Selection.Items.Count == 1);
|
|
||||||
|
var a = Put("Label", 100, 100, 60, 20);
|
||||||
|
var b = Put("TextBox", 200, 140, 80, 24);
|
||||||
|
grp.Selection.Set(new List<ControlViewModel> { a, b }, a);
|
||||||
|
grp.GroupSelection();
|
||||||
|
|
||||||
|
var wrap = grp.Pages[0].Controls.OfType<ContainerViewModel>().FirstOrDefault(c => c.Type == "Panel");
|
||||||
|
Check("그룹: 패널이 생긴다", wrap is not null);
|
||||||
|
Check("그룹: 멤버가 패널 자식이 된다",
|
||||||
|
wrap is not null && wrap.Children.Count == 2
|
||||||
|
&& !grp.Pages[0].Controls.Contains(a) && !grp.Pages[0].Controls.Contains(b),
|
||||||
|
$"자식 {wrap?.Children.Count} 최상위 {grp.Pages[0].Controls.Count}");
|
||||||
|
Check("그룹: 패널이 멤버 bbox 를 덮는다",
|
||||||
|
wrap is not null && wrap.X == 100 && wrap.Y == 100
|
||||||
|
&& wrap.Width == 180 && wrap.Height == 64,
|
||||||
|
$"실제 ({wrap?.X},{wrap?.Y},{wrap?.Width},{wrap?.Height})");
|
||||||
|
Check("그룹: 자식 좌표가 부모 상대로 바뀐다",
|
||||||
|
wrap is not null && wrap.Children.Any(c => c.X == 0 && c.Y == 0)
|
||||||
|
&& wrap.Children.Any(c => c.X == 100 && c.Y == 40),
|
||||||
|
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.UngroupSelection();
|
||||||
|
Check("그룹 풀기: 최상위로 돌아온다",
|
||||||
|
grp.Pages[0].Controls.Count == 2
|
||||||
|
&& !grp.Pages[0].Controls.OfType<ContainerViewModel>().Any(),
|
||||||
|
$"최상위 {grp.Pages[0].Controls.Count}");
|
||||||
|
Check("그룹 풀기: 좌표가 절대값으로 복원된다",
|
||||||
|
grp.Pages[0].Controls.Any(c => c.X == 140 && c.Y == 100)
|
||||||
|
&& grp.Pages[0].Controls.Any(c => c.X == 240 && c.Y == 140),
|
||||||
|
string.Join(" ", grp.Pages[0].Controls.Select(c => $"({c.X},{c.Y})")));
|
||||||
|
|
||||||
|
// 가드 ① 라디오는 부모가 곧 배타 단위 — 일부만 묶으면 '택1'이 갈라진다
|
||||||
|
var radioDoc = new DesignerViewModel(business.CreateNew());
|
||||||
|
radioDoc.AddControlAt("RadioButton", new Point(100, 100));
|
||||||
|
radioDoc.AddControlAt("RadioButton", new Point(100, 140));
|
||||||
|
radioDoc.Selection.Set(radioDoc.Pages[0].Controls.ToList(), radioDoc.Pages[0].Controls[0]);
|
||||||
|
var beforeRadio = radioDoc.Pages[0].Controls.Count;
|
||||||
|
radioDoc.GroupSelection();
|
||||||
|
Check("그룹 가드: 라디오는 묶이지 않는다",
|
||||||
|
radioDoc.Pages[0].Controls.Count == beforeRadio
|
||||||
|
&& !radioDoc.Pages[0].Controls.Any(c => c.Type == "Panel"));
|
||||||
|
|
||||||
|
// 가드 ② 사이에 남이 끼어 있으면 쌓임 순서가 바뀐다
|
||||||
|
var zDoc = new DesignerViewModel(business.CreateNew());
|
||||||
|
zDoc.AddControlAt("Label", new Point(100, 100));
|
||||||
|
zDoc.AddControlAt("Label", new Point(120, 120));
|
||||||
|
zDoc.AddControlAt("Label", new Point(140, 140));
|
||||||
|
var zTop = zDoc.Pages[0].Controls[0];
|
||||||
|
var zBottom = zDoc.Pages[0].Controls[2];
|
||||||
|
zDoc.Selection.Set(new List<ControlViewModel> { zTop, zBottom }, zTop);
|
||||||
|
var beforeZ = zDoc.Pages[0].Controls.Count;
|
||||||
|
zDoc.GroupSelection();
|
||||||
|
Check("그룹 가드: 사이에 남이 끼면 묶지 않는다",
|
||||||
|
zDoc.Pages[0].Controls.Count == beforeZ
|
||||||
|
&& !zDoc.Pages[0].Controls.Any(c => c.Type == "Panel"));
|
||||||
|
}
|
||||||
|
|
||||||
// 20-1) 인라인 텍스트 편집 — 더블클릭 요청 이벤트 + 커밋
|
// 20-1) 인라인 텍스트 편집 — 더블클릭 요청 이벤트 + 커밋
|
||||||
var inlineTarget = Find("Label");
|
var inlineTarget = Find("Label");
|
||||||
@@ -278,29 +362,35 @@ public static class EditSmoke
|
|||||||
lay.Selection.Set(new[] { Pick("TextBox"), Pick("Label") });
|
lay.Selection.Set(new[] { Pick("TextBox"), Pick("Label") });
|
||||||
lay.GroupSelection();
|
lay.GroupSelection();
|
||||||
|
|
||||||
var groupRow = lay.LayerRows.OfType<LayerGroupViewModel>().FirstOrDefault();
|
// 그룹은 이제 진짜 패널이라 폴더 행(LayerGroupViewModel)이 아니라 컨테이너 행으로 나온다.
|
||||||
Check("레이어: 그룹 폴더 행 1개", lay.LayerRows.OfType<LayerGroupViewModel>().Count() == 1);
|
// 폴더 행은 GroupId 시절의 표현이었고, 그 시절 그룹은 저장하면 사라졌다.
|
||||||
Check("레이어: 폴더 멤버 2 + 기본 이름",
|
var wrapRow = lay.LayerRows.OfType<ContainerViewModel>().FirstOrDefault(c => c.Type == "Panel");
|
||||||
groupRow is { Members.Count: 2 } && groupRow.Name.StartsWith("그룹"),
|
Check("레이어: 그룹이 패널 행으로 나온다", wrapRow is not null);
|
||||||
$"실제 {groupRow?.Members.Count} / {groupRow?.Name}");
|
Check("레이어: 패널 행 아래 멤버 2",
|
||||||
|
wrapRow is not null && wrapRow.Children.Count == 2,
|
||||||
|
$"실제 {wrapRow?.Children.Count}");
|
||||||
|
|
||||||
// 아웃라인은 페이지 헤더로 시작하고 '페이지 추가' 행으로 끝난다
|
// 아웃라인은 페이지 헤더로 시작하고 '페이지 추가' 행으로 끝난다
|
||||||
Check("레이어: 첫 행은 페이지 헤더", lay.LayerRows.FirstOrDefault() is LayerPageViewModel);
|
// 페이지는 별도 구획으로 나갔다 — 한 장짜리 문서의 트리에는 페이지 마디가 없다
|
||||||
Check("레이어: 마지막 행은 페이지 추가", lay.LayerRows.LastOrDefault() is LayerAddPageViewModel);
|
Check("레이어: 페이지 구획이 페이지를 담는다",
|
||||||
|
lay.PageRows.Count == lay.Pages.Count && lay.PageRows.Count == 1);
|
||||||
|
Check("레이어: 한 장이면 트리에 페이지 마디가 없다",
|
||||||
|
!lay.ShowPageNodes && !lay.LayerRows.OfType<LayerPageViewModel>().Any());
|
||||||
|
Check("레이어: 첫 행이 곧 컨트롤·그룹이다", lay.LayerRows.FirstOrDefault() is not LayerPageViewModel);
|
||||||
Check("레이어: 페이지 헤더는 선택 대상 아님",
|
Check("레이어: 페이지 헤더는 선택 대상 아님",
|
||||||
lay.LayerRows.OfType<ILayerRow>().Where(r => r is LayerPageViewModel or LayerAddPageViewModel)
|
lay.LayerRows.OfType<ILayerRow>().Where(r => r is LayerPageViewModel)
|
||||||
.All(r => !r.IsSelectableRow));
|
.All(r => !r.IsSelectableRow));
|
||||||
|
|
||||||
// 폴더 바로 뒤에 멤버가 들여쓰기된 채 이어져야 한다(평탄화 시퀀스)
|
// 패널 바로 뒤에 자식이 들여쓰기된 채 이어져야 한다(평탄화 시퀀스)
|
||||||
var folderIndex = lay.LayerRows.IndexOf(groupRow!);
|
var folderIndex = wrapRow is null ? -1 : lay.LayerRows.IndexOf(wrapRow);
|
||||||
Check("레이어: 폴더 뒤에 멤버가 들여쓰여 이어짐",
|
Check("레이어: 패널 뒤에 자식이 들여쓰여 이어짐",
|
||||||
folderIndex >= 0
|
folderIndex >= 0
|
||||||
&& lay.LayerRows.Skip(folderIndex + 1).Take(2)
|
&& lay.LayerRows.Skip(folderIndex + 1).Take(2)
|
||||||
.OfType<ControlViewModel>().Count(c => c.LayerIndent == 32) == 2);
|
.OfType<ControlViewModel>().Count(c => c.LayerIndent > 0) == 2);
|
||||||
Check("레이어: 페이지 직속 컨트롤 들여쓰기 12",
|
Check("레이어: 한 장이면 최상위 들여쓰기가 0",
|
||||||
lay.LayerRows.OfType<ControlViewModel>()
|
lay.LayerRows.OfType<ControlViewModel>()
|
||||||
.Where(c => c.Model.GroupId is null && c.Parent is null)
|
.Where(c => c.Model.GroupId is null && c.Parent is null)
|
||||||
.All(c => c.LayerIndent == 12));
|
.All(c => c.LayerIndent == 0));
|
||||||
|
|
||||||
// 목록 맨 위 = z-order 최상위 (모델 리스트 index 0)
|
// 목록 맨 위 = z-order 최상위 (모델 리스트 index 0)
|
||||||
var topControl = lay.LayerRows.OfType<ControlViewModel>().First();
|
var topControl = lay.LayerRows.OfType<ControlViewModel>().First();
|
||||||
@@ -308,33 +398,9 @@ public static class EditSmoke
|
|||||||
ReferenceEquals(lay.Document.Pages[0].Controls[0], topControl.Model),
|
ReferenceEquals(lay.Document.Pages[0].Controls[0], topControl.Model),
|
||||||
$"실제 맨 위 {topControl.Id} / 모델 첫째 {lay.Document.Pages[0].Controls[0].Id}");
|
$"실제 맨 위 {topControl.Id} / 모델 첫째 {lay.Document.Pages[0].Controls[0].Id}");
|
||||||
|
|
||||||
// 접으면 멤버 행이 빠지고, 펴면 되돌아온다
|
|
||||||
var expandedCount = lay.LayerRows.Count;
|
|
||||||
groupRow!.IsExpanded = false;
|
|
||||||
lay.RebuildLayerRows();
|
|
||||||
Check("레이어: 접으면 멤버 행 제거", lay.LayerRows.Count == expandedCount - 2,
|
|
||||||
$"실제 {lay.LayerRows.Count} 기대 {expandedCount - 2}");
|
|
||||||
groupRow = lay.LayerRows.OfType<LayerGroupViewModel>().First();
|
|
||||||
groupRow.IsExpanded = true;
|
|
||||||
lay.RebuildLayerRows();
|
|
||||||
Check("레이어: 펴면 멤버 행 복귀", lay.LayerRows.Count == expandedCount);
|
|
||||||
|
|
||||||
// 그룹 일괄 숨김이 전 멤버에 퍼진다
|
|
||||||
groupRow = lay.LayerRows.OfType<LayerGroupViewModel>().First();
|
|
||||||
groupRow.IsHiddenFlag = true;
|
|
||||||
Check("레이어: 그룹 숨김 → 전 멤버", groupRow.Members.All(m => m.IsHiddenFlag));
|
|
||||||
groupRow.IsHiddenFlag = false;
|
|
||||||
Check("레이어: 그룹 표시 복귀", groupRow.Members.All(m => !m.IsHiddenFlag));
|
|
||||||
|
|
||||||
// 그룹 이름은 문서가 소유 — 비우면 기본값으로 되돌아간다
|
|
||||||
groupRow.Name = "머리글 묶음";
|
|
||||||
Check("레이어: 그룹 개명", lay.GroupNameOf(groupRow.GroupId) == "머리글 묶음");
|
|
||||||
groupRow.Name = string.Empty;
|
|
||||||
Check("레이어: 이름 비우면 기본값", lay.GroupNameOf(groupRow.GroupId) == "그룹");
|
|
||||||
|
|
||||||
// 컨테이너 자식이 계층으로 보이고, 접으면 목록에서 빠진다
|
// 컨테이너 자식이 계층으로 보이고, 접으면 목록에서 빠진다
|
||||||
lay.AddControlAt("Panel", new Point(300, 600));
|
lay.AddControlAt("Panel", new Point(300, 600));
|
||||||
var layerPanel = lay.Pages[0].Controls.OfType<PanelViewModel>().First();
|
var layerPanel = lay.Pages[0].Controls.OfType<PanelViewModel>().Last();
|
||||||
lay.AddControlAt("Label", new Point(layerPanel.X + 20, layerPanel.Y + 20));
|
lay.AddControlAt("Label", new Point(layerPanel.X + 20, layerPanel.Y + 20));
|
||||||
lay.RebuildLayerRows();
|
lay.RebuildLayerRows();
|
||||||
Check("레이어: 컨테이너 자식이 목록에 나옴",
|
Check("레이어: 컨테이너 자식이 목록에 나옴",
|
||||||
@@ -369,47 +435,17 @@ public static class EditSmoke
|
|||||||
lay.SetLayerTypeFilter(Array.Empty<string>());
|
lay.SetLayerTypeFilter(Array.Empty<string>());
|
||||||
Check("레이어: 타입 필터 해제", !lay.HasLayerTypeFilter);
|
Check("레이어: 타입 필터 해제", !lay.HasLayerTypeFilter);
|
||||||
|
|
||||||
// 붙여넣은 복제본은 원본 그룹에 끼지 않는다(GroupId 재매핑)
|
// 패널 자식을 복사해 붙이면 최상위로 나온다 — 원본 패널에 끼면 안 된다
|
||||||
groupRow = lay.LayerRows.OfType<LayerGroupViewModel>().First();
|
var paneled = lay.LayerRows.OfType<ContainerViewModel>().FirstOrDefault(c => c.Type == "Panel");
|
||||||
var sourceGroupId = groupRow.Members[0].Model.GroupId;
|
if (paneled is { Children.Count: > 0 })
|
||||||
lay.Selection.SetSingle(groupRow.Members[0]);
|
{
|
||||||
lay.CopySelection();
|
lay.Selection.SetSingle(paneled.Children[0]);
|
||||||
lay.Paste();
|
lay.CopySelection();
|
||||||
Check("레이어: 붙여넣기가 원본 그룹에 끼지 않음",
|
lay.Paste();
|
||||||
lay.Selection.Items[0].Model.GroupId != sourceGroupId,
|
Check("레이어: 패널 자식 붙여넣기는 원본 패널에 끼지 않음",
|
||||||
$"원본 {sourceGroupId} 복제 {lay.Selection.Items[0].Model.GroupId}");
|
lay.Selection.Items.Count > 0 && !ReferenceEquals(lay.Selection.Items[0].Parent, paneled));
|
||||||
|
}
|
||||||
|
|
||||||
// 멤버가 1개로 줄면 그룹은 자동 해제되고 이름도 정리된다
|
|
||||||
groupRow = lay.LayerRows.OfType<LayerGroupViewModel>().First();
|
|
||||||
lay.Selection.SetSingle(groupRow.Members[1]);
|
|
||||||
lay.DeleteSelection();
|
|
||||||
Check("레이어: 멤버 1개 남으면 그룹 자동 해제",
|
|
||||||
!lay.LayerRows.OfType<LayerGroupViewModel>().Any()
|
|
||||||
&& lay.Pages[0].Controls.All(c => c.Model.GroupId is null)
|
|
||||||
&& lay.Document.GroupNames.Count == 0);
|
|
||||||
|
|
||||||
// 그룹은 편집 전용 상태다 — 저장 XML 에 흔적이 남으면 왕복 불변식이 깨진다
|
|
||||||
lay.Selection.Set(lay.Pages[0].Controls.Take(2).ToList());
|
|
||||||
lay.GroupSelection();
|
|
||||||
Check("레이어: 그룹은 XML 에 나가지 않음",
|
|
||||||
!serializer.Write(lay.Document).Contains("GroupId", StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
// 강조 3상태 — 그룹 전체 선택이면 폴더=Blue, 멤버=Navy 이고 연속 블록으로 이어진다
|
|
||||||
groupRow = lay.LayerRows.OfType<LayerGroupViewModel>().First();
|
|
||||||
lay.Selection.Set(groupRow.Members.ToList(), groupRow.Members[0]);
|
|
||||||
lay.UpdateLayerHighlight();
|
|
||||||
Check("레이어: 그룹 전원 선택 → 폴더 Blue", groupRow.Highlight == LayerHighlight.Blue,
|
|
||||||
$"실제 {groupRow.Highlight}");
|
|
||||||
Check("레이어: 그룹 전원 선택 → 멤버 Navy",
|
|
||||||
groupRow.Members.All(m => m.Highlight == LayerHighlight.Navy));
|
|
||||||
Check("레이어: 폴더와 멤버가 이어진 블록",
|
|
||||||
groupRow.MergeBottom && groupRow.Members[0].MergeTop);
|
|
||||||
|
|
||||||
lay.Selection.SetSingle(groupRow.Members[0]);
|
|
||||||
lay.UpdateLayerHighlight();
|
|
||||||
Check("레이어: 멤버 하나만 선택 → 그 행만 Blue, 폴더 무강조",
|
|
||||||
groupRow.Members[0].Highlight == LayerHighlight.Blue
|
|
||||||
&& groupRow.Highlight == LayerHighlight.None);
|
|
||||||
|
|
||||||
// 강조 계산이 페이지 헤더에서 끊긴다(블록이 페이지를 넘지 않는다)
|
// 강조 계산이 페이지 헤더에서 끊긴다(블록이 페이지를 넘지 않는다)
|
||||||
Check("레이어: 페이지 헤더는 항상 무강조",
|
Check("레이어: 페이지 헤더는 항상 무강조",
|
||||||
@@ -493,7 +529,7 @@ public static class EditSmoke
|
|||||||
{
|
{
|
||||||
var pg = new DesignerViewModel(business.CreateNew());
|
var pg = new DesignerViewModel(business.CreateNew());
|
||||||
pg.AddControlAt("Label", new Point(100, 100));
|
pg.AddControlAt("Label", new Point(100, 100));
|
||||||
var pageRow = pg.LayerRows.OfType<LayerPageViewModel>().First();
|
var pageRow = pg.PageRows[0];
|
||||||
|
|
||||||
pg.ActivatePage(pageRow.Page);
|
pg.ActivatePage(pageRow.Page);
|
||||||
Check("페이지 속성: 페이지 모드 진입", pg.InspectedPage is not null && pg.Selection.Items.Count == 0);
|
Check("페이지 속성: 페이지 모드 진입", pg.InspectedPage is not null && pg.Selection.Items.Count == 0);
|
||||||
@@ -1093,6 +1129,262 @@ public static class EditSmoke
|
|||||||
dirtyDoc.Undo.Undo();
|
dirtyDoc.Undo.Undo();
|
||||||
Check("더티: 분기 후에는 저장 지점 소실로 계속 dirty", dirtyDoc.Undo.IsDirty);
|
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}");
|
||||||
|
|
||||||
|
// 기준 전환 — 여럿을 골라 놓고 '서로 맞추기'와 '종이에 맞추기'가 갈린다
|
||||||
|
var a = Put(300, 500, 60, 20);
|
||||||
|
var b = Put(340, 540, 60, 20);
|
||||||
|
alignDoc.Selection.Set(new List<ControlViewModel> { a, b }, a);
|
||||||
|
|
||||||
|
alignDoc.AlignToPage = false;
|
||||||
|
alignDoc.Align("Left");
|
||||||
|
Check("정렬: 여럿을 서로 맞추면 선택 영역 왼쪽", a.X == 300 && b.X == 300,
|
||||||
|
$"실제 {a.X} / {b.X}");
|
||||||
|
|
||||||
|
alignDoc.AlignToPage = true;
|
||||||
|
alignDoc.Align("Left");
|
||||||
|
Check("정렬: 기준을 페이지로 바꾸면 종이 왼쪽", a.X == 0 && b.X == 0, $"실제 {a.X} / {b.X}");
|
||||||
|
|
||||||
|
alignDoc.Align("Right");
|
||||||
|
Check("정렬: 페이지 기준 오른쪽", a.X == page0.WidthDip - a.Width && b.X == page0.WidthDip - b.Width,
|
||||||
|
$"실제 {a.X} / {b.X}");
|
||||||
|
alignDoc.AlignToPage = false;
|
||||||
|
|
||||||
|
// 컨테이너 자식은 페이지가 아니라 부모가 기준 — 페이지 기준이면 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).
|
||||||
|
{
|
||||||
|
var snapDoc = new DesignerViewModel(business.CreateNew());
|
||||||
|
ControlViewModel Place(DesignerViewModel doc, string type, double x, double y, double w, double h)
|
||||||
|
{
|
||||||
|
doc.AddControlAt(type, new Point(x, y));
|
||||||
|
var vm = doc.Pages[0].Controls[^1];
|
||||||
|
vm.X = x;
|
||||||
|
vm.Y = y;
|
||||||
|
vm.Width = w;
|
||||||
|
vm.Height = h;
|
||||||
|
return vm;
|
||||||
|
}
|
||||||
|
static Point Middle(ControlViewModel vm) => new(vm.X + vm.Width / 2, vm.Y + vm.Height / 2);
|
||||||
|
|
||||||
|
// 형제 둘을 같은 좌변에 세로로 벌려 둔다 — 가이드 병합과 선분 범위를 함께 본다
|
||||||
|
var anchorTop = Place(snapDoc, "Label", 200, 100, 60, 20);
|
||||||
|
var anchorBottom = Place(snapDoc, "Label", 200, 400, 60, 20);
|
||||||
|
// 폭을 형제와 다르게 둔다 — 같으면 좌·중·우가 동시에 맞아 가이드가 3개 나온다
|
||||||
|
// (그것도 옳은 동작이지만 여기서 보려는 것은 '같은 좌표의 형제 둘이 하나로 합쳐지는가'다)
|
||||||
|
var mover = Place(snapDoc, "Label", 197, 250, 40, 20);
|
||||||
|
|
||||||
|
var grab = Middle(mover);
|
||||||
|
snapDoc.Interaction.PointerDown(grab, none);
|
||||||
|
snapDoc.Interaction.PointerMove(new Point(grab.X + 3, grab.Y), none);
|
||||||
|
Check("스냅: 형제 좌변에 붙는다", mover.X == anchorTop.X, $"실제 {mover.X}");
|
||||||
|
Check("스냅: 같은 좌표 형제 둘이 가이드 하나로 병합된다", snapDoc.Overlay.Guides.Count == 1,
|
||||||
|
$"실제 {snapDoc.Overlay.Guides.Count}");
|
||||||
|
var guide = snapDoc.Overlay.Guides.Count > 0 ? snapDoc.Overlay.Guides[0] : null;
|
||||||
|
Check("스냅: 가이드가 무한선이 아니라 선분이다",
|
||||||
|
guide is not null && guide.IsVertical && guide.Y2 - guide.Y1 < 1000,
|
||||||
|
guide is null ? "가이드 없음" : $"길이 {guide.Y2 - guide.Y1}");
|
||||||
|
Check("스냅: 선분이 관련된 형제 전부를 덮는다",
|
||||||
|
guide is not null && guide.Y1 <= anchorTop.Y && guide.Y2 >= anchorBottom.Y + anchorBottom.Height,
|
||||||
|
guide is null ? "가이드 없음" : $"{guide.Y1}~{guide.Y2}");
|
||||||
|
snapDoc.Interaction.PointerUp(new Point(grab.X + 3, grab.Y), none);
|
||||||
|
Check("스냅: 놓으면 표시가 사라진다",
|
||||||
|
snapDoc.Overlay.Guides.Count == 0 && snapDoc.Overlay.Spacings.Count == 0);
|
||||||
|
|
||||||
|
// Alt 는 격자까지 전부 끈다
|
||||||
|
var freeStart = Middle(mover);
|
||||||
|
var beforeX = mover.X;
|
||||||
|
snapDoc.Interaction.PointerDown(freeStart, none);
|
||||||
|
snapDoc.Interaction.PointerMove(new Point(freeStart.X + 3, freeStart.Y), alt);
|
||||||
|
Check("스냅: Alt 는 가이드를 내지 않는다", snapDoc.Overlay.Guides.Count == 0);
|
||||||
|
Check("스냅: Alt 는 정확히 그만큼 옮긴다", mover.X == beforeX + 3, $"실제 {mover.X}");
|
||||||
|
snapDoc.Interaction.PointerUp(new Point(freeStart.X + 3, freeStart.Y), alt);
|
||||||
|
|
||||||
|
// 간격 균등 — 30 간격 둘 뒤에 이어 붙는다
|
||||||
|
var spacingDoc = new DesignerViewModel(business.CreateNew());
|
||||||
|
Place(spacingDoc, "Label", 100, 600, 50, 20);
|
||||||
|
Place(spacingDoc, "Label", 180, 600, 50, 20); // 간격 30
|
||||||
|
var third = Place(spacingDoc, "Label", 257, 600, 40, 20); // 260 이면 균등
|
||||||
|
|
||||||
|
var spacingGrab = Middle(third);
|
||||||
|
spacingDoc.Interaction.PointerDown(spacingGrab, none);
|
||||||
|
spacingDoc.Interaction.PointerMove(new Point(spacingGrab.X + 3, spacingGrab.Y), none);
|
||||||
|
Check("스냅: 같은 간격으로 이어 붙는다", third.X == 260, $"실제 {third.X}");
|
||||||
|
Check("스냅: 간격 막대가 보인다", spacingDoc.Overlay.Spacings.Count >= 1,
|
||||||
|
$"실제 {spacingDoc.Overlay.Spacings.Count}");
|
||||||
|
spacingDoc.Interaction.PointerUp(new Point(spacingGrab.X + 3, spacingGrab.Y), none);
|
||||||
|
|
||||||
|
// 컨테이너 자식도 후보가 된다 / 컨테이너는 제 자식에게 붙지 않는다
|
||||||
|
var nestDoc = new DesignerViewModel(business.CreateNew());
|
||||||
|
var nestPanel = Place(nestDoc, "Panel", 100, 100, 300, 200) as ContainerViewModel;
|
||||||
|
if (nestPanel is not null)
|
||||||
|
{
|
||||||
|
nestDoc.Selection.SetSingle(nestPanel);
|
||||||
|
nestDoc.AddControlAt("Label", new Point(150, 150));
|
||||||
|
var nestChild = nestPanel.Children.Count > 0 ? nestPanel.Children[^1] : null;
|
||||||
|
if (nestChild is not null)
|
||||||
|
{
|
||||||
|
nestChild.X = 50; // 월드 150
|
||||||
|
nestChild.Y = 50;
|
||||||
|
nestChild.Width = 60;
|
||||||
|
nestChild.Height = 20;
|
||||||
|
|
||||||
|
var outsider = Place(nestDoc, "Label", 147, 500, 60, 20);
|
||||||
|
nestDoc.Selection.SetSingle(outsider);
|
||||||
|
var outGrab = Middle(outsider);
|
||||||
|
nestDoc.Interaction.PointerDown(outGrab, none);
|
||||||
|
nestDoc.Interaction.PointerMove(new Point(outGrab.X + 3, outGrab.Y), none);
|
||||||
|
Check("스냅: 컨테이너 자식도 후보가 된다", outsider.X == 150, $"실제 {outsider.X}");
|
||||||
|
nestDoc.Interaction.PointerUp(new Point(outGrab.X + 3, outGrab.Y), none);
|
||||||
|
|
||||||
|
var panelBefore = nestPanel.X;
|
||||||
|
var panelGrab = new Point(nestPanel.X + 5, nestPanel.Y + 5);
|
||||||
|
nestDoc.Selection.SetSingle(nestPanel);
|
||||||
|
nestDoc.Interaction.PointerDown(panelGrab, none);
|
||||||
|
nestDoc.Interaction.PointerMove(new Point(panelGrab.X + 40, panelGrab.Y), none);
|
||||||
|
Check("스냅: 컨테이너는 제 자식에게 붙지 않는다", nestPanel.X != panelBefore,
|
||||||
|
$"실제 {nestPanel.X} (시작 {panelBefore})");
|
||||||
|
nestDoc.Interaction.PointerUp(new Point(panelGrab.X + 40, panelGrab.Y), none);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 리사이즈 — 이동 드래그를 한 번도 안 한 상태에서 바로 핸들을 잡아도 붙어야 한다
|
||||||
|
var resizeDoc = new DesignerViewModel(business.CreateNew());
|
||||||
|
var wall = Place(resizeDoc, "Label", 300, 100, 60, 200);
|
||||||
|
var box = Place(resizeDoc, "Label", 100, 150, 100, 40);
|
||||||
|
resizeDoc.Selection.SetSingle(box);
|
||||||
|
resizeDoc.RefreshOverlay();
|
||||||
|
|
||||||
|
var eastHandle = new Point(box.X + box.Width, box.Y + box.Height / 2);
|
||||||
|
resizeDoc.Interaction.PointerDown(eastHandle, none);
|
||||||
|
resizeDoc.Interaction.PointerMove(new Point(298, eastHandle.Y), none);
|
||||||
|
Check("스냅: 리사이즈가 첫 드래그부터 형제 모서리에 붙는다",
|
||||||
|
box.X + box.Width == wall.X, $"실제 우변 {box.X + box.Width}");
|
||||||
|
Check("스냅: 리사이즈에도 가이드가 보인다", resizeDoc.Overlay.Guides.Count >= 1,
|
||||||
|
$"실제 {resizeDoc.Overlay.Guides.Count}");
|
||||||
|
resizeDoc.Interaction.PointerUp(new Point(298, eastHandle.Y), none);
|
||||||
|
Check("스냅: 리사이즈 후에도 표시가 사라진다", resizeDoc.Overlay.Guides.Count == 0);
|
||||||
|
}
|
||||||
|
|
||||||
// 25) 연속 Undo 로 빈 문서까지
|
// 25) 연속 Undo 로 빈 문서까지
|
||||||
var guard = 0;
|
var guard = 0;
|
||||||
while (designer.Undo.CanUndo && guard++ < 80)
|
while (designer.Undo.CanUndo && guard++ < 80)
|
||||||
|
|||||||
@@ -0,0 +1,390 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Interop;
|
||||||
|
using System.Windows.Shell;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
|
||||||
|
namespace SheetMe.Designer.Diagnostics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 최대화 사각형 자동 검증 — <c>--maxrect [출력파일]</c>.
|
||||||
|
///
|
||||||
|
/// <b>무엇을 재는가.</b> <see cref="Services.MaximizeToWorkArea"/> 를 건 창에
|
||||||
|
/// ① <c>WM_GETMINMAXINFO</c> 를 직접 보내 답을 뜯어보고,
|
||||||
|
/// ② 실제로 창을 띄워 최대화해 <b>OS 가 그 답을 지켰는지</b> 창 사각형으로 확인한다.
|
||||||
|
///
|
||||||
|
/// <b>기대값을 전사(轉寫)하지 않는다.</b> 구현의 계산을 이 파일에 베껴 적으면 같은 착각을 양쪽이
|
||||||
|
/// 공유해 아무것도 검증하지 못하고, 구현이 조금만 달라져도(예: 양보 폭 1→2) 멀쩡한 동작에서
|
||||||
|
/// 거짓 실패가 난다. 대신 <b>성질</b>을 단언한다 — 모니터 안에 들어가는가, 자동 숨김이 없는 변은
|
||||||
|
/// 작업영역과 같은가, 있는 변은 안쪽으로 물러섰는가, 작업표시줄과 겹치지 않는가.
|
||||||
|
///
|
||||||
|
/// <b>화면이 한 번 번쩍인다.</b> ⑧⑨ 는 창을 실제로 띄워 최대화·복원한다 — 그 경로에서만
|
||||||
|
/// StateChanged 배선과 OS 의 실제 동작을 잡을 수 있다. 진단을 사람이 직접 돌리므로 감수한다.
|
||||||
|
///
|
||||||
|
/// <b>재지 못하는 것.</b> 자동 숨김 작업표시줄이 정말 다시 튀어나오는지, 다른 배율·다중 모니터에서
|
||||||
|
/// 어떤지는 그 환경에서 이 진단을 돌려 봐야 안다.
|
||||||
|
/// </summary>
|
||||||
|
public static class MaximizeCheck
|
||||||
|
{
|
||||||
|
#region Member Fields
|
||||||
|
private const int WmGetMinMaxInfo = 0x0024;
|
||||||
|
private const uint MonitorinfofPrimary = 0x00000001;
|
||||||
|
private const uint AbmGetState = 0x00000004;
|
||||||
|
private const uint AbmGetTaskbarPos = 0x00000005;
|
||||||
|
private const uint AbmGetAutoHideBarEx = 0x0000000B;
|
||||||
|
private const int AbsAutoHide = 0x00000001;
|
||||||
|
|
||||||
|
private const int ProbeMinWidth = 940;
|
||||||
|
private const int ProbeMinHeight = 640;
|
||||||
|
private const double ProbeBorder = 6;
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct RECT
|
||||||
|
{
|
||||||
|
public int Left;
|
||||||
|
public int Top;
|
||||||
|
public int Right;
|
||||||
|
public int Bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct POINT
|
||||||
|
{
|
||||||
|
public int X;
|
||||||
|
public int Y;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct MONITORINFO
|
||||||
|
{
|
||||||
|
public int cbSize;
|
||||||
|
public RECT rcMonitor;
|
||||||
|
public RECT rcWork;
|
||||||
|
public uint dwFlags;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct APPBARDATA
|
||||||
|
{
|
||||||
|
public uint cbSize;
|
||||||
|
public IntPtr hWnd;
|
||||||
|
public uint uCallbackMessage;
|
||||||
|
public uint uEdge;
|
||||||
|
public RECT rc;
|
||||||
|
public IntPtr lParam;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct MINMAXINFO
|
||||||
|
{
|
||||||
|
public POINT ptReserved;
|
||||||
|
public POINT ptMaxSize;
|
||||||
|
public POINT ptMaxPosition;
|
||||||
|
public POINT ptMinTrackSize;
|
||||||
|
public POINT ptMaxTrackSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>훅이 아무것도 안 써도 검사가 통과하지 않도록 보내기 전에 채워 두는 값</summary>
|
||||||
|
private static readonly MINMAXINFO Sentinel = new()
|
||||||
|
{
|
||||||
|
ptMaxSize = new POINT { X = 12345, Y = 23456 },
|
||||||
|
ptMaxPosition = new POINT { X = -4321, Y = -8765 },
|
||||||
|
};
|
||||||
|
|
||||||
|
private delegate bool MonitorEnumProc(IntPtr monitor, IntPtr dc, ref RECT rect, IntPtr data);
|
||||||
|
|
||||||
|
private sealed record MonitorFacts(string Name, RECT Bounds, RECT Work, bool Primary, bool[] AutoHide);
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Methods
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern bool EnumDisplayMonitors(IntPtr dc, IntPtr clip, MonitorEnumProc callback, IntPtr data);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool GetMonitorInfo(IntPtr monitor, ref MONITORINFO info);
|
||||||
|
|
||||||
|
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern IntPtr SHAppBarMessage(uint message, ref APPBARDATA data);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern IntPtr SendMessage(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern bool SetWindowPos(IntPtr hwnd, IntPtr after, int x, int y, int cx, int cy, uint flags);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern bool GetWindowRect(IntPtr hwnd, out RECT rect);
|
||||||
|
|
||||||
|
public static int Run(string? outputPath)
|
||||||
|
{
|
||||||
|
var report = new StringBuilder();
|
||||||
|
int failures;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
failures = Measure(report);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// 꼬리에서 터져도 여기까지 모은 리포트는 남긴다 — 원인을 찾는 유일한 단서다
|
||||||
|
report.AppendLine($"진단 자체가 예외로 중단됨 — {ex.GetType().Name}: {ex.Message}");
|
||||||
|
failures = -1;
|
||||||
|
}
|
||||||
|
report.AppendLine();
|
||||||
|
report.AppendLine(failures < 0 ? "결과: 진단 중단" : $"결과: 실패 {failures}건");
|
||||||
|
return Finish(report, outputPath, failures switch { 0 => 0, < 0 => 2, _ => 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int Measure(StringBuilder report)
|
||||||
|
{
|
||||||
|
var monitors = ListMonitors();
|
||||||
|
if (monitors.Count == 0)
|
||||||
|
{
|
||||||
|
report.AppendLine("모니터를 하나도 조회하지 못했습니다.");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var taskbar = TaskbarRect();
|
||||||
|
var state = Empty();
|
||||||
|
var taskbarAutoHide = (SHAppBarMessage(AbmGetState, ref state).ToInt64() & AbsAutoHide) != 0;
|
||||||
|
var failures = 0;
|
||||||
|
|
||||||
|
// 프로브는 WindowStyle=None 이어야 한다 — 기본값이면 Attach 의 가드에 걸려 아무 일도 안 일어난다
|
||||||
|
var probe = NewProbe(ProbeMinWidth, ProbeMinHeight);
|
||||||
|
var handle = new WindowInteropHelper(probe).EnsureHandle();
|
||||||
|
Services.MaximizeToWorkArea.Attach(probe);
|
||||||
|
|
||||||
|
foreach (var monitor in monitors)
|
||||||
|
{
|
||||||
|
report.AppendLine($"모니터 {monitor.Name}{(monitor.Primary ? " (주)" : string.Empty)}");
|
||||||
|
report.AppendLine($" rcMonitor {Show(monitor.Bounds)} rcWork {Show(monitor.Work)}");
|
||||||
|
report.AppendLine($" 자동숨김 감지 좌={monitor.AutoHide[0]} 상={monitor.AutoHide[1]} 우={monitor.AutoHide[2]} 하={monitor.AutoHide[3]}");
|
||||||
|
|
||||||
|
// 그 모니터 안으로 옮긴다 — MonitorFromWindow 가 고르는 모니터가 곧 검사 대상이다.
|
||||||
|
// SWP_NOSIZE|SWP_NOZORDER|SWP_NOACTIVATE
|
||||||
|
SetWindowPos(handle, IntPtr.Zero, monitor.Bounds.Left + 20, monitor.Bounds.Top + 20, 0, 0, 0x0001 | 0x0004 | 0x0010);
|
||||||
|
|
||||||
|
var answer = Ask(handle);
|
||||||
|
var max = Absolute(answer, monitor.Bounds);
|
||||||
|
report.AppendLine($" 답 ptMaxPosition=({answer.ptMaxPosition.X},{answer.ptMaxPosition.Y}) ptMaxSize=({answer.ptMaxSize.X},{answer.ptMaxSize.Y}) → 창 {Show(max)}");
|
||||||
|
report.AppendLine($" 답 ptMinTrackSize=({answer.ptMinTrackSize.X},{answer.ptMinTrackSize.Y})");
|
||||||
|
|
||||||
|
failures += Check(report, "① 최대화 창이 모니터 안에 들어간다",
|
||||||
|
max.Left >= monitor.Bounds.Left && max.Top >= monitor.Bounds.Top
|
||||||
|
&& max.Right <= monitor.Bounds.Right && max.Bottom <= monitor.Bounds.Bottom);
|
||||||
|
|
||||||
|
failures += Check(report, "② 자동 숨김이 없는 변은 작업영역 그대로다",
|
||||||
|
(monitor.AutoHide[0] || max.Left == monitor.Work.Left)
|
||||||
|
&& (monitor.AutoHide[1] || max.Top == monitor.Work.Top)
|
||||||
|
&& (monitor.AutoHide[2] || max.Right == monitor.Work.Right)
|
||||||
|
&& (monitor.AutoHide[3] || max.Bottom == monitor.Work.Bottom));
|
||||||
|
|
||||||
|
// 값이 아니라 방향만 본다 — 양보 폭을 1→2 로 바꿔도 진단이 깨지지 않아야 한다
|
||||||
|
failures += Check(report, "③ 자동 숨김이 있는 변은 모니터 가장자리에서 물러선다",
|
||||||
|
(!monitor.AutoHide[0] || max.Left > monitor.Bounds.Left)
|
||||||
|
&& (!monitor.AutoHide[1] || max.Top > monitor.Bounds.Top)
|
||||||
|
&& (!monitor.AutoHide[2] || max.Right < monitor.Bounds.Right)
|
||||||
|
&& (!monitor.AutoHide[3] || max.Bottom < monitor.Bounds.Bottom));
|
||||||
|
|
||||||
|
// 원래 결함을 그 언어 그대로 표현한 검사. 구현이 1차 판정에 쓰지 않는 API 라 독립적이다.
|
||||||
|
// 자동 숨김이면 작업표시줄이 자리를 예약하지 않아 겹치는 것이 정상이므로 건너뛴다.
|
||||||
|
if (taskbar is { } bar && !taskbarAutoHide && Intersects(bar, monitor.Bounds))
|
||||||
|
{
|
||||||
|
failures += Check(report, "④ 작업표시줄을 덮지 않는다", !Intersects(max, bar));
|
||||||
|
}
|
||||||
|
|
||||||
|
// OS 는 최대화 크기를 최소 트래킹 크기까지 되밀어 올린다 — 이게 크면 ①②가 무의미해진다
|
||||||
|
failures += Check(report, "⑤ 최소 크기가 최대화 크기를 넘지 않는다",
|
||||||
|
answer.ptMinTrackSize.X <= answer.ptMaxSize.X && answer.ptMinTrackSize.Y <= answer.ptMaxSize.Y);
|
||||||
|
report.AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
var primary = monitors.Find(m => m.Primary) ?? monitors[0];
|
||||||
|
|
||||||
|
// ⑦ 표준 크롬 창에는 걸리지 않아야 한다 — 걸면 클라이언트가 프레임 두께만큼 줄어든다
|
||||||
|
var normal = new Window { WindowStyle = WindowStyle.SingleBorderWindow, ShowInTaskbar = false };
|
||||||
|
var normalHandle = new WindowInteropHelper(normal).EnsureHandle();
|
||||||
|
Services.MaximizeToWorkArea.Attach(normal);
|
||||||
|
var untouched = Ask(normalHandle);
|
||||||
|
failures += Check(report, "⑦ 표준 크롬 창은 건드리지 않는다",
|
||||||
|
untouched.ptMaxSize.X == Sentinel.ptMaxSize.X && untouched.ptMaxSize.Y == Sentinel.ptMaxSize.Y);
|
||||||
|
normal.Close();
|
||||||
|
probe.Close();
|
||||||
|
|
||||||
|
report.AppendLine();
|
||||||
|
failures += Live(report, primary);
|
||||||
|
return failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 실제로 띄워서 최대화·복원해 본다 — 여기서만 잡히는 것 두 가지.
|
||||||
|
/// ⑧ OS 가 우리 답을 지키는가(메시지 응답만 보면 알 수 없다).
|
||||||
|
/// ⑨ StateChanged 배선이 살아 있는가(Attach 시점 스냅샷만 보면 그 한 줄을 지워도 통과한다).
|
||||||
|
/// </summary>
|
||||||
|
private static int Live(StringBuilder report, MonitorFacts monitor)
|
||||||
|
{
|
||||||
|
var failures = 0;
|
||||||
|
var chrome = new WindowChrome { CaptionHeight = 40, ResizeBorderThickness = new Thickness(ProbeBorder) };
|
||||||
|
var probe = NewProbe(ProbeMinWidth, ProbeMinHeight);
|
||||||
|
WindowChrome.SetWindowChrome(probe, chrome);
|
||||||
|
probe.WindowStartupLocation = WindowStartupLocation.Manual;
|
||||||
|
probe.Left = monitor.Bounds.Left + 40;
|
||||||
|
probe.Top = monitor.Bounds.Top + 40;
|
||||||
|
probe.ShowActivated = false;
|
||||||
|
probe.Show();
|
||||||
|
Pump();
|
||||||
|
Services.MaximizeToWorkArea.Attach(probe);
|
||||||
|
|
||||||
|
var restoredBorder = chrome.ResizeBorderThickness;
|
||||||
|
probe.WindowState = WindowState.Maximized;
|
||||||
|
Pump();
|
||||||
|
GetWindowRect(new WindowInteropHelper(probe).Handle, out var maximized);
|
||||||
|
var maximizedBorder = chrome.ResizeBorderThickness;
|
||||||
|
|
||||||
|
probe.WindowState = WindowState.Normal;
|
||||||
|
Pump();
|
||||||
|
var afterRestore = chrome.ResizeBorderThickness;
|
||||||
|
|
||||||
|
// 두 번 붙여도 복원 두께를 잃지 않아야 한다 — 최대화 중에 캡처한 0 을 기억하면 창이 못 커진다
|
||||||
|
Services.MaximizeToWorkArea.Attach(probe);
|
||||||
|
probe.WindowState = WindowState.Maximized;
|
||||||
|
Pump();
|
||||||
|
probe.WindowState = WindowState.Normal;
|
||||||
|
Pump();
|
||||||
|
var afterReattach = chrome.ResizeBorderThickness;
|
||||||
|
|
||||||
|
// ⑥ 좁은 화면 흉내 — 작업영역보다 큰 최소 크기를 요구해 본다.
|
||||||
|
// 눌러 두지 않으면 OS 가 최대화 창을 그 크기까지 도로 부풀려 작업표시줄을 다시 덮는다.
|
||||||
|
// 띄운 창에서 재야 한다 — MinWidth/MinHeight 는 창 객체에 있고, 띄우지 않은 HWND 에는
|
||||||
|
// RootVisual 이 없어 훅이 창을 찾지 못한다(그 상태로 재다가 한 번 헛다리를 짚었다).
|
||||||
|
var workHeight = monitor.Work.Bottom - monitor.Work.Top;
|
||||||
|
var handle = new WindowInteropHelper(probe).Handle;
|
||||||
|
probe.MinHeight = workHeight + 200;
|
||||||
|
var cramped = Ask(handle);
|
||||||
|
probe.MinHeight = ProbeMinHeight;
|
||||||
|
probe.Close();
|
||||||
|
|
||||||
|
report.AppendLine($"좁은 화면 흉내 — 최소 높이 {workHeight + 200} 요구, 작업영역 높이 {workHeight}");
|
||||||
|
report.AppendLine($" 답 ptMinTrackSize=({cramped.ptMinTrackSize.X},{cramped.ptMinTrackSize.Y}) ptMaxSize=({cramped.ptMaxSize.X},{cramped.ptMaxSize.Y})");
|
||||||
|
failures += Check(report, "⑥ 작업영역보다 큰 최소 크기는 눌린다",
|
||||||
|
cramped.ptMaxSize.Y > 0 && cramped.ptMinTrackSize.Y <= cramped.ptMaxSize.Y);
|
||||||
|
|
||||||
|
report.AppendLine($"실제 최대화 창 사각형 {Show(maximized)} 작업영역 {Show(monitor.Work)}");
|
||||||
|
report.AppendLine($"리사이즈 띠 복원={restoredBorder.Top} 최대화={maximizedBorder.Top} 복원후={afterRestore.Top} 재부착후={afterReattach.Top}");
|
||||||
|
|
||||||
|
failures += Check(report, "⑧ OS 가 답을 지킨다 — 실제 최대화 창이 작업영역 안이다",
|
||||||
|
maximized.Left >= monitor.Work.Left && maximized.Top >= monitor.Work.Top
|
||||||
|
&& maximized.Right <= monitor.Work.Right && maximized.Bottom <= monitor.Work.Bottom);
|
||||||
|
failures += Check(report, "⑨ 최대화하면 리사이즈 띠가 걷히고 복원하면 돌아온다",
|
||||||
|
restoredBorder.Top > 0 && maximizedBorder == new Thickness(0) && afterRestore == restoredBorder);
|
||||||
|
failures += Check(report, "⑩ 두 번 붙여도 복원 두께를 잃지 않는다", afterReattach == restoredBorder);
|
||||||
|
return failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Window NewProbe(double minWidth, double minHeight) => new()
|
||||||
|
{
|
||||||
|
WindowStyle = WindowStyle.None,
|
||||||
|
ShowInTaskbar = false,
|
||||||
|
MinWidth = minWidth,
|
||||||
|
MinHeight = minHeight,
|
||||||
|
Width = minWidth,
|
||||||
|
Height = Math.Min(minHeight, 400),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>메시지를 실제로 보내고 되읽는다 — 창의 WndProc 사슬(WPF + 우리 훅)이 전부 돈다</summary>
|
||||||
|
private static MINMAXINFO Ask(IntPtr handle)
|
||||||
|
{
|
||||||
|
var buffer = Marshal.AllocHGlobal(Marshal.SizeOf<MINMAXINFO>());
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 0 이 아니라 센티넬을 채워 보낸다. 0 으로 두면 기대값이 (0,0) 인 모니터에서
|
||||||
|
// 훅이 아무것도 안 써도 검사가 통과해 버린다 — 실제로 그 상태를 한 번 놓칠 뻔했다.
|
||||||
|
Marshal.StructureToPtr(Sentinel, buffer, fDeleteOld: false);
|
||||||
|
SendMessage(handle, WmGetMinMaxInfo, IntPtr.Zero, buffer);
|
||||||
|
return Marshal.PtrToStructure<MINMAXINFO>(buffer);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Marshal.FreeHGlobal(buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>모니터 상대 답을 절대 좌표 사각형으로</summary>
|
||||||
|
private static RECT Absolute(MINMAXINFO answer, RECT bounds) => new()
|
||||||
|
{
|
||||||
|
Left = bounds.Left + answer.ptMaxPosition.X,
|
||||||
|
Top = bounds.Top + answer.ptMaxPosition.Y,
|
||||||
|
Right = bounds.Left + answer.ptMaxPosition.X + answer.ptMaxSize.X,
|
||||||
|
Bottom = bounds.Top + answer.ptMaxPosition.Y + answer.ptMaxSize.Y,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static RECT? TaskbarRect()
|
||||||
|
{
|
||||||
|
var data = new APPBARDATA { cbSize = (uint)Marshal.SizeOf<APPBARDATA>() };
|
||||||
|
return SHAppBarMessage(AbmGetTaskbarPos, ref data) == IntPtr.Zero ? null : data.rc;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static APPBARDATA Empty() => new() { cbSize = (uint)Marshal.SizeOf<APPBARDATA>() };
|
||||||
|
|
||||||
|
private static List<MonitorFacts> ListMonitors()
|
||||||
|
{
|
||||||
|
var list = new List<MonitorFacts>();
|
||||||
|
var index = 0;
|
||||||
|
EnumDisplayMonitors(IntPtr.Zero, IntPtr.Zero, (IntPtr monitor, IntPtr _, ref RECT _, IntPtr _) =>
|
||||||
|
{
|
||||||
|
var info = new MONITORINFO { cbSize = Marshal.SizeOf<MONITORINFO>() };
|
||||||
|
if (GetMonitorInfo(monitor, ref info))
|
||||||
|
{
|
||||||
|
var bars = new bool[4];
|
||||||
|
for (uint edge = 0; edge < 4; edge++)
|
||||||
|
{
|
||||||
|
var data = new APPBARDATA
|
||||||
|
{
|
||||||
|
cbSize = (uint)Marshal.SizeOf<APPBARDATA>(),
|
||||||
|
uEdge = edge,
|
||||||
|
rc = info.rcMonitor,
|
||||||
|
};
|
||||||
|
bars[edge] = SHAppBarMessage(AbmGetAutoHideBarEx, ref data) != IntPtr.Zero;
|
||||||
|
}
|
||||||
|
list.Add(new MonitorFacts($"#{++index}", info.rcMonitor, info.rcWork,
|
||||||
|
(info.dwFlags & MonitorinfofPrimary) != 0, bars));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}, IntPtr.Zero);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Intersects(RECT a, RECT b)
|
||||||
|
=> a.Left < b.Right && a.Right > b.Left && a.Top < b.Bottom && a.Bottom > b.Top;
|
||||||
|
|
||||||
|
/// <summary>레이아웃과 창 상태 전이가 한 바퀴 돌게 한다</summary>
|
||||||
|
private static void Pump()
|
||||||
|
{
|
||||||
|
for (var i = 0; i < 3; i++)
|
||||||
|
{
|
||||||
|
Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.ContextIdle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Show(RECT r) => $"({r.Left},{r.Top})~({r.Right},{r.Bottom})";
|
||||||
|
|
||||||
|
private static int Check(StringBuilder report, string label, bool ok)
|
||||||
|
{
|
||||||
|
report.AppendLine($" {(ok ? "PASS" : "FAIL")} {label}");
|
||||||
|
return ok ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int Finish(StringBuilder report, string? outputPath, int code)
|
||||||
|
{
|
||||||
|
var text = report.ToString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(outputPath))
|
||||||
|
{
|
||||||
|
File.WriteAllText(outputPath, text, Encoding.UTF8);
|
||||||
|
}
|
||||||
|
Console.Error.Write(text);
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using SheetMe.Designer.DataBusiness;
|
||||||
|
using SheetMe.Designer.Services;
|
||||||
|
using SheetMe.Designer.ViewModels;
|
||||||
|
using SheetMe.Designer.ViewModels.Controls;
|
||||||
|
using SheetMe.Designer.Views;
|
||||||
|
|
||||||
|
namespace SheetMe.Designer.Diagnostics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 스냅 표시 오프스크린 스냅샷 — <c>--snap-shots <출력폴더></c>.
|
||||||
|
///
|
||||||
|
/// <b>왜 필요한가.</b> 기존 <c>--render-smoke</c>·<c>--db-render</c> 는 종이(PageView)만 찍어
|
||||||
|
/// 오버레이가 안 나온다. 스냅은 "붙는 좌표"만 맞아서는 쓸 수 없다 — 선이 어디서 어디까지 그어지는지,
|
||||||
|
/// 여러 개가 동시에 보이는지, 간격 막대가 어떻게 생겼는지는 그림으로만 확인된다.
|
||||||
|
///
|
||||||
|
/// <b>게이트는 수치로 건다.</b> 같이 남기는 <c>_report.txt</c> 의 좌표·개수가 판정 기준이고
|
||||||
|
/// PNG 는 눈 검증 보조다. 픽셀 비교는 테마·글꼴에 흔들려 회귀 게이트로 쓸 수 없다.
|
||||||
|
///
|
||||||
|
/// 드래그 <b>도중</b>을 찍는다 — PointerUp 이 표시를 지우기 때문에 캡처는 반드시 그 전이다.
|
||||||
|
/// </summary>
|
||||||
|
public static class SnapShots
|
||||||
|
{
|
||||||
|
#region Member Fields
|
||||||
|
private static readonly PointerContext Plain = new(false, false, false, 1);
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Methods
|
||||||
|
public static int Run(string outputDirectory)
|
||||||
|
{
|
||||||
|
var lines = new List<string>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(outputDirectory);
|
||||||
|
Application.Current.ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
||||||
|
ThemeManager.Apply(light: true);
|
||||||
|
|
||||||
|
foreach (var (name, zoom, build) in Scenarios())
|
||||||
|
{
|
||||||
|
var designer = new DesignerViewModel(new FormDesignDataBusiness().CreateNew());
|
||||||
|
designer.Zoom = zoom;
|
||||||
|
var grab = build(designer);
|
||||||
|
|
||||||
|
designer.Interaction.PointerDown(grab.From, Plain);
|
||||||
|
designer.Interaction.PointerMove(grab.To, Plain);
|
||||||
|
|
||||||
|
Capture(designer, Path.Combine(outputDirectory, $"{name}.png"));
|
||||||
|
lines.Add($"{name} 줌={zoom:0.##} 가이드={designer.Overlay.Guides.Count} 간격={designer.Overlay.Spacings.Count}");
|
||||||
|
foreach (var guide in designer.Overlay.Guides)
|
||||||
|
{
|
||||||
|
lines.Add($" 가이드 {(guide.IsVertical ? "세로" : "가로")} {guide.Position:0.##}"
|
||||||
|
+ $" ({guide.X1:0.##},{guide.Y1:0.##})~({guide.X2:0.##},{guide.Y2:0.##})");
|
||||||
|
}
|
||||||
|
foreach (var mark in designer.Overlay.Spacings)
|
||||||
|
{
|
||||||
|
lines.Add($" 간격 {mark.Text} ({mark.X1:0.##},{mark.Y1:0.##})~({mark.X2:0.##},{mark.Y2:0.##})");
|
||||||
|
}
|
||||||
|
|
||||||
|
designer.Interaction.PointerUp(grab.To, Plain);
|
||||||
|
if (designer.Overlay.Guides.Count != 0 || designer.Overlay.Spacings.Count != 0)
|
||||||
|
{
|
||||||
|
lines.Add($" FAIL 놓은 뒤에도 표시가 남았다");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var failed = lines.Count(l => l.Contains("FAIL", StringComparison.Ordinal));
|
||||||
|
lines.Add($"결과: 실패 {failed}건");
|
||||||
|
File.WriteAllText(Path.Combine(outputDirectory, "_report.txt"),
|
||||||
|
string.Join(Environment.NewLine, lines), Encoding.UTF8);
|
||||||
|
Console.Error.WriteLine(string.Join(Environment.NewLine, lines));
|
||||||
|
return failed == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"스냅 스냅샷 실패 — {ex}");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct Grab(Point From, Point To);
|
||||||
|
|
||||||
|
private static IEnumerable<(string Name, double Zoom, Func<DesignerViewModel, Grab> Build)> Scenarios()
|
||||||
|
{
|
||||||
|
yield return ("01-형제-좌변", 1.0, d =>
|
||||||
|
{
|
||||||
|
Place(d, "Label", 200, 100, 60, 20);
|
||||||
|
Place(d, "Label", 200, 400, 60, 20);
|
||||||
|
var mover = Place(d, "Label", 197, 250, 40, 20);
|
||||||
|
return Drag(d, mover, 3, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return ("02-좌중우-동시", 1.0, d =>
|
||||||
|
{
|
||||||
|
Place(d, "Label", 200, 120, 60, 20);
|
||||||
|
var mover = Place(d, "Label", 197, 300, 60, 20);
|
||||||
|
return Drag(d, mover, 3, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return ("03-용지-중앙", 1.0, d =>
|
||||||
|
{
|
||||||
|
var mover = Place(d, "Label", 334, 300, 40, 20);
|
||||||
|
return Drag(d, mover, 3, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return ("04-간격-이어붙이기", 1.0, d =>
|
||||||
|
{
|
||||||
|
Place(d, "Label", 100, 300, 50, 20);
|
||||||
|
Place(d, "Label", 180, 300, 50, 20);
|
||||||
|
var mover = Place(d, "Label", 257, 300, 40, 20);
|
||||||
|
return Drag(d, mover, 3, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return ("05-간격-사이끼우기", 1.0, d =>
|
||||||
|
{
|
||||||
|
Place(d, "Label", 100, 300, 50, 20);
|
||||||
|
Place(d, "Label", 300, 300, 50, 20);
|
||||||
|
var mover = Place(d, "Label", 202, 300, 40, 20);
|
||||||
|
return Drag(d, mover, 3, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return ("06-줌-절반", 0.5, d =>
|
||||||
|
{
|
||||||
|
// 컨트롤을 높게 잡는다 — 줌 0.5 에서는 핸들 판정 반경이 12 라
|
||||||
|
// 높이 20 짜리를 가운데서 잡으면 상단 핸들에 먼저 걸려 리사이즈로 빠진다
|
||||||
|
Place(d, "Label", 200, 100, 60, 40);
|
||||||
|
Place(d, "Label", 200, 400, 60, 40);
|
||||||
|
var mover = Place(d, "Label", 190, 250, 40, 40);
|
||||||
|
return Drag(d, mover, 4, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return ("07-줌-두배", 2.0, d =>
|
||||||
|
{
|
||||||
|
Place(d, "Label", 200, 100, 60, 20);
|
||||||
|
Place(d, "Label", 200, 400, 60, 20);
|
||||||
|
var mover = Place(d, "Label", 198, 250, 40, 20);
|
||||||
|
return Drag(d, mover, 3, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ControlViewModel Place(DesignerViewModel designer, string type,
|
||||||
|
double x, double y, double w, double h)
|
||||||
|
{
|
||||||
|
designer.AddControlAt(type, new Point(x, y));
|
||||||
|
var vm = designer.Pages[0].Controls[^1];
|
||||||
|
vm.X = x;
|
||||||
|
vm.Y = y;
|
||||||
|
vm.Width = w;
|
||||||
|
vm.Height = h;
|
||||||
|
return vm;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Grab Drag(DesignerViewModel designer, ControlViewModel mover, double dx, double dy)
|
||||||
|
{
|
||||||
|
designer.Selection.SetSingle(mover);
|
||||||
|
designer.RefreshOverlay();
|
||||||
|
var from = new Point(mover.X + mover.Width / 2, mover.Y + mover.Height / 2);
|
||||||
|
return new Grab(from, new Point(from.X + dx, from.Y + dy));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>종이 위에 오버레이를 그대로 겹쳐 찍는다 — 1페이지 문서라 월드 좌표가 곧 종이 좌표다</summary>
|
||||||
|
private static void Capture(DesignerViewModel designer, string pngPath)
|
||||||
|
{
|
||||||
|
var page = designer.Pages[0];
|
||||||
|
var width = page.WidthDip;
|
||||||
|
var height = page.HeightDip;
|
||||||
|
|
||||||
|
var root = new Grid { Width = width, Height = height, Background = Brushes.White };
|
||||||
|
root.Children.Add(new PageView { DataContext = page });
|
||||||
|
root.Children.Add(new SelectionOverlayView { DataContext = designer.Overlay });
|
||||||
|
|
||||||
|
root.Measure(new Size(width, height));
|
||||||
|
root.Arrange(new Rect(0, 0, width, height));
|
||||||
|
root.UpdateLayout();
|
||||||
|
|
||||||
|
var bitmap = new RenderTargetBitmap(
|
||||||
|
(int)Math.Ceiling(width), (int)Math.Ceiling(height), 96, 96, PixelFormats.Pbgra32);
|
||||||
|
bitmap.Render(root);
|
||||||
|
|
||||||
|
var encoder = new PngBitmapEncoder();
|
||||||
|
encoder.Frames.Add(BitmapFrame.Create(bitmap));
|
||||||
|
using var stream = File.Create(pngPath);
|
||||||
|
encoder.Save(stream);
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
using SheetMe.Core.Layout;
|
||||||
using SheetMe.Designer.ViewModels;
|
using SheetMe.Designer.ViewModels;
|
||||||
using SheetMe.Designer.ViewModels.Controls;
|
using SheetMe.Designer.ViewModels.Controls;
|
||||||
|
|
||||||
@@ -78,6 +79,9 @@ public sealed class InteractionController
|
|||||||
mode = Mode.Resize;
|
mode = Mode.Resize;
|
||||||
resizeHandleIndex = handleIndex;
|
resizeHandleIndex = handleIndex;
|
||||||
CaptureOrigBounds();
|
CaptureOrigBounds();
|
||||||
|
// 리사이즈도 후보를 모아야 한다. 예전에는 여기서 안 모아서, 이동 드래그를 한 번 한 뒤라야
|
||||||
|
// 리사이즈 스냅이 — 그것도 그때의 낡은 좌표로 — 동작했다.
|
||||||
|
BeginSnap();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,12 +89,24 @@ 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)
|
||||||
{
|
{
|
||||||
mode = Mode.None;
|
// 최상위 컨트롤은 들어갈 곳이 없다 — 예전처럼 바로 글자 편집이다
|
||||||
designer.RequestInlineEdit(hit);
|
if (hit.Parent is not null && !hit.IsSelected)
|
||||||
return;
|
{
|
||||||
|
mode = Mode.None;
|
||||||
|
designer.Selection.SetSingle(hit);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (DesignerViewModel.IsTextEditable(hit))
|
||||||
|
{
|
||||||
|
mode = Mode.None;
|
||||||
|
designer.RequestInlineEdit(hit);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
downControl = hit;
|
downControl = hit;
|
||||||
@@ -188,11 +204,13 @@ public sealed class InteractionController
|
|||||||
|
|
||||||
case Mode.Move:
|
case Mode.Move:
|
||||||
designer.ReassignPagesAfterMove(origBounds.Keys.ToList());
|
designer.ReassignPagesAfterMove(origBounds.Keys.ToList());
|
||||||
designer.Overlay.SetGuides(null, null);
|
designer.Overlay.ClearGuides();
|
||||||
|
designer.Snap.EndDrag();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Mode.Resize:
|
case Mode.Resize:
|
||||||
designer.Overlay.SetGuides(null, null);
|
designer.Overlay.ClearGuides();
|
||||||
|
designer.Snap.EndDrag();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Mode.Marquee:
|
case Mode.Marquee:
|
||||||
@@ -236,7 +254,8 @@ public sealed class InteractionController
|
|||||||
vm.Width = rect.Width;
|
vm.Width = rect.Width;
|
||||||
vm.Height = rect.Height;
|
vm.Height = rect.Height;
|
||||||
}
|
}
|
||||||
designer.Overlay.SetGuides(null, null);
|
designer.Overlay.ClearGuides();
|
||||||
|
designer.Snap.EndDrag();
|
||||||
mode = Mode.None;
|
mode = Mode.None;
|
||||||
origBounds.Clear();
|
origBounds.Clear();
|
||||||
designer.RefreshOverlay();
|
designer.RefreshOverlay();
|
||||||
@@ -287,16 +306,60 @@ public sealed class InteractionController
|
|||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Methods - Private
|
#region Methods - Private
|
||||||
|
/// <summary>리사이즈로 맞은 모서리를 선분 가이드로 — 이동 때와 같은 모양으로 보여야 한다</summary>
|
||||||
|
private static IReadOnlyList<SnapGuide> EdgeGuides(SnapEdgeOutcome? hitX, SnapEdgeOutcome? hitY, Rect rect)
|
||||||
|
{
|
||||||
|
if (hitX is not { Snapped: true } && hitY is not { Snapped: true })
|
||||||
|
{
|
||||||
|
return Array.Empty<SnapGuide>();
|
||||||
|
}
|
||||||
|
var list = new List<SnapGuide>(2);
|
||||||
|
if (hitX is { Snapped: true } x)
|
||||||
|
{
|
||||||
|
var start = rect.Top;
|
||||||
|
var end = rect.Bottom;
|
||||||
|
foreach (var source in x.Matched)
|
||||||
|
{
|
||||||
|
start = Math.Min(start, source.Top);
|
||||||
|
end = Math.Max(end, source.Bottom);
|
||||||
|
}
|
||||||
|
list.Add(new SnapGuide(true, x.Value, start, end, x.Kind));
|
||||||
|
}
|
||||||
|
if (hitY is { Snapped: true } y)
|
||||||
|
{
|
||||||
|
var start = rect.Left;
|
||||||
|
var end = rect.Right;
|
||||||
|
foreach (var source in y.Matched)
|
||||||
|
{
|
||||||
|
start = Math.Min(start, source.Left);
|
||||||
|
end = Math.Max(end, source.Right);
|
||||||
|
}
|
||||||
|
list.Add(new SnapGuide(false, y.Value, start, end, y.Kind));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
private void BeginMove()
|
private void BeginMove()
|
||||||
{
|
{
|
||||||
CaptureOrigBounds();
|
CaptureOrigBounds();
|
||||||
|
BeginSnap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 스냅 후보 수집 — 이동·리사이즈 공용.
|
||||||
|
///
|
||||||
|
/// 제외 집합은 선택 항목만으로는 모자라다. 끌려가는 컨테이너의 자식은 선택에 없지만 함께 움직이므로,
|
||||||
|
/// 빼지 않으면 컨테이너가 제 자식에게 붙어 그 자리에서 굳는다.
|
||||||
|
/// </summary>
|
||||||
|
private void BeginSnap()
|
||||||
|
{
|
||||||
// Primary 는 없을 수 있다(선택 항목은 있는데 주선택이 비는 경로가 있다) —
|
// Primary 는 없을 수 있다(선택 항목은 있는데 주선택이 비는 경로가 있다) —
|
||||||
// ?! 로 넘기면 PageOf → RootAncestorOf 가 즉시 역참조해 이동 시작이 예외로 죽는다
|
// ?! 로 넘기면 PageOf → RootAncestorOf 가 즉시 역참조해 이동 시작이 예외로 죽는다
|
||||||
var anchor = designer.Selection.Primary ?? designer.Selection.Items.FirstOrDefault();
|
var anchor = designer.Selection.Primary ?? designer.Selection.Items.FirstOrDefault();
|
||||||
var page = (anchor is null ? null : designer.PageOf(anchor)) ?? designer.Pages.FirstOrDefault();
|
var page = (anchor is null ? null : designer.PageOf(anchor)) ?? designer.Pages.FirstOrDefault();
|
||||||
if (page is not null)
|
if (page is not null)
|
||||||
{
|
{
|
||||||
designer.Snap.BeginDrag(page, designer.Selection.Items.ToHashSet());
|
designer.Snap.BeginDrag(designer, page, designer.SelectionWithDescendants());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,15 +370,20 @@ public sealed class InteractionController
|
|||||||
var dx = world.X - downPoint.X;
|
var dx = world.X - downPoint.X;
|
||||||
var dy = world.Y - downPoint.Y;
|
var dy = world.Y - downPoint.Y;
|
||||||
|
|
||||||
var snap = designer.Snap.SnapMove(groupBounds0, dx, dy, ctx.Alt);
|
var snap = designer.Snap.SnapMove(groupBounds0, dx, dy, ctx.Alt, designer.Zoom);
|
||||||
designer.Overlay.SetGuides(snap.GuideX, snap.GuideY);
|
designer.Overlay.SetGuides(snap.Guides, snap.Spacings);
|
||||||
|
|
||||||
// origBounds 는 월드 좌표 — 로컬 = (원본 월드 + 보정 델타) - 부모 체인 오프셋
|
// origBounds 는 월드 좌표 — 로컬 = (원본 월드 + 보정 델타) - 부모 체인 오프셋
|
||||||
foreach (var (vm, rect) in origBounds)
|
foreach (var (vm, rect) in origBounds)
|
||||||
{
|
{
|
||||||
var offset = designer.ParentWorldOffset(vm);
|
var offset = designer.ParentWorldOffset(vm);
|
||||||
vm.X = Math.Round(rect.X + snap.Dx - offset.X);
|
var x = rect.X + snap.Dx - offset.X;
|
||||||
vm.Y = Math.Round(rect.Y + snap.Dy - offset.Y);
|
var y = rect.Y + snap.Dy - offset.Y;
|
||||||
|
// 정합된 축은 반올림하지 않는다. 형제 폭이 홀수면 중심이 반정수라, 여기서 반올림하면
|
||||||
|
// 중앙에 맞췄는데 0.5px 어긋난 채로 놓이고 가이드선이 모서리에서 떠 보인다.
|
||||||
|
// 저장은 LegacyFormat.FormatPair 가 어차피 정수로 쓰므로 포맷은 상관없다.
|
||||||
|
vm.X = snap.SnappedX ? x : Math.Round(x);
|
||||||
|
vm.Y = snap.SnappedY ? y : Math.Round(y);
|
||||||
}
|
}
|
||||||
|
|
||||||
designer.Selection.NotifyBoundsChanged();
|
designer.Selection.NotifyBoundsChanged();
|
||||||
@@ -332,21 +400,27 @@ public sealed class InteractionController
|
|||||||
var bottom = rect.Bottom;
|
var bottom = rect.Bottom;
|
||||||
|
|
||||||
// 핸들별 이동 모서리 (0=NW,1=N,2=NE,3=E,4=SE,5=S,6=SW,7=W)
|
// 핸들별 이동 모서리 (0=NW,1=N,2=NE,3=E,4=SE,5=S,6=SW,7=W)
|
||||||
|
SnapEdgeOutcome? hitX = null;
|
||||||
|
SnapEdgeOutcome? hitY = null;
|
||||||
if (resizeHandleIndex is 0 or 6 or 7)
|
if (resizeHandleIndex is 0 or 6 or 7)
|
||||||
{
|
{
|
||||||
left = designer.Snap.SnapEdge(world.X, isXAxis: true, ctx.Alt);
|
hitX = designer.Snap.SnapEdge(world.X, isXAxis: true, ctx.Alt, designer.Zoom);
|
||||||
|
left = hitX.Value.Value;
|
||||||
}
|
}
|
||||||
if (resizeHandleIndex is 2 or 3 or 4)
|
if (resizeHandleIndex is 2 or 3 or 4)
|
||||||
{
|
{
|
||||||
right = designer.Snap.SnapEdge(world.X, isXAxis: true, ctx.Alt);
|
hitX = designer.Snap.SnapEdge(world.X, isXAxis: true, ctx.Alt, designer.Zoom);
|
||||||
|
right = hitX.Value.Value;
|
||||||
}
|
}
|
||||||
if (resizeHandleIndex is 0 or 1 or 2)
|
if (resizeHandleIndex is 0 or 1 or 2)
|
||||||
{
|
{
|
||||||
top = designer.Snap.SnapEdge(world.Y, isXAxis: false, ctx.Alt);
|
hitY = designer.Snap.SnapEdge(world.Y, isXAxis: false, ctx.Alt, designer.Zoom);
|
||||||
|
top = hitY.Value.Value;
|
||||||
}
|
}
|
||||||
if (resizeHandleIndex is 4 or 5 or 6)
|
if (resizeHandleIndex is 4 or 5 or 6)
|
||||||
{
|
{
|
||||||
bottom = designer.Snap.SnapEdge(world.Y, isXAxis: false, ctx.Alt);
|
hitY = designer.Snap.SnapEdge(world.Y, isXAxis: false, ctx.Alt, designer.Zoom);
|
||||||
|
bottom = hitY.Value.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
var newRect = new Rect(
|
var newRect = new Rect(
|
||||||
@@ -355,6 +429,10 @@ public sealed class InteractionController
|
|||||||
Math.Max(MinSize, right - left),
|
Math.Max(MinSize, right - left),
|
||||||
Math.Max(MinSize, bottom - top));
|
Math.Max(MinSize, bottom - top));
|
||||||
|
|
||||||
|
// 선분은 최종 사각형이 정해진 뒤에 조립한다 — 교차축도 스냅으로 바뀔 수 있어서,
|
||||||
|
// 스냅 계산 시점의 좌표로 범위를 만들면 선이 어긋난 자리에 걸린다.
|
||||||
|
designer.Overlay.SetGuides(EdgeGuides(hitX, hitY, newRect), Array.Empty<SpacingMark>());
|
||||||
|
|
||||||
// 그룹 비례 스케일 — origBounds 기준 절대 재계산
|
// 그룹 비례 스케일 — origBounds 기준 절대 재계산
|
||||||
var sx = newRect.Width / Math.Max(1, groupBounds0.Width);
|
var sx = newRect.Width / Math.Max(1, groupBounds0.Width);
|
||||||
var sy = newRect.Height / Math.Max(1, groupBounds0.Height);
|
var sy = newRect.Height / Math.Max(1, groupBounds0.Height);
|
||||||
@@ -409,7 +487,8 @@ public sealed class InteractionController
|
|||||||
var positions = SelectionOverlayViewModel.HandlePositions(bbox);
|
var positions = SelectionOverlayViewModel.HandlePositions(bbox);
|
||||||
|
|
||||||
// 반경 내 '최근접' 핸들 선택 — 작은 컨트롤에서 인접 핸들 오인 방지
|
// 반경 내 '최근접' 핸들 선택 — 작은 컨트롤에서 인접 핸들 오인 방지
|
||||||
const double hitRadius = 6;
|
// 화면 기준 6px — 월드 고정이면 줌 300%에서 그림은 8px 인데 판정이 18px 로 벌어진다
|
||||||
|
var hitRadius = 6 / designer.Zoom;
|
||||||
var best = -1;
|
var best = -1;
|
||||||
var bestDistance = double.MaxValue;
|
var bestDistance = double.MaxValue;
|
||||||
for (var i = 0; i < positions.Length; i++)
|
for (var i = 0; i < positions.Length; i++)
|
||||||
|
|||||||
@@ -0,0 +1,405 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Interop;
|
||||||
|
using System.Windows.Shell;
|
||||||
|
|
||||||
|
namespace SheetMe.Designer.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 최대화 크기를 <b>작업영역</b>으로 제한한다 — 작업표시줄을 덮지 않게.
|
||||||
|
///
|
||||||
|
/// <b>왜 필요한가.</b> 메인 창은 <c>WindowStyle="None"</c> + WindowChrome 으로 크롬을 직접 그린다.
|
||||||
|
/// 그 조합에서 OS 는 최대화 사각형을 작업영역(rcWork)이 아니라 <b>모니터 전체</b>(rcMonitor)를 기준으로
|
||||||
|
/// 잡고, 거기에 리사이즈 프레임 두께만큼 더 부풀린다. 실측으로 창이 <c>(-7,-7)~(1927,1087)</c> 이 되어
|
||||||
|
/// 작업영역 <c>(0,0)~(1920,1032)</c> 를 아래로 55px 넘겼다 — 작업표시줄이 통째로 가려졌다.
|
||||||
|
/// 표준 크롬 창에는 이 문제가 없다(OS 가 알아서 작업영역에 맞춘다). 그래서 이 보정은
|
||||||
|
/// <c>WindowStyle=None</c> 창에만 건다.
|
||||||
|
///
|
||||||
|
/// 고치는 방법은 <c>WM_GETMINMAXINFO</c> 한 통이다. OS 가 "최대화하면 어디에 얼마만큼"을 물어볼 때
|
||||||
|
/// 작업영역을 그대로 답한다. 레이아웃·XAML·테마는 건드리지 않는다.
|
||||||
|
///
|
||||||
|
/// <b>전제 — 이 프로세스는 System DPI aware 다.</b> 매니페스트에 DPI 선언이 없어 WPF 가 기동 중
|
||||||
|
/// <c>SetProcessDPIAware()</c> 로 올린 상태다. 그래서 MINMAXINFO·MONITORINFO·APPBARDATA 가 모두
|
||||||
|
/// 같은 좌표 공간(시스템 DPI 기준 물리 픽셀)에 있고, rcWork 를 <b>변환 없이</b> 넣을 수 있다.
|
||||||
|
/// 누군가 app.manifest 로 PerMonitorV2 를 선언하면 이 전제가 무너진다 — 좌표가 모니터별 실제 픽셀이 되고
|
||||||
|
/// WM_DPICHANGED 가 들어오기 시작하므로 이 파일을 다시 검토해야 한다.
|
||||||
|
/// PixelsPerDip 이나 TransformToDevice 를 곱하지 말 것. 지금 곱하면 125%/150% 에서 이중 스케일이 된다.
|
||||||
|
///
|
||||||
|
/// 조회에 실패하면 아무것도 쓰지 않는다 — 예전처럼 작업표시줄을 덮을 뿐 크래시는 없다.
|
||||||
|
/// </summary>
|
||||||
|
public static class MaximizeToWorkArea
|
||||||
|
{
|
||||||
|
#region Member Fields
|
||||||
|
private const int WmGetMinMaxInfo = 0x0024;
|
||||||
|
|
||||||
|
/// <summary>가장 가까운 모니터 — PRIMARY 를 쓰면 보조 모니터에서 최대화할 때 주 모니터 사각형을 쓴다</summary>
|
||||||
|
private const uint MonitorDefaultToNearest = 0x00000002;
|
||||||
|
|
||||||
|
private const uint AbmGetState = 0x00000004;
|
||||||
|
private const uint AbmGetTaskbarPos = 0x00000005;
|
||||||
|
private const uint AbmGetAutoHideBarEx = 0x0000000B;
|
||||||
|
private const int AbsAutoHide = 0x00000001;
|
||||||
|
|
||||||
|
private const uint AbeLeft = 0;
|
||||||
|
private const uint AbeTop = 1;
|
||||||
|
private const uint AbeRight = 2;
|
||||||
|
private const uint AbeBottom = 3;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 자동 숨김 작업표시줄에게 양보하는 두께.
|
||||||
|
///
|
||||||
|
/// 자동 숨김이면 작업영역이 모니터 전체와 같아진다. 그대로 꽉 채우면 셸이 이 창을
|
||||||
|
/// '전체 화면 앱'으로 보고 가장자리 호버에 반응하지 않는다 — 작업표시줄이 영영 안 나온다.
|
||||||
|
/// 1px 로 안 나오는 셸 버전이 있으면 이 값만 2 로 올린다.
|
||||||
|
/// </summary>
|
||||||
|
private const int AutoHideReserve = 1;
|
||||||
|
|
||||||
|
/// <summary>이 창에 이미 붙였는가 — 중복 부착 표식(창과 함께 사라진다)</summary>
|
||||||
|
private static readonly DependencyProperty AttachedProperty = DependencyProperty.RegisterAttached(
|
||||||
|
"Attached", typeof(bool), typeof(MaximizeToWorkArea), new PropertyMetadata(false));
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct RECT
|
||||||
|
{
|
||||||
|
public int Left;
|
||||||
|
public int Top;
|
||||||
|
public int Right;
|
||||||
|
public int Bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct POINT
|
||||||
|
{
|
||||||
|
public int X;
|
||||||
|
public int Y;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct MONITORINFO
|
||||||
|
{
|
||||||
|
public int cbSize;
|
||||||
|
public RECT rcMonitor;
|
||||||
|
public RECT rcWork;
|
||||||
|
public uint dwFlags;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct APPBARDATA
|
||||||
|
{
|
||||||
|
public uint cbSize;
|
||||||
|
public IntPtr hWnd;
|
||||||
|
public uint uCallbackMessage;
|
||||||
|
public uint uEdge;
|
||||||
|
public RECT rc;
|
||||||
|
public IntPtr lParam;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OS 가 최대화 크기를 물어보는 구조체.
|
||||||
|
/// <b>필드 순서를 바꾸면 조용히 오작동한다</b> — 네이티브 레이아웃 그대로여야 한다.
|
||||||
|
/// </summary>
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct MINMAXINFO
|
||||||
|
{
|
||||||
|
public POINT ptReserved;
|
||||||
|
public POINT ptMaxSize;
|
||||||
|
public POINT ptMaxPosition;
|
||||||
|
public POINT ptMinTrackSize;
|
||||||
|
public POINT ptMaxTrackSize;
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Methods
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = false)]
|
||||||
|
private static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint flags);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = false)]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool GetMonitorInfo(IntPtr monitor, ref MONITORINFO info);
|
||||||
|
|
||||||
|
[DllImport("shell32.dll", CharSet = CharSet.Unicode, SetLastError = false)]
|
||||||
|
private static extern IntPtr SHAppBarMessage(uint message, ref APPBARDATA data);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 이 창의 최대화 크기를 작업영역으로 제한한다 — 창 핸들이 생긴 뒤(SourceInitialized)에 불러야 한다.
|
||||||
|
///
|
||||||
|
/// WindowChrome 은 <b>창마다 따로 만든 인스턴스</b>여야 한다. Style Setter 로 공유되면
|
||||||
|
/// 그 인스턴스가 봉인(Freezable)돼 최대화 시 리사이즈 띠를 끄는 쓰기가 예외를 던진다.
|
||||||
|
/// </summary>
|
||||||
|
public static void Attach(Window window)
|
||||||
|
{
|
||||||
|
// 표준 크롬 창은 이미 작업영역 기준으로 최대화된다. 거기에 창 사각형을 rcWork 로 고정하면
|
||||||
|
// 프레임 두께만큼 클라이언트가 줄고 테두리가 안쪽에 드러난다.
|
||||||
|
if (window.WindowStyle != WindowStyle.None)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 두 번 붙으면 최대화 중에 캡처한 0 을 '복원 시 두께'로 기억해 버려,
|
||||||
|
// 복원해도 리사이즈 띠가 영영 0 인 창이 된다(가장자리를 끌어 크기를 못 바꾼다).
|
||||||
|
if ((bool)window.GetValue(AttachedProperty))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.SetValue(AttachedProperty, true);
|
||||||
|
|
||||||
|
// HWND 로 찾는다. PresentationSource.FromVisual 은 창이 아직 보이기 전에 null 을 돌려줘
|
||||||
|
// 조용히 아무것도 안 붙는다(진단 --maxrect 가 이 상태를 잡았다).
|
||||||
|
var handle = new WindowInteropHelper(window).Handle;
|
||||||
|
if (handle == IntPtr.Zero || HwndSource.FromHwnd(handle) is not { } source)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddHook 과 RemoveHook 은 같은 델리게이트 인스턴스여야 한다
|
||||||
|
HwndSourceHook hook = OnMessage;
|
||||||
|
source.AddHook(hook);
|
||||||
|
// Closing 이 아니라 Closed 다. 닫기 확인에서 취소되면(MainView.OnWindowClosing) 창이 살아남는데,
|
||||||
|
// 그때 훅을 이미 떼었으면 버그가 조용히 재발한다.
|
||||||
|
window.Closed += (_, _) => source.RemoveHook(hook);
|
||||||
|
|
||||||
|
TrackResizeBorder(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 최대화 중에는 리사이즈 띠를 걷는다.
|
||||||
|
///
|
||||||
|
/// 창을 작업영역에 정확히 맞추고 나서 드러난 증상이다. 전에는 창이 화면 밖으로 7px 밀려 있어
|
||||||
|
/// WindowChrome 의 상단 6px 리사이즈 띠가 화면 밖에 있었다. 이제 그 띠가 타이틀바 맨 위로 올라와
|
||||||
|
/// <b>최대화 상태에서 타이틀바 위쪽 6px 이 리사이즈 커서가 되고 그 부분 클릭이 먹지 않는다</b>
|
||||||
|
/// (실측: 최대화 시 y=1·3·5 에서 HTTOP, y=7 부터 HTCAPTION). 오른쪽 위 닫기 버튼의 윗변이
|
||||||
|
/// 그 띠에 걸리므로 "마우스를 구석으로 던져 닫기"가 안 된다.
|
||||||
|
///
|
||||||
|
/// 어차피 최대화된 창은 가장자리를 끌어 크기를 바꿀 수 없다 — 띠가 있을 이유가 없다.
|
||||||
|
/// 여백(Margin) 보정이 아니라 히트테스트 영역 보정이다. 창 사각형은 그대로 작업영역이다.
|
||||||
|
/// </summary>
|
||||||
|
private static void TrackResizeBorder(Window window)
|
||||||
|
{
|
||||||
|
if (WindowChrome.GetWindowChrome(window) is not { } chrome)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var restored = chrome.ResizeBorderThickness;
|
||||||
|
void Sync() => chrome.ResizeBorderThickness =
|
||||||
|
window.WindowState == WindowState.Maximized ? default : restored;
|
||||||
|
window.StateChanged += (_, _) => Sync();
|
||||||
|
Sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IntPtr OnMessage(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
|
||||||
|
{
|
||||||
|
if (msg != WmGetMinMaxInfo || lParam == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 보통은 handled 를 false 로 둔다. true 로 막으면 WPF 자신의 처리가 잘려
|
||||||
|
// MinWidth=940 / MinHeight=640 이 사라진다(창을 그 아래로 줄일 수 있게 된다).
|
||||||
|
// WPF 는 우리 뒤에 돌면서 트래킹 크기만 손대므로 우리 값은 그대로 살아남는다.
|
||||||
|
// 예외는 하나 — 최소 크기가 화면에 안 들어가는 경우다(Fill 안 설명 참조).
|
||||||
|
handled = Fill(hwnd, lParam);
|
||||||
|
}
|
||||||
|
catch (DllNotFoundException)
|
||||||
|
{
|
||||||
|
// user32/shell32 가 없는 환경은 사실상 없지만, 여기서 터지면 창이 안 뜬다.
|
||||||
|
// 아무것도 안 쓰면 예전 동작(작업표시줄을 덮음)으로 남을 뿐이다.
|
||||||
|
}
|
||||||
|
catch (EntryPointNotFoundException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
return IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <returns>WPF 의 처리를 막아야 하면 true</returns>
|
||||||
|
private static bool Fill(IntPtr hwnd, IntPtr lParam)
|
||||||
|
{
|
||||||
|
var monitor = MonitorFromWindow(hwnd, MonitorDefaultToNearest);
|
||||||
|
if (monitor == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var info = new MONITORINFO { cbSize = Marshal.SizeOf<MONITORINFO>() };
|
||||||
|
if (!GetMonitorInfo(monitor, ref info))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var max = Reserve(info.rcWork, info.rcMonitor);
|
||||||
|
var width = max.Right - max.Left;
|
||||||
|
var height = max.Bottom - max.Top;
|
||||||
|
if (width <= 0 || height <= 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 구조체 전체를 읽어 두 필드만 바꾸고 되쓴다. 새로 만들어 덮으면 OS 가 미리 채워 보낸
|
||||||
|
// 트래킹 크기 기본값이 지워진다 — lParam 은 기본값이 이미 들어찬 채로 온다.
|
||||||
|
var mmi = Marshal.PtrToStructure<MINMAXINFO>(lParam);
|
||||||
|
// ptMaxPosition 은 절대 좌표가 아니라 rcMonitor 좌상단 기준 상대 좌표다.
|
||||||
|
// rcWork 는 항상 rcMonitor 안쪽이라 이 차는 음수가 될 수 없다(Abs 를 씌우지 말 것).
|
||||||
|
mmi.ptMaxPosition = new POINT { X = max.Left - info.rcMonitor.Left, Y = max.Top - info.rcMonitor.Top };
|
||||||
|
mmi.ptMaxSize = new POINT { X = width, Y = height };
|
||||||
|
|
||||||
|
// 여기서 멈추면 좁은 화면에서 보정이 조용히 무효가 된다.
|
||||||
|
//
|
||||||
|
// OS 는 최대화 크기를 최소 트래킹 크기까지 <b>되밀어 올린다</b>. 그 값은 우리 뒤에 도는 WPF 가
|
||||||
|
// MinWidth/MinHeight(940x640 DIP)로 채운다. 작업영역이 그보다 작으면 최대화 창이 도로 부풀어
|
||||||
|
// 작업표시줄을 다시 덮는다 — 1366x768 을 125% 로 쓰면 최소 높이가 800px 인데 작업영역은 720px 다.
|
||||||
|
// 우리가 먼저 눌러 봐야 WPF 가 도로 덮어쓴다(진단 --maxrect 가 그 상태를 잡았다).
|
||||||
|
//
|
||||||
|
// 그래서 <b>그때만</b> 마지막 말을 한다: WPF 의 처리를 막고 트래킹 크기를 직접 채운다.
|
||||||
|
// 화면에 들어가지도 않는 최소 크기는 최소 크기 구실을 못 한다 — 그 화면에서는 최소 크기가 양보한다.
|
||||||
|
// 보통(작업영역이 최소 크기보다 큰 경우)에는 이 가지를 타지 않으므로
|
||||||
|
// MinWidth/MinHeight 는 WPF 가 계산한 그대로 살아 있다.
|
||||||
|
var window = SourceWindow(hwnd);
|
||||||
|
if (MinimumInPixels(window, hwnd) is not { } min || (min.X <= width && min.Y <= height))
|
||||||
|
{
|
||||||
|
Marshal.StructureToPtr(mmi, lParam, fDeleteOld: false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
mmi.ptMinTrackSize = new POINT { X = Math.Min(min.X, width), Y = Math.Min(min.Y, height) };
|
||||||
|
// 이 가지에서는 WPF 가 안 도므로 MaxWidth/MaxHeight 도 여기서 반영해 준다.
|
||||||
|
// (MainView 에는 없다 — 있으면 ptMaxTrackSize 가 ptMaxSize 를 잘라 최대화가 작아진다.)
|
||||||
|
if (window is not null)
|
||||||
|
{
|
||||||
|
if (ToPixels(window.MaxWidth, hwnd, horizontal: true) is { } maxWidth)
|
||||||
|
{
|
||||||
|
mmi.ptMaxTrackSize.X = maxWidth;
|
||||||
|
}
|
||||||
|
if (ToPixels(window.MaxHeight, hwnd, horizontal: false) is { } maxHeight)
|
||||||
|
{
|
||||||
|
mmi.ptMaxTrackSize.Y = maxHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Marshal.StructureToPtr(mmi, lParam, fDeleteOld: false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Window? SourceWindow(IntPtr hwnd)
|
||||||
|
=> HwndSource.FromHwnd(hwnd)?.RootVisual as Window;
|
||||||
|
|
||||||
|
/// <summary>MinWidth/MinHeight 를 픽셀로 — 창을 못 찾거나 제약이 없으면 null</summary>
|
||||||
|
private static POINT? MinimumInPixels(Window? window, IntPtr hwnd)
|
||||||
|
{
|
||||||
|
if (window is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var x = ToPixels(window.MinWidth, hwnd, horizontal: true);
|
||||||
|
var y = ToPixels(window.MinHeight, hwnd, horizontal: false);
|
||||||
|
return x is null && y is null ? null : new POINT { X = x ?? 0, Y = y ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DIP → 픽셀. 변환 배율은 WPF 에게 묻는다 — 우리가 DPI 를 직접 읽어 곱하면
|
||||||
|
/// System DPI aware 전제가 깨진 날 이중 스케일이 된다.
|
||||||
|
/// </summary>
|
||||||
|
private static int? ToPixels(double dip, IntPtr hwnd, bool horizontal)
|
||||||
|
{
|
||||||
|
if (double.IsNaN(dip) || double.IsInfinity(dip) || dip <= 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var transform = HwndSource.FromHwnd(hwnd)?.CompositionTarget?.TransformToDevice;
|
||||||
|
var scale = horizontal ? transform?.M11 : transform?.M22;
|
||||||
|
return (int)Math.Ceiling(dip * (scale is > 0 ? scale.Value : 1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 자동 숨김 작업표시줄이 붙은 변에서 <see cref="AutoHideReserve"/> 만큼 물러선다.
|
||||||
|
///
|
||||||
|
/// 판정은 <b>변 단위</b>다. "rcWork == rcMonitor 일 때만"으로 묶으면
|
||||||
|
/// 하단 자동 숨김 + 우측 도킹 앱바 조합에서 감지에 실패한다.
|
||||||
|
/// </summary>
|
||||||
|
private static RECT Reserve(RECT work, RECT monitor)
|
||||||
|
{
|
||||||
|
var bars = new[]
|
||||||
|
{
|
||||||
|
HasAutoHideBar(monitor, AbeLeft),
|
||||||
|
HasAutoHideBar(monitor, AbeTop),
|
||||||
|
HasAutoHideBar(monitor, AbeRight),
|
||||||
|
HasAutoHideBar(monitor, AbeBottom),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 폴백은 '한 변도 못 찾았을 때'가 아니라 '그 변을 못 찾았을 때' 돈다.
|
||||||
|
// 전자로 두면 좌측의 서드파티 자동 숨김 바 하나를 찾은 것 때문에
|
||||||
|
// EX 가 놓친 하단 작업표시줄이 구제받지 못한다.
|
||||||
|
if (FallbackEdge(monitor) is { } edge && !bars[edge])
|
||||||
|
{
|
||||||
|
bars[edge] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 변마다 '그 변이 모니터 가장자리에 닿아 있을 때'만 물러선다.
|
||||||
|
// 닿아 있지 않다면 다른 앱바가 이미 자리를 예약해 창이 가장자리에 닿지 않는다 — 물러서면 틈만 생긴다.
|
||||||
|
// 좌·상은 크기만 줄이면 안 되고 위치까지 밀어야 한다. Fill 이 이 사각형에서 ptMaxPosition 을
|
||||||
|
// 파생시키므로 여기서 left/top 을 옮기는 것으로 충분하다 — 순서를 바꾸지 말 것.
|
||||||
|
if (bars[AbeLeft] && work.Left == monitor.Left)
|
||||||
|
{
|
||||||
|
work.Left += AutoHideReserve;
|
||||||
|
}
|
||||||
|
if (bars[AbeTop] && work.Top == monitor.Top)
|
||||||
|
{
|
||||||
|
work.Top += AutoHideReserve;
|
||||||
|
}
|
||||||
|
if (bars[AbeRight] && work.Right == monitor.Right)
|
||||||
|
{
|
||||||
|
work.Right -= AutoHideReserve;
|
||||||
|
}
|
||||||
|
if (bars[AbeBottom] && work.Bottom == monitor.Bottom)
|
||||||
|
{
|
||||||
|
work.Bottom -= AutoHideReserve;
|
||||||
|
}
|
||||||
|
return work;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ABM_GETAUTOHIDEBAREX 가 놓쳤을 때의 2차 판정 — 자동 숨김 바가 붙은 변, 없으면 null.
|
||||||
|
///
|
||||||
|
/// <b>주 작업표시줄만 본다.</b> ABM_GETSTATE 도 ABM_GETTASKBARPOS 도 주 작업표시줄 전용이라
|
||||||
|
/// 보조 모니터의 작업표시줄은 여기서도 안 잡힌다(교차 검사에서 걸러진다). 그러니 이 폴백이 실제로
|
||||||
|
/// 덮는 것은 "주 모니터에서 EX 조회가 실패한 경우"뿐이다 — 보조 모니터까지 구제하려면
|
||||||
|
/// 모니터별 앱바 열거라는 다른 수단이 필요하다.
|
||||||
|
///
|
||||||
|
/// 끝내 못 정하면 <b>양보하지 않는다</b>. "모르면 하단에 1px" 로 두면
|
||||||
|
/// 자동 숨김을 쓰지 않는 모든 사용자에게 최대화 창 아래로 바탕화면 한 줄이 상시 보인다.
|
||||||
|
/// </summary>
|
||||||
|
private static uint? FallbackEdge(RECT monitor)
|
||||||
|
{
|
||||||
|
var data = new APPBARDATA { cbSize = (uint)Marshal.SizeOf<APPBARDATA>() };
|
||||||
|
if ((SHAppBarMessage(AbmGetState, ref data).ToInt64() & AbsAutoHide) == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pos = new APPBARDATA { cbSize = (uint)Marshal.SizeOf<APPBARDATA>() };
|
||||||
|
if (SHAppBarMessage(AbmGetTaskbarPos, ref pos) == IntPtr.Zero || !Intersects(pos.rc, monitor))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return pos.uEdge <= AbeBottom ? pos.uEdge : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 이 모니터의 그 변에 자동 숨김 바가 있는가.
|
||||||
|
/// rc 로 모니터를 지목할 수 있는 EX 판을 쓴다 — 구판(ABM_GETAUTOHIDEBAR)은 주 모니터만 본다.
|
||||||
|
/// </summary>
|
||||||
|
private static bool HasAutoHideBar(RECT monitor, uint edge)
|
||||||
|
{
|
||||||
|
var data = new APPBARDATA
|
||||||
|
{
|
||||||
|
cbSize = (uint)Marshal.SizeOf<APPBARDATA>(),
|
||||||
|
uEdge = edge,
|
||||||
|
rc = monitor,
|
||||||
|
};
|
||||||
|
return SHAppBarMessage(AbmGetAutoHideBarEx, ref data) != IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Intersects(RECT a, RECT b)
|
||||||
|
=> a.Left < b.Right && a.Right > b.Left && a.Top < b.Bottom && a.Bottom > b.Top;
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -1,125 +1,86 @@
|
|||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
using SheetMe.Core.Layout;
|
||||||
using SheetMe.Designer.ViewModels;
|
using SheetMe.Designer.ViewModels;
|
||||||
using SheetMe.Designer.ViewModels.Controls;
|
using SheetMe.Designer.ViewModels.Controls;
|
||||||
|
|
||||||
namespace SheetMe.Designer.Services;
|
namespace SheetMe.Designer.Services;
|
||||||
|
|
||||||
/// <summary>스냅 결과 — 보정된 이동량과 표시할 가이드 좌표(월드)</summary>
|
|
||||||
public readonly record struct SnapResult(double Dx, double Dy, double? GuideX, double? GuideY);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 그리드/정렬 스냅 순수 계산.
|
/// 스냅 — 뷰모델과 순수 계산(<see cref="SnapSolver"/>) 사이의 얇은 어댑터.
|
||||||
/// 드래그 시작 시 후보(비선택 형제의 좌/중/우·상/중/하 + 용지 경계/중앙)를 1회 사전수집해
|
///
|
||||||
/// 프레임당 재수집을 피한다.
|
/// <b>왜 계산이 여기 없는가.</b> 스냅 규칙은 눈으로만 확인되는 종류의 코드다 — 후보 하나,
|
||||||
|
/// 열거 순서 하나가 바뀌면 붙는 자리가 조용히 달라지고 드래그해 보기 전엔 아무도 모른다.
|
||||||
|
/// 그래서 판단은 전부 SheetMe.Core 로 옮겨 단위 테스트로 못 박고, 여기에는
|
||||||
|
/// "어떤 사각형들이 후보인가"와 좌표 변환만 남긴다.
|
||||||
|
///
|
||||||
|
/// 허용 오차는 <b>화면 픽셀</b>이다. 월드 고정이면 줌 50%에서 두 배로 끈적이고 200%에서 절반으로
|
||||||
|
/// 미끄러진다 — 손끝 감각은 화면 거리로 정해진다.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SnapEngine
|
public sealed class SnapEngine
|
||||||
{
|
{
|
||||||
#region Member Fields
|
#region Member Fields
|
||||||
private readonly List<double> candidatesX = new();
|
private readonly SnapSolver solver = new();
|
||||||
private readonly List<double> candidatesY = new();
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Properties
|
#region Properties
|
||||||
/// <summary>그리드 간격(px)</summary>
|
/// <summary>정렬이 실패한 축의 폴백 격자(월드 px)</summary>
|
||||||
public double GridSize { get; set; } = 4;
|
public double GridSize { get; set; } = 4;
|
||||||
|
|
||||||
/// <summary>정렬 스냅 허용 거리(px)</summary>
|
/// <summary>정렬 허용 거리 — <b>화면</b> px</summary>
|
||||||
public double Tolerance { get; set; } = 6;
|
public double ScreenTolerance { get; set; } = 6;
|
||||||
|
|
||||||
|
/// <summary>간격 균등 스냅 사용</summary>
|
||||||
|
public bool EnableSpacing { get; set; } = true;
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Methods
|
#region Methods
|
||||||
/// <summary>드래그 시작 — 스냅 후보 사전수집(월드 좌표)</summary>
|
/// <summary>
|
||||||
public void BeginDrag(PageViewModel page, IReadOnlySet<ControlViewModel> moving)
|
/// 드래그 시작 — 후보를 한 번만 모은다.
|
||||||
|
///
|
||||||
|
/// 후보에는 <b>컨테이너 자식까지</b> 넣는다. 히트테스트는 재귀하는데 스냅만 최상위를 보던 것은
|
||||||
|
/// 규칙 불일치였다 — 패널 안 라벨에 맞추려 해도 붙지 않았다.
|
||||||
|
/// 잠긴 컨트롤은 후보로 <b>남긴다</b>(고정 기준선으로 쓰는 것이 맞다). 숨긴 것만 뺀다.
|
||||||
|
/// </summary>
|
||||||
|
public void BeginDrag(DesignerViewModel designer, PageViewModel page,
|
||||||
|
IReadOnlySet<ControlViewModel> moving)
|
||||||
{
|
{
|
||||||
candidatesX.Clear();
|
var siblings = new List<SnapRect>();
|
||||||
candidatesY.Clear();
|
foreach (var vm in designer.ControlsOfPage(page))
|
||||||
|
|
||||||
// 용지 경계·중앙
|
|
||||||
candidatesX.Add(0);
|
|
||||||
candidatesX.Add(page.WidthDip / 2);
|
|
||||||
candidatesX.Add(page.WidthDip);
|
|
||||||
candidatesY.Add(page.OffsetY);
|
|
||||||
candidatesY.Add(page.OffsetY + page.HeightDip / 2);
|
|
||||||
candidatesY.Add(page.OffsetY + page.HeightDip);
|
|
||||||
|
|
||||||
foreach (var sibling in page.Controls)
|
|
||||||
{
|
{
|
||||||
if (moving.Contains(sibling) || sibling.Model.Hidden)
|
// 끌려가는 컨테이너의 자식은 선택 목록에 없지만 부모를 따라 움직인다 —
|
||||||
|
// 빼지 않으면 자기 자신에게 붙는다
|
||||||
|
if (moving.Contains(vm) || vm.Model.Hidden)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
candidatesX.Add(sibling.X);
|
siblings.Add(ToSnapRect(designer.WorldBoundsOf(vm)));
|
||||||
candidatesX.Add(sibling.X + sibling.Width / 2);
|
|
||||||
candidatesX.Add(sibling.X + sibling.Width);
|
|
||||||
|
|
||||||
var top = page.OffsetY + sibling.Y;
|
|
||||||
candidatesY.Add(top);
|
|
||||||
candidatesY.Add(top + sibling.Height / 2);
|
|
||||||
candidatesY.Add(top + sibling.Height);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var paper = new SnapRect(0, page.OffsetY, page.WidthDip, page.HeightDip);
|
||||||
|
solver.Prepare(new SnapContext
|
||||||
|
{
|
||||||
|
Pages = new[] { paper },
|
||||||
|
Siblings = siblings,
|
||||||
|
// 페이지를 넘는 '균등 간격'은 뜻이 없다
|
||||||
|
SpacingPage = paper,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>드래그 끝 — 후보를 버린다. 남겨 두면 다음 드래그가 낡은 좌표에 붙는다</summary>
|
||||||
/// 이동 스냅 — bbox(월드)에 (dx,dy) 적용 시 정렬 후보와 최근접 정합.
|
public void EndDrag() => solver.Reset();
|
||||||
/// free=true(Alt)면 스냅 없이 원본 이동량 그대로.
|
|
||||||
/// </summary>
|
|
||||||
public SnapResult SnapMove(Rect bbox, double dx, double dy, bool free)
|
|
||||||
{
|
|
||||||
if (free)
|
|
||||||
{
|
|
||||||
return new SnapResult(dx, dy, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
var movedLeft = bbox.X + dx;
|
/// <summary>이동 스냅 — bbox(월드)에 (dx,dy) 를 적용한 자리에서 정렬·간격·격자 순으로 붙인다</summary>
|
||||||
var movedTop = bbox.Y + dy;
|
public SnapOutcome SnapMove(Rect bbox, double dx, double dy, bool free, double zoom)
|
||||||
|
=> solver.SolveMove(ToSnapRect(bbox), dx, dy, OptionsFor(zoom), free);
|
||||||
|
|
||||||
var (adjustX, guideX) = SnapAxis(new[] { movedLeft, movedLeft + bbox.Width / 2, movedLeft + bbox.Width }, candidatesX);
|
/// <summary>리사이즈 모서리 스냅</summary>
|
||||||
var (adjustY, guideY) = SnapAxis(new[] { movedTop, movedTop + bbox.Height / 2, movedTop + bbox.Height }, candidatesY);
|
public SnapEdgeOutcome SnapEdge(double value, bool isXAxis, bool free, double zoom)
|
||||||
|
=> solver.SolveEdge(value, isXAxis, OptionsFor(zoom), free);
|
||||||
|
|
||||||
// 정합 실패 축은 그리드 스냅
|
private SnapOptions OptionsFor(double zoom)
|
||||||
var resultDx = guideX is not null ? dx + adjustX : Math.Round(movedLeft / GridSize) * GridSize - bbox.X;
|
=> SnapOptions.ForZoom(ScreenTolerance, GridSize, zoom, EnableSpacing);
|
||||||
var resultDy = guideY is not null ? dy + adjustY : Math.Round(movedTop / GridSize) * GridSize - bbox.Y;
|
|
||||||
|
|
||||||
return new SnapResult(resultDx, resultDy, guideX, guideY);
|
private static SnapRect ToSnapRect(Rect rect)
|
||||||
}
|
=> new(rect.X, rect.Y, rect.Width, rect.Height);
|
||||||
|
|
||||||
/// <summary>리사이즈 스냅 — 움직이는 모서리 좌표만 정합(간이: 그리드 우선)</summary>
|
|
||||||
public double SnapEdge(double value, bool isXAxis, bool free)
|
|
||||||
{
|
|
||||||
if (free)
|
|
||||||
{
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
var candidates = isXAxis ? candidatesX : candidatesY;
|
|
||||||
foreach (var candidate in candidates)
|
|
||||||
{
|
|
||||||
if (Math.Abs(candidate - value) <= Tolerance)
|
|
||||||
{
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Math.Round(value / GridSize) * GridSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
private (double Adjust, double? Guide) SnapAxis(double[] edges, List<double> candidates)
|
|
||||||
{
|
|
||||||
var best = double.MaxValue;
|
|
||||||
double adjust = 0;
|
|
||||||
double? guide = null;
|
|
||||||
foreach (var edge in edges)
|
|
||||||
{
|
|
||||||
foreach (var candidate in candidates)
|
|
||||||
{
|
|
||||||
var distance = candidate - edge;
|
|
||||||
if (Math.Abs(distance) <= Tolerance && Math.Abs(distance) < Math.Abs(best))
|
|
||||||
{
|
|
||||||
best = distance;
|
|
||||||
adjust = distance;
|
|
||||||
guide = candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (adjust, guide);
|
|
||||||
}
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -993,6 +993,55 @@
|
|||||||
<Setter Property="CornerRadius" Value="8" />
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
<Setter Property="Padding" Value="12" />
|
<Setter Property="Padding" Value="12" />
|
||||||
</Style>
|
</Style>
|
||||||
|
<!--
|
||||||
|
구획 머리 — 좌측 패널을 페이지/레이어로 가르는 띠.
|
||||||
|
아래 1px 선이 필수다: 라이트에서 B.PanelHeader(#FAFAFA)와 B.Panel(#FFFFFF)이 1.02:1 이라
|
||||||
|
배경색만으로는 구획 경계가 보이지 않는다.
|
||||||
|
-->
|
||||||
|
<Style x:Key="SectionHead" TargetType="Border">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource B.PanelHeader}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource B.Line}" />
|
||||||
|
<Setter Property="BorderThickness" Value="0,0,0,1" />
|
||||||
|
<Setter Property="Padding" Value="10,5,6,5" />
|
||||||
|
<Setter Property="MinHeight" Value="30" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="SectionHeadText" TargetType="TextBlock">
|
||||||
|
<Setter Property="FontWeight" Value="Bold" />
|
||||||
|
<Setter Property="FontSize" Value="12" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
페이지 행 — 강조는 IsSelected 가 아니라 IsCurrent 가 그린다.
|
||||||
|
암시 ListBoxItem 의 선택 배경을 꺼야 두 강조가 겹쳐 보이지 않는다(레이어 행과 같은 규율).
|
||||||
|
-->
|
||||||
|
<Style x:Key="PageRowItem" TargetType="ListBoxItem">
|
||||||
|
<Setter Property="Padding" Value="4,2" />
|
||||||
|
<Setter Property="Margin" Value="2,1" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ListBoxItem">
|
||||||
|
<Border x:Name="bd" Background="Transparent" CornerRadius="5"
|
||||||
|
Padding="{TemplateBinding Padding}" SnapsToDevicePixels="True">
|
||||||
|
<ContentPresenter />
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Hover}" />
|
||||||
|
</Trigger>
|
||||||
|
<DataTrigger Binding="{Binding IsCurrent}" Value="True">
|
||||||
|
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.LayerSubtree}" />
|
||||||
|
</DataTrigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
<Style x:Key="PanelHeader" TargetType="Border">
|
<Style x:Key="PanelHeader" TargetType="Border">
|
||||||
<Setter Property="Background" Value="{DynamicResource B.Surface}" />
|
<Setter Property="Background" Value="{DynamicResource B.Surface}" />
|
||||||
<Setter Property="BorderBrush" Value="{DynamicResource B.Line}" />
|
<Setter Property="BorderBrush" Value="{DynamicResource B.Line}" />
|
||||||
|
|||||||
@@ -49,6 +49,15 @@
|
|||||||
<SolidColorBrush x:Key="B.Danger" Color="#E04A2B" />
|
<SolidColorBrush x:Key="B.Danger" Color="#E04A2B" />
|
||||||
<SolidColorBrush x:Key="B.Ok" Color="#5BC46E" />
|
<SolidColorBrush x:Key="B.Ok" Color="#5BC46E" />
|
||||||
|
|
||||||
|
<!-- 캔버스 오버레이 — 종이는 양 테마 모두 흰색이라(PageView 의 PaperBrush) 값이 같다.
|
||||||
|
테마별로 가르면 흰 종이 위 대비가 무너진다. -->
|
||||||
|
<!-- 스위치 꺼짐 트랙 — 기존 토큰으로는 못 만든다. B.Input 은 라이트에서 패널과 같은 색이고,
|
||||||
|
다크는 입력 테두리가 Transparent 라 알약이 통째로 사라진다(1.23:1). 비텍스트 3:1 을 넘긴 값이다. -->
|
||||||
|
<SolidColorBrush x:Key="B.SwitchOff" Color="#666666" />
|
||||||
|
|
||||||
|
<SolidColorBrush x:Key="B.CanvasGuide" Color="#F24822" /><!-- 정렬 가이드 -->
|
||||||
|
<SolidColorBrush x:Key="B.CanvasSpacing" Color="#F24822" /><!-- 간격 균등 막대 -->
|
||||||
|
|
||||||
<!-- SQL 구문 색 — 어두운 배경 대비 4.5:1 이상 -->
|
<!-- SQL 구문 색 — 어두운 배경 대비 4.5:1 이상 -->
|
||||||
<SolidColorBrush x:Key="B.SqlKeyword" Color="#6FB6F2" />
|
<SolidColorBrush x:Key="B.SqlKeyword" Color="#6FB6F2" />
|
||||||
<SolidColorBrush x:Key="B.SqlFunction" Color="#C89BE8" />
|
<SolidColorBrush x:Key="B.SqlFunction" Color="#C89BE8" />
|
||||||
|
|||||||
@@ -83,6 +83,15 @@
|
|||||||
<SolidColorBrush x:Key="B.Danger" Color="#C4321A" />
|
<SolidColorBrush x:Key="B.Danger" Color="#C4321A" />
|
||||||
<SolidColorBrush x:Key="B.Ok" Color="#1A7F37" />
|
<SolidColorBrush x:Key="B.Ok" Color="#1A7F37" />
|
||||||
|
|
||||||
|
<!-- 캔버스 오버레이 — 종이는 양 테마 모두 흰색이라(PageView 의 PaperBrush) 값이 같다.
|
||||||
|
테마별로 가르면 흰 종이 위 대비가 무너진다. -->
|
||||||
|
<!-- 스위치 꺼짐 트랙 — 기존 토큰으로는 못 만든다. B.Input 은 라이트에서 패널과 같은 색이고,
|
||||||
|
다크는 입력 테두리가 Transparent 라 알약이 통째로 사라진다(1.23:1). 비텍스트 3:1 을 넘긴 값이다. -->
|
||||||
|
<SolidColorBrush x:Key="B.SwitchOff" Color="#8A8A8A" />
|
||||||
|
|
||||||
|
<SolidColorBrush x:Key="B.CanvasGuide" Color="#F24822" /><!-- 정렬 가이드 -->
|
||||||
|
<SolidColorBrush x:Key="B.CanvasSpacing" Color="#F24822" /><!-- 간격 균등 막대 -->
|
||||||
|
|
||||||
<!-- SQL 구문 색 — 흰 배경 대비 4.5:1 이상.
|
<!-- SQL 구문 색 — 흰 배경 대비 4.5:1 이상.
|
||||||
치환 변수는 이 편집기에서 가장 중요한 조각이라 가장 진하게 둔다. -->
|
치환 변수는 이 편집기에서 가장 중요한 조각이라 가장 진하게 둔다. -->
|
||||||
<SolidColorBrush x:Key="B.SqlKeyword" Color="#0B5FA5" />
|
<SolidColorBrush x:Key="B.SqlKeyword" Color="#0B5FA5" />
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ public sealed class DesignerViewModel : ViewModelBase
|
|||||||
private bool isTabOrderMode;
|
private bool isTabOrderMode;
|
||||||
private readonly List<ControlViewModel> tabOrderSequence = new();
|
private readonly List<ControlViewModel> tabOrderSequence = new();
|
||||||
private readonly Dictionary<string, SpreadGridInfo>? spreadDesigns;
|
private readonly Dictionary<string, SpreadGridInfo>? spreadDesigns;
|
||||||
|
|
||||||
|
/// <summary>정렬을 페이지에 맞출 것인가 — 인스펙터 토글이 세운다</summary>
|
||||||
|
private bool alignToPage;
|
||||||
// 레이어 목록 상태 — 접힌 그룹과 타입 필터는 문서가 아니라 편집 세션에 속한다(Undo 대상 아님)
|
// 레이어 목록 상태 — 접힌 그룹과 타입 필터는 문서가 아니라 편집 세션에 속한다(Undo 대상 아님)
|
||||||
private readonly HashSet<string> collapsedGroups = new(StringComparer.Ordinal);
|
private readonly HashSet<string> collapsedGroups = new(StringComparer.Ordinal);
|
||||||
private readonly HashSet<PageViewModel> collapsedPages = new();
|
private readonly HashSet<PageViewModel> collapsedPages = new();
|
||||||
@@ -154,7 +157,7 @@ public sealed class DesignerViewModel : ViewModelBase
|
|||||||
Selection.ActivePage = value;
|
Selection.ActivePage = value;
|
||||||
}
|
}
|
||||||
// 아웃라인이 전 페이지를 담으므로 목록 자체는 그대로다 — 활성 표시만 갱신한다
|
// 아웃라인이 전 페이지를 담으므로 목록 자체는 그대로다 — 활성 표시만 갱신한다
|
||||||
foreach (var header in LayerRows.OfType<LayerPageViewModel>())
|
foreach (var header in PageRows)
|
||||||
{
|
{
|
||||||
header.NotifyStateChanged();
|
header.NotifyStateChanged();
|
||||||
}
|
}
|
||||||
@@ -210,6 +213,12 @@ public sealed class DesignerViewModel : ViewModelBase
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public ObservableCollection<object> LayerRows { get; } = new();
|
public ObservableCollection<object> LayerRows { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>페이지 구획 목록 — 트리와 별도. 페이지 조작(추가·복제·삭제·이동)은 전부 이쪽이다</summary>
|
||||||
|
public ObservableCollection<LayerPageViewModel> PageRows { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>트리에 페이지 마디를 둘 것인가 — 한 장짜리 문서에서는 뜻이 없다</summary>
|
||||||
|
public bool ShowPageNodes => Pages.Count > 1;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 컨트롤 행이 하나도 없는지 — 빈 목록에 안내를 띄우기 위한 플래그.
|
/// 컨트롤 행이 하나도 없는지 — 빈 목록에 안내를 띄우기 위한 플래그.
|
||||||
/// 페이지 헤더와 '페이지 추가' 행은 늘 있으므로 행 수가 아니라 컨트롤 행 유무로 판단한다.
|
/// 페이지 헤더와 '페이지 추가' 행은 늘 있으므로 행 수가 아니라 컨트롤 행 유무로 판단한다.
|
||||||
@@ -239,6 +248,8 @@ public sealed class DesignerViewModel : ViewModelBase
|
|||||||
if (SetProperty(ref zoom, clamped))
|
if (SetProperty(ref zoom, clamped))
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(ZoomPercentText));
|
OnPropertyChanged(nameof(ZoomPercentText));
|
||||||
|
// 오버레이 선 두께를 화면 1px 로 유지하려면 오버레이가 배율을 알아야 한다
|
||||||
|
Overlay.Zoom = clamped;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,7 +281,7 @@ public sealed class DesignerViewModel : ViewModelBase
|
|||||||
Snap = new SnapEngine
|
Snap = new SnapEngine
|
||||||
{
|
{
|
||||||
GridSize = ConfigService.Current.GridSize,
|
GridSize = ConfigService.Current.GridSize,
|
||||||
Tolerance = ConfigService.Current.SnapThreshold,
|
ScreenTolerance = ConfigService.Current.SnapThreshold,
|
||||||
};
|
};
|
||||||
Undo = new UndoService(() => Document, ReplaceDocument);
|
Undo = new UndoService(() => Document, ReplaceDocument);
|
||||||
Interaction = new InteractionController(this);
|
Interaction = new InteractionController(this);
|
||||||
@@ -921,9 +932,38 @@ public sealed class DesignerViewModel : ViewModelBase
|
|||||||
index++;
|
index++;
|
||||||
vm.Model.Props.SetText("TabIndex", (999999 + index).ToString());
|
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++)
|
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();
|
Inspector.Rebuild();
|
||||||
}
|
}
|
||||||
@@ -953,6 +993,51 @@ public sealed class DesignerViewModel : ViewModelBase
|
|||||||
public IReadOnlyList<string> ControlNames()
|
public IReadOnlyList<string> ControlNames()
|
||||||
=> AllControls().Select(c => c.Id).Where(id => id.Length > 0).Distinct(StringComparer.Ordinal).ToList();
|
=> AllControls().Select(c => c.Id).Where(id => id.Length > 0).Distinct(StringComparer.Ordinal).ToList();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 한 페이지의 컨트롤 순회(컨테이너 자식 포함) — 스냅 후보 수집용.
|
||||||
|
/// 스냅은 앵커 페이지 안에서만 본다. 전 페이지를 넣으면 후보가 폭발하고,
|
||||||
|
/// 다른 종이의 좌표에 붙는 것은 뜻도 없다.
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<ControlViewModel> ControlsOfPage(PageViewModel page)
|
||||||
|
{
|
||||||
|
foreach (var vm in page.Controls)
|
||||||
|
{
|
||||||
|
foreach (var descendant in Descend(vm))
|
||||||
|
{
|
||||||
|
yield return descendant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>선택 항목과 그 자손 전부 — 끌려가는 컨테이너의 자식을 스냅 후보에서 빼는 데 쓴다</summary>
|
||||||
|
public HashSet<ControlViewModel> SelectionWithDescendants()
|
||||||
|
{
|
||||||
|
var set = new HashSet<ControlViewModel>();
|
||||||
|
foreach (var item in Selection.Items)
|
||||||
|
{
|
||||||
|
foreach (var vm in Descend(item))
|
||||||
|
{
|
||||||
|
set.Add(vm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<ControlViewModel> Descend(ControlViewModel viewModel)
|
||||||
|
{
|
||||||
|
yield return viewModel;
|
||||||
|
if (viewModel is ContainerViewModel container)
|
||||||
|
{
|
||||||
|
foreach (var child in container.Children)
|
||||||
|
{
|
||||||
|
foreach (var descendant in Descend(child))
|
||||||
|
{
|
||||||
|
yield return descendant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>문서 전체 컨트롤 순회(컨테이너 자식 포함, 모델 순서)</summary>
|
/// <summary>문서 전체 컨트롤 순회(컨테이너 자식 포함, 모델 순서)</summary>
|
||||||
private IEnumerable<ControlViewModel> AllControls()
|
private IEnumerable<ControlViewModel> AllControls()
|
||||||
{
|
{
|
||||||
@@ -1416,35 +1501,175 @@ public sealed class DesignerViewModel : ViewModelBase
|
|||||||
Selection.Set(page.Controls.Where(c => !c.Model.Hidden), page.Controls.LastOrDefault());
|
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)
|
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;
|
return;
|
||||||
}
|
}
|
||||||
var bbox = SelectionWorldBounds();
|
|
||||||
if (bbox is null)
|
// 부모와 그 자식을 함께 선택할 수 있다(레이어 목록). 부모를 옮기면 자식 월드 좌표가 따라 움직이는데
|
||||||
|
// 자식에게도 절대좌표를 대입하면 두 번 옮겨져 어긋난다 — 조상이 선택에 있는 것은 뺀다.
|
||||||
|
var targets = Selection.Items.Where(vm => !HasSelectedAncestor(vm)).ToList();
|
||||||
|
if (targets.Count == 0)
|
||||||
{
|
{
|
||||||
return;
|
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);
|
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);
|
||||||
case "Left": vm.X = Math.Round(bbox.Value.Left - offset.X); break;
|
var aligned = SheetMe.Core.Layout.AlignSolver.Apply(world, reference, direction);
|
||||||
case "Right": vm.X = Math.Round(bbox.Value.Right - vm.Width - offset.X); break;
|
var x = aligned.X - offset.X;
|
||||||
case "Top": vm.Y = Math.Round(bbox.Value.Top - offset.Y); break;
|
var y = aligned.Y - offset.Y;
|
||||||
case "Bottom": vm.Y = Math.Round(bbox.Value.Bottom - vm.Height - offset.Y); break;
|
changed |= x != vm.X || y != vm.Y;
|
||||||
case "CenterH": vm.X = Math.Round(bbox.Value.Left + (bbox.Value.Width - vm.Width) / 2 - offset.X); break;
|
moves.Add((vm, x, y));
|
||||||
case "CenterV": vm.Y = Math.Round(bbox.Value.Top + (bbox.Value.Height - vm.Height) / 2 - offset.Y); break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (!changed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Undo.Snapshot();
|
||||||
|
foreach (var (vm, x, y) in moves)
|
||||||
|
{
|
||||||
|
vm.X = x;
|
||||||
|
vm.Y = y;
|
||||||
|
}
|
||||||
|
// 페이지 소속은 바뀌지 않는다(세로 다중 페이지는 위에서 거부했다) — ReassignPagesAfterMove 는 부르지 않는다
|
||||||
Selection.NotifyBoundsChanged();
|
Selection.NotifyBoundsChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 정렬 기준 — 무엇에 맞출 것인가.
|
||||||
|
///
|
||||||
|
/// 여럿을 골라 놓고도 "서로 맞추기"와 "종이에 맞추기"는 다른 작업이다.
|
||||||
|
/// 예전에는 전자만 가능했다.
|
||||||
|
/// </summary>
|
||||||
|
public bool AlignToPage
|
||||||
|
{
|
||||||
|
get => alignToPage;
|
||||||
|
set => SetProperty(ref alignToPage, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>정렬 기준 사각형(월드)</summary>
|
||||||
|
private SheetMe.Core.Layout.SnapRect? ReferenceFor(IReadOnlyList<ControlViewModel> targets)
|
||||||
|
{
|
||||||
|
if (alignToPage)
|
||||||
|
{
|
||||||
|
return PageOf(targets[0]) is { } target
|
||||||
|
? new SheetMe.Core.Layout.SnapRect(0, target.OffsetY, target.WidthDip, target.HeightDip)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (alignToPage)
|
||||||
|
{
|
||||||
|
return "페이지 기준";
|
||||||
|
}
|
||||||
|
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 string AlignSelectionScopeLabel()
|
||||||
|
{
|
||||||
|
var items = Selection.Items;
|
||||||
|
if (items.Count >= 2)
|
||||||
|
{
|
||||||
|
return "선택 영역";
|
||||||
|
}
|
||||||
|
return items.Count == 1 && items[0].Parent is not null ? "부모" : "페이지";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>간격 균등이 뜻을 갖는가 — 사이가 있어야 나눌 것이 있다</summary>
|
||||||
|
public bool CanDistribute() => Selection.Items.Count >= 3;
|
||||||
|
|
||||||
|
/// <summary>세로 정렬이 가능한가 — 여러 페이지에 걸친 선택에서는 뜻이 없다</summary>
|
||||||
|
public bool CanAlignVertical()
|
||||||
|
=> Selection.Items.Count > 0 && Selection.Items.Select(PageOf).Distinct().Count() <= 1;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 선택 그룹화 — 동일 GroupId 부여(2개 이상, 최상위 컨트롤만) + 기본 이름 등록.
|
/// 선택 그룹화 — 동일 GroupId 부여(2개 이상, 최상위 컨트롤만) + 기본 이름 등록.
|
||||||
/// 잠긴 컨트롤은 애초에 선택에 들어오지 않는다(SelectionService 가 거른다).
|
/// 잠긴 컨트롤은 애초에 선택에 들어오지 않는다(SelectionService 가 거른다).
|
||||||
@@ -1452,21 +1677,93 @@ public sealed class DesignerViewModel : ViewModelBase
|
|||||||
public void GroupSelection()
|
public void GroupSelection()
|
||||||
{
|
{
|
||||||
var targets = Selection.Items.Where(vm => vm.Parent is null && PageOf(vm) is not null).ToList();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
Undo.Snapshot();
|
if (GroupBlockReason(page, targets) is { } reason)
|
||||||
var groupId = Guid.NewGuid().ToString("N");
|
|
||||||
foreach (var vm in targets)
|
|
||||||
{
|
{
|
||||||
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>
|
/// <summary>
|
||||||
/// 선택 그룹 해제 — 선택에 걸린 그룹의 <b>전 멤버</b>를 푼다(선택되지 않은 멤버 포함).
|
/// 선택 그룹 해제 — 선택에 걸린 그룹의 <b>전 멤버</b>를 푼다(선택되지 않은 멤버 포함).
|
||||||
/// 일부만 풀면 멤버 1개짜리 반쪽 그룹이 남아 다음 선택부터 동작이 이상해진다.
|
/// 일부만 풀면 멤버 1개짜리 반쪽 그룹이 남아 다음 선택부터 동작이 이상해진다.
|
||||||
@@ -1478,6 +1775,16 @@ public sealed class DesignerViewModel : ViewModelBase
|
|||||||
.Where(id => id is not null)
|
.Where(id => id is not null)
|
||||||
.Distinct(StringComparer.Ordinal)
|
.Distinct(StringComparer.Ordinal)
|
||||||
.ToList();
|
.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)
|
if (groupIds.Count == 0)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -1497,9 +1804,76 @@ public sealed class DesignerViewModel : ViewModelBase
|
|||||||
RebuildLayerRows();
|
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>
|
||||||
|
/// 이 컨트롤을 클릭했을 때 실제로 잡히는 것 — 최상위 컨테이너까지 거슬러 올라간다.
|
||||||
|
/// 그룹이 진짜 패널이 되면서, 자식을 집으면 그 하나만 끌려가던 문제를 여기서 막는다.
|
||||||
|
/// </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 };
|
||||||
@@ -1653,10 +2027,17 @@ public sealed class DesignerViewModel : ViewModelBase
|
|||||||
IsRebuildingLayers = true;
|
IsRebuildingLayers = true;
|
||||||
LayerRows.Clear();
|
LayerRows.Clear();
|
||||||
|
|
||||||
|
PageRows.Clear();
|
||||||
foreach (var page in Pages)
|
foreach (var page in Pages)
|
||||||
{
|
{
|
||||||
var header = new LayerPageViewModel(this, page);
|
var header = new LayerPageViewModel(this, page);
|
||||||
LayerRows.Add(header);
|
PageRows.Add(header);
|
||||||
|
// 한 장뿐이면 트리에 페이지 마디를 두지 않는다 — 모든 컨트롤이 그 페이지 소속이라 정보가 0 이고,
|
||||||
|
// 최상위가 들여쓰기 없이 바로 보인다. 두 장 이상일 때만 경계 구분자로 남긴다.
|
||||||
|
if (ShowPageNodes)
|
||||||
|
{
|
||||||
|
LayerRows.Add(header);
|
||||||
|
}
|
||||||
if (!header.IsExpanded)
|
if (!header.IsExpanded)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
@@ -1694,7 +2075,6 @@ public sealed class DesignerViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LayerRows.Add(new LayerAddPageViewModel());
|
|
||||||
|
|
||||||
IsRebuildingLayers = false;
|
IsRebuildingLayers = false;
|
||||||
UpdateLayerHighlight();
|
UpdateLayerHighlight();
|
||||||
@@ -1704,10 +2084,10 @@ public sealed class DesignerViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>페이지 직속 컨트롤·그룹 폴더의 들여쓰기 — 페이지 노드 아래라 항상 한 칸 들어간다</summary>
|
/// <summary>페이지 직속 컨트롤·그룹 폴더의 들여쓰기 — 페이지 노드 아래라 항상 한 칸 들어간다</summary>
|
||||||
private const double PageChildIndent = 12;
|
private double PageChildIndent => ShowPageNodes ? 12 : 0;
|
||||||
|
|
||||||
/// <summary>그룹 멤버 들여쓰기 — 페이지 직속보다 한 단계 더</summary>
|
/// <summary>그룹 멤버 들여쓰기 — 페이지 직속보다 한 단계 더</summary>
|
||||||
private const double GroupMemberIndent = 32;
|
private double GroupMemberIndent => PageChildIndent + 20;
|
||||||
|
|
||||||
/// <summary>컨테이너(패널/그룹박스) 자식의 추가 들여쓰기</summary>
|
/// <summary>컨테이너(패널/그룹박스) 자식의 추가 들여쓰기</summary>
|
||||||
private const double ContainerChildIndent = 16;
|
private const double ContainerChildIndent = 16;
|
||||||
@@ -1868,7 +2248,7 @@ public sealed class DesignerViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
Selection.Clear();
|
Selection.Clear();
|
||||||
InspectedPage = page;
|
InspectedPage = page;
|
||||||
foreach (var header in LayerRows.OfType<LayerPageViewModel>())
|
foreach (var header in PageRows)
|
||||||
{
|
{
|
||||||
header.NotifyStateChanged();
|
header.NotifyStateChanged();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,81 @@ public sealed class InspectorViewModel : ViewModelBase
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool HasSelection => designer.Selection.Items.Count > 0 || designer.InspectedPage is not null;
|
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>'선택 영역' 세그먼트 문구 — 하나만 골랐으면 그건 부모(또는 페이지)다</summary>
|
||||||
|
public string AlignSelectionScopeLabel => designer.AlignSelectionScopeLabel();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 기준 선택 줄을 보일 것인가 — 고를 것이 둘일 때만.
|
||||||
|
/// 최상위 컨트롤 하나를 골랐으면 '선택 영역'도 곧 페이지라 알약 둘이 똑같은 말을 하게 된다.
|
||||||
|
/// </summary>
|
||||||
|
public bool ShowAlignScope => designer.Selection.Items.Count >= 2
|
||||||
|
|| (designer.Selection.Items.Count == 1 && designer.Selection.Items[0].Parent is not null);
|
||||||
|
|
||||||
|
/// <summary>컨테이너 안의 것을 페이지에 맞추면 부모 밖으로 나간다 — 막지는 않되 말은 해 준다</summary>
|
||||||
|
public string PageScopeTip => designer.Selection.Items.Count == 1
|
||||||
|
&& designer.Selection.Items[0].Parent is not null
|
||||||
|
? "종이 기준으로 맞춥니다 — 부모 밖으로 나가면 실행 시 잘릴 수 있습니다"
|
||||||
|
: "종이 기준으로 맞춥니다";
|
||||||
|
|
||||||
|
/// <summary>기준 = 선택 영역(또는 부모)</summary>
|
||||||
|
public bool AlignToSelection
|
||||||
|
{
|
||||||
|
get => !designer.AlignToPage;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (value && designer.AlignToPage)
|
||||||
|
{
|
||||||
|
designer.AlignToPage = false;
|
||||||
|
NotifyAlignScope();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>기준 = 페이지</summary>
|
||||||
|
public bool AlignToPage
|
||||||
|
{
|
||||||
|
get => designer.AlignToPage;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (value && !designer.AlignToPage)
|
||||||
|
{
|
||||||
|
designer.AlignToPage = true;
|
||||||
|
NotifyAlignScope();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>간격 균등 버튼을 보일 것인가 — 사이가 있어야 나눌 것이 있다(3개 이상)</summary>
|
||||||
|
public bool CanDistribute => designer.CanDistribute();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 정렬 실행 — 뷰의 DataContext 가 인스펙터라 디자이너 커맨드에 직접 닿을 수 없어 위임한다.
|
||||||
|
/// </summary>
|
||||||
|
public M.Framework.WPF.ICustomCommand AlignCommand { get; }
|
||||||
|
|
||||||
|
/// <summary>간격 균등 실행 — 위와 같은 이유로 위임</summary>
|
||||||
|
public M.Framework.WPF.ICustomCommand DistributeCommand { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 선택된 탭. 컨트롤을 바꿔도 유지된다 — 데이터 탭에서 여러 컨트롤의 태그를 잇달아 손보는
|
/// 선택된 탭. 컨트롤을 바꿔도 유지된다 — 데이터 탭에서 여러 컨트롤의 태그를 잇달아 손보는
|
||||||
/// 작업이 흔한데, 선택할 때마다 디자인 탭으로 되돌아가면 매번 다시 눌러야 한다.
|
/// 작업이 흔한데, 선택할 때마다 디자인 탭으로 되돌아가면 매번 다시 눌러야 한다.
|
||||||
@@ -123,6 +198,44 @@ public sealed class InspectorViewModel : ViewModelBase
|
|||||||
_ => "표시할 속성이 없습니다",
|
_ => "표시할 속성이 없습니다",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>제목 줄 이름 — 타입은 툴팁(<see cref="Summary"/>)으로 보낸다</summary>
|
||||||
|
public string HeaderName
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (designer.Selection.Items.Count == 0)
|
||||||
|
{
|
||||||
|
return designer.InspectedPage is { } page ? $"페이지 {page.Index + 1}" : "선택 없음";
|
||||||
|
}
|
||||||
|
return designer.Selection.Items.Count == 1
|
||||||
|
? designer.Selection.Primary?.Id ?? string.Empty
|
||||||
|
: $"{designer.Selection.Items.Count}개 선택";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 제목 줄 아이콘. 없는 이름을 넘기면 예외 없이 빈 자리가 되므로 반드시 카탈로그에 있는 이름이어야 한다.
|
||||||
|
/// 여러 개를 골랐어도 타입이 같으면 그 타입 아이콘을 보여 준다 — 섞였을 때만 일반 도형.
|
||||||
|
/// </summary>
|
||||||
|
public string HeaderIcon
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var items = designer.Selection.Items;
|
||||||
|
if (items.Count == 0)
|
||||||
|
{
|
||||||
|
return "file-text";
|
||||||
|
}
|
||||||
|
var type = items[0].Type;
|
||||||
|
return items.All(vm => vm.Type == type)
|
||||||
|
? SheetMe.Designer.Controls.PaletteIconCatalog.IconOf(type)
|
||||||
|
: "square";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>더보기 버튼은 하나를 골랐을 때만 — 여럿에 걸친 컨트롤 단위 명령이 없다</summary>
|
||||||
|
public bool IsSingleSelection => designer.Selection.Items.Count == 1;
|
||||||
|
|
||||||
/// <summary>선택 요약 텍스트</summary>
|
/// <summary>선택 요약 텍스트</summary>
|
||||||
public string Summary
|
public string Summary
|
||||||
{
|
{
|
||||||
@@ -146,12 +259,30 @@ public sealed class InspectorViewModel : ViewModelBase
|
|||||||
public InspectorViewModel(DesignerViewModel designer)
|
public InspectorViewModel(DesignerViewModel designer)
|
||||||
{
|
{
|
||||||
this.designer = designer;
|
this.designer = designer;
|
||||||
|
AlignCommand = new M.Framework.WPF.Command(
|
||||||
|
(object parameter) => designer.Align(parameter as string ?? string.Empty));
|
||||||
|
DistributeCommand = new M.Framework.WPF.Command(
|
||||||
|
(object parameter) => designer.AdjustSpacing(parameter as string ?? string.Empty));
|
||||||
designer.Selection.SetChanged += Rebuild;
|
designer.Selection.SetChanged += Rebuild;
|
||||||
designer.Selection.Changed += RefreshBoundsRows; // 드래그/리사이즈 중 X/Y/W/H 실시간 갱신
|
designer.Selection.Changed += RefreshBoundsRows; // 드래그/리사이즈 중 X/Y/W/H 실시간 갱신
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Methods
|
#region Methods
|
||||||
|
/// <summary>정렬 기준에 딸린 표시들 — 선택이 바뀌거나 기준 토글을 누를 때 함께 움직인다</summary>
|
||||||
|
private void NotifyAlignScope()
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(CanAlignVertical));
|
||||||
|
OnPropertyChanged(nameof(CanDistribute));
|
||||||
|
OnPropertyChanged(nameof(AlignScopeText));
|
||||||
|
OnPropertyChanged(nameof(VerticalScopeText));
|
||||||
|
OnPropertyChanged(nameof(AlignSelectionScopeLabel));
|
||||||
|
OnPropertyChanged(nameof(ShowAlignScope));
|
||||||
|
OnPropertyChanged(nameof(PageScopeTip));
|
||||||
|
OnPropertyChanged(nameof(AlignToSelection));
|
||||||
|
OnPropertyChanged(nameof(AlignToPage));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>경계(X/Y/W/H) 행 값만 갱신 — 이동/리사이즈 프레임(재구성 없이 가볍게)</summary>
|
/// <summary>경계(X/Y/W/H) 행 값만 갱신 — 이동/리사이즈 프레임(재구성 없이 가볍게)</summary>
|
||||||
private void RefreshBoundsRows()
|
private void RefreshBoundsRows()
|
||||||
{
|
{
|
||||||
@@ -175,6 +306,11 @@ public sealed class InspectorViewModel : ViewModelBase
|
|||||||
currentSection = null;
|
currentSection = null;
|
||||||
OnPropertyChanged(nameof(Summary));
|
OnPropertyChanged(nameof(Summary));
|
||||||
OnPropertyChanged(nameof(HasSelection));
|
OnPropertyChanged(nameof(HasSelection));
|
||||||
|
OnPropertyChanged(nameof(HeaderName));
|
||||||
|
OnPropertyChanged(nameof(HeaderIcon));
|
||||||
|
OnPropertyChanged(nameof(IsSingleSelection));
|
||||||
|
OnPropertyChanged(nameof(ShowAlignBar));
|
||||||
|
NotifyAlignScope();
|
||||||
|
|
||||||
var items = designer.Selection.Items;
|
var items = designer.Selection.Items;
|
||||||
if (items.Count == 0)
|
if (items.Count == 0)
|
||||||
|
|||||||
@@ -1,16 +1,58 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
using SheetMe.Core.Layout;
|
||||||
|
|
||||||
namespace SheetMe.Designer.ViewModels;
|
namespace SheetMe.Designer.ViewModels;
|
||||||
|
|
||||||
/// <summary>오버레이 가이드선 1개 — 월드 좌표 수직/수평선</summary>
|
/// <summary>
|
||||||
|
/// 오버레이 가이드선 1개 — 월드 좌표 <b>선분</b>(양끝).
|
||||||
|
///
|
||||||
|
/// 예전에는 위치 하나만 들고 화면 끝에서 끝까지(±100000) 그었다. 그러면 "무엇에 맞춘 것인지"가
|
||||||
|
/// 안 보인다 — 종이 밖 회색 배경까지 선이 뻗어 대비도 죽었다. 관련된 것들만 잇는다.
|
||||||
|
/// </summary>
|
||||||
public sealed class GuideLineInfo
|
public sealed class GuideLineInfo
|
||||||
{
|
{
|
||||||
/// <summary>수직선 여부(false=수평선)</summary>
|
public double X1 { get; init; }
|
||||||
public bool IsVertical { get; init; }
|
public double Y1 { get; init; }
|
||||||
|
public double X2 { get; init; }
|
||||||
|
public double Y2 { get; init; }
|
||||||
|
|
||||||
/// <summary>선 위치(월드 X 또는 Y)</summary>
|
/// <summary>수직선 여부 — 진단·테스트용 파생(뷰는 X1..Y2 만 쓴다)</summary>
|
||||||
public double Position { get; init; }
|
public bool IsVertical => Math.Abs(X1 - X2) < 0.001;
|
||||||
|
|
||||||
|
/// <summary>정합 좌표 — 진단·테스트용 파생</summary>
|
||||||
|
public double Position => IsVertical ? X1 : Y1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 간격 균등 표시 1개 — 막대 하나와 양끝 갈고리 둘. 전부 월드 좌표.
|
||||||
|
/// 좌표를 여기서 다 구워 두는 것은 이 저장소 규약이다(<see cref="HandleInfo"/> 와 같다) —
|
||||||
|
/// XAML 에서는 산술을 할 수 없다.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SpacingMarkInfo
|
||||||
|
{
|
||||||
|
public double X1 { get; init; }
|
||||||
|
public double Y1 { get; init; }
|
||||||
|
public double X2 { get; init; }
|
||||||
|
public double Y2 { get; init; }
|
||||||
|
|
||||||
|
/// <summary>시작 쪽 갈고리</summary>
|
||||||
|
public double CapAX1 { get; init; }
|
||||||
|
public double CapAY1 { get; init; }
|
||||||
|
public double CapAX2 { get; init; }
|
||||||
|
public double CapAY2 { get; init; }
|
||||||
|
|
||||||
|
/// <summary>끝 쪽 갈고리</summary>
|
||||||
|
public double CapBX1 { get; init; }
|
||||||
|
public double CapBY1 { get; init; }
|
||||||
|
public double CapBX2 { get; init; }
|
||||||
|
public double CapBY2 { get; init; }
|
||||||
|
|
||||||
|
/// <summary>간격값 표시</summary>
|
||||||
|
public string Text { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public double TextX { get; init; }
|
||||||
|
public double TextY { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>탭순서 배지 1개 — 월드 좌표(컨트롤 좌상단)</summary>
|
/// <summary>탭순서 배지 1개 — 월드 좌표(컨트롤 좌상단)</summary>
|
||||||
@@ -63,6 +105,7 @@ public sealed class SelectionOverlayViewModel : ViewModelBase
|
|||||||
private double marY;
|
private double marY;
|
||||||
private double marW;
|
private double marW;
|
||||||
private double marH;
|
private double marH;
|
||||||
|
private double zoom = 1;
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Properties
|
#region Properties
|
||||||
@@ -105,6 +148,28 @@ public sealed class SelectionOverlayViewModel : ViewModelBase
|
|||||||
/// <summary>정렬 가이드선 목록</summary>
|
/// <summary>정렬 가이드선 목록</summary>
|
||||||
public ObservableCollection<GuideLineInfo> Guides { get; } = new();
|
public ObservableCollection<GuideLineInfo> Guides { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>간격 균등 표시 목록</summary>
|
||||||
|
public ObservableCollection<SpacingMarkInfo> Spacings { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 캔버스 줌 — 오버레이는 줌이 걸린 층에 얹혀 있어서, 선을 화면 1px 로 유지하려면
|
||||||
|
/// 두께에 이 역수를 걸어야 한다. 갈고리 길이도 같은 이유로 이걸 쓴다.
|
||||||
|
/// </summary>
|
||||||
|
public double Zoom
|
||||||
|
{
|
||||||
|
get => zoom;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref zoom, value <= 0 ? 1 : value))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(InverseZoom));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>화면 1px 에 해당하는 월드 길이</summary>
|
||||||
|
public double InverseZoom => 1.0 / zoom;
|
||||||
|
|
||||||
/// <summary>탭순서 배지 목록(탭순서 편집 모드 전용)</summary>
|
/// <summary>탭순서 배지 목록(탭순서 편집 모드 전용)</summary>
|
||||||
public ObservableCollection<TabBadgeInfo> TabBadges { get; } = new();
|
public ObservableCollection<TabBadgeInfo> TabBadges { get; } = new();
|
||||||
#endregion
|
#endregion
|
||||||
@@ -170,18 +235,115 @@ public sealed class SelectionOverlayViewModel : ViewModelBase
|
|||||||
MarH = rect.Value.Height;
|
MarH = rect.Value.Height;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>가이드선 교체</summary>
|
/// <summary>가이드·간격 표시 지우기</summary>
|
||||||
public void SetGuides(double? guideX, double? guideY)
|
public void ClearGuides()
|
||||||
{
|
{
|
||||||
Guides.Clear();
|
if (Guides.Count > 0)
|
||||||
if (guideX is not null)
|
|
||||||
{
|
{
|
||||||
Guides.Add(new GuideLineInfo { IsVertical = true, Position = guideX.Value });
|
Guides.Clear();
|
||||||
}
|
}
|
||||||
if (guideY is not null)
|
if (Spacings.Count > 0)
|
||||||
{
|
{
|
||||||
Guides.Add(new GuideLineInfo { IsVertical = false, Position = guideY.Value });
|
Spacings.Clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 스냅 결과를 표시형으로 — 계산은 <see cref="SheetMe.Core.Layout.SnapSolver"/> 가 끝냈고
|
||||||
|
/// 여기서는 그리기 좌표만 굽는다.
|
||||||
|
///
|
||||||
|
/// 내용이 이전과 같으면 컬렉션을 건드리지 않는다. 드래그는 프레임마다 들어오는데
|
||||||
|
/// 매번 Clear+Add 하면 ItemsControl 이 컨테이너를 통째로 다시 만든다.
|
||||||
|
/// </summary>
|
||||||
|
public void SetGuides(IReadOnlyList<SnapGuide> guides, IReadOnlyList<SpacingMark> spacings)
|
||||||
|
{
|
||||||
|
if (!SameGuides(guides))
|
||||||
|
{
|
||||||
|
Guides.Clear();
|
||||||
|
foreach (var guide in guides)
|
||||||
|
{
|
||||||
|
Guides.Add(guide.IsVertical
|
||||||
|
? new GuideLineInfo { X1 = guide.Position, X2 = guide.Position, Y1 = guide.Start, Y2 = guide.End }
|
||||||
|
: new GuideLineInfo { Y1 = guide.Position, Y2 = guide.Position, X1 = guide.Start, X2 = guide.End });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SameSpacings(spacings))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Spacings.Clear();
|
||||||
|
// 갈고리는 화면 4px — 줌이 걸린 층이라 월드 길이로 환산해 굽는다
|
||||||
|
var cap = 4 * InverseZoom;
|
||||||
|
foreach (var mark in spacings)
|
||||||
|
{
|
||||||
|
var mid = (mark.Start + mark.End) / 2;
|
||||||
|
var text = FormatGap(mark.Gap);
|
||||||
|
// 칩은 좌상단 기준으로 놓이므로 중심 보정을 여기서 넣는다(뷰 설명 참조).
|
||||||
|
// 폭은 글꼴을 재지 않고 글자 수로 어림한다 — 1~4자짜리 숫자라 오차가 눈에 안 띈다.
|
||||||
|
var halfWidth = (4 + 3.4 * text.Length) * InverseZoom;
|
||||||
|
var halfHeight = 7 * InverseZoom;
|
||||||
|
Spacings.Add(mark.IsHorizontal
|
||||||
|
? new SpacingMarkInfo
|
||||||
|
{
|
||||||
|
X1 = mark.Start, Y1 = mark.Cross, X2 = mark.End, Y2 = mark.Cross,
|
||||||
|
CapAX1 = mark.Start, CapAY1 = mark.Cross - cap, CapAX2 = mark.Start, CapAY2 = mark.Cross + cap,
|
||||||
|
CapBX1 = mark.End, CapBY1 = mark.Cross - cap, CapBX2 = mark.End, CapBY2 = mark.Cross + cap,
|
||||||
|
Text = text, TextX = mid - halfWidth, TextY = mark.Cross - halfHeight,
|
||||||
|
}
|
||||||
|
: new SpacingMarkInfo
|
||||||
|
{
|
||||||
|
X1 = mark.Cross, Y1 = mark.Start, X2 = mark.Cross, Y2 = mark.End,
|
||||||
|
CapAX1 = mark.Cross - cap, CapAY1 = mark.Start, CapAX2 = mark.Cross + cap, CapAY2 = mark.Start,
|
||||||
|
CapBX1 = mark.Cross - cap, CapBY1 = mark.End, CapBX2 = mark.Cross + cap, CapBY2 = mark.End,
|
||||||
|
Text = text, TextX = mark.Cross - halfWidth, TextY = mid - halfHeight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatGap(double gap) => $"{Math.Round(gap):0}";
|
||||||
|
|
||||||
|
private bool SameGuides(IReadOnlyList<SnapGuide> guides)
|
||||||
|
{
|
||||||
|
if (Guides.Count != guides.Count)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (var i = 0; i < guides.Count; i++)
|
||||||
|
{
|
||||||
|
var a = Guides[i];
|
||||||
|
var b = guides[i];
|
||||||
|
var x1 = b.IsVertical ? b.Position : b.Start;
|
||||||
|
var y1 = b.IsVertical ? b.Start : b.Position;
|
||||||
|
var x2 = b.IsVertical ? b.Position : b.End;
|
||||||
|
var y2 = b.IsVertical ? b.End : b.Position;
|
||||||
|
if (Math.Abs(a.X1 - x1) > 0.001 || Math.Abs(a.Y1 - y1) > 0.001
|
||||||
|
|| Math.Abs(a.X2 - x2) > 0.001 || Math.Abs(a.Y2 - y2) > 0.001)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool SameSpacings(IReadOnlyList<SpacingMark> spacings)
|
||||||
|
{
|
||||||
|
if (Spacings.Count != spacings.Count)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (var i = 0; i < spacings.Count; i++)
|
||||||
|
{
|
||||||
|
var a = Spacings[i];
|
||||||
|
var b = spacings[i];
|
||||||
|
var x1 = b.IsHorizontal ? b.Start : b.Cross;
|
||||||
|
var y1 = b.IsHorizontal ? b.Cross : b.Start;
|
||||||
|
if (Math.Abs(a.X1 - x1) > 0.001 || Math.Abs(a.Y1 - y1) > 0.001)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,115 @@
|
|||||||
<ctl:IconNameToVisualConverter x:Key="IconName"/>
|
<ctl:IconNameToVisualConverter x:Key="IconName"/>
|
||||||
<BooleanToVisibilityConverter x:Key="BoolToVisibility"/>
|
<BooleanToVisibilityConverter x:Key="BoolToVisibility"/>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
탭 — 밑줄형. 알약은 '고르는 필터'로 읽히는데 탭은 화면을 가르는 것이지 값이 아니다.
|
||||||
|
좌측 패널 탭 스타일을 재사용할 수 없다: 저쪽은 TabControl 의 키 없는 암시 스타일이고
|
||||||
|
템플릿 안에 알약 컨테이너가 박혀 있어, 고치면 레이어·팔레트 패널이 통째로 바뀐다.
|
||||||
|
미선택도 두께 2 를 Transparent 로 잡아 둔다 — 안 그러면 고르는 순간 글자가 2px 튄다.
|
||||||
|
밑줄과 글자는 같은 토큰이어야 한다. B.Accent 는 라이트에서 3.0:1 이라 글자로 못 쓴다.
|
||||||
|
-->
|
||||||
|
<Style x:Key="TabUnderline" TargetType="ToggleButton">
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
|
||||||
|
<Setter Property="FontSize" Value="12"/>
|
||||||
|
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ToggleButton">
|
||||||
|
<Border x:Name="bd" Background="Transparent" BorderBrush="Transparent"
|
||||||
|
BorderThickness="0,0,0,2" Padding="0,7,0,5" SnapsToDevicePixels="True">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
|
<ContentPresenter.Resources>
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ToggleButton}}"/>
|
||||||
|
<Setter Property="TextTrimming" Value="CharacterEllipsis"/>
|
||||||
|
</Style>
|
||||||
|
</ContentPresenter.Resources>
|
||||||
|
</ContentPresenter>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource B.Ink}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsKeyboardFocused" Value="True">
|
||||||
|
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Hover}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsChecked" Value="True">
|
||||||
|
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.AccentText}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource B.AccentText}"/>
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
스위치 — 참/거짓 속성. 체크박스는 17px 사각형이 목록 오른쪽에 흩어져 정렬 축이 없다.
|
||||||
|
손잡이는 켬·끔 모두 흰색이다. 색이 아니라 <b>위치</b>가 상태를 말하게 한다.
|
||||||
|
불확정(다중선택 값 불일치)은 가운데 — 꺼짐으로 보이면 조용한 덮어쓰기 사고가 된다.
|
||||||
|
미끄러지지 않고 튄다: 이 저장소에는 Storyboard 가 한 곳도 없다. 여기만 움직이면 물성이 달라진다.
|
||||||
|
-->
|
||||||
|
<Style x:Key="SwitchToggle" TargetType="ToggleButton">
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Left"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
|
||||||
|
<Setter Property="FontSize" Value="12"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ToggleButton">
|
||||||
|
<!--
|
||||||
|
스위치가 앞, 글씨가 뒤다. 라벨을 72px 칸에 밀어 넣으면
|
||||||
|
"저장본 재조회" 같은 이름이 두세 줄로 접혀 행이 뚱뚱해진다.
|
||||||
|
앞으로 빼면 글씨가 남은 폭을 다 쓰고 스위치끼리 세로 축도 또렷해진다.
|
||||||
|
라벨까지 한 ToggleButton 안이라 글씨를 눌러도 켜진다.
|
||||||
|
-->
|
||||||
|
<DockPanel Background="Transparent">
|
||||||
|
<Border x:Name="track" DockPanel.Dock="Left" Width="36" Height="20"
|
||||||
|
CornerRadius="10" SnapsToDevicePixels="True"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Background="{DynamicResource B.SwitchOff}"
|
||||||
|
BorderThickness="1" BorderBrush="Transparent">
|
||||||
|
<Ellipse x:Name="knob" Width="14" Height="14" Margin="3,0"
|
||||||
|
HorizontalAlignment="Left" VerticalAlignment="Center"
|
||||||
|
Fill="{DynamicResource B.OnAccent}"/>
|
||||||
|
</Border>
|
||||||
|
<ContentPresenter Margin="8,0,0,0" VerticalAlignment="Center">
|
||||||
|
<ContentPresenter.Resources>
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ToggleButton}}"/>
|
||||||
|
<Setter Property="TextTrimming" Value="CharacterEllipsis"/>
|
||||||
|
</Style>
|
||||||
|
</ContentPresenter.Resources>
|
||||||
|
</ContentPresenter>
|
||||||
|
</DockPanel>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsChecked" Value="True">
|
||||||
|
<Setter TargetName="track" Property="Background" Value="{DynamicResource B.AccentFill}"/>
|
||||||
|
<Setter TargetName="knob" Property="HorizontalAlignment" Value="Right"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsChecked" Value="{x:Null}">
|
||||||
|
<Setter TargetName="knob" Property="HorizontalAlignment" Value="Center"/>
|
||||||
|
<Setter TargetName="track" Property="Opacity" Value="0.55"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="track" Property="Opacity" Value="0.85"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource B.Ink}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsKeyboardFocused" Value="True">
|
||||||
|
<Setter TargetName="track" Property="BorderBrush" Value="{DynamicResource B.Accent}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsEnabled" Value="False">
|
||||||
|
<Setter TargetName="track" Property="Opacity" Value="0.45"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!-- 3탭 알약 — 좌측 패널 탭과 같은 결(선택만 채움) -->
|
<!-- 3탭 알약 — 좌측 패널 탭과 같은 결(선택만 채움) -->
|
||||||
<Style x:Key="TabPill" TargetType="ToggleButton">
|
<Style x:Key="TabPill" TargetType="ToggleButton">
|
||||||
<Setter Property="Cursor" Value="Hand"/>
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
@@ -65,7 +174,7 @@
|
|||||||
(자르기보다 낫다 — 잘린 라벨은 툴팁을 띄워봐야 아는 정보가 된다).
|
(자르기보다 낫다 — 잘린 라벨은 툴팁을 띄워봐야 아는 정보가 된다).
|
||||||
-->
|
-->
|
||||||
<Style x:Key="RowLabel" TargetType="TextBlock">
|
<Style x:Key="RowLabel" TargetType="TextBlock">
|
||||||
<Setter Property="Width" Value="92"/>
|
<Setter Property="Width" Value="72"/>
|
||||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||||
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
|
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
|
||||||
<Setter Property="FontSize" Value="12"/>
|
<Setter Property="FontSize" Value="12"/>
|
||||||
@@ -98,10 +207,79 @@
|
|||||||
<Setter Property="MinWidth" Value="22"/>
|
<Setter Property="MinWidth" Value="22"/>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<!-- 세그먼트 한 칸 — 붙어 있는 아이콘 토글(굵게/기울임/밑줄/취소선) -->
|
<!--
|
||||||
<!-- 폭 30 + 간격 2 = 4칸 128px. 라벨 92 와 합쳐 220px 라 패널 최소폭(250 → 행 가용 221)에 들어간다 -->
|
정렬 버튼 — 액션이지 상태가 아니다. SegmentToggle 처럼 채우면 '켜져 있다'로 읽힌다.
|
||||||
<Style x:Key="SegmentToggle" TargetType="ToggleButton">
|
채움 없이 호버 반응만 남긴다. 30x26 / radius 6 / 사이 2px 은 [200] 와이어프레임 .icn 실측값.
|
||||||
|
-->
|
||||||
|
<Style x:Key="AlignBtn" TargetType="Button">
|
||||||
<Setter Property="Width" Value="30"/>
|
<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>
|
||||||
|
|
||||||
|
<!-- 정렬 기준 알약 — 둘 중 하나만 켜지는 선택지라 RadioButton 이다(토글 두 개면 둘 다 꺼질 수 있다) -->
|
||||||
|
<Style x:Key="ScopePill" TargetType="RadioButton">
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="FontSize" Value="11.5"/>
|
||||||
|
<Setter Property="Margin" Value="0,0,4,0"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
|
||||||
|
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="RadioButton">
|
||||||
|
<Border x:Name="bd" Background="{DynamicResource B.Input}" CornerRadius="6"
|
||||||
|
BorderThickness="1" BorderBrush="{DynamicResource B.InputBorder}"
|
||||||
|
Padding="9,3" SnapsToDevicePixels="True">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
|
<ContentPresenter.Resources>
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{Binding Foreground, RelativeSource={RelativeSource AncestorType=RadioButton}}"/>
|
||||||
|
</Style>
|
||||||
|
</ContentPresenter.Resources>
|
||||||
|
</ContentPresenter>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Hover}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsChecked" Value="True">
|
||||||
|
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Accent}"/>
|
||||||
|
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Accent}"/>
|
||||||
|
<Setter Property="Foreground" Value="White"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- 세그먼트 한 칸 — 붙어 있는 아이콘 토글(굵게/기울임/밑줄/취소선).
|
||||||
|
폭을 고정하지 않는다 — UniformGrid 가 가로를 꽉 채워 나눈다(목업의 "가로 꽉"). -->
|
||||||
|
<Style x:Key="SegmentToggle" TargetType="ToggleButton">
|
||||||
<Setter Property="Height" Value="26"/>
|
<Setter Property="Height" Value="26"/>
|
||||||
<Setter Property="Cursor" Value="Hand"/>
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
<Setter Property="Template">
|
<Setter Property="Template">
|
||||||
@@ -168,18 +346,22 @@
|
|||||||
<!-- 접이식 섹션 헤더 — 클릭으로 접기/펴기. 인스펙터는 고급 속성까지 켜면 수십 행이 된다 -->
|
<!-- 접이식 섹션 헤더 — 클릭으로 접기/펴기. 인스펙터는 고급 속성까지 켜면 수십 행이 된다 -->
|
||||||
<DataTemplate DataType="{x:Type ins:SectionRowViewModel}">
|
<DataTemplate DataType="{x:Type ins:SectionRowViewModel}">
|
||||||
<Button Command="{Binding ToggleCommand}" Cursor="Hand" HorizontalContentAlignment="Stretch"
|
<Button Command="{Binding ToggleCommand}" Cursor="Hand" HorizontalContentAlignment="Stretch"
|
||||||
Margin="0,10,0,3" Padding="0,3">
|
Margin="0,0,0,2" Padding="0,3">
|
||||||
<Button.Template>
|
<Button.Template>
|
||||||
<ControlTemplate TargetType="Button">
|
<ControlTemplate TargetType="Button">
|
||||||
<Border x:Name="bd" Background="Transparent" CornerRadius="5" Padding="3,4">
|
<!-- 구분선은 섹션 '위'에 긋는다 — 아래에 그으면 마지막 섹션 밑에 뜬 선이 남는다 -->
|
||||||
|
<Border x:Name="bd" Background="Transparent" CornerRadius="5" Padding="3,9,3,5" Margin="0,4,0,0"
|
||||||
|
BorderThickness="0,1,0,0" BorderBrush="{DynamicResource B.Line}">
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
<ContentControl DockPanel.Dock="Left" Margin="0,0,5,0" VerticalAlignment="Center"
|
<ContentControl DockPanel.Dock="Left" Margin="0,0,5,0" VerticalAlignment="Center"
|
||||||
IsTabStop="False" Focusable="False"
|
IsTabStop="False" Focusable="False"
|
||||||
Content="{Binding ChevronIcon, Converter={StaticResource IconName}, ConverterParameter=12}"/>
|
Content="{Binding ChevronIcon, Converter={StaticResource IconName}, ConverterParameter=12}"/>
|
||||||
<TextBlock DockPanel.Dock="Right" Text="{Binding CountText}" FontSize="11"
|
<!-- 개수는 접혔을 때만. 펼쳤으면 바로 아래가 답이라 잡음이다 -->
|
||||||
Foreground="{DynamicResource B.Muted}" VerticalAlignment="Center" Margin="6,0,2,0"/>
|
<TextBlock x:Name="count" DockPanel.Dock="Right" Text="{Binding CountText}" FontSize="11"
|
||||||
|
Foreground="{DynamicResource B.Muted}" VerticalAlignment="Center" Margin="6,0,2,0"
|
||||||
|
Visibility="Collapsed"/>
|
||||||
<TextBlock Text="{Binding Label}" FontWeight="Bold" FontSize="12"
|
<TextBlock Text="{Binding Label}" FontWeight="Bold" FontSize="12"
|
||||||
Foreground="{DynamicResource B.Muted}" VerticalAlignment="Center"
|
Foreground="{DynamicResource B.Ink}" VerticalAlignment="Center"
|
||||||
TextTrimming="CharacterEllipsis"/>
|
TextTrimming="CharacterEllipsis"/>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</Border>
|
</Border>
|
||||||
@@ -187,6 +369,9 @@
|
|||||||
<Trigger Property="IsMouseOver" Value="True">
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Hover}"/>
|
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Hover}"/>
|
||||||
</Trigger>
|
</Trigger>
|
||||||
|
<DataTrigger Binding="{Binding IsExpanded}" Value="False">
|
||||||
|
<Setter TargetName="count" Property="Visibility" Value="Visible"/>
|
||||||
|
</DataTrigger>
|
||||||
</ControlTemplate.Triggers>
|
</ControlTemplate.Triggers>
|
||||||
</ControlTemplate>
|
</ControlTemplate>
|
||||||
</Button.Template>
|
</Button.Template>
|
||||||
@@ -240,13 +425,13 @@
|
|||||||
<DataTemplate DataType="{x:Type ins:AlignRowViewModel}">
|
<DataTemplate DataType="{x:Type ins:AlignRowViewModel}">
|
||||||
<DockPanel Margin="0,2">
|
<DockPanel Margin="0,2">
|
||||||
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}" VerticalAlignment="Top" Margin="0,4,6,0"/>
|
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}" VerticalAlignment="Top" Margin="0,4,6,0"/>
|
||||||
<ItemsControl ItemsSource="{Binding Cells}" HorizontalAlignment="Left">
|
<ItemsControl ItemsSource="{Binding Cells}">
|
||||||
<ItemsControl.ItemsPanel>
|
<ItemsControl.ItemsPanel>
|
||||||
<ItemsPanelTemplate><UniformGrid Columns="3"/></ItemsPanelTemplate>
|
<ItemsPanelTemplate><UniformGrid Columns="3"/></ItemsPanelTemplate>
|
||||||
</ItemsControl.ItemsPanel>
|
</ItemsControl.ItemsPanel>
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<Button Width="30" Height="26" Padding="0" Margin="0,0,2,2" Cursor="Hand"
|
<Button Height="26" Padding="0" Margin="0,0,2,2" Cursor="Hand"
|
||||||
ToolTip="{Binding Value}"
|
ToolTip="{Binding Value}"
|
||||||
Command="{Binding DataContext.SelectCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
|
Command="{Binding DataContext.SelectCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
|
||||||
CommandParameter="{Binding Value}">
|
CommandParameter="{Binding Value}">
|
||||||
@@ -288,9 +473,10 @@
|
|||||||
<DataTemplate DataType="{x:Type ins:SegmentRowViewModel}">
|
<DataTemplate DataType="{x:Type ins:SegmentRowViewModel}">
|
||||||
<DockPanel Margin="0,2">
|
<DockPanel Margin="0,2">
|
||||||
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
|
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
|
||||||
<ItemsControl ItemsSource="{Binding Items}" HorizontalAlignment="Left">
|
<!-- 가로를 꽉 채워 나눈다 — 라벨을 72 로 줄인 만큼이 그대로 칸으로 간다 -->
|
||||||
|
<ItemsControl ItemsSource="{Binding Items}">
|
||||||
<ItemsControl.ItemsPanel>
|
<ItemsControl.ItemsPanel>
|
||||||
<ItemsPanelTemplate><StackPanel Orientation="Horizontal"/></ItemsPanelTemplate>
|
<ItemsPanelTemplate><UniformGrid Rows="1"/></ItemsPanelTemplate>
|
||||||
</ItemsControl.ItemsPanel>
|
</ItemsControl.ItemsPanel>
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
@@ -333,12 +519,13 @@
|
|||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
|
|
||||||
<DataTemplate DataType="{x:Type ins:ToggleRowViewModel}">
|
<DataTemplate DataType="{x:Type ins:ToggleRowViewModel}">
|
||||||
<DockPanel Margin="0,2">
|
<DockPanel Margin="0,3">
|
||||||
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}" ToolTip="{Binding Label}"/>
|
<TextBlock DockPanel.Dock="Right" Style="{StaticResource MixedHint}"/>
|
||||||
<!-- 3상태: 다중선택에서 값이 갈리면 불확정(IsOn=null). 꺼짐으로 보이면 안 된다 -->
|
<!-- 3상태: 다중선택에서 값이 갈리면 불확정(IsOn=null). 꺼짐으로 보이면 안 된다 -->
|
||||||
<CheckBox IsChecked="{Binding IsOn}" IsThreeState="{Binding IsMixed, Mode=OneWay}"
|
<ToggleButton Style="{StaticResource SwitchToggle}"
|
||||||
VerticalAlignment="Center"/>
|
IsChecked="{Binding IsOn}" IsThreeState="{Binding IsMixed, Mode=OneWay}"
|
||||||
<TextBlock Style="{StaticResource MixedHint}"/>
|
Content="{Binding Label}" ToolTip="{Binding Label}"
|
||||||
|
AutomationProperties.Name="{Binding Label}"/>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
|
|
||||||
@@ -368,7 +555,7 @@
|
|||||||
</DockPanel>
|
</DockPanel>
|
||||||
<!-- 이 컨트롤 타입에서 실제로 쓰이는 태그(빈도순). 아직 안 골랐을 때만 뜬다 —
|
<!-- 이 컨트롤 타입에서 실제로 쓰이는 태그(빈도순). 아직 안 골랐을 때만 뜬다 —
|
||||||
이미 정해진 행 아래에도 칩을 깔면 좁은 인스펙터가 금방 길어진다. -->
|
이미 정해진 행 아래에도 칩을 깔면 좁은 인스펙터가 금방 길어진다. -->
|
||||||
<ItemsControl ItemsSource="{Binding Suggestions}" Margin="92,5,0,2"
|
<ItemsControl ItemsSource="{Binding Suggestions}" Margin="72,5,0,2"
|
||||||
Visibility="{Binding ShowSuggestions, Converter={StaticResource BoolToVisibility}}">
|
Visibility="{Binding ShowSuggestions, Converter={StaticResource BoolToVisibility}}">
|
||||||
<ItemsControl.ItemsPanel>
|
<ItemsControl.ItemsPanel>
|
||||||
<ItemsPanelTemplate>
|
<ItemsPanelTemplate>
|
||||||
@@ -395,10 +582,26 @@
|
|||||||
</DockPanel>
|
</DockPanel>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
|
|
||||||
|
<!-- 전체 속성 — 목록의 탈출구라 채움 없는 테두리 버튼으로 둔다(값이 아니라 이동이다) -->
|
||||||
<DataTemplate DataType="{x:Type ins:ToggleAdvancedRowViewModel}">
|
<DataTemplate DataType="{x:Type ins:ToggleAdvancedRowViewModel}">
|
||||||
<Button Content="{Binding ButtonText}" Command="{Binding ToggleCommand}"
|
<Button Command="{Binding ToggleCommand}" Cursor="Hand"
|
||||||
Margin="0,6,0,2" Padding="6,3" HorizontalAlignment="Stretch"
|
Margin="0,10,0,2" HorizontalAlignment="Stretch" FontSize="11">
|
||||||
Background="{DynamicResource B.Chip}" BorderBrush="{DynamicResource B.Line2}" FontSize="11" Foreground="{DynamicResource B.Muted}"/>
|
<Button.Template>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border x:Name="bd" Background="Transparent" CornerRadius="6" Padding="6,5"
|
||||||
|
BorderThickness="1" BorderBrush="{DynamicResource B.Line2}"
|
||||||
|
SnapsToDevicePixels="True">
|
||||||
|
<TextBlock Text="{Binding ButtonText}" HorizontalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource B.Muted}" FontSize="11"/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Hover}"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Button.Template>
|
||||||
|
</Button>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
|
|
||||||
<DataTemplate DataType="{x:Type ins:AddPropertyRowViewModel}">
|
<DataTemplate DataType="{x:Type ins:AddPropertyRowViewModel}">
|
||||||
@@ -576,44 +779,116 @@
|
|||||||
|
|
||||||
<Grid>
|
<Grid>
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
<TextBlock DockPanel.Dock="Top" Text="{Binding Summary}" FontWeight="Bold"
|
<!-- 제목 — 아이콘으로 무엇을 편집 중인지 즉시 읽히고, ⋮ 가 컨트롤 단위 명령의 자리다.
|
||||||
Margin="10,10,10,2" Foreground="{DynamicResource B.Muted}" TextTrimming="CharacterEllipsis"/>
|
이름만 남기고 타입은 툴팁으로 보낸다(Summary 가 "TextBox — TextBox4" 형태). -->
|
||||||
|
<DockPanel DockPanel.Dock="Top" Margin="10,8,6,4"
|
||||||
|
Visibility="{Binding HasSelection, Converter={StaticResource BoolToVisibility}}">
|
||||||
|
<ContentControl DockPanel.Dock="Left" Margin="0,0,7,0" VerticalAlignment="Center"
|
||||||
|
IsTabStop="False" Focusable="False"
|
||||||
|
Content="{Binding HeaderIcon, Converter={StaticResource IconName}, ConverterParameter=16|B.Muted}"/>
|
||||||
|
<Button DockPanel.Dock="Right" Width="24" Height="24" Padding="0"
|
||||||
|
Style="{StaticResource Subtle}" ToolTip="이 컨트롤에 대한 명령"
|
||||||
|
AutomationProperties.Name="더보기"
|
||||||
|
Visibility="{Binding IsSingleSelection, Converter={StaticResource BoolToVisibility}}"
|
||||||
|
Content="{Binding Source=ellipsis-vertical, Converter={StaticResource IconName}, ConverterParameter=14|B.Muted}"/>
|
||||||
|
<TextBlock Text="{Binding HeaderName}" FontWeight="Bold" FontSize="12.5"
|
||||||
|
Foreground="{DynamicResource B.Ink}" VerticalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis" ToolTip="{Binding Summary}"/>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
디자인 / 데이터 / 동작 3탭 알약([200] 인스펙터 구성).
|
디자인 / 데이터 / 동작 — 밑줄 탭.
|
||||||
배지는 눌러보지 않아도 볼 게 있는지 알려준다 — 컨트롤에 따라 데이터·동작 탭이
|
배지를 뺐다. 개수가 0 인 탭은 RefreshTabCounts 가 탭 자체를 접으므로
|
||||||
통째로 비는 경우가 흔해서(라벨·선 등), 배지가 없으면 빈 탭을 계속 눌러보게 된다.
|
배지가 "0"을 보여 줄 기회가 없고, 누르기 전에 정확한 개수를 알아야 할 일이 없다.
|
||||||
-->
|
-->
|
||||||
<Border DockPanel.Dock="Top" Margin="10,4,10,8" Padding="2" CornerRadius="8"
|
<Border DockPanel.Dock="Top" Margin="10,2,10,8"
|
||||||
Background="{DynamicResource B.Chip}"
|
BorderBrush="{DynamicResource B.Line}" BorderThickness="0,0,0,1"
|
||||||
Visibility="{Binding ShowTabStrip, Converter={StaticResource BoolToVisibility}}">
|
Visibility="{Binding ShowTabStrip, Converter={StaticResource BoolToVisibility}}">
|
||||||
<UniformGrid Rows="1">
|
<UniformGrid Rows="1">
|
||||||
<ToggleButton Style="{StaticResource TabPill}" IsChecked="{Binding IsDesignTab, Mode=TwoWay}"
|
<ToggleButton Style="{StaticResource TabUnderline}" IsChecked="{Binding IsDesignTab, Mode=TwoWay}"
|
||||||
ToolTip="글자·색·정렬·크기 등 보이는 방식">
|
Content="디자인" ToolTip="글자·색·정렬·크기 등 보이는 방식"/>
|
||||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
<ToggleButton Style="{StaticResource TabUnderline}" IsChecked="{Binding IsDataTab, Mode=TwoWay}"
|
||||||
<TextBlock Text="디자인"/>
|
Content="데이터"
|
||||||
<TextBlock Text="{Binding DesignCount}" Style="{StaticResource TabBadge}"/>
|
|
||||||
</StackPanel>
|
|
||||||
</ToggleButton>
|
|
||||||
<ToggleButton Style="{StaticResource TabPill}" IsChecked="{Binding IsDataTab, Mode=TwoWay}"
|
|
||||||
Visibility="{Binding ShowDataTab, Converter={StaticResource BoolToVisibility}}"
|
Visibility="{Binding ShowDataTab, Converter={StaticResource BoolToVisibility}}"
|
||||||
ToolTip="자동 채움·조회·초기값·입력 형식 등 값에 관한 것">
|
ToolTip="자동 채움·조회·초기값·입력 형식 등 값에 관한 것"/>
|
||||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
<ToggleButton Style="{StaticResource TabUnderline}" IsChecked="{Binding IsBehaviorTab, Mode=TwoWay}"
|
||||||
<TextBlock Text="데이터"/>
|
Content="동작"
|
||||||
<TextBlock Text="{Binding DataCount}" Style="{StaticResource TabBadge}"/>
|
|
||||||
</StackPanel>
|
|
||||||
</ToggleButton>
|
|
||||||
<ToggleButton Style="{StaticResource TabPill}" IsChecked="{Binding IsBehaviorTab, Mode=TwoWay}"
|
|
||||||
Visibility="{Binding ShowBehaviorTab, Converter={StaticResource BoolToVisibility}}"
|
Visibility="{Binding ShowBehaviorTab, Converter={StaticResource BoolToVisibility}}"
|
||||||
ToolTip="액션 태그·대상 컨트롤 등 조작했을 때 벌어지는 일">
|
ToolTip="액션 태그·대상 컨트롤 등 조작했을 때 벌어지는 일"/>
|
||||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
|
||||||
<TextBlock Text="동작"/>
|
|
||||||
<TextBlock Text="{Binding BehaviorCount}" Style="{StaticResource TabBadge}"/>
|
|
||||||
</StackPanel>
|
|
||||||
</ToggleButton>
|
|
||||||
</UniformGrid>
|
</UniformGrid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
정렬 — 기준은 언제나 '담고 있는 것'이다. 선택 1개면 페이지(컨테이너 안이면 그 컨테이너),
|
||||||
|
2개 이상이면 선택 영역. 툴팁이 지금 무엇이 기준인지 말해 준다.
|
||||||
|
|
||||||
|
행 VM(Rows)이 아니라 여기 두는 이유 둘: AlignRowViewModel 은 이미 '텍스트 정렬'로
|
||||||
|
이름과 템플릿을 선점했고, 정렬은 스크롤과 무관하게 늘 보이는 편이 조작에 맞다.
|
||||||
|
-->
|
||||||
|
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="12,2,10,6"
|
||||||
|
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}"/>
|
||||||
|
|
||||||
|
<!-- 가로 축과 세로 축 사이 틈 8px — 참조 .align .gap -->
|
||||||
|
<Button Style="{StaticResource AlignBtn}" Margin="8,0,2,0" 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>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
간격 균등 — 사이가 있어야 나눌 것이 있다(3개 이상). 없으면 회색으로 남기지 않고 접는다.
|
||||||
|
정렬 6개와 같은 줄에 두면 8칸 276px 가 되어 패널 최소 폭(250)에서 잘린다 — 줄을 나눈다.
|
||||||
|
-->
|
||||||
|
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="12,0,10,6"
|
||||||
|
Visibility="{Binding CanDistribute, Converter={StaticResource BoolToVisibility}}">
|
||||||
|
<TextBlock Text="간격" VerticalAlignment="Center" FontSize="11.5" Margin="0,0,8,0"
|
||||||
|
Foreground="{DynamicResource B.Muted}"/>
|
||||||
|
<Button Style="{StaticResource AlignBtn}" Command="{Binding DistributeCommand}" CommandParameter="HorizEqual"
|
||||||
|
AutomationProperties.Name="가로 간격 균등" ToolTip="가로 간격을 고르게"
|
||||||
|
Content="{Binding Source=distribute-h, Converter={StaticResource IconName}, ConverterParameter=18|B.Muted}"/>
|
||||||
|
<Button Style="{StaticResource AlignBtn}" Command="{Binding DistributeCommand}" CommandParameter="VertEqual"
|
||||||
|
AutomationProperties.Name="세로 간격 균등" ToolTip="세로 간격을 고르게"
|
||||||
|
Content="{Binding Source=distribute-v, Converter={StaticResource IconName}, ConverterParameter=18|B.Muted}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
정렬 기준 — 여럿을 골라 놓고도 '서로 맞추기'와 '종이에 맞추기'는 다른 작업이다.
|
||||||
|
왼쪽 문구는 선택에 따라 바뀐다: 여럿이면 '선택 영역', 하나면 '부모'(또는 '페이지').
|
||||||
|
-->
|
||||||
|
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="12,0,10,10"
|
||||||
|
Visibility="{Binding ShowAlignScope, Converter={StaticResource BoolToVisibility}}">
|
||||||
|
<TextBlock Text="기준" VerticalAlignment="Center" FontSize="11.5" Margin="0,0,8,0"
|
||||||
|
Foreground="{DynamicResource B.Muted}"/>
|
||||||
|
<RadioButton Style="{StaticResource ScopePill}" GroupName="AlignScope"
|
||||||
|
IsChecked="{Binding AlignToSelection, Mode=TwoWay}"
|
||||||
|
Content="{Binding AlignSelectionScopeLabel}"/>
|
||||||
|
<RadioButton Style="{StaticResource ScopePill}" GroupName="AlignScope"
|
||||||
|
IsChecked="{Binding AlignToPage, Mode=TwoWay}" Content="페이지"
|
||||||
|
ToolTip="{Binding PageScopeTip}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<ItemsControl ItemsSource="{Binding Rows}" Margin="10,0,10,10">
|
<ItemsControl ItemsSource="{Binding Rows}" Margin="10,0,10,10">
|
||||||
<!-- 접힌 섹션의 행은 컨테이너만 숨긴다 — 행 VM 을 재생성하지 않아 편집 상태가 유지된다 -->
|
<!-- 접힌 섹션의 행은 컨테이너만 숨긴다 — 행 VM 을 재생성하지 않아 편집 상태가 유지된다 -->
|
||||||
|
|||||||
@@ -23,34 +23,104 @@
|
|||||||
<ctl:IconSpecConverter x:Key="IconSpec"/>
|
<ctl:IconSpecConverter x:Key="IconSpec"/>
|
||||||
<BooleanToVisibilityConverter x:Key="BoolToVisibility"/>
|
<BooleanToVisibilityConverter x:Key="BoolToVisibility"/>
|
||||||
</UserControl.Resources>
|
</UserControl.Resources>
|
||||||
<DockPanel>
|
<!--
|
||||||
<!-- 검색줄 — 검색칩(가변폭) + 우측 타입 필터 버튼 2개 -->
|
두 구획으로 나눈다 — 위는 페이지, 아래는 레이어.
|
||||||
<DockPanel DockPanel.Dock="Top">
|
페이지 조작(추가·복제·삭제·활성화)이 트리 안에 섞여 있으면 컨트롤을 찾는 눈과 경쟁한다.
|
||||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal"
|
페이지 행을 누르면 캔버스가 그 페이지로 스크롤한다(ActivatePage).
|
||||||
VerticalAlignment="Center" Margin="0,8,8,6">
|
트리는 좁히지 않는다 — 검색이 문서 전체를 훑는 성질과 그룹의 페이지 횡단 집계가 거기 걸려 있다.
|
||||||
<!-- 타입 필터 — 이름을 몰라도 "체크박스만" 처럼 종류로 좁힐 수 있어야 한다 -->
|
-->
|
||||||
<Button Width="26" Height="22" Padding="0"
|
<Grid>
|
||||||
Margin="0,0,2,0" Click="OnLayerFilterClick"
|
<Grid.RowDefinitions>
|
||||||
ToolTip="{Binding CurrentDesigner.LayerTypeFilterTip}">
|
<RowDefinition Height="Auto"/>
|
||||||
<Button.Style>
|
<RowDefinition Height="Auto"/>
|
||||||
<Style TargetType="Button" BasedOn="{StaticResource Subtle}">
|
<RowDefinition Height="*"/>
|
||||||
<Setter Property="Content"
|
</Grid.RowDefinitions>
|
||||||
Value="{Binding Source=filter, Converter={StaticResource IconName}, ConverterParameter=13|B.Muted}"/>
|
|
||||||
<Style.Triggers>
|
<!-- ══ 페이지 ══ -->
|
||||||
<DataTrigger Binding="{Binding CurrentDesigner.HasLayerTypeFilter}" Value="True">
|
<DockPanel Grid.Row="0">
|
||||||
<Setter Property="Content"
|
<Border DockPanel.Dock="Top" Style="{StaticResource SectionHead}">
|
||||||
Value="{Binding Source=filter, Converter={StaticResource IconName}, ConverterParameter=13|B.Accent}"/>
|
<DockPanel>
|
||||||
</DataTrigger>
|
<Button DockPanel.Dock="Right" Style="{StaticResource Subtle}" Width="24" Height="22" Padding="0"
|
||||||
</Style.Triggers>
|
Click="OnPageAddClick" ToolTip="페이지 추가" AutomationProperties.Name="페이지 추가"
|
||||||
</Style>
|
Content="{Binding Source=plus, Converter={StaticResource IconName}, ConverterParameter=14|B.Muted}"/>
|
||||||
</Button.Style>
|
<TextBlock Style="{StaticResource SectionHeadText}" Text="페이지"/>
|
||||||
</Button>
|
</DockPanel>
|
||||||
<!-- 초기화는 숨기지 않고 흐리게 둔다 — 버튼이 사라지면 옆 버튼 위치가 흔들린다 -->
|
</Border>
|
||||||
<Button Style="{StaticResource Subtle}" Width="26" Height="22" Padding="0"
|
<!-- 5행까지 보이고 그 뒤로는 이 목록 안에서만 스크롤한다 — 레이어 트리를 밀어내면 안 된다 -->
|
||||||
Click="OnLayerFilterClearClick" ToolTip="타입 필터 해제"
|
<ListBox x:Name="PageListBox" ItemsSource="{Binding CurrentDesigner.PageRows}"
|
||||||
IsEnabled="{Binding CurrentDesigner.HasLayerTypeFilter}"
|
MaxHeight="134" Padding="2,3" BorderThickness="0" Background="Transparent"
|
||||||
Content="{Binding Source=filter-x, Converter={StaticResource IconName}, ConverterParameter=13|B.Muted}"/>
|
ItemContainerStyle="{StaticResource PageRowItem}"
|
||||||
</StackPanel>
|
PreviewMouseLeftButtonDown="OnPageRowPressed"
|
||||||
|
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<DockPanel Height="22">
|
||||||
|
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal"
|
||||||
|
Style="{StaticResource LayerRowActions}">
|
||||||
|
<Button Style="{StaticResource Subtle}" Width="24" Height="20" Padding="0"
|
||||||
|
Margin="1,0,0,0" Click="OnPageDuplicateClick" ToolTip="페이지 복제"
|
||||||
|
Content="{Binding Source=copy, Converter={StaticResource IconName}, ConverterParameter=13|B.Muted}"/>
|
||||||
|
<Button Style="{StaticResource Subtle}" Width="24" Height="20" Padding="0"
|
||||||
|
Margin="1,0,0,0" Click="OnPageDeleteClick" ToolTip="페이지 삭제"
|
||||||
|
Visibility="{Binding CanDelete, Converter={StaticResource BoolToVisibility}}"
|
||||||
|
Content="{Binding Source=trash-2, Converter={StaticResource IconName}, ConverterParameter=13|B.Muted}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<ContentControl DockPanel.Dock="Left" Margin="2,0,6,0" VerticalAlignment="Center"
|
||||||
|
IsTabStop="False" Focusable="False"
|
||||||
|
Content="{Binding PageIconSpec, Converter={StaticResource IconSpec}}"/>
|
||||||
|
<TextBlock DockPanel.Dock="Right" Text="{Binding CountText}" FontSize="11" Margin="6,0,4,0"
|
||||||
|
VerticalAlignment="Center" Foreground="{DynamicResource B.Muted}"/>
|
||||||
|
<TextBlock Text="{Binding Title}" VerticalAlignment="Center" FontSize="12"
|
||||||
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
</DockPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<Border Grid.Row="1" Height="1" Background="{DynamicResource B.Line}"/>
|
||||||
|
|
||||||
|
<!-- ══ 레이어 ══ -->
|
||||||
|
<DockPanel Grid.Row="2">
|
||||||
|
<Border DockPanel.Dock="Top" Style="{StaticResource SectionHead}">
|
||||||
|
<DockPanel>
|
||||||
|
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
|
<!-- 검색은 접어 둔다 — 컨트롤이 몇 개뿐인 서식이 대부분이라 검색칩이 상시 자리를 먹을 이유가 없다.
|
||||||
|
끌 때 검색어를 지운다: 상자가 접힌 채 필터만 살아 있으면 목록이 고장난 것처럼 보인다. -->
|
||||||
|
<ToggleButton x:Name="LayerSearchToggle" Style="{StaticResource MiniToggle}"
|
||||||
|
Checked="OnLayerSearchOpened" Unchecked="OnLayerSearchClosed"
|
||||||
|
ToolTip="레이어 검색" AutomationProperties.Name="레이어 검색"
|
||||||
|
Content="{Binding Source=search, Converter={StaticResource IconName}, ConverterParameter=13|B.Muted}"/>
|
||||||
|
<!-- 타입 필터 — 이름을 몰라도 "체크박스만" 처럼 종류로 좁힐 수 있어야 한다 -->
|
||||||
|
<Button Width="24" Height="22" Padding="0" Margin="1,0,0,0"
|
||||||
|
Click="OnLayerFilterClick"
|
||||||
|
ToolTip="{Binding CurrentDesigner.LayerTypeFilterTip}">
|
||||||
|
<Button.Style>
|
||||||
|
<Style TargetType="Button" BasedOn="{StaticResource Subtle}">
|
||||||
|
<Setter Property="Content"
|
||||||
|
Value="{Binding Source=filter, Converter={StaticResource IconName}, ConverterParameter=13|B.Muted}"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding CurrentDesigner.HasLayerTypeFilter}" Value="True">
|
||||||
|
<Setter Property="Content"
|
||||||
|
Value="{Binding Source=filter, Converter={StaticResource IconName}, ConverterParameter=13|B.Accent}"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</Button.Style>
|
||||||
|
</Button>
|
||||||
|
<!-- 초기화는 숨기지 않고 흐리게 둔다 — 버튼이 사라지면 옆 버튼 위치가 흔들린다 -->
|
||||||
|
<Button Style="{StaticResource Subtle}" Width="24" Height="22" Padding="0" Margin="1,0,0,0"
|
||||||
|
Click="OnLayerFilterClearClick" ToolTip="타입 필터 해제"
|
||||||
|
IsEnabled="{Binding CurrentDesigner.HasLayerTypeFilter}"
|
||||||
|
Content="{Binding Source=filter-x, Converter={StaticResource IconName}, ConverterParameter=13|B.Muted}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Style="{StaticResource SectionHeadText}" Text="레이어"/>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 검색줄 — 위 돋보기를 눌러야 나온다 -->
|
||||||
|
<DockPanel DockPanel.Dock="Top"
|
||||||
|
Visibility="{Binding IsChecked, ElementName=LayerSearchToggle, Converter={StaticResource BoolToVisibility}}">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
레이어 검색칩 — 컨트롤이 수백 개인 서식에서 이름으로 좁혀 찾는다.
|
레이어 검색칩 — 컨트롤이 수백 개인 서식에서 이름으로 좁혀 찾는다.
|
||||||
@@ -58,7 +128,7 @@
|
|||||||
BorderBrush 도 필수: 라이트에서 B.Input 과 B.Panel 이 둘 다 흰색이라
|
BorderBrush 도 필수: 라이트에서 B.Input 과 B.Panel 이 둘 다 흰색이라
|
||||||
테두리가 없으면 칩 경계 대비가 정확히 1.00:1 — 입력란이 거기 있다는 것 자체가 안 보인다.
|
테두리가 없으면 칩 경계 대비가 정확히 1.00:1 — 입력란이 거기 있다는 것 자체가 안 보인다.
|
||||||
-->
|
-->
|
||||||
<Border Height="32" CornerRadius="7" Margin="8,8,0,6"
|
<Border Height="32" CornerRadius="7" Margin="8,6,8,6"
|
||||||
Background="{DynamicResource B.Input}" BorderThickness="1">
|
Background="{DynamicResource B.Input}" BorderThickness="1">
|
||||||
<Border.Style>
|
<Border.Style>
|
||||||
<Style TargetType="Border">
|
<Style TargetType="Border">
|
||||||
@@ -131,30 +201,19 @@
|
|||||||
</Style.Triggers>
|
</Style.Triggers>
|
||||||
</Style>
|
</Style>
|
||||||
</DockPanel.Style>
|
</DockPanel.Style>
|
||||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal"
|
<!--
|
||||||
Style="{StaticResource LayerRowActions}">
|
페이지 조작은 전부 위쪽 페이지 구획으로 갔다. 여기 남은 것은
|
||||||
<Button Style="{StaticResource Subtle}" Width="24" Height="22" Padding="0"
|
경계 구분자다 — 연속 선택 블록이 페이지 경계에서 끊기게 하는 역할과
|
||||||
Margin="1,0,0,0" Click="OnPageDuplicateClick" ToolTip="페이지 복제"
|
페이지 접기(트리를 좁히는 유일한 수단)만 남긴다.
|
||||||
Content="{Binding Source=copy, Converter={StaticResource IconName}, ConverterParameter=13|B.Muted}"/>
|
-->
|
||||||
<Button Style="{StaticResource Subtle}" Width="24" Height="22" Padding="0"
|
<StackPanel Orientation="Horizontal" Height="20">
|
||||||
Margin="1,0,0,0" Click="OnPageDeleteClick" ToolTip="페이지 삭제"
|
<ToggleButton Style="{StaticResource MiniChevron}" Width="18" Height="18"
|
||||||
Visibility="{Binding CanDelete, Converter={StaticResource BoolToVisibility}}"
|
IsChecked="{Binding IsExpanded}" ToolTip="페이지 접기/펼치기"
|
||||||
Content="{Binding Source=trash-2, Converter={StaticResource IconName}, ConverterParameter=13|B.Muted}"/>
|
Content="{Binding ChevronSpec, Converter={StaticResource IconSpec}}"/>
|
||||||
</StackPanel>
|
<ContentControl Margin="0,0,5,0" VerticalAlignment="Center" IsTabStop="False"
|
||||||
<StackPanel>
|
Content="{Binding Source=file-text, Converter={StaticResource IconName}, ConverterParameter=11|B.Muted}"/>
|
||||||
<StackPanel Orientation="Horizontal">
|
<TextBlock Text="{Binding Title}" FontSize="11" VerticalAlignment="Center"
|
||||||
<ToggleButton Style="{StaticResource MiniChevron}" Width="18" Height="20"
|
Foreground="{DynamicResource B.Muted}"/>
|
||||||
IsChecked="{Binding IsExpanded}" ToolTip="페이지 접기/펼치기"
|
|
||||||
Content="{Binding ChevronSpec, Converter={StaticResource IconSpec}}"/>
|
|
||||||
<ContentControl Margin="0,0,6,0" VerticalAlignment="Center" IsTabStop="False"
|
|
||||||
Content="{Binding PageIconSpec, Converter={StaticResource IconSpec}}"/>
|
|
||||||
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" VerticalAlignment="Center"/>
|
|
||||||
<TextBlock Text="{Binding CountText}" FontSize="11" Margin="6,0,0,0"
|
|
||||||
VerticalAlignment="Center" Foreground="{DynamicResource B.Muted}"/>
|
|
||||||
</StackPanel>
|
|
||||||
<TextBlock Text="(비어 있음)" FontSize="11" Margin="20,1,0,0"
|
|
||||||
Foreground="{DynamicResource B.Muted}"
|
|
||||||
Visibility="{Binding IsEmpty, Converter={StaticResource BoolToVisibility}}"/>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
@@ -310,5 +369,6 @@
|
|||||||
IsHitTestVisible="False"
|
IsHitTestVisible="False"
|
||||||
Visibility="{Binding CurrentDesigner.IsLayerListEmpty, Converter={StaticResource BoolToVisibility}}"/>
|
Visibility="{Binding CurrentDesigner.IsLayerListEmpty, Converter={StaticResource BoolToVisibility}}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
</Grid>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -69,9 +69,6 @@ public partial class LayerPanelView : System.Windows.Controls.UserControl
|
|||||||
case LayerPageViewModel page:
|
case LayerPageViewModel page:
|
||||||
designer.ActivatePage(page.Page);
|
designer.ActivatePage(page.Page);
|
||||||
break;
|
break;
|
||||||
case LayerAddPageViewModel:
|
|
||||||
designer.AddPage();
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +113,48 @@ public partial class LayerPanelView : System.Windows.Controls.UserControl
|
|||||||
e.Handled = handled;
|
e.Handled = handled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>돋보기를 켰다 — 상자가 실제로 보이게 된 뒤에 포커스를 준다(한 틱 뒤)</summary>
|
||||||
|
private void OnLayerSearchOpened(object sender, RoutedEventArgs e)
|
||||||
|
=> Dispatcher.BeginInvoke(new Action(() =>
|
||||||
|
{
|
||||||
|
LayerSearchBox.Focus();
|
||||||
|
System.Windows.Input.Keyboard.Focus(LayerSearchBox);
|
||||||
|
}), System.Windows.Threading.DispatcherPriority.Input);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 돋보기를 껐다 — 검색어를 지운다.
|
||||||
|
/// 상자가 접힌 채 필터만 살아 있으면 목록이 통째로 비어 보이고 원인을 찾을 단서가 화면에 없다.
|
||||||
|
/// </summary>
|
||||||
|
private void OnLayerSearchClosed(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (Designer is { } designer)
|
||||||
|
{
|
||||||
|
designer.LayerFilter = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>페이지 구획의 + — 트리 안에 있던 '페이지 추가' 행을 대신한다</summary>
|
||||||
|
private void OnPageAddClick(object sender, RoutedEventArgs e) => Designer?.AddPage();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 페이지 행 누름 — 그 페이지를 활성화한다(캔버스가 거기로 스크롤하고 인스펙터가 용지 속성을 연다).
|
||||||
|
///
|
||||||
|
/// ListBox 의 선택으로 처리하지 않는 이유: 복제·삭제 버튼을 눌러도 행 선택이 먼저 일어나
|
||||||
|
/// 엉뚱한 페이지가 활성화된다. 눌린 지점이 버튼 위면 넘긴다.
|
||||||
|
/// </summary>
|
||||||
|
private void OnPageRowPressed(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.OriginalSource is DependencyObject source
|
||||||
|
&& FindAncestor<System.Windows.Controls.Primitives.ButtonBase>(source) is not null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (RowDataOf(e.OriginalSource) is LayerPageViewModel row)
|
||||||
|
{
|
||||||
|
Designer?.ActivatePage(row.Page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>페이지 헤더의 복제 버튼</summary>
|
/// <summary>페이지 헤더의 복제 버튼</summary>
|
||||||
private void OnPageDuplicateClick(object sender, RoutedEventArgs e)
|
private void OnPageDuplicateClick(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -188,12 +188,19 @@
|
|||||||
<MenuItem Header="그룹(_G)" Command="{Binding CurrentDesigner.GroupCommand}" InputGestureText="Ctrl+G"/>
|
<MenuItem Header="그룹(_G)" Command="{Binding CurrentDesigner.GroupCommand}" InputGestureText="Ctrl+G"/>
|
||||||
<MenuItem Header="그룹 해제(_U)" Command="{Binding CurrentDesigner.UngroupCommand}" InputGestureText="Ctrl+Shift+G"/>
|
<MenuItem Header="그룹 해제(_U)" Command="{Binding CurrentDesigner.UngroupCommand}" InputGestureText="Ctrl+Shift+G"/>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
<MenuItem Header="왼쪽 맞춤" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="Left"/>
|
<!-- 기준: 선택 1개면 페이지(컨테이너 안이면 그 컨테이너), 2개 이상이면 선택 영역 -->
|
||||||
<MenuItem Header="오른쪽 맞춤" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="Right"/>
|
<MenuItem Header="왼쪽 맞춤" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="Left"
|
||||||
<MenuItem Header="위 맞춤" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="Top"/>
|
ToolTip="선택 1개면 페이지(또는 부모 컨테이너) 기준, 2개 이상이면 선택 영역 기준"/>
|
||||||
<MenuItem Header="아래 맞춤" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="Bottom"/>
|
<MenuItem Header="오른쪽 맞춤" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="Right"
|
||||||
<MenuItem Header="가로 가운데" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="CenterH"/>
|
ToolTip="선택 1개면 페이지(또는 부모 컨테이너) 기준, 2개 이상이면 선택 영역 기준"/>
|
||||||
<MenuItem Header="세로 가운데" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="CenterV"/>
|
<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/>
|
<Separator/>
|
||||||
<MenuItem Header="같은 크기로">
|
<MenuItem Header="같은 크기로">
|
||||||
<MenuItem Header="너비/높이 모두(_B)" Command="{Binding CurrentDesigner.SizeToControlCommand}" CommandParameter="Both"/>
|
<MenuItem Header="너비/높이 모두(_B)" Command="{Binding CurrentDesigner.SizeToControlCommand}" CommandParameter="Both"/>
|
||||||
@@ -213,8 +220,10 @@
|
|||||||
<MenuItem Header="붙이기(_C)" Command="{Binding CurrentDesigner.SpacingCommand}" CommandParameter="VertConcat"/>
|
<MenuItem Header="붙이기(_C)" Command="{Binding CurrentDesigner.SpacingCommand}" CommandParameter="VertConcat"/>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
<MenuItem Header="페이지 가로 가운데(_H)" Command="{Binding CurrentDesigner.CenterInPageCommand}" CommandParameter="H"/>
|
<MenuItem Header="페이지 가로 가운데 — 묶음째(_H)"
|
||||||
<MenuItem Header="페이지 세로 가운데(_V)" Command="{Binding CurrentDesigner.CenterInPageCommand}" CommandParameter="V"/>
|
ToolTip="상대 배치를 유지한 채 선택 전체를 페이지 가운데로 옮깁니다" Command="{Binding CurrentDesigner.CenterInPageCommand}" CommandParameter="H"/>
|
||||||
|
<MenuItem Header="페이지 세로 가운데 — 묶음째(_V)"
|
||||||
|
ToolTip="상대 배치를 유지한 채 선택 전체를 페이지 가운데로 옮깁니다" Command="{Binding CurrentDesigner.CenterInPageCommand}" CommandParameter="V"/>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
<MenuItem Header="{Binding CurrentDesigner.TabOrderMenuHeader, FallbackValue=탭순서 편집 시작}"
|
<MenuItem Header="{Binding CurrentDesigner.TabOrderMenuHeader, FallbackValue=탭순서 편집 시작}"
|
||||||
Command="{Binding CurrentDesigner.TabOrderCommand}"
|
Command="{Binding CurrentDesigner.TabOrderCommand}"
|
||||||
|
|||||||
@@ -19,6 +19,19 @@ public partial class MainView : Window
|
|||||||
ZoomPopup.Closed += (_, _) => zoomPopupClosedAt = Environment.TickCount;
|
ZoomPopup.Closed += (_, _) => zoomPopupClosedAt = Environment.TickCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 창 핸들이 막 생긴 시점 — 최대화 보정을 건다.
|
||||||
|
///
|
||||||
|
/// Loaded 가 아니라 여기다. Loaded 는 창이 이미 보인 뒤라 창 생성 시점의 첫 WM_GETMINMAXINFO 를 놓친다.
|
||||||
|
/// 지금은 기동 시 최대화하지 않아 결과가 같지만, '마지막 창 상태 기억'이 생기면
|
||||||
|
/// 기동 순간 잘못된 크기가 한 프레임 번쩍인다.
|
||||||
|
/// </summary>
|
||||||
|
protected override void OnSourceInitialized(EventArgs e)
|
||||||
|
{
|
||||||
|
base.OnSourceInitialized(e);
|
||||||
|
MaximizeToWorkArea.Attach(this);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>기동 — 테마 메뉴 체크 동기화 + 뷰모델 Loaded 커맨드 실행(구 CustomWindow.LoadedCommand 대체)</summary>
|
/// <summary>기동 — 테마 메뉴 체크 동기화 + 뷰모델 Loaded 커맨드 실행(구 CustomWindow.LoadedCommand 대체)</summary>
|
||||||
private void OnWindowLoaded(object sender, RoutedEventArgs e)
|
private void OnWindowLoaded(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
|
|
||||||
AllowsTransparency 는 켜지 않는다: 레이어드 윈도우가 되면 ClearType 이 꺼져 13px 한글이
|
AllowsTransparency 는 켜지 않는다: 레이어드 윈도우가 되면 ClearType 이 꺼져 13px 한글이
|
||||||
뭉개지고, 진단 렌더러가 찍는 PNG 배경이 알파가 되어 라이트 결함이 가려진다.
|
뭉개지고, 진단 렌더러가 찍는 PNG 배경이 알파가 되어 라이트 결함이 가려진다.
|
||||||
|
|
||||||
|
ResizeMode="NoResize" 를 CanResize 로 바꾸지 말 것. 이 창은 WindowStyle=None 이라
|
||||||
|
최대화가 열리는 순간 작업표시줄을 덮는다(메인 창에서 실제로 겪은 결함이다).
|
||||||
|
꼭 바꿔야 한다면 SourceInitialized 에서 Services.MaximizeToWorkArea.Attach(this) 를 함께 걸어야 한다.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<!-- 앱 셸과 같은 크롬. GlassFrameThickness 0,0,0,1 = DWM 그림자 유지, ResizeBorder 0 = 크기 고정 -->
|
<!-- 앱 셸과 같은 크롬. GlassFrameThickness 0,0,0,1 = DWM 그림자 유지, ResizeBorder 0 = 크기 고정 -->
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:vm="clr-namespace:SheetMe.Designer.ViewModels"
|
xmlns:vm="clr-namespace:SheetMe.Designer.ViewModels"
|
||||||
|
xmlns:v="clr-namespace:SheetMe.Designer.Views"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
d:DataContext="{d:DesignInstance vm:SelectionOverlayViewModel}"
|
d:DataContext="{d:DesignInstance vm:SelectionOverlayViewModel}"
|
||||||
IsHitTestVisible="False">
|
IsHitTestVisible="False">
|
||||||
@@ -11,39 +12,6 @@
|
|||||||
<!-- 선택/마퀴/가이드 표시 전용 오버레이 — 입력은 월드 파이프라인이 처리(기하 히트테스트) -->
|
<!-- 선택/마퀴/가이드 표시 전용 오버레이 — 입력은 월드 파이프라인이 처리(기하 히트테스트) -->
|
||||||
<Canvas>
|
<Canvas>
|
||||||
|
|
||||||
<!-- 정렬 가이드선 -->
|
|
||||||
<ItemsControl ItemsSource="{Binding Guides}">
|
|
||||||
<ItemsControl.ItemsPanel>
|
|
||||||
<ItemsPanelTemplate>
|
|
||||||
<Canvas/>
|
|
||||||
</ItemsPanelTemplate>
|
|
||||||
</ItemsControl.ItemsPanel>
|
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate>
|
|
||||||
<Line Stroke="#FF4FA3" StrokeThickness="1" StrokeDashArray="4 3">
|
|
||||||
<Line.Style>
|
|
||||||
<Style TargetType="Line">
|
|
||||||
<Style.Triggers>
|
|
||||||
<DataTrigger Binding="{Binding IsVertical}" Value="True">
|
|
||||||
<Setter Property="X1" Value="{Binding Position}"/>
|
|
||||||
<Setter Property="X2" Value="{Binding Position}"/>
|
|
||||||
<Setter Property="Y1" Value="-100000"/>
|
|
||||||
<Setter Property="Y2" Value="100000"/>
|
|
||||||
</DataTrigger>
|
|
||||||
<DataTrigger Binding="{Binding IsVertical}" Value="False">
|
|
||||||
<Setter Property="Y1" Value="{Binding Position}"/>
|
|
||||||
<Setter Property="Y2" Value="{Binding Position}"/>
|
|
||||||
<Setter Property="X1" Value="-100000"/>
|
|
||||||
<Setter Property="X2" Value="100000"/>
|
|
||||||
</DataTrigger>
|
|
||||||
</Style.Triggers>
|
|
||||||
</Style>
|
|
||||||
</Line.Style>
|
|
||||||
</Line>
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
|
|
||||||
<!-- 선택 박스 -->
|
<!-- 선택 박스 -->
|
||||||
<Rectangle Canvas.Left="{Binding SelX}" Canvas.Top="{Binding SelY}"
|
<Rectangle Canvas.Left="{Binding SelX}" Canvas.Top="{Binding SelY}"
|
||||||
Width="{Binding SelW}" Height="{Binding SelH}"
|
Width="{Binding SelW}" Height="{Binding SelH}"
|
||||||
@@ -110,5 +78,76 @@
|
|||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
|
|
||||||
|
<!-- ══ 스냅 표시 ══
|
||||||
|
선택 박스·핸들보다 뒤에 둔다. 앞에 두면 선택한 컨트롤 밑에 깔려 정작 지금 붙은 자리가 안 보인다.
|
||||||
|
선 두께와 대시는 화면 1px 로 고정한다 — 줌이 걸린 층이라 InverseZoom 을 곱한다.
|
||||||
|
(StrokeDashArray 는 두께의 배수 단위라 자동으로 따라온다.)
|
||||||
|
EdgeMode=Aliased: 월드 좌표가 화면에서 비정수로 떨어지면 1px 선이 2px 회색으로 번진다.
|
||||||
|
전부 축정렬 직선이라 부작용이 없다. -->
|
||||||
|
|
||||||
|
<!-- 정렬 가이드선 -->
|
||||||
|
<ItemsControl ItemsSource="{Binding Guides}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<Canvas/>
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Line X1="{Binding X1}" Y1="{Binding Y1}" X2="{Binding X2}" Y2="{Binding Y2}"
|
||||||
|
Stroke="{DynamicResource B.CanvasGuide}" RenderOptions.EdgeMode="Aliased"
|
||||||
|
StrokeThickness="{Binding DataContext.InverseZoom,
|
||||||
|
RelativeSource={RelativeSource AncestorType=v:SelectionOverlayView}}"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<!-- 간격 균등 — 막대 하나에 양끝 갈고리, 가운데 수치 -->
|
||||||
|
<ItemsControl ItemsSource="{Binding Spacings}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<Canvas/>
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Canvas>
|
||||||
|
<Line X1="{Binding X1}" Y1="{Binding Y1}" X2="{Binding X2}" Y2="{Binding Y2}"
|
||||||
|
Stroke="{DynamicResource B.CanvasSpacing}" RenderOptions.EdgeMode="Aliased"
|
||||||
|
StrokeThickness="{Binding DataContext.InverseZoom,
|
||||||
|
RelativeSource={RelativeSource AncestorType=v:SelectionOverlayView}}"/>
|
||||||
|
<Line X1="{Binding CapAX1}" Y1="{Binding CapAY1}" X2="{Binding CapAX2}" Y2="{Binding CapAY2}"
|
||||||
|
Stroke="{DynamicResource B.CanvasSpacing}" RenderOptions.EdgeMode="Aliased"
|
||||||
|
StrokeThickness="{Binding DataContext.InverseZoom,
|
||||||
|
RelativeSource={RelativeSource AncestorType=v:SelectionOverlayView}}"/>
|
||||||
|
<Line X1="{Binding CapBX1}" Y1="{Binding CapBY1}" X2="{Binding CapBX2}" Y2="{Binding CapBY2}"
|
||||||
|
Stroke="{DynamicResource B.CanvasSpacing}" RenderOptions.EdgeMode="Aliased"
|
||||||
|
StrokeThickness="{Binding DataContext.InverseZoom,
|
||||||
|
RelativeSource={RelativeSource AncestorType=v:SelectionOverlayView}}"/>
|
||||||
|
|
||||||
|
<!-- 수치 칩. 막대가 정렬 가이드선과 같은 자리에 놓이는 경우가 흔해서
|
||||||
|
막대만으로는 '간격을 재고 있다'가 안 읽힌다 — 숫자가 그걸 가른다.
|
||||||
|
크기 0 Canvas 에 담아 그 점을 중심으로 놓고, 줌이 걸린 층이라 역배율로 되돌려
|
||||||
|
글자 크기를 화면 고정으로 만든다. -->
|
||||||
|
<!-- 좌상단 기준으로 놓는다. 가운데 정렬을 XAML 로 하려면 0 크기 컨테이너에 담아야 하는데,
|
||||||
|
그러면 자식이 폭 0 으로 측정돼 글자가 통째로 사라진다(실제로 그렇게 만들어 봤다).
|
||||||
|
그래서 중심 보정은 뷰모델이 좌표에 미리 넣어 둔다. -->
|
||||||
|
<Border Canvas.Left="{Binding TextX}" Canvas.Top="{Binding TextY}"
|
||||||
|
Background="{DynamicResource B.CanvasSpacing}" CornerRadius="2" Padding="3,0"
|
||||||
|
RenderTransformOrigin="0,0">
|
||||||
|
<Border.RenderTransform>
|
||||||
|
<ScaleTransform
|
||||||
|
ScaleX="{Binding DataContext.InverseZoom,
|
||||||
|
RelativeSource={RelativeSource AncestorType=v:SelectionOverlayView}}"
|
||||||
|
ScaleY="{Binding DataContext.InverseZoom,
|
||||||
|
RelativeSource={RelativeSource AncestorType=v:SelectionOverlayView}}"/>
|
||||||
|
</Border.RenderTransform>
|
||||||
|
<TextBlock Text="{Binding Text}" Foreground="White" FontSize="10" FontWeight="Bold"/>
|
||||||
|
</Border>
|
||||||
|
</Canvas>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
using SheetMe.Core.Layout;
|
||||||
|
|
||||||
|
namespace SheetMe.Core.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 스냅 계산 고정 — 손에 닿는 감각을 숫자로 못 박는다.
|
||||||
|
///
|
||||||
|
/// 스냅은 "어쩐지 잘 붙는다"로는 지킬 수 없다. 후보가 하나 늘거나 열거 순서가 바뀌는 것만으로
|
||||||
|
/// 붙는 자리가 조용히 달라지고, 그건 드래그해 보기 전엔 아무도 모른다.
|
||||||
|
/// 그래서 승자 규칙·가이드 개수·선분 범위·간격 균등을 전부 여기서 고정한다.
|
||||||
|
/// </summary>
|
||||||
|
[TestClass]
|
||||||
|
public sealed class SnapSolverTests
|
||||||
|
{
|
||||||
|
#region Member Fields
|
||||||
|
private static readonly SnapRect Page = new(0, 0, 720, 856);
|
||||||
|
private static readonly SnapOptions Options = new(Tolerance: 6, GridSize: 4, EnableSpacing: true);
|
||||||
|
private static readonly SnapOptions NoSpacing = Options with { EnableSpacing = false };
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Methods
|
||||||
|
private static SnapSolver Solver(params SnapRect[] siblings)
|
||||||
|
{
|
||||||
|
var solver = new SnapSolver();
|
||||||
|
solver.Prepare(new SnapContext
|
||||||
|
{
|
||||||
|
Pages = new[] { Page },
|
||||||
|
Siblings = siblings,
|
||||||
|
SpacingPage = Page,
|
||||||
|
});
|
||||||
|
return solver;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 정렬 ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 형제_좌변에_붙는다()
|
||||||
|
{
|
||||||
|
// 형제 폭을 81 로 잡아 중심(140.5)이 이동체 우변(137)과 '같은 거리'가 되지 않게 한다 —
|
||||||
|
// 80 이면 중심이 140 이라 우변도 정확히 +3 이 되어 가이드가 둘 나온다(그건 그것대로 옳다)
|
||||||
|
var solver = Solver(new SnapRect(100, 50, 81, 20));
|
||||||
|
var result = solver.SolveMove(new SnapRect(0, 200, 40, 10), 97, 0, NoSpacing, free: false);
|
||||||
|
|
||||||
|
Assert.IsTrue(result.SnappedX);
|
||||||
|
Assert.AreEqual(100, 0 + result.Dx, 1e-9, "좌변이 형제 좌변에 맞아야 한다");
|
||||||
|
Assert.AreEqual(1, result.Guides.Count);
|
||||||
|
var guide = result.Guides[0];
|
||||||
|
Assert.IsTrue(guide.IsVertical);
|
||||||
|
Assert.AreEqual(100, guide.Position, 1e-9);
|
||||||
|
Assert.AreEqual(SnapGuideKind.SiblingEdge, guide.Kind);
|
||||||
|
// 선분은 형제와 이동체를 모두 덮는 만큼만 — 화면을 가로지르지 않는다
|
||||||
|
Assert.AreEqual(50, guide.Start, 1e-9);
|
||||||
|
Assert.AreEqual(210, guide.End, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 허용오차_경계는_포함이고_그_바깥은_격자로_떨어진다()
|
||||||
|
{
|
||||||
|
var solver = Solver(new SnapRect(100, 50, 80, 20));
|
||||||
|
|
||||||
|
var onEdge = solver.SolveMove(new SnapRect(0, 200, 40, 10), 94, 0, NoSpacing, free: false);
|
||||||
|
Assert.IsTrue(onEdge.SnappedX, "거리 6은 붙는다");
|
||||||
|
|
||||||
|
var justOutside = solver.SolveMove(new SnapRect(0, 200, 40, 10), 93.999, 0, NoSpacing, free: false);
|
||||||
|
Assert.IsFalse(justOutside.SnappedX, "거리 6.001 은 안 붙는다");
|
||||||
|
Assert.AreEqual(0, result4(justOutside.Dx) % 4, "정합 실패 축은 4px 격자");
|
||||||
|
Assert.AreEqual(0, justOutside.Guides.Count);
|
||||||
|
|
||||||
|
static double result4(double dx) => Math.Round(dx, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 같은_좌표의_형제_둘은_가이드_하나로_합쳐지고_범위가_넓어진다()
|
||||||
|
{
|
||||||
|
var solver = Solver(new SnapRect(100, 0, 50, 20), new SnapRect(100, 300, 50, 20));
|
||||||
|
var result = solver.SolveMove(new SnapRect(0, 200, 40, 10), 97, 0, NoSpacing, free: false);
|
||||||
|
|
||||||
|
Assert.AreEqual(1, result.Guides.Count, "같은 자리에 선을 겹쳐 긋지 않는다");
|
||||||
|
Assert.AreEqual(0, result.Guides[0].Start, 1e-9);
|
||||||
|
Assert.AreEqual(320, result.Guides[0].End, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 서로_다른_자리에_동시에_맞으면_가이드가_둘이다()
|
||||||
|
{
|
||||||
|
// 이동체 폭 40. Left=100 에 형제 A 의 좌변, Right=140 에 형제 B 의 좌변
|
||||||
|
var solver = Solver(new SnapRect(100, 0, 50, 20), new SnapRect(140, 400, 50, 20));
|
||||||
|
var result = solver.SolveMove(new SnapRect(0, 200, 40, 10), 97, 0, NoSpacing, free: false);
|
||||||
|
|
||||||
|
Assert.IsTrue(result.SnappedX);
|
||||||
|
Assert.AreEqual(2, result.Guides.Count);
|
||||||
|
CollectionAssert.AreEquivalent(
|
||||||
|
new[] { 100.0, 140.0 },
|
||||||
|
result.Guides.Select(g => g.Position).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 거리가_같아도_부호가_반대면_한쪽만_그린다()
|
||||||
|
{
|
||||||
|
// 이동 후 Left=100 기준: 형제 A 좌변 103(+3), 형제 B 좌변 97(−3).
|
||||||
|
// 형제를 넓게 잡아 중심·우변이 사정권에 안 들어오게 한다(들어오면 더 가까운 매치가 승자가 된다)
|
||||||
|
var solver = Solver(new SnapRect(103, 0, 200, 20), new SnapRect(97, 400, 200, 20));
|
||||||
|
var result = solver.SolveMove(new SnapRect(0, 200, 40, 10), 100, 0, NoSpacing, free: false);
|
||||||
|
|
||||||
|
Assert.IsTrue(result.SnappedX);
|
||||||
|
Assert.AreEqual(1, result.Guides.Count,
|
||||||
|
"보정 후 실제로 맞는 것만 그린다 — 반대편에 선을 그으면 '붙었다고 그려 놓고 안 붙은' 선이 된다");
|
||||||
|
Assert.AreEqual(97, result.Guides[0].Position, 1e-9, "동률이면 좌표가 작은 쪽이 이긴다");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 중심_정합은_반정수를_보존한다()
|
||||||
|
{
|
||||||
|
var solver = Solver(new SnapRect(100, 0, 81, 20)); // CenterX = 140.5
|
||||||
|
var result = solver.SolveMove(new SnapRect(0, 200, 40, 10), 120, 0, NoSpacing, free: false);
|
||||||
|
|
||||||
|
Assert.IsTrue(result.SnappedX);
|
||||||
|
Assert.AreEqual(120.5, 0 + result.Dx, 1e-9, "반올림하면 중심이 0.5px 어긋난다");
|
||||||
|
Assert.AreEqual(SnapGuideKind.SiblingCenter, result.Guides[0].Kind);
|
||||||
|
Assert.AreEqual(140.5, result.Guides[0].Position, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 용지_가이드는_용지_전체로_긋는다()
|
||||||
|
{
|
||||||
|
var solver = Solver();
|
||||||
|
var result = solver.SolveMove(new SnapRect(3, 200, 40, 10), 0, 0, NoSpacing, free: false);
|
||||||
|
|
||||||
|
Assert.IsTrue(result.SnappedX);
|
||||||
|
Assert.AreEqual(SnapGuideKind.PageEdge, result.Guides[0].Kind);
|
||||||
|
Assert.AreEqual(0, result.Guides[0].Start, 1e-9);
|
||||||
|
Assert.AreEqual(856, result.Guides[0].End, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 용지_중앙에_붙는다()
|
||||||
|
{
|
||||||
|
var solver = Solver();
|
||||||
|
// 폭 40 이 용지 중앙(360)에 오려면 Left=340
|
||||||
|
var result = solver.SolveMove(new SnapRect(337, 200, 40, 10), 0, 0, NoSpacing, free: false);
|
||||||
|
|
||||||
|
Assert.IsTrue(result.SnappedX);
|
||||||
|
Assert.AreEqual(340, 337 + result.Dx, 1e-9);
|
||||||
|
Assert.AreEqual(SnapGuideKind.PageCenter, result.Guides[0].Kind);
|
||||||
|
Assert.AreEqual(360, result.Guides[0].Position, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 후보가_없으면_격자로_떨어지고_가이드는_없다()
|
||||||
|
{
|
||||||
|
var solver = Solver(new SnapRect(500, 500, 20, 20));
|
||||||
|
var result = solver.SolveMove(new SnapRect(101, 200, 40, 10), 0, 0, NoSpacing, free: false);
|
||||||
|
|
||||||
|
Assert.IsFalse(result.SnappedX);
|
||||||
|
Assert.AreEqual(100, 101 + result.Dx, 1e-9);
|
||||||
|
Assert.AreEqual(0, result.Guides.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 형제_모서리가_용지_중앙보다_이긴다()
|
||||||
|
{
|
||||||
|
// 이동체 Left=337 → 형제 좌변 340 이 +3, 중심 357 → 용지 중앙 360 도 +3.
|
||||||
|
// 둘 다 실제로 맞으므로 선은 둘 다 그린다 — 갈리는 것은 '어느 쪽으로 보정하는가'다.
|
||||||
|
var solver = Solver(new SnapRect(340, 0, 200, 20));
|
||||||
|
var result = solver.SolveMove(new SnapRect(337, 200, 40, 10), 0, 0, NoSpacing, free: false);
|
||||||
|
|
||||||
|
Assert.AreEqual(340, 337 + result.Dx, 1e-9, "거리가 같으면 형제 모서리가 용지 중앙보다 이긴다");
|
||||||
|
Assert.AreEqual(2, result.Guides.Count);
|
||||||
|
Assert.IsTrue(result.Guides.Any(g => g.Kind == SnapGuideKind.SiblingEdge));
|
||||||
|
Assert.IsTrue(result.Guides.Any(g => g.Kind == SnapGuideKind.PageCenter));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 한_축만_붙어도_다른_축은_격자로_간다()
|
||||||
|
{
|
||||||
|
var solver = Solver(new SnapRect(100, 0, 50, 20));
|
||||||
|
var result = solver.SolveMove(new SnapRect(97, 201, 40, 10), 0, 0, NoSpacing, free: false);
|
||||||
|
|
||||||
|
Assert.IsTrue(result.SnappedX);
|
||||||
|
Assert.IsFalse(result.SnappedY);
|
||||||
|
Assert.AreEqual(200, 201 + result.Dy, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void Alt_는_격자까지_전부_끈다()
|
||||||
|
{
|
||||||
|
var solver = Solver(new SnapRect(100, 50, 80, 20));
|
||||||
|
var result = solver.SolveMove(new SnapRect(0, 200, 40, 10), 97.3, 11.7, Options, free: true);
|
||||||
|
|
||||||
|
Assert.AreEqual(97.3, result.Dx, 1e-9);
|
||||||
|
Assert.AreEqual(11.7, result.Dy, 1e-9);
|
||||||
|
Assert.IsFalse(result.SnappedX);
|
||||||
|
Assert.AreEqual(0, result.Guides.Count);
|
||||||
|
Assert.AreEqual(0, result.Spacings.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 같은_입력을_두_번_풀어도_결과가_같다()
|
||||||
|
{
|
||||||
|
var solver = Solver(new SnapRect(100, 0, 50, 20), new SnapRect(140, 400, 50, 20));
|
||||||
|
var first = solver.SolveMove(new SnapRect(0, 200, 40, 10), 97, 0, Options, free: false);
|
||||||
|
var second = solver.SolveMove(new SnapRect(0, 200, 40, 10), 97, 0, Options, free: false);
|
||||||
|
|
||||||
|
Assert.AreEqual(first.Dx, second.Dx, 1e-9);
|
||||||
|
Assert.AreEqual(first.Guides.Count, second.Guides.Count, "스크래치 버퍼가 새면 두 번째가 불어난다");
|
||||||
|
Assert.AreEqual(first.Spacings.Count, second.Spacings.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 페이지가_멀리_떨어져_있어도_정밀도가_유지된다()
|
||||||
|
{
|
||||||
|
var solver = new SnapSolver();
|
||||||
|
solver.Prepare(new SnapContext
|
||||||
|
{
|
||||||
|
Pages = new[] { new SnapRect(0, 10000, 720, 856) },
|
||||||
|
Siblings = new[] { new SnapRect(100, 10050, 80, 20) },
|
||||||
|
});
|
||||||
|
var result = solver.SolveMove(new SnapRect(0, 10200, 40, 10), 97, 0, NoSpacing, free: false);
|
||||||
|
|
||||||
|
Assert.AreEqual(100, 0 + result.Dx, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 간격 균등 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 두_형제_사이에_끼면_남는_틈을_반씩_나눈다()
|
||||||
|
{
|
||||||
|
// 틈 [50,200] = 150, 이동 폭 40 → 양쪽 55 씩 → Left = 105
|
||||||
|
var solver = Solver(new SnapRect(0, 0, 50, 20), new SnapRect(200, 0, 50, 20));
|
||||||
|
var result = solver.SolveMove(new SnapRect(103, 0, 40, 20), 0, 0, Options, free: false);
|
||||||
|
|
||||||
|
Assert.IsTrue(result.SnappedX);
|
||||||
|
Assert.AreEqual(105, 103 + result.Dx, 1e-9);
|
||||||
|
Assert.AreEqual(2, result.Spacings.Count);
|
||||||
|
Assert.AreEqual(55, result.Spacings[0].Gap, 1e-9);
|
||||||
|
Assert.AreEqual(55, result.Spacings[1].Gap, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 줄_뒤에_같은_간격으로_이어_붙는다()
|
||||||
|
{
|
||||||
|
// 간격 30 (50→80). 뒤에 붙으면 Left = 130 + 30 = 160
|
||||||
|
var solver = Solver(new SnapRect(0, 0, 50, 20), new SnapRect(80, 0, 50, 20));
|
||||||
|
var result = solver.SolveMove(new SnapRect(157, 0, 40, 20), 0, 0, Options, free: false);
|
||||||
|
|
||||||
|
Assert.IsTrue(result.SnappedX);
|
||||||
|
Assert.AreEqual(160, 157 + result.Dx, 1e-9);
|
||||||
|
Assert.AreEqual(30, result.Spacings[^1].Gap, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 줄_앞에_같은_간격으로_이어_붙는다()
|
||||||
|
{
|
||||||
|
// 간격 30 (150→180). 앞에 붙으면 Left = 100 − 30 − 40 = 30
|
||||||
|
var solver = Solver(new SnapRect(100, 0, 50, 20), new SnapRect(180, 0, 50, 20));
|
||||||
|
var result = solver.SolveMove(new SnapRect(33, 0, 40, 20), 0, 0, Options, free: false);
|
||||||
|
|
||||||
|
Assert.IsTrue(result.SnappedX);
|
||||||
|
Assert.AreEqual(30, 33 + result.Dx, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 교차축이_안_겹치면_같은_줄이_아니다()
|
||||||
|
{
|
||||||
|
var solver = Solver(new SnapRect(0, 500, 50, 20), new SnapRect(80, 500, 50, 20));
|
||||||
|
var result = solver.SolveMove(new SnapRect(157, 0, 40, 20), 0, 0, Options, free: false);
|
||||||
|
|
||||||
|
Assert.AreEqual(0, result.Spacings.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 형제가_하나면_간격_후보가_없다()
|
||||||
|
{
|
||||||
|
var solver = Solver(new SnapRect(0, 0, 50, 20));
|
||||||
|
var result = solver.SolveMove(new SnapRect(300, 0, 40, 20), 0, 0, Options, free: false);
|
||||||
|
|
||||||
|
Assert.AreEqual(0, result.Spacings.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 정렬이_되면_간격은_보지_않는다()
|
||||||
|
{
|
||||||
|
// 뒤에 붙는 자리(160)와 형제 좌변 정합(158)이 둘 다 사정권
|
||||||
|
var solver = Solver(new SnapRect(0, 0, 50, 20), new SnapRect(80, 0, 50, 20), new SnapRect(158, 300, 20, 20));
|
||||||
|
var result = solver.SolveMove(new SnapRect(157, 0, 40, 20), 0, 0, Options, free: false);
|
||||||
|
|
||||||
|
Assert.IsTrue(result.SnappedX);
|
||||||
|
Assert.AreEqual(158, 157 + result.Dx, 1e-9, "정렬이 이긴다");
|
||||||
|
Assert.AreEqual(0, result.Spacings.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 용지_밖으로_나가는_간격_자리는_버린다()
|
||||||
|
{
|
||||||
|
// 앞에 붙는 자리가 음수가 되는 배치
|
||||||
|
var solver = Solver(new SnapRect(10, 0, 50, 20), new SnapRect(90, 0, 50, 20));
|
||||||
|
var result = solver.SolveMove(new SnapRect(-58, 0, 40, 20), 0, 0, Options, free: false);
|
||||||
|
|
||||||
|
Assert.AreEqual(0, result.Spacings.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 겹치는_형제가_너무_많으면_간격을_포기하고_정렬만_한다()
|
||||||
|
{
|
||||||
|
var many = new SnapRect[70];
|
||||||
|
for (var i = 0; i < many.Length; i++)
|
||||||
|
{
|
||||||
|
many[i] = new SnapRect(i * 60, 0, 50, 20);
|
||||||
|
}
|
||||||
|
var solver = Solver(many);
|
||||||
|
var result = solver.SolveMove(new SnapRect(58, 0, 40, 20), 0, 0, Options, free: false);
|
||||||
|
|
||||||
|
Assert.AreEqual(0, result.Spacings.Count, "성능 가드");
|
||||||
|
Assert.IsTrue(result.SnappedX, "정렬 스냅은 그대로 돈다");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 리사이즈 · 줌 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 모서리_스냅은_첫_매치가_아니라_최근접이다()
|
||||||
|
{
|
||||||
|
var solver = Solver(new SnapRect(100, 0, 0, 20), new SnapRect(104, 0, 0, 20));
|
||||||
|
var result = solver.SolveEdge(102.5, isXAxis: true, Options, free: false);
|
||||||
|
|
||||||
|
Assert.IsTrue(result.Snapped);
|
||||||
|
Assert.AreEqual(104, result.Value, 1e-9, "이동 스냅과 같은 규칙이어야 한다");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 모서리_후보가_없으면_격자로_떨어진다()
|
||||||
|
{
|
||||||
|
var solver = Solver(new SnapRect(500, 0, 10, 20));
|
||||||
|
var result = solver.SolveEdge(102, isXAxis: true, Options, free: false);
|
||||||
|
|
||||||
|
Assert.IsFalse(result.Snapped);
|
||||||
|
Assert.AreEqual(104, result.Value, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 모서리_스냅도_Alt_로_꺼진다()
|
||||||
|
{
|
||||||
|
var solver = Solver(new SnapRect(100, 0, 0, 20));
|
||||||
|
var result = solver.SolveEdge(102.5, isXAxis: true, Options, free: true);
|
||||||
|
|
||||||
|
Assert.IsFalse(result.Snapped);
|
||||||
|
Assert.AreEqual(102.5, result.Value, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 허용오차는_화면_기준이라_줌에_반비례한다()
|
||||||
|
{
|
||||||
|
Assert.AreEqual(3, SnapOptions.ForZoom(6, 4, 2.0, true).Tolerance, 1e-9);
|
||||||
|
Assert.AreEqual(12, SnapOptions.ForZoom(6, 4, 0.5, true).Tolerance, 1e-9);
|
||||||
|
Assert.AreEqual(6, SnapOptions.ForZoom(6, 4, 1.0, true).Tolerance, 1e-9);
|
||||||
|
// 범위 밖 줌이 들어와도 오차가 폭주하지 않는다
|
||||||
|
Assert.AreEqual(24, SnapOptions.ForZoom(6, 4, 0.01, true).Tolerance, 1e-9);
|
||||||
|
Assert.AreEqual(2, SnapOptions.ForZoom(6, 4, 99, true).Tolerance, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 같은_거리라도_줌에_따라_붙고_안_붙는다()
|
||||||
|
{
|
||||||
|
var solver = Solver(new SnapRect(104, 0, 50, 20));
|
||||||
|
var bbox = new SnapRect(100, 200, 40, 10);
|
||||||
|
|
||||||
|
var zoomedIn = solver.SolveMove(bbox, 0, 0, SnapOptions.ForZoom(6, 4, 2.0, false), free: false);
|
||||||
|
Assert.IsFalse(zoomedIn.SnappedX, "줌 200% 에서 허용오차는 3 — 거리 4는 안 붙는다");
|
||||||
|
|
||||||
|
var zoomedOut = solver.SolveMove(bbox, 0, 0, SnapOptions.ForZoom(6, 4, 0.5, false), free: false);
|
||||||
|
Assert.IsTrue(zoomedOut.SnappedX, "줌 50% 에서 허용오차는 12 — 붙는다");
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user