피그마식 속성 조작 — 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
@@ -56,6 +56,14 @@ public abstract class PropertyRowViewModel : ViewModelBase
/// <summary>커밋 콜백 — InspectorViewModel 이 배선(스냅샷+적용)</summary>
public Action<string>? Commit { get; set; }
/// <summary>
/// 연속 커밋을 Undo 한 스텝으로 묶을지 — ↑/↓ 연타용.
/// 커밋 한 번이 문서 전체 딥클론 1회라, ↑ 를 20번 누르면 스냅샷 20개가 쌓여
/// 이력 용량(100)을 절반 가까이 갉아먹는다. 값이 아니라 <b>커밋 방식만</b> 바꾸므로
/// Initialize/Commit 규약에는 영향이 없다.
/// </summary>
public bool CoalesceUndo { get; set; }
#endregion
#region Constructors
@@ -188,6 +196,104 @@ public sealed class PairRowViewModel : PropertyRowViewModel
}
}
/// <summary>정렬 격자 한 칸</summary>
public sealed class AlignCell : ViewModelBase
{
private readonly AlignRowViewModel owner;
/// <summary>저장되는 값(레거시 문자열 그대로 — TopLeft, Left …)</summary>
public string Value { get; }
/// <summary>칸에 그릴 표시 — 9칸은 방향 화살표, 3칸은 정렬 아이콘명</summary>
public string Glyph { get; }
/// <summary>Lucide 아이콘명(있으면 화살표 대신 아이콘)</summary>
public string Icon { get; }
/// <summary>아이콘으로 그릴지 화살표로 그릴지</summary>
public bool HasIcon => Icon.Length > 0;
public bool HasGlyph => Icon.Length == 0;
/// <summary>현재 선택된 칸인지</summary>
public bool IsCurrent => string.Equals(owner.ValueText, Value, StringComparison.Ordinal);
/// <summary>값 변경 통지 — 소유 행이 호출</summary>
public void NotifyCurrentChanged() => OnPropertyChanged(nameof(IsCurrent));
public AlignCell(AlignRowViewModel owner, string value, string glyph, string icon)
{
this.owner = owner;
Value = value;
Glyph = glyph;
Icon = icon;
}
}
/// <summary>
/// 정렬 선택 — 콤보 대신 격자.
///
/// 종전에는 "MiddleCenter" 같은 <b>레거시 원문 열거값</b>이 드롭다운에 그대로 떴다. 무슨 뜻인지
/// 알려면 아홉 개를 다 열어 봐야 하고, 고르고 나서도 맞게 골랐는지 글자로만 확인된다.
/// 값 집합이 타입마다 다르므로(라벨 9값 ContentAlignment / 텍스트박스 3값) 칸 수에 따라
/// 3열 격자가 3×3 이나 1×3 으로 알아서 접힌다.
/// </summary>
public sealed class AlignRowViewModel : PropertyRowViewModel
{
/// <summary>격자 칸들 — Choices 순서 그대로</summary>
public List<AlignCell> Cells { get; } = new();
/// <summary>칸 클릭 — 값 지정</summary>
public M.Framework.WPF.ICustomCommand? SelectCommand { get; }
public AlignRowViewModel(string label, IEnumerable<string> choices) : base(label)
{
foreach (var choice in choices)
{
Cells.Add(new AlignCell(this, choice, GlyphOf(choice), IconOf(choice)));
}
SelectCommand = new M.Framework.WPF.Command((object parameter) =>
{
if (parameter is string value)
{
ValueText = value;
}
});
}
/// <summary>값이 바뀌면 어느 칸이 켜졌는지 다시 알린다</summary>
protected override void OnValueApplied()
{
foreach (var cell in Cells)
{
cell.NotifyCurrentChanged();
}
}
private static string IconOf(string choice) => choice switch
{
"Left" => "align-left",
"Center" => "align-center",
"Right" => "align-right",
_ => string.Empty,
};
/// <summary>ContentAlignment 9값은 방향 화살표가 가장 빨리 읽힌다</summary>
private static string GlyphOf(string choice) => choice switch
{
"TopLeft" => "↖",
"TopCenter" => "↑",
"TopRight" => "↗",
"MiddleLeft" => "←",
"MiddleCenter" => "•",
"MiddleRight" => "→",
"BottomLeft" => "↙",
"BottomCenter" => "↓",
"BottomRight" => "↘",
_ => choice,
};
}
/// <summary>세그먼트 한 칸 — 아이콘 토글 하나</summary>
public sealed class SegmentItem
{