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