From 4b400b94e00046ab63f4d950c58c907c999a4f91 Mon Sep 17 00:00:00 2001 From: Msystech Date: Fri, 21 Aug 2026 09:17:06 +0900 Subject: [PATCH] =?UTF-8?q?=EC=BA=94=EB=B2=84=EC=8A=A4=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EA=B3=A0=EB=A5=B8=20=EA=B2=83=EC=9D=84=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=20=EB=AA=A9=EB=A1=9D=EC=9D=B4=20=EB=94=B0=EB=9D=BC?= =?UTF-8?q?=EC=98=A4=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1,067개짜리 서식에서 종이 한가운데 컨트롤을 클릭하면 목록에 아무 변화가 없는 경우가 흔했다. 둘 중 하나였다 — 행이 뷰포트 밖이거나, 조상이 접혀 있어 행 자체가 만들어지지 않았거나. 접기는 레이어 목록만의 개념이라 캔버스에는 자식이 그대로 보인다. 그래서 사용자는 "분명히 골랐는데 레이어에는 선택이 없는" 상태를 보고 어느 쪽이 맞는지 판단할 단서를 잃는다. 접기·검색·그룹을 쓸수록 어긋나므로 기존 기능이 오히려 신뢰를 깎고 있었다. 반대 방향(목록 더블클릭 → 캔버스 스크롤·페이지 전환)은 이미 잘 되어 있어 비대칭이 더 눈에 띄었다. 이번 것은 새 기능이 아니라 그 역방향을 잇는 일이다. ## 어떻게 선택이 바뀌면 주 선택의 행을 찾아 스크롤한다. 행이 없으면 조상(페이지 마디·그룹 폴더·컨테이너)을 편다 — 그 결과로 목록이 다시 만들어지고, 재구성 이벤트가 이 경로를 한 번 더 부를 때 행이 있으므로 스크롤만 하고 끝난다. 펴기는 한 번만 시도한다(이미 펴져 있으면 false 를 돌려주므로 재구성이 무한히 돌지 않는다 — 검사로 고정했다). 필터는 건드리지 않는다. 펼 것이 없는데도 행이 없다면 검색·타입 필터에 걸린 것이다. 그건 사용자가 건 것이라 임의로 풀면 그쪽이 더 놀랍다 — 아무것도 하지 않는다. 되먹임은 기존 syncing 가드가 막는다. 목록에서 고르는 경로는 그 가드에 걸려 스크롤이 돌지 않으므로, 클릭하던 자리가 움직이지 않는다. ## 판정 - 단위 시험 388 · edit-smoke 404건 전건 통과 (자동 스크롤 4건 추가) - --dialog-shots 69장 · --modal-check · --maxrect · --scale-budget 전건 통과 - --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변 검사는 VM 층(조상 펴기·행 생성·재진입 방지)까지다. 실제 스크롤 위치는 ListBox 가상화가 관여하므로 앱에서 긴 서식으로 한 번 확인하는 것이 좋다. Co-Authored-By: Claude Fable 5 --- .../Behaviors/LayerListBehavior.cs | 52 +++++++++++++++++++ src/SheetMe.Designer/Diagnostics/EditSmoke.cs | 16 ++++++ .../ViewModels/DesignerViewModel.cs | 40 ++++++++++++++ 3 files changed, 108 insertions(+) diff --git a/src/SheetMe.Designer/Behaviors/LayerListBehavior.cs b/src/SheetMe.Designer/Behaviors/LayerListBehavior.cs index 500febf..0f677f8 100644 --- a/src/SheetMe.Designer/Behaviors/LayerListBehavior.cs +++ b/src/SheetMe.Designer/Behaviors/LayerListBehavior.cs @@ -138,6 +138,58 @@ public static class LayerListBehavior { syncing.Remove(list); } + RevealPrimary(list, designer); + } + + /// + /// 캔버스에서 고른 것을 목록이 따라오게 한다 — 조상을 펴고 그 행으로 스크롤한다. + /// + /// 왜 필요한가. 1,067개짜리 서식에서 종이 한가운데 컨트롤을 클릭하면 + /// 목록에는 아무 변화가 없는 경우가 흔했다 — 행이 뷰포트 밖이거나, 조상이 접혀 있어 + /// 행 자체가 만들어지지 않았거나. 강조를 해도 눈에 안 보이니 선택이 안 된 것으로 읽힌다. + /// 반대 방향(목록 더블클릭 → 캔버스)은 이미 잘 되어 있어 비대칭이 더 눈에 띄었다. + /// + /// 펴기는 한 번만 시도한다. 편 결과로 목록이 다시 만들어지면 재구성 이벤트가 + /// 이 경로를 한 번 더 부르는데, 그때는 행이 있으므로 스크롤만 하고 끝난다. + /// 행이 끝내 없으면(검색·타입 필터에 걸려 빠진 경우) 아무것도 하지 않는다 — + /// 필터를 사용자가 건 것이라 임의로 풀면 그쪽이 더 놀랍다. + /// + private static void RevealPrimary(ListBox list, DesignerViewModel designer) + { + if (designer.Selection.Primary is not { } primary) + { + return; + } + if (RowOf(list, primary) is { } row) + { + list.ScrollIntoView(row); + return; + } + if (!designer.ExpandLayerPathTo(primary)) + { + return; // 펼 것이 없는데도 행이 없다 = 필터에 걸린 것이다 + } + // 재구성은 비동기로 도는 경로가 있다 — 한 틱 뒤에 다시 찾아본다 + list.Dispatcher.BeginInvoke(new Action(() => + { + if (RowOf(list, primary) is { } revealed) + { + list.ScrollIntoView(revealed); + } + }), System.Windows.Threading.DispatcherPriority.Background); + } + + /// 이 컨트롤을 담고 있는 목록 행 — 없으면 null(접혔거나 필터에 걸렸다) + private static object? RowOf(ListBox list, ControlViewModel control) + { + foreach (var row in list.Items) + { + if (DesignerViewModel.ControlsOfLayerRow(row).Contains(control)) + { + return row; + } + } + return null; } /// 현재 선택으로부터 강조돼야 할 행들 diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs index a1f9433..e952da2 100644 --- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs @@ -1236,6 +1236,22 @@ public static class EditSmoke fresh.LayerSubText == fresh.Id, $"'{fresh.LayerName}' / '{fresh.LayerSubText}'"); + // 접힌 조상 때문에 행 자체가 없는 상태를 편다. + // 접기는 레이어 목록만의 개념이라 캔버스에는 자식이 그대로 보인다 — + // 종이에서 클릭했는데 목록에 아무 반응이 없으면 어느 쪽이 맞는지 알 수 없다. + wrapped.IsLayerExpanded = false; + tree.RebuildLayerRows(); + var hiddenChild = wrapped.Children[0]; + Check("자동 스크롤: 접히면 자식 행이 목록에 없다", + !tree.LayerRows.Any(r => DesignerViewModel.ControlsOfLayerRow(r).Contains(hiddenChild))); + + Check("자동 스크롤: 조상을 펴면 true 를 돌려준다", tree.ExpandLayerPathTo(hiddenChild)); + tree.RebuildLayerRows(); + Check("자동 스크롤: 편 뒤에는 행이 생긴다", + tree.LayerRows.Any(r => DesignerViewModel.ControlsOfLayerRow(r).Contains(hiddenChild))); + Check("자동 스크롤: 이미 펴져 있으면 false(무한 재구성 방지)", + !tree.ExpandLayerPathTo(hiddenChild)); + var bare = new ControlElement { Type = "TextBox", Id = "TextBox9", diff --git a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs index fcdffbd..2cb669e 100644 --- a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs +++ b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs @@ -2780,6 +2780,46 @@ public sealed class DesignerViewModel : ViewModelBase /// 페이지 펼침 상태 — 기본은 펼침. 키를 인덱스가 아니라 페이지 인스턴스로 잡는다 /// (인덱스로 잡으면 페이지를 중간에 끼우거나 지웠을 때 엉뚱한 페이지가 접힌다) + /// + /// 이 컨트롤의 행이 목록에 나타나도록 조상을 전부 편다 — 실제로 편 것이 있으면 true. + /// + /// 왜 필요한가. 접기는 레이어 목록만의 개념이라 캔버스에는 자식이 그대로 보인다. + /// 그래서 종이 한가운데 컨트롤을 클릭했는데 그 조상이 접혀 있으면 + /// 목록에는 행 자체가 없어 아무 반응이 없다 — 사용자 눈에는 선택이 안 된 것으로 보이고, + /// 캔버스와 목록 중 어느 쪽이 맞는지 판단할 단서를 잃는다. + /// 접기·검색·그룹을 쓸수록 어긋나므로, 기존 기능이 오히려 신뢰를 깎고 있었다. + /// + /// 반대 방향(목록 더블클릭 → 캔버스로 스크롤)은 로 이미 있다 — + /// 이것은 그 역방향을 잇는 일이지 새 기능이 아니다. + /// + public bool ExpandLayerPathTo(ControlViewModel viewModel) + { + var changed = false; + + // 페이지 마디 — 공개 메서드를 쓴다(목록 재구성 요청까지 함께 도는 경로다) + if (PageOf(viewModel) is { } page && !IsPageExpanded(page)) + { + SetPageExpanded(page, true); + changed = true; + } + // 그룹 폴더 — 그룹 멤버는 폴더가 접히면 행이 안 만들어진다 + if (viewModel.Model.GroupId is { } groupId && !IsGroupExpanded(groupId)) + { + SetGroupExpanded(groupId, true); + changed = true; + } + // 컨테이너 조상 — 자기 자신은 펴지 않는다(자기 행은 이미 있다) + for (var parent = viewModel.Parent; parent is not null; parent = parent.Parent) + { + if (!parent.IsLayerExpanded) + { + parent.IsLayerExpanded = true; + changed = true; + } + } + return changed; + } + public bool IsPageExpanded(PageViewModel page) => !collapsedPages.Contains(page); /// 페이지 펼침/접기