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.Gaps = m.template.Gaps
130 w.border = m.template.border
131 w.stashWidth, w.stashGap = m.template.stashWidth, m.template.stashGap
132 w.SetOutput(m.template.Output.W, m.template.Output.H)
133 w.SetUsable(m.template.Usable)
134 if hidden {
135 m.hidden = append(m.hidden, w)
136 } else {
137 // normalize keeps a trailing empty workspace: stay above it.
138 at := min(max(pos, 0), len(m.Workspaces)-1)
139 m.Workspaces = slices.Insert(m.Workspaces, at, w)
140 if at <= m.Active {
141 m.Active++
142 }
143 }
144 m.applyNamed()
145 m.normalize()
146}
147
148// useSpecs sets the overrides of every named workspace, including those
149// that live on other monitors and may move here.
150func (m *Monitor) useSpecs(all []NamedWorkspace) {
151 m.named = append([]NamedWorkspace(nil), all...)
152 m.applyNamed()
153}
154
155// Current is the workspace on screen.
156func (m *Monitor) Current() *Workspace {
157 if m.shown != nil {
158 return m.shown
159 }
160 return m.Workspaces[m.Active]
161}
162
163func (m *Monitor) newWorkspace() *Workspace {
164 w := m.template
165 w.colBuf, w.rowBuf, w.tileBuf, w.zoomBuf = nil, nil, nil, nil
166 w.Columns, w.Floats, w.maximized, w.floatFocus, w.home = nil, nil, nil, false, ""
167 w.overviewAfter = nil
168 w.Stash, w.stashAt, w.stashFocus, w.stashHidden, w.hiddenFullscreen = nil, 0, false, false, 0
169 (*m.nextID)++
170 w.ID = *m.nextID
171 return &w
172}
173
174// normalize keeps one trailing empty unnamed workspace and drops other empty
175// unnamed ones that are not active. The active workspace never changes.
176func (m *Monitor) normalize() {
177 spare := func(i int) bool { return m.Workspaces[i].empty() && m.Workspaces[i].Name == "" }
178 for i := len(m.Workspaces) - 2; i >= 0; i-- {
179 if spare(i) && i != m.Active {
180 m.Workspaces = append(m.Workspaces[:i], m.Workspaces[i+1:]...)
181 if i < m.Active {
182 m.Active--
183 }
184 }
185 }
186 for n := len(m.Workspaces); n >= 2 && spare(n-1) && spare(n-2) && m.Active != n-1; n-- {
187 m.Workspaces = m.Workspaces[:n-1]
188 }
189 if n := len(m.Workspaces); n == 0 || !spare(n-1) {
190 m.Workspaces = append(m.Workspaces, m.newWorkspace())
191 }
192 for _, w := range m.hidden {
193 if indexOf(m.Workspaces, w.overviewAfter) < 0 {
194 w.overviewAfter = nil
195 }
196 }
197}
198
199// Focus switches to the numbered workspace at index i, clamped to the list.
200func (m *Monitor) Focus(i int) {
201 i = min(max(i, 0), len(m.Workspaces)-1)
202 m.stopSwitch()
203 m.shown = nil
204 m.Active = i
205 m.normalize()
206}
207
208// stepWorkspace switches to the previous or next workspace (dir -1/1) when
209// axis is the current layout's workspace axis, and reports whether it did;
210// Focus clamps past the first or the trailing workspace. A hidden named
211// workspace is outside the numbered list: it never steps.
212func (m *Monitor) stepWorkspace(axis layoutAxis, dir int) bool {
213 step := m.Current().policy().workspaceStep(axis, dir)
214 if m.shown != nil || step == 0 {
215 return false
216 }
217 m.Focus(m.Active + step)
218 return true
219}
220
221// occupiedStep is the numbered workspace dir (-1/1) away along axis when
222// it is the layout's workspace axis and that workspace is not the trailing
223// empty one, or -1.
224func (m *Monitor) occupiedStep(axis layoutAxis, dir int) int {
225 step := m.Current().policy().workspaceStep(axis, dir)
226 to := m.Active + step
227 if m.shown != nil || step == 0 || to < 0 || to >= len(m.Workspaces) {
228 return -1
229 }
230 if w := m.Workspaces[to]; w.empty() && w.Name == "" {
231 return -1
232 }
233 return to
234}
235
236// MoveWorkspace swaps the active numbered workspace with its neighbor up
237// (dir -1) or down (dir 1); the view follows it. It never passes the
238// trailing empty workspace.
239func (m *Monitor) MoveWorkspace(dir int) {
240 to := m.Active + dir
241 if m.shown != nil || (dir != -1 && dir != 1) || to < 0 || to >= len(m.Workspaces)-1 || m.Active >= len(m.Workspaces)-1 {
242 return
243 }
244 m.Workspaces[m.Active], m.Workspaces[to] = m.Workspaces[to], m.Workspaces[m.Active]
245 m.stopSwitch()
246 m.Active = to
247 m.normalize()
248}
249
250// FocusNumber switches to workspace n (1-based); past the end means the last.
251func (m *Monitor) FocusNumber(n int) { m.Focus(n - 1) }
252
253// show puts w on screen, numbered or hidden.
254func (m *Monitor) show(w *Workspace) {
255 if i := indexOf(m.Workspaces, w); i >= 0 {
256 m.Focus(i)
257 return
258 }
259 m.stopSwitch()
260 m.shown = w
261 m.normalize()
262}
263
264// ToggleNamed shows the named workspace, or returns to the previous one when
265// it is already on screen. Unknown names do nothing.
266func (m *Monitor) ToggleNamed(name string) {
267 target := m.byName(name)
268 switch cur := m.Current(); {
269 case target == nil:
270 case target != cur:
271 anchor := cur
272 if m.isHidden(cur) {
273 anchor = cur.overviewAfter
274 }
275 if indexOf(m.Workspaces, anchor) < 0 {
276 anchor = m.Workspaces[m.Active]
277 }
278 target.overviewAfter = anchor
279 m.back = cur
280 m.show(target)
281 case m.back != nil && m.has(m.back):
282 back := m.back
283 m.back = nil
284 m.show(back)
285 case m.shown != nil:
286 m.back = nil
287 m.shown = nil
288 m.normalize()
289 }
290}
291
292// byName returns the configured workspace with that name, or nil.
293func (m *Monitor) byName(name string) *Workspace {
294 for w := range m.all() {
295 if w.Name == name {
296 return w
297 }
298 }
299 return nil
300}
301
302func indexOf(list []*Workspace, w *Workspace) int {
303 for i, v := range list {
304 if v == w {
305 return i
306 }
307 }
308 return -1
309}
310
311// has reports whether w still belongs to the monitor.
312func (m *Monitor) has(w *Workspace) bool {
313 return indexOf(m.Workspaces, w) >= 0 || indexOf(m.hidden, w) >= 0
314}
315
316// find returns the workspace holding the window; i is -1 for hidden ones.
317func (m *Monitor) find(id WindowID) (*Workspace, int) {
318 for i, w := range m.Workspaces {
319 if w.has(id) {
320 return w, i
321 }
322 }
323 for _, w := range m.hidden {
324 if w.has(id) {
325 return w, -1
326 }
327 }
328 return nil, -1
329}
330
331// AddWindow places a new window on the workspace on screen; with
332// stash.capture, in its stash when shown.
333func (m *Monitor) AddWindow(id WindowID) { m.addWindow(id, 0) }
334
335// addWindow is AddWindow with the width in cells of the new column (zero:
336// automatic).
337func (m *Monitor) addWindow(id WindowID, cells int) {
338 if w, _ := m.find(id); w != nil {
339 return
340 }
341 if w := m.Current(); !m.stashCapture || !w.captureStash(id) {
342 w.addWindow(id, cells)
343 }
344 m.normalize()
345}
346
347// AddFloating adds a floating window to the active workspace.
348func (m *Monitor) AddFloating(id WindowID, width, height int) {
349 if w, _ := m.find(id); w != nil {
350 return
351 }
352 m.Current().AddFloating(id, width, height)
353 m.normalize()
354}
355
356// RemoveWindow drops the window wherever it is; focus stays on the active
357// workspace.
358func (m *Monitor) RemoveWindow(id WindowID) {
359 heir := m.sw.heir(id)
360 if w, _ := m.find(id); w != nil {
361 w.RemoveWindow(id)
362 m.normalize()
363 }
364 m.sw.remove(id, heir)
365}
366
367// SetFullscreen applies a client request, in place. A client request never
368// moves the user (ADR 011): the view and the focus stay where they are.
369func (m *Monitor) SetFullscreen(id WindowID, on bool) {
370 if w, _ := m.find(id); w != nil {
371 w.SetFullscreen(id, on)
372 }
373}
374
375// ToggleFullscreen is the bind: the focused window goes fullscreen in place,
376// or leaves it. Focus moves leave it too (Workspace.leaveCover).
377func (m *Monitor) ToggleFullscreen() { m.Current().ToggleFullscreen() }
378
379// MoveToWorkspace moves the focused column (or only the focused window) to
380// numbered workspace index i. A floating window always moves alone. Focus
381// stays here unless followMove is set (ADR 017).
382func (m *Monitor) MoveToWorkspace(i int, column bool) {
383 i = min(max(i, 0), len(m.Workspaces)-1)
384 cur, to := m.Current(), m.Workspaces[i]
385 id, ok := cur.Focused()
386 if !ok || to == cur {
387 return
388 }
389 if f := cur.floatIndex(id); f >= 0 {
390 fl := cur.Floats[f]
391 if fl.free && fl.back != nil {
392 // Its column place is on this workspace.
393 fl = fl.rehome()
394 }
395 cur.RemoveWindow(id)
396 to.AddFloating(id, fl.W, fl.H)
397 to.Floats[len(to.Floats)-1] = fl
398 // As receive: a fullscreen window would hide it.
399 to.Activate(id)
400 } else if f := cur.stashIndex(id); f >= 0 {
401 // It joins the end of the stash there, shown and selected.
402 fl := cur.Stash[f].rehome()
403 cur.RemoveWindow(id)
404 to.addStash(fl)
405 to.Activate(id)
406 } else {
407 col := Column{Windows: []WindowID{id}}
408 ok := true
409 if column {
410 col, ok = cur.takeColumn()
411 } else {
412 _, ok = cur.takeWindow()
413 }
414 if !ok {
415 return
416 }
417 to.receive(col, -1)
418 }
419 if m.followMove {
420 m.Active = i
421 m.shown = nil
422 }
423 m.normalize()
424}
425
426// receive adds a column moved in from another workspace and focuses it: at
427// index at, or where a new window goes when at < 0. A fullscreen window
428// there is left, so the moved window is seen.
429func (w *Workspace) receive(col Column, at int) {
430 id := col.Windows[col.Focus]
431 switch {
432 case at < 0:
433 w.addColumn(col)
434 default:
435 w.insertColumn(at, col)
436 }
437 w.Activate(id)
438}
439
440// Focused is the focused window of the workspace on screen; in the
441// overview, the selected stash card when it is one.
442func (m *Monitor) Focused() (WindowID, bool) {
443 if id := m.card(); id != 0 {
444 return id, true
445 }
446 return m.Current().Focused()
447}
448
449// Layout places every window: the workspace on screen laid out, others hidden.
450// The slice is fresh, the caller's to keep; the per-frame paths use layoutInto.
451func (m *Monitor) Layout() []Placement { return m.layoutInto(nil) }
452
453// layoutInto is Layout built in dst[:0]: the result aliases dst's storage,
454// so a reused dst's previous result is overwritten and only a caller done
455// with it may pass it. It is never the monitor's own scratch.
456func (m *Monitor) layoutInto(dst []Placement) []Placement {
457 if m.switcherShown() {
458 return m.switcherLayout(dst[:0])
459 }
460 if m.ov.open {
461 return m.overviewLayout(dst[:0])
462 }
463 result := dst[:0]
464 cur := m.Current()
465 for w := range m.all() {
466 if w == cur {
467 result = w.appendLayout(result)
468 continue
469 }
470 result = w.appendHidden(result, true)
471 }
472 return m.slideLayout(result)
473}
474
475// Output is the whole logical monitor, whatever the workspaces' sizes.
476func (m *Monitor) Output() Rect { return m.template.Output }
477
478// Frame is where the workspace on screen draws and takes input, in monitor
479// coordinates: its viewport, or the whole monitor in a preview (the
480// overview or the column switcher). Slides
481// involving a sized workspace remain settled inside the current frame.
482func (m *Monitor) Frame() Rect {
483 if m.previewing() {
484 return m.Output()
485 }
486 return m.Current().Output
487}
488
489// viewport is where the workspace on screen is seen: Frame, and the
490// usable area its tiles live in.
491func (m *Monitor) viewport() viewport {
492 return viewport{frame: m.Frame(), usable: m.Current().Usable}
493}
494
495// frameHas reports whether the output-local point is inside the frame.
496func (m *Monitor) frameHas(x, y float64) bool {
497 f := m.Frame()
498 return x >= float64(f.X) && x < float64(f.X+f.W) && y >= float64(f.Y) && y < float64(f.Y+f.H)
499}
500
501func (m *Monitor) each(f func(*Workspace)) {
502 f(&m.template)
503 for _, w := range m.Workspaces {
504 f(w)
505 }
506 for _, w := range m.hidden {
507 f(w)
508 }
509}
510func (m *Monitor) SetOutput(width, height int) {
511 m.each(func(w *Workspace) { w.SetOutput(width, height) })
512}
513func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) }
514func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps(g) }) }
515func (m *Monitor) SetBorder(b int) {
516 m.each(func(w *Workspace) { w.border = max(b, 0); w.reconcileFloats() })
517}
518
519// SetStash sets the width of a stashed window (stash.width; 0 is the
520// default) and the space between it and its neighbors (stash.gap), in
521// percent of the usable width.
522func (m *Monitor) SetStash(width, gap int) {
523 if width != 0 {
524 width = min(max(width, 10), 90)
525 }
526 m.each(func(w *Workspace) { w.stashWidth, w.stashGap = width, min(max(gap, 0), 10) })
527}
528
529// SetStashCapture makes new windows join the shown stash (stash.capture).
530func (m *Monitor) SetStashCapture(on bool) { m.stashCapture = on }
531
532// SetFollowMove makes moves to another workspace show it (focus.follow-move).
533func (m *Monitor) SetFollowMove(on bool) { m.followMove = on }
534
535// SetMaxColumns sets the default; named workspaces may override it.
536func (m *Monitor) SetMaxColumns(n int) {
537 m.template.MaxColumns = n
538 m.applyNamed()
539}
540
541// SetOverflow sets the default; named workspaces may override it.
542func (m *Monitor) SetOverflow(o Overflow) {
543 m.template.Overflow = o
544 m.applyNamed()
545}
546
547// applyNamed gives every workspace the defaults or its named overrides.
548func (m *Monitor) applyNamed() {
549 specs := map[string]NamedWorkspace{}
550 for _, s := range m.named {
551 specs[s.Name] = s
552 }
553 m.each(func(w *Workspace) {
554 s := specs[w.Name]
555 before, axis := w.Overflow, w.policy().content
556 w.Overflow = m.template.Overflow
557 if s.Overflow != "" {
558 w.Overflow = s.Overflow
559 }
560 if before != w.Overflow && w.policy().equalCells {
561 w.unmaximize()
562 }
563 // The view offset belongs to the old content axis: SetMaxColumns
564 // scrolls back to the focus along the new one.
565 if w.policy().content != axis {
566 w.view.stop()
567 w.View = 0
568 }
569 w.SetMaxColumns(m.template.MaxColumns)
570 if s.MaxColumns > 0 {
571 w.SetMaxColumns(s.MaxColumns)
572 }
573 // Named workspaces take their configured size; any other keeps its own.
574 if w.Name != "" {
575 w.SetSize(s.Size[0], s.Size[1])
576 }
577 })
578}
579
580// SetNamed applies bind-only named workspaces from config. Removed named
581// workspaces hand their windows to the active numbered workspace.
582func (m *Monitor) SetNamed(specs []NamedWorkspace) {
583 cur := m.Current()
584 want := map[string]NamedWorkspace{}
585 for _, s := range specs {
586 want[s.Name] = s
587 }
588 byName := map[string]*Workspace{}
589 for _, w := range m.Workspaces {
590 if _, ok := want[w.Name]; w.Name != "" && !ok {
591 w.Name = ""
592 w.SetSize(0, 0)
593 }
594 if w.Name != "" {
595 byName[w.Name] = w
596 }
597 }
598 var hidden []*Workspace
599 for _, w := range m.hidden {
600 if _, ok := want[w.Name]; ok {
601 byName[w.Name] = w
602 hidden = append(hidden, w)
603 continue
604 }
605 if w == cur {
606 cur = nil
607 }
608 for _, c := range w.Columns {
609 for _, id := range c.Windows {
610 m.Workspaces[m.Active].AddWindow(id)
611 }
612 }
613 for _, f := range w.Floats {
614 if f.free && f.back != nil {
615 f = f.rehome()
616 }
617 m.Workspaces[m.Active].AddFloating(f.ID, f.W, f.H)
618 m.Workspaces[m.Active].Floats[len(m.Workspaces[m.Active].Floats)-1] = f
619 }
620 m.Workspaces[m.Active].reconcileFloats()
621 for _, f := range w.Stash {
622 m.Workspaces[m.Active].Stash = append(m.Workspaces[m.Active].Stash, f.rehome())
623 }
624 }
625 m.hidden = hidden
626 m.named = append([]NamedWorkspace(nil), specs...)
627 var nextHidden []*Workspace
628 for _, s := range specs {
629 w := byName[s.Name]
630 if w == nil {
631 w = m.newWorkspace()
632 w.Name = s.Name
633 }
634 if i := indexOf(m.Workspaces, w); i >= 0 {
635 m.removeNumbered(i)
636 }
637 nextHidden = append(nextHidden, w)
638 }
639 m.hidden = nextHidden
640 if m.back != nil && !m.has(m.back) {
641 m.back = nil
642 }
643 m.shown = nil
644 switch {
645 case cur == nil:
646 case indexOf(m.hidden, cur) >= 0:
647 m.shown = cur
648 case indexOf(m.Workspaces, cur) >= 0:
649 m.Active = indexOf(m.Workspaces, cur)
650 }
651 m.applyNamed()
652 m.normalize()
653}
654
655// removeNumbered drops Workspaces[i] and keeps Active on the same workspace
656// when possible.
657func (m *Monitor) removeNumbered(i int) {
658 m.Workspaces = append(m.Workspaces[:i], m.Workspaces[i+1:]...)
659 if i < m.Active || m.Active >= len(m.Workspaces) {
660 m.Active = max(m.Active-1, 0)
661 }
662 if len(m.Workspaces) == 0 {
663 m.Workspaces = append(m.Workspaces, m.newWorkspace())
664 }
665}