Skip to content

Commit 9de6070

Browse files
committed
Fix tools/permissions dialog height instability when scrolling
Size dialog to content (capped by max), pad scrollview output to exact viewport height, and pin with Height+MaxHeight to prevent layout shifts. Assisted-By: docker-agent
1 parent 39d9c66 commit 9de6070

1 file changed

Lines changed: 50 additions & 11 deletions

File tree

pkg/tui/dialog/readonly_scroll_dialog.go

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package dialog
22

33
import (
4+
"strings"
5+
46
"charm.land/bubbles/v2/key"
57
tea "charm.land/bubbletea/v2"
68
"charm.land/lipgloss/v2"
@@ -36,6 +38,12 @@ type readOnlyScrollDialog struct {
3638
helpKeys []string // pairs of [key, description] for the footer
3739
}
3840

41+
// Dialog chrome: border (top+bottom=2) + padding (top+bottom=2).
42+
const dialogChrome = 4
43+
44+
// Fixed lines outside the scrollable region: header (title + separator + space) + footer (space + help).
45+
const fixedLines = 5
46+
3947
// newReadOnlyScrollDialog creates a new read-only scrollable dialog.
4048
func newReadOnlyScrollDialog(
4149
size readOnlyScrollDialogSize,
@@ -75,37 +83,68 @@ func (d *readOnlyScrollDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
7583
return d, nil
7684
}
7785

78-
func (d *readOnlyScrollDialog) dialogSize() (dialogWidth, maxHeight, contentWidth int) {
86+
func (d *readOnlyScrollDialog) dialogWidth() (dialogWidth, contentWidth int) {
7987
s := d.size
8088
dialogWidth = d.ComputeDialogWidth(s.widthPercent, s.minWidth, s.maxWidth)
81-
maxHeight = min(d.Height()*s.heightPercent/100, s.heightMax)
8289
contentWidth = d.ContentWidth(dialogWidth, 2) - d.scrollview.ReservedCols()
83-
return dialogWidth, maxHeight, contentWidth
90+
return dialogWidth, contentWidth
91+
}
92+
93+
// maxViewport returns the maximum number of scrollable lines that fit.
94+
func (d *readOnlyScrollDialog) maxViewport() int {
95+
s := d.size
96+
maxHeight := min(d.Height()*s.heightPercent/100, s.heightMax)
97+
return max(1, maxHeight-fixedLines-dialogChrome)
98+
}
99+
100+
// dialogHeight computes the actual dialog height based on content and viewport.
101+
func (d *readOnlyScrollDialog) dialogHeight(contentLineCount int) int {
102+
s := d.size
103+
maxHeight := min(d.Height()*s.heightPercent/100, s.heightMax)
104+
needed := contentLineCount + fixedLines + dialogChrome
105+
return min(needed, maxHeight)
84106
}
85107

86108
func (d *readOnlyScrollDialog) Position() (row, col int) {
87-
dialogWidth, maxHeight, _ := d.dialogSize()
88-
return CenterPosition(d.Width(), d.Height(), dialogWidth, maxHeight)
109+
dw, _ := d.dialogWidth()
110+
// Use max possible height for stable centering.
111+
s := d.size
112+
maxHeight := min(d.Height()*s.heightPercent/100, s.heightMax)
113+
return CenterPosition(d.Width(), d.Height(), dw, maxHeight)
89114
}
90115

91116
func (d *readOnlyScrollDialog) View() string {
92-
dialogWidth, maxHeight, contentWidth := d.dialogSize()
93-
allLines := d.render(contentWidth, maxHeight)
117+
dialogWidth, contentWidth := d.dialogWidth()
118+
maxViewport := d.maxViewport()
119+
allLines := d.render(contentWidth, maxViewport)
94120

95121
const headerLines = 3 // title + separator + space
96122
contentLines := allLines[headerLines:]
97123

124+
// Viewport: show all content if it fits, otherwise cap at maxViewport.
125+
viewport := min(len(contentLines), maxViewport)
126+
98127
regionWidth := contentWidth + d.scrollview.ReservedCols()
99-
visibleLines := max(1, maxHeight-headerLines-2-4) // 2 = footer (space + help), 4 = dialog chrome
100-
d.scrollview.SetSize(regionWidth, visibleLines)
128+
d.scrollview.SetSize(regionWidth, viewport)
101129

102130
dialogRow, dialogCol := d.Position()
103131
d.scrollview.SetPosition(dialogCol+3, dialogRow+2+headerLines)
104132
d.scrollview.SetContent(contentLines, len(contentLines))
105133

106-
parts := append(allLines[:headerLines], d.scrollview.View())
134+
// Use ViewWithLines to guarantee exactly `viewport` lines of output.
135+
scrollOut := d.scrollview.View()
136+
scrollOutLines := strings.Split(scrollOut, "\n")
137+
for len(scrollOutLines) < viewport {
138+
scrollOutLines = append(scrollOutLines, "")
139+
}
140+
scrollOutLines = scrollOutLines[:viewport]
141+
142+
parts := make([]string, 0, headerLines+viewport+2)
143+
parts = append(parts, allLines[:headerLines]...)
144+
parts = append(parts, scrollOutLines...)
107145
parts = append(parts, "", RenderHelpKeys(regionWidth, d.helpKeys...))
108146

147+
height := d.dialogHeight(len(contentLines))
109148
content := lipgloss.JoinVertical(lipgloss.Left, parts...)
110-
return styles.DialogStyle.Padding(1, 2).Width(dialogWidth).Render(content)
149+
return styles.DialogStyle.Padding(1, 2).Width(dialogWidth).Height(height).MaxHeight(height).Render(content)
111150
}

0 commit comments

Comments
 (0)