detached e74381d

fix(renderer): plan scroll damage before emission

commit
e74381d02c8545c2185e2a4afe3ba5972a8fabff
author
brice · · 2026-07-15 08:19 UTC
committer
brice · · 2026-07-15 08:19 UTC
parent
616526c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +136 -81
M pkg/renderer/damage_plan.go +8 -5
@@ -1,6 +1,9 @@
11 package renderer
22
3-import "sort"
3+import (
4+ "cmp"
5+ "slices"
6+)
47
58 const maxPlannedDamageSpans = 4096
69
@@ -32,11 +35,11 @@ func buildDamagePlan(frame Frame, damage []Damage, skip *Damage) ([]damageSpan,
3235 }
3336 }
3437
35- sort.Slice(spans, func(i, j int) bool {
36- if spans[i].y != spans[j].y {
37- return spans[i].y < spans[j].y
38+ slices.SortFunc(spans, func(a, b damageSpan) int {
39+ if c := cmp.Compare(a.y, b.y); c != 0 {
40+ return c
3841 }
39- return spans[i].x < spans[j].x
42+ return cmp.Compare(a.x, b.x)
4043 })
4144
4245 return mergeDamageSpans(spans), false
@@ -1,6 +1,9 @@
1 package renderer1 package renderer
2 2
3-import "sort"3+import (
4+ "cmp"
5+ "slices"
6+)
4 7
5 const maxPlannedDamageSpans = 40968 const maxPlannedDamageSpans = 4096
6 9
@@ -32,11 +35,11 @@ func buildDamagePlan(frame Frame, damage []Damage, skip *Damage) ([]damageSpan,
32 }35 }
33 }36 }
34 37
35- sort.Slice(spans, func(i, j int) bool {38+ slices.SortFunc(spans, func(a, b damageSpan) int {
36- if spans[i].y != spans[j].y {39+ if c := cmp.Compare(a.y, b.y); c != 0 {
37- return spans[i].y < spans[j].y40+ return c
38 }41 }
39- return spans[i].x < spans[j].x42+ return cmp.Compare(a.x, b.x)
40 })43 })
41 44
42 return mergeDamageSpans(spans), false45 return mergeDamageSpans(spans), false
M pkg/renderer/damage_plan_test.go +77 -70
@@ -6,57 +6,85 @@ import (
66 "testing"
77 )
88
9-func TestBuildDamagePlanMergesReverseOverlaps(t *testing.T) {
10- frame := NewFrame(10, 2)
11- got, full := buildDamagePlan(frame, []Damage{
12- {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1},
13- {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1},
14- }, nil)
15- want := []damageSpan{{y: 0, x: 0, width: 9}}
16- if full || !reflect.DeepEqual(got, want) {
17- t.Fatalf("buildDamagePlan() = %#v, full = %v; want %#v, false", got, full, want)
9+func TestBuildDamagePlan(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ frame Frame
13+ damage []Damage
14+ want []damageSpan
15+ full bool
16+ }{
17+ {
18+ name: "merges reverse overlaps",
19+ frame: NewFrame(10, 2),
20+ damage: []Damage{
21+ {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1},
22+ {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1},
23+ },
24+ want: []damageSpan{{y: 0, x: 0, width: 9}},
25+ },
26+ {
27+ name: "merges text and clear",
28+ frame: NewFrame(10, 1),
29+ damage: []Damage{
30+ {Kind: DamageClear, X: 5, Y: 0, Width: 4, Height: 1},
31+ {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1},
32+ },
33+ want: []damageSpan{{y: 0, x: 0, width: 9}},
34+ },
35+ {
36+ name: "orders multi-row spans",
37+ frame: NewFrame(10, 3),
38+ damage: []Damage{
39+ {Kind: DamageText, X: 5, Y: 2, Width: 2, Height: 1},
40+ {Kind: DamageText, X: 4, Y: 0, Width: 2, Height: 2},
41+ {Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 2},
42+ {Kind: DamageScrollUp, X: 0, Y: 0, Width: 10, Height: 3, Count: 1},
43+ },
44+ want: []damageSpan{
45+ {y: 0, x: 1, width: 2}, {y: 0, x: 4, width: 2},
46+ {y: 1, x: 1, width: 2}, {y: 1, x: 4, width: 2},
47+ {y: 2, x: 5, width: 2},
48+ },
49+ },
50+ {
51+ name: "clamps rectangles",
52+ frame: NewFrame(10, 3),
53+ damage: []Damage{
54+ {Kind: DamageText, X: -2, Y: -1, Width: 5, Height: 3},
55+ {Kind: DamageClear, X: 8, Y: 1, Width: 5, Height: 3},
56+ },
57+ want: []damageSpan{{y: 0, x: 0, width: 3}, {y: 1, x: 0, width: 3}, {y: 1, x: 8, width: 2}, {y: 2, x: 8, width: 2}},
58+ },
59+ {
60+ name: "does not mutate input",
61+ frame: NewFrame(10, 2),
62+ damage: []Damage{
63+ {Kind: DamageText, X: 5, Y: 1, Width: 2, Height: 1},
64+ {Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 1},
65+ },
66+ want: []damageSpan{{y: 0, x: 1, width: 2}, {y: 1, x: 5, width: 2}},
67+ },
68+ {
69+ name: "exceeding budget requests full redraw",
70+ frame: NewFrame(1, maxPlannedDamageSpans+1),
71+ damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: maxPlannedDamageSpans + 1}},
72+ want: nil,
73+ full: true,
74+ },
1875 }
19-}
20-
21-func TestBuildDamagePlanMergesTextAndClear(t *testing.T) {
22- frame := NewFrame(10, 1)
23- got, full := buildDamagePlan(frame, []Damage{
24- {Kind: DamageClear, X: 5, Y: 0, Width: 4, Height: 1},
25- {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1},
26- }, nil)
27- want := []damageSpan{{y: 0, x: 0, width: 9}}
28- if full || !reflect.DeepEqual(got, want) {
29- t.Fatalf("buildDamagePlan() = %#v, full = %v; want %#v, false", got, full, want)
30- }
31-}
32-
33-func TestBuildDamagePlanOrdersMultiRowSpans(t *testing.T) {
34- frame := NewFrame(10, 3)
35- got, full := buildDamagePlan(frame, []Damage{
36- {Kind: DamageText, X: 5, Y: 2, Width: 2, Height: 1},
37- {Kind: DamageText, X: 4, Y: 0, Width: 2, Height: 2},
38- {Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 2},
39- {Kind: DamageScrollUp, X: 0, Y: 0, Width: 10, Height: 3, Count: 1},
40- }, nil)
41- want := []damageSpan{
42- {y: 0, x: 1, width: 2}, {y: 0, x: 4, width: 2},
43- {y: 1, x: 1, width: 2}, {y: 1, x: 4, width: 2},
44- {y: 2, x: 5, width: 2},
45- }
46- if full || !reflect.DeepEqual(got, want) {
47- t.Fatalf("buildDamagePlan() = %#v, full = %v; want %#v, false", got, full, want)
48- }
49-}
5076
51-func TestBuildDamagePlanClampsRectangles(t *testing.T) {
52- frame := NewFrame(10, 3)
53- got, full := buildDamagePlan(frame, []Damage{
54- {Kind: DamageText, X: -2, Y: -1, Width: 5, Height: 3},
55- {Kind: DamageClear, X: 8, Y: 1, Width: 5, Height: 3},
56- }, nil)
57- want := []damageSpan{{y: 0, x: 0, width: 3}, {y: 1, x: 0, width: 3}, {y: 1, x: 8, width: 2}, {y: 2, x: 8, width: 2}}
58- if full || !reflect.DeepEqual(got, want) {
59- t.Fatalf("buildDamagePlan() = %#v, full = %v; want %#v, false", got, full, want)
77+ for _, tt := range tests {
78+ t.Run(tt.name, func(t *testing.T) {
79+ original := append([]Damage(nil), tt.damage...)
80+ got, full := buildDamagePlan(tt.frame, tt.damage, nil)
81+ if full != tt.full || !reflect.DeepEqual(got, tt.want) {
82+ t.Fatalf("buildDamagePlan() = %#v, full = %v; want %#v, %v", got, full, tt.want, tt.full)
83+ }
84+ if !reflect.DeepEqual(tt.damage, original) {
85+ t.Fatalf("buildDamagePlan mutated damage: got %#v, want %#v", tt.damage, original)
86+ }
87+ })
6088 }
6189 }
6290
@@ -70,27 +98,6 @@ func TestClampRectRejectsOverflowingBounds(t *testing.T) {
7098 }
7199 }
72100
73-func TestBuildDamagePlanDoesNotMutateInput(t *testing.T) {
74- frame := NewFrame(10, 2)
75- damage := []Damage{
76- {Kind: DamageText, X: 5, Y: 1, Width: 2, Height: 1},
77- {Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 1},
78- }
79- original := append([]Damage(nil), damage...)
80- _, _ = buildDamagePlan(frame, damage, nil)
81- if !reflect.DeepEqual(damage, original) {
82- t.Fatalf("buildDamagePlan mutated damage: got %#v, want %#v", damage, original)
83- }
84-}
85-
86-func TestBuildDamagePlanExceedingBudgetRequestsFullRedraw(t *testing.T) {
87- frame := NewFrame(1, maxPlannedDamageSpans+1)
88- got, full := buildDamagePlan(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: frame.Height}}, nil)
89- if !full || got != nil {
90- t.Fatalf("buildDamagePlan() = %#v, full = %v; want nil, true", got, full)
91- }
92-}
93-
94101 func TestSyncRectRejectsOverflowingBounds(t *testing.T) {
95102 r := New(Capabilities{})
96103 frame := NewFrame(2, 1)
@@ -6,57 +6,85 @@ import (
6 "testing"6 "testing"
7 )7 )
8 8
9-func TestBuildDamagePlanMergesReverseOverlaps(t *testing.T) {9+func TestBuildDamagePlan(t *testing.T) {
10- frame := NewFrame(10, 2)10+ tests := []struct {
11- got, full := buildDamagePlan(frame, []Damage{11+ name string
12- {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1},12+ frame Frame
13- {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1},13+ damage []Damage
14- }, nil)14+ want []damageSpan
15- want := []damageSpan{{y: 0, x: 0, width: 9}}15+ full bool
16- if full || !reflect.DeepEqual(got, want) {16+ }{
17- t.Fatalf("buildDamagePlan() = %#v, full = %v; want %#v, false", got, full, want)17+ {
18+ name: "merges reverse overlaps",
19+ frame: NewFrame(10, 2),
20+ damage: []Damage{
21+ {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1},
22+ {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1},
23+ },
24+ want: []damageSpan{{y: 0, x: 0, width: 9}},
25+ },
26+ {
27+ name: "merges text and clear",
28+ frame: NewFrame(10, 1),
29+ damage: []Damage{
30+ {Kind: DamageClear, X: 5, Y: 0, Width: 4, Height: 1},
31+ {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1},
32+ },
33+ want: []damageSpan{{y: 0, x: 0, width: 9}},
34+ },
35+ {
36+ name: "orders multi-row spans",
37+ frame: NewFrame(10, 3),
38+ damage: []Damage{
39+ {Kind: DamageText, X: 5, Y: 2, Width: 2, Height: 1},
40+ {Kind: DamageText, X: 4, Y: 0, Width: 2, Height: 2},
41+ {Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 2},
42+ {Kind: DamageScrollUp, X: 0, Y: 0, Width: 10, Height: 3, Count: 1},
43+ },
44+ want: []damageSpan{
45+ {y: 0, x: 1, width: 2}, {y: 0, x: 4, width: 2},
46+ {y: 1, x: 1, width: 2}, {y: 1, x: 4, width: 2},
47+ {y: 2, x: 5, width: 2},
48+ },
49+ },
50+ {
51+ name: "clamps rectangles",
52+ frame: NewFrame(10, 3),
53+ damage: []Damage{
54+ {Kind: DamageText, X: -2, Y: -1, Width: 5, Height: 3},
55+ {Kind: DamageClear, X: 8, Y: 1, Width: 5, Height: 3},
56+ },
57+ want: []damageSpan{{y: 0, x: 0, width: 3}, {y: 1, x: 0, width: 3}, {y: 1, x: 8, width: 2}, {y: 2, x: 8, width: 2}},
58+ },
59+ {
60+ name: "does not mutate input",
61+ frame: NewFrame(10, 2),
62+ damage: []Damage{
63+ {Kind: DamageText, X: 5, Y: 1, Width: 2, Height: 1},
64+ {Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 1},
65+ },
66+ want: []damageSpan{{y: 0, x: 1, width: 2}, {y: 1, x: 5, width: 2}},
67+ },
68+ {
69+ name: "exceeding budget requests full redraw",
70+ frame: NewFrame(1, maxPlannedDamageSpans+1),
71+ damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: maxPlannedDamageSpans + 1}},
72+ want: nil,
73+ full: true,
74+ },
18 }75 }
19-}
20-
21-func TestBuildDamagePlanMergesTextAndClear(t *testing.T) {
22- frame := NewFrame(10, 1)
23- got, full := buildDamagePlan(frame, []Damage{
24- {Kind: DamageClear, X: 5, Y: 0, Width: 4, Height: 1},
25- {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1},
26- }, nil)
27- want := []damageSpan{{y: 0, x: 0, width: 9}}
28- if full || !reflect.DeepEqual(got, want) {
29- t.Fatalf("buildDamagePlan() = %#v, full = %v; want %#v, false", got, full, want)
30- }
31-}
32-
33-func TestBuildDamagePlanOrdersMultiRowSpans(t *testing.T) {
34- frame := NewFrame(10, 3)
35- got, full := buildDamagePlan(frame, []Damage{
36- {Kind: DamageText, X: 5, Y: 2, Width: 2, Height: 1},
37- {Kind: DamageText, X: 4, Y: 0, Width: 2, Height: 2},
38- {Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 2},
39- {Kind: DamageScrollUp, X: 0, Y: 0, Width: 10, Height: 3, Count: 1},
40- }, nil)
41- want := []damageSpan{
42- {y: 0, x: 1, width: 2}, {y: 0, x: 4, width: 2},
43- {y: 1, x: 1, width: 2}, {y: 1, x: 4, width: 2},
44- {y: 2, x: 5, width: 2},
45- }
46- if full || !reflect.DeepEqual(got, want) {
47- t.Fatalf("buildDamagePlan() = %#v, full = %v; want %#v, false", got, full, want)
48- }
49-}
50 76
51-func TestBuildDamagePlanClampsRectangles(t *testing.T) {77+ for _, tt := range tests {
52- frame := NewFrame(10, 3)78+ t.Run(tt.name, func(t *testing.T) {
53- got, full := buildDamagePlan(frame, []Damage{79+ original := append([]Damage(nil), tt.damage...)
54- {Kind: DamageText, X: -2, Y: -1, Width: 5, Height: 3},80+ got, full := buildDamagePlan(tt.frame, tt.damage, nil)
55- {Kind: DamageClear, X: 8, Y: 1, Width: 5, Height: 3},81+ if full != tt.full || !reflect.DeepEqual(got, tt.want) {
56- }, nil)82+ t.Fatalf("buildDamagePlan() = %#v, full = %v; want %#v, %v", got, full, tt.want, tt.full)
57- want := []damageSpan{{y: 0, x: 0, width: 3}, {y: 1, x: 0, width: 3}, {y: 1, x: 8, width: 2}, {y: 2, x: 8, width: 2}}83+ }
58- if full || !reflect.DeepEqual(got, want) {84+ if !reflect.DeepEqual(tt.damage, original) {
59- t.Fatalf("buildDamagePlan() = %#v, full = %v; want %#v, false", got, full, want)85+ t.Fatalf("buildDamagePlan mutated damage: got %#v, want %#v", tt.damage, original)
86+ }
87+ })
60 }88 }
61 }89 }
62 90
@@ -70,27 +98,6 @@ func TestClampRectRejectsOverflowingBounds(t *testing.T) {
70 }98 }
71 }99 }
72 100
73-func TestBuildDamagePlanDoesNotMutateInput(t *testing.T) {
74- frame := NewFrame(10, 2)
75- damage := []Damage{
76- {Kind: DamageText, X: 5, Y: 1, Width: 2, Height: 1},
77- {Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 1},
78- }
79- original := append([]Damage(nil), damage...)
80- _, _ = buildDamagePlan(frame, damage, nil)
81- if !reflect.DeepEqual(damage, original) {
82- t.Fatalf("buildDamagePlan mutated damage: got %#v, want %#v", damage, original)
83- }
84-}
85-
86-func TestBuildDamagePlanExceedingBudgetRequestsFullRedraw(t *testing.T) {
87- frame := NewFrame(1, maxPlannedDamageSpans+1)
88- got, full := buildDamagePlan(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: frame.Height}}, nil)
89- if !full || got != nil {
90- t.Fatalf("buildDamagePlan() = %#v, full = %v; want nil, true", got, full)
91- }
92-}
93-
94 func TestSyncRectRejectsOverflowingBounds(t *testing.T) {101 func TestSyncRectRejectsOverflowingBounds(t *testing.T) {
95 r := New(Capabilities{})102 r := New(Capabilities{})
96 frame := NewFrame(2, 1)103 frame := NewFrame(2, 1)
M pkg/renderer/output_min_test.go +34 -0
@@ -2,6 +2,7 @@ package renderer
22
33 import (
44 "bytes"
5+ "strconv"
56 "strings"
67 "testing"
78 )
@@ -429,6 +430,39 @@ func TestCanonicalDamagePreservesWideHeadTracking(t *testing.T) {
429430 }
430431 }
431432
433+func TestScrollPlannerFallbackRedrawsWithoutScrollEscape(t *testing.T) {
434+ r := New(Capabilities{})
435+ base := NewFrame(1, maxPlannedDamageSpans+1)
436+ if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
437+ t.Fatal(err)
438+ }
439+
440+ frame := base.Clone()
441+ frame.ScrollUp(0, frame.Height-1, 1)
442+ frame.Set(0, frame.Height-1, Cell{Rune: 'X', Style: DefaultStyle()})
443+ damage := []Damage{
444+ {Kind: DamageScrollUp, X: 0, Y: 0, Width: frame.Width, Height: frame.Height, Count: 1},
445+ {Kind: DamageText, X: 0, Y: 0, Width: frame.Width, Height: frame.Height},
446+ }
447+ out, err := r.Draw(frame, damage)
448+ if err != nil {
449+ t.Fatal(err)
450+ }
451+ if scrollEscape := "\x1b[1;" + strconv.Itoa(frame.Height) + "r"; strings.Contains(string(out), scrollEscape) {
452+ t.Fatalf("planner fallback emitted scroll escape %q before full redraw: %q", scrollEscape, string(out))
453+ }
454+ if !strings.Contains(string(out), "X") {
455+ t.Fatalf("planner fallback did not redraw the frame: %q", string(out))
456+ }
457+ out, err = r.Draw(frame, nil)
458+ if err != nil {
459+ t.Fatal(err)
460+ }
461+ if len(out) != 0 {
462+ t.Fatalf("planner fallback did not synchronize shadow; follow-up output = %q", string(out))
463+ }
464+}
465+
432466 func TestDamagePlannerFallbackRedrawsAndSynchronizesShadow(t *testing.T) {
433467 r := New(Capabilities{})
434468 frame := NewFrame(1, maxPlannedDamageSpans+1)
@@ -2,6 +2,7 @@ package renderer
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5+ "strconv"
5 "strings"6 "strings"
6 "testing"7 "testing"
7 )8 )
@@ -429,6 +430,39 @@ func TestCanonicalDamagePreservesWideHeadTracking(t *testing.T) {
429 }430 }
430 }431 }
431 432
433+func TestScrollPlannerFallbackRedrawsWithoutScrollEscape(t *testing.T) {
434+ r := New(Capabilities{})
435+ base := NewFrame(1, maxPlannedDamageSpans+1)
436+ if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
437+ t.Fatal(err)
438+ }
439+
440+ frame := base.Clone()
441+ frame.ScrollUp(0, frame.Height-1, 1)
442+ frame.Set(0, frame.Height-1, Cell{Rune: 'X', Style: DefaultStyle()})
443+ damage := []Damage{
444+ {Kind: DamageScrollUp, X: 0, Y: 0, Width: frame.Width, Height: frame.Height, Count: 1},
445+ {Kind: DamageText, X: 0, Y: 0, Width: frame.Width, Height: frame.Height},
446+ }
447+ out, err := r.Draw(frame, damage)
448+ if err != nil {
449+ t.Fatal(err)
450+ }
451+ if scrollEscape := "\x1b[1;" + strconv.Itoa(frame.Height) + "r"; strings.Contains(string(out), scrollEscape) {
452+ t.Fatalf("planner fallback emitted scroll escape %q before full redraw: %q", scrollEscape, string(out))
453+ }
454+ if !strings.Contains(string(out), "X") {
455+ t.Fatalf("planner fallback did not redraw the frame: %q", string(out))
456+ }
457+ out, err = r.Draw(frame, nil)
458+ if err != nil {
459+ t.Fatal(err)
460+ }
461+ if len(out) != 0 {
462+ t.Fatalf("planner fallback did not synchronize shadow; follow-up output = %q", string(out))
463+ }
464+}
465+
432 func TestDamagePlannerFallbackRedrawsAndSynchronizesShadow(t *testing.T) {466 func TestDamagePlannerFallbackRedrawsAndSynchronizesShadow(t *testing.T) {
433 r := New(Capabilities{})467 r := New(Capabilities{})
434 frame := NewFrame(1, maxPlannedDamageSpans+1)468 frame := NewFrame(1, maxPlannedDamageSpans+1)
M pkg/renderer/renderer.go +17 -6
@@ -81,16 +81,23 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
8181 }
8282
8383 if scroll, ok := findSafeScroll(frame, damage); ok && r.canApplyScroll(frame, scroll, damage) {
84+ spans, full := buildDamagePlan(frame, damage, &scroll)
85+ if full {
86+ r.writeFull(buf, frame, &st)
87+ r.advanceShadow(frame)
88+ if r.caps.SynchronizedOutput {
89+ buf.WriteString(SyncEndCSI)
90+ }
91+ return copyBytes(buf), nil
92+ }
93+
8494 emitScrollUp(buf, scroll)
8595 // emitScrollUp resets the SGR pen to default (matching st's initial
8696 // pen) but leaves the cursor wherever the DECSTBM restore put it —
8797 // terminal-dependent, so cursor tracking stays invalidated.
8898 r.applyScroll(scroll)
89- if r.writeDamage(buf, frame, damage, &scroll, &st) {
90- r.advanceShadow(frame)
91- } else {
92- r.advanceDamage(frame, damage, &scroll)
93- }
99+ r.emitDamageSpans(buf, frame, spans, &st)
100+ r.advanceDamage(frame, damage, &scroll)
94101 if r.caps.SynchronizedOutput {
95102 buf.WriteString(SyncEndCSI)
96103 }
@@ -191,11 +198,15 @@ func (r *Renderer) writeDamage(out *bytes.Buffer, frame Frame, damage []Damage,
191198 r.writeFull(out, frame, st)
192199 return true
193200 }
201+ r.emitDamageSpans(out, frame, spans, st)
202+ return false
203+}
204+
205+func (r *Renderer) emitDamageSpans(out *bytes.Buffer, frame Frame, spans []damageSpan, st *drawState) {
194206 for _, span := range spans {
195207 r.emitSpan(out, frame, span.y, span.x, span.width, st)
196208 }
197209 out.WriteString("\x1b[0m")
198- return false
199210 }
200211
201212 func (r *Renderer) lineDirty(frame Frame, y int) bool {
@@ -81,16 +81,23 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
81 }81 }
82 82
83 if scroll, ok := findSafeScroll(frame, damage); ok && r.canApplyScroll(frame, scroll, damage) {83 if scroll, ok := findSafeScroll(frame, damage); ok && r.canApplyScroll(frame, scroll, damage) {
84+ spans, full := buildDamagePlan(frame, damage, &scroll)
85+ if full {
86+ r.writeFull(buf, frame, &st)
87+ r.advanceShadow(frame)
88+ if r.caps.SynchronizedOutput {
89+ buf.WriteString(SyncEndCSI)
90+ }
91+ return copyBytes(buf), nil
92+ }
93+
84 emitScrollUp(buf, scroll)94 emitScrollUp(buf, scroll)
85 // emitScrollUp resets the SGR pen to default (matching st's initial95 // emitScrollUp resets the SGR pen to default (matching st's initial
86 // pen) but leaves the cursor wherever the DECSTBM restore put it —96 // pen) but leaves the cursor wherever the DECSTBM restore put it —
87 // terminal-dependent, so cursor tracking stays invalidated.97 // terminal-dependent, so cursor tracking stays invalidated.
88 r.applyScroll(scroll)98 r.applyScroll(scroll)
89- if r.writeDamage(buf, frame, damage, &scroll, &st) {99+ r.emitDamageSpans(buf, frame, spans, &st)
90- r.advanceShadow(frame)100+ r.advanceDamage(frame, damage, &scroll)
91- } else {
92- r.advanceDamage(frame, damage, &scroll)
93- }
94 if r.caps.SynchronizedOutput {101 if r.caps.SynchronizedOutput {
95 buf.WriteString(SyncEndCSI)102 buf.WriteString(SyncEndCSI)
96 }103 }
@@ -191,11 +198,15 @@ func (r *Renderer) writeDamage(out *bytes.Buffer, frame Frame, damage []Damage,
191 r.writeFull(out, frame, st)198 r.writeFull(out, frame, st)
192 return true199 return true
193 }200 }
201+ r.emitDamageSpans(out, frame, spans, st)
202+ return false
203+}
204+
205+func (r *Renderer) emitDamageSpans(out *bytes.Buffer, frame Frame, spans []damageSpan, st *drawState) {
194 for _, span := range spans {206 for _, span := range spans {
195 r.emitSpan(out, frame, span.y, span.x, span.width, st)207 r.emitSpan(out, frame, span.y, span.x, span.width, st)
196 }208 }
197 out.WriteString("\x1b[0m")209 out.WriteString("\x1b[0m")
198- return false
199 }210 }
200 211
201 func (r *Renderer) lineDirty(frame Frame, y int) bool {212 func (r *Renderer) lineDirty(frame Frame, y int) bool {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close