detached 84a151f

feat: add Renderer.Pending, make closed Renderer inert, size TextInt buffer

Pending reports a built frame waiting for the GPU so the app can retry on
a short timer. Resize, Input and Released return early after Close.
commit
84a151f3e511ef9c0e90c454f9ef9c24e514370a
author
brice · · 2026-10-01 17:40 UTC
parent
eba94e9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +73 -7
M internal/ui/controls.go +5 -4
@@ -233,15 +233,16 @@ func (n Node) Heading(text string, options ...HeadingOption) {
233233 }
234234 }
235235
236-// TextInt shows prefix followed by v in decimal without fmt boxing. It builds
237-// the text in a stack buffer, so the only allocation is the final string.
236+// TextInt shows prefix followed by v in decimal without fmt boxing. A prefix
237+// up to 76 bytes is built in a stack buffer, so the only allocation is the
238+// final string; a longer prefix costs one more.
238239 func (n Node) TextInt(prefix string, v int64, options ...ContainerOption) {
239240 child := n.child("text", "", options)
240241 if !child.valid() {
241242 return
242243 }
243- var stack [64]byte
244- buf := append(stack[:0], prefix...)
244+ var stack [96]byte
245+ buf := append(stack[:0], prefix...) // grows on the heap for a long prefix
245246 buf = strconv.AppendInt(buf, v, 10)
246247 child.element.text = string(buf)
247248 }
@@ -233,15 +233,16 @@ func (n Node) Heading(text string, options ...HeadingOption) {
233 }233 }
234 }234 }
235 235
236-// TextInt shows prefix followed by v in decimal without fmt boxing. It builds236+// TextInt shows prefix followed by v in decimal without fmt boxing. A prefix
237-// the text in a stack buffer, so the only allocation is the final string.237+// up to 76 bytes is built in a stack buffer, so the only allocation is the
238+// final string; a longer prefix costs one more.
238 func (n Node) TextInt(prefix string, v int64, options ...ContainerOption) {239 func (n Node) TextInt(prefix string, v int64, options ...ContainerOption) {
239 child := n.child("text", "", options)240 child := n.child("text", "", options)
240 if !child.valid() {241 if !child.valid() {
241 return242 return
242 }243 }
243- var stack [64]byte244+ var stack [96]byte
244- buf := append(stack[:0], prefix...)245+ buf := append(stack[:0], prefix...) // grows on the heap for a long prefix
245 buf = strconv.AppendInt(buf, v, 10)246 buf = strconv.AppendInt(buf, v, 10)
246 child.element.text = string(buf)247 child.element.text = string(buf)
247 }248 }
M internal/ui/renderer.go +21 -3
@@ -97,6 +97,7 @@ type target interface {
9797 Resize(width, height int32)
9898 Draw(display []layout.Command, scale float64, out *session.Output) (bool, error)
9999 Released(buffer uint64) error
100+ Waiting() bool
100101 Close() error
101102 }
102103
@@ -179,8 +180,18 @@ func newRenderer(cfg RendererConfig, t target, fonts text.FontSource) (*Renderer
179180 // (for example a finished paste); call Render after receiving. Safe from any goroutine.
180181 func (r *Renderer) Wake() <-chan struct{} { return r.rt.wake }
181182
183+// Pending reports that a built frame is waiting for the GPU (a buffer still
184+// being drawn, or a release wait not yet installed) rather than for the
185+// compositor. No event announces this: arm a short timer (for example 2 ms)
186+// and call Render again. It is false while every buffer belongs to the
187+// compositor; Released is the wake-up for that case.
188+func (r *Renderer) Pending() bool { return r.unsent && r.target.Waiting() }
189+
182190 // Resize sets the logical surface size and scale. Invalid values are ignored.
183191 func (r *Renderer) Resize(width, height int, scale float64) {
192+ if r.closed {
193+ return
194+ }
184195 w, h := float64(width), float64(height)
185196 if width <= 0 || height <= 0 || !(scale > 0) || math.IsInf(scale, 0) {
186197 return
@@ -203,7 +214,7 @@ func (r *Renderer) Invalidate() { r.rt.Redraw() }
203214
204215 // Input routes one event and reports whether a redraw is needed.
205216 func (r *Renderer) Input(ev *Input) bool {
206- if ev == nil {
217+ if ev == nil || r.closed {
207218 return false
208219 }
209220 switch ev.Kind {
@@ -249,7 +260,7 @@ func (r *Renderer) Input(ev *Input) bool {
249260 // Resize. When it returns true, out describes the frame to present.
250261 func (r *Renderer) Render[T any](out *Output, model *T, view func(*Frame, *T)) (bool, error) {
251262 if r.closed {
252- return false, errors.New("nefergui: renderer closed")
263+ return false, errClosed
253264 }
254265 if out == nil || model == nil || view == nil {
255266 return false, errors.New("nefergui: nil output, model or view")
@@ -322,7 +333,14 @@ func cursorOf(k css.Keyword) Cursor {
322333
323334 // Released must be called when the release eventfd of an Output buffer is
324335 // readable. It lets the buffer be reused.
325-func (r *Renderer) Released(buffer uint64) error { return r.target.Released(buffer) }
336+func (r *Renderer) Released(buffer uint64) error {
337+ if r.closed {
338+ return errClosed
339+ }
340+ return r.target.Released(buffer)
341+}
342+
343+var errClosed = errors.New("nefergui: renderer closed")
326344
327345 // Close cancels pending pastes and frees the GPU resources. The Renderer is
328346 // unusable afterwards.
@@ -97,6 +97,7 @@ type target interface {
97 Resize(width, height int32)97 Resize(width, height int32)
98 Draw(display []layout.Command, scale float64, out *session.Output) (bool, error)98 Draw(display []layout.Command, scale float64, out *session.Output) (bool, error)
99 Released(buffer uint64) error99 Released(buffer uint64) error
100+ Waiting() bool
100 Close() error101 Close() error
101 }102 }
102 103
@@ -179,8 +180,18 @@ func newRenderer(cfg RendererConfig, t target, fonts text.FontSource) (*Renderer
179 // (for example a finished paste); call Render after receiving. Safe from any goroutine.180 // (for example a finished paste); call Render after receiving. Safe from any goroutine.
180 func (r *Renderer) Wake() <-chan struct{} { return r.rt.wake }181 func (r *Renderer) Wake() <-chan struct{} { return r.rt.wake }
181 182
183+// Pending reports that a built frame is waiting for the GPU (a buffer still
184+// being drawn, or a release wait not yet installed) rather than for the
185+// compositor. No event announces this: arm a short timer (for example 2 ms)
186+// and call Render again. It is false while every buffer belongs to the
187+// compositor; Released is the wake-up for that case.
188+func (r *Renderer) Pending() bool { return r.unsent && r.target.Waiting() }
189+
182 // Resize sets the logical surface size and scale. Invalid values are ignored.190 // Resize sets the logical surface size and scale. Invalid values are ignored.
183 func (r *Renderer) Resize(width, height int, scale float64) {191 func (r *Renderer) Resize(width, height int, scale float64) {
192+ if r.closed {
193+ return
194+ }
184 w, h := float64(width), float64(height)195 w, h := float64(width), float64(height)
185 if width <= 0 || height <= 0 || !(scale > 0) || math.IsInf(scale, 0) {196 if width <= 0 || height <= 0 || !(scale > 0) || math.IsInf(scale, 0) {
186 return197 return
@@ -203,7 +214,7 @@ func (r *Renderer) Invalidate() { r.rt.Redraw() }
203 214
204 // Input routes one event and reports whether a redraw is needed.215 // Input routes one event and reports whether a redraw is needed.
205 func (r *Renderer) Input(ev *Input) bool {216 func (r *Renderer) Input(ev *Input) bool {
206- if ev == nil {217+ if ev == nil || r.closed {
207 return false218 return false
208 }219 }
209 switch ev.Kind {220 switch ev.Kind {
@@ -249,7 +260,7 @@ func (r *Renderer) Input(ev *Input) bool {
249 // Resize. When it returns true, out describes the frame to present.260 // Resize. When it returns true, out describes the frame to present.
250 func (r *Renderer) Render[T any](out *Output, model *T, view func(*Frame, *T)) (bool, error) {261 func (r *Renderer) Render[T any](out *Output, model *T, view func(*Frame, *T)) (bool, error) {
251 if r.closed {262 if r.closed {
252- return false, errors.New("nefergui: renderer closed")263+ return false, errClosed
253 }264 }
254 if out == nil || model == nil || view == nil {265 if out == nil || model == nil || view == nil {
255 return false, errors.New("nefergui: nil output, model or view")266 return false, errors.New("nefergui: nil output, model or view")
@@ -322,7 +333,14 @@ func cursorOf(k css.Keyword) Cursor {
322 333
323 // Released must be called when the release eventfd of an Output buffer is334 // Released must be called when the release eventfd of an Output buffer is
324 // readable. It lets the buffer be reused.335 // readable. It lets the buffer be reused.
325-func (r *Renderer) Released(buffer uint64) error { return r.target.Released(buffer) }336+func (r *Renderer) Released(buffer uint64) error {
337+ if r.closed {
338+ return errClosed
339+ }
340+ return r.target.Released(buffer)
341+}
342+
343+var errClosed = errors.New("nefergui: renderer closed")
326 344
327 // Close cancels pending pastes and frees the GPU resources. The Renderer is345 // Close cancels pending pastes and frees the GPU resources. The Renderer is
328 // unusable afterwards.346 // unusable afterwards.
M internal/ui/target_mock_test.go +44 -0
@@ -258,3 +258,47 @@ func (_c *mocktarget_Resize_Call) RunAndReturn(run func(width int32, height int3
258258 _c.Run(run)
259259 return _c
260260 }
261+
262+// Waiting provides a mock function for the type mocktarget
263+func (_mock *mocktarget) Waiting() bool {
264+ ret := _mock.Called()
265+
266+ if len(ret) == 0 {
267+ panic("no return value specified for Waiting")
268+ }
269+
270+ var r0 bool
271+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
272+ r0 = returnFunc()
273+ } else {
274+ r0 = ret.Get(0).(bool)
275+ }
276+ return r0
277+}
278+
279+// mocktarget_Waiting_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Waiting'
280+type mocktarget_Waiting_Call struct {
281+ *mock.Call
282+}
283+
284+// Waiting is a helper method to define mock.On call
285+func (_e *mocktarget_Expecter) Waiting() *mocktarget_Waiting_Call {
286+ return &mocktarget_Waiting_Call{Call: _e.mock.On("Waiting")}
287+}
288+
289+func (_c *mocktarget_Waiting_Call) Run(run func()) *mocktarget_Waiting_Call {
290+ _c.Call.Run(func(args mock.Arguments) {
291+ run()
292+ })
293+ return _c
294+}
295+
296+func (_c *mocktarget_Waiting_Call) Return(b bool) *mocktarget_Waiting_Call {
297+ _c.Call.Return(b)
298+ return _c
299+}
300+
301+func (_c *mocktarget_Waiting_Call) RunAndReturn(run func() bool) *mocktarget_Waiting_Call {
302+ _c.Call.Return(run)
303+ return _c
304+}
@@ -258,3 +258,47 @@ func (_c *mocktarget_Resize_Call) RunAndReturn(run func(width int32, height int3
258 _c.Run(run)258 _c.Run(run)
259 return _c259 return _c
260 }260 }
261+
262+// Waiting provides a mock function for the type mocktarget
263+func (_mock *mocktarget) Waiting() bool {
264+ ret := _mock.Called()
265+
266+ if len(ret) == 0 {
267+ panic("no return value specified for Waiting")
268+ }
269+
270+ var r0 bool
271+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
272+ r0 = returnFunc()
273+ } else {
274+ r0 = ret.Get(0).(bool)
275+ }
276+ return r0
277+}
278+
279+// mocktarget_Waiting_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Waiting'
280+type mocktarget_Waiting_Call struct {
281+ *mock.Call
282+}
283+
284+// Waiting is a helper method to define mock.On call
285+func (_e *mocktarget_Expecter) Waiting() *mocktarget_Waiting_Call {
286+ return &mocktarget_Waiting_Call{Call: _e.mock.On("Waiting")}
287+}
288+
289+func (_c *mocktarget_Waiting_Call) Run(run func()) *mocktarget_Waiting_Call {
290+ _c.Call.Run(func(args mock.Arguments) {
291+ run()
292+ })
293+ return _c
294+}
295+
296+func (_c *mocktarget_Waiting_Call) Return(b bool) *mocktarget_Waiting_Call {
297+ _c.Call.Return(b)
298+ return _c
299+}
300+
301+func (_c *mocktarget_Waiting_Call) RunAndReturn(run func() bool) *mocktarget_Waiting_Call {
302+ _c.Call.Return(run)
303+ return _c
304+}
M renderer.go +3 -0
@@ -10,6 +10,9 @@ import "github.com/bnema/nefergui/internal/ui"
1010 // Input and Resize, call Render when a redraw is needed, import and present
1111 // the Output buffer, and call Released when its ReleaseFD becomes readable.
1212 //
13+// Pending() reports a built frame waiting for the GPU rather than the
14+// compositor; arm a short timer (about 2 ms) and call Render again.
15+//
1316 // Render is generic over the model type, so it is a generic method:
1417 //
1518 // ok, err := r.Render(&out, &model, view)
@@ -10,6 +10,9 @@ import "github.com/bnema/nefergui/internal/ui"
10 // Input and Resize, call Render when a redraw is needed, import and present10 // Input and Resize, call Render when a redraw is needed, import and present
11 // the Output buffer, and call Released when its ReleaseFD becomes readable.11 // the Output buffer, and call Released when its ReleaseFD becomes readable.
12 //12 //
13+// Pending() reports a built frame waiting for the GPU rather than the
14+// compositor; arm a short timer (about 2 ms) and call Render again.
15+//
13 // Render is generic over the model type, so it is a generic method:16 // Render is generic over the model type, so it is a generic method:
14 //17 //
15 // ok, err := r.Render(&out, &model, view)18 // ok, err := r.Render(&out, &model, view)

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 1

Tags 12

↑↓ moveenter openesc close