From e18ad871007d616275904488b5f91b9654072056 Mon Sep 17 00:00:00 2001 From: Msystech Date: Wed, 12 Aug 2026 14:35:16 +0900 Subject: [PATCH] =?UTF-8?q?=ED=94=BC=EA=B7=B8=EB=A7=88=EC=8B=9D=20?= =?UTF-8?q?=EC=86=8D=EC=84=B1=20=EC=A1=B0=EC=9E=91=20=E2=80=94=20Enter=20?= =?UTF-8?q?=ED=99=95=EC=A0=95=C2=B7=ED=99=94=EC=82=B4=ED=91=9C=20=EC=A6=9D?= =?UTF-8?q?=EA=B0=90=C2=B7=EC=A0=95=EB=A0=AC=20=EA=B2=A9=EC=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지적: "피그마같은 도구처럼 보기 편하게, 특히 속성 사용하기 불편해". 불편의 핵심은 밀도가 아니라 **조작**이었다. 모든 칸이 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 --- .../Behaviors/InspectorFieldBehavior.cs | 164 ++++++++++++++++++ .../Inspector/InspectorViewModel.cs | 36 +++- .../ViewModels/Inspector/PropertyRows.cs | 106 +++++++++++ src/SheetMe.Designer/Views/InspectorView.xaml | 77 +++++++- 4 files changed, 372 insertions(+), 11 deletions(-) create mode 100644 src/SheetMe.Designer/Behaviors/InspectorFieldBehavior.cs diff --git a/src/SheetMe.Designer/Behaviors/InspectorFieldBehavior.cs b/src/SheetMe.Designer/Behaviors/InspectorFieldBehavior.cs new file mode 100644 index 0000000..4c05725 --- /dev/null +++ b/src/SheetMe.Designer/Behaviors/InspectorFieldBehavior.cs @@ -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; + +/// +/// 인스펙터 입력 칸의 키보드 조작 — 피그마식 사용감의 핵심이다. +/// +/// 종전에는 모든 칸이 UpdateSourceTrigger=LostFocus 하나였다. 값을 고치고 나서 +/// **다른 곳을 눌러야** 반영되니, 캔버스를 보며 숫자를 맞춰 가는 작업이 성립하지 않았다. +/// +/// 붙이는 동작 셋: +/// · Enter — 즉시 확정하고 전체 선택(확정됐다는 신호 + 이어서 덮어 치기 쉽다). +/// 여러 줄 입력에서는 줄바꿈이어야 하므로 건드리지 않는다. +/// · Esc — 소스에서 화면을 다시 읽어 되돌린다. 커밋이 없었으므로 Undo 도 남지 않는다. +/// · ↑/↓ — 숫자 칸에서 증감하고 곧바로 확정한다. 증분은 칸마다 지정한다. +/// +/// UpdateSourceTrigger 는 절대 PropertyChanged 로 바꾸지 말 것. 커밋 한 번이 문서 전체 +/// 딥클론 1회(Undo 스냅샷)라 한 글자마다 스냅샷이 쌓이고, 경계 행은 커밋 결과가 정규화 값으로 +/// 되쓰여 캐럿이 튄다. 피그마식 사용감은 트리거가 아니라 명시적 확정 제스처로 얻는다. +/// +public static class InspectorFieldBehavior +{ + #region Attached Properties + /// Enter 확정 · Esc 되돌리기를 붙인다 + 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); + + /// ↑/↓ 로 숫자를 증감한다(숫자 칸에만 붙일 것) + 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); + + /// + /// Ctrl 미세 증분. 0 이면 미세조정 없음이 기본이다 — X/Y/너비/높이는 읽기·쓰기 양쪽에서 + /// 반올림되므로 0.1 을 더해도 모델은 그대로인데 문자열만 바뀌어, 화면은 그대로면서 + /// Undo 스냅샷과 '수정됨' 표시만 남는다. 소수를 실제로 받는 칸(글꼴 크기)에서만 켠다. + /// + 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; + } + + /// 바인딩 소스에 지금 값을 밀어 넣는다(LostFocus 를 기다리지 않는다) + private static void Commit(TextBox box) + => BindingOperations.GetBindingExpression(box, TextBox.TextProperty)?.UpdateSource(); + #endregion +} diff --git a/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs b/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs index 217732d..39736d7 100644 --- a/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs +++ b/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs @@ -196,7 +196,7 @@ public sealed class InspectorViewModel : ViewModelBase // 폰트(공통) — Font Property(상속 시 빈 값) AddSection("글꼴"); AddFontRow("글꼴", f => f.Family, (f, v) => f.Family = v.Length == 0 ? f.Family : v); - AddFontRow("크기(pt)", f => f.SizePt.ToString("0.##", CultureInfo.InvariantCulture), + AddFontNumberRow("크기(pt)", f => f.SizePt.ToString("0.##", CultureInfo.InvariantCulture), (f, v) => f.SizePt = double.TryParse(v, NumberStyles.Number, CultureInfo.InvariantCulture, out var size) && size > 0 ? size : f.SizePt); AddFontStyleSegment(); } @@ -391,6 +391,9 @@ public sealed class InspectorViewModel : ViewModelBase PropEditorKind.MultilineText or PropEditorKind.StringList => new MultilineTextRowViewModel(def.Label), PropEditorKind.Number => new NumberRowViewModel(def.Label), PropEditorKind.Toggle => new ToggleRowViewModel(def.Label), + // 정렬은 격자로 — 레거시 원문 열거값("MiddleCenter")을 드롭다운에 그대로 두면 뜻이 안 보인다 + PropEditorKind.Choice when def.Key == "TextAlign" + => new AlignRowViewModel(def.Label, def.Choices ?? Array.Empty()), PropEditorKind.Choice => new ChoiceRowViewModel(def.Label, def.Choices ?? Array.Empty()), PropEditorKind.Color => new ColorRowViewModel(def.Label), PropEditorKind.DataInterfaceTag => new TagPickerRowViewModel(def.Label, @@ -422,7 +425,7 @@ public sealed class InspectorViewModel : ViewModelBase private NumberRowViewModel CreateBoundsRow(string label, Func get, Action set) { - var row = new NumberRowViewModel(label); + var row = new NumberRowViewModel(label) { CoalesceUndo = true }; var binding = new RowBinding { Get = vm => Math.Round(get(vm)).ToString(CultureInfo.InvariantCulture), @@ -454,6 +457,24 @@ public sealed class InspectorViewModel : ViewModelBase }); } + /// + /// 숫자로 다루는 글꼴 값(크기 pt) — 화살표 증감이 붙도록 NumberRow 로 만든다. + /// 종전에는 TextRow 라, 정작 가장 미세조정하고 싶은 칸이 스핀 대상에서 빠져 있었다. + /// + private void AddFontNumberRow(string label, Func get, Action set) + { + AddRow(new NumberRowViewModel(label) { CoalesceUndo = true }, new RowBinding + { + Get = vm => get(vm.EffectiveFont), + Set = (vm, value) => + { + var font = CurrentFontOf(vm); + set(font, value); + vm.Model.Props.SetText("Font", LegacyFormat.FormatFont(font)); + }, + }); + } + /// /// 글꼴 스타일 세그먼트 — 굵게·기울임·밑줄을 한 줄에. /// 체크박스 하나에 한 줄씩이면 세 줄(약 100px)이고, 기울임은 아예 빠져 있었다. @@ -552,7 +573,16 @@ public sealed class InspectorViewModel : ViewModelBase { return; } - designer.Undo.Snapshot(); + // 화살표 연타는 400ms 코얼레스로 한 스텝에 묶는다 — 커밋 1회가 문서 딥클론 1회라 + // 그대로 두면 ↑ 스무 번에 스냅샷 스무 개가 쌓여 편집 이력이 밀려 나간다. + if (row.CoalesceUndo) + { + designer.Undo.SnapshotForNudge(); + } + else + { + designer.Undo.Snapshot(); + } foreach (var target in targets) { binding.Set(target, value); diff --git a/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs b/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs index 5605a7c..b088765 100644 --- a/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs +++ b/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs @@ -56,6 +56,14 @@ public abstract class PropertyRowViewModel : ViewModelBase /// 커밋 콜백 — InspectorViewModel 이 배선(스냅샷+적용) public Action? Commit { get; set; } + + /// + /// 연속 커밋을 Undo 한 스텝으로 묶을지 — ↑/↓ 연타용. + /// 커밋 한 번이 문서 전체 딥클론 1회라, ↑ 를 20번 누르면 스냅샷 20개가 쌓여 + /// 이력 용량(100)을 절반 가까이 갉아먹는다. 값이 아니라 커밋 방식만 바꾸므로 + /// Initialize/Commit 규약에는 영향이 없다. + /// + public bool CoalesceUndo { get; set; } #endregion #region Constructors @@ -188,6 +196,104 @@ public sealed class PairRowViewModel : PropertyRowViewModel } } +/// 정렬 격자 한 칸 +public sealed class AlignCell : ViewModelBase +{ + private readonly AlignRowViewModel owner; + + /// 저장되는 값(레거시 문자열 그대로 — TopLeft, Left …) + public string Value { get; } + + /// 칸에 그릴 표시 — 9칸은 방향 화살표, 3칸은 정렬 아이콘명 + public string Glyph { get; } + + /// Lucide 아이콘명(있으면 화살표 대신 아이콘) + public string Icon { get; } + + /// 아이콘으로 그릴지 화살표로 그릴지 + public bool HasIcon => Icon.Length > 0; + + public bool HasGlyph => Icon.Length == 0; + + /// 현재 선택된 칸인지 + public bool IsCurrent => string.Equals(owner.ValueText, Value, StringComparison.Ordinal); + + /// 값 변경 통지 — 소유 행이 호출 + 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; + } +} + +/// +/// 정렬 선택 — 콤보 대신 격자. +/// +/// 종전에는 "MiddleCenter" 같은 레거시 원문 열거값이 드롭다운에 그대로 떴다. 무슨 뜻인지 +/// 알려면 아홉 개를 다 열어 봐야 하고, 고르고 나서도 맞게 골랐는지 글자로만 확인된다. +/// 값 집합이 타입마다 다르므로(라벨 9값 ContentAlignment / 텍스트박스 3값) 칸 수에 따라 +/// 3열 격자가 3×3 이나 1×3 으로 알아서 접힌다. +/// +public sealed class AlignRowViewModel : PropertyRowViewModel +{ + /// 격자 칸들 — Choices 순서 그대로 + public List Cells { get; } = new(); + + /// 칸 클릭 — 값 지정 + public M.Framework.WPF.ICustomCommand? SelectCommand { get; } + + public AlignRowViewModel(string label, IEnumerable 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; + } + }); + } + + /// 값이 바뀌면 어느 칸이 켜졌는지 다시 알린다 + 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, + }; + + /// ContentAlignment 9값은 방향 화살표가 가장 빨리 읽힌다 + private static string GlyphOf(string choice) => choice switch + { + "TopLeft" => "↖", + "TopCenter" => "↑", + "TopRight" => "↗", + "MiddleLeft" => "←", + "MiddleCenter" => "•", + "MiddleRight" => "→", + "BottomLeft" => "↙", + "BottomCenter" => "↓", + "BottomRight" => "↘", + _ => choice, + }; +} + /// 세그먼트 한 칸 — 아이콘 토글 하나 public sealed class SegmentItem { diff --git a/src/SheetMe.Designer/Views/InspectorView.xaml b/src/SheetMe.Designer/Views/InspectorView.xaml index d9ac81d..085b75c 100644 --- a/src/SheetMe.Designer/Views/InspectorView.xaml +++ b/src/SheetMe.Designer/Views/InspectorView.xaml @@ -5,6 +5,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ins="clr-namespace:SheetMe.Designer.ViewModels.Inspector" xmlns:ctl="clr-namespace:SheetMe.Designer.Controls" + xmlns:bh="clr-namespace:SheetMe.Designer.Behaviors" mc:Ignorable="d" d:DataContext="{d:DesignInstance ins:InspectorViewModel}"> @@ -196,7 +197,7 @@ - + @@ -213,20 +214,76 @@ - + - + + + + + + + + + + + + + + + + + + @@ -253,7 +310,7 @@ - @@ -265,7 +322,11 @@ - + + @@ -299,7 +360,7 @@ Command="{Binding BrowseCommand}" ToolTip="목록에서 선택" Content="{Binding Source=list, Converter={StaticResource IconName}, ConverterParameter=13}"/> - @@ -350,7 +411,7 @@ ToolTip="마스크 편집기 — 자주 쓰는 마스크 목록과 미리보기" Content="{Binding Source=list, Converter={StaticResource IconName}, ConverterParameter=13}"/> - @@ -383,7 +444,7 @@ -