엔터를 꾹 누르면 캐럿 줄과 LOCAL 칸 줄이 벌어졌다 - 캐럿은 글에서 센 줄 자리로 두고, 메아리 막기 중에도 LOCAL 칸은 편집기 스크롤을 따라가게 했다. Ctrl+휠로 키우면 칸 위가 비고, 내리면 BASE 가 다른 줄을 봤다. 칸 목록은 가상화라 안 그린 줄 높이를 어림으로 세어(1,806줄 847행: 편집기 28,896 · 목록 27,940) 같은 픽셀이 다른 줄을 가리키는데, 이 차이를 네 칸 공통 이동값으로 메우다 467px 까지 커졌다. 두 줄 넘게 어긋나면 LOCAL 목록을 실제로 굴리고 차이를 칸별 보정(_paneBias)으로 기억한다. BASE · REMOTE 는 LOCAL 가운데 줄과 같은 줄끼리 맞춘다(AlignOtherPanesByRow) - 한쪽에만 있는 줄 묶음 안은 맞추지 않는다(짝을 억지로 불러 초당 600번 되풀이하며 타이핑을 밀어내던 것을 기록으로 잡았다). 맞추기 기준은 편집기에 보이는 첫 줄. 편집기에 묻는 값 둘이 틀려 (GetFirstVisibleLineIndex 886 -> 1033, 글자 크기 바꾼 뒤 GetRectFromCharacterIndex 가 몇 초씩 옛 배치로 1,892px 틀림) 줄 바꿈이 꺼져 있으면 고정 줄 높이로 계산한다. UIA 로 읽은 실제 첫 줄과 대조했다. 시험: 빌드 경고 0 · 오류 0. 화면 g17-hold-enter - 엔터 40번 뒤 LOCAL 칸 번호 = 편집기 줄(936) · 같은 높이, 확대 뒤 · 30칸 내린 뒤 LOCAL = BASE, 칸 위 비지 않음. 꾹 누름 흉내에서 40번 중 30~34번만 들어가는 것은 고치기 전 커밋도 같고 따로 누르면 40번 다 들어간다(Windows 가 반복 키를 합침). 기존 회귀는 사용자 지시(방금 요청한 것만)로 돌리지 않았다. Agent: main (claude-opus-5-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1811 lines
92 KiB
C#
1811 lines
92 KiB
C#
using System;
|
|
using System.Windows;
|
|
using System.Windows.Controls;
|
|
using System.Windows.Input;
|
|
using System.Windows.Interop;
|
|
using System.Windows.Media;
|
|
using System.Windows.Threading;
|
|
using MergeHub.Models;
|
|
using MergeHub.Services;
|
|
using UbisamBase.Core.Messaging;
|
|
using MergeHub.ViewModels;
|
|
|
|
namespace MergeHub.Views
|
|
{
|
|
/// <summary>
|
|
/// 홈 = 머지툴. DataContext(HomeViewModel)는 플랫폼 TabManager 가 DI 로 만들어 붙여준다.
|
|
///
|
|
/// LOCAL 패널은 세 겹이다 - 왼쪽 여백(줄 번호·[>]), 색과 문법을 그리는 표시층,
|
|
/// 그 위에 겹친 편집기. 세 겹이 같은 줄을 같은 눈높이에 보여줘야 하므로
|
|
/// 스크롤은 편집기가 주인이고 나머지가 따라간다.
|
|
/// </summary>
|
|
public partial class HomeView : UserControl
|
|
{
|
|
/// <summary>
|
|
/// 스크롤 맞추기가 진행 중인지. <b>한 번 켜지면 이번 일로 밀려서 오는 스크롤 이벤트가
|
|
/// 다 지나간 뒤에 풀린다.</b>
|
|
///
|
|
/// try/finally 로 곧바로 풀면 안 된다 - 목록(ListBox)의 ScrollChanged 는 배치가 끝난
|
|
/// <b>다음</b>에 오므로 그때는 이미 풀려 있고, 그 메아리가 다시 편집기를 굴린다.
|
|
/// 워드랩을 켜면 두 좌표계(편집기의 보이는 줄 ↔ 원본 칸의 행)의 환산이 딱 맞아떨어지지
|
|
/// 않아 그 메아리가 조금씩 어긋나고, 서로 밀어내다가 한 자리에 눌러앉는다 -
|
|
/// 3-Way 에서 워드랩을 켜면 세로 스크롤이 아예 안 되던 증상이 이것이다.
|
|
/// </summary>
|
|
private int _syncHold;
|
|
|
|
private bool Syncing => _syncHold > 0;
|
|
|
|
/// <summary>이번 스크롤 맞추기가 끝날 때까지 메아리를 막는다.</summary>
|
|
private void HoldSync()
|
|
{
|
|
_syncHold++;
|
|
|
|
// Input 은 배치(Render/Loaded)보다 늦게 돈다. 목록이 스크롤 이벤트를 다 뱉은 뒤에 풀린다.
|
|
Dispatcher.BeginInvoke(new Action(() =>
|
|
{
|
|
if (_syncHold > 0) _syncHold--;
|
|
}), DispatcherPriority.Input);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 아래층 글자를 가로로 미는 양. 편집기가 가로로 스크롤한 만큼 음수로 들어간다.
|
|
/// 여백(줄번호·[>])은 밀지 않고 글자만 밀어야 해서 이렇게 따로 둔다.
|
|
/// </summary>
|
|
public static readonly DependencyProperty TextShiftXProperty =
|
|
DependencyProperty.Register(nameof(TextShiftX), typeof(double), typeof(HomeView),
|
|
new PropertyMetadata(0.0));
|
|
|
|
public double TextShiftX
|
|
{
|
|
get => (double)GetValue(TextShiftXProperty);
|
|
set => SetValue(TextShiftXProperty, value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 위쪽 원본 패널(BASE·REMOTE·LOCAL 원본)의 글자를 가로로 미는 양.
|
|
///
|
|
/// <see cref="TextShiftX"/> 와 따로 두는 이유 - 3-Way 에서는 위 세 칸과 아래 MERGED 의
|
|
/// <b>폭이 다르다</b>. 같은 값을 쓰면 넓은 MERGED 가 다 보여서 못 미는 만큼
|
|
/// 좁은 위 칸들도 못 밀어, 정작 잘려 보이는 쪽이 움직이지 않는다.
|
|
/// 2-Way 에서는 BASE 와 LOCAL 이 같은 폭이라 이 값이 <see cref="TextShiftX"/> 를 그대로 따라간다.
|
|
/// </summary>
|
|
public static readonly DependencyProperty SourceShiftXProperty =
|
|
DependencyProperty.Register(nameof(SourceShiftX), typeof(double), typeof(HomeView),
|
|
new PropertyMetadata(0.0, OnSourceShiftXChanged));
|
|
|
|
public double SourceShiftX
|
|
{
|
|
get => (double)GetValue(SourceShiftXProperty);
|
|
set => SetValue(SourceShiftXProperty, value);
|
|
}
|
|
|
|
/// <summary>편집기와 아래층이 똑같이 써야 하는 줄바꿈 방식.</summary>
|
|
public static readonly DependencyProperty WrapModeProperty =
|
|
DependencyProperty.Register(nameof(WrapMode), typeof(TextWrapping), typeof(HomeView),
|
|
new PropertyMetadata(TextWrapping.NoWrap));
|
|
|
|
public TextWrapping WrapMode
|
|
{
|
|
get => (TextWrapping)GetValue(WrapModeProperty);
|
|
set => SetValue(WrapModeProperty, value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 위쪽 원본 세 칸의 가로 스크롤 설정.
|
|
///
|
|
/// 워드랩을 끄면 <c>Hidden</c> - 막대는 안 보이되 <b>굴릴 수 있는 상태여야</b>
|
|
/// 얼마나 더 갈 수 있는지(ExtentWidth)를 잴 수 있고, 그 값으로 <see cref="SourceShiftX"/> 를 묶는다.
|
|
/// 워드랩을 켜면 <c>Disabled</c> - 이래야 칸 너비가 정해져 글이 접힌다.
|
|
/// <c>Hidden</c> 인 채로 두면 너비가 무한으로 잡혀 <b>아무리 켜도 접히지 않는다.</b>
|
|
/// </summary>
|
|
public static readonly DependencyProperty SourceBarsProperty =
|
|
DependencyProperty.Register(nameof(SourceBars), typeof(ScrollBarVisibility), typeof(HomeView),
|
|
new PropertyMetadata(ScrollBarVisibility.Hidden));
|
|
|
|
public ScrollBarVisibility SourceBars
|
|
{
|
|
get => (ScrollBarVisibility)GetValue(SourceBarsProperty);
|
|
set => SetValue(SourceBarsProperty, value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 원본 세 칸 아래에 붙는 <b>진짜</b> 가로 스크롤 막대의 값.
|
|
///
|
|
/// 왜 따로 두나 — 원본 칸은 ScrollViewer 를 가로로 굴리지 않는다(줄 번호까지 밀려 나가고
|
|
/// 세 칸이 어긋난다). 대신 글자만 <see cref="SourceShiftX"/> 로 민다. 그래서 ScrollViewer 가
|
|
/// 만들어 주는 막대는 <b>움직이지 않는 막대</b>가 되어 쓸 수 없다.
|
|
/// 막대를 따로 놓고 이 값과 <see cref="SourceShiftX"/> 를 서로 맞춘다.
|
|
/// </summary>
|
|
public static readonly DependencyProperty SourceScrollValueProperty =
|
|
DependencyProperty.Register(nameof(SourceScrollValue), typeof(double), typeof(HomeView),
|
|
new PropertyMetadata(0.0, OnSourceScrollValueChanged));
|
|
|
|
public double SourceScrollValue
|
|
{
|
|
get => (double)GetValue(SourceScrollValueProperty);
|
|
set => SetValue(SourceScrollValueProperty, value);
|
|
}
|
|
|
|
/// <summary>원본 칸에서 아직 못 보고 있는 가로 폭. 막대의 Maximum.</summary>
|
|
public static readonly DependencyProperty SourceScrollMaxProperty =
|
|
DependencyProperty.Register(nameof(SourceScrollMax), typeof(double), typeof(HomeView),
|
|
new PropertyMetadata(0.0));
|
|
|
|
public double SourceScrollMax
|
|
{
|
|
get => (double)GetValue(SourceScrollMaxProperty);
|
|
set => SetValue(SourceScrollMaxProperty, value);
|
|
}
|
|
|
|
/// <summary>지금 보이는 폭. 막대 손잡이의 크기를 정한다 - 없으면 손잡이가 점처럼 작아진다.</summary>
|
|
public static readonly DependencyProperty SourceViewportProperty =
|
|
DependencyProperty.Register(nameof(SourceViewport), typeof(double), typeof(HomeView),
|
|
new PropertyMetadata(0.0));
|
|
|
|
public double SourceViewport
|
|
{
|
|
get => (double)GetValue(SourceViewportProperty);
|
|
set => SetValue(SourceViewportProperty, value);
|
|
}
|
|
|
|
/// <summary>원본 칸 막대를 보일지. 3-Way 이고, 워드랩이 꺼져 있고, 실제로 밀 곳이 있을 때만.</summary>
|
|
public static readonly DependencyProperty SourceBarVisibilityProperty =
|
|
DependencyProperty.Register(nameof(SourceBarVisibility), typeof(Visibility), typeof(HomeView),
|
|
new PropertyMetadata(Visibility.Collapsed));
|
|
|
|
public Visibility SourceBarVisibility
|
|
{
|
|
get => (Visibility)GetValue(SourceBarVisibilityProperty);
|
|
set => SetValue(SourceBarVisibilityProperty, value);
|
|
}
|
|
|
|
/// <summary>막대와 <see cref="SourceShiftX"/> 가 서로를 다시 부르지 않게 막는다.</summary>
|
|
private bool _syncingSourceBar;
|
|
|
|
private static void OnSourceScrollValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
|
{
|
|
if (d is not HomeView view || view._syncingSourceBar) return;
|
|
|
|
view._syncingSourceBar = true;
|
|
try
|
|
{
|
|
view.SourceShiftX = -(double)e.NewValue;
|
|
}
|
|
finally
|
|
{
|
|
view._syncingSourceBar = false;
|
|
}
|
|
}
|
|
|
|
private static void OnSourceShiftXChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
|
{
|
|
if (d is not HomeView view || view._syncingSourceBar) return;
|
|
|
|
view._syncingSourceBar = true;
|
|
try
|
|
{
|
|
view.SourceScrollValue = -(double)e.NewValue;
|
|
}
|
|
finally
|
|
{
|
|
view._syncingSourceBar = false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 원본 칸 막대의 범위를 다시 잰다. 글이 바뀌거나 칸 폭이 달라지면 밀 수 있는 폭도 달라진다.
|
|
/// 지금 밀어 둔 값이 새 범위를 넘어서면 끝으로 당겨 둔다 - 안 그러면 빈 곳을 보게 된다.
|
|
/// </summary>
|
|
private void UpdateSourceScrollRange()
|
|
{
|
|
var threeWay = DataContext is HomeViewModel { IsThreeWay: true };
|
|
var room = threeWay && WrapMode == TextWrapping.NoWrap ? SourceScrollRoom() : 0;
|
|
|
|
SourceScrollMax = room;
|
|
SourceViewport = SourceViewportWidth();
|
|
SourceBarVisibility = room > 0.5 ? Visibility.Visible : Visibility.Collapsed;
|
|
|
|
if (-SourceShiftX > room) SourceShiftX = -room;
|
|
}
|
|
|
|
/// <summary>원본 칸에서 지금 보이는 폭.</summary>
|
|
private double SourceViewportWidth()
|
|
{
|
|
foreach (var pane in new[] { BaseLines, RemoteLines, OriginalLocalLines })
|
|
{
|
|
if (FindScrollViewer(pane) is { } viewer && viewer.ViewportWidth > 0) return viewer.ViewportWidth;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/// <summary>워드랩을 켜면 가로 스크롤이 필요 없다.</summary>
|
|
public static readonly DependencyProperty HorizontalBarsProperty =
|
|
DependencyProperty.Register(nameof(HorizontalBars), typeof(ScrollBarVisibility), typeof(HomeView),
|
|
new PropertyMetadata(ScrollBarVisibility.Auto));
|
|
|
|
public ScrollBarVisibility HorizontalBars
|
|
{
|
|
get => (ScrollBarVisibility)GetValue(HorizontalBarsProperty);
|
|
set => SetValue(HorizontalBarsProperty, value);
|
|
}
|
|
|
|
/// <summary>ViewModel 이 글을 갈아 끼우는 중. 사용자의 편집과 구분하려고 둔다.</summary>
|
|
private bool _replacingText;
|
|
|
|
public HomeView()
|
|
{
|
|
InitializeComponent();
|
|
|
|
DataContextChanged += OnDataContextChanged;
|
|
|
|
// Ctrl+휠로 코드 글자 크기(11차 F-5). 휠 한 칸에 1씩. 목록 · 편집기의 스크롤보다 먼저 가로챈다.
|
|
PreviewMouseWheel += (_, e) =>
|
|
{
|
|
if (Keyboard.Modifiers != ModifierKeys.Control || DataContext is not HomeViewModel vm) return;
|
|
var command = e.Delta > 0 ? vm.FontBiggerCommand : vm.FontSmallerCommand;
|
|
if (command.CanExecute(null)) command.Execute(null);
|
|
e.Handled = true;
|
|
};
|
|
|
|
// 설정 화면에서 비교 규칙을 고치고 돌아오면 그때 다시 비교한다.
|
|
IsVisibleChanged += (_, e) =>
|
|
{
|
|
if (e.NewValue is true && DataContext is HomeViewModel vm) vm.RecompareIfSettingsChanged();
|
|
if (e.NewValue is true) FocusEditorWhenIdle();
|
|
};
|
|
// 두 패널은 같은 이동값을 쓴다. LOCAL 만 밀면 BASE 와 줄이 어긋나 보인다 -
|
|
// 나란히 놓고 보는 도구에서 이건 곧바로 눈에 띈다.
|
|
LocalLines.RenderTransform = _decorationShift;
|
|
BaseLines.RenderTransform = _decorationShift;
|
|
RemoteLines.RenderTransform = _decorationShift;
|
|
OriginalLocalLines.RenderTransform = _decorationShift;
|
|
|
|
foreach (var pane in new[] { LocalLines, BaseLines, RemoteLines, OriginalLocalLines })
|
|
BlockSelfScroll(pane);
|
|
|
|
LocalEditor.Loaded += (_, _) =>
|
|
{
|
|
HookEditorScroll();
|
|
MatchTextWidths();
|
|
};
|
|
|
|
// 글꼴·배율·창 크기가 바뀌면 다시 재서 맞춘다.
|
|
LayoutUpdated += (_, _) => AlignDecorationToEditor();
|
|
|
|
Loaded += (_, _) =>
|
|
{
|
|
HookHorizontalWheel();
|
|
FocusEditorWhenIdle();
|
|
|
|
// 켤 때는 사용자 이름 창이 먼저 떠 본 창이 늦게 활성이 된다. 그때도 한 번 더 본다.
|
|
if (Window.GetWindow(this) is { } window && !_watchingActivation)
|
|
{
|
|
_watchingActivation = true;
|
|
window.Activated += (_, _) => { if (IsVisible) FocusEditorWhenIdle(); };
|
|
}
|
|
};
|
|
Unloaded += (_, _) => UnhookHorizontalWheel();
|
|
}
|
|
|
|
// ================= 가로 휠 =================
|
|
|
|
private HwndSource? _wheelSource;
|
|
|
|
/// <summary>기울임 휠(가로 스크롤) 메시지. WPF 는 이 메시지를 라우팅해 주지 않는다.</summary>
|
|
private const int WM_MOUSEHWHEEL = 0x020E;
|
|
|
|
/// <summary>
|
|
/// 마우스 가로 휠을 직접 받아 코드 패널을 옆으로 굴린다.
|
|
///
|
|
/// WPF 에는 가로 휠 이벤트가 아예 없다 - <c>MouseWheel</c> 은 세로뿐이라, 창 메시지를
|
|
/// 직접 받아야 한다. 기울임 휠이 달린 마우스에서 "가로 휠이 안 먹는" 이유가 이것이다.
|
|
/// </summary>
|
|
private void HookHorizontalWheel()
|
|
{
|
|
if (_wheelSource is not null) return;
|
|
if (PresentationSource.FromVisual(this) is not HwndSource source) return;
|
|
|
|
_wheelSource = source;
|
|
source.AddHook(OnWindowMessage);
|
|
}
|
|
|
|
private void UnhookHorizontalWheel()
|
|
{
|
|
_wheelSource?.RemoveHook(OnWindowMessage);
|
|
_wheelSource = null;
|
|
}
|
|
|
|
private IntPtr OnWindowMessage(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
|
|
{
|
|
if (msg != WM_MOUSEHWHEEL) return IntPtr.Zero;
|
|
if (!IsVisible) return IntPtr.Zero;
|
|
|
|
// wParam 상위 16비트가 굴린 양이다. 오른쪽으로 굴리면 양수.
|
|
var delta = (short)((wParam.ToInt64() >> 16) & 0xFFFF);
|
|
if (delta == 0) return IntPtr.Zero;
|
|
|
|
// 세로 휠 한 칸이 세 줄인 것과 같은 감각으로 맞춘다.
|
|
const double StepPerNotch = 48;
|
|
var step = delta / 120.0 * StepPerNotch;
|
|
|
|
// 마우스가 놓인 칸을 민다. 3-Way 에서는 위 원본 세 칸과 아래 MERGED 의 폭이 달라
|
|
// 한 덩어리로 묶을 수 없다 - 가리키는 쪽이 움직이는 편이 손에 맞는다.
|
|
if (ScrollSourcePanes(step) || ScrollEditor(step)) handled = true;
|
|
|
|
return IntPtr.Zero;
|
|
}
|
|
|
|
/// <summary>MERGED(=2-Way 의 LOCAL) 편집기를 옆으로 굴린다. 아래층·BASE 는 스크롤 이벤트가 따라온다.</summary>
|
|
private bool ScrollEditor(double step)
|
|
{
|
|
if (FindScrollViewer(LocalEditor) is not { } viewer) return false;
|
|
if (viewer.ScrollableWidth <= 0) return false;
|
|
|
|
var offset = viewer.HorizontalOffset + step;
|
|
viewer.ScrollToHorizontalOffset(Math.Max(0, Math.Min(offset, viewer.ScrollableWidth)));
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 3-Way 의 위 원본 세 칸을 한꺼번에 민다. 셋은 폭도 행도 같으므로 늘 같이 움직여야 한다.
|
|
/// 마우스가 그 위에 있을 때만 잡는다.
|
|
/// </summary>
|
|
private bool ScrollSourcePanes(double step)
|
|
{
|
|
if (DataContext is not HomeViewModel { IsThreeWay: true }) return false;
|
|
if (!IsPointerOver(BaseLines) && !IsPointerOver(RemoteLines) && !IsPointerOver(OriginalLocalLines))
|
|
return false;
|
|
|
|
var room = SourceScrollRoom();
|
|
if (room <= 0) return false;
|
|
|
|
SourceShiftX = Math.Max(-room, Math.Min(0, SourceShiftX - step));
|
|
return true;
|
|
}
|
|
|
|
/// <summary>원본 칸에서 아직 못 보고 있는 가로 폭. 셋 중 가장 긴 줄을 기준으로 삼는다.</summary>
|
|
private double SourceScrollRoom()
|
|
{
|
|
var room = 0.0;
|
|
foreach (var pane in new[] { BaseLines, RemoteLines, OriginalLocalLines })
|
|
{
|
|
if (FindScrollViewer(pane) is not { } viewer) continue;
|
|
room = Math.Max(room, viewer.ExtentWidth - viewer.ViewportWidth);
|
|
}
|
|
return room;
|
|
}
|
|
|
|
private static bool IsPointerOver(FrameworkElement element)
|
|
{
|
|
if (!element.IsVisible || element.ActualWidth <= 0) return false;
|
|
|
|
var p = Mouse.GetPosition(element);
|
|
return p.X >= 0 && p.Y >= 0 && p.X <= element.ActualWidth && p.Y <= element.ActualHeight;
|
|
}
|
|
|
|
private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
|
|
{
|
|
if (e.OldValue is HomeViewModel old)
|
|
{
|
|
old.EditorTextReplaced -= OnEditorTextReplaced;
|
|
old.ScrollToRowRequested -= OnScrollToRowRequested;
|
|
old.CaretIndexRequested -= OnCaretIndexRequested;
|
|
old.PropertyChanged -= OnViewModelPropertyChanged;
|
|
old.ReadEditorText = null;
|
|
old.ReadCaretIndex = null;
|
|
BindPaneViews(null);
|
|
}
|
|
|
|
if (e.NewValue is HomeViewModel now)
|
|
{
|
|
now.EditorTextReplaced += OnEditorTextReplaced;
|
|
now.ScrollToRowRequested += OnScrollToRowRequested;
|
|
now.CaretIndexRequested += OnCaretIndexRequested;
|
|
now.PropertyChanged += OnViewModelPropertyChanged;
|
|
ApplyActionsHidden(now.IsActionsHidden);
|
|
ApplyCodeFont(now.CodeFontSize);
|
|
|
|
// 편집기 글은 이 컨트롤이 들고 있고, ViewModel 은 필요할 때만 읽어 간다.
|
|
// 바인딩으로 매 글자마다 문서 전체를 넘기면 큰 파일에서 눈에 띄게 느리다.
|
|
now.ReadEditorText = () => LocalEditor.Text;
|
|
|
|
// 탭을 옮길 때 캐럿 자리를 맡겼다 되살리는 데 쓴다(12차 E-2).
|
|
now.ReadCaretIndex = () => LocalEditor.CaretIndex;
|
|
|
|
// 칸마다 자기 줄만(11차 F-10)
|
|
BindPaneViews(now);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 문서 탭을 <b>가운데 단추로</b> 누르면 닫는다(12차 E-2) - 브라우저 · notepad++ 와 같은 손버릇이다.
|
|
///
|
|
/// 왼쪽 단추는 손대지 않는다. 탭 줄이 ListBox 라 고르기가 곧 탭 옮기기이고,
|
|
/// 여기서 <c>Handled</c> 로 먹어 버리면 그 고르기가 일어나지 않는다.
|
|
/// </summary>
|
|
private void DocTab_MouseDown(object sender, MouseButtonEventArgs e)
|
|
{
|
|
if (e.ChangedButton != MouseButton.Middle) return;
|
|
if (DataContext is not HomeViewModel vm) return;
|
|
if (sender is not FrameworkElement element || element.DataContext is not DocTab tab) return;
|
|
|
|
if (vm.CloseTabCommand.CanExecute(tab)) vm.CloseTabCommand.Execute(tab);
|
|
e.Handled = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 탭에 맡겨 둔 캐럿 자리로 돌아간다(12차 E-2).
|
|
///
|
|
/// 글을 다 실은 <b>뒤</b>에 옮겨야 한다 - 배치가 끝나기 전에 옮기면 화면이 따라오지 않아
|
|
/// 캐럿만 저 아래에 있고 보이는 곳은 맨 위가 된다. 그래서 다음 차례로 미룬다.
|
|
/// </summary>
|
|
private void OnCaretIndexRequested(object? sender, int caretIndex)
|
|
{
|
|
Dispatcher.BeginInvoke(new Action(() =>
|
|
{
|
|
var at = Math.Max(0, Math.Min(caretIndex, LocalEditor.Text.Length));
|
|
LocalEditor.CaretIndex = at;
|
|
LocalEditor.ScrollToLine(LocalEditor.GetLineIndexFromCharacterIndex(at));
|
|
}), DispatcherPriority.Loaded);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 2-Way ↔ 3-Way 로 바뀌면 패널 배치가 통째로 달라진다.
|
|
/// 워드랩의 접히는 자리를 정하는 글자 폭 보정도 그때 다시 잡아야 한다.
|
|
/// </summary>
|
|
private void OnViewModelPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
|
{
|
|
if (e.PropertyName == nameof(HomeViewModel.CodeFontSize) && sender is HomeViewModel fontVm)
|
|
{
|
|
ApplyCodeFont(fontVm.CodeFontSize);
|
|
return;
|
|
}
|
|
|
|
if (e.PropertyName == nameof(HomeViewModel.IsActionsHidden) && sender is HomeViewModel hideVm)
|
|
{
|
|
ApplyActionsHidden(hideVm.IsActionsHidden);
|
|
return;
|
|
}
|
|
|
|
if (e.PropertyName == nameof(HomeViewModel.DiffVersion))
|
|
{
|
|
ScheduleResync(); // 칸마다 높이가 달라졌다 - 다시 맞추고 선을 다시 그린다(11차 F-10)
|
|
return;
|
|
}
|
|
|
|
// 두 파일 비교 ↔ Git 을 오가면 [<] 단추가 생기고 사라진다 - 잡아 둘 자리도 함께 바뀐다.
|
|
if (e.PropertyName == nameof(HomeViewModel.BothWays))
|
|
{
|
|
ApplyGutterReserve();
|
|
ScheduleResync();
|
|
return;
|
|
}
|
|
|
|
if (e.PropertyName != nameof(HomeViewModel.IsThreeWay)) return;
|
|
ScheduleResync();
|
|
|
|
// 배치가 통째로 달라지므로 원본 칸의 가로 위치는 처음으로 되돌린다.
|
|
SourceShiftX = 0;
|
|
|
|
// 사용자가 구분선을 끌어 놓은 칸 폭은 여기서 놓아준다. 3-Way 의 세 칸과
|
|
// 2-Way 의 두 칸은 애초에 다른 배치라 그대로 물려받을 것이 없고,
|
|
// 특히 REMOTE 칸은 폭을 Style 이 정하는데 끌어서 생긴 값이 남아 있으면
|
|
// <b>2-Way 로 돌아와도 접히지 않아 오른쪽에 빈 칸이 남는다.</b>
|
|
foreach (var column in PaneColumns) column.ClearValue(ColumnDefinition.WidthProperty);
|
|
|
|
// 열 폭이 실제로 다시 잡힌 뒤에 재야 한다.
|
|
Dispatcher.BeginInvoke(new Action(MatchTextWidths), DispatcherPriority.Loaded);
|
|
}
|
|
|
|
/// <summary>편집기 스크롤 감시를 이미 걸었는지. Loaded 는 탭을 오갈 때마다 다시 온다 -
|
|
/// 그때마다 또 걸면 스크롤 한 번에 맞추기가 두 번 돌아 메아리가 두 배가 된다.</summary>
|
|
private bool _editorScrollHooked;
|
|
|
|
/// <summary>
|
|
/// 편집기가 스크롤하면 나머지 두 겹과 BASE 패널을 같이 움직인다.
|
|
/// 편집기 안쪽 ScrollViewer 는 템플릿이 만들어 주므로 Loaded 이후에만 잡을 수 있다.
|
|
/// </summary>
|
|
private void HookEditorScroll()
|
|
{
|
|
if (_editorScrollHooked) return;
|
|
if (FindScrollViewer(LocalEditor) is not { } viewer) return;
|
|
|
|
_editorScrollHooked = true;
|
|
|
|
viewer.ScrollChanged += (_, e) =>
|
|
{
|
|
if (e.VerticalChange != 0 || e.ViewportHeightChange != 0) LinkOverlay.InvalidateVisual();
|
|
if (e.VerticalChange == 0 && e.HorizontalChange == 0) return;
|
|
|
|
// LOCAL 표시층(아래층 목록)과 글자 가로 밀기는 <b>메아리 막기 중에도</b> 편집기를 따라간다(2026-09-30 사용자 보고).
|
|
// 엔터를 꾹 누르면 재정렬마다 칸 다시 맞추기가 막기를 걸어 두는데, 그 사이 편집기가 캐럿을 들이려고 구른 것이
|
|
// 메아리로 버려져 아래층이 몇 줄 뒤처졌다(캐럿은 929행, 아래층 글은 926행 자리) - G-7 과 같은 고리.
|
|
// 이 목록은 굴러도 편집기를 되움직이지 않으므로(Lines_ScrollChanged 가 LocalLines 는 받지 않는다) 늘 맞춰도 메아리가 없다.
|
|
// 가로는 네 패널 모두 "글자만" 민다(TextShiftX) - 여백(줄번호·[>])은 제자리에 있어야 한다.
|
|
// 표시층 ListBox 를 가로로 굴리는 방법은 못 쓴다 - 행이 칸 너비에 맞춰 늘어나
|
|
// 굴릴 여지(ScrollableWidth)가 0 이라 아무 일도 일어나지 않는다.
|
|
Scroll(LocalLines, e.VerticalOffset + Bias(LocalLines), null);
|
|
TextShiftX = -e.HorizontalOffset;
|
|
|
|
// 2-Way 는 BASE 와 LOCAL 의 폭이 같으니 한 덩어리로 움직인다(가로 글자 밀기 - 되움직임 없음).
|
|
// 3-Way 의 위 세 칸은 폭이 달라 따로 움직이므로 여기서 건드리지 않는다.
|
|
if (DataContext is not HomeViewModel { IsThreeWay: true }) SourceShiftX = TextShiftX;
|
|
|
|
if (Syncing) return;
|
|
HoldSync();
|
|
|
|
// 세로는 BASE·REMOTE 가 가운데를 맞춰 같이 굴러간다 - 이쪽은 굴리면 되돌아오는 칸이라 메아리를 막는다.
|
|
if (e.VerticalChange != 0) SyncPanesFrom(MergeLine.TextPane.Local, e.VerticalOffset, e.ViewportHeight);
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// 나란히 놓인 패널들의 <b>글자 폭</b>을 똑같이 맞춘다.
|
|
///
|
|
/// 워드랩은 폭이 1px 만 달라도 접히는 자리가 달라져 행이 어긋난다.
|
|
/// 편집기에는 세로 스크롤 막대가 늘 붙어 있고 그 칸 오른쪽에는 미니맵이 있으므로,
|
|
/// <summary>칸 폭을 사용자가 끌어 놓을 수 있는 세 칸.</summary>
|
|
private ColumnDefinition[] PaneColumns => new[] { LocalColumn, BaseColumn, RemoteColumn };
|
|
|
|
/// <summary>
|
|
/// 코드 칸 글자 크기를 화면에 건다(11차 F-5). 글자 · 줄 높이 · 줄 번호 칸 · 여백 단추 · 편집기 여백을
|
|
/// <see cref="CodeFontMetrics"/> 한 벌로 바꾼다 - 넷 가운데 하나라도 따로 놀면 행이 어긋난다.
|
|
/// 행 높이의 하한(<see cref="MergeLine.MinimumRowHeight"/>)도 같이 옮겨야 글자를 줄였을 때 행이 준다.
|
|
/// </summary>
|
|
/// <summary>마지막으로 건 크기 한 벌. 모드가 바뀔 때 여백만 다시 잡으려고 들고 있는다.</summary>
|
|
private CodeFontMetrics _metrics = CodeFontMetrics.For(CodeFontMetrics.Default);
|
|
|
|
/// <summary>
|
|
/// 두 파일 비교에서만 나오는 <c>[<]</c> 단추 자리를 <b>줄마다 늘 잡아 둔다</b>(2026-09-28 사용자 보고).
|
|
///
|
|
/// 그 칸을 <c>Auto</c> 로 두었더니 단추가 보이는 줄에서만 칸이 벌어져, <b>같은 칸 안에서도
|
|
/// 줄마다 글이 시작하는 자리가 달랐다</b> - 화면에서는 03 · 04 줄만 오른쪽으로 밀려 보였다.
|
|
/// 모드에 따라 0 ↔ 단추 폭으로 갈아 끼우면, 한 모드 안에서는 모든 줄이 같은 자리에서 시작한다.
|
|
/// Git 모드는 왼쪽이 지난 커밋이라 이 단추가 아예 없으므로 0 - 그때는 여백이 예전 그대로다.
|
|
///
|
|
/// <para>편집기 여백(<c>EditorGutterMargin</c>)도 같은 값만큼 밀어야 한다 - 투명한 편집기와
|
|
/// 아래 표시층이 어긋나면 캐럿이 글자와 다른 자리에 선다.</para>
|
|
/// </summary>
|
|
private void ApplyGutterReserve()
|
|
{
|
|
var reserve = (DataContext as HomeViewModel)?.BothWays == true ? _metrics.ButtonWidth : 0;
|
|
|
|
Resources["GutterBothWaysWidth"] = new GridLength(reserve);
|
|
Resources["EditorGutterMargin"] = new Thickness(_metrics.GutterWidth + reserve, 0, 0, 0);
|
|
}
|
|
|
|
private void ApplyCodeFont(double size)
|
|
{
|
|
var m = CodeFontMetrics.For(size);
|
|
|
|
Resources["CodeFontSize"] = m.FontSize;
|
|
Resources["CodeLineHeight"] = m.LineHeight;
|
|
Resources["GutterWidth"] = new GridLength(m.GutterWidth);
|
|
Resources["GutterNumberWidth"] = new GridLength(m.NumberWidth);
|
|
Resources["GutterNarrowNumberWidth"] = new GridLength(m.NarrowNumberWidth);
|
|
Resources["GutterMarkWidth"] = new GridLength(m.MarkWidth);
|
|
Resources["GutterButtonWidth"] = new GridLength(m.ButtonWidth);
|
|
Resources["GutterButtonHeight"] = m.ButtonHeight;
|
|
Resources["GutterButtonFontSize"] = m.ButtonFontSize;
|
|
Resources["BookmarkHeight"] = m.BookmarkHeight;
|
|
|
|
_metrics = m;
|
|
ApplyGutterReserve();
|
|
|
|
if (MergeLine.MinimumRowHeight != m.LineHeight)
|
|
{
|
|
MergeLine.MinimumRowHeight = m.LineHeight;
|
|
if (DataContext is HomeViewModel vm)
|
|
{
|
|
foreach (var line in vm.Lines) line.RefreshRowHeight();
|
|
}
|
|
}
|
|
|
|
Dispatcher.BeginInvoke(new Action(MatchTextWidths), System.Windows.Threading.DispatcherPriority.Loaded);
|
|
ScheduleResync();
|
|
}
|
|
|
|
private bool _watchingActivation;
|
|
|
|
/// <summary>
|
|
/// 키보드 포커스가 비어 있거나 이 화면 밖(아래 탭 줄 등)에 있으면 편집기로 준다(11차 F-5 에서 찾음).
|
|
///
|
|
/// 켜자마자는 포커스가 아무 데도 없어 단축키(Ctrl+S · F9 · Ctrl++ …)가 코드 칸을 한 번 누르기 전까지
|
|
/// 하나도 듣지 않았다 - 단축키는 이 화면의 PreviewKeyDown 이 받기 때문이다. 탭을 눌러 돌아와도
|
|
/// 포커스가 탭 줄에 남아 같았다.
|
|
/// 이 화면 안(찾기 칸 · 커밋 메시지 칸 등)이나 다른 창(대화상자)에 있는 포커스는 건드리지 않는다.
|
|
/// </summary>
|
|
private void FocusEditorWhenIdle() =>
|
|
Dispatcher.BeginInvoke(new Action(() =>
|
|
{
|
|
if (!IsVisible || !LocalEditor.IsVisible) return;
|
|
|
|
var window = Window.GetWindow(this);
|
|
if (window is null) return;
|
|
|
|
if (Keyboard.FocusedElement is DependencyObject focused)
|
|
{
|
|
if (IsAncestorOf(focused)) return;
|
|
if (!ReferenceEquals(Window.GetWindow(focused), window)) return;
|
|
}
|
|
|
|
// 창이 아직 활성이 아니면(이름 창이 떠 있을 때) 논리 포커스만 정해 둔다 - 활성이 되면 WPF 가 거기로 돌려준다.
|
|
FocusManager.SetFocusedElement(window, LocalEditor);
|
|
if (window.IsActive) LocalEditor.Focus();
|
|
}), DispatcherPriority.Input);
|
|
|
|
/// <summary>감추기 전 ACTIONS 칸 폭. 구분선을 끌어 바꿔 뒀으면 그 폭으로 돌아온다.</summary>
|
|
private GridLength _actionsWidth = new GridLength(214);
|
|
|
|
/// <summary>
|
|
/// 오른쪽 ACTIONS 칸을 감추거나 보인다(11차 F-4). 감추면 칸과 그 앞 구분선의 폭을 0 으로 해
|
|
/// 코드 칸이 그만큼 넓어진다. 폭을 Style 로 쥐지 않는 까닭 - 구분선을 끌면 폭이 로컬 값으로 박혀
|
|
/// Style 이 더는 못 바꾼다. 단추 · 단축키는 명령으로 불리므로 칸이 안 보여도 그대로 듣는다.
|
|
/// </summary>
|
|
private void ApplyActionsHidden(bool hidden)
|
|
{
|
|
if (hidden)
|
|
{
|
|
if (ActionsColumn.ActualWidth > 0) _actionsWidth = new GridLength(ActionsColumn.ActualWidth);
|
|
ActionsColumn.MinWidth = 0;
|
|
ActionsColumn.Width = new GridLength(0);
|
|
ActionsSplitColumn.Width = new GridLength(0);
|
|
ActionsPanel.Visibility = Visibility.Collapsed;
|
|
ActionsSplitter.Visibility = Visibility.Collapsed;
|
|
}
|
|
else
|
|
{
|
|
ActionsPanel.Visibility = Visibility.Visible;
|
|
ActionsSplitter.Visibility = Visibility.Visible;
|
|
ActionsSplitColumn.Width = new GridLength(4);
|
|
ActionsColumn.Width = _actionsWidth;
|
|
ActionsColumn.MinWidth = 170;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 구분선을 끌고 난 뒤 칸 폭을 다시 <b>비율</b>로 돌려놓는다.
|
|
///
|
|
/// GridSplitter 는 끌고 나면 양옆 칸을 <b>고정 픽셀</b>로 바꿔 놓는다. 그러면 두 가지가 어긋난다.
|
|
/// 창 크기가 바뀌어도 칸이 따라 늘지 않고, 3-Way 에서 끌어 놓은 채 2-Way 로 돌아오면
|
|
/// REMOTE 칸이 접히면서 생긴 자리를 <b>아무도 채우지 않아 오른쪽에 빈 공간이 남는다.</b>
|
|
/// </summary>
|
|
private void PaneSplitter_DragCompleted(object sender, System.Windows.Controls.Primitives.DragCompletedEventArgs e)
|
|
{
|
|
foreach (var column in PaneColumns)
|
|
{
|
|
// 접혀 있는 칸(2-Way 의 REMOTE)은 건드리지 않는다. Style 이 폭을 쥐고 있다.
|
|
if (column.ActualWidth <= 0) continue;
|
|
if (!column.Width.IsAbsolute) continue;
|
|
|
|
column.Width = new GridLength(column.ActualWidth, GridUnitType.Star);
|
|
}
|
|
}
|
|
|
|
/// 표시층에 그만큼 오른쪽 여백을 줘서 남는 폭을 같게 만든다.
|
|
///
|
|
/// 맞춰야 할 묶음이 모드마다 다르다.
|
|
/// 2-Way : BASE 와 LOCAL(편집기)이 나란히 있으므로 BASE 가 미니맵 폭까지 물러나야 한다.
|
|
/// 3-Way : 원본 세 칸이 서로 같은 폭이고 셋 다 미니맵이 없다. 여기서 BASE 만 미니맵
|
|
/// 폭을 빼면 <b>BASE 만 좁아져</b> 세 칸의 접히는 자리가 어긋난다.
|
|
/// 아래 MERGED 는 폭 자체가 달라 애초에 같이 맞출 수 없다(높이도 따로 센다).
|
|
/// </summary>
|
|
private void MatchTextWidths()
|
|
{
|
|
var bar = SystemParameters.VerticalScrollBarWidth;
|
|
var threeWay = (DataContext as HomeViewModel)?.IsThreeWay == true;
|
|
|
|
// 패널 머리(제목·경로)의 높이를 서로 맞춘다.
|
|
//
|
|
// 예전에는 상수로 못 박아 두었는데(124 / 74), 실제 내용보다 훨씬 커서
|
|
// <b>경로 줄과 코드 사이가 휑하게 비었다.</b> 이제 내용에 맞춰 줄어들고,
|
|
// 서로 다른 패널끼리는 SharedSizeGroup 이 가장 큰 것에 맞춰 준다
|
|
// (REMOTE 만 "Conflict N" 줄이 하나 더 있다).
|
|
//
|
|
// 2-Way 에서는 BASE 와 LOCAL(편집기 칸)이 나란히 있으므로 MERGED 머리도 같은 무리에 넣는다.
|
|
// 3-Way 에서는 MERGED 가 아래 줄로 내려가 맞출 상대가 없다 - 넣어 두면 그쪽 경로 줄 때문에
|
|
// 위 세 칸의 머리까지 덩달아 커진다.
|
|
MergedHeaderRow.SharedSizeGroup = threeWay ? null : "PaneHeader";
|
|
|
|
// 편집기 아래층은 편집기의 세로 막대만큼 물러난다 - 그 막대는 편집기가 그린다.
|
|
LocalLines.Margin = new Thickness(0, 0, bar, 0);
|
|
|
|
// 원본 세 칸은 <b>자기 세로 막대를 직접 그린다.</b> 그 막대가 이미 자리를 차지하므로
|
|
// 여백으로 또 물러나면 그만큼 글자 폭이 좁아져 접히는 자리가 어긋난다.
|
|
RemoteLines.Margin = new Thickness(0);
|
|
OriginalLocalLines.Margin = new Thickness(0);
|
|
|
|
// 2-Way 의 BASE 는 LOCAL 과 폭을 맞춰야 한다. LOCAL 쪽 칸에는 미니맵이 붙어 있으므로
|
|
// 그 폭만큼만 더 물러난다(막대 몫은 자기 막대가 이미 가져갔다).
|
|
BaseLines.Margin = threeWay
|
|
? new Thickness(0)
|
|
: new Thickness(0, 0, MiniMap.Width, 0);
|
|
|
|
// 칸 폭이 달라졌으니 원본 칸 막대의 범위도 다시 잰다.
|
|
ApplySourceScrollUnit();
|
|
UpdateSourceScrollRange();
|
|
}
|
|
|
|
/// <summary>표시층을 편집기 글자 위치에 맞추는 이동값. 아래 AlignDecorationToEditor 가 정한다.</summary>
|
|
private readonly TranslateTransform _decorationShift = new TranslateTransform();
|
|
|
|
/// <summary>
|
|
/// 표시층(색·문법)을 편집기가 알려 주는 글자 좌표에 맞춘다.
|
|
///
|
|
/// 두 층은 서로 다른 방식으로 글을 배치한다 - TextBox 는 자기 문서 레이아웃으로,
|
|
/// 아래층은 ListBox 행으로. 줄 간격은 같게 맞춰 놓았지만 <b>시작 위치가 일정하게 어긋난다</b>.
|
|
/// 상수로 보정하면 글꼴·배율이 바뀔 때마다 다시 틀리므로, 실제 좌표를 재서 맞춘다.
|
|
/// </summary>
|
|
private void AlignDecorationToEditor()
|
|
{
|
|
if (LocalEditor.Text.Length == 0) return;
|
|
|
|
// 반드시 <b>같은 줄끼리</b> 견줘야 한다.
|
|
//
|
|
// 예전에는 편집기의 0번 글자와 "처음 그려진 표시층 행"을 견줬는데, 목록이 가상화라
|
|
// 스크롤을 내리면 처음 그려진 행이 0행이 아니다. 그러면 "0번 글자(화면 위로 한참 올라간 자리)"와
|
|
// "화면 맨 위 행"을 비교하게 돼 이동값이 스크롤한 거리만큼 커지고, 표시층 글자가 통째로
|
|
// 위로 밀려 보인다 - 아래로 스크롤한 뒤 전체 되돌리기를 하면 실제로 그렇게 됐다.
|
|
//
|
|
// 또 <b>칸 안에 보이는 행</b>이어야 한다(6차 B-4 확인 중 발견). "처음 만들어진 행" 은 멀리 건너뛴 직후에
|
|
// 화면 밖에 남은 행일 수 있고, 편집기는 화면 밖 글자의 좌표를 어림으로 준다. 7,479줄 파일에서
|
|
// "마지막 차이" 로 가자 그 어림이 9줄쯤 틀려 네 칸이 통째로 위로 밀리고 아래가 비었다.
|
|
//
|
|
// 2026-09-30: 기준을 <b>편집기에 보이는 첫 줄</b>로 바꿨다. 예전에는 "목록에 보이는 첫 행" 을 편집기에서 찾았는데,
|
|
// 가상화 목록의 높이 어림이 틀려 목록이 편집기보다 수십 줄 앞서 있으면 그 줄이 편집기 화면 밖이라
|
|
// 맞추기를 포기했다(기록: 확대 뒤 95번 중 95번 "화면 밖") - 한 번도 바로잡지 못한 채 칸이 통째로 밀렸다.
|
|
// 편집기 첫 줄은 늘 화면 안이다. 그 행이 목록에 아직 안 그려졌으면 먼저 불러오고 다음 배치 때 맞춘다.
|
|
// 행 번호 ↔ 편집기 줄 번호는 지운 줄(편집기에 줄이 없는 행)이 있으면 다르다 - 표(RowOfEditorLine)로 바꾼다.
|
|
if (DataContext is not HomeViewModel vm || vm.Lines.Count == 0) return;
|
|
// GetFirstVisibleLineIndex 는 쓰지 않는다 - 글꼴 기본 줄 간격으로 어림해 우리가 늘린 줄 높이를 모른다
|
|
// (기록: 실제 첫 줄 886 에서 1033 을 줬다, 아래로 갈수록 더 틀린다). 점으로 글자를 찾는 것도 확대 직후엔
|
|
// 옛 배치로 답해 80줄쯤 위를 줬다(기록: 그 줄 top -1871). 줄 높이가 고정(BlockLineHeight)이니 나눗셈이 정확하다.
|
|
// 줄 바꿈을 켜면 한 줄이 여러 칸이라 나눗셈이 안 맞는다 - 그때만 점으로 찾는다.
|
|
int editorLine;
|
|
var fixedLines = LocalEditor.TextWrapping == TextWrapping.NoWrap;
|
|
if (fixedLines)
|
|
editorLine = Math.Min((int)Math.Ceiling((LocalEditor.VerticalOffset - 0.5) / LineHeight), LocalEditor.LineCount - 1); // 위가 잘리지 않은 첫 줄
|
|
else
|
|
{
|
|
var topChar = LocalEditor.GetCharacterIndexFromPoint(new Point(LocalEditor.Padding.Left + 2, 1), snapToText: true);
|
|
editorLine = topChar < 0 ? -1 : LocalEditor.GetLineIndexFromCharacterIndex(topChar);
|
|
if (editorLine >= 0 && LocalEditor.GetRectFromCharacterIndex(LocalEditor.GetCharacterIndexFromLineIndex(editorLine)).Top < -1 &&
|
|
editorLine + 1 < LocalEditor.LineCount) editorLine++; // 윗줄이 반쯤 잘려 있으면 다음 줄
|
|
}
|
|
if (editorLine < 0) return;
|
|
|
|
var row = vm.RowOfEditorLine(editorLine);
|
|
if (row < 0 || row >= vm.Lines.Count) return;
|
|
if (LocalLines.ItemContainerGenerator.ContainerFromIndex(row) is not ListBoxItem container || !container.IsVisible)
|
|
{
|
|
LocalLines.ScrollIntoView(vm.Lines[row]);
|
|
return;
|
|
}
|
|
if (FindFirst<Controls.CodeTextBlock>(container) is not { } sample) return;
|
|
|
|
var charIndex = LocalEditor.GetCharacterIndexFromLineIndex(editorLine);
|
|
if (charIndex < 0) return;
|
|
|
|
try
|
|
{
|
|
var charRect = LocalEditor.GetRectFromCharacterIndex(charIndex);
|
|
if (fixedLines)
|
|
{
|
|
// 글자 크기를 바꾼 뒤 GetRectFromCharacterIndex 는 몇 초씩 옛 배치로 답했다(기록: 보이는 첫 줄 728 을
|
|
// 1,892px 위라고 했다 - 그래서 "화면 밖" 으로 빠져 한 번도 맞추지 못하고 칸이 69줄 어긋났다).
|
|
// 줄 높이가 고정이면 줄 자리는 계산으로 정확하다 - 줄 상자 위 + 글자 위 여백(lead, 아래에서 쓰는 값과 같은 식).
|
|
var lineTop = editorLine * LineHeight - LocalEditor.VerticalOffset + LocalEditor.BorderThickness.Top + LocalEditor.Padding.Top;
|
|
var glyphLead = sample.BaselineOffset - sample.FontFamily.Baseline * sample.FontSize;
|
|
charRect = new Rect(charRect.IsEmpty ? 0 : charRect.X, lineTop + glyphLead, 0, LineHeight);
|
|
}
|
|
|
|
// 편집기 화면 밖이면 어림값이다. 맞추지 않고 다음 배치 때 다시 본다.
|
|
if (charRect.IsEmpty || charRect.Top < -1 || charRect.Top > LocalEditor.ActualHeight) return;
|
|
|
|
var editorPoint = LocalEditor.TransformToVisual(this).Transform(charRect.TopLeft);
|
|
|
|
var decorationPoint = sample.TransformToVisual(this).Transform(new Point(0, 0));
|
|
|
|
// 두 좌표는 기준이 다르다. 실제로 재 보면(Consolas 12 · 줄 높이 20):
|
|
// GetRectFromCharacterIndex(0).Top = 4.67 → 줄상자 위 여백을 지난 "글자 상자" 위쪽
|
|
// CodeTextBlock 의 (0,0) = 0 → 여백을 포함한 "줄상자" 위쪽
|
|
// 이 여백(lead)을 빼지 않으면 표시층이 딱 그만큼 아래로 밀려서, 캐럿과 선택 영역이
|
|
// 글자보다 반 줄 위에 있는 것처럼 보인다. 여백은 글꼴에서 직접 구하므로
|
|
// 글자 크기나 화면 배율이 바뀌어도 따라간다.
|
|
var lead = sample.BaselineOffset - sample.FontFamily.Baseline * sample.FontSize;
|
|
if (double.IsNaN(lead) || double.IsInfinity(lead)) return;
|
|
|
|
// decorationPoint 는 지금 적용된 이동값이 이미 반영된 자리다.
|
|
// 그래서 "보정 없는 자리"로 되돌린 뒤 필요한 총 이동값을 구한다.
|
|
var wanted = editorPoint.Y - (decorationPoint.Y - _decorationShift.Y + lead);
|
|
|
|
// 이미 맞아 있으면 건드리지 않는다 - 매 프레임 흔들리는 것을 막는다.
|
|
if (Math.Abs(wanted - _decorationShift.Y) < 0.5)
|
|
{
|
|
AlignOtherPanesByRow();
|
|
return;
|
|
}
|
|
|
|
// 두 줄 넘게 어긋났으면 이동값으로 메우지 않고 <b>목록을 그만큼 실제로 굴린다</b>(2026-09-30 사용자 보고).
|
|
// 칸 목록은 가상화라 안 그린 줄의 높이를 어림으로 세어, 같은 픽셀 자리라도 편집기와 다른 줄을 본다
|
|
// (기록으로 잰 값: 1,806줄 파일 847행에서 전체 길이가 편집기 28,896 · 목록 27,940 → 이동값 467px).
|
|
// 이동값은 네 칸에 함께 걸리므로 커지면 칸이 통째로 밀려 위가 텅 비었다 - Ctrl+휠로 키우면 더 드러났다.
|
|
// 굴리고 나면 다음 배치 때 여기로 다시 와서 한 줄 안쪽의 차이만 이동값으로 맞춘다.
|
|
// LOCAL 목록은 굴러도 편집기를 되움직이지 않는다(Lines_ScrollChanged 가 받지 않는다) - 메아리 없음.
|
|
var rowHeight = Math.Max(1, container.ActualHeight);
|
|
if (Math.Abs(wanted) > rowHeight * 2 && FindScrollViewer(LocalLines) is { } listViewer)
|
|
{
|
|
_decorationShift.Y = 0;
|
|
_paneBias[LocalLines] = Bias(LocalLines) - wanted; // 다음 편집기 스크롤도 이만큼 더해 굴린다
|
|
listViewer.ScrollToVerticalOffset(Clamp(listViewer.VerticalOffset - wanted, listViewer.ScrollableHeight));
|
|
LinkOverlay.InvalidateVisual();
|
|
return;
|
|
}
|
|
|
|
// 가로는 건드리지 않는다. 여백(줄번호·[>])까지 밀려나기 때문이다 -
|
|
// 가로 스크롤은 글자에만 거는 TextShiftX 가 따로 맡는다.
|
|
_decorationShift.Y = wanted;
|
|
LinkOverlay.InvalidateVisual(); // 표시층이 움직였다 - 잇는 선도 따라간다
|
|
}
|
|
catch (InvalidOperationException)
|
|
{
|
|
// 아직 배치 전이면 좌표를 못 준다. 다음 기회에 다시 맞춘다.
|
|
}
|
|
}
|
|
|
|
/// <summary>칸마다 마지막으로 불러온 짝 행. 같은 행을 배치마다 거듭 부르지 않게 한다.</summary>
|
|
private readonly System.Collections.Generic.Dictionary<ListBox, int> _pairRequested = new System.Collections.Generic.Dictionary<ListBox, int>();
|
|
|
|
/// <summary>
|
|
/// BASE · REMOTE · 원본 LOCAL 칸을 <b>같은 줄</b> 기준으로 LOCAL 칸에 맞춘다(2026-09-30 사용자 보고 "화면이 잘린다").
|
|
///
|
|
/// 이 칸들은 가운데 맞추기(<see cref="SyncPanesFrom"/>)가 픽셀 자리로 굴린다. 그런데 칸 목록은 가상화라 안 그린 줄의
|
|
/// 높이를 어림으로 세어, 파일 아래쪽에서는 같은 픽셀이 다른 줄을 가리켰다(사진: LOCAL 839~876 · BASE 807~845, 30줄 어긋남).
|
|
/// LOCAL 칸 가운데 보이는 줄이 그 칸에도 있으면 두 행의 윗변을 견줘 두 줄 넘게 다르면 그 칸을 그만큼 굴린다.
|
|
/// 두 줄 안쪽의 차이, 그리고 한쪽에만 있는 줄 묶음 한가운데의 어긋남은 가운데 맞추기가 일부러 만든 것이라 그대로 둔다.
|
|
/// 굴리는 동안 메아리 막기를 건다 - 이 칸이 구르면 편집기를 따라 움직이게 하는 길이 있다(Lines_ScrollChanged).
|
|
/// </summary>
|
|
private void AlignOtherPanesByRow()
|
|
{
|
|
if (DataContext is not HomeViewModel vm || vm.Lines.Count == 0) return;
|
|
if (ContainerAtMiddle(LocalLines) is not { } localItem) return;
|
|
var row = LocalLines.ItemContainerGenerator.IndexFromContainer(localItem);
|
|
if (row < 0) return;
|
|
var localTop = localItem.TranslatePoint(new Point(0, 0), LocalLines).Y;
|
|
var rowHeight = Math.Max(1, localItem.ActualHeight);
|
|
|
|
foreach (var (list, kind) in VisiblePanes(vm))
|
|
{
|
|
if (kind == MergeLine.TextPane.Local) continue;
|
|
|
|
// LOCAL 가운데 줄이 이 칸에도 있을 때만 맞춘다. 한쪽에만 있는 줄 묶음 한가운데라면 두 칸이 어긋나 보이는 것이
|
|
// 가운데 맞추기가 일부러 만든 모양이다. 거기서 짝(묶음 아래 줄)을 억지로 불러오면 가운데 맞추기가 도로 밀어내
|
|
// 배치마다 되풀이됐다(기록: 엔터 40번 뒤 한 줄(894→924)을 초당 600번 넘게 불러 타이핑 입력이 밀렸다).
|
|
if (vm.Lines[row].PaneHeight(kind, false) <= 0) continue;
|
|
|
|
if (FindScrollViewer(list) is not { } viewer) continue;
|
|
if (list.ItemContainerGenerator.ContainerFromIndex(row) is not ListBoxItem item || !item.IsVisible)
|
|
{
|
|
// 짝이 아직 안 그려졌다(어림이 크게 틀림) - 먼저 화면 안으로 부르고, 다음 배치 때 윗변을 맞춘다.
|
|
// 같은 줄은 한 번만 부른다 - 부른 뒤에도 안 보이면 배치마다 다시 부르며 멈추지 않는다.
|
|
if (_pairRequested.TryGetValue(list, out var asked) && asked == row) continue;
|
|
_pairRequested[list] = row;
|
|
HoldSync();
|
|
list.ScrollIntoView(vm.Lines[row]);
|
|
continue;
|
|
}
|
|
_pairRequested.Remove(list);
|
|
|
|
var dy = item.TranslatePoint(new Point(0, 0), list).Y - localTop;
|
|
if (Math.Abs(dy) <= rowHeight * 2) continue;
|
|
|
|
HoldSync();
|
|
_paneBias[list] = Bias(list) + dy; // 가운데 맞추기도 다음부터 이만큼 더해 굴린다 - 되돌려 밀고 당기지 않게
|
|
viewer.ScrollToVerticalOffset(Clamp(viewer.VerticalOffset + dy, viewer.ScrollableHeight));
|
|
LinkOverlay.InvalidateVisual();
|
|
}
|
|
}
|
|
|
|
/// <summary>목록 칸의 세로 가운데를 덮는 행. 없으면 null.</summary>
|
|
private static ListBoxItem? ContainerAtMiddle(ListBox list)
|
|
{
|
|
if (FindFirst<VirtualizingStackPanel>(list) is not { } panel) return null;
|
|
var middle = list.ActualHeight / 2;
|
|
foreach (UIElement child in panel.Children)
|
|
{
|
|
if (child is not ListBoxItem item || !item.IsVisible) continue;
|
|
var top = item.TranslatePoint(new Point(0, 0), list).Y;
|
|
if (top <= middle && top + item.ActualHeight > middle) return item;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 목록 칸 안에서 위가 잘리지 않고 보이는 첫 행. 없으면 null.
|
|
/// 행 판(가상화 패널)의 자식만 훑는다 - 배치가 끝날 때마다 불리므로 시각 트리 전체를 뒤지면 무겁다.
|
|
/// 목록 자신에 걸린 이동값(<see cref="_decorationShift"/>)은 목록 기준 좌표에 들어가지 않는다.
|
|
/// </summary>
|
|
private static ListBoxItem? FirstVisibleContainer(ListBox list)
|
|
{
|
|
if (FindFirst<VirtualizingStackPanel>(list) is not { } panel) return null;
|
|
|
|
ListBoxItem? best = null;
|
|
var bestTop = double.MaxValue;
|
|
|
|
foreach (UIElement child in panel.Children)
|
|
{
|
|
if (child is not ListBoxItem item || !item.IsVisible) continue;
|
|
|
|
var top = item.TransformToAncestor(list).Transform(new Point(0, 0)).Y;
|
|
if (top < -0.5 || top >= list.ActualHeight || top >= bestTop) continue;
|
|
|
|
best = item;
|
|
bestTop = top;
|
|
}
|
|
|
|
return best;
|
|
}
|
|
|
|
private static T? FindFirst<T>(DependencyObject root) where T : DependencyObject
|
|
{
|
|
if (root is T hit) return hit;
|
|
|
|
var count = VisualTreeHelper.GetChildrenCount(root);
|
|
for (var i = 0; i < count; i++)
|
|
{
|
|
if (FindFirst<T>(VisualTreeHelper.GetChild(root, i)) is { } found) return found;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// ================= 세로 스크롤 맞추기 =================
|
|
|
|
/// <summary>코드 한 줄의 높이. XAML 의 CodeLineHeight 와 같은 값이어야 한다.</summary>
|
|
private double LineHeight =>
|
|
TryFindResource("CodeLineHeight") is double h && h > 0 ? h : 16;
|
|
|
|
/// <summary>원본 세 칸. 늘 함께 움직인다.</summary>
|
|
private ListBox[] SourcePanes => new[] { BaseLines, RemoteLines, OriginalLocalLines };
|
|
|
|
/// <summary>
|
|
/// 원본 칸을 <b>행 단위</b>로 굴릴지 <b>픽셀 단위</b>로 굴릴지 정한다.
|
|
///
|
|
/// 워드랩을 켠 3-Way 에서는 위 세 칸과 아래 MERGED 의 폭이 달라 같은 행이라도 접히는
|
|
/// 횟수가 다르다. 픽셀로 맞추려면 행마다 실제 높이를 알아야 하는데, 목록은 <b>가상화</b>라
|
|
/// 화면에 그려진 행만 자기 높이를 알려 준다. 아직 안 그려진 행은 기본값(한 줄)로 잡혀
|
|
/// 환산이 어긋나고, 그 어긋남이 되먹임으로 커져 스크롤이 한 자리에 눌러앉는다.
|
|
///
|
|
/// 그래서 이때는 <b>재지 않고 세는</b> 쪽으로 바꾼다. 행 단위로 굴리면 "몇 번째 행이
|
|
/// 맨 위인가"만 주고받으면 되므로 높이를 몰라도 정확히 맞는다.
|
|
/// 워드랩이 꺼져 있으면 모든 행이 같은 높이라 픽셀 그대로가 더 부드럽다.
|
|
/// </summary>
|
|
private void ApplySourceScrollUnit()
|
|
{
|
|
// 11차 F-10: 칸마다 자기 줄 높이로 가운데를 맞춘다(PaneMapping) - 행 단위로 굴릴 까닭이 없다.
|
|
const ScrollUnit unit = ScrollUnit.Pixel;
|
|
|
|
foreach (var pane in SourcePanes)
|
|
{
|
|
if (VirtualizingPanel.GetScrollUnit(pane) != unit) VirtualizingPanel.SetScrollUnit(pane, unit);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 표시층 ListBox 가 스스로 굴러가지 못하게 막는다.
|
|
///
|
|
/// ListBox 는 고른 줄이 화면 밖이면 알아서 굴려 보여 준다. 보통은 고마운 동작이지만
|
|
/// 여기서는 <b>스크롤 주인이 편집기 하나</b>여야 세 겹(여백·표시층·편집기)이 같은 줄을
|
|
/// 같은 눈높이에 놓는다. 특히 <b>가로</b>로 굴리면 줄 번호까지 화면 밖으로 밀려 나가고
|
|
/// 세 칸이 서로 어긋난다 — MERGED 에 긴 줄을 쓴 순간 원본 칸의 글이 통째로 사라지던 증상이 이것이다.
|
|
/// </summary>
|
|
private static void BlockSelfScroll(FrameworkElement pane) =>
|
|
pane.AddHandler(RequestBringIntoViewEvent,
|
|
new RequestBringIntoViewEventHandler((_, e) => e.Handled = true));
|
|
|
|
/// <summary>
|
|
/// 읽기 전용 패널(LOCAL 원본 · BASE · REMOTE)이 굴렀을 때.
|
|
///
|
|
/// <b>세로만</b> 받는다. 가로로는 이 칸들이 스스로 굴러서는 안 되는데,
|
|
/// ListBox 가 "고른 줄을 보이게" 하려고 선택이 바뀔 때마다 제멋대로 가로로 굴리기 때문이다
|
|
/// (MERGED 에 긴 줄을 쓰면 그 순간 세 칸의 글이 통째로 화면 밖으로 밀려났다).
|
|
/// 가로 이동은 <see cref="SourceShiftX"/> 한 곳으로만 한다 — 그래야 여백(줄 번호)이 제자리에 남는다.
|
|
/// </summary>
|
|
private void Lines_ScrollChanged(object sender, ScrollChangedEventArgs e)
|
|
{
|
|
if (sender is not ListBox source) return;
|
|
|
|
// 어느 칸이든 구르면 잇는 선을 다시 그린다(11차 F-10) - LOCAL 표시층은 편집기보다 한 박자 늦게 굴러 그 사이 옛 자리로 그려졌다.
|
|
if (e.VerticalChange != 0 || e.ViewportHeightChange != 0 || e.ExtentHeightChange != 0) LinkOverlay.InvalidateVisual();
|
|
|
|
if (!ReferenceEquals(source, BaseLines) &&
|
|
!ReferenceEquals(source, RemoteLines) &&
|
|
!ReferenceEquals(source, OriginalLocalLines)) return;
|
|
|
|
// 저 혼자 가로로 굴렀으면 되돌려 놓는다. 스크롤 막대는 감춰 두었지만
|
|
// 굴릴 수 있는 상태여야 "얼마나 더 갈 수 있는지"(ExtentWidth)를 잴 수 있다.
|
|
if (e.HorizontalOffset != 0)
|
|
{
|
|
Scroll(source, null, 0);
|
|
return;
|
|
}
|
|
|
|
// 글이나 칸 폭이 바뀌면 밀 수 있는 폭도 달라진다. 스크롤 이벤트가 그때마다 온다.
|
|
if (e.ExtentWidthChange != 0 || e.ViewportWidthChange != 0) UpdateSourceScrollRange();
|
|
|
|
if (Syncing) return;
|
|
if (e.VerticalChange == 0) return;
|
|
|
|
HoldSync();
|
|
|
|
// 이 칸을 기준으로 나머지(편집기 · 다른 원본 칸)가 가운데를 맞춰 따라온다(11차 F-10).
|
|
var kind = ReferenceEquals(source, BaseLines) ? MergeLine.TextPane.Base
|
|
: ReferenceEquals(source, RemoteLines) ? MergeLine.TextPane.Remote
|
|
: MergeLine.TextPane.OriginalLocal;
|
|
// 이 칸의 픽셀 자리에는 보정값이 들어 있다 - 빼서 "줄 기준" 자리로 바꾼 뒤 다른 칸을 맞춘다.
|
|
SyncPanesFrom(kind, e.VerticalOffset - Bias(source), e.ViewportHeight);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 칸 목록마다 "픽셀 자리 → 같은 줄" 보정값(2026-09-30 사용자 보고: 확대하면 화면이 잘린다 · 밑으로 쭉 내리면 두 칸 속도가 다르다).
|
|
///
|
|
/// 칸 목록은 가상화라 안 그린 줄의 높이를 어림으로 세어, 편집기와 같은 픽셀로 굴려도 파일 아래쪽에서는 다른 줄을 본다
|
|
/// (기록: 1,806줄 847행에서 편집기 전체 28,896 · 목록 27,940). 줄 기준 맞추기(<see cref="AlignDecorationToEditor"/> ·
|
|
/// <see cref="AlignOtherPanesByRow"/>)가 찾은 차이를 여기 쌓고, 픽셀로 굴리는 자리(편집기 스크롤 · 가운데 맞추기)는 늘 이 값을 더한다.
|
|
/// 처음에는 맞추는 쪽이 그냥 굴렸더니 다음 편집기 스크롤이 픽셀 자리로 되돌려 둘이 밀고 당겼다(기록: 12704.9 ↔ 13070.6).
|
|
/// 목록을 통째로 갈아 끼우면(새 파일) 비운다.
|
|
/// </summary>
|
|
private readonly System.Collections.Generic.Dictionary<ListBox, double> _paneBias = new System.Collections.Generic.Dictionary<ListBox, double>();
|
|
|
|
private double Bias(ListBox list) => _paneBias.TryGetValue(list, out var b) ? b : 0;
|
|
|
|
private static void Scroll(DependencyObject target, double? vertical, double? horizontal)
|
|
{
|
|
if (FindScrollViewer(target) is not { } viewer) return;
|
|
|
|
if (vertical is { } v) viewer.ScrollToVerticalOffset(v);
|
|
if (horizontal is { } h) viewer.ScrollToHorizontalOffset(h);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 캐럿이 놓인 줄을 선택 줄로 삼는다. Accept / Revert 는 이 줄을 대상으로 한다.
|
|
/// 편집기 줄 번호와 화면 행 번호는 1:1 이다 - 지워진 줄 자리에 빈 줄(필러)을 넣어 뒀기 때문.
|
|
/// </summary>
|
|
private void LocalEditor_SelectionChanged(object sender, RoutedEventArgs e)
|
|
{
|
|
if (DataContext is not HomeViewModel vm) return;
|
|
|
|
// 행·열을 글에서 직접 센다.
|
|
//
|
|
// TextBox 의 GetLineIndexFromCharacterIndex / GetCharacterIndexFromLineIndex 는
|
|
// "화면에 보이는 줄" 기준이고, 글을 갈아 끼운 직후에는 CaretIndex 와 어긋난 값을 준다.
|
|
// 실제로 11글자짜리 줄에서 Home 을 눌렀는데 "10열"이 나오는 것을 확인했다.
|
|
var (row, column) = RowColumnAt(LocalEditor.Text, LocalEditor.CaretIndex);
|
|
vm.ReportCaret(row, column);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 글에서 캐럿 위치의 (행, 열)을 센다. 둘 다 0부터다.
|
|
/// 줄바꿈은 CRLF·LF 를 함께 다루고, CR 은 앞 줄의 끝이라 열에 세지 않는다.
|
|
/// </summary>
|
|
private static (int Row, int Column) RowColumnAt(string text, int caret)
|
|
{
|
|
if (caret < 0) caret = 0;
|
|
if (caret > text.Length) caret = text.Length;
|
|
|
|
var row = 0;
|
|
var lineStart = 0;
|
|
|
|
for (var i = 0; i < caret; i++)
|
|
{
|
|
if (text[i] != '\n') continue;
|
|
row++;
|
|
lineStart = i + 1;
|
|
}
|
|
|
|
var column = caret - lineStart;
|
|
|
|
// "...\r|\n" 처럼 CR 과 LF 사이에 캐럿이 있으면 그 줄의 끝으로 본다.
|
|
if (column > 0 && caret <= text.Length && caret > 0 && text[caret - 1] == '\r') column--;
|
|
|
|
return (row, Math.Max(0, column));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 정렬이 바뀌어 편집기 글을 갈아 끼울 때, 캐럿이 맨 앞으로 튀지 않게 자리를 지킨다.
|
|
/// 글자 수 기준이라 완벽하지는 않지만, 타이핑 중 흐름이 끊기는 것은 막아 준다.
|
|
/// </summary>
|
|
private void OnEditorTextReplaced(object? sender, string text)
|
|
{
|
|
var caret = LocalEditor.CaretIndex;
|
|
|
|
// 머지 주석 줄이 끼거나 빠졌으면 캐럿을 <b>누르기 전과 같은 행</b>에 둔다(2026-09-30).
|
|
// 글자 수로만 지키면 Accept 뒤 캐럿이 새 주석 줄에 서서, Alt+↓ 가 방금 반영한 줄을 "다음 차이" 로 잡았다.
|
|
var targetLine = (DataContext as HomeViewModel)?.TakeCaretLine() ?? -1;
|
|
var oldLine = targetLine >= 0 ? LocalEditor.GetLineIndexFromCharacterIndex(caret) : -1;
|
|
var caretColumn = oldLine >= 0 ? caret - LocalEditor.GetCharacterIndexFromLineIndex(oldLine) : 0;
|
|
|
|
// 글을 갈아 끼우면 TextChanged 가 도는데, 그걸 사용자의 편집으로 오해하면
|
|
// 다시 정렬이 끝없이 되풀이된다.
|
|
_replacingText = true;
|
|
try
|
|
{
|
|
LocalEditor.Text = text;
|
|
}
|
|
finally
|
|
{
|
|
_replacingText = false;
|
|
}
|
|
|
|
if (targetLine >= 0 && LineSpan(text, targetLine) is { } span)
|
|
{
|
|
LocalEditor.CaretIndex = span.Start + Math.Min(caretColumn, span.Length);
|
|
return;
|
|
}
|
|
|
|
LocalEditor.CaretIndex = Math.Min(caret, text.Length);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 글 안 <paramref name="line"/> 번째 줄(0부터)의 시작 자리와 길이(줄끝 글자 빼고). 줄이 모자라면 마지막 줄.
|
|
/// 편집기에 묻지 않고 글에서 직접 센다 - 글을 막 갈아 끼운 순간 TextBox 의 줄 정보는 아직 <b>옛 글</b> 기준이다
|
|
/// (기록으로 확인: 새 글 7번 줄을 물었더니 옛 글의 주석 줄 자리 · 길이가 돌아왔다).
|
|
/// </summary>
|
|
private static (int Start, int Length)? LineSpan(string text, int line)
|
|
{
|
|
if (text is null) return null;
|
|
var start = 0;
|
|
for (var i = 0; i < line; i++)
|
|
{
|
|
var next = text.IndexOf('\n', start);
|
|
if (next < 0) break;
|
|
start = next + 1;
|
|
}
|
|
var end = text.IndexOf('\n', start);
|
|
if (end < 0) end = text.Length;
|
|
var length = end - start;
|
|
if (length > 0 && text[end - 1] == '\r') length--;
|
|
return (start, length);
|
|
}
|
|
|
|
/// <summary>사용자가 글자를 고쳤다. 무거운 일은 ViewModel 이 잠시 뒤에 한다.</summary>
|
|
private void LocalEditor_TextChanged(object sender, TextChangedEventArgs e)
|
|
{
|
|
if (_replacingText) return;
|
|
if (DataContext is HomeViewModel vm) vm.NotifyEditorEdited(LocalEditor.Text, LocalEditor.CaretIndex);
|
|
}
|
|
|
|
// ================= 차이 탐색 · 이동 · 북마크 =================
|
|
|
|
/// <summary>
|
|
/// 단축키. 어떤 키가 무슨 일을 하는지는 <see cref="ShortcutMap"/> 한 곳에만 적혀 있고
|
|
/// 설명 창도 같은 표를 읽는다 - 둘이 갈라지면 설명이 곧 거짓말이 된다.
|
|
///
|
|
/// 이 화면 전체(UserControl)의 <c>PreviewKeyDown</c> 에 걸어 둔다. 편집기에만 걸면
|
|
/// 파일 목록이나 찾기 칸에 초점이 있을 때 단축키가 죽고, 반대로 편집기가 먼저 삼키는
|
|
/// 키(Home/End)도 있어서다. 표에 없는 키는 손대지 않으므로 <c>Ctrl+A</c>·<c>Ctrl+Z</c> 같은
|
|
/// 편집 기본 동작은 그대로 편집기로 간다.
|
|
/// </summary>
|
|
private void View_PreviewKeyDown(object sender, KeyEventArgs e)
|
|
{
|
|
// Alt 를 누르면 WPF 는 e.Key 에 Key.System 을 넣고 진짜 키는 e.SystemKey 로 보낸다.
|
|
var key = e.Key == Key.System ? e.SystemKey : e.Key;
|
|
|
|
if (ShortcutMap.Resolve(key, Keyboard.Modifiers) is not { } action) return;
|
|
if (RunShortcut(action)) e.Handled = true;
|
|
}
|
|
|
|
/// <summary>표가 고른 동작을 실제로 시킨다.</summary>
|
|
private bool RunShortcut(ShortcutAction action)
|
|
{
|
|
if (DataContext is not HomeViewModel vm) return false;
|
|
|
|
switch (action)
|
|
{
|
|
case ShortcutAction.NextDiff: return Run(vm.NextDiffCommand);
|
|
case ShortcutAction.PrevDiff: return Run(vm.PrevDiffCommand);
|
|
case ShortcutAction.FirstDiff: return Run(vm.FirstDiffCommand);
|
|
case ShortcutAction.LastDiff: return Run(vm.LastDiffCommand);
|
|
|
|
// 화살표는 WinMerge 와 같이 "어느 쪽으로 복사하는가"를 가리킨다.
|
|
// 2-Way 의 왼쪽은 BASE, 오른쪽은 편집 중인 내 칸.
|
|
// 3-Way 는 위에 LOCAL(Mine) · BASE · REMOTE(Theirs) 를 늘어놓고 결과는 아래 MERGED 하나뿐이라,
|
|
// "오른쪽으로" = 왼쪽 끝 LOCAL(Mine) 을 결과로, "왼쪽으로" = 오른쪽 끝 REMOTE(Theirs) 를 결과로 읽는다.
|
|
case ShortcutAction.CopyLeftToRight:
|
|
return Run(vm.IsThreeWay ? (ICommand)vm.AcceptLocalCommand : vm.AcceptCommand);
|
|
|
|
// 2-Way 에서는 12차 G-2 부터 <이 줄을 왼쪽 파일로>다([<] 단추와 같은 일).
|
|
// 예전에는 '이 줄 되돌리기'였는데, 화살표가 가리키는 대로 왼쪽으로 보내는 자리로 넘겼다.
|
|
// 되돌리기는 Ctrl+Z 가 맡는다.
|
|
case ShortcutAction.CopyRightToLeft:
|
|
return Run(vm.IsThreeWay ? (ICommand)vm.AcceptRemoteCommand : vm.TakeLineToBaseCommand,
|
|
vm.IsThreeWay ? null : vm.SelectedLine);
|
|
|
|
// 이 덩어리를 왼쪽 파일로(12차 G-2). Alt+Shift+→ 의 거울이다.
|
|
case ShortcutAction.AcceptBlockToBase: return Run(vm.AcceptBlockToBaseCommand);
|
|
|
|
case ShortcutAction.Undo: return Run(vm.UndoCommand);
|
|
case ShortcutAction.Redo: return Run(vm.RedoCommand);
|
|
|
|
// 병합 중의 Ctrl+S 는 "이 파일 해결로 저장" 이다. 예전에는 Save & Commit 이라
|
|
// 파일 하나를 저장하려다 커밋까지 가려 했다(6차 A-2). 병합이 아니면 지금까지와 같다.
|
|
case ShortcutAction.Save:
|
|
return Run(vm.IsPlainMode ? (ICommand)vm.SavePlainCommand
|
|
: vm.Workspace.IsMergeInProgress ? vm.SaveResolvedCommand
|
|
: vm.SaveAndCommitCommand);
|
|
|
|
case ShortcutAction.Recompare: return Run(vm.ReloadCommand);
|
|
|
|
case ShortcutAction.ToggleActions: return Run(vm.ToggleActionsCommand);
|
|
|
|
case ShortcutAction.FontBigger: return Run(vm.FontBiggerCommand);
|
|
case ShortcutAction.FontSmaller: return Run(vm.FontSmallerCommand);
|
|
case ShortcutAction.FontReset: return Run(vm.FontResetCommand);
|
|
|
|
case ShortcutAction.OpenFolderCompare:
|
|
UbisamBase.Core.Modules.TabManager.Activate(ViewIds.Folder);
|
|
return true;
|
|
|
|
case ShortcutAction.GoToLine:
|
|
GoToBox.Focus();
|
|
GoToBox.SelectAll();
|
|
return true;
|
|
|
|
case ShortcutAction.NextConflict: return Run(vm.NextConflictCommand);
|
|
case ShortcutAction.PrevConflict: return Run(vm.PrevConflictCommand);
|
|
case ShortcutAction.AutoMerge: return Run(vm.AutoMergeCommand);
|
|
|
|
case ShortcutAction.NextBookmark: return Run(vm.NextBookmarkCommand);
|
|
|
|
case ShortcutAction.PrevFile: return Run(vm.PrevFileCommand);
|
|
case ShortcutAction.NextFile: return Run(vm.NextFileCommand);
|
|
|
|
// 12차 E-2 문서 탭
|
|
case ShortcutAction.NextTab: return Run(vm.NextTabCommand);
|
|
case ShortcutAction.PrevTab: return Run(vm.PreviousTabCommand);
|
|
case ShortcutAction.CloseTab: return Run(vm.CloseTabCommand);
|
|
|
|
case ShortcutAction.ToggleBookmark:
|
|
if (vm.SelectedLine is null) return false;
|
|
vm.ToggleBookmarkCommand.Execute(vm.SelectedLine);
|
|
return true;
|
|
|
|
case ShortcutAction.AcceptBlock: return Run(vm.AcceptBlockCommand);
|
|
|
|
case ShortcutAction.Find: OpenFind(showReplace: false); return true;
|
|
case ShortcutAction.Replace: OpenFind(showReplace: true); return true;
|
|
case ShortcutAction.FindNext: FindStep(forward: true); return true;
|
|
case ShortcutAction.FindPrev: FindStep(forward: false); return true;
|
|
|
|
// 찾기 칸이 닫혀 있으면 Esc 는 손대지 않는다 - 다른 곳에서 쓸 자리를 뺏지 않기 위해서다.
|
|
case ShortcutAction.CloseFind:
|
|
if (FindBar.Visibility != Visibility.Visible) return false;
|
|
CloseFind();
|
|
return true;
|
|
|
|
case ShortcutAction.ShowHelp:
|
|
ShortcutHelpWindow.ShowOrActivate(Window.GetWindow(this));
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>지금 못 하는 동작(예: 고칠 게 없는데 저장)이면 키를 먹지 않고 넘긴다.</summary>
|
|
private static bool Run(ICommand command) => Run(command, null);
|
|
|
|
/// <summary>줄 하나를 받는 동작용(12차 G-2 - [<] 처럼 고른 줄에 대고 하는 것).</summary>
|
|
private static bool Run(ICommand command, object? parameter)
|
|
{
|
|
if (!command.CanExecute(parameter)) return false;
|
|
command.Execute(parameter);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 최근 목록에서 하나를 고르면 연다.
|
|
///
|
|
/// 고른 뒤 바로 선택을 지운다 - 같은 짝을 다시 고를 수 있어야 하는데,
|
|
/// 선택이 남아 있으면 두 번째부터는 아무 일도 일어나지 않는다.
|
|
/// </summary>
|
|
private void Recent_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
|
{
|
|
if (sender is not ComboBox box || box.SelectedItem is not Services.RecentPair pair) return;
|
|
|
|
box.SelectedIndex = -1;
|
|
if (DataContext is HomeViewModel vm) vm.OpenRecent(pair);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 패널 머리글 우클릭 → 전체 경로 복사.
|
|
///
|
|
/// 예전에는 읽기전용 <c>TextBox</c> 를 두어 드래그로 복사했다. 그 줄을 없애면서(C-1)
|
|
/// <b>복사하는 방법까지 없어지면 안 되므로</b> 메뉴로 옮겼다.
|
|
/// </summary>
|
|
private void CopyBasePath_Click(object sender, RoutedEventArgs e) =>
|
|
CopyPath((DataContext as HomeViewModel)?.BaseFileLocation);
|
|
|
|
private void CopyLocalPath_Click(object sender, RoutedEventArgs e) =>
|
|
CopyPath((DataContext as HomeViewModel)?.SelectedFileFullPath);
|
|
|
|
private static void CopyPath(string? path)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(path)) return;
|
|
|
|
try
|
|
{
|
|
Clipboard.SetText(path);
|
|
UbisamBase.Core.Messaging.MessageUtil.ShowSuccessToast("경로를 복사했습니다.");
|
|
}
|
|
catch (System.Runtime.InteropServices.COMException)
|
|
{
|
|
// 다른 프로그램이 클립보드를 붙들고 있으면 실패한다. 알리고 넘어간다.
|
|
UbisamBase.Core.Messaging.MessageUtil.ShowErrorToast("복사하지 못했습니다. 잠시 뒤 다시 해 보세요.");
|
|
}
|
|
}
|
|
|
|
// ================= 끌어다 놓기 =================
|
|
|
|
/// <summary>
|
|
/// 탐색기에서 끌어 온 것을 받을 수 있다고 알린다. 이 표시가 없으면 커서에
|
|
/// <b>금지 표시</b>가 뜨고 놓아도 아무 일이 없다.
|
|
/// </summary>
|
|
private void View_DragOver(object sender, DragEventArgs e)
|
|
{
|
|
e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop) ? DragDropEffects.Copy : DragDropEffects.None;
|
|
e.Handled = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 화면 아무 데나 놓았을 때. 판단은 명령줄로 들어오는 길과 <b>같은 것</b>을 쓴다 -
|
|
/// 폴더를 놓으면 폴더 비교 탭으로 넘어간다. 파일 화면이라고 폴더를 버리면 불친절하다.
|
|
/// </summary>
|
|
private void View_Drop(object sender, DragEventArgs e)
|
|
{
|
|
if (DataContext is not HomeViewModel vm) return;
|
|
if (DroppedPaths(e) is not { Length: > 0 } paths) return;
|
|
|
|
// 파일 하나를 놓았는데 왼쪽만 차 있으면 오른쪽을 채운다.
|
|
// 그러지 않으면 하나씩 끌어다 놓아서는 짝을 만들 수 없다 - 두 번째가 첫 번째를 밀어낸다.
|
|
if (paths.Length == 1 && System.IO.File.Exists(paths[0]) &&
|
|
!string.IsNullOrWhiteSpace(vm.LeftFile) && string.IsNullOrWhiteSpace(vm.RightFile))
|
|
{
|
|
vm.DropFile(paths[0], left: false);
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
|
|
vm.Workspace.RouteDroppedPaths(paths);
|
|
e.Handled = true;
|
|
}
|
|
|
|
/// <summary>왼쪽·오른쪽 칸에 바로 놓으면 <b>그 자리에만</b> 들어간다.</summary>
|
|
private void LeftFile_Drop(object sender, DragEventArgs e) => DropIntoSlot(e, left: true);
|
|
|
|
private void RightFile_Drop(object sender, DragEventArgs e) => DropIntoSlot(e, left: false);
|
|
|
|
private void DropIntoSlot(DragEventArgs e, bool left)
|
|
{
|
|
if (DataContext is not HomeViewModel vm) return;
|
|
if (DroppedPaths(e) is not { Length: > 0 } paths) return;
|
|
|
|
// 폴더를 파일 칸에 놓은 것은 뜻이 통하지 않는다. 그때는 화면 전체 규칙으로 넘긴다.
|
|
if (System.IO.Directory.Exists(paths[0]))
|
|
{
|
|
vm.Workspace.RouteDroppedPaths(paths);
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
|
|
// 두 개를 한꺼번에 놓았으면 나머지 하나는 반대쪽에 넣는다.
|
|
if (paths.Length > 1 && !System.IO.Directory.Exists(paths[1]))
|
|
{
|
|
if (left) vm.RightFile = paths[1];
|
|
else vm.LeftFile = paths[1];
|
|
}
|
|
|
|
// 칸 값만 바꾸면 화면은 옛 파일을 그대로 보여 준다. 넣고 바로 다시 견준다.
|
|
vm.DropFile(paths[0], left);
|
|
|
|
e.Handled = true;
|
|
}
|
|
|
|
private static string[]? DroppedPaths(DragEventArgs e) =>
|
|
e.Data.GetDataPresent(DataFormats.FileDrop) ? e.Data.GetData(DataFormats.FileDrop) as string[] : null;
|
|
|
|
private void ShortcutHelp_Click(object sender, RoutedEventArgs e) =>
|
|
ShortcutHelpWindow.ShowOrActivate(Window.GetWindow(this));
|
|
|
|
/// <summary>프로그램 정보 창(12차 B-2). 값은 모두 실행 파일에서 읽는다(설정 칸은 2026-09-30 에 뺐다).</summary>
|
|
private void About_Click(object sender, RoutedEventArgs e) =>
|
|
AboutWindow.ShowOrActivate(Window.GetWindow(this));
|
|
|
|
// ================= 찾기 =================
|
|
|
|
/// <summary>
|
|
/// 찾기 칸을 연다. 글자를 골라 둔 채로 눌렀으면 그 말을 그대로 채워 준다 -
|
|
/// 화면에서 눈에 띈 이름을 다시 타이핑하지 않게 하려는 것이다.
|
|
/// </summary>
|
|
private void OpenFind(bool showReplace)
|
|
{
|
|
FindBar.Visibility = Visibility.Visible;
|
|
ReplaceRow.Visibility = showReplace ? Visibility.Visible : Visibility.Collapsed;
|
|
|
|
var picked = LocalEditor.SelectedText;
|
|
if (!string.IsNullOrEmpty(picked) && !picked.Contains("\n")) FindBox.Text = picked;
|
|
|
|
if (showReplace) { ReplaceBox.Focus(); ReplaceBox.SelectAll(); }
|
|
else { FindBox.Focus(); FindBox.SelectAll(); }
|
|
}
|
|
|
|
private void CloseFind_Click(object sender, RoutedEventArgs e) => CloseFind();
|
|
|
|
private void CloseFind()
|
|
{
|
|
FindBar.Visibility = Visibility.Collapsed;
|
|
FindStatus.Text = string.Empty;
|
|
LocalEditor.Focus();
|
|
}
|
|
|
|
private void FindNext_Click(object sender, RoutedEventArgs e) => FindStep(forward: true);
|
|
private void FindPrev_Click(object sender, RoutedEventArgs e) => FindStep(forward: false);
|
|
|
|
/// <summary>
|
|
/// 지금 고른 자리를 바꾸고 다음으로 넘어간다.
|
|
///
|
|
/// 고른 자리가 찾는 말이 아니면 바꾸지 않고 <b>먼저 찾기만 한다</b> -
|
|
/// 엉뚱한 자리를 덮어쓰는 것보다 한 번 더 누르게 하는 편이 낫다.
|
|
/// </summary>
|
|
private void Replace_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
var needle = FindBox.Text;
|
|
if (string.IsNullOrEmpty(needle)) return;
|
|
|
|
var how = FindMatchCase.IsChecked == true
|
|
? StringComparison.Ordinal
|
|
: StringComparison.OrdinalIgnoreCase;
|
|
|
|
if (LocalEditor.SelectionLength == needle.Length &&
|
|
string.Equals(LocalEditor.SelectedText, needle, how))
|
|
{
|
|
LocalEditor.SelectedText = ReplaceBox.Text;
|
|
}
|
|
|
|
FindStep(forward: true);
|
|
}
|
|
|
|
/// <summary>글 전체에서 한 번에 바꾼다. 되돌리기는 편집기의 Ctrl+Z 가 맡는다.</summary>
|
|
private void ReplaceAll_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
var needle = FindBox.Text;
|
|
if (string.IsNullOrEmpty(needle)) return;
|
|
|
|
var how = FindMatchCase.IsChecked == true
|
|
? StringComparison.Ordinal
|
|
: StringComparison.OrdinalIgnoreCase;
|
|
|
|
var text = LocalEditor.Text;
|
|
var count = CountMatches(text, needle, how);
|
|
if (count == 0) { FindStatus.Text = "없음"; return; }
|
|
|
|
var caret = LocalEditor.CaretIndex;
|
|
LocalEditor.Text = ReplaceAll(text, needle, ReplaceBox.Text, how);
|
|
LocalEditor.CaretIndex = Math.Min(caret, LocalEditor.Text.Length);
|
|
|
|
FindStatus.Text = $"{count}개 바꿈";
|
|
MessageUtil.ShowSuccessToast($"{count}개를 바꿨습니다");
|
|
}
|
|
|
|
private static string ReplaceAll(string text, string needle, string with, StringComparison how)
|
|
{
|
|
var sb = new System.Text.StringBuilder(text.Length);
|
|
var at = 0;
|
|
|
|
while (true)
|
|
{
|
|
var next = text.IndexOf(needle, at, how);
|
|
if (next < 0) break;
|
|
|
|
sb.Append(text, at, next - at).Append(with);
|
|
at = next + needle.Length;
|
|
}
|
|
|
|
sb.Append(text, at, text.Length - at);
|
|
return sb.ToString();
|
|
}
|
|
|
|
|
|
/// <summary>찾기 칸에서 Enter 는 다음, Shift+Enter 는 이전, Esc 는 닫기.</summary>
|
|
private void FindBox_KeyDown(object sender, KeyEventArgs e)
|
|
{
|
|
switch (e.Key)
|
|
{
|
|
case Key.Enter:
|
|
FindStep(forward: (Keyboard.Modifiers & ModifierKeys.Shift) == 0);
|
|
e.Handled = true;
|
|
break;
|
|
|
|
case Key.Escape:
|
|
CloseFind();
|
|
e.Handled = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// LOCAL 본문에서 찾은 자리를 골라 화면에 들인다.
|
|
/// 끝(또는 처음)까지 가면 반대쪽 끝으로 한 바퀴 돈다 - 차이 이동과 같은 감각이다.
|
|
/// </summary>
|
|
private void FindStep(bool forward)
|
|
{
|
|
var needle = FindBox.Text;
|
|
if (string.IsNullOrEmpty(needle))
|
|
{
|
|
OpenFind(showReplace: ReplaceRow.Visibility == Visibility.Visible);
|
|
return;
|
|
}
|
|
|
|
var hay = LocalEditor.Text;
|
|
if (hay.Length == 0) { FindStatus.Text = "없음"; return; }
|
|
|
|
var how = FindMatchCase.IsChecked == true
|
|
? StringComparison.Ordinal
|
|
: StringComparison.OrdinalIgnoreCase;
|
|
|
|
int found;
|
|
if (forward)
|
|
{
|
|
// 지금 고른 자리 바로 다음부터 - 안 그러면 같은 자리에 계속 머문다.
|
|
var from = LocalEditor.SelectionStart + (LocalEditor.SelectionLength > 0 ? 1 : 0);
|
|
if (from >= hay.Length) from = 0;
|
|
|
|
found = hay.IndexOf(needle, from, how);
|
|
if (found < 0) found = hay.IndexOf(needle, 0, how);
|
|
}
|
|
else
|
|
{
|
|
var upto = LocalEditor.SelectionStart - 1;
|
|
found = upto >= 0 ? hay.LastIndexOf(needle, Math.Min(upto, hay.Length - 1), how) : -1;
|
|
if (found < 0) found = hay.LastIndexOf(needle, hay.Length - 1, how);
|
|
}
|
|
|
|
if (found < 0)
|
|
{
|
|
FindStatus.Text = "없음";
|
|
return;
|
|
}
|
|
|
|
LocalEditor.Select(found, needle.Length);
|
|
BringCharIntoView(found);
|
|
|
|
FindStatus.Text = $"{CountMatches(hay, needle, how)}개";
|
|
}
|
|
|
|
private static int CountMatches(string hay, string needle, StringComparison how)
|
|
{
|
|
var count = 0;
|
|
for (var at = 0; at <= hay.Length - needle.Length;)
|
|
{
|
|
var next = hay.IndexOf(needle, at, how);
|
|
if (next < 0) break;
|
|
count++;
|
|
at = next + 1;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
/// <summary>이동 상자에서 Enter 를 치면 그 줄로 간다.</summary>
|
|
private void GoToBox_KeyDown(object sender, KeyEventArgs e)
|
|
{
|
|
if (e.Key != Key.Enter) return;
|
|
if (DataContext is not HomeViewModel vm) return;
|
|
|
|
vm.GoToCommand.Execute(null);
|
|
LocalEditor.Focus();
|
|
e.Handled = true;
|
|
}
|
|
|
|
/// <summary>여백을 더블클릭하면 그 줄에 북마크를 켜고 끈다.</summary>
|
|
private void Gutter_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
|
{
|
|
if (e.ClickCount != 2) return;
|
|
if (DataContext is not HomeViewModel vm) return;
|
|
if (sender is not FrameworkElement element || element.DataContext is not MergeLine line) return;
|
|
|
|
vm.ToggleBookmarkCommand.Execute(line);
|
|
e.Handled = true;
|
|
}
|
|
|
|
private void ToggleReadOnly_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (DataContext is HomeViewModel vm) vm.IsReadOnly = !vm.IsReadOnly;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 워드랩을 켜고 끈다. 편집기와 아래층이 같은 규칙으로 접혀야 두 겹이 어긋나지 않는다.
|
|
/// 접을 때는 가로로 밀 일이 없으므로 밀어 둔 값도 되돌린다.
|
|
/// </summary>
|
|
private void ToggleWrap_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (DataContext is not HomeViewModel vm) return;
|
|
|
|
vm.WordWrap = !vm.WordWrap;
|
|
|
|
WrapMode = vm.WordWrap ? TextWrapping.Wrap : TextWrapping.NoWrap;
|
|
HorizontalBars = vm.WordWrap ? ScrollBarVisibility.Disabled : ScrollBarVisibility.Auto;
|
|
SourceBars = vm.WordWrap ? ScrollBarVisibility.Disabled : ScrollBarVisibility.Hidden;
|
|
|
|
if (vm.WordWrap)
|
|
{
|
|
TextShiftX = 0;
|
|
SourceShiftX = 0;
|
|
}
|
|
|
|
// 워드랩을 켜면 밀 곳이 없어지므로 막대도 사라져야 한다.
|
|
ApplySourceScrollUnit();
|
|
UpdateSourceScrollRange();
|
|
ScheduleResync(); // 줄 높이가 바뀌었다 - 칸 맞추기 · 잇는 선 다시(11차 F-10)
|
|
}
|
|
|
|
/// <summary>ViewModel 이 "이 행으로 가라"고 하면 캐럿과 스크롤을 함께 옮긴다.</summary>
|
|
/// <summary>
|
|
/// ViewModel 이 알려 준 <b>논리 행</b>으로 캐럿을 옮기고 그 자리가 보이게 굴린다.
|
|
///
|
|
/// TextBox 의 <c>GetCharacterIndexFromLineIndex</c> · <c>ScrollToLine</c> 은 모두
|
|
/// "화면에 보이는 줄" 기준이다. 워드랩이 켜지면 한 행이 여러 줄로 접히므로
|
|
/// 논리 행 번호를 그대로 넘기면 <b>다른 줄로 간다</b> - 실제로 "다음 차이"를 눌렀을 때
|
|
/// 상태바는 6행인데 화면에서 선택된 줄은 8행이었고, 아래로 갈수록 더 벌어졌다.
|
|
/// 그래서 논리 행 → 글자 위치로 직접 바꾼 뒤, 스크롤할 때만 다시 보이는 줄로 환산한다.
|
|
/// </summary>
|
|
private void OnScrollToRowRequested(object? sender, int row)
|
|
{
|
|
// 정렬 행 → 편집기 줄(11차 F-10 - 편집기에는 LOCAL 에 있는 줄만 있다). LOCAL 에 없는 행이면 바로 다음 줄.
|
|
var line = DataContext is HomeViewModel vm ? vm.EditorLineOfRow(row) : row;
|
|
var start = CharacterIndexOfLine(LocalEditor.Text, line);
|
|
if (start < 0) return;
|
|
|
|
LocalEditor.CaretIndex = start;
|
|
LocalEditor.Focus();
|
|
|
|
// 배치가 끝난 뒤에 굴린다. 글을 갈아 끼운 직후에는 글자 좌표를 물어봐야
|
|
// 옛 정보가 나온다. 그 줄의 윗변을 칸 <b>가운데</b>에 둔다(11차 F-10, Araxis 처럼) - LOCAL 에서 지운 덩어리로 가면
|
|
// 윗변이 곧 지운 자리라, 다른 칸이 가운데 기준으로 따라오며 지운 줄들이 BASE 칸 가운데에 온다.
|
|
Dispatcher.BeginInvoke(new Action(() => CenterCharInView(start)), DispatcherPriority.Loaded);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 캐럿이 있는 줄을 화면 위쪽에 여유를 두고 보이게 굴린다.
|
|
///
|
|
/// <c>TextBox.ScrollToLine</c> 을 쓰지 않는다 - 그 API 는 "화면에 보이는 줄" 번호를 받는데
|
|
/// 워드랩이 켜지면 논리 행과 어긋나고, 이 화면처럼 편집기가 세 겹의 스크롤 주인일 때는
|
|
/// 아예 굴러가지 않는 경우가 있었다(실제로 마지막 차이로 가도 화면이 맨 위에 남았다).
|
|
/// 대신 캐럿의 실제 좌표를 재서 스크롤뷰어를 그만큼 직접 옮긴다 - 워드랩이든 아니든 같다.
|
|
/// </summary>
|
|
private void BringCaretIntoView() => BringCharIntoView(LocalEditor.CaretIndex);
|
|
|
|
/// <summary>그 글자가 있는 줄의 윗변을 편집기 칸 가운데로 굴린다(차이 이동 · 11차 F-10).</summary>
|
|
private void CenterCharInView(int charIndex)
|
|
{
|
|
if (FindScrollViewer(LocalEditor) is not { } viewer) return;
|
|
|
|
Rect rect;
|
|
try
|
|
{
|
|
rect = LocalEditor.GetRectFromCharacterIndex(Math.Min(charIndex, LocalEditor.Text.Length));
|
|
}
|
|
catch (InvalidOperationException)
|
|
{
|
|
return;
|
|
}
|
|
if (double.IsInfinity(rect.Top) || double.IsNaN(rect.Top)) return;
|
|
|
|
var y = Clamp(viewer.VerticalOffset + rect.Top - viewer.ViewportHeight / 2, viewer.ScrollableHeight);
|
|
viewer.ScrollToVerticalOffset(y);
|
|
|
|
// 다른 칸은 <b>여기서 직접</b> 맞춘다(12차 G-7). 편집기의 ScrollChanged 에 맡기면 안 된다 -
|
|
// 캐럿을 옮기는 순간 편집기가 "캐럿을 화면에 들이려고" 먼저 한 번 구르고, 그 스크롤이 건 메아리 막기
|
|
// (HoldSync)는 Input 우선순위에서 풀리는데 이 가운데 맞추기는 그보다 앞선 Loaded 에서 돈다.
|
|
// 그래서 이 두 번째 스크롤은 메아리로 여겨져 버려졌고, 목록 칸은 첫 스크롤 자리(차이 줄이 칸 맨 아래)에
|
|
// 남았다. 뒤로 크게 돌면 표시층 맞추기가 그 차이를 이동값으로 메워 두 칸이 통째로 반 칸 밀려 아래가 잘렸다
|
|
// (기록: 편집기 74.4 · 목록 436.7 · 이동값 357).
|
|
HoldSync();
|
|
Scroll(LocalLines, y + Bias(LocalLines), null);
|
|
SyncPanesFrom(MergeLine.TextPane.Local, y, viewer.ViewportHeight);
|
|
}
|
|
|
|
/// <summary>글 안의 어느 자리든 화면에 들여 놓는다(캐럿·찾은 자리 공통).</summary>
|
|
private void BringCharIntoView(int charIndex)
|
|
{
|
|
if (FindScrollViewer(LocalEditor) is not { } viewer) return;
|
|
|
|
Rect caret;
|
|
try
|
|
{
|
|
caret = LocalEditor.GetRectFromCharacterIndex(charIndex);
|
|
}
|
|
catch (InvalidOperationException)
|
|
{
|
|
return; // 아직 배치 전이면 좌표를 못 준다.
|
|
}
|
|
|
|
if (double.IsInfinity(caret.Top) || double.IsNaN(caret.Top)) return;
|
|
|
|
var margin = caret.Height > 0 ? caret.Height * 3 : 60;
|
|
var wanted = viewer.VerticalOffset + caret.Top - margin;
|
|
|
|
// 이미 화면 안 적당한 자리에 있으면 굳이 흔들지 않는다.
|
|
if (caret.Top >= margin && caret.Top + caret.Height <= viewer.ViewportHeight - margin) return;
|
|
|
|
viewer.ScrollToVerticalOffset(Math.Max(0, wanted));
|
|
}
|
|
|
|
/// <summary>논리 행(0부터)의 첫 글자 위치. 그런 행이 없으면 -1.</summary>
|
|
private static int CharacterIndexOfLine(string text, int row)
|
|
{
|
|
if (row < 0) return -1;
|
|
if (row == 0) return 0;
|
|
|
|
var line = 0;
|
|
|
|
for (var i = 0; i < text.Length; i++)
|
|
{
|
|
if (text[i] != '\n') continue;
|
|
|
|
line++;
|
|
if (line == row) return i + 1;
|
|
}
|
|
|
|
return -1;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 각 패널의 글 높이를 행 모델에 알려 준다.
|
|
/// 두 패널이 서로의 높이를 알아야 워드랩에서 더 높은 쪽에 맞춰 행이 맞물린다.
|
|
/// </summary>
|
|
private static void ReportTextHeight(object sender, SizeChangedEventArgs e, MergeLine.TextPane pane)
|
|
{
|
|
if (sender is FrameworkElement element && element.DataContext is MergeLine line)
|
|
line.ReportTextHeight(pane, e.NewSize.Height);
|
|
}
|
|
|
|
private void BaseText_SizeChanged(object sender, SizeChangedEventArgs e) =>
|
|
ReportTextHeight(sender, e, MergeLine.TextPane.Base);
|
|
|
|
private void LocalText_SizeChanged(object sender, SizeChangedEventArgs e) =>
|
|
ReportTextHeight(sender, e, MergeLine.TextPane.Local);
|
|
|
|
private void RemoteText_SizeChanged(object sender, SizeChangedEventArgs e) =>
|
|
ReportTextHeight(sender, e, MergeLine.TextPane.Remote);
|
|
|
|
private void OriginalLocalText_SizeChanged(object sender, SizeChangedEventArgs e) =>
|
|
ReportTextHeight(sender, e, MergeLine.TextPane.OriginalLocal);
|
|
|
|
/// <summary>미니맵에서 찍은 위치의 줄로 세 겹을 함께 이동시킨다.</summary>
|
|
private void MiniMap_LineRequested(object sender, MergeLine line)
|
|
{
|
|
if (DataContext is not HomeViewModel vm) return;
|
|
|
|
vm.SelectedLine = line;
|
|
|
|
var index = vm.Lines.IndexOf(line);
|
|
if (index < 0) return;
|
|
|
|
// 스크롤 주인은 편집기 하나다. 표시층 ListBox 를 직접 굴리면 여백까지 밀려 나간다.
|
|
OnScrollToRowRequested(this, index);
|
|
}
|
|
|
|
private static ScrollViewer? FindScrollViewer(DependencyObject root)
|
|
{
|
|
if (root is ScrollViewer viewer) return viewer;
|
|
|
|
var count = VisualTreeHelper.GetChildrenCount(root);
|
|
for (var i = 0; i < count; i++)
|
|
{
|
|
var found = FindScrollViewer(VisualTreeHelper.GetChild(root, i));
|
|
if (found is not null) return found;
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
}
|