11차 F-10: 칸마다 자기 줄 · 바뀐 덩어리를 잇는 선 (Araxis 식)

- 정렬 행은 그대로 두고 칸마다 그 칸에 있는 줄만 걸러 보임(빈 줄로 맞추지 않음), 칸마다 자기 행 높이
- 편집기에서 빈 줄 없앰(StripFillers 삭제), 편집기 줄 <-> 정렬 행 표(EditorSnapshot.Rows)
- 스크롤은 가운데 기준으로 따라옴(PaneMapping), 차이 이동은 그 자리를 가운데로
- 틈에 바뀐 덩어리 사다리꼴 선 · 경계선 · 옮김 점선(PaneLinkOverlay), 3분할은 위 세 칸 사이
- 지운 줄 되살리기 [>] 는 BASE 칸 끝

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
김승준 2026-09-22 21:54:39 +09:00
parent ce731c7b07
commit c317a7f70b
11 changed files with 871 additions and 179 deletions

View File

@ -167,6 +167,7 @@ A-1 에서 값으로 확인한 것 — 지운 파일은 "삭제되어 건너뜀
- F-6: 줄 간격을 좁혀 1920 × 1080 두 칸 40 → 50줄(매뉴얼의 화면 설명 · 그림이 있으면 새로). 숫자 24벌 1,572건. - F-6: 줄 간격을 좁혀 1920 × 1080 두 칸 40 → 50줄(매뉴얼의 화면 설명 · 그림이 있으면 새로). 숫자 24벌 1,572건.
- F-8: 매뉴얼 되돌리기 설명 — 저장 · 저장 후 커밋 뒤에도 Undo/Redo(커밋은 그대로, 화면만), 편집기에 친 것 · 규칙 바꾸기도 이어짐, 요약 `· 저장됨`, [전체 되돌리기] 는 파일을 연 시점(저장했으면 화면만). Git 탭 충돌 화면 "이번 병합에서 푼 파일" + [다시 충돌로](git.exe 필요 — 알아 둘 한계에도). 숫자 24벌 1,593건. - F-8: 매뉴얼 되돌리기 설명 — 저장 · 저장 후 커밋 뒤에도 Undo/Redo(커밋은 그대로, 화면만), 편집기에 친 것 · 규칙 바꾸기도 이어짐, 요약 `· 저장됨`, [전체 되돌리기] 는 파일을 연 시점(저장했으면 화면만). Git 탭 충돌 화면 "이번 병합에서 푼 파일" + [다시 충돌로](git.exe 필요 — 알아 둘 한계에도). 숫자 24벌 1,593건.
- F-9: 매뉴얼 설정 표 "저장 > 저장할 때 백업"(기본 끔) — 백업 자리 `{설정 폴더}\Backup\MergeHub\날짜\드라이브\원래 폴더\이름.날짜-시각.확장자` · 목록.txt, 모든 저장(병합 중 Save · 파일 단위 Accept · 폴더 복사 포함), 못 만들면 저장 안 함, 저절로 지우지 않음, ACTIONS [백업 폴더 열기]. 숫자 24벌 1,618건. - F-9: 매뉴얼 설정 표 "저장 > 저장할 때 백업"(기본 끔) — 백업 자리 `{설정 폴더}\Backup\MergeHub\날짜\드라이브\원래 폴더\이름.날짜-시각.확장자` · 목록.txt, 모든 저장(병합 중 Save · 파일 단위 Accept · 폴더 복사 포함), 못 만들면 저장 안 함, 저절로 지우지 않음, ACTIONS [백업 폴더 열기]. 숫자 24벌 1,618건.
- F-10: 매뉴얼 파일 비교 화면 설명과 그림을 새로 — 칸마다 자기 줄(빈 줄로 맞추지 않음) · 두 칸(3분할은 위 세 칸) 사이 잇는 선과 색 · 옮김 점선 · 가운데 기준 스크롤 · 차이 이동이 가운데로 · 지운 줄 되살리기 [>] 가 BASE 칸 끝. 상태 줄 `N행` 은 파일 줄 번호. 숫자 25벌 1,639건.
## B-1. 배포 묶음을 다시 만들고 꼭 필요한 파일을 가림 (2026-09-22, 완료) ## 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, 완료) ## F-12. C# 코드 스타일 맞춤의 기본값을 AURORA 로 (2026-09-18, 완료)
사용자: "기본값은 원본형태를 따르고 C#인 경우에 AURORA 스타일로 하자" → 물어서 정한 것은 **제품 기본값 AURORA · C# 파일만 · 저장 동작은 지금 그대로**(켜져 있으면 저장도 그 스타일로 쓴다). 사용자: "기본값은 원본형태를 따르고 C#인 경우에 AURORA 스타일로 하자" → 물어서 정한 것은 **제품 기본값 AURORA · C# 파일만 · 저장 동작은 지금 그대로**(켜져 있으면 저장도 그 스타일로 쓴다).

View File

@ -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-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-8** — 저장한 뒤에도 Undo/Redo: 저장하는 길마다 되돌리기 목록을 비우던 것을 두고, "저장할 것 있음" 을 목록이 아니라 "지금 글이 마지막 저장과 다른가" 로(`SaveBaseline`). 커밋 뒤 새 HEAD 로 다시 읽기 · 비교 규칙 바꾸기 · 편집기에 친 뒤 재정렬처럼 줄을 다시 세울 때 이력을 글 단위로 옮겨 지킴(친 것도 한 걸음, 300걸음까지). 전체 되돌리기는 파일을 연 시점. 병합 중 푼 파일은 Git 탭 [다시 충돌로] 로 되돌림 — LibGit2Sharp 에 길이 없어 이 단추만 `git checkout -m`(사용자 결정), 해결 기록에서도 뺌 | 아래 |
| 2026-09-22 | **11차 F-7 (추가 요청)** — "대형 모니터에서 오른쪽이 잘려 종료 · ACTIONS 가 안 보임": 창이 모니터 밖으로 나간 것. 켤 때 창 모드로 기억한 자리를 모니터에 조금만 걸쳐도 그대로 놓았다 — 해상도 · 모니터 배치가 바뀐 뒤 오른쪽으로 넘침(재현 840px). 플랫폼이 가장 많이 겹치는 모니터 안으로 밀게 고침(플랫폼 changelog, 사용자 확인 뒤 배포 09-22). 가장 좁은 창 × 화면 배율 80~133% 에서 안쪽 화면은 원래 넘치지 않음 | 아래 | | 2026-09-22 | **11차 F-7 (추가 요청)** — "대형 모니터에서 오른쪽이 잘려 종료 · ACTIONS 가 안 보임": 창이 모니터 밖으로 나간 것. 켤 때 창 모드로 기억한 자리를 모니터에 조금만 걸쳐도 그대로 놓았다 — 해상도 · 모니터 배치가 바뀐 뒤 오른쪽으로 넘침(재현 840px). 플랫폼이 가장 많이 겹치는 모니터 안으로 밀게 고침(플랫폼 changelog, 사용자 확인 뒤 배포 09-22). 가장 좁은 창 × 화면 배율 80~133% 에서 안쪽 화면은 원래 넘치지 않음 | 아래 |

View File

