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