diff --git a/doc/history/2026-09-23-12차.md b/doc/history/2026-09-23-12차.md index b126531..2d467b0 100644 --- a/doc/history/2026-09-23-12차.md +++ b/doc/history/2026-09-23-12차.md @@ -2,6 +2,28 @@ 지시서: [../작업지시/12차.md](../작업지시/12차.md) +## 스크롤을 내리면 프로그램이 뻗는다 (2026-10-01, 사용자 보고 · 완료) + +어제 커밋(`f105bab` 칸 어긋남 고침) 뒤부터 생겼다. Windows 이벤트에 `UbisamBase.Launcher.exe` **응답 없음(AppHang)** 두 번(13:44 · 13:45) — 죽은 것이 아니라 +화면 실이 되풀이에 갇혔다. 같은 재료로 BASE 칸을 휠로 끝까지 내리자 재현(9초 만에 꺼짐). 임시 기록으로 찾은 원인 넷(기록 코드는 뺐다): + +1. **사람이 BASE 를 굴려도 BASE 를 LOCAL 쪽으로 되돌렸다.** BASE 맞추기는 늘 LOCAL 을 기준으로 봐서, BASE 를 휠로 굴린 만큼(48px)을 매번 끌어당겼다. + 그 사이 보정값이 오르내리며(694 → 22) 다른 칸을 다시 움직여 초당 32번 밀고 당겼다. → **누가 굴렸는지**를 손이 닿은 칸(휠 · 누름 · 키)으로 정하고, + LOCAL 쪽일 때만 BASE 를 맞춘다. 스크롤 이벤트로 정하면 글자 크기를 바꿀 때 목록이 저절로 내는 스크롤까지 "BASE 를 굴렸다" 로 읽어 확대 뒤 BASE 를 안 맞췄다(14초 동안 0번). + 확대 · 다음 차이 · 줄 이동(프로그램이 편집기를 옮김)은 LOCAL 기준. +2. **따라 굴러가는 도중에 쟀다.** 다른 칸에 굴리라고 한 자리는 다음 배치에야 들어가는데, 배치마다 도는 맞추기가 그 사이에 재서 휠 한 칸(48px)을 늘 어긋남으로 보고 + 보정값에 쌓았다(6,619px). 그 엉터리 보정값으로 BASE 를 기준 삼으면 편집기 · LOCAL 이 수십~백 줄 엉뚱한 곳으로 갔다. → 메아리 막기 중이면 크게 굴리는 맞추기를 하지 않고 + **앱이 한가할 때(ApplicationIdle) 한 번** 맞춘다 — 한가할 때 도는 일은 입력을 밀어내지 않는다. 못 굴리는 끝에서는 보정값 · 다시 그리기를 건드리지 않고 **실제로 움직인 만큼만** 보정한다. +3. **목록 항목 번호를 정렬 행 번호로 썼다.** 칸 목록은 그 칸에 있는 줄만 거른 목록(`PaneView`, 11차 F-10)이라, 한쪽에만 있는 줄이 앞에 있으면 그만큼 밀린다. + 어제 넣은 코드가 `ContainerFromIndex(row)` · `IndexFromContainer` 로 둘을 같게 써서 엉뚱한 줄끼리 견줬다(한쪽에만 있는 줄 10개 → 157px, 마지막 차이에서 칸이 통째로 228px 밀림). + → 줄 객체로 오간다(`RowOf` · `ContainerOfRow`). 실제 파일처럼 한쪽에만 있는 줄이 많으면 이 어긋남이 커져 되풀이가 길어졌을 것이다. +4. **사람이 빠르게 굴리면 스크롤이 버려졌다.** 메아리 막기 중에 온 스크롤은 버리는데, 빠르게 굴리면 사람의 다음 휠까지 버려져 따라가는 칸이 중간에 멈췄다 + (BASE 784 · LOCAL 555, 편집기를 굴리면 BASE 37 · LOCAL 473). → 사람이 굴리는 칸의 스크롤이 버려지면 표시만 남기고 멈춘 뒤 한 번 따라잡는다. + 따라잡기로 다른 칸이 움직여 생기는 스크롤은 사람이 굴린 칸이 아니라 표시하지 않는다 — 되풀이가 없다. BASE 를 굴릴 때는 보정값을 **지금 보이는 줄에서 다시 잰다**(`MeasureBias`). + +시험(사용자 지시로 이것만): `g20-scroll-end`(새, 보통 · 빠르게 — 편집기 · BASE 휠로 끝까지 · 처음까지) 모든 단계 **응답**, BASE · LOCAL 가운데 줄 한 줄 안. +`g21-blocks-fast`(새, 60줄마다 오른쪽에서 5줄 빼고 7줄 넣은 재료) 응답 · 따라옴 — 화면 가운데가 한쪽에만 있는 묶음에 걸리면 그 자리는 일부러 맞추지 않아 10줄 남짓 벗어날 때가 있다. +`g7-verify`(Alt+↓) 실패 0, `g17`(엔터 · 확대 · 내리기) 통과(남은 ✗ 는 대본 한계). **사용자 파일로는 확인하지 않았다** — 실제 저장소를 읽지 않는다. 기존 회귀는 돌리지 않았다. ## 끌 때 저장하지 않은 편집을 묻는다 (2026-09-30 밤, 사용자 요청 · 결정 · 완료) 사용자 보고: 편집이 있는데 하단 [종료] 를 누르면 그냥 꺼진다. 편집은 보관본(E-1)이 다음에 되살렸지만(g16 로 확인), 사용자 판단은 diff --git a/doc/history/changelog.md b/doc/history/changelog.md index d21680d..8f7704f 100644 --- a/doc/history/changelog.md +++ b/doc/history/changelog.md @@ -8,6 +8,7 @@ | 날짜 | 항목 | 커밋 | |---|---|---| +| 2026-10-01 | **스크롤을 내리면 프로그램이 뻗던 것(사용자 보고)** — 어제 칸 맞추기(`f105bab`) 뒤 응답 없음. ① 사람이 BASE 를 굴려도 LOCAL 기준으로 되돌려 초당 32번 밀고 당김 → 손이 닿은 칸(휠 · 누름 · 키)을 기준으로, LOCAL 쪽일 때만 BASE 를 맞춤 ② 따라 굴러가는 도중에 재서 보정값이 휠 한 칸씩 6,619px 까지 쌓임 → 메아리 막기 중이면 큰 맞추기는 앱이 한가할 때 한 번, 못 굴리면 실제로 움직인 만큼만 ③ 칸 목록(그 칸에 있는 줄만)의 항목 번호를 정렬 행 번호로 써 엉뚱한 줄끼리 견줌 → 줄 객체로 오감 ④ 빠르게 굴리면 사람의 스크롤이 메아리로 버려져 따라가는 칸이 멈춤 → 멈춘 뒤 한 번 따라잡기, BASE 를 굴릴 때 보정값을 보이는 줄에서 다시 잼. 화면 `g20-scroll-end` · `g21-blocks-fast`(새) 응답 · 따라옴, `g7` 실패 0, `g17` 통과. 사용자 파일로는 확인 안 함 · 기존 회귀는 돌리지 않음. 빌드 경고 0 · 오류 0 | 아래 | | 2026-09-30 | **끌 때 저장하지 않은 편집을 묻는다(사용자 요청 · 결정)** — 파일 비교의 모든 탭(지금 화면 · Git 모드 · 뒤 탭 · 닫은 탭, 메모 제외)에 편집이 있으면 [저장하고 종료] · [저장하지 않고 종료] · [취소]. 저장은 기존 길(`SaveWorkingCopy` · `WritePlainFile`), 하나라도 실패하면 끄지 않음. 버리면 걸려 있던 보관 쓰기를 기다린 뒤 보관본을 비워 다음에 안 살아남. 플랫폼 `ICloseGuard` 사용(수정 없음) — 곧바로 답하면 셸의 `Close()` 가 Closing 안에서 불려 창이 안 꺼져 `await Task.Yield()` 로 비켜섬. 화면 `g19-exit-confirm` 다섯 경우 실패 0(Git 모드는 화면 시험 안 함). **기존 회귀는 사용자 지시로 돌리지 않음.** 빌드 경고 0 · 오류 0 | 아래 | | 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 | 아래 | diff --git a/doc/진행상황.md b/doc/진행상황.md index 2485786..6e293c1 100644 --- a/doc/진행상황.md +++ b/doc/진행상황.md @@ -446,6 +446,7 @@ | 상태 | 항목 | |---|---| +| **완료** | **스크롤을 내리면 프로그램이 뻗는다**(사용자 보고, 어제 `f105bab` 뒤) — 응답 없음(AppHang). 원인 넷: BASE 를 굴려도 LOCAL 쪽으로 되돌려 밀고 당김 · 따라 굴러가는 도중에 재서 보정값이 6,619px 까지 쌓임 · 칸 목록 항목 번호를 정렬 행 번호로 씀(한쪽에만 있는 줄만큼 엉뚱한 줄끼리 견줌) · 빠르게 굴리면 사람의 스크롤이 버려짐. 손이 닿은 칸을 기준으로, 큰 맞추기는 한가할 때 한 번, 줄 객체로 오가기, 버려지면 멈춘 뒤 따라잡기. 화면 `g20` · `g21`(새) 응답, `g7` 실패 0, `g17` 통과 | | **완료** | **끌 때 저장하지 않은 편집을 묻는다**(사용자 요청 · 결정) — 파일 비교의 모든 탭(Git 모드 · 뒤 탭 · 닫은 탭 포함, 메모 제외)에 편집이 있으면 [저장하고 종료] · [저장하지 않고 종료] · [취소]. 버리면 보관본도 비워 다음에 안 살아남. 플랫폼 `ICloseGuard` 를 그대로 씀(플랫폼 수정 없음). 화면 `g19-exit-confirm` 실패 0. Git 모드는 화면 시험 안 함 · 기존 회귀는 사용자 지시로 안 돌림 | | **완료** | **엔터 꾹 · 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` ① 새 줄 · 번호 제자리. 기존 회귀는 사용자 지시로 안 돌림 | diff --git a/src/MergeHub/Views/HomeView.xaml.cs b/src/MergeHub/Views/HomeView.xaml.cs index 1820cd2..f712f28 100644 --- a/src/MergeHub/Views/HomeView.xaml.cs +++ b/src/MergeHub/Views/HomeView.xaml.cs @@ -243,6 +243,7 @@ namespace MergeHub.Views { if (Keyboard.Modifiers != ModifierKeys.Control || DataContext is not HomeViewModel vm) return; var command = e.Delta > 0 ? vm.FontBiggerCommand : vm.FontSmallerCommand; + _scrollDriver = MergeLine.TextPane.Local; // 확대 뒤에는 BASE 를 LOCAL 에 맞춘다 if (command.CanExecute(null)) command.Execute(null); e.Handled = true; }; @@ -272,6 +273,19 @@ namespace MergeHub.Views // 글꼴·배율·창 크기가 바뀌면 다시 재서 맞춘다. LayoutUpdated += (_, _) => AlignDecorationToEditor(); + // 어느 칸을 사람이 굴렸는지는 손이 닿은 칸으로 정한다(2026-10-01). 스크롤 이벤트로 정하면 글자 크기를 바꿀 때 + // 목록이 저절로 내는 스크롤까지 "BASE 를 굴렸다" 로 읽어, 확대 뒤 BASE 를 LOCAL 에 맞추지 않았다(기록: 14초 동안 한 번도). + void DriveFrom(UIElement pane, MergeLine.TextPane kind) + { + pane.PreviewMouseWheel += (_, _) => _scrollDriver = kind; + pane.PreviewMouseDown += (_, _) => _scrollDriver = kind; // 스크롤 막대 끌기 · 칸 누르기 + pane.PreviewKeyDown += (_, _) => _scrollDriver = kind; + } + DriveFrom(LocalEditor, MergeLine.TextPane.Local); + DriveFrom(LocalLines, MergeLine.TextPane.Local); + DriveFrom(BaseLines, MergeLine.TextPane.Base); + DriveFrom(RemoteLines, MergeLine.TextPane.Remote); + DriveFrom(OriginalLocalLines, MergeLine.TextPane.OriginalLocal); Loaded += (_, _) => { HookHorizontalWheel(); @@ -531,7 +545,13 @@ namespace MergeHub.Views // 3-Way 의 위 세 칸은 폭이 달라 따로 움직이므로 여기서 건드리지 않는다. if (DataContext is not HomeViewModel { IsThreeWay: true }) SourceShiftX = TextShiftX; - if (Syncing) return; + if (Syncing) + { + // 메아리 막기 중에 온 스크롤은 버린다. 그런데 사람이 편집기를 빠르게 굴리면 다음 휠까지 버려져 + // BASE 가 따라가지 못하고 맨 위에 남았다(기록: BASE 37 · LOCAL 473). 사람이 굴리는 쪽이면 멈춘 뒤 따라잡는다. + if (e.VerticalChange != 0 && _scrollDriver == MergeLine.TextPane.Local) { _catchUp = true; AlignWhenSettled(); } + return; + } HoldSync(); // 세로는 BASE·REMOTE 가 가운데를 맞춰 같이 굴러간다 - 이쪽은 굴리면 되돌아오는 칸이라 메아리를 막는다. @@ -776,7 +796,7 @@ namespace MergeHub.Views var row = vm.RowOfEditorLine(editorLine); if (row < 0 || row >= vm.Lines.Count) return; - if (LocalLines.ItemContainerGenerator.ContainerFromIndex(row) is not ListBoxItem container || !container.IsVisible) + if (ContainerOfRow(vm, LocalLines, row) is not { } container || !container.IsVisible) { LocalLines.ScrollIntoView(vm.Lines[row]); return; @@ -835,11 +855,23 @@ namespace MergeHub.Views 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; + // 아직 따라 굴러가는 중이면 재지 않는다 - 굴리라고 한 자리가 다음 배치에야 들어가서, 지금 재면 그만큼을 + // 어긋남으로 보고 보정값에 쌓는다(기록: 휠 한 칸마다 48px 씩 6,619px 까지). 멈춘 뒤 한 번 맞춘다. + if (Syncing) { AlignWhenSettled(); return; } + + // 실제로 움직인 만큼만 보정한다(2026-10-01 사용자 보고 "스크롤을 내리면 프로그램이 뻗는다"). + // 목록 끝이라 더 못 굴리는데 보정값을 쌓고 다시 그리기를 부르면, 배치마다 같은 일을 되풀이할 수 있다. + // 못 굴리면 아래 이동값으로 넘긴다 - 그러면 다음 배치에서 "맞음" 으로 끝난다. + var target = Clamp(listViewer.VerticalOffset - wanted, listViewer.ScrollableHeight); + var moved = target - listViewer.VerticalOffset; + if (Math.Abs(moved) >= 1) + { + _decorationShift.Y = 0; + _paneBias[LocalLines] = Bias(LocalLines) + moved; // 다음 편집기 스크롤도 이만큼 더해 굴린다 + listViewer.ScrollToVerticalOffset(target); + LinkOverlay.InvalidateVisual(); + return; + } } // 가로는 건드리지 않는다. 여백(줄번호·[>])까지 밀려나기 때문이다 - @@ -853,6 +885,59 @@ namespace MergeHub.Views } } + /// 한가할 때 맞추기가 이미 걸려 있다. + private bool _alignQueued; + + /// 사람이 굴린 칸의 스크롤이 메아리 막기에 버려졌다 - 멈춘 뒤 그 칸 기준으로 한 번 따라잡는다. + private bool _catchUp; + + /// + /// 스크롤이 멈춘 뒤(입력 · 배치가 다 끝나 앱이 한가할 때) 칸 맞추기를 한 번 더 돌린다. + /// 배치마다 도는 자리(LayoutUpdated)에서 곧바로 다시 굴리면, 굴린 것이 또 배치를 부르고 그 배치가 또 맞추기를 불러 + /// 입력 차례가 오지 않을 수 있다(2026-10-01 사용자 보고 "스크롤을 내리면 뻗는다"). 한가할 때 도는 일은 입력을 밀어내지 않는다. + /// + private void AlignWhenSettled() + { + if (_alignQueued) return; + _alignQueued = true; + Dispatcher.BeginInvoke(new Action(() => + { + _alignQueued = false; + if (Syncing) { AlignWhenSettled(); return; } + if (_catchUp) + { + // 사람이 굴린 칸의 스크롤이 버려졌다 - 그 칸의 마지막 자리로 나머지를 한 번 맞춘다. + // 이걸로 다른 칸이 움직여 생기는 스크롤은 사람이 굴린 칸이 아니라 표시를 남기지 않는다 - 되풀이가 없다. + _catchUp = false; + if (DriverPane() is { } driver && FindScrollViewer(driver) is { } viewer) + { + MeasureBias(driver, _scrollDriver); + HoldSync(); + SyncPanesFrom(_scrollDriver, viewer.VerticalOffset - Bias(driver), viewer.ViewportHeight); + } + else if (FindScrollViewer(LocalEditor) is { } editor) + { + HoldSync(); + SyncPanesFrom(MergeLine.TextPane.Local, editor.VerticalOffset, editor.ViewportHeight); + } + return; + } + AlignDecorationToEditor(); + }), DispatcherPriority.ApplicationIdle); + } + + /// 사람이 굴린 원본 칸(BASE · REMOTE · 원본 LOCAL). LOCAL 쪽이면 null. + private ListBox? DriverPane() => _scrollDriver switch + { + MergeLine.TextPane.Base => BaseLines, + MergeLine.TextPane.Remote => RemoteLines, + MergeLine.TextPane.OriginalLocal => OriginalLocalLines, + _ => null + }; + + /// 마지막으로 사람이 굴린 칸. 편집기 · LOCAL 이면 BASE 를 LOCAL 에 맞추고, BASE · REMOTE 면 그 칸이 기준이라 되돌리지 않는다. + private MergeLine.TextPane _scrollDriver = MergeLine.TextPane.Local; + /// 칸마다 마지막으로 불러온 짝 행. 같은 행을 배치마다 거듭 부르지 않게 한다. private readonly System.Collections.Generic.Dictionary _pairRequested = new System.Collections.Generic.Dictionary(); @@ -868,8 +953,19 @@ namespace MergeHub.Views private void AlignOtherPanesByRow() { if (DataContext is not HomeViewModel vm || vm.Lines.Count == 0) return; + + // 사람이 BASE · REMOTE 를 굴리는 중이면 그 칸이 기준이다 - 되돌리지 않는다(2026-10-01 사용자 보고 "스크롤을 내리면 뻗는다"). + // 예전에는 늘 LOCAL 을 기준으로 BASE 를 되돌려, BASE 를 휠로 굴리는 동안 굴린 만큼(기록: 48px)을 매번 끌어당겼다. + // 그 사이 보정값이 오르내리며(694 → 22) 다른 칸을 다시 움직여 초당 32번 서로 밀고 당겼다 - 큰 파일에서는 응답 없음으로 번졌다. + // 그때는 예전처럼 나머지 칸이 가운데 맞추기(SyncPanesFrom)로 그 칸을 따라온다. + if (_scrollDriver != MergeLine.TextPane.Local) return; + + // 따라 굴러가는 중이면 재지 않는다 - BASE 는 가운데 맞추기가 굴리라고 한 자리로 다음 배치에야 간다. + // 그 사이에 재면 휠 한 칸(48px)을 늘 어긋남으로 보고 보정값에 쌓았다(기록: 6,619px). 멈춘 뒤 한 번 맞춘다. + if (Syncing) { AlignWhenSettled(); return; } + if (ContainerAtMiddle(LocalLines) is not { } localItem) return; - var row = LocalLines.ItemContainerGenerator.IndexFromContainer(localItem); + var row = RowOf(vm, LocalLines, localItem); if (row < 0) return; var localTop = localItem.TranslatePoint(new Point(0, 0), LocalLines).Y; var rowHeight = Math.Max(1, localItem.ActualHeight); @@ -884,7 +980,7 @@ namespace MergeHub.Views 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 (ContainerOfRow(vm, list, row) is not { } item || !item.IsVisible) { // 짝이 아직 안 그려졌다(어림이 크게 틀림) - 먼저 화면 안으로 부르고, 다음 배치 때 윗변을 맞춘다. // 같은 줄은 한 번만 부른다 - 부른 뒤에도 안 보이면 배치마다 다시 부르며 멈추지 않는다. @@ -899,13 +995,32 @@ namespace MergeHub.Views var dy = item.TranslatePoint(new Point(0, 0), list).Y - localTop; if (Math.Abs(dy) <= rowHeight * 2) continue; + // 실제로 움직일 수 있는 만큼만. 칸 끝이라 못 움직이면 보정값도 다시 그리기도 건드리지 않는다 - + // 그러지 않으면 배치마다 같은 어긋남을 보고 같은 일을 되풀이한다. + var target = Clamp(viewer.VerticalOffset + dy, viewer.ScrollableHeight); + var moved = target - viewer.VerticalOffset; + if (Math.Abs(moved) < 1) continue; + HoldSync(); - _paneBias[list] = Bias(list) + dy; // 가운데 맞추기도 다음부터 이만큼 더해 굴린다 - 되돌려 밀고 당기지 않게 - viewer.ScrollToVerticalOffset(Clamp(viewer.VerticalOffset + dy, viewer.ScrollableHeight)); + _paneBias[list] = Bias(list) + moved; // 가운데 맞추기도 다음부터 이만큼 더해 굴린다 - 되돌려 밀고 당기지 않게 + viewer.ScrollToVerticalOffset(target); LinkOverlay.InvalidateVisual(); } } + // 칸 목록은 정렬 행 전체가 아니라 그 칸에 있는 줄만 거른 목록이다(PaneView, 11차 F-10). + // 그래서 목록의 몇 번째 항목과 정렬 행 번호는 다르다 - 한쪽에만 있는 줄이 앞에 있으면 그만큼 밀린다. + // 둘을 같은 것으로 쓰면 엉뚱한 줄끼리 견줘 칸을 잘못 굴렸다(2026-10-01 기록: 한쪽에만 있는 줄 10개 → 157px). + // 줄 객체로 오간다. + + /// 목록 항목이 가리키는 정렬 행 번호. 못 찾으면 -1. + private static int RowOf(HomeViewModel vm, ListBox list, ListBoxItem item) => + list.ItemContainerGenerator.ItemFromContainer(item) is MergeLine line ? vm.Lines.IndexOf(line) : -1; + + /// 정렬 행의 목록 항목. 그 칸에 없는 줄이거나 아직 안 그려졌으면 null. + private static ListBoxItem? ContainerOfRow(HomeViewModel vm, ListBox list, int row) => + row >= 0 && row < vm.Lines.Count ? list.ItemContainerGenerator.ContainerFromItem(vm.Lines[row]) as ListBoxItem : null; + /// 목록 칸의 세로 가운데를 덮는 행. 없으면 null. private static ListBoxItem? ContainerAtMiddle(ListBox list) { @@ -1033,8 +1148,14 @@ namespace MergeHub.Views // 글이나 칸 폭이 바뀌면 밀 수 있는 폭도 달라진다. 스크롤 이벤트가 그때마다 온다. if (e.ExtentWidthChange != 0 || e.ViewportWidthChange != 0) UpdateSourceScrollRange(); - if (Syncing) return; if (e.VerticalChange == 0) return; + if (Syncing) + { + // 다른 칸을 따라 굴리는 동안 들어온 스크롤은 메아리로 보고 버린다. 그런데 사람이 이 칸을 빠르게 굴리면 + // 그 다음 휠까지 버려져 편집기가 중간에 멈췄다(기록: BASE 784 · LOCAL 555). 사람이 굴리는 칸이면 멈춘 뒤 따라잡는다. + if (DriverPane() == source) { _catchUp = true; AlignWhenSettled(); } + return; + } HoldSync(); @@ -1043,9 +1164,32 @@ namespace MergeHub.Views : ReferenceEquals(source, RemoteLines) ? MergeLine.TextPane.Remote : MergeLine.TextPane.OriginalLocal; // 이 칸의 픽셀 자리에는 보정값이 들어 있다 - 빼서 "줄 기준" 자리로 바꾼 뒤 다른 칸을 맞춘다. + // 보정값은 지금 보이는 줄에서 다시 잰다(2026-10-01). 목록은 안 그린 줄 높이를 어림하므로 멀리 굴러가면 + // 어림이 바뀌는데, 예전 자리에서 잰 값을 그대로 쓰면 편집기 · LOCAL 이 수십~백 줄 엉뚱한 곳으로 갔다 + // (기록: BASE 26 · LOCAL 81, BASE 761 · LOCAL 618). 사람이 굴릴 때만 한 번씩 재므로 되풀이를 만들지 않는다. + MeasureBias(source, kind); SyncPanesFrom(kind, e.VerticalOffset - Bias(source), e.ViewportHeight); } + /// + /// 칸 목록의 보정값(실제 픽셀 자리 - 줄 높이로 센 자리)을 가운데 보이는 줄에서 잰다. + /// 그 줄이 이 칸에 없으면(높이 0) 가운데 행이 잡히지 않아 그대로 둔다. + /// + private void MeasureBias(ListBox list, MergeLine.TextPane kind) + { + if (DataContext is not HomeViewModel vm || vm.Lines.Count == 0) return; + if (FindScrollViewer(list) is not { } viewer || ContainerAtMiddle(list) is not { } item) return; + var row = RowOf(vm, list, item); + if (row < 0 || row >= vm.Lines.Count) return; + + var modelTop = 0.0; + var measured = MeasuredRows; + for (var i = 0; i < row; i++) modelTop += vm.Lines[i].PaneHeight(kind, measured); + + var actualTop = viewer.VerticalOffset + item.TranslatePoint(new Point(0, 0), list).Y; + _paneBias[list] = actualTop - modelTop; + } + /// /// 칸 목록마다 "픽셀 자리 → 같은 줄" 보정값(2026-09-30 사용자 보고: 확대하면 화면이 잘린다 · 밑으로 쭉 내리면 두 칸 속도가 다르다). /// @@ -1686,6 +1830,7 @@ namespace MergeHub.Views /// 그 글자가 있는 줄의 윗변을 편집기 칸 가운데로 굴린다(차이 이동 · 11차 F-10). private void CenterCharInView(int charIndex) { + _scrollDriver = MergeLine.TextPane.Local; // 프로그램이 편집기를 옮겼다(다음 차이 · 줄 이동) - 나머지 칸은 LOCAL 을 따른다 if (FindScrollViewer(LocalEditor) is not { } viewer) return; Rect rect;