feat/data-dnd e05cb15

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

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