main f2d166b

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

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