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