From 8c76a978a99b39fbb15db694b4788a1a9b446404 Mon Sep 17 00:00:00 2001 From: Msystech Date: Fri, 21 Aug 2026 09:05:30 +0900 Subject: [PATCH] =?UTF-8?q?=EC=BA=94=EB=B2=84=EC=8A=A4=C2=B7=EC=9D=B8?= =?UTF-8?q?=EC=8A=A4=ED=8E=99=ED=84=B0=20=EC=9E=94=EC=86=90=EC=A7=88=20?= =?UTF-8?q?=EB=84=B7=20=E2=80=94=20=EB=A7=88=ED=80=B4=C2=B7=EC=B9=98?= =?UTF-8?q?=EC=88=98=20=EC=B9=A9=C2=B7=EC=A3=BD=EC=9D=80=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=C2=B7=EC=9E=98=EB=A6=B0=20=EC=84=A4=EB=AA=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 패널 안에 그린 마퀴가 아무것도 안 잡았다 마퀴가 최상위만 훑고 컨테이너 안으로 안 들어갔다. 패널 안 체크박스 5개를 한 번에 고르려고 안쪽을 끌면 패널만 잡히거나 아무것도 안 잡혀서, Ctrl+클릭 반복이나 레이어 목록으로 우회했다. 컨테이너 자식은 전체 컨트롤의 18.3%(실측 18,749개/컨테이너 4,099개)라 드문 경우가 아니다. 규칙은 '담김'이다 — 마퀴가 컨테이너 사각형 안에 들어가 있으면 그 안의 것을 고른다 (내 상자가 패널보다 작으니 그 안을 고르려는 뜻이다). 걸치기만 하면 예전처럼 패널이 잡힌다. 모드도 수정키도 없이 그린 모양만으로 갈리므로 예측할 수 있다. 한 군데를 더 고쳐야 했다. 클릭 선택이 쓰는 GroupMatesOf 는 자식을 부모 컨테이너로 되돌리는데 (패널 안의 것을 집으면 패널이 움직여야 하므로 옳다) 마퀴가 그것을 그대로 쓰고 있었다 — 안쪽을 정확히 골라 놓고도 결과가 다시 패널 하나가 되어 위 규칙이 아무 일도 못 한다. 마퀴 전용 경로를 따로 뒀다. 걸치기 동작이 그대로인 것도 검사로 고정했다. ## 끄는 동안 숫자가 캔버스에 없었다 라벨 폭을 종이 칸에 맞추는 동안 숫자는 300px 떨어진 인스펙터에만 떴다 — 끌고, 손 떼고, 오른쪽 보고, 다시 끈다. 간격 수치 칩은 균등 스냅이 성립할 때만 나타나므로 "지금 이게 몇 px 인가"는 드래그 중에 알 방법이 아예 없었다. 이동이면 좌표, 크기 조절이면 크기를 보여 준다 — 지금 바꾸고 있는 값이 그것이다. 오버레이 층이라 종이에는 없고, 줌이 걸린 층이라 역배율로 글자 크기를 화면 고정으로 만든다. ## 눌러도 아무 일이 없는 ⋮ 버튼 Command·Click·ContextMenu 가 전부 없었다. 그런데 툴팁은 '이 컨트롤에 대한 명령'이라 "단일 선택일 때만 되는 뭔가가 있다"고 오해시켰다. 없는 버튼보다 죽은 버튼이 나쁘다 — 컨트롤 단위 명령은 캔버스 우클릭 메뉴에 이미 다 있다. ## 주석이 논증한 값과 실제 값이 달랐다 라벨 폭 주석이 "92px + 줄바꿈이면 두 줄로 접혀 전부 읽힌다"고 근거까지 적어 놓고 값은 72 였다. 그래서 '액션 대상 컨…' 처럼 잘렸다. 92 로 맞췄다. 같은 자리에서 설명 행(ReadOnlyRow)이 값처럼 잘리고 있었다 — "타입이 달라 값의 뜻이 다른 속성 2개 — 타입별로 따로 고르세요" 에서 지시에 해당하는 뒷부분이 사라져 무엇을 하라는 건지 알 수 없었다. 접어서 다 보여 준다. ## 판정 - 단위 시험 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 --- src/SheetMe.Designer/Diagnostics/EditSmoke.cs | 67 +++++++++++++++++ .../Services/InteractionController.cs | 19 +++-- .../ViewModels/DesignerViewModel.cs | 71 ++++++++++++++++--- .../ViewModels/SelectionOverlayViewModel.cs | 41 +++++++++++ src/SheetMe.Designer/Views/InspectorView.xaml | 22 ++++-- .../Views/SelectionOverlayView.xaml | 16 +++++ 6 files changed, 214 insertions(+), 22 deletions(-) diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs index 9f6b503..ee0ad2b 100644 --- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs @@ -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); + + // 크기 조절일 때는 크기를 보여 준다 — 지금 바꾸고 있는 값이 그것이다 + 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().First(); + var boxWorld = mq.WorldBoundsOf(box); + + // ① 패널 안쪽을 끈다 — 자식이 잡혀야 한다 + 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}개)"); + + // ② 패널을 걸치도록 끈다 — 예전처럼 패널 자체가 잡혀야 한다 + 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) 태그 행이 사실대로 말하는가. { // 카탈로그에 없는 이름은 표식이 붙어야 한다 — 그러나 막지는 않는다. diff --git a/src/SheetMe.Designer/Services/InteractionController.cs b/src/SheetMe.Designer/Services/InteractionController.cs index 81f8b77..a2581c9 100644 --- a/src/SheetMe.Designer/Services/InteractionController.cs +++ b/src/SheetMe.Designer/Services/InteractionController.cs @@ -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); + } } /// 드래그 첫 실변경 시 1회 스냅샷 — 드래그 전체 = Undo 1스텝 diff --git a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs index 3a273f1..fcdffbd 100644 --- a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs +++ b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs @@ -864,21 +864,70 @@ public sealed class DesignerViewModel : ViewModelBase var result = new List(); foreach (var page in Pages) { - foreach (var vm in page.Controls) - { - if (vm.Model.Hidden || vm.Model.Locked) - { - continue; - } - if (worldRect.IntersectsWith(WorldBoundsOf(vm))) - { - result.Add(vm); - } - } + CollectIntersecting(page.Controls, worldRect, result); } return result; } + /// + /// 마퀴에 걸리는 것을 모은다 — 컨테이너 안에 그린 마퀴는 그 안의 것을 고른다. + /// + /// 전에는 최상위만 훑어서, 패널 안 체크박스 5개를 한 번에 고르려고 패널 안쪽을 끌면 + /// 패널만 잡히거나 아무것도 안 잡혔다 — 결국 Ctrl+클릭 반복이나 레이어 목록으로 우회했다. + /// 컨테이너 자식은 전체 컨트롤의 18.3%(실측 18,749/컨테이너 4,099개)라 드문 경우가 아니다. + /// + /// 규칙은 '담김'이다. 마퀴가 컨테이너 사각형 안에 들어가 있으면 + /// 사용자는 그 안의 것을 고르려는 것이다(내 상자가 패널보다 작으니까). + /// 걸치기만 하면 예전처럼 컨테이너 자체가 잡힌다 — 패널을 통째로 고르는 조작이 그대로 남는다. + /// 모드도 수정키도 없이 그린 모양만으로 갈리므로 예측할 수 있다. + /// + private void CollectIntersecting(IEnumerable controls, Rect worldRect, + List result) + { + foreach (var vm in controls) + { + if (vm.Model.Hidden || vm.Model.Locked) + { + continue; + } + 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); + } + } + + /// + /// 마퀴가 최종적으로 고를 것 — 그룹은 통째로 넓히되 컨테이너 자식은 그대로 둔다. + /// + /// 클릭 선택은 를 쓰는데 그것은 자식을 부모 컨테이너로 되돌린다 + /// (패널 안의 것을 집으면 패널이 움직여야 하므로 옳다). 마퀴에 그것을 그대로 쓰면 + /// 안쪽을 정확히 골라 놓고도 결과가 다시 패널 하나가 되어, 위 규칙이 아무 일도 하지 않는다. + /// + public List MarqueeHits(Rect worldRect) + { + var hits = ControlsIntersecting(worldRect); + var result = new List(); + foreach (var vm in hits) + { + // 그룹(GroupId)은 페이지 최상위 개념이다 — 컨테이너 자식에는 붙지 않는다 + if (vm.Parent is null && vm.Model.GroupId is not null) + { + result.AddRange(GroupMatesOf(vm)); + continue; + } + result.Add(vm); + } + return result.Distinct().ToList(); + } + /// 오버레이(선택 박스/핸들) 재계산 public void RefreshOverlay() { diff --git a/src/SheetMe.Designer/ViewModels/SelectionOverlayViewModel.cs b/src/SheetMe.Designer/ViewModels/SelectionOverlayViewModel.cs index b3531d2..3ec05e1 100644 --- a/src/SheetMe.Designer/ViewModels/SelectionOverlayViewModel.cs +++ b/src/SheetMe.Designer/ViewModels/SelectionOverlayViewModel.cs @@ -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 /// 탭순서 배지 목록(탭순서 편집 모드 전용) public ObservableCollection TabBadges { get; } = new(); + /// + /// 끄는 동안 보여 주는 치수·좌표 — 드래그 중에만 있다. + /// + /// 전에는 숫자가 300px 떨어진 인스펙터에만 떴다. 라벨 폭을 종이 칸에 맞추려면 + /// 끌고, 손 떼고, 오른쪽을 보고, 다시 끄는 왕복이 붙었다. + /// 간격 수치 칩은 균등 스냅이 성립하는 순간에만 나타나므로 + /// "지금 이게 몇 px 인가"는 드래그 중에 알 방법이 없었다. + /// + public string DragBadgeText { get => dragBadgeText; private set => SetProperty(ref dragBadgeText, value); } + + /// 치수 칩을 보일지 + public bool ShowDragBadge { get => showDragBadge; private set => SetProperty(ref showDragBadge, value); } + + /// 치수 칩 X(월드) — 선택 상자 좌하단 바깥 + public double DragBadgeX { get => dragBadgeX; private set => SetProperty(ref dragBadgeX, value); } + + /// 치수 칩 Y(월드) + public double DragBadgeY { get => dragBadgeY; private set => SetProperty(ref dragBadgeY, value); } + + /// + /// 끄는 동안의 치수·좌표를 갱신한다 — 이동이면 좌표, 크기 조절이면 크기를 보여 준다. + /// 사용자가 지금 바꾸고 있는 값을 보여 주는 것이 요점이다. + /// + 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)}"; + // 선택 상자 아래에 둔다 — 위에 두면 위쪽으로 끌 때 커서가 칩을 밀고 다닌다 + DragBadgeX = bounds.X; + DragBadgeY = bounds.Bottom + (6 * InverseZoom); + ShowDragBadge = true; + } + + /// 드래그가 끝났다 — 칩을 치운다 + public void ClearDragBadge() => ShowDragBadge = false; + /// /// 잠긴 컨트롤의 테두리 — 편집 크롬이라 종이에는 그려지지 않는다. /// diff --git a/src/SheetMe.Designer/Views/InspectorView.xaml b/src/SheetMe.Designer/Views/InspectorView.xaml index 49654ac..0dce641 100644 --- a/src/SheetMe.Designer/Views/InspectorView.xaml +++ b/src/SheetMe.Designer/Views/InspectorView.xaml @@ -175,7 +175,8 @@ (자르기보다 낫다 — 잘린 라벨은 툴팁을 띄워봐야 아는 정보가 된다). -->