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}