layout.go RawHistoryFolder

1package core
2
3import (
4	"math"
5	"slices"
6	"time"
7
8	"github.com/bnema/neferwl/internal/ports"
9)
10
11type WindowID = ports.WindowID
12
13type Rect = ports.Rect
14
15// maxCells is the largest width a rule or a slot may declare, in cells.
16const maxCells = 16
17
18// Column.Cells is the width of the column in cells, a cell being one
19// max-columns-th of the usable width. Zero (auto) shares the width equally
20// with the other auto columns, up to MaxColumns visible at once.
21type Column struct {
22	Windows   []WindowID
23	Cells     int
24	FullWidth bool
25	Focus     int
26	// Slot is the declared column number (workspace.<name>.column.N) of a
27	// slot window; 0 for normal columns.
28	Slot int
29	// Shares are the row heights set by set-window-height, in percent, in
30	// Windows order. They apply only while there is one per window and
31	// they sum to 100; otherwise the rows share the height equally.
32	Shares []int
33}
34type Placement struct {
35	ID                          WindowID
36	Rect                        Rect
37	Fullscreen, Focused, Hidden bool
38	// Floating windows sit at their own size. Below marks a native float
39	// ordered behind the columns (never a stashed window).
40	Floating, Below bool
41	// Peek is a stashed window peeking in beside the selected one, or an
42	// overview preview of a neighbor workspace: dimmed.
43	Peek bool
44	// Veil is the share (0..1) of the peek veil (Stash.Dim) the window
45	// draws: 1 for a settled peek, less while a stash slide brings it to
46	// the centre. Set wherever Peek is, and on a sliding stash selection.
47	Veil float64
48	// Preview is the scale of an overview preview: its buffer is drawn
49	// that much smaller in Rect, the client keeps its size. 0 otherwise.
50	Preview float64
51	// Neighbors are the sides touching a tile visible in the usable area.
52	// Inset reserves room for drawn lines: all sides of a float; for
53	// tiles without gaps, only the right and bottom shared sides.
54	Neighbors, Inset ports.Sides
55	// Leaving marks a window that closed or hid but is still drawn while
56	// it fades out (transition.go). It stays Hidden, so input, focus,
57	// popups and configures ignore it; only the scene shows it.
58	Leaving bool
59	// Fade, Dim and Zoom are set on shown layouts only (refreshShown),
60	// animated: the window's fade (0 opaque, 1 invisible), a veil opacity
61	// added to the one Veil draws (0..1), and the content zoom as drawn
62	// when a scale motion runs (Preview times the drawn/settled width, 1
63	// when the content is drawn at its size; 0 without one). Preview
64	// itself stays the settled one: input, popups and the renderer's paint
65	// order read it to tell a card from a window.
66	Fade, Dim, Zoom float64
67}
68
69// peeking marks p a peek drawing share of the peek veil (Stash.Dim).
70func (p *Placement) peeking(veil float64) { p.Peek, p.Veil = true, veil }
71
72// Overflow says what happens past MaxColumns columns.
73type Overflow string
74
75const (
76	// OverflowScroll opens further columns to the right; the view scrolls.
77	OverflowScroll Overflow = "scroll"
78	// OverflowCascade wraps columns into vertically scrolling viewport bands.
79	OverflowCascade Overflow = "cascade"
80	// OverflowFixed keeps every column on screen: past the max, each new
81	// column splits the newest one, alternating top/bottom and left/right
82	// (dwindle spiral).
83	OverflowFixed Overflow = "fixed"
84)
85
86type Workspace struct {
87	// ID stays with the workspace across reorder and monitor moves.
88	ID uint64
89	// Name is set for workspaces declared in config; empty for dynamic ones.
90	Name string
91	// overviewAfter is the numbered workspace from which this named workspace
92	// was invoked. It only orders overview rows; numbering stays unchanged.
93	overviewAfter *Workspace
94	Overflow      Overflow
95	Columns       []Column
96	// maximized remembers recently maximized columns by window ID, newest
97	// first; stack() filters removed windows at read time.
98	maximized []WindowID
99	// recent holds one window per column the user focused, newest first
100	// (switcher.go); columns that went are dropped when read.
101	recent []WindowID
102	Focus  int
103	View   int
104	// View is the settled offset along the layout's content axis.
105	// view is its presentation offset, in logical pixels.
106	view slide
107	// Output is the effective viewport in monitor coordinates: the whole
108	// monitor unless the workspace has a size override, then a centered
109	// rectangle no larger than the monitor. Fullscreen fills it.
110	Output Rect
111	// Usable is the layer reservations of the monitor intersected with Output.
112	Usable Rect
113	// monitor is the full logical monitor and reserved its area left by
114	// layer surfaces, both from the monitor; size is the override (0 inherits).
115	monitor, reserved Rect
116	size              [2]int
117	Gaps              int
118	MaxColumns        int
119	// border is drawn around floating windows, inside their rect.
120	border     int
121	fullscreen WindowID
122	// left is the window whose fullscreen the user left for another
123	// window: its client requests are refused until it is focused again,
124	// so an app that re-asks at once (Wine) does not cover that window.
125	left WindowID
126	// Floats are native floating windows in user selection order;
127	// below/above and dialog groups determine their layout order.
128	Floats     []Float
129	floatFocus bool
130	// Stash holds the windows set aside by toggle-window-stash, left to
131	// right (stash.go); stashAt is the selected one. stashFocus is set
132	// while it has the focus, under a focused native float.
133	Stash   []Float
134	stashAt int
135	// stashView slides the stash: the selected window is drawn at
136	// stashAt+stashView.off, in stash windows. Pure presentation over
137	// stashAt.
138	stashView   slide
139	stashFocus  bool
140	stashHidden bool
141	// stashWidth is the width of a stashed window (0: the default) and
142	// stashGap the space between it and its neighbors, in percent of the
143	// usable width.
144	stashWidth, stashGap int
145	// hiddenFullscreen holds a stashed window's fullscreen while the stash
146	// is hidden, restored when shown.
147	hiddenFullscreen WindowID
148	// stashOver is the covering fullscreen window the stash shows over, a
149	// user override (ToggleStashVisible) or a stashed window's activation
150	// (Activate); 0 without. Bound to that window,
151	// it ends with its cover. Focusing another window ends it too.
152	stashOver WindowID
153	// home is the monitor (key or connector) the workspace belongs to; ""
154	// means the one it is on. On another monitor it is a guest; homePos is
155	// its position there, where it returns.
156	home    string
157	homePos int
158	// termAt is when core last spawned a terminal for this workspace.
159	termAt time.Time
160	// colBuf, rowBuf and tileBuf are appendLayout's scratch (column rects,
161	// the rows of one column, the tiles before the floats are merged in),
162	// reused across calls and never handed out: owner goroutine only, and
163	// appendLayout does not nest on one workspace.
164	colBuf, rowBuf []Rect
165	tileBuf        []Placement
166	// zoomBuf is overviewZoom's scratch for the tiles it measures.
167	zoomBuf []Placement
168}
169
170// origPlace remembers where a stashed window was: its column, its row in a
171// stacked column, the column's width in cells and slot.
172type origPlace struct {
173	col, row, slot int
174	// stacked holds the windows left in the column: the window returns to
175	// its row in the column that still holds one of them.
176	stacked []WindowID
177	cells   int
178	// fullWidth is the column's maximize-column state.
179	fullWidth bool
180}
181
182// Float is a floating window and its client size, logical.
183type Float struct {
184	ID   WindowID
185	W, H int
186	// below places a covering native float behind columns and stash.
187	below bool
188	// back records the former column of a stashed window or a free float;
189	// nil for native floats.
190	back *origPlace
191	// free marks a window the user floated (toggle-floating) or dragged:
192	// core owns its size and its centre is at cx, cy, fractions of the
193	// usable area, so it keeps its place on any output.
194	free   bool
195	cx, cy float64
196	// over is the parent of a dialog: while the parent covers the output
197	// fullscreen, the dialog shows over it and may take the focus.
198	over WindowID
199	// modal marks a dialog that blocks its parent: the user focusing the
200	// parent focuses it (modalOf).
201	modal bool
202}
203
204func (w *Workspace) empty() bool {
205	return len(w.Columns) == 0 && len(w.Floats) == 0 && len(w.Stash) == 0
206}
207
208// columnWindows counts the windows in the columns.
209func (w *Workspace) columnWindows() int {
210	n := 0
211	for _, c := range w.Columns {
212		n += len(c.Windows)
213	}
214	return n
215}
216
217// appendHidden appends every window of w as a Hidden placement, in the order
218// of windows (columns, stash, then floats when floats is set), without
219// building the list.
220func (w *Workspace) appendHidden(dst []Placement, floats bool) []Placement {
221	for _, c := range w.Columns {
222		for _, id := range c.Windows {
223			dst = append(dst, Placement{ID: id, Hidden: true})
224		}
225	}
226	for _, f := range w.Stash {
227		dst = append(dst, Placement{ID: f.ID, Hidden: true})
228	}
229	if floats {
230		for _, f := range w.Floats {
231			dst = append(dst, Placement{ID: f.ID, Hidden: true})
232		}
233	}
234	return dst
235}
236
237// windows lists every window: columns first, then the stash, then native
238// floats.
239func (w *Workspace) windows() []WindowID { return w.appendWindows(nil) }
240
241// appendWindows appends windows' list to dst.
242func (w *Workspace) appendWindows(dst []WindowID) []WindowID {
243	for _, c := range w.Columns {
244		dst = append(dst, c.Windows...)
245	}
246	for _, f := range w.Stash {
247		dst = append(dst, f.ID)
248	}
249	for _, f := range w.Floats {
250		dst = append(dst, f.ID)
251	}
252	return dst
253}
254
255// cell is the 1-based column and row of a tiled window, 0, 0 otherwise.
256func (w *Workspace) cell(id WindowID) (col, row int) {
257	for ci, c := range w.Columns {
258		if ri := slices.Index(c.Windows, id); ri >= 0 {
259			return ci + 1, ri + 1
260		}
261	}
262	return 0, 0
263}
264
265func (w *Workspace) floatIndex(id WindowID) int {
266	return slices.IndexFunc(w.Floats, func(f Float) bool { return f.ID == id })
267}
268
269// coversFloat uses the placed rectangle, not a map-time size. Allow up to
270// two border widths plus two logical pixels per axis for scale rounding.
271func (w *Workspace) coversFloat(f Float) bool {
272	if w.fullscreen == f.ID || w.Usable.W <= 0 || w.Usable.H <= 0 {
273		return false
274	}
275	r := w.floatRect(f)
276	return w.Usable.W-r.W <= 2*w.border+2 && w.Usable.H-r.H <= 2*w.border+2
277}
278
279// reconcileFloats promotes floats that no longer cover, without raising
280// existing covering floats on automatic geometry changes. It also drops
281// float focus when no raised float remains on top.
282func (w *Workspace) reconcileFloats() {
283	for i := range w.Floats {
284		if w.Floats[i].below && !w.coversFloat(w.Floats[i]) && w.fullscreen != w.Floats[i].ID {
285			w.Floats[i].below = false
286		}
287	}
288	if len(w.Floats) == 0 || w.Floats[len(w.Floats)-1].below {
289		w.floatFocus = false
290	}
291}
292
293// canLeaveFloat reports whether columns or a shown stash can receive focus.
294func (w *Workspace) canLeaveFloat() bool {
295	return len(w.Columns) > 0 || !w.stashHidden && len(w.Stash) > 0
296}
297
298// leaveFloat moves focus to the columns or shown stash under the float.
299// Without either, it keeps focus and reports false.
300func (w *Workspace) leaveFloat() bool {
301	if !w.canLeaveFloat() {
302		return false
303	}
304	w.floatFocus = false
305	w.raiseColumns()
306	return true
307}
308
309// raiseColumns moves only covering floats above the columns behind them.
310// The user-selected column group rises as one; dialogs remain above it.
311func (w *Workspace) raiseColumns() {
312	for i := range w.Floats {
313		if w.coversFloat(w.Floats[i]) {
314			w.Floats[i].below = true
315		}
316	}
317}
318
319// AddFloating shows a native floating window over the columns and the
320// stash, and focuses it.
321func (w *Workspace) AddFloating(id WindowID, width, height int) {
322	if id == 0 || w.has(id) {
323		return
324	}
325	w.Floats = append(w.Floats, Float{ID: id, W: width, H: height})
326	w.floatFocus = true
327}
328
329// AddDialog is AddFloating for a dialog of parent: it shows, focused, over
330// its parent's fullscreen. A dialog follows a user action in its parent
331// (a portal request, a file chooser), so the user expects to see it.
332func (w *Workspace) AddDialog(id, parent WindowID, width, height int) {
333	if id == 0 || w.has(id) {
334		return
335	}
336	w.Floats = append(w.Floats, Float{ID: id, W: width, H: height, over: parent})
337	w.floatFocus = true
338}
339
340// SetDialogParent follows a dialog's parent after it mapped: a cleared or
341// changed parent ends its place over the old one's fullscreen.
342func (w *Workspace) SetDialogParent(id, parent WindowID) {
343	if i := w.floatIndex(id); i >= 0 {
344		w.Floats[i].over = parent
345		w.reconcileFloats()
346	}
347}
348
349// SetModal marks a dialog as blocking its parent or not. It moves no focus
350// (a client request, ADR 011): the next user focus on the parent goes to
351// the dialog. A dialog maps focused anyway (AddDialog).
352func (w *Workspace) SetModal(id WindowID, modal bool) {
353	if i := w.floatIndex(id); i >= 0 {
354		w.Floats[i].modal = modal
355	}
356}
357
358// modalOf is the modal dialog that blocks window id, 0 when none: the
359// topmost modal dialog of id, or of that dialog in turn (a confirmation
360// over a "Save as…"). Only dialogs on id's workspace count.
361func (w *Workspace) modalOf(id WindowID) WindowID {
362	blocker := WindowID(0)
363	// Each hop goes to another float: at most len(w.Floats) hops.
364	for range len(w.Floats) {
365		next := WindowID(0)
366		for i := len(w.Floats) - 1; i >= 0; i-- {
367			if f := w.Floats[i]; f.modal && f.over == id && id != 0 && f.ID != id && f.ID != blocker {
368				next = f.ID
369				break
370			}
371		}
372		if next == 0 {
373			break
374		}
375		blocker, id = next, next
376	}
377	return blocker
378}
379
380// focusModal sends the focus the user just moved onto the parent of a
381// modal dialog to that dialog, unless the move came from the dialog: a move
382// away from a dialog reaches its parent, and the next one goes on. Nothing
383// happens when the focus did not move.
384func (w *Workspace) focusModal(from WindowID) {
385	to, ok := w.Focused()
386	if !ok || to == from {
387		return
388	}
389	d := w.modalOf(to)
390	if d == 0 || d == from {
391		return
392	}
393	if c := w.cover(); c != 0 && c != d {
394		if i := w.floatIndex(d); i >= 0 && !w.overCover(w.Floats[i]) {
395			// Hidden under another window's fullscreen: leave it, as
396			// Activate does for a window not shown over the cover, so
397			// the dialog shows.
398			w.leaveFullscreen()
399		}
400	}
401	w.FocusID(d)
402}
403
404// overCover reports whether float f is drawn over the covering fullscreen
405// window: a dialog of the cover, or of a stashed window while the stash
406// shows over the cover (a "Save as…" of the app the user picked there),
407// or a dialog of such a dialog (an "Overwrite?" over the "Save as…").
408func (w *Workspace) overCover(f Float) bool {
409	c := w.cover()
410	if c == 0 || f.ID == c {
411		return false
412	}
413	owner := w.dialogOwner(f, c)
414	return owner == c || owner != 0 && w.stashOverCover() && w.stashIndex(owner) >= 0
415}
416
417// dialogOwner is the window float f is a dialog of, through dialogs of
418// dialogs: the first parent that is no float, or stop. 0 when f is no
419// dialog, its chain ends on a plain float, or it loops.
420func (w *Workspace) dialogOwner(f Float, stop WindowID) WindowID {
421	// Each hop goes to another float: at most len(w.Floats) hops.
422	for range len(w.Floats) {
423		if f.over == 0 || f.over == f.ID {
424			return 0
425		}
426		if f.over == stop {
427			return stop
428		}
429		i := w.floatIndex(f.over)
430		if i < 0 {
431			return f.over
432		}
433		f = w.Floats[i]
434	}
435	return 0
436}
437
438// coverDialog is the topmost dialog shown over the covering fullscreen
439// window (overCover) when the floats have the focus, else 0. The floats it
440// hides cannot take the focus.
441func (w *Workspace) coverDialog() WindowID {
442	if !w.floatFocus {
443		return 0
444	}
445	for i := len(w.Floats) - 1; i >= 0; i-- {
446		if f := w.Floats[i]; !f.below && w.overCover(f) {
447			return f.ID
448		}
449	}
450	return 0
451}
452
453// coverDialogFocused reports whether a dialog over the covering fullscreen
454// window has the focus.
455func (w *Workspace) coverDialogFocused() bool { return w.coverDialog() != 0 }
456
457// ResizeFloating records the size a floating window draws. Core sizes a
458// free float: its reports only fill a missing size.
459func (w *Workspace) ResizeFloating(id WindowID, width, height int) {
460	if i := w.floatIndex(id); i >= 0 && (!w.Floats[i].free || w.Floats[i].W <= 0) {
461		w.Floats[i].W, w.Floats[i].H = width, height
462		w.reconcileFloats()
463	}
464}
465
466// has reports whether the workspace holds the window.
467func (w *Workspace) has(id WindowID) bool {
468	if w.isFloat(id) {
469		return true
470	}
471	for _, c := range w.Columns {
472		for _, v := range c.Windows {
473			if v == id {
474				return true
475			}
476		}
477	}
478	return false
479}
480
481func (w *Workspace) Focused() (WindowID, bool) {
482	// Nothing else is drawn but its dialogs: the covering fullscreen window
483	// has the focus unless one of them took it.
484	if full := w.cover(); full != 0 {
485		if d := w.coverDialog(); d != 0 {
486			return d, true
487		}
488		if w.stashOverCover() {
489			return w.Stash[w.stashAt].ID, true
490		}
491		return full, true
492	}
493	if w.floatFocus && len(w.Floats) > 0 && !w.Floats[len(w.Floats)-1].below {
494		return w.Floats[len(w.Floats)-1].ID, true
495	}
496	if w.stashFocused() {
497		return w.Stash[w.stashAt].ID, true
498	}
499	if w.Focus < 0 || w.Focus >= len(w.Columns) {
500		return 0, false
501	}
502	c := w.Columns[w.Focus]
503	if c.Focus < 0 || c.Focus >= len(c.Windows) {
504		return 0, false
505	}
506	return c.Windows[c.Focus], true
507}
508func (w *Workspace) AddWindow(id WindowID) { w.addWindow(id, 0) }
509
510// addWindow is AddWindow with the width of the new column in cells (zero:
511// automatic).
512func (w *Workspace) addWindow(id WindowID, cells int) {
513	if id == 0 || w.has(id) {
514		return
515	}
516	if w.cover() != 0 {
517		// A new window never moves the view off a covering fullscreen
518		// window (ADR 011): it waits, hidden, after the focused column.
519		at := len(w.Columns)
520		if !w.policy().equalCells {
521			// A covering float or stashed window may have no column under it.
522			at = min(w.Focus+1, len(w.Columns))
523		}
524		w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Cells: cells})
525		return
526	}
527	w.addColumn(Column{Windows: []WindowID{id}, Cells: cells})
528}
529
530// addColumn places a new column like a new window and focuses it.
531func (w *Workspace) addColumn(col Column) {
532	at := 0
533	if w.policy().equalCells {
534		// Ordered overflow depends only on window order: new windows go last.
535		at = len(w.Columns)
536	} else if len(w.Columns) > 0 {
537		at = w.Focus + 1
538	}
539	if w.policy().maximizeHides() && len(w.Columns) > 0 {
540		w.unmaximize()
541	}
542	w.Columns = append(w.Columns, Column{})
543	copy(w.Columns[at+1:], w.Columns[at:])
544	w.Columns[at] = col
545	w.Focus = at
546	w.scroll()
547}
548
549// AddSlotWindow places a slot window in its own column, ordered by slot
550// number before the other columns. Focus stays on the focused window: slot
551// windows are automatic events (ADR 011 golden rule).
552func (w *Workspace) AddSlotWindow(id WindowID, slot int, cells int) {
553	if id == 0 || w.has(id) {
554		return
555	}
556	// Right after the last slot column with a lower number, else first.
557	at := 0
558	for i, c := range w.Columns {
559		if c.Slot > 0 && c.Slot < slot {
560			at = i + 1
561		}
562	}
563	w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Cells: cells, Slot: slot})
564	if len(w.Columns) > 1 && at <= w.Focus {
565		w.Focus++
566	}
567	w.scroll()
568}
569
570// setSlotCells updates the width in cells of slot column n, if present.
571func (w *Workspace) setSlotCells(n int, cells int) {
572	for i := range w.Columns {
573		if w.Columns[i].Slot == n {
574			w.Columns[i].Cells = cells
575		}
576	}
577	w.scroll()
578}
579
580// inSlot reports whether window id is in slot column n.
581func (w *Workspace) inSlot(id WindowID, n int) bool {
582	for _, c := range w.Columns {
583		if c.Slot == n && slices.Contains(c.Windows, id) {
584			return true
585		}
586	}
587	return false
588}
589
590// unslot turns slot column n into a normal column.
591func (w *Workspace) unslot(n int) {
592	for i := range w.Columns {
593		if w.Columns[i].Slot == n {
594			w.Columns[i].Slot = 0
595		}
596	}
597}
598
599func (w *Workspace) RemoveWindow(id WindowID) {
600	w.maximized = slices.DeleteFunc(w.maximized, func(v WindowID) bool { return v == id })
601	w.recent = slices.DeleteFunc(w.recent, func(v WindowID) bool { return v == id })
602	if w.fullscreen == id {
603		w.fullscreen = 0
604	}
605	if w.stashOver == id {
606		w.stashOver = 0
607	}
608	if w.hiddenFullscreen == id {
609		w.hiddenFullscreen = 0
610	}
611	if w.left == id {
612		w.left = 0
613	}
614	if i := w.floatIndex(id); i >= 0 {
615		w.Floats = slices.Delete(w.Floats, i, i+1)
616		if len(w.Floats) == 0 || w.Floats[len(w.Floats)-1].below {
617			w.floatFocus = false
618		}
619		return
620	}
621	if i := w.stashIndex(id); i >= 0 {
622		// The right neighbor takes the selection, else the left one.
623		w.Stash = slices.Delete(w.Stash, i, i+1)
624		if i < w.stashAt || w.stashAt == len(w.Stash) {
625			w.stashAt = max(w.stashAt-1, 0)
626		}
627		if len(w.Stash) == 0 {
628			w.stashFocus, w.stashHidden, w.stashOver = false, false, 0
629		}
630		return
631	}
632	for i := range w.Columns {
633		for j, v := range w.Columns[i].Windows {
634			if v != id {
635				continue
636			}
637			c := &w.Columns[i]
638			c.Windows = append(c.Windows[:j], c.Windows[j+1:]...)
639			c.Shares = nil
640			if len(c.Windows) == 0 {
641				w.Columns = append(w.Columns[:i], w.Columns[i+1:]...)
642				if len(w.Columns) == 0 {
643					w.Focus = 0
644					w.View = 0
645					return
646				}
647				if i < w.Focus || (i == w.Focus && i > 0) {
648					w.Focus--
649				}
650			} else {
651				if j < c.Focus {
652					c.Focus--
653				} else if j == c.Focus && c.Focus > 0 {
654					c.Focus--
655				}
656			}
657			w.scroll()
658			return
659		}
660	}
661}
662
663// FocusID selects a window and scrolls its column into view; a native
664// float is raised, a stashed window is selected.
665func (w *Workspace) FocusID(id WindowID) bool {
666	if i := w.floatIndex(id); i >= 0 {
667		f := w.Floats[i]
668		f.below = false
669		w.Floats = append(slices.Delete(w.Floats, i, i+1), f)
670		if !w.overCover(f) {
671			// A dialog shown over the cover keeps the stash over it;
672			// leaving it returns there.
673			w.dropStashOver()
674		}
675		w.floatFocus = true
676		return true
677	}
678	if i := w.stashIndex(id); i >= 0 {
679		if w.stashHidden {
680			return false
681		}
682		w.stashAt, w.stashFocus, w.floatFocus = i, true, false
683		return true
684	}
685	for i := range w.Columns {
686		for j, v := range w.Columns[i].Windows {
687			if v == id {
688				w.dropStashOver()
689				w.floatFocus, w.stashFocus = false, false
690				w.raiseColumns()
691				if w.policy().maximizeHides() && w.Focus != i && w.Focus < len(w.Columns) && !w.Columns[i].FullWidth {
692					w.unmaximize()
693				}
694				w.Focus = i
695				w.Columns[i].Focus = j
696				w.scroll()
697				return true
698			}
699		}
700	}
701	return false
702}
703
704// FocusColumn moves focus to the column on that side. From a covering
705// fullscreen window it leaves fullscreen for the window there (leaveCover);
706// false means it stays, with no window on that side.
707func (w *Workspace) FocusColumn(dir int) bool {
708	from, _ := w.Focused()
709	defer w.focusModal(from)
710	if w.coverDialogFocused() {
711		// The first move goes back to the window under it: the stash
712		// over the cover, or the cover.
713		w.floatFocus = false
714		return true
715	}
716	if w.pinned() && !w.stashOverCover() {
717		return w.leaveCover(func() { w.focusColumn(dir) })
718	}
719	w.focusColumn(dir)
720	return true
721}
722
723func (w *Workspace) focusColumn(dir int) {
724	if w.floatFocus {
725		// The first move leaves the native float for the stash or columns,
726		// if there is one: otherwise the float keeps the focus.
727		w.leaveFloat()
728		return
729	}
730	if w.stashFocused() {
731		// Inside the stash, stopping at its ends: only hiding it leaves.
732		if (dir == -1 || dir == 1) && w.stashAt+dir >= 0 && w.stashAt+dir < len(w.Stash) {
733			w.stashAt += dir
734		}
735		return
736	}
737	if i := w.columnToward(dir); i >= 0 {
738		w.raiseColumns()
739		if w.policy().maximizeHides() {
740			w.unmaximize()
741		}
742		w.Focus = i
743		w.scroll()
744	}
745}
746
747// columnToward returns the column focus-column-left/right (dir -1/1) goes
748// to, or -1 at the edge. Scroll overflow follows the column order; fixed
749// overflow stacks columns (spiral), so it follows the screen: the column
750// on that side. Cascade stays in its band: past the band edge is the edge.
751func (w *Workspace) columnToward(dir int) int {
752	if len(w.Columns) == 0 || (dir != -1 && dir != 1) || w.pinned() {
753		return -1
754	}
755	if w.onScreenFocus() {
756		return w.screenNeighbor(dir, 0)
757	}
758	if i := w.Focus + dir; !w.leavesBand(i) {
759		return i
760	}
761	return -1
762}
763
764// onScreenFocus reports whether directional focus follows the screen:
765// fixed overflow with every column visible.
766func (w *Workspace) onScreenFocus() bool {
767	return w.Overflow == OverflowFixed && w.fullscreen == 0 && !w.Columns[w.Focus].FullWidth
768}
769
770// FocusWindow moves focus inside the column, then to a fixed-overflow column
771// above or below on screen. Up at the top raises the topmost demoted covering
772// float; false means no window is available in that direction. From a
773// covering fullscreen window it leaves fullscreen for that window.
774func (w *Workspace) FocusWindow(dir int) bool {
775	from, _ := w.Focused()
776	defer w.focusModal(from)
777	if w.coverDialogFocused() {
778		w.floatFocus = false
779		return true
780	}
781	if w.pinned() && !w.stashOverCover() {
782		return w.leaveCover(func() { w.focusWindow(dir) })
783	}
784	return w.focusWindow(dir)
785}
786
787func (w *Workspace) focusWindow(dir int) bool {
788	if w.floatFocus {
789		return w.leaveFloat()
790	}
791	if w.stashFocused() {
792		// The stash is one row: up and down do nothing there.
793		return true
794	}
795	if len(w.Columns) == 0 || (dir != -1 && dir != 1) {
796		return false
797	}
798	c := &w.Columns[w.Focus]
799	if c.Focus+dir >= 0 && c.Focus+dir < len(c.Windows) {
800		c.Focus += dir
801		w.raiseColumns()
802		w.scroll()
803		return true
804	}
805	if w.policy().wraps && w.focusBand(dir) {
806		return true
807	}
808	if w.onScreenFocus() {
809		if i := w.screenNeighbor(0, dir); i >= 0 {
810			w.Focus = i
811			w.raiseColumns()
812			w.Columns[i].Focus = 0
813			if dir < 0 {
814				w.Columns[i].Focus = len(w.Columns[i].Windows) - 1
815			}
816			return true
817		}
818	}
819	if dir < 0 {
820		for i := len(w.Floats) - 1; i >= 0; i-- {
821			if f := w.Floats[i]; f.below && w.coversFloat(f) {
822				return w.FocusID(f.ID)
823			}
824		}
825	}
826	return false
827}
828
829// screenNeighbor returns the column on screen next to the focused one, left
830// or right (dx -1/1) or above or below (dy -1/1), or -1. The closest wins,
831// then the one sharing the longest edge, then the nearest in column order.
832func (w *Workspace) screenNeighbor(dx, dy int) int {
833	rects := w.columnRects()
834	cur := rects[w.Focus]
835	best, bestDist, bestOverlap := -1, 0, 0
836	for i, r := range rects {
837		var overlap, dist int
838		switch {
839		case dx < 0:
840			overlap, dist = min(cur.Y+cur.H, r.Y+r.H)-max(cur.Y, r.Y), cur.X-(r.X+r.W)
841		case dx > 0:
842			overlap, dist = min(cur.Y+cur.H, r.Y+r.H)-max(cur.Y, r.Y), r.X-(cur.X+cur.W)
843		case dy < 0:
844			overlap, dist = min(cur.X+cur.W, r.X+r.W)-max(cur.X, r.X), cur.Y-(r.Y+r.H)
845		default:
846			overlap, dist = min(cur.X+cur.W, r.X+r.W)-max(cur.X, r.X), r.Y-(cur.Y+cur.H)
847		}
848		if i == w.Focus || overlap <= 0 || dist < 0 {
849			continue
850		}
851		closer := best >= 0 && (dist < bestDist || dist == bestDist && (overlap > bestOverlap ||
852			overlap == bestOverlap && abs(i-w.Focus) < abs(best-w.Focus)))
853		if best < 0 || closer {
854			best, bestDist, bestOverlap = i, dist, overlap
855		}
856	}
857	return best
858}
859
860func abs(n int) int { return max(n, -n) }
861func (w *Workspace) MoveColumn(dir int) {
862	if w.onFloat() {
863		return
864	}
865	if (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) {
866		i := w.Focus
867		w.Columns[i], w.Columns[i+dir] = w.Columns[i+dir], w.Columns[i]
868		w.Focus += dir
869		w.scroll()
870	}
871}
872
873// MoveWindow moves the focused tile up or down (dir -1/1) inside its
874// column; focus follows it. A slot window leaving row 0 releases the slot.
875// Past the column, a cascade column moves to the adjacent band.
876func (w *Workspace) MoveWindow(dir int) {
877	if w.onFloat() || len(w.Columns) == 0 || (dir != -1 && dir != 1) || w.fullscreenHides() {
878		return
879	}
880	c := &w.Columns[w.Focus]
881	to := c.Focus + dir
882	if to < 0 || to >= len(c.Windows) {
883		w.moveBand(dir)
884		return
885	}
886	if c.Focus == 0 || to == 0 {
887		w.dropSlotOf(c.Windows[0])
888	}
889	c.Windows[c.Focus], c.Windows[to] = c.Windows[to], c.Windows[c.Focus]
890	if validShares(*c) {
891		c.Shares[c.Focus], c.Shares[to] = c.Shares[to], c.Shares[c.Focus]
892	}
893	c.Focus = to
894	w.scroll()
895}
896
897// minShare is the smallest row height set-window-height leaves, percent.
898const minShare = 10
899
900// ResizeRow changes the focused window's height share by pct percent; the
901// other rows of its column give or take it in proportion to their shares.
902// Every row keeps at least minShare percent.
903func (w *Workspace) ResizeRow(pct int) {
904	if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() {
905		return
906	}
907	c := &w.Columns[w.Focus]
908	n := len(c.Windows)
909	if n < 2 || n*minShare > 100 {
910		return
911	}
912	shares := c.Shares
913	if !validShares(*c) {
914		shares = make([]int, n)
915		for i := range shares {
916			shares[i] = 100 / n
917		}
918		shares[n-1] += 100 % n
919	} else {
920		shares = slices.Clone(shares)
921	}
922	f := c.Focus
923	target := min(max(shares[f]+pct, minShare), 100-minShare*(n-1))
924	delta := target - shares[f]
925	if delta == 0 {
926		return
927	}
928	// The others share -delta in proportion to their room: what they have
929	// above minShare when shrinking, their share when growing.
930	weight := func(v int) int {
931		if delta > 0 {
932			return v - minShare
933		}
934		return v
935	}
936	total := 0
937	for i, v := range shares {
938		if i != f {
939			total += weight(v)
940		}
941	}
942	left := delta
943	for i, v := range shares {
944		if i == f {
945			continue
946		}
947		part := 0
948		if total > 0 {
949			part = delta * weight(v) / total
950		}
951		shares[i] = v - part
952		left -= part
953	}
954	shares[f] = target
955	// Rounding: the remainder goes to the rows that still have room, last
956	// first.
957	for i := n - 1; left != 0 && i >= 0; i-- {
958		if i == f {
959			continue
960		}
961		step := left
962		if left > 0 {
963			step = min(left, shares[i]-minShare)
964		}
965		shares[i] -= step
966		left -= step
967	}
968	c.Shares = shares
969}
970
971// takeColumn removes the focused column and returns it, as a normal column.
972func (w *Workspace) takeColumn() (Column, bool) {
973	if len(w.Columns) == 0 || w.onFloat() {
974		return Column{}, false
975	}
976	col := w.Columns[w.Focus]
977	col.Slot = 0
978	if slices.Contains(col.Windows, w.fullscreen) {
979		w.fullscreen = 0
980	}
981	if slices.Contains(col.Windows, w.stashOver) {
982		w.stashOver = 0
983	}
984	if slices.Contains(col.Windows, w.left) {
985		w.left = 0
986	}
987	// Its windows leave this workspace's recent use with it.
988	w.recent = slices.DeleteFunc(w.recent, func(v WindowID) bool { return slices.Contains(col.Windows, v) })
989	w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1)
990	w.Focus = min(w.Focus, max(len(w.Columns)-1, 0))
991	if len(w.Columns) == 0 {
992		w.View = 0
993	} else {
994		w.scroll()
995	}
996	return col, true
997}
998
999// insertColumn adds a column at index at (clamped) and focuses it.
1000func (w *Workspace) insertColumn(at int, col Column) {
1001	at = min(max(at, 0), len(w.Columns))
1002	if w.policy().maximizeHides() && len(w.Columns) > 0 {
1003		w.unmaximize()
1004	}
1005	w.Columns = slices.Insert(w.Columns, at, col)
1006	w.Focus = at
1007	w.scroll()
1008}
1009
1010// GrowColumn widens the focused column by one cell, taking it from another
1011// column of its lane, or from the empty space in scroll overflow. A lane
1012// that cannot grow further goes back to equal shares when cycle is set
1013// (cycle-column-width); otherwise nothing changes (set-column-width +N).
1014// A lane is the band of a cascade, or the whole row in fixed overflow while
1015// it holds MaxColumns columns at most; past that the spiral ignores cells.
1016func (w *Workspace) GrowColumn(cycle bool) {
1017	if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() {
1018		return
1019	}
1020	w.unmaximize()
1021	defer w.scroll()
1022	k := max(w.MaxColumns, 1)
1023	if w.policy().equalCells {
1024		if !w.policy().wraps && !w.laneRow() {
1025			return
1026		}
1027		first, last := w.laneBounds(w.laneOf(w.Focus))
1028		n := last - first + 1
1029		if n == 1 || n >= k {
1030			// Nothing to grow, but a lane left uneven (a column joined
1031			// it, or max-columns shrank) still cycles back to equal.
1032			if cycle && slices.ContainsFunc(w.Columns[first:last+1], func(c Column) bool { return c.Cells != 0 }) {
1033				w.setLaneCells(first, make([]int, n))
1034			}
1035			return
1036		}
1037		cells, ok := w.laneCells(first, last, w.Focus)
1038		if !ok {
1039			w.setLaneCells(first, cells)
1040			if k%n > 0 {
1041				return // The remainder to the focused column was the step.
1042			}
1043		}
1044		if d := w.donor(first, last, cells); d >= 0 {
1045			w.moveCell(first, cells, d, w.Focus)
1046		} else if cycle {
1047			w.setLaneCells(first, make([]int, n))
1048		}
1049		return
1050	}
1051	c := &w.Columns[w.Focus]
1052	cur := w.scrollCells(w.Focus)
1053	switch {
1054	case cur < k:
1055		c.Cells = cur + 1
1056	case cycle:
1057		c.Cells = 0
1058	}
1059}
1060
1061// ShrinkColumn narrows the focused column by one cell, giving it to its
1062// right neighbor in the lane (the left one at the lane's end). A column
1063// never goes below one cell.
1064func (w *Workspace) ShrinkColumn() {
1065	if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() {
1066		return
1067	}
1068	w.unmaximize()
1069	defer w.scroll()
1070	k := max(w.MaxColumns, 1)
1071	if w.policy().equalCells {
1072		if !w.policy().wraps && !w.laneRow() {
1073			return
1074		}
1075		first, last := w.laneBounds(w.laneOf(w.Focus))
1076		n := last - first + 1
1077		if n == 1 || n >= k {
1078			return
1079		}
1080		cells, _ := w.laneCells(first, last, -1)
1081		if cells[w.Focus-first] <= 1 {
1082			return
1083		}
1084		to := w.Focus + 1
1085		if to > last {
1086			to = w.Focus - 1
1087		}
1088		w.moveCell(first, cells, w.Focus, to)
1089		return
1090	}
1091	w.Columns[w.Focus].Cells = max(w.scrollCells(w.Focus)-1, 1)
1092}
1093
1094// scrollCells is the width in cells of column i in scroll overflow: its
1095// cells, at most the ones in view for an auto column.
1096func (w *Workspace) scrollCells(i int) int {
1097	if c := w.Columns[i].Cells; c > 0 {
1098		return c
1099	}
1100	k := max(w.MaxColumns, 1)
1101	return k / min(len(w.Columns), k)
1102}
1103
1104// laneCells is the cells of the lane first..last, summing to MaxColumns.
1105// The stored cells are used when every column is set and they sum to it
1106// (ok); otherwise each column gets an equal share and the remainder goes
1107// to column remAt, or one cell each to the leftmost columns when remAt is
1108// negative.
1109func (w *Workspace) laneCells(first, last, remAt int) (cells []int, ok bool) {
1110	k := max(w.MaxColumns, 1)
1111	n := last - first + 1
1112	cells = make([]int, n)
1113	sum := 0
1114	for i := range cells {
1115		cells[i] = w.Columns[first+i].Cells
1116		sum += cells[i]
1117		if cells[i] == 0 {
1118			sum = -1 << 30
1119		}
1120	}
1121	if sum == k {
1122		return cells, true
1123	}
1124	for i := range cells {
1125		cells[i] = k / n
1126	}
1127	if remAt >= 0 {
1128		cells[remAt-first] += k % n
1129	} else {
1130		for i := range k % n {
1131			cells[i]++
1132		}
1133	}
1134	return cells, false
1135}
1136
1137// setLaneCells stores cells on the lane's columns, from column first.
1138func (w *Workspace) setLaneCells(first int, cells []int) {
1139	for i, c := range cells {
1140		w.Columns[first+i].Cells = c
1141	}
1142}
1143
1144// donor is the lane column that gives a cell to the focused one: among the
1145// others with more than one cell, the largest; on a tie the nearest to the
1146// focus, the right side first. -1 when there is none.
1147func (w *Workspace) donor(first, last int, cells []int) int {
1148	best := -1
1149	for i := first; i <= last; i++ {
1150		c := cells[i-first]
1151		if i == w.Focus || c <= 1 {
1152			continue
1153		}
1154		if best < 0 {
1155			best = i
1156			continue
1157		}
1158		bc, bd, d := cells[best-first], abs(best-w.Focus), abs(i-w.Focus)
1159		if c > bc || c == bc && (d < bd || d == bd && i > w.Focus) {
1160			best = i
1161		}
1162	}
1163	return best
1164}
1165
1166// moveCell moves one cell from column from to column to of the lane that
1167// starts at first, and stores the lane.
1168func (w *Workspace) moveCell(first int, cells []int, from, to int) {
1169	cells[from-first]--
1170	cells[to-first]++
1171	w.setLaneCells(first, cells)
1172}
1173
1174// maximize sets FullWidth on column i; unlike ToggleFullWidth it never toggles.
1175func (w *Workspace) maximize(i int) {
1176	if i >= 0 && i < len(w.Columns) {
1177		w.Columns[i].FullWidth = true
1178		id := w.Columns[i].Windows[w.Columns[i].Focus]
1179		w.maximized = slices.DeleteFunc(w.maximized, func(v WindowID) bool { return v == id })
1180		w.maximized = slices.Insert(w.maximized, 0, id)
1181	}
1182}
1183
1184// hiddenByMaximized reports whether the maximized focused column hides
1185// column i on screen: in fixed overflow it hides the rest of the row. A
1186// maximized cascade column has its band to itself, so it hides nothing.
1187func (w *Workspace) hiddenByMaximized(i int) bool {
1188	return w.policy().maximizeHides() && w.Focus < len(w.Columns) && w.Columns[w.Focus].FullWidth && i != w.Focus
1189}
1190
1191// unmaximize clears the focused column in scroll and cascade, or all
1192// maximized columns in fixed overflow (including after Escape changed focus).
1193func (w *Workspace) unmaximize() {
1194	if w.policy().maximizeHides() {
1195		w.clearMaximized()
1196	} else if w.Focus < len(w.Columns) {
1197		w.Columns[w.Focus].FullWidth = false
1198	}
1199}
1200
1201// clearMaximized unmaximizes every column and forgets the history.
1202func (w *Workspace) clearMaximized() {
1203	for i := range w.Columns {
1204		w.Columns[i].FullWidth = false
1205	}
1206	w.maximized = nil
1207}
1208
1209// transferMaximization changes the maximized column without forgetting MRU.
1210func (w *Workspace) transferMaximization(i int) {
1211	if i == w.Focus || i < 0 || i >= len(w.Columns) {
1212		return
1213	}
1214	w.Columns[w.Focus].FullWidth = false
1215	w.maximize(i)
1216}
1217
1218// ToggleFullWidth expands the focused tiled column without changing its saved width.
1219func (w *Workspace) ToggleFullWidth() {
1220	if len(w.Columns) == 0 || w.onFloat() {
1221		return
1222	}
1223	if w.Columns[w.Focus].FullWidth {
1224		w.unmaximize()
1225	} else {
1226		w.maximize(w.Focus)
1227	}
1228	w.scroll()
1229}
1230
1231func (w *Workspace) ToggleFullscreen() {
1232	id, ok := w.Focused()
1233	if !ok {
1234		return
1235	}
1236	if w.stashOverCover() {
1237		// A stashed window the user picked over the cover replaces it.
1238		w.leaveFullscreen()
1239	}
1240	if w.fullscreen == id {
1241		// The user was on it: it keeps the focus, in front of floats that
1242		// mapped meanwhile. Cleared first, so that floats it covers drop
1243		// below the columns.
1244		w.fullscreen = 0
1245		w.FocusID(id)
1246		w.endFullscreen()
1247		return
1248	}
1249	w.fullscreen = id
1250	w.scroll()
1251}
1252
1253// endFullscreen ends the fullscreen: floats it kept demoted are promoted
1254// again and the view follows the focus.
1255func (w *Workspace) endFullscreen() {
1256	w.fullscreen, w.stashOver = 0, 0
1257	w.reconcileFloats()
1258	w.scroll()
1259}
1260
1261// leaveFullscreen ends the fullscreen for another window the user chose,
1262// and refuses its client's requests until it is focused again (mayCover,
1263// settleLeft).
1264func (w *Workspace) leaveFullscreen() {
1265	if w.fullscreen != 0 {
1266		w.left = w.fullscreen
1267	}
1268	w.endFullscreen()
1269}
1270
1271// Activate focuses a window and makes it visible: it leaves another
1272// window's fullscreen, which hides everything else. Over a covering
1273// window, a dialog drawn over it, a stashed window or a dialog of one
1274// shows over it instead, as ToggleStashVisible does: the cover stays
1275// fullscreen behind, so a game never sees its fullscreen end. A stashed
1276// window whose own fullscreen waited while hidden replaces the cover
1277// (coverStays); a dialog of a stashed window activates that window
1278// first.
1279func (w *Workspace) Activate(id WindowID) {
1280	if c := w.cover(); c != 0 && id != c {
1281		owner := id
1282		if f := w.floatIndex(id); f >= 0 {
1283			owner = w.dialogOwner(w.Floats[f], c)
1284		}
1285		if owner != id && owner != c && w.stashIndex(owner) >= 0 && !w.coverStays(owner) {
1286			w.Activate(owner)
1287			w.FocusID(id)
1288			w.focusModal(0)
1289			return
1290		}
1291		if i := w.stashIndex(owner); i >= 0 && w.coverStays(owner) {
1292			if !w.stashOverCover() {
1293				w.showStashOver()
1294			}
1295			w.stashAt, w.stashFocus, w.floatFocus = i, true, false
1296		}
1297		if owner == c || w.stashOverCover() && w.stashIndex(owner) >= 0 {
1298			if owner != id {
1299				w.FocusID(id)
1300			}
1301			w.focusModal(0)
1302			return
1303		}
1304	}
1305	if i := w.stashIndex(id); i >= 0 && w.stashHidden {
1306		// A hidden stashed window must be seen: show the stash with it,
1307		// selected. Another window's fullscreen leaves first, so its own
1308		// pending fullscreen comes back.
1309		w.leaveFullscreen()
1310		w.stashAt = i
1311		w.showStash()
1312	}
1313	if w.fullscreen != 0 && w.fullscreen != id {
1314		w.leaveFullscreen()
1315	}
1316	w.FocusID(id)
1317	w.focusModal(0)
1318}
1319
1320// coverStays reports whether activating stashed window id keeps the
1321// covering window fullscreen under the stash: a cover outside the stash,
1322// and no pending fullscreen of id's own that would replace it.
1323func (w *Workspace) coverStays(id WindowID) bool {
1324	c := w.cover()
1325	return c != 0 && w.stashIndex(c) < 0 && !(w.stashHidden && w.hiddenFullscreen == id)
1326}
1327
1328// SetFullscreen applies a client request. It never moves focus: client
1329// requests are automatic events (ADR 011 golden rule).
1330func (w *Workspace) SetFullscreen(id WindowID, on bool) {
1331	if on && !w.mayCover(id) {
1332		return
1333	}
1334	if w.isFloat(id) {
1335		if w.stashHidden && w.stashIndex(id) >= 0 {
1336			// Applied when the stash is shown again.
1337			if on {
1338				w.hiddenFullscreen = id
1339			} else if w.hiddenFullscreen == id {
1340				w.hiddenFullscreen = 0
1341			}
1342			return
1343		}
1344	}
1345	if !w.has(id) {
1346		return
1347	}
1348	if !on {
1349		if w.fullscreen == id {
1350			w.endFullscreen()
1351		}
1352		return
1353	}
1354	if id != w.stashOver {
1355		// Another window covers: the override was for the old cover.
1356		w.dropStashOver()
1357	}
1358	w.fullscreen = id
1359	if !w.isFloat(id) {
1360		w.scroll()
1361	}
1362}
1363
1364// SetOutput sets the logical size of the monitor. The workspace viewport
1365// follows: the whole monitor, or the centered size override.
1366func (w *Workspace) SetOutput(width, height int) {
1367	old := w.monitor
1368	w.monitor = Rect{W: max(width, 0), H: max(height, 0)}
1369	if w.reserved == old {
1370		w.reserved = w.monitor
1371	}
1372	w.SetUsable(w.reserved)
1373}
1374
1375// SetUsable takes the monitor area left by layer surfaces, clamps negative
1376// dimensions to zero and clips it to the monitor. The usable rectangle is
1377// its intersection with the viewport. Effective gaps shrink to fit it.
1378func (w *Workspace) SetUsable(r Rect) {
1379	r.X = min(max(r.X, 0), w.monitor.W)
1380	r.Y = min(max(r.Y, 0), w.monitor.H)
1381	r.W = min(max(r.W, 0), w.monitor.W-r.X)
1382	r.H = min(max(r.H, 0), w.monitor.H-r.Y)
1383	w.reserved = r
1384	w.fit()
1385}
1386
1387// SetSize overrides the logical size of the workspace; a zero side inherits
1388// the monitor, one larger than it is clamped to it.
1389func (w *Workspace) SetSize(width, height int) {
1390	w.size = [2]int{max(width, 0), max(height, 0)}
1391	w.fit()
1392}
1393
1394// fit derives the viewport and usable area from the monitor, its reservations
1395// and the size override.
1396func (w *Workspace) fit() {
1397	side := func(want, have int) int {
1398		if want <= 0 {
1399			return have
1400		}
1401		return min(want, have)
1402	}
1403	vw, vh := side(w.size[0], w.monitor.W), side(w.size[1], w.monitor.H)
1404	w.Output = Rect{X: (w.monitor.W - vw) / 2, Y: (w.monitor.H - vh) / 2, W: vw, H: vh}
1405	r, o := w.reserved, w.Output
1406	x0, y0 := max(r.X, o.X), max(r.Y, o.Y)
1407	x1, y1 := min(r.X+r.W, o.X+o.W), min(r.Y+r.H, o.Y+o.H)
1408	u := Rect{X: min(max(x0, o.X), o.X+o.W), Y: min(max(y0, o.Y), o.Y+o.H)}
1409	u.W, u.H = max(x1-u.X, 0), max(y1-u.Y, 0)
1410	w.Usable = u
1411	w.reconcileFloats()
1412	w.scroll()
1413}
1414func (w *Workspace) SetGaps(g int) {
1415	if g < 0 {
1416		g = 0
1417	}
1418	w.Gaps = g
1419	w.scroll()
1420}
1421func (w *Workspace) SetMaxColumns(n int) {
1422	w.MaxColumns = n
1423	w.scroll()
1424}
1425
1426// overviewArea is the monitor area the overview lays rows out in: the whole
1427// area left by layer surfaces, whatever the workspace's size. Tiles keep the
1428// workspace's own geometry, scaled uniformly.
1429func (w *Workspace) overviewArea() Rect { return w.reserved }
1430
1431func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) }
1432
1433// settleLeft drops the latch once its window has the focus again, however
1434// it got it; core calls it after each event.
1435func (w *Workspace) settleLeft() {
1436	if id, ok := w.Focused(); ok && id == w.left {
1437		w.left = 0
1438	}
1439}
1440
1441// mayCover reports whether a client may make id fullscreen. In fixed
1442// overflow it would cover the output at once and take the keyboard from
1443// the focused window, so only the focused window may; the request of
1444// another is refused and the user can still toggle-fullscreen it. A
1445// hidden stash's request waits for the show (showStash). A window whose
1446// fullscreen the user left may only once focused again, in any overflow.
1447func (w *Workspace) mayCover(id WindowID) bool {
1448	if id == w.left {
1449		if focused, ok := w.Focused(); !ok || focused != id {
1450			return false
1451		}
1452		w.left = 0
1453	}
1454	if !w.policy().equalCells || w.stashHidden && w.stashIndex(id) >= 0 {
1455		return true
1456	}
1457	focused, ok := w.Focused()
1458	return ok && focused == id
1459}
1460
1461// pinned reports whether the covering fullscreen window hides every other
1462// window: focus moves leave it (leaveCover), and the overview shows it as
1463// the front card of its row. Only in scroll overflow does moving to a
1464// tiled neighbor scroll it off and show the target instead.
1465func (w *Workspace) pinned() bool {
1466	c := w.cover()
1467	return c != 0 && (w.policy().equalCells || w.isFloat(c))
1468}
1469
1470// leaveCover runs a focus move from the covering fullscreen window as if it
1471// were not fullscreen. When the move reaches another window, fullscreen
1472// ends and that window shows, focused; otherwise (an edge) nothing changes
1473// and the caller goes on to the next workspace or monitor.
1474func (w *Workspace) leaveCover(move func()) bool {
1475	w.focusCover()
1476	full := w.fullscreen
1477	w.fullscreen = 0
1478	move()
1479	if id, ok := w.Focused(); ok && id != full {
1480		w.fullscreen = full
1481		w.leaveFullscreen()
1482		return true
1483	}
1484	w.fullscreen = full
1485	return false
1486}
1487
1488// focusCover points the focus state at the covering fullscreen window,
1489// which Focused already reports, so a user action acts on the window on
1490// screen, not on one it hides.
1491func (w *Workspace) focusCover() {
1492	c := w.cover()
1493	if c == 0 {
1494		return
1495	}
1496	if d := w.coverDialog(); d != 0 {
1497		// Its dialog is on screen with the focus: the action is on it.
1498		w.FocusID(d)
1499		return
1500	}
1501	if w.stashOverCover() {
1502		// The stash shown over it has the focus.
1503		w.stashFocus, w.floatFocus = true, false
1504		return
1505	}
1506	if w.isFloat(c) {
1507		w.FocusID(c)
1508		return
1509	}
1510	w.floatFocus, w.stashFocus = false, false
1511	for i, col := range w.Columns {
1512		if j := slices.Index(col.Windows, c); j >= 0 {
1513			w.Focus, w.Columns[i].Focus = i, j
1514		}
1515	}
1516}
1517
1518// cover returns the fullscreen window that covers the output, or 0. The
1519// fullscreen is exclusive: while it covers, no other window of the
1520// workspace is drawn, so the output can scan it out.
1521func (w *Workspace) cover() WindowID {
1522	if w.fullscreen == 0 {
1523		return 0
1524	}
1525	if w.isFloat(w.fullscreen) || w.policy().equalCells {
1526		return w.fullscreen
1527	}
1528	// Scroll overflow: only while its column is aligned on the output.
1529	for i, c := range w.Columns {
1530		if slices.Contains(c.Windows, w.fullscreen) && w.columnX(i)-w.View == 0 {
1531			return w.fullscreen
1532		}
1533	}
1534	return 0
1535}
1536
1537// fullscreenHides reports whether a fullscreen window hides the focused
1538// column's layout: the others (fixed) or its own column (scroll). Column
1539// edits would change what the user cannot see.
1540func (w *Workspace) fullscreenHides() bool {
1541	return w.fullscreen != 0 && (w.policy().equalCells || w.fullscreenColumn(w.Focus))
1542}
1543
1544func (w *Workspace) fullscreenColumn(i int) bool {
1545	for _, id := range w.Columns[i].Windows {
1546		if id == w.fullscreen && id != 0 {
1547			return true
1548		}
1549	}
1550	return false
1551}
1552func (w *Workspace) columnWidth(i int) int {
1553	return w.columnWidthFor(i, false)
1554}
1555
1556func (w *Workspace) columnWidthFor(i int, ignoreFullWidth bool) int {
1557	if width, ok := w.specialWidth(i, ignoreFullWidth); ok {
1558		return width
1559	}
1560	if w.policy().wraps || w.laneRow() {
1561		return w.cellWidth(i)
1562	}
1563	return w.scrollWidth(i)
1564}
1565
1566// specialWidth is the width of a maximized or fullscreen column.
1567func (w *Workspace) specialWidth(i int, ignoreFullWidth bool) (int, bool) {
1568	if w.Columns[i].FullWidth && !ignoreFullWidth {
1569		return max(w.Usable.W-2*w.gap(), 0), true
1570	}
1571	if w.fullscreenColumn(i) {
1572		return w.Output.W, true
1573	}
1574	return 0, false
1575}
1576
1577// laneRow reports whether the columns share one row of cells: fixed
1578// overflow up to MaxColumns of them. Past that the spiral applies and
1579// cells are ignored.
1580func (w *Workspace) laneRow() bool {
1581	return w.Overflow == OverflowFixed && len(w.Columns) <= max(w.MaxColumns, 1)
1582}
1583
1584// scrollWidth is the width of column i when it is sized on its own: auto
1585// columns share the usable width as the visible ones would, the others take
1586// their cells (at most MaxColumns) and the gaps between them.
1587func (w *Workspace) scrollWidth(i int) int {
1588	k := max(w.MaxColumns, 1)
1589	if w.policy().equalCells {
1590		// Fixed overflow past MaxColumns: the spiral's slots.
1591		return w.equalShare(k)
1592	}
1593	c := w.Columns[i].Cells
1594	if c <= 0 {
1595		return w.equalShare(min(len(w.Columns), k))
1596	}
1597	c = min(c, k)
1598	return max(c*w.equalShare(k)+(c-1)*w.gap(), 0)
1599}
1600func (w *Workspace) columnX(i int) int {
1601	x := w.Usable.X + w.gap()
1602	for j := 0; j < i; j++ {
1603		x += w.columnWidth(j) + w.gap()
1604	}
1605	return x
1606}
1607func (w *Workspace) scroll() {
1608	before := w.View
1609	defer func() { w.retarget(before) }()
1610	if w.policy().wraps {
1611		w.View = w.band(w.Focus) * w.Usable.H
1612		return
1613	}
1614	if w.Overflow == OverflowFixed {
1615		w.View = 0
1616		return
1617	}
1618	if len(w.Columns) == 0 {
1619		return
1620	}
1621	left := w.columnX(w.Focus) - w.View
1622	width := w.columnWidth(w.Focus)
1623	if w.fullscreenColumn(w.Focus) {
1624		w.View += left - w.Output.X
1625		return
1626	}
1627	minX := w.Usable.X + w.gap()
1628	maxX := w.Usable.X + w.Usable.W - w.gap()
1629	if width > maxX-minX || left < minX {
1630		w.View += left - minX
1631	} else if left+width > maxX {
1632		w.View += left + width - maxX
1633	}
1634}
1635
1636// columnRects returns each column's area on screen, before stacking windows.
1637func (w *Workspace) columnRects() []Rect {
1638	return w.columnRectsInto(nil, false)
1639}
1640
1641// columnRectsFor shares the fixed layout geometry with the overview's hidden
1642// columns card, without changing FullWidth or the clients' saved buffers.
1643func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect {
1644	return w.columnRectsInto(nil, ignoreFullWidth)
1645}
1646
1647// columnRectsInto is columnRectsFor built in dst's storage (dst[:0] grown
1648// when too small): the result aliases dst, so a caller passing a reused
1649// buffer must be done with the previous result.
1650func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
1651	if w.policy().wraps {
1652		return w.cascadeRectsInto(dst, ignoreFullWidth)
1653	}
1654	g := w.gap()
1655	y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0)
1656	rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
1657	view := w.View + w.shiftPixels()
1658	x := w.Usable.X + g
1659	for i := range w.Columns {
1660		width := w.columnWidthFor(i, ignoreFullWidth)
1661		rects[i] = Rect{X: x - view, Y: y, W: width, H: h}
1662		x += width + g
1663	}
1664	if !ignoreFullWidth && w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth {
1665		rects[w.Focus].X = w.Usable.X + g
1666		return rects
1667	}
1668	k := max(w.MaxColumns, 1)
1669	if w.Overflow != OverflowFixed || len(w.Columns) <= k {
1670		return rects
1671	}
1672	// The last slot holds the spiral; the columns before it keep their place.
1673	area := rects[k-1]
1674	for i := k - 1; i < len(w.Columns)-1; i++ {
1675		rects[i], area = split(area, g, (i-k+1)%2 == 0)
1676	}
1677	rects[len(rects)-1] = area
1678	return rects
1679}
1680
1681// stackRects cuts r in n rows with a gap between them; the last row takes
1682// the rounding remainder. Rows past the bottom (gaps taller than r) are
1683// clamped to it, like windows stacked in a column.
1684func stackRects(r Rect, n, gap int) []Rect {
1685	return stackRectsInto(nil, r, n, gap)
1686}
1687
1688// stackRectsInto appends the n rows of stackRects to dst (reset with dst[:0]
1689// to reuse its storage).
1690func stackRectsInto(dst []Rect, r Rect, n, gap int) []Rect {
1691	base := len(dst)
1692	dst = slices.Grow(dst, n)[:base+n]
1693	rows := dst[base:]
1694	avail := max(r.H-(n-1)*gap, 0)
1695	height := avail / n
1696	yy, bottom := r.Y, r.Y+r.H
1697	for i := range rows {
1698		hh := height
1699		if i == n-1 {
1700			hh = avail - height*(n-1)
1701		}
1702		yy = min(yy, bottom)
1703		hh = min(hh, bottom-yy)
1704		rows[i] = Rect{X: r.X, Y: yy, W: r.W, H: hh}
1705		yy += hh + gap
1706	}
1707	return dst
1708}
1709
1710// rowRects splits column rect r into the rows of c: by c.Shares when they
1711// are valid, else equally (stackRects).
1712func rowRects(r Rect, c Column, gap int) []Rect {
1713	return rowRectsInto(nil, r, c, gap)
1714}
1715
1716// rowRectsInto appends the rows of rowRects to dst (reset with dst[:0] to
1717// reuse its storage).
1718func rowRectsInto(dst []Rect, r Rect, c Column, gap int) []Rect {
1719	n := len(c.Windows)
1720	if !validShares(c) {
1721		return stackRectsInto(dst, r, n, gap)
1722	}
1723	base := len(dst)
1724	dst = slices.Grow(dst, n)[:base+n]
1725	rows := dst[base:]
1726	avail := max(r.H-(n-1)*gap, 0)
1727	yy, bottom, used := r.Y, r.Y+r.H, 0
1728	for i, share := range c.Shares {
1729		hh := avail * share / 100
1730		if i == n-1 {
1731			hh = avail - used
1732		}
1733		used += hh
1734		yy = min(yy, bottom)
1735		hh = min(hh, bottom-yy)
1736		rows[i] = Rect{X: r.X, Y: yy, W: r.W, H: hh}
1737		yy += hh + gap
1738	}
1739	return dst
1740}
1741
1742// validShares reports whether c.Shares has one share per window, each at
1743// least 1, summing to 100.
1744func validShares(c Column) bool {
1745	if len(c.Shares) != len(c.Windows) || len(c.Shares) < 2 {
1746		return false
1747	}
1748	sum := 0
1749	for _, v := range c.Shares {
1750		if v < 1 {
1751			return false
1752		}
1753		sum += v
1754	}
1755	return sum == 100
1756}
1757
1758// split cuts r in two halves with a gap: top/bottom when vertical, else left/right.
1759func split(r Rect, gap int, vertical bool) (Rect, Rect) {
1760	if vertical {
1761		h := max((r.H-gap)/2, 0)
1762		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)}
1763	}
1764	w := max((r.W-gap)/2, 0)
1765	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}
1766}
1767
1768// Layout places every window of the workspace in a fresh slice the caller
1769// owns. The per-frame paths use layoutInto instead.
1770func (w *Workspace) Layout() []Placement { return w.appendLayout(nil) }
1771
1772// layoutInto is Layout built in dst[:0]: the result aliases dst's storage
1773// (a reused buffer's previous result is overwritten), so only a caller that
1774// is done with the previous one may pass it. It never returns the
1775// workspace's own scratch.
1776func (w *Workspace) layoutInto(dst []Placement) []Placement { return w.appendLayout(dst[:0]) }
1777
1778// appendLayout appends the workspace's placements to dst.
1779func (w *Workspace) appendLayout(dst []Placement) []Placement {
1780	gap := w.gap()
1781	// The column and row rects live in the workspace's scratch: each is dead
1782	// once the placements it feeds are built (owner goroutine only, and
1783	// layoutInto does not nest on a workspace).
1784	w.colBuf = w.columnRectsInto(w.colBuf, false)
1785	cols := w.colBuf
1786	focusedID, _ := w.Focused()
1787	cover := w.cover()
1788	tiles := w.tileBuf[:0]
1789	for i, c := range w.Columns {
1790		col := cols[i]
1791		n := len(c.Windows)
1792		if n == 0 {
1793			continue
1794		}
1795		fullColumn := w.fullscreenColumn(i)
1796		if fullColumn {
1797			col.W = w.Output.W
1798		}
1799		w.rowBuf = rowRectsInto(w.rowBuf[:0], col, c, gap)
1800		rows := w.rowBuf
1801		for j, id := range c.Windows {
1802			r := rows[j]
1803			full := w.fullscreen == id && id != 0
1804			// Fixed overflow cannot scroll to other columns while one fills
1805			// the view. Keep them in place, but out of the scene.
1806			hidden := (cover != 0 && id != cover) || ((fullColumn || w.policy().equalCells && w.fullscreen != 0) && !full) || w.hiddenByMaximized(i)
1807			if full {
1808				// Scroll mode aligns the view on the column; fixed never scrolls.
1809				r = w.Output
1810				if !w.policy().equalCells {
1811					r.X = col.X
1812				}
1813			}
1814			if hidden {
1815				r = Rect{}
1816			}
1817			tiles = append(tiles, Placement{ID: id, Rect: r, Fullscreen: full, Focused: id == focusedID, Hidden: hidden})
1818		}
1819	}
1820	// Lines are shared within the usable area: a tile under a panel (the
1821	// next cascade band starts right below a bottom one) is no neighbor.
1822	setVisibleNeighbors(tiles, gap, w.Usable)
1823	// tiles lives in the workspace's scratch until they are copied below.
1824	w.tileBuf = tiles
1825	// The column/stash group sits between demoted covering floats and
1826	// floats above it. Keep the order within each group stable.
1827	result := dst
1828	const (
1829		belowTiles = iota
1830		coveringFloats
1831		dialogs
1832	)
1833	appendFloats := func(group int) {
1834		for _, f := range w.Floats {
1835			below := f.below && w.coversFloat(f)
1836			// Dialogs stay over covering floats even when the latter is
1837			// selected. Fullscreen floats remain in the upper group.
1838			order := dialogs
1839			if below {
1840				order = belowTiles
1841			} else if w.coversFloat(f) || w.fullscreen == f.ID {
1842				order = coveringFloats
1843			}
1844			if order != group {
1845				continue
1846			}
1847			// A covering fullscreen window hides every float but the
1848			// dialogs over it (overCover): the others show again when it
1849			// leaves fullscreen.
1850			p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Below: below, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: cover != 0 && f.ID != cover && !w.overCover(f)}
1851			if p.Hidden {
1852				p.Rect = Rect{}
1853				result = append(result, p)
1854				continue
1855			}
1856			if w.fullscreen == f.ID {
1857				p.Rect, p.Fullscreen, p.Inset = w.Output, true, 0
1858			}
1859			result = append(result, p)
1860		}
1861	}
1862	appendFloats(belowTiles)
1863	result = append(result, tiles...)
1864	if w.stashOverCover() {
1865		// The stash the user showed over a covering window tops it, under
1866		// the dialogs it may open meanwhile.
1867		appendFloats(coveringFloats)
1868		result = w.appendStash(result, focusedID, cover)
1869	} else {
1870		result = w.appendStash(result, focusedID, cover)
1871		appendFloats(coveringFloats)
1872	}
1873	appendFloats(dialogs)
1874	return result
1875}
1876
1877// setVisibleNeighbors reserves client space only for shared lines that
1878// are actually in the usable area. A scrolled-off, hidden or under-panel
1879// tile cannot give a lone visible tile a border (or shrink its client).
1880func setVisibleNeighbors(tiles []Placement, gap int, area Rect) {
1881	visible := func(p Placement) bool {
1882		return !p.Hidden && !p.Fullscreen && p.Rect.Overlaps(area)
1883	}
1884	overlap := func(a0, a1, b0, b1 int) bool { return a0 < b1 && b0 < a1 }
1885	for i := range tiles {
1886		a := &tiles[i]
1887		a.Neighbors, a.Inset = 0, 0
1888		if !visible(*a) {
1889			continue
1890		}
1891		for j := range tiles {
1892			if i == j || !visible(tiles[j]) {
1893				continue
1894			}
1895			b, r := tiles[j].Rect, a.Rect
1896			// Only count a shared edge strictly within the area.
1897			switch {
1898			case r.X+r.W+gap == b.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H) && r.X+r.W > area.X && r.X+r.W < area.X+area.W:
1899				a.Neighbors |= ports.SideRight
1900			case b.X+b.W+gap == r.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H) && r.X > area.X && r.X < area.X+area.W:
1901				a.Neighbors |= ports.SideLeft
1902			case r.Y+r.H+gap == b.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W) && r.Y+r.H > area.Y && r.Y+r.H < area.Y+area.H:
1903				a.Neighbors |= ports.SideBottom
1904			case b.Y+b.H+gap == r.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W) && r.Y > area.Y && r.Y < area.Y+area.H:
1905				a.Neighbors |= ports.SideTop
1906			}
1907		}
1908		a.Inset = a.Neighbors
1909		if gap == 0 {
1910			a.Inset &= ports.SideRight | ports.SideBottom
1911		}
1912	}
1913}
1914
1915// floatRect places a floating window, its border around its client size,
1916// clamped to the usable area: a native one centred, a free one at its
1917// centre, kept inside the area.
1918func (w *Workspace) floatRect(f Float) Rect {
1919	u := w.Usable
1920	fw, fh := f.W, f.H
1921	if fw <= 0 || fh <= 0 {
1922		fw, fh = u.W/2, u.H/2
1923	}
1924	fw, fh = min(fw+2*w.border, u.W), min(fh+2*w.border, u.H)
1925	if !f.free {
1926		return Rect{X: u.X + (u.W-fw)/2, Y: u.Y + (u.H-fh)/2, W: fw, H: fh}
1927	}
1928	x := u.X + int(math.Round(f.cx*float64(u.W))) - fw/2
1929	y := u.Y + int(math.Round(f.cy*float64(u.H))) - fh/2
1930	x = min(max(x, u.X), u.X+u.W-fw)
1931	y = min(max(y, u.Y), u.Y+u.H-fh)
1932	return Rect{X: x, Y: y, W: fw, H: fh}
1933}
1934
1935// imposedFloat reports whether core, rather than the client, owns the
1936// size of a floating window: a stashed one or a free float.
1937func (w *Workspace) imposedFloat(id WindowID) bool {
1938	if i := w.floatIndex(id); i >= 0 {
1939		return w.Floats[i].free
1940	}
1941	return w.stashIndex(id) >= 0
1942}
1943
1944// ToggleWindowStash stashes the focused tile, or returns the focused
1945// stashed window to its column. A native float becomes a new column.
1946func (w *Workspace) ToggleWindowStash() {
1947	id, ok := w.Focused()
1948	// A fullscreen window (a Wine game in scanout) keeps its place: it
1949	// leaves fullscreen first, then may float.
1950	if !ok || id == w.fullscreen {
1951		return
1952	}
1953	switch {
1954	case w.floatIndex(id) >= 0 && w.Floats[w.floatIndex(id)].free:
1955		// A free float keeps its place and size in the stash.
1956		f := w.Floats[w.floatIndex(id)]
1957		w.RemoveWindow(id)
1958		w.addStash(f)
1959	case w.floatIndex(id) >= 0:
1960		w.RemoveWindow(id)
1961		w.restore(id, nil)
1962	case w.stashIndex(id) >= 0:
1963		if w.stashOverCover() {
1964			// Its column would be hidden under the cover: leave it.
1965			w.leaveFullscreen()
1966		}
1967		w.unstash(w.stashIndex(id))
1968	default:
1969		w.stashWindow(id)
1970	}
1971}

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