v0.3.0 cf370a0

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

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close