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	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}

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