@ -379,7 +379,7 @@
| **완료** | F-7 대형 모니터에서 오른쪽(종료 · ACTIONS)이 잘림 — 창이 모니터 밖으로 나간 것. 켤 때 창 모드로 기억한 자리를 모니터에 조금만 걸쳐도 그대로 놓던 것을 가장 많이 겹치는 모니터 안으로 밀게(플랫폼 `FitToMonitors`). 재현 840px 넘침 → 0. 좁은 창 × 화면 배율 80~133% 는 원래 넘친 것 없음. PlacementTest +8. 플랫폼 배포함(09-22) | | **완료** | F-7 대형 모니터에서 오른쪽(종료 · ACTIONS)이 잘림 — 창이 모니터 밖으로 나간 것. 켤 때 창 모드로 기억한 자리를 모니터에 조금만 걸쳐도 그대로 놓던 것을 가장 많이 겹치는 모니터 안으로 밀게(플랫폼 `FitToMonitors`). 재현 840px 넘침 → 0. 좁은 창 × 화면 배율 80~133% 는 원래 넘친 것 없음. PlacementTest +8. 플랫폼 배포함(09-22) |
| **완료** | F-8 저장한 뒤에도 되돌리기 — 저장 · 저장 후 커밋 뒤 Undo/Redo 유지(저장할 것 있음은 "마지막 저장과 다른가" 로), 줄을 다시 세울 때(커밋 뒤 새 HEAD · 규칙 바꾸기 · 친 뒤 재정렬) 이력을 글 단위로 옮겨 지킴, 친 것도 한 걸음. 전체 되돌리기는 파일을 연 시점. Git 탭 "푼 파일" + [다시 충돌로](이 단추만 git.exe, 사용자 결정). MergeSaveTest +21 | | **완료** | F-8 저장한 뒤에도 되돌리기 — 저장 · 저장 후 커밋 뒤 Undo/Redo 유지(저장할 것 있음은 "마지막 저장과 다른가" 로), 줄을 다시 세울 때(커밋 뒤 새 HEAD · 규칙 바꾸기 · 친 뒤 재정렬) 이력을 글 단위로 옮겨 지킴, 친 것도 한 걸음. 전체 되돌리기는 파일을 연 시점. Git 탭 "푼 파일" + [다시 충돌로](이 단추만 git.exe, 사용자 결정). MergeSaveTest +21 |
| **완료** | F-9 저장할 때 백업 — 설정(기본 끔)을 켜면 모든 저장(두 파일 · mergetool · Save & Commit · 병합 중 Save · 파일 단위 Accept · 폴더 복사) 직전에 지금 파일을 바이트 그대로 `{설정 폴더}\Backup\MergeHub\날짜\…` 에 모음(목록.txt, 저절로 안 지움). 못 만들면 저장 안 함. ACTIONS [백업 폴더 열기]. 하네스 +25 | | **완료** | 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-11 Git 계정 · URL · 암호 저장 확인 (지금은 주소 · 계정만 저장, 토큰은 메모리) |
| **완료** | F-12 C# 코드 스타일 맞춤 기본값을 AURORA 로 (2026-09-18 사용자 결정) — 설정 없는 첫 실행에서 AURORA, 중괄호만 다른 C# 짝 "차이 없음" · 같은 모양의 C++ 짝은 "남은 차이 4줄"(원본 그대로). 저장 동작은 그대로. 하네스 StyleTest +12(90건) | | **완료** | F-12 C# 코드 스타일 맞춤 기본값을 AURORA 로 (2026-09-18 사용자 결정) — 설정 없는 첫 실행에서 AURORA, 중괄호만 다른 C# 짝 "차이 없음" · 같은 모양의 C++ 짝은 "남은 차이 4줄"(원본 그대로). 저장 동작은 그대로. 하네스 StyleTest +12(90건) |

View File

@ -166,19 +166,12 @@ namespace MergeHub.Models
public bool HasProblem => !string.IsNullOrEmpty(Problem); public bool HasProblem => !string.IsNullOrEmpty(Problem);
// ---- 워드랩에서 패널들의 행 높이 맞추기 ---- // ---- 칸마다 자기 행 높이 (11차 F-10) ----
// //
// 워드랩을 켜면 줄마다 접히는 횟수가 달라 높이가 제각각이 된다. 패널이 각자 자기 글 // 예전에는 모든 칸이 같은 행을 같은 눈높이에 놓았다(한쪽에 없는 줄은 빈 줄로 채움). 그래서 워드랩으로
// 높이만 쓰면, 한쪽만 접힌 행에서 그 아래가 통째로 밀린다. 그래서 "같은 눈높이에 // 한쪽만 접힌 행은 큰 쪽 높이를 함께 썼다. 이제는 칸마다 자기 줄만 보이고(Araxis 식) 두 칸은 잇는 선으로
// 있어야 하는 패널끼리" 높이 중 큰 값을 함께 쓴다. // 짝을 보이므로 <b>높이를 맞출 까닭이 없다</b> - 칸마다 자기 글 높이를 쓴다. 이 높이는 스크롤 맞추기 ·
// // 잇는 선의 자리 계산(<see cref="PaneHeight"/>)에도 쓰인다.
// 여기서 중요한 것은 <b>같이 맞춰야 하는 묶음이 하나가 아니라는 점</b>이다.
// 2-Way : BASE ↔ LOCAL(편집기) 이 나란히 있으므로 넷이 한 묶음이다.
// 3-Way : 위의 원본 세 칸은 폭이 같아 서로 맞춰야 하지만,
// 아래 MERGED 는 폭이 전혀 달라(가로 전체) 접히는 자리가 애초에 다르다.
// 같은 높이를 강요하면 MERGED 는 안 접힌 줄에 빈칸이 생기고,
// 그 위에 겹친 편집기와도 어긋난다.
// 그래서 묶음을 둘로 나눈다 - RowHeight(MERGED·편집기) 와 SourceRowHeight(원본 칸들).
private double _baseTextHeight; private double _baseTextHeight;
private double _localTextHeight; private double _localTextHeight;
@ -198,8 +191,71 @@ namespace MergeHub.Models
/// <summary>편집기가 겹친 칸(2-Way LOCAL · 3-Way MERGED)의 행 높이.</summary> /// <summary>편집기가 겹친 칸(2-Way LOCAL · 3-Way MERGED)의 행 높이.</summary>
[ObservableProperty] private double _rowHeight = MinimumRowHeight; [ObservableProperty] private double _rowHeight = MinimumRowHeight;
/// <summary>읽기 전용 원본 칸들의 행 높이. 2-Way 에서는 RowHeight 와 같다.</summary> /// <summary>BASE 칸의 행 높이.</summary>
[ObservableProperty] private double _sourceRowHeight = MinimumRowHeight; [ObservableProperty] private double _baseRowHeight = MinimumRowHeight;
/// <summary>REMOTE 칸의 행 높이.</summary>
[ObservableProperty] private double _remoteRowHeight = MinimumRowHeight;
/// <summary>3-Way LOCAL(Mine) 원본 칸의 행 높이.</summary>
[ObservableProperty] private double _originalLocalRowHeight = MinimumRowHeight;
/// <summary>
/// 그 칸에서 이 행이 차지하는 높이(11차 F-10). 그 칸에 없는 줄이면 0.
/// <paramref name="measured"/> 가 false 면(워드랩 끔) 모든 줄이 한 줄 높이라 잰 값을 보지 않는다 -
/// 화면에 안 그려진 행은 잰 값이 없어 어림이 되므로, 한 줄 높이가 정확할 때는 그것을 쓴다.
/// </summary>
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,
};
}
/// <summary>그 칸에 이 행의 줄이 있는지(11차 F-10 - 칸마다 자기 줄만 보인다).</summary>
public bool IsIn(TextPane pane) => pane switch
{
TextPane.Base => HasBase,
TextPane.Local => HasLocal,
TextPane.Remote => HasRemote,
_ => HasOriginalLocal,
};
/// <summary>
/// 두 칸 사이에서 이 행이 다른지 - 잇는 선을 그을 덩어리를 가른다(11차 F-10). 무시 규칙에 걸린 줄은 같은 것으로 본다.
/// 머지 주석 줄도 한쪽에만 있는 줄이라 다른 것으로 친다(어디에 끼워 넣었는지 보여야 한다).
/// </summary>
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,
};
/// <summary> /// <summary>
/// 접히지 않은 보통 줄의 높이. HomeView 의 CodeLineHeight 와 같은 값이어야 한다. /// 접히지 않은 보통 줄의 높이. HomeView 의 CodeLineHeight 와 같은 값이어야 한다.
@ -228,18 +284,11 @@ namespace MergeHub.Models
} }
private void ApplyRowHeights() private void ApplyRowHeights()
{
if (IsThreeWay)
{ {
RowHeight = Math.Max(MinimumRowHeight, _localTextHeight); RowHeight = Math.Max(MinimumRowHeight, _localTextHeight);
SourceRowHeight = Math.Max(MinimumRowHeight, BaseRowHeight = Math.Max(MinimumRowHeight, _baseTextHeight);
Math.Max(_originalLocalTextHeight, Math.Max(_baseTextHeight, _remoteTextHeight))); RemoteRowHeight = Math.Max(MinimumRowHeight, _remoteTextHeight);
return; OriginalLocalRowHeight = Math.Max(MinimumRowHeight, _originalLocalTextHeight);
}
var shared = Math.Max(MinimumRowHeight, Math.Max(_baseTextHeight, _localTextHeight));
RowHeight = shared;
SourceRowHeight = shared;
} }
/// <summary>무시 규칙(줄 필터)에 걸린 줄. 차이로 세지 않고 옅게만 표시한다.</summary> /// <summary>무시 규칙(줄 필터)에 걸린 줄. 차이로 세지 않고 옅게만 표시한다.</summary>
@ -257,6 +306,15 @@ namespace MergeHub.Models
public bool IsMoved => !string.IsNullOrEmpty(MoveHint); public bool IsMoved => !string.IsNullOrEmpty(MoveHint);
/// <summary>
/// 자리 옮김 짝 번호(11차 F-10). 같은 번호의 BASE 쪽 줄(<see cref="MoveFromBase"/>)과 LOCAL 쪽 줄이 한 짝이다 - 두 칸 사이에
/// 옮긴 곳까지 잇는 선을 그린다. 행 번호가 아니라 줄에 붙여 두어 머지 주석이 끼어들어 번호가 밀려도 짝이 유지된다. 0 이면 옮김 아님.
/// </summary>
public int MoveGroup { get; set; }
/// <summary>옮김 짝에서 떠난 쪽(BASE 에 있던 자리)인지. false 면 옮겨 온 쪽(LOCAL).</summary>
public bool MoveFromBase { get; set; }
/// <summary>사용자가 여백을 눌러 표시해 둔 줄. F2 로 옮겨 다닌다.</summary> /// <summary>사용자가 여백을 눌러 표시해 둔 줄. F2 로 옮겨 다닌다.</summary>
[ObservableProperty] private bool _isBookmarked; [ObservableProperty] private bool _isBookmarked;

