쿼리 편집기 — 한글에서 색과 글자가 어긋나던 문제, 다크 제목 표시줄, 목업 맞춤

■ 한글을 치면 색칠이 글자와 어긋났다 (가장 중요)

색칠 레이어가 글자 위치를 <b>직접 재서</b> 잡고 있었다. 앞부분을 FormattedText 로 측정해
그 폭만큼 오른쪽으로 옮겨 그리는 방식이다. ASCII 에서는 맞았지만 한글에서 무너졌다 —
편집 글꼴(Consolas)에 한글 글리프가 없어 폰트 대체가 일어나는데,
TextBox 의 내부 조판과 이쪽 측정이 같은 글꼴·같은 셰이핑을 고르리라는 보장이 없다.
조금만 달라도 뒤로 갈수록 벌어져 색과 글자가 따로 논다.

이제 <b>TextBox 에게 좌표를 묻는다</b>(GetRectFromCharacterIndex).
여백·스크롤·줄 높이·폰트 대체가 전부 TextBox 기준으로 이미 반영돼 오고,
조각마다 위치를 새로 물으므로 오차가 누적되지 않는다.
줄 높이를 손으로 계산하던 코드도 함께 사라졌다.
편집 글꼴 순서도 한글이 있는 것(D2Coding → 굴림체 → Consolas)으로 바꿔 대체 자체를 줄였다.

--query-popup 진단에 정렬 검사 2건을 넣었다: 한글이 섞인 줄에서 글자 좌표가 뒤로 밀리지 않는가,
마지막 글자 다음 좌표가 캐럿 자리와 이어지는가. 현재 9/9.

■ 다크 모드인데 제목 표시줄만 밝게 남던 문제

ThemedWindow 스타일은 창 <b>안쪽</b>만 칠한다. 제목 표시줄은 OS 가 그리고
기본값은 Windows 의 테마를 따르므로, 앱을 다크로 바꿔도 Windows 가 라이트면 흰 띠가 남는다.

DWM 속성(DWMWA_USE_IMMERSIVE_DARK_MODE)으로 창별로 지정한다. 커스텀 크롬을 직접 그리는 것보다
훨씬 싸고, 최소화·최대화·닫기의 동작과 접근성이 OS 것 그대로 남는다.
창마다 손으로 넣으면 새 대화상자에서 반드시 빠지므로 App 에서 Window 클래스 핸들러로 한 번만 걸었다 —
지금 있는 창과 앞으로 만들 창 모두 자동으로 적용된다.
테마를 바꾸는 순간 이미 떠 있는 창들도 함께 맞춘다(ThemeManager.ApplyChromeToOpenWindows).

■ 목업과 위치 맞춤

앞 배지(아이콘) · 검색칸의 돋보기와 안내 문구 · 안내 상자의 ⓘ 를 넣었다.
갈래 칩 순서는 개수순에서 <b>고정 순서</b>(환자·서식·작업자·컨트롤)로 바꿨다 —
컨트롤 수는 서식마다 달라서 개수순으로 두면 서식을 바꿀 때마다 칩이 자리를 바꿔
손이 기억하지 못한다.

