feat(tui): lazygit-style titled panel borders

All panels wrapped in titled rounded borders (╭─ Title ──╮). Focused
panel gets accent-colored border, unfocused panels get muted border.

- Monitors panel: titled "Monitors", focused when detail is closed
- Logs panel: titled "Logs", always unfocused (passive display)
- Detail panel: titled with monitor name, focused when open

Table's own RoundedBorder replaced with HiddenBorder — the titled
panel border provides the visual frame, table uses space-separated
columns internally. Consistent chrome across all panels.
This commit is contained in:
2026-06-20 19:30:59 -04:00
parent 065d5d74bb
commit e5ac4a1fec
4 changed files with 77 additions and 32 deletions
+52
View File
@@ -0,0 +1,52 @@
package tui
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
func (m Model) titledPanel(title, content string, width int, focused bool) string {
borderColor := m.theme.Border
titleColor := m.theme.Muted
if focused {
borderColor = m.theme.Accent
titleColor = m.theme.Accent
}
bc := lipgloss.NewStyle().Foreground(borderColor)
tc := lipgloss.NewStyle().Foreground(titleColor).Bold(true)
innerW := width - 2
if innerW < 10 {
innerW = 10
}
titleRendered := tc.Render(" " + title + " ")
titleLen := len([]rune(title)) + 2
fillLen := innerW - titleLen - 1
if fillLen < 0 {
fillLen = 0
}
top := bc.Render("╭─") + titleRendered + bc.Render(strings.Repeat("─", fillLen)+"╮")
contentStyle := lipgloss.NewStyle().Width(innerW).MaxWidth(innerW)
inner := contentStyle.Render(content)
var lines []string
lines = append(lines, top)
for _, line := range strings.Split(inner, "\n") {
lines = append(lines, bc.Render("│")+line+strings.Repeat(" ", max(0, innerW-lipgloss.Width(line)))+bc.Render("│"))
}
lines = append(lines, bc.Render("╰"+strings.Repeat("─", innerW)+"╯"))
return strings.Join(lines, "\n")
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
+2 -3
View File
@@ -52,8 +52,7 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
} }
t := table.New(). t := table.New().
Border(lipgloss.RoundedBorder()). Border(lipgloss.HiddenBorder()).
BorderStyle(m.st.tableBorderStyle).
Width(tableWidth). Width(tableWidth).
Headers(headers...). Headers(headers...).
Rows(rows...). Rows(rows...).
@@ -94,5 +93,5 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
return base return base
}) })
return "\n" + t.Render() return t.Render()
} }
+23 -20
View File
@@ -157,35 +157,38 @@ func (m Model) viewDashboard() string {
availW := m.termWidth - chromePadH availW := m.termWidth - chromePadH
leftW := availW * 70 / 100 leftW := availW * 70 / 100
rightW := availW - leftW rightW := availW - leftW
m.contentWidth = leftW m.contentWidth = leftW - 2
monitors := m.viewSitesTab() monitors := m.viewSitesTab()
left := lipgloss.NewStyle().Width(leftW).Render(monitors) monPanel := m.titledPanel("Monitors", monitors, leftW, !m.detailOpen)
sidebarBorder := lipgloss.NewStyle(). sidebarContent := m.viewLogsSidebar(rightW-2, m.maxTableRows)
Border(lipgloss.RoundedBorder()). logPanel := m.titledPanel("Logs", sidebarContent, rightW, false)
BorderForeground(m.theme.Border). top := lipgloss.JoinHorizontal(lipgloss.Top, monPanel, logPanel)
BorderLeft(false).
BorderBottom(false)
innerW := rightW - 2
if innerW < 10 {
innerW = 10
}
sidebar := m.viewLogsSidebar(innerW, m.maxTableRows)
right := sidebarBorder.Render(sidebar)
top := lipgloss.JoinHorizontal(lipgloss.Top, left, right)
if m.detailOpen { if m.detailOpen {
detail := m.viewDetailInline(availW) site := ""
content = top + "\n" + detail if m.cursor < len(m.sites) {
site = m.sites[m.cursor].Name
}
detail := m.viewDetailInline(availW - 2)
detailPanel := m.titledPanel(site, detail, availW, true)
content = top + "\n" + detailPanel
} else { } else {
content = top content = top
} }
} else { } else {
m.contentWidth = m.termWidth m.contentWidth = m.termWidth - 2
monitors := m.viewSitesTab() monitors := m.viewSitesTab()
availW := m.termWidth - chromePadH
monPanel := m.titledPanel("Monitors", monitors, availW, !m.detailOpen)
if m.detailOpen { if m.detailOpen {
detail := m.viewDetailInline(m.termWidth - chromePadH) site := ""
content = monitors + "\n" + detail if m.cursor < len(m.sites) {
site = m.sites[m.cursor].Name
}
detail := m.viewDetailInline(availW - 2)
detailPanel := m.titledPanel(site, detail, availW, true)
content = monPanel + "\n" + detailPanel
} else { } else {
content = monitors content = monPanel
} }
} }
case tabMaint: case tabMaint:
-9
View File
@@ -18,15 +18,6 @@ func (m Model) viewDetailInline(width int) string {
var b strings.Builder var b strings.Builder
title := m.st.titleStyle.Render(site.Name)
b.WriteString(" " + title + "\n")
divW := width - 4
if divW < 20 {
divW = 20
}
b.WriteString(" " + m.st.subtleStyle.Render(strings.Repeat("─", divW)) + "\n")
status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)) status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))
latency := m.fmtLatency(site.Latency) latency := m.fmtLatency(site.Latency)
uptime := m.fmtUptime(hist.Statuses) uptime := m.fmtUptime(hist.Statuses)