피그마식 속성 조작 — Enter 확정·화살표 증감·정렬 격자

사용자 지적: "피그마같은 도구처럼 보기 편하게, 특히 속성 사용하기 불편해".
불편의 핵심은 밀도가 아니라 **조작**이었다. 모든 칸이 UpdateSourceTrigger=LostFocus 하나라
값을 고치고 나서 **다른 곳을 눌러야** 반영됐다 — 캔버스를 보며 숫자를 맞춰 가는 작업이
성립하지 않는다.

[Enter 확정] 즉시 커밋하고 결과를 전체 선택한다(확정 신호이자 곧바로 덮어 칠 수 있는 상태).
여러 줄 입력은 Enter 가 줄바꿈이어야 하므로 AcceptsReturn 으로 코드에서 가른다 — 스타일 예외로
빼면 새 여러 줄 칸이 생길 때마다 잊는다.

[Esc 되돌리기] **편집 전 값을 따로 보관하지 않는다.** LostFocus 트리거라 소스는 아직 손대지 않은
상태이고 소스가 곧 백업이다 — UpdateTarget() 으로 화면만 다시 읽는다.
(초안은 포커스 시점 문자열을 기억했는데, 그러면 칸에 포커스를 둔 채 캔버스에서 컨트롤을 끌었을 때
Esc 가 낡은 값을 되살리고 LostFocus 가 그걸 커밋해 **컨트롤이 원위치로 돌아간다**. 검증에서 잡았다.)

[↑/↓ 증감] 1씩, Shift 10씩. 증분은 칸마다 지정한다 — **Ctrl 미세조정은 기본 끔**이다.
X/Y/너비/높이는 읽기·쓰기 양쪽에서 반올림하므로 0.1 을 더해도 모델은 그대로인데 문자열만 바뀌어,
화면은 그대로면서 Undo 스냅샷과 '수정됨' 표시만 남는다. 소수를 실제로 받는 글꼴 크기에서만 켠다.
그리고 **글꼴 크기는 NumberRow 가 아니라 TextRow 였다** — 정작 가장 미세조정하고 싶은 칸이 스핀
대상에서 빠져 있었다. NumberRow 로 승격했다.

[Undo 폭주 차단] 커밋 1회 = 문서 전체 딥클론 1회다. ↑ 를 스무 번 누르면 스냅샷 스무 개가 쌓여
용량(100)을 갉아먹고, 저장 지점이 밀려나면 영구 '수정됨'이 된다. 행에 CoalesceUndo 플래그를 두고
경계·크기 행만 기존 SnapshotForNudge(400ms 코얼레스)로 보낸다. 값이 아니라 **커밋 방식만** 바꾸므로
Initialize/Commit 규약은 그대로다.

[정렬 격자] 콤보에 "MiddleCenter" 같은 레거시 원문 열거값이 그대로 떴다. 무슨 뜻인지 알려면 아홉 개를
다 열어 봐야 하고 고르고 나서도 글자로만 확인된다. 3열 격자로 바꿔 9값은 3×3 방향 화살표,
3값은 1×3 정렬 아이콘으로 접힌다(같은 템플릿이 값 개수에 따라 알아서).
**ToggleButton 이 아니라 Button 을 쓴다** — 토글이면 켜진 칸을 다시 눌러 해제되고, 그때 값이 빈
문자열이 되어 Set 이 속성을 **삭제**한다. ControlRegistry 주석이 경고하는 '선택만으로 속성이
사라지는' 사고(운영 249건)와 같은 계열이다.

[바꾸지 않은 것] UpdateSourceTrigger 는 그대로 둔다. PropertyChanged 로 바꾸면 한 글자마다 딥클론이
쌓이고, 이름 행은 글자마다 RenameControl 이 돌며, 경계 행은 커밋 결과가 정규화 값으로 되쓰여 캐럿이
튄다. 피그마식 사용감은 트리거가 아니라 명시적 확정 제스처로 얻는다.

[다음 후보 — 이번엔 넣지 않았다] 라벨을 끌어서 값을 바꾸는 스크럽은 UndoService 에
BeginBatch/EndBatch 를 넣어야 안전하다(지금 구조면 드래그 프레임마다 스냅샷 1개). 캡처 해제 처리도
이 저장소에 사고 선례가 있어 별도 작업으로 남긴다.

