diff --git a/src/SheetMe.Designer/Behaviors/LayerDragBehavior.cs b/src/SheetMe.Designer/Behaviors/LayerDragBehavior.cs
new file mode 100644
index 0000000..c7e5346
--- /dev/null
+++ b/src/SheetMe.Designer/Behaviors/LayerDragBehavior.cs
@@ -0,0 +1,262 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Documents;
+using System.Windows.Input;
+using System.Windows.Media;
+using SheetMe.Designer.ViewModels;
+using SheetMe.Designer.ViewModels.Controls;
+
+namespace SheetMe.Designer.Behaviors;
+
+///
+/// 레이어 목록 드래그 재정렬 — 행을 끌어다 놓아 z-order(겹침 순서)를 바꾼다.
+///
+/// 목록은 z-order 최상위가 맨 위이므로 위로 끌면 앞으로 나오고 아래로 끌면 뒤로 물러난다.
+/// 대상은 같은 부모 안의 최상위 컨트롤끼리로 제한한다 — 컨테이너 안팎으로 옮기는 것은
+/// 좌표계가 바뀌는 별개의 연산이고, 그룹 폴더·페이지 행은 순서를 갖지 않는다.
+///
+/// z-order 는 저장되는 값이라(XML Object 순서) 오작동이 실제 서식의 겹침을 망가뜨린다.
+/// 그래서 드롭 위치 판정은 행 높이의 절반 기준으로 단순하게 두고, 삽입선을 눈으로 보여준다.
+///
+public static class LayerDragBehavior
+{
+ #region Attached Properties
+ /// 드래그 재정렬을 붙일 디자이너 — 문서 탭이 바뀌면 갈아탄다
+ public static readonly DependencyProperty DesignerProperty = DependencyProperty.RegisterAttached(
+ "Designer", typeof(DesignerViewModel), typeof(LayerDragBehavior),
+ new PropertyMetadata(null, OnDesignerChanged));
+
+ public static void SetDesigner(DependencyObject element, DesignerViewModel? value)
+ => element.SetValue(DesignerProperty, value);
+
+ public static DesignerViewModel? GetDesigner(DependencyObject element)
+ => (DesignerViewModel?)element.GetValue(DesignerProperty);
+ #endregion
+
+ #region Member Fields
+ private const double DragThreshold = 5;
+
+ private static ListBox? activeList;
+ private static ControlViewModel? dragSource;
+ private static Point pressPoint;
+ private static bool pressed;
+ private static InsertionAdorner? adorner;
+ #endregion
+
+ #region Methods
+ private static void OnDesignerChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e)
+ {
+ if (sender is not ListBox list)
+ {
+ return;
+ }
+ list.PreviewMouseLeftButtonDown -= OnPress;
+ list.PreviewMouseMove -= OnMove;
+ list.PreviewMouseLeftButtonUp -= OnRelease;
+ list.QueryContinueDrag -= OnQueryContinue;
+ list.DragOver -= OnDragOver;
+ list.Drop -= OnDrop;
+ list.DragLeave -= OnDragLeave;
+
+ if (e.NewValue is not DesignerViewModel)
+ {
+ return;
+ }
+ list.AllowDrop = true;
+ list.PreviewMouseLeftButtonDown += OnPress;
+ list.PreviewMouseMove += OnMove;
+ list.PreviewMouseLeftButtonUp += OnRelease;
+ list.QueryContinueDrag += OnQueryContinue;
+ list.DragOver += OnDragOver;
+ list.Drop += OnDrop;
+ list.DragLeave += OnDragLeave;
+ }
+
+ /// 누른 지점 기억 — 실제 드래그는 임계 거리를 넘어야 시작한다(클릭 선택을 방해하지 않도록)
+ private static void OnPress(object sender, MouseButtonEventArgs e)
+ {
+ if (sender is not ListBox list)
+ {
+ return;
+ }
+ // 눈·자물쇠·셰브론 같은 행 안의 버튼에서 시작한 누름은 드래그가 아니다
+ if (e.OriginalSource is DependencyObject origin
+ && FindAncestor(origin) is not null)
+ {
+ return;
+ }
+ var row = RowDataOf(e.OriginalSource);
+ // 최상위 컨트롤만 — 컨테이너 자식은 부모 안에서만 의미가 있어 목록 드래그로 다루지 않는다
+ dragSource = row is ControlViewModel control && control.Parent is null ? control : null;
+ activeList = list;
+ pressPoint = e.GetPosition(list);
+ pressed = dragSource is not null;
+ }
+
+ private static void OnMove(object sender, MouseEventArgs e)
+ {
+ if (!pressed || dragSource is null || activeList is null || e.LeftButton != MouseButtonState.Pressed)
+ {
+ return;
+ }
+ var delta = e.GetPosition(activeList) - pressPoint;
+ if (Math.Abs(delta.X) < DragThreshold && Math.Abs(delta.Y) < DragThreshold)
+ {
+ return;
+ }
+ pressed = false;
+ DragDrop.DoDragDrop(activeList, new DataObject(typeof(ControlViewModel), dragSource), DragDropEffects.Move);
+ ClearAdorner();
+ dragSource = null;
+ }
+
+ private static void OnRelease(object sender, MouseButtonEventArgs e) => pressed = false;
+
+ /// Esc 로 드래그 취소 — 삽입선도 함께 걷는다
+ private static void OnQueryContinue(object sender, QueryContinueDragEventArgs e)
+ {
+ if (e.EscapePressed)
+ {
+ ClearAdorner();
+ }
+ }
+
+ private static void OnDragOver(object sender, DragEventArgs e)
+ {
+ if (sender is not ListBox list || !e.Data.GetDataPresent(typeof(ControlViewModel)))
+ {
+ return;
+ }
+ var (target, above) = ResolveTarget(list, e);
+ if (target is null)
+ {
+ ClearAdorner();
+ e.Effects = DragDropEffects.None;
+ e.Handled = true;
+ return;
+ }
+ e.Effects = DragDropEffects.Move;
+ e.Handled = true;
+ ShowAdorner(list, target, above);
+ }
+
+ private static void OnDragLeave(object sender, DragEventArgs e) => ClearAdorner();
+
+ private static void OnDrop(object sender, DragEventArgs e)
+ {
+ ClearAdorner();
+ if (sender is not ListBox list
+ || GetDesigner(list) is not { } designer
+ || e.Data.GetData(typeof(ControlViewModel)) is not ControlViewModel source)
+ {
+ return;
+ }
+ var (targetItem, above) = ResolveTarget(list, e);
+ if (targetItem?.DataContext is not ControlViewModel target
+ || ReferenceEquals(target, source)
+ || target.Parent is not null)
+ {
+ return;
+ }
+ designer.MoveControlNextTo(source, target, above);
+ e.Handled = true;
+ }
+
+ /// 커서 아래의 드롭 대상 행과 위/아래 판정
+ private static (ListBoxItem? Item, bool Above) ResolveTarget(ListBox list, DragEventArgs e)
+ {
+ var hit = list.InputHitTest(e.GetPosition(list)) as DependencyObject;
+ var item = hit is null ? null : FindAncestor(hit);
+ if (item?.DataContext is not ControlViewModel control || control.Parent is not null)
+ {
+ return (null, false);
+ }
+ var local = e.GetPosition(item);
+ return (item, local.Y < item.ActualHeight / 2);
+ }
+
+ private static void ShowAdorner(ListBox list, ListBoxItem item, bool above)
+ {
+ var layer = AdornerLayer.GetAdornerLayer(list);
+ if (layer is null)
+ {
+ return;
+ }
+ if (adorner is not null && ReferenceEquals(adorner.AdornedElement, item) && adorner.Above == above)
+ {
+ return;
+ }
+ ClearAdorner();
+ adorner = new InsertionAdorner(item, above);
+ layer.Add(adorner);
+ }
+
+ private static void ClearAdorner()
+ {
+ if (adorner is null)
+ {
+ return;
+ }
+ AdornerLayer.GetAdornerLayer(adorner.AdornedElement)?.Remove(adorner);
+ adorner = null;
+ }
+
+ private static object? RowDataOf(object? source)
+ => source is DependencyObject node ? FindAncestor(node)?.DataContext : null;
+
+ private static T? FindAncestor(DependencyObject? node) where T : DependencyObject
+ {
+ while (node is not null)
+ {
+ if (node is T match)
+ {
+ return match;
+ }
+ node = node is Visual or System.Windows.Media.Media3D.Visual3D
+ ? VisualTreeHelper.GetParent(node)
+ : LogicalTreeHelper.GetParent(node);
+ }
+ return null;
+ }
+ #endregion
+
+ #region Types
+ ///
+ /// 드롭 위치 삽입선 — 대상 행의 위 또는 아래 모서리에 2px 선을 그린다.
+ ///
+ /// [200] 은 대상 행의 BorderThickness 를 0→2 로 올려 표시하는데, 그러면 행 내용이 2px 밀려
+ /// 드래그 중 목록이 들썩인다. 어도너는 레이아웃 바깥에 그려져 그런 흔들림이 없다.
+ ///
+ private sealed class InsertionAdorner : Adorner
+ {
+ private static readonly Pen Line = CreatePen();
+
+ public bool Above { get; }
+
+ public InsertionAdorner(UIElement adorned, bool above) : base(adorned)
+ {
+ Above = above;
+ IsHitTestVisible = false;
+ }
+
+ protected override void OnRender(DrawingContext drawingContext)
+ {
+ var width = ((FrameworkElement)AdornedElement).ActualWidth;
+ var height = ((FrameworkElement)AdornedElement).ActualHeight;
+ var y = Above ? 1 : height - 1;
+ drawingContext.DrawLine(Line, new Point(0, y), new Point(width, y));
+ }
+
+ private static Pen CreatePen()
+ {
+ // 어도너는 리소스 조회 컨텍스트 밖이라 DynamicResource 가 닿지 않는다.
+ // 브랜드색은 양 테마 공용값(B.Accent)이라 여기서만 상수로 둔다.
+ var brush = new SolidColorBrush(Color.FromRgb(0x00, 0x99, 0xFF));
+ brush.Freeze();
+ var pen = new Pen(brush, 2);
+ pen.Freeze();
+ return pen;
+ }
+ }
+ #endregion
+}
diff --git a/src/SheetMe.Designer/Controls/LayerRowTemplateSelector.cs b/src/SheetMe.Designer/Controls/LayerRowTemplateSelector.cs
index b57e27b..7e47f8e 100644
--- a/src/SheetMe.Designer/Controls/LayerRowTemplateSelector.cs
+++ b/src/SheetMe.Designer/Controls/LayerRowTemplateSelector.cs
@@ -5,7 +5,7 @@ using SheetMe.Designer.ViewModels;
namespace SheetMe.Designer.Controls;
///
-/// 레이어 목록 행 템플릿 선택 — 그룹 폴더 행과 컨트롤 행을 가른다.
+/// 레이어 목록 행 템플릿 선택 — 페이지 헤더 / 그룹 폴더 / 컨트롤 / '페이지 추가' 네 종류를 가른다.
///
/// DataTemplate DataType 암시 매칭을 쓸 수 없다: WPF 는 정확한 런타임 형으로만 찾는데
/// 컨트롤 행의 실제 형은 LabelViewModel·TextBoxViewModel 처럼 제각각이고
@@ -13,12 +13,23 @@ namespace SheetMe.Designer.Controls;
///
public sealed class LayerRowTemplateSelector : DataTemplateSelector
{
+ /// 페이지 헤더 행
+ public DataTemplate? PageTemplate { get; set; }
+
/// 그룹 폴더 행
public DataTemplate? GroupTemplate { get; set; }
/// 컨트롤 행
public DataTemplate? ControlTemplate { get; set; }
- public override DataTemplate? SelectTemplate(object? item, DependencyObject container)
- => item is LayerGroupViewModel ? GroupTemplate : ControlTemplate;
+ /// 맨 아래 '페이지 추가' 행
+ public DataTemplate? AddPageTemplate { get; set; }
+
+ public override DataTemplate? SelectTemplate(object? item, DependencyObject container) => item switch
+ {
+ LayerPageViewModel => PageTemplate,
+ LayerGroupViewModel => GroupTemplate,
+ LayerAddPageViewModel => AddPageTemplate,
+ _ => ControlTemplate,
+ };
}
diff --git a/src/SheetMe.Designer/Controls/PaletteIconCatalog.cs b/src/SheetMe.Designer/Controls/PaletteIconCatalog.cs
index f0c3073..e162400 100644
--- a/src/SheetMe.Designer/Controls/PaletteIconCatalog.cs
+++ b/src/SheetMe.Designer/Controls/PaletteIconCatalog.cs
@@ -93,6 +93,35 @@ public sealed class TypeToIconConverter : IValueConverter
=> throw new NotSupportedException();
}
+///
+/// "아이콘명|크기|브러시키" 한 문자열 → Lucide 아이콘 비주얼.
+///
+/// 색이 행 상태에 따라 달라져야 할 때 쓴다. 보통은 색을 ConverterParameter 에 적지만
+/// ConverterParameter 는 바인딩할 수 없어서, 색이 변하는 순간 아이콘이 이전 색에 굳어 버린다
+/// (선택 밴드 위에서 파란 아이콘이 사라지는 식). 그래서 사양 전체를 값 쪽으로 옮겼다.
+///
+public sealed class IconSpecConverter : IValueConverter
+{
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (value is not string spec)
+ {
+ return null;
+ }
+ var first = spec.IndexOf('|');
+ if (first < 0)
+ {
+ return LucideIcons.Icon(spec, 14, "B.Muted");
+ }
+ var name = spec[..first];
+ var (size, brushKey) = IconParam.Parse(spec[(first + 1)..], 14);
+ return LucideIcons.Icon(name, size, brushKey);
+ }
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => throw new NotSupportedException();
+}
+
/// 팔레트 타입명 → 카테고리명 컨버터(CollectionViewSource 그룹핑용)
public sealed class TypeToCategoryConverter : IValueConverter
{
diff --git a/src/SheetMe.Designer/Diagnostics/DialogShots.cs b/src/SheetMe.Designer/Diagnostics/DialogShots.cs
index 2b39150..e4ab742 100644
--- a/src/SheetMe.Designer/Diagnostics/DialogShots.cs
+++ b/src/SheetMe.Designer/Diagnostics/DialogShots.cs
@@ -128,9 +128,68 @@ public static class DialogShots
("12-inspector-behavior", () => HostInspector(designer, "TextBox1", InspectorTab.Behavior)),
("13-layer-type-filter", () => new Views.LayerTypeFilterDialog(
designer.LayerTypeCandidates(), new[] { "Label" })),
+ // 레이어 아웃라인 — 선택 없음 / 컨트롤 하나 선택 / 그룹 전체 선택 세 상태를 각각 찍는다.
+ // 강조는 IsSelected 가 아니라 Highlight 3상태가 그리므로 눈으로 확인할 경로가 이것뿐이다.
+ ("14-layer-plain", () => HostLayerPanel(designer, LayerShotMode.None)),
+ ("15-layer-selected", () => HostLayerPanel(designer, LayerShotMode.Single)),
+ ("16-layer-group", () => HostLayerPanel(designer, LayerShotMode.Group)),
};
}
+ /// 레이어 패널 스냅숏의 선택 상태
+ private enum LayerShotMode
+ {
+ /// 선택 없음
+ None,
+
+ /// 컨트롤 하나 선택 — 파란 밴드 하나
+ Single,
+
+ /// 그룹 전체 선택 — 폴더 파랑 + 멤버 서브트리, 이어진 블록
+ Group,
+ }
+
+ ///
+ /// 레이어 패널을 실제 패널 폭(255px)의 창에 담는다.
+ /// MainViewModel 을 통째로 만들면 DB 조회가 딸려 오므로, 패널이 읽는 CurrentDesigner 하나만
+ /// 들고 있는 최소 DataContext 를 세워 준다.
+ ///
+ private static Window HostLayerPanel(DesignerViewModel designer, LayerShotMode mode)
+ {
+ designer.Selection.Clear();
+ designer.SetLayerTypeFilter(Array.Empty());
+ designer.LayerFilter = string.Empty;
+
+ var page = designer.Pages[0];
+ if (mode != LayerShotMode.None && page.Controls.Count >= 3)
+ {
+ designer.Selection.Set(new[] { page.Controls[^1], page.Controls[^2] });
+ designer.GroupSelection();
+ if (mode == LayerShotMode.Single)
+ {
+ designer.Selection.SetSingle(page.Controls[0]);
+ }
+ }
+ designer.RebuildLayerRows();
+
+ return new Window
+ {
+ Title = $"레이어 — {mode}",
+ Width = 255,
+ Height = 620,
+ Content = new Views.LayerPanelView { DataContext = new LayerShotContext(designer) },
+ Background = Application.Current.TryFindResource("B.Panel") as Brush,
+ };
+ }
+
+ /// 패널이 바인딩하는 CurrentDesigner 만 노출하는 스냅숏 전용 DataContext
+ private sealed class LayerShotContext
+ {
+ public LayerShotContext(DesignerViewModel designer) => CurrentDesigner = designer;
+
+ public DesignerViewModel CurrentDesigner { get; }
+ }
+
/// 인스펙터를 실제 패널 폭(300px)의 창에 담는다 — 컨트롤 하나를 선택한 상태로
private static Window HostInspector(DesignerViewModel designer, string controlId,
InspectorTab tab = InspectorTab.Design)
diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
index 492f143..309fc11 100644
--- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
+++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
@@ -279,12 +279,29 @@ public static class EditSmoke
groupRow is { Members.Count: 2 } && groupRow.Name.StartsWith("그룹"),
$"실제 {groupRow?.Members.Count} / {groupRow?.Name}");
+ // 아웃라인은 페이지 헤더로 시작하고 '페이지 추가' 행으로 끝난다
+ Check("레이어: 첫 행은 페이지 헤더", lay.LayerRows.FirstOrDefault() is LayerPageViewModel);
+ Check("레이어: 마지막 행은 페이지 추가", lay.LayerRows.LastOrDefault() is LayerAddPageViewModel);
+ Check("레이어: 페이지 헤더는 선택 대상 아님",
+ lay.LayerRows.OfType().Where(r => r is LayerPageViewModel or LayerAddPageViewModel)
+ .All(r => !r.IsSelectableRow));
+
// 폴더 바로 뒤에 멤버가 들여쓰기된 채 이어져야 한다(평탄화 시퀀스)
var folderIndex = lay.LayerRows.IndexOf(groupRow!);
Check("레이어: 폴더 뒤에 멤버가 들여쓰여 이어짐",
folderIndex >= 0
&& lay.LayerRows.Skip(folderIndex + 1).Take(2)
- .OfType().Count(c => c.LayerIndent == 20) == 2);
+ .OfType().Count(c => c.LayerIndent == 32) == 2);
+ Check("레이어: 페이지 직속 컨트롤 들여쓰기 12",
+ lay.LayerRows.OfType()
+ .Where(c => c.Model.GroupId is null && c.Parent is null)
+ .All(c => c.LayerIndent == 12));
+
+ // 목록 맨 위 = z-order 최상위 (모델 리스트 index 0)
+ var topControl = lay.LayerRows.OfType().First();
+ Check("레이어: 목록 맨 위가 z-order 최상위",
+ ReferenceEquals(lay.Document.Pages[0].Controls[0], topControl.Model),
+ $"실제 맨 위 {topControl.Id} / 모델 첫째 {lay.Document.Pages[0].Controls[0].Id}");
// 접으면 멤버 행이 빠지고, 펴면 되돌아온다
var expandedCount = lay.LayerRows.Count;
@@ -371,6 +388,87 @@ public static class EditSmoke
lay.GroupSelection();
Check("레이어: 그룹은 XML 에 나가지 않음",
!serializer.Write(lay.Document).Contains("GroupId", StringComparison.OrdinalIgnoreCase));
+
+ // 강조 3상태 — 그룹 전체 선택이면 폴더=Blue, 멤버=Navy 이고 연속 블록으로 이어진다
+ groupRow = lay.LayerRows.OfType().First();
+ lay.Selection.Set(groupRow.Members.ToList(), groupRow.Members[0]);
+ lay.UpdateLayerHighlight();
+ Check("레이어: 그룹 전원 선택 → 폴더 Blue", groupRow.Highlight == LayerHighlight.Blue,
+ $"실제 {groupRow.Highlight}");
+ Check("레이어: 그룹 전원 선택 → 멤버 Navy",
+ groupRow.Members.All(m => m.Highlight == LayerHighlight.Navy));
+ Check("레이어: 폴더와 멤버가 이어진 블록",
+ groupRow.MergeBottom && groupRow.Members[0].MergeTop);
+
+ lay.Selection.SetSingle(groupRow.Members[0]);
+ lay.UpdateLayerHighlight();
+ Check("레이어: 멤버 하나만 선택 → 그 행만 Blue, 폴더 무강조",
+ groupRow.Members[0].Highlight == LayerHighlight.Blue
+ && groupRow.Highlight == LayerHighlight.None);
+
+ // 강조 계산이 페이지 헤더에서 끊긴다(블록이 페이지를 넘지 않는다)
+ Check("레이어: 페이지 헤더는 항상 무강조",
+ lay.LayerRows.OfType().All(p => p.Highlight == LayerHighlight.None));
+
+ // 페이지 복제 — 컨트롤 이름 유일성과 그룹 분리가 지켜져야 한다
+ var beforePages = lay.Pages.Count;
+ var beforeIds = lay.Pages[0].Controls.Select(c => c.Id).ToHashSet(StringComparer.Ordinal);
+ var copied = lay.DuplicatePage(lay.Pages[0]);
+ Check("레이어: 페이지 복제 — 장수 +1", lay.Pages.Count == beforePages + 1 && copied is not null);
+ if (copied is not null)
+ {
+ Check("레이어: 페이지 복제 — 컨트롤 수 동일",
+ copied.Controls.Count == lay.Pages[0].Controls.Count);
+ Check("레이어: 페이지 복제 — 이름이 전부 새로 발급됨",
+ copied.Controls.All(c => !beforeIds.Contains(c.Id)));
+ Check("레이어: 페이지 복제 — 그룹이 원본과 분리됨",
+ copied.Controls.Where(c => c.Model.GroupId is not null)
+ .All(c => lay.Pages[0].Controls.All(o => o.Model.GroupId != c.Model.GroupId)));
+ }
+ Check("레이어: 전 페이지가 아웃라인에 나옴",
+ lay.LayerRows.OfType().Count() == lay.Pages.Count);
+
+ // 페이지 접기 — 그 페이지 컨트롤 행만 빠진다
+ var pageHeader = lay.LayerRows.OfType().First();
+ var beforeCollapse = lay.LayerRows.Count;
+ pageHeader.IsExpanded = false;
+ lay.RebuildLayerRows();
+ Check("레이어: 페이지 접기 — 행 감소", lay.LayerRows.Count < beforeCollapse);
+ Check("레이어: 페이지 접어도 헤더는 남음",
+ lay.LayerRows.OfType().Count() == lay.Pages.Count);
+ lay.LayerRows.OfType().First().IsExpanded = true;
+ lay.RebuildLayerRows();
+ Check("레이어: 페이지 펴기 — 행 복귀", lay.LayerRows.Count == beforeCollapse);
+
+ // 드래그 재정렬 — 목록에서 아래 행을 맨 위 행 '위'로 옮기면 z-order 최상위가 된다
+ var order = lay.LayerRows.OfType()
+ .Where(c => c.Parent is null && ReferenceEquals(lay.PageOf(c), lay.Pages[0])).ToList();
+ if (order.Count >= 2)
+ {
+ var mover = order[^1];
+ var anchor = order[0];
+ lay.MoveControlNextTo(mover, anchor, above: true);
+ Check("레이어: 드래그로 맨 위에 놓으면 z-order 최상위",
+ ReferenceEquals(lay.Pages[0].Model.Controls[0], mover.Model),
+ $"실제 {lay.Pages[0].Model.Controls[0].Id} 기대 {mover.Id}");
+ Check("레이어: 재정렬이 되돌리기 스텝", lay.Undo.CanUndo);
+ }
+
+ // 눈·자물쇠도 되돌릴 수 있어야 한다
+ var flagTarget = lay.LayerRows.OfType().First();
+ lay.Undo.Snapshot();
+ var depthBefore = lay.Undo.CanUndo;
+ flagTarget.IsLockedFlag = true;
+ lay.Undo.Undo();
+ Check("레이어: 잠금 토글이 Undo 로 원복",
+ lay.Pages.SelectMany(p => p.Controls).All(c => !c.Model.Locked) && depthBefore);
+
+ // 데이터 배선 배지
+ var badgeTarget = lay.Pages[0].Controls[0];
+ Check("레이어: 배선 없는 컨트롤은 배지 없음", !badgeTarget.HasDataBinding);
+ badgeTarget.Model.Props.SetText("DataTableField", "DT.Item(\"ChtNum\")");
+ Check("레이어: DataTableField 있으면 배지", badgeTarget.HasDataBinding);
+ badgeTarget.Model.Props.SetText("DataTableField", string.Empty);
}
// 20-3) 문서 간 복사/붙여넣기 — A 서식에서 복사 → B 서식에 붙여넣기(클립보드 공유)
diff --git a/src/SheetMe.Designer/Themes/DesignerTheme.xaml b/src/SheetMe.Designer/Themes/DesignerTheme.xaml
index bff02db..c1e8f88 100644
--- a/src/SheetMe.Designer/Themes/DesignerTheme.xaml
+++ b/src/SheetMe.Designer/Themes/DesignerTheme.xaml
@@ -682,6 +682,78 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/SheetMe.Designer/Themes/Tokens.Light.xaml b/src/SheetMe.Designer/Themes/Tokens.Light.xaml
index dc58890..ca5d191 100644
--- a/src/SheetMe.Designer/Themes/Tokens.Light.xaml
+++ b/src/SheetMe.Designer/Themes/Tokens.Light.xaml
@@ -29,6 +29,18 @@
+
+
+
+
+
diff --git a/src/SheetMe.Designer/ViewModels/Controls/ControlViewModel.cs b/src/SheetMe.Designer/ViewModels/Controls/ControlViewModel.cs
index c098725..ad285e2 100644
--- a/src/SheetMe.Designer/ViewModels/Controls/ControlViewModel.cs
+++ b/src/SheetMe.Designer/ViewModels/Controls/ControlViewModel.cs
@@ -10,13 +10,16 @@ namespace SheetMe.Designer.ViewModels.Controls;
/// 모델이 진실이며 VM 은 버려도 되는 투영(Undo 복원 시 재생성)이다.
/// 좌표계: WinForms px = WPF DIP 1:1.
///
-public abstract class ControlViewModel : ViewModelBase
+public abstract class ControlViewModel : ViewModelBase, ILayerRow
{
#region Member Fields
private bool isSelected;
private bool isHovered;
private double layerIndent;
private bool isLayerExpanded = true;
+ private LayerHighlight layerHighlight;
+ private bool layerMergeTop;
+ private bool layerMergeBottom;
#endregion
#region Properties
@@ -107,15 +110,27 @@ public abstract class ControlViewModel : ViewModelBase
/// 상속 기점 부모 글자색(재해석용 보관)
public Brush ParentForeground { get; private set; } = Brushes.Black;
+ ///
+ /// 잠금·숨김 토글 직전에 되돌리기 지점을 남기는 콜백 — 평탄화 때 디자이너가 배선한다.
+ /// (VM 은 UndoService 를 모르고, 알게 하면 모델 투영이라는 역할이 흐려진다)
+ ///
+ public Action? LayerFlagChanging { get; set; }
+
/// 잠금 플래그(레이어 패널 토글)
public bool IsLockedFlag
{
get => Model.Locked;
set
{
+ if (Model.Locked == value)
+ {
+ return;
+ }
+ LayerFlagChanging?.Invoke();
Model.Locked = value;
OnPropertyChanged(nameof(IsLockedFlag));
OnPropertyChanged(nameof(LockIcon));
+ OnPropertyChanged(nameof(LockIconSpec));
}
}
@@ -125,9 +140,15 @@ public abstract class ControlViewModel : ViewModelBase
get => Model.Hidden;
set
{
+ if (Model.Hidden == value)
+ {
+ return;
+ }
+ LayerFlagChanging?.Invoke();
Model.Hidden = value;
OnPropertyChanged(nameof(IsHiddenFlag));
OnPropertyChanged(nameof(HiddenIcon));
+ OnPropertyChanged(nameof(HiddenIconSpec));
OnPropertyChanged(nameof(DesignOpacity));
}
}
@@ -175,6 +196,21 @@ public abstract class ControlViewModel : ViewModelBase
/// 레이어 숨김 토글 아이콘명
public string HiddenIcon => Model.Hidden ? "eye-off" : "eye";
+ ///
+ /// 강조 밴드 위 아이콘 색 — 밴드는 짙은 파랑 채움이라 보조색(B.Muted)을 그대로 두면
+ /// 눈·자물쇠가 1.6:1 로 묻힌다. 선택한 행에서 잠금 상태가 안 보이면 배지의 의미가 없다.
+ ///
+ private string RowIconKey => layerHighlight == LayerHighlight.Blue ? "B.OnAccent" : "B.Muted";
+
+ /// 잠금 토글 아이콘 사양
+ public string LockIconSpec => $"{LockIcon}|13|{RowIconKey}";
+
+ /// 숨김 토글 아이콘 사양
+ public string HiddenIconSpec => $"{HiddenIcon}|13|{RowIconKey}";
+
+ /// 컨테이너 자식 접기 셰브론 사양
+ public string LayerChevronSpec => $"{LayerChevronIcon}|12|{RowIconKey}";
+
///
/// 레이어 목록 들여쓰기(px) — 그룹 멤버·컨테이너 자식의 계층을 나타낸다.
/// 목록이 평탄한 ListBox 라(TreeView 아님) 깊이를 여백으로만 표현한다 — 평탄화 시 대입된다.
@@ -197,6 +233,7 @@ public abstract class ControlViewModel : ViewModelBase
if (SetProperty(ref isLayerExpanded, value))
{
OnPropertyChanged(nameof(LayerChevronIcon));
+ OnPropertyChanged(nameof(LayerChevronSpec));
LayerExpandedChanged?.Invoke();
}
}
@@ -207,6 +244,62 @@ public abstract class ControlViewModel : ViewModelBase
/// 펼침 셰브론 아이콘명
public string LayerChevronIcon => isLayerExpanded ? "chevron-down" : "chevron-right";
+
+ ///
+ /// 데이터 배선 배지 — 이 컨트롤이 조회/데이터소스와 엮여 있는가.
+ ///
+ /// 판정 기준을 새로 만들지 않고 인스펙터의 '데이터' 탭 분류와 같은 세 속성을 쓴다.
+ /// 목록에서 "값이 어디서 오는 컨트롤인지"를 한눈에 가려내려는 표시이지 편집 대상이 아니다.
+ ///
+ public bool HasDataBinding =>
+ !string.IsNullOrWhiteSpace(Prop("DataInterfaceTag"))
+ || !string.IsNullOrWhiteSpace(Prop("DataTableField"))
+ || !string.IsNullOrWhiteSpace(Prop("Query"));
+
+ ///
+ /// 레이어 행 타입 아이콘 사양 — 강조 밴드 위에서는 아이콘 색도 함께 뒤집는다.
+ /// 색을 ConverterParameter 에 두면 바인딩이 안 돼 선택 순간 아이콘이 밴드에 묻힌다.
+ ///
+ public string LayerIconSpec =>
+ $"{SheetMe.Designer.Controls.PaletteIconCatalog.IconOf(Type)}|14|" + layerHighlight switch
+ {
+ LayerHighlight.Blue => "B.OnAccent",
+ LayerHighlight.Navy => "B.AccentText",
+ _ => "B.Accent",
+ };
+ #endregion
+
+ #region ILayerRow
+ /// 레이어 행 강조 상태
+ public LayerHighlight Highlight => layerHighlight;
+
+ /// 위 행과 이어진 강조 블록인가
+ public bool MergeTop => layerMergeTop;
+
+ /// 아래 행과 이어진 강조 블록인가
+ public bool MergeBottom => layerMergeBottom;
+
+ /// 컨트롤 행은 언제나 선택 대상
+ public bool IsSelectableRow => true;
+
+ /// 강조 상태 적용 — 디자이너가 선택이 바뀔 때마다 전 행을 한 번에 계산해 밀어 넣는다
+ public void ApplyHighlight(LayerHighlight highlight, bool mergeTop, bool mergeBottom)
+ {
+ var highlightChanged = layerHighlight != highlight;
+ layerHighlight = highlight;
+ layerMergeTop = mergeTop;
+ layerMergeBottom = mergeBottom;
+ OnPropertyChanged(nameof(Highlight));
+ OnPropertyChanged(nameof(MergeTop));
+ OnPropertyChanged(nameof(MergeBottom));
+ if (highlightChanged)
+ {
+ OnPropertyChanged(nameof(LayerIconSpec));
+ OnPropertyChanged(nameof(LockIconSpec));
+ OnPropertyChanged(nameof(HiddenIconSpec));
+ OnPropertyChanged(nameof(LayerChevronSpec));
+ }
+ }
#endregion
#region Constructors
diff --git a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs
index 160c736..b6ecb7b 100644
--- a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs
+++ b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs
@@ -76,8 +76,9 @@ public sealed class DesignerViewModel : ViewModelBase
private readonly Dictionary? spreadDesigns;
// 레이어 목록 상태 — 접힌 그룹과 타입 필터는 문서가 아니라 편집 세션에 속한다(Undo 대상 아님)
private readonly HashSet collapsedGroups = new(StringComparer.Ordinal);
+ private readonly HashSet collapsedPages = new();
private readonly HashSet layerTypeFilter = new(StringComparer.Ordinal);
- private System.Collections.Specialized.INotifyCollectionChanged? watchedControls;
+ private readonly List watchedControls = new();
#endregion
#region Properties
@@ -151,27 +152,31 @@ public sealed class DesignerViewModel : ViewModelBase
{
Selection.ActivePage = value;
}
- WatchLayerSource(value);
- RebuildLayerRows();
+ // 아웃라인이 전 페이지를 담으므로 목록 자체는 그대로다 — 활성 표시만 갱신한다
+ foreach (var header in LayerRows.OfType())
+ {
+ header.NotifyStateChanged();
+ }
}
}
}
///
- /// 활성 페이지의 최상위 컬렉션을 구독해 컨트롤 추가·삭제·순서 변경을 레이어 목록에 반영한다.
+ /// 문서 전 페이지의 최상위 컬렉션을 구독해 컨트롤 추가·삭제·순서 변경을 레이어 목록에 반영한다.
/// (예전에는 page.Controls 를 그대로 ItemsSource 로 줘서 공짜로 따라왔지만,
- /// 그룹 폴더가 끼면서 목록이 평탄화 결과가 됐다 — 원본이 변할 때 다시 만들어야 한다.)
+ /// 아웃라인이 평탄화 결과가 된 뒤로는 원본이 변할 때 다시 만들어야 한다.)
///
- private void WatchLayerSource(PageViewModel? page)
+ private void WatchLayerSource()
{
- if (watchedControls is not null)
+ foreach (var watched in watchedControls)
{
- watchedControls.CollectionChanged -= OnLayerSourceChanged;
+ watched.CollectionChanged -= OnLayerSourceChanged;
}
- watchedControls = page?.Controls;
- if (watchedControls is not null)
+ watchedControls.Clear();
+ foreach (var page in Pages)
{
- watchedControls.CollectionChanged += OnLayerSourceChanged;
+ page.Controls.CollectionChanged += OnLayerSourceChanged;
+ watchedControls.Add(page.Controls);
}
}
@@ -204,13 +209,16 @@ public sealed class DesignerViewModel : ViewModelBase
///
public ObservableCollection