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