main bcc9a45

renderer.go RawHistoryFolder

1//go:build linux
2
3package ui
4
5import (
6	"context"
7	"errors"
8	"fmt"
9	"math"
10	"os"
11	"slices"
12
13	"github.com/bnema/nefergui/internal/css"
14	"github.com/bnema/nefergui/internal/edit"
15	"github.com/bnema/nefergui/internal/layout"
16	"github.com/bnema/nefergui/internal/presentation/session"
17	"github.com/bnema/nefergui/internal/presentation/vkdevice"
18	"github.com/bnema/nefergui/internal/text"
19)
20
21// Format is a DRM format and modifier the compositor accepts.
22type Format struct {
23	FourCC   uint32
24	Modifier uint64
25}
26
27// Clipboard is the optional asynchronous clipboard; IME the optional input method.
28type (
29	Clipboard = edit.AsyncClipboard
30	IME       = edit.IME
31)
32
33// Plane, Timeline and Retired are the transport-facing parts of Output.
34type (
35	Plane    = session.Plane
36	Timeline = session.Timeline
37	Retired  = session.Retired
38)
39
40// Cursor is the pointer shape the surface wants.
41type Cursor uint8
42
43const (
44	CursorDefault Cursor = iota
45	CursorPointer
46	CursorText
47	CursorNotAllowed
48)
49
50// RendererConfig configures a Wayland-free Renderer.
51type RendererConfig struct {
52	MainDevice  uint64   // dev_t from linux-dmabuf feedback main_device
53	Formats     []Format // compositor preference order, tranches flattened
54	Transparent bool     // ARGB8888 instead of XRGB8888
55	Styles      string   // optional CSS file path
56	Clipboard   Clipboard
57	IME         IME
58}
59
60// Input is one platform input event. Text is read only during the call.
61type Input struct {
62	Kind         InputKind
63	X, Y, DX, DY float64 // logical pixels; DX, DY are scroll deltas
64	Button       uint32  // evdev code
65	Pressed      bool
66	Repeat       bool
67	Keysym       uint32 // xkb keysym
68	Text         []byte
69	Modifiers    Modifiers
70}
71
72// Output describes one frame to present, in physical pixels. Every file
73// descriptor stays owned by the Renderer. Slices are reused and valid until the
74// next Render.
75type Output struct {
76	Buffer            uint64
77	NewBuffer         bool
78	Retired           []Retired
79	Width, Height     int32
80	FourCC            uint32
81	Modifier          uint64
82	Planes            [4]Plane
83	PlaneCount        int
84	Acquire, Release  Timeline
85	NewTimelines      bool
86	AcquirePoint      uint64
87	ReleasePoint      uint64
88	ReleaseFD         int
89	Damage            []Rect
90	Cursor            Cursor
91	InputRects        []Rect
92	InputRectsChanged bool
93}
94
95// target is the GPU seam of Renderer. *session.Target implements it.
96type target interface {
97	Resize(width, height int32)
98	Draw(display []layout.Command, scale float64, out *session.Output) (bool, error)
99	Released(buffer uint64) error
100	Waiting() bool
101	Close() error
102}
103
104// Renderer renders views into DMA-BUF buffers for a transport it does not
105// know. It owns no Wayland object and starts no goroutine. All methods except
106// Wake run on one owner goroutine.
107type Renderer struct {
108	rt     *runtime
109	target target
110	tout   session.Output
111	sized  bool
112	unsent bool // a built frame still waits for a free buffer
113	w, h   float64
114	scale  float64
115	pw, ph int32
116	closed bool
117
118	// Last input region handed out, to report only changes.
119	region     []Rect
120	regionNil  bool
121	haveRegion bool
122}
123
124// asyncClipboard lets an AsyncClipboard satisfy the editor's Clipboard port;
125// synchronous reads are not supported, paste uses ReadTextAsync.
126type asyncClipboard struct{ edit.AsyncClipboard }
127
128func (asyncClipboard) ReadText(int) ([]byte, error) {
129	return nil, errors.New("nefergui: synchronous clipboard read not supported")
130}
131
132// NewRenderer opens the GPU for cfg.MainDevice and loads fonts and styles. It
133// allocates no images.
134func NewRenderer(cfg RendererConfig) (*Renderer, error) {
135	formats := make([]vkdevice.Format, len(cfg.Formats))
136	for i, f := range cfg.Formats {
137		formats[i] = vkdevice.Format{FourCC: f.FourCC, Modifier: f.Modifier}
138	}
139	t, err := session.NewTarget(session.TargetConfig{MainDevice: cfg.MainDevice, Formats: formats, Transparent: cfg.Transparent})
140	if err != nil {
141		return nil, fmt.Errorf("nefergui: open renderer: %w", err)
142	}
143	r, err := newRenderer(cfg, t, text.SystemSource{})
144	if err != nil {
145		_ = t.Close()
146		return nil, err
147	}
148	return r, nil
149}
150
151func newRenderer(cfg RendererConfig, t target, fonts text.FontSource) (*Renderer, error) {
152	rt := newRuntime()
153	if cfg.Styles != "" {
154		data, err := os.ReadFile(cfg.Styles)
155		if err != nil {
156			return nil, fmt.Errorf("nefergui: styles: %w", err)
157		}
158		rt.styles = css.Compile(css.UA(), css.Parse(string(data)))
159	}
160	catalog, err := text.Load(fonts)
161	if err != nil {
162		return nil, fmt.Errorf("nefergui: fonts: %w", err)
163	}
164	if len(catalog.Faces) == 0 {
165		return nil, errors.New("nefergui: no usable fonts")
166	}
167	rt.setTextEngine(text.NewEngine(catalog))
168	ctx, cancel := context.WithCancel(context.Background())
169	rt.pasteCtx, rt.pasteCancel = ctx, cancel
170	if cfg.Clipboard != nil {
171		rt.clipboard = asyncClipboard{cfg.Clipboard}
172	}
173	if cfg.IME != nil {
174		rt.ime = cfg.IME
175	}
176	return &Renderer{rt: rt, target: t, scale: 1}, nil
177}
178
179// Wake returns a channel signaled when work arrives from another goroutine
180// (for example a finished paste); call Render after receiving. Safe from any goroutine.
181func (r *Renderer) Wake() <-chan struct{} { return r.rt.wake }
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.
188func (r *Renderer) Pending() bool { return r.unsent && r.target.Waiting() }
189
190// Resize sets the logical surface size and scale. Invalid values are ignored.
191func (r *Renderer) Resize(width, height int, scale float64) {
192	if r.closed {
193		return
194	}
195	w, h := float64(width), float64(height)
196	if width <= 0 || height <= 0 || !(scale > 0) || math.IsInf(scale, 0) {
197		return
198	}
199	pw, ph := math.Ceil(w*scale-1e-9), math.Ceil(h*scale-1e-9)
200	if pw > math.MaxInt32 || ph > math.MaxInt32 {
201		return
202	}
203	if r.sized && r.w == w && r.h == h && r.scale == scale {
204		return
205	}
206	r.sized, r.w, r.h, r.scale = true, w, h, scale
207	r.pw, r.ph = int32(pw), int32(ph)
208	r.target.Resize(r.pw, r.ph)
209	r.rt.route(platformInput{Kind: "resize", Width: w, Height: h, Scale: scale})
210}
211
212// Invalidate requests a rebuild at the next Render.
213func (r *Renderer) Invalidate() { r.rt.Redraw() }
214
215// Input routes one event and reports whether a redraw is needed.
216func (r *Renderer) Input(ev *Input) bool {
217	if ev == nil || r.closed {
218		return false
219	}
220	switch ev.Kind {
221	case InputReset:
222		r.rt.route(platformInput{Kind: "leave"})
223		r.rt.route(platformInput{Kind: "focus-out"})
224	case InputPointerMotion:
225		r.rt.route(platformInput{Kind: "motion", X: ev.X, Y: ev.Y})
226	case InputPointerPress:
227		r.rt.route(platformInput{Kind: "press", X: ev.X, Y: ev.Y, Button: ev.Button, Shift: ev.Modifiers&ModShift != 0})
228	case InputPointerRelease:
229		r.rt.route(platformInput{Kind: "release", X: ev.X, Y: ev.Y, Button: ev.Button})
230	case InputPointerAxis:
231		if ev.DX == 0 && ev.DY == 0 {
232			return false
233		}
234		r.rt.route(platformInput{Kind: "axis", X: ev.X, Y: ev.Y, DX: ev.DX, DY: ev.DY})
235	case InputPointerLeave:
236		r.rt.route(platformInput{Kind: "leave"})
237	case InputFocusIn:
238		r.rt.route(platformInput{Kind: "focus-in"})
239	case InputFocusOut:
240		r.rt.route(platformInput{Kind: "focus-out"})
241	case InputKey:
242		shift, ctrl := ev.Modifiers&ModShift != 0, ev.Modifiers&ModCtrl != 0
243		key := keyEvent{Name: keysymName(ev.Keysym), Pressed: ev.Pressed, Repeat: ev.Repeat, Shift: shift, Ctrl: ctrl}
244		if len(ev.Text) > 0 {
245			key.Text = string(ev.Text)
246		}
247		r.rt.route(platformInput{Kind: "key", Key: key, Shift: shift, Ctrl: ctrl})
248	default:
249		return false
250	}
251	r.rt.mu.Lock()
252	need := r.rt.redraw || len(r.rt.pending) > 0
253	r.rt.mu.Unlock()
254	return need
255}
256
257// Render builds the view and, when something changed and a buffer is free,
258// draws it. It returns false without touching the GPU when nothing changed, no
259// buffer is available yet (call it again after Released) or before the first
260// Resize. When it returns true, out describes the frame to present.
261func (r *Renderer) Render[T any](out *Output, model *T, view func(*Frame, *T)) (bool, error) {
262	if r.closed {
263		return false, errClosed
264	}
265	if out == nil || model == nil || view == nil {
266		return false, errors.New("nefergui: nil output, model or view")
267	}
268	if !r.sized {
269		return false, nil
270	}
271	if r.rt.Build(func(f *Frame) { view(f, model) }) {
272		r.unsent = true
273	}
274	if !r.unsent {
275		return false, nil
276	}
277	ok, err := r.target.Draw(r.rt.output.Display, r.scale, &r.tout)
278	if err != nil || !ok {
279		return false, err
280	}
281	r.unsent = false
282	r.export(out)
283	return true, nil
284}
285
286// Measure returns the logical size, in pixels rounded up, that view needs for
287// model: its natural width, at most maxWidth, and the height the content takes
288// at that width (text wraps at maxWidth). Use it to size a surface before
289// creating it. The size includes the root's margins.
290//
291// The view is built in a temporary runtime that shares only this Renderer's
292// styles and text engine. Those caches only grow during Measure: it does not
293// age their entries, so a later Render finds its own entries intact. Hover,
294// focus, scroll offsets, editor state, queued input, the Wake channel and the
295// target are neither read nor changed, nothing is drawn, and the view sees no
296// events.
297//
298// There is no surface yet, so the height is indefinite: inside the view
299// Frame.Size reports an unbounded height (1<<20), and percentage height,
300// min-height and max-height of the root are treated as auto, as for nested
301// boxes, instead of resolving against that value. Lengths in pixels apply.
302//
303// Call it from the owner goroutine, before or after Resize, never from inside
304// a view callback. It allocates and shapes text: use it when opening a
305// surface, not on every frame. It returns an error when the Renderer is
306// closed, model or view is nil, maxWidth is not a finite number above zero, or
307// the view cannot be laid out (the layout error is wrapped). A view that
308// declares no root measures 0 by 0.
309func (r *Renderer) Measure[T any](model *T, view func(*Frame, *T), maxWidth float64) (width, height float64, err error) {
310	if r.closed {
311		return 0, 0, errClosed
312	}
313	if model == nil || view == nil {
314		return 0, 0, errors.New("nefergui: nil model or view")
315	}
316	if math.IsNaN(maxWidth) || math.IsInf(maxWidth, 0) || maxWidth <= 0 {
317		return 0, 0, errors.New("nefergui: Measure needs a finite maxWidth above zero")
318	}
319	const tall = 1 << 20
320	tmp := &runtime{
321		wake: make(chan struct{}, 1), styles: r.rt.styles, textEngine: r.rt.textEngine,
322		width: maxWidth, height: tall, scale: 1, redraw: true,
323		edits: make(map[string]*edit.State), measuring: true,
324	}
325	if !tmp.Build(func(f *Frame) { view(f, model) }) {
326		if tmp.layoutErr != nil {
327			return 0, 0, fmt.Errorf("nefergui: measure: %w", tmp.layoutErr)
328		}
329		return 0, 0, errors.New("nefergui: measure: could not build the view")
330	}
331	if tmp.committed == nil {
332		return 0, 0, nil
333	}
334	root := tmp.layoutTree(tmp.committed)
335	if root.Style != nil {
336		st := layout.IndefiniteHeights(*root.Style)
337		root.Style = &st
338	}
339	opts := layout.Options{Width: maxWidth, Height: tall, TextEngine: tmp.textEngine}
340	natural, err := layout.Natural(root, opts)
341	if err != nil {
342		return 0, 0, fmt.Errorf("nefergui: measure: %w", err)
343	}
344	// Natural is a border box without margins, while the surface holds the
345	// margin box: Layout subtracts the margins from the width it is given.
346	var m layout.Edges
347	if tmp.output.Tree != nil {
348		m = tmp.output.Tree.Margin
349	}
350	w := math.Min(natural.W+math.Max(0, m.Left)+math.Max(0, m.Right), maxWidth)
351	opts.Width = w
352	out, err := layout.Layout(root, opts)
353	if err != nil {
354		return 0, 0, fmt.Errorf("nefergui: measure: %w", err)
355	}
356	if out.Tree == nil {
357		return 0, 0, nil
358	}
359	tree := out.Tree
360	h := tree.Border.Y + tree.Border.H + math.Max(0, tree.Margin.Bottom)
361	return math.Ceil(w), math.Ceil(h), nil
362}
363
364// export copies the target's frame description into out and adds the parts
365// derived from the UI: damage, cursor and the staged input region.
366func (r *Renderer) export(out *Output) {
367	t := &r.tout
368	retired := append(out.Retired[:0], t.Retired...)
369	damage := append(out.Damage[:0], Rect{Width: t.Width, Height: t.Height})
370	rects := out.InputRects
371	*out = Output{
372		Buffer: t.Buffer, NewBuffer: t.NewBuffer, Retired: retired,
373		Width: t.Width, Height: t.Height, FourCC: t.FourCC, Modifier: t.Modifier,
374		Planes: t.Planes, PlaneCount: t.PlaneCount, Acquire: t.Acquire, Release: t.Release,
375		NewTimelines: t.NewTimelines, AcquirePoint: t.AcquirePoint, ReleasePoint: t.ReleasePoint, ReleaseFD: t.ReleaseFD,
376		Damage: damage, Cursor: cursorOf(r.rt.cursor()), InputRects: rects[:0],
377	}
378	rt := r.rt
379	if !rt.inputStaged {
380		return
381	}
382	rt.inputStaged = false
383	// A region equal to the last reported one costs nothing.
384	if r.haveRegion && r.regionNil == rt.inputNil && slices.Equal(r.region, rt.inputRects) {
385		return
386	}
387	r.haveRegion, r.regionNil = true, rt.inputNil
388	r.region = append(r.region[:0], rt.inputRects...)
389	out.InputRectsChanged = true
390	if rt.inputNil {
391		out.InputRects = nil
392		return
393	}
394	if out.InputRects == nil {
395		out.InputRects = []Rect{}
396	}
397	out.InputRects = append(out.InputRects, rt.inputRects...)
398}
399
400func cursorOf(k css.Keyword) Cursor {
401	switch k {
402	case css.KeywordPointer:
403		return CursorPointer
404	case css.KeywordText:
405		return CursorText
406	case css.KeywordNotAllowed:
407		return CursorNotAllowed
408	}
409	return CursorDefault
410}
411
412// Released must be called when the release eventfd of an Output buffer is
413// readable. It lets the buffer be reused.
414func (r *Renderer) Released(buffer uint64) error {
415	if r.closed {
416		return errClosed
417	}
418	return r.target.Released(buffer)
419}
420
421var errClosed = errors.New("nefergui: renderer closed")
422
423// Close cancels pending pastes and frees the GPU resources. The Renderer is
424// unusable afterwards.
425func (r *Renderer) Close() error {
426	if r.closed {
427		return nil
428	}
429	r.closed = true
430	if r.rt.pasteCancel != nil {
431		r.rt.pasteCancel()
432	}
433	return r.target.Close()
434}
435
436// xkb keysym values; internal/ui never imports an xkb library.
437const (
438	keysymSpace     = 0x0020
439	keysymBackspace = 0xff08
440	keysymTab       = 0xff09
441	keysymReturn    = 0xff0d
442	keysymEscape    = 0xff1b
443	keysymHome      = 0xff50
444	keysymLeft      = 0xff51
445	keysymUp        = 0xff52
446	keysymRight     = 0xff53
447	keysymDown      = 0xff54
448	keysymPageUp    = 0xff55
449	keysymPageDown  = 0xff56
450	keysymEnd       = 0xff57
451	keysymKPEnter   = 0xff8d
452	keysymISOLeft   = 0xfe20
453	keysymDelete    = 0xffff
454)
455
456// keysymName maps a keysym to the name interaction.go dispatches on; other
457// keysyms have no name because only their text matters.
458func keysymName(sym uint32) string {
459	switch sym {
460	case keysymSpace:
461		return "space"
462	case keysymTab:
463		return "tab"
464	case keysymISOLeft:
465		return "iso_left_tab"
466	case keysymLeft:
467		return "left"
468	case keysymRight:
469		return "right"
470	case keysymUp:
471		return "up"
472	case keysymDown:
473		return "down"
474	case keysymHome:
475		return "home"
476	case keysymEnd:
477		return "end"
478	case keysymPageUp:
479		return "page_up"
480	case keysymPageDown:
481		return "page_down"
482	case keysymBackspace:
483		return "backspace"
484	case keysymDelete:
485		return "delete"
486	case 'a', 'A':
487		return "a"
488	case 'c', 'C':
489		return "c"
490	case 'x', 'X':
491		return "x"
492	case 'v', 'V':
493		return "v"
494	case keysymReturn:
495		return "return"
496	case keysymKPEnter:
497		return "kp_enter"
498	case keysymEscape:
499		return "escape"
500	}
501	return ""
502}

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