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