main f2d166b

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close