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