캔버스에서 고른 것을 레이어 목록이 따라오게 한다
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:
co-authored by
Claude Fable 5
parent
6a0331ec8a
commit
4b400b94e0
@@ -138,6 +138,58 @@ public static class LayerListBehavior
|
||||
{
|
||||
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>
|
||||
|
||||
@@ -1236,6 +1236,22 @@ public static class EditSmoke
|
||||
fresh.LayerSubText == fresh.Id,
|
||||
$"'{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
|
||||
{
|
||||
Type = "TextBox", Id = "TextBox9",
|
||||
|
||||
@@ -2780,6 +2780,46 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
|
||||
/// <summary>페이지 펼침 상태 — 기본은 펼침. 키를 인덱스가 아니라 <b>페이지 인스턴스</b>로 잡는다
|
||||
/// (인덱스로 잡으면 페이지를 중간에 끼우거나 지웠을 때 엉뚱한 페이지가 접힌다)</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);
|
||||
|
||||
/// <summary>페이지 펼침/접기</summary>
|
||||
|
||||
Reference in New Issue
Block a user