v0.1.0 d9218d6

layout.go RawHistoryFolder

1package core
2
3import (
4	"fmt"
5	"github.com/bnema/neferwl/internal/ports"
6	"math/bits"
7	"slices"
8	"strconv"
9	"strings"
10	"time"
11)
12
13type WindowID = ports.WindowID
14
15type Rect = ports.Rect
16
17type Width struct{ Num, Den, Pixels int }
18
19func ParseWidth(s string) (Width, error) {
20	if s == "1" {
21		return Width{Num: 1, Den: 1}, nil
22	}
23	if strings.HasSuffix(s, "px") {
24		n, e := strconv.Atoi(strings.TrimSuffix(s, "px"))
25		if e == nil && n > 0 {
26			return Width{Pixels: n}, nil
27		}
28	} else if strings.HasSuffix(s, "%") {
29		n, e := strconv.Atoi(strings.TrimSuffix(s, "%"))
30		if e == nil && n > 0 && n <= 100 {
31			return Width{Num: n, Den: 100}, nil
32		}
33	} else {
34		p := strings.Split(s, "/")
35		if len(p) == 2 {
36			a, e1 := strconv.Atoi(p[0])
37			b, e2 := strconv.Atoi(p[1])
38			if e1 == nil && e2 == nil && a > 0 && a <= b {
39				return Width{Num: a, Den: b}, nil
40			}
41		}
42	}
43	return Width{}, fmt.Errorf("invalid width %q", s)
44}
45
46// Resolve returns column pixels: round(frac * (usableW - gaps)) - gaps.
47// Widths are clamped to the available area (zero if no space remains).
48func (v Width) Resolve(usableW, gaps int) int {
49	if usableW <= 0 {
50		return 0
51	}
52	gaps = min(max(gaps, 0), usableW/2)
53	limit := max(usableW-2*gaps, 0)
54	if limit == 0 {
55		return 0
56	}
57	if v.Pixels > 0 {
58		return min(v.Pixels, limit)
59	}
60	if v.Den <= 0 || v.Num <= 0 {
61		return 1
62	}
63	// Cap invalid fractions above one before multiplying; the result would
64	// be clamped to the available width anyway.
65	num, den := uint64(min(v.Num, v.Den)), uint64(v.Den)
66	avail := uint64(usableW - gaps)
67	q, r := avail/den, avail%den
68	hi, lo := bits.Mul64(r, num)
69	lo, carry := bits.Add64(lo, den/2, 0)
70	hi += carry
71	// hi < den: r and num are both smaller than den.
72	fraction, _ := bits.Div64(hi, lo, den)
73	rounded := q*num + fraction // no larger than avail (plus rounding)
74	if rounded <= uint64(gaps) {
75		return 1
76	}
77	return int(min(rounded-uint64(gaps), uint64(limit)))
78}
79
80// Column.Width is zero (auto) until the user picks a preset: auto columns share
81// the usable width equally, up to MaxColumns visible at once.
82type Column struct {
83	Windows []WindowID
84	Width   Width
85	Focus   int
86	// Slot is the declared column number (workspace.<name>.column.N) of a
87	// slot window; 0 for normal columns.
88	Slot int
89}
90type Placement struct {
91	ID                          WindowID
92	Rect                        Rect
93	Fullscreen, Focused, Hidden bool
94	// Floating windows sit over the columns at their own size.
95	Floating bool
96	// Neighbors are the sides touching another tiled window. Inset are the
97	// sides where the border takes room from the window: all sides of a
98	// float; for tiles, the right and bottom neighbors only, so two
99	// windows share one separator line (tmux style). With gaps, each tile
100	// insets all its neighbor sides.
101	Neighbors, Inset ports.Sides
102}
103
104// Overflow says what happens past MaxColumns columns.
105type Overflow string
106
107const (
108	// OverflowScroll opens further columns to the right; the view scrolls.
109	OverflowScroll Overflow = "scroll"
110	// OverflowFixed keeps every column on screen: past the max, each new
111	// column splits the newest one, alternating top/bottom and left/right
112	// (dwindle spiral).
113	OverflowFixed Overflow = "fixed"
114)
115
116type Workspace struct {
117	// Name is set for workspaces declared in config; empty for dynamic ones.
118	Name       string
119	Overflow   Overflow
120	Columns    []Column
121	Focus      int
122	ViewX      int
123	Output     Rect
124	Usable     Rect
125	Gaps       int
126	MaxColumns int
127	// border is drawn around floating windows, inside their rect.
128	border     int
129	presets    []Width
130	fullscreen WindowID
131	// Floats are floating windows, bottom to top; floatFocus is set while
132	// the top one has the focus instead of the columns.
133	Floats     []Float
134	floatFocus bool
135	// home is the monitor (key or connector) the workspace belongs to; ""
136	// means the one it is on. On another monitor it is a guest; homePos is
137	// its position there, where it returns.
138	home    string
139	homePos int
140	// termAt is when core last spawned a terminal for this workspace.
141	termAt time.Time
142	// origin is set on the workspace a fixed-overflow fullscreen window
143	// moved to; back is where it returns there (Monitor.fullscreenHome).
144	origin *Workspace
145	back   origPlace
146}
147
148// origPlace remembers where a window was: its column, its row in a stacked
149// column, the column's width and slot, or its floating size.
150type origPlace struct {
151	// id is the window that went fullscreen; tiled lists the windows that
152	// joined its workspace tiled, floating there until they go home.
153	id             WindowID
154	tiled          []WindowID
155	col, row, slot int
156	// stacked holds the windows left in the column: the window returns to
157	// its row in the column that still holds one of them.
158	stacked []WindowID
159	width   Width
160	float   *Float
161}
162
163// Float is a floating window and its client size, logical.
164type Float struct {
165	ID   WindowID
166	W, H int
167}
168
169func (w *Workspace) empty() bool { return len(w.Columns) == 0 && len(w.Floats) == 0 }
170
171// windows lists every window: columns first, then floating ones.
172func (w *Workspace) windows() []WindowID {
173	var ids []WindowID
174	for _, c := range w.Columns {
175		ids = append(ids, c.Windows...)
176	}
177	for _, f := range w.Floats {
178		ids = append(ids, f.ID)
179	}
180	return ids
181}
182
183func (w *Workspace) floatIndex(id WindowID) int {
184	return slices.IndexFunc(w.Floats, func(f Float) bool { return f.ID == id })
185}
186
187// AddFloating shows a window over the columns and focuses it.
188func (w *Workspace) AddFloating(id WindowID, width, height int) {
189	if id == 0 || w.has(id) {
190		return
191	}
192	w.Floats = append(w.Floats, Float{ID: id, W: width, H: height})
193	w.floatFocus = true
194}
195
196// ResizeFloating records the size a floating window draws.
197func (w *Workspace) ResizeFloating(id WindowID, width, height int) {
198	if i := w.floatIndex(id); i >= 0 {
199		w.Floats[i].W, w.Floats[i].H = width, height
200	}
201}
202
203// has reports whether the workspace holds the window.
204func (w *Workspace) has(id WindowID) bool {
205	if w.floatIndex(id) >= 0 {
206		return true
207	}
208	for _, c := range w.Columns {
209		for _, v := range c.Windows {
210			if v == id {
211				return true
212			}
213		}
214	}
215	return false
216}
217
218func (w *Workspace) Focused() (WindowID, bool) {
219	if w.floatFocus && len(w.Floats) > 0 {
220		return w.Floats[len(w.Floats)-1].ID, true
221	}
222	if w.Focus < 0 || w.Focus >= len(w.Columns) {
223		return 0, false
224	}
225	c := w.Columns[w.Focus]
226	if c.Focus < 0 || c.Focus >= len(c.Windows) {
227		return 0, false
228	}
229	return c.Windows[c.Focus], true
230}
231func (w *Workspace) AddWindow(id WindowID) {
232	if id == 0 || w.has(id) {
233		return
234	}
235	w.addColumn(Column{Windows: []WindowID{id}})
236}
237
238// addColumn places a new column like a new window and focuses it.
239func (w *Workspace) addColumn(col Column) {
240	at := 0
241	if w.Overflow == OverflowFixed {
242		// The spiral depends only on window order: new windows go last.
243		at = len(w.Columns)
244	} else if len(w.Columns) > 0 {
245		at = w.Focus + 1
246	}
247	w.Columns = append(w.Columns, Column{})
248	copy(w.Columns[at+1:], w.Columns[at:])
249	w.Columns[at] = col
250	w.Focus = at
251	w.scroll()
252}
253
254// AddSlotWindow places a slot window in its own column, ordered by slot
255// number before the other columns. Focus stays on the focused window: slot
256// windows are automatic events (ADR 011 golden rule).
257func (w *Workspace) AddSlotWindow(id WindowID, slot int, width Width) {
258	if id == 0 || w.has(id) {
259		return
260	}
261	// Right after the last slot column with a lower number, else first.
262	at := 0
263	for i, c := range w.Columns {
264		if c.Slot > 0 && c.Slot < slot {
265			at = i + 1
266		}
267	}
268	w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Width: width, Slot: slot})
269	if len(w.Columns) > 1 && at <= w.Focus {
270		w.Focus++
271	}
272	w.scroll()
273}
274
275// setSlotWidth updates the width of slot column n, if present.
276func (w *Workspace) setSlotWidth(n int, width Width) {
277	for i := range w.Columns {
278		if w.Columns[i].Slot == n {
279			w.Columns[i].Width = width
280		}
281	}
282	w.scroll()
283}
284
285// inSlot reports whether window id is in slot column n.
286func (w *Workspace) inSlot(id WindowID, n int) bool {
287	for _, c := range w.Columns {
288		if c.Slot == n && slices.Contains(c.Windows, id) {
289			return true
290		}
291	}
292	return false
293}
294
295// unslot turns slot column n into a normal column.
296func (w *Workspace) unslot(n int) {
297	for i := range w.Columns {
298		if w.Columns[i].Slot == n {
299			w.Columns[i].Slot = 0
300		}
301	}
302}
303
304func (w *Workspace) RemoveWindow(id WindowID) {
305	if i := w.floatIndex(id); i >= 0 {
306		w.Floats = slices.Delete(w.Floats, i, i+1)
307		if w.fullscreen == id {
308			w.fullscreen = 0
309		}
310		if len(w.Floats) == 0 {
311			w.floatFocus = false
312		}
313		return
314	}
315	for i := range w.Columns {
316		for j, v := range w.Columns[i].Windows {
317			if v != id {
318				continue
319			}
320			c := &w.Columns[i]
321			c.Windows = append(c.Windows[:j], c.Windows[j+1:]...)
322			if w.fullscreen == id {
323				w.fullscreen = 0
324			}
325			if len(c.Windows) == 0 {
326				w.Columns = append(w.Columns[:i], w.Columns[i+1:]...)
327				if len(w.Columns) == 0 {
328					w.Focus = 0
329					w.ViewX = 0
330					return
331				}
332				if i < w.Focus || (i == w.Focus && i > 0) {
333					w.Focus--
334				}
335			} else {
336				if j < c.Focus {
337					c.Focus--
338				} else if j == c.Focus && c.Focus > 0 {
339					c.Focus--
340				}
341			}
342			w.scroll()
343			return
344		}
345	}
346}
347
348// FocusID selects a window and scrolls its column into view; a floating
349// window is raised.
350func (w *Workspace) FocusID(id WindowID) bool {
351	if i := w.floatIndex(id); i >= 0 {
352		f := w.Floats[i]
353		w.Floats = append(slices.Delete(w.Floats, i, i+1), f)
354		w.floatFocus = true
355		return true
356	}
357	w.floatFocus = false
358	for i := range w.Columns {
359		for j, v := range w.Columns[i].Windows {
360			if v == id {
361				w.Focus = i
362				w.Columns[i].Focus = j
363				w.scroll()
364				return true
365			}
366		}
367	}
368	return false
369}
370func (w *Workspace) FocusColumn(dir int) {
371	if w.floatFocus {
372		// The first move leaves the floating window for the columns.
373		w.floatFocus = false
374		return
375	}
376	if len(w.Columns) > 0 && (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) {
377		w.Focus += dir
378		w.scroll()
379	}
380}
381
382// FocusWindow moves focus inside the column; false means it was already at the edge.
383func (w *Workspace) FocusWindow(dir int) bool {
384	if w.floatFocus {
385		w.floatFocus = false
386		return true
387	}
388	if len(w.Columns) == 0 || (dir != -1 && dir != 1) {
389		return false
390	}
391	c := &w.Columns[w.Focus]
392	if c.Focus+dir >= 0 && c.Focus+dir < len(c.Windows) {
393		c.Focus += dir
394		w.scroll()
395		return true
396	}
397	return false
398}
399func (w *Workspace) MoveColumn(dir int) {
400	if w.floatFocus {
401		return
402	}
403	if (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) {
404		i := w.Focus
405		w.Columns[i], w.Columns[i+dir] = w.Columns[i+dir], w.Columns[i]
406		w.Focus += dir
407		w.scroll()
408	}
409}
410
411// takeColumn removes the focused column and returns it, as a normal column.
412func (w *Workspace) takeColumn() (Column, bool) {
413	if len(w.Columns) == 0 || w.floatFocus {
414		return Column{}, false
415	}
416	col := w.Columns[w.Focus]
417	col.Slot = 0
418	if slices.Contains(col.Windows, w.fullscreen) {
419		w.fullscreen = 0
420	}
421	w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1)
422	w.Focus = min(w.Focus, max(len(w.Columns)-1, 0))
423	if len(w.Columns) == 0 {
424		w.ViewX = 0
425	} else {
426		w.scroll()
427	}
428	return col, true
429}
430
431// insertColumn adds a column at index at (clamped) and focuses it.
432func (w *Workspace) insertColumn(at int, col Column) {
433	at = min(max(at, 0), len(w.Columns))
434	w.Columns = slices.Insert(w.Columns, at, col)
435	w.Focus = at
436	w.scroll()
437}
438
439func (w *Workspace) CycleWidth() {
440	if len(w.Columns) == 0 || len(w.presets) == 0 || w.floatFocus {
441		return
442	}
443	// auto → presets in order → auto.
444	c := &w.Columns[w.Focus]
445	next := Width{}
446	if c.Width == next {
447		next = w.presets[0]
448	}
449	for i, v := range w.presets {
450		if v == c.Width && i+1 < len(w.presets) {
451			next = w.presets[i+1]
452			break
453		}
454	}
455	c.Width = next
456	w.scroll()
457}
458func (w *Workspace) ToggleFullscreen() {
459	id, ok := w.Focused()
460	if !ok {
461		return
462	}
463	if w.fullscreen == id {
464		w.fullscreen = 0
465	} else {
466		w.fullscreen = id
467	}
468	w.scroll()
469}
470
471// Activate focuses a window and makes it visible: a tiled window leaves
472// another window's fullscreen, which would hide it.
473func (w *Workspace) Activate(id WindowID) {
474	if w.fullscreen != 0 && w.fullscreen != id && w.floatIndex(id) < 0 {
475		w.fullscreen = 0
476	}
477	w.FocusID(id)
478}
479
480// SetFullscreen applies a client request. It never moves focus: client
481// requests are automatic events (ADR 011 golden rule).
482func (w *Workspace) SetFullscreen(id WindowID, on bool) {
483	if w.floatIndex(id) >= 0 {
484		if on {
485			w.fullscreen = id
486		} else if w.fullscreen == id {
487			w.fullscreen = 0
488		}
489		return
490	}
491	for i := range w.Columns {
492		for _, v := range w.Columns[i].Windows {
493			if v == id {
494				if on {
495					w.fullscreen = id
496				} else if w.fullscreen == id {
497					w.fullscreen = 0
498				}
499				w.scroll()
500				return
501			}
502		}
503	}
504}
505func (w *Workspace) SetOutput(width, height int) {
506	if width < 0 {
507		width = 0
508	}
509	if height < 0 {
510		height = 0
511	}
512	old := w.Output
513	w.Output = Rect{W: width, H: height}
514	if w.Usable == old {
515		w.Usable = w.Output
516	}
517	w.SetUsable(w.Usable)
518}
519
520// SetUsable clamps negative dimensions to zero and clips the usable rectangle
521// to the output. Effective gaps shrink to fit the available area.
522func (w *Workspace) SetUsable(r Rect) {
523	r.X = min(max(r.X, 0), w.Output.W)
524	r.Y = min(max(r.Y, 0), w.Output.H)
525	r.W = min(max(r.W, 0), w.Output.W-r.X)
526	r.H = min(max(r.H, 0), w.Output.H-r.Y)
527	w.Usable = r
528	w.scroll()
529}
530func (w *Workspace) SetGaps(g int) {
531	if g < 0 {
532		g = 0
533	}
534	w.Gaps = g
535	w.scroll()
536}
537func (w *Workspace) SetPresets(v []Width) { w.presets = append([]Width(nil), v...) }
538func (w *Workspace) SetMaxColumns(n int) {
539	w.MaxColumns = n
540	w.scroll()
541}
542func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) }
543func (w *Workspace) fullscreenColumn(i int) bool {
544	for _, id := range w.Columns[i].Windows {
545		if id == w.fullscreen && id != 0 {
546			return true
547		}
548	}
549	return false
550}
551func (w *Workspace) columnWidth(i int) int {
552	if w.fullscreenColumn(i) {
553		return w.Output.W
554	}
555	g := w.gap()
556	// Fixed overflow never scrolls, so presets would push columns off screen.
557	if w.Columns[i].Width == (Width{}) || w.Overflow == OverflowFixed {
558		if len(w.Columns) == 1 {
559			return max(w.Usable.W-2*g, 0)
560		}
561		// Equal shares of the width left after gaps; a remainder under k pixels stays empty.
562		k := min(len(w.Columns), max(w.MaxColumns, 1))
563		return max((w.Usable.W-g*(k+1))/k, 0)
564	}
565	return w.Columns[i].Width.Resolve(w.Usable.W, g)
566}
567func (w *Workspace) columnX(i int) int {
568	x := w.Usable.X + w.gap()
569	for j := 0; j < i; j++ {
570		x += w.columnWidth(j) + w.gap()
571	}
572	return x
573}
574func (w *Workspace) scroll() {
575	if w.Overflow == OverflowFixed {
576		w.ViewX = 0
577		return
578	}
579	if len(w.Columns) == 0 {
580		return
581	}
582	left := w.columnX(w.Focus) - w.ViewX
583	width := w.columnWidth(w.Focus)
584	if w.fullscreenColumn(w.Focus) {
585		w.ViewX += left
586		return
587	}
588	minX := w.Usable.X + w.gap()
589	maxX := w.Usable.X + w.Usable.W - w.gap()
590	if width > maxX-minX || left < minX {
591		w.ViewX += left - minX
592	} else if left+width > maxX {
593		w.ViewX += left + width - maxX
594	}
595}
596
597// columnRects returns each column's area on screen, before stacking windows.
598func (w *Workspace) columnRects() []Rect {
599	g := w.gap()
600	y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0)
601	rects := make([]Rect, len(w.Columns))
602	for i := range w.Columns {
603		rects[i] = Rect{X: w.columnX(i) - w.ViewX, Y: y, W: w.columnWidth(i), H: h}
604	}
605	k := max(w.MaxColumns, 1)
606	if w.Overflow != OverflowFixed || len(w.Columns) <= k {
607		return rects
608	}
609	// The last slot holds the spiral; the columns before it keep their place.
610	area := rects[k-1]
611	for i := k - 1; i < len(w.Columns)-1; i++ {
612		rects[i], area = split(area, g, (i-k+1)%2 == 0)
613	}
614	rects[len(rects)-1] = area
615	return rects
616}
617
618// split cuts r in two halves with a gap: top/bottom when vertical, else left/right.
619func split(r Rect, gap int, vertical bool) (Rect, Rect) {
620	if vertical {
621		h := max((r.H-gap)/2, 0)
622		return Rect{X: r.X, Y: r.Y, W: r.W, H: h}, Rect{X: r.X, Y: r.Y + h + gap, W: r.W, H: max(r.H-gap-h, 0)}
623	}
624	w := max((r.W-gap)/2, 0)
625	return Rect{X: r.X, Y: r.Y, W: w, H: r.H}, Rect{X: r.X + w + gap, Y: r.Y, W: max(r.W-gap-w, 0), H: r.H}
626}
627
628func (w *Workspace) Layout() []Placement {
629	var result []Placement
630	gap := w.gap()
631	cols := w.columnRects()
632	focusedID, _ := w.Focused()
633	floatFocused := w.floatIndex(focusedID) >= 0
634	for i, c := range w.Columns {
635		col := cols[i]
636		n := len(c.Windows)
637		if n == 0 {
638			continue
639		}
640		fullColumn := w.fullscreenColumn(i)
641		if fullColumn {
642			col.W = w.Output.W
643		}
644		available := max(col.H-(n-1)*gap, 0)
645		height := available / n
646		y := col.Y
647		bottom := col.Y + col.H
648		for j, id := range c.Windows {
649			h := height
650			if j == n-1 {
651				h = available - height*(n-1)
652			}
653			y = min(y, bottom)
654			h = min(h, bottom-y)
655			r := Rect{X: col.X, Y: y, W: col.W, H: h}
656			full := w.fullscreen == id && id != 0
657			// Fixed overflow keeps every column on screen: hide them all
658			// behind a fullscreen window.
659			hidden := (fullColumn || w.Overflow == OverflowFixed && w.fullscreen != 0) && !full
660			if full {
661				// Scroll mode aligns the view on the column; fixed never scrolls.
662				r = Rect{X: col.X, Y: 0, W: w.Output.W, H: w.Output.H}
663				if w.Overflow == OverflowFixed {
664					r.X = 0
665				}
666			}
667			if hidden {
668				r = Rect{}
669			}
670			focused := !floatFocused && i == w.Focus && j == c.Focus
671			result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: focused, Hidden: hidden})
672			y += h + gap
673		}
674	}
675	setNeighbors(result, gap)
676	// Floating windows go last: they are drawn and hit on top.
677	for _, f := range w.Floats {
678		p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: floatFocused && f.ID == focusedID, Inset: ports.SideAll}
679		// Floats stay above a fullscreen window: a dialog opened from a
680		// fullscreen app must be seen.
681		if w.fullscreen == f.ID {
682			p.Rect, p.Fullscreen = Rect{W: w.Output.W, H: w.Output.H}, true
683		}
684		result = append(result, p)
685	}
686	return result
687}
688
689// setNeighbors marks the sides where tiles touch across the gap, from
690// geometry alone: client sizes do not change as the view scrolls.
691func setNeighbors(tiles []Placement, gap int) {
692	overlap := func(a0, a1, b0, b1 int) bool { return a0 < b1 && b0 < a1 }
693	for i := range tiles {
694		a := &tiles[i]
695		if a.Hidden || a.Fullscreen || a.Rect.W <= 0 || a.Rect.H <= 0 {
696			continue
697		}
698		for j := range tiles {
699			b := tiles[j].Rect
700			if i == j || tiles[j].Hidden || tiles[j].Fullscreen || b.W <= 0 || b.H <= 0 {
701				continue
702			}
703			r := a.Rect
704			switch {
705			case r.X+r.W+gap == b.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H):
706				a.Neighbors |= ports.SideRight
707			case b.X+b.W+gap == r.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H):
708				a.Neighbors |= ports.SideLeft
709			case r.Y+r.H+gap == b.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W):
710				a.Neighbors |= ports.SideBottom
711			case b.Y+b.H+gap == r.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W):
712				a.Neighbors |= ports.SideTop
713			}
714		}
715		a.Inset = a.Neighbors
716		if gap == 0 {
717			a.Inset &= ports.SideRight | ports.SideBottom
718		}
719	}
720}
721
722// floatRect centres a floating window in the usable area, its border
723// around its client size, clamped to the area.
724func (w *Workspace) floatRect(f Float) Rect {
725	u := w.Usable
726	fw, fh := f.W, f.H
727	if fw <= 0 || fh <= 0 {
728		fw, fh = u.W/2, u.H/2
729	}
730	fw, fh = min(fw+2*w.border, u.W), min(fh+2*w.border, u.H)
731	return Rect{X: u.X + (u.W-fw)/2, Y: u.Y + (u.H-fh)/2, W: fw, H: fh}
732}

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 25

Tags 10

↑↓ moveenter openesc close