인스펙터가 손을 놓지 않는다, 그리고 저장 확인이 사람 말을 한다

3단계 계속.

<b>① 선택이 바뀌면 편집하던 칸과 스크롤이 사라졌다.</b>
선택 집합이 바뀔 때마다 Rows.Clear() 로 행을 통째로 다시 만들기 때문이다.
하루 수백 건을 다루는 사람에게 그건 "다음 컨트롤로 넘어가면 손이 처음으로 돌아간다"는 뜻이다 —
라벨 20개의 글꼴을 차례로 고치는 일이 20번의 스크롤·클릭이 된다.

행을 키 기준 갱신으로 바꾸는 쪽이 더 근본적이지만 <b>이번에는 하지 않는다</b>.
이 패널은 회귀 기록이 가장 많은 곳이고(MainView.xaml:321-322, LayerPanelView.xaml:127-129),
실제 아픔은 재구성 비용이 아니라 포커스·스크롤 소실이다
(--scale-budget 이 뷰모델 작업은 마이크로초 단위임을 보여 준다).
그래서 재구성은 그대로 두고 포커스와 스크롤만 되돌린다. 행은 라벨로 찾는다 —
타입이 달라도 같은 이름의 행(글꼴·정렬·표시)은 같은 일을 한다.

Rebuild 를 감쌌다. 본문에 이른 return 이 여러 갈래 있어서 끝에 이벤트를 두면
도는 경로와 안 도는 경로가 갈리고, 그러면 <b>어떤 선택 변경에서만</b> 포커스가 안 돌아온다 —
그런 결함은 재현 조건을 찾기 전까지 "가끔 그런다"로만 보인다.
복원은 DispatcherPriority.Loaded 로 미룬다. 즉시 부르면 컨테이너가 아직 없어 빈 트리를 뒤진다
(이 세션에서 같은 함정을 두 번 밟았다).
사용자가 그 사이 다른 칸을 눌렀으면 포커스를 빼앗지 않는다.

<b>② F4 로 인스펙터에 들어간다.</b> 인스펙터로 포커스를 보내는 키가 저장소에 하나도 없었다.
캔버스에서 Tab 을 누르면 좌측 패널·문서 탭·플로팅 바를 다 지나야 인스펙터에 닿았다.
커맨드로 만들지 않는다 — 그러면 뷰모델이 뷰의 포커스를 알아야 한다.

<b>③ 저장 확인 문면을 두 층으로 갈랐다.</b>
이 대화상자는 세 부류가 다 보는 유일한 위험 지점인데(사내 인력·병원 전산팀·의료진),
본문에 E_SdgMst/E_SctMst, SdgDelYon='Y', ShtCneYon='Y' 가 그대로 노출되어 있었다.
사내 인력에게는 정확한 정보이고, 전산팀에게는 읽을 수 없는 문장이고, 간호부에게는 공포다.
위층은 사람 말("이 서식의 새 버전을 만듭니다. 지금 쓰이는 버전은 이력으로 남습니다"),
테이블·컬럼은 접힌 상세로 내렸다.

