캔버스·인스펙터 잔손질 넷 — 마퀴·치수 칩·죽은 버튼·잘린 설명

## 패널 안에 그린 마퀴가 아무것도 안 잡았다

마퀴가 최상위만 훑고 컨테이너 안으로 안 들어갔다. 패널 안 체크박스 5개를 한 번에 고르려고
안쪽을 끌면 패널만 잡히거나 아무것도 안 잡혀서, Ctrl+클릭 반복이나 레이어 목록으로 우회했다.
컨테이너 자식은 전체 컨트롤의 <b>18.3%</b>(실측 18,749개/컨테이너 4,099개)라 드문 경우가 아니다.

규칙은 <b>'담김'</b>이다 — 마퀴가 컨테이너 사각형 안에 들어가 있으면 그 안의 것을 고른다
(내 상자가 패널보다 작으니 그 안을 고르려는 뜻이다). 걸치기만 하면 예전처럼 패널이 잡힌다.
모드도 수정키도 없이 <b>그린 모양만으로</b> 갈리므로 예측할 수 있다.

한 군데를 더 고쳐야 했다. 클릭 선택이 쓰는 GroupMatesOf 는 자식을 부모 컨테이너로 되돌리는데
(패널 안의 것을 집으면 패널이 움직여야 하므로 옳다) 마퀴가 그것을 그대로 쓰고 있었다 —
안쪽을 정확히 골라 놓고도 결과가 다시 패널 하나가 되어 위 규칙이 아무 일도 못 한다.
마퀴 전용 경로를 따로 뒀다. 걸치기 동작이 그대로인 것도 검사로 고정했다.

## 끄는 동안 숫자가 캔버스에 없었다

라벨 폭을 종이 칸에 맞추는 동안 숫자는 300px 떨어진 인스펙터에만 떴다 —
끌고, 손 떼고, 오른쪽 보고, 다시 끈다. 간격 수치 칩은 <b>균등 스냅이 성립할 때만</b> 나타나므로
"지금 이게 몇 px 인가"는 드래그 중에 알 방법이 아예 없었다.

이동이면 좌표, 크기 조절이면 크기를 보여 준다 — 지금 <b>바꾸고 있는 값</b>이 그것이다.
오버레이 층이라 종이에는 없고, 줌이 걸린 층이라 역배율로 글자 크기를 화면 고정으로 만든다.

## 눌러도 아무 일이 없는 ⋮ 버튼

Command·Click·ContextMenu 가 <b>전부 없었다</b>. 그런데 툴팁은 '이 컨트롤에 대한 명령'이라
"단일 선택일 때만 되는 뭔가가 있다"고 오해시켰다. 없는 버튼보다 죽은 버튼이 나쁘다 —
컨트롤 단위 명령은 캔버스 우클릭 메뉴에 이미 다 있다.

## 주석이 논증한 값과 실제 값이 달랐다

라벨 폭 주석이 "92px + 줄바꿈이면 두 줄로 접혀 전부 읽힌다"고 근거까지 적어 놓고
값은 72 였다. 그래서 '액션 대상 컨…' 처럼 잘렸다. 92 로 맞췄다.

같은 자리에서 설명 행(ReadOnlyRow)이 값처럼 잘리고 있었다 —
"타입이 달라 값의 뜻이 다른 속성 2개 — 타입별로 따로 고르세요" 에서
<b>지시에 해당하는 뒷부분</b>이 사라져 무엇을 하라는 건지 알 수 없었다. 접어서 다 보여 준다.

## 판정

