feat/data-dnd e05cb15

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

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