using System; using System.Collections.Generic; using System.Collections.Specialized; using System.Windows; using System.Windows.Controls; using System.Windows.Data; using System.Windows.Media; using System.Windows.Threading; using MergeHub.Models; using MergeHub.Services; using MergeHub.ViewModels; namespace MergeHub.Views { /// /// 칸마다 자기 줄만 보이는 화면(11차 F-10, Araxis 식). /// /// 속은 예전처럼 정렬 행 하나()다 - 차이 · 반영 · 되돌리기 · 차이 이동이 그대로 쓴다. /// 칸마다 그 칸에 있는 줄만 걸러 보이고(빈 줄로 높이를 맞추지 않는다), 두 칸의 짝은 틈에 그리는 잇는 선으로 보인다. /// 스크롤은 한 칸을 굴리면 나머지가 가운데 기준으로 따라온다(). /// public partial class HomeView { /// 그 칸에 있는 줄만 보이는 목록. 반영 · 되살리기로 LOCAL 줄이 생기고 사라지면 바로 따라간다. private static ListCollectionView PaneView(HomeViewModel vm, MergeLine.TextPane pane) { var view = new ListCollectionView(vm.Lines) { Filter = o => o is MergeLine line && line.IsIn(pane) }; if (pane == MergeLine.TextPane.Local) { view.IsLiveFiltering = true; view.LiveFilteringProperties.Add(nameof(MergeLine.HasLocal)); } else if (pane == MergeLine.TextPane.Base) { // 두 파일 비교는 왼쪽으로도 반영한다(12차 B-1) - 왼쪽에 줄이 생기고 사라지므로 BASE 칸도 따라가야 한다. // 이게 없으면 왼쪽에 넣은 줄이 안 보이고, 왼쪽에서 지운 줄이 빈 칸으로 남는다. view.IsLiveFiltering = true; view.LiveFilteringProperties.Add(nameof(MergeLine.HasBase)); } return view; } private HomeViewModel? _linkedVm; /// 네 칸에 자기 줄 목록을 붙인다. 새 ViewModel 이 올 때 한 번. private void BindPaneViews(HomeViewModel? vm) { if (_linkedVm is not null) _linkedVm.Lines.CollectionChanged -= OnLinesChanged; _linkedVm = vm; if (vm is null) { foreach (var list in new[] { LocalLines, BaseLines, RemoteLines, OriginalLocalLines }) list.ItemsSource = null; return; } LocalLines.ItemsSource = PaneView(vm, MergeLine.TextPane.Local); BaseLines.ItemsSource = PaneView(vm, MergeLine.TextPane.Base); RemoteLines.ItemsSource = PaneView(vm, MergeLine.TextPane.Remote); OriginalLocalLines.ItemsSource = PaneView(vm, MergeLine.TextPane.OriginalLocal); vm.Lines.CollectionChanged += OnLinesChanged; LinkOverlay.Draw = DrawLinks; } private void OnLinesChanged(object? sender, NotifyCollectionChangedEventArgs e) => ScheduleResync(); private bool _resyncQueued; /// 글 · 줄이 바뀌면 칸마다 높이가 달라진다 - 배치가 끝난 뒤 편집기 기준으로 한 번 다시 맞추고 선을 다시 그린다. private void ScheduleResync() { if (_resyncQueued) return; _resyncQueued = true; Dispatcher.BeginInvoke(new Action(() => { _resyncQueued = false; if (FindScrollViewer(LocalEditor) is { } viewer) { HoldSync(); SyncPanesFrom(MergeLine.TextPane.Local, viewer.VerticalOffset, viewer.ViewportHeight); } LinkOverlay.InvalidateVisual(); }), DispatcherPriority.Loaded); } /// /// 칸 목록에서 줄을 눌렀다. 선택 줄은 ViewModel 이 쥔다 - 목록은 한 방향으로만 받는다(OneWay). /// 고른 줄이 그 칸에 없으면(다른 칸에만 있는 줄) 목록 선택은 비지만, 그것을 ViewModel 로 되돌려 보내지 않는다. /// private void PaneLines_SelectionChanged(object sender, SelectionChangedEventArgs e) { if (DataContext is not HomeViewModel vm) return; if (e.AddedItems.Count == 0 || e.AddedItems[0] is not MergeLine line) return; if (!ReferenceEquals(vm.SelectedLine, line)) vm.SelectedLine = line; } /// 지금 보이는 칸들(목록 · 칸 종류). LOCAL 은 편집기가 주인이라 목록과 함께 따로 다룬다. private (ListBox List, MergeLine.TextPane Kind)[] VisiblePanes(HomeViewModel vm) => vm.IsThreeWay ? new[] { (OriginalLocalLines, MergeLine.TextPane.OriginalLocal), (BaseLines, MergeLine.TextPane.Base), (RemoteLines, MergeLine.TextPane.Remote), (LocalLines, MergeLine.TextPane.Local), } : new[] { (BaseLines, MergeLine.TextPane.Base), (LocalLines, MergeLine.TextPane.Local), }; /// 잇는 선을 그릴 칸 짝(왼쪽 · 오른쪽). 3-Way 는 위 세 칸 사이 두 틈(사용자 결정 - MERGED 는 따라 스크롤만). private (ListBox Left, MergeLine.TextPane LeftKind, ListBox Right, MergeLine.TextPane RightKind)[] LinkPairs(HomeViewModel vm) => vm.IsThreeWay ? new[] { (OriginalLocalLines, MergeLine.TextPane.OriginalLocal, BaseLines, MergeLine.TextPane.Base), (BaseLines, MergeLine.TextPane.Base, RemoteLines, MergeLine.TextPane.Remote), } : new[] { (BaseLines, MergeLine.TextPane.Base, LocalLines, MergeLine.TextPane.Local), }; /// 워드랩을 켰을 때만 칸마다 잰 줄 높이를 쓴다. 끄면 모든 줄이 한 줄 높이라 그것이 정확하다. private bool MeasuredRows => WrapMode == TextWrapping.Wrap; /// 정렬 행마다 그 칸에서의 높이(없는 줄은 0). private double[] PaneHeights(HomeViewModel vm, MergeLine.TextPane pane) { var lines = vm.Lines; var heights = new double[lines.Count]; var measured = MeasuredRows; for (var i = 0; i < heights.Length; i++) heights[i] = lines[i].PaneHeight(pane, measured); return heights; } /// /// 한 칸()이 굴렀다 - 나머지 칸을 가운데 기준으로 따라오게 한다. /// 같은 줄이 이어지는 곳은 1:1, 한쪽에만 있는 덩어리를 지날 때는 비율로 따라가 한 번에 튀지 않는다. /// private void SyncPanesFrom(MergeLine.TextPane master, double offset, double viewport) { if (DataContext is not HomeViewModel vm || vm.Lines.Count == 0) return; var fromHeights = PaneHeights(vm, master); var center = offset + viewport / 2; foreach (var (list, kind) in VisiblePanes(vm)) { if (kind == master) continue; var map = new PaneMapping(fromHeights, PaneHeights(vm, kind)); var target = map.Map(center); if (kind == MergeLine.TextPane.Local) { // LOCAL 칸의 주인은 편집기다. 아래 표시층은 편집기와 같은 자리로. if (FindScrollViewer(LocalEditor) is not { } editor) continue; var y = Clamp(target - editor.ViewportHeight / 2, editor.ScrollableHeight); editor.ScrollToVerticalOffset(y); Scroll(LocalLines, y, null); continue; } if (FindScrollViewer(list) is not { } viewer) continue; viewer.ScrollToVerticalOffset(Clamp(target - viewer.ViewportHeight / 2, viewer.ScrollableHeight)); } LinkOverlay.InvalidateVisual(); } private static double Clamp(double value, double max) => Math.Max(0, Math.Min(value, Math.Max(0, max))); // ================= 잇는 선 그리기 ================= /// 한 칸의 세로 좌표를 그림판 기준으로 재는 도구. 그릴 때마다 새로 만든다. private sealed class PaneGeometry { private readonly ListBox _list; private readonly Visual _to; private readonly int[] _index; // 정렬 행 i 앞에 이 칸의 줄이 몇 개 있는지 (길이 n+1) private readonly double[] _tops; // 이 칸 k 번째 줄의 윗변(모델 높이 기준, 길이 count+1) private readonly int _anchorIndex = -1; private readonly double _anchorTop; public PaneGeometry(ListBox list, Visual to, IList lines, MergeLine.TextPane pane, bool measured) { _list = list; _to = to; var n = lines.Count; _index = new int[n + 1]; var heights = new List(n); for (var i = 0; i < n; i++) { var h = lines[i].PaneHeight(pane, measured); _index[i + 1] = _index[i] + (h > 0 ? 1 : 0); if (h > 0) heights.Add(h); } _tops = PaneLinks.Tops(heights); Count = heights.Count; var origin = list.TransformToVisual(to).Transform(new Point(0, 0)); Left = origin.X; Top = origin.Y; Right = origin.X + list.ActualWidth; Bottom = origin.Y + list.ActualHeight; // 어림의 기준점: 칸 안에 보이는 첫 줄 if (FirstVisibleContainer(list) is { } first) { _anchorIndex = list.ItemContainerGenerator.IndexFromContainer(first); if (_anchorIndex >= 0) _anchorTop = first.TransformToVisual(to).Transform(new Point(0, 0)).Y; } else if (FindScrollViewer(list) is { } viewer) { _anchorIndex = 0; _anchorTop = Top - viewer.VerticalOffset; } } public int Count { get; } public double Left { get; } public double Right { get; } public double Top { get; } public double Bottom { get; } public bool Ok => _anchorIndex >= 0 && Right > Left; /// 정렬 행 경계 i(행 i 의 윗변)의 세로 자리. 그려진 줄이면 그 자리, 아니면 보이는 첫 줄에서 모델 높이로 어림. public double Y(int i) { var k = _index[Math.Max(0, Math.Min(i, _index.Length - 1))]; if (k < Count && _list.ItemContainerGenerator.ContainerFromIndex(k) is FrameworkElement item && item.IsVisible) return item.TransformToVisual(_to).Transform(new Point(0, 0)).Y; if (k == Count && k > 0 && _list.ItemContainerGenerator.ContainerFromIndex(k - 1) is FrameworkElement last && last.IsVisible) return last.TransformToVisual(_to).Transform(new Point(0, last.ActualHeight)).Y; return _anchorTop + (_tops[k] - _tops[Math.Min(_anchorIndex, Count)]); } } private static readonly Dictionary LinkPens = new Dictionary(); private static (Brush Fill, Pen Edge, Pen Strong) PensFor(Brush highlight) { var c = highlight is SolidColorBrush solid ? solid.Color : Colors.Gray; if (LinkPens.TryGetValue(c, out var found)) return found; var fill = new SolidColorBrush(Color.FromArgb(0x38, c.R, c.G, c.B)); var line = new SolidColorBrush(Color.FromArgb(0xD0, c.R, c.G, c.B)); fill.Freeze(); line.Freeze(); var edge = new Pen(line, 1); var strong = new Pen(line, 2); edge.Freeze(); strong.Freeze(); var pens = (fill, edge, strong); LinkPens[c] = pens; return pens; } private static readonly Pen MovePen = CreateMovePen(); private static Pen CreateMovePen() { var c = ((SolidColorBrush)Controls.LineHighlightBrushes.Moved).Color; var pen = new Pen(new SolidColorBrush(Color.FromArgb(0xF0, c.R, c.G, c.B)), 2.5) { DashStyle = DashStyles.Dash }; pen.Freeze(); return pen; } /// 덩어리 색. 두 칸 가운데 어느 쪽에만 있는지 · 충돌인지로 가른다. private static Brush BlockBrush(IList lines, LinkBlock block, MergeLine.TextPane left, MergeLine.TextPane right) { bool anyLeft = false, anyRight = false, conflict = false; LineHighlight first = LineHighlight.None; for (var i = block.Start; i < block.End; i++) { var line = lines[i]; anyLeft |= line.IsIn(left); anyRight |= line.IsIn(right); conflict |= line.IsThreeWay && line.IsConflict; if (first == LineHighlight.None) first = line.Highlight; } if (conflict) return Controls.LineHighlightBrushes.Conflict; // BASE 쪽에만 있으면 지운 것, BASE 가 아닌 쪽에만 있으면 넣은 것. BASE 가 왼쪽(2-Way · BASE|REMOTE)일 수도 // 오른쪽(LOCAL(Mine)|BASE)일 수도 있다 - 방향을 따라 정한다. var baseIsLeft = left == MergeLine.TextPane.Base; var onlyBase = baseIsLeft ? !anyRight : !anyLeft; var onlyOther = baseIsLeft ? !anyLeft : !anyRight; if (onlyOther) return right == MergeLine.TextPane.Remote ? Controls.LineHighlightBrushes.RemoteChange : Controls.LineHighlightBrushes.Added; if (onlyBase) return Controls.LineHighlightBrushes.Removed; if (right == MergeLine.TextPane.Remote) return Controls.LineHighlightBrushes.RemoteChange; return first is LineHighlight.Edited or LineHighlight.Applied ? Controls.LineHighlightBrushes.For(first) : Controls.LineHighlightBrushes.Changed; } /// 그림판에 잇는 선을 그린다. 칸이 구를 때 · 글이 바뀔 때마다 다시 그린다. private void DrawLinks(DrawingContext dc) { if (DataContext is not HomeViewModel vm) return; var lines = vm.Lines; if (lines.Count == 0) return; var measured = MeasuredRows; foreach (var (leftList, leftKind, rightList, rightKind) in LinkPairs(vm)) { if (!leftList.IsVisible || !rightList.IsVisible || leftList.ActualHeight <= 0 || rightList.ActualHeight <= 0) continue; PaneGeometry left, right; try { left = new PaneGeometry(leftList, LinkOverlay, lines, leftKind, measured); right = new PaneGeometry(rightList, LinkOverlay, lines, rightKind, measured); } catch (InvalidOperationException) { continue; // 아직 배치 전 - 다음 그리기에서 } if (!left.Ok || !right.Ok) continue; var top = Math.Max(left.Top, right.Top); var bottom = Math.Min(left.Bottom, right.Bottom); if (bottom <= top) continue; dc.PushClip(new RectangleGeometry(new Rect(left.Left, top, Math.Max(0, right.Right - left.Left), bottom - top))); var changed = new bool[lines.Count]; for (var i = 0; i < changed.Length; i++) changed[i] = lines[i].DiffersBetween(leftKind, rightKind); foreach (var block in PaneLinks.Blocks(changed)) { var l1 = left.Y(block.Start); var l2 = left.Y(block.End); var r1 = right.Y(block.Start); var r2 = right.Y(block.End); if (Math.Max(l2, r2) < top - 2 || Math.Min(l1, r1) > bottom + 2) continue; var (fill, edge, strong) = PensFor(BlockBrush(lines, block, leftKind, rightKind)); DrawBlock(dc, fill, edge, strong, left, right, l1, l2, r1, r2); } // 자리만 옮긴 덩어리 - 떠난 자리(BASE)에서 옮겨 간 자리(LOCAL)까지 점선으로(두 칸 비교만) if (!vm.IsThreeWay) DrawMoves(dc, lines, left, right, top, bottom); dc.Pop(); } } /// /// 덩어리 하나: 두 칸 안의 위 · 아래 경계선과 틈의 사다리꼴. 한쪽에 없는 덩어리는 그 칸에 굵은 선 하나(어디에 들어갈 자리인지). /// private static void DrawBlock(DrawingContext dc, Brush fill, Pen edge, Pen strong, PaneGeometry left, PaneGeometry right, double l1, double l2, double r1, double r2) { DrawEdges(dc, edge, strong, left.Left, left.Right, l1, l2); DrawEdges(dc, edge, strong, right.Left, right.Right, r1, r2); var gap = new StreamGeometry(); using (var g = gap.Open()) { g.BeginFigure(new Point(left.Right, l1), isFilled: true, isClosed: true); g.LineTo(new Point(right.Left, r1), isStroked: true, isSmoothJoin: false); g.LineTo(new Point(right.Left, r2), isStroked: false, isSmoothJoin: false); g.LineTo(new Point(left.Right, l2), isStroked: true, isSmoothJoin: false); } gap.Freeze(); dc.DrawGeometry(fill, edge, gap); } private static void DrawEdges(DrawingContext dc, Pen edge, Pen strong, double x1, double x2, double y1, double y2) { if (y2 - y1 < 0.5) { dc.DrawLine(strong, new Point(x1, y1), new Point(x2, y1)); return; } dc.DrawLine(edge, new Point(x1, y1), new Point(x2, y1)); dc.DrawLine(edge, new Point(x1, y2), new Point(x2, y2)); } private static void DrawMoves(DrawingContext dc, IList lines, PaneGeometry left, PaneGeometry right, double top, double bottom) { // 짝 번호 → [떠난 자리 시작, 끝, 옮겨 간 자리 시작, 끝] (정렬 행) var groups = new Dictionary(); for (var i = 0; i < lines.Count; i++) { var line = lines[i]; if (line.MoveGroup == 0 || !line.IsDifferent) continue; var fromSide = line.MoveFromBase && line.HasBase; var toSide = !line.MoveFromBase && line.HasLocal; if (!fromSide && !toSide) continue; if (!groups.TryGetValue(line.MoveGroup, out var g)) groups[line.MoveGroup] = g = new[] { -1, -1, -1, -1 }; var at = fromSide ? 0 : 2; if (g[at] < 0) g[at] = i; g[at + 1] = i + 1; } foreach (var g in groups.Values) { if (g[0] < 0 || g[2] < 0) continue; var l1 = left.Y(g[0]); var l2 = left.Y(g[1]); var r1 = right.Y(g[2]); var r2 = right.Y(g[3]); if (Math.Max(l2, r2) < top || Math.Min(l1, r1) > bottom) continue; // 틈을 가로지르는 두 곡선 - 위끼리 · 아래끼리 var mid = (left.Right + right.Left) / 2; var curve = new StreamGeometry(); using (var c = curve.Open()) { c.BeginFigure(new Point(left.Right, (l1 + l2) / 2), isFilled: false, isClosed: false); c.BezierTo(new Point(mid, (l1 + l2) / 2), new Point(mid, (r1 + r2) / 2), new Point(right.Left, (r1 + r2) / 2), isStroked: true, isSmoothJoin: true); } curve.Freeze(); dc.DrawGeometry(null, MovePen, curve); } } } }