- 단위 시험 388 · edit-smoke 396건 전건 통과 (마퀴 2건 · 치수 칩 4건 추가)
- --dialog-shots 69장 전건 통과 (인스펙터 8종을 눈으로 대조 — 라벨·설명 잘림 없음)
- --modal-check · --maxrect · --scale-budget 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-21 09:05:30 +09:00
co-authored by Claude Fable 5
parent f6b456753c
commit 8c76a978a9
6 changed files with 214 additions and 22 deletions
@@ -1112,6 +1112,73 @@ public static class EditSmoke
$"ValueText='{choiceRow.ValueText}' IsMixed={choiceRow.IsMixed}");
}
// 15-a0000000) 끄는 동안 치수·좌표가 캔버스에 보인다.
//
// 전에는 숫자가 300px 떨어진 인스펙터에만 떴다 — 끌고, 손 떼고, 오른쪽을 보고, 다시 끈다.
// 간격 수치 칩은 균등 스냅이 성립할 때만 나타나므로 "지금 몇 px 인가"는 알 방법이 없었다.
{
var badge = new DesignerViewModel(new FormDesignDataBusiness().CreateNew());
badge.AddControlAt("Label", new Point(80, 80));
var one = badge.Pages[0].Controls[0];
badge.Selection.SetSingle(one);
Check("치수 칩: 평소에는 안 보인다", !badge.Overlay.ShowDragBadge);
var world = badge.WorldBoundsOf(one);
var grab = new Point(world.X + world.Width / 2, world.Y + world.Height / 2);
var noSnap = new PointerContext(false, false, true, 1);
badge.Interaction.PointerDown(grab, noSnap);
badge.Interaction.PointerMove(new Point(grab.X + 30, grab.Y + 10), noSnap);
Check("치수 칩: 끄는 동안 좌표가 보인다",
badge.Overlay.ShowDragBadge && badge.Overlay.DragBadgeText.StartsWith("X ", StringComparison.Ordinal),
badge.Overlay.DragBadgeText);
badge.Interaction.PointerUp(new Point(grab.X + 30, grab.Y + 10), noSnap);
Check("치수 칩: 놓으면 사라진다", !badge.Overlay.ShowDragBadge);
// 크기 조절일 때는 <b>크기</b>를 보여 준다 — 지금 바꾸고 있는 값이 그것이다
badge.Selection.SetSingle(one);
var bounds = badge.WorldBoundsOf(one);
var handles = ViewModels.SelectionOverlayViewModel.HandlePositions(bounds);
badge.Interaction.PointerDown(handles[4], noSnap); // 우하단
badge.Interaction.PointerMove(new Point(handles[4].X + 25, handles[4].Y + 12), noSnap);
Check("치수 칩: 크기 조절 중에는 크기가 보인다",
badge.Overlay.ShowDragBadge && badge.Overlay.DragBadgeText.Contains('×'),
badge.Overlay.DragBadgeText);
badge.Interaction.PointerUp(new Point(handles[4].X + 25, handles[4].Y + 12), noSnap);
}
// 15-a000000) 패널 안에 그린 마퀴는 그 안의 것을 고른다.
//
// 전에는 마퀴가 최상위만 훑어서, 패널 안 체크박스 5개를 한 번에 고르려고
// 패널 안쪽을 끌면 패널만 잡히거나 아무것도 안 잡혔다 —
// 결국 Ctrl+클릭 반복이나 레이어 목록으로 우회했다.
// 컨테이너 자식은 전체 컨트롤의 18.3%(실측)라 드문 경우가 아니다.
{
var mq = new DesignerViewModel(new FormDesignDataBusiness().CreateNew());
mq.AddControlAt("CheckBox", new Point(100, 100));
mq.AddControlAt("CheckBox", new Point(100, 140));
mq.Selection.Set(mq.Pages[0].Controls.ToList(), mq.Pages[0].Controls[0]);
mq.GroupSelection();
var box = mq.Pages[0].Controls.OfType<ViewModels.Controls.ContainerViewModel>().First();
var boxWorld = mq.WorldBoundsOf(box);
// ① 패널 <b>안쪽</b>을 끈다 — 자식이 잡혀야 한다
var inside = new Rect(boxWorld.X + 2, boxWorld.Y + 2,
Math.Max(1, boxWorld.Width - 4), Math.Max(1, boxWorld.Height - 4));
var innerHits = mq.MarqueeHits(inside);
Check("마퀴: 패널 안에 그리면 안의 것이 잡힌다",
innerHits.Count == box.Children.Count
&& innerHits.All(h => ReferenceEquals(h.Parent, box)),
$"{innerHits.Count}개 (패널 자식 {box.Children.Count}개)");
// ② 패널을 <b>걸치도록</b> 끈다 — 예전처럼 패널 자체가 잡혀야 한다
var across = new Rect(boxWorld.X - 20, boxWorld.Y - 20, boxWorld.Width + 40, 30);
var outerHits = mq.MarqueeHits(across);
Check("마퀴: 패널에 걸치면 패널이 잡힌다(기존 동작 유지)",
outerHits.Count == 1 && ReferenceEquals(outerHits[0], box),
$"{outerHits.Count}개 — {string.Join(",", outerHits.Select(h => h.Type))}");
}
// 15-a00000) 태그 행이 사실대로 말하는가.
{
// 카탈로그에 없는 이름은 표식이 붙어야 한다 — 그러나 <b>막지는 않는다</b>.
@@ -233,20 +233,21 @@ public sealed class InteractionController
designer.ReassignPagesAfterMove(origBounds.Keys.ToList());
designer.Overlay.ClearGuides();
designer.Snap.EndDrag();
designer.Overlay.ClearDragBadge();
break;
case Mode.Resize:
designer.Overlay.ClearGuides();
designer.Snap.EndDrag();
designer.Overlay.ClearDragBadge();
break;
case Mode.Marquee:
var rect = RectFrom(downPoint, world);
designer.Overlay.UpdateMarquee(null);
var hits = designer.ControlsIntersecting(rect)
.SelectMany(designer.GroupMatesOf)
.Distinct()
.ToList();
// 마퀴 전용 규칙 — 그룹은 넓히되 컨테이너 자식은 부모로 되돌리지 않는다.
// GroupMatesOf 를 그대로 쓰면 패널 안쪽을 정확히 골라 놓고도 결과가 패널 하나가 된다.
var hits = designer.MarqueeHits(rect);
if (ctx.Ctrl || ctx.Shift)
{
var merged = designer.Selection.Items.Union(hits).ToList();
@@ -285,6 +286,7 @@ public sealed class InteractionController
{
designer.Overlay.ClearGuides();
designer.Snap.EndDrag();
designer.Overlay.ClearDragBadge();
mode = Mode.None;
origBounds.Clear();
downControl = null;
@@ -443,6 +445,11 @@ public sealed class InteractionController
}
designer.Selection.NotifyBoundsChanged();
// 끄는 동안 지금 값을 보여 준다 — 숫자가 인스펙터에만 있으면 끌다 말고 오른쪽을 봐야 한다
if (designer.SelectionWorldBounds() is { } movedBounds)
{
designer.Overlay.SetDragBadge(movedBounds, resizing: false);
}
}
private void DoResize(Point world, PointerContext ctx)
@@ -509,6 +516,10 @@ public sealed class InteractionController
}
designer.Selection.NotifyBoundsChanged();
if (designer.SelectionWorldBounds() is { } sizedBounds)
{
designer.Overlay.SetDragBadge(sizedBounds, resizing: true);
}
}
/// <summary>드래그 첫 실변경 시 1회 스냅샷 — 드래그 전체 = Undo 1스텝</summary>
@@ -864,19 +864,68 @@ public sealed class DesignerViewModel : ViewModelBase
var result = new List<ControlViewModel>();
foreach (var page in Pages)
{
foreach (var vm in page.Controls)
CollectIntersecting(page.Controls, worldRect, result);
}
return result;
}
/// <summary>
/// 마퀴에 걸리는 것을 모은다 — <b>컨테이너 안에 그린 마퀴는 그 안의 것을 고른다</b>.
///
/// 전에는 최상위만 훑어서, 패널 안 체크박스 5개를 한 번에 고르려고 패널 안쪽을 끌면
/// 패널만 잡히거나 아무것도 안 잡혔다 — 결국 Ctrl+클릭 반복이나 레이어 목록으로 우회했다.
/// 컨테이너 자식은 전체 컨트롤의 18.3%(실측 18,749/컨테이너 4,099개)라 드문 경우가 아니다.
///
/// <b>규칙은 '담김'이다.</b> 마퀴가 컨테이너 사각형 <b>안에</b> 들어가 있으면
/// 사용자는 그 안의 것을 고르려는 것이다(내 상자가 패널보다 작으니까).
/// 걸치기만 하면 예전처럼 컨테이너 자체가 잡힌다 — 패널을 통째로 고르는 조작이 그대로 남는다.
/// 모드도 수정키도 없이 그린 모양만으로 갈리므로 예측할 수 있다.
/// </summary>
private void CollectIntersecting(IEnumerable<ControlViewModel> controls, Rect worldRect,
List<ControlViewModel> result)
{
foreach (var vm in controls)
{
if (vm.Model.Hidden || vm.Model.Locked)
{
continue;
}
if (worldRect.IntersectsWith(WorldBoundsOf(vm)))
var bounds = WorldBoundsOf(vm);
if (!worldRect.IntersectsWith(bounds))
{
continue;
}
if (vm is ContainerViewModel container && bounds.Contains(worldRect))
{
CollectIntersecting(container.Children, worldRect, result);
continue;
}
result.Add(vm);
}
}
/// <summary>
/// 마퀴가 최종적으로 고를 것 — 그룹은 통째로 넓히되 <b>컨테이너 자식은 그대로 둔다</b>.
///
/// 클릭 선택은 <see cref="GroupMatesOf"/> 를 쓰는데 그것은 자식을 부모 컨테이너로 되돌린다
/// (패널 안의 것을 집으면 패널이 움직여야 하므로 옳다). 마퀴에 그것을 그대로 쓰면
/// 안쪽을 정확히 골라 놓고도 결과가 다시 패널 하나가 되어, 위 규칙이 아무 일도 하지 않는다.
/// </summary>
public List<ControlViewModel> MarqueeHits(Rect worldRect)
{
var hits = ControlsIntersecting(worldRect);
var result = new List<ControlViewModel>();
foreach (var vm in hits)
{
// 그룹(GroupId)은 페이지 최상위 개념이다 — 컨테이너 자식에는 붙지 않는다
if (vm.Parent is null && vm.Model.GroupId is not null)
{
result.AddRange(GroupMatesOf(vm));
continue;
}
return result;
result.Add(vm);
}
return result.Distinct().ToList();
}
/// <summary>오버레이(선택 박스/핸들) 재계산</summary>
@@ -116,6 +116,10 @@ public sealed class SelectionOverlayViewModel : ViewModelBase
private double selW;
private double selH;
private bool showHandles;
private string dragBadgeText = string.Empty;
private bool showDragBadge;
private double dragBadgeX;
private double dragBadgeY;
private bool hasMarquee;
private double marX;
private double marY;
@@ -189,6 +193,43 @@ public sealed class SelectionOverlayViewModel : ViewModelBase
/// <summary>탭순서 배지 목록(탭순서 편집 모드 전용)</summary>
public ObservableCollection<TabBadgeInfo> TabBadges { get; } = new();
/// <summary>
/// 끄는 동안 보여 주는 치수·좌표 — <b>드래그 중에만</b> 있다.
///
/// 전에는 숫자가 300px 떨어진 인스펙터에만 떴다. 라벨 폭을 종이 칸에 맞추려면
/// 끌고, 손 떼고, 오른쪽을 보고, 다시 끄는 왕복이 붙었다.
/// 간격 수치 칩은 <b>균등 스냅이 성립하는 순간에만</b> 나타나므로
/// "지금 이게 몇 px 인가"는 드래그 중에 알 방법이 없었다.
/// </summary>
public string DragBadgeText { get => dragBadgeText; private set => SetProperty(ref dragBadgeText, value); }
/// <summary>치수 칩을 보일지</summary>
public bool ShowDragBadge { get => showDragBadge; private set => SetProperty(ref showDragBadge, value); }
/// <summary>치수 칩 X(월드) — 선택 상자 좌하단 바깥</summary>
public double DragBadgeX { get => dragBadgeX; private set => SetProperty(ref dragBadgeX, value); }
/// <summary>치수 칩 Y(월드)</summary>
public double DragBadgeY { get => dragBadgeY; private set => SetProperty(ref dragBadgeY, value); }
/// <summary>
/// 끄는 동안의 치수·좌표를 갱신한다 — 이동이면 좌표, 크기 조절이면 크기를 보여 준다.
/// 사용자가 지금 <b>바꾸고 있는 값</b>을 보여 주는 것이 요점이다.
/// </summary>
public void SetDragBadge(Rect bounds, bool resizing)
{
DragBadgeText = resizing
? $"{Math.Round(bounds.Width)} × {Math.Round(bounds.Height)}"
: $"X {Math.Round(bounds.X)} · Y {Math.Round(bounds.Y)}";
// 선택 상자 <b>아래</b>에 둔다 — 위에 두면 위쪽으로 끌 때 커서가 칩을 밀고 다닌다
DragBadgeX = bounds.X;
DragBadgeY = bounds.Bottom + (6 * InverseZoom);
ShowDragBadge = true;
}
/// <summary>드래그가 끝났다 — 칩을 치운다</summary>
public void ClearDragBadge() => ShowDragBadge = false;
/// <summary>
/// 잠긴 컨트롤의 테두리 — <b>편집 크롬</b>이라 종이에는 그려지지 않는다.
///
+15 -7
View File
@@ -175,7 +175,8 @@
(자르기보다 낫다 — 잘린 라벨은 툴팁을 띄워봐야 아는 정보가 된다).
-->
<Style x:Key="RowLabel" TargetType="TextBlock">
<Setter Property="Width" Value="72"/>
<!-- 위 주석이 92 를 논증해 놓고 값은 72 였다 — 그래서 '액션 대상 컨…' 처럼 잘렸다 -->
<Setter Property="Width" Value="92"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
<Setter Property="FontSize" Value="12"/>
@@ -694,8 +695,15 @@
<DataTemplate DataType="{x:Type ins:ReadOnlyRowViewModel}">
<DockPanel Margin="0,2">
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
<!--
이 자리는 값이 아니라 <b>설명</b>이다 — 자르면 결론이 사라진다.
"타입이 달라 값의 뜻이 다른 속성 2개 — 타입별로 따로 고르세요" 에서
지시에 해당하는 뒷부분이 잘려 나가 무엇을 하라는 건지 알 수 없었다.
접어서 다 보여 주고, 그래도 좁으면 툴팁이 받는다.
-->
<TextBlock Text="{Binding ValueText}" FontSize="11" Foreground="{DynamicResource B.Muted}"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
VerticalAlignment="Center" TextWrapping="Wrap"
ToolTip="{Binding ValueText}"/>
</DockPanel>
</DataTemplate>
@@ -906,11 +914,11 @@
<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}"/>
<!--
⋮ 버튼을 지웠다. Command·Click·ContextMenu 가 <b>전부 없어</b> 눌러도 아무 일이 없었는데,
툴팁은 '이 컨트롤에 대한 명령' 이라 '단일 선택일 때만 되는 뭔가가 있다'고 오해시켰다.
없는 버튼보다 죽은 버튼이 나쁘다. 컨트롤 단위 명령은 캔버스 우클릭 메뉴에 다 있다.
-->
<TextBlock Text="{Binding HeaderName}" FontWeight="Bold" FontSize="12.5"
Foreground="{DynamicResource B.Ink}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" ToolTip="{Binding Summary}"/>
@@ -66,6 +66,22 @@
</ItemsControl.ItemTemplate>
</ItemsControl>
<!--
끄는 동안의 치수·좌표 — 편집 크롬이라 종이에는 없다.
줌이 걸린 층이라 역배율로 되돌려 글자 크기를 화면 고정으로 만든다
(간격 수치 칩이 이미 같은 방식을 쓴다).
-->
<Border Canvas.Left="{Binding DragBadgeX}" Canvas.Top="{Binding DragBadgeY}"
Background="{DynamicResource B.AccentFill}" CornerRadius="3" Padding="5,1"
RenderTransformOrigin="0,0"
Visibility="{Binding ShowDragBadge, Converter={StaticResource BoolToVisibility}}">
<Border.RenderTransform>
<ScaleTransform ScaleX="{Binding InverseZoom}" ScaleY="{Binding InverseZoom}"/>
</Border.RenderTransform>
<TextBlock Text="{Binding DragBadgeText}" FontSize="11"
Foreground="{DynamicResource B.OnAccent}"/>
</Border>
<!-- 마퀴(러버밴드) -->
<Rectangle Canvas.Left="{Binding MarX}" Canvas.Top="{Binding MarY}"
Width="{Binding MarW}" Height="{Binding MarH}"