screens.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close