v0.8.0 446d1e3

monitor.go RawHistoryFolder

1package core
2
3import (
4	"iter"
5	"slices"
6)
7
8// Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers
9// are positions: Cmd+N targets Workspaces[N-1]. An empty workspace always sits
10// below the last one, and an empty unnamed workspace is removed once left.
11// Output-wide settings apply to every workspace.
12//
13// Named workspaces come from config and are never removed while configured.
14// Named workspaces are outside normal numbered up/down navigation. Their
15// `workspace <name>` binds and the overview can show them.
16type Monitor struct {
17	// Name is the connector; Key identifies the physical monitor (make,
18	// model and serial, else the connector). Workspaces remember the Key of
19	// their home monitor.
20	Name, Key  string
21	Workspaces []*Workspace
22	Active     int
23	hidden     []*Workspace
24	shown      *Workspace // hidden workspace on screen, nil for Workspaces[Active]
25	back       *Workspace // where a named workspace toggle returns to
26	template   Workspace
27	// slideBuf and slideShown are slideLayout's scratch (the neighbor's
28	// layout and its placements by window), reused and never handed out.
29	slideBuf   []Placement
30	slideShown map[WindowID]Placement
31	nextID     *uint64 // shared by the monitors of one Core; owner goroutine only
32	named      []NamedWorkspace
33	// followMove shows the target workspace after a move to it.
34	followMove bool
35	// stashCapture puts new windows in the shown stash (stash.capture).
36	stashCapture bool
37	// switchView slides the view between numbered workspaces: the view is
38	// at Active+switchView.off, in workspaces.
39	switchView slide
40	// switchAxis is frozen from the source workspace for each transition.
41	switchAxis layoutAxis
42	// switchList is the numbered list a landing slide measures from (the
43	// one its swipe began on); switchView.off is then from the current
44	// workspace's place in it.
45	switchList []*Workspace
46	// ov holds the overview selection and its Escape snapshot.
47	ov overviewState
48	// sw is the column switcher (switcher.go).
49	sw switcherState
50	// overviewOpens counts the overview's openings: a swipe sliding when
51	// one happens is dropped (gesture.go).
52	overviewOpens int
53}
54
55// NamedWorkspace configures a named workspace. Zero MaxColumns and an empty
56// Overflow follow the monitor defaults.
57type NamedWorkspace struct {
58	Name string
59	// Monitor is the home monitor (connector or key); "" follows the focus.
60	Monitor    string
61	MaxColumns int
62	Overflow   Overflow
63	// Size overrides the logical width and height; a zero side inherits
64	// the monitor and the scale is always the monitor's.
65	Size [2]int
66}
67
68func NewMonitor() *Monitor { return newMonitor("", "") }
69
70func newMonitor(name, key string) *Monitor {
71	return newMonitorWithIDs(name, key, new(uint64))
72}
73
74func newMonitorWithIDs(name, key string, nextID *uint64) *Monitor {
75	m := &Monitor{Name: name, Key: key, nextID: nextID}
76	m.normalize()
77	return m
78}
79
80// matches reports whether home designates this monitor, by key or connector.
81func (m *Monitor) matches(home string) bool {
82	return home != "" && (home == m.Key || home == m.Name)
83}
84
85// all yields every workspace, numbered then hidden, without copying.
86// Callers must not add or remove workspaces while ranging.
87func (m *Monitor) all() iter.Seq[*Workspace] {
88	return func(yield func(*Workspace) bool) {
89		for _, w := range m.Workspaces {
90			if !yield(w) {
91				return
92			}
93		}
94		for _, w := range m.hidden {
95			if !yield(w) {
96				return
97			}
98		}
99	}
100}
101
102// isHidden reports whether w is in the hidden list.
103func (m *Monitor) isHidden(w *Workspace) bool { return indexOf(m.hidden, w) >= 0 }
104
105// take removes w from the monitor. The monitor keeps a workspace on screen:
106// the one at the same position, or the numbered active one.
107func (m *Monitor) take(w *Workspace) {
108	if m.back == w {
109		m.back = nil
110	}
111	if i := indexOf(m.hidden, w); i >= 0 {
112		m.hidden = append(m.hidden[:i], m.hidden[i+1:]...)
113		if m.shown == w {
114			m.shown = nil
115		}
116		m.normalize()
117		return
118	}
119	if i := indexOf(m.Workspaces, w); i >= 0 {
120		m.removeNumbered(i)
121		m.normalize()
122	}
123}
124
125// adopt adds a workspace from another monitor: hidden, or numbered at pos
126// (clamped above the trailing empty workspace). It takes this monitor's
127// output-wide settings and does not change what is on screen.
128func (m *Monitor) adopt(w *Workspace, hidden bool, pos int) {
129	w.Gaps = m.template.Gaps
130	w.border = m.template.border
131	w.stashWidth, w.stashGap = m.template.stashWidth, m.template.stashGap
132	w.SetOutput(m.template.Output.W, m.template.Output.H)
133	w.SetUsable(m.template.Usable)
134	if hidden {
135		m.hidden = append(m.hidden, w)
136	} else {
137		// normalize keeps a trailing empty workspace: stay above it.
138		at := min(max(pos, 0), len(m.Workspaces)-1)
139		m.Workspaces = slices.Insert(m.Workspaces, at, w)
140		if at <= m.Active {
141			m.Active++
142		}
143	}
144	m.applyNamed()
145	m.normalize()
146}
147
148// useSpecs sets the overrides of every named workspace, including those
149// that live on other monitors and may move here.
150func (m *Monitor) useSpecs(all []NamedWorkspace) {
151	m.named = append([]NamedWorkspace(nil), all...)
152	m.applyNamed()
153}
154
155// Current is the workspace on screen.
156func (m *Monitor) Current() *Workspace {
157	if m.shown != nil {
158		return m.shown
159	}
160	return m.Workspaces[m.Active]
161}
162
163func (m *Monitor) newWorkspace() *Workspace {
164	w := m.template
165	w.colBuf, w.rowBuf, w.tileBuf, w.zoomBuf = nil, nil, nil, nil
166	w.Columns, w.Floats, w.maximized, w.floatFocus, w.home = nil, nil, nil, false, ""
167	w.overviewAfter = nil
168	w.Stash, w.stashAt, w.stashFocus, w.stashHidden, w.hiddenFullscreen = nil, 0, false, false, 0
169	(*m.nextID)++
170	w.ID = *m.nextID
171	return &w
172}
173
174// normalize keeps one trailing empty unnamed workspace and drops other empty
175// unnamed ones that are not active. The active workspace never changes.
176func (m *Monitor) normalize() {
177	spare := func(i int) bool { return m.Workspaces[i].empty() && m.Workspaces[i].Name == "" }
178	for i := len(m.Workspaces) - 2; i >= 0; i-- {
179		if spare(i) && i != m.Active {
180			m.Workspaces = append(m.Workspaces[:i], m.Workspaces[i+1:]...)
181			if i < m.Active {
182				m.Active--
183			}
184		}
185	}
186	for n := len(m.Workspaces); n >= 2 && spare(n-1) && spare(n-2) && m.Active != n-1; n-- {
187		m.Workspaces = m.Workspaces[:n-1]
188	}
189	if n := len(m.Workspaces); n == 0 || !spare(n-1) {
190		m.Workspaces = append(m.Workspaces, m.newWorkspace())
191	}
192	for _, w := range m.hidden {
193		if indexOf(m.Workspaces, w.overviewAfter) < 0 {
194			w.overviewAfter = nil
195		}
196	}
197}
198
199// Focus switches to the numbered workspace at index i, clamped to the list.
200func (m *Monitor) Focus(i int) {
201	i = min(max(i, 0), len(m.Workspaces)-1)
202	m.stopSwitch()
203	m.shown = nil
204	m.Active = i
205	m.normalize()
206}
207
208// stepWorkspace switches to the previous or next workspace (dir -1/1) when
209// axis is the current layout's workspace axis, and reports whether it did;
210// Focus clamps past the first or the trailing workspace. A hidden named
211// workspace is outside the numbered list: it never steps.
212func (m *Monitor) stepWorkspace(axis layoutAxis, dir int) bool {
213	step := m.Current().policy().workspaceStep(axis, dir)
214	if m.shown != nil || step == 0 {
215		return false
216	}
217	m.Focus(m.Active + step)
218	return true
219}
220
221// occupiedStep is the numbered workspace dir (-1/1) away along axis when
222// it is the layout's workspace axis and that workspace is not the trailing
223// empty one, or -1.
224func (m *Monitor) occupiedStep(axis layoutAxis, dir int) int {
225	step := m.Current().policy().workspaceStep(axis, dir)
226	to := m.Active + step
227	if m.shown != nil || step == 0 || to < 0 || to >= len(m.Workspaces) {
228		return -1
229	}
230	if w := m.Workspaces[to]; w.empty() && w.Name == "" {
231		return -1
232	}
233	return to
234}
235
236// MoveWorkspace swaps the active numbered workspace with its neighbor up
237// (dir -1) or down (dir 1); the view follows it. It never passes the
238// trailing empty workspace.
239func (m *Monitor) MoveWorkspace(dir int) {
240	to := m.Active + dir
241	if m.shown != nil || (dir != -1 && dir != 1) || to < 0 || to >= len(m.Workspaces)-1 || m.Active >= len(m.Workspaces)-1 {
242		return
243	}
244	m.Workspaces[m.Active], m.Workspaces[to] = m.Workspaces[to], m.Workspaces[m.Active]
245	m.stopSwitch()
246	m.Active = to
247	m.normalize()
248}
249
250// FocusNumber switches to workspace n (1-based); past the end means the last.
251func (m *Monitor) FocusNumber(n int) { m.Focus(n - 1) }
252
253// show puts w on screen, numbered or hidden.
254func (m *Monitor) show(w *Workspace) {
255	if i := indexOf(m.Workspaces, w); i >= 0 {
256		m.Focus(i)
257		return
258	}
259	m.stopSwitch()
260	m.shown = w
261	m.normalize()
262}
263
264// ToggleNamed shows the named workspace, or returns to the previous one when
265// it is already on screen. Unknown names do nothing.
266func (m *Monitor) ToggleNamed(name string) {
267	target := m.byName(name)
268	switch cur := m.Current(); {
269	case target == nil:
270	case target != cur:
271		anchor := cur
272		if m.isHidden(cur) {
273			anchor = cur.overviewAfter
274		}
275		if indexOf(m.Workspaces, anchor) < 0 {
276			anchor = m.Workspaces[m.Active]
277		}
278		target.overviewAfter = anchor
279		m.back = cur
280		m.show(target)
281	case m.back != nil && m.has(m.back):
282		back := m.back
283		m.back = nil
284		m.show(back)
285	case m.shown != nil:
286		m.back = nil
287		m.shown = nil
288		m.normalize()
289	}
290}
291
292// byName returns the configured workspace with that name, or nil.
293func (m *Monitor) byName(name string) *Workspace {
294	for w := range m.all() {
295		if w.Name == name {
296			return w
297		}
298	}
299	return nil
300}
301
302func indexOf(list []*Workspace, w *Workspace) int {
303	for i, v := range list {
304		if v == w {
305			return i
306		}
307	}
308	return -1
309}
310
311// has reports whether w still belongs to the monitor.
312func (m *Monitor) has(w *Workspace) bool {
313	return indexOf(m.Workspaces, w) >= 0 || indexOf(m.hidden, w) >= 0
314}
315
316// find returns the workspace holding the window; i is -1 for hidden ones.
317func (m *Monitor) find(id WindowID) (*Workspace, int) {
318	for i, w := range m.Workspaces {
319		if w.has(id) {
320			return w, i
321		}
322	}
323	for _, w := range m.hidden {
324		if w.has(id) {
325			return w, -1
326		}
327	}
328	return nil, -1
329}
330
331// AddWindow places a new window on the workspace on screen; with
332// stash.capture, in its stash when shown.
333func (m *Monitor) AddWindow(id WindowID) { m.addWindow(id, 0) }
334
335// addWindow is AddWindow with the width in cells of the new column (zero:
336// automatic).
337func (m *Monitor) addWindow(id WindowID, cells int) {
338	if w, _ := m.find(id); w != nil {
339		return
340	}
341	if w := m.Current(); !m.stashCapture || !w.captureStash(id) {
342		w.addWindow(id, cells)
343	}
344	m.normalize()
345}
346
347// AddFloating adds a floating window to the active workspace.
348func (m *Monitor) AddFloating(id WindowID, width, height int) {
349	if w, _ := m.find(id); w != nil {
350		return
351	}
352	m.Current().AddFloating(id, width, height)
353	m.normalize()
354}
355
356// RemoveWindow drops the window wherever it is; focus stays on the active
357// workspace.
358func (m *Monitor) RemoveWindow(id WindowID) {
359	heir := m.sw.heir(id)
360	if w, _ := m.find(id); w != nil {
361		w.RemoveWindow(id)
362		m.normalize()
363	}
364	m.sw.remove(id, heir)
365}
366
367// SetFullscreen applies a client request, in place. A client request never
368// moves the user (ADR 011): the view and the focus stay where they are.
369func (m *Monitor) SetFullscreen(id WindowID, on bool) {
370	if w, _ := m.find(id); w != nil {
371		w.SetFullscreen(id, on)
372	}
373}
374
375// ToggleFullscreen is the bind: the focused window goes fullscreen in place,
376// or leaves it. Focus moves leave it too (Workspace.leaveCover).
377func (m *Monitor) ToggleFullscreen() { m.Current().ToggleFullscreen() }
378
379// MoveToWorkspace moves the focused column (or only the focused window) to
380// numbered workspace index i. A floating window always moves alone. Focus
381// stays here unless followMove is set (ADR 017).
382func (m *Monitor) MoveToWorkspace(i int, column bool) {
383	i = min(max(i, 0), len(m.Workspaces)-1)
384	cur, to := m.Current(), m.Workspaces[i]
385	id, ok := cur.Focused()
386	if !ok || to == cur {
387		return
388	}
389	if f := cur.floatIndex(id); f >= 0 {
390		fl := cur.Floats[f]
391		if fl.free && fl.back != nil {
392			// Its column place is on this workspace.
393			fl = fl.rehome()
394		}
395		cur.RemoveWindow(id)
396		to.AddFloating(id, fl.W, fl.H)
397		to.Floats[len(to.Floats)-1] = fl
398		// As receive: a fullscreen window would hide it.
399		to.Activate(id)
400	} else if f := cur.stashIndex(id); f >= 0 {
401		// It joins the end of the stash there, shown and selected.
402		fl := cur.Stash[f].rehome()
403		cur.RemoveWindow(id)
404		to.addStash(fl)
405		to.Activate(id)
406	} else {
407		col := Column{Windows: []WindowID{id}}
408		ok := true
409		if column {
410			col, ok = cur.takeColumn()
411		} else {
412			_, ok = cur.takeWindow()
413		}
414		if !ok {
415			return
416		}
417		to.receive(col, -1)
418	}
419	if m.followMove {
420		m.Active = i
421		m.shown = nil
422	}
423	m.normalize()
424}
425
426// receive adds a column moved in from another workspace and focuses it: at
427// index at, or where a new window goes when at < 0. A fullscreen window
428// there is left, so the moved window is seen.
429func (w *Workspace) receive(col Column, at int) {
430	id := col.Windows[col.Focus]
431	switch {
432	case at < 0:
433		w.addColumn(col)
434	default:
435		w.insertColumn(at, col)
436	}
437	w.Activate(id)
438}
439
440// Focused is the focused window of the workspace on screen; in the
441// overview, the selected stash card when it is one.
442func (m *Monitor) Focused() (WindowID, bool) {
443	if id := m.card(); id != 0 {
444		return id, true
445	}
446	return m.Current().Focused()
447}
448
449// Layout places every window: the workspace on screen laid out, others hidden.
450// The slice is fresh, the caller's to keep; the per-frame paths use layoutInto.
451func (m *Monitor) Layout() []Placement { return m.layoutInto(nil) }
452
453// layoutInto is Layout built in dst[:0]: the result aliases dst's storage,
454// so a reused dst's previous result is overwritten and only a caller done
455// with it may pass it. It is never the monitor's own scratch.
456func (m *Monitor) layoutInto(dst []Placement) []Placement {
457	if m.switcherShown() {
458		return m.switcherLayout(dst[:0])
459	}
460	if m.ov.open {
461		return m.overviewLayout(dst[:0])
462	}
463	result := dst[:0]
464	cur := m.Current()
465	for w := range m.all() {
466		if w == cur {
467			result = w.appendLayout(result)
468			continue
469		}
470		result = w.appendHidden(result, true)
471	}
472	return m.slideLayout(result)
473}
474
475// Output is the whole logical monitor, whatever the workspaces' sizes.
476func (m *Monitor) Output() Rect { return m.template.Output }
477
478// Frame is where the workspace on screen draws and takes input, in monitor
479// coordinates: its viewport, or the whole monitor in a preview (the
480// overview or the column switcher). Slides
481// involving a sized workspace remain settled inside the current frame.
482func (m *Monitor) Frame() Rect {
483	if m.previewing() {
484		return m.Output()
485	}
486	return m.Current().Output
487}
488
489// viewport is where the workspace on screen is seen: Frame, and the
490// usable area its tiles live in.
491func (m *Monitor) viewport() viewport {
492	return viewport{frame: m.Frame(), usable: m.Current().Usable}
493}
494
495// frameHas reports whether the output-local point is inside the frame.
496func (m *Monitor) frameHas(x, y float64) bool {
497	f := m.Frame()
498	return x >= float64(f.X) && x < float64(f.X+f.W) && y >= float64(f.Y) && y < float64(f.Y+f.H)
499}
500
501func (m *Monitor) each(f func(*Workspace)) {
502	f(&m.template)
503	for _, w := range m.Workspaces {
504		f(w)
505	}
506	for _, w := range m.hidden {
507		f(w)
508	}
509}
510func (m *Monitor) SetOutput(width, height int) {
511	m.each(func(w *Workspace) { w.SetOutput(width, height) })
512}
513func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) }
514func (m *Monitor) SetGaps(g int)    { m.each(func(w *Workspace) { w.SetGaps(g) }) }
515func (m *Monitor) SetBorder(b int) {
516	m.each(func(w *Workspace) { w.border = max(b, 0); w.reconcileFloats() })
517}
518
519// SetStash sets the width of a stashed window (stash.width; 0 is the
520// default) and the space between it and its neighbors (stash.gap), in
521// percent of the usable width.
522func (m *Monitor) SetStash(width, gap int) {
523	if width != 0 {
524		width = min(max(width, 10), 90)
525	}
526	m.each(func(w *Workspace) { w.stashWidth, w.stashGap = width, min(max(gap, 0), 10) })
527}
528
529// SetStashCapture makes new windows join the shown stash (stash.capture).
530func (m *Monitor) SetStashCapture(on bool) { m.stashCapture = on }
531
532// SetFollowMove makes moves to another workspace show it (focus.follow-move).
533func (m *Monitor) SetFollowMove(on bool) { m.followMove = on }
534
535// SetMaxColumns sets the default; named workspaces may override it.
536func (m *Monitor) SetMaxColumns(n int) {
537	m.template.MaxColumns = n
538	m.applyNamed()
539}
540
541// SetOverflow sets the default; named workspaces may override it.
542func (m *Monitor) SetOverflow(o Overflow) {
543	m.template.Overflow = o
544	m.applyNamed()
545}
546
547// applyNamed gives every workspace the defaults or its named overrides.
548func (m *Monitor) applyNamed() {
549	specs := map[string]NamedWorkspace{}
550	for _, s := range m.named {
551		specs[s.Name] = s
552	}
553	m.each(func(w *Workspace) {
554		s := specs[w.Name]
555		before, axis := w.Overflow, w.policy().content
556		w.Overflow = m.template.Overflow
557		if s.Overflow != "" {
558			w.Overflow = s.Overflow
559		}
560		if before != w.Overflow && w.policy().equalCells {
561			w.unmaximize()
562		}
563		// The view offset belongs to the old content axis: SetMaxColumns
564		// scrolls back to the focus along the new one.
565		if w.policy().content != axis {
566			w.view.stop()
567			w.View = 0
568		}
569		w.SetMaxColumns(m.template.MaxColumns)
570		if s.MaxColumns > 0 {
571			w.SetMaxColumns(s.MaxColumns)
572		}
573		// Named workspaces take their configured size; any other keeps its own.
574		if w.Name != "" {
575			w.SetSize(s.Size[0], s.Size[1])
576		}
577	})
578}
579
580// SetNamed applies bind-only named workspaces from config. Removed named
581// workspaces hand their windows to the active numbered workspace.
582func (m *Monitor) SetNamed(specs []NamedWorkspace) {
583	cur := m.Current()
584	want := map[string]NamedWorkspace{}
585	for _, s := range specs {
586		want[s.Name] = s
587	}
588	byName := map[string]*Workspace{}
589	for _, w := range m.Workspaces {
590		if _, ok := want[w.Name]; w.Name != "" && !ok {
591			w.Name = ""
592			w.SetSize(0, 0)
593		}
594		if w.Name != "" {
595			byName[w.Name] = w
596		}
597	}
598	var hidden []*Workspace
599	for _, w := range m.hidden {
600		if _, ok := want[w.Name]; ok {
601			byName[w.Name] = w
602			hidden = append(hidden, w)
603			continue
604		}
605		if w == cur {
606			cur = nil
607		}
608		for _, c := range w.Columns {
609			for _, id := range c.Windows {
610				m.Workspaces[m.Active].AddWindow(id)
611			}
612		}
613		for _, f := range w.Floats {
614			if f.free && f.back != nil {
615				f = f.rehome()
616			}
617			m.Workspaces[m.Active].AddFloating(f.ID, f.W, f.H)
618			m.Workspaces[m.Active].Floats[len(m.Workspaces[m.Active].Floats)-1] = f
619		}
620		m.Workspaces[m.Active].reconcileFloats()
621		for _, f := range w.Stash {
622			m.Workspaces[m.Active].Stash = append(m.Workspaces[m.Active].Stash, f.rehome())
623		}
624	}
625	m.hidden = hidden
626	m.named = append([]NamedWorkspace(nil), specs...)
627	var nextHidden []*Workspace
628	for _, s := range specs {
629		w := byName[s.Name]
630		if w == nil {
631			w = m.newWorkspace()
632			w.Name = s.Name
633		}
634		if i := indexOf(m.Workspaces, w); i >= 0 {
635			m.removeNumbered(i)
636		}
637		nextHidden = append(nextHidden, w)
638	}
639	m.hidden = nextHidden
640	if m.back != nil && !m.has(m.back) {
641		m.back = nil
642	}
643	m.shown = nil
644	switch {
645	case cur == nil:
646	case indexOf(m.hidden, cur) >= 0:
647		m.shown = cur
648	case indexOf(m.Workspaces, cur) >= 0:
649		m.Active = indexOf(m.Workspaces, cur)
650	}
651	m.applyNamed()
652	m.normalize()
653}
654
655// removeNumbered drops Workspaces[i] and keeps Active on the same workspace
656// when possible.
657func (m *Monitor) removeNumbered(i int) {
658	m.Workspaces = append(m.Workspaces[:i], m.Workspaces[i+1:]...)
659	if i < m.Active || m.Active >= len(m.Workspaces) {
660		m.Active = max(m.Active-1, 0)
661	}
662	if len(m.Workspaces) == 0 {
663		m.Workspaces = append(m.Workspaces, m.newWorkspace())
664	}
665}

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