From c317a7f70baf1568e32712bed96dde657e707503 Mon Sep 17 00:00:00 2001 From: ksj09406 Date: Tue, 22 Sep 2026 21:54:39 +0900 Subject: [PATCH] =?UTF-8?q?11=EC=B0=A8=20F-10:=20=EC=B9=B8=EB=A7=88?= =?UTF-8?q?=EB=8B=A4=20=EC=9E=90=EA=B8=B0=20=EC=A4=84=20=C2=B7=20=EB=B0=94?= =?UTF-8?q?=EB=80=90=20=EB=8D=A9=EC=96=B4=EB=A6=AC=EB=A5=BC=20=EC=9E=87?= =?UTF-8?q?=EB=8A=94=20=EC=84=A0=20(Araxis=20=EC=8B=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 정렬 행은 그대로 두고 칸마다 그 칸에 있는 줄만 걸러 보임(빈 줄로 맞추지 않음), 칸마다 자기 행 높이 - 편집기에서 빈 줄 없앰(StripFillers 삭제), 편집기 줄 <-> 정렬 행 표(EditorSnapshot.Rows) - 스크롤은 가운데 기준으로 따라옴(PaneMapping), 차이 이동은 그 자리를 가운데로 - 틈에 바뀐 덩어리 사다리꼴 선 · 경계선 · 옮김 점선(PaneLinkOverlay), 3분할은 위 세 칸 사이 - 지운 줄 되살리기 [>] 는 BASE 칸 끝 Co-Authored-By: Claude Opus 5 --- doc/history/2026-09-18-11차.md | 43 ++ doc/history/changelog.md | 1 + doc/진행상황.md | 2 +- src/MergeHub/Models/MergeLine.cs | 108 +++-- src/MergeHub/Services/EditorLayout.cs | 97 ++-- src/MergeHub/Services/PaneLinks.cs | 120 +++++ src/MergeHub/ViewModels/HomeViewModel.cs | 41 +- .../Views/Controls/PaneLinkOverlay.cs | 31 ++ src/MergeHub/Views/HomeView.Links.cs | 423 ++++++++++++++++++ src/MergeHub/Views/HomeView.xaml | 57 ++- src/MergeHub/Views/HomeView.xaml.cs | 127 +++--- 11 files changed, 871 insertions(+), 179 deletions(-) create mode 100644 src/MergeHub/Services/PaneLinks.cs create mode 100644 src/MergeHub/Views/Controls/PaneLinkOverlay.cs create mode 100644 src/MergeHub/Views/HomeView.Links.cs diff --git a/doc/history/2026-09-18-11차.md b/doc/history/2026-09-18-11차.md index fba8e1d..6e61169 100644 --- a/doc/history/2026-09-18-11차.md +++ b/doc/history/2026-09-18-11차.md @@ -167,6 +167,7 @@ A-1 에서 값으로 확인한 것 — 지운 파일은 "삭제되어 건너뜀 - F-6: 줄 간격을 좁혀 1920 × 1080 두 칸 40 → 50줄(매뉴얼의 화면 설명 · 그림이 있으면 새로). 숫자 24벌 1,572건. - F-8: 매뉴얼 되돌리기 설명 — 저장 · 저장 후 커밋 뒤에도 Undo/Redo(커밋은 그대로, 화면만), 편집기에 친 것 · 규칙 바꾸기도 이어짐, 요약 `· 저장됨`, [전체 되돌리기] 는 파일을 연 시점(저장했으면 화면만). Git 탭 충돌 화면 "이번 병합에서 푼 파일" + [다시 충돌로](git.exe 필요 — 알아 둘 한계에도). 숫자 24벌 1,593건. - F-9: 매뉴얼 설정 표 "저장 > 저장할 때 백업"(기본 끔) — 백업 자리 `{설정 폴더}\Backup\MergeHub\날짜\드라이브\원래 폴더\이름.날짜-시각.확장자` · 목록.txt, 모든 저장(병합 중 Save · 파일 단위 Accept · 폴더 복사 포함), 못 만들면 저장 안 함, 저절로 지우지 않음, ACTIONS [백업 폴더 열기]. 숫자 24벌 1,618건. +- F-10: 매뉴얼 파일 비교 화면 설명과 그림을 새로 — 칸마다 자기 줄(빈 줄로 맞추지 않음) · 두 칸(3분할은 위 세 칸) 사이 잇는 선과 색 · 옮김 점선 · 가운데 기준 스크롤 · 차이 이동이 가운데로 · 지운 줄 되살리기 [>] 가 BASE 칸 끝. 상태 줄 `N행` 은 파일 줄 번호. 숫자 25벌 1,639건. ## B-1. 배포 묶음을 다시 만들고 꼭 필요한 파일을 가림 (2026-09-22, 완료) @@ -882,6 +883,48 @@ libgit2 의 다듬기(`PrettifyMessages`)를 켜면 **손대지 않은 뒤 커 - 백업은 저절로 지워지지 않는다(사용자 결정) — 오래 쓰면 쌓인다. [백업 폴더 열기] 에서 날짜 폴더째 지우면 된다(매뉴얼에 적는다). - 폴더 비교의 [삭제] 는 저장이 아니라 백업하지 않는다(지금처럼 먼저 묻는다). +## F-10. 칸마다 자기 줄 · 바뀐 덩어리를 잇는 선 (Araxis 식) (2026-09-22, 완료) + +사용자 요청: 지시서의 "옮긴 덩어리를 선으로" 에서 넓혀 "Araxis 머지툴처럼 라인 바뀐 위치 UI 표시를 중점으로"(Araxis 화면 두 장). 물어 정한 것: **지금 보기를 Araxis 식으로 바꿈**(두 보기를 두지 않음), **3분할에서도 잇는 선**, 3분할은 **위 세 칸(LOCAL(Mine) | BASE | REMOTE) 사이만 잇고 MERGED 는 따라 스크롤**. + +### 고치기 전 + +- 모든 칸이 **정렬 행 하나**(`Lines`)를 같은 번호로 보이고, 한쪽에 없는 줄 자리에는 빈 줄(필러)을 채워 같은 줄을 같은 눈높이에 두었다. 편집기에도 빈 줄이 들어가 저장 · 재정렬 전에 걷어냈다(`StripFillers`). +- 자리 옮김은 줄 색과 "N행으로 옮김" 글로만 알렸다 - 어디로 갔는지는 두 칸을 눈으로 따라가야 했다. + +### 고친 것 + +- **속은 그대로, 보이는 것만** — 정렬 행 `Lines` 는 그대로 두고(차이 · 반영 · 되돌리기 · 차이 이동 · 머지 주석 · 미니맵이 그대로 쓴다) 칸마다 **그 칸에 있는 줄만** 걸러 보인다(`ListCollectionView` 필터, LOCAL 은 줄이 생기고 사라지면 바로 따라감). 칸마다 자기 행 높이(워드랩에서 서로 맞추지 않음). +- **편집기에서 빈 줄을 없앰** — 편집기 글이 곧 파일 글. 편집기 줄 ↔ 정렬 행은 `EditorSnapshot.Rows` 표로 옮긴다(캐럿 · 친 줄 바로 보이기 · 차이 이동). `StripFillers` 는 쓸 곳이 없어져 지웠다(매 재정렬마다 돌던 줄 정렬 계산도 없어짐). 상태 줄 `N행` 이 이제 파일 줄 번호 그대로다. +- **스크롤 맞추기** — 한 칸을 굴리면 나머지가 **가운데 기준**으로 따라온다(`PaneMapping`). 두 칸에 함께 있는 줄의 가운데를 짝점으로 삼아 그 사이는 비율로 잇는다 - 같은 줄이 이어지는 곳은 1:1, 한쪽에만 있는 덩어리를 지날 때는 한쪽이 경계에 멈춰 있고 다른 쪽만 지나간다(한 번에 튀지 않음). +- **잇는 선** — 칸 위에 겹친 투명한 그림판(`PaneLinkOverlay`, 마우스 통과)에 바뀐 덩어리마다 두 칸 안의 위 · 아래 경계선과 틈의 사다리꼴을 그린다. 한쪽에 없는 덩어리는 그 칸에 굵은 선 하나(들어갈/빠진 자리). 색: 추가 파랑 · 삭제 빨강 · 수정 주황 · 충돌 분홍 · 상대만 바꿈(REMOTE) · 직접 수정/반영. 자리 옮김은 떠난 자리(BASE)에서 옮겨 간 자리(LOCAL)까지 청록 점선 곡선(두 칸 비교). 칸 사이 틈 4 → 48px(끌면 구분선처럼 폭이 바뀜). +- 칸 좌표는 **그려진 줄이면 그 자리**, 화면 밖이면 보이는 첫 줄에서 모델 높이로 어림(`PaneLinks.Tops`). +- **지운 줄 되살리기 [>]** — 예전에는 LOCAL 의 빈 줄에 달려 있었다. 이제 BASE 칸 그 줄 끝에 단다(Araxis 처럼). +- **차이 이동은 그 자리를 가운데로** — 예전엔 캐럿 줄을 위쪽에 두었는데, LOCAL 에서 지운 덩어리로 가면 지운 줄이 화면 위로 벗어났다. 이제 그 줄 윗변(= 지운 자리)을 가운데로 → 지운 줄들이 BASE 칸 가운데에 온다. +- 3분할: 위 세 칸 사이 두 틈에 같은 선. 충돌 줄은 칸마다 줄 번호가 달라도(Mine 36 · BASE 31 · REMOTE 31) 같은 높이로 이어진다. +- 계산은 화면 없이 검사: `PaneLinks`(윗변 · 덩어리) · `PaneMapping`(가운데 짝점 · 비율) · `EditorLayout.LineOfRow/RowOfLine`. + +### 확인 (화면) + +| 경우 | 결과 | +|---|---| +| 메서드 옮김 · 두 줄 추가 · 한 줄 수정 · 두 줄 삭제 (`f10-shot.ps1`) | 칸마다 자기 줄(BASE 49 · LOCAL 49), 추가 BASE 18 경계 → LOCAL 18~19, 삭제 BASE 25~32 → LOCAL 26 경계, 수정 37 ↔ 31, 옮긴 Gamma 청록 점선 | +| 400줄 · 60줄 추가 · 40줄 삭제 스크롤 (`f10-sync.ps1`) | 가운데 줄: 0% 25/25 · 20% 99/99 · 추가 지날 때 BASE 101 멈춤 · LOCAL 새 줄 36 · 55% 169/169 · 65% 206/206. BASE 를 굴려 삭제를 지날 때 LOCAL 250 멈춤 | +| 다음 차이 → 삭제 덩어리 | BASE 251~290 가 칸 가운데, LOCAL 310/311 사이 빨간 선 | +| BASE 칸 [>] (지운 줄 되살리기) · Undo | 편집기 420 → 422줄(줄 + 머지 주석) → Undo 420 | +| 3분할 병합 충돌(C1Repo · 새 시험 저장소) | 세 칸 사이 선, 내가 넣은 5줄 → BASE 경계로 비스듬히(파랑), 상대가 지운 5줄 BASE 40~44 → REMOTE 경계(빨강), MERGED 따라 스크롤 | +| 워드랩 켬 | 세 줄로 접힌 LOCAL 31 ↔ BASE 37 사다리꼴이 덩어리 위아래에 붙음 | +| 회귀 | 병합 한 길(e11-e1-merge) 충돌 24 → 26 풀기 · Take 18 = 로그 18 · 기준 대조 같음 · 303초. F-8 저장 · 입력/규칙 · Save & Commit 되돌리기 모두 전과 같음 | + +- 하네스 **PaneLinkTest 새로 17건**(윗변 · 덩어리 · 같은 칸 1:1 · 추가/삭제 사이 비율 · 단조 · 워드랩 · 공통 줄 없음 · 빈 칸), **EditorTest 14 → 18**(빈 줄 없이 LOCAL 줄만 · 행 ↔ 줄 표 — 옛 "빈 줄 넣고 걷어내기" 검사는 그 동작이 없어져 새 규칙으로 바꿈). MergeSaveTest · AlignTest · RulesTest · MemberTest · MoveTest · NoteTest 그대로 통과. Debug 빌드 경고 0 · 오류 0. +- 처음 그렸을 때 선이 안 보였다(그림판이 칸의 조상이 아니라 좌표 변환이 예외 → `TransformToVisual`). 스크롤 직후 LOCAL 표시층이 늦게 구르는 사이 옛 자리로 선이 한 번 그려졌다 → 어느 칸이 굴러도 다시 그림. 3분할 LOCAL(Mine)|BASE 에서 "내가 넣은 줄" 이 삭제 색으로 → BASE 가 오른쪽인 짝의 방향을 따름. + +### 남은 것 + +- 옮김 점선은 두 칸 비교만(3분할은 옮김을 찾지 않는다). +- 워드랩을 켜면 화면 밖 줄의 높이는 어림이라, 긴 파일을 멀리 건너뛴 직후에는 가운데 맞춤이 몇 줄 어긋날 수 있다(그려지면 바로잡힘 — 예전 방식에도 있던 한계). +- 3분할 MERGED 와 위 칸 사이 선은 두지 않았다(사용자 결정). + ## F-12. C# 코드 스타일 맞춤의 기본값을 AURORA 로 (2026-09-18, 완료) 사용자: "기본값은 원본형태를 따르고 C#인 경우에 AURORA 스타일로 하자" → 물어서 정한 것은 **제품 기본값 AURORA · C# 파일만 · 저장 동작은 지금 그대로**(켜져 있으면 저장도 그 스타일로 쓴다). diff --git a/doc/history/changelog.md b/doc/history/changelog.md index 19cce16..34da6cd 100644 --- a/doc/history/changelog.md +++ b/doc/history/changelog.md @@ -8,6 +8,7 @@ | 날짜 | 항목 | 커밋 | |---|---|---| +| 2026-09-22 | **11차 F-10** — Araxis 식 화면: 칸마다 자기 줄만(빈 줄로 높이를 맞추지 않음), 두 칸(3분할은 위 세 칸) 사이 틈에 바뀐 덩어리마다 사다리꼴 잇는 선 · 칸 안 경계선(한쪽에 없으면 굵은 선 하나) · 옮긴 덩어리는 청록 점선. 스크롤은 가운데 기준으로 따라옴(`PaneMapping` — 함께 있는 줄 가운데를 짝점, 사이는 비율). 속의 정렬 행은 그대로라 반영 · 되돌리기 · 차이 이동이 그대로 동작. 편집기에서 빈 줄을 없애 `StripFillers` 를 지움(편집기 줄 ↔ 행 표). 지운 줄 [>] 는 BASE 칸 끝, 차이 이동은 가운데로 | 아래 | | 2026-09-22 | **11차 F-9** — 저장할 때 백업: 설정 "저장할 때 백업"(기본 끔)을 켜면 모든 저장(두 파일 · mergetool · Save & Commit · 병합 중 Save · 파일 단위 Accept · 폴더 비교 복사) 직전에 지금 파일을 바이트 그대로 `{설정 폴더}\Backup\MergeHub\날짜\드라이브\원래 폴더\이름.날짜-시각.확장자` 로 복사(`FileBackup`, 날짜 폴더마다 목록.txt, 저절로 안 지움 — 사용자 결정). 원본 폴더 · 저장소 밖이라 폴더 비교 · 변경분 · git status 에 안 섞임. 못 만들면 저장하지 않음 | 아래 | | 2026-09-22 | **11차 F-8** — 저장한 뒤에도 Undo/Redo: 저장하는 길마다 되돌리기 목록을 비우던 것을 두고, "저장할 것 있음" 을 목록이 아니라 "지금 글이 마지막 저장과 다른가" 로(`SaveBaseline`). 커밋 뒤 새 HEAD 로 다시 읽기 · 비교 규칙 바꾸기 · 편집기에 친 뒤 재정렬처럼 줄을 다시 세울 때 이력을 글 단위로 옮겨 지킴(친 것도 한 걸음, 300걸음까지). 전체 되돌리기는 파일을 연 시점. 병합 중 푼 파일은 Git 탭 [다시 충돌로] 로 되돌림 — LibGit2Sharp 에 길이 없어 이 단추만 `git checkout -m`(사용자 결정), 해결 기록에서도 뺌 | 아래 | | 2026-09-22 | **11차 F-7 (추가 요청)** — "대형 모니터에서 오른쪽이 잘려 종료 · ACTIONS 가 안 보임": 창이 모니터 밖으로 나간 것. 켤 때 창 모드로 기억한 자리를 모니터에 조금만 걸쳐도 그대로 놓았다 — 해상도 · 모니터 배치가 바뀐 뒤 오른쪽으로 넘침(재현 840px). 플랫폼이 가장 많이 겹치는 모니터 안으로 밀게 고침(플랫폼 changelog, 사용자 확인 뒤 배포 09-22). 가장 좁은 창 × 화면 배율 80~133% 에서 안쪽 화면은 원래 넘치지 않음 | 아래 | diff --git a/doc/진행상황.md b/doc/진행상황.md index 9d571f2..535ef2c 100644 --- a/doc/진행상황.md +++ b/doc/진행상황.md @@ -379,7 +379,7 @@ | **완료** | F-7 대형 모니터에서 오른쪽(종료 · ACTIONS)이 잘림 — 창이 모니터 밖으로 나간 것. 켤 때 창 모드로 기억한 자리를 모니터에 조금만 걸쳐도 그대로 놓던 것을 가장 많이 겹치는 모니터 안으로 밀게(플랫폼 `FitToMonitors`). 재현 840px 넘침 → 0. 좁은 창 × 화면 배율 80~133% 는 원래 넘친 것 없음. PlacementTest +8. 플랫폼 배포함(09-22) | | **완료** | F-8 저장한 뒤에도 되돌리기 — 저장 · 저장 후 커밋 뒤 Undo/Redo 유지(저장할 것 있음은 "마지막 저장과 다른가" 로), 줄을 다시 세울 때(커밋 뒤 새 HEAD · 규칙 바꾸기 · 친 뒤 재정렬) 이력을 글 단위로 옮겨 지킴, 친 것도 한 걸음. 전체 되돌리기는 파일을 연 시점. Git 탭 "푼 파일" + [다시 충돌로](이 단추만 git.exe, 사용자 결정). MergeSaveTest +21 | | **완료** | F-9 저장할 때 백업 — 설정(기본 끔)을 켜면 모든 저장(두 파일 · mergetool · Save & Commit · 병합 중 Save · 파일 단위 Accept · 폴더 복사) 직전에 지금 파일을 바이트 그대로 `{설정 폴더}\Backup\MergeHub\날짜\…` 에 모음(목록.txt, 저절로 안 지움). 못 만들면 저장 안 함. ACTIONS [백업 폴더 열기]. 하네스 +25 | -| 대기 | F-10 덩어리가 어디로 갔는지 UI 로 잇기 (지금은 "자리 옮김" 색만) | +| **완료** | F-10 칸마다 자기 줄 · 바뀐 덩어리를 잇는 선(Araxis 식, 사용자 요청으로 넓힘) — 빈 줄로 맞추지 않고 칸마다 자기 줄만, 틈에 사다리꼴 선 · 옮김 점선, 가운데 기준 스크롤, 차이 이동은 가운데로, 지운 줄 [>] 는 BASE 칸 끝. 3분할은 위 세 칸 사이. 편집기 빈 줄(StripFillers) 없앰. PaneLinkTest 17 새로 · EditorTest 14 → 18 | | 대기 | F-11 Git 계정 · URL · 암호 저장 확인 (지금은 주소 · 계정만 저장, 토큰은 메모리) | | **완료** | F-12 C# 코드 스타일 맞춤 기본값을 AURORA 로 (2026-09-18 사용자 결정) — 설정 없는 첫 실행에서 AURORA, 중괄호만 다른 C# 짝 "차이 없음" · 같은 모양의 C++ 짝은 "남은 차이 4줄"(원본 그대로). 저장 동작은 그대로. 하네스 StyleTest +12(90건) | diff --git a/src/MergeHub/Models/MergeLine.cs b/src/MergeHub/Models/MergeLine.cs index f3b9644..3453365 100644 --- a/src/MergeHub/Models/MergeLine.cs +++ b/src/MergeHub/Models/MergeLine.cs @@ -166,19 +166,12 @@ namespace MergeHub.Models public bool HasProblem => !string.IsNullOrEmpty(Problem); - // ---- 워드랩에서 패널들의 행 높이 맞추기 ---- + // ---- 칸마다 자기 행 높이 (11차 F-10) ---- // - // 워드랩을 켜면 줄마다 접히는 횟수가 달라 높이가 제각각이 된다. 패널이 각자 자기 글 - // 높이만 쓰면, 한쪽만 접힌 행에서 그 아래가 통째로 밀린다. 그래서 "같은 눈높이에 - // 있어야 하는 패널끼리" 높이 중 큰 값을 함께 쓴다. - // - // 여기서 중요한 것은 같이 맞춰야 하는 묶음이 하나가 아니라는 점이다. - // 2-Way : BASE ↔ LOCAL(편집기) 이 나란히 있으므로 넷이 한 묶음이다. - // 3-Way : 위의 원본 세 칸은 폭이 같아 서로 맞춰야 하지만, - // 아래 MERGED 는 폭이 전혀 달라(가로 전체) 접히는 자리가 애초에 다르다. - // 같은 높이를 강요하면 MERGED 는 안 접힌 줄에 빈칸이 생기고, - // 그 위에 겹친 편집기와도 어긋난다. - // 그래서 묶음을 둘로 나눈다 - RowHeight(MERGED·편집기) 와 SourceRowHeight(원본 칸들). + // 예전에는 모든 칸이 같은 행을 같은 눈높이에 놓았다(한쪽에 없는 줄은 빈 줄로 채움). 그래서 워드랩으로 + // 한쪽만 접힌 행은 큰 쪽 높이를 함께 썼다. 이제는 칸마다 자기 줄만 보이고(Araxis 식) 두 칸은 잇는 선으로 + // 짝을 보이므로 높이를 맞출 까닭이 없다 - 칸마다 자기 글 높이를 쓴다. 이 높이는 스크롤 맞추기 · + // 잇는 선의 자리 계산()에도 쓰인다. private double _baseTextHeight; private double _localTextHeight; @@ -198,8 +191,71 @@ namespace MergeHub.Models /// 편집기가 겹친 칸(2-Way LOCAL · 3-Way MERGED)의 행 높이. [ObservableProperty] private double _rowHeight = MinimumRowHeight; - /// 읽기 전용 원본 칸들의 행 높이. 2-Way 에서는 RowHeight 와 같다. - [ObservableProperty] private double _sourceRowHeight = MinimumRowHeight; + /// BASE 칸의 행 높이. + [ObservableProperty] private double _baseRowHeight = MinimumRowHeight; + + /// REMOTE 칸의 행 높이. + [ObservableProperty] private double _remoteRowHeight = MinimumRowHeight; + + /// 3-Way LOCAL(Mine) 원본 칸의 행 높이. + [ObservableProperty] private double _originalLocalRowHeight = MinimumRowHeight; + + /// + /// 그 칸에서 이 행이 차지하는 높이(11차 F-10). 그 칸에 없는 줄이면 0. + /// 가 false 면(워드랩 끔) 모든 줄이 한 줄 높이라 잰 값을 보지 않는다 - + /// 화면에 안 그려진 행은 잰 값이 없어 어림이 되므로, 한 줄 높이가 정확할 때는 그것을 쓴다. + /// + public double PaneHeight(TextPane pane, bool measured) + { + var present = pane switch + { + TextPane.Base => HasBase, + TextPane.Local => HasLocal, + TextPane.Remote => HasRemote, + _ => HasOriginalLocal, + }; + if (!present) return 0; + if (!measured) return MinimumRowHeight; + + return pane switch + { + TextPane.Base => BaseRowHeight, + TextPane.Local => RowHeight, + TextPane.Remote => RemoteRowHeight, + _ => OriginalLocalRowHeight, + }; + } + + /// 그 칸에 이 행의 줄이 있는지(11차 F-10 - 칸마다 자기 줄만 보인다). + public bool IsIn(TextPane pane) => pane switch + { + TextPane.Base => HasBase, + TextPane.Local => HasLocal, + TextPane.Remote => HasRemote, + _ => HasOriginalLocal, + }; + + /// + /// 두 칸 사이에서 이 행이 다른지 - 잇는 선을 그을 덩어리를 가른다(11차 F-10). 무시 규칙에 걸린 줄은 같은 것으로 본다. + /// 머지 주석 줄도 한쪽에만 있는 줄이라 다른 것으로 친다(어디에 끼워 넣었는지 보여야 한다). + /// + public bool DiffersBetween(TextPane left, TextPane right) + { + if (IsIgnored) return false; + var inLeft = IsIn(left); + var inRight = IsIn(right); + if (inLeft != inRight) return true; + if (!inLeft) return false; + return !SameText(TextOf(left), TextOf(right)); + } + + private string TextOf(TextPane pane) => pane switch + { + TextPane.Base => BaseText, + TextPane.Local => LocalText, + TextPane.Remote => RemoteText, + _ => OriginalLocalText, + }; /// /// 접히지 않은 보통 줄의 높이. HomeView 의 CodeLineHeight 와 같은 값이어야 한다. @@ -229,17 +285,10 @@ namespace MergeHub.Models private void ApplyRowHeights() { - if (IsThreeWay) - { - RowHeight = Math.Max(MinimumRowHeight, _localTextHeight); - SourceRowHeight = Math.Max(MinimumRowHeight, - Math.Max(_originalLocalTextHeight, Math.Max(_baseTextHeight, _remoteTextHeight))); - return; - } - - var shared = Math.Max(MinimumRowHeight, Math.Max(_baseTextHeight, _localTextHeight)); - RowHeight = shared; - SourceRowHeight = shared; + RowHeight = Math.Max(MinimumRowHeight, _localTextHeight); + BaseRowHeight = Math.Max(MinimumRowHeight, _baseTextHeight); + RemoteRowHeight = Math.Max(MinimumRowHeight, _remoteTextHeight); + OriginalLocalRowHeight = Math.Max(MinimumRowHeight, _originalLocalTextHeight); } /// 무시 규칙(줄 필터)에 걸린 줄. 차이로 세지 않고 옅게만 표시한다. @@ -257,6 +306,15 @@ namespace MergeHub.Models public bool IsMoved => !string.IsNullOrEmpty(MoveHint); + /// + /// 자리 옮김 짝 번호(11차 F-10). 같은 번호의 BASE 쪽 줄()과 LOCAL 쪽 줄이 한 짝이다 - 두 칸 사이에 + /// 옮긴 곳까지 잇는 선을 그린다. 행 번호가 아니라 줄에 붙여 두어 머지 주석이 끼어들어 번호가 밀려도 짝이 유지된다. 0 이면 옮김 아님. + /// + public int MoveGroup { get; set; } + + /// 옮김 짝에서 떠난 쪽(BASE 에 있던 자리)인지. false 면 옮겨 온 쪽(LOCAL). + public bool MoveFromBase { get; set; } + /// 사용자가 여백을 눌러 표시해 둔 줄. F2 로 옮겨 다닌다. [ObservableProperty] private bool _isBookmarked; diff --git a/src/MergeHub/Services/EditorLayout.cs b/src/MergeHub/Services/EditorLayout.cs index 7ed514b..5a692df 100644 --- a/src/MergeHub/Services/EditorLayout.cs +++ b/src/MergeHub/Services/EditorLayout.cs @@ -1,94 +1,77 @@ -using System; +using System; using System.Collections.Generic; namespace MergeHub.Services { - /// 편집기에 그려 넣을 줄들과, 그중 어느 줄이 필러인지. + /// 편집기에 그려 넣을 줄들과, 편집기 줄마다 정렬 행 번호. public sealed class EditorSnapshot { - public EditorSnapshot(string[] lines, bool[] filler) + public EditorSnapshot(string[] lines, int[] rows) { Lines = lines; - Filler = filler; + Rows = rows; } - /// 편집기 본문. 필러 자리는 빈 문자열이다. - public string[] Lines { get; } + /// 편집기 줄마다 정렬 행 번호(11차 F-10). 편집기에는 LOCAL 에 있는 줄만 들어가므로 줄 번호와 행 번호가 다르다. + public int[] Rows { get; } - /// 같은 인덱스의 줄이 필러(= LOCAL 에 없는 줄)인지. - public bool[] Filler { get; } + /// 편집기 본문 - LOCAL 에 있는 줄만. + public string[] Lines { get; } } /// /// LOCAL 패널을 "항상 고칠 수 있는 편집기"로 쓰기 위한 줄 계산. /// - /// 화면에서 BASE 와 LOCAL 은 같은 내용이 같은 눈높이에 와야 한다. 그래서 BASE 에만 있는 - /// 줄(= LOCAL 에서 지워진 줄) 자리에는 LOCAL 쪽에 빈 줄(필러)을 끼워 넣는다. - /// 필러는 화면을 맞추려고 넣은 것일 뿐 파일 내용이 아니므로, 저장하거나 다시 정렬하기 전에 - /// 걷어내야 한다. - /// - /// 문제는 사용자가 편집기에서 줄을 넣고 지운 뒤라 필러 위치가 밀려 있다는 점이다. - /// 그래서 "고치기 전 편집기 내용"과 "지금 편집기 내용"을 정렬해 보고, - /// 예전 필러와 그대로 대응되면서 여전히 비어 있는 줄만 필러로 판정해 걷어낸다. - /// 필러 자리에 사용자가 뭔가 써 넣었다면 그건 새로 추가한 줄이므로 그대로 살린다. + /// 10차까지는 BASE 에만 있는 줄 자리에 LOCAL 쪽 빈 줄(필러)을 끼워 두 칸의 같은 줄을 같은 눈높이에 두고, + /// 저장 · 재정렬 전에 그 빈 줄을 걷어냈다(StripFillers). 11차 F-10 부터 칸마다 자기 줄만 보이고(Araxis 식) + /// 두 칸은 잇는 선으로 짝을 보이므로 빈 줄을 넣지 않는다 - 편집기 글이 곧 파일 글이고, 걷어낼 것도 없다. + /// 편집기 줄 ↔ 정렬 행은 로 옮긴다. /// public static class EditorLayout { - /// 정렬 결과를 편집기에 그릴 줄들로 바꾼다. + /// + /// 정렬 결과를 편집기에 그릴 줄들로 바꾼다. + /// + /// 11차 F-10 부터 빈 줄(필러)을 넣지 않는다 - 칸마다 자기 줄만 보이고(Araxis 식) 두 칸은 잇는 선으로 짝을 보인다. + /// 그래서 편집기 글이 곧 파일 글이고, 어느 편집기 줄이 몇 번째 정렬 행인지는 가 알려 준다. + /// public static EditorSnapshot Build(IReadOnlyList<(string Text, bool HasLocal)> rows) { - var lines = new string[rows.Count]; - var filler = new bool[rows.Count]; + var lines = new List(rows.Count); + var map = new List(rows.Count); for (var i = 0; i < rows.Count; i++) { - filler[i] = !rows[i].HasLocal; - lines[i] = rows[i].HasLocal ? rows[i].Text : string.Empty; + if (!rows[i].HasLocal) continue; + lines.Add(rows[i].Text); + map.Add(i); } - return new EditorSnapshot(lines, filler); + return new EditorSnapshot(lines.ToArray(), map.ToArray()); } /// - /// 사용자가 고친 편집기 내용에서 필러를 걷어내 실제 LOCAL 줄만 남긴다. + /// 정렬 행 → 편집기 줄(11차 F-10). 그 행이 LOCAL 에 없으면(지운 줄) 바로 다음에 있는 줄, 뒤에 없으면 마지막 줄. + /// 편집기가 비었으면 0. /// - /// 고치기 전 편집기 줄(필러 포함) - /// 그때 어느 줄이 필러였는지 - /// 지금 편집기 줄 - public static List StripFillers(IReadOnlyList previous, - IReadOnlyList wasFiller, - IReadOnlyList current) + public static int LineOfRow(IReadOnlyList rows, int row) { - var kept = new List(current.Count); - - // 고치기 전 내용이 없으면 걷어낼 필러도 없다. - if (previous.Count == 0) + if (rows.Count == 0) return 0; + int lo = 0, hi = rows.Count; // rows 는 오름차순 - row 이상인 첫 자리 + while (lo < hi) { - foreach (var line in current) kept.Add(line); - return kept; + var mid = (lo + hi) / 2; + if (rows[mid] < row) lo = mid + 1; else hi = mid; } + return lo < rows.Count ? lo : rows.Count - 1; + } - // 어느 줄이 그대로 남았는지 짚어 준다. 빈 줄은 흔해서 앵커가 되지 못하므로 - // 주변의 진짜 코드 줄에 딸려 자리를 잡는다 - 그래서 정렬기를 쓰는 것이다. - var map = LineAligner.Align(previous, current); - - foreach (var row in map) - { - if (row.LocalNumber is null) continue; // 사용자가 지운 줄 - - var text = row.LocalText ?? string.Empty; - - var cameFromFiller = row.BaseNumber is int oldNumber - && oldNumber >= 1 && oldNumber <= wasFiller.Count - && wasFiller[oldNumber - 1]; - - // 필러였고 여전히 비어 있으면 화면용 빈 줄일 뿐이다. - if (cameFromFiller && text.Length == 0) continue; - - kept.Add(text); - } - - return kept; + /// 편집기 줄 → 정렬 행(11차 F-10). 범위 밖이면 가까운 끝. 편집기가 비었으면 0. + public static int RowOfLine(IReadOnlyList rows, int line) + { + if (rows.Count == 0) return 0; + if (line < 0) return rows[0]; + return line < rows.Count ? rows[line] : rows[rows.Count - 1]; } /// 두 줄 목록이 같은지. 편집기 글을 다시 써야 하는지 판단할 때 쓴다. diff --git a/src/MergeHub/Services/PaneLinks.cs b/src/MergeHub/Services/PaneLinks.cs new file mode 100644 index 0000000..4f3d0af --- /dev/null +++ b/src/MergeHub/Services/PaneLinks.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; + +namespace MergeHub.Services +{ + /// 정렬 행 [Start, End) 한 덩어리 - 두 칸 사이에 잇는 선 하나. + public readonly struct LinkBlock + { + public LinkBlock(int start, int end) + { + Start = start; + End = end; + } + + public int Start { get; } + public int End { get; } + public int Length => End - Start; + + public override string ToString() => $"[{Start},{End})"; + } + + /// + /// 칸마다 자기 줄만 보이는 화면(11차 F-10, Araxis 식)에서 두 칸을 잇는 계산. 화면 없이 검사한다. + /// + /// 속은 지금처럼 정렬 행(한쪽에 없는 줄은 그 칸에서 높이 0) 하나다. 칸 k 의 높이 목록 = 정렬 행마다 그 칸에서의 높이 + /// (그 칸에 없는 줄이면 0). 이것으로 ① 한 칸의 자리를 다른 칸의 자리로 옮기고(스크롤 맞추기) ② 바뀐 덩어리를 찾는다(잇는 선). + /// + public static class PaneLinks + { + /// 정렬 행 i 의 윗변 자리. 길이 n+1 - 마지막은 칸 전체 높이. + public static double[] Tops(IReadOnlyList heights) + { + var tops = new double[heights.Count + 1]; + for (var i = 0; i < heights.Count; i++) tops[i + 1] = tops[i] + Math.Max(0, heights[i]); + return tops; + } + + /// + /// 바뀐 행이 이어지는 덩어리들. [i] = 정렬 행 i 가 두 칸 사이에서 다른가. + /// + public static List Blocks(IReadOnlyList changed) + { + var blocks = new List(); + var i = 0; + while (i < changed.Count) + { + if (!changed[i]) { i++; continue; } + var start = i; + while (i < changed.Count && changed[i]) i++; + blocks.Add(new LinkBlock(start, i)); + } + return blocks; + } + } + + /// + /// 한 칸(A)의 세로 자리를 다른 칸(B)의 자리로 옮긴다 - 스크롤을 맞출 때 쓴다(11차 F-10). + /// + /// 두 칸에 함께 있는 줄의 가운데를 짝점으로 삼고 그 사이는 비율로 잇는다. 같은 줄이 이어지는 곳은 1:1, + /// 한쪽에만 있는 덩어리를 지날 때는 짝점 사이가 벌어진 만큼 천천히/빨리 따라간다 - 덩어리 경계에서 한 번에 튀지 않는다 + /// (Araxis 가 이렇게 따라간다). 함께 있는 줄이 없으면 두 칸 전체 높이의 비율로. + /// + public sealed class PaneMapping + { + private readonly double[] _a; + private readonly double[] _b; + + public PaneMapping(IReadOnlyList heightsA, IReadOnlyList heightsB) + { + if (heightsA.Count != heightsB.Count) throw new ArgumentException("두 칸의 정렬 행 수가 다릅니다."); + + var topA = PaneLinks.Tops(heightsA); + var topB = PaneLinks.Tops(heightsB); + TotalA = topA[topA.Length - 1]; + TotalB = topB[topB.Length - 1]; + + var xs = new List { 0 }; + var ys = new List { 0 }; + for (var i = 0; i < heightsA.Count; i++) + { + if (heightsA[i] <= 0 || heightsB[i] <= 0) continue; + xs.Add(topA[i] + heightsA[i] / 2); + ys.Add(topB[i] + heightsB[i] / 2); + } + xs.Add(TotalA); + ys.Add(TotalB); + + _a = xs.ToArray(); + _b = ys.ToArray(); + } + + public double TotalA { get; } + public double TotalB { get; } + + /// A 의 자리 → B 의 자리. 끝 밖은 기울기 1 로 늘인다. + public double Map(double yA) => Interpolate(_a, _b, yA); + + /// B 의 자리 → A 의 자리. + public double MapBack(double yB) => Interpolate(_b, _a, yB); + + private static double Interpolate(double[] from, double[] to, double y) + { + var last = from.Length - 1; + if (y <= from[0]) return to[0] + (y - from[0]); + if (y >= from[last]) return to[last] + (y - from[last]); + + // from 은 줄어들지 않는다 - y 가 들어가는 마지막 구간 + int lo = 0, hi = last; + while (hi - lo > 1) + { + var mid = (lo + hi) / 2; + if (from[mid] <= y) lo = mid; else hi = mid; + } + + var span = from[hi] - from[lo]; + if (span <= 0) return to[hi]; + return to[lo] + (to[hi] - to[lo]) * (y - from[lo]) / span; + } + } +} diff --git a/src/MergeHub/ViewModels/HomeViewModel.cs b/src/MergeHub/ViewModels/HomeViewModel.cs index 3ceeaf0..37972af 100644 --- a/src/MergeHub/ViewModels/HomeViewModel.cs +++ b/src/MergeHub/ViewModels/HomeViewModel.cs @@ -1632,7 +1632,7 @@ namespace MergeHub.ViewModels _syncingEditor = false; _editorLines = Array.Empty(); _typedOriginals.Clear(); // 편집기를 줄에서 다시 채웠다 - 친 것은 줄에 이미 들어갔다 - _editorFiller = Array.Empty(); + _editorRows = Array.Empty(); InvalidateDiffBlocks(); BaseHeader = "BASE"; @@ -1795,13 +1795,20 @@ namespace MergeHub.ViewModels /// private static void MarkMoves(IReadOnlyList aligned, List rows) { + var group = 0; foreach (var move in MoveDetector.Find(aligned)) { var fromLine = rows[move.RemovedStart].BaseNumber; var toLine = rows[move.AddedStart].LocalNumber; + group++; for (var k = 0; k < move.Length; k++) { + // 두 칸 사이에 옮긴 곳까지 잇는 선의 짝(11차 F-10) + rows[move.RemovedStart + k].MoveGroup = group; + rows[move.RemovedStart + k].MoveFromBase = true; + rows[move.AddedStart + k].MoveGroup = group; + rows[move.RemovedStart + k].MoveHint = toLine is null ? "자리를 옮김" : $"{toLine}행으로 옮김"; @@ -2336,7 +2343,18 @@ namespace MergeHub.ViewModels /// 편집기에 마지막으로 그려 넣은 줄과 그중 필러였던 자리. 사용자가 고친 뒤 되짚는 기준. private string[] _editorLines = Array.Empty(); - private bool[] _editorFiller = Array.Empty(); + + /// + /// 편집기 줄마다 정렬 행 번호(11차 F-10). 편집기에는 LOCAL 에 있는 줄만 들어간다 - 예전처럼 빈 줄(필러)로 + /// 행 번호를 맞추지 않으므로 캐럿 줄 ↔ 행, 행 ↔ 편집기 줄은 이 표로 옮긴다. + /// + private int[] _editorRows = Array.Empty(); + + /// 정렬 행 → 편집기 줄. 그 행이 LOCAL 에 없으면(지운 줄) 바로 다음 줄. View 가 캐럿을 옮길 때 쓴다. + public int EditorLineOfRow(int row) => EditorLayout.LineOfRow(_editorRows, row); + + /// 편집기 줄 → 정렬 행. + public int RowOfEditorLine(int line) => EditorLayout.RowOfLine(_editorRows, line); /// 모델 쪽에서 편집기 글을 갈아 끼우는 중. 되먹임(다시 정렬)을 막는다. private bool _syncingEditor; @@ -2443,7 +2461,7 @@ namespace MergeHub.ViewModels /// 이 자리에서 화면을 맞췄으면 true. private bool ShowTypedLineNow(string text, int caretIndex) { - if (_editorLines.Length == 0 || Lines.Count != _editorLines.Length) return false; + if (_editorLines.Length == 0 || _editorRows.Length != _editorLines.Length) return false; // 한 번만 훑으면서 (캐럿 줄 번호, 그 줄의 범위, 전체 줄 수)를 함께 구한다. int line = 0, start = 0, end = -1, count = 1; @@ -2466,10 +2484,14 @@ namespace MergeHub.ViewModels // 글자가 그대로면 그릴 것이 없다. 이것도 "맞춰 둔 것"이라 true 다. if (string.Equals(_editorLines[line], value, StringComparison.Ordinal)) return true; + // 편집기 줄 → 정렬 행(11차 F-10 - 편집기에는 빈 줄이 없다). + var row = _editorRows[line]; + if (row < 0 || row >= Lines.Count) return false; + // 친 줄의 원래 글을 한 번만 적어 둔다 - 재정렬 때 되돌리기 한 걸음으로 삼는다(11차 F-8). - if (!_typedOriginals.ContainsKey(line)) _typedOriginals[line] = Lines[line].LocalText; + if (!_typedOriginals.ContainsKey(row)) _typedOriginals[row] = Lines[row].LocalText; _editorLines[line] = value; - Lines[line].LocalText = value; + Lines[row].LocalText = value; DiffVersion++; return true; } @@ -2486,7 +2508,8 @@ namespace MergeHub.ViewModels _reflowTimer.Stop(); var current = SplitLines(CurrentEditorText); - var real = string.Join("\n", EditorLayout.StripFillers(_editorLines, _editorFiller, current)); + // 편집기에는 빈 줄(필러)이 없다(11차 F-10) - 편집기 글이 곧 LOCAL 글이다. + var real = string.Join("\n", current); // 편집기에서 친 것도 되돌리기 한 걸음이다(11차 F-8). 예전에는 재정렬이 목록을 통째로 비워, // 글자 하나만 쳐도 그 전의 Accept 들을 Undo 할 수 없었다. 친 줄을 원래 글로 돌려 친 것 전의 글을 얻고, @@ -2579,7 +2602,7 @@ namespace MergeHub.ViewModels _editorLines = snapshot.Lines; _typedOriginals.Clear(); // 편집기를 줄에서 다시 채웠다 - 친 것은 줄에 이미 들어갔다 - _editorFiller = snapshot.Filler; + _editorRows = snapshot.Rows; var text = string.Join(Environment.NewLine, snapshot.Lines); if (string.Equals(text, CurrentEditorText, StringComparison.Ordinal)) return; @@ -3292,7 +3315,7 @@ namespace MergeHub.ViewModels if (Lines.Count == 0) return; // 편집 중이던 내용을 잃지 않도록 지금 편집기에 있는 글을 기준으로 다시 만든다. - var current = EditorLayout.StripFillers(_editorLines, _editorFiller, SplitLines(CurrentEditorText)); + var current = SplitLines(CurrentEditorText); // 편집기 글 = LOCAL 글(11차 F-10 - 빈 줄 없음) ReloadLinesFrom(string.Join("\n", current), keepAsEdited: true, moveSelection: false); } @@ -3539,7 +3562,9 @@ namespace MergeHub.ViewModels /// View 가 캐럿을 옮길 때마다 불러 준다. public void ReportCaret(int row, int column) { + // 편집기 줄 번호 = 파일 줄 번호(11차 F-10 - 편집기에 빈 줄이 없다). 선택 줄 · 차이 위치는 정렬 행으로 센다. CaretPosition = $"{row + 1}행 {column + 1}열"; + row = RowOfEditorLine(row); // 프로그램이 옮기는 중이면 목표 행이 이미 정해져 있다 - 그 값을 지킨다. if (_movingCaret) return; diff --git a/src/MergeHub/Views/Controls/PaneLinkOverlay.cs b/src/MergeHub/Views/Controls/PaneLinkOverlay.cs new file mode 100644 index 0000000..b6a45a1 --- /dev/null +++ b/src/MergeHub/Views/Controls/PaneLinkOverlay.cs @@ -0,0 +1,31 @@ +using System; +using System.Windows; +using System.Windows.Media; + +namespace MergeHub.Views.Controls +{ + /// + /// 칸들 위에 겹쳐 두는 투명한 그림판(11차 F-10). 두 칸 사이 틈에 바뀐 덩어리를 잇는 선 · 칸 안의 덩어리 경계선을 그린다. + /// 마우스는 통과시킨다(IsHitTestVisible = false) - 아래 칸 · 구분선이 그대로 받는다. + /// 무엇을 그릴지는 화면(HomeView)이 안다 - 행 · 칸 좌표를 쥔 쪽이 로 그린다. + /// + public sealed class PaneLinkOverlay : FrameworkElement + { + public PaneLinkOverlay() + { + IsHitTestVisible = false; + SnapsToDevicePixels = true; + } + + /// 그릴 때 부른다. + public Action? Draw { get; set; } + + protected override void OnRender(DrawingContext dc) + { + if (ActualWidth <= 0 || ActualHeight <= 0) return; + dc.PushClip(new RectangleGeometry(new Rect(0, 0, ActualWidth, ActualHeight))); + Draw?.Invoke(dc); + dc.Pop(); + } + } +} diff --git a/src/MergeHub/Views/HomeView.Links.cs b/src/MergeHub/Views/HomeView.Links.cs new file mode 100644 index 0000000..79c22c6 --- /dev/null +++ b/src/MergeHub/Views/HomeView.Links.cs @@ -0,0 +1,423 @@ +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)); + } + 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); + } + } + } +} diff --git a/src/MergeHub/Views/HomeView.xaml b/src/MergeHub/Views/HomeView.xaml index 20341a3..668fe20 100644 --- a/src/MergeHub/Views/HomeView.xaml +++ b/src/MergeHub/Views/HomeView.xaml @@ -25,6 +25,8 @@ 12 + + 48 66 46 40 @@ -154,11 +156,35 @@ - + + + + @@ -193,7 +219,7 @@ 여백의 [<] 를 누르면 그 줄을 결과(MERGED)로 되돌린다 - "Accept Mine". --> - + @@ -243,7 +269,7 @@ 누르면 그 줄을 LOCAL 로 가져온다. 방향을 헷갈리지 않게 LOCAL 쪽 [>] 와 화살표를 반대로 했다. --> - + @@ -846,7 +872,8 @@ - + + - + + - + Background="Transparent"/> + + + diff --git a/src/MergeHub/Views/HomeView.xaml.cs b/src/MergeHub/Views/HomeView.xaml.cs index 3160817..67aabfa 100644 --- a/src/MergeHub/Views/HomeView.xaml.cs +++ b/src/MergeHub/Views/HomeView.xaml.cs @@ -391,6 +391,7 @@ namespace MergeHub.Views old.ScrollToRowRequested -= OnScrollToRowRequested; old.PropertyChanged -= OnViewModelPropertyChanged; old.ReadEditorText = null; + BindPaneViews(null); } if (e.NewValue is HomeViewModel now) @@ -404,6 +405,9 @@ namespace MergeHub.Views // 편집기 글은 이 컨트롤이 들고 있고, ViewModel 은 필요할 때만 읽어 간다. // 바인딩으로 매 글자마다 문서 전체를 넘기면 큰 파일에서 눈에 띄게 느리다. now.ReadEditorText = () => LocalEditor.Text; + + // 칸마다 자기 줄만(11차 F-10) + BindPaneViews(now); } } @@ -425,7 +429,14 @@ namespace MergeHub.Views return; } + if (e.PropertyName == nameof(HomeViewModel.DiffVersion)) + { + ScheduleResync(); // 칸마다 높이가 달라졌다 - 다시 맞추고 선을 다시 그린다(11차 F-10) + return; + } + if (e.PropertyName != nameof(HomeViewModel.IsThreeWay)) return; + ScheduleResync(); // 배치가 통째로 달라지므로 원본 칸의 가로 위치는 처음으로 되돌린다. SourceShiftX = 0; @@ -457,6 +468,7 @@ namespace MergeHub.Views viewer.ScrollChanged += (_, e) => { + if (e.VerticalChange != 0 || e.ViewportHeightChange != 0) LinkOverlay.InvalidateVisual(); if (Syncing) return; if (e.VerticalChange == 0 && e.HorizontalChange == 0) return; @@ -467,7 +479,7 @@ namespace MergeHub.Views // 표시층 ListBox 를 가로로 굴리는 방법은 못 쓴다 - 행이 칸 너비에 맞춰 늘어나 // 굴릴 여지(ScrollableWidth)가 0 이라 아무 일도 일어나지 않는다. Scroll(LocalLines, e.VerticalOffset, null); - ScrollSourcePanesTo(e.VerticalOffset); + if (e.VerticalChange != 0) SyncPanesFrom(MergeLine.TextPane.Local, e.VerticalOffset, e.ViewportHeight); TextShiftX = -e.HorizontalOffset; // 2-Way 는 BASE 와 LOCAL 의 폭이 같으니 한 덩어리로 움직인다. @@ -515,6 +527,7 @@ namespace MergeHub.Views } Dispatcher.BeginInvoke(new Action(MatchTextWidths), System.Windows.Threading.DispatcherPriority.Loaded); + ScheduleResync(); } private bool _watchingActivation; @@ -701,6 +714,7 @@ namespace MergeHub.Views // 가로는 건드리지 않는다. 여백(줄번호·[>])까지 밀려나기 때문이다 - // 가로 스크롤은 글자에만 거는 TextShiftX 가 따로 맡는다. _decorationShift.Y = wanted; + LinkOverlay.InvalidateVisual(); // 표시층이 움직였다 - 잇는 선도 따라간다 } catch (InvalidOperationException) { @@ -749,16 +763,6 @@ namespace MergeHub.Views // ================= 세로 스크롤 맞추기 ================= - /// - /// 위쪽 원본 칸과 아래 MERGED 의 행 높이가 다른 상태. - /// - /// 3-Way 에서 두 무리는 칸 너비가 다르므로, 워드랩을 켜면 접히는 자리가 달라 - /// 같은 행이라도 높이가 달라진다. 이때는 픽셀을 그대로 넘겨서는 맞출 수 없다 - - /// 접힌 줄을 지날 때마다 조금씩 벌어져 결국 다른 줄을 보게 된다. - /// - private bool SourceRowsDifferFromEditor => - WrapMode == TextWrapping.Wrap && DataContext is HomeViewModel { IsThreeWay: true }; - /// 코드 한 줄의 높이. XAML 의 CodeLineHeight 와 같은 값이어야 한다. private double LineHeight => TryFindResource("CodeLineHeight") is double h && h > 0 ? h : 16; @@ -780,7 +784,8 @@ namespace MergeHub.Views /// private void ApplySourceScrollUnit() { - var unit = SourceRowsDifferFromEditor ? ScrollUnit.Item : ScrollUnit.Pixel; + // 11차 F-10: 칸마다 자기 줄 높이로 가운데를 맞춘다(PaneMapping) - 행 단위로 굴릴 까닭이 없다. + const ScrollUnit unit = ScrollUnit.Pixel; foreach (var pane in SourcePanes) { @@ -788,55 +793,6 @@ namespace MergeHub.Views } } - /// - /// 편집기 화면 맨 위에 걸린 논리 행. 좌표로 직접 물어본다. - /// - /// 스크롤 값을 줄 높이로 나눠 세는 방법은 못 쓴다 - 실제로 재 보면 스크롤 160 일 때 - /// 편집기가 말하는 첫 줄은 11 인데 화면 맨 위에 있는 것은 9행이었다. TextBox 의 - /// 줄 번호 계산은 화면에 그려진 것과 딱 맞아떨어지지 않는다. - /// 그래서 "왼쪽 위 점에 있는 글자가 몇 행인가"를 물어본다 - 이건 그려진 대로 답한다. - /// - private int EditorTopRow() - { - var index = LocalEditor.GetCharacterIndexFromPoint(new Point(2, 2), snapToText: true); - if (index < 0) return 0; - - var row = RowColumnAt(LocalEditor.Text, index).Row; - return row < 0 ? 0 : row; - } - - /// 그 논리 행이 편집기에서 시작하는 세로 위치(위의 역). 역시 좌표로 잰다. - private double EditorOffsetOfRow(int row) - { - if (FindScrollViewer(LocalEditor) is not { } viewer) return 0; - - var start = CharacterIndexOfLine(LocalEditor.Text, row); - if (start < 0) return viewer.VerticalOffset; - - try - { - // GetRectFromCharacterIndex 는 보이는 영역 기준 좌표를 준다. - // 지금 굴러가 있는 만큼을 더해야 문서 전체 기준이 된다. - 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; - } - } - - /// 편집기가 굴러간 만큼 위쪽 원본 세 칸을 따라오게 한다. - private void ScrollSourcePanesTo(double editorOffset) - { - // 행 단위로 굴리는 중이면 오프셋의 뜻이 "픽셀"이 아니라 "몇 번째 행"이다. - var offset = SourceRowsDifferFromEditor ? EditorTopRow() : editorOffset; - - foreach (var pane in SourcePanes) Scroll(pane, offset, null); - } - /// /// 표시층 ListBox 가 스스로 굴러가지 못하게 막는다. /// @@ -860,6 +816,10 @@ namespace MergeHub.Views 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; @@ -880,17 +840,11 @@ namespace MergeHub.Views HoldSync(); - var editorOffset = SourceRowsDifferFromEditor - ? EditorOffsetOfRow((int)Math.Round(e.VerticalOffset)) - : e.VerticalOffset; - - Scroll(LocalEditor, editorOffset, null); - Scroll(LocalLines, editorOffset, null); - - foreach (var other in new[] { BaseLines, RemoteLines, OriginalLocalLines }) - { - if (!ReferenceEquals(other, source)) Scroll(other, e.VerticalOffset, null); - } + // 이 칸을 기준으로 나머지(편집기 · 다른 원본 칸)가 가운데를 맞춰 따라온다(11차 F-10). + var kind = ReferenceEquals(source, BaseLines) ? MergeLine.TextPane.Base + : ReferenceEquals(source, RemoteLines) ? MergeLine.TextPane.Remote + : MergeLine.TextPane.OriginalLocal; + SyncPanesFrom(kind, e.VerticalOffset, e.ViewportHeight); } private static void Scroll(DependencyObject target, double? vertical, double? horizontal) @@ -1426,6 +1380,7 @@ namespace MergeHub.Views // 워드랩을 켜면 밀 곳이 없어지므로 막대도 사라져야 한다. ApplySourceScrollUnit(); UpdateSourceScrollRange(); + ScheduleResync(); // 줄 높이가 바뀌었다 - 칸 맞추기 · 잇는 선 다시(11차 F-10) } /// ViewModel 이 "이 행으로 가라"고 하면 캐럿과 스크롤을 함께 옮긴다. @@ -1440,15 +1395,18 @@ namespace MergeHub.Views /// private void OnScrollToRowRequested(object? sender, int row) { - var start = CharacterIndexOfLine(LocalEditor.Text, 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(); // 배치가 끝난 뒤에 굴린다. 글을 갈아 끼운 직후에는 글자 좌표를 물어봐야 - // 옛 정보가 나온다. - Dispatcher.BeginInvoke(new Action(BringCaretIntoView), DispatcherPriority.Loaded); + // 옛 정보가 나온다. 그 줄의 윗변을 칸 가운데에 둔다(11차 F-10, Araxis 처럼) - LOCAL 에서 지운 덩어리로 가면 + // 윗변이 곧 지운 자리라, 다른 칸이 가운데 기준으로 따라오며 지운 줄들이 BASE 칸 가운데에 온다. + Dispatcher.BeginInvoke(new Action(() => CenterCharInView(start)), DispatcherPriority.Loaded); } /// @@ -1461,6 +1419,25 @@ namespace MergeHub.Views /// private void BringCaretIntoView() => BringCharIntoView(LocalEditor.CaretIndex); + /// 그 글자가 있는 줄의 윗변을 편집기 칸 가운데로 굴린다(차이 이동 · 11차 F-10). + 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; + + viewer.ScrollToVerticalOffset(Math.Max(0, viewer.VerticalOffset + rect.Top - viewer.ViewportHeight / 2)); + } + /// 글 안의 어느 자리든 화면에 들여 놓는다(캐럿·찾은 자리 공통). private void BringCharIntoView(int charIndex) {