v0.4.0 b6be3a8

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

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 26

Tags 10

↑↓ moveenter openesc close