View File

@ -1,94 +1,77 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
namespace MergeHub.Services namespace MergeHub.Services
{ {
/// <summary>편집기에 그려 넣을 줄들과, 그중 어느 줄이 필러인지.</summary> /// <summary>편집기에 그려 넣을 줄들과, 편집기 줄마다 정렬 행 번호.</summary>
public sealed class EditorSnapshot public sealed class EditorSnapshot
{ {
public EditorSnapshot(string[] lines, bool[] filler) public EditorSnapshot(string[] lines, int[] rows)
{ {
Lines = lines; Lines = lines;
Filler = filler; Rows = rows;
} }
/// <summary>편집기 본문. 필러 자리는 빈 문자열이다.</summary> /// <summary>편집기 줄마다 정렬 행 번호(11차 F-10). 편집기에는 LOCAL 에 있는 줄만 들어가므로 줄 번호와 행 번호가 다르다.</summary>
public string[] Lines { get; } public int[] Rows { get; }
/// <summary>같은 인덱스의 줄이 필러(= LOCAL 에 없는 줄)인지.</summary> /// <summary>편집기 본문 - LOCAL 에 있는 줄만.</summary>
public bool[] Filler { get; } public string[] Lines { get; }
} }
/// <summary> /// <summary>
/// LOCAL 패널을 "항상 고칠 수 있는 편집기"로 쓰기 위한 줄 계산. /// LOCAL 패널을 "항상 고칠 수 있는 편집기"로 쓰기 위한 줄 계산.
/// ///
/// 화면에서 BASE 와 LOCAL 은 같은 내용이 같은 눈높이에 와야 한다. 그래서 BASE 에만 있는 /// 10차까지는 BASE 에만 있는 줄 자리에 LOCAL 쪽 <b>빈 줄(필러)</b>을 끼워 두 칸의 같은 줄을 같은 눈높이에 두고,
/// 줄(= LOCAL 에서 지워진 줄) 자리에는 LOCAL 쪽에 <b>빈 줄(필러)</b>을 끼워 넣는다. /// 저장 · 재정렬 전에 그 빈 줄을 걷어냈다(StripFillers). 11차 F-10 부터 칸마다 자기 줄만 보이고(Araxis 식)
/// 필러는 화면을 맞추려고 넣은 것일 뿐 파일 내용이 아니므로, 저장하거나 다시 정렬하기 전에 /// 두 칸은 잇는 선으로 짝을 보이므로 <b>빈 줄을 넣지 않는다</b> - 편집기 글이 곧 파일 글이고, 걷어낼 것도 없다.
/// 걷어내야 한다. /// 편집기 줄 ↔ 정렬 행은 <see cref="EditorSnapshot.Rows"/> 로 옮긴다.
///
/// 문제는 사용자가 편집기에서 줄을 넣고 지운 뒤라 필러 위치가 밀려 있다는 점이다.
/// 그래서 "고치기 전 편집기 내용"과 "지금 편집기 내용"을 정렬해 보고,
/// <b>예전 필러와 그대로 대응되면서 여전히 비어 있는 줄</b>만 필러로 판정해 걷어낸다.
/// 필러 자리에 사용자가 뭔가 써 넣었다면 그건 새로 추가한 줄이므로 그대로 살린다.
/// </summary> /// </summary>
public static class EditorLayout public static class EditorLayout
{ {
/// <summary>정렬 결과를 편집기에 그릴 줄들로 바꾼다.</summary> /// <summary>
/// 정렬 결과를 편집기에 그릴 줄들로 바꾼다.
///
/// 11차 F-10 부터 <b>빈 줄(필러)을 넣지 않는다</b> - 칸마다 자기 줄만 보이고(Araxis 식) 두 칸은 잇는 선으로 짝을 보인다.
/// 그래서 편집기 글이 곧 파일 글이고, 어느 편집기 줄이 몇 번째 정렬 행인지는 <see cref="EditorSnapshot.Rows"/> 가 알려 준다.
/// </summary>
public static EditorSnapshot Build(IReadOnlyList<(string Text, bool HasLocal)> rows) public static EditorSnapshot Build(IReadOnlyList<(string Text, bool HasLocal)> rows)
{ {
var lines = new string[rows.Count]; var lines = new List<string>(rows.Count);
var filler = new bool[rows.Count]; var map = new List<int>(rows.Count);
for (var i = 0; i < rows.Count; i++) for (var i = 0; i < rows.Count; i++)
{ {
filler[i] = !rows[i].HasLocal; if (!rows[i].HasLocal) continue;
lines[i] = rows[i].HasLocal ? rows[i].Text : string.Empty; lines.Add(rows[i].Text);
map.Add(i);
} }
return new EditorSnapshot(lines, filler); return new EditorSnapshot(lines.ToArray(), map.ToArray());
} }
/// <summary> /// <summary>
/// 사용자가 고친 편집기 내용에서 필러를 걷어내 실제 LOCAL 줄만 남긴다. /// 정렬 행 → 편집기 줄(11차 F-10). 그 행이 LOCAL 에 없으면(지운 줄) 바로 다음에 있는 줄, 뒤에 없으면 마지막 줄.
/// 편집기가 비었으면 0.
/// </summary> /// </summary>
/// <param name="previous">고치기 전 편집기 줄(필러 포함)</param> public static int LineOfRow(IReadOnlyList<int> rows, int row)
/// <param name="wasFiller">그때 어느 줄이 필러였는지</param>
/// <param name="current">지금 편집기 줄</param>
public static List<string> StripFillers(IReadOnlyList<string> previous,
IReadOnlyList<bool> wasFiller,
IReadOnlyList<string> current)
{ {
var kept = new List<string>(current.Count); if (rows.Count == 0) return 0;
int lo = 0, hi = rows.Count; // rows 는 오름차순 - row 이상인 첫 자리
// 고치기 전 내용이 없으면 걷어낼 필러도 없다. while (lo < hi)
if (previous.Count == 0)
{ {
foreach (var line in current) kept.Add(line); var mid = (lo + hi) / 2;
return kept; if (rows[mid] < row) lo = mid + 1; else hi = mid;
}
return lo < rows.Count ? lo : rows.Count - 1;
} }
// 어느 줄이 그대로 남았는지 짚어 준다. 빈 줄은 흔해서 앵커가 되지 못하므로 /// <summary>편집기 줄 → 정렬 행(11차 F-10). 범위 밖이면 가까운 끝. 편집기가 비었으면 0.</summary>
// 주변의 진짜 코드 줄에 딸려 자리를 잡는다 - 그래서 정렬기를 쓰는 것이다. public static int RowOfLine(IReadOnlyList<int> rows, int line)
var map = LineAligner.Align(previous, current);
foreach (var row in map)
{ {
if (row.LocalNumber is null) continue; // 사용자가 지운 줄 if (rows.Count == 0) return 0;
if (line < 0) return rows[0];
var text = row.LocalText ?? string.Empty; return line < rows.Count ? rows[line] : rows[rows.Count - 1];
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;
} }
/// <summary>두 줄 목록이 같은지. 편집기 글을 다시 써야 하는지 판단할 때 쓴다.</summary> /// <summary>두 줄 목록이 같은지. 편집기 글을 다시 써야 하는지 판단할 때 쓴다.</summary>

View File

@ -0,0 +1,120 @@
using System;
using System.Collections.Generic;
namespace MergeHub.Services
{
/// <summary>정렬 행 [Start, End) 한 덩어리 - 두 칸 사이에 잇는 선 하나.</summary>
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})";
}
/// <summary>
/// 칸마다 자기 줄만 보이는 화면(11차 F-10, Araxis 식)에서 두 칸을 잇는 계산. 화면 없이 검사한다.
///
/// 속은 지금처럼 <b>정렬 행</b>(한쪽에 없는 줄은 그 칸에서 높이 0) 하나다. 칸 k 의 높이 목록 = 정렬 행마다 그 칸에서의 높이
/// (그 칸에 없는 줄이면 0). 이것으로 ① 한 칸의 자리를 다른 칸의 자리로 옮기고(스크롤 맞추기) ② 바뀐 덩어리를 찾는다(잇는 선).
/// </summary>
public static class PaneLinks
{
/// <summary>정렬 행 i 의 윗변 자리. 길이 n+1 - 마지막은 칸 전체 높이.</summary>
public static double[] Tops(IReadOnlyList<double> 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;
}
/// <summary>
/// 바뀐 행이 이어지는 덩어리들. <paramref name="changed"/>[i] = 정렬 행 i 가 두 칸 사이에서 다른가.
/// </summary>
public static List<LinkBlock> Blocks(IReadOnlyList<bool> changed)
{
var blocks = new List<LinkBlock>();
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;
}
}
/// <summary>
/// 한 칸(A)의 세로 자리를 다른 칸(B)의 자리로 옮긴다 - 스크롤을 맞출 때 쓴다(11차 F-10).
///
/// 두 칸에 <b>함께 있는 줄</b>의 가운데를 짝점으로 삼고 그 사이는 비율로 잇는다. 같은 줄이 이어지는 곳은 1:1,
/// 한쪽에만 있는 덩어리를 지날 때는 짝점 사이가 벌어진 만큼 천천히/빨리 따라간다 - 덩어리 경계에서 한 번에 튀지 않는다
/// (Araxis 가 이렇게 따라간다). 함께 있는 줄이 없으면 두 칸 전체 높이의 비율로.
/// </summary>
public sealed class PaneMapping
{
private readonly double[] _a;
private readonly double[] _b;
public PaneMapping(IReadOnlyList<double> heightsA, IReadOnlyList<double> 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<double> { 0 };
var ys = new List<double> { 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; }
/// <summary>A 의 자리 → B 의 자리. 끝 밖은 기울기 1 로 늘인다.</summary>
public double Map(double yA) => Interpolate(_a, _b, yA);
/// <summary>B 의 자리 → A 의 자리.</summary>
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;
}
}
}

View File

@ -1632,7 +1632,7 @@ namespace MergeHub.ViewModels
_syncingEditor = false; _syncingEditor = false;
_editorLines = Array.Empty<string>(); _editorLines = Array.Empty<string>();
_typedOriginals.Clear(); // 편집기를 줄에서 다시 채웠다 - 친 것은 줄에 이미 들어갔다 _typedOriginals.Clear(); // 편집기를 줄에서 다시 채웠다 - 친 것은 줄에 이미 들어갔다
_editorFiller = Array.Empty<bool>(); _editorRows = Array.Empty<int>();
InvalidateDiffBlocks(); InvalidateDiffBlocks();
BaseHeader = "BASE"; BaseHeader = "BASE";
@ -1795,13 +1795,20 @@ namespace MergeHub.ViewModels
/// </summary> /// </summary>
private static void MarkMoves(IReadOnlyList<AlignedRow> aligned, List<MergeLine> rows) private static void MarkMoves(IReadOnlyList<AlignedRow> aligned, List<MergeLine> rows)
{ {
var group = 0;
foreach (var move in MoveDetector.Find(aligned)) foreach (var move in MoveDetector.Find(aligned))
{ {
var fromLine = rows[move.RemovedStart].BaseNumber; var fromLine = rows[move.RemovedStart].BaseNumber;
var toLine = rows[move.AddedStart].LocalNumber; var toLine = rows[move.AddedStart].LocalNumber;
group++;
for (var k = 0; k < move.Length; k++) 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 rows[move.RemovedStart + k].MoveHint = toLine is null
? "자리를 옮김" ? "자리를 옮김"
: $"{toLine}행으로 옮김"; : $"{toLine}행으로 옮김";
@ -2336,7 +2343,18 @@ namespace MergeHub.ViewModels
/// <summary>편집기에 마지막으로 그려 넣은 줄과 그중 필러였던 자리. 사용자가 고친 뒤 되짚는 기준.</summary> /// <summary>편집기에 마지막으로 그려 넣은 줄과 그중 필러였던 자리. 사용자가 고친 뒤 되짚는 기준.</summary>
private string[] _editorLines = Array.Empty<string>(); private string[] _editorLines = Array.Empty<string>();
private bool[] _editorFiller = Array.Empty<bool>();
/// <summary>
/// 편집기 줄마다 정렬 행 번호(11차 F-10). 편집기에는 LOCAL 에 있는 줄만 들어간다 - 예전처럼 빈 줄(필러)로
/// 행 번호를 맞추지 않으므로 캐럿 줄 ↔ 행, 행 ↔ 편집기 줄은 이 표로 옮긴다.
/// </summary>
private int[] _editorRows = Array.Empty<int>();
/// <summary>정렬 행 → 편집기 줄. 그 행이 LOCAL 에 없으면(지운 줄) 바로 다음 줄. View 가 캐럿을 옮길 때 쓴다.</summary>
public int EditorLineOfRow(int row) => EditorLayout.LineOfRow(_editorRows, row);
/// <summary>편집기 줄 → 정렬 행.</summary>
public int RowOfEditorLine(int line) => EditorLayout.RowOfLine(_editorRows, line);
/// <summary>모델 쪽에서 편집기 글을 갈아 끼우는 중. 되먹임(다시 정렬)을 막는다.</summary> /// <summary>모델 쪽에서 편집기 글을 갈아 끼우는 중. 되먹임(다시 정렬)을 막는다.</summary>
private bool _syncingEditor; private bool _syncingEditor;
@ -2443,7 +2461,7 @@ namespace MergeHub.ViewModels
/// <returns>이 자리에서 화면을 맞췄으면 true.</returns> /// <returns>이 자리에서 화면을 맞췄으면 true.</returns>
private bool ShowTypedLineNow(string text, int caretIndex) 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; int line = 0, start = 0, end = -1, count = 1;
@ -2466,10 +2484,14 @@ namespace MergeHub.ViewModels
// 글자가 그대로면 그릴 것이 없다. 이것도 "맞춰 둔 것"이라 true 다. // 글자가 그대로면 그릴 것이 없다. 이것도 "맞춰 둔 것"이라 true 다.
if (string.Equals(_editorLines[line], value, StringComparison.Ordinal)) return 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). // 친 줄의 원래 글을 한 번만 적어 둔다 - 재정렬 때 되돌리기 한 걸음으로 삼는다(11차 F-8).
if (!_typedOriginals.ContainsKey(line)) _typedOriginals[line] = Lines[line].LocalText; if (!_typedOriginals.ContainsKey(row)) _typedOriginals[row] = Lines[row].LocalText;
_editorLines[line] = value; _editorLines[line] = value;
Lines[line].LocalText = value; Lines[row].LocalText = value;
DiffVersion++; DiffVersion++;
return true; return true;
} }
@ -2486,7 +2508,8 @@ namespace MergeHub.ViewModels
_reflowTimer.Stop(); _reflowTimer.Stop();
var current = SplitLines(CurrentEditorText); 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). 예전에는 재정렬이 목록을 통째로 비워, // 편집기에서 친 것도 되돌리기 한 걸음이다(11차 F-8). 예전에는 재정렬이 목록을 통째로 비워,
// 글자 하나만 쳐도 그 전의 Accept 들을 Undo 할 수 없었다. 친 줄을 원래 글로 돌려 친 것 전의 글을 얻고, // 글자 하나만 쳐도 그 전의 Accept 들을 Undo 할 수 없었다. 친 줄을 원래 글로 돌려 친 것 전의 글을 얻고,
@ -2579,7 +2602,7 @@ namespace MergeHub.ViewModels
_editorLines = snapshot.Lines; _editorLines = snapshot.Lines;
_typedOriginals.Clear(); // 편집기를 줄에서 다시 채웠다 - 친 것은 줄에 이미 들어갔다 _typedOriginals.Clear(); // 편집기를 줄에서 다시 채웠다 - 친 것은 줄에 이미 들어갔다
_editorFiller = snapshot.Filler; _editorRows = snapshot.Rows;
var text = string.Join(Environment.NewLine, snapshot.Lines); var text = string.Join(Environment.NewLine, snapshot.Lines);
if (string.Equals(text, CurrentEditorText, StringComparison.Ordinal)) return; if (string.Equals(text, CurrentEditorText, StringComparison.Ordinal)) return;
@ -3292,7 +3315,7 @@ namespace MergeHub.ViewModels
if (Lines.Count == 0) return; 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); ReloadLinesFrom(string.Join("\n", current), keepAsEdited: true, moveSelection: false);
} }
@ -3539,7 +3562,9 @@ namespace MergeHub.ViewModels
/// <summary>View 가 캐럿을 옮길 때마다 불러 준다.</summary> /// <summary>View 가 캐럿을 옮길 때마다 불러 준다.</summary>
public void ReportCaret(int row, int column) public void ReportCaret(int row, int column)
{ {
// 편집기 줄 번호 = 파일 줄 번호(11차 F-10 - 편집기에 빈 줄이 없다). 선택 줄 · 차이 위치는 정렬 행으로 센다.
CaretPosition = $"{row + 1}행 {column + 1}열"; CaretPosition = $"{row + 1}행 {column + 1}열";
row = RowOfEditorLine(row);
// 프로그램이 옮기는 중이면 목표 행이 이미 정해져 있다 - 그 값을 지킨다. // 프로그램이 옮기는 중이면 목표 행이 이미 정해져 있다 - 그 값을 지킨다.
if (_movingCaret) return; if (_movingCaret) return;

View File

@ -0,0 +1,31 @@
using System;
using System.Windows;
using System.Windows.Media;
namespace MergeHub.Views.Controls
{
/// <summary>
/// 칸들 위에 겹쳐 두는 투명한 그림판(11차 F-10). 두 칸 사이 틈에 바뀐 덩어리를 잇는 선 · 칸 안의 덩어리 경계선을 그린다.
/// 마우스는 통과시킨다(IsHitTestVisible = false) - 아래 칸 · 구분선이 그대로 받는다.
/// 무엇을 그릴지는 화면(HomeView)이 안다 - 행 · 칸 좌표를 쥔 쪽이 <see cref="Draw"/> 로 그린다.
/// </summary>
public sealed class PaneLinkOverlay : FrameworkElement
{
public PaneLinkOverlay()
{
IsHitTestVisible = false;
SnapsToDevicePixels = true;
}
/// <summary>그릴 때 부른다.</summary>
public Action<DrawingContext>? 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();
}
}
}

View File

@ -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
{
/// <summary>
/// 칸마다 자기 줄만 보이는 화면(11차 F-10, Araxis 식).
///
/// 속은 예전처럼 <b>정렬 행</b> 하나(<see cref="HomeViewModel.Lines"/>)다 - 차이 · 반영 · 되돌리기 · 차이 이동이 그대로 쓴다.
/// 칸마다 그 칸에 있는 줄만 걸러 보이고(빈 줄로 높이를 맞추지 않는다), 두 칸의 짝은 틈에 그리는 잇는 선으로 보인다.
/// 스크롤은 한 칸을 굴리면 나머지가 <b>가운데 기준</b>으로 따라온다(<see cref="PaneMapping"/>).
/// </summary>
public partial class HomeView
{
/// <summary>그 칸에 있는 줄만 보이는 목록. 반영 · 되살리기로 LOCAL 줄이 생기고 사라지면 바로 따라간다.</summary>
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;
/// <summary>네 칸에 자기 줄 목록을 붙인다. 새 ViewModel 이 올 때 한 번.</summary>
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;
/// <summary>글 · 줄이 바뀌면 칸마다 높이가 달라진다 - 배치가 끝난 뒤 편집기 기준으로 한 번 다시 맞추고 선을 다시 그린다.</summary>
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);
}
/// <summary>
/// 칸 목록에서 줄을 눌렀다. 선택 줄은 ViewModel 이 쥔다 - 목록은 한 방향으로만 받는다(OneWay).
/// 고른 줄이 그 칸에 없으면(다른 칸에만 있는 줄) 목록 선택은 비지만, 그것을 ViewModel 로 되돌려 보내지 않는다.
/// </summary>
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;
}
/// <summary>지금 보이는 칸들(목록 · 칸 종류). LOCAL 은 편집기가 주인이라 목록과 함께 따로 다룬다.</summary>
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),
};
/// <summary>잇는 선을 그릴 칸 짝(왼쪽 · 오른쪽). 3-Way 는 위 세 칸 사이 두 틈(사용자 결정 - MERGED 는 따라 스크롤만).</summary>
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),
};
/// <summary>워드랩을 켰을 때만 칸마다 잰 줄 높이를 쓴다. 끄면 모든 줄이 한 줄 높이라 그것이 정확하다.</summary>
private bool MeasuredRows => WrapMode == TextWrapping.Wrap;
/// <summary>정렬 행마다 그 칸에서의 높이(없는 줄은 0).</summary>
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;
}
/// <summary>
/// 한 칸(<paramref name="master"/>)이 굴렀다 - 나머지 칸을 가운데 기준으로 따라오게 한다.
/// 같은 줄이 이어지는 곳은 1:1, 한쪽에만 있는 덩어리를 지날 때는 비율로 따라가 한 번에 튀지 않는다.
/// </summary>
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)));
// ================= 잇는 선 그리기 =================
/// <summary>한 칸의 세로 좌표를 그림판 기준으로 재는 도구. 그릴 때마다 새로 만든다.</summary>
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<MergeLine> lines, MergeLine.TextPane pane, bool measured)
{
_list = list;
_to = to;
var n = lines.Count;
_index = new int[n + 1];
var heights = new List<double>(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;
/// <summary>정렬 행 경계 i(행 i 의 윗변)의 세로 자리. 그려진 줄이면 그 자리, 아니면 보이는 첫 줄에서 모델 높이로 어림.</summary>
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<Color, (Brush Fill, Pen Edge, Pen Strong)> LinkPens = new Dictionary<Color, (Brush, Pen, Pen)>();
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;
}
/// <summary>덩어리 색. 두 칸 가운데 어느 쪽에만 있는지 · 충돌인지로 가른다.</summary>
private static Brush BlockBrush(IList<MergeLine> 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;
}
/// <summary>그림판에 잇는 선을 그린다. 칸이 구를 때 · 글이 바뀔 때마다 다시 그린다.</summary>
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();
}
}
/// <summary>
/// 덩어리 하나: 두 칸 안의 위 · 아래 경계선과 틈의 사다리꼴. 한쪽에 없는 덩어리는 그 칸에 굵은 선 하나(어디에 들어갈 자리인지).
/// </summary>
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<MergeLine> lines, PaneGeometry left, PaneGeometry right, double top, double bottom)
{
// 짝 번호 → [떠난 자리 시작, 끝, 옮겨 간 자리 시작, 끝] (정렬 행)
var groups = new Dictionary<int, int[]>();
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);
}
}
}
}

