v0.6.1 aa12db3

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// export copies the target's frame description into out and adds the parts
287// derived from the UI: damage, cursor and the staged input region.
288func (r *Renderer) export(out *Output) {
289	t := &r.tout
290	retired := append(out.Retired[:0], t.Retired...)
291	damage := append(out.Damage[:0], Rect{Width: t.Width, Height: t.Height})
292	rects := out.InputRects
293	*out = Output{
294		Buffer: t.Buffer, NewBuffer: t.NewBuffer, Retired: retired,
295		Width: t.Width, Height: t.Height, FourCC: t.FourCC, Modifier: t.Modifier,
296		Planes: t.Planes, PlaneCount: t.PlaneCount, Acquire: t.Acquire, Release: t.Release,
297		NewTimelines: t.NewTimelines, AcquirePoint: t.AcquirePoint, ReleasePoint: t.ReleasePoint, ReleaseFD: t.ReleaseFD,
298		Damage: damage, Cursor: cursorOf(r.rt.cursor()), InputRects: rects[:0],
299	}
300	rt := r.rt
301	if !rt.inputStaged {
302		return
303	}
304	rt.inputStaged = false
305	// A region equal to the last reported one costs nothing.
306	if r.haveRegion && r.regionNil == rt.inputNil && slices.Equal(r.region, rt.inputRects) {
307		return
308	}
309	r.haveRegion, r.regionNil = true, rt.inputNil
310	r.region = append(r.region[:0], rt.inputRects...)
311	out.InputRectsChanged = true
312	if rt.inputNil {
313		out.InputRects = nil
314		return
315	}
316	if out.InputRects == nil {
317		out.InputRects = []Rect{}
318	}
319	out.InputRects = append(out.InputRects, rt.inputRects...)
320}
321
322func cursorOf(k css.Keyword) Cursor {
323	switch k {
324	case css.KeywordPointer:
325		return CursorPointer
326	case css.KeywordText:
327		return CursorText
328	case css.KeywordNotAllowed:
329		return CursorNotAllowed
330	}
331	return CursorDefault
332}
333
334// Released must be called when the release eventfd of an Output buffer is
335// readable. It lets the buffer be reused.
336func (r *Renderer) Released(buffer uint64) error {
337	if r.closed {
338		return errClosed
339	}
340	return r.target.Released(buffer)
341}
342
343var errClosed = errors.New("nefergui: renderer closed")
344
345// Close cancels pending pastes and frees the GPU resources. The Renderer is
346// unusable afterwards.
347func (r *Renderer) Close() error {
348	if r.closed {
349		return nil
350	}
351	r.closed = true
352	if r.rt.pasteCancel != nil {
353		r.rt.pasteCancel()
354	}
355	return r.target.Close()
356}
357
358// xkb keysym values; internal/ui never imports an xkb library.
359const (
360	keysymSpace     = 0x0020
361	keysymBackspace = 0xff08
362	keysymTab       = 0xff09
363	keysymReturn    = 0xff0d
364	keysymEscape    = 0xff1b
365	keysymHome      = 0xff50
366	keysymLeft      = 0xff51
367	keysymUp        = 0xff52
368	keysymRight     = 0xff53
369	keysymDown      = 0xff54
370	keysymPageUp    = 0xff55
371	keysymPageDown  = 0xff56
372	keysymEnd       = 0xff57
373	keysymKPEnter   = 0xff8d
374	keysymISOLeft   = 0xfe20
375	keysymDelete    = 0xffff
376)
377
378// keysymName maps a keysym to the name interaction.go dispatches on; other
379// keysyms have no name because only their text matters.
380func keysymName(sym uint32) string {
381	switch sym {
382	case keysymSpace:
383		return "space"
384	case keysymTab:
385		return "tab"
386	case keysymISOLeft:
387		return "iso_left_tab"
388	case keysymLeft:
389		return "left"
390	case keysymRight:
391		return "right"
392	case keysymUp:
393		return "up"
394	case keysymDown:
395		return "down"
396	case keysymHome:
397		return "home"
398	case keysymEnd:
399		return "end"
400	case keysymPageUp:
401		return "page_up"
402	case keysymPageDown:
403		return "page_down"
404	case keysymBackspace:
405		return "backspace"
406	case keysymDelete:
407		return "delete"
408	case 'a', 'A':
409		return "a"
410	case 'c', 'C':
411		return "c"
412	case 'x', 'X':
413		return "x"
414	case 'v', 'V':
415		return "v"
416	case keysymReturn:
417		return "return"
418	case keysymKPEnter:
419		return "kp_enter"
420	case keysymEscape:
421		return "escape"
422	}
423	return ""
424}

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