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

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