v0.5.0 ccce658

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

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