overview.go RawHistoryFolder

1package core
2
3import (
4	"context"
5	"math"
6	"slices"
7
8	"github.com/bnema/neferwl/internal/ports"
9)
10
11// overviewState owns the provisional selection and the snapshot of each row
12// it showed. row is the workspace whose stack has a provisional front; nil
13// uses its real front.
14type overviewState struct {
15	open             bool
16	from             *Workspace
17	back             *Workspace
18	rows             map[*Workspace]rowSnapshot
19	row              *Workspace
20	front            stackItem
21	frontAt          int
22	cardOf           *Workspace
23	card             WindowID
24	selected         WindowID
25	selectedAt       int
26	scrollX, scrollY float64
27	// scrolled is set once a three-finger gesture stepped up or down: it
28	// waits for the fingers to lift before the next step. sideways is set
29	// once it stepped to a column: it stays on columns and steps once per
30	// overviewScrollRepeat.
31	scrolled, sideways bool
32}
33
34// rowSnapshot is a row as the overview first showed it: browsing moves its
35// focus and scroll, closing puts them back.
36type rowSnapshot struct {
37	focus     WindowID
38	floats    []Float
39	fullWidth WindowID
40	// FullWidth history is restored when active binds changed it.
41	maximized []WindowID
42}
43
44// The overview scales workspace rows without resizing clients. Covering
45// floats and the columns form a linear stack of cards; small floats stay
46// hidden. Up/down traverse the stack before changing workspace. A pinned
47// fullscreen window is the front card of its row, the windows it hides
48// behind it; choosing one of them leaves fullscreen.
49
50// A workspace's stash shows as a pile of cards pinned to the left edge of
51// its row: the selected stashed window in front at overviewCardZoom, up to
52// overviewCards-1 others behind it, each overviewCardStep further up and
53// right, dimmed. The row takes the width the pile leaves.
54const (
55	overviewCardZoom = 0.2
56	overviewCards    = 4
57	overviewCardStep = 8
58	overviewPeekStep = 0.07
59)
60
61// overviewMaxZoom caps the current row so its neighbors show above and
62// below; overviewMinZoom floors the shrink of wide workspaces, which then
63// scroll to keep the selected column in view.
64const overviewMaxZoom, overviewMinZoom = 0.6, 0.25
65
66// ToggleOverview opens the overview, or closes it on the selection. Over
67// a covering fullscreen window (a game) too: its row shows it, composed
68// rather than scanned out while the overview is open.
69func (m *Monitor) ToggleOverview() {
70	if m.ov.open {
71		m.closeOverview()
72		return
73	}
74	w := m.Current()
75	// A slide in progress lands at once: the overview lays out the
76	// settled state.
77	m.stopSwitch()
78	m.each(func(w *Workspace) { w.view.stop(); w.stashView.stop() })
79	m.ov.open, m.ov.from, m.ov.back = true, w, m.back
80	m.ov.scrollX, m.ov.scrollY, m.ov.scrolled, m.ov.sideways = 0, 0, false, false
81	m.overviewOpens++
82	m.selectRow()
83}
84
85// remember snapshots w the first time the overview shows it.
86func (m *Monitor) remember(w *Workspace) {
87	if _, ok := m.ov.rows[w]; ok {
88		return
89	}
90	if m.ov.rows == nil {
91		m.ov.rows = make(map[*Workspace]rowSnapshot)
92	}
93	s := rowSnapshot{
94		floats:    append([]Float(nil), w.Floats...),
95		maximized: slices.Clone(w.maximized),
96	}
97	s.focus, _ = w.Focused()
98	if len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth {
99		c := w.Columns[w.Focus]
100		s.fullWidth = c.Windows[c.Focus]
101	}
102	m.ov.rows[w] = s
103}
104
105// selectRow resets provisional stack and stash selection for a new row.
106func (m *Monitor) selectRow() {
107	m.remember(m.Current())
108	m.ov.card, m.ov.cardOf = 0, nil
109	m.ov.front, m.ov.row, m.ov.frontAt = stackItem{}, nil, 0
110	m.ov.selected, m.ov.selectedAt = 0, -1
111	w := m.Current()
112	// A fullscreen window has the focus: its card is in front.
113	w.focusCover()
114	if w.floatFocus && len(w.Columns) > 0 && (len(w.stack()) < 2 || m.stackFront(w).kind == stackColumns) {
115		w.floatFocus = false
116	}
117	if len(w.Stash) > 0 && (len(w.stack()) == 0 || w.stashFocused() && !w.floatFocus) {
118		m.selectCard(w, w.stashAt)
119	}
120}
121
122// selectCard selects stash entry i of w.
123func (m *Monitor) selectCard(w *Workspace, i int) {
124	m.ov.card, m.ov.cardOf = w.Stash[i].ID, w
125}
126
127// card is the selected stash card, 0 when there is none: the overview is
128// closed, the selection is on a column, or its window left the stash on
129// screen (unmapped, unstashed, or another workspace shown by a bind).
130func (m *Monitor) card() WindowID {
131	w := m.ov.cardOf
132	if !m.ov.open || w == nil || w != m.Current() || w.stashIndex(m.ov.card) < 0 {
133		return 0
134	}
135	return m.ov.card
136}
137
138// cardAt is the pile entry of w that is selected, -1 when none.
139func (m *Monitor) cardAt(w *Workspace) int {
140	if id := m.card(); id != 0 && w == m.ov.cardOf {
141		return w.stashIndex(id)
142	}
143	return -1
144}
145
146// overviewTarget resolves the active preview to one window for validation
147// and window actions. Presentation state never falls back to a covering float.
148func (m *Monitor) overviewTarget() WindowID {
149	if id := m.card(); id != 0 {
150		return id
151	}
152	w := m.Current()
153	item := m.stackFront(w)
154	switch item.kind {
155	case stackFloat:
156		return item.id
157	case stackColumn:
158		i := w.columnOf(item.id)
159		if i >= 0 {
160			c := w.Columns[i]
161			return c.Windows[c.Focus]
162		}
163	case stackColumns:
164		i := w.Focus
165		if len(w.Columns) == 0 {
166			return 0
167		}
168		if w.overviewMaximized() {
169			i = w.columnOf(m.ov.selected)
170			if i < 0 || i == w.Focus {
171				i = m.hiddenColumn(w)
172			}
173		}
174		if i >= 0 {
175			c := w.Columns[i]
176			return c.Windows[c.Focus]
177		}
178	}
179	return 0
180}
181
182// overviewBlocks makes window mutations explicit: they are disabled while
183// selection is provisional. Workspace and monitor navigation and moves to
184// another workspace remain active.
185func overviewBlocks(a Action) bool {
186	if _, _, ok := ResizeArg(a); ok {
187		return true
188	}
189	switch a {
190	case ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth,
191		ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash,
192		ActionToggleStashVisible, ActionConsumeOrExpelLeft,
193		ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight,
194		ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown,
195		ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspaceUp,
196		ActionMoveWorkspaceDown, ActionToggleFloating:
197		return true
198	}
199	return false
200}
201
202// overviewMoveTo moves the selected preview at once, like close-window: the
203// overview stays open and closing it does not undo the move. Nothing moves
204// without a selected preview, from a stash card, or when the bind has
205// nowhere to go.
206func (m *Monitor) overviewMoveTo(a Action) {
207	cur, id := m.Current(), m.overviewTarget()
208	i, column, ok := m.moveDest(a)
209	if m.card() != 0 || id == 0 || !ok || m.Workspaces[i] == cur {
210		return
211	}
212	if cur.pinned() && id != cur.cover() {
213		// Focused reports the covering window while it covers.
214		cur.leaveFullscreen()
215	}
216	cur.FocusID(id)
217	m.MoveToWorkspace(i, column)
218	// The row that received the window keeps it focused on close.
219	if to, _ := m.find(id); to != nil && to != cur {
220		delete(m.ov.rows, to)
221	}
222	m.selectRow()
223}
224
225// closeOverview closes the overview on the selection: a card shows the
226// stash on it. A fullscreen request another stashed window made while
227// the stash was hidden is dropped: the picked card stays in front. A
228// window a pinned fullscreen window hides leaves that fullscreen.
229func (m *Monitor) closeOverview() {
230	w := m.Current()
231	front, target := m.stackFront(w), m.overviewTarget()
232	if w.pinned() && target != w.cover() {
233		w.leaveFullscreen()
234	}
235	if i := m.cardAt(w); i >= 0 {
236		if w.hiddenFullscreen != w.Stash[i].ID {
237			w.hiddenFullscreen = 0
238		}
239		w.stashAt = i
240		w.showStash()
241	} else if front != (stackItem{}) {
242		w.apply(front, target)
243	}
244	for r, s := range m.ov.rows {
245		if r != w && m.has(r) {
246			restoreRow(r, s)
247		}
248	}
249	m.ov = overviewState{}
250}
251
252// CancelOverview closes the overview and returns to the workspace and
253// window it opened on, if they are still there. Every row it showed gets
254// its focus back.
255func (m *Monitor) CancelOverview() {
256	if from := m.ov.from; from != nil && m.has(from) {
257		m.show(from)
258	}
259	for r, s := range m.ov.rows {
260		if m.has(r) {
261			restoreRow(r, s)
262		}
263	}
264	m.back = m.ov.back
265	if m.back != nil && !m.has(m.back) {
266		m.back = nil
267	}
268	m.ov = overviewState{}
269}
270
271// restoreRow puts w back as snapshot s found it.
272func restoreRow(w *Workspace, s rowSnapshot) {
273	if s.focus != 0 {
274		w.FocusID(s.focus)
275	} else {
276		w.floatFocus, w.stashFocus = false, false
277	}
278	// FocusID on a float raises it; restore the initial order and below
279	// flags for floats still present, leaving new floats in their order.
280	current := w.Floats
281	w.Floats = nil
282	for _, f := range s.floats {
283		for i, now := range current {
284			if now.ID == f.ID {
285				now.below = f.below
286				w.Floats = append(w.Floats, now)
287				current = append(current[:i], current[i+1:]...)
288				break
289			}
290		}
291	}
292	w.Floats = append(w.Floats, current...)
293	// Fixed overflow maximizes at most one column: only the one holding
294	// the anchor window, wherever active binds moved it. Scroll overflow
295	// keeps each column's own width.
296	if w.policy().equalCells {
297		w.unmaximize()
298		for i := range w.Columns {
299			if s.fullWidth != 0 && slices.Contains(w.Columns[i].Windows, s.fullWidth) {
300				w.maximize(i)
301				break
302			}
303		}
304	}
305	// Windows that left the row since leave its maximize history too.
306	w.maximized = slices.DeleteFunc(slices.Clone(s.maximized), func(id WindowID) bool { return !w.has(id) })
307	w.scroll()
308}
309
310// overviewWorkspaces inserts named rows below their invocation workspace,
311// in configured order within each group. Unattached rows follow the last
312// occupied numbered row (or the first). Empty invocation anchors stay visible;
313// other empty rows are skipped unless current.
314func (m *Monitor) overviewWorkspaces() []*Workspace {
315	fallback := m.Workspaces[0]
316	for _, w := range m.Workspaces {
317		if !w.empty() {
318			fallback = w
319		}
320	}
321	groups := make(map[*Workspace][]*Workspace)
322	named := slices.Clone(m.hidden)
323	rank := func(w *Workspace) int {
324		for i, spec := range m.named {
325			if spec.Name == w.Name {
326				return i
327			}
328		}
329		return len(m.named)
330	}
331	slices.SortStableFunc(named, func(a, b *Workspace) int { return rank(a) - rank(b) })
332	for _, w := range named {
333		if w.empty() && w != m.Current() {
334			continue
335		}
336		anchor := w.overviewAfter
337		if indexOf(m.Workspaces, anchor) < 0 {
338			anchor = fallback
339		}
340		groups[anchor] = append(groups[anchor], w)
341	}
342	var rows []*Workspace
343	for _, w := range m.Workspaces {
344		if !w.empty() || w == m.Current() || len(groups[w]) > 0 {
345			rows = append(rows, w)
346			rows = append(rows, groups[w]...)
347		}
348	}
349	return rows
350}
351
352// showOverview changes rows without committing the provisional stack. Entering
353// a named group uses its invocation anchor as the toggle return target, not
354// rows merely browsed in the overview.
355func (m *Monitor) showOverview(w *Workspace) {
356	if w.policy().workspace != m.Current().policy().workspace {
357		m.ov.scrollX, m.ov.scrollY, m.ov.scrolled, m.ov.sideways = 0, 0, false, false
358	}
359	if w != m.Current() && m.isHidden(w) && !m.isHidden(m.Current()) {
360		m.back = m.Current()
361		if indexOf(m.Workspaces, w.overviewAfter) >= 0 {
362			m.back = w.overviewAfter
363		}
364	}
365	m.show(w)
366	m.selectRow()
367}
368
369// OverviewMove shares navigation rules across keys, scrolls and swipes.
370// Vertical moves stay in a selected stash pile; left stays, and right
371// deselects it, even when the row has no main card.
372func (m *Monitor) OverviewMove(dx, dy int) {
373	if dy != 0 {
374		m.overviewVertical(dy)
375	} else if dx != 0 {
376		m.overviewHorizontal(dx)
377	}
378}
379
380func (m *Monitor) overviewVertical(dy int) {
381	w := m.Current()
382	if at := m.cardAt(w); at >= 0 {
383		m.selectCard(w, min(max(at+dy, 0), len(w.Stash)-1))
384		return
385	}
386	if m.moveStack(-dy) {
387		return
388	}
389	rows := m.overviewWorkspaces()
390	i := indexOf(rows, w) + dy
391	if i >= 0 && i < len(rows) {
392		m.showOverview(rows[i])
393	}
394}
395
396func (m *Monitor) overviewHorizontal(dx int) {
397	w := m.Current()
398	if m.cardAt(w) >= 0 {
399		if dx > 0 {
400			m.ov.card, m.ov.cardOf = 0, nil
401			m.ov.selected = 0
402			item := m.stackFront(w)
403			if item.kind == stackColumns {
404				if w.overviewMaximized() {
405					i := m.hiddenColumn(w)
406					m.ov.selected, m.ov.selectedAt = w.Columns[i].Windows[w.Columns[i].Focus], i
407				} else {
408					w.selectOverviewColumn(0)
409					m.ov.selected = w.Columns[0].Windows[w.Columns[0].Focus]
410				}
411			}
412		}
413		return
414	}
415	front := m.stackFront(w)
416	if front.kind != stackColumns {
417		if dx < 0 && m.moveStack(1) {
418			return
419		}
420		if dx < 0 && len(w.Stash) > 0 {
421			m.selectCard(w, w.stashAt)
422		}
423		return
424	}
425	if len(w.Columns) == 0 {
426		return
427	}
428	i := w.columnOf(m.ov.selected)
429	if i < 0 || w.overviewMaximized() && i == w.Focus {
430		if w.overviewMaximized() {
431			i = m.hiddenColumn(w)
432		} else {
433			i = w.Focus
434		}
435	}
436	first := 0
437	if w.overviewMaximized() && w.Focus == 0 {
438		first = 1
439	}
440	if dx < 0 && i == first && len(w.Stash) > 0 {
441		m.selectCard(w, w.stashAt)
442		return
443	}
444	next := min(max(i+dx, 0), len(w.Columns)-1)
445	if w.overviewMaximized() && next == w.Focus {
446		next = min(max(next+dx, 0), len(w.Columns)-1)
447		if next == w.Focus {
448			return
449		}
450	}
451	m.ov.selected = w.Columns[next].Windows[w.Columns[next].Focus]
452	m.ov.selectedAt = next
453	if !w.overviewMaximized() {
454		// Ordinary group navigation retains its existing scroll-to-selection.
455		w.selectOverviewColumn(next)
456	}
457}
458
459// OverviewPick closes the overview on the clicked window.
460func (m *Monitor) OverviewPick(id WindowID) {
461	w, _ := m.find(id)
462	if w == nil {
463		return
464	}
465	if w.floatIndex(id) >= 0 && w.stashIndex(id) < 0 {
466		i := w.floatIndex(id)
467		if !w.coversFloat(w.Floats[i]) && !(id == w.cover() && w.pinned()) {
468			return
469		}
470	}
471	m.showOverview(w)
472	m.ov.card, m.ov.cardOf = 0, nil
473	if i := w.stashIndex(id); i >= 0 {
474		m.selectCard(w, i)
475	} else if item, ok := w.itemOf(id); ok {
476		m.setFront(w, item)
477		if i := w.columnOf(id); i >= 0 {
478			if item.kind == stackColumns && !w.overviewMaximized() {
479				w.selectOverviewColumn(i)
480			}
481			m.ov.selected = id
482			for j, v := range w.Columns[i].Windows {
483				if v == id {
484					w.Columns[i].Focus = j
485					break
486				}
487			}
488		}
489	}
490	m.closeOverview()
491}
492
493// overviewRows places neighboring workspaces along the current policy axis.
494// Named boundaries reserve extra spacing and a perpendicular divider.
495func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) {
496	cur := m.Current()
497	u := cur.overviewArea()
498	gap := max(1, u.H*3/100)
499	hc := cur.rowHeight()
500	y := u.Y + (u.H-hc)/2
501	rows := map[*Workspace]Rect{cur: {Y: y}}
502	var dividers []ports.Separator
503	list := m.overviewWorkspaces()
504	i := indexOf(list, cur)
505	for _, next := range []int{i - 1, i + 1} {
506		if next < 0 || next >= len(list) {
507			continue
508		}
509		w := list[next]
510		spacing := gap
511		boundary := m.isHidden(w) != m.isHidden(cur)
512		if boundary {
513			spacing *= 2
514		}
515		top := y + hc
516		if next < i {
517			rows[w] = Rect{Y: y - spacing - w.rowHeight()}
518			top = y - spacing
519		} else {
520			rows[w] = Rect{Y: y + hc + spacing}
521		}
522		if cur.policy().workspace == horizontalAxis {
523			width := int(math.Round(float64(cur.Usable.W)*cur.overviewZoom())) + cur.pileWidth()
524			neighborWidth := int(math.Round(float64(w.Usable.W)*w.overviewZoom())) + w.pileWidth()
525			dx := (width+neighborWidth)/2 + spacing
526			if next < i {
527				dx = -dx
528			}
529			rows[w] = Rect{X: dx, Y: u.Y + (u.H-w.rowHeight())/2}
530		}
531		if boundary {
532			margin := u.W * 4 / 100
533			r := Rect{X: u.X + margin, Y: top + spacing/2, W: u.W - 2*margin, H: 1}
534			if cur.policy().workspace == horizontalAxis {
535				width := int(math.Round(float64(cur.Usable.W)*cur.overviewZoom())) + cur.pileWidth()
536				x := u.X + u.W/2 + width/2 + spacing/2
537				if next < i {
538					x = u.X + u.W/2 - width/2 - spacing/2
539				}
540				r = Rect{X: x, Y: u.Y + u.H*4/100, W: 1, H: u.H * 92 / 100}
541			}
542			dividers = append(dividers, ports.Separator{Rect: r})
543		}
544	}
545	return rows, dividers
546}
547
548// overviewLayout places the current workspace and its overview neighbors,
549// each with its full-width stash pile and covering-float stack. Other rows
550// stay hidden; numbered and named workspaces share the same preview geometry.
551func (m *Monitor) overviewLayout(result []Placement) []Placement {
552	cur := m.Current()
553	rows, _ := m.overviewRows()
554	type group struct {
555		w          *Workspace
556		start, end int
557	}
558	var groups [3]group
559	count := 0
560	for w := range m.all() {
561		ry, shown := rows[w]
562		if shown {
563			at := m.cardAt(w)
564			start := len(result)
565			result = append(result, m.stackRow(w, ry.Y, w != cur, at < 0)...)
566			result = append(result, w.pile(ry.Y, w != cur, at)...)
567			groups[count] = group{w, start, len(result)}
568			count++
569			for i := start; i < len(result); i++ {
570				if !result[i].Hidden {
571					result[i].Rect.X += ry.X
572				}
573			}
574		}
575		for _, f := range w.Floats {
576			if _, drawn := w.itemOf(f.ID); !shown || !drawn {
577				result = append(result, Placement{ID: f.ID, Hidden: true})
578			}
579		}
580		if !shown {
581			// Columns and stash only: the floats were listed above.
582			result = w.appendHidden(result, false)
583		}
584	}
585	if cur.policy().workspace == horizontalAxis {
586		left, right := int(^uint(0)>>1), -int(^uint(0)>>1)
587		for _, g := range groups[:count] {
588			if g.w != cur {
589				continue
590			}
591			for _, p := range result[g.start:g.end] {
592				if !p.Hidden {
593					left = min(left, p.Rect.X)
594					right = max(right, p.Rect.X+p.Rect.W)
595				}
596			}
597		}
598		if left > right {
599			left = cur.overviewArea().X + cur.overviewArea().W/2
600			right = left
601		}
602		gap := max(1, cur.overviewArea().H*3/100)
603		for _, g := range groups[:count] {
604			w, origin := g.w, rows[g.w]
605			if w == cur {
606				continue
607			}
608			lo, hi := int(^uint(0)>>1), -int(^uint(0)>>1)
609			for _, p := range result[g.start:g.end] {
610				if !p.Hidden {
611					lo = min(lo, p.Rect.X)
612					hi = max(hi, p.Rect.X+p.Rect.W)
613				}
614			}
615			if lo > hi {
616				continue
617			}
618			dx := right + gap - lo
619			if origin.X < 0 {
620				dx = left - gap - hi
621			}
622			for i := g.start; i < g.end; i++ {
623				if !result[i].Hidden {
624					result[i].Rect.X += dx
625				}
626			}
627		}
628	}
629	return result
630}
631
632// overviewDividers uses final placements so rules cannot cross translated cards.
633func (m *Monitor) overviewDividers(layout []Placement) []ports.Separator {
634	if m.Current().policy().workspace != horizontalAxis {
635		_, lines := m.overviewRows()
636		return lines
637	}
638	cur := m.Current()
639	list := m.overviewWorkspaces()
640	at := indexOf(list, cur)
641	bounds := func(w *Workspace) (int, int) {
642		lo, hi := int(^uint(0)>>1), -int(^uint(0)>>1)
643		for _, p := range layout {
644			if w.has(p.ID) && !p.Hidden {
645				lo = min(lo, p.Rect.X)
646				hi = max(hi, p.Rect.X+p.Rect.W)
647			}
648		}
649		return lo, hi
650	}
651	left, right := bounds(cur)
652	u := cur.overviewArea()
653	if left > right {
654		left = u.X + u.W/2
655		right = left
656	}
657	var lines []ports.Separator
658	for _, i := range []int{at - 1, at + 1} {
659		if i < 0 || i >= len(list) || m.isHidden(list[i]) == m.isHidden(cur) {
660			continue
661		}
662		lo, hi := bounds(list[i])
663		if lo > hi {
664			continue
665		}
666		x := (right + lo) / 2
667		if i < at {
668			x = (hi + left) / 2
669		}
670		lines = append(lines, ports.Separator{Rect: Rect{X: x, Y: u.Y + u.H*4/100, W: 1, H: u.H * 92 / 100}})
671	}
672	return lines
673}
674
675// fan places front and neighboring cards, farthest visible first and front
676// last. A card may contain several tiles; omitted cards stay in the layout
677// hidden so their clients are not left showing on screen.
678func fan(cards [][]Placement, front, maxBehind, maxBefore int, circular bool,
679	behind, before func(int) (int, int), dim, lit bool) []Placement {
680	n := len(cards)
681	if n == 0 {
682		return nil
683	}
684	front = min(max(front, 0), n-1)
685	out := make([]Placement, 0, n)
686	add := func(i, distance int, offset func(int) (int, int), peek bool) {
687		dx, dy := offset(distance)
688		for _, p := range cards[i] {
689			if !p.Hidden {
690				p.Rect.X += dx
691				p.Rect.Y += dy
692				if p.Peek || peek || dim {
693					p.peeking(1)
694				}
695				p.Focused = p.Focused && !peek && !dim && lit
696			}
697			out = append(out, p)
698		}
699	}
700	shown := make([]bool, n)
701	// Behind and before are in nearest-first order; paint farthest first.
702	for d := min(maxBehind, n-1); d >= 1; d-- {
703		i := front + d
704		if circular {
705			i = (front + d) % n
706		}
707		if i >= n {
708			continue
709		}
710		add(i, d, behind, true)
711		shown[i] = true
712	}
713	for d := min(maxBefore, n-1); d >= 1; d-- {
714		i := front - d
715		if circular {
716			i = (front - d + n) % n
717		}
718		if i < 0 {
719			continue
720		}
721		if shown[i] {
722			continue
723		}
724		add(i, d, before, true)
725		shown[i] = true
726	}
727	add(front, 0, behind, false)
728	shown[front] = true
729	for i, card := range cards {
730		if shown[i] {
731			continue
732		}
733		for _, p := range card {
734			p.Hidden = true
735			p.Preview = 0
736			p.Focused = false
737			out = append(out, p)
738		}
739	}
740	return out
741}
742
743// pileWidth is the room the stash pile of w takes on the left of its row,
744// its margin included; 0 without a stash.
745func (w *Workspace) pileWidth() int {
746	if len(w.Stash) == 0 {
747		return 0
748	}
749	card := int(math.Round(float64(w.stashRect().W) * overviewCardZoom))
750	// Never more than a third of the width: the columns keep the rest.
751	a := w.overviewArea()
752	return min(card+(min(len(w.Stash), overviewCards)-1)*w.cardStep()+a.W*2/100, a.W/3)
753}
754
755// cardStep is the offset between stacked cards, smaller on tiny outputs.
756func (w *Workspace) cardStep() int {
757	a := w.overviewArea()
758	return min(overviewCardStep, a.W/100, a.H/50)
759}
760
761// pile places the stash of w as cards on the left of the row whose top is
762// at y: entry front (the stash selection when -1) in front, the next ones
763// behind it, dimmed, the rest hidden. Only a front card of the current row
764// (dim false) with front set is selected.
765func (w *Workspace) pile(y int, dim bool, front int) []Placement {
766	n := len(w.Stash)
767	if n == 0 {
768		return nil
769	}
770	selected := front >= 0 && !dim
771	if front < 0 {
772		front = w.stashAt
773	}
774	front = min(max(front, 0), n-1)
775	r := w.stashRect()
776	u := w.overviewArea()
777	cw := int(math.Round(float64(r.W) * overviewCardZoom))
778	ch := int(math.Round(float64(r.H) * overviewCardZoom))
779	rowH := int(math.Round(float64(w.Usable.H) * w.overviewZoom()))
780	if w.policy().wraps {
781		n, _ := w.cascadeBands()
782		rowH = min(int(math.Round(float64(n*w.Usable.H)*w.overviewZoom())), u.H*8/10)
783	}
784	// Stack rows reserve two peek steps above the front card. Align the
785	// stash with that front band, not with the top of its envelope.
786	x0, y0 := u.X+u.W/100, w.frontRowY(y)+(rowH-ch)/2
787	cards := make([][]Placement, n)
788	for i := range w.Stash {
789		cards[i] = []Placement{{ID: w.Stash[i].ID, Rect: Rect{X: x0, Y: y0, W: cw, H: ch}, Preview: overviewCardZoom, Focused: selected}}
790	}
791	step := w.cardStep()
792	return fan(cards, front, min(n, overviewCards)-1, 0, true,
793		func(d int) (int, int) { return d * step, -d * step },
794		func(int) (int, int) { return 0, 0 }, dim, selected)
795}
796
797// overviewZoom is the scale that fits every column of w in the usable
798// width, within the overview bounds.
799func (w *Workspace) overviewZoom() float64 {
800	// Only the span is read: the tiles go in the zoom scratch, dead on return.
801	var preview overviewPreview
802	if w.policy().wraps {
803		n, _ := w.cascadeBands()
804		preview.bounds = Rect{W: w.Usable.W, H: n * w.Usable.H}
805	} else {
806		w.zoomBuf, preview = w.previewTilesInto(w.zoomBuf[:0])
807	}
808	span := preview.bounds.W
809	if span <= 0 {
810		return overviewMaxZoom
811	}
812	room := float64(w.overviewArea().W-w.pileWidth()) * 0.96
813	fit := room / float64(span)
814	if w.policy().wraps && preview.bounds.H > 0 {
815		fit = min(fit, float64(w.overviewArea().H)*0.8/float64(preview.bounds.H))
816	}
817	z := min(max(fit, overviewMinZoom), overviewMaxZoom)
818	if len(w.stack()) > 1 {
819		// Reserve two clear peeks on either side of the front row.
820		z = min(z, overviewMaxZoom/(1+4*overviewPeekStep))
821	}
822	return z
823}
824
825// peekStep is the shared step for a stack row and its front-card inset.
826func (w *Workspace) peekStep() int {
827	return max(1, int(math.Round(float64(w.Usable.H)*w.overviewZoom()*overviewPeekStep)))
828}
829
830// frontRowY skips the space reserved for two cards peeking above the front.
831// A single-card row starts at y, as before.
832func (w *Workspace) frontRowY(y int) int {
833	if len(w.stack()) > 1 {
834		return y + 2*w.peekStep()
835	}
836	return y
837}
838
839// rowHeight includes the largest possible stack envelope (two peeks above
840// and two below). Neighbor rows use the same measure as the current row.
841func (w *Workspace) rowHeight() int {
842	height := w.Usable.H
843	if w.policy().wraps {
844		n, _ := w.cascadeBands()
845		height *= n
846	}
847	h := int(math.Round(float64(height) * w.overviewZoom()))
848	if w.policy().wraps {
849		h = min(h, w.overviewArea().H*8/10)
850	}
851	if len(w.stack()) > 1 {
852		h += 4 * w.peekStep()
853	}
854	return h
855}
856
857// overviewPreview describes unscaled content independently of its presentation.
858type overviewPreview struct {
859	bounds   Rect
860	selected Rect
861}
862
863// previewTiles lays out unscrolled tiles relative to the usable origin.
864func (w *Workspace) previewTiles() ([]Placement, overviewPreview) {
865	return w.previewTilesInto(make([]Placement, 0, w.columnWindows()))
866}
867
868// previewTilesInto is previewTiles appending to tiles (dst[:0] to reuse its
869// storage: the result then aliases it). The column rects use w.colBuf, dead
870// when it returns.
871func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, overviewPreview) {
872	span, sel := 0, Rect{}
873	if w.pinned() {
874		c := w.cover()
875		// The row shows the columns a pinned fullscreen window hides,
876		// laid out as without it; its own tile stays marked fullscreen.
877		v := *w
878		v.fullscreen = 0
879		tiles, preview := v.previewTilesInto(tiles)
880		w.colBuf, w.rowBuf = v.colBuf, v.rowBuf
881		for i := range tiles {
882			tiles[i].Fullscreen = tiles[i].ID == c
883		}
884		return tiles, preview
885	}
886	g := w.gap()
887	h := max(w.Usable.H-2*g, 0)
888	var rects []Rect
889	if w.Overflow == OverflowFixed {
890		w.colBuf = w.columnRectsInto(w.colBuf, false)
891		rects = w.colBuf
892		span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile.
893	}
894	maximized := w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth
895	for i, c := range w.Columns {
896		r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h}
897		if w.policy().wraps {
898			width := w.cellWidth()
899			r.X = w.cellX(i)
900			r.Y = g + w.band(i)*w.Usable.H
901			r.W = width
902		} else if w.Overflow == OverflowFixed {
903			r = rects[i]
904			r.X, r.Y = r.X-w.Usable.X, r.Y-w.Usable.Y
905		} else if w.fullscreenColumn(i) {
906			// A fullscreen column shows at the width it had in the row.
907			r.W = max(w.Usable.W-2*g, 0)
908		}
909		if maximized && i != w.Focus {
910			// FullWidth hides other columns on screen. Their old buffers
911			// cannot fit their current spiral rects; show only the maximized
912			// column until navigation restores the normal layout.
913			for _, id := range c.Windows {
914				tiles = append(tiles, Placement{ID: id, Hidden: true})
915			}
916			continue
917		}
918		w.rowBuf = rowRectsInto(w.rowBuf[:0], r, c, g)
919		for j, t := range w.rowBuf {
920			tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == w.Focus && j == c.Focus})
921		}
922		if w.Overflow != OverflowFixed {
923			span = max(span, r.X+r.W+g)
924		}
925		if i == w.Focus {
926			sel = r
927		}
928	}
929	bounds := Rect{W: span, H: w.Usable.H}
930	if w.policy().wraps {
931		n, _ := w.cascadeBands()
932		bounds.W, bounds.H = w.Usable.W, n*w.Usable.H
933	}
934	return tiles, overviewPreview{bounds: bounds, selected: sel}
935}
936
937// previewRow scales the tiles of w into a row whose top is at y, centred
938// in the usable width, or scrolled so the focused column shows when the
939// row is wider, right of the stash pile. Dimmed rows (neighbor
940// workspaces) have no focus; neither has a row whose pile has it (lit
941// false).
942func (w *Workspace) previewRow(y int, dim, lit bool) []Placement {
943	tiles, preview := w.previewTiles()
944	return w.previewGeometry(y, dim, lit, tiles, preview)
945}
946
947func (w *Workspace) previewRowTiles(y int, dim, lit bool, tiles []Placement, span int, sel Rect) []Placement {
948	return w.previewGeometry(y, dim, lit, tiles, overviewPreview{bounds: Rect{W: span, H: w.Usable.H}, selected: sel})
949}
950
951func (w *Workspace) previewGeometry(y int, dim, lit bool, tiles []Placement, preview overviewPreview) []Placement {
952	span, sel := preview.bounds.W, preview.selected
953	u := w.overviewArea()
954	z := w.overviewZoom()
955	scale := func(v int) int { return int(math.Round(float64(v) * z)) }
956	width := scale(span)
957	// Rows stay centred on the output like rows without a stash; the pile
958	// only pushes a row right when they would overlap.
959	x := max(u.X+(u.W-width)/2, u.X+w.pileWidth())
960	left := w.pileWidth()
961	u.X, u.W = u.X+left, u.W-left
962	if width > u.W {
963		x = u.X + u.W/2 - scale(sel.X+sel.W/2)
964		x = min(max(x, u.X+u.W-width), u.X)
965	}
966	top, bottom := y, y+min(scale(preview.bounds.H), u.H*8/10)
967	if w.policy().wraps && preview.bounds.H > w.Usable.H {
968		room := bottom - top
969		offset := room/2 - scale(sel.Y+sel.H/2)
970		y += min(0, max(room-scale(preview.bounds.H), offset))
971	}
972	for i := range tiles {
973		if tiles[i].Hidden {
974			continue
975		}
976		r := tiles[i].Rect
977		x0, y0 := scale(r.X), scale(r.Y)
978		tiles[i].Rect = Rect{X: x + x0, Y: y + y0, W: scale(r.X+r.W) - x0, H: scale(r.Y+r.H) - y0}
979		if w.policy().wraps && (tiles[i].Rect.Y < top || tiles[i].Rect.Y+tiles[i].Rect.H > bottom) {
980			tiles[i].Hidden, tiles[i].Focused, tiles[i].Preview = true, false, 0
981			continue
982		}
983		tiles[i].Preview = z
984		if dim {
985			tiles[i].peeking(1)
986		}
987		tiles[i].Focused = tiles[i].Focused && !dim && lit
988	}
989	return tiles
990}
991
992// overviewOutline frames the selected preview with active lines of width
993// b, just outside it.
994func overviewOutline(layout []Placement, b int) []ports.Separator {
995	for _, p := range layout {
996		if p.Preview > 0 && p.Focused && !p.Hidden {
997			r := p.Rect
998			return []ports.Separator{
999				{Rect: Rect{X: r.X - b, Y: r.Y - b, W: r.W + 2*b, H: b}, Active: true},
1000				{Rect: Rect{X: r.X - b, Y: r.Y + r.H, W: r.W + 2*b, H: b}, Active: true},
1001				{Rect: Rect{X: r.X - b, Y: r.Y, W: b, H: r.H}, Active: true},
1002				{Rect: Rect{X: r.X + r.W, Y: r.Y, W: b, H: r.H}, Active: true},
1003			}
1004		}
1005	}
1006	return nil
1007}
1008
1009// overviewClickFade is the fade from which a card takes no click.
1010const overviewClickFade = 0.5
1011
1012// overviewIn is the preview under the output-local point in layout, if
1013// any. Front cards take priority over peeks, including peeks appended after
1014// a front tile in another row's layout. The click path passes the shown
1015// layout, so a card is hit where it is drawn while it moves. A card that is
1016// nearly invisible (still fading in) is not clickable.
1017func overviewIn(layout []Placement, x, y float64) WindowID {
1018	for i := len(layout) - 1; i >= 0; i-- {
1019		p := layout[i]
1020		r := p.Rect
1021		if p.Preview > 0 && !p.Hidden && !p.Peek && p.Fade < overviewClickFade && x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H) {
1022			return p.ID
1023		}
1024	}
1025	for i := len(layout) - 1; i >= 0; i-- {
1026		p := layout[i]
1027		r := p.Rect
1028		if p.Preview > 0 && !p.Hidden && p.Fade < overviewClickFade && x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H) {
1029			return p.ID
1030		}
1031	}
1032	return 0
1033}
1034
1035// overviewClick closes the overview on the preview under the pointer, on
1036// the output it shows on. picked is false when no preview is there.
1037func (c *Core) overviewClick(ctx context.Context) (picked bool, err error) {
1038	i := c.screenAt(c.cursorX, c.cursorY)
1039	if i < 0 {
1040		return false, nil
1041	}
1042	sc := c.screens[i]
1043	if !sc.mon.ov.open {
1044		return false, nil
1045	}
1046	// The cards are hit where they are drawn: a click during the open or a
1047	// navigation animation picks what moves under the pointer.
1048	id := overviewIn(sc.shownLayout(), c.cursorX-float64(sc.x), c.cursorY-float64(sc.y))
1049	if id == 0 {
1050		return false, nil
1051	}
1052	now := c.now()
1053	shots := c.snapshot(now)
1054	sc.mon.OverviewPick(id)
1055	c.transition(shots, now)
1056	c.focusScreen = i
1057	c.keyboard.takeBack()
1058	if err := c.workspaceVisible(ctx, true); err != nil {
1059		return true, err
1060	}
1061	return true, c.publish(ctx)
1062}
1063
1064// overviewAction runs a bind while the overview is open: focus binds move
1065// the selection, close-window and move-to-workspace binds act on the
1066// selected preview, other mutations are dropped. handled is false for binds
1067// that run as usual.
1068func (m *Monitor) overviewAction(a Action) (e Effect, handled bool) {
1069	if !m.ov.open {
1070		return Effect{}, false
1071	}
1072	if a == ActionCloseWindow {
1073		return Effect{Close: m.overviewTarget()}, true
1074	}
1075	if movesToWorkspace(a) {
1076		m.overviewMoveTo(a)
1077		return Effect{}, true
1078	}
1079	return Effect{}, m.overviewFocus(a) || overviewBlocks(a)
1080}
1081
1082// overviewFocus runs a focus bind as an overview move, so the focus binds
1083// and the bare keys navigate the overview the same way: true when a was
1084// one of them.
1085func (m *Monitor) overviewFocus(a Action) bool {
1086	switch a {
1087	case ActionFocusColumnLeft:
1088		m.OverviewMove(-1, 0)
1089	case ActionFocusColumnRight:
1090		m.OverviewMove(1, 0)
1091	case ActionFocusWindowUp, ActionFocusWorkspaceUp:
1092		m.OverviewMove(0, -1)
1093	case ActionFocusWindowDown, ActionFocusWorkspaceDown:
1094		m.OverviewMove(0, 1)
1095	default:
1096		return false
1097	}
1098	return true
1099}
1100
1101const (
1102	// overviewScrollStep is the scroll distance, in libinput's pointer
1103	// units, that moves the selection one column or workspace.
1104	overviewScrollStep = 60
1105	// overviewScrollRepeat is the three-finger distance of each further
1106	// column step in one scroll: longer than the first, so a short scroll
1107	// reaches the next column exactly and a long one goes on.
1108	overviewScrollRepeat = 150
1109)
1110
1111// overviewScroll moves the overview selection with a scroll frame: a row
1112// step when the vertical scroll reaches overviewScrollStep and leads, else
1113// a column step. Three-finger updates (encoded here as AxisFinger) step
1114// to the next column after
1115// overviewScrollStep, then once per overviewScrollRepeat, and stay on
1116// columns until they lift; up or down they step once per scroll, so a card
1117// in a stack is easy to pick.
1118// Continuous scrolling steps once per overviewScrollStep and a wheel once
1119// per notch (high-resolution wheels add up their fractions of a notch).
1120// Vertical steps select cards first, then rows; natural scroll
1121// (touchpad.* or mouse.*) flips both axes as libinput reports. The end of
1122// a scroll resets it (scrollStop).
1123func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
1124	// The accumulator uses logical content/workspace axes, independent of
1125	// their physical orientation. Stash selection retains its own axes.
1126	if a.Source == ports.AxisFinger && m.Current().policy().wraps && m.cardAt(m.Current()) < 0 {
1127		a.Vertical, a.Horizontal = a.Horizontal, a.Vertical
1128	}
1129	if a.Vertical.Stop || a.Horizontal.Stop {
1130		return false
1131	}
1132	finger := a.Source == ports.AxisFinger
1133	if finger && m.ov.scrolled {
1134		return false
1135	}
1136	// A sideways finger scroll stays on columns; other sources do not, and
1137	// start from zero rather than from its leftover.
1138	sideways := finger && m.ov.sideways
1139	if !finger && m.ov.sideways {
1140		m.ov.scrollX, m.ov.scrollY = 0, 0
1141	}
1142	stepX := float64(overviewScrollStep)
1143	if sideways {
1144		stepX = overviewScrollRepeat
1145	}
1146	add := func(acc *float64, ax ports.ScrollAxis) {
1147		switch {
1148		case !ax.Set:
1149		case a.Source == ports.AxisWheel:
1150			*acc += float64(ax.V120) / 120 * overviewScrollStep
1151		default:
1152			*acc += ax.Value
1153		}
1154	}
1155	if !sideways {
1156		add(&m.ov.scrollY, a.Vertical)
1157	}
1158	add(&m.ov.scrollX, a.Horizontal)
1159	for {
1160		switch {
1161		case math.Abs(m.ov.scrollY) >= overviewScrollStep && math.Abs(m.ov.scrollY) >= math.Abs(m.ov.scrollX):
1162			step := sign(m.ov.scrollY)
1163			axis := m.Current().policy().workspace
1164			m.OverviewMove(0, step)
1165			if axis != m.Current().policy().workspace {
1166				m.ov.scrollX, m.ov.scrollY = 0, 0
1167				m.ov.scrolled = finger
1168				return true
1169			}
1170			m.ov.scrollY -= float64(step) * overviewScrollStep
1171			m.ov.scrollX = 0
1172			if finger {
1173				m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true
1174				return true
1175			}
1176		case math.Abs(m.ov.scrollX) >= stepX:
1177			m.OverviewMove(sign(m.ov.scrollX), 0)
1178			m.ov.scrollX -= float64(sign(m.ov.scrollX)) * stepX
1179			m.ov.scrollY = 0
1180			if finger {
1181				m.ov.sideways, stepX = true, overviewScrollRepeat
1182			}
1183		default:
1184			return changed
1185		}
1186		changed = true
1187	}
1188}
1189
1190// scrollStop ends a scroll on every monitor: the fingers lifted, maybe
1191// after the pointer took the focus to another output, so the next scroll
1192// starts from zero wherever it goes.
1193func (c *Core) scrollStop(a ports.PointerAxis) {
1194	if !a.Vertical.Stop && !a.Horizontal.Stop {
1195		return
1196	}
1197	for _, sc := range c.screens {
1198		sc.mon.ov.scrollX, sc.mon.ov.scrollY, sc.mon.ov.scrolled, sc.mon.ov.sideways = 0, 0, false, false
1199	}
1200}
1201
1202func sign(v float64) int {
1203	if v < 0 {
1204		return -1
1205	}
1206	return 1
1207}
1208
1209// overviewKeyboardTaken reports whether a layer surface (a launcher) or a
1210// grabbing menu has the keyboard: it gets the keys, not the overview.
1211func (c *Core) overviewKeyboardTaken() bool {
1212	id := c.keyboardFocus()
1213	_, _, layer := c.layerOf(id)
1214	return layer || c.popups[id] != nil
1215}
1216
1217// overviewKey runs an overview key: true when the key was one.
1218func (m *Monitor) overviewKey(key ports.KeyEvent) bool {
1219	if key.Mods != 0 {
1220		return false
1221	}
1222	switch keyName(key.Keysym) {
1223	case "h", "Left":
1224		m.OverviewMove(-1, 0)
1225	case "l", "Right":
1226		m.OverviewMove(1, 0)
1227	case "k", "Up":
1228		m.OverviewMove(0, -1)
1229	case "j", "Down":
1230		m.OverviewMove(0, 1)
1231	case "Return", "KP_Enter":
1232		m.ToggleOverview()
1233	case "Escape":
1234		m.CancelOverview()
1235	default:
1236		return false
1237	}
1238	return true
1239}

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