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 info ports.OutputInfo
14 mon *Monitor
15 scale, cfgScale float64
16 primary bool
17 x, y int // logical origin in the global layout
18 off bool // turned off by a client (output power management)
19 layers []ports.LayerSurface
20 placed []ports.SceneLayer
21 // capture holds the attached layers of the capture exclusion: shown over
22 // a fullscreen window (capture.go).
23 capture map[WindowID]bool
24 // capScene and capMarks are what the last scene carried (Capture and
25 // CaptureIndicators): immutable, shared by the next one while unchanged.
26 capScene *ports.SceneCapture
27 capMarks []ports.CaptureIndicator
28}
29
30func (s *screen) name() string { return s.info.Name }
31
32func (s *screen) placement() ports.OutputPlacement {
33 o := s.mon.Output()
34 return ports.OutputPlacement{Info: s.info, X: s.x, Y: s.y, Width: o.W, Height: o.H, Scale: s.scale, Primary: s.primary}
35}
36
37// setScale resizes the logical output; layer placement follows.
38func (s *screen) setScale(v float64) {
39 s.scale = SnapScale(v)
40 s.mon.SetOutput(logical(s.info.Width, s.scale), logical(s.info.Height, s.scale))
41 s.arrange()
42}
43
44// arrange places the layer surfaces and gives workspaces the usable area.
45func (s *screen) arrange() {
46 var usable ports.Rect
47 o := s.mon.Output()
48 s.placed, usable = arrangeLayers(o.W, o.H, s.layers)
49 s.mon.SetUsable(usable)
50}
51
52// order sorts screens by config order, then connection order, and places them
53// in the global layout (placeOutputs).
54func (c *Core) order() {
55 rank := func(s *screen) int {
56 if i := c.outputIndex(s.name()); i >= 0 {
57 return i
58 }
59 return len(c.cfg.Outputs)
60 }
61 focused := c.cur()
62 slices.SortStableFunc(c.screens, func(a, b *screen) int { return rank(a) - rank(b) })
63 c.focusScreen = slices.Index(c.screens, focused)
64 items := make([]placeItem, len(c.screens))
65 for i, s := range c.screens {
66 o := s.mon.Output()
67 items[i].name, items[i].w, items[i].h = s.name(), o.W, o.H
68 if cfg, ok := c.outputConfig(s.name()); ok {
69 items[i].pos, items[i].anchor = cfg.Pos, cfg.Anchor
70 }
71 }
72 for i, p := range placeOutputs(items) {
73 c.screens[i].x, c.screens[i].y = p.X, p.Y
74 }
75}
76
77// outputIndex is the position of the output.<name> entry in the config, or
78// -1; names are unique in the config.
79func (c *Core) outputIndex(name string) int {
80 return slices.IndexFunc(c.cfg.Outputs, func(o ports.OutputConfig) bool { return o.Name == name })
81}
82
83// outputConfig returns the output.<name> entry.
84func (c *Core) outputConfig(name string) (ports.OutputConfig, bool) {
85 i := c.outputIndex(name)
86 if i < 0 {
87 return ports.OutputConfig{}, false
88 }
89 return c.cfg.Outputs[i], true
90}
91
92// layout lists the connected outputs; the placeholder is not one.
93func (c *Core) layout() ports.Layout {
94 l := make(ports.Layout, 0, len(c.screens))
95 for _, s := range c.screens {
96 if s.name() != "" {
97 l = append(l, s.placement())
98 }
99 }
100 return l
101}
102
103// cur is the focused screen: new windows open there and binds apply to it.
104func (c *Core) cur() *screen { return c.screens[c.focusScreen] }
105
106func (c *Core) screenIndex(name string) int {
107 return slices.IndexFunc(c.screens, func(s *screen) bool { return s.name() == name })
108}
109
110// screenOf returns the screen and workspace holding the window.
111func (c *Core) screenOf(id WindowID) (*screen, *Workspace) {
112 for _, s := range c.screens {
113 if w, _ := s.mon.find(id); w != nil {
114 return s, w
115 }
116 }
117 return nil, nil
118}
119
120// byName finds a named workspace on any screen.
121func (c *Core) byName(name string) (*screen, *Workspace) {
122 for _, s := range c.screens {
123 if w := s.mon.byName(name); w != nil {
124 return s, w
125 }
126 }
127 return nil, nil
128}
129
130// isPrimary reports output.<name>.primary.
131func (c *Core) isPrimary(name string) bool {
132 o, _ := c.outputConfig(name)
133 return o.Primary
134}
135
136// anyWindow reports whether any workspace holds a window.
137func (c *Core) anyWindow() bool {
138 for _, s := range c.screens {
139 for w := range s.mon.all() {
140 if !w.empty() {
141 return true
142 }
143 }
144 }
145 return false
146}
147
148// configScale is output.<name>.scale, else 1.
149func (c *Core) configScale(name string) float64 {
150 v := 1.0
151 if o, ok := c.outputConfig(name); ok && o.Scale != 0 {
152 v = o.Scale
153 }
154 return SnapScale(v)
155}
156
157// applyConfigScales picks up output.<name>.scale when it changed in the
158// config, replacing any live scale-up/scale-down adjustment.
159func (c *Core) applyConfigScales() {
160 for _, s := range c.screens {
161 if v := c.configScale(s.name()); v != s.cfgScale {
162 s.cfgScale = v
163 s.setScale(v)
164 }
165 }
166 c.order()
167}
168
169// addScreen handles a connected output or a new mode for a known one. The
170// first output takes over the placeholder screen core starts with; a new
171// monitor gets its named workspaces and takes its guests back.
172func (c *Core) addScreen(info ports.OutputInfo) {
173 if i := c.screenIndex(info.Name); i >= 0 {
174 s := c.screens[i]
175 s.info = info
176 if s.mon.Key != info.Key() {
177 // Another monitor on the same connector: key rules change.
178 s.mon.Key = info.Key()
179 c.settings(s.mon)
180 }
181 s.setScale(s.scale)
182 c.order()
183 return
184 }
185 var s *screen
186 if len(c.screens) == 1 && c.screens[0].name() == "" {
187 s = c.screens[0]
188 s.info = info
189 } else {
190 s = &screen{info: info, mon: newMonitorWithIDs(info.Name, info.Key(), &c.nextWorkspaceID)}
191 c.screens = append(c.screens, s)
192 }
193 s.off = c.offGone[info.Name]
194 delete(c.offGone, info.Name)
195 s.mon.Name, s.mon.Key = info.Name, info.Key()
196 c.settings(s.mon)
197 s.cfgScale = c.configScale(info.Name)
198 s.setScale(s.cfgScale)
199 s.primary = c.isPrimary(info.Name)
200 c.order()
201 // The primary output takes the focus while nothing is open yet
202 // (startup). Later, plugging it in never moves the user.
203 if s.primary && !c.anyWindow() {
204 c.focusScreen = slices.Index(c.screens, s)
205 }
206 c.named()
207}
208
209// removeScreen moves every workspace of an unplugged output to the focused
210// screen (another one if it was focused), numbered ones below its numbered
211// workspaces, hidden ones hidden. They remember their home and position and
212// return when it is plugged in again. The last output becomes the
213// placeholder again: it keeps its windows for the next output.
214func (c *Core) removeScreen(name string) {
215 i := c.screenIndex(name)
216 if i < 0 {
217 return
218 }
219 // A capture session's off-screen workspace of this output goes with it.
220 if c.configures.cw.sc == c.screens[i] {
221 c.configures.cw.reset()
222 }
223 if c.screens[i].off {
224 if c.offGone == nil {
225 c.offGone = map[string]bool{}
226 }
227 c.offGone[name] = true
228 }
229 if len(c.screens) == 1 {
230 c.screens[0].info = ports.OutputInfo{}
231 c.screens[0].layers = nil
232 return
233 }
234 gone := c.screens[i]
235 focused := c.cur()
236 c.screens = slices.Delete(c.screens, i, i+1)
237 c.focusScreen = max(slices.Index(c.screens, focused), 0)
238 host := c.cur()
239 for pos, w := range gone.mon.Workspaces {
240 if w.empty() && w.Name == "" {
241 continue
242 }
243 switch {
244 case host.mon.matches(w.home):
245 // A guest from the host goes home.
246 w.home = ""
247 host.mon.adopt(w, false, w.homePos)
248 continue
249 case w.home == "":
250 w.home, w.homePos = gone.info.Key(), pos
251 }
252 host.mon.adopt(w, false, len(host.mon.Workspaces))
253 }
254 for _, w := range gone.mon.hidden {
255 switch {
256 case host.mon.matches(w.home):
257 w.home = ""
258 case w.home == "":
259 w.home = gone.info.Key()
260 }
261 host.mon.adopt(w, true, 0)
262 }
263 c.order()
264}
265
266// settleGuests returns guests to their connected home monitor, at their
267// original positions. A guest on screen stays until the user leaves it
268// (golden rule: automatic events never move the user).
269func (c *Core) settleGuests() {
270 for _, home := range c.screens {
271 type guest struct {
272 w, anchor *Workspace
273 host *Monitor
274 hidden bool
275 }
276 var list []guest
277 for _, host := range c.screens {
278 if host == home {
279 continue
280 }
281 for w := range host.mon.all() {
282 if home.mon.matches(w.home) && host.mon.Current() != w {
283 list = append(list, guest{w: w, anchor: w.overviewAfter, host: host.mon, hidden: host.mon.isHidden(w)})
284 }
285 }
286 }
287 // Snapshot every anchor before take normalizes a host and clears
288 // references to numbered workspaces returning alongside named rows.
289 for _, g := range list {
290 g.host.take(g.w)
291 }
292 slices.SortStableFunc(list, func(a, b guest) int { return a.w.homePos - b.w.homePos })
293 // A monitor showing an empty workspace (just plugged in) shows
294 // its first returning workspace instead.
295 idle := home.mon.Current().empty() && home.mon.Current().Name == ""
296 for _, g := range list {
297 g.w.home = ""
298 home.mon.adopt(g.w, g.hidden, g.w.homePos)
299 if idle && !g.hidden {
300 home.mon.show(g.w)
301 idle = false
302 }
303 }
304 for _, g := range list {
305 if g.hidden && indexOf(home.mon.Workspaces, g.anchor) >= 0 {
306 g.w.overviewAfter = g.anchor
307 }
308 }
309 }
310}
311
312// named applies the named workspaces from config. One that exists stays on
313// its screen; a new one is created on its home monitor, or on the first
314// screen as a guest when its home is not connected.
315func (c *Core) named() {
316 target := map[string]*screen{}
317 for _, spec := range c.specs {
318 if s, _ := c.byName(spec.Name); s != nil {
319 target[spec.Name] = s
320 continue
321 }
322 target[spec.Name] = c.screens[0]
323 for _, s := range c.screens {
324 if s.mon.matches(spec.Monitor) {
325 target[spec.Name] = s
326 }
327 }
328 }
329 for _, s := range c.screens {
330 var mine []NamedWorkspace
331 for _, spec := range c.specs {
332 if target[spec.Name] == s {
333 mine = append(mine, spec)
334 }
335 }
336 s.mon.SetNamed(mine)
337 s.mon.useSpecs(c.specs)
338 }
339 for _, spec := range c.specs {
340 s, w := c.byName(spec.Name)
341 w.home = ""
342 if spec.Monitor != "" && !s.mon.matches(spec.Monitor) {
343 w.home = spec.Monitor
344 }
345 }
346}
347
348// bringNamed prepares a "workspace <name>" toggle from the focused screen.
349// The workspace goes to its home monitor when connected, else to the focused
350// screen; that screen takes the focus. It reports true when the workspace
351// is already on screen there after a focus change: reaching it must not
352// toggle it away.
353func (c *Core) bringNamed(name string) bool {
354 src, w := c.byName(name)
355 i := slices.IndexFunc(c.specs, func(s NamedWorkspace) bool { return s.Name == name })
356 if w == nil || i < 0 {
357 return false
358 }
359 to, home := c.focusScreen, c.specs[i].Monitor
360 if h := slices.IndexFunc(c.screens, func(s *screen) bool { return s.mon.matches(home) }); h >= 0 {
361 to, home = h, ""
362 }
363 dst := c.screens[to]
364 if to != c.focusScreen {
365 c.focusScreen = to
366 if src == dst && dst.mon.Current() == w {
367 return true
368 }
369 }
370 if src != dst {
371 moveNamed(src, dst, w, home)
372 }
373 return false
374}
375
376// moveNamed moves the named workspace w from src to dst, without changing
377// what dst shows. home is its new w.home: a guest keeps waiting for its
378// home; any other forgets the output it was unplugged from.
379func moveNamed(src, dst *screen, w *Workspace, home string) {
380 hidden := src.mon.isHidden(w)
381 src.mon.take(w)
382 w.home = home
383 dst.mon.adopt(w, hidden, len(dst.mon.Workspaces))
384}
385
386// settings applies output-wide config to a monitor.
387func (c *Core) settings(m *Monitor) {
388 rules := c.cfg.Layout.LayoutRules
389 for _, o := range c.cfg.Layout.Outputs {
390 if m.matches(o.Output) {
391 rules = o.Over(rules)
392 }
393 }
394 m.SetOverflow(Overflow(rules.Overflow))
395 m.SetMaxColumns(rules.MaxColumns)
396 m.SetPresets(c.presets)
397 m.SetGaps(c.cfg.Layout.Gaps)
398 m.SetBorder(c.cfg.Border.Width)
399 m.SetStash(c.cfg.Stash.Width, c.cfg.Stash.Gap)
400 m.SetStashCapture(c.cfg.Stash.Capture)
401 m.SetFollowMove(c.cfg.Focus.FollowMove)
402}
403
404// direction is where a monitor action looks for a neighbor screen.
405type direction uint8
406
407const (
408 dirLeft direction = iota + 1
409 dirRight
410 dirUp
411 dirDown
412)
413
414// neighbor returns the index of the screen next to the focused one in
415// direction d, or -1 at the edge. Candidates lie fully beyond the focused
416// screen's edge; one sharing part of that edge wins over a diagonal one, then
417// the smallest gap, the largest shared edge, the closest centers and the
418// lowest index decide. Overlapping outputs (for instance two anchored to the
419// same reference) are reached only when no screen lies fully beyond the edge
420// in that direction: a second pass then takes the screens whose rectangle
421// intersects the focused one and whose center is strictly beyond the focused
422// center, ranked the same way with a gap of 0. A screen that does have a
423// strict candidate never falls back, so an overlapping one can stay out of
424// reach. Screens with the same center on the axis are unreachable by direction
425// (the pointer still reaches them). There is no wrap.
426func (c *Core) neighbor(d direction) int {
427 if d < dirLeft || d > dirDown {
428 return -1
429 }
430 // span is the extent of s along the move (lo, hi) and across it.
431 span := func(s *screen) (lo, hi, clo, chi int) {
432 o := s.mon.Output()
433 if d == dirUp || d == dirDown {
434 return s.y, s.y + o.H, s.x, s.x + o.W
435 }
436 return s.x, s.x + o.W, s.y, s.y + o.H
437 }
438 lo, hi, clo, chi := span(c.cur())
439 pick := func(fallback bool) int {
440 best, bestKey := -1, [4]int{}
441 for i, s := range c.screens {
442 if i == c.focusScreen || s.name() == "" {
443 continue
444 }
445 l, h, cl, ch := span(s)
446 gap := l - hi
447 beyond := l+h > lo+hi // centers, doubled
448 if d == dirLeft || d == dirUp {
449 gap = lo - h
450 beyond = l+h < lo+hi
451 }
452 if fallback {
453 if !beyond || min(hi, h)-max(lo, l) <= 0 || min(chi, ch)-max(clo, cl) <= 0 {
454 continue
455 }
456 gap = 0
457 } else if gap < 0 {
458 continue
459 }
460 overlap := min(chi, ch) - max(clo, cl)
461 diagonal := 0
462 if overlap <= 0 {
463 diagonal = 1
464 }
465 key := [4]int{diagonal, gap, -max(overlap, 0), abs(cl + ch - clo - chi)}
466 if best < 0 || slices.Compare(key[:], bestKey[:]) < 0 {
467 best, bestKey = i, key
468 }
469 }
470 return best
471 }
472 if i := pick(false); i >= 0 {
473 return i
474 }
475 return pick(true)
476}
477
478// moveWorkspace moves the workspace on screen to the neighbor screen, where
479// it is appended and shown; it becomes its new home (ADR 011).
480func (c *Core) moveWorkspace(d direction) {
481 to := c.neighbor(d)
482 if to < 0 {
483 return
484 }
485 from := c.cur()
486 w := from.mon.Current()
487 if w.empty() && w.Name == "" {
488 return
489 }
490 hidden := from.mon.isHidden(w)
491 from.mon.take(w)
492 w.home = ""
493 dst := c.screens[to]
494 dst.mon.adopt(w, hidden, len(dst.mon.Workspaces))
495 dst.mon.show(w)
496 c.focusScreen = to
497}