루프를 키보드로 닫는다 — Tab 다음, Shift+Tab 이전, Enter 안, Esc 밖

이 도구의 일은 "컨트롤 하나 골라 값 하나 바꾸고 다음으로"의 반복인데,
<b>그 루프가 마우스 없이는 한 바퀴도 돌지 않았다.</b> 선택을 옮길 키가 하나도 없었다 —
캔버스 키맵 18개 중 선택 이동 0, 편집 시작 0. 화살표는 선택이 아니라 컨트롤을 민다.
Label 59,997 + TextBox 28,574 의 문구를 고치는 일이 전부 더블클릭에 묶여 있었다.

<b>읽는 순서로 돈다 — z-order 가 아니다.</b> 계획서는 z-order 순회를 적었지만,
Tab 을 누르는 이유는 "서식을 위에서 아래로 훑으며 문구를 고치는 것"이다.
기록지는 위→아래·왼쪽→오른쪽으로 읽으므로 그 순서가 손에 맞는다.
z-order 는 겹침을 정하는 축이라 화면상 순서와 무관하게 튄다.
같은 줄로 볼 만큼 가까우면(8px = 그리드 두 칸) X 로 가른다.
EMR 런타임 입력 순서인 ApplyTabOrder 와는 다른 개념이다 — 그건 저장되는 값이다.

<b>범위는 형제다.</b> 컨테이너 안으로는 Enter, 밖으로는 Esc.
전체를 한 줄로 이으면 패널 안 라디오 15,026개를 지나야 다음 항목에 닿는다.

Esc 가 이제 한 겹만 나간다. 전에는 항상 선택을 통째로 풀어서,
패널 자식을 고른 뒤 Esc 를 누르면 부모로 돌아가는 대신 선택이 사라졌다.
드래그 중 Esc 는 그대로 드래그 취소다.

선택이 옮겨지면 RevealControl 로 화면에 데려온다 —
레이어 목록 화살표 이동에 이것이 없어서 화면 밖 컨트롤을 눈 없이 편집할 수 있었다.

PreviewKeyDown 에 붙인다. KeyDown 이면 WPF 포커스 관리자가 Tab 을 먼저 먹어
포커스가 캔버스 밖(좌측 패널·문서 탭·플로팅 바)으로 나간다.

검사는 배선이 아니라 <b>한 바퀴가 도는지</b>를 본다 — 9건:
읽는 순서 · 끝에서 처음으로 되돌아옴 · 역방향 · 컨테이너 안 · 밖 · 나갈 곳 없음 · 편집 시작.
컨테이너는 GroupSelection 이 아니라 문서에 직접 만든다 —
그 경로에는 거절 조건(라디오·z 불연속)이 있어 막히면 순회 대신 그룹 규칙을 재게 된다
(실제로 처음에 그렇게 막혔다).

