detached 9454687

fix(render): track windows each target drew and keep overlay refusals apart from scanout

A target records only the windows it drew with their rects, so a window moved to the overlay plane and back, or left out, is redrawn or cleared. Linear filtering pads damage by half the upscale factor. The overlay checks its own plane's IN_FORMATS and caches refusals in a separate field; popups anywhere in the list and bordered windows keep composition; Close turns the overlay off. A logical size change counts as a full change.
commit
94546875409355af178fb7814654c8281032fe04
author
brice · · 2026-09-26 12:13 UTC
parent
6f16509
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +185 -45
M internal/adapters/drm/output.go +1 -0
@@ -608,6 +608,7 @@ func (o *Output) Close() {
608608 if o.cursor != nil {
609609 o.cursor.props(req, o.crtc, cursorState{})
610610 }
611+ o.overlayProps(req, overlayWin{})
611612 req.set(o.crtc, o.vrrProp, 0)
612613 s := o.saved
613614 var blob uint32
@@ -608,6 +608,7 @@ func (o *Output) Close() {
608 if o.cursor != nil {608 if o.cursor != nil {
609 o.cursor.props(req, o.crtc, cursorState{})609 o.cursor.props(req, o.crtc, cursorState{})
610 }610 }
611+ o.overlayProps(req, overlayWin{})
611 req.set(o.crtc, o.vrrProp, 0)612 req.set(o.crtc, o.vrrProp, 0)
612 s := o.saved613 s := o.saved
613 var blob uint32614 var blob uint32
M internal/adapters/drm/overlay.go +12 -4
@@ -65,8 +65,12 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
6565 if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 {
6666 continue
6767 }
68+ if w.Popup {
69+ // Popups draw after every window, wherever they are listed.
70+ return ports.SceneWindow{}, ports.SurfaceContent{}, "window_above"
71+ }
6872 c := surfaces[w.ID]
69- if c.DMABuf != nil && c.Opaque && len(c.Children) == 0 && !w.Popup {
73+ if c.DMABuf != nil && c.Opaque && len(c.Children) == 0 {
7074 pick = w
7175 continue
7276 }
@@ -83,6 +87,10 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
8387 return ports.SceneWindow{}, ports.SurfaceContent{}, "layer_above"
8488 }
8589 }
90+ if !pick.Fullscreen && !pick.Borderless && s.Border.Width > 0 {
91+ // The overlay shows the buffer alone; its border would go with it.
92+ return ports.SceneWindow{}, ports.SurfaceContent{}, "border"
93+ }
8694 c := surfaces[pick.ID]
8795 pw, ph := float64(pick.Rect.W)*scale, float64(pick.Rect.H)*scale
8896 if float64(c.Width) != pw || float64(c.Height) != ph || c.Geometry != (ports.Rect{}) && (c.Geometry.X != 0 || c.Geometry.Y != 0) {
@@ -107,7 +115,7 @@ func (o *Output) overlayFrame(s ports.Scene, surfaces map[ports.WindowID]ports.S
107115 w, c, reason = overlayCandidate(s, surfaces)
108116 if reason == "" {
109117 var fb uint32
110- if fb, reason = o.scanoutFB(c.DMABuf, time.Now()); reason == "" {
118+ if fb, reason = o.overlayFB(c.DMABuf, time.Now()); reason == "" {
111119 scale := s.Scale
112120 if scale <= 0 {
113121 scale = 1
@@ -173,7 +181,7 @@ func (o *Output) testOverlay(fb uint32, ov overlayWin) bool {
173181 return true
174182 }
175183 if cfb := o.clientFBs[ov.buf]; cfb != nil && refused(err) {
176- cfb.failed = "overlay_refused"
184+ cfb.overlayFailed = "overlay_refused"
177185 }
178186 o.log.Info().Str("component", "render").Err(err).Str("connector", o.conn.name).Msg("overlay refused")
179187 return false
@@ -191,7 +199,7 @@ func (o *Output) overlayConflict(err error, buf uint64) bool {
191199 return false
192200 }
193201 if cfb := o.clientFBs[buf]; cfb != nil {
194- cfb.failed = "cursor_conflict"
202+ cfb.overlayFailed = "cursor_conflict"
195203 }
196204 o.log.Info().Str("component", "render").Bool("overlay", false).Str("reason", "cursor_conflict").Str("connector", o.conn.name).Msg("overlay")
197205 o.overlayReason = "cursor_conflict"
@@ -65,8 +65,12 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
65 if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 {65 if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 {
66 continue66 continue
67 }67 }
68+ if w.Popup {
69+ // Popups draw after every window, wherever they are listed.
70+ return ports.SceneWindow{}, ports.SurfaceContent{}, "window_above"
71+ }
68 c := surfaces[w.ID]72 c := surfaces[w.ID]
69- if c.DMABuf != nil && c.Opaque && len(c.Children) == 0 && !w.Popup {73+ if c.DMABuf != nil && c.Opaque && len(c.Children) == 0 {
70 pick = w74 pick = w
71 continue75 continue
72 }76 }
@@ -83,6 +87,10 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
83 return ports.SceneWindow{}, ports.SurfaceContent{}, "layer_above"87 return ports.SceneWindow{}, ports.SurfaceContent{}, "layer_above"
84 }88 }
85 }89 }
90+ if !pick.Fullscreen && !pick.Borderless && s.Border.Width > 0 {
91+ // The overlay shows the buffer alone; its border would go with it.
92+ return ports.SceneWindow{}, ports.SurfaceContent{}, "border"
93+ }
86 c := surfaces[pick.ID]94 c := surfaces[pick.ID]
87 pw, ph := float64(pick.Rect.W)*scale, float64(pick.Rect.H)*scale95 pw, ph := float64(pick.Rect.W)*scale, float64(pick.Rect.H)*scale
88 if float64(c.Width) != pw || float64(c.Height) != ph || c.Geometry != (ports.Rect{}) && (c.Geometry.X != 0 || c.Geometry.Y != 0) {96 if float64(c.Width) != pw || float64(c.Height) != ph || c.Geometry != (ports.Rect{}) && (c.Geometry.X != 0 || c.Geometry.Y != 0) {
@@ -107,7 +115,7 @@ func (o *Output) overlayFrame(s ports.Scene, surfaces map[ports.WindowID]ports.S
107 w, c, reason = overlayCandidate(s, surfaces)115 w, c, reason = overlayCandidate(s, surfaces)
108 if reason == "" {116 if reason == "" {
109 var fb uint32117 var fb uint32
110- if fb, reason = o.scanoutFB(c.DMABuf, time.Now()); reason == "" {118+ if fb, reason = o.overlayFB(c.DMABuf, time.Now()); reason == "" {
111 scale := s.Scale119 scale := s.Scale
112 if scale <= 0 {120 if scale <= 0 {
113 scale = 1121 scale = 1
@@ -173,7 +181,7 @@ func (o *Output) testOverlay(fb uint32, ov overlayWin) bool {
173 return true181 return true
174 }182 }
175 if cfb := o.clientFBs[ov.buf]; cfb != nil && refused(err) {183 if cfb := o.clientFBs[ov.buf]; cfb != nil && refused(err) {
176- cfb.failed = "overlay_refused"184+ cfb.overlayFailed = "overlay_refused"
177 }185 }
178 o.log.Info().Str("component", "render").Err(err).Str("connector", o.conn.name).Msg("overlay refused")186 o.log.Info().Str("component", "render").Err(err).Str("connector", o.conn.name).Msg("overlay refused")
179 return false187 return false
@@ -191,7 +199,7 @@ func (o *Output) overlayConflict(err error, buf uint64) bool {
191 return false199 return false
192 }200 }
193 if cfb := o.clientFBs[buf]; cfb != nil {201 if cfb := o.clientFBs[buf]; cfb != nil {
194- cfb.failed = "cursor_conflict"202+ cfb.overlayFailed = "cursor_conflict"
195 }203 }
196 o.log.Info().Str("component", "render").Bool("overlay", false).Str("reason", "cursor_conflict").Str("connector", o.conn.name).Msg("overlay")204 o.log.Info().Str("component", "render").Bool("overlay", false).Str("reason", "cursor_conflict").Str("connector", o.conn.name).Msg("overlay")
197 o.overlayReason = "cursor_conflict"205 o.overlayReason = "cursor_conflict"
M internal/adapters/drm/overlay_test.go +25 -3
@@ -2,6 +2,7 @@ package drm
22
33 import (
44 "testing"
5+ "time"
56
67 "github.com/bnema/nefertty/internal/ports"
78 "github.com/stretchr/testify/mock"
@@ -19,6 +20,7 @@ func overlayOutput(t *testing.T, errs ...error) (*Output, *mockkms, *[]commitRec
1920 }
2021 o.primary.props = props
2122 o.stray = []*plane{{id: tOverlay, typ: planeOverlay, props: props, zposValue: 2}}
23+ o.primary.formats = nil // the overlay's own formats decide
2224 o.pickOverlay()
2325 if o.overlay == nil {
2426 t.Fatal("no overlay picked")
@@ -63,7 +65,7 @@ func TestOverlayCandidate(t *testing.T) {
6365 func TestOverlayChosen(t *testing.T) {
6466 o, k, commits := overlayOutput(t)
6567 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Once()
66- o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
68+ o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
6769 s, c := overlayScene()
6870 ov, rest := o.overlayFrame(s, c)
6971 if ov.fb != 88 || len(rest.Windows) != 1 || rest.Windows[0].ID != 1 {
@@ -91,7 +93,7 @@ func TestOverlayChosen(t *testing.T) {
9193 func TestOverlayTestRefusedFallsBack(t *testing.T) {
9294 o, k, _ := overlayOutput(t, unix.EINVAL)
9395 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Once()
94- o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
96+ o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
9597 s, c := overlayScene()
9698 ov, _ := o.overlayFrame(s, c)
9799 if o.testOverlay(70, ov) {
@@ -100,6 +102,26 @@ func TestOverlayTestRefusedFallsBack(t *testing.T) {
100102 if ov2, rest := o.overlayFrame(s, c); ov2.fb != 0 || len(rest.Windows) != 2 {
101103 t.Fatal("refused buffer tried again")
102104 }
105+ // The overlay's refusal does not bar direct scanout of the buffer.
106+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
107+ if fb, reason := o.scanoutFB(c[2].DMABuf, time.Now()); fb != 88 || reason != "" {
108+ t.Fatalf("scanout after overlay refusal: %d %q", fb, reason)
109+ }
110+}
111+
112+// Popups are drawn above every window: one listed first still blocks the
113+// overlay; a bordered window does too.
114+func TestOverlayPopupAndBorder(t *testing.T) {
115+ s, c := overlayScene()
116+ s.Windows = append([]ports.SceneWindow{{ID: 7, Popup: true, Rect: ports.Rect{W: 5, H: 5}}}, s.Windows...)
117+ if _, _, reason := overlayCandidate(s, c); reason != "window_above" {
118+ t.Fatalf("popup: %q", reason)
119+ }
120+ s, _ = overlayScene()
121+ s.Border.Width = 2
122+ if _, _, reason := overlayCandidate(s, c); reason != "border" {
123+ t.Fatalf("border: %q", reason)
124+ }
103125 }
104126
105127 // A cursor commit refused with the overlay on drops the overlay: the
@@ -107,7 +129,7 @@ func TestOverlayTestRefusedFallsBack(t *testing.T) {
107129 func TestOverlayCursorConflictDropsOverlay(t *testing.T) {
108130 o, k, _ := overlayOutput(t, nil, unix.EINVAL)
109131 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Once()
110- o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
132+ o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
111133 s, c := overlayScene()
112134 ov, _ := o.overlayFrame(s, c)
113135 if err := o.commitWith(70, nil, false, false, pendingFrame{}, ov); err != nil {
@@ -2,6 +2,7 @@ package drm
2 2
3 import (3 import (
4 "testing"4 "testing"
5+ "time"
5 6
6 "github.com/bnema/nefertty/internal/ports"7 "github.com/bnema/nefertty/internal/ports"
7 "github.com/stretchr/testify/mock"8 "github.com/stretchr/testify/mock"
@@ -19,6 +20,7 @@ func overlayOutput(t *testing.T, errs ...error) (*Output, *mockkms, *[]commitRec
19 }20 }
20 o.primary.props = props21 o.primary.props = props
21 o.stray = []*plane{{id: tOverlay, typ: planeOverlay, props: props, zposValue: 2}}22 o.stray = []*plane{{id: tOverlay, typ: planeOverlay, props: props, zposValue: 2}}
23+ o.primary.formats = nil // the overlay's own formats decide
22 o.pickOverlay()24 o.pickOverlay()
23 if o.overlay == nil {25 if o.overlay == nil {
24 t.Fatal("no overlay picked")26 t.Fatal("no overlay picked")
@@ -63,7 +65,7 @@ func TestOverlayCandidate(t *testing.T) {
63 func TestOverlayChosen(t *testing.T) {65 func TestOverlayChosen(t *testing.T) {
64 o, k, commits := overlayOutput(t)66 o, k, commits := overlayOutput(t)
65 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Once()67 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Once()
66- o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB}}68+ o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
67 s, c := overlayScene()69 s, c := overlayScene()
68 ov, rest := o.overlayFrame(s, c)70 ov, rest := o.overlayFrame(s, c)
69 if ov.fb != 88 || len(rest.Windows) != 1 || rest.Windows[0].ID != 1 {71 if ov.fb != 88 || len(rest.Windows) != 1 || rest.Windows[0].ID != 1 {
@@ -91,7 +93,7 @@ func TestOverlayChosen(t *testing.T) {
91 func TestOverlayTestRefusedFallsBack(t *testing.T) {93 func TestOverlayTestRefusedFallsBack(t *testing.T) {
92 o, k, _ := overlayOutput(t, unix.EINVAL)94 o, k, _ := overlayOutput(t, unix.EINVAL)
93 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Once()95 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Once()
94- o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB}}96+ o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
95 s, c := overlayScene()97 s, c := overlayScene()
96 ov, _ := o.overlayFrame(s, c)98 ov, _ := o.overlayFrame(s, c)
97 if o.testOverlay(70, ov) {99 if o.testOverlay(70, ov) {
@@ -100,6 +102,26 @@ func TestOverlayTestRefusedFallsBack(t *testing.T) {
100 if ov2, rest := o.overlayFrame(s, c); ov2.fb != 0 || len(rest.Windows) != 2 {102 if ov2, rest := o.overlayFrame(s, c); ov2.fb != 0 || len(rest.Windows) != 2 {
101 t.Fatal("refused buffer tried again")103 t.Fatal("refused buffer tried again")
102 }104 }
105+ // The overlay's refusal does not bar direct scanout of the buffer.
106+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
107+ if fb, reason := o.scanoutFB(c[2].DMABuf, time.Now()); fb != 88 || reason != "" {
108+ t.Fatalf("scanout after overlay refusal: %d %q", fb, reason)
109+ }
110+}
111+
112+// Popups are drawn above every window: one listed first still blocks the
113+// overlay; a bordered window does too.
114+func TestOverlayPopupAndBorder(t *testing.T) {
115+ s, c := overlayScene()
116+ s.Windows = append([]ports.SceneWindow{{ID: 7, Popup: true, Rect: ports.Rect{W: 5, H: 5}}}, s.Windows...)
117+ if _, _, reason := overlayCandidate(s, c); reason != "window_above" {
118+ t.Fatalf("popup: %q", reason)
119+ }
120+ s, _ = overlayScene()
121+ s.Border.Width = 2
122+ if _, _, reason := overlayCandidate(s, c); reason != "border" {
123+ t.Fatalf("border: %q", reason)
124+ }
103 }125 }
104 126
105 // A cursor commit refused with the overlay on drops the overlay: the127 // A cursor commit refused with the overlay on drops the overlay: the
@@ -107,7 +129,7 @@ func TestOverlayTestRefusedFallsBack(t *testing.T) {
107 func TestOverlayCursorConflictDropsOverlay(t *testing.T) {129 func TestOverlayCursorConflictDropsOverlay(t *testing.T) {
108 o, k, _ := overlayOutput(t, nil, unix.EINVAL)130 o, k, _ := overlayOutput(t, nil, unix.EINVAL)
109 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Once()131 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Once()
110- o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB}}132+ o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
111 s, c := overlayScene()133 s, c := overlayScene()
112 ov, _ := o.overlayFrame(s, c)134 ov, _ := o.overlayFrame(s, c)
113 if err := o.commitWith(70, nil, false, false, pendingFrame{}, ov); err != nil {135 if err := o.commitWith(70, nil, false, false, pendingFrame{}, ov); err != nil {
M internal/adapters/drm/scanout.go +52 -22
@@ -91,39 +91,69 @@ type clientFB struct {
9191 failed string
9292 // noAsync: KMS refused an async flip to it; it flips at vblank.
9393 noAsync bool
94+ // overlayFailed is why the overlay plane refused it; kept apart from
95+ // failed, so a refusal on one plane does not bar the other.
96+ overlayFailed string
97+ importErr bool
9498 }
9599
96-// scanoutFB returns the framebuffer of a client buffer, importing it on
97-// first use. A buffer KMS refuses is remembered with its reason.
100+// scanoutFB returns the framebuffer of a client buffer for the primary
101+// plane, importing it on first use. A buffer KMS refuses is remembered
102+// with its reason.
98103 func (o *Output) scanoutFB(b *ports.DMABuf, now time.Time) (uint32, string) {
99- if fb := o.clientFBs[b.ID]; fb != nil {
100- fb.last = now
101- return fb.fbID, fb.failed
104+ return o.planeFB(o.primary, b, now, func(fb *clientFB) *string { return &fb.failed })
105+}
106+
107+// overlayFB is scanoutFB for the overlay plane, with its own formats and
108+// refusal reason.
109+func (o *Output) overlayFB(b *ports.DMABuf, now time.Time) (uint32, string) {
110+ return o.planeFB(o.overlay, b, now, func(fb *clientFB) *string { return &fb.overlayFailed })
111+}
112+
113+// planeFB imports b once as a framebuffer; reason is the plane's refusal
114+// field. Both planes share the framebuffer: they show the same format.
115+func (o *Output) planeFB(p *plane, b *ports.DMABuf, now time.Time, reason func(*clientFB) *string) (uint32, string) {
116+ fb := o.clientFBs[b.ID]
117+ if fb == nil {
118+ fb = &clientFB{}
119+ o.clientFBs[b.ID] = fb
120+ }
121+ fb.last = now
122+ why := reason(fb)
123+ if *why != "" {
124+ return 0, *why
102125 }
103- fb := &clientFB{last: now}
104- o.clientFBs[b.ID] = fb
105- format, ok := o.scanoutFormat(b)
126+ format, ok := planeFormat(p, b)
106127 if !ok {
107- fb.failed = "format"
108- return 0, fb.failed
128+ *why = "format"
129+ return 0, *why
109130 }
110- id, err := o.k.addFB(b, format)
111- if err != nil {
112- o.log.Info().Err(err).Uint32("format", b.Format).Uint64("modifier", b.Modifier).Msg("scanout import failed")
113- fb.failed = "import_failed"
114- return 0, fb.failed
131+ if fb.fbID == 0 {
132+ if fb.importErr {
133+ *why = "import_failed"
134+ return 0, *why
135+ }
136+ id, err := o.k.addFB(b, format)
137+ if err != nil {
138+ o.log.Info().Err(err).Uint32("format", b.Format).Uint64("modifier", b.Modifier).Msg("scanout import failed")
139+ fb.importErr, *why = true, "import_failed"
140+ return 0, *why
141+ }
142+ fb.fbID = id
115143 }
116- fb.fbID = id
117- return id, ""
144+ return fb.fbID, ""
118145 }
119146
120147 // scanoutFormat is the framebuffer format a client buffer is scanned out
121-// as, when the primary plane lists it with the buffer's modifier. The
122-// primary plane shows no alpha: an alpha format goes as its X variant
123-// when the plane has that one.
124-func (o *Output) scanoutFormat(b *ports.DMABuf) (uint32, bool) {
148+// as on the primary plane (see planeFormat).
149+func (o *Output) scanoutFormat(b *ports.DMABuf) (uint32, bool) { return planeFormat(o.primary, b) }
150+
151+// planeFormat is the framebuffer format a client buffer shows as on p,
152+// when p lists it with the buffer's modifier. Planes here show no alpha:
153+// an alpha format goes as its X variant when the plane has that one.
154+func planeFormat(p *plane, b *ports.DMABuf) (uint32, bool) {
125155 has := func(f uint32) bool {
126- return slices.Contains(o.primary.formats, ports.DMABufFormat{Format: f, Modifier: b.Modifier})
156+ return slices.Contains(p.formats, ports.DMABufFormat{Format: f, Modifier: b.Modifier})
127157 }
128158 if x, ok := opaqueVariant[b.Format]; ok && has(x) {
129159 return x, true
@@ -91,39 +91,69 @@ type clientFB struct {
91 failed string91 failed string
92 // noAsync: KMS refused an async flip to it; it flips at vblank.92 // noAsync: KMS refused an async flip to it; it flips at vblank.
93 noAsync bool93 noAsync bool
94+ // overlayFailed is why the overlay plane refused it; kept apart from
95+ // failed, so a refusal on one plane does not bar the other.
96+ overlayFailed string
97+ importErr bool
94 }98 }
95 99
96-// scanoutFB returns the framebuffer of a client buffer, importing it on100+// scanoutFB returns the framebuffer of a client buffer for the primary
97-// first use. A buffer KMS refuses is remembered with its reason.101+// plane, importing it on first use. A buffer KMS refuses is remembered
102+// with its reason.
98 func (o *Output) scanoutFB(b *ports.DMABuf, now time.Time) (uint32, string) {103 func (o *Output) scanoutFB(b *ports.DMABuf, now time.Time) (uint32, string) {
99- if fb := o.clientFBs[b.ID]; fb != nil {104+ return o.planeFB(o.primary, b, now, func(fb *clientFB) *string { return &fb.failed })
100- fb.last = now105+}
101- return fb.fbID, fb.failed106+
107+// overlayFB is scanoutFB for the overlay plane, with its own formats and
108+// refusal reason.
109+func (o *Output) overlayFB(b *ports.DMABuf, now time.Time) (uint32, string) {
110+ return o.planeFB(o.overlay, b, now, func(fb *clientFB) *string { return &fb.overlayFailed })
111+}
112+
113+// planeFB imports b once as a framebuffer; reason is the plane's refusal
114+// field. Both planes share the framebuffer: they show the same format.
115+func (o *Output) planeFB(p *plane, b *ports.DMABuf, now time.Time, reason func(*clientFB) *string) (uint32, string) {
116+ fb := o.clientFBs[b.ID]
117+ if fb == nil {
118+ fb = &clientFB{}
119+ o.clientFBs[b.ID] = fb
120+ }
121+ fb.last = now
122+ why := reason(fb)
123+ if *why != "" {
124+ return 0, *why
102 }125 }
103- fb := &clientFB{last: now}126+ format, ok := planeFormat(p, b)
104- o.clientFBs[b.ID] = fb
105- format, ok := o.scanoutFormat(b)
106 if !ok {127 if !ok {
107- fb.failed = "format"128+ *why = "format"
108- return 0, fb.failed129+ return 0, *why
109 }130 }
110- id, err := o.k.addFB(b, format)131+ if fb.fbID == 0 {
111- if err != nil {132+ if fb.importErr {
112- o.log.Info().Err(err).Uint32("format", b.Format).Uint64("modifier", b.Modifier).Msg("scanout import failed")133+ *why = "import_failed"
113- fb.failed = "import_failed"134+ return 0, *why
114- return 0, fb.failed135+ }
136+ id, err := o.k.addFB(b, format)
137+ if err != nil {
138+ o.log.Info().Err(err).Uint32("format", b.Format).Uint64("modifier", b.Modifier).Msg("scanout import failed")
139+ fb.importErr, *why = true, "import_failed"
140+ return 0, *why
141+ }
142+ fb.fbID = id
115 }143 }
116- fb.fbID = id144+ return fb.fbID, ""
117- return id, ""
118 }145 }
119 146
120 // scanoutFormat is the framebuffer format a client buffer is scanned out147 // scanoutFormat is the framebuffer format a client buffer is scanned out
121-// as, when the primary plane lists it with the buffer's modifier. The148+// as on the primary plane (see planeFormat).
122-// primary plane shows no alpha: an alpha format goes as its X variant149+func (o *Output) scanoutFormat(b *ports.DMABuf) (uint32, bool) { return planeFormat(o.primary, b) }
123-// when the plane has that one.150+
124-func (o *Output) scanoutFormat(b *ports.DMABuf) (uint32, bool) {151+// planeFormat is the framebuffer format a client buffer shows as on p,
152+// when p lists it with the buffer's modifier. Planes here show no alpha:
153+// an alpha format goes as its X variant when the plane has that one.
154+func planeFormat(p *plane, b *ports.DMABuf) (uint32, bool) {
125 has := func(f uint32) bool {155 has := func(f uint32) bool {
126- return slices.Contains(o.primary.formats, ports.DMABufFormat{Format: f, Modifier: b.Modifier})156+ return slices.Contains(p.formats, ports.DMABufFormat{Format: f, Modifier: b.Modifier})
127 }157 }
128 if x, ok := opaqueVariant[b.Format]; ok && has(x) {158 if x, ok := opaqueVariant[b.Format]; ok && has(x) {
129 return x, true159 return x, true
M internal/adapters/vulkan/damage.go +42 -13
@@ -2,6 +2,7 @@ package vulkan
22
33 import (
44 "image"
5+ "math"
56
67 "github.com/bnema/nefertty/internal/ports"
78 )
@@ -12,16 +13,25 @@ import (
1213 // keeps the rest of the image. A changed scene, a target never drawn or
1314 // a history too short redraws everything.
1415
15-// damageRegion collects the region of a target to redraw this frame.
16+// damageRegion collects the region of a target to redraw this frame and
17+// what the target will hold after it: the windows drawn, their content
18+// Seq and rect.
1619 type damageRegion struct {
1720 held *target // nil: redraw everything
1821 area image.Rectangle
1922 seen map[ports.WindowID]bool
2023 bound image.Rectangle
24+ drawn map[ports.WindowID]heldWindow
25+}
26+
27+// heldWindow is a window a target holds: its content Seq and rect.
28+type heldWindow struct {
29+ seq uint64
30+ rect image.Rectangle
2131 }
2232
2333 func newDamage(tg *target, s ports.Scene, bounds image.Rectangle) *damageRegion {
24- d := &damageRegion{seen: map[ports.WindowID]bool{}, bound: bounds}
34+ d := &damageRegion{seen: map[ports.WindowID]bool{}, bound: bounds, drawn: map[ports.WindowID]heldWindow{}}
2535 // Seq 0 is a scene core did not number (setup, tests): redraw all.
2636 if tg.valid && s.Seq != 0 && tg.sceneSeq == s.Seq {
2737 d.held = tg
@@ -41,11 +51,16 @@ func (d *damageRegion) add(r image.Rectangle) {
4151 // window records a window drawn in rect (physical): if its content changed
4252 // and no finer damage comes (content), the whole rect is redrawn.
4353 func (d *damageRegion) window(id ports.WindowID, c ports.SurfaceContent, rect image.Rectangle) {
44- if d.held == nil || d.seen[id] {
54+ if d.seen[id] {
4555 return
4656 }
4757 d.seen[id] = true
48- held, ok := d.held.seqs[id]
58+ d.drawn[id] = heldWindow{seq: c.Seq, rect: rect}
59+ if d.held == nil {
60+ return
61+ }
62+ hw, ok := d.held.windows[id]
63+ held := hw.seq
4964 if ok && held == c.Seq {
5065 return
5166 }
@@ -69,7 +84,8 @@ func (d *damageRegion) content(id ports.WindowID, c *ports.SurfaceContent, full,
6984 if d.held == nil {
7085 return
7186 }
72- held, ok := d.held.seqs[id]
87+ hw, ok := d.held.windows[id]
88+ held := hw.seq
7389 if !ok || held == c.Seq || len(c.Children) > 0 {
7490 return
7591 }
@@ -77,9 +93,12 @@ func (d *damageRegion) content(id ports.WindowID, c *ports.SurfaceContent, full,
7793 if !known || c.Width <= 0 || c.Height <= 0 {
7894 return
7995 }
96+ // Linear filtering spreads a texel over half a buffer pixel around
97+ // it: scale/2 target pixels, plus one for rounding.
8098 pad := 0
8199 if full.Dx() != c.Width || full.Dy() != c.Height {
82- pad = 1
100+ s := max(float64(full.Dx())/float64(c.Width), float64(full.Dy())/float64(c.Height))
101+ pad = int(math.Ceil(s/2)) + 1
83102 }
84103 for _, r := range rects {
85104 x0 := full.Min.X + r.X*full.Dx()/c.Width - pad
@@ -90,6 +109,19 @@ func (d *damageRegion) content(id ports.WindowID, c *ports.SurfaceContent, full,
90109 }
91110 }
92111
112+// finish adds the rects of windows the target holds that this frame does
113+// not draw (e.g. a window that moved to an overlay plane and back).
114+func (d *damageRegion) finish() {
115+ if d.held == nil {
116+ return
117+ }
118+ for id, hw := range d.held.windows {
119+ if _, ok := d.drawn[id]; !ok {
120+ d.add(hw.rect)
121+ }
122+ }
123+}
124+
93125 // clip limits the draws to the region; draws outside it are dropped.
94126 func (d *damageRegion) clip(ds []draw) []draw {
95127 if d.held == nil {
@@ -107,11 +139,8 @@ func (d *damageRegion) clip(ds []draw) []draw {
107139 return out
108140 }
109141
110-// hold records what the target holds after this frame.
111-func (tg *target) hold(s ports.Scene, contents map[ports.WindowID]ports.SurfaceContent) {
112- tg.valid, tg.sceneSeq = true, s.Seq
113- tg.seqs = make(map[ports.WindowID]uint64, len(contents))
114- for id, c := range contents {
115- tg.seqs[id] = c.Seq
116- }
142+// hold records what the target holds after this frame: only the windows
143+// it drew.
144+func (tg *target) hold(s ports.Scene, d *damageRegion) {
145+ tg.valid, tg.sceneSeq, tg.windows = true, s.Seq, d.drawn
117146 }
@@ -2,6 +2,7 @@ package vulkan
2 2
3 import (3 import (
4 "image"4 "image"
5+ "math"
5 6
6 "github.com/bnema/nefertty/internal/ports"7 "github.com/bnema/nefertty/internal/ports"
7 )8 )
@@ -12,16 +13,25 @@ import (
12 // keeps the rest of the image. A changed scene, a target never drawn or13 // keeps the rest of the image. A changed scene, a target never drawn or
13 // a history too short redraws everything.14 // a history too short redraws everything.
14 15
15-// damageRegion collects the region of a target to redraw this frame.16+// damageRegion collects the region of a target to redraw this frame and
17+// what the target will hold after it: the windows drawn, their content
18+// Seq and rect.
16 type damageRegion struct {19 type damageRegion struct {
17 held *target // nil: redraw everything20 held *target // nil: redraw everything
18 area image.Rectangle21 area image.Rectangle
19 seen map[ports.WindowID]bool22 seen map[ports.WindowID]bool
20 bound image.Rectangle23 bound image.Rectangle
24+ drawn map[ports.WindowID]heldWindow
25+}
26+
27+// heldWindow is a window a target holds: its content Seq and rect.
28+type heldWindow struct {
29+ seq uint64
30+ rect image.Rectangle
21 }31 }
22 32
23 func newDamage(tg *target, s ports.Scene, bounds image.Rectangle) *damageRegion {33 func newDamage(tg *target, s ports.Scene, bounds image.Rectangle) *damageRegion {
24- d := &damageRegion{seen: map[ports.WindowID]bool{}, bound: bounds}34+ d := &damageRegion{seen: map[ports.WindowID]bool{}, bound: bounds, drawn: map[ports.WindowID]heldWindow{}}
25 // Seq 0 is a scene core did not number (setup, tests): redraw all.35 // Seq 0 is a scene core did not number (setup, tests): redraw all.
26 if tg.valid && s.Seq != 0 && tg.sceneSeq == s.Seq {36 if tg.valid && s.Seq != 0 && tg.sceneSeq == s.Seq {
27 d.held = tg37 d.held = tg
@@ -41,11 +51,16 @@ func (d *damageRegion) add(r image.Rectangle) {
41 // window records a window drawn in rect (physical): if its content changed51 // window records a window drawn in rect (physical): if its content changed
42 // and no finer damage comes (content), the whole rect is redrawn.52 // and no finer damage comes (content), the whole rect is redrawn.
43 func (d *damageRegion) window(id ports.WindowID, c ports.SurfaceContent, rect image.Rectangle) {53 func (d *damageRegion) window(id ports.WindowID, c ports.SurfaceContent, rect image.Rectangle) {
44- if d.held == nil || d.seen[id] {54+ if d.seen[id] {
45 return55 return
46 }56 }
47 d.seen[id] = true57 d.seen[id] = true
48- held, ok := d.held.seqs[id]58+ d.drawn[id] = heldWindow{seq: c.Seq, rect: rect}
59+ if d.held == nil {
60+ return
61+ }
62+ hw, ok := d.held.windows[id]
63+ held := hw.seq
49 if ok && held == c.Seq {64 if ok && held == c.Seq {
50 return65 return
51 }66 }
@@ -69,7 +84,8 @@ func (d *damageRegion) content(id ports.WindowID, c *ports.SurfaceContent, full,
69 if d.held == nil {84 if d.held == nil {
70 return85 return
71 }86 }
72- held, ok := d.held.seqs[id]87+ hw, ok := d.held.windows[id]
88+ held := hw.seq
73 if !ok || held == c.Seq || len(c.Children) > 0 {89 if !ok || held == c.Seq || len(c.Children) > 0 {
74 return90 return
75 }91 }
@@ -77,9 +93,12 @@ func (d *damageRegion) content(id ports.WindowID, c *ports.SurfaceContent, full,
77 if !known || c.Width <= 0 || c.Height <= 0 {93 if !known || c.Width <= 0 || c.Height <= 0 {
78 return94 return
79 }95 }
96+ // Linear filtering spreads a texel over half a buffer pixel around
97+ // it: scale/2 target pixels, plus one for rounding.
80 pad := 098 pad := 0
81 if full.Dx() != c.Width || full.Dy() != c.Height {99 if full.Dx() != c.Width || full.Dy() != c.Height {
82- pad = 1100+ s := max(float64(full.Dx())/float64(c.Width), float64(full.Dy())/float64(c.Height))
101+ pad = int(math.Ceil(s/2)) + 1
83 }102 }
84 for _, r := range rects {103 for _, r := range rects {
85 x0 := full.Min.X + r.X*full.Dx()/c.Width - pad104 x0 := full.Min.X + r.X*full.Dx()/c.Width - pad
@@ -90,6 +109,19 @@ func (d *damageRegion) content(id ports.WindowID, c *ports.SurfaceContent, full,
90 }109 }
91 }110 }
92 111
112+// finish adds the rects of windows the target holds that this frame does
113+// not draw (e.g. a window that moved to an overlay plane and back).
114+func (d *damageRegion) finish() {
115+ if d.held == nil {
116+ return
117+ }
118+ for id, hw := range d.held.windows {
119+ if _, ok := d.drawn[id]; !ok {
120+ d.add(hw.rect)
121+ }
122+ }
123+}
124+
93 // clip limits the draws to the region; draws outside it are dropped.125 // clip limits the draws to the region; draws outside it are dropped.
94 func (d *damageRegion) clip(ds []draw) []draw {126 func (d *damageRegion) clip(ds []draw) []draw {
95 if d.held == nil {127 if d.held == nil {
@@ -107,11 +139,8 @@ func (d *damageRegion) clip(ds []draw) []draw {
107 return out139 return out
108 }140 }
109 141
110-// hold records what the target holds after this frame.142+// hold records what the target holds after this frame: only the windows
111-func (tg *target) hold(s ports.Scene, contents map[ports.WindowID]ports.SurfaceContent) {143+// it drew.
112- tg.valid, tg.sceneSeq = true, s.Seq144+func (tg *target) hold(s ports.Scene, d *damageRegion) {
113- tg.seqs = make(map[ports.WindowID]uint64, len(contents))145+ tg.valid, tg.sceneSeq, tg.windows = true, s.Seq, d.drawn
114- for id, c := range contents {
115- tg.seqs[id] = c.Seq
116- }
117 }146 }
M internal/adapters/vulkan/damage_test.go +38 -0
@@ -96,3 +96,41 @@ func TestRendererDamageMatchesFullRedraw(t *testing.T) {
9696 step(3, c)
9797 step(4, c)
9898 }
99+
100+// A window left out of a frame (on an overlay plane) and drawn again in
101+// the same scene is redrawn whole; one held but no longer drawn is
102+// cleared.
103+func TestRendererDamageWindowLeftOutAndBack(t *testing.T) {
104+ newR := func() *Renderer {
105+ r, err := New(64, 48)
106+ if err != nil {
107+ t.Skipf("Vulkan unavailable: %v", err)
108+ }
109+ t.Cleanup(r.Close)
110+ return r
111+ }
112+ damaged, full := newR(), newR()
113+ c := solidContent(t, 16, 16, color.RGBA{200, 10, 10, 255})
114+ c.ID, c.Seq = 1, 1
115+ contents := map[ports.WindowID]ports.SurfaceContent{1: c}
116+ with := ports.Scene{Seq: 9, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: 16, H: 16}, Borderless: true}}}
117+ without := with
118+ without.Windows = nil
119+ check := func(s ports.Scene, what string) {
120+ t.Helper()
121+ if err := render(damaged, s, contents); err != nil {
122+ t.Fatal(err)
123+ }
124+ plain := s
125+ plain.Seq = 0
126+ if err := render(full, plain, contents); err != nil {
127+ t.Fatal(err)
128+ }
129+ if !bytes.Equal(damaged.Pixels().Pix, full.Pixels().Pix) {
130+ t.Fatalf("%s differs from a full redraw", what)
131+ }
132+ }
133+ check(without, "overlay frame")
134+ check(with, "overlay refused, window composed")
135+ check(without, "window on the overlay again")
136+}
@@ -96,3 +96,41 @@ func TestRendererDamageMatchesFullRedraw(t *testing.T) {
96 step(3, c)96 step(3, c)
97 step(4, c)97 step(4, c)
98 }98 }
99+
100+// A window left out of a frame (on an overlay plane) and drawn again in
101+// the same scene is redrawn whole; one held but no longer drawn is
102+// cleared.
103+func TestRendererDamageWindowLeftOutAndBack(t *testing.T) {
104+ newR := func() *Renderer {
105+ r, err := New(64, 48)
106+ if err != nil {
107+ t.Skipf("Vulkan unavailable: %v", err)
108+ }
109+ t.Cleanup(r.Close)
110+ return r
111+ }
112+ damaged, full := newR(), newR()
113+ c := solidContent(t, 16, 16, color.RGBA{200, 10, 10, 255})
114+ c.ID, c.Seq = 1, 1
115+ contents := map[ports.WindowID]ports.SurfaceContent{1: c}
116+ with := ports.Scene{Seq: 9, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: 16, H: 16}, Borderless: true}}}
117+ without := with
118+ without.Windows = nil
119+ check := func(s ports.Scene, what string) {
120+ t.Helper()
121+ if err := render(damaged, s, contents); err != nil {
122+ t.Fatal(err)
123+ }
124+ plain := s
125+ plain.Seq = 0
126+ if err := render(full, plain, contents); err != nil {
127+ t.Fatal(err)
128+ }
129+ if !bytes.Equal(damaged.Pixels().Pix, full.Pixels().Pix) {
130+ t.Fatalf("%s differs from a full redraw", what)
131+ }
132+ }
133+ check(without, "overlay frame")
134+ check(with, "overlay refused, window composed")
135+ check(without, "window on the overlay again")
136+}
M internal/adapters/vulkan/frame.go +2 -1
@@ -171,6 +171,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
171171 tg := r.target()
172172 dmg := newDamage(tg, s, image.Rect(0, 0, r.width, r.height))
173173 ds := r.draws(s, contents, dmg)
174+ dmg.finish()
174175 r.dropPools()
175176 partial := !dmg.all()
176177 if partial {
@@ -268,7 +269,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
268269 runtime.KeepAlive(waits)
269270 slot.frame, slot.busy, r.submitted = frame, true, frame
270271 tg.layout = b.NewLayout
271- tg.hold(s, contents)
272+ tg.hold(s, dmg)
272273 r.last, r.readBack = tg, false
273274 r.dropUnused()
274275 r.dropShm()
@@ -171,6 +171,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
171 tg := r.target()171 tg := r.target()
172 dmg := newDamage(tg, s, image.Rect(0, 0, r.width, r.height))172 dmg := newDamage(tg, s, image.Rect(0, 0, r.width, r.height))
173 ds := r.draws(s, contents, dmg)173 ds := r.draws(s, contents, dmg)
174+ dmg.finish()
174 r.dropPools()175 r.dropPools()
175 partial := !dmg.all()176 partial := !dmg.all()
176 if partial {177 if partial {
@@ -268,7 +269,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
268 runtime.KeepAlive(waits)269 runtime.KeepAlive(waits)
269 slot.frame, slot.busy, r.submitted = frame, true, frame270 slot.frame, slot.busy, r.submitted = frame, true, frame
270 tg.layout = b.NewLayout271 tg.layout = b.NewLayout
271- tg.hold(s, contents)272+ tg.hold(s, dmg)
272 r.last, r.readBack = tg, false273 r.last, r.readBack = tg, false
273 r.dropUnused()274 r.dropUnused()
274 r.dropShm()275 r.dropShm()
M internal/adapters/vulkan/targets.go +1 -1
@@ -32,7 +32,7 @@ type target struct {
3232 // content Seq; valid once a frame was drawn whole.
3333 valid bool
3434 sceneSeq uint64
35- seqs map[ports.WindowID]uint64
35+ windows map[ports.WindowID]heldWindow
3636 }
3737
3838 // fourccXRGB is DRM_FORMAT_XRGB8888: B8G8R8A8 in memory, alpha ignored.
@@ -32,7 +32,7 @@ type target struct {
32 // content Seq; valid once a frame was drawn whole.32 // content Seq; valid once a frame was drawn whole.
33 valid bool33 valid bool
34 sceneSeq uint6434 sceneSeq uint64
35- seqs map[ports.WindowID]uint6435+ windows map[ports.WindowID]heldWindow
36 }36 }
37 37
38 // fourccXRGB is DRM_FORMAT_XRGB8888: B8G8R8A8 in memory, alpha ignored.38 // fourccXRGB is DRM_FORMAT_XRGB8888: B8G8R8A8 in memory, alpha ignored.
M internal/adapters/wayland/damage_test.go +10 -0
@@ -89,6 +89,16 @@ func TestDamageHistory(t *testing.T) {
8989 if !ok || len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
9090 t.Fatalf("damage %v %v", got, ok)
9191 }
92+ // Same buffer size, new logical size (buffer scale): full.
93+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, bufs[0], int32(0), int32(0))
94+ requestProtocol(t, c, surf, wayland.SurfaceRequestSetBufferScale, int32(2))
95+ requestProtocol(t, c, surf, wayland.SurfaceRequestDamageBuffer, int32(0), int32(0), int32(1), int32(1))
96+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
97+ scaled := next()
98+ if _, ok := scaled.DamageSince(second.Seq); ok {
99+ t.Fatal("logical resize kept partial damage")
100+ }
101+ second = scaled
92102 // No damage with a new buffer: full.
93103 requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, bufs[0], int32(0), int32(0))
94104 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
@@ -89,6 +89,16 @@ func TestDamageHistory(t *testing.T) {
89 if !ok || len(got) != 2 || got[0] != want[0] || got[1] != want[1] {89 if !ok || len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
90 t.Fatalf("damage %v %v", got, ok)90 t.Fatalf("damage %v %v", got, ok)
91 }91 }
92+ // Same buffer size, new logical size (buffer scale): full.
93+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, bufs[0], int32(0), int32(0))
94+ requestProtocol(t, c, surf, wayland.SurfaceRequestSetBufferScale, int32(2))
95+ requestProtocol(t, c, surf, wayland.SurfaceRequestDamageBuffer, int32(0), int32(0), int32(1), int32(1))
96+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
97+ scaled := next()
98+ if _, ok := scaled.DamageSince(second.Seq); ok {
99+ t.Fatal("logical resize kept partial damage")
100+ }
101+ second = scaled
92 // No damage with a new buffer: full.102 // No damage with a new buffer: full.
93 requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, bufs[0], int32(0), int32(0))103 requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, bufs[0], int32(0), int32(0))
94 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)104 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
M internal/adapters/wayland/surface.go +2 -1
@@ -303,7 +303,8 @@ func (s *surface) applyCommit() {
303303 if state, ok := s.server.buffers[s.current.Resource]; ok {
304304 if c, ok := state.content(0); ok {
305305 c.LogicalW, c.LogicalH = s.logicalSize(c.Width, c.Height)
306- s.commitDamage(true, !s.has || c.Width != s.content.Width || c.Height != s.content.Height, c.Width, c.Height)
306+ resized := !s.has || c.Width != s.content.Width || c.Height != s.content.Height || c.LogicalW != s.content.LogicalW || c.LogicalH != s.content.LogicalH
307+ s.commitDamage(true, resized, c.Width, c.Height)
307308 if s.sub.parent == nil && (c.Width != s.lastW || c.Height != s.lastH) {
308309 s.lastW, s.lastH = c.Width, c.Height
309310 s.server.log.Info().Uint64("id", uint64(s.windowID())).Int("w", c.Width).Int("h", c.Height).Msg("buffer size")
@@ -303,7 +303,8 @@ func (s *surface) applyCommit() {
303 if state, ok := s.server.buffers[s.current.Resource]; ok {303 if state, ok := s.server.buffers[s.current.Resource]; ok {
304 if c, ok := state.content(0); ok {304 if c, ok := state.content(0); ok {
305 c.LogicalW, c.LogicalH = s.logicalSize(c.Width, c.Height)305 c.LogicalW, c.LogicalH = s.logicalSize(c.Width, c.Height)
306- s.commitDamage(true, !s.has || c.Width != s.content.Width || c.Height != s.content.Height, c.Width, c.Height)306+ resized := !s.has || c.Width != s.content.Width || c.Height != s.content.Height || c.LogicalW != s.content.LogicalW || c.LogicalH != s.content.LogicalH
307+ s.commitDamage(true, resized, c.Width, c.Height)
307 if s.sub.parent == nil && (c.Width != s.lastW || c.Height != s.lastH) {308 if s.sub.parent == nil && (c.Width != s.lastW || c.Height != s.lastH) {
308 s.lastW, s.lastH = c.Width, c.Height309 s.lastW, s.lastH = c.Width, c.Height
309 s.server.log.Info().Uint64("id", uint64(s.windowID())).Int("w", c.Width).Int("h", c.Height).Msg("buffer size")310 s.server.log.Info().Uint64("id", uint64(s.windowID())).Int("w", c.Width).Int("h", c.Height).Msg("buffer size")

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 23

Tags 10

↑↓ moveenter openesc close