From f105bab13c90a7ecbf83f9f6ab033bb135366183 Mon Sep 17 00:00:00 2001 From: ksj09406 Date: Wed, 30 Sep 2026 19:32:30 +0900 Subject: [PATCH] =?UTF-8?q?=EC=97=94=ED=84=B0=20=EA=BE=B9=20=C2=B7=20Ctrl+?= =?UTF-8?q?=ED=9C=A0=20=ED=99=95=EB=8C=80=20=C2=B7=20=EC=AD=89=20=EB=82=B4?= =?UTF-8?q?=EB=A6=AC=EA=B8=B0=EC=97=90=EC=84=9C=20=EC=B9=B8=20=EC=96=B4?= =?UTF-8?q?=EA=B8=8B=EB=82=A8=20(=EC=82=AC=EC=9A=A9=EC=9E=90=20=EB=B3=B4?= =?UTF-8?q?=EA=B3=A0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 엔터를 꾹 누르면 캐럿 줄과 LOCAL 칸 줄이 벌어졌다 - 캐럿은 글에서 센 줄 자리로 두고, 메아리 막기 중에도 LOCAL 칸은 편집기 스크롤을 따라가게 했다. Ctrl+휠로 키우면 칸 위가 비고, 내리면 BASE 가 다른 줄을 봤다. 칸 목록은 가상화라 안 그린 줄 높이를 어림으로 세어(1,806줄 847행: 편집기 28,896 · 목록 27,940) 같은 픽셀이 다른 줄을 가리키는데, 이 차이를 네 칸 공통 이동값으로 메우다 467px 까지 커졌다. 두 줄 넘게 어긋나면 LOCAL 목록을 실제로 굴리고 차이를 칸별 보정(_paneBias)으로 기억한다. BASE · REMOTE 는 LOCAL 가운데 줄과 같은 줄끼리 맞춘다(AlignOtherPanesByRow) - 한쪽에만 있는 줄 묶음 안은 맞추지 않는다(짝을 억지로 불러 초당 600번 되풀이하며 타이핑을 밀어내던 것을 기록으로 잡았다). 맞추기 기준은 편집기에 보이는 첫 줄. 편집기에 묻는 값 둘이 틀려 (GetFirstVisibleLineIndex 886 -> 1033, 글자 크기 바꾼 뒤 GetRectFromCharacterIndex 가 몇 초씩 옛 배치로 1,892px 틀림) 줄 바꿈이 꺼져 있으면 고정 줄 높이로 계산한다. UIA 로 읽은 실제 첫 줄과 대조했다. 시험: 빌드 경고 0 · 오류 0. 화면 g17-hold-enter - 엔터 40번 뒤 LOCAL 칸 번호 = 편집기 줄(936) · 같은 높이, 확대 뒤 · 30칸 내린 뒤 LOCAL = BASE, 칸 위 비지 않음. 꾹 누름 흉내에서 40번 중 30~34번만 들어가는 것은 고치기 전 커밋도 같고 따로 누르면 40번 다 들어간다(Windows 가 반복 키를 합침). 기존 회귀는 사용자 지시(방금 요청한 것만)로 돌리지 않았다. Agent: main (claude-opus-5-5) Co-Authored-By: Claude Opus 5.5 --- doc/history/2026-09-23-12차.md | 28 +++++ doc/history/changelog.md | 1 + doc/진행상황.md | 1 + src/MergeHub/Views/HomeView.Links.cs | 12 +- src/MergeHub/Views/HomeView.xaml.cs | 172 ++++++++++++++++++++++++--- 5 files changed, 196 insertions(+), 18 deletions(-) diff --git a/doc/history/2026-09-23-12차.md b/doc/history/2026-09-23-12차.md index 56b5ef9..3e091eb 100644 --- a/doc/history/2026-09-23-12차.md +++ b/doc/history/2026-09-23-12차.md @@ -2,6 +2,34 @@ 지시서: [../작업지시/12차.md](../작업지시/12차.md) +## 엔터를 꾹 누르면 줄이 어긋난다 · Ctrl+휠로 키우면 화면이 잘린다 · 내리면 BASE 와 LOCAL 이 따로 논다 (2026-09-30 밤, 사용자 보고 · 완료) + +### 엔터 꾹 — 캐럿과 LOCAL 칸의 줄이 다르다 (사진) + +편집기 줄 → 정렬 행 자리를 **글자 수**로 지켜, 엔터가 여러 번 쌓이는 동안 캐럿 줄과 LOCAL 칸 줄이 벌어졌다. +글을 갈아 끼운 뒤 캐럿은 글에서 직접 센 줄 자리로 둔다(`TakeCaretLine` · `LineSpan`, 앞 커밋의 방식 그대로). +또 편집기가 스크롤될 때 **메아리 막기 중에도 LOCAL 칸은 따라가게** 했다 — 막는 동안 LOCAL 만 멈춰 칸이 편집기에 뒤처졌다. + +### Ctrl+휠 확대 · 쭉 내리기 — 칸이 통째로 밀리고 BASE 가 다른 줄을 본다 + +임시 기록(`MERGEHUB_ZOOM_TRACE`, 끝나고 뺌)으로 잰 것: +1) 칸 목록은 가상화라 안 그린 줄 높이를 어림으로 센다 — 1,806줄 847행에서 전체 길이가 편집기 28,896 · 목록 27,940, 같은 픽셀이 다른 줄을 가리킨다. + 이 차이를 네 칸 공통 이동값(`_decorationShift`)으로 메우다 467px 까지 커져 **칸 위가 텅 비었다**. → + 두 줄 넘게 어긋나면 이동값 대신 **LOCAL 목록을 그만큼 실제로 굴리고** 그 차이를 칸별 보정(`_paneBias`)으로 기억한다(다음 편집기 스크롤도 더해 굴림, 목록을 다시 세우면 비움). +2) BASE · REMOTE 는 **같은 줄끼리** 맞춘다(`AlignOtherPanesByRow`): LOCAL 가운데 줄이 그 칸에도 있으면 두 행 윗변을 견줘 두 줄 넘게 다르면 굴린다. + 한쪽에만 있는 줄 묶음 한가운데는 맞추지 않는다 — 처음엔 짝(묶음 아래 줄)을 불러왔는데 가운데 맞추기가 도로 밀어내 **초당 600번 넘게 되풀이하며 타이핑 입력을 밀어냈다**(기록 16,081번). 같은 줄은 한 번만 부른다. +3) 맞추기 기준을 **편집기에 보이는 첫 줄**로 바꿨다. 예전엔 "목록에 보이는 첫 행" 을 편집기에서 찾았는데 목록이 수십 줄 앞서 있으면 그 줄이 편집기 화면 밖이라 **95번 중 95번 맞추기를 포기**했다. +4) 편집기에 묻는 값 둘이 틀렸다(UIA 로 실제 보이는 줄을 읽어 대조): + - `TextBox.GetFirstVisibleLineIndex` — 글꼴 기본 줄 간격으로 어림해 늘린 줄 높이를 모른다. 실제 886 에서 1033. + - `GetRectFromCharacterIndex` — **글자 크기를 바꾼 뒤 몇 초씩 옛 배치로 답했다**(보이는 첫 줄 728 을 1,892px 위라고). 그래서 매번 "화면 밖" 으로 빠져 LOCAL 이 69줄 어긋난 채 남았다. + → 줄 바꿈이 꺼져 있으면 줄 높이가 고정(`BlockLineHeight`)이니 **계산**한다: 첫 줄 = ⌈VerticalOffset ÷ 줄 높이⌉, 그 줄 위치 = 줄 × 줄 높이 − VerticalOffset + 글자 위 여백. + 정상 상태에서 계산값과 편집기 답의 차이 0 을 기록으로 확인했다. 줄 바꿈을 켜면 예전처럼 점으로 찾는다. + +시험(`g17-hold-enter`, 사용자 지시로 이 두 건만): 엔터 40번 → LOCAL 칸 번호 = 편집기 줄 번호(936) · 같은 높이(y 1379). +확대 뒤 편집기(UIA) 첫 줄 702 · LOCAL · BASE 가운데 722/722 · 칸 위 비지 않음. 30칸 내린 뒤 776 · 795/795. 확대만 하는 판도 708/708 · 775/775. +**키를 꾹 누른 흉내에서는 40번 중 30~34번만 들어갔다** — 고치기 전 커밋도 같았고, 따로따로 누르면 40번 다 들어간다. 앱이 바쁠 때 Windows 가 반복 키를 합쳐 보내는 것이라 편집을 잃는 것이 아니다. +대본의 "확대 뒤 Method100 높이" 칸은 확대로 Method100 이 화면 밖에 나가 견줄 것이 없어 ✗ 로 남는다(대본 한계). +**기존 회귀는 사용자 지시로 돌리지 않았다.** ## 머지 주석 · 타이핑 속도 · 탭 알림 (2026-09-30 저녁, 사용자 요청 · 완료) ### Accept 뒤 Alt+↓ 를 두 번 눌러야 다음 차이로 간다 (사용자 보고) diff --git a/doc/history/changelog.md b/doc/history/changelog.md index 5141b38..c9ee4b9 100644 --- a/doc/history/changelog.md +++ b/doc/history/changelog.md @@ -8,6 +8,7 @@ | 날짜 | 항목 | 커밋 | |---|---|---| +| 2026-09-30 | **엔터 꾹 · 확대 · 쭉 내리기에서 칸 어긋남(사용자 보고)** — ① 엔터를 꾹 누르면 캐럿 줄과 LOCAL 칸 줄이 벌어졌다 → 캐럿은 글에서 센 줄 자리로, 메아리 막기 중에도 LOCAL 칸은 편집기를 따라감. ② Ctrl+휠 확대 뒤 칸 위가 비고 내리면 BASE 가 다른 줄: 가상화 목록 높이 어림 차이(편집기 28,896 · 목록 27,940)를 네 칸 공통 이동값으로 메우다 467px 까지 커졌다 → 두 줄 넘으면 칸을 실제로 굴리고 칸별 보정으로 기억, BASE · REMOTE 는 같은 줄끼리 맞춤(한쪽에만 있는 묶음 안은 제외 — 짝을 억지로 불러 초당 600번 되풀이하던 것을 기록으로 잡음). ③ 편집기 `GetFirstVisibleLineIndex`(886 → 1033) · 확대 직후 `GetRectFromCharacterIndex`(1,892px 틀림)가 틀려 → 줄 높이 고정이면 계산. 화면 `g17-hold-enter` 통과(남은 ✗ 는 대본 한계 · 꾹 누름 흉내에서 Windows 가 반복 키를 합치는 것 — 고치기 전과 같음). **기존 회귀는 사용자 지시로 돌리지 않음.** 빌드 경고 0 · 오류 0 | 아래 | | 2026-09-30 | **머지 주석 · 타이핑 속도 · 탭 알림(사용자 요청 · 보고)** — ① Accept 뒤 캐럿이 새 주석 줄에 서서 Alt+↓ 를 두 번 눌러야 했다 → 주석을 끼우기 직전 캐럿 행을 기억해 글을 갈아 끼운 뒤 그 행으로(글을 막 갈아 끼운 TextBox 의 줄 정보가 옛 글 기준이라는 것을 기록으로 확인 → 줄 자리는 글에서 직접 셈). ② 줄을 지우는 반영에도 주석 — 한 줄 `삭제`, 여러 줄 `삭제 N줄`(사용자 선택, 왼쪽 · 3-Way · 구간 · 메서드 모두). ③ 색 구분 "줄 안에서 바뀐 부분" 에 줄 수. ④ 타이핑: 1,806줄에서 재 보니 엔터마다 재정렬 0.4초의 대부분이 목록에 1,800줄을 한 줄씩 넣는 알림(파일 비교 자체는 0.012초) → `MergeLineCollection.ReplaceAll`(알림 한 번) + `Patch`(번호 말고 같은 줄은 기존 객체를 두고 번호만, 가운데만 빼고 넣기) → 엔터 한 번 약 0.55초 → 약 0.05초(뒤 다시 칠한 줄 290 → 0~8). ⑤ 탭이 20개를 넘어 조용히 닫히면 알림. 하네스 `NoteTest` 106 · `PatchTest`(새) 16, 화면 `g12-note` · `g14-inline` · `g15-typing-tabs` 통과. **기존 회귀는 사용자 지시로 돌리지 않음.** 빌드 경고 0 · 오류 0 | 아래 | | 2026-09-30 | **커밋 뒤 사용자 요청 셋** — ① 폴더 비교에서 두 번 눌러 파일 비교로 가도 하단 탭이 옛 탭에 남던 것: 사용자가 쓰던 앱을 읽기만 해서 재현(화면 파일 비교 · 선택 0번), 원인은 공용 플랫폼 셸의 `NavList` 가 `SelectedMainTab` 을 따라가지 않는 것 — 허락받아 `UbisamCommonPlatform` 에서 고침(MergeHub 코드 변경 없음), `g10-drop` 선택 [0] → [1]. ② Git 탭에 폴더 · 파일을 끌어다 놓으면 그 저장소 최상위를 연다(`PathUtil.RepositoryFolderFor`, `[폴더...]` 와 같은 규칙, 탭 전체 Preview 로 받아 TextBox 가 먹는 문제 회피). `PathTest` 62 → 68건(먼저 실패 확인). 흉내 낸 OLE 끌기는 이미 되는 폴더 비교 칸에도 안 들어가 도구 한계로 보고 사용자가 직접 확인(로그 저장소 열기 두 번). ③ 파일 비교 편집 칸 바탕을 회색(페이지 바탕이 비침) → 밝음 #FAFAFA · 어두움 #252830(순검정 · #1E1E1E 를 거쳐 후보를 보고 사용자가 고름, `PaneBackgroundConverter`). `g11-pane` 두 테마 화소로 확인. 빌드 경고 0 · 오류 0 | 아래 | | 2026-09-30 | **12차 G 묶음 마무리(알파 · 베타 전)** — ① 설정의 `프로그램 정보`(만든 사람 · 사내 문의처) 칸을 뺐다 — 개발자 정보가 담당을 보인다(사용자). [정보] 창은 실행 파일 값만 읽고(`ProgramInfoReader.For` 의 설정 인수를 걷음), 빈 칸 안내 글에서 없어진 설정 자리를 뺐다. ② 사용자 요청 "작성자를 입력하지 않으면 알람" — 주석 남기기가 켜져 있는데 작성자가 비면 **반영 명령 맨 앞**(여덟 곳)에서 알림 창을 띄우고 반영하지 않는다(사용자 선택, 줄마다가 아니라 명령마다 — [모두 반영] 이 줄 수만큼 알리지 않게). 예전에는 git 이름 · 윈도우 계정으로 조용히 채웠다. 미리보기도 그 이름을 안 보인다. 한때 "주석 늘 켬" 으로 바꿨다가 사용자 지시로 원복. ③ 데모(설정 두 칸 · [데모 →] 단추 · 전환 코드)를 걷어냄. 이 PC 의 시험 재료는 휴지통으로, 설정 · 최근 목록의 시험 경로를 비움. 하네스 `AboutTest` 30 · `NoteTest` 99 · `OverviewTest` 15건(둘은 먼저 고쳐 컴파일 실패 확인), 화면 `g5` · `g6` · `g9-author`(새) 실패 0. 빌드 경고 0 · 오류 0 | 아래 | diff --git a/doc/진행상황.md b/doc/진행상황.md index d2d5883..dce5ce0 100644 --- a/doc/진행상황.md +++ b/doc/진행상황.md @@ -446,6 +446,7 @@ | 상태 | 항목 | |---|---| +| **완료** | **엔터 꾹 · Ctrl+휠 확대 · 쭉 내리기에서 칸이 어긋난다**(사용자 보고) — 캐럿 줄 ≠ LOCAL 칸 줄, 확대하면 칸 위가 비고, 내리면 BASE 가 다른 줄을 봤다. 가상화 목록의 높이 어림 차이를 이동값으로 메우지 않고 칸을 실제로 굴려 칸별로 기억, BASE 는 같은 줄끼리 맞춤, 편집기 보이는 줄은 줄 높이로 계산(편집기의 `GetFirstVisibleLineIndex` · 확대 직후 `GetRectFromCharacterIndex` 가 틀렸다). 화면 `g17-hold-enter` — 엔터 뒤 936=936, 확대 · 30칸 내린 뒤 LOCAL = BASE. 기존 회귀는 사용자 지시로 안 돌림 | | **완료** | **파일 비교 타이핑 · 엔터 속도**(사용자 보고) — 재서 보니 엔터마다 도는 재정렬 0.4초의 대부분이 1,800줄을 목록에 한 줄씩 넣는 알림이었다. 한 번에 넣기(`ReplaceAll`) + 번호 말고 같은 줄은 기존 객체를 두고 가운데만 바꾸기(`Patch`)로 엔터 한 번 약 0.55초 → 약 0.05초. 하네스 `PatchTest` 16건, 화면 `g15` ① 새 줄 · 번호 제자리. 기존 회귀는 사용자 지시로 안 돌림 | | **완료** | **머지 주석 두 가지**(사용자 보고) — Accept 뒤 캐럿이 새 주석 줄에 서서 Alt+↓ 를 두 번 눌러야 했던 것(캐럿을 누르기 전 행에 둔다), 줄을 지우는 반영에도 주석 + `삭제` · 여러 줄이면 `삭제 N줄`(사용자 선택). `NoteTest` 106건, 화면 `g12-note` 실패 0 | | **완료** | **"줄 안에서 바뀐 부분" 줄 수 · 탭 20개 넘을 때 알림**(사용자 요청) — 색 구분에 개수(`g14-inline`), 오래 안 본 탭을 닫으면 알림(`g15` ②: 21번째에서 알림 · 20개 유지) | diff --git a/src/MergeHub/Views/HomeView.Links.cs b/src/MergeHub/Views/HomeView.Links.cs index 9a48a6d..545f155 100644 --- a/src/MergeHub/Views/HomeView.Links.cs +++ b/src/MergeHub/Views/HomeView.Links.cs @@ -63,7 +63,12 @@ namespace MergeHub.Views LinkOverlay.Draw = DrawLinks; } - private void OnLinesChanged(object? sender, NotifyCollectionChangedEventArgs e) => ScheduleResync(); + private void OnLinesChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + // 통째로 갈아 끼우면(새 파일 · 큰 재정렬) 목록의 높이 어림도 새로 시작한다 - 옛 보정값은 버린다. + if (e.Action == NotifyCollectionChangedAction.Reset) _paneBias.Clear(); + ScheduleResync(); + } private bool _resyncQueued; @@ -159,12 +164,13 @@ namespace MergeHub.Views if (FindScrollViewer(LocalEditor) is not { } editor) continue; var y = Clamp(target - editor.ViewportHeight / 2, editor.ScrollableHeight); editor.ScrollToVerticalOffset(y); - Scroll(LocalLines, y, null); + Scroll(LocalLines, y + Bias(LocalLines), null); continue; } if (FindScrollViewer(list) is not { } viewer) continue; - viewer.ScrollToVerticalOffset(Clamp(target - viewer.ViewportHeight / 2, viewer.ScrollableHeight)); + // 칸 목록은 안 그린 줄 높이를 어림하므로 줄 기준 보정값을 더한다(_paneBias 참고). + viewer.ScrollToVerticalOffset(Clamp(target - viewer.ViewportHeight / 2 + Bias(list), viewer.ScrollableHeight)); } LinkOverlay.InvalidateVisual(); diff --git a/src/MergeHub/Views/HomeView.xaml.cs b/src/MergeHub/Views/HomeView.xaml.cs index 6ca4149..1820cd2 100644 --- a/src/MergeHub/Views/HomeView.xaml.cs +++ b/src/MergeHub/Views/HomeView.xaml.cs @@ -515,22 +515,27 @@ 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; - HoldSync(); - - // 세로는 아래층·BASE·REMOTE 가 같이 굴러가고, + // LOCAL 표시층(아래층 목록)과 글자 가로 밀기는 메아리 막기 중에도 편집기를 따라간다(2026-09-30 사용자 보고). + // 엔터를 꾹 누르면 재정렬마다 칸 다시 맞추기가 막기를 걸어 두는데, 그 사이 편집기가 캐럿을 들이려고 구른 것이 + // 메아리로 버려져 아래층이 몇 줄 뒤처졌다(캐럿은 929행, 아래층 글은 926행 자리) - G-7 과 같은 고리. + // 이 목록은 굴러도 편집기를 되움직이지 않으므로(Lines_ScrollChanged 가 LocalLines 는 받지 않는다) 늘 맞춰도 메아리가 없다. // 가로는 네 패널 모두 "글자만" 민다(TextShiftX) - 여백(줄번호·[>])은 제자리에 있어야 한다. // 표시층 ListBox 를 가로로 굴리는 방법은 못 쓴다 - 행이 칸 너비에 맞춰 늘어나 // 굴릴 여지(ScrollableWidth)가 0 이라 아무 일도 일어나지 않는다. - Scroll(LocalLines, e.VerticalOffset, null); - if (e.VerticalChange != 0) SyncPanesFrom(MergeLine.TextPane.Local, e.VerticalOffset, e.ViewportHeight); + Scroll(LocalLines, e.VerticalOffset + Bias(LocalLines), null); TextShiftX = -e.HorizontalOffset; - // 2-Way 는 BASE 와 LOCAL 의 폭이 같으니 한 덩어리로 움직인다. + // 2-Way 는 BASE 와 LOCAL 의 폭이 같으니 한 덩어리로 움직인다(가로 글자 밀기 - 되움직임 없음). // 3-Way 의 위 세 칸은 폭이 달라 따로 움직이므로 여기서 건드리지 않는다. if (DataContext is not HomeViewModel { IsThreeWay: true }) SourceShiftX = TextShiftX; + + if (Syncing) return; + HoldSync(); + + // 세로는 BASE·REMOTE 가 가운데를 맞춰 같이 굴러간다 - 이쪽은 굴리면 되돌아오는 칸이라 메아리를 막는다. + if (e.VerticalChange != 0) SyncPanesFrom(MergeLine.TextPane.Local, e.VerticalOffset, e.ViewportHeight); }; } @@ -745,18 +750,54 @@ namespace MergeHub.Views // 또 칸 안에 보이는 행이어야 한다(6차 B-4 확인 중 발견). "처음 만들어진 행" 은 멀리 건너뛴 직후에 // 화면 밖에 남은 행일 수 있고, 편집기는 화면 밖 글자의 좌표를 어림으로 준다. 7,479줄 파일에서 // "마지막 차이" 로 가자 그 어림이 9줄쯤 틀려 네 칸이 통째로 위로 밀리고 아래가 비었다. - if (FirstVisibleContainer(LocalLines) is not { } container) return; + // + // 2026-09-30: 기준을 편집기에 보이는 첫 줄로 바꿨다. 예전에는 "목록에 보이는 첫 행" 을 편집기에서 찾았는데, + // 가상화 목록의 높이 어림이 틀려 목록이 편집기보다 수십 줄 앞서 있으면 그 줄이 편집기 화면 밖이라 + // 맞추기를 포기했다(기록: 확대 뒤 95번 중 95번 "화면 밖") - 한 번도 바로잡지 못한 채 칸이 통째로 밀렸다. + // 편집기 첫 줄은 늘 화면 안이다. 그 행이 목록에 아직 안 그려졌으면 먼저 불러오고 다음 배치 때 맞춘다. + // 행 번호 ↔ 편집기 줄 번호는 지운 줄(편집기에 줄이 없는 행)이 있으면 다르다 - 표(RowOfEditorLine)로 바꾼다. + if (DataContext is not HomeViewModel vm || vm.Lines.Count == 0) return; + // GetFirstVisibleLineIndex 는 쓰지 않는다 - 글꼴 기본 줄 간격으로 어림해 우리가 늘린 줄 높이를 모른다 + // (기록: 실제 첫 줄 886 에서 1033 을 줬다, 아래로 갈수록 더 틀린다). 점으로 글자를 찾는 것도 확대 직후엔 + // 옛 배치로 답해 80줄쯤 위를 줬다(기록: 그 줄 top -1871). 줄 높이가 고정(BlockLineHeight)이니 나눗셈이 정확하다. + // 줄 바꿈을 켜면 한 줄이 여러 칸이라 나눗셈이 안 맞는다 - 그때만 점으로 찾는다. + int editorLine; + var fixedLines = LocalEditor.TextWrapping == TextWrapping.NoWrap; + if (fixedLines) + editorLine = Math.Min((int)Math.Ceiling((LocalEditor.VerticalOffset - 0.5) / LineHeight), LocalEditor.LineCount - 1); // 위가 잘리지 않은 첫 줄 + else + { + var topChar = LocalEditor.GetCharacterIndexFromPoint(new Point(LocalEditor.Padding.Left + 2, 1), snapToText: true); + editorLine = topChar < 0 ? -1 : LocalEditor.GetLineIndexFromCharacterIndex(topChar); + if (editorLine >= 0 && LocalEditor.GetRectFromCharacterIndex(LocalEditor.GetCharacterIndexFromLineIndex(editorLine)).Top < -1 && + editorLine + 1 < LocalEditor.LineCount) editorLine++; // 윗줄이 반쯤 잘려 있으면 다음 줄 + } + if (editorLine < 0) return; + + var row = vm.RowOfEditorLine(editorLine); + if (row < 0 || row >= vm.Lines.Count) return; + if (LocalLines.ItemContainerGenerator.ContainerFromIndex(row) is not ListBoxItem container || !container.IsVisible) + { + LocalLines.ScrollIntoView(vm.Lines[row]); + return; + } if (FindFirst(container) is not { } sample) return; - var row = LocalLines.ItemContainerGenerator.IndexFromContainer(container); - if (row < 0) return; - - var charIndex = CharacterIndexOfLine(LocalEditor.Text, row); + var charIndex = LocalEditor.GetCharacterIndexFromLineIndex(editorLine); if (charIndex < 0) return; try { var charRect = LocalEditor.GetRectFromCharacterIndex(charIndex); + if (fixedLines) + { + // 글자 크기를 바꾼 뒤 GetRectFromCharacterIndex 는 몇 초씩 옛 배치로 답했다(기록: 보이는 첫 줄 728 을 + // 1,892px 위라고 했다 - 그래서 "화면 밖" 으로 빠져 한 번도 맞추지 못하고 칸이 69줄 어긋났다). + // 줄 높이가 고정이면 줄 자리는 계산으로 정확하다 - 줄 상자 위 + 글자 위 여백(lead, 아래에서 쓰는 값과 같은 식). + var lineTop = editorLine * LineHeight - LocalEditor.VerticalOffset + LocalEditor.BorderThickness.Top + LocalEditor.Padding.Top; + var glyphLead = sample.BaselineOffset - sample.FontFamily.Baseline * sample.FontSize; + charRect = new Rect(charRect.IsEmpty ? 0 : charRect.X, lineTop + glyphLead, 0, LineHeight); + } // 편집기 화면 밖이면 어림값이다. 맞추지 않고 다음 배치 때 다시 본다. if (charRect.IsEmpty || charRect.Top < -1 || charRect.Top > LocalEditor.ActualHeight) return; @@ -779,7 +820,27 @@ namespace MergeHub.Views var wanted = editorPoint.Y - (decorationPoint.Y - _decorationShift.Y + lead); // 이미 맞아 있으면 건드리지 않는다 - 매 프레임 흔들리는 것을 막는다. - if (Math.Abs(wanted - _decorationShift.Y) < 0.5) return; + if (Math.Abs(wanted - _decorationShift.Y) < 0.5) + { + AlignOtherPanesByRow(); + return; + } + + // 두 줄 넘게 어긋났으면 이동값으로 메우지 않고 목록을 그만큼 실제로 굴린다(2026-09-30 사용자 보고). + // 칸 목록은 가상화라 안 그린 줄의 높이를 어림으로 세어, 같은 픽셀 자리라도 편집기와 다른 줄을 본다 + // (기록으로 잰 값: 1,806줄 파일 847행에서 전체 길이가 편집기 28,896 · 목록 27,940 → 이동값 467px). + // 이동값은 네 칸에 함께 걸리므로 커지면 칸이 통째로 밀려 위가 텅 비었다 - Ctrl+휠로 키우면 더 드러났다. + // 굴리고 나면 다음 배치 때 여기로 다시 와서 한 줄 안쪽의 차이만 이동값으로 맞춘다. + // LOCAL 목록은 굴러도 편집기를 되움직이지 않는다(Lines_ScrollChanged 가 받지 않는다) - 메아리 없음. + var rowHeight = Math.Max(1, container.ActualHeight); + if (Math.Abs(wanted) > rowHeight * 2 && FindScrollViewer(LocalLines) is { } listViewer) + { + _decorationShift.Y = 0; + _paneBias[LocalLines] = Bias(LocalLines) - wanted; // 다음 편집기 스크롤도 이만큼 더해 굴린다 + listViewer.ScrollToVerticalOffset(Clamp(listViewer.VerticalOffset - wanted, listViewer.ScrollableHeight)); + LinkOverlay.InvalidateVisual(); + return; + } // 가로는 건드리지 않는다. 여백(줄번호·[>])까지 밀려나기 때문이다 - // 가로 스크롤은 글자에만 거는 TextShiftX 가 따로 맡는다. @@ -792,6 +853,73 @@ namespace MergeHub.Views } } + /// 칸마다 마지막으로 불러온 짝 행. 같은 행을 배치마다 거듭 부르지 않게 한다. + private readonly System.Collections.Generic.Dictionary _pairRequested = new System.Collections.Generic.Dictionary(); + + /// + /// BASE · REMOTE · 원본 LOCAL 칸을 같은 줄 기준으로 LOCAL 칸에 맞춘다(2026-09-30 사용자 보고 "화면이 잘린다"). + /// + /// 이 칸들은 가운데 맞추기()가 픽셀 자리로 굴린다. 그런데 칸 목록은 가상화라 안 그린 줄의 + /// 높이를 어림으로 세어, 파일 아래쪽에서는 같은 픽셀이 다른 줄을 가리켰다(사진: LOCAL 839~876 · BASE 807~845, 30줄 어긋남). + /// LOCAL 칸 가운데 보이는 줄이 그 칸에도 있으면 두 행의 윗변을 견줘 두 줄 넘게 다르면 그 칸을 그만큼 굴린다. + /// 두 줄 안쪽의 차이, 그리고 한쪽에만 있는 줄 묶음 한가운데의 어긋남은 가운데 맞추기가 일부러 만든 것이라 그대로 둔다. + /// 굴리는 동안 메아리 막기를 건다 - 이 칸이 구르면 편집기를 따라 움직이게 하는 길이 있다(Lines_ScrollChanged). + /// + private void AlignOtherPanesByRow() + { + if (DataContext is not HomeViewModel vm || vm.Lines.Count == 0) return; + if (ContainerAtMiddle(LocalLines) is not { } localItem) return; + var row = LocalLines.ItemContainerGenerator.IndexFromContainer(localItem); + if (row < 0) return; + var localTop = localItem.TranslatePoint(new Point(0, 0), LocalLines).Y; + var rowHeight = Math.Max(1, localItem.ActualHeight); + + foreach (var (list, kind) in VisiblePanes(vm)) + { + if (kind == MergeLine.TextPane.Local) continue; + + // LOCAL 가운데 줄이 이 칸에도 있을 때만 맞춘다. 한쪽에만 있는 줄 묶음 한가운데라면 두 칸이 어긋나 보이는 것이 + // 가운데 맞추기가 일부러 만든 모양이다. 거기서 짝(묶음 아래 줄)을 억지로 불러오면 가운데 맞추기가 도로 밀어내 + // 배치마다 되풀이됐다(기록: 엔터 40번 뒤 한 줄(894→924)을 초당 600번 넘게 불러 타이핑 입력이 밀렸다). + if (vm.Lines[row].PaneHeight(kind, false) <= 0) continue; + + if (FindScrollViewer(list) is not { } viewer) continue; + if (list.ItemContainerGenerator.ContainerFromIndex(row) is not ListBoxItem item || !item.IsVisible) + { + // 짝이 아직 안 그려졌다(어림이 크게 틀림) - 먼저 화면 안으로 부르고, 다음 배치 때 윗변을 맞춘다. + // 같은 줄은 한 번만 부른다 - 부른 뒤에도 안 보이면 배치마다 다시 부르며 멈추지 않는다. + if (_pairRequested.TryGetValue(list, out var asked) && asked == row) continue; + _pairRequested[list] = row; + HoldSync(); + list.ScrollIntoView(vm.Lines[row]); + continue; + } + _pairRequested.Remove(list); + + var dy = item.TranslatePoint(new Point(0, 0), list).Y - localTop; + if (Math.Abs(dy) <= rowHeight * 2) continue; + + HoldSync(); + _paneBias[list] = Bias(list) + dy; // 가운데 맞추기도 다음부터 이만큼 더해 굴린다 - 되돌려 밀고 당기지 않게 + viewer.ScrollToVerticalOffset(Clamp(viewer.VerticalOffset + dy, viewer.ScrollableHeight)); + LinkOverlay.InvalidateVisual(); + } + } + + /// 목록 칸의 세로 가운데를 덮는 행. 없으면 null. + private static ListBoxItem? ContainerAtMiddle(ListBox list) + { + if (FindFirst(list) is not { } panel) return null; + var middle = list.ActualHeight / 2; + foreach (UIElement child in panel.Children) + { + if (child is not ListBoxItem item || !item.IsVisible) continue; + var top = item.TranslatePoint(new Point(0, 0), list).Y; + if (top <= middle && top + item.ActualHeight > middle) return item; + } + return null; + } + /// /// 목록 칸 안에서 위가 잘리지 않고 보이는 첫 행. 없으면 null. /// 행 판(가상화 패널)의 자식만 훑는다 - 배치가 끝날 때마다 불리므로 시각 트리 전체를 뒤지면 무겁다. @@ -914,9 +1042,23 @@ namespace MergeHub.Views var kind = ReferenceEquals(source, BaseLines) ? MergeLine.TextPane.Base : ReferenceEquals(source, RemoteLines) ? MergeLine.TextPane.Remote : MergeLine.TextPane.OriginalLocal; - SyncPanesFrom(kind, e.VerticalOffset, e.ViewportHeight); + // 이 칸의 픽셀 자리에는 보정값이 들어 있다 - 빼서 "줄 기준" 자리로 바꾼 뒤 다른 칸을 맞춘다. + SyncPanesFrom(kind, e.VerticalOffset - Bias(source), e.ViewportHeight); } + /// + /// 칸 목록마다 "픽셀 자리 → 같은 줄" 보정값(2026-09-30 사용자 보고: 확대하면 화면이 잘린다 · 밑으로 쭉 내리면 두 칸 속도가 다르다). + /// + /// 칸 목록은 가상화라 안 그린 줄의 높이를 어림으로 세어, 편집기와 같은 픽셀로 굴려도 파일 아래쪽에서는 다른 줄을 본다 + /// (기록: 1,806줄 847행에서 편집기 전체 28,896 · 목록 27,940). 줄 기준 맞추기( · + /// )가 찾은 차이를 여기 쌓고, 픽셀로 굴리는 자리(편집기 스크롤 · 가운데 맞추기)는 늘 이 값을 더한다. + /// 처음에는 맞추는 쪽이 그냥 굴렸더니 다음 편집기 스크롤이 픽셀 자리로 되돌려 둘이 밀고 당겼다(기록: 12704.9 ↔ 13070.6). + /// 목록을 통째로 갈아 끼우면(새 파일) 비운다. + /// + private readonly System.Collections.Generic.Dictionary _paneBias = new System.Collections.Generic.Dictionary(); + + private double Bias(ListBox list) => _paneBias.TryGetValue(list, out var b) ? b : 0; + private static void Scroll(DependencyObject target, double? vertical, double? horizontal) { if (FindScrollViewer(target) is not { } viewer) return; @@ -1567,7 +1709,7 @@ namespace MergeHub.Views // 남았다. 뒤로 크게 돌면 표시층 맞추기가 그 차이를 이동값으로 메워 두 칸이 통째로 반 칸 밀려 아래가 잘렸다 // (기록: 편집기 74.4 · 목록 436.7 · 이동값 357). HoldSync(); - Scroll(LocalLines, y, null); + Scroll(LocalLines, y + Bias(LocalLines), null); SyncPanesFrom(MergeLine.TextPane.Local, y, viewer.ViewportHeight); }