v0.2.0 13c6a90

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

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