검증: 테스트 124/124, edit-smoke 실패 0, 왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 픽셀 차이 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-12 14:35:16 +09:00
co-authored by Claude Opus 5
parent 394807f8df
commit e18ad87100
4 changed files with 372 additions and 11 deletions
@@ -0,0 +1,164 @@
using System.Globalization;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Input;
namespace SheetMe.Designer.Behaviors;
/// <summary>
/// 인스펙터 입력 칸의 키보드 조작 — 피그마식 사용감의 핵심이다.
///
/// 종전에는 모든 칸이 <c>UpdateSourceTrigger=LostFocus</c> 하나였다. 값을 고치고 나서
/// **다른 곳을 눌러야** 반영되니, 캔버스를 보며 숫자를 맞춰 가는 작업이 성립하지 않았다.
///
/// 붙이는 동작 셋:
/// · <b>Enter</b> — 즉시 확정하고 전체 선택(확정됐다는 신호 + 이어서 덮어 치기 쉽다).
/// 여러 줄 입력에서는 줄바꿈이어야 하므로 건드리지 않는다.
/// · <b>Esc</b> — 소스에서 화면을 다시 읽어 되돌린다. 커밋이 없었으므로 Undo 도 남지 않는다.
/// · <b>↑/↓</b> — 숫자 칸에서 증감하고 곧바로 확정한다. 증분은 칸마다 지정한다.
///
/// <b>UpdateSourceTrigger 는 절대 PropertyChanged 로 바꾸지 말 것.</b> 커밋 한 번이 문서 전체
/// 딥클론 1회(Undo 스냅샷)라 한 글자마다 스냅샷이 쌓이고, 경계 행은 커밋 결과가 정규화 값으로
/// 되쓰여 캐럿이 튄다. 피그마식 사용감은 트리거가 아니라 <b>명시적 확정 제스처</b>로 얻는다.
/// </summary>
public static class InspectorFieldBehavior
{
#region Attached Properties
/// <summary>Enter 확정 · Esc 되돌리기를 붙인다</summary>
public static readonly DependencyProperty CommitOnEnterProperty =
DependencyProperty.RegisterAttached(
"CommitOnEnter", typeof(bool), typeof(InspectorFieldBehavior),
new PropertyMetadata(false, OnCommitOnEnterChanged));
public static void SetCommitOnEnter(DependencyObject element, bool value)
=> element.SetValue(CommitOnEnterProperty, value);
public static bool GetCommitOnEnter(DependencyObject element)
=> (bool)element.GetValue(CommitOnEnterProperty);
/// <summary>↑/↓ 로 숫자를 증감한다(숫자 칸에만 붙일 것)</summary>
public static readonly DependencyProperty SpinProperty =
DependencyProperty.RegisterAttached(
"Spin", typeof(bool), typeof(InspectorFieldBehavior),
new PropertyMetadata(false, OnSpinChanged));
public static void SetSpin(DependencyObject element, bool value)
=> element.SetValue(SpinProperty, value);
public static bool GetSpin(DependencyObject element)
=> (bool)element.GetValue(SpinProperty);
/// <summary>
/// Ctrl 미세 증분. <b>0 이면 미세조정 없음</b>이 기본이다 — X/Y/너비/높이는 읽기·쓰기 양쪽에서
/// 반올림되므로 0.1 을 더해도 모델은 그대로인데 문자열만 바뀌어, 화면은 그대로면서
/// Undo 스냅샷과 '수정됨' 표시만 남는다. 소수를 실제로 받는 칸(글꼴 크기)에서만 켠다.
/// </summary>
public static readonly DependencyProperty FineStepProperty =
DependencyProperty.RegisterAttached(
"FineStep", typeof(double), typeof(InspectorFieldBehavior), new PropertyMetadata(0.0));
public static void SetFineStep(DependencyObject element, double value)
=> element.SetValue(FineStepProperty, value);
public static double GetFineStep(DependencyObject element)
=> (double)element.GetValue(FineStepProperty);
#endregion
#region Methods
private static void OnCommitOnEnterChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not TextBox box)
{
return;
}
box.PreviewKeyDown -= OnKeyDown;
if (e.NewValue is true)
{
box.PreviewKeyDown += OnKeyDown;
}
}
private static void OnSpinChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not TextBox box)
{
return;
}
box.PreviewKeyDown -= OnSpinKeyDown;
if (e.NewValue is true)
{
box.PreviewKeyDown += OnSpinKeyDown;
}
}
private static void OnKeyDown(object sender, KeyEventArgs e)
{
if (sender is not TextBox box)
{
return;
}
// 여러 줄 입력에서 Enter 는 줄바꿈이다 — 확정으로 가로채면 본문을 못 쓴다
if (e.Key == Key.Enter && !box.AcceptsReturn)
{
Commit(box);
// 확정 결과(정규화된 값)를 선택 상태로 보여준다 — 확정 신호이자 곧바로 덮어 칠 수 있는 상태
box.SelectAll();
e.Handled = true;
return;
}
if (e.Key == Key.Escape)
{
// 편집 전 값을 따로 보관하지 않는다. LostFocus 트리거라 소스는 아직 손대지 않은 상태이고,
// 소스가 곧 백업이다. 문자열을 기억해 두면 그 사이 캔버스 드래그로 소스가 바뀌었을 때
// 낡은 값을 되살려 컨트롤을 원위치로 되돌리는 사고가 난다.
BindingOperations.GetBindingExpression(box, TextBox.TextProperty)?.UpdateTarget();
box.SelectAll();
e.Handled = true;
}
}
private static void OnSpinKeyDown(object sender, KeyEventArgs e)
{
if (sender is not TextBox box || (e.Key != Key.Up && e.Key != Key.Down))
{
return;
}
// 숫자가 아니면 캐럿 이동 기본 동작을 살려 둔다.
// 다중선택으로 값이 갈린 칸은 "" 이라 여기서 걸리는데 **이것이 의도된 방어**다 —
// 0 으로 간주해 증감하면 서로 다르던 값이 한 값으로 뭉개진다.
// 커밋 경로가 전부 invariant 이므로 파싱도 invariant 여야 왕복이 안 깨진다.
if (!double.TryParse(box.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var value))
{
return;
}
var fine = GetFineStep(box);
double step;
if (Keyboard.Modifiers.HasFlag(ModifierKeys.Control) && fine > 0)
{
step = fine;
}
else if (Keyboard.Modifiers.HasFlag(ModifierKeys.Shift))
{
step = 10;
}
else
{
step = 1;
}
value += e.Key == Key.Up ? step : -step;
box.Text = value.ToString("0.##", CultureInfo.InvariantCulture);
box.CaretIndex = box.Text.Length;
Commit(box);
e.Handled = true;
}
/// <summary>바인딩 소스에 지금 값을 밀어 넣는다(LostFocus 를 기다리지 않는다)</summary>
private static void Commit(TextBox box)
=> BindingOperations.GetBindingExpression(box, TextBox.TextProperty)?.UpdateSource();
#endregion
}