인스펙터가 손을 놓지 않는다, 그리고 저장 확인이 사람 말을 한다
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:
co-authored by
Claude Opus 5
parent
7e38ba654a
commit
d72ab3ce1e
@@ -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>
|
||||
public void Rebuild()
|
||||
{
|
||||
// 감싸는 이유: 본문에 이른 return 이 여러 갈래 있어, 끝에 이벤트를 두면
|
||||
// 도는 경로와 안 도는 경로가 갈린다. 그러면 어떤 선택 변경에서는 포커스가 안 돌아온다.
|
||||
Rebuilding?.Invoke();
|
||||
try
|
||||
{
|
||||
RebuildCore();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Rebuilt?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
private void RebuildCore()
|
||||
{
|
||||
Rows.Clear();
|
||||
boundsRows.Clear();
|
||||
|
||||
@@ -679,10 +679,22 @@ internal sealed class MainViewModel : ViewModelBase
|
||||
document.Title = register.SheetName;
|
||||
}
|
||||
|
||||
// 문면을 두 층으로 가른다.
|
||||
//
|
||||
// 이 대화상자는 <b>세 부류가 다 보는 유일한 위험 지점</b>이다 —
|
||||
// 사내 개발·지원, 병원 전산팀, 의료진·간호부. 그런데 본문에
|
||||
// E_SdgMst/E_SctMst, SdgDelYon='Y', ShtCneYon='Y' 가 그대로 노출되어 있었다.
|
||||
// 사내 인력에게는 정확한 정보이고, 전산팀에게는 읽을 수 없는 문장이고,
|
||||
// 간호부에게는 공포다. 위층은 사람 말로, 테이블·컬럼은 접힌 아래층에 둔다
|
||||
// (상세는 접혀 있고 사내 인력이 펼쳐 본다).
|
||||
var inPlace = "제자리 갱신 서식이면 지금 버전이 바로 갱신됩니다.";
|
||||
var confirm = DialogService.Confirm("DB 저장 확인",
|
||||
$"서식 [{document.FormId}] {document.Title} 을(를) DB(E_SdgMst/E_SctMst)에 저장할까요?",
|
||||
"기존 활성 디자인은 이력(SdgDelYon='Y')으로 보존되고 새 버전이 생성됩니다.\n" +
|
||||
"(제자리 갱신 서식(ShtCneYon='Y')은 기존 버전이 갱신됩니다)",
|
||||
$"[{document.FormId}] {document.Title} — 이 서식의 새 버전을 만듭니다.",
|
||||
"지금 쓰이는 버전은 이력으로 남고, 앞으로는 새 버전이 열립니다.\n"
|
||||
+ inPlace + "\n\n"
|
||||
+ "기술 정보 — E_SdgMst/E_SctMst 에 기록합니다. "
|
||||
+ "기존 활성 디자인은 SdgDelYon='Y' 로 이력 전환되고 새 SdgKey 가 생성됩니다. "
|
||||
+ "ShtCneYon='Y' 서식은 기존 버전을 갱신합니다.",
|
||||
yes: "저장", no: "취소");
|
||||
if (!confirm)
|
||||
{
|
||||
|
||||
@@ -892,8 +892,8 @@
|
||||
ToolTip="{Binding PageScopeTip}"/>
|
||||
</StackPanel>
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<ItemsControl ItemsSource="{Binding Rows}" Margin="10,0,10,10">
|
||||
<ScrollViewer x:Name="RowScroll" VerticalScrollBarVisibility="Auto">
|
||||
<ItemsControl x:Name="RowHost" ItemsSource="{Binding Rows}" Margin="10,0,10,10">
|
||||
<!-- 접힌 섹션의 행은 컨테이너만 숨긴다 — 행 VM 을 재생성하지 않아 편집 상태가 유지된다 -->
|
||||
<ItemsControl.ItemContainerStyle>
|
||||
<Style TargetType="ContentPresenter">
|
||||
|
||||
@@ -1,12 +1,156 @@
|
||||
using System.Windows;
|
||||
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;
|
||||
|
||||
/// <summary>속성 인스펙터 뷰 — 행 VM 암시적 템플릿 렌더.</summary>
|
||||
/// <summary>
|
||||
/// 속성 인스펙터 뷰 — 행 VM 암시적 템플릿 렌더.
|
||||
///
|
||||
/// <b>선택이 바뀌어도 손이 이어지게 한다.</b> 선택 집합이 바뀌면 뷰모델이 행을 통째로 다시 만들어서,
|
||||
/// 그대로 두면 편집하던 칸과 스크롤 위치가 매번 사라진다. 하루 수백 건을 다루는 사람에게 그건
|
||||
/// "다음 컨트롤로 넘어가면 손이 처음으로 돌아간다"는 뜻이다 —
|
||||
/// 라벨 20개의 글꼴을 차례로 고치는 일이 20번의 스크롤·클릭이 된다.
|
||||
///
|
||||
/// 행을 키 기준으로 갱신하는 쪽이 더 근본적이지만 이 패널은 회귀 기록이 가장 많은 곳이라
|
||||
/// 이번에는 재구성을 그대로 두고 <b>포커스와 스크롤만 되돌린다</b>.
|
||||
/// 행을 라벨로 찾는다 — 타입이 달라도 같은 이름의 행(글꼴·정렬·표시)은 같은 일을 한다.
|
||||
/// </summary>
|
||||
public partial class InspectorView : UserControl
|
||||
{
|
||||
#region Member Fields
|
||||
/// <summary>재구성 직전에 포커스가 있던 행의 라벨</summary>
|
||||
private string? focusedLabel;
|
||||
|
||||
private double savedOffset;
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public InspectorView()
|
||||
{
|
||||
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
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
WindowStyle="None" WindowStartupLocation="CenterScreen"
|
||||
Background="{DynamicResource B.AppBg}" FontFamily="Malgun Gothic" FontSize="13"
|
||||
Loaded="OnWindowLoaded" StateChanged="OnWindowStateChanged" Closing="OnWindowClosing"
|
||||
PreviewKeyDown="OnWindowPreviewKeyDown"
|
||||
Style="{StaticResource ThemedWindow}">
|
||||
|
||||
<!--
|
||||
@@ -30,6 +31,9 @@
|
||||
<KeyBinding Key="S" Modifiers="Control+Shift" Command="{Binding SaveAsFileCommand}"/>
|
||||
<KeyBinding Key="P" Modifiers="Control" Command="{Binding PrintCommand}"/>
|
||||
<KeyBinding Key="W" Modifiers="Control" Command="{Binding CloseDocumentCommand}"/>
|
||||
<!-- F4 로 인스펙터 첫 칸으로 들어간다. 전에는 인스펙터로 포커스를 보내는 키가 저장소에
|
||||
하나도 없어서, 캔버스에서 값을 고치려면 Tab 으로 좌측 패널·문서 탭·플로팅 바를 다 지나야 했다. -->
|
||||
<!-- F4 는 코드비하인드에서 다룬다 — 커맨드로 하면 뷰모델이 뷰의 포커스를 알아야 한다 -->
|
||||
</Window.InputBindings>
|
||||
|
||||
<!-- GlassFrameThickness 0,0,0,1: DWM 창 그림자 활성화([200]SheetMe 크롬과 동일) -->
|
||||
@@ -742,7 +746,7 @@
|
||||
BorderBrush="{DynamicResource B.Line}" BorderThickness="0,0,0,1">
|
||||
<TextBlock Text="속성" FontWeight="Bold" Foreground="{DynamicResource B.Muted}"/>
|
||||
</Border>
|
||||
<v:InspectorView DataContext="{Binding CurrentDesigner.Inspector}"/>
|
||||
<v:InspectorView x:Name="Inspector" DataContext="{Binding CurrentDesigner.Inspector}"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</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>
|
||||
private void OnWindowStateChanged(object sender, EventArgs e) =>
|
||||
MaxGlyph.Text = WindowState == WindowState.Maximized ? "" : "";
|
||||
|
||||
Reference in New Issue
Block a user