View File

@ -25,6 +25,8 @@
<!-- 코드 글자 크기와 그에 따르는 여백(11차 F-5). 기본 12 의 값이다 - 화면(ApplyCodeFont)이 <!-- 코드 글자 크기와 그에 따르는 여백(11차 F-5). 기본 12 의 값이다 - 화면(ApplyCodeFont)이
CodeFontMetrics 로 함께 바꾼다. DynamicResource 로 읽어야 바뀐 값이 바로 그려진다. --> CodeFontMetrics 로 함께 바꾼다. DynamicResource 로 읽어야 바뀐 값이 바로 그려진다. -->
<system:Double x:Key="CodeFontSize">12</system:Double> <system:Double x:Key="CodeFontSize">12</system:Double>
<!-- 칸 사이 잇는 선 자리 폭(11차 F-10) -->
<GridLength x:Key="LinkGapWidth">48</GridLength>
<GridLength x:Key="GutterWidth">66</GridLength> <GridLength x:Key="GutterWidth">66</GridLength>
<GridLength x:Key="GutterNumberWidth">46</GridLength> <GridLength x:Key="GutterNumberWidth">46</GridLength>
<GridLength x:Key="GutterNarrowNumberWidth">40</GridLength> <GridLength x:Key="GutterNarrowNumberWidth">40</GridLength>
@ -154,11 +156,35 @@
<DataTemplate x:Key="BaseLineTemplate"> <DataTemplate x:Key="BaseLineTemplate">
<!-- 여백 폭(66)을 LOCAL 과 맞춘다. 접히는 자리가 같아야 워드랩에서도 행이 어긋나지 않는다. <!-- 여백 폭(66)을 LOCAL 과 맞춘다. 접히는 자리가 같아야 워드랩에서도 행이 어긋나지 않는다.
66 인 까닭은 LocalLineTemplate 에 적었다(6차 B-4). --> 66 인 까닭은 LocalLineTemplate 에 적었다(6차 B-4). -->
<Grid MinHeight="{Binding SourceRowHeight}"> <Grid MinHeight="{Binding BaseRowHeight}">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="{DynamicResource GutterWidth}"/> <ColumnDefinition Width="{DynamicResource GutterWidth}"/>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<!-- LOCAL 에서 지운 줄을 되살리는 [>] (11차 F-10). 예전에는 LOCAL 의 빈 줄(필러)에 달려 있었는데
이제 LOCAL 칸에는 그 줄이 없으므로 BASE 쪽 줄 끝에 단다(Araxis 처럼). LOCAL 에 있는 줄은 LOCAL 여백의 [>] 그대로. -->
<Button Grid.Column="2" Content="&gt;" Padding="0" Margin="2,0,2,0" MinWidth="16"
Height="{DynamicResource GutterButtonHeight}" MinHeight="{DynamicResource GutterButtonHeight}"
FontSize="{DynamicResource GutterButtonFontSize}" VerticalAlignment="Top"
Command="{Binding DataContext.TakeLineCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}"
ToolTip="LOCAL 에서 지운 이 줄을 되살리기 (BASE 내용으로 가져오기)">
<Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding CanTakeBase}" Value="True"/>
<Condition Binding="{Binding HasLocal}" Value="False"/>
</MultiDataTrigger.Conditions>
<Setter Property="Visibility" Value="Visible"/>
</MultiDataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<TextBlock Grid.Column="0" Text="{Binding BaseNumberText}" Style="{StaticResource Mono}" LineHeight="{DynamicResource CodeLineHeight}" LineStackingStrategy="BlockLineHeight" <TextBlock Grid.Column="0" Text="{Binding BaseNumberText}" Style="{StaticResource Mono}" LineHeight="{DynamicResource CodeLineHeight}" LineStackingStrategy="BlockLineHeight"
Foreground="{DynamicResource Ubisam.Brush.Muted}" TextAlignment="Right" Foreground="{DynamicResource Ubisam.Brush.Muted}" TextAlignment="Right"
VerticalAlignment="Top" Margin="0,0,8,0"/> VerticalAlignment="Top" Margin="0,0,8,0"/>
@ -193,7 +219,7 @@
여백의 [&lt;] 를 누르면 그 줄을 결과(MERGED)로 되돌린다 - "Accept Mine". 여백의 [&lt;] 를 누르면 그 줄을 결과(MERGED)로 되돌린다 - "Accept Mine".
--> -->
<DataTemplate x:Key="OriginalLocalLineTemplate"> <DataTemplate x:Key="OriginalLocalLineTemplate">
<Grid MinHeight="{Binding SourceRowHeight}"> <Grid MinHeight="{Binding OriginalLocalRowHeight}">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="{DynamicResource GutterNumberWidth}"/> <ColumnDefinition Width="{DynamicResource GutterNumberWidth}"/>
<ColumnDefinition Width="{DynamicResource GutterButtonWidth}"/> <ColumnDefinition Width="{DynamicResource GutterButtonWidth}"/>
@ -243,7 +269,7 @@
누르면 그 줄을 LOCAL 로 가져온다. 방향을 헷갈리지 않게 LOCAL 쪽 [&gt;] 와 화살표를 반대로 했다. 누르면 그 줄을 LOCAL 로 가져온다. 방향을 헷갈리지 않게 LOCAL 쪽 [&gt;] 와 화살표를 반대로 했다.
--> -->
<DataTemplate x:Key="RemoteLineTemplate"> <DataTemplate x:Key="RemoteLineTemplate">
<Grid MinHeight="{Binding SourceRowHeight}"> <Grid MinHeight="{Binding RemoteRowHeight}">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="{DynamicResource GutterNumberWidth}"/> <ColumnDefinition Width="{DynamicResource GutterNumberWidth}"/>
<ColumnDefinition Width="{DynamicResource GutterButtonWidth}"/> <ColumnDefinition Width="{DynamicResource GutterButtonWidth}"/>
@ -846,7 +872,8 @@
</ColumnDefinition> </ColumnDefinition>
<ColumnDefinition Width="4"/> <ColumnDefinition Width="4"/>
<ColumnDefinition x:Name="LocalColumn" Width="*" MinWidth="200"/> <ColumnDefinition x:Name="LocalColumn" Width="*" MinWidth="200"/>
<ColumnDefinition Width="4"/> <!-- 칸 사이 틈(11차 F-10). 바뀐 덩어리를 잇는 선을 그리는 자리라 구분선보다 넓다. 끌면 구분선처럼 폭이 바뀐다. -->
<ColumnDefinition Width="{StaticResource LinkGapWidth}"/>
<ColumnDefinition x:Name="BaseColumn" Width="*" MinWidth="200"/> <ColumnDefinition x:Name="BaseColumn" Width="*" MinWidth="200"/>
<!-- 3-Way 를 안 쓰면 REMOTE 칸과 그 앞 구분선을 폭 0 으로 접는다. <!-- 3-Way 를 안 쓰면 REMOTE 칸과 그 앞 구분선을 폭 0 으로 접는다.
@ -857,7 +884,7 @@
<Setter Property="Width" Value="0"/> <Setter Property="Width" Value="0"/>
<Style.Triggers> <Style.Triggers>
<DataTrigger Binding="{Binding IsThreeWay}" Value="True"> <DataTrigger Binding="{Binding IsThreeWay}" Value="True">
<Setter Property="Width" Value="4"/> <Setter Property="Width" Value="{StaticResource LinkGapWidth}"/>
</DataTrigger> </DataTrigger>
</Style.Triggers> </Style.Triggers>
</Style> </Style>
@ -1046,7 +1073,7 @@
그리기라 자르지 않으면 아래 바닥 글 위에 코드가 겹쳐 그려진다. --> 그리기라 자르지 않으면 아래 바닥 글 위에 코드가 겹쳐 그려진다. -->
<Grid Grid.Row="1" ClipToBounds="True"> <Grid Grid.Row="1" ClipToBounds="True">
<ListBox x:Name="OriginalLocalLines" BorderThickness="0" Background="Transparent" <ListBox x:Name="OriginalLocalLines" BorderThickness="0" Background="Transparent"
ItemsSource="{Binding Lines}" SelectedItem="{Binding SelectedLine}" SelectedItem="{Binding SelectedLine, Mode=OneWay}" SelectionChanged="PaneLines_SelectionChanged"
ItemTemplate="{StaticResource OriginalLocalLineTemplate}" ItemTemplate="{StaticResource OriginalLocalLineTemplate}"
ItemContainerStyle="{StaticResource CodeLineItem}" ItemContainerStyle="{StaticResource CodeLineItem}"
VirtualizingPanel.ScrollUnit="Pixel" VirtualizingPanel.ScrollUnit="Pixel"
@ -1142,7 +1169,7 @@
그리기라 자르지 않으면 아래 바닥 글 위에 코드가 겹쳐 그려진다. --> 그리기라 자르지 않으면 아래 바닥 글 위에 코드가 겹쳐 그려진다. -->
<Grid Grid.Row="1" ClipToBounds="True"> <Grid Grid.Row="1" ClipToBounds="True">
<ListBox x:Name="BaseLines" BorderThickness="0" Background="Transparent" <ListBox x:Name="BaseLines" BorderThickness="0" Background="Transparent"
ItemsSource="{Binding Lines}" SelectedItem="{Binding SelectedLine}" SelectedItem="{Binding SelectedLine, Mode=OneWay}" SelectionChanged="PaneLines_SelectionChanged"
ItemTemplate="{StaticResource BaseLineTemplate}" ItemTemplate="{StaticResource BaseLineTemplate}"
ItemContainerStyle="{StaticResource CodeLineItem}" ItemContainerStyle="{StaticResource CodeLineItem}"
VirtualizingPanel.ScrollUnit="Pixel" VirtualizingPanel.ScrollUnit="Pixel"
@ -1173,8 +1200,9 @@
</Grid> </Grid>
</Border> </Border>
<GridSplitter Grid.Column="3" Grid.Row="0" Width="4" HorizontalAlignment="Stretch" DragCompleted="PaneSplitter_DragCompleted" <!-- 칸 사이 틈 전체가 구분선이다(11차 F-10). 바탕은 비워 두고 잇는 선은 LinkOverlay 가 그 위에 그린다. -->
Background="{DynamicResource Ubisam.Brush.Table.GridLine}"/> <GridSplitter Grid.Column="3" Grid.Row="0" HorizontalAlignment="Stretch" DragCompleted="PaneSplitter_DragCompleted"
Background="Transparent"/>
<!-- <!--
③ LOCAL(2-Way) = MERGED(3-Way). ③ LOCAL(2-Way) = MERGED(3-Way).
@ -1248,7 +1276,7 @@
--> -->
<Grid Grid.Column="0"> <Grid Grid.Column="0">
<ListBox x:Name="LocalLines" BorderThickness="0" Background="Transparent" <ListBox x:Name="LocalLines" BorderThickness="0" Background="Transparent"
ItemsSource="{Binding Lines}" SelectedItem="{Binding SelectedLine}" SelectedItem="{Binding SelectedLine, Mode=OneWay}" SelectionChanged="PaneLines_SelectionChanged"
ItemTemplate="{StaticResource LocalLineTemplate}" ItemTemplate="{StaticResource LocalLineTemplate}"
ItemContainerStyle="{StaticResource CodeLineItem}" ItemContainerStyle="{StaticResource CodeLineItem}"
VirtualizingPanel.ScrollUnit="Pixel" VirtualizingPanel.ScrollUnit="Pixel"
@ -1349,9 +1377,9 @@
</Grid> </Grid>
</Border> </Border>
<GridSplitter Grid.Column="5" Grid.Row="0" Width="4" HorizontalAlignment="Stretch" DragCompleted="PaneSplitter_DragCompleted" <GridSplitter Grid.Column="5" Grid.Row="0" HorizontalAlignment="Stretch" DragCompleted="PaneSplitter_DragCompleted"
Visibility="{Binding IsThreeWay, Converter={StaticResource BoolToVis}}" Visibility="{Binding IsThreeWay, Converter={StaticResource BoolToVis}}"
Background="{DynamicResource Ubisam.Brush.Table.GridLine}"/> Background="Transparent"/>
<!-- <!--
③-2 REMOTE (3-Way). 머리·바닥 높이를 BASE/LOCAL 과 똑같이 맞춰야 ③-2 REMOTE (3-Way). 머리·바닥 높이를 BASE/LOCAL 과 똑같이 맞춰야
@ -1395,7 +1423,7 @@
그리기라 자르지 않으면 아래 바닥 글 위에 코드가 겹쳐 그려진다. --> 그리기라 자르지 않으면 아래 바닥 글 위에 코드가 겹쳐 그려진다. -->
<Grid Grid.Row="1" ClipToBounds="True"> <Grid Grid.Row="1" ClipToBounds="True">
<ListBox x:Name="RemoteLines" BorderThickness="0" Background="Transparent" <ListBox x:Name="RemoteLines" BorderThickness="0" Background="Transparent"
ItemsSource="{Binding Lines}" SelectedItem="{Binding SelectedLine}" SelectedItem="{Binding SelectedLine, Mode=OneWay}" SelectionChanged="PaneLines_SelectionChanged"
ItemTemplate="{StaticResource RemoteLineTemplate}" ItemTemplate="{StaticResource RemoteLineTemplate}"
ItemContainerStyle="{StaticResource CodeLineItem}" ItemContainerStyle="{StaticResource CodeLineItem}"
VirtualizingPanel.ScrollUnit="Pixel" VirtualizingPanel.ScrollUnit="Pixel"
@ -1443,6 +1471,9 @@
Visibility="{Binding IsThreeWay, Converter={StaticResource BoolToVis}}" Visibility="{Binding IsThreeWay, Converter={StaticResource BoolToVis}}"
Background="{DynamicResource Ubisam.Brush.Table.GridLine}"/> Background="{DynamicResource Ubisam.Brush.Table.GridLine}"/>
<!-- 잇는 선(11차 F-10). 위 칸들 위에 겹친 투명한 그림판 - 마우스는 통과한다. -->
<ctl:PaneLinkOverlay x:Name="LinkOverlay" Grid.Row="0" Grid.Column="2" Grid.ColumnSpan="5" Panel.ZIndex="5"/>
<GridSplitter x:Name="ActionsSplitter" Grid.Column="7" Grid.RowSpan="3" Width="4" HorizontalAlignment="Stretch" DragCompleted="PaneSplitter_DragCompleted" <GridSplitter x:Name="ActionsSplitter" Grid.Column="7" Grid.RowSpan="3" Width="4" HorizontalAlignment="Stretch" DragCompleted="PaneSplitter_DragCompleted"
Background="{DynamicResource Ubisam.Brush.Table.GridLine}"/> Background="{DynamicResource Ubisam.Brush.Table.GridLine}"/>