회귀: 테스트 230/230, 편집 스모크 실패 0, 팝업·정렬 점검 9/9, 검증 실행 점검 10/10,
DB 왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 바이트 동일.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-13 16:05:33 +09:00
co-authored by Claude Opus 5
parent 68fc01c6ba
commit 3e26af8739
7 changed files with 224 additions and 94 deletions
@@ -65,13 +65,19 @@
<!-- ── 머리말 ── -->
<StackPanel DockPanel.Dock="Top" Margin="0,0,0,12">
<StackPanel Orientation="Horizontal">
<!-- 목업의 앞 배지 — 무슨 창인지 한눈에 -->
<Border Width="34" Height="34" CornerRadius="9" Margin="0,0,10,0"
Background="{DynamicResource B.AccentFill}">
<TextBlock Text="🗄" FontSize="16" Foreground="{DynamicResource B.OnAccent}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<TextBlock Text="쿼리 편집" FontSize="17" FontWeight="Bold"
Foreground="{DynamicResource B.Ink}" VerticalAlignment="Center"/>
<Border x:Name="TargetChip" CornerRadius="6" Padding="8,2" Margin="10,0,0,0"
VerticalAlignment="Center"
Background="{DynamicResource B.Input}" BorderBrush="{DynamicResource B.InputBorder}"
BorderThickness="1">
<TextBlock x:Name="TargetChipText" FontSize="11.5" FontFamily="Consolas, D2Coding, 굴림체"
<TextBlock x:Name="TargetChipText" FontSize="11.5" FontFamily="D2Coding, 굴림체, Consolas"
Foreground="{DynamicResource B.AccentText}"/>
</Border>
</StackPanel>
@@ -172,7 +178,7 @@
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Background="{DynamicResource B.Surface}" Padding="8,6">
<TextBlock x:Name="LineNumbers" FontFamily="Consolas, D2Coding, 굴림체" FontSize="13"
<TextBlock x:Name="LineNumbers" FontFamily="D2Coding, 굴림체, Consolas" FontSize="13"
Foreground="{DynamicResource B.Muted}" TextAlignment="Right" MinWidth="24"/>
</Border>
<Grid Grid.Column="1">
@@ -192,7 +198,7 @@
<TextBlock DockPanel.Dock="Right" Text="{Binding KindLabel}" FontSize="10"
Foreground="{DynamicResource B.Muted}" Margin="10,0,0,0"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding Text}" FontFamily="Consolas, D2Coding, 굴림체"
<TextBlock Text="{Binding Text}" FontFamily="D2Coding, 굴림체, Consolas"
TextTrimming="CharacterEllipsis"/>
</DockPanel>
</DataTemplate>
@@ -201,7 +207,7 @@
</Border>
</Popup>
<TextBox x:Name="SqlBox"
FontFamily="Consolas, D2Coding, 굴림체" FontSize="13"
FontFamily="D2Coding, 굴림체, Consolas" FontSize="13"
AcceptsReturn="True" AcceptsTab="False"
TextWrapping="NoWrap" Padding="8,6"
Background="Transparent" BorderThickness="0"
@@ -226,10 +232,20 @@
<TextBlock DockPanel.Dock="Top" Text="치환 변수" Style="{StaticResource CardTitle}"
Margin="0,0,0,10"/>
<TextBox x:Name="VariableSearch" DockPanel.Dock="Top" Margin="0,0,0,8" Padding="8,4"
VerticalContentAlignment="Center" TextChanged="OnVariableSearchChanged"
AutomationProperties.Name="치환 변수 검색"
ToolTip="변수명·설명·초성으로 찾습니다 (예: ㅊㅌㅂㅎ → 차트번호)"/>
<!-- 검색: 돋보기를 겹쳐 둔다 — 빈 상자만 있으면 무엇을 하는 칸인지 안 읽힌다 -->
<Grid DockPanel.Dock="Top" Margin="0,0,0,8">
<TextBox x:Name="VariableSearch" Padding="28,4,8,4"
VerticalContentAlignment="Center" TextChanged="OnVariableSearchChanged"
AutomationProperties.Name="치환 변수 검색"
ToolTip="변수명·설명·초성으로 찾습니다 (예: ㅊㅌㅂㅎ → 차트번호)"/>
<TextBlock Text="🔍" FontSize="11" Margin="9,0,0,0" IsHitTestVisible="False"
HorizontalAlignment="Left" VerticalAlignment="Center"
Foreground="{DynamicResource B.Muted}"/>
<TextBlock x:Name="SearchPlaceholder" Text="변수명 또는 설명 검색" FontSize="11.5"
Margin="30,0,0,0" IsHitTestVisible="False"
HorizontalAlignment="Left" VerticalAlignment="Center"
Foreground="{DynamicResource B.Muted}"/>
</Grid>
<!-- 갈래 칩 -->
<ItemsControl x:Name="BucketChips" DockPanel.Dock="Top" Margin="0,0,0,8">
@@ -253,8 +269,13 @@
<!-- 안내 -->
<Border DockPanel.Dock="Bottom" Margin="0,8,0,0" Padding="10,7" CornerRadius="8"
Background="{DynamicResource B.Input}">
<TextBlock x:Name="VariableHint" FontSize="10.5" TextWrapping="Wrap"
Foreground="{DynamicResource B.Muted}"/>
<DockPanel>
<TextBlock DockPanel.Dock="Left" Text="ⓘ" FontSize="11" Margin="0,0,7,0"
VerticalAlignment="Top"
Foreground="{DynamicResource B.AccentText}"/>
<TextBlock x:Name="VariableHint" FontSize="10.5" TextWrapping="Wrap"
Foreground="{DynamicResource B.Muted}"/>
</DockPanel>
</Border>
<ListBox x:Name="VariableList" FontSize="11.5" MouseDoubleClick="OnInsertVariable"
@@ -277,7 +298,7 @@
ToolTip="커서 위치에 삽입"/>
<TextBlock DockPanel.Dock="Right" Text="{Binding ShortForm}"
Foreground="{DynamicResource B.Muted}" FontSize="10.5"
FontFamily="Consolas, D2Coding, 굴림체"
FontFamily="D2Coding, 굴림체, Consolas"
VerticalAlignment="Center" Margin="8,0,0,0"/>
<TextBlock Text="{Binding Description}" FontFamily="Segoe UI, 맑은 고딕" FontSize="12"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
@@ -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);
}
/// <summary>카탈로그 + 이 서식의 컨트롤 — 컨트롤은 문서마다 달라 여기서 합친다</summary>
private IReadOnlyList<QueryVariable> 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;
}