feat(tui): pin detail panel footer keys at bottom of sidebar
CI / test (pull_request) Successful in 1m53s
CI / lint (pull_request) Successful in 1m17s
CI / vulncheck (pull_request) Successful in 1m1s

Footer keys (period selector, edit/history/SLA shortcuts, Esc) are now
pinned at the bottom of the detail panel, always visible regardless of
scroll position. Scrollable content fills the space between the header
and the pinned footer.
This commit is contained in:
2026-06-30 20:46:43 -04:00
parent 631f07c242
commit 1d14f640f4
3 changed files with 48 additions and 23 deletions
+19 -5
View File
@@ -6,7 +6,7 @@ import (
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
func (m Model) titledPanelH(title, content string, width, height, scrollOffset int, focused bool) string { func (m Model) titledPanelH(title, content, footer string, width, height, scrollOffset int, focused bool) string {
if height <= 0 { if height <= 0 {
return m.titledPanel(title, content, width, focused) return m.titledPanel(title, content, width, focused)
} }
@@ -40,7 +40,13 @@ func (m Model) titledPanelH(title, content string, width, height, scrollOffset i
inner := contentStyle.Render(content) inner := contentStyle.Render(content)
contentLines := strings.Split(inner, "\n") contentLines := strings.Split(inner, "\n")
bodyH := height - 2 var footerLines []string
if footer != "" {
footerRendered := contentStyle.Render(footer)
footerLines = strings.Split(footerRendered, "\n")
}
bodyH := height - 2 - len(footerLines)
if bodyH < 1 { if bodyH < 1 {
bodyH = 1 bodyH = 1
} }
@@ -58,13 +64,21 @@ func (m Model) titledPanelH(title, content string, width, height, scrollOffset i
} }
visible := contentLines[scrollOffset:end] visible := contentLines[scrollOffset:end]
borderLine := func(line string) string {
return bc.Render("│") + line + strings.Repeat(" ", max(0, innerW-lipgloss.Width(line))) + bc.Render("│")
}
emptyLine := borderLine(strings.Repeat(" ", innerW))
var lines []string var lines []string
lines = append(lines, top) lines = append(lines, top)
for _, line := range visible { for _, line := range visible {
lines = append(lines, bc.Render("│")+line+strings.Repeat(" ", max(0, innerW-lipgloss.Width(line)))+bc.Render("│")) lines = append(lines, borderLine(line))
} }
for len(lines) < height-1 { for len(lines) < height-1-len(footerLines) {
lines = append(lines, bc.Render("│")+strings.Repeat(" ", innerW)+bc.Render("│")) lines = append(lines, emptyLine)
}
for _, line := range footerLines {
lines = append(lines, borderLine(line))
} }
lines = append(lines, bottom) lines = append(lines, bottom)
+2 -1
View File
@@ -192,7 +192,8 @@ func (m Model) viewMonitorsLayout() string {
} }
monHeight := lipgloss.Height(monPanel) monHeight := lipgloss.Height(monPanel)
detail := m.viewDetailInline(detailW-2, monHeight) detail := m.viewDetailInline(detailW-2, monHeight)
detailPanel := m.zones.Mark("panel-detail", m.titledPanelH(title, detail, detailW, monHeight, m.detailScrollOffset, m.focusedPanel == panelDetail)) footer := m.detailFooter(detailW - 2)
detailPanel := m.zones.Mark("panel-detail", m.titledPanelH(title, detail, footer, detailW, monHeight, m.detailScrollOffset, m.focusedPanel == panelDetail))
topParts = append(topParts, detailPanel) topParts = append(topParts, detailPanel)
} }
+27 -17
View File
@@ -143,8 +143,6 @@ func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, wid
b.WriteString(" " + label.Render("No state changes") + "\n") b.WriteString(" " + label.Render("No state changes") + "\n")
} }
b.WriteString("\n " + m.detailKeys() + "\n")
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String()) return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
} }
@@ -216,6 +214,33 @@ func (m Model) detailKeys() string {
return m.st.subtleStyle.Render("[e] Edit [h] History [s] SLA [Esc] Back") return m.st.subtleStyle.Render("[e] Edit [h] History [s] SLA [Esc] Back")
} }
func (m Model) detailFooter(width int) string {
label := m.st.subtleStyle
var lines []string
switch m.detailMode {
case detailSLA:
var keys []string
for i, p := range slaPeriods {
k := fmt.Sprintf("[%s] %s", p.key, p.label)
if i == m.slaPeriodIdx {
keys = append(keys, m.st.titleStyle.Render(k))
} else {
keys = append(keys, label.Render(k))
}
}
lines = append(lines, " "+strings.Join(keys, " "))
lines = append(lines, " "+label.Render("[Esc] Back"))
case detailHistory:
lines = append(lines, " "+label.Render("[Esc] Back"))
default:
lines = append(lines, " "+m.detailKeys())
}
content := strings.Join(lines, "\n")
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(content)
}
func (m Model) fmtStatusWord(status string) string { func (m Model) fmtStatusWord(status string) string {
switch status { switch status {
case "DOWN", "SSL EXP": case "DOWN", "SSL EXP":
@@ -292,20 +317,6 @@ func (m Model) viewSLASidebar(width, _ int) string {
} }
} }
b.WriteString("\n")
var keys []string
for i, p := range slaPeriods {
k := fmt.Sprintf("[%s] %s", p.key, p.label)
if i == m.slaPeriodIdx {
keys = append(keys, m.st.titleStyle.Render(k))
} else {
keys = append(keys, label.Render(k))
}
}
b.WriteString(" " + strings.Join(keys, " ") + "\n")
b.WriteString(" " + label.Render("[Esc] Back") + "\n")
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String()) return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
} }
@@ -368,7 +379,6 @@ func (m Model) viewHistorySidebar(width, _ int) string {
statParts = append(statParts, "avg "+fmtDuration(avg)) statParts = append(statParts, "avg "+fmtDuration(avg))
} }
b.WriteString(" " + label.Render(strings.Join(statParts, " │ ")) + "\n") b.WriteString(" " + label.Render(strings.Join(statParts, " │ ")) + "\n")
b.WriteString(" " + label.Render("[Esc] Back") + "\n")
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String()) return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
} }