layout.go RawHistoryFolder

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

Keyboard shortcuts

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

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

Branches 23

Tags 10

↑↓ moveenter openesc close