v0.8.0 446d1e3

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

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