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