v0.1.0 d9218d6

monitor.go RawHistoryFolder

1package core
2
3import "slices"
4
5// Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers
6// are positions: Cmd+N targets Workspaces[N-1]. An empty workspace always sits
7// below the last one, and an empty unnamed workspace is removed once left,
8// except the first. Output-wide settings apply to every workspace.
9//
10// Named workspaces come from config and are never removed while configured.
11// A hidden one is not numbered and not reachable by up/down: only its
12// `workspace <name>` bind shows it.
13type Monitor struct {
14	// Name is the connector; Key identifies the physical monitor (make,
15	// model and serial, else the connector). Workspaces remember the Key of
16	// their home monitor.
17	Name, Key  string
18	Workspaces []*Workspace
19	Active     int
20	hidden     []*Workspace
21	shown      *Workspace // hidden workspace on screen, nil for Workspaces[Active]
22	back       *Workspace // where a named workspace toggle returns to
23	template   Workspace
24	named      []NamedWorkspace
25	// followMove shows the target workspace after a move to it.
26	followMove bool
27}
28
29// NamedWorkspace configures a named workspace. Zero MaxColumns and an empty
30// Overflow follow the monitor defaults.
31type NamedWorkspace struct {
32	Name string
33	// Monitor is the home monitor (connector or key); "" for the first one.
34	Monitor    string
35	Hidden     bool
36	MaxColumns int
37	Overflow   Overflow
38}
39
40func NewMonitor() *Monitor { return newMonitor("", "") }
41
42func newMonitor(name, key string) *Monitor {
43	m := &Monitor{Name: name, Key: key}
44	m.normalize()
45	return m
46}
47
48// matches reports whether home designates this monitor, by key or connector.
49func (m *Monitor) matches(home string) bool {
50	return home != "" && (home == m.Key || home == m.Name)
51}
52
53// all lists every workspace, numbered then hidden.
54func (m *Monitor) all() []*Workspace {
55	return append(append([]*Workspace(nil), m.Workspaces...), m.hidden...)
56}
57
58// isHidden reports whether w is in the hidden list.
59func (m *Monitor) isHidden(w *Workspace) bool { return indexOf(m.hidden, w) >= 0 }
60
61// take removes w from the monitor. The monitor keeps a workspace on screen:
62// the one at the same position, or the numbered active one.
63func (m *Monitor) take(w *Workspace) {
64	if m.back == w {
65		m.back = nil
66	}
67	if i := indexOf(m.hidden, w); i >= 0 {
68		m.hidden = append(m.hidden[:i], m.hidden[i+1:]...)
69		if m.shown == w {
70			m.shown = nil
71		}
72		m.normalize()
73		return
74	}
75	if i := indexOf(m.Workspaces, w); i >= 0 {
76		m.removeNumbered(i)
77		m.normalize()
78	}
79}
80
81// adopt adds a workspace from another monitor: hidden, or numbered at pos
82// (clamped above the trailing empty workspace). It takes this monitor's
83// output-wide settings and does not change what is on screen.
84func (m *Monitor) adopt(w *Workspace, hidden bool, pos int) {
85	w.presets = append([]Width(nil), m.template.presets...)
86	w.Gaps = m.template.Gaps
87	w.border = m.template.border
88	w.SetOutput(m.template.Output.W, m.template.Output.H)
89	w.SetUsable(m.template.Usable)
90	if hidden {
91		m.hidden = append(m.hidden, w)
92	} else {
93		// normalize keeps a trailing empty workspace: stay above it.
94		at := min(max(pos, 0), len(m.Workspaces)-1)
95		m.Workspaces = slices.Insert(m.Workspaces, at, w)
96		if at <= m.Active {
97			m.Active++
98		}
99	}
100	m.applyNamed()
101	m.normalize()
102}
103
104// useSpecs sets the overrides of every named workspace, including those
105// that live on other monitors and may move here.
106func (m *Monitor) useSpecs(all []NamedWorkspace) {
107	m.named = append([]NamedWorkspace(nil), all...)
108	m.applyNamed()
109}
110
111// Current is the workspace on screen.
112func (m *Monitor) Current() *Workspace {
113	if m.shown != nil {
114		return m.shown
115	}
116	return m.Workspaces[m.Active]
117}
118
119func (m *Monitor) newWorkspace() *Workspace {
120	w := m.template
121	w.presets = append([]Width(nil), m.template.presets...)
122	w.Columns, w.Floats, w.floatFocus, w.home, w.origin, w.back = nil, nil, false, "", nil, origPlace{}
123	return &w
124}
125
126// normalize keeps one trailing empty unnamed workspace and drops other empty
127// unnamed ones that are neither the first nor active. The active workspace
128// never changes.
129func (m *Monitor) normalize() {
130	m.fullscreenHome()
131	spare := func(i int) bool { return m.Workspaces[i].empty() && m.Workspaces[i].Name == "" }
132	for i := len(m.Workspaces) - 2; i >= 1; i-- {
133		if spare(i) && i != m.Active {
134			m.Workspaces = append(m.Workspaces[:i], m.Workspaces[i+1:]...)
135			if i < m.Active {
136				m.Active--
137			}
138		}
139	}
140	for n := len(m.Workspaces); n >= 2 && spare(n-1) && spare(n-2) && m.Active != n-1; n-- {
141		m.Workspaces = m.Workspaces[:n-1]
142	}
143	if n := len(m.Workspaces); n == 0 || !spare(n-1) {
144		m.Workspaces = append(m.Workspaces, m.newWorkspace())
145	}
146}
147
148// Focus switches to the numbered workspace at index i, clamped to the list.
149func (m *Monitor) Focus(i int) {
150	i = min(max(i, 0), len(m.Workspaces)-1)
151	m.shown = nil
152	m.Active = i
153	m.normalize()
154}
155
156// FocusNumber switches to workspace n (1-based); past the end means the last.
157func (m *Monitor) FocusNumber(n int) { m.Focus(n - 1) }
158
159// show puts w on screen, numbered or hidden.
160func (m *Monitor) show(w *Workspace) {
161	if i := indexOf(m.Workspaces, w); i >= 0 {
162		m.Focus(i)
163		return
164	}
165	m.shown = w
166	m.normalize()
167}
168
169// ToggleNamed shows the named workspace, or returns to the previous one when
170// it is already on screen. Unknown names do nothing.
171func (m *Monitor) ToggleNamed(name string) {
172	target := m.byName(name)
173	switch cur := m.Current(); {
174	case target == nil:
175	case target != cur:
176		m.back = cur
177		m.show(target)
178	case m.back != nil && m.has(m.back):
179		back := m.back
180		m.back = nil
181		m.show(back)
182	case m.shown != nil:
183		m.back = nil
184		m.shown = nil
185		m.normalize()
186	}
187}
188
189// byName returns the configured workspace with that name, or nil.
190func (m *Monitor) byName(name string) *Workspace {
191	for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) {
192		if w.Name == name {
193			return w
194		}
195	}
196	return nil
197}
198
199func indexOf(list []*Workspace, w *Workspace) int {
200	for i, v := range list {
201		if v == w {
202			return i
203		}
204	}
205	return -1
206}
207
208// has reports whether w still belongs to the monitor.
209func (m *Monitor) has(w *Workspace) bool {
210	return indexOf(m.Workspaces, w) >= 0 || indexOf(m.hidden, w) >= 0
211}
212
213// find returns the workspace holding the window; i is -1 for hidden ones.
214func (m *Monitor) find(id WindowID) (*Workspace, int) {
215	for i, w := range m.Workspaces {
216		if w.has(id) {
217			return w, i
218		}
219	}
220	for _, w := range m.hidden {
221		if w.has(id) {
222			return w, -1
223		}
224	}
225	return nil, -1
226}
227
228// AddWindow places a new window on the workspace on screen. On a
229// fullscreen workspace it floats, above the fullscreen window: a tiled one
230// would be hidden behind it (fixed overflow) while it has the focus.
231func (m *Monitor) AddWindow(id WindowID) {
232	if w, _ := m.find(id); w != nil {
233		return
234	}
235	if cur := m.Current(); cur.origin != nil {
236		cur.joinFullscreen(id)
237		m.normalize()
238		return
239	}
240	m.Current().AddWindow(id)
241	m.normalize()
242}
243
244// AddFloating adds a floating window to the active workspace.
245func (m *Monitor) AddFloating(id WindowID, width, height int) {
246	if w, _ := m.find(id); w != nil {
247		return
248	}
249	m.Current().AddFloating(id, width, height)
250	m.normalize()
251}
252
253// RemoveWindow drops the window wherever it is; focus stays on the active
254// workspace, except that closing a fullscreen workspace goes back to its origin.
255func (m *Monitor) RemoveWindow(id WindowID) {
256	if w, _ := m.find(id); w != nil {
257		if w.origin != nil && id == w.back.id && m.has(w.origin) {
258			// Its dialogs go home with the view.
259			origin, back := w.origin, w.back
260			w.RemoveWindow(id)
261			w.origin, w.back = nil, origPlace{}
262			m.foldInto(w, origin, back.col, false, back.tiled)
263		} else {
264			w.RemoveWindow(id)
265		}
266		m.normalize()
267	}
268}
269
270// SetFullscreen applies a client request. Under fixed overflow the window
271// gets its own workspace, as with the bind. A client request never moves
272// the user (ADR 011): the view follows only a window that is focused and
273// on screen, and a window coming back does not take the focus.
274func (m *Monitor) SetFullscreen(id WindowID, on bool) {
275	w, _ := m.find(id)
276	switch {
277	case w == nil:
278	case w.origin != nil && id != w.back.id:
279		// A dialog of a fullscreen workspace already floats above it; its
280		// own fullscreen would take the workspace from the window it holds.
281	case on && m.ownWorkspace(w, id):
282		focused, _ := w.Focused()
283		m.enterFullscreen(w, id, w == m.Current() && focused == id)
284	case !on && w.origin != nil && w.fullscreen == id:
285		// The user was on that window: they stay on it, back home.
286		m.leaveFullscreen(w, m.Current() == w)
287	default:
288		w.SetFullscreen(id, on)
289	}
290	m.normalize()
291}
292
293// ToggleFullscreen is the bind. Under fixed overflow the focused window
294// moves to a new workspace below the current one, so workspace up/down
295// still reach the others; toggling again sends it back where it was.
296func (m *Monitor) ToggleFullscreen() {
297	w := m.Current()
298	id, ok := w.Focused()
299	switch {
300	case !ok:
301	case w.origin != nil && w.fullscreen == w.back.id:
302		// From its window or one of its dialogs.
303		m.leaveFullscreen(w, true)
304	case m.ownWorkspace(w, id):
305		m.enterFullscreen(w, id, true)
306	default:
307		w.ToggleFullscreen()
308	}
309	m.normalize()
310}
311
312// ownWorkspace reports whether going fullscreen moves the window to its own
313// workspace: fixed overflow hides every other window behind a fullscreen
314// one and never scrolls, so it would leave the user stuck. Not needed when
315// the window is alone, or already has its own workspace.
316func (m *Monitor) ownWorkspace(w *Workspace, id WindowID) bool {
317	return w.Overflow == OverflowFixed && w.origin == nil && w.fullscreen != id && len(w.windows()) > 1 && m.has(w)
318}
319
320// enterFullscreen moves window id from w to a new workspace just below the
321// numbered one on screen, fullscreen, and shows it when show is set. It
322// remembers the window's place in w to return there.
323func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) {
324	fs := m.newWorkspace()
325	fs.Overflow, fs.MaxColumns = w.Overflow, w.MaxColumns
326	fs.origin = w
327	fs.back.id = id
328	if f := w.floatIndex(id); f >= 0 {
329		fl := w.Floats[f]
330		fs.back.float = &fl
331		w.RemoveWindow(id)
332		fs.AddFloating(id, fl.W, fl.H)
333	} else {
334		col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) })
335		c := w.Columns[col]
336		fs.back = origPlace{id: id, col: col, row: slices.Index(c.Windows, id), slot: c.Slot, width: c.Width}
337		for _, v := range c.Windows {
338			if v != id {
339				fs.back.stacked = append(fs.back.stacked, v)
340			}
341		}
342		w.RemoveWindow(id)
343		fs.AddWindow(id)
344		fs.Columns[0].Width = c.Width
345	}
346	fs.SetFullscreen(id, true)
347	at := m.Active + 1
348	if i := indexOf(m.Workspaces, w); i >= 0 {
349		at = i + 1
350	}
351	m.Workspaces = slices.Insert(m.Workspaces, at, fs)
352	if at <= m.Active {
353		m.Active++
354	}
355	if show {
356		m.show(fs)
357	}
358}
359
360// leaveFullscreen sends the window of fs back to its place in the origin,
361// with the windows opened there meanwhile (its dialogs) just after it, and
362// drops fs; the view goes along when fs was on screen. focus is set when
363// the user was on the window: it gets the focus back there.
364func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) {
365	if m.fullscreenHome(); fs.origin == nil {
366		fs.SetFullscreen(fs.fullscreen, false)
367		return
368	}
369	origin, back, id := fs.origin, fs.back, fs.back.id
370	fs.origin, fs.back = nil, origPlace{}
371	fs.RemoveWindow(id)
372	// A closure: back.col is where the window lands, known after the switch.
373	defer func() { m.foldInto(fs, origin, back.col+1, focus, back.tiled) }()
374	switch prev := origin.Focus; {
375	case back.float != nil && focus:
376		origin.AddFloating(id, back.float.W, back.float.H)
377	case back.float != nil:
378		// At the bottom: the top float, maybe focused, stays on top.
379		origin.Floats = slices.Insert(origin.Floats, 0, Float{ID: id, W: back.float.W, H: back.float.H})
380	case slices.ContainsFunc(origin.Columns, back.holdsStack):
381		back.col = slices.IndexFunc(origin.Columns, back.holdsStack)
382		c := &origin.Columns[back.col]
383		// Below the windows that were above it and are still there.
384		row := 0
385		for _, v := range back.stacked[:back.row] {
386			if slices.Contains(c.Windows, v) {
387				row++
388			}
389		}
390		row = min(row, len(c.Windows))
391		c.Windows = slices.Insert(c.Windows, row, id)
392		if focus {
393			origin.Focus, c.Focus, origin.floatFocus = back.col, row, false
394		} else if row <= c.Focus && len(c.Windows) > 1 {
395			c.Focus++
396		}
397	default:
398		at := min(back.col, len(origin.Columns))
399		origin.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot})
400		if focus {
401			origin.floatFocus = false
402		} else if len(origin.Columns) > 1 {
403			origin.Focus = prev
404			if at <= prev {
405				origin.Focus++
406			}
407		}
408	}
409}
410
411// foldInto moves what is left in fs (windows opened while fullscreen: its
412// dialogs) to origin, tiled ones from column at, floating ones on top, and
413// drops fs. The view goes along when fs was on screen.
414func (m *Monitor) foldInto(fs, origin *Workspace, at int, focus bool, tiled []WindowID) {
415	shown := m.Current() == fs
416	at = min(max(at, 0), len(origin.Columns))
417	for _, c := range slices.Backward(fs.Columns) {
418		origin.Columns = slices.Insert(origin.Columns, at, Column{Windows: c.Windows, Width: c.Width, Focus: c.Focus})
419		if at <= origin.Focus && len(origin.Columns) > 1 {
420			origin.Focus++
421		}
422	}
423	var floats []Float
424	for _, f := range fs.Floats {
425		if !slices.Contains(tiled, f.ID) {
426			floats = append(floats, f)
427			continue
428		}
429		origin.Columns = slices.Insert(origin.Columns, at, Column{Windows: []WindowID{f.ID}})
430		if at <= origin.Focus && len(origin.Columns) > 1 {
431			origin.Focus++
432		}
433		at++
434	}
435	if focus || shown {
436		origin.Floats = append(origin.Floats, floats...)
437		origin.floatFocus = origin.floatFocus || len(floats) > 0 && fs.floatFocus
438	} else {
439		// The user is elsewhere: below, the focused float keeps the focus.
440		origin.Floats = append(floats, origin.Floats...)
441	}
442	fs.Columns, fs.Floats = nil, nil
443	origin.scroll()
444	m.take(fs)
445	if shown {
446		m.show(origin)
447	}
448}
449
450// awayInSlot reports whether id is away from slot n of w, fullscreen in a
451// workspace that returns it there.
452func (m *Monitor) awayInSlot(w *Workspace, id WindowID, n int) bool {
453	for _, fs := range m.all() {
454		if fs.origin == w && fs.back.id == id && fs.back.slot == n {
455			return true
456		}
457	}
458	return false
459}
460
461// holdsStack reports whether c holds a window of the stack left behind.
462func (p origPlace) holdsStack(c Column) bool {
463	return slices.ContainsFunc(c.Windows, func(v WindowID) bool { return slices.Contains(p.stacked, v) })
464}
465
466// fullscreenHome keeps each fullscreen workspace linked to its origin only
467// while its window is still there and fullscreen, and the origin is still
468// on this monitor. Otherwise (the window moved out or activated away from
469// fullscreen, the origin gone to another monitor) it is a normal workspace.
470// Other windows opened there (a dialog) stay when the window returns.
471func (m *Monitor) fullscreenHome() {
472	for _, w := range m.all() {
473		if w.origin == nil {
474			continue
475		}
476		if !m.has(w.origin) || w.fullscreen != w.back.id || !w.has(w.back.id) {
477			w.origin, w.back = nil, origPlace{}
478		}
479	}
480}
481
482// MoveToWorkspace moves the focused column (or only the focused window) to
483// numbered workspace index i. A floating window always moves alone. Focus
484// stays here unless followMove is set (ADR 017).
485func (m *Monitor) MoveToWorkspace(i int, column bool) {
486	i = min(max(i, 0), len(m.Workspaces)-1)
487	cur, to := m.Current(), m.Workspaces[i]
488	id, ok := cur.Focused()
489	if !ok || to == cur {
490		return
491	}
492	if f := cur.floatIndex(id); f >= 0 {
493		fl := cur.Floats[f]
494		cur.RemoveWindow(id)
495		to.AddFloating(id, fl.W, fl.H)
496		to.FocusID(id)
497	} else {
498		col := Column{Windows: []WindowID{id}}
499		ok := true
500		if column {
501			col, ok = cur.takeColumn()
502		} else {
503			_, ok = cur.takeWindow()
504		}
505		if !ok {
506			return
507		}
508		to.receive(col, -1)
509	}
510	if m.followMove {
511		m.Active = i
512		m.shown = nil
513	}
514	m.normalize()
515}
516
517// receive adds a column moved in from another workspace and focuses it: at
518// index at, or where a new window goes when at < 0. It stays visible: on a
519// fixed-overflow fullscreen workspace its windows float above, and an
520// in-place fullscreen is left.
521func (w *Workspace) receive(col Column, at int) {
522	id := col.Windows[col.Focus]
523	switch {
524	case w.origin != nil:
525		for _, v := range col.Windows {
526			w.joinFullscreen(v)
527		}
528	case at < 0:
529		w.addColumn(col)
530	default:
531		w.insertColumn(at, col)
532	}
533	w.Activate(id)
534}
535
536// joinFullscreen adds a tiled window to a fullscreen workspace fs: it floats
537// above the fullscreen window, which would hide it (fixed overflow), and
538// tiles again once home.
539func (fs *Workspace) joinFullscreen(id WindowID) {
540	fs.AddFloating(id, 0, 0)
541	fs.back.tiled = append(fs.back.tiled, id)
542}
543
544func (m *Monitor) Focused() (WindowID, bool) { return m.Current().Focused() }
545
546// Layout places every window: the workspace on screen laid out, others hidden.
547func (m *Monitor) Layout() []Placement {
548	var result []Placement
549	cur := m.Current()
550	for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) {
551		if w == cur {
552			result = append(result, w.Layout()...)
553			continue
554		}
555		for _, id := range w.windows() {
556			result = append(result, Placement{ID: id, Hidden: true})
557		}
558	}
559	return result
560}
561
562func (m *Monitor) Output() Rect { return m.template.Output }
563
564func (m *Monitor) each(f func(*Workspace)) {
565	f(&m.template)
566	for _, w := range m.Workspaces {
567		f(w)
568	}
569	for _, w := range m.hidden {
570		f(w)
571	}
572}
573func (m *Monitor) SetOutput(width, height int) {
574	m.each(func(w *Workspace) { w.SetOutput(width, height) })
575}
576func (m *Monitor) SetUsable(r Rect)     { m.each(func(w *Workspace) { w.SetUsable(r) }) }
577func (m *Monitor) SetGaps(g int)        { m.each(func(w *Workspace) { w.SetGaps(g) }) }
578func (m *Monitor) SetBorder(b int)      { m.each(func(w *Workspace) { w.border = max(b, 0) }) }
579func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) }
580
581// SetFollowMove makes moves to another workspace show it (focus.follow-move).
582func (m *Monitor) SetFollowMove(on bool) { m.followMove = on }
583
584// SetMaxColumns sets the default; named workspaces may override it.
585func (m *Monitor) SetMaxColumns(n int) {
586	m.template.MaxColumns = n
587	m.applyNamed()
588}
589
590// SetOverflow sets the default; named workspaces may override it.
591func (m *Monitor) SetOverflow(o Overflow) {
592	m.template.Overflow = o
593	m.applyNamed()
594}
595
596// applyNamed gives every workspace the defaults or its named overrides.
597func (m *Monitor) applyNamed() {
598	specs := map[string]NamedWorkspace{}
599	for _, s := range m.named {
600		specs[s.Name] = s
601	}
602	m.each(func(w *Workspace) {
603		s := specs[w.Name]
604		w.Overflow = m.template.Overflow
605		if s.Overflow != "" {
606			w.Overflow = s.Overflow
607		}
608		w.SetMaxColumns(m.template.MaxColumns)
609		if s.MaxColumns > 0 {
610			w.SetMaxColumns(s.MaxColumns)
611		}
612	})
613}
614
615// SetNamed applies the named workspaces from config. New numbered ones are
616// inserted above the trailing empty workspace (never before the first one).
617// A workspace dropped from config becomes a normal one; a dropped hidden one
618// hands its windows to the active numbered workspace. The workspace on
619// screen stays on screen.
620func (m *Monitor) SetNamed(specs []NamedWorkspace) {
621	cur := m.Current()
622	want := map[string]NamedWorkspace{}
623	for _, s := range specs {
624		want[s.Name] = s
625	}
626	byName := map[string]*Workspace{}
627	for _, w := range m.Workspaces {
628		if _, ok := want[w.Name]; w.Name != "" && !ok {
629			w.Name = ""
630		}
631		if w.Name != "" {
632			byName[w.Name] = w
633		}
634	}
635	var hidden []*Workspace
636	for _, w := range m.hidden {
637		if _, ok := want[w.Name]; ok {
638			byName[w.Name] = w
639			hidden = append(hidden, w)
640			continue
641		}
642		if w == cur {
643			cur = nil
644		}
645		for _, c := range w.Columns {
646			for _, id := range c.Windows {
647				m.Workspaces[m.Active].AddWindow(id)
648			}
649		}
650		for _, f := range w.Floats {
651			m.Workspaces[m.Active].AddFloating(f.ID, f.W, f.H)
652		}
653	}
654	m.hidden = hidden
655	m.named = append([]NamedWorkspace(nil), specs...)
656	var nextHidden []*Workspace
657	for _, s := range specs {
658		w := byName[s.Name]
659		if w == nil {
660			w = m.newWorkspace()
661			w.Name = s.Name
662		}
663		i := indexOf(m.Workspaces, w)
664		switch {
665		case s.Hidden:
666			if i >= 0 {
667				m.removeNumbered(i)
668			}
669			nextHidden = append(nextHidden, w)
670		case i < 0:
671			at := max(len(m.Workspaces)-1, 1)
672			m.Workspaces = append(m.Workspaces[:at], append([]*Workspace{w}, m.Workspaces[at:]...)...)
673			if at <= m.Active {
674				m.Active++
675			}
676		}
677	}
678	m.hidden = nextHidden
679	if m.back != nil && !m.has(m.back) {
680		m.back = nil
681	}
682	m.shown = nil
683	switch {
684	case cur == nil:
685	case indexOf(m.hidden, cur) >= 0:
686		m.shown = cur
687	case indexOf(m.Workspaces, cur) >= 0:
688		m.Active = indexOf(m.Workspaces, cur)
689	}
690	m.applyNamed()
691	m.normalize()
692}
693
694// removeNumbered drops Workspaces[i] and keeps Active on the same workspace
695// when possible.
696func (m *Monitor) removeNumbered(i int) {
697	m.Workspaces = append(m.Workspaces[:i], m.Workspaces[i+1:]...)
698	if i < m.Active || m.Active >= len(m.Workspaces) {
699		m.Active = max(m.Active-1, 0)
700	}
701	if len(m.Workspaces) == 0 {
702		m.Workspaces = append(m.Workspaces, m.newWorkspace())
703	}
704}

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 26

Tags 10

↑↓ moveenter openesc close