View File

@ -391,6 +391,7 @@ namespace MergeHub.Views
old.ScrollToRowRequested -= OnScrollToRowRequested; old.ScrollToRowRequested -= OnScrollToRowRequested;
old.PropertyChanged -= OnViewModelPropertyChanged; old.PropertyChanged -= OnViewModelPropertyChanged;
old.ReadEditorText = null; old.ReadEditorText = null;
BindPaneViews(null);
} }
if (e.NewValue is HomeViewModel now) if (e.NewValue is HomeViewModel now)
@ -404,6 +405,9 @@ namespace MergeHub.Views
// 편집기 글은 이 컨트롤이 들고 있고, ViewModel 은 필요할 때만 읽어 간다. // 편집기 글은 이 컨트롤이 들고 있고, ViewModel 은 필요할 때만 읽어 간다.
// 바인딩으로 매 글자마다 문서 전체를 넘기면 큰 파일에서 눈에 띄게 느리다. // 바인딩으로 매 글자마다 문서 전체를 넘기면 큰 파일에서 눈에 띄게 느리다.
now.ReadEditorText = () => LocalEditor.Text; now.ReadEditorText = () => LocalEditor.Text;
// 칸마다 자기 줄만(11차 F-10)
BindPaneViews(now);
} }
} }
@ -425,7 +429,14 @@ namespace MergeHub.Views
return; return;
} }
if (e.PropertyName == nameof(HomeViewModel.DiffVersion))
{
ScheduleResync(); // 칸마다 높이가 달라졌다 - 다시 맞추고 선을 다시 그린다(11차 F-10)
return;
}
if (e.PropertyName != nameof(HomeViewModel.IsThreeWay)) return; if (e.PropertyName != nameof(HomeViewModel.IsThreeWay)) return;
ScheduleResync();
// 배치가 통째로 달라지므로 원본 칸의 가로 위치는 처음으로 되돌린다. // 배치가 통째로 달라지므로 원본 칸의 가로 위치는 처음으로 되돌린다.
SourceShiftX = 0; SourceShiftX = 0;
@ -457,6 +468,7 @@ namespace MergeHub.Views
viewer.ScrollChanged += (_, e) => viewer.ScrollChanged += (_, e) =>
{ {
if (e.VerticalChange != 0 || e.ViewportHeightChange != 0) LinkOverlay.InvalidateVisual();
if (Syncing) return; if (Syncing) return;
if (e.VerticalChange == 0 && e.HorizontalChange == 0) return; if (e.VerticalChange == 0 && e.HorizontalChange == 0) return;
@ -467,7 +479,7 @@ namespace MergeHub.Views
// 표시층 ListBox 를 가로로 굴리는 방법은 못 쓴다 - 행이 칸 너비에 맞춰 늘어나 // 표시층 ListBox 를 가로로 굴리는 방법은 못 쓴다 - 행이 칸 너비에 맞춰 늘어나
// 굴릴 여지(ScrollableWidth)가 0 이라 아무 일도 일어나지 않는다. // 굴릴 여지(ScrollableWidth)가 0 이라 아무 일도 일어나지 않는다.
Scroll(LocalLines, e.VerticalOffset, null); Scroll(LocalLines, e.VerticalOffset, null);
ScrollSourcePanesTo(e.VerticalOffset); if (e.VerticalChange != 0) SyncPanesFrom(MergeLine.TextPane.Local, e.VerticalOffset, e.ViewportHeight);
TextShiftX = -e.HorizontalOffset; TextShiftX = -e.HorizontalOffset;
// 2-Way 는 BASE 와 LOCAL 의 폭이 같으니 한 덩어리로 움직인다. // 2-Way 는 BASE 와 LOCAL 의 폭이 같으니 한 덩어리로 움직인다.
@ -515,6 +527,7 @@ namespace MergeHub.Views
} }
Dispatcher.BeginInvoke(new Action(MatchTextWidths), System.Windows.Threading.DispatcherPriority.Loaded); Dispatcher.BeginInvoke(new Action(MatchTextWidths), System.Windows.Threading.DispatcherPriority.Loaded);
ScheduleResync();
} }
private bool _watchingActivation; private bool _watchingActivation;
@ -701,6 +714,7 @@ namespace MergeHub.Views
// 가로는 건드리지 않는다. 여백(줄번호·[>])까지 밀려나기 때문이다 - // 가로는 건드리지 않는다. 여백(줄번호·[>])까지 밀려나기 때문이다 -
// 가로 스크롤은 글자에만 거는 TextShiftX 가 따로 맡는다. // 가로 스크롤은 글자에만 거는 TextShiftX 가 따로 맡는다.
_decorationShift.Y = wanted; _decorationShift.Y = wanted;
LinkOverlay.InvalidateVisual(); // 표시층이 움직였다 - 잇는 선도 따라간다
} }
catch (InvalidOperationException) catch (InvalidOperationException)
{ {
@ -749,16 +763,6 @@ namespace MergeHub.Views
// ================= 세로 스크롤 맞추기 ================= // ================= 세로 스크롤 맞추기 =================
/// <summary>
/// 위쪽 원본 칸과 아래 MERGED 의 <b>행 높이가 다른</b> 상태.
///
/// 3-Way 에서 두 무리는 칸 너비가 다르므로, 워드랩을 켜면 접히는 자리가 달라
/// 같은 행이라도 높이가 달라진다. 이때는 픽셀을 그대로 넘겨서는 맞출 수 없다 -
/// 접힌 줄을 지날 때마다 조금씩 벌어져 결국 다른 줄을 보게 된다.
/// </summary>
private bool SourceRowsDifferFromEditor =>
WrapMode == TextWrapping.Wrap && DataContext is HomeViewModel { IsThreeWay: true };
/// <summary>코드 한 줄의 높이. XAML 의 CodeLineHeight 와 같은 값이어야 한다.</summary> /// <summary>코드 한 줄의 높이. XAML 의 CodeLineHeight 와 같은 값이어야 한다.</summary>
private double LineHeight => private double LineHeight =>
TryFindResource("CodeLineHeight") is double h && h > 0 ? h : 16; TryFindResource("CodeLineHeight") is double h && h > 0 ? h : 16;
@ -780,7 +784,8 @@ namespace MergeHub.Views
/// </summary> /// </summary>
private void ApplySourceScrollUnit() private void ApplySourceScrollUnit()
{ {
var unit = SourceRowsDifferFromEditor ? ScrollUnit.Item : ScrollUnit.Pixel; // 11차 F-10: 칸마다 자기 줄 높이로 가운데를 맞춘다(PaneMapping) - 행 단위로 굴릴 까닭이 없다.
const ScrollUnit unit = ScrollUnit.Pixel;
foreach (var pane in SourcePanes) foreach (var pane in SourcePanes)
{ {
@ -788,55 +793,6 @@ namespace MergeHub.Views
} }
} }
/// <summary>
/// 편집기 화면 <b>맨 위에 걸린 논리 행</b>. 좌표로 직접 물어본다.
///
/// 스크롤 값을 줄 높이로 나눠 세는 방법은 못 쓴다 - 실제로 재 보면 스크롤 160 일 때
/// 편집기가 말하는 첫 줄은 11 인데 화면 맨 위에 있는 것은 9행이었다. TextBox 의
/// 줄 번호 계산은 화면에 그려진 것과 딱 맞아떨어지지 않는다.
/// 그래서 "왼쪽 위 점에 있는 글자가 몇 행인가"를 물어본다 - 이건 그려진 대로 답한다.
/// </summary>
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;
}
/// <summary>그 논리 행이 편집기에서 시작하는 세로 위치(위의 역). 역시 좌표로 잰다.</summary>
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 는 <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;
}
}
/// <summary>편집기가 굴러간 만큼 위쪽 원본 세 칸을 따라오게 한다.</summary>
private void ScrollSourcePanesTo(double editorOffset)
{
// 행 단위로 굴리는 중이면 오프셋의 뜻이 "픽셀"이 아니라 "몇 번째 행"이다.
var offset = SourceRowsDifferFromEditor ? EditorTopRow() : editorOffset;
foreach (var pane in SourcePanes) Scroll(pane, offset, null);
}
/// <summary> /// <summary>
/// 표시층 ListBox 가 스스로 굴러가지 못하게 막는다. /// 표시층 ListBox 가 스스로 굴러가지 못하게 막는다.
/// ///
@ -860,6 +816,10 @@ namespace MergeHub.Views
private void Lines_ScrollChanged(object sender, ScrollChangedEventArgs e) private void Lines_ScrollChanged(object sender, ScrollChangedEventArgs e)
{ {
if (sender is not ListBox source) return; 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) && if (!ReferenceEquals(source, BaseLines) &&
!ReferenceEquals(source, RemoteLines) && !ReferenceEquals(source, RemoteLines) &&
!ReferenceEquals(source, OriginalLocalLines)) return; !ReferenceEquals(source, OriginalLocalLines)) return;
@ -880,17 +840,11 @@ namespace MergeHub.Views
HoldSync(); HoldSync();
var editorOffset = SourceRowsDifferFromEditor // 이 칸을 기준으로 나머지(편집기 · 다른 원본 칸)가 가운데를 맞춰 따라온다(11차 F-10).
? EditorOffsetOfRow((int)Math.Round(e.VerticalOffset)) var kind = ReferenceEquals(source, BaseLines) ? MergeLine.TextPane.Base
: e.VerticalOffset; : ReferenceEquals(source, RemoteLines) ? MergeLine.TextPane.Remote
: MergeLine.TextPane.OriginalLocal;
Scroll(LocalEditor, editorOffset, null); SyncPanesFrom(kind, e.VerticalOffset, e.ViewportHeight);
Scroll(LocalLines, editorOffset, null);
foreach (var other in new[] { BaseLines, RemoteLines, OriginalLocalLines })
{
if (!ReferenceEquals(other, source)) Scroll(other, e.VerticalOffset, null);
}
} }
private static void Scroll(DependencyObject target, double? vertical, double? horizontal) private static void Scroll(DependencyObject target, double? vertical, double? horizontal)
@ -1426,6 +1380,7 @@ namespace MergeHub.Views
// 워드랩을 켜면 밀 곳이 없어지므로 막대도 사라져야 한다. // 워드랩을 켜면 밀 곳이 없어지므로 막대도 사라져야 한다.
ApplySourceScrollUnit(); ApplySourceScrollUnit();
UpdateSourceScrollRange(); UpdateSourceScrollRange();
ScheduleResync(); // 줄 높이가 바뀌었다 - 칸 맞추기 · 잇는 선 다시(11차 F-10)
} }
/// <summary>ViewModel 이 "이 행으로 가라"고 하면 캐럿과 스크롤을 함께 옮긴다.</summary> /// <summary>ViewModel 이 "이 행으로 가라"고 하면 캐럿과 스크롤을 함께 옮긴다.</summary>
@ -1440,15 +1395,18 @@ namespace MergeHub.Views
/// </summary> /// </summary>
private void OnScrollToRowRequested(object? sender, int row) 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; if (start < 0) return;
LocalEditor.CaretIndex = start; LocalEditor.CaretIndex = start;
LocalEditor.Focus(); LocalEditor.Focus();
// 배치가 끝난 뒤에 굴린다. 글을 갈아 끼운 직후에는 글자 좌표를 물어봐야 // 배치가 끝난 뒤에 굴린다. 글을 갈아 끼운 직후에는 글자 좌표를 물어봐야
// 옛 정보가 나온다. // 옛 정보가 나온다. 그 줄의 윗변을 칸 <b>가운데</b>에 둔다(11차 F-10, Araxis 처럼) - LOCAL 에서 지운 덩어리로 가면
Dispatcher.BeginInvoke(new Action(BringCaretIntoView), DispatcherPriority.Loaded); // 윗변이 곧 지운 자리라, 다른 칸이 가운데 기준으로 따라오며 지운 줄들이 BASE 칸 가운데에 온다.
Dispatcher.BeginInvoke(new Action(() => CenterCharInView(start)), DispatcherPriority.Loaded);
} }
/// <summary> /// <summary>
@ -1461,6 +1419,25 @@ namespace MergeHub.Views
/// </summary> /// </summary>
private void BringCaretIntoView() => BringCharIntoView(LocalEditor.CaretIndex); private void BringCaretIntoView() => BringCharIntoView(LocalEditor.CaretIndex);
/// <summary>그 글자가 있는 줄의 윗변을 편집기 칸 가운데로 굴린다(차이 이동 · 11차 F-10).</summary>
private void CenterCharInView(int charIndex)
{
if (FindScrollViewer(LocalEditor) is not { } viewer) return;
Rect rect;
try
{
rect = LocalEditor.GetRectFromCharacterIndex(Math.Min(charIndex, LocalEditor.Text.Length));
}
catch (InvalidOperationException)
{
return;
}
if (double.IsInfinity(rect.Top) || double.IsNaN(rect.Top)) return;
viewer.ScrollToVerticalOffset(Math.Max(0, viewer.VerticalOffset + rect.Top - viewer.ViewportHeight / 2));
}
/// <summary>글 안의 어느 자리든 화면에 들여 놓는다(캐럿·찾은 자리 공통).</summary> /// <summary>글 안의 어느 자리든 화면에 들여 놓는다(캐럿·찾은 자리 공통).</summary>
private void BringCharIntoView(int charIndex) private void BringCharIntoView(int charIndex)
{ {