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