diff --git a/src/SheetMe.Core/Catalog/ControlRegistry.cs b/src/SheetMe.Core/Catalog/ControlRegistry.cs index 385f064..a3f50ec 100644 --- a/src/SheetMe.Core/Catalog/ControlRegistry.cs +++ b/src/SheetMe.Core/Catalog/ControlRegistry.cs @@ -48,7 +48,7 @@ public static class ControlRegistry Type = "MaskedTextBox", DisplayName = "마스크입력", DefaultW = 160, DefaultH = 22, CreatesField = true, Properties = new PropertyDef[] { - new() { Key = "Mask", Label = "마스크", Editor = PropEditorKind.Text }, + new() { Key = "Mask", Label = "마스크", Editor = PropEditorKind.Mask }, new() { Key = "IsRequiredValue", Label = "필수 입력", Editor = PropEditorKind.Choice, Choices = new[] { "No", "Yes" } }, new() { Key = "DataInterfaceTag", Label = "데이터 태그", Editor = PropEditorKind.DataInterfaceTag }, }, @@ -244,6 +244,9 @@ public enum PropEditorKind DataActionTag, /// SQL 쿼리 — 전용 편집기 창(치환 변수 삽입 지원) SqlQuery, + + /// 입력 마스크 — 프리셋 목록 + 미리보기 대화상자 + Mask, } /// 인스펙터 속성 정의 1건 — PropBag 의 레거시 Property 를 편집 diff --git a/src/SheetMe.Core/Catalog/LegacyMaskCatalog.cs b/src/SheetMe.Core/Catalog/LegacyMaskCatalog.cs new file mode 100644 index 0000000..d8a5f78 --- /dev/null +++ b/src/SheetMe.Core/Catalog/LegacyMaskCatalog.cs @@ -0,0 +1,63 @@ +namespace SheetMe.Core.Catalog; + +/// 마스크 프리셋 1건 +/// 분류 — 편집기 목록의 그룹 헤더 +/// Mask 속성에 들어갈 값 +/// 사람이 읽는 설명 +/// 운영 활성 디자인에서의 사용 건수(0 = 운영 미사용, 표준 제공분) +public sealed record MaskPreset(string Group, string Mask, string Description, int UsageCount); + +/// +/// MaskedTextBox 마스크 프리셋 — 레거시 .NET 마스크 디자이너의 대응물. +/// +/// .NET 기본 프리셋(짧은 날짜/전화번호/우편번호…)은 문화권 일반 서식이라 이 시스템과 안 맞는다. +/// 대신 운영 활성 디자인 전수 census(2026-08-12)에서 실제로 쓰이는 마스크를 사용 빈도와 함께 +/// 제공한다 — MaskedTextBox 475개 / Mask 보유 460개 / 고유 44종. +/// 이렇게 하면 디자이너가 기존 서식과 같은 표기를 그대로 고를 수 있어 표기 흔들림이 줄어든다. +/// +public static class LegacyMaskCatalog +{ + #region Member Fields + /// 프리셋 — 그룹별, 그룹 안에서는 사용 빈도 내림차순 + public static readonly IReadOnlyList All = new MaskPreset[] + { + // ── 날짜 ── + new("날짜", "0000년 90월 90일", "연월일 (한글)", 198), + new("날짜", "0000-00-00", "연-월-일", 51), + new("날짜", "0000.00.00.", "연.월.일.", 21), + new("날짜", "0000년 90월 90일", "연월일 (한글, 넓은 간격)", 12), + new("날짜", "0000년 00월 00일", "연월일 (한글, 전체 필수)", 6), + new("날짜", "0000 년 00월 00일", "연월일 (한글, 연도 뒤 공백)", 4), + new("날짜", "0000.90.90.", "연.월.일. (월일 선택)", 4), + new("날짜", "90월 90일", "월일", 15), + new("날짜", "0000-00", "연-월", 1), + new("날짜", @"2\000년 90월 90일", "연월일 (2000년대 고정)", 1), + + // ── 시간 ── + new("시간", "90:90", "시:분", 36), + new("시간", "90시90분", "시분 (한글)", 22), + new("시간", "90:00", "시:분 (분 필수)", 11), + new("시간", "00:00", "시:분 (전체 필수)", 8), + new("시간", "00시 00분", "시분 (한글, 넓은 간격)", 5), + new("시간", "00분 00초", "분초", 1), + new("시간", "90시간90분", "소요 시간", 1), + + // ── 날짜+시간 ── + new("날짜+시간", "0000-00-00 90:00", "연-월-일 시:분", 5), + new("날짜+시간", "0000년 90월 90일 90시 90분", "연월일 시분 (한글)", 4), + new("날짜+시간", "####/##/## ##:##", "연/월/일 시:분 (선택 입력)", 1), + + // ── 번호 ── + new("번호", "000-9000-0000", "휴대전화", 6), + new("번호", "000-00-00000-0", "사업자·기관 번호", 1), + + // ── 표준(운영 미사용, 필요 시) ── + new("표준", "000000-0000000", "주민등록번호", 0), + new("표준", "00000", "우편번호(5자리)", 0), + new("표준", "LLL", "영문 3자", 0), + }; + + /// 운영에서 실제 쓰이는 프리셋만(사용 건수 > 0) + public static IEnumerable InUse => All.Where(p => p.UsageCount > 0); + #endregion +} diff --git a/src/SheetMe.Core/Serialization/LegacyMask.cs b/src/SheetMe.Core/Serialization/LegacyMask.cs index b0f7d7e..5578998 100644 --- a/src/SheetMe.Core/Serialization/LegacyMask.cs +++ b/src/SheetMe.Core/Serialization/LegacyMask.cs @@ -74,5 +74,108 @@ public static class LegacyMask } return result.ToString(); } + + /// + /// 마스크에 입력을 채운 결과 — 마스크 편집기의 시험 입력 미리보기용. + /// + /// 규칙은 둘뿐이라 결과를 예측할 수 있다. + /// + /// 리터럴은 항상 그대로 출력되고 입력을 소비하지 않는다. + /// 지시자는 입력에서 자기에게 맞는 첫 문자를 가져온다. 맞지 않는 문자와 + /// 공백은 구분자로 보고 건너뛴다. 채워지지 않은 자리는 프롬프트 문자로 남는다. + /// + /// + /// + /// 덕분에 값만 치든(20260812) 구분자를 같이 치든(2026-08-12, 2026년 08월 12일) + /// 같은 결과가 나온다. 공백을 건너뛰는 이유는 9 가 공백도 받아들이기 때문이다 — + /// 그대로 두면 0000년 90월 90일2026년 08월 12일 을 넣을 때 구분자 공백이 + /// 데이터 자리를 먹어 한 칸씩 밀린다. 9 의 공백 허용은 '빈 자리'를 위한 것이고 + /// 그건 프롬프트 문자가 이미 표현한다. + /// + /// + /// + /// 한계: 리터럴이 숫자인 마스크(2\000년 — 운영 460건 중 1건)는 사용자가 리터럴까지 치면 + /// 어디까지가 데이터인지 원리적으로 가릴 수 없다. 이때는 편집 가능한 자리만 치면 된다(26). + /// 미리보기용이므로 규칙을 단순하게 두는 편이 낫다고 판단했다. + /// + /// + public static string Apply(string? mask, string? input, char promptChar = DefaultPromptChar) + { + if (string.IsNullOrEmpty(mask)) + { + return string.Empty; + } + + var source = input ?? string.Empty; + var next = 0; + var result = new StringBuilder(mask.Length); + + for (var i = 0; i < mask.Length; i++) + { + var c = mask[i]; + switch (c) + { + case '\\': + result.Append(i + 1 < mask.Length ? mask[++i] : c); + break; + + case '<' or '>' or '|': + break; + + case '0' or '9' or '#' or 'L' or '?' or '&' or 'C' or 'A' or 'a': + while (next < source.Length + && (char.IsWhiteSpace(source[next]) || !Accepts(c, source[next]))) + { + next++; + } + if (next < source.Length) + { + result.Append(Transform(mask, i, source[next])); + next++; + } + else + { + result.Append(promptChar); + } + break; + + default: + result.Append(c); + break; + } + } + return result.ToString(); + } + + /// 지시자가 받아들이는 문자인지 — WinForms MaskedTextBox 규약 + private static bool Accepts(char directive, char value) => directive switch + { + '0' => char.IsDigit(value), + '9' => char.IsDigit(value) || value == ' ', + '#' => char.IsDigit(value) || value is ' ' or '+' or '-', + 'L' => char.IsLetter(value), + '?' => char.IsLetter(value) || value == ' ', + 'A' => char.IsLetterOrDigit(value), + 'a' => char.IsLetterOrDigit(value) || value == ' ', + '&' => !char.IsControl(value), + 'C' => !char.IsControl(value) || value == ' ', + _ => false, + }; + + /// 직전에 나온 대소문자 변환자(< > |)를 적용한다 + private static char Transform(string mask, int directiveIndex, char value) + { + for (var i = directiveIndex - 1; i >= 0; i--) + { + switch (mask[i]) + { + case '>': return char.ToUpperInvariant(value); + case '<': return char.ToLowerInvariant(value); + case '|': return value; + default: continue; // 변환자를 만날 때까지 거슬러 올라간다 + } + } + return value; + } #endregion } diff --git a/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs b/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs index eff8eb5..fb5aabb 100644 --- a/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs +++ b/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs @@ -257,6 +257,7 @@ public sealed class InspectorViewModel : ViewModelBase PropEditorKind.DataActionTag => new TagPickerRowViewModel(def.Label, "액션 태그 선택 — 더블클릭/버튼 액션(EN_DataActionTyp)", LegacyTagCatalog.DataActionTags), PropEditorKind.SqlQuery => new QueryRowViewModel(def.Label), + PropEditorKind.Mask => new MaskRowViewModel(def.Label), _ => new TextRowViewModel(def.Label), }; AddRow(row, binding); diff --git a/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs b/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs index 93691c6..6bb6ddc 100644 --- a/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs +++ b/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs @@ -167,6 +167,40 @@ public sealed class TagPickerRowViewModel : PropertyRowViewModel } } +/// +/// 마스크 행 — 값과 표시 미리보기를 함께 보여주고, 찾아보기로 프리셋 편집기를 연다. +/// 레거시는 WinForms 기본 마스크 디자이너(프리셋 + 시험 입력)를 제공했다. +/// +public sealed class MaskRowViewModel : PropertyRowViewModel +{ + /// 이 마스크가 화면에 어떻게 보이는지 — 인스펙터에서 바로 확인 + public string Preview => ValueText.Length == 0 + ? "(마스크 없음)" + : SheetMe.Core.Serialization.LegacyMask.ToPromptDisplay(ValueText); + + /// 마스크 편집기 열기 + public M.Framework.WPF.ICustomCommand? BrowseCommand { get; set; } + + public MaskRowViewModel(string label) : base(label) + { + BrowseCommand = new M.Framework.WPF.Command((sender, e) => OnBrowse()); + } + + protected override void OnValueApplied() => OnPropertyChanged(nameof(Preview)); + + private void OnBrowse() + { + var dialog = new Views.MaskPickerDialogView(ValueText) + { + Owner = System.Windows.Application.Current.MainWindow, + }; + if (dialog.ShowDialog() == true) + { + ValueText = dialog.Mask; + } + } +} + /// SQL 쿼리 행 — 요약 표시 + 전용 편집기 창(치환 변수 삽입) public sealed class QueryRowViewModel : PropertyRowViewModel { diff --git a/src/SheetMe.Designer/Views/InspectorView.xaml b/src/SheetMe.Designer/Views/InspectorView.xaml index 89e39c6..630cd47 100644 --- a/src/SheetMe.Designer/Views/InspectorView.xaml +++ b/src/SheetMe.Designer/Views/InspectorView.xaml @@ -106,6 +106,24 @@ + + + + +