Skip to content

Commit a6c1d3d

Browse files
committed
Merge pull request 'Add TUI scrolling' (#28) from dev/24-tui-scrolling into v0.3.0
Reviewed-on: https://git.cer.sh/Axodouble/QUptime/pulls/28
2 parents ffcf434 + 7991b2a commit a6c1d3d

4 files changed

Lines changed: 112 additions & 71 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
1111
- New documented deployment methods for Tailscale and EdgeVPN, with example `docker-compose.yml` files and wrapper scripts in `docker/tailscale/` and `docker/edgevpn/`.
1212
- New builder command `qu builder`, which generates a standalone HTML alert-template builder
1313

14+
### Changed
15+
16+
- **TUI modals now scroll** when a form is taller than the terminal (e.g. the SMTP "Add alert" form on a short window). The view auto-centres on the focused field and shows `↑/↓ N more` indicators when content is clipped above or below. #24
17+
- TUI main page no longer overflows on very short terminals — the body shrinks all the way down to a single row instead of pinning to 5.
18+
1419
## [v0.2.3] — 2026-05-19
1520

1621
### Changed

internal/tui/forms.go

Lines changed: 82 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -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 }
215220
func (f *form) Init() tea.Cmd { return f.initCmd }
216221

217222
func (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

253315
func (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)

internal/tui/tabs.go

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package tui
22

33
import (
4+
"strconv"
45
"strings"
56
"time"
67

@@ -133,9 +134,9 @@ func (c *checksTab) SelectedName() string {
133134
func (c *checksTab) Refresh(st transport.StatusResponse) {
134135
rows := make([]table.Row, 0, len(st.Checks))
135136
for _, ch := range st.Checks {
136-
okTotal := lipgloss.NewStyle().Render("0/0")
137+
okTotal := "0/0"
137138
if ch.Total > 0 {
138-
okTotal = lipgloss.NewStyle().Render(itoa(ch.OKCount) + "/" + itoa(ch.Total))
139+
okTotal = strconv.Itoa(ch.OKCount) + "/" + strconv.Itoa(ch.Total)
139140
}
140141
alerts := strings.Join(ch.Alerts, ",")
141142
if alerts == "" {
@@ -255,29 +256,6 @@ func livenessText(live bool) string {
255256
return "dead"
256257
}
257258

258-
func itoa(i int) string {
259-
// avoid pulling fmt in the hot path of refresh
260-
if i == 0 {
261-
return "0"
262-
}
263-
neg := i < 0
264-
if neg {
265-
i = -i
266-
}
267-
var buf [20]byte
268-
pos := len(buf)
269-
for i > 0 {
270-
pos--
271-
buf[pos] = byte('0' + i%10)
272-
i /= 10
273-
}
274-
if neg {
275-
pos--
276-
buf[pos] = '-'
277-
}
278-
return string(buf[pos:])
279-
}
280-
281259
func truncate(s string, max int) string {
282260
if max <= 0 || len(s) <= max {
283261
return s

internal/tui/tui.go

Lines changed: 22 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -196,18 +196,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
196196
}
197197

198198
// Pass through to the active tab so j/k/PgUp/PgDn scroll the table.
199-
switch m.active {
200-
case tabPeers:
201-
_, cmd := m.peers.Update(msg)
202-
return m, cmd
203-
case tabChecks:
204-
_, cmd := m.checks.Update(msg)
205-
return m, cmd
206-
case tabAlerts:
207-
_, cmd := m.alertsT.Update(msg)
208-
return m, cmd
209-
}
210-
return m, nil
199+
return m, m.forwardToActiveTab(msg)
211200
}
212201

213202
func (m model) handleKey(km tea.KeyMsg) (tea.Model, tea.Cmd) {
@@ -220,14 +209,8 @@ func (m model) handleKey(km tea.KeyMsg) (tea.Model, tea.Cmd) {
220209
case "shift+tab", "left", "H":
221210
m.active = (m.active + 2) % 3
222211
return m, tea.ClearScreen
223-
case "1":
224-
m.active = tabPeers
225-
return m, tea.ClearScreen
226-
case "2":
227-
m.active = tabChecks
228-
return m, tea.ClearScreen
229-
case "3":
230-
m.active = tabAlerts
212+
case "1", "2", "3":
213+
m.active = tabIndex(km.String()[0] - '1')
231214
return m, tea.ClearScreen
232215
case "r":
233216
m.setFlash("refreshing…", flashInfo)
@@ -253,18 +236,23 @@ func (m model) handleKey(km tea.KeyMsg) (tea.Model, tea.Cmd) {
253236
}
254237

255238
// Forward everything else (arrow keys etc.) to the active tab.
239+
return m, m.forwardToActiveTab(km)
240+
}
241+
242+
// forwardToActiveTab passes msg to whichever tab is currently focused.
243+
// Used for both arbitrary tea.Msg pass-through (mouse, ticks) and for
244+
// keys handleKey didn't claim.
245+
func (m model) forwardToActiveTab(msg tea.Msg) tea.Cmd {
246+
var cmd tea.Cmd
256247
switch m.active {
257248
case tabPeers:
258-
_, cmd := m.peers.Update(km)
259-
return m, cmd
249+
_, cmd = m.peers.Update(msg)
260250
case tabChecks:
261-
_, cmd := m.checks.Update(km)
262-
return m, cmd
251+
_, cmd = m.checks.Update(msg)
263252
case tabAlerts:
264-
_, cmd := m.alertsT.Update(km)
265-
return m, cmd
253+
_, cmd = m.alertsT.Update(msg)
266254
}
267-
return m, nil
255+
return cmd
268256
}
269257

270258
// =============================================================
@@ -700,11 +688,15 @@ func (m *model) setFlash(s string, level flashLevel) {
700688

701689
func (m *model) resizeTabs() {
702690
// Rows consumed outside the body: header (variable), tabs (1),
703-
// body's own rounded border (2), flash (1), help (1).
691+
// body's own rounded border (2), flash (1), help (1). On terminals
692+
// too small to honor the reservation, shrink the body all the way
693+
// down to 1 row rather than letting the page overflow — the table
694+
// will collapse to a single visible row but the rest of the chrome
695+
// stays on screen.
704696
reserved := m.headerHeight() + 5
705697
bodyH := m.height - reserved
706-
if bodyH < 5 {
707-
bodyH = 5
698+
if bodyH < 1 {
699+
bodyH = 1
708700
}
709701
bodyW := m.width - 4
710702
if bodyW < 20 {

0 commit comments

Comments
 (0)