캔버스에서 고른 것을 레이어 목록이 따라오게 한다

1,067개짜리 서식에서 종이 한가운데 컨트롤을 클릭하면 목록에 아무 변화가 없는 경우가 흔했다.
둘 중 하나였다 — 행이 뷰포트 밖이거나, 조상이 접혀 있어 <b>행 자체가 만들어지지 않았거나</b>.

접기는 레이어 목록만의 개념이라 캔버스에는 자식이 그대로 보인다. 그래서 사용자는
"분명히 골랐는데 레이어에는 선택이 없는" 상태를 보고 어느 쪽이 맞는지 판단할 단서를 잃는다.
접기·검색·그룹을 쓸수록 어긋나므로 <b>기존 기능이 오히려 신뢰를 깎고 있었다</b>.

반대 방향(목록 더블클릭 → 캔버스 스크롤·페이지 전환)은 이미 잘 되어 있어 비대칭이 더 눈에 띄었다.
이번 것은 새 기능이 아니라 그 역방향을 잇는 일이다.

## 어떻게

선택이 바뀌면 주 선택의 행을 찾아 스크롤한다. 행이 없으면 조상(페이지 마디·그룹 폴더·컨테이너)을
편다 — 그 결과로 목록이 다시 만들어지고, 재구성 이벤트가 이 경로를 한 번 더 부를 때
행이 있으므로 스크롤만 하고 끝난다. 펴기는 <b>한 번만</b> 시도한다(이미 펴져 있으면 false 를
돌려주므로 재구성이 무한히 돌지 않는다 — 검사로 고정했다).

<b>필터는 건드리지 않는다.</b> 펼 것이 없는데도 행이 없다면 검색·타입 필터에 걸린 것이다.
그건 사용자가 건 것이라 임의로 풀면 그쪽이 더 놀랍다 — 아무것도 하지 않는다.

되먹임은 기존 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 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-21 09:17:06 +09:00
co-authored by Claude Fable 5
parent 6a0331ec8a
commit 4b400b94e0
3 changed files with 108 additions and 0 deletions
@@ -138,6 +138,58 @@ public static class LayerListBehavior
{ {
syncing.Remove(list); syncing.Remove(list);
} }
RevealPrimary(list, designer);
}
/// <summary>
/// 캔버스에서 고른 것을 목록이 <b>따라오게</b> 한다 — 조상을 펴고 그 행으로 스크롤한다.
///
/// <b>왜 필요한가.</b> 1,067개짜리 서식에서 종이 한가운데 컨트롤을 클릭하면
/// 목록에는 아무 변화가 없는 경우가 흔했다 — 행이 뷰포트 밖이거나, 조상이 접혀 있어
/// <b>행 자체가 만들어지지 않았거나</b>. 강조를 해도 눈에 안 보이니 선택이 안 된 것으로 읽힌다.
/// 반대 방향(목록 더블클릭 → 캔버스)은 이미 잘 되어 있어 비대칭이 더 눈에 띄었다.
///
/// <b>펴기는 한 번만 시도한다.</b> 편 결과로 목록이 다시 만들어지면 재구성 이벤트가
/// 이 경로를 한 번 더 부르는데, 그때는 행이 있으므로 스크롤만 하고 끝난다.
/// 행이 끝내 없으면(검색·타입 필터에 걸려 빠진 경우) 아무것도 하지 않는다 —
/// 필터를 사용자가 건 것이라 임의로 풀면 그쪽이 더 놀랍다.
/// </summary>
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);
}
/// <summary>이 컨트롤을 담고 있는 목록 행 — 없으면 null(접혔거나 필터에 걸렸다)</summary>
private static object? RowOf(ListBox list, ControlViewModel control)
{
foreach (var row in list.Items)
{
if (DesignerViewModel.ControlsOfLayerRow(row).Contains(control))
{
return row;
}
}
return null;
} }
/// <summary>현재 선택으로부터 강조돼야 할 행들</summary> /// <summary>현재 선택으로부터 강조돼야 할 행들</summary>
@@ -1236,6 +1236,22 @@ public static class EditSmoke
fresh.LayerSubText == fresh.Id, fresh.LayerSubText == fresh.Id,
$"'{fresh.LayerName}' / '{fresh.LayerSubText}'"); $"'{fresh.LayerName}' / '{fresh.LayerSubText}'");
// 접힌 조상 때문에 <b>행 자체가 없는</b> 상태를 편다.
// 접기는 레이어 목록만의 개념이라 캔버스에는 자식이 그대로 보인다 —
// 종이에서 클릭했는데 목록에 아무 반응이 없으면 어느 쪽이 맞는지 알 수 없다.
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 var bare = new ControlElement
{ {
Type = "TextBox", Id = "TextBox9", Type = "TextBox", Id = "TextBox9",
@@ -2780,6 +2780,46 @@ public sealed class DesignerViewModel : ViewModelBase
/// <summary>페이지 펼침 상태 — 기본은 펼침. 키를 인덱스가 아니라 <b>페이지 인스턴스</b>로 잡는다 /// <summary>페이지 펼침 상태 — 기본은 펼침. 키를 인덱스가 아니라 <b>페이지 인스턴스</b>로 잡는다
/// (인덱스로 잡으면 페이지를 중간에 끼우거나 지웠을 때 엉뚱한 페이지가 접힌다)</summary> /// (인덱스로 잡으면 페이지를 중간에 끼우거나 지웠을 때 엉뚱한 페이지가 접힌다)</summary>
/// <summary>
/// 이 컨트롤의 행이 목록에 <b>나타나도록</b> 조상을 전부 편다 — 실제로 편 것이 있으면 true.
///
/// <b>왜 필요한가.</b> 접기는 레이어 목록만의 개념이라 캔버스에는 자식이 그대로 보인다.
/// 그래서 종이 한가운데 컨트롤을 클릭했는데 그 조상이 접혀 있으면
/// 목록에는 <b>행 자체가 없어</b> 아무 반응이 없다 — 사용자 눈에는 선택이 안 된 것으로 보이고,
/// 캔버스와 목록 중 어느 쪽이 맞는지 판단할 단서를 잃는다.
/// 접기·검색·그룹을 쓸수록 어긋나므로, 기존 기능이 오히려 신뢰를 깎고 있었다.
///
/// 반대 방향(목록 더블클릭 → 캔버스로 스크롤)은 <see cref="RevealControl"/> 로 이미 있다 —
/// 이것은 <b>그 역방향을 잇는 일</b>이지 새 기능이 아니다.
/// </summary>
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); public bool IsPageExpanded(PageViewModel page) => !collapsedPages.Contains(page);
/// <summary>페이지 펼침/접기</summary> /// <summary>페이지 펼침/접기</summary>