@@ -108,6 +108,7 @@ type form struct {
108108 busy bool
109109 err string
110110 width int // current terminal width; inputs resize to fill it
111+ height int // available content height inside the modal chrome
111112
112113 // initCmd is the blink Cmd produced by focusing the first field at
113114 // construction time. The parent dispatches it via Init() so the
@@ -117,6 +118,10 @@ type form struct {
117118 submit func (values []string ) tea.Cmd
118119}
119120
121+ // modalChromeHeight is the number of vertical rows modalStyle eats
122+ // around the inner content: 2 border (top+bottom) + 2 padding (top+bottom).
123+ const modalChromeHeight = 4
124+
120125// defaultFieldWidth is the fallback input width used before the first
121126// WindowSizeMsg has arrived. Once we know the terminal size, inputs
122127// grow to fill the available horizontal space.
@@ -215,45 +220,106 @@ func (f *form) Title() string { return f.title }
215220func (f * form ) Init () tea.Cmd { return f .initCmd }
216221
217222func (f * form ) View () string {
218- var b strings.Builder
219- fmt .Fprintf (& b , "%s\n \n " , titleStyle .Render (f .title ))
223+ lines , focusStart , focusEnd := f .renderLines ()
224+ if f .height > 0 && len (lines ) > f .height {
225+ lines = clipAroundFocus (lines , focusStart , focusEnd , f .height )
226+ }
227+ return strings .Join (lines , "\n " )
228+ }
229+
230+ // renderLines builds the full form view as a slice of terminal rows and
231+ // reports the [start, end) line range covered by the focused field, so
232+ // View() can window around it when the form is taller than the modal.
233+ func (f * form ) renderLines () (lines []string , focusStart , focusEnd int ) {
234+ focusStart , focusEnd = - 1 , - 1
235+ lines = append (lines , strings .Split (titleStyle .Render (f .title ), "\n " )... )
236+ lines = append (lines , "" )
220237 for i , fld := range f .fields {
238+ start := len (lines )
221239 marker := " "
222240 labelStyle := subtleStyle
223241 if i == f .cursor {
224242 marker = "▸ "
225243 labelStyle = lipgloss .NewStyle ().Foreground (colorAccent ).Bold (true )
226244 }
227- fmt .Fprintf (& b , "%s%s\n " , marker , labelStyle .Render (fld .label ))
245+ lines = append (lines , marker + labelStyle .Render (fld .label ))
246+ body := " " + fld .input .View ()
228247 if fld .multiline {
229- fmt .Fprintf (& b , "%s\n " , fld .textarea .View ())
230- } else {
231- fmt .Fprintf (& b , " %s\n " , fld .input .View ())
248+ body = fld .textarea .View ()
232249 }
250+ lines = append (lines , strings .Split (body , "\n " )... )
233251 if i == f .cursor && fld .hint != "" {
234- fmt . Fprintf ( & b , " %s \n " , helpStyle .Render (fld .hint ))
252+ lines = append ( lines , " " + helpStyle .Render (fld .hint ))
235253 }
236- b .WriteByte ('\n' )
254+ if i == f .cursor {
255+ focusStart , focusEnd = start , len (lines )
256+ }
257+ lines = append (lines , "" )
237258 }
238259 if f .err != "" {
239- fmt .Fprintf (& b , "%s\n \n " , flashErrorStyle .Render ("error: " + f .err ))
260+ lines = append (lines , flashErrorStyle .Render ("error: " + f .err ), "" )
261+ }
262+ help := "↑↓ field enter next/submit esc cancel"
263+ if f .cursor < len (f .fields ) && f .fields [f .cursor ].multiline {
264+ help = "tab field enter newline shift+enter/ctrl+s submit esc cancel"
240265 }
241266 if f .busy {
242- fmt . Fprintf ( & b , "%s \n " , flashWarnStyle .Render ("working…" ))
267+ lines = append ( lines , flashWarnStyle .Render ("working…" ))
243268 } else {
244- help := "↑↓ field enter next/submit esc cancel"
245- if f .cursor < len (f .fields ) && f .fields [f .cursor ].multiline {
246- help = "tab field enter newline shift+enter/ctrl+s submit esc cancel"
247- }
248- fmt .Fprintf (& b , "%s\n " , helpStyle .Render (help ))
269+ lines = append (lines , helpStyle .Render (help ))
249270 }
250- return b .String ()
271+ return
272+ }
273+
274+ // clipAroundFocus returns a maxH-tall window of lines that includes the
275+ // [focusStart, focusEnd) range. When there is content above or below the
276+ // window, the boundary lines are replaced with "↑ N more" / "↓ N more"
277+ // indicators — unless doing so would hide the focused range, in which
278+ // case the indicator is skipped to keep the cursor visible.
279+ func clipAroundFocus (lines []string , focusStart , focusEnd , maxH int ) []string {
280+ n := len (lines )
281+ if maxH <= 0 || n <= maxH {
282+ return lines
283+ }
284+ if focusStart < 0 {
285+ focusStart , focusEnd = 0 , 1
286+ }
287+ focusH := focusEnd - focusStart
288+ if focusH < 1 {
289+ focusH = 1
290+ }
291+ // When the focused field is taller than the available window, pin
292+ // the start to the field's label rather than centering — the label
293+ // is the most important thing to keep on screen.
294+ start := focusStart
295+ if focusH < maxH {
296+ start = focusStart - (maxH - focusH )/ 2
297+ }
298+ if start + maxH > n {
299+ start = n - maxH
300+ }
301+ if start < 0 {
302+ start = 0
303+ }
304+ end := start + maxH
305+ out := append ([]string (nil ), lines [start :end ]... )
306+ if start > 0 && focusStart > start {
307+ out [0 ] = subtleStyle .Render (fmt .Sprintf ("↑ %d more above" , start ))
308+ }
309+ if end < n && focusEnd <= end - 1 {
310+ out [len (out )- 1 ] = subtleStyle .Render (fmt .Sprintf ("↓ %d more below" , n - end ))
311+ }
312+ return out
251313}
252314
253315func (f * form ) Update (msg tea.Msg ) (modal , tea.Cmd ) {
254316 switch msg := msg .(type ) {
255317 case tea.WindowSizeMsg :
256318 f .width = msg .Width
319+ f .height = msg .Height - modalChromeHeight
320+ if f .height < 1 {
321+ f .height = 1
322+ }
257323 w := fieldWidthFor (msg .Width )
258324 for i := range f .fields {
259325 f .fields [i ].setWidth (w )
0 commit comments