From d6052610b935ae9f5448c1adef9fb16a39d96afd Mon Sep 17 00:00:00 2001 From: Msystech Date: Fri, 14 Aug 2026 17:07:09 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A3=A8=ED=94=84=EB=A5=BC=20=ED=82=A4?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=EB=A1=9C=20=EB=8B=AB=EB=8A=94=EB=8B=A4=20?= =?UTF-8?q?=E2=80=94=20Tab=20=EB=8B=A4=EC=9D=8C,=20Shift+Tab=20=EC=9D=B4?= =?UTF-8?q?=EC=A0=84,=20Enter=20=EC=95=88,=20Esc=20=EB=B0=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이 도구의 일은 "컨트롤 하나 골라 값 하나 바꾸고 다음으로"의 반복인데, 그 루프가 마우스 없이는 한 바퀴도 돌지 않았다. 선택을 옮길 키가 하나도 없었다 — 캔버스 키맵 18개 중 선택 이동 0, 편집 시작 0. 화살표는 선택이 아니라 컨트롤을 민다. Label 59,997 + TextBox 28,574 의 문구를 고치는 일이 전부 더블클릭에 묶여 있었다. 읽는 순서로 돈다 — z-order 가 아니다. 계획서는 z-order 순회를 적었지만, Tab 을 누르는 이유는 "서식을 위에서 아래로 훑으며 문구를 고치는 것"이다. 기록지는 위→아래·왼쪽→오른쪽으로 읽으므로 그 순서가 손에 맞는다. z-order 는 겹침을 정하는 축이라 화면상 순서와 무관하게 튄다. 같은 줄로 볼 만큼 가까우면(8px = 그리드 두 칸) X 로 가른다. EMR 런타임 입력 순서인 ApplyTabOrder 와는 다른 개념이다 — 그건 저장되는 값이다. 범위는 형제다. 컨테이너 안으로는 Enter, 밖으로는 Esc. 전체를 한 줄로 이으면 패널 안 라디오 15,026개를 지나야 다음 항목에 닿는다. Esc 가 이제 한 겹만 나간다. 전에는 항상 선택을 통째로 풀어서, 패널 자식을 고른 뒤 Esc 를 누르면 부모로 돌아가는 대신 선택이 사라졌다. 드래그 중 Esc 는 그대로 드래그 취소다. 선택이 옮겨지면 RevealControl 로 화면에 데려온다 — 레이어 목록 화살표 이동에 이것이 없어서 화면 밖 컨트롤을 눈 없이 편집할 수 있었다. PreviewKeyDown 에 붙인다. KeyDown 이면 WPF 포커스 관리자가 Tab 을 먼저 먹어 포커스가 캔버스 밖(좌측 패널·문서 탭·플로팅 바)으로 나간다. 검사는 배선이 아니라 한 바퀴가 도는지를 본다 — 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 --- .../Behaviors/CanvasKeyboardBehavior.cs | 7 ++ src/SheetMe.Designer/Diagnostics/EditSmoke.cs | 73 +++++++++++++++++ .../Services/InteractionController.cs | 8 +- .../ViewModels/DesignerViewModel.cs | 82 +++++++++++++++++++ 4 files changed, 169 insertions(+), 1 deletion(-) diff --git a/src/SheetMe.Designer/Behaviors/CanvasKeyboardBehavior.cs b/src/SheetMe.Designer/Behaviors/CanvasKeyboardBehavior.cs index 8b7cbfd..ecc5368 100644 --- a/src/SheetMe.Designer/Behaviors/CanvasKeyboardBehavior.cs +++ b/src/SheetMe.Designer/Behaviors/CanvasKeyboardBehavior.cs @@ -51,6 +51,13 @@ public sealed class CanvasKeyboardBehavior : Behavior case Key.Down: designer.NudgeSelection(0, step); break; case Key.Delete: designer.DeleteSelection(); break; case Key.Escape: designer.Interaction.Cancel(); break; + + // 루프를 키보드로 닫는 세 키. 여기까지는 "컨트롤 하나 골라 값 하나 바꾸고 다음으로"가 + // 마우스 없이 한 바퀴도 돌지 않았다 — 선택을 옮길 키가 하나도 없었다. + // PreviewKeyDown 이라 WPF 포커스 관리자보다 먼저 Tab 을 본다(안 그러면 캔버스 밖으로 나간다). + case Key.Tab: designer.SelectSibling(backward: shift); break; + case Key.Enter: handled = designer.EnterSelection(); break; + case Key.F2: handled = designer.EnterSelection(); break; case Key.A when ctrl: designer.SelectAllOnActivePage(); break; case Key.C when ctrl: designer.CopySelection(); break; case Key.X when ctrl: designer.CutSelection(); break; diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs index f69bf19..1aa36f7 100644 --- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs @@ -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().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}회"); + } + // 미리보기가 인쇄 필터를 실제로 쓰는가. // PrintFilter 단위 테스트는 판정이 옳다는 것만 말한다 — 렌더 경로가 그 판정을 부르는지는 // 다른 문제이고, 이 앱에서 "코드는 맞아 보이는데 화면은 다른" 일이 여러 번 있었다. diff --git a/src/SheetMe.Designer/Services/InteractionController.cs b/src/SheetMe.Designer/Services/InteractionController.cs index ffe1c34..c8a0637 100644 --- a/src/SheetMe.Designer/Services/InteractionController.cs +++ b/src/SheetMe.Designer/Services/InteractionController.cs @@ -277,7 +277,13 @@ public sealed class InteractionController } if (mode == Mode.None) { - designer.Selection.Clear(); + // 컨테이너 안에 들어가 있으면 먼저 한 겹 나간다 — Enter 로 들어가고 Esc 로 나오는 짝이다. + // 나갈 곳이 없을 때만 선택을 푼다(전에는 항상 풀어서, 패널 자식을 고른 뒤 Esc 를 누르면 + // 부모로 돌아가는 대신 선택이 통째로 사라졌다). + if (!designer.SelectParent()) + { + designer.Selection.Clear(); + } return; } CancelDrag(); diff --git a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs index 465d77c..608920b 100644 --- a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs +++ b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs @@ -793,6 +793,88 @@ public sealed class DesignerViewModel : ViewModelBase /// 있는지 알 수 없었다. 다중 페이지 서식이면 다른 페이지에 있을 수도 있어 더 그렇다. /// 스크롤은 뷰포트 크기를 알아야 하므로 캔버스 뷰에 위임한다. /// + /// + /// 형제 중 다음(또는 이전) 컨트롤을 고른다 — Tab / Shift+Tab. + /// + /// 왜 z-order 가 아니라 읽는 순서인가. 계획서는 z-order 순회를 적었지만, + /// 이 도구에서 Tab 을 누르는 이유는 "서식을 위에서 아래로 훑으며 문구를 고치는 것"이다. + /// 기록지는 위에서 아래·왼쪽에서 오른쪽으로 읽으므로 그 순서가 손에 맞는다. + /// z-order 는 겹침을 정하는 축이라 화면상 순서와 무관하게 튄다. + /// (EMR 런타임 입력 순서인 와는 다른 개념이다 — 그건 저장되는 값이다.) + /// + /// 범위는 형제다. 컨테이너 안으로는 Enter, 밖으로는 Esc 로 오간다 — + /// 전체를 한 줄로 이으면 패널 안 라디오 15,026개를 지나야 다음 항목에 닿는다. + /// + public void SelectSibling(bool backward) + { + var page = Selection.ActivePage ?? SelectedPage ?? Pages.FirstOrDefault(); + if (page is null) + { + return; + } + var current = Selection.Primary; + var siblings = SiblingsOf(current, page); + if (siblings.Count == 0) + { + return; + } + // 읽는 순서 — 같은 줄로 볼 만큼 가까우면(8px) X 로 가른다. 그리드가 4px 이라 그 두 칸이다. + var ordered = siblings + .OrderBy(c => Math.Round(c.Y / 8)) + .ThenBy(c => c.X) + .ThenBy(c => c.Model.Id, StringComparer.Ordinal) + .ToList(); + + var index = current is null ? -1 : ordered.IndexOf(current); + var next = index < 0 + ? ordered[backward ? ^1 : 0] + : ordered[((index + (backward ? -1 : 1)) % ordered.Count + ordered.Count) % ordered.Count]; + + Selection.SetSingle(next); + // 눈에 보이지 않는 것을 고쳐 놓는 일이 생기지 않게 화면으로 데려온다 — + // 레이어 목록 화살표 이동에 이것이 없어서 화면 밖 컨트롤을 눈 없이 편집할 수 있었다. + RevealControl(next); + } + + /// Enter — 컨테이너면 안으로 들어가고, 글자를 담는 것이면 편집을 시작한다 + public bool EnterSelection() + { + if (Selection.Primary is not { } primary) + { + return false; + } + if (primary is ContainerViewModel container && container.Children.FirstOrDefault() is { } child) + { + Selection.SetSingle(child); + RevealControl(child); + return true; + } + if (IsTextEditable(primary)) + { + RequestInlineEdit(primary); + return true; + } + return false; + } + + /// Esc — 컨테이너 밖으로 한 겹 나간다. 나갈 곳이 없으면 false(호출부가 선택을 푼다) + public bool SelectParent() + { + if (Selection.Primary?.Parent is not { } parent) + { + return false; + } + Selection.SetSingle(parent); + RevealControl(parent); + return true; + } + + /// 이 컨트롤과 같은 부모를 가진 것들 — 부모가 없으면 페이지 최상위 + private List SiblingsOf(ControlViewModel? viewModel, PageViewModel page) + => viewModel?.Parent is ContainerViewModel parent + ? parent.Children.ToList() + : ControlsOfPage(page).Where(c => c.Parent is null).ToList(); + public void RevealControl(ControlViewModel viewModel) { // 다른 페이지의 컨트롤이면 그 페이지를 활성으로 — 안 그러면 선택만 되고 화면은 안 따라간다