게이트: 테스트 285/285, --edit-smoke 0실패(신규 9), --dialog-shots 넘침 0(대조군 4/4),
--scale-budget 5/5, --maxrect 0실패, --cleartype 11/11, --modal-check 0실패, 빌드 경고 0,
--db-smoke 1271건 diff 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-14 17:07:09 +09:00
co-authored by Claude Opus 5
parent 1b49f07dd9
commit d6052610b9
4 changed files with 169 additions and 1 deletions
@@ -1,5 +1,6 @@
using System.IO;
using System.Windows;
using SheetMe.Core.Models;
using SheetMe.Core.Serialization;
using SheetMe.Designer.DataBusiness;
using SheetMe.Designer.Services;
@@ -78,6 +79,78 @@ public static class EditSmoke
$"지운 자리 {lastSpot}, 새로 놓인 자리 ({again.X},{again.Y})");
}
// 키보드로 루프가 닫히는가 — Tab 으로 다음, Shift+Tab 으로 이전, Esc 로 밖, Enter 로 안.
{
var loop = new DesignerViewModel(new FormDesignDataBusiness().CreateNew());
var sheet = loop.Pages[0];
// 읽는 순서가 보이도록 아래→위 역순으로 놓는다. 순회가 z-order 를 따르면 이 순서가 그대로 나온다.
loop.AddControlAt("Label", new Point(60, 300));
loop.AddControlAt("Label", new Point(60, 200));
loop.AddControlAt("Label", new Point(60, 100));
var byY = sheet.Controls.Where(c => c.Type == "Label").OrderBy(c => c.Y).ToList();
loop.Selection.Clear();
loop.SelectSibling(backward: false);
var first = loop.Selection.Primary;
loop.SelectSibling(backward: false);
var second = loop.Selection.Primary;
loop.SelectSibling(backward: false);
loop.SelectSibling(backward: false);
var wrapped = loop.Selection.Primary;
Check("Tab: 읽는 순서로 돈다(위에서 아래)",
ReferenceEquals(first, byY[0]) && ReferenceEquals(second, byY[1]),
$"1번째 Y={first?.Y} 2번째 Y={second?.Y} (기대 {byY[0].Y}, {byY[1].Y})");
Check("Tab: 끝에서 처음으로 돌아온다", ReferenceEquals(wrapped, byY[0]),
$"네 번째에서 Y={wrapped?.Y} (기대 {byY[0].Y})");
loop.SelectSibling(backward: true);
Check("Shift+Tab: 거꾸로 간다", ReferenceEquals(loop.Selection.Primary, byY[^1]),
$"Y={loop.Selection.Primary?.Y} (기대 {byY[^1].Y})");
// 컨테이너 안팎 — Enter 로 들어가고 Esc 로 나온다.
// GroupSelection 을 쓰지 않는다: 그 경로에는 거절 조건(라디오·z 불연속)이 있어
// 여기서 막히면 순회를 재는 대신 그룹 규칙을 재게 된다. 문서에 직접 만든다.
var nested = new FormDocument();
var nestedPage = new FormPage { Width = 720, Height = 856 };
nested.Pages.Add(nestedPage);
var panelModel = new ControlElement
{
Type = "Panel", Id = "Panel9",
Bounds = new LayoutRect { X = 40, Y = 40, W = 300, H = 200 },
};
foreach (var (name, y) in new[] { ("자식A", 10.0), ("자식B", 60.0) })
{
var kid = new ControlElement
{
Type = "Label", Id = name,
Bounds = new LayoutRect { X = 10, Y = y, W = 120, H = 18 },
};
kid.Props.SetText("Text", name);
panelModel.Children.Add(kid);
}
nestedPage.Controls.Add(panelModel);
var inner = new DesignerViewModel(nested);
var wrap = inner.Pages[0].Controls.OfType<ContainerViewModel>().First();
loop = inner;
loop.Selection.SetSingle(wrap);
Check("Enter: 컨테이너 안으로 들어간다",
loop.EnterSelection() && loop.Selection.Primary?.Parent == wrap);
Check("Esc: 컨테이너 밖으로 나온다",
loop.SelectParent() && ReferenceEquals(loop.Selection.Primary, wrap));
Check("Esc: 나갈 곳이 없으면 false", !loop.SelectParent());
// 글자를 담은 것에서 Enter 는 편집을 시작한다
var editable = wrap.Children.First();
loop.Selection.SetSingle(editable);
var asked = 0;
void Count(ControlViewModel _) => asked++;
loop.InlineEditRequested += Count;
loop.EnterSelection();
loop.InlineEditRequested -= Count;
Check("Enter: 글자면 편집을 시작한다", asked == 1, $"요청 {asked}회");
}
// 미리보기가 인쇄 필터를 <b>실제로 쓰는가</b>.
// PrintFilter 단위 테스트는 판정이 옳다는 것만 말한다 — 렌더 경로가 그 판정을 부르는지는
// 다른 문제이고, 이 앱에서 "코드는 맞아 보이는데 화면은 다른" 일이 여러 번 있었다.