|
1 | 1 | package dialog |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "strings" |
| 5 | + |
4 | 6 | "charm.land/bubbles/v2/key" |
5 | 7 | tea "charm.land/bubbletea/v2" |
6 | 8 | "charm.land/lipgloss/v2" |
@@ -36,6 +38,12 @@ type readOnlyScrollDialog struct { |
36 | 38 | helpKeys []string // pairs of [key, description] for the footer |
37 | 39 | } |
38 | 40 |
|
| 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 | + |
39 | 47 | // newReadOnlyScrollDialog creates a new read-only scrollable dialog. |
40 | 48 | func newReadOnlyScrollDialog( |
41 | 49 | size readOnlyScrollDialogSize, |
@@ -75,37 +83,68 @@ func (d *readOnlyScrollDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { |
75 | 83 | return d, nil |
76 | 84 | } |
77 | 85 |
|
78 | | -func (d *readOnlyScrollDialog) dialogSize() (dialogWidth, maxHeight, contentWidth int) { |
| 86 | +func (d *readOnlyScrollDialog) dialogWidth() (dialogWidth, contentWidth int) { |
79 | 87 | s := d.size |
80 | 88 | dialogWidth = d.ComputeDialogWidth(s.widthPercent, s.minWidth, s.maxWidth) |
81 | | - maxHeight = min(d.Height()*s.heightPercent/100, s.heightMax) |
82 | 89 | 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) |
84 | 106 | } |
85 | 107 |
|
86 | 108 | 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) |
89 | 114 | } |
90 | 115 |
|
91 | 116 | 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) |
94 | 120 |
|
95 | 121 | const headerLines = 3 // title + separator + space |
96 | 122 | contentLines := allLines[headerLines:] |
97 | 123 |
|
| 124 | + // Viewport: show all content if it fits, otherwise cap at maxViewport. |
| 125 | + viewport := min(len(contentLines), maxViewport) |
| 126 | + |
98 | 127 | 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) |
101 | 129 |
|
102 | 130 | dialogRow, dialogCol := d.Position() |
103 | 131 | d.scrollview.SetPosition(dialogCol+3, dialogRow+2+headerLines) |
104 | 132 | d.scrollview.SetContent(contentLines, len(contentLines)) |
105 | 133 |
|
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...) |
107 | 145 | parts = append(parts, "", RenderHelpKeys(regionWidth, d.helpKeys...)) |
108 | 146 |
|
| 147 | + height := d.dialogHeight(len(contentLines)) |
109 | 148 | 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) |
111 | 150 | } |
0 commit comments