diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
index 3367716..8d4fb90 100644
--- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
+++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
@@ -212,6 +212,34 @@ public static class EditSmoke
Check("완성: Esc 는 값을 바꾸지 않는다", tagRow.ValueText == before && !tagRow.ShowCompletions);
}
+ // 간격을 숫자로 놓는다 — 4px 씩이 아니라 정확한 값으로.
+ {
+ var gaps = new DesignerViewModel(new FormDesignDataBusiness().CreateNew());
+ gaps.AddControlAt("Label", new Point(60, 100));
+ gaps.AddControlAt("Label", new Point(60, 160));
+ gaps.AddControlAt("Label", new Point(60, 260));
+ var rows = gaps.Pages[0].Controls.Where(c => c.Type == "Label").OrderBy(c => c.Y).ToList();
+ gaps.Selection.Set(rows);
+
+ Check("간격값: 갈린 값은 빈 칸", gaps.CurrentSpacing(horizontal: false) is null);
+ gaps.SetExactSpacing(24, horizontal: false);
+ Check("간격값: 24 로 놓으면 24 가 된다", gaps.CurrentSpacing(horizontal: false) == 24,
+ $"실제 {gaps.CurrentSpacing(horizontal: false)}");
+
+ var head = rows.First();
+ var headY = head.Y;
+ gaps.SetExactSpacing(8, horizontal: false);
+ Check("간격값: 첫 항목은 움직이지 않는다", Math.Abs(head.Y - headY) < 0.5,
+ $"{headY} → {head.Y}");
+ Check("간격값: 다시 넣어도 밀리지 않는다", gaps.CurrentSpacing(horizontal: false) == 8);
+
+ // 음수·헛값은 무시한다 — 조용히 뒤엉킨 배치가 되는 것보다 아무 일도 안 하는 쪽이 낫다
+ gaps.SetExactSpacing(-4, horizontal: false);
+ Check("간격값: 음수는 무시한다", gaps.CurrentSpacing(horizontal: false) == 8);
+ gaps.SetExactSpacing(double.NaN, horizontal: false);
+ Check("간격값: 숫자가 아니면 무시한다", gaps.CurrentSpacing(horizontal: false) == 8);
+ }
+
// 미리보기가 인쇄 필터를 실제로 쓰는가.
// PrintFilter 단위 테스트는 판정이 옳다는 것만 말한다 — 렌더 경로가 그 판정을 부르는지는
// 다른 문제이고, 이 앱에서 "코드는 맞아 보이는데 화면은 다른" 일이 여러 번 있었다.
diff --git a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs
index 608920b..00fdc32 100644
--- a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs
+++ b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs
@@ -2420,6 +2420,69 @@ public sealed class DesignerViewModel : ViewModelBase
/// 간격 조절 — 가로/세로 균등·넓게·좁게·붙이기 (레거시 TK_Horiz/VertSpace 계열).
/// 축 정렬 순서로 첫 컨트롤 고정, 이후 컨트롤 이동. 넓게/좁게 단위는 그리드 크기.
///
+ ///
+ /// 선택한 것들 사이 간격을 정확한 픽셀 값으로 놓는다.
+ ///
+ /// 왜 필요한가. 여기까지 간격을 조절하는 길은 4px 씩 넓게/좁게뿐이었다.
+ /// 20px 를 32px 로 바꾸려면 3번, 로고 아래 4단 메뉴로 하면 12왕복이었다.
+ /// 단축키(Alt+[ / Alt+])를 붙여 왕복은 없앴지만 여전히 4px 씩이다.
+ /// 드래그 스냅은 형제 간격을 정확히 계산해 분홍 표식까지 그리는데(SnapSolver),
+ /// 명령 경로에는 그 값을 넣을 칸이 없었다.
+ ///
+ /// 첫 항목은 움직이지 않는다 — 기준이 흔들리면 같은 값을 두 번 넣어도 자리가 계속 밀린다.
+ ///
+ public void SetExactSpacing(double gap, bool horizontal)
+ {
+ if (Selection.Items.Count < 2 || !double.IsFinite(gap) || gap < 0)
+ {
+ return;
+ }
+ var ordered = Selection.Items
+ .Select(vm => (Vm: vm, World: WorldBoundsOf(vm), Offset: ParentWorldOffset(vm)))
+ .OrderBy(x => horizontal ? x.World.X : x.World.Y)
+ .ToList();
+
+ Undo.Snapshot();
+ var cursor = horizontal ? ordered[0].World.Right : ordered[0].World.Bottom;
+ for (var i = 1; i < ordered.Count; i++)
+ {
+ var (vm, world, offset) = ordered[i];
+ if (horizontal)
+ {
+ vm.X = Math.Round(cursor + gap - offset.X);
+ cursor += gap + world.Width;
+ continue;
+ }
+ vm.Y = Math.Round(cursor + gap - offset.Y);
+ cursor += gap + world.Height;
+ }
+ Selection.NotifyBoundsChanged();
+ }
+
+ ///
+ /// 지금 선택의 간격 — 숫자 칸에 무엇을 보여 줄지.
+ /// 값이 서로 다르면 null(빈 칸) — 하나로 보여 주면 그 값이 이미 적용된 것처럼 읽힌다.
+ ///
+ public double? CurrentSpacing(bool horizontal)
+ {
+ if (Selection.Items.Count < 2)
+ {
+ return null;
+ }
+ var ordered = Selection.Items
+ .Select(vm => WorldBoundsOf(vm))
+ .OrderBy(r => horizontal ? r.X : r.Y)
+ .ToList();
+ var gaps = new List();
+ for (var i = 1; i < ordered.Count; i++)
+ {
+ gaps.Add(Math.Round(horizontal
+ ? ordered[i].X - ordered[i - 1].Right
+ : ordered[i].Y - ordered[i - 1].Bottom));
+ }
+ return gaps.Distinct().Count() == 1 ? gaps[0] : null;
+ }
+
public void AdjustSpacing(string mode)
{
var isHorizontal = mode.StartsWith("Horiz", StringComparison.Ordinal);
diff --git a/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs b/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs
index c77f4bb..f12b38e 100644
--- a/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs
+++ b/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs
@@ -112,6 +112,46 @@ public sealed class InspectorViewModel : ViewModelBase
/// 간격 균등 버튼을 보일 것인가 — 사이가 있어야 나눌 것이 있다(3개 이상)
public bool CanDistribute => designer.CanDistribute();
+ ///
+ /// 정확한 간격은 둘부터 뜻이 있다 — 라벨과 입력칸 한 쌍이 가장 흔한 경우다.
+ /// 균등 나누기()가 셋을 요구하는 것과 기준이 다르다.
+ ///
+ public bool CanSpaceExactly => designer.Selection.Items.Count >= 2;
+
+ ///
+ /// 지금 세로 간격. 값이 갈리면 빈 칸이다 —
+ /// 하나로 보여 주면 그 값이 이미 적용된 것처럼 읽힌다.
+ ///
+ public string VerticalGapText
+ {
+ get => designer.CurrentSpacing(horizontal: false) is { } gap
+ ? gap.ToString("0", System.Globalization.CultureInfo.InvariantCulture)
+ : string.Empty;
+ set
+ {
+ if (double.TryParse(value, System.Globalization.NumberStyles.Float,
+ System.Globalization.CultureInfo.InvariantCulture, out var gap))
+ {
+ designer.SetExactSpacing(gap, horizontal: false);
+ }
+ }
+ }
+
+ public string HorizontalGapText
+ {
+ get => designer.CurrentSpacing(horizontal: true) is { } gap
+ ? gap.ToString("0", System.Globalization.CultureInfo.InvariantCulture)
+ : string.Empty;
+ set
+ {
+ if (double.TryParse(value, System.Globalization.NumberStyles.Float,
+ System.Globalization.CultureInfo.InvariantCulture, out var gap))
+ {
+ designer.SetExactSpacing(gap, horizontal: true);
+ }
+ }
+ }
+
///
/// 정렬 실행 — 뷰의 DataContext 가 인스펙터라 디자이너 커맨드에 직접 닿을 수 없어 위임한다.
///
@@ -274,6 +314,9 @@ public sealed class InspectorViewModel : ViewModelBase
{
OnPropertyChanged(nameof(CanAlignVertical));
OnPropertyChanged(nameof(CanDistribute));
+ OnPropertyChanged(nameof(CanSpaceExactly));
+ OnPropertyChanged(nameof(VerticalGapText));
+ OnPropertyChanged(nameof(HorizontalGapText));
OnPropertyChanged(nameof(AlignScopeText));
OnPropertyChanged(nameof(VerticalScopeText));
OnPropertyChanged(nameof(AlignSelectionScopeLabel));
diff --git a/src/SheetMe.Designer/Views/InspectorView.xaml b/src/SheetMe.Designer/Views/InspectorView.xaml
index 2ae79a7..1a17713 100644
--- a/src/SheetMe.Designer/Views/InspectorView.xaml
+++ b/src/SheetMe.Designer/Views/InspectorView.xaml
@@ -908,6 +908,34 @@
Content="{Binding Source=distribute-v, Converter={StaticResource IconName}, ConverterParameter=18|B.Muted}"/>
+
+
+
+
+
+
+
+
+