From 3e26af8739186631bb0867d23e0cbd6fbf649e5c Mon Sep 17 00:00:00 2001 From: Msystech Date: Thu, 13 Aug 2026 16:05:33 +0900 Subject: [PATCH] =?UTF-8?q?=EC=BF=BC=EB=A6=AC=20=ED=8E=B8=EC=A7=91?= =?UTF-8?q?=EA=B8=B0=20=E2=80=94=20=ED=95=9C=EA=B8=80=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=83=89=EA=B3=BC=20=EA=B8=80=EC=9E=90=EA=B0=80=20=EC=96=B4?= =?UTF-8?q?=EA=B8=8B=EB=82=98=EB=8D=98=20=EB=AC=B8=EC=A0=9C,=20=EB=8B=A4?= =?UTF-8?q?=ED=81=AC=20=EC=A0=9C=EB=AA=A9=20=ED=91=9C=EC=8B=9C=EC=A4=84,?= =?UTF-8?q?=20=EB=AA=A9=EC=97=85=20=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ■ 한글을 치면 색칠이 글자와 어긋났다 (가장 중요) 색칠 레이어가 글자 위치를 직접 재서 잡고 있었다. 앞부분을 FormattedText 로 측정해 그 폭만큼 오른쪽으로 옮겨 그리는 방식이다. ASCII 에서는 맞았지만 한글에서 무너졌다 — 편집 글꼴(Consolas)에 한글 글리프가 없어 폰트 대체가 일어나는데, TextBox 의 내부 조판과 이쪽 측정이 같은 글꼴·같은 셰이핑을 고르리라는 보장이 없다. 조금만 달라도 뒤로 갈수록 벌어져 색과 글자가 따로 논다. 이제 TextBox 에게 좌표를 묻는다(GetRectFromCharacterIndex). 여백·스크롤·줄 높이·폰트 대체가 전부 TextBox 기준으로 이미 반영돼 오고, 조각마다 위치를 새로 물으므로 오차가 누적되지 않는다. 줄 높이를 손으로 계산하던 코드도 함께 사라졌다. 편집 글꼴 순서도 한글이 있는 것(D2Coding → 굴림체 → Consolas)으로 바꿔 대체 자체를 줄였다. --query-popup 진단에 정렬 검사 2건을 넣었다: 한글이 섞인 줄에서 글자 좌표가 뒤로 밀리지 않는가, 마지막 글자 다음 좌표가 캐럿 자리와 이어지는가. 현재 9/9. ■ 다크 모드인데 제목 표시줄만 밝게 남던 문제 ThemedWindow 스타일은 창 안쪽만 칠한다. 제목 표시줄은 OS 가 그리고 기본값은 Windows 의 테마를 따르므로, 앱을 다크로 바꿔도 Windows 가 라이트면 흰 띠가 남는다. DWM 속성(DWMWA_USE_IMMERSIVE_DARK_MODE)으로 창별로 지정한다. 커스텀 크롬을 직접 그리는 것보다 훨씬 싸고, 최소화·최대화·닫기의 동작과 접근성이 OS 것 그대로 남는다. 창마다 손으로 넣으면 새 대화상자에서 반드시 빠지므로 App 에서 Window 클래스 핸들러로 한 번만 걸었다 — 지금 있는 창과 앞으로 만들 창 모두 자동으로 적용된다. 테마를 바꾸는 순간 이미 떠 있는 창들도 함께 맞춘다(ThemeManager.ApplyChromeToOpenWindows). ■ 목업과 위치 맞춤 앞 배지(아이콘) · 검색칸의 돋보기와 안내 문구 · 안내 상자의 ⓘ 를 넣었다. 갈래 칩 순서는 개수순에서 고정 순서(환자·서식·작업자·컨트롤)로 바꿨다 — 컨트롤 수는 서식마다 달라서 개수순으로 두면 서식을 바꿀 때마다 칩이 자리를 바꿔 손이 기억하지 못한다. 회귀: 테스트 230/230, 편집 스모크 실패 0, 팝업·정렬 점검 9/9, 검증 실행 점검 10/10, DB 왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 바이트 동일. Co-Authored-By: Claude Opus 5 --- src/SheetMe.Designer/App.xaml.cs | 12 ++ .../Controls/SqlHighlightLayer.cs | 143 ++++++++---------- src/SheetMe.Designer/Diagnostics/DbSmoke.cs | 29 ++++ src/SheetMe.Designer/Services/ThemeManager.cs | 16 ++ .../Services/WindowChromeTheme.cs | 59 ++++++++ .../Views/QueryEditorWindow.xaml | 43 ++++-- .../Views/QueryEditorWindow.xaml.cs | 16 +- 7 files changed, 224 insertions(+), 94 deletions(-) create mode 100644 src/SheetMe.Designer/Services/WindowChromeTheme.cs diff --git a/src/SheetMe.Designer/App.xaml.cs b/src/SheetMe.Designer/App.xaml.cs index 2e3435e..e02f95c 100644 --- a/src/SheetMe.Designer/App.xaml.cs +++ b/src/SheetMe.Designer/App.xaml.cs @@ -58,6 +58,18 @@ public partial class App : Application } Services.ThemeManager.LoadSaved(); + + // 제목 표시줄은 OS 가 그린다 — 창이 만들어질 때마다 앱 테마에 맞춰 준다. + // 창마다 손으로 넣으면 새 대화상자에서 반드시 빠지므로 클래스 단위로 한 번만 건다. + EventManager.RegisterClassHandler(typeof(Window), Window.LoadedEvent, + new RoutedEventHandler((sender, _) => + { + if (sender is Window window) + { + Services.WindowChromeTheme.Apply(window, !Services.ThemeManager.IsLight); + } + })); + var main = new MainView(); if (launch?.ShtCod.Length > 0 && main.DataContext is ViewModels.MainViewModel viewModel) { diff --git a/src/SheetMe.Designer/Controls/SqlHighlightLayer.cs b/src/SheetMe.Designer/Controls/SqlHighlightLayer.cs index e725129..acaa935 100644 --- a/src/SheetMe.Designer/Controls/SqlHighlightLayer.cs +++ b/src/SheetMe.Designer/Controls/SqlHighlightLayer.cs @@ -146,104 +146,87 @@ public sealed class SqlHighlightLayer : FrameworkElement var tokens = SqlTokenizer.Tokenize(text); var typeface = new Typeface(source.FontFamily, source.FontStyle, source.FontWeight, source.FontStretch); var dpi = VisualTreeHelper.GetDpi(this).PixelsPerDip; - - // TextBox 의 내부 여백 + 스크롤 오프셋만큼 옮겨 그린다 - var offsetX = source.Padding.Left + source.BorderThickness.Left - (scroller?.HorizontalOffset ?? 0); - var offsetY = source.Padding.Top + source.BorderThickness.Top - (scroller?.VerticalOffset ?? 0); - - // 줄 높이는 실제 글꼴에서 잰다 — 상수로 두면 글꼴이 바뀔 때 어긋난다 - var probe = new FormattedText("Ag", CultureInfo.InvariantCulture, FlowDirection.LeftToRight, - typeface, source.FontSize, Brushes.Black, dpi); - var lineHeight = probe.Height; - - // 커서가 있는 줄 — 긴 쿼리에서 지금 어디를 고치고 있는지 잃지 않게 옅은 띠를 깐다 - var caretLine = LineIndexOf(text, source.CaretIndex); - - var lineIndex = 0; - var lineStart = 0; var height = ActualHeight; - while (lineStart <= text.Length) + // 커서가 있는 줄 — 긴 쿼리에서 지금 어디를 고치고 있는지 잃지 않게 옅은 띠를 깐다. + // 위치는 TextBox 에게 묻는다(아래 이유와 같다). + if (source.SelectionLength == 0 && TryFindResource("B.Hover") is Brush band) { - var newline = text.IndexOf('\n', lineStart); - var lineEnd = newline < 0 ? text.Length : newline; - var y = offsetY + lineIndex * lineHeight; - - if (lineIndex == caretLine && y + lineHeight >= 0 && y <= height - && source.SelectionLength == 0 - && TryFindResource("B.Hover") is Brush band) + var caretRect = RectAt(source.CaretIndex); + if (caretRect is { } cr && cr.Bottom >= 0 && cr.Top <= height) { - dc.DrawRectangle(band, null, new Rect(0, y, Math.Max(0, ActualWidth), lineHeight)); + dc.DrawRectangle(band, null, new Rect(0, cr.Top, Math.Max(0, ActualWidth), cr.Height)); } - - // 화면 밖 줄은 그리지 않는다 — 긴 쿼리에서 매 입력마다 전부 그리면 눈에 띄게 느려진다 - if (y > height) - { - break; - } - if (y + lineHeight >= 0) - { - DrawLine(dc, text, tokens, lineStart, lineEnd, typeface, dpi, offsetX, y); - } - - if (newline < 0) - { - break; - } - lineStart = newline + 1; - lineIndex++; } - } - /// 주어진 위치가 몇 번째 줄인지(0부터) - private static int LineIndexOf(string text, int index) - { - var line = 0; - var upto = Math.Clamp(index, 0, text.Length); - for (var i = 0; i < upto; i++) + foreach (var token in tokens) { - if (text[i] == '\n') + // 조각이 줄을 걸치면 줄마다 나눠 그린다 — 한 번에 그리면 줄바꿈이 무시된다 + var pieceStart = token.Start; + while (pieceStart < token.End) { - line++; + var newline = text.IndexOf('\n', pieceStart, token.End - pieceStart); + var pieceEnd = newline < 0 ? token.End : newline; + if (pieceEnd > pieceStart) + { + DrawPiece(dc, text, pieceStart, pieceEnd, token.Kind, typeface, dpi, height); + } + if (newline < 0) + { + break; + } + pieceStart = newline + 1; } } - return line; } /// - /// 한 줄을 조각별 색으로 그린다. - /// 조각 경계마다 를 새로 만들되, x 위치는 앞부분 전체를 다시 재서 잡는다 — - /// 폭을 누적하면 자간·힌팅 때문에 뒤로 갈수록 한두 픽셀씩 밀린다. + /// 그 글자의 화면 위치 — TextBox 에게 직접 묻는다. + /// + /// 예전에는 앞부분 글자 폭을 이쪽에서 재서 x 를 계산했는데, 한글이 들어오면 어긋났다. + /// 편집기 글꼴(Consolas)에는 한글 글리프가 없어 폰트 대체가 일어나는데, + /// TextBox 의 내부 조판과 이쪽 측정이 같은 글꼴·같은 셰이핑을 고르리라는 + /// 보장이 없다. 조금만 달라도 뒤로 갈수록 벌어져 색과 글자가 따로 논다. + /// + /// 좌표를 물어보면 여백·스크롤·줄 높이·폰트 대체가 전부 TextBox 기준으로 이미 반영돼 온다. /// - private void DrawLine(DrawingContext dc, string text, IReadOnlyList tokens, - int lineStart, int lineEnd, Typeface typeface, double dpi, double offsetX, double y) + private Rect? RectAt(int index) { - foreach (var token in tokens) + if (source is null) { - if (token.End <= lineStart || token.Start >= lineEnd) - { - continue; - } - var start = Math.Max(token.Start, lineStart); - var end = Math.Min(token.End, lineEnd); - if (end <= start) - { - continue; - } - - var prefix = text[lineStart..start]; - var x = offsetX; - if (prefix.Length > 0) - { - var measured = new FormattedText(prefix, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, - typeface, source!.FontSize, Brushes.Black, dpi); - x += measured.WidthIncludingTrailingWhitespace; - } - - var piece = new FormattedText(text[start..end], CultureInfo.InvariantCulture, FlowDirection.LeftToRight, - typeface, source!.FontSize, BrushFor(token.Kind), dpi); - dc.DrawText(piece, new Point(x, y)); + return null; } + var clamped = Math.Clamp(index, 0, source.Text.Length); + var rect = source.GetRectFromCharacterIndex(clamped); + if (rect.IsEmpty || double.IsInfinity(rect.Top) || double.IsNaN(rect.Top)) + { + return null; + } + return rect; + } + + /// + /// 한 줄 안의 조각 하나를 그 자리에 그린다. + /// + /// 시작 위치를 TextBox 에게 물으므로 조각마다 오차가 초기화된다 — + /// 폭을 누적하던 예전 방식과 달리 뒤로 갈수록 밀리지 않는다. + /// + private void DrawPiece(DrawingContext dc, string text, int start, int end, + SqlTokenKind kind, Typeface typeface, double dpi, double height) + { + if (RectAt(start) is not { } rect) + { + return; + } + // 화면 밖은 그리지 않는다 — 긴 쿼리에서 매 입력마다 전부 그리면 눈에 띄게 느려진다 + if (rect.Bottom < 0 || rect.Top > height) + { + return; + } + + var piece = new FormattedText(text[start..end], CultureInfo.CurrentCulture, FlowDirection.LeftToRight, + typeface, source!.FontSize, BrushFor(kind), dpi); + dc.DrawText(piece, new Point(rect.X, rect.Y)); } #endregion } diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs index 49d92db..01db52c 100644 --- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs @@ -701,6 +701,35 @@ public static class DbSmoke // ⑦ 바깥 클릭으로 닫히는 설정인지 Check("바깥 클릭으로 닫히는 설정(StaysOpen=False)", !popup.StaysOpen); + // ⑧ 색칠 레이어와 실제 글자 위치가 맞는지 — 한글이 섞이면 폰트 대체 때문에 어긋났었다. + // 레이어는 TextBox 에게 좌표를 물으므로, 같은 인덱스의 사각형이 곧 그리는 자리다. + // 여기서는 '캐럿이 글자 끝에 있을 때 그 x 가 앞 글자들 폭의 합과 단조 증가하는가'를 본다. + Type("SELECT ㄴㅁㅇㄹ FROM ㅎㄱ"); + var xs = new List(); + for (var i = 0; i <= box.Text.Length; i++) + { + var r = box.GetRectFromCharacterIndex(i); + xs.Add(r.IsEmpty ? double.NaN : r.X); + } + var monotonic = true; + for (var i = 1; i < xs.Count; i++) + { + if (!double.IsNaN(xs[i]) && !double.IsNaN(xs[i - 1]) && xs[i] < xs[i - 1] - 0.01) + { + monotonic = false; + break; + } + } + Check("한글 섞인 줄에서 글자 좌표가 뒤로 밀리지 않는다", monotonic, + $"첫 x {xs[0]:F1} → 끝 x {xs[^1]:F1}"); + + // 마지막 글자의 오른쪽 끝이 캐럿 자리와 같아야 한다(레이어가 이 값으로 그린다) + var lastCharRect = box.GetRectFromCharacterIndex(box.Text.Length - 1); + var caretRect = box.GetRectFromCharacterIndex(box.Text.Length); + Check("마지막 글자 다음 좌표가 캐럿 자리와 이어진다", + !lastCharRect.IsEmpty && !caretRect.IsEmpty && caretRect.X >= lastCharRect.X, + $"글자 {lastCharRect.X:F1} / 캐럿 {caretRect.X:F1}"); + window.Close(); DrainDispatcher(); } diff --git a/src/SheetMe.Designer/Services/ThemeManager.cs b/src/SheetMe.Designer/Services/ThemeManager.cs index 6e0ac71..9bb44fe 100644 --- a/src/SheetMe.Designer/Services/ThemeManager.cs +++ b/src/SheetMe.Designer/Services/ThemeManager.cs @@ -43,6 +43,20 @@ public static class ThemeManager /// 라이트↔다크 토글 public static void Toggle() => Apply(!IsLight); + /// + /// 열려 있는 모든 창의 제목 표시줄을 지금 테마에 맞춘다. + /// + /// 제목 표시줄은 창 안쪽 자원(B.*)이 아니라 OS 가 그리므로 사전을 바꿔도 따라오지 않는다. + /// 테마를 바꾼 순간 이미 떠 있는 창들도 함께 맞춰 줘야 위쪽만 밝은 창이 남지 않는다. + /// + public static void ApplyChromeToOpenWindows() + { + foreach (Window window in Application.Current.Windows) + { + WindowChromeTheme.Apply(window, !IsLight); + } + } + /// 테마 적용 — App.Resources 병합 사전에서 토큰 dict 를 찾아 교체 public static void Apply(bool light) { @@ -57,11 +71,13 @@ public static class ThemeManager { dictionaries[i] = new ResourceDictionary { Source = tokensUri }; Save(); + ApplyChromeToOpenWindows(); return; } } dictionaries.Add(new ResourceDictionary { Source = tokensUri }); Save(); + ApplyChromeToOpenWindows(); } private static void Save() diff --git a/src/SheetMe.Designer/Services/WindowChromeTheme.cs b/src/SheetMe.Designer/Services/WindowChromeTheme.cs new file mode 100644 index 0000000..0926207 --- /dev/null +++ b/src/SheetMe.Designer/Services/WindowChromeTheme.cs @@ -0,0 +1,59 @@ +using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Interop; + +namespace SheetMe.Designer.Services; + +/// +/// 창 제목 표시줄을 앱 테마에 맞춘다. +/// +/// 왜 필요한가. ThemedWindow 스타일은 창 안쪽만 칠한다. 제목 표시줄은 OS 가 그리며 +/// 기본값은 Windows 의 테마를 따른다 — 앱을 다크로 바꿔도 Windows 가 라이트면 흰 띠가 남아 +/// 창 위쪽만 밝게 뜬다. 반대 조합도 마찬가지다. +/// +/// DWM 속성 하나(DWMWA_USE_IMMERSIVE_DARK_MODE)로 창별 지정이 가능하다. +/// 커스텀 크롬을 직접 그리는 것보다 훨씬 적은 비용으로 같은 결과를 얻는다 — +/// 최소화·최대화·닫기의 동작과 접근성이 OS 것 그대로 남는다. +/// +/// Windows 10 1809 이전에는 이 속성이 없다. 실패해도 그냥 무시한다(예전 모습으로 남을 뿐이다). +/// +public static class WindowChromeTheme +{ + #region Member Fields + // Windows 10 20H1(빌드 18985) 이상. 그 이전 빌드는 19 를 쓰던 시기가 있어 둘 다 시도한다. + private const int UseImmersiveDarkMode = 20; + private const int UseImmersiveDarkModeBefore20H1 = 19; + #endregion + + #region Methods + [DllImport("dwmapi.dll", CharSet = CharSet.Unicode, SetLastError = false)] + private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int value, int size); + + /// + /// 이 창의 제목 표시줄을 어둡게/밝게 — 창 핸들이 생긴 뒤에 불러야 한다. + /// + public static void Apply(Window window, bool dark) + { + try + { + var handle = new WindowInteropHelper(window).Handle; + if (handle == IntPtr.Zero) + { + return; + } + var value = dark ? 1 : 0; + if (DwmSetWindowAttribute(handle, UseImmersiveDarkMode, ref value, sizeof(int)) != 0) + { + DwmSetWindowAttribute(handle, UseImmersiveDarkModeBefore20H1, ref value, sizeof(int)); + } + } + catch (DllNotFoundException) + { + // dwmapi 가 없는 환경 — 제목 표시줄만 OS 기본으로 남는다 + } + catch (EntryPointNotFoundException) + { + } + } + #endregion +} diff --git a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml index a3ea31f..13e279e 100644 --- a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml +++ b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml @@ -65,13 +65,19 @@ + + + + - @@ -172,7 +178,7 @@ - @@ -192,7 +198,7 @@ - @@ -201,7 +207,7 @@ - + + + + + + @@ -253,8 +269,13 @@ - + + + + diff --git a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs index 63e19d8..3fbd473 100644 --- a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs +++ b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs @@ -792,7 +792,10 @@ public partial class QueryEditorWindow : Window #region Methods - Variables private void OnVariableSearchChanged(object sender, TextChangedEventArgs e) - => ApplyVariableFilter(VariableSearch.Text); + { + SearchPlaceholder.Visibility = VariableSearch.Text.Length == 0 ? Visibility.Visible : Visibility.Collapsed; + ApplyVariableFilter(VariableSearch.Text); + } /// 카탈로그 + 이 서식의 컨트롤 — 컨트롤은 문서마다 달라 여기서 합친다 private IReadOnlyList AllVariables() @@ -808,9 +811,16 @@ public partial class QueryEditorWindow : Window { new { Name = "전체", Count = all.Count, IsActive = activeBucket.Length == 0 }, }; - foreach (var group in all.GroupBy(v => v.Bucket).OrderByDescending(g => g.Count())) + // 순서는 개수가 아니라 고정이다 — 컨트롤 수는 서식마다 달라서, 개수순으로 두면 + // 서식을 바꿀 때마다 칩이 자리를 바꿔 손이 기억하지 못한다. + var order = new[] { "환자", "서식", "작업자", "컨트롤" }; + var byBucket = all.GroupBy(v => v.Bucket).ToDictionary(g => g.Key, g => g.Count()); + foreach (var name in order) { - chips.Add(new { Name = group.Key, Count = group.Count(), IsActive = activeBucket == group.Key }); + if (byBucket.TryGetValue(name, out var count) && count > 0) + { + chips.Add(new { Name = name, Count = count, IsActive = activeBucket == name }); + } } BucketChips.ItemsSource = chips; }