B 묶음: 3-Way 화면 세 가지
B-1. 원본 세 칸에 가로 스크롤 막대 신설. 이 칸들은 ScrollViewer 를 가로로 굴리지 않고(줄 번호까지 밀려 나간다) 글자만 SourceShiftX 로 민다. 그래서 ScrollViewer 가 만드는 막대는 움직이지 않는 막대가 되어 감춰 두고 있었고, 긴 줄이 있다는 사실 자체를 알 수 없었다. 막대를 따로 놓고 SourceShiftX 와 값을 서로 맞춘다. B-2. 워드랩을 켠 3-Way 에서 세로 스크롤이 아예 안 되던 문제. 원인이 둘이었다. - 메아리를 막는 자물쇠를 try/finally 로 그 자리에서 풀고 있었다. 목록의 스크롤 알림은 배치가 끝난 다음에 오므로 그때는 이미 열려 있었고, 그 메아리가 다시 편집기를 굴렸다. 이제 그 일로 밀려서 오는 알림이 다 지나간 뒤에 푼다. - 두 좌표계를 픽셀로 환산하고 있었는데, 목록이 가상화라 안 그려진 행의 높이를 알 수 없어 환산이 어긋났다. 워드랩일 때는 행 단위로 굴려 "몇 번째 행이 맨 위인가"만 주고받는다. 곁들여, 편집기 맨 위 행을 스크롤 값 나눗셈이 아니라 좌표로 물어본다. B-3. Take Mine 을 눌러도 남은 충돌이 줄지 않던 문제. "내 것으로 간다"는 결정은 글을 바꾸지 않아 되돌리기 스택에 남지 않는데, RefreshAppliedFlags 가 스택만 보고 반영 표시를 다시 칠하면서 그 표시를 지웠다. 줄 번호로 기억해 둔 결정을 같이 보도록 고치고, 같은 판단을 IsDecidedAsMine 하나로 합쳤다. 확인: 막대 끌기/가로 휠/워드랩 토글, 워드랩 켠 채 네 칸이 같은 행을 맨 위에 놓는 것, 충돌 6개를 Take Mine/Theirs 번갈아 눌러 "충돌 정리 끝"까지. 테스트 399건 그대로 통과. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
b5bd06cb49
commit
179dffb1ad
64
doc/진행상황.md
64
doc/진행상황.md
@ -110,9 +110,9 @@ CommunityToolkit 커맨드는 CommandManager 를 구독하지 않는데 이 둘
|
||||
|
||||
| 상태 | 항목 |
|
||||
|---|---|
|
||||
| 대기 | B-1 가로 스크롤 막대가 없음 |
|
||||
| 대기 | B-2 아래로 내리면 칸마다 보이는 행 수가 다름 |
|
||||
| 대기 | B-3 Take Mine / Take Theirs 3-Way 동작 확인 |
|
||||
| **완료** | B-1 가로 스크롤 막대가 없음 |
|
||||
| **완료** | B-2 아래로 내리면 칸마다 보이는 행 수가 다름 |
|
||||
| **완료** | B-3 Take Mine / Take Theirs 3-Way 동작 확인 (동작하지 않아 고침) |
|
||||
|
||||
### C. 잔손질
|
||||
|
||||
@ -136,6 +136,64 @@ CommunityToolkit 커맨드는 CommandManager 를 구독하지 않는데 이 둘
|
||||
|
||||
---
|
||||
|
||||
### B. 3-Way 화면 — **완료** (2026-09-09)
|
||||
|
||||
#### B-1. 원본 세 칸에 가로 스크롤 막대가 없었다
|
||||
|
||||
원본 칸은 ScrollViewer 를 <b>가로로 굴리지 않는다.</b> 굴리면 줄 번호까지 화면 밖으로
|
||||
밀려 나가고 세 칸이 서로 어긋난다. 대신 글자만 `SourceShiftX` 로 민다. 그래서 ScrollViewer 가
|
||||
만들어 주는 막대는 <b>움직이지 않는 막대</b>가 되어 감춰 두고 있었고, 결과적으로 긴 줄이
|
||||
있다는 사실 자체를 알 수 없었다(가로 휠을 굴려 봐야 알 수 있었다).
|
||||
|
||||
막대를 따로 놓고 `SourceShiftX` 와 서로 값을 맞추게 했다. 세 칸이 늘 같이 움직이므로
|
||||
세 막대의 손잡이도 같은 자리에 있다. 워드랩을 켜면 밀 곳이 없으므로 사라진다.
|
||||
|
||||
**확인**: 손잡이를 끌면 세 칸이 함께 움직이고, 가로 휠을 굴리면 손잡이가 따라온다.
|
||||
워드랩을 켜면 세 막대가 사라지고 끄면 돌아온다.
|
||||
|
||||
#### B-2. 워드랩을 켠 3-Way 에서 세로 스크롤이 아예 안 됐다
|
||||
|
||||
증상은 "행 수가 다르다"였는데, 재 보니 <b>스크롤이 한 자리에 눌러앉아 있었다</b>.
|
||||
스크롤 값을 350 으로 바꿔도 곧바로 80 으로 되돌아왔다.
|
||||
|
||||
원인이 둘이었다.
|
||||
|
||||
**하나 — 메아리를 막는 자물쇠가 너무 일찍 풀렸다.** 편집기가 굴러가면 원본 칸을 따라
|
||||
굴리는데, 목록(ListBox)의 스크롤 알림은 <b>배치가 끝난 다음</b>에 온다. 자물쇠를
|
||||
`try/finally` 로 그 자리에서 풀어 버리니 그때는 이미 열려 있었고, 그 메아리가 다시 편집기를
|
||||
굴렸다. 워드랩이 꺼져 있으면 환산이 1:1 이라 같은 값이 되돌아와 조용히 끝나지만,
|
||||
켜면 조금씩 어긋나 서로 밀어내다 멈춘다. 자물쇠를 <b>그 일로 밀려서 오는 알림이 다 지나간
|
||||
뒤</b>(`DispatcherPriority.Input`)에 풀도록 바꿨다.
|
||||
|
||||
**둘 — 두 좌표계를 픽셀로 환산하고 있었다.** 위 세 칸과 아래 MERGED 는 폭이 달라 접히는
|
||||
횟수가 다르므로, 픽셀로 맞추려면 행마다 실제 높이를 알아야 한다. 그런데 목록은 <b>가상화</b>라
|
||||
화면에 그려진 행만 자기 높이를 알려 준다. 아직 안 그려진 행은 "한 줄"로 잡혀 환산이 어긋났다.
|
||||
<b>재지 않고 세는</b> 쪽으로 바꿨다 - 워드랩일 때는 원본 칸을 행 단위로 굴려
|
||||
"몇 번째 행이 맨 위인가"만 주고받는다. 높이를 몰라도 정확히 맞는다.
|
||||
|
||||
곁들여, 편집기 화면 맨 위의 행을 알아낼 때 <b>스크롤 값을 줄 높이로 나누던 것</b>을
|
||||
좌표로 물어보는 방식으로 바꿨다. 실제로 재 보니 스크롤 160 에서 편집기가 말하는 첫 줄은
|
||||
11 인데 화면 맨 위에 있는 것은 9행이었다. TextBox 의 줄 번호 계산은 그려진 것과 맞지 않는다.
|
||||
|
||||
**확인**: 워드랩을 켠 3-Way 에서 네 칸이 같은 행을 맨 위에 놓는다(원본 09 / BASE 05 /
|
||||
REMOTE 05 = 같은 줄, MERGED 09). 워드랩을 끈 경우도 그대로다(16 / 12 / 12 / 16).
|
||||
|
||||
#### B-3. Take Mine 이 충돌을 정리하지 못했다
|
||||
|
||||
Take Theirs 는 되는데 <b>Take Mine 을 눌러도 "남은 충돌" 이 줄지 않았다.</b>
|
||||
|
||||
"내 것으로 간다"는 결정은 <b>글을 바꾸지 않는다</b>(이미 내 원본과 같은 줄이다).
|
||||
그래서 되돌리기 스택에 남지 않는데, 편집 뒤 정리 단계(`RefreshAppliedFlags`)가
|
||||
<b>되돌리기 스택만 보고</b> 반영 표시를 다시 칠하면서 방금 세운 표시를 지워 버렸다.
|
||||
줄 번호로 따로 기억해 둔 결정(`_resolvedBaseNumbers` · `_resolvedMineNumbers`)을
|
||||
같이 보도록 고쳤다. 같은 판단이 두 군데 흩어져 있던 것도 `IsDecidedAsMine` 하나로 합쳤다.
|
||||
|
||||
**확인**: 충돌 6개짜리 C++ 파일에서 Take Mine · Take Theirs 를 번갈아 눌러
|
||||
6 → 5 → 4 → … → <b>충돌 정리 끝</b> 까지 갔다.
|
||||
|
||||
**검사**: 전체 399건 그대로 통과.
|
||||
---
|
||||
|
||||
### A-5. 폴더 고르는 창에서 상위 폴더를 고를 수 없던 문제 — **완료** (2026-09-09)
|
||||
|
||||
**처음에 요구를 잘못 읽었다.** "최상위 단에서 봐야 하는데 가장 깊은 곳에서 폴더 열기밖에 안 된다"를
|
||||
|
||||
@ -1290,10 +1290,7 @@ namespace MergeHub.ViewModels
|
||||
row.IsManuallyEdited = true;
|
||||
|
||||
// "내 것으로 간다"고 정했던 충돌은 내용이 그대로라 글만 봐서는 알 수 없다.
|
||||
if (row.BaseNumber is int baseNumber
|
||||
? _resolvedBaseNumbers.Contains(baseNumber)
|
||||
: row.OriginalLocalNumber is int mineNumber && _resolvedMineNumbers.Contains(mineNumber))
|
||||
row.IsApplied = true;
|
||||
if (IsDecidedAsMine(row)) row.IsApplied = true;
|
||||
|
||||
Lines.Add(row);
|
||||
}
|
||||
@ -2012,6 +2009,18 @@ namespace MergeHub.ViewModels
|
||||
/// "머지 반영함"과 "직접 수정" 표시를 편집 이력에서 다시 계산한다.
|
||||
/// 둘을 섞으면 안 된다 - 사용자가 직접 지운 줄이 "머지 반영함"으로 보이면 안 되기 때문이다.
|
||||
/// Undo 로 되돌린 줄은 자동으로 표시가 풀린다.
|
||||
/// <summary>
|
||||
/// "내 것으로 간다"고 정해 둔 충돌 줄인지.
|
||||
///
|
||||
/// 그 결정은 <b>글을 바꾸지 않는다</b>(이미 내 원본과 같은 줄이다). 그래서 되돌리기
|
||||
/// 스택에도 남지 않고, 다시 정렬하면 줄 객체 자체가 새로 만들어진다.
|
||||
/// 붙들어 둘 것이 줄 번호밖에 없어 여기에 따로 모아 둔다.
|
||||
/// </summary>
|
||||
private bool IsDecidedAsMine(MergeLine line) =>
|
||||
line.BaseNumber is int baseNumber
|
||||
? _resolvedBaseNumbers.Contains(baseNumber)
|
||||
: line.OriginalLocalNumber is int mineNumber && _resolvedMineNumbers.Contains(mineNumber);
|
||||
|
||||
/// </summary>
|
||||
private void RefreshAppliedFlags()
|
||||
{
|
||||
@ -2029,7 +2038,9 @@ namespace MergeHub.ViewModels
|
||||
|
||||
foreach (var line in Lines)
|
||||
{
|
||||
line.IsApplied = applied.Contains(line);
|
||||
// "내 것으로 간다"고 정한 충돌은 글을 바꾸지 않아 되돌리기 스택에 남지 않는다.
|
||||
// 그것까지 여기서 지워 버리면 Take Mine 을 눌러도 남은 충돌이 줄지 않는다.
|
||||
line.IsApplied = applied.Contains(line) || IsDecidedAsMine(line);
|
||||
line.IsManuallyEdited = edited.Contains(line);
|
||||
|
||||
if (!line.IsApplied) line.AppliedNote = null;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<UserControl x:Class="MergeHub.Views.HomeView"
|
||||
<UserControl x:Class="MergeHub.Views.HomeView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
@ -831,6 +831,22 @@
|
||||
RelativeSource={RelativeSource AncestorType=UserControl}}"
|
||||
ScrollViewer.ScrollChanged="Lines_ScrollChanged"/>
|
||||
|
||||
<!--
|
||||
원본 칸의 가로 스크롤 막대. 3-Way 에서 워드랩을 끄고 긴 줄이 있을 때만 보인다.
|
||||
|
||||
ScrollViewer 가 만들어 주는 막대를 쓰지 못하는 이유가 있다 - 이 칸들은
|
||||
ScrollViewer 를 가로로 굴리지 않고(줄 번호까지 밀려 나가고 세 칸이 어긋난다)
|
||||
글자만 SourceShiftX 로 민다. 그래서 그 막대는 <b>움직이지 않는 막대</b>가 된다.
|
||||
세 칸은 늘 같이 움직이므로 세 막대의 손잡이도 같은 자리에 있다.
|
||||
-->
|
||||
<ScrollBar Grid.Row="2" Orientation="Horizontal" Height="14"
|
||||
Minimum="0" SmallChange="24"
|
||||
LargeChange="{Binding SourceViewport, RelativeSource={RelativeSource AncestorType=UserControl}}"
|
||||
Maximum="{Binding SourceScrollMax, RelativeSource={RelativeSource AncestorType=UserControl}}"
|
||||
ViewportSize="{Binding SourceViewport, RelativeSource={RelativeSource AncestorType=UserControl}}"
|
||||
Value="{Binding SourceScrollValue, Mode=TwoWay, RelativeSource={RelativeSource AncestorType=UserControl}}"
|
||||
Visibility="{Binding SourceBarVisibility, RelativeSource={RelativeSource AncestorType=UserControl}}"/>
|
||||
|
||||
<!-- 3-Way 에서는 세로가 빠듯하다. 아래 MERGED 의 상태바에 같은 정보가 있어 여기는 접는다. -->
|
||||
<Border Grid.Row="2" Padding="8,5" BorderThickness="0,1,0,0"
|
||||
Height="{StaticResource PaneFooterHeight}"
|
||||
@ -903,6 +919,22 @@
|
||||
RelativeSource={RelativeSource AncestorType=UserControl}}"
|
||||
ScrollViewer.ScrollChanged="Lines_ScrollChanged"/>
|
||||
|
||||
<!--
|
||||
원본 칸의 가로 스크롤 막대. 3-Way 에서 워드랩을 끄고 긴 줄이 있을 때만 보인다.
|
||||
|
||||
ScrollViewer 가 만들어 주는 막대를 쓰지 못하는 이유가 있다 - 이 칸들은
|
||||
ScrollViewer 를 가로로 굴리지 않고(줄 번호까지 밀려 나가고 세 칸이 어긋난다)
|
||||
글자만 SourceShiftX 로 민다. 그래서 그 막대는 <b>움직이지 않는 막대</b>가 된다.
|
||||
세 칸은 늘 같이 움직이므로 세 막대의 손잡이도 같은 자리에 있다.
|
||||
-->
|
||||
<ScrollBar Grid.Row="2" Orientation="Horizontal" Height="14"
|
||||
Minimum="0" SmallChange="24"
|
||||
LargeChange="{Binding SourceViewport, RelativeSource={RelativeSource AncestorType=UserControl}}"
|
||||
Maximum="{Binding SourceScrollMax, RelativeSource={RelativeSource AncestorType=UserControl}}"
|
||||
ViewportSize="{Binding SourceViewport, RelativeSource={RelativeSource AncestorType=UserControl}}"
|
||||
Value="{Binding SourceScrollValue, Mode=TwoWay, RelativeSource={RelativeSource AncestorType=UserControl}}"
|
||||
Visibility="{Binding SourceBarVisibility, RelativeSource={RelativeSource AncestorType=UserControl}}"/>
|
||||
|
||||
<Border Grid.Row="2" Padding="8,5" BorderThickness="0,1,0,0"
|
||||
Height="{StaticResource PaneFooterHeight}"
|
||||
Visibility="{Binding IsTwoWay, Converter={StaticResource BoolToVis}}"
|
||||
@ -1234,6 +1266,22 @@
|
||||
RelativeSource={RelativeSource AncestorType=UserControl}}"
|
||||
ScrollViewer.ScrollChanged="Lines_ScrollChanged"/>
|
||||
|
||||
<!--
|
||||
원본 칸의 가로 스크롤 막대. 3-Way 에서 워드랩을 끄고 긴 줄이 있을 때만 보인다.
|
||||
|
||||
ScrollViewer 가 만들어 주는 막대를 쓰지 못하는 이유가 있다 - 이 칸들은
|
||||
ScrollViewer 를 가로로 굴리지 않고(줄 번호까지 밀려 나가고 세 칸이 어긋난다)
|
||||
글자만 SourceShiftX 로 민다. 그래서 그 막대는 <b>움직이지 않는 막대</b>가 된다.
|
||||
세 칸은 늘 같이 움직이므로 세 막대의 손잡이도 같은 자리에 있다.
|
||||
-->
|
||||
<ScrollBar Grid.Row="2" Orientation="Horizontal" Height="14"
|
||||
Minimum="0" SmallChange="24"
|
||||
LargeChange="{Binding SourceViewport, RelativeSource={RelativeSource AncestorType=UserControl}}"
|
||||
Maximum="{Binding SourceScrollMax, RelativeSource={RelativeSource AncestorType=UserControl}}"
|
||||
ViewportSize="{Binding SourceViewport, RelativeSource={RelativeSource AncestorType=UserControl}}"
|
||||
Value="{Binding SourceScrollValue, Mode=TwoWay, RelativeSource={RelativeSource AncestorType=UserControl}}"
|
||||
Visibility="{Binding SourceBarVisibility, RelativeSource={RelativeSource AncestorType=UserControl}}"/>
|
||||
|
||||
<Border Grid.Row="2" Padding="8,5" BorderThickness="0,1,0,0"
|
||||
Height="{StaticResource PaneFooterHeight}"
|
||||
Visibility="{Binding IsTwoWay, Converter={StaticResource BoolToVis}}"
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
@ -21,7 +21,31 @@ namespace MergeHub.Views
|
||||
/// </summary>
|
||||
public partial class HomeView : UserControl
|
||||
{
|
||||
private bool _syncing;
|
||||
/// <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>
|
||||
/// 아래층 글자를 가로로 미는 양. 편집기가 가로로 스크롤한 만큼 음수로 들어간다.
|
||||
@ -47,7 +71,7 @@ namespace MergeHub.Views
|
||||
/// </summary>
|
||||
public static readonly DependencyProperty SourceShiftXProperty =
|
||||
DependencyProperty.Register(nameof(SourceShiftX), typeof(double), typeof(HomeView),
|
||||
new PropertyMetadata(0.0));
|
||||
new PropertyMetadata(0.0, OnSourceShiftXChanged));
|
||||
|
||||
public double SourceShiftX
|
||||
{
|
||||
@ -84,6 +108,116 @@ namespace MergeHub.Views
|
||||
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),
|
||||
@ -265,38 +399,39 @@ namespace MergeHub.Views
|
||||
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 (_syncing) return;
|
||||
if (Syncing) return;
|
||||
if (e.VerticalChange == 0 && e.HorizontalChange == 0) return;
|
||||
|
||||
_syncing = true;
|
||||
try
|
||||
{
|
||||
// 세로는 아래층·BASE·REMOTE 가 같이 굴러가고,
|
||||
// 가로는 네 패널 모두 "글자만" 민다(TextShiftX) - 여백(줄번호·[>])은 제자리에 있어야 한다.
|
||||
// 표시층 ListBox 를 가로로 굴리는 방법은 못 쓴다 - 행이 칸 너비에 맞춰 늘어나
|
||||
// 굴릴 여지(ScrollableWidth)가 0 이라 아무 일도 일어나지 않는다.
|
||||
Scroll(LocalLines, e.VerticalOffset, null);
|
||||
ScrollSourcePanesTo(e.VerticalOffset);
|
||||
TextShiftX = -e.HorizontalOffset;
|
||||
HoldSync();
|
||||
|
||||
// 2-Way 는 BASE 와 LOCAL 의 폭이 같으니 한 덩어리로 움직인다.
|
||||
// 3-Way 의 위 세 칸은 폭이 달라 따로 움직이므로 여기서 건드리지 않는다.
|
||||
if (DataContext is not HomeViewModel { IsThreeWay: true }) SourceShiftX = TextShiftX;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_syncing = false;
|
||||
}
|
||||
// 세로는 아래층·BASE·REMOTE 가 같이 굴러가고,
|
||||
// 가로는 네 패널 모두 "글자만" 민다(TextShiftX) - 여백(줄번호·[>])은 제자리에 있어야 한다.
|
||||
// 표시층 ListBox 를 가로로 굴리는 방법은 못 쓴다 - 행이 칸 너비에 맞춰 늘어나
|
||||
// 굴릴 여지(ScrollableWidth)가 0 이라 아무 일도 일어나지 않는다.
|
||||
Scroll(LocalLines, e.VerticalOffset, null);
|
||||
ScrollSourcePanesTo(e.VerticalOffset);
|
||||
TextShiftX = -e.HorizontalOffset;
|
||||
|
||||
// 2-Way 는 BASE 와 LOCAL 의 폭이 같으니 한 덩어리로 움직인다.
|
||||
// 3-Way 의 위 세 칸은 폭이 달라 따로 움직이므로 여기서 건드리지 않는다.
|
||||
if (DataContext is not HomeViewModel { IsThreeWay: true }) SourceShiftX = TextShiftX;
|
||||
};
|
||||
}
|
||||
|
||||
@ -325,6 +460,10 @@ namespace MergeHub.Views
|
||||
BaseLines.Margin = threeWay
|
||||
? new Thickness(0, 0, bar, 0)
|
||||
: new Thickness(0, 0, bar + MiniMap.Width, 0);
|
||||
|
||||
// 칸 폭이 달라졌으니 원본 칸 막대의 범위도 다시 잰다.
|
||||
ApplySourceScrollUnit();
|
||||
UpdateSourceScrollRange();
|
||||
}
|
||||
|
||||
/// <summary>표시층을 편집기 글자 위치에 맞추는 이동값. 아래 AlignDecorationToEditor 가 정한다.</summary>
|
||||
@ -418,95 +557,78 @@ namespace MergeHub.Views
|
||||
private double LineHeight =>
|
||||
TryFindResource("CodeLineHeight") is double h && h > 0 ? h : 20;
|
||||
|
||||
/// <summary>원본 칸의 행 높이표. 세 칸은 같은 높이를 쓰므로 하나면 된다.</summary>
|
||||
private double[] SourceHeights()
|
||||
/// <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()
|
||||
{
|
||||
if (DataContext is not HomeViewModel vm) return Array.Empty<double>();
|
||||
var unit = SourceRowsDifferFromEditor ? ScrollUnit.Item : ScrollUnit.Pixel;
|
||||
|
||||
var heights = new double[vm.Lines.Count];
|
||||
var fallback = LineHeight;
|
||||
|
||||
for (var i = 0; i < heights.Length; i++)
|
||||
foreach (var pane in SourcePanes)
|
||||
{
|
||||
var h = vm.Lines[i].SourceRowHeight;
|
||||
heights[i] = h > 0 ? h : fallback;
|
||||
if (VirtualizingPanel.GetScrollUnit(pane) != unit) VirtualizingPanel.SetScrollUnit(pane, unit);
|
||||
}
|
||||
|
||||
return heights;
|
||||
}
|
||||
|
||||
/// <summary>편집기에서 그 행이 차지하는 시각 줄 구간. 워드랩이면 한 행이 여러 줄이다.</summary>
|
||||
private (int First, int Count) EditorRowSpan(string text, int row, int rowCount)
|
||||
/// <summary>
|
||||
/// 편집기 화면 <b>맨 위에 걸린 논리 행</b>. 좌표로 직접 물어본다.
|
||||
///
|
||||
/// 스크롤 값을 줄 높이로 나눠 세는 방법은 못 쓴다 - 실제로 재 보면 스크롤 160 일 때
|
||||
/// 편집기가 말하는 첫 줄은 11 인데 화면 맨 위에 있는 것은 9행이었다. TextBox 의
|
||||
/// 줄 번호 계산은 화면에 그려진 것과 딱 맞아떨어지지 않는다.
|
||||
/// 그래서 "왼쪽 위 점에 있는 글자가 몇 행인가"를 물어본다 - 이건 그려진 대로 답한다.
|
||||
/// </summary>
|
||||
private int EditorTopRow()
|
||||
{
|
||||
var start = CharacterIndexOfLine(text, row);
|
||||
if (start < 0) return (row, 1);
|
||||
var index = LocalEditor.GetCharacterIndexFromPoint(new Point(2, 2), snapToText: true);
|
||||
if (index < 0) return 0;
|
||||
|
||||
var first = LocalEditor.GetLineIndexFromCharacterIndex(start);
|
||||
|
||||
var nextStart = row + 1 < rowCount ? CharacterIndexOfLine(text, row + 1) : -1;
|
||||
var next = nextStart >= 0
|
||||
? LocalEditor.GetLineIndexFromCharacterIndex(nextStart)
|
||||
: LocalEditor.LineCount;
|
||||
|
||||
return (first, Math.Max(1, next - first));
|
||||
var row = RowColumnAt(LocalEditor.Text, index).Row;
|
||||
return row < 0 ? 0 : row;
|
||||
}
|
||||
|
||||
/// <summary>편집기의 세로 위치를 원본 칸의 세로 위치로 옮긴다.</summary>
|
||||
private double SourceOffsetFromEditor(double editorOffset)
|
||||
/// <summary>그 논리 행이 편집기에서 시작하는 세로 위치(위의 역). 역시 좌표로 잰다.</summary>
|
||||
private double EditorOffsetOfRow(int row)
|
||||
{
|
||||
var heights = SourceHeights();
|
||||
if (heights.Length == 0) return editorOffset;
|
||||
if (FindScrollViewer(LocalEditor) is not { } viewer) return 0;
|
||||
|
||||
var text = LocalEditor.Text;
|
||||
var lineH = LineHeight;
|
||||
var start = CharacterIndexOfLine(LocalEditor.Text, row);
|
||||
if (start < 0) return viewer.VerticalOffset;
|
||||
|
||||
var topVisual = (int)Math.Floor(editorOffset / lineH);
|
||||
if (topVisual < 0) topVisual = 0;
|
||||
if (topVisual > LocalEditor.LineCount - 1) topVisual = Math.Max(0, LocalEditor.LineCount - 1);
|
||||
|
||||
var row = RowColumnAt(text, LocalEditor.GetCharacterIndexFromLineIndex(topVisual)).Row;
|
||||
if (row < 0 || row >= heights.Length) return editorOffset;
|
||||
|
||||
var span = EditorRowSpan(text, row, heights.Length);
|
||||
var fraction = Fraction(editorOffset - span.First * lineH, span.Count * lineH);
|
||||
|
||||
var offset = 0.0;
|
||||
for (var i = 0; i < row; i++) offset += heights[i];
|
||||
|
||||
return offset + fraction * heights[row];
|
||||
}
|
||||
|
||||
/// <summary>원본 칸의 세로 위치를 편집기의 세로 위치로 옮긴다(위의 역).</summary>
|
||||
private double EditorOffsetFromSource(double sourceOffset)
|
||||
{
|
||||
var heights = SourceHeights();
|
||||
if (heights.Length == 0) return sourceOffset;
|
||||
|
||||
var row = 0;
|
||||
var acc = 0.0;
|
||||
while (row < heights.Length - 1 && acc + heights[row] <= sourceOffset)
|
||||
try
|
||||
{
|
||||
acc += heights[row];
|
||||
row++;
|
||||
// GetRectFromCharacterIndex 는 <b>보이는 영역 기준</b> 좌표를 준다.
|
||||
// 지금 굴러가 있는 만큼을 더해야 문서 전체 기준이 된다.
|
||||
var top = LocalEditor.GetRectFromCharacterIndex(start).Top;
|
||||
if (double.IsNaN(top) || double.IsInfinity(top)) return viewer.VerticalOffset;
|
||||
|
||||
return Math.Max(0, viewer.VerticalOffset + top);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
return viewer.VerticalOffset;
|
||||
}
|
||||
|
||||
var fraction = Fraction(sourceOffset - acc, heights[row]);
|
||||
var span = EditorRowSpan(LocalEditor.Text, row, heights.Length);
|
||||
|
||||
return (span.First + fraction * span.Count) * LineHeight;
|
||||
}
|
||||
|
||||
private static double Fraction(double part, double whole) =>
|
||||
whole <= 0 ? 0 : Math.Max(0, Math.Min(1, part / whole));
|
||||
|
||||
/// <summary>편집기가 굴러간 만큼 위쪽 원본 세 칸을 따라오게 한다.</summary>
|
||||
private void ScrollSourcePanesTo(double editorOffset)
|
||||
{
|
||||
var offset = SourceRowsDifferFromEditor ? SourceOffsetFromEditor(editorOffset) : editorOffset;
|
||||
// 행 단위로 굴리는 중이면 오프셋의 뜻이 "픽셀"이 아니라 "몇 번째 행"이다.
|
||||
var offset = SourceRowsDifferFromEditor ? EditorTopRow() : editorOffset;
|
||||
|
||||
Scroll(BaseLines, offset, null);
|
||||
Scroll(RemoteLines, offset, null);
|
||||
Scroll(OriginalLocalLines, offset, null);
|
||||
foreach (var pane in SourcePanes) Scroll(pane, offset, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -544,27 +666,24 @@ namespace MergeHub.Views
|
||||
return;
|
||||
}
|
||||
|
||||
if (_syncing) return;
|
||||
// 글이나 칸 폭이 바뀌면 밀 수 있는 폭도 달라진다. 스크롤 이벤트가 그때마다 온다.
|
||||
if (e.ExtentWidthChange != 0 || e.ViewportWidthChange != 0) UpdateSourceScrollRange();
|
||||
|
||||
if (Syncing) return;
|
||||
if (e.VerticalChange == 0) return;
|
||||
|
||||
_syncing = true;
|
||||
try
|
||||
{
|
||||
var editorOffset = SourceRowsDifferFromEditor
|
||||
? EditorOffsetFromSource(e.VerticalOffset)
|
||||
: e.VerticalOffset;
|
||||
HoldSync();
|
||||
|
||||
Scroll(LocalEditor, editorOffset, null);
|
||||
Scroll(LocalLines, editorOffset, null);
|
||||
var editorOffset = SourceRowsDifferFromEditor
|
||||
? EditorOffsetOfRow((int)Math.Round(e.VerticalOffset))
|
||||
: e.VerticalOffset;
|
||||
|
||||
foreach (var other in new[] { BaseLines, RemoteLines, OriginalLocalLines })
|
||||
{
|
||||
if (!ReferenceEquals(other, source)) Scroll(other, e.VerticalOffset, null);
|
||||
}
|
||||
}
|
||||
finally
|
||||
Scroll(LocalEditor, editorOffset, null);
|
||||
Scroll(LocalLines, editorOffset, null);
|
||||
|
||||
foreach (var other in new[] { BaseLines, RemoteLines, OriginalLocalLines })
|
||||
{
|
||||
_syncing = false;
|
||||
if (!ReferenceEquals(other, source)) Scroll(other, e.VerticalOffset, null);
|
||||
}
|
||||
}
|
||||
|
||||
@ -973,6 +1092,10 @@ namespace MergeHub.Views
|
||||
TextShiftX = 0;
|
||||
SourceShiftX = 0;
|
||||
}
|
||||
|
||||
// 워드랩을 켜면 밀 곳이 없어지므로 막대도 사라져야 한다.
|
||||
ApplySourceScrollUnit();
|
||||
UpdateSourceScrollRange();
|
||||
}
|
||||
|
||||
/// <summary>ViewModel 이 "이 행으로 가라"고 하면 캐럿과 스크롤을 함께 옮긴다.</summary>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user