1package core
2
3import (
4 "iter"
5 "slices"
6)
7
8// Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers
9// are positions: Cmd+N targets Workspaces[N-1]. An empty workspace always sits
10// below the last one, and an empty unnamed workspace is removed once left.
11// Output-wide settings apply to every workspace.
12//
13// Named workspaces come from config and are never removed while configured.
14// Named workspaces are outside normal numbered up/down navigation. Their
15// `workspace <name>` binds and the overview can show them.
16type Monitor struct {
17 // Name is the connector; Key identifies the physical monitor (make,
18 // model and serial, else the connector). Workspaces remember the Key of
19 // their home monitor.
20 Name, Key string
21 Workspaces []*Workspace
22 Active int
23 hidden []*Workspace
24 shown *Workspace // hidden workspace on screen, nil for Workspaces[Active]
25 back *Workspace // where a named workspace toggle returns to
26 template Workspace
27 // slideBuf and slideShown are slideLayout's scratch (the neighbor's
28 // layout and its placements by window), reused and never handed out.
29 slideBuf []Placement
30 slideShown map[WindowID]Placement
31 nextID *uint64 // shared by the monitors of one Core; owner goroutine only
32 named []NamedWorkspace
33 // followMove shows the target workspace after a move to it.
34 followMove bool
35 // stashCapture puts new windows in the shown stash (stash.capture).
36 stashCapture bool
37 // switchView slides the view between numbered workspaces: the view is
38 // at Active+switchView.off, in workspaces.
39 switchView slide
40 // switchAxis is frozen from the source workspace for each transition.
41 switchAxis layoutAxis
42 // switchList is the numbered list a landing slide measures from (the
43 // one its swipe began on); switchView.off is then from the current
44 // workspace's place in it.
45 switchList []*Workspace
46 // ov holds the overview selection and its Escape snapshot.
47 ov overviewState
48 // sw is the column switcher (switcher.go).
49 sw switcherState
50 // overviewOpens counts the overview's openings: a swipe sliding when
51 // one happens is dropped (gesture.go).
52 overviewOpens int
53}
54
55// NamedWorkspace configures a named workspace. Zero MaxColumns and an empty
56// Overflow follow the monitor defaults.
57type NamedWorkspace struct {
58 Name string
59 // Monitor is the home monitor (connector or key); "" follows the focus.
60 Monitor string
61 MaxColumns int
62 Overflow Overflow
63 // Size overrides the logical width and height; a zero side inherits
64 // the monitor and the scale is always the monitor's.
65 Size [2]int
66}
67
68func NewMonitor() *Monitor { return newMonitor("", "") }
69
70func newMonitor(name, key string) *Monitor {
71 return newMonitorWithIDs(name, key, new(uint64))
72}
73
74func newMonitorWithIDs(name, key string, nextID *uint64) *Monitor {
75 m := &Monitor{Name: name, Key: key, nextID: nextID}
76 m.normalize()
77 return m
78}
79
80// matches reports whether home designates this monitor, by key or connector.
81func (m *Monitor) matches(home string) bool {
82 return home != "" && (home == m.Key || home == m.Name)
83}
84
85// all yields every workspace, numbered then hidden, without copying.
86// Callers must not add or remove workspaces while ranging.
87func (m *Monitor) all() iter.Seq[*Workspace] {
88 return func(yield func(*Workspace) bool) {
89 for _, w := range m.Workspaces {
90 if !yield(w) {
91 return
92 }
93 }
94 for _, w := range m.hidden {
95 if !yield(w) {
96 return
97 }
98 }
99 }
100}
101
102// isHidden reports whether w is in the hidden list.
103func (m *Monitor) isHidden(w *Workspace) bool { return indexOf(m.hidden, w) >= 0 }
104
105// take removes w from the monitor. The monitor keeps a workspace on screen:
106// the one at the same position, or the numbered active one.
107func (m *Monitor) take(w *Workspace) {
108 if m.back == w {
109 m.back = nil
110 }
111 if i := indexOf(m.hidden, w); i >= 0 {
112 m.hidden = append(m.hidden[:i], m.hidden[i+1:]...)
113 if m.shown == w {
114 m.shown = nil
115 }
116 m.normalize()
117 return
118 }
119 if i := indexOf(m.Workspaces, w); i >= 0 {
120 m.removeNumbered(i)
121 m.normalize()
122 }
123}
124
125// adopt adds a workspace from another monitor: hidden, or numbered at pos
126// (clamped above the trailing empty workspace). It takes this monitor's
127// output-wide settings and does not change what is on screen.
128func (m *Monitor) adopt(w *Workspace, hidden bool, pos int) {
129 w.presets = append([]Width(nil), m.template.presets...)
130 w.Gaps = m.template.Gaps
131 w.border = m.template.border
132 w.stashWidth, w.stashGap = m.template.stashWidth, m.template.stashGap
133 w.SetOutput(m.template.Output.W, m.template.Output.H)
134 w.SetUsable(m.template.Usable)
135 if hidden {
136 m.hidden = append(m.hidden, w)
137 } else {
138 // normalize keeps a trailing empty workspace: stay above it.
139 at := min(max(pos, 0), len(m.Workspaces)-1)
140 m.Workspaces = slices.Insert(m.Workspaces, at, w)
141 if at <= m.Active {
142 m.Active++
143 }
144 }
145 m.applyNamed()
146 m.normalize()
147}
148
149// useSpecs sets the overrides of every named workspace, including those
150// that live on other monitors and may move here.
151func (m *Monitor) useSpecs(all []NamedWorkspace) {
152 m.named = append([]NamedWorkspace(nil), all...)
153 m.applyNamed()
154}
155
156// Current is the workspace on screen.
157func (m *Monitor) Current() *Workspace {
158 if m.shown != nil {
159 return m.shown
160 }
161 return m.Workspaces[m.Active]
162}
163
164func (m *Monitor) newWorkspace() *Workspace {
165 w := m.template
166 w.colBuf, w.rowBuf, w.tileBuf, w.zoomBuf = nil, nil, nil, nil
167 w.presets = append([]Width(nil), m.template.presets...)
168 w.Columns, w.Floats, w.maximized, w.floatFocus, w.home = nil, nil, nil, false, ""
169 w.overviewAfter = nil
170 w.Stash, w.stashAt, w.stashFocus, w.stashHidden, w.hiddenFullscreen = nil, 0, false, false, 0
171 (*m.nextID)++
172 w.ID = *m.nextID
173 return &w
174}
175
176// normalize keeps one trailing empty unnamed workspace and drops other empty
177// unnamed ones that are not active. The active workspace never changes.
178func (m *Monitor) normalize() {
179 spare := func(i int) bool { return m.Workspaces[i].empty() && m.Workspaces[i].Name == "" }
180 for i := len(m.Workspaces) - 2; i >= 0; i-- {
181 if spare(i) && i != m.Active {
182 m.Workspaces = append(m.Workspaces[:i], m.Workspaces[i+1:]...)
183 if i < m.Active {
184 m.Active--
185 }
186 }
187 }
188 for n := len(m.Workspaces); n >= 2 && spare(n-1) && spare(n-2) && m.Active != n-1; n-- {
189 m.Workspaces = m.Workspaces[:n-1]
190 }
191 if n := len(m.Workspaces); n == 0 || !spare(n-1) {
192 m.Workspaces = append(m.Workspaces, m.newWorkspace())
193 }
194 for _, w := range m.hidden {
195 if indexOf(m.Workspaces, w.overviewAfter) < 0 {
196 w.overviewAfter = nil
197 }
198 }
199}
200
201// Focus switches to the numbered workspace at index i, clamped to the list.
202func (m *Monitor) Focus(i int) {
203 i = min(max(i, 0), len(m.Workspaces)-1)
204 m.stopSwitch()
205 m.shown = nil
206 m.Active = i
207 m.normalize()
208}
209
210// MoveWorkspace swaps the active numbered workspace with its neighbor up
211// (dir -1) or down (dir 1); the view follows it. It never passes the
212// trailing empty workspace.
213func (m *Monitor) MoveWorkspace(dir int) {
214 to := m.Active + dir
215 if m.shown != nil || (dir != -1 && dir != 1) || to < 0 || to >= len(m.Workspaces)-1 || m.Active >= len(m.Workspaces)-1 {
216 return
217 }
218 m.Workspaces[m.Active], m.Workspaces[to] = m.Workspaces[to], m.Workspaces[m.Active]
219 m.stopSwitch()
220 m.Active = to
221 m.normalize()
222}
223
224// FocusNumber switches to workspace n (1-based); past the end means the last.
225func (m *Monitor) FocusNumber(n int) { m.Focus(n - 1) }
226
227// show puts w on screen, numbered or hidden.
228func (m *Monitor) show(w *Workspace) {
229 if i := indexOf(m.Workspaces, w); i >= 0 {
230 m.Focus(i)
231 return
232 }
233 m.stopSwitch()
234 m.shown = w
235 m.normalize()
236}
237
238// ToggleNamed shows the named workspace, or returns to the previous one when
239// it is already on screen. Unknown names do nothing.
240func (m *Monitor) ToggleNamed(name string) {
241 target := m.byName(name)
242 switch cur := m.Current(); {
243 case target == nil:
244 case target != cur:
245 anchor := cur
246 if m.isHidden(cur) {
247 anchor = cur.overviewAfter
248 }
249 if indexOf(m.Workspaces, anchor) < 0 {
250 anchor = m.Workspaces[m.Active]
251 }
252 target.overviewAfter = anchor
253 m.back = cur
254 m.show(target)
255 case m.back != nil && m.has(m.back):
256 back := m.back
257 m.back = nil
258 m.show(back)
259 case m.shown != nil:
260 m.back = nil
261 m.shown = nil
262 m.normalize()
263 }
264}
265
266// byName returns the configured workspace with that name, or nil.
267func (m *Monitor) byName(name string) *Workspace {
268 for w := range m.all() {
269 if w.Name == name {
270 return w
271 }
272 }
273 return nil
274}
275
276func indexOf(list []*Workspace, w *Workspace) int {
277 for i, v := range list {
278 if v == w {
279 return i
280 }
281 }
282 return -1
283}
284
285// has reports whether w still belongs to the monitor.
286func (m *Monitor) has(w *Workspace) bool {
287 return indexOf(m.Workspaces, w) >= 0 || indexOf(m.hidden, w) >= 0
288}
289
290// find returns the workspace holding the window; i is -1 for hidden ones.
291func (m *Monitor) find(id WindowID) (*Workspace, int) {
292 for i, w := range m.Workspaces {
293 if w.has(id) {
294 return w, i
295 }
296 }
297 for _, w := range m.hidden {
298 if w.has(id) {
299 return w, -1
300 }
301 }
302 return nil, -1
303}
304
305// AddWindow places a new window on the workspace on screen; with
306// stash.capture, in its stash when shown.
307func (m *Monitor) AddWindow(id WindowID) { m.addWindow(id, Width{}) }
308
309// addWindow is AddWindow with the width of the new column (zero: automatic).
310func (m *Monitor) addWindow(id WindowID, width Width) {
311 if w, _ := m.find(id); w != nil {
312 return
313 }
314 if w := m.Current(); !m.stashCapture || !w.captureStash(id) {
315 w.addWindow(id, width)
316 }
317 m.normalize()
318}
319
320// AddFloating adds a floating window to the active workspace.
321func (m *Monitor) AddFloating(id WindowID, width, height int) {
322 if w, _ := m.find(id); w != nil {
323 return
324 }
325 m.Current().AddFloating(id, width, height)
326 m.normalize()
327}
328
329// RemoveWindow drops the window wherever it is; focus stays on the active
330// workspace.
331func (m *Monitor) RemoveWindow(id WindowID) {
332 heir := m.sw.heir(id)
333 if w, _ := m.find(id); w != nil {
334 w.RemoveWindow(id)
335 m.normalize()
336 }
337 m.sw.remove(id, heir)
338}
339
340// SetFullscreen applies a client request, in place. A client request never
341// moves the user (ADR 011): the view and the focus stay where they are.
342func (m *Monitor) SetFullscreen(id WindowID, on bool) {
343 if w, _ := m.find(id); w != nil {
344 w.SetFullscreen(id, on)
345 }
346}
347
348// ToggleFullscreen is the bind: the focused window goes fullscreen in place,
349// or leaves it. Focus moves leave it too (Workspace.leaveCover).
350func (m *Monitor) ToggleFullscreen() { m.Current().ToggleFullscreen() }
351
352// MoveToWorkspace moves the focused column (or only the focused window) to
353// numbered workspace index i. A floating window always moves alone. Focus
354// stays here unless followMove is set (ADR 017).
355func (m *Monitor) MoveToWorkspace(i int, column bool) {
356 i = min(max(i, 0), len(m.Workspaces)-1)
357 cur, to := m.Current(), m.Workspaces[i]
358 id, ok := cur.Focused()
359 if !ok || to == cur {
360 return
361 }
362 if f := cur.floatIndex(id); f >= 0 {
363 fl := cur.Floats[f]
364 if fl.free && fl.back != nil {
365 // Its column place is on this workspace.
366 fl = fl.rehome()
367 }
368 cur.RemoveWindow(id)
369 to.AddFloating(id, fl.W, fl.H)
370 to.Floats[len(to.Floats)-1] = fl
371 // As receive: a fullscreen window would hide it.
372 to.Activate(id)
373 } else if f := cur.stashIndex(id); f >= 0 {
374 // It joins the end of the stash there, shown and selected.
375 fl := cur.Stash[f].rehome()
376 cur.RemoveWindow(id)
377 to.addStash(fl)
378 to.Activate(id)
379 } else {
380 col := Column{Windows: []WindowID{id}}
381 ok := true
382 if column {
383 col, ok = cur.takeColumn()
384 } else {
385 _, ok = cur.takeWindow()
386 }
387 if !ok {
388 return
389 }
390 to.receive(col, -1)
391 }
392 if m.followMove {
393 m.Active = i
394 m.shown = nil
395 }
396 m.normalize()
397}
398
399// receive adds a column moved in from another workspace and focuses it: at
400// index at, or where a new window goes when at < 0. A fullscreen window
401// there is left, so the moved window is seen.
402func (w *Workspace) receive(col Column, at int) {
403 id := col.Windows[col.Focus]
404 switch {
405 case at < 0:
406 w.addColumn(col)
407 default:
408 w.insertColumn(at, col)
409 }
410 w.Activate(id)
411}
412
413// Focused is the focused window of the workspace on screen; in the
414// overview, the selected stash card when it is one.
415func (m *Monitor) Focused() (WindowID, bool) {
416 if id := m.card(); id != 0 {
417 return id, true
418 }
419 return m.Current().Focused()
420}
421
422// Layout places every window: the workspace on screen laid out, others hidden.
423// The slice is fresh, the caller's to keep; the per-frame paths use layoutInto.
424func (m *Monitor) Layout() []Placement { return m.layoutInto(nil) }
425
426// layoutInto is Layout built in dst[:0]: the result aliases dst's storage,
427// so a reused dst's previous result is overwritten and only a caller done
428// with it may pass it. It is never the monitor's own scratch.
429func (m *Monitor) layoutInto(dst []Placement) []Placement {
430 if m.switcherShown() {
431 return m.switcherLayout(dst[:0])
432 }
433 if m.ov.open {
434 return m.overviewLayout(dst[:0])
435 }
436 result := dst[:0]
437 cur := m.Current()
438 for w := range m.all() {
439 if w == cur {
440 result = w.appendLayout(result)
441 continue
442 }
443 result = w.appendHidden(result, true)
444 }
445 return m.slideLayout(result)
446}
447
448// Output is the whole logical monitor, whatever the workspaces' sizes.
449func (m *Monitor) Output() Rect { return m.template.Output }
450
451// Frame is where the workspace on screen draws and takes input, in monitor
452// coordinates: its viewport, or the whole monitor in a preview (the
453// overview or the column switcher). Slides
454// involving a sized workspace remain settled inside the current frame.
455func (m *Monitor) Frame() Rect {
456 if m.previewing() {
457 return m.Output()
458 }
459 return m.Current().Output
460}
461
462// viewport is where the workspace on screen is seen: Frame, and the
463// usable area its tiles live in.
464func (m *Monitor) viewport() viewport {
465 return viewport{frame: m.Frame(), usable: m.Current().Usable}
466}
467
468// frameHas reports whether the output-local point is inside the frame.
469func (m *Monitor) frameHas(x, y float64) bool {
470 f := m.Frame()
471 return x >= float64(f.X) && x < float64(f.X+f.W) && y >= float64(f.Y) && y < float64(f.Y+f.H)
472}
473
474func (m *Monitor) each(f func(*Workspace)) {
475 f(&m.template)
476 for _, w := range m.Workspaces {
477 f(w)
478 }
479 for _, w := range m.hidden {
480 f(w)
481 }
482}
483func (m *Monitor) SetOutput(width, height int) {
484 m.each(func(w *Workspace) { w.SetOutput(width, height) })
485}
486func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) }
487func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps(g) }) }
488func (m *Monitor) SetBorder(b int) {
489 m.each(func(w *Workspace) { w.border = max(b, 0); w.reconcileFloats() })
490}
491
492// SetStash sets the width of a stashed window (stash.width; 0 is the
493// default) and the space between it and its neighbors (stash.gap), in
494// percent of the usable width.
495func (m *Monitor) SetStash(width, gap int) {
496 if width != 0 {
497 width = min(max(width, 10), 90)
498 }
499 m.each(func(w *Workspace) { w.stashWidth, w.stashGap = width, min(max(gap, 0), 10) })
500}
501
502// SetStashCapture makes new windows join the shown stash (stash.capture).
503func (m *Monitor) SetStashCapture(on bool) { m.stashCapture = on }
504
505func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) }
506
507// SetFollowMove makes moves to another workspace show it (focus.follow-move).
508func (m *Monitor) SetFollowMove(on bool) { m.followMove = on }
509
510// SetMaxColumns sets the default; named workspaces may override it.
511func (m *Monitor) SetMaxColumns(n int) {
512 m.template.MaxColumns = n
513 m.applyNamed()
514}
515
516// SetOverflow sets the default; named workspaces may override it.
517func (m *Monitor) SetOverflow(o Overflow) {
518 m.template.Overflow = o
519 m.applyNamed()
520}
521
522// applyNamed gives every workspace the defaults or its named overrides.
523func (m *Monitor) applyNamed() {
524 specs := map[string]NamedWorkspace{}
525 for _, s := range m.named {
526 specs[s.Name] = s
527 }
528 m.each(func(w *Workspace) {
529 s := specs[w.Name]
530 before, axis := w.Overflow, w.policy().content
531 w.Overflow = m.template.Overflow
532 if s.Overflow != "" {
533 w.Overflow = s.Overflow
534 }
535 if before != w.Overflow && w.policy().equalCells {
536 w.unmaximize()
537 }
538 // The view offset belongs to the old content axis: SetMaxColumns
539 // scrolls back to the focus along the new one.
540 if w.policy().content != axis {
541 w.view.stop()
542 w.View = 0
543 }
544 w.SetMaxColumns(m.template.MaxColumns)
545 if s.MaxColumns > 0 {
546 w.SetMaxColumns(s.MaxColumns)
547 }
548 // Named workspaces take their configured size; any other keeps its own.
549 if w.Name != "" {
550 w.SetSize(s.Size[0], s.Size[1])
551 }
552 })
553}
554
555// SetNamed applies bind-only named workspaces from config. Removed named
556// workspaces hand their windows to the active numbered workspace.
557func (m *Monitor) SetNamed(specs []NamedWorkspace) {
558 cur := m.Current()
559 want := map[string]NamedWorkspace{}
560 for _, s := range specs {
561 want[s.Name] = s
562 }
563 byName := map[string]*Workspace{}
564 for _, w := range m.Workspaces {
565 if _, ok := want[w.Name]; w.Name != "" && !ok {
566 w.Name = ""
567 w.SetSize(0, 0)
568 }
569 if w.Name != "" {
570 byName[w.Name] = w
571 }
572 }
573 var hidden []*Workspace
574 for _, w := range m.hidden {
575 if _, ok := want[w.Name]; ok {
576 byName[w.Name] = w
577 hidden = append(hidden, w)
578 continue
579 }
580 if w == cur {
581 cur = nil
582 }
583 for _, c := range w.Columns {
584 for _, id := range c.Windows {
585 m.Workspaces[m.Active].AddWindow(id)
586 }
587 }
588 for _, f := range w.Floats {
589 if f.free && f.back != nil {
590 f = f.rehome()
591 }
592 m.Workspaces[m.Active].AddFloating(f.ID, f.W, f.H)
593 m.Workspaces[m.Active].Floats[len(m.Workspaces[m.Active].Floats)-1] = f
594 }
595 m.Workspaces[m.Active].reconcileFloats()
596 for _, f := range w.Stash {
597 m.Workspaces[m.Active].Stash = append(m.Workspaces[m.Active].Stash, f.rehome())
598 }
599 }
600 m.hidden = hidden
601 m.named = append([]NamedWorkspace(nil), specs...)
602 var nextHidden []*Workspace
603 for _, s := range specs {
604 w := byName[s.Name]
605 if w == nil {
606 w = m.newWorkspace()
607 w.Name = s.Name
608 }
609 if i := indexOf(m.Workspaces, w); i >= 0 {
610 m.removeNumbered(i)
611 }
612 nextHidden = append(nextHidden, w)
613 }
614 m.hidden = nextHidden
615 if m.back != nil && !m.has(m.back) {
616 m.back = nil
617 }
618 m.shown = nil
619 switch {
620 case cur == nil:
621 case indexOf(m.hidden, cur) >= 0:
622 m.shown = cur
623 case indexOf(m.Workspaces, cur) >= 0:
624 m.Active = indexOf(m.Workspaces, cur)
625 }
626 m.applyNamed()
627 m.normalize()
628}
629
630// removeNumbered drops Workspaces[i] and keeps Active on the same workspace
631// when possible.
632func (m *Monitor) removeNumbered(i int) {
633 m.Workspaces = append(m.Workspaces[:i], m.Workspaces[i+1:]...)
634 if i < m.Active || m.Active >= len(m.Workspaces) {
635 m.Active = max(m.Active-1, 0)
636 }
637 if len(m.Workspaces) == 0 {
638 m.Workspaces = append(m.Workspaces, m.newWorkspace())
639 }
640}