v0.2.0 13c6a90

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 placed left to right, in
11// config order (output.<name>.*), then in connection order.
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 assigns x.
53func (c *Core) order() {
54	rank := func(s *screen) int {
55		for i, o := range c.cfg.Outputs {
56			if o.Name == s.name() {
57				return i
58			}
59		}
60		return len(c.cfg.Outputs)
61	}
62	focused := c.cur()
63	slices.SortStableFunc(c.screens, func(a, b *screen) int { return rank(a) - rank(b) })
64	c.focusScreen = slices.Index(c.screens, focused)
65	// Explicitly placed outputs reserve their position first; auto-placed
66	// outputs follow the rightmost explicit edge in configuration order.
67	x := 0
68	for _, s := range c.screens {
69		for _, o := range c.cfg.Outputs {
70			if o.Name == s.name() && o.Pos != nil {
71				s.x, s.y = o.Pos.X, o.Pos.Y
72				x = max(x, s.x+s.mon.Output().W)
73				break
74			}
75		}
76	}
77	for _, s := range c.screens {
78		explicit := false
79		for _, o := range c.cfg.Outputs {
80			if o.Name == s.name() && o.Pos != nil {
81				explicit = true
82				break
83			}
84		}
85		if !explicit {
86			s.x, s.y = x, 0
87			x += s.mon.Output().W
88		}
89	}
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	for _, o := range c.cfg.Outputs {
133		if o.Name == name && o.Primary {
134			return true
135		}
136	}
137	return false
138}
139
140// anyWindow reports whether any workspace holds a window.
141func (c *Core) anyWindow() bool {
142	for _, s := range c.screens {
143		for w := range s.mon.all() {
144			if !w.empty() {
145				return true
146			}
147		}
148	}
149	return false
150}
151
152// configScale is output.<name>.scale, else 1.
153func (c *Core) configScale(name string) float64 {
154	v := 1.0
155	for _, o := range c.cfg.Outputs {
156		if o.Name == name && o.Scale != 0 {
157			v = o.Scale
158		}
159	}
160	return SnapScale(v)
161}
162
163// applyConfigScales picks up output.<name>.scale when it changed in the
164// config, replacing any live scale-up/scale-down adjustment.
165func (c *Core) applyConfigScales() {
166	for _, s := range c.screens {
167		if v := c.configScale(s.name()); v != s.cfgScale {
168			s.cfgScale = v
169			s.setScale(v)
170		}
171	}
172	c.order()
173}
174
175// addScreen handles a connected output or a new mode for a known one. The
176// first output takes over the placeholder screen core starts with; a new
177// monitor gets its named workspaces and takes its guests back.
178func (c *Core) addScreen(info ports.OutputInfo) {
179	if i := c.screenIndex(info.Name); i >= 0 {
180		s := c.screens[i]
181		s.info = info
182		if s.mon.Key != info.Key() {
183			// Another monitor on the same connector: key rules change.
184			s.mon.Key = info.Key()
185			c.settings(s.mon)
186		}
187		s.setScale(s.scale)
188		c.order()
189		return
190	}
191	var s *screen
192	if len(c.screens) == 1 && c.screens[0].name() == "" {
193		s = c.screens[0]
194		s.info = info
195	} else {
196		s = &screen{info: info, mon: newMonitorWithIDs(info.Name, info.Key(), &c.nextWorkspaceID)}
197		c.screens = append(c.screens, s)
198	}
199	s.mon.Name, s.mon.Key = info.Name, info.Key()
200	c.settings(s.mon)
201	s.cfgScale = c.configScale(info.Name)
202	s.setScale(s.cfgScale)
203	s.primary = c.isPrimary(info.Name)
204	c.order()
205	// The primary output takes the focus while nothing is open yet
206	// (startup). Later, plugging it in never moves the user.
207	if s.primary && !c.anyWindow() {
208		c.focusScreen = slices.Index(c.screens, s)
209	}
210	c.named()
211}
212
213// removeScreen moves every workspace of an unplugged output to the focused
214// screen (another one if it was focused), numbered ones below its numbered
215// workspaces, hidden ones hidden. They remember their home and position and
216// return when it is plugged in again. The last output becomes the
217// placeholder again: it keeps its windows for the next output.
218func (c *Core) removeScreen(name string) {
219	i := c.screenIndex(name)
220	if i < 0 {
221		return
222	}
223	// A capture session's off-screen workspace of this output goes with it.
224	if c.configures.cw.sc == c.screens[i] {
225		c.configures.cw.reset()
226	}
227	if len(c.screens) == 1 {
228		c.screens[0].info = ports.OutputInfo{}
229		c.screens[0].layers = nil
230		return
231	}
232	gone := c.screens[i]
233	focused := c.cur()
234	c.screens = slices.Delete(c.screens, i, i+1)
235	c.focusScreen = max(slices.Index(c.screens, focused), 0)
236	host := c.cur()
237	for pos, w := range gone.mon.Workspaces {
238		if w.empty() && w.Name == "" {
239			continue
240		}
241		switch {
242		case host.mon.matches(w.home):
243			// A guest from the host goes home.
244			w.home = ""
245			host.mon.adopt(w, false, w.homePos)
246			continue
247		case w.home == "":
248			w.home, w.homePos = gone.info.Key(), pos
249		}
250		host.mon.adopt(w, false, len(host.mon.Workspaces))
251	}
252	for _, w := range gone.mon.hidden {
253		switch {
254		case host.mon.matches(w.home):
255			w.home = ""
256		case w.home == "":
257			w.home = gone.info.Key()
258		}
259		host.mon.adopt(w, true, 0)
260	}
261	c.order()
262}
263
264// settleGuests returns guests to their connected home monitor, at their
265// original positions. A guest on screen stays until the user leaves it
266// (golden rule: automatic events never move the user).
267func (c *Core) settleGuests() {
268	for _, home := range c.screens {
269		type guest struct {
270			w, anchor *Workspace
271			host      *Monitor
272			hidden    bool
273		}
274		var list []guest
275		for _, host := range c.screens {
276			if host == home {
277				continue
278			}
279			for w := range host.mon.all() {
280				if home.mon.matches(w.home) && host.mon.Current() != w {
281					list = append(list, guest{w: w, anchor: w.overviewAfter, host: host.mon, hidden: host.mon.isHidden(w)})
282				}
283			}
284		}
285		// Snapshot every anchor before take normalizes a host and clears
286		// references to numbered workspaces returning alongside named rows.
287		for _, g := range list {
288			g.host.take(g.w)
289		}
290		slices.SortStableFunc(list, func(a, b guest) int { return a.w.homePos - b.w.homePos })
291		// A monitor showing an empty workspace (just plugged in) shows
292		// its first returning workspace instead.
293		idle := home.mon.Current().empty() && home.mon.Current().Name == ""
294		for _, g := range list {
295			g.w.home = ""
296			home.mon.adopt(g.w, g.hidden, g.w.homePos)
297			if idle && !g.hidden {
298				home.mon.show(g.w)
299				idle = false
300			}
301		}
302		for _, g := range list {
303			if g.hidden && indexOf(home.mon.Workspaces, g.anchor) >= 0 {
304				g.w.overviewAfter = g.anchor
305			}
306		}
307	}
308}
309
310// named applies the named workspaces from config. One that exists stays on
311// its screen; a new one is created on its home monitor, or on the first
312// screen as a guest when its home is not connected.
313func (c *Core) named() {
314	target := map[string]*screen{}
315	for _, spec := range c.specs {
316		if s, _ := c.byName(spec.Name); s != nil {
317			target[spec.Name] = s
318			continue
319		}
320		target[spec.Name] = c.screens[0]
321		for _, s := range c.screens {
322			if s.mon.matches(spec.Monitor) {
323				target[spec.Name] = s
324			}
325		}
326	}
327	for _, s := range c.screens {
328		var mine []NamedWorkspace
329		for _, spec := range c.specs {
330			if target[spec.Name] == s {
331				mine = append(mine, spec)
332			}
333		}
334		s.mon.SetNamed(mine)
335		s.mon.useSpecs(c.specs)
336	}
337	for _, spec := range c.specs {
338		s, w := c.byName(spec.Name)
339		w.home = ""
340		if spec.Monitor != "" && !s.mon.matches(spec.Monitor) {
341			w.home = spec.Monitor
342		}
343	}
344}
345
346// bringNamed prepares a "workspace <name>" toggle from the focused screen.
347// The workspace goes to its home monitor when connected, else to the focused
348// screen; that screen takes the focus. It reports true when the workspace
349// is already on screen there after a focus change: reaching it must not
350// toggle it away.
351func (c *Core) bringNamed(name string) bool {
352	src, w := c.byName(name)
353	i := slices.IndexFunc(c.specs, func(s NamedWorkspace) bool { return s.Name == name })
354	if w == nil || i < 0 {
355		return false
356	}
357	to, home := c.focusScreen, c.specs[i].Monitor
358	if h := slices.IndexFunc(c.screens, func(s *screen) bool { return s.mon.matches(home) }); h >= 0 {
359		to, home = h, ""
360	}
361	dst := c.screens[to]
362	if to != c.focusScreen {
363		c.focusScreen = to
364		if src == dst && dst.mon.Current() == w {
365			return true
366		}
367	}
368	if src != dst {
369		moveNamed(src, dst, w, home)
370	}
371	return false
372}
373
374// moveNamed moves the named workspace w from src to dst, without changing
375// what dst shows. home is its new w.home: a guest keeps waiting for its
376// home; any other forgets the output it was unplugged from.
377func moveNamed(src, dst *screen, w *Workspace, home string) {
378	hidden := src.mon.isHidden(w)
379	src.mon.take(w)
380	w.home = home
381	dst.mon.adopt(w, hidden, len(dst.mon.Workspaces))
382}
383
384// settings applies output-wide config to a monitor.
385func (c *Core) settings(m *Monitor) {
386	rules := c.cfg.Layout.LayoutRules
387	for _, o := range c.cfg.Layout.Outputs {
388		if m.matches(o.Output) {
389			rules = o.Over(rules)
390		}
391	}
392	m.SetOverflow(Overflow(rules.Overflow))
393	m.SetMaxColumns(rules.MaxColumns)
394	m.SetPresets(c.presets)
395	m.SetGaps(c.cfg.Layout.Gaps)
396	m.SetBorder(c.cfg.Border.Width)
397	m.SetStash(c.cfg.Stash.Width, c.cfg.Stash.Gap)
398	m.SetStashCapture(c.cfg.Stash.Capture)
399	m.SetFollowMove(c.cfg.Focus.FollowMove)
400}
401
402// neighbor returns the screen index left (-1) or right (+1) of the focused
403// one, or -1 at the edge.
404func (c *Core) neighbor(dir int) int {
405	i := c.focusScreen + dir
406	if i < 0 || i >= len(c.screens) {
407		return -1
408	}
409	return i
410}
411
412// moveWorkspace moves the workspace on screen to the neighbor screen, where
413// it is appended and shown; it becomes its new home (ADR 011).
414func (c *Core) moveWorkspace(dir int) {
415	to := c.neighbor(dir)
416	if to < 0 {
417		return
418	}
419	from := c.cur()
420	w := from.mon.Current()
421	if w.empty() && w.Name == "" {
422		return
423	}
424	hidden := from.mon.isHidden(w)
425	from.mon.take(w)
426	w.home = ""
427	dst := c.screens[to]
428	dst.mon.adopt(w, hidden, len(dst.mon.Workspaces))
429	dst.mon.show(w)
430	c.focusScreen = to
431}

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 25

Tags 10

↑↓ moveenter openesc close