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; }