1package core
2
3import (
4 "slices"
5
6 "github.com/bnema/neferwl/internal/ports"
7)
8
9// screen is one connected output: its monitor tree, scale, layer surfaces and
10// place in the global layout (ADR 011). Outputs are ordered by config
11// (output.<name>.*), then by connection; order places them (placeOutputs).
12type screen struct {
13 positionHint positionHintState
14 info ports.OutputInfo
15 mon *Monitor
16 scale, cfgScale float64
17 // transform is how the physical target holds the screen's logical
18 // output (output.<name>.transform); 90/270 swap width and height.
19 transform ports.BufferTransform
20 primary bool
21 x, y int // logical origin in the global layout
22 off bool // turned off by a client (output power management)
23 layers []ports.LayerSurface
24 placed []ports.SceneLayer
25 // capture holds the attached layers of the capture exclusion: shown over
26 // a fullscreen window (capture.go).
27 capture map[WindowID]bool
28 // capScene and capMarks are what the last scene carried (Capture and
29 // CaptureIndicators): immutable, shared by the next one while unchanged.
30 capScene *ports.SceneCapture
31 capMarks []ports.CaptureIndicator
32 // last is the scene published for this screen: an identical next one
33 // keeps its Seq (Scene.SameAs). Zero after a protected publish.
34 last ports.Scene
35 // settledLayout is the monitor's layout as of the last publish: what
36 // configures are sized from. shown is the same with the rect motions applied: what is
37 // drawn and hit-tested. They are the same slice while no rect motion
38 // runs. Both are built once per publish (refreshShown); shown is nil
39 // before the first publish, then callers measure the monitor's layout.
40 settledLayout, shown []Placement
41 // layoutBuf backs settledLayout (and shown, while no rect motion runs),
42 // rebuilt in place by every refreshShown; shownBuf and settledBuf back
43 // shown and settledLayout while rect motions run. All three are owner
44 // goroutine scratch reused across publishes: a Scene never aliases them
45 // (its windows are copied from the placements), and a caller that
46 // keeps a layout past the next publish copies it (Layout() is fresh).
47 layoutBuf, shownBuf, settledBuf []Placement
48 // leaveIDs is withLeaving's scratch list.
49 leaveIDs []WindowID
50 // rects are the running per-window rect motions (transition.go) of
51 // rectsWS, the workspace they began on (nil: the overview); created
52 // lazily.
53 rects map[WindowID]rectMotion
54 rectsWS *Workspace
55}
56
57// springing reports whether a camera or rect spring runs on the screen.
58func (s *screen) springing() bool { return s.mon.springing() || len(s.rects) > 0 }
59
60// stopRects drops the rect motions: the windows are drawn where they are.
61func (s *screen) stopRects() { clear(s.rects); s.rectsWS = nil }
62
63// rectsOwner is what the screen's rect motions belong to: its workspace on
64// screen, or nil in a preview (the overview or the column switcher), where the
65// workspace changes under the cards.
66func (s *screen) rectsOwner() *Workspace {
67 if s.mon.previewing() {
68 return nil
69 }
70 return s.mon.Current()
71}
72
73// shownLayout is the layout as drawn, for hit-testing and popups; before
74// the first publish it is the monitor's.
75func (s *screen) shownLayout() []Placement {
76 if s.shown != nil {
77 return s.shown
78 }
79 return s.mon.Layout()
80}
81
82func (s *screen) name() string { return s.info.Name }
83
84// modeSize is the physical mode size in the screen's logical orientation:
85// width and height swap for a 90/270 transform.
86func (s *screen) modeSize() (w, h int) {
87 return s.transform.Size(s.info.Width, s.info.Height)
88}
89
90// rect is the screen's place in the global logical layout.
91func (s *screen) rect() Rect {
92 o := s.mon.Output()
93 return Rect{X: s.x, Y: s.y, W: o.W, H: o.H}
94}
95
96func (s *screen) placement() ports.OutputPlacement {
97 r := s.rect()
98 return ports.OutputPlacement{Info: s.info, X: r.X, Y: r.Y, Width: r.W, Height: r.H, Scale: s.scale, Transform: s.transform, Primary: s.primary}
99}
100
101// setScale resizes the logical output; layer placement follows.
102func (s *screen) setScale(v float64) {
103 s.scale = SnapScale(v)
104 w, h := s.modeSize()
105 s.mon.SetOutput(logical(w, s.scale), logical(h, s.scale))
106 s.arrange()
107}
108
109// arrange places the layer surfaces and gives workspaces the usable area.
110func (s *screen) arrange() {
111 var usable ports.Rect
112 o := s.mon.Output()
113 s.placed, usable = arrangeLayers(o.W, o.H, s.layers)
114 s.mon.SetUsable(usable)
115}
116
117// order sorts screens by config order, then connection order, and places them
118// in the global layout (placeOutputs).
119func (c *Core) order() {
120 rank := func(s *screen) int {
121 if i := c.outputIndex(s.name()); i >= 0 {
122 return i
123 }
124 return len(c.cfg.Outputs)
125 }
126 focused := c.cur()
127 slices.SortStableFunc(c.screens, func(a, b *screen) int { return rank(a) - rank(b) })
128 c.focusScreen = slices.Index(c.screens, focused)
129 items := make([]placeItem, len(c.screens))
130 for i, s := range c.screens {
131 r := s.rect()
132 items[i].name, items[i].w, items[i].h = s.name(), r.W, r.H
133 if cfg, ok := c.outputConfig(s.name()); ok {
134 items[i].pos, items[i].anchor = cfg.Pos, cfg.Anchor
135 }
136 }
137 for i, p := range placeOutputs(items) {
138 c.screens[i].x, c.screens[i].y = p.X, p.Y
139 }
140}
141
142// outputIndex is the position of the output.<name> entry in the config, or
143// -1; names are unique in the config.
144func (c *Core) outputIndex(name string) int {
145 return slices.IndexFunc(c.cfg.Outputs, func(o ports.OutputConfig) bool { return o.Name == name })
146}
147
148// outputConfig returns the output.<name> entry.
149func (c *Core) outputConfig(name string) (ports.OutputConfig, bool) {
150 i := c.outputIndex(name)
151 if i < 0 {
152 return ports.OutputConfig{}, false
153 }
154 return c.cfg.Outputs[i], true
155}
156
157// layout lists the connected outputs; the placeholder is not one.
158func (c *Core) layout() ports.Layout { return c.layoutInto(make(ports.Layout, 0, len(c.screens))) }
159
160// layoutInto is layout built in l[:0]: the result aliases l's storage.
161func (c *Core) layoutInto(l ports.Layout) ports.Layout {
162 l = l[:0]
163 for _, s := range c.screens {
164 if s.name() != "" {
165 l = append(l, s.placement())
166 }
167 }
168 return l
169}
170
171// cur is the focused screen: new windows open there and binds apply to it.
172func (c *Core) cur() *screen { return c.screens[c.focusScreen] }
173
174func (c *Core) screenIndex(name string) int {
175 return slices.IndexFunc(c.screens, func(s *screen) bool { return s.name() == name })
176}
177
178// screenAt is the index of the first connected screen containing the global
179// point, or -1. It does not allocate.
180func (c *Core) screenAt(x, y float64) int {
181 for i, s := range c.screens {
182 if s.name() == "" {
183 continue
184 }
185 r := s.rect()
186 if x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H) {
187 return i
188 }
189 }
190 return -1
191}
192
193// clampPointer keeps the pointer on a screen: a point outside every screen
194// stays on the screen under (fromX, fromY), or the first one, clamped to its
195// edges.
196func (c *Core) clampPointer(fromX, fromY, x, y float64) (float64, float64) {
197 i := c.screenAt(x, y)
198 if i >= 0 {
199 return x, y
200 }
201 if i = c.screenAt(fromX, fromY); i < 0 {
202 i = slices.IndexFunc(c.screens, func(s *screen) bool { return s.name() != "" })
203 if i < 0 {
204 return x, y
205 }
206 }
207 r := c.screens[i].rect()
208 return min(max(x, float64(r.X)), float64(r.X+r.W)-1), min(max(y, float64(r.Y)), float64(r.Y+r.H)-1)
209}
210
211// screenOf returns the screen and workspace holding the window.
212func (c *Core) screenOf(id WindowID) (*screen, *Workspace) {
213 for _, s := range c.screens {
214 if w, _ := s.mon.find(id); w != nil {
215 return s, w
216 }
217 }
218 return nil, nil
219}
220
221// byName finds a named workspace on any screen.
222func (c *Core) byName(name string) (*screen, *Workspace) {
223 for _, s := range c.screens {
224 if w := s.mon.byName(name); w != nil {
225 return s, w
226 }
227 }
228 return nil, nil
229}
230
231// isPrimary reports output.<name>.primary.
232func (c *Core) isPrimary(name string) bool {
233 o, _ := c.outputConfig(name)
234 return o.Primary
235}
236
237// anyWindow reports whether any workspace holds a window.
238func (c *Core) anyWindow() bool {
239 for _, s := range c.screens {
240 for w := range s.mon.all() {
241 if !w.empty() {
242 return true
243 }
244 }
245 }
246 return false
247}
248
249// configScale is output.<name>.scale, else 1.
250func (c *Core) configScale(name string) float64 {
251 v := 1.0
252 if o, ok := c.outputConfig(name); ok && o.Scale != 0 {
253 v = o.Scale
254 }
255 return SnapScale(v)
256}
257
258// configTransform is output.<name>.transform, else 0.
259func (c *Core) configTransform(name string) ports.BufferTransform {
260 o, _ := c.outputConfig(name)
261 return o.Transform
262}
263
264// applyOutputConfig picks up output.<name>.scale and .transform when they
265// changed in the config. A new scale replaces any live scale-up/scale-down
266// adjustment; a transform alone keeps it.
267func (c *Core) applyOutputConfig() {
268 for _, s := range c.screens {
269 v, t := c.configScale(s.name()), c.configTransform(s.name())
270 if v == s.cfgScale && t == s.transform {
271 continue
272 }
273 scale := s.scale
274 if v != s.cfgScale {
275 scale = v
276 }
277 s.cfgScale, s.transform = v, t
278 s.setScale(scale)
279 }
280 c.order()
281}
282
283// addScreen handles a connected output or a new mode for a known one. The
284// first output takes over the placeholder screen core starts with; a new
285// monitor gets its named workspaces and takes its guests back.
286func (c *Core) addScreen(info ports.OutputInfo) {
287 if i := c.screenIndex(info.Name); i >= 0 {
288 s := c.screens[i]
289 s.info = info
290 if s.mon.Key != info.Key() {
291 // Another monitor on the same connector: key rules change.
292 s.mon.Key = info.Key()
293 c.settings(s.mon)
294 }
295 s.transform = c.configTransform(info.Name)
296 s.setScale(s.scale)
297 c.order()
298 return
299 }
300 var s *screen
301 if len(c.screens) == 1 && c.screens[0].name() == "" {
302 s = c.screens[0]
303 s.info = info
304 } else {
305 s = &screen{info: info, mon: newMonitorWithIDs(info.Name, info.Key(), &c.nextWorkspaceID)}
306 c.screens = append(c.screens, s)
307 }
308 s.off = c.offGone[info.Name]
309 delete(c.offGone, info.Name)
310 s.mon.Name, s.mon.Key = info.Name, info.Key()
311 c.settings(s.mon)
312 s.cfgScale = c.configScale(info.Name)
313 s.transform = c.configTransform(info.Name)
314 s.setScale(s.cfgScale)
315 s.primary = c.isPrimary(info.Name)
316 c.order()
317 // The primary output takes the focus while nothing is open yet
318 // (startup). Later, plugging it in never moves the user.
319 if s.primary && !c.anyWindow() {
320 c.focusScreen = slices.Index(c.screens, s)
321 }
322 c.named()
323}
324
325// removeScreen moves every workspace of an unplugged output to the focused
326// screen (another one if it was focused), numbered ones below its numbered
327// workspaces, hidden ones hidden. They remember their home and position and
328// return when it is plugged in again. The last output becomes the
329// placeholder again: it keeps its windows for the next output.
330func (c *Core) removeScreen(name string) {
331 i := c.screenIndex(name)
332 if i < 0 {
333 return
334 }
335 if c.switcher.sc == c.screens[i] {
336 c.cancelSwitcher()
337 }
338 // A capture session's off-screen workspace of this output goes with it.
339 if c.configures.cw.sc == c.screens[i] {
340 c.configures.cw.reset()
341 }
342 if c.screens[i].off {
343 if c.offGone == nil {
344 c.offGone = map[string]bool{}
345 }
346 c.offGone[name] = true
347 }
348 if len(c.screens) == 1 {
349 c.screens[0].info = ports.OutputInfo{}
350 c.screens[0].layers = nil
351 // Nothing draws the placeholder: its springs would only wake the
352 // fallback timer, and start from the old output's geometry if an
353 // output came back.
354 c.screens[0].stopAnimations()
355 return
356 }
357 gone := c.screens[i]
358 if c.swipe != nil && c.swipe.screen == gone {
359 // Its slide would stay on the workspaces the host adopts.
360 c.dropSwipe()
361 c.swipe = nil
362 }
363 focused := c.cur()
364 c.screens = slices.Delete(c.screens, i, i+1)
365 c.focusScreen = max(slices.Index(c.screens, focused), 0)
366 host := c.cur()
367 for pos, w := range gone.mon.Workspaces {
368 if w.empty() && w.Name == "" {
369 continue
370 }
371 switch {
372 case host.mon.matches(w.home):
373 // A guest from the host goes home.
374 w.home = ""
375 host.mon.adopt(w, false, w.homePos)
376 continue
377 case w.home == "":
378 w.home, w.homePos = gone.info.Key(), pos
379 }
380 host.mon.adopt(w, false, len(host.mon.Workspaces))
381 }
382 for _, w := range gone.mon.hidden {
383 switch {
384 case host.mon.matches(w.home):
385 w.home = ""
386 case w.home == "":
387 w.home = gone.info.Key()
388 }
389 host.mon.adopt(w, true, 0)
390 }
391 c.order()
392}
393
394// settleGuests returns guests to their connected home monitor, at their
395// original positions. A guest on screen stays until the user leaves it
396// (golden rule: automatic events never move the user).
397func (c *Core) settleGuests() {
398 for _, home := range c.screens {
399 type guest struct {
400 w, anchor *Workspace
401 host *Monitor
402 hidden bool
403 }
404 var list []guest
405 for _, host := range c.screens {
406 if host == home {
407 continue
408 }
409 for w := range host.mon.all() {
410 if home.mon.matches(w.home) && host.mon.Current() != w {
411 list = append(list, guest{w: w, anchor: w.overviewAfter, host: host.mon, hidden: host.mon.isHidden(w)})
412 }
413 }
414 }
415 // Snapshot every anchor before take normalizes a host and clears
416 // references to numbered workspaces returning alongside named rows.
417 for _, g := range list {
418 g.host.take(g.w)
419 }
420 slices.SortStableFunc(list, func(a, b guest) int { return a.w.homePos - b.w.homePos })
421 // A monitor showing an empty workspace (just plugged in) shows
422 // its first returning workspace instead.
423 idle := home.mon.Current().empty() && home.mon.Current().Name == ""
424 for _, g := range list {
425 g.w.home = ""
426 home.mon.adopt(g.w, g.hidden, g.w.homePos)
427 if idle && !g.hidden {
428 home.mon.show(g.w)
429 idle = false
430 }
431 }
432 for _, g := range list {
433 if g.hidden && indexOf(home.mon.Workspaces, g.anchor) >= 0 {
434 g.w.overviewAfter = g.anchor
435 }
436 }
437 }
438}
439
440// named applies the named workspaces from config. One that exists stays on
441// its screen; a new one is created on its home monitor, or on the first
442// screen as a guest when its home is not connected.
443func (c *Core) named() {
444 target := map[string]*screen{}
445 for _, spec := range c.specs {
446 if s, _ := c.byName(spec.Name); s != nil {
447 target[spec.Name] = s
448 continue
449 }
450 target[spec.Name] = c.screens[0]
451 for _, s := range c.screens {
452 if s.mon.matches(spec.Monitor) {
453 target[spec.Name] = s
454 }
455 }
456 }
457 for _, s := range c.screens {
458 var mine []NamedWorkspace
459 for _, spec := range c.specs {
460 if target[spec.Name] == s {
461 mine = append(mine, spec)
462 }
463 }
464 s.mon.SetNamed(mine)
465 s.mon.useSpecs(c.specs)
466 }
467 for _, spec := range c.specs {
468 s, w := c.byName(spec.Name)
469 w.home = ""
470 if spec.Monitor != "" && !s.mon.matches(spec.Monitor) {
471 w.home = spec.Monitor
472 }
473 }
474}
475
476// bringNamed prepares a "workspace <name>" toggle from the focused screen.
477// The workspace goes to its home monitor when connected, else to the focused
478// screen; that screen takes the focus. It reports true when the workspace
479// is already on screen there after a focus change: reaching it must not
480// toggle it away.
481func (c *Core) bringNamed(name string) bool {
482 src, w := c.byName(name)
483 i := slices.IndexFunc(c.specs, func(s NamedWorkspace) bool { return s.Name == name })
484 if w == nil || i < 0 {
485 return false
486 }
487 to, home := c.focusScreen, c.specs[i].Monitor
488 if h := slices.IndexFunc(c.screens, func(s *screen) bool { return s.mon.matches(home) }); h >= 0 {
489 to, home = h, ""
490 }
491 dst := c.screens[to]
492 if to != c.focusScreen {
493 c.focusScreen = to
494 if src == dst && dst.mon.Current() == w {
495 return true
496 }
497 }
498 if src != dst {
499 moveNamed(src, dst, w, home)
500 }
501 return false
502}
503
504// moveNamed moves the named workspace w from src to dst, without changing
505// what dst shows. home is its new w.home: a guest keeps waiting for its
506// home; any other forgets the output it was unplugged from.
507func moveNamed(src, dst *screen, w *Workspace, home string) {
508 hidden := src.mon.isHidden(w)
509 src.mon.take(w)
510 w.home = home
511 dst.mon.adopt(w, hidden, len(dst.mon.Workspaces))
512}
513
514// settings applies output-wide config to a monitor.
515func (c *Core) settings(m *Monitor) {
516 rules := c.cfg.Layout.LayoutRules
517 for _, o := range c.cfg.Layout.Outputs {
518 if m.matches(o.Output) {
519 rules = o.Over(rules)
520 }
521 }
522 m.SetOverflow(Overflow(rules.Overflow))
523 m.SetMaxColumns(rules.MaxColumns)
524 m.SetGaps(c.cfg.Layout.Gaps)
525 m.SetBorder(c.cfg.Border.Width)
526 m.SetStash(c.cfg.Stash.Width, c.cfg.Stash.Gap)
527 m.SetStashCapture(c.cfg.Stash.Capture)
528 m.SetStashTerminal(c.cfg.Stash.SpawnTerminal && len(c.cfg.Terminal.Command) > 0)
529 m.SetFollowMove(c.cfg.Focus.FollowMove)
530}
531
532// direction is where a monitor action looks for a neighbor screen.
533type direction uint8
534
535const (
536 dirLeft direction = iota + 1
537 dirRight
538 dirUp
539 dirDown
540)
541
542// neighbor returns the index of the screen next to the focused one in
543// direction d, or -1 at the edge. Candidates lie fully beyond the focused
544// screen's edge; one sharing part of that edge wins over a diagonal one, then
545// the smallest gap, the largest shared edge, the closest centers and the
546// lowest index decide. Overlapping outputs (for instance two anchored to the
547// same reference) are reached only when no screen lies fully beyond the edge
548// in that direction: a second pass then takes the screens whose rectangle
549// intersects the focused one and whose center is strictly beyond the focused
550// center, ranked the same way with a gap of 0. A screen that does have a
551// strict candidate never falls back, so an overlapping one can stay out of
552// reach. Screens with the same center on the axis are unreachable by direction
553// (the pointer still reaches them). There is no wrap.
554func (c *Core) neighbor(d direction) int {
555 if d < dirLeft || d > dirDown {
556 return -1
557 }
558 // span is the extent of s along the move (lo, hi) and across it.
559 span := func(s *screen) (lo, hi, clo, chi int) {
560 r := s.rect()
561 if d == dirUp || d == dirDown {
562 return r.Y, r.Y + r.H, r.X, r.X + r.W
563 }
564 return r.X, r.X + r.W, r.Y, r.Y + r.H
565 }
566 lo, hi, clo, chi := span(c.cur())
567 pick := func(fallback bool) int {
568 best, bestKey := -1, [4]int{}
569 for i, s := range c.screens {
570 if i == c.focusScreen || s.name() == "" {
571 continue
572 }
573 l, h, cl, ch := span(s)
574 gap := l - hi
575 beyond := l+h > lo+hi // centers, doubled
576 if d == dirLeft || d == dirUp {
577 gap = lo - h
578 beyond = l+h < lo+hi
579 }
580 if fallback {
581 if !beyond || min(hi, h)-max(lo, l) <= 0 || min(chi, ch)-max(clo, cl) <= 0 {
582 continue
583 }
584 gap = 0
585 } else if gap < 0 {
586 continue
587 }
588 overlap := min(chi, ch) - max(clo, cl)
589 diagonal := 0
590 if overlap <= 0 {
591 diagonal = 1
592 }
593 key := [4]int{diagonal, gap, -max(overlap, 0), abs(cl + ch - clo - chi)}
594 if best < 0 || slices.Compare(key[:], bestKey[:]) < 0 {
595 best, bestKey = i, key
596 }
597 }
598 return best
599 }
600 if i := pick(false); i >= 0 {
601 return i
602 }
603 return pick(true)
604}
605
606// moveWorkspace moves the workspace on screen to the neighbor screen, where
607// it is appended and shown; it becomes its new home (ADR 011).
608func (c *Core) moveWorkspace(d direction) {
609 to := c.neighbor(d)
610 if to < 0 {
611 return
612 }
613 from := c.cur()
614 w := from.mon.Current()
615 if w.empty() && w.Name == "" {
616 return
617 }
618 hidden := from.mon.isHidden(w)
619 from.mon.take(w)
620 w.home = ""
621 dst := c.screens[to]
622 dst.mon.adopt(w, hidden, len(dst.mon.Workspaces))
623 dst.mon.show(w)
624 c.focusScreen = to
625}