게이트: 테스트 285/285, --edit-smoke 0실패, --dialog-shots 글자 2,465개 검사 넘침 0,
--modal-check 0실패, --scale-budget 5/5, --maxrect 0실패, --cleartype 11/11, 빌드 경고 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:30:11 +09:00
co-authored by Claude Opus 5
parent 7e38ba654a
commit d72ab3ce1e
6 changed files with 214 additions and 7 deletions
@@ -298,8 +298,38 @@ public sealed class InspectorViewModel : ViewModelBase
} }
} }
/// <summary>
/// 행이 통째로 다시 만들어지려는 순간 / 다 만들어진 순간.
///
/// 뷰가 포커스와 스크롤을 되돌려 놓는 데 쓴다. 선택이 바뀌면 <see cref="Rows"/> 를 비우고
/// 새로 만들기 때문에, 그대로 두면 <b>컨트롤을 옮길 때마다 편집하던 칸과 스크롤 위치가 사라진다</b>.
/// 하루 수백 건을 다루는 사람에게 그건 "다음 것으로 넘어가면 손이 처음으로 돌아간다"는 뜻이다.
///
/// 행 컬렉션을 키 기준 갱신으로 바꾸는 쪽이 더 근본적이지만, 이 패널은 회귀 기록이 가장 많은 곳이라
/// 이번에는 손대지 않는다. 실제 아픔은 재구성 비용이 아니라 <b>포커스·스크롤 소실</b>이고
/// (--scale-budget 이 뷰모델 작업은 마이크로초 단위임을 보여 준다) 그것만 되돌려도 손이 이어진다.
/// </summary>
public event Action? Rebuilding;
public event Action? Rebuilt;
/// <summary>행 재구성 — 선택 집합 변경 시</summary> /// <summary>행 재구성 — 선택 집합 변경 시</summary>
public void Rebuild() public void Rebuild()
{
// 감싸는 이유: 본문에 이른 return 이 여러 갈래 있어, 끝에 이벤트를 두면
// 도는 경로와 안 도는 경로가 갈린다. 그러면 어떤 선택 변경에서는 포커스가 안 돌아온다.
Rebuilding?.Invoke();
try
{
RebuildCore();
}
finally
{
Rebuilt?.Invoke();
}
}
private void RebuildCore()
{ {
Rows.Clear(); Rows.Clear();
boundsRows.Clear(); boundsRows.Clear();
@@ -679,10 +679,22 @@ internal sealed class MainViewModel : ViewModelBase
document.Title = register.SheetName; document.Title = register.SheetName;
} }
// 문면을 두 층으로 가른다.
//
// 이 대화상자는 <b>세 부류가 다 보는 유일한 위험 지점</b>이다 —
// 사내 개발·지원, 병원 전산팀, 의료진·간호부. 그런데 본문에
// E_SdgMst/E_SctMst, SdgDelYon='Y', ShtCneYon='Y' 가 그대로 노출되어 있었다.
// 사내 인력에게는 정확한 정보이고, 전산팀에게는 읽을 수 없는 문장이고,
// 간호부에게는 공포다. 위층은 사람 말로, 테이블·컬럼은 접힌 아래층에 둔다
// (상세는 접혀 있고 사내 인력이 펼쳐 본다).
var inPlace = "제자리 갱신 서식이면 지금 버전이 바로 갱신됩니다.";
var confirm = DialogService.Confirm("DB 저장 확인", var confirm = DialogService.Confirm("DB 저장 확인",
$"서식 [{document.FormId}] {document.Title} 을(를) DB(E_SdgMst/E_SctMst)에 저장할까요?", $"[{document.FormId}] {document.Title} — 이 서식의 새 버전을 만듭니다.",
"기존 활성 디자인은 이력(SdgDelYon='Y')으로 보존되고 새 버전이 생성됩니다.\n" + "지금 쓰이는 버전은 이력으로 남고, 앞으로는 새 버전이 열립니다.\n"
"(제자리 갱신 서식(ShtCneYon='Y')은 기존 버전이 갱신됩니다)", + inPlace + "\n\n"
+ "기술 정보 — E_SdgMst/E_SctMst 에 기록합니다. "
+ "기존 활성 디자인은 SdgDelYon='Y' 로 이력 전환되고 새 SdgKey 가 생성됩니다. "
+ "ShtCneYon='Y' 서식은 기존 버전을 갱신합니다.",
yes: "저장", no: "취소"); yes: "저장", no: "취소");
if (!confirm) if (!confirm)
{ {
@@ -892,8 +892,8 @@
ToolTip="{Binding PageScopeTip}"/> ToolTip="{Binding PageScopeTip}"/>
</StackPanel> </StackPanel>
<ScrollViewer VerticalScrollBarVisibility="Auto"> <ScrollViewer x:Name="RowScroll" VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Rows}" Margin="10,0,10,10"> <ItemsControl x:Name="RowHost" ItemsSource="{Binding Rows}" Margin="10,0,10,10">
<!-- 접힌 섹션의 행은 컨테이너만 숨긴다 — 행 VM 을 재생성하지 않아 편집 상태가 유지된다 --> <!-- 접힌 섹션의 행은 컨테이너만 숨긴다 — 행 VM 을 재생성하지 않아 편집 상태가 유지된다 -->
<ItemsControl.ItemContainerStyle> <ItemsControl.ItemContainerStyle>
<Style TargetType="ContentPresenter"> <Style TargetType="ContentPresenter">
@@ -1,12 +1,156 @@
using System.Windows;
using System.Windows.Controls; using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Media;
using SheetMe.Designer.ViewModels.Inspector;
namespace SheetMe.Designer.Views; namespace SheetMe.Designer.Views;
/// <summary>속성 인스펙터 뷰 — 행 VM 암시적 템플릿 렌더.</summary> /// <summary>
/// 속성 인스펙터 뷰 — 행 VM 암시적 템플릿 렌더.
///
/// <b>선택이 바뀌어도 손이 이어지게 한다.</b> 선택 집합이 바뀌면 뷰모델이 행을 통째로 다시 만들어서,
/// 그대로 두면 편집하던 칸과 스크롤 위치가 매번 사라진다. 하루 수백 건을 다루는 사람에게 그건
/// "다음 컨트롤로 넘어가면 손이 처음으로 돌아간다"는 뜻이다 —
/// 라벨 20개의 글꼴을 차례로 고치는 일이 20번의 스크롤·클릭이 된다.
///
/// 행을 키 기준으로 갱신하는 쪽이 더 근본적이지만 이 패널은 회귀 기록이 가장 많은 곳이라
/// 이번에는 재구성을 그대로 두고 <b>포커스와 스크롤만 되돌린다</b>.
/// 행을 라벨로 찾는다 — 타입이 달라도 같은 이름의 행(글꼴·정렬·표시)은 같은 일을 한다.
/// </summary>
public partial class InspectorView : UserControl public partial class InspectorView : UserControl
{ {
#region Member Fields
/// <summary>재구성 직전에 포커스가 있던 행의 라벨</summary>
private string? focusedLabel;
private double savedOffset;
#endregion
#region Constructors
public InspectorView() public InspectorView()
{ {
InitializeComponent(); InitializeComponent();
DataContextChanged += OnDataContextChanged;
} }
#endregion
#region Methods
private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
{
if (e.OldValue is InspectorViewModel old)
{
old.Rebuilding -= OnRebuilding;
old.Rebuilt -= OnRebuilt;
}
if (e.NewValue is InspectorViewModel fresh)
{
fresh.Rebuilding += OnRebuilding;
fresh.Rebuilt += OnRebuilt;
}
}
private void OnRebuilding()
{
savedOffset = RowScroll.VerticalOffset;
focusedLabel = null;
if (Keyboard.FocusedElement is DependencyObject focused && IsDescendantOfRows(focused))
{
focusedLabel = RowLabelOf(focused);
}
}
private void OnRebuilt()
{
// 컨테이너가 아직 없다 — 레이아웃이 한 번 돌아야 행이 생긴다.
// Loaded 나 즉시 호출로는 늘 빈 트리를 뒤지게 된다(이 저장소에서 같은 함정을 두 번 밟았다).
Dispatcher.BeginInvoke(new Action(Restore), System.Windows.Threading.DispatcherPriority.Loaded);
}
private void Restore()
{
RowScroll.ScrollToVerticalOffset(savedOffset);
if (focusedLabel is null)
{
return;
}
var target = FindRowByLabel(focusedLabel);
focusedLabel = null;
// 포커스를 강제로 옮기지 않는다 — 사용자가 그 사이에 다른 곳을 눌렀으면 그쪽이 옳다
if (target is not null && Keyboard.FocusedElement is not TextBoxBase)
{
target.MoveFocus(new TraversalRequest(FocusNavigationDirection.First));
}
}
/// <summary>Ctrl+1/2/3 이나 F4 로 인스펙터에 들어올 때 첫 편집 칸을 잡아 준다</summary>
public void FocusFirstRow()
{
if (RowHost.Items.Count == 0)
{
return;
}
RowHost.MoveFocus(new TraversalRequest(FocusNavigationDirection.First));
}
private bool IsDescendantOfRows(DependencyObject node)
{
for (var i = 0; i < 40; i++)
{
if (ReferenceEquals(node, RowHost))
{
return true;
}
var parent = VisualParentOf(node);
if (parent is null)
{
return false;
}
node = parent;
}
return false;
}
/// <summary>이 요소가 속한 행의 라벨 — 행 VM 을 DataContext 로 갖는 조상을 찾는다</summary>
private static string? RowLabelOf(DependencyObject node)
{
for (var i = 0; i < 40; i++)
{
if (node is FrameworkElement { DataContext: PropertyRowViewModel row })
{
return row.Label;
}
var parent = VisualParentOf(node);
if (parent is null)
{
return null;
}
node = parent;
}
return null;
}
private FrameworkElement? FindRowByLabel(string label)
{
foreach (var item in RowHost.Items)
{
if (item is not PropertyRowViewModel row || !string.Equals(row.Label, label, StringComparison.Ordinal))
{
continue;
}
if (RowHost.ItemContainerGenerator.ContainerFromItem(row) is FrameworkElement container)
{
return container;
}
}
return null;
}
/// <summary>ContentPresenter 는 시각 부모만 갖는 경우가 있어 논리 부모로도 올라간다</summary>
private static DependencyObject? VisualParentOf(DependencyObject node)
=> node is Visual or System.Windows.Media.Media3D.Visual3D
? VisualTreeHelper.GetParent(node)
: LogicalTreeHelper.GetParent(node);
#endregion
} }
+5 -1
View File
@@ -17,6 +17,7 @@
WindowStyle="None" WindowStartupLocation="CenterScreen" WindowStyle="None" WindowStartupLocation="CenterScreen"
Background="{DynamicResource B.AppBg}" FontFamily="Malgun Gothic" FontSize="13" Background="{DynamicResource B.AppBg}" FontFamily="Malgun Gothic" FontSize="13"
Loaded="OnWindowLoaded" StateChanged="OnWindowStateChanged" Closing="OnWindowClosing" Loaded="OnWindowLoaded" StateChanged="OnWindowStateChanged" Closing="OnWindowClosing"
PreviewKeyDown="OnWindowPreviewKeyDown"
Style="{StaticResource ThemedWindow}"> Style="{StaticResource ThemedWindow}">
<!-- <!--
@@ -30,6 +31,9 @@
<KeyBinding Key="S" Modifiers="Control+Shift" Command="{Binding SaveAsFileCommand}"/> <KeyBinding Key="S" Modifiers="Control+Shift" Command="{Binding SaveAsFileCommand}"/>
<KeyBinding Key="P" Modifiers="Control" Command="{Binding PrintCommand}"/> <KeyBinding Key="P" Modifiers="Control" Command="{Binding PrintCommand}"/>
<KeyBinding Key="W" Modifiers="Control" Command="{Binding CloseDocumentCommand}"/> <KeyBinding Key="W" Modifiers="Control" Command="{Binding CloseDocumentCommand}"/>
<!-- F4 로 인스펙터 첫 칸으로 들어간다. 전에는 인스펙터로 포커스를 보내는 키가 저장소에
하나도 없어서, 캔버스에서 값을 고치려면 Tab 으로 좌측 패널·문서 탭·플로팅 바를 다 지나야 했다. -->
<!-- F4 는 코드비하인드에서 다룬다 — 커맨드로 하면 뷰모델이 뷰의 포커스를 알아야 한다 -->
</Window.InputBindings> </Window.InputBindings>
<!-- GlassFrameThickness 0,0,0,1: DWM 창 그림자 활성화([200]SheetMe 크롬과 동일) --> <!-- GlassFrameThickness 0,0,0,1: DWM 창 그림자 활성화([200]SheetMe 크롬과 동일) -->
@@ -742,7 +746,7 @@
BorderBrush="{DynamicResource B.Line}" BorderThickness="0,0,0,1"> BorderBrush="{DynamicResource B.Line}" BorderThickness="0,0,0,1">
<TextBlock Text="속성" FontWeight="Bold" Foreground="{DynamicResource B.Muted}"/> <TextBlock Text="속성" FontWeight="Bold" Foreground="{DynamicResource B.Muted}"/>
</Border> </Border>
<v:InspectorView DataContext="{Binding CurrentDesigner.Inspector}"/> <v:InspectorView x:Name="Inspector" DataContext="{Binding CurrentDesigner.Inspector}"/>
</DockPanel> </DockPanel>
</Border> </Border>
</Grid> </Grid>
@@ -152,6 +152,23 @@ public partial class MainView : Window
} }
} }
/// <summary>
/// F4 — 인스펙터 첫 편집 칸으로 들어간다.
///
/// 전에는 인스펙터로 포커스를 보내는 키가 저장소에 <b>하나도 없었다</b>.
/// 캔버스에서 Tab 을 누르면 좌측 패널·문서 탭·플로팅 바를 다 지나야 인스펙터에 닿았다.
/// 커맨드로 만들지 않는다 — 그러면 뷰모델이 뷰의 포커스를 알아야 한다.
/// </summary>
private void OnWindowPreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{
if (e.Key != System.Windows.Input.Key.F4)
{
return;
}
Inspector.FocusFirstRow();
e.Handled = true;
}
/// <summary>최대화/복원 글리프 전환(Segoe MDL2: E922=최대화, E923=복원)</summary> /// <summary>최대화/복원 글리프 전환(Segoe MDL2: E922=최대화, E923=복원)</summary>
private void OnWindowStateChanged(object sender, EventArgs e) => private void OnWindowStateChanged(object sender, EventArgs e) =>
MaxGlyph.Text = WindowState == WindowState.Maximized ? "" : ""; MaxGlyph.Text = WindowState == WindowState.Maximized ? "" : "";