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