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