diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
index ee0ad2b..a1f9433 100644
--- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
+++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
@@ -1229,6 +1229,37 @@ public static class EditSmoke
Check("레이어: 컨테이너가 자식 수를 말한다", wrapped.LayerChildCountText == "(2)",
wrapped.LayerChildCountText);
+ // 둘째 줄은 항상 있어야 한다 — 없으면 1줄/2줄이 섞여 훑는 리듬이 깨진다.
+ // 세 갈래를 다 본다: 문구 없음 / 문구가 타입명 그대로(갓 놓은 것) / 사람이 쓴 문구.
+ var fresh = wrapped.Children[0]; // 갓 놓은 라디오 — 문구가 "라디오버튼"이다
+ Check("레이어: 갓 놓은 것은 둘째 줄이 이름뿐이다(타입명 중복 없음)",
+ fresh.LayerSubText == fresh.Id,
+ $"'{fresh.LayerName}' / '{fresh.LayerSubText}'");
+
+ var bare = new ControlElement
+ {
+ Type = "TextBox", Id = "TextBox9",
+ Bounds = new LayoutRect { X = 10, Y = 10, W = 100, H = 20 },
+ };
+ var bareDoc = new FormDocument();
+ var barePage = new FormPage { Width = 720, Height = 856 };
+ barePage.Controls.Add(bare);
+ bareDoc.Pages.Add(barePage);
+ var bareVm = new DesignerViewModel(bareDoc).Pages[0].Controls[0];
+ Check("레이어: 문구가 없는 행도 둘째 줄이 있다", bareVm.LayerSubText.Length > 0,
+ $"'{bareVm.LayerName}' / '{bareVm.LayerSubText}'");
+ Check("레이어: 문구가 없으면 둘째 줄은 타입명이다", bareVm.LayerSubText == "텍스트박스",
+ bareVm.LayerSubText);
+ Check("레이어: 같은 글자를 두 줄에 두 번 쓰지 않는다",
+ !bareVm.LayerSubText.Contains(bareVm.Id, StringComparison.Ordinal),
+ $"Id={bareVm.Id} 둘째줄='{bareVm.LayerSubText}'");
+
+ bare.Props.SetText("Text", "환자명");
+ var named = new DesignerViewModel(bareDoc).Pages[0].Controls[0];
+ Check("레이어: 사람이 쓴 문구면 타입·이름을 함께 보여 준다",
+ named.LayerSubText == "텍스트박스 · TextBox9",
+ $"'{named.LayerName}' / '{named.LayerSubText}'");
+
// 중첩까지 센다 — "이걸 지우면 몇 개가 같이 지워지나"가 그 수다.
// 중첩 구조는 모델로 직접 짠다(Ctrl+G 를 두 번 겹치는 경로는 이 검사의 대상이 아니다).
var nestDoc = new FormDocument();
diff --git a/src/SheetMe.Designer/ViewModels/Controls/ControlViewModel.cs b/src/SheetMe.Designer/ViewModels/Controls/ControlViewModel.cs
index 7968473..ff6efc2 100644
--- a/src/SheetMe.Designer/ViewModels/Controls/ControlViewModel.cs
+++ b/src/SheetMe.Designer/ViewModels/Controls/ControlViewModel.cs
@@ -199,6 +199,33 @@ public abstract class ControlViewModel : ViewModelBase, ILayerRow
/// 이름 칸을 그릴지 — 문구와 이름이 다를 때만
public bool HasLayerId => LayerId.Length > 0;
+ ///
+ /// 레이어 행 둘째 줄 — 항상 있다. 그래야 행 높이가 일정하다.
+ ///
+ /// 전에는 표시명이 곧 Id 인 행(문구가 없는 컨트롤)에서 둘째 줄이 통째로 빠져
+ /// 목록에 1줄과 2줄이 섞였다. 훑을 때 리듬이 깨지고, 어느 행이 무엇인지
+ /// 눈이 매번 다시 자리를 잡아야 했다.
+ ///
+ /// 타입명을 앞에 둔다. 아이콘만으로는 타입이 안 갈리는 짝이 실제로 있다 —
+ /// 라벨과 마스크입력이 같은 글리프를 쓰고, 리스트박스와 체크리스트도 같다.
+ /// 이름(Id)은 표시명과 다를 때만 뒤에 붙인다 — 같으면 윗줄과 같은 글자를 두 번 쓰는 셈이다.
+ /// 이름을 계속 보여 주는 이유는 그것이 외부 참조 키이기 때문이다(수식·액션 대상·이벤트 규약).
+ ///
+ public string LayerSubText
+ {
+ get
+ {
+ var typeName = SheetMe.Core.Catalog.ControlRegistry.Find(Type)?.DisplayName ?? Type;
+ // 이름이 곧 표시명이면(문구 없는 컨트롤) 타입만 — 같은 글자를 두 번 쓰지 않는다
+ if (!HasLayerId)
+ {
+ return typeName;
+ }
+ // 갓 놓은 컨트롤은 문구가 타입명 그대로다("라디오버튼") — 윗줄이 이미 타입을 말했으므로 이름만
+ return LayerName == typeName ? LayerId : $"{typeName} · {LayerId}";
+ }
+ }
+
/// 레이어 잠금 토글 아이콘명
public string LockIcon => Model.Locked ? "lock" : "unlock";
diff --git a/src/SheetMe.Designer/Views/LayerPanelView.xaml b/src/SheetMe.Designer/Views/LayerPanelView.xaml
index ca96be7..213e442 100644
--- a/src/SheetMe.Designer/Views/LayerPanelView.xaml
+++ b/src/SheetMe.Designer/Views/LayerPanelView.xaml
@@ -349,10 +349,16 @@
- 항상 있다 — 없으면 목록에 1줄 행과 2줄 행이 섞여
+ 훑을 때 눈이 매번 자리를 다시 잡아야 한다.
+ 타입명을 앞에 두는 이유: 아이콘만으로 안 갈리는 짝이 실제로 있다
+ (라벨·마스크입력이 같은 글리프, 리스트박스·체크리스트도 같다).
+ 이름(Id)은 표시명과 다를 때만 뒤에 붙는다 — 외부 참조 키라 계속 보여야 한다.
+ -->
+
+ ToolTip="{Binding LayerSubText}">