v0.21.0 cd9706d

floating_pane.go RawHistoryFolder

1package tui
2
3import "strings"
4
5// FloatingPane is a stateful viewport for an independent TUI view. It keeps
6// its scroll position while the same view is active, follows a focused row
7// into view, and composes that view over a live background frame.
8//
9// A FloatingPane is deliberately frontend-neutral: callers own their view's
10// data and key handling, while the pane owns geometry, clipping, and visual
11// composition.
12type FloatingPane struct {
13	id     string
14	scroll int
15}
16
17// FloatingPaneRect describes a pane's outer border in zero-based terminal
18// coordinates. Content occupies the rectangle inside that border.
19type FloatingPaneRect struct {
20	X, Y          int
21	Width, Height int
22	Drawer        bool
23	Borderless    bool
24}
25
26func (r FloatingPaneRect) ContentWidth() int {
27	if r.Drawer || r.Borderless {
28		return max(1, r.Width)
29	}
30	return max(1, r.Width-2)
31}
32
33func (r FloatingPaneRect) ContentHeight() int {
34	if r.Borderless {
35		return max(1, r.Height)
36	}
37	if r.Drawer {
38		return max(1, r.Height-1)
39	}
40	return max(1, r.Height-2)
41}
42
43func (r FloatingPaneRect) contentX() int {
44	if r.Drawer || r.Borderless {
45		return r.X
46	}
47	return r.X + 1
48}
49
50func (r FloatingPaneRect) contentY() int {
51	if r.Borderless {
52		return r.Y
53	}
54	return r.Y + 1
55}
56
57// FloatingPaneMaxRect returns the largest pane for the current terminal. The
58// final pane is shortened to its content by Compose. Below 80 columns it
59// becomes a full-width bottom drawer so dialogs remain readable and usable.
60func FloatingPaneMaxRect(cols, rows int) FloatingPaneRect {
61	if cols < 1 || rows < 1 {
62		return FloatingPaneRect{}
63	}
64	height := max(1, rows*4/5)
65	if rows >= 5 {
66		height = max(5, height)
67	}
68	if height > rows {
69		height = rows
70	}
71	if cols < 80 {
72		return FloatingPaneRect{Y: rows - height, Width: cols, Height: height, Drawer: true}
73	}
74	width := max(6, cols*4/5)
75	if width > cols {
76		width = cols
77	}
78	return FloatingPaneRect{
79		X:      (cols - width) / 2,
80		Y:      (rows - height) / 2,
81		Width:  width,
82		Height: height,
83	}
84}
85
86// Reset discards the viewport position. Callers normally do not need this:
87// Compose resets it automatically when the view identity changes.
88func (p *FloatingPane) Reset() {
89	if p == nil {
90		return
91	}
92	p.id = ""
93	p.scroll = 0
94}
95
96// FloatingPaneFrame contains the dimmed background and foreground rows to
97// paint at absolute terminal positions. Renderer.DrawFloating consumes it.
98type FloatingPaneFrame struct {
99	Background []string
100	Lines      []string
101	Rect       FloatingPaneRect
102}
103
104// Compose returns a complete floating-pane frame. title is centered in the
105// pane border. contentCursorRow is relative to content and is used both to
106// retain the pane's own viewport and to return an absolute terminal cursor
107// row. Pass -1 when the foreground has no text cursor.
108func (p *FloatingPane) Compose(theme Theme, id, title string, background, content []string, cols, rows, contentCursorRow, contentCursorCol int) (pane FloatingPaneFrame, cursorRow, cursorCol int) {
109	pane.Background = DimLines(fitFloatingBackground(background, cols, rows))
110	if cols < 1 || rows < 1 {
111		return pane, -1, 0
112	}
113	if p == nil {
114		p = &FloatingPane{}
115	}
116	if p.id != id {
117		p.id = id
118		p.scroll = 0
119	}
120
121	pane.Rect = FloatingPaneMaxRect(cols, rows)
122	chromeRows := 2
123	if pane.Rect.Drawer {
124		chromeRows = 1
125	}
126	if len(content)+chromeRows < pane.Rect.Height {
127		pane.Rect.Height = max(1, len(content)+chromeRows)
128		if pane.Rect.Drawer {
129			pane.Rect.Y = rows - pane.Rect.Height
130		} else {
131			pane.Rect.Y = (rows - pane.Rect.Height) / 2
132		}
133	}
134	tooSmall := pane.Rect.Width < 3 || (!pane.Rect.Drawer && pane.Rect.Height < 3) || (pane.Rect.Drawer && pane.Rect.Height < 2)
135	if tooSmall {
136		// There is no room for a reliable border. Keep the foreground usable
137		// on a tiny terminal by treating the entire screen as its viewport.
138		pane.Rect = FloatingPaneRect{Width: cols, Height: rows, Borderless: true}
139	}
140
141	viewportRows := pane.Rect.ContentHeight()
142	p.follow(contentCursorRow, len(content), viewportRows)
143	content = p.viewport(content, viewportRows)
144	pane.Lines = floatingPaneLines(theme, pane.Rect, title, content)
145
146	if contentCursorRow >= p.scroll && contentCursorRow < p.scroll+viewportRows {
147		cursorRow = pane.Rect.contentY() + contentCursorRow - p.scroll
148		cursorCol = pane.Rect.contentX() + max(0, contentCursorCol)
149		cursorCol = min(cursorCol, pane.Rect.contentX()+pane.Rect.ContentWidth()-1)
150		cursorCol = min(cursorCol, cols-1)
151		return pane, cursorRow, cursorCol
152	}
153	return pane, -1, 0
154}
155
156func (p *FloatingPane) follow(focusRow, contentRows, viewportRows int) {
157	if focusRow < 0 || viewportRows < 1 {
158		p.scroll = clampFloatingScroll(p.scroll, contentRows, viewportRows)
159		return
160	}
161	if focusRow < p.scroll {
162		p.scroll = focusRow
163	} else if focusRow >= p.scroll+viewportRows {
164		p.scroll = focusRow - viewportRows + 1
165	}
166	p.scroll = clampFloatingScroll(p.scroll, contentRows, viewportRows)
167}
168
169func (p *FloatingPane) viewport(content []string, rows int) []string {
170	if rows < 1 {
171		return nil
172	}
173	p.scroll = clampFloatingScroll(p.scroll, len(content), rows)
174	end := min(len(content), p.scroll+rows)
175	return content[p.scroll:end]
176}
177
178func clampFloatingScroll(scroll, contentRows, viewportRows int) int {
179	if scroll < 0 {
180		return 0
181	}
182	if maxScroll := max(0, contentRows-viewportRows); scroll > maxScroll {
183		return maxScroll
184	}
185	return scroll
186}
187
188func fitFloatingBackground(background []string, cols, rows int) []string {
189	frame := make([]string, rows)
190	if cols < 1 || rows < 1 {
191		return frame
192	}
193	if len(background) > rows {
194		background = background[len(background)-rows:]
195	}
196	start := rows - len(background)
197	for row, line := range background {
198		frame[start+row] = truncateToWidth(line, cols)
199	}
200	return frame
201}
202
203func floatingPaneLines(theme Theme, rect FloatingPaneRect, title string, content []string) []string {
204	if rect.Width < 1 || rect.Height < 1 {
205		return nil
206	}
207	tooSmall := rect.Width < 3 || (!rect.Drawer && rect.Height < 3) || (rect.Drawer && rect.Height < 2)
208	if tooSmall {
209		lines := make([]string, rect.Height)
210		copy(lines, content)
211		return lines
212	}
213	lines := make([]string, rect.Height)
214	if rect.Drawer {
215		lines[0] = floatingPaneTitle(theme, title, rect.Width, false)
216		for row := 0; row < rect.ContentHeight(); row++ {
217			line := ""
218			if row < len(content) {
219				line = truncateToWidth(content[row], rect.ContentWidth())
220			}
221			if width := visibleWidth(line); width < rect.ContentWidth() {
222				line += strings.Repeat(" ", rect.ContentWidth()-width)
223			}
224			lines[row+1] = line
225		}
226		return lines
227	}
228	border := theme.FGColor(theme.Muted, "│")
229	lines[0] = floatingPaneTitle(theme, title, rect.Width, true)
230	lines[len(lines)-1] = theme.FGColor(theme.Muted, "╰"+strings.Repeat("─", rect.Width-2)+"╯")
231	for row := 0; row < rect.ContentHeight(); row++ {
232		line := ""
233		if row < len(content) {
234			line = truncateToWidth(content[row], rect.ContentWidth())
235		}
236		if width := visibleWidth(line); width < rect.ContentWidth() {
237			line += strings.Repeat(" ", rect.ContentWidth()-width)
238		}
239		lines[row+1] = border + line + border
240	}
241	return lines
242}
243
244func floatingPaneTitle(theme Theme, title string, width int, boxed bool) string {
245	if width < 1 {
246		return ""
247	}
248	interior := width
249	left, right := "", ""
250	if boxed {
251		if width < 3 {
252			return theme.FGColor(theme.Muted, strings.Repeat("─", width))
253		}
254		interior -= 2
255		left, right = "╭", "╮"
256	}
257	label := ""
258	if title != "" {
259		label = " " + truncateToWidth(title, max(1, interior-2)) + " "
260	}
261	labelWidth := visibleWidth(label)
262	if labelWidth > interior {
263		label = truncateToWidth(label, interior)
264		labelWidth = visibleWidth(label)
265	}
266	before := max(0, (interior-labelWidth)/2)
267	after := max(0, interior-labelWidth-before)
268	return theme.FGColor(theme.Muted, left+strings.Repeat("─", before)+label+strings.Repeat("─", after)+right)
269}

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 1

Tags 476

↑↓ moveenter openesc close