feat/hints a471fa4

cascade.go RawHistoryFolder

1package core
2
3import "slices"
4
5// Cascade bands are explicit: Column.BandStart marks the first column of
6// each band, and moves keep the other bands as they are. A band also ends
7// where it would pass MaxColumns columns (max-columns shrank, or a column
8// was inserted into a full band), and a maximized column sits alone in its
9// band. scroll freezes those ends into BandStart, so the bands stay when
10// max-columns grows again or the column is unmaximized. Cells only share
11// a band's width (laneWidth); they never move a column to a band.
12
13// startsBand reports whether column i starts a band, n being the count of
14// columns in the band before it.
15func (w *Workspace) startsBand(i, n int) bool {
16	if i == 0 {
17		return true
18	}
19	c := w.Columns
20	return c[i].BandStart || n >= max(w.MaxColumns, 1) || c[i].FullWidth || c[i-1].FullWidth
21}
22
23// walkBands calls f with each band and its column range, in order, until f
24// returns false.
25func (w *Workspace) walkBands(f func(b, first, last int) bool) {
26	b, first, n := 0, 0, 0
27	for i := range w.Columns {
28		if i > first && w.startsBand(i, n) {
29			if !f(b, first, i-1) {
30				return
31			}
32			b, first, n = b+1, i, 0
33		}
34		n++
35	}
36	if len(w.Columns) > 0 {
37		f(b, first, len(w.Columns)-1)
38	}
39}
40
41// markBands freezes the band starts into BandStart.
42func (w *Workspace) markBands() {
43	n := 0
44	for i := range w.Columns {
45		s := w.startsBand(i, n)
46		w.Columns[i].BandStart = s
47		if s {
48			n = 0
49		}
50		n++
51	}
52}
53
54// band is the cascade band of column i.
55func (w *Workspace) band(i int) (band int) {
56	w.walkBands(func(b, _, last int) bool {
57		band = b
58		return last < i
59	})
60	return band
61}
62
63// bandBounds is the column range of band b, first > last past the bands.
64func (w *Workspace) bandBounds(b int) (first, last int) {
65	first, last = 0, -1
66	if b < 0 {
67		return first, last
68	}
69	w.walkBands(func(n, f, l int) bool {
70		if n == b {
71			first, last = f, l
72		}
73		return n < b
74	})
75	return first, last
76}
77
78// cellX is the x of column i in its lane, from the usable area's left edge.
79func (w *Workspace) cellX(i int) int {
80	first, last := w.laneBounds(w.laneOf(i))
81	S, u, sum := w.laneStats(first, last)
82	x := w.gap()
83	for j := first; j < i; j++ {
84		x += w.laneWidth(j, first, last, S, u, sum) + w.gap()
85	}
86	return x
87}
88func (w *Workspace) cascadeBands() (n, spacing int) {
89	if len(w.Columns) == 0 || w.Usable.H <= 0 {
90		return 1, 0
91	}
92	return w.band(len(w.Columns)-1) + 1, w.Usable.H
93}
94
95// cellWidth is the width of column i in its lane: the columns of a band
96// (the whole row in fixed overflow) share the usable width by their cells,
97// auto columns sharing what the others leave, so a lone column fills it.
98func (w *Workspace) cellWidth(i int) int {
99	first, last := w.laneBounds(w.laneOf(i))
100	S, u, sum := w.laneStats(first, last)
101	return w.laneWidth(i, first, last, S, u, sum)
102}
103
104// laneStats measures the lane first..last: S is the sum of the cells of
105// its set columns, u the count of its auto columns and sum the total of
106// their weights (laneWeight).
107func (w *Workspace) laneStats(first, last int) (S, u, sum int) {
108	for i := first; i <= last; i++ {
109		if c := w.Columns[i].Cells; c > 0 {
110			S += c
111		} else {
112			u++
113		}
114	}
115	for i := first; i <= last; i++ {
116		sum += w.laneWeight(i, S, u)
117	}
118	return S, u, sum
119}
120
121// laneWeight is the share of column i in its lane, given its stats. With
122// room left for every auto column, they share the cells the set ones leave
123// and the set ones keep their cells; otherwise an auto column weighs one
124// cell.
125func (w *Workspace) laneWeight(i, S, u int) int {
126	k := max(w.MaxColumns, 1)
127	c := w.Columns[i].Cells
128	switch {
129	case u == 0:
130		return c
131	case k-S >= u:
132		if c > 0 {
133			return c * u
134		}
135		return k - S
136	case c > 0:
137		return c
138	}
139	return 1
140}
141
142// laneWidth is the width of column i of the lane first..last with the
143// stats of laneStats: the usable width after gaps, split by weight. The
144// remainder under one pixel per weight stays empty, as in equalShare.
145func (w *Workspace) laneWidth(i, first, last, S, u, sum int) int {
146	if sum <= 0 {
147		return 0
148	}
149	avail := max(w.Usable.W-w.gap()*(last-first+2), 0)
150	// floor(avail*weight/sum) without overflowing on a huge area.
151	weight := w.laneWeight(i, S, u)
152	return avail/sum*weight + avail%sum*weight/sum
153}
154
155// equalShare is the width of each of k columns sharing the usable width
156// after gaps; a remainder under k pixels stays empty.
157func (w *Workspace) equalShare(k int) int {
158	k = max(k, 1)
159	return max((w.Usable.W-w.gap()*(k+1))/k, 0)
160}
161
162// focusBand focuses the column in the band dir away; a maximized column
163// keeps its own band, so it stays maximized.
164func (w *Workspace) focusBand(dir int) bool {
165	i := w.laneNeighbor(dir)
166	if i < 0 {
167		return false
168	}
169	w.Focus = i
170	w.Columns[i].Focus = 0
171	if dir < 0 {
172		w.Columns[i].Focus = len(w.Columns[i].Windows) - 1
173	}
174	w.raiseColumns()
175	w.scroll()
176	return true
177}
178func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
179	g := w.gap()
180	rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
181	view := w.View + w.shiftPixels()
182	w.walkBands(func(lane, first, last int) bool {
183		S, u, sum := w.laneStats(first, last)
184		x := g
185		for i := first; i <= last; i++ {
186			cw := w.laneWidth(i, first, last, S, u, sum)
187			r := Rect{X: w.Usable.X + x, Y: w.Usable.Y + g + lane*w.Usable.H - view, W: cw, H: max(w.Usable.H-2*g, 0)}
188			if w.Columns[i].FullWidth && !ignoreFullWidth {
189				r.X = w.Usable.X + g
190				r.W = max(w.Usable.W-2*g, 0)
191			}
192			rects[i] = r
193			x += cw + g
194		}
195		return true
196	})
197	return rects
198}
199
200func (w *Workspace) cascadePoints() []float64 {
201	n, spacing := w.cascadeBands()
202	points := make([]float64, n)
203	for i := range points {
204		points[i] = float64(i * spacing)
205	}
206	return points
207}
208
209func (w *Workspace) cascadeFocus(view int) int {
210	if len(w.Columns) == 0 {
211		return 0
212	}
213	b := min(max(view/max(w.Usable.H, 1), 0), w.band(len(w.Columns)-1))
214	return w.laneNeighbor(b - w.band(w.Focus))
215}
216
217// laneOf is the lane of column i in the overview: its band, or the only
218// lane of a layout that does not wrap.
219func (w *Workspace) laneOf(i int) int {
220	if w.policy().wraps {
221		return w.band(i)
222	}
223	return 0
224}
225
226// laneBounds is the range of columns of a lane, first > last when the lane
227// does not exist. A layout that does not wrap has the whole row as lane 0.
228func (w *Workspace) laneBounds(lane int) (first, last int) {
229	if w.policy().wraps {
230		return w.bandBounds(lane)
231	}
232	if lane != 0 || len(w.Columns) == 0 {
233		return 0, -1
234	}
235	return 0, len(w.Columns) - 1
236}
237
238// leavesBand reports whether column i, where a left/right move from the
239// focused column goes, is past the workspace or in another cascade band:
240// the move then goes to the neighbor monitor first.
241func (w *Workspace) leavesBand(i int) bool {
242	return i < 0 || i >= len(w.Columns) || w.policy().wraps && w.band(i) != w.band(w.Focus)
243}
244
245// moveBand moves the focused cascade column to the band dir away
246// (move-window-up/down past its column), keeping its offset in the band;
247// focus follows it. See shiftBand.
248func (w *Workspace) moveBand(dir int) {
249	if !w.policy().wraps || len(w.Columns) == 0 {
250		return
251	}
252	first, _ := w.bandBounds(w.band(w.Focus))
253	if w.shiftBand(w.Focus, w.Focus, dir, w.Focus-first) {
254		w.scroll()
255	}
256}
257
258// laneNeighbor is the closest column to the focus in the lane d lanes away,
259// the focus's offset in its lane kept; -1 past the first or last lane.
260func (w *Workspace) laneNeighbor(d int) int {
261	lane := w.laneOf(w.Focus)
262	first, _ := w.laneBounds(lane)
263	nfirst, nlast := w.laneBounds(lane + d)
264	if nfirst > nlast {
265		return -1
266	}
267	return min(nfirst+w.Focus-first, nlast)
268}
269
270// bandList copies the cascade columns into their bands.
271func (w *Workspace) bandList() [][]Column {
272	var bands [][]Column
273	w.walkBands(func(_, first, last int) bool {
274		bands = append(bands, slices.Clone(w.Columns[first:last+1]))
275		return true
276	})
277	return bands
278}
279
280// setBands stores bands as the columns, empty bands dropped, and focuses
281// the column holding window focus.
282func (w *Workspace) setBands(bands [][]Column, focus WindowID) {
283	cols := w.Columns[:0]
284	for _, b := range bands {
285		for i, c := range b {
286			c.BandStart = i == 0
287			cols = append(cols, c)
288		}
289	}
290	w.Columns = cols
291	if i := w.columnOf(focus); i >= 0 {
292		w.Focus = i
293	}
294}
295
296// fits reports whether cols can join band b: it has room for them and
297// neither side holds a maximized column, which keeps a band to itself.
298func (w *Workspace) fits(bands [][]Column, b int, cols []Column) bool {
299	if b < 0 || b >= len(bands) || len(bands[b])+len(cols) > max(w.MaxColumns, 1) {
300		return false
301	}
302	full := func(c Column) bool { return c.FullWidth }
303	return !slices.ContainsFunc(bands[b], full) && !slices.ContainsFunc(cols, full)
304}
305
306// sendBand moves cols, already taken out of band b, to the band dir away:
307// they join it at index at (clamped) when they fit, else they make a new
308// band between the two, or past the outer band. When cols were alone in
309// band b, it goes away and a band that cannot take them is passed: the
310// new band goes on its far side, so repeated moves always progress.
311func (w *Workspace) sendBand(bands [][]Column, b, dir, at int, cols []Column) [][]Column {
312	t, nb := b+dir, b // A new band between b and t, before b going up.
313	if dir > 0 {
314		nb = b + 1
315	}
316	if len(bands[b]) == 0 {
317		bands = slices.Delete(bands, b, b+1)
318		t, nb = b-1, b-1 // Going up: before band t.
319		if dir > 0 {
320			t, nb = b, b+1 // Going down: after band t.
321		}
322	}
323	if w.fits(bands, t, cols) {
324		at = min(max(at, 0), len(bands[t]))
325		bands[t] = slices.Insert(bands[t], at, cols...)
326		return bands
327	}
328	return slices.Insert(bands, min(max(nb, 0), len(bands)), slices.Clone(cols))
329}
330
331// shiftBand moves the columns lo..hi of one cascade band to the band dir
332// away (see sendBand), at offset at in it. It reports whether they moved.
333func (w *Workspace) shiftBand(lo, hi, dir, at int) bool {
334	if len(w.Columns) == 0 || lo < 0 || hi >= len(w.Columns) || lo > hi {
335		return false
336	}
337	b := w.band(lo)
338	if w.band(hi) != b {
339		return false
340	}
341	first, last := w.bandBounds(b)
342	if lo == first && hi == last && (b+dir < 0 || b+dir > w.band(len(w.Columns)-1)) {
343		return false // Alone at the outer band: nothing to cross.
344	}
345	id, _ := w.Focused()
346	bands := w.bandList()
347	cols := slices.Clone(bands[b][lo-first : hi-first+1])
348	bands[b] = slices.Delete(bands[b], lo-first, hi-first+1)
349	w.setBands(w.sendBand(bands, b, dir, at, cols), id)
350	return true
351}
352
353// columnIDs lists the windows of each column, in order: its identity for
354// restoreBand. The column is the one headed by the first of them still
355// heading a column, so it is found after its first window closed or its
356// stack changed order, and a window that left it for a column of its own
357// comes after the one that stayed first.
358func columnIDs(cols []Column) [][]WindowID {
359	ids := make([][]WindowID, len(cols))
360	for i, c := range cols {
361		ids[i] = slices.Clone(c.Windows)
362	}
363	return ids
364}
365
366// isolateBand gives the focused cascade column a band of its own: the
367// columns on its left go to the band above, the ones on its right to the
368// band below (sendBand), and the column remembers them for restoreBand.
369func (w *Workspace) isolateBand() {
370	b := w.band(w.Focus)
371	first, last := w.bandBounds(b)
372	if first == last {
373		return
374	}
375	id, _ := w.Focused()
376	bands := w.bandList()
377	p := w.Focus - first
378	left := slices.Clone(bands[b][:p])
379	right := slices.Clone(bands[b][p+1:])
380	c := bands[b][p]
381	// Both lists keep the band's left-to-right order. Earlier pushes
382	// (cycle-column-width) took the far ends of the band, so the new ones
383	// were nearer the column.
384	c.pushedUp = append(c.pushedUp, columnIDs(left)...)
385	c.pushedDown = append(columnIDs(right), c.pushedDown...)
386	bands[b] = []Column{c}
387	if len(right) > 0 {
388		bands = w.sendBand(bands, b, 1, 0, right)
389	}
390	if len(left) > 0 {
391		bands = w.sendBand(bands, b, -1, max(w.MaxColumns, 1), left)
392	}
393	w.setBands(bands, id)
394}
395
396// pushNeighbor makes room in the focused column's full cascade band for a
397// wider column: the column at the far end of the band leaves it. The
398// rightmost goes to the band below; from the last column, the leftmost
399// goes to the band above, at its right end. The column remembers it.
400func (w *Workspace) pushNeighbor() {
401	first, last := w.bandBounds(w.band(w.Focus))
402	if first == last {
403		return
404	}
405	id, _ := w.Focused()
406	c := &w.Columns[w.Focus]
407	if w.Focus < last {
408		c.pushedDown = append(c.pushedDown, columnIDs(w.Columns[last:last+1])...)
409		w.shiftBand(last, last, 1, 0)
410	} else {
411		c.pushedUp = append(columnIDs(w.Columns[first:first+1]), c.pushedUp...)
412		w.shiftBand(first, first, -1, max(w.MaxColumns, 1))
413	}
414	w.Focus = w.columnOf(id)
415}
416
417// restoreBand brings back the columns isolateBand or pushNeighbor sent
418// away, those still in the adjacent band, as many as the band has room
419// for, nearest to the focused column first: the ones sent up at the start
420// of the band, the ones sent down at its end, in their former order. The
421// others stay where they are. It reports whether the column remembered
422// any.
423func (w *Workspace) restoreBand() bool {
424	c := &w.Columns[w.Focus]
425	up, down := c.pushedUp, c.pushedDown
426	c.pushedUp, c.pushedDown = nil, nil
427	if len(up) == 0 && len(down) == 0 {
428		return false
429	}
430	id, _ := w.Focused()
431	bands := w.bandList()
432	b := w.band(w.Focus)
433	room := max(max(w.MaxColumns, 1)-len(bands[b]), 0)
434	// take removes from band nb the remembered columns found there, at
435	// most room of them, in the order of ids walked from the focused
436	// column outward (rev: ids end nearest).
437	take := func(nb int, ids [][]WindowID, rev bool) []Column {
438		if nb < 0 || nb >= len(bands) {
439			return nil
440		}
441		var got []Column
442		for k := range ids {
443			if room == 0 {
444				break
445			}
446			e := ids[k]
447			if rev {
448				e = ids[len(ids)-1-k]
449			}
450			// The column is named across the workspace first: one that
451			// moved farther away stays there, with no stand-in.
452			head := slices.IndexFunc(e, func(v WindowID) bool {
453				return slices.ContainsFunc(w.Columns, func(c Column) bool { return c.Windows[0] == v })
454			})
455			if head < 0 {
456				continue
457			}
458			i := slices.IndexFunc(bands[nb], func(c Column) bool { return c.Windows[0] == e[head] })
459			if i < 0 {
460				continue
461			}
462			got = append(got, bands[nb][i])
463			bands[nb] = slices.Delete(bands[nb], i, i+1)
464			room--
465		}
466		if rev {
467			slices.Reverse(got)
468		}
469		return got
470	}
471	above := take(b-1, up, true)
472	below := take(b+1, down, false)
473	bands[b] = slices.Concat(above, bands[b], below)
474	w.setBands(bands, id)
475	return true
476}
477
478// spawnIndex is where a new cascade column goes: after the focused band,
479// which it joins when it has room; otherwise it starts a band of its own
480// between it and the next (newBand).
481func (w *Workspace) spawnIndex() (at int, newBand bool) {
482	if len(w.Columns) == 0 {
483		return 0, true
484	}
485	b := w.band(w.Focus)
486	first, last := w.bandBounds(b)
487	bands := [][]Column{w.Columns[first : last+1]}
488	return last + 1, !w.fits(bands, 0, []Column{{}})
489}
490
491// deleteColumn removes column i; a band it started keeps its start.
492func (w *Workspace) deleteColumn(i int) {
493	if w.policy().wraps && w.Columns[i].BandStart && i+1 < len(w.Columns) {
494		w.Columns[i+1].BandStart = true
495	}
496	w.Columns = slices.Delete(w.Columns, i, i+1)
497}
498
499// forgetBands drops the cascade band starts and pushed columns: the bands
500// pack again in column order.
501func (w *Workspace) forgetBands() {
502	for i := range w.Columns {
503		c := &w.Columns[i]
504		c.BandStart, c.pushedUp, c.pushedDown = false, nil, nil
505	}
506}

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