Skip to content

Commit 9130979

Browse files
geevensinghCopilot
andcommitted
Lock hunk-bar viewport indicator to cursor with optimistic ghost paint
The viewport band's painted position was sourced from VM.Viewport, which only updates after the editor finishes scrolling and lays itself out. On a fast band drag the chain MouseMove -> RequestScrollByVerticalFraction -> editor.ScrollToVerticalOffset -> editor layout pass -> ScrollChanged -> Viewport update -> PropertyChanged -> InvalidateVisual -> paint takes more than one render tick, so the band visibly trailed the cursor by several frames on large files. Decouple the bar's paint from the editor's scroll: on every MouseMove during a drag, set a _dragGhostBandTop in bar pixels (clamped to the bar's drag range) and call InvalidateVisual right away. OnRender translates the computed viewport band so its top sits at the ghost, so the band paints at the cursor on the very next render tick. The editor still scrolls via the normal path; once it catches up the translation collapses to zero. ResetInteraction clears the ghost so the band snaps back to the editor's truth after the drag ends. Add HunkOverviewBarGeometry.TranslateBand + GetBandHeight as the pure-geometry primitives the ghost path needs, with unit tests for both. Public surface of the geometry helper grows by two static methods; no behaviour change for non-drag paint or other callers. AI-Local-Session: b53d5fe5-38cc-41dd-b987-3def5ef3f0d3 AI-Cloud-Session: 744dd836-01e5-48af-91db-daa8241a01d7 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 997720e commit 9130979

4 files changed

Lines changed: 208 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@ body. Keep section headings exact and write notes in Markdown.
3232
that a press landing on a hunk marker which overlaps the viewport
3333
band can still promote to a drag if the user moves the mouse past
3434
the system drag threshold — previously, the jump fired immediately
35-
and stole the drag.
35+
and stole the drag. The viewport indicator paints at the cursor's
36+
position on every mouse-move frame instead of waiting for the
37+
editor's scroll + layout pass to round-trip — keeps the indicator
38+
truly stuck to the cursor even on large files where the editor
39+
scroll can't quite keep up at fast drag speeds.
3640
- Language-aware syntax highlighting for TypeScript (`.ts`, `.tsx`),
3741
YAML (`.yaml`, `.yml`), Go (`.go`), Rust (`.rs`), Ruby (`.rb`),
3842
Bash / shell (`.sh`, `.bash`, `.zsh`), and TOML (`.toml`). Combined

DiffViewer.Tests/Rendering/HunkOverviewBarGeometryTests.cs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,4 +624,95 @@ public void GetBandTopY_SingleSideBand_UsesThatRectsTop()
624624
// Right rect only: y in [38, 120]. Top = 38.
625625
HunkOverviewBarGeometry.GetBandTopY(band).Should().BeApproximately(38, 0.001);
626626
}
627+
628+
// ---------- GetBandHeight ----------
629+
630+
[Fact]
631+
public void GetBandHeight_SymmetricBand_ReturnsRectHeight()
632+
{
633+
var state = new DiffViewer.Models.ViewportState(10, 30, 10, 30);
634+
var band = HunkOverviewBarGeometry.ComputeViewport(
635+
state, 100, 100, BarWidth, 200, ColumnWidth)!;
636+
// Both rects: y in [18, 60] → height = 42.
637+
HunkOverviewBarGeometry.GetBandHeight(band).Should().BeApproximately(42, 0.001);
638+
}
639+
640+
[Fact]
641+
public void GetBandHeight_AsymmetricBand_UsesLargerHeight()
642+
{
643+
// Left: lines 10..30 of 100 → [18, 60], h=42.
644+
// Right: lines 5..10 of 50 → [16, 40], h=24.
645+
// Larger height = 42.
646+
var state = new DiffViewer.Models.ViewportState(10, 30, 5, 10);
647+
var band = HunkOverviewBarGeometry.ComputeViewport(
648+
state, 100, 50, BarWidth, 200, ColumnWidth)!;
649+
HunkOverviewBarGeometry.GetBandHeight(band).Should().BeApproximately(42, 0.001);
650+
}
651+
652+
[Fact]
653+
public void GetBandHeight_SingleSideBand_UsesThatRectsHeight()
654+
{
655+
var state = new DiffViewer.Models.ViewportState(0, 0, 20, 60);
656+
var band = HunkOverviewBarGeometry.ComputeViewport(
657+
state, 100, 100, BarWidth, 200, ColumnWidth)!;
658+
// Right rect only: y in [38, 120] → height = 82.
659+
HunkOverviewBarGeometry.GetBandHeight(band).Should().BeApproximately(82, 0.001);
660+
}
661+
662+
// ---------- TranslateBand ----------
663+
664+
[Fact]
665+
public void TranslateBand_PositiveDy_ShiftsBothRectsDown()
666+
{
667+
var band = new HunkOverviewBarGeometry.ViewportBand(
668+
new Rect(0, 10, 10, 20),
669+
new Rect(22, 10, 10, 20));
670+
var moved = HunkOverviewBarGeometry.TranslateBand(band, 15);
671+
moved.LeftRect!.Value.Should().Be(new Rect(0, 25, 10, 20));
672+
moved.RightRect!.Value.Should().Be(new Rect(22, 25, 10, 20));
673+
}
674+
675+
[Fact]
676+
public void TranslateBand_NegativeDy_ShiftsBothRectsUp()
677+
{
678+
var band = new HunkOverviewBarGeometry.ViewportBand(
679+
new Rect(0, 30, 10, 20),
680+
new Rect(22, 30, 10, 20));
681+
var moved = HunkOverviewBarGeometry.TranslateBand(band, -12);
682+
moved.LeftRect!.Value.Should().Be(new Rect(0, 18, 10, 20));
683+
moved.RightRect!.Value.Should().Be(new Rect(22, 18, 10, 20));
684+
}
685+
686+
[Fact]
687+
public void TranslateBand_ZeroDy_ReturnsSameInstance()
688+
{
689+
// Fast-path: avoids allocating a new ViewportBand for no-op
690+
// translations (common when the editor has caught up exactly).
691+
var band = new HunkOverviewBarGeometry.ViewportBand(
692+
new Rect(0, 10, 10, 20),
693+
new Rect(22, 10, 10, 20));
694+
HunkOverviewBarGeometry.TranslateBand(band, 0).Should().BeSameAs(band);
695+
}
696+
697+
[Fact]
698+
public void TranslateBand_LeftOnlyBand_LeavesRightNull()
699+
{
700+
var band = new HunkOverviewBarGeometry.ViewportBand(
701+
new Rect(0, 10, 10, 20),
702+
null);
703+
var moved = HunkOverviewBarGeometry.TranslateBand(band, 5);
704+
moved.LeftRect!.Value.Should().Be(new Rect(0, 15, 10, 20));
705+
moved.RightRect.Should().BeNull();
706+
}
707+
708+
[Fact]
709+
public void TranslateBand_RightOnlyBand_LeavesLeftNull()
710+
{
711+
var band = new HunkOverviewBarGeometry.ViewportBand(
712+
null,
713+
new Rect(22, 10, 10, 20));
714+
var moved = HunkOverviewBarGeometry.TranslateBand(band, 5);
715+
moved.LeftRect.Should().BeNull();
716+
moved.RightRect!.Value.Should().Be(new Rect(22, 15, 10, 20));
717+
}
627718
}

DiffViewer/Rendering/HunkOverviewBar.cs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,20 @@ private enum BarInteractionState
9393
/// </summary>
9494
private double _bandDragOffsetFromTop;
9595

96+
/// <summary>
97+
/// Optimistic band-top Y while a drag is active, in bar coords.
98+
/// Set on every MouseMove from the cursor position; cleared on drag
99+
/// end. When set, <see cref="OnRender"/> translates the computed
100+
/// viewport band so its top sits here — decouples the band's
101+
/// painted position from the editor's actual scroll, which trails
102+
/// MouseMove by a layout pass and can fall many frames behind
103+
/// under fast drag. The editor scroll still catches up via the
104+
/// normal <see cref="DiffPaneViewModel.RequestScrollByVerticalFraction"/>
105+
/// path; the ghost just makes the bar's visual feedback frame-
106+
/// instant so the band feels truly stuck to the cursor.
107+
/// </summary>
108+
private double? _dragGhostBandTop;
109+
96110
public HunkOverviewBar()
97111
{
98112
Cursor = Cursors.Hand;
@@ -231,6 +245,18 @@ protected override void OnRender(DrawingContext dc)
231245
_vm.Viewport, leftTotal, rightTotal, ActualWidth, ActualHeight, ColumnWidth);
232246
if (viewportBand is not null)
233247
{
248+
// Optimistic translation during drag — see _dragGhostBandTop
249+
// for why. Outside a drag, the ghost is null and the band
250+
// paints at the editor's actual viewport position.
251+
if (_dragGhostBandTop is double ghostTop)
252+
{
253+
double actualTop = HunkOverviewBarGeometry.GetBandTopY(viewportBand);
254+
double dy = ghostTop - actualTop;
255+
if (dy != 0)
256+
{
257+
viewportBand = HunkOverviewBarGeometry.TranslateBand(viewportBand, dy);
258+
}
259+
}
234260
DrawViewport(dc, viewportBand);
235261
}
236262
}
@@ -393,6 +419,15 @@ private void ResetInteraction()
393419
_pendingHunkIndex = -1;
394420
_bandWasUnderClick = false;
395421
if (IsMouseCaptured) ReleaseMouseCapture();
422+
if (_dragGhostBandTop is not null)
423+
{
424+
// Clear the ghost first, then invalidate so the next paint
425+
// shows the band where the editor actually settled. If the
426+
// editor hasn't quite caught up yet, the band may snap by a
427+
// pixel or two — fine, drag is over.
428+
_dragGhostBandTop = null;
429+
InvalidateVisual();
430+
}
396431
}
397432

398433
/// <summary>
@@ -504,10 +539,50 @@ private void EmitDragScroll(Point p)
504539
{
505540
if (_vm is null) return;
506541
double targetBandTopY = p.Y - _bandDragOffsetFromTop;
542+
543+
// Paint the band at the cursor on the very next render tick,
544+
// without waiting for the editor's scroll → layout → ScrollChanged
545+
// → ViewportState → PropertyChanged round-trip. Without this
546+
// decoupling, the band visibly trails the cursor by however long
547+
// the editor takes to settle its layout pass; on large files
548+
// that's enough to drop the band several frames behind a fast
549+
// drag, which reads as the band "sticking" to lower-than-cursor
550+
// positions. The editor still catches up via the normal scroll
551+
// path below; the ghost just makes the bar's visual frame-instant.
552+
_dragGhostBandTop = ClampGhostTop(targetBandTopY);
553+
InvalidateVisual();
554+
507555
double fraction = ActualHeight <= 0 ? 0 : targetBandTopY / ActualHeight;
508556
_vm.RequestScrollByVerticalFraction(fraction);
509557
}
510558

559+
/// <summary>
560+
/// Clamp the ghost band's top to <c>[0, ActualHeight − bandHeight]</c>
561+
/// so it can't be dragged off either end of the bar. Band height is
562+
/// estimated from the current viewport band; if no band exists
563+
/// (shouldn't happen mid-drag — we wouldn't have started one) we
564+
/// fall back to clamping against the full bar height, which is
565+
/// harmlessly loose.
566+
/// </summary>
567+
private double ClampGhostTop(double top)
568+
{
569+
double maxTop = ActualHeight;
570+
if (_vm is not null)
571+
{
572+
int leftTotal = Math.Max(1, _vm.LeftDocument.LineCount);
573+
int rightTotal = Math.Max(1, _vm.RightDocument.LineCount);
574+
var band = HunkOverviewBarGeometry.ComputeViewport(
575+
_vm.Viewport, leftTotal, rightTotal, ActualWidth, ActualHeight, ColumnWidth);
576+
if (band is not null)
577+
{
578+
maxTop = Math.Max(0, ActualHeight - HunkOverviewBarGeometry.GetBandHeight(band));
579+
}
580+
}
581+
if (top < 0) top = 0;
582+
if (top > maxTop) top = maxTop;
583+
return top;
584+
}
585+
511586
private IReadOnlyList<HunkOverviewBarGeometry.HunkBarLayout> ComputeLayouts()
512587
{
513588
if (_vm is null) return Array.Empty<HunkOverviewBarGeometry.HunkBarLayout>();

DiffViewer/Rendering/HunkOverviewBarGeometry.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,43 @@ public static double GetBandTopY(ViewportBand band)
305305
return top == double.MaxValue ? 0 : top;
306306
}
307307

308+
/// <summary>
309+
/// Vertical height of the viewport band, in bar coordinates — the
310+
/// distance from <see cref="GetBandTopY"/> to the lower of the two
311+
/// rects' bottoms. Used during sticky-thumb drag to clamp the
312+
/// optimistic ghost band so it can't be dragged off the ends of the
313+
/// bar. For mixed bands the two rects have the same height in
314+
/// practice (both come from the same viewport line range) but we
315+
/// take the max to be safe against off-by-one rounding.
316+
/// Returns 0 for empty bands (matches <see cref="GetBandTopY"/>).
317+
/// </summary>
318+
public static double GetBandHeight(ViewportBand band)
319+
{
320+
double h = 0;
321+
if (band.LeftRect is Rect L) h = Math.Max(h, L.Height);
322+
if (band.RightRect is Rect R) h = Math.Max(h, R.Height);
323+
return h;
324+
}
325+
326+
/// <summary>
327+
/// Return a copy of <paramref name="band"/> with each rect's Y
328+
/// translated by <paramref name="dy"/>. Used during drag to paint
329+
/// an "optimistic" band at the cursor position without waiting for
330+
/// the editor's scroll → ScrollChanged → ViewportState round-trip;
331+
/// the actual editor scroll catches up at its own pace.
332+
/// </summary>
333+
public static ViewportBand TranslateBand(ViewportBand band, double dy)
334+
{
335+
if (dy == 0) return band;
336+
Rect? left = band.LeftRect is Rect L
337+
? new Rect(L.X, L.Y + dy, L.Width, L.Height)
338+
: (Rect?)null;
339+
Rect? right = band.RightRect is Rect R
340+
? new Rect(R.X, R.Y + dy, R.Width, R.Height)
341+
: (Rect?)null;
342+
return new ViewportBand(left, right);
343+
}
344+
308345
/// <summary>
309346
/// Classifies a hunk's content for color selection — pure-add (all
310347
/// inserted lines), pure-delete (all removed lines), or mixed.

0 commit comments

Comments
 (0)