feat(tui): stable detail panel with scroll, pinned footer, and 60/40 split #165
@@ -6,6 +6,85 @@ import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func (m Model) titledPanelH(title, content, footer string, width, height, scrollOffset int, focused bool) string {
|
||||
if height <= 0 {
|
||||
return m.titledPanel(title, content, width, focused)
|
||||
}
|
||||
|
||||
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)+"╮")
|
||||
bottom := bc.Render("╰" + strings.Repeat("─", innerW) + "╯")
|
||||
|
||||
contentStyle := lipgloss.NewStyle().Width(innerW).MaxWidth(innerW)
|
||||
inner := contentStyle.Render(content)
|
||||
contentLines := strings.Split(inner, "\n")
|
||||
|
||||
var footerLines []string
|
||||
if footer != "" {
|
||||
footerRendered := contentStyle.Render(footer)
|
||||
footerLines = strings.Split(footerRendered, "\n")
|
||||
}
|
||||
|
||||
bodyH := height - 2 - len(footerLines)
|
||||
if bodyH < 1 {
|
||||
bodyH = 1
|
||||
}
|
||||
|
||||
if scrollOffset > len(contentLines)-bodyH {
|
||||
scrollOffset = len(contentLines) - bodyH
|
||||
}
|
||||
if scrollOffset < 0 {
|
||||
scrollOffset = 0
|
||||
}
|
||||
|
||||
end := scrollOffset + bodyH
|
||||
if end > len(contentLines) {
|
||||
end = len(contentLines)
|
||||
}
|
||||
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
|
||||
lines = append(lines, top)
|
||||
for _, line := range visible {
|
||||
lines = append(lines, borderLine(line))
|
||||
}
|
||||
for len(lines) < height-1-len(footerLines) {
|
||||
lines = append(lines, emptyLine)
|
||||
}
|
||||
for _, line := range footerLines {
|
||||
lines = append(lines, borderLine(line))
|
||||
}
|
||||
lines = append(lines, bottom)
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (m Model) titledPanel(title, content string, width int, focused bool) string {
|
||||
borderColor := m.theme.Border
|
||||
titleColor := m.theme.Muted
|
||||
|
||||
+6
-5
@@ -160,11 +160,12 @@ type Model struct {
|
||||
historySiteName string
|
||||
historySiteID int
|
||||
|
||||
slaReport monitor.SLAReport
|
||||
slaDailyBreakdown []monitor.DayReport
|
||||
slaSiteName string
|
||||
slaSiteID int
|
||||
slaPeriodIdx int
|
||||
slaReport monitor.SLAReport
|
||||
slaDailyBreakdown []monitor.DayReport
|
||||
slaSiteName string
|
||||
slaSiteID int
|
||||
slaPeriodIdx int
|
||||
detailScrollOffset int
|
||||
|
||||
isAdmin bool
|
||||
zones *zone.Manager
|
||||
|
||||
@@ -298,6 +298,18 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if m.focusedPanel == panelDetail && m.detailOpen {
|
||||
if msg.Button == tea.MouseButtonWheelUp {
|
||||
m.detailScrollOffset -= 3
|
||||
} else {
|
||||
m.detailScrollOffset += 3
|
||||
}
|
||||
if m.detailScrollOffset < 0 {
|
||||
m.detailScrollOffset = 0
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
listLen := m.currentListLen()
|
||||
if msg.Button == tea.MouseButtonWheelUp {
|
||||
if m.cursor > 0 {
|
||||
@@ -588,6 +600,13 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
m.recalcLayout()
|
||||
case "up", "k":
|
||||
if m.focusedPanel == panelDetail && m.detailOpen {
|
||||
m.detailScrollOffset--
|
||||
if m.detailScrollOffset < 0 {
|
||||
m.detailScrollOffset = 0
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if m.focusedPanel == panelMaint {
|
||||
m.scrollMaintCursor(-1)
|
||||
return m, nil
|
||||
@@ -604,10 +623,15 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.syncSelectedID()
|
||||
if m.detailOpen && m.cursor < len(m.sites) {
|
||||
m.detailMode = detailDefault
|
||||
m.detailScrollOffset = 0
|
||||
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||
}
|
||||
}
|
||||
case "down", "j":
|
||||
if m.focusedPanel == panelDetail && m.detailOpen {
|
||||
m.detailScrollOffset++
|
||||
return m, nil
|
||||
}
|
||||
if m.focusedPanel == panelMaint {
|
||||
m.scrollMaintCursor(1)
|
||||
return m, nil
|
||||
@@ -625,6 +649,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.syncSelectedID()
|
||||
if m.detailOpen && m.cursor < len(m.sites) {
|
||||
m.detailMode = detailDefault
|
||||
m.detailScrollOffset = 0
|
||||
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||
}
|
||||
}
|
||||
@@ -652,6 +677,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if len(m.sites) > 0 {
|
||||
m.detailOpen = !m.detailOpen
|
||||
m.detailMode = detailDefault
|
||||
m.detailScrollOffset = 0
|
||||
m.recalcLayout()
|
||||
st := m.store
|
||||
ctx := m.ctx
|
||||
@@ -702,9 +728,11 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.focusedPanel = panelMonitors
|
||||
} else if m.detailOpen && m.detailMode != detailDefault {
|
||||
m.detailMode = detailDefault
|
||||
m.detailScrollOffset = 0
|
||||
} else if m.detailOpen {
|
||||
m.detailOpen = false
|
||||
m.detailMode = detailDefault
|
||||
m.detailScrollOffset = 0
|
||||
m.recalcLayout()
|
||||
st := m.store
|
||||
ctx := m.ctx
|
||||
@@ -719,6 +747,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.historySiteID = site.ID
|
||||
m.historyChanges = nil
|
||||
m.detailMode = detailHistory
|
||||
m.detailScrollOffset = 0
|
||||
return m, m.loadHistoryCmd(site.ID)
|
||||
}
|
||||
case "s":
|
||||
@@ -728,6 +757,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.slaSiteID = site.ID
|
||||
m.slaPeriodIdx = 2
|
||||
m.detailMode = detailSLA
|
||||
m.detailScrollOffset = 0
|
||||
return m, m.loadSLACmd(site.ID, m.slaPeriodIdx)
|
||||
}
|
||||
case "1", "2", "3", "4":
|
||||
@@ -735,6 +765,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
idx := int(msg.String()[0]-'0') - 1
|
||||
if idx >= 0 && idx < len(slaPeriods) {
|
||||
m.slaPeriodIdx = idx
|
||||
m.detailScrollOffset = 0
|
||||
return m, m.loadSLACmd(m.slaSiteID, idx)
|
||||
}
|
||||
}
|
||||
@@ -860,6 +891,7 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
m.syncSelectedID()
|
||||
m.focusedPanel = panelMonitors
|
||||
if m.detailOpen {
|
||||
m.detailScrollOffset = 0
|
||||
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||
}
|
||||
return m, nil
|
||||
|
||||
@@ -142,7 +142,7 @@ func TestDetailLoad_CachesAndViewDoesNoIO(t *testing.T) {
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
_ = m.viewDetailInline(80)
|
||||
_ = m.viewDetailInline(80, 30)
|
||||
}
|
||||
if ms.stateChangeCalls != 1 {
|
||||
t.Errorf("View performed DB IO: store hit %d times (want 1, from the Cmd only)", ms.stateChangeCalls)
|
||||
|
||||
@@ -161,7 +161,7 @@ func (m Model) viewMonitorsLayout() string {
|
||||
}
|
||||
remaining := availW - maintW
|
||||
if showDetail {
|
||||
monW = remaining * 45 / 100
|
||||
monW = remaining * 60 / 100
|
||||
detailW = remaining - monW
|
||||
} else {
|
||||
monW = remaining
|
||||
@@ -190,8 +190,10 @@ func (m Model) viewMonitorsLayout() string {
|
||||
case detailHistory:
|
||||
title = "History · " + title
|
||||
}
|
||||
detail := m.viewDetailInline(detailW - 2)
|
||||
detailPanel := m.zones.Mark("panel-detail", m.titledPanel(title, detail, detailW, m.focusedPanel == panelDetail))
|
||||
monHeight := lipgloss.Height(monPanel)
|
||||
detail := m.viewDetailInline(detailW-2, monHeight)
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,23 +10,23 @@ import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func (m Model) viewDetailInline(width int) string {
|
||||
func (m Model) viewDetailInline(width, height int) string {
|
||||
if m.cursor >= len(m.sites) {
|
||||
return ""
|
||||
}
|
||||
switch m.detailMode {
|
||||
case detailSLA:
|
||||
return m.viewSLASidebar(width)
|
||||
return m.viewSLASidebar(width, height)
|
||||
case detailHistory:
|
||||
return m.viewHistorySidebar(width)
|
||||
return m.viewHistorySidebar(width, height)
|
||||
default:
|
||||
site := m.sites[m.cursor]
|
||||
hist, _ := m.engine.GetHistory(site.ID)
|
||||
return m.viewDetailSidebar(site, hist, width)
|
||||
return m.viewDetailSidebar(site, hist, width, height)
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, width int) string {
|
||||
func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, width, _ int) string {
|
||||
dot := m.st.subtleStyle.Render(" · ")
|
||||
label := m.st.subtleStyle
|
||||
var b strings.Builder
|
||||
@@ -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("\n " + m.detailKeys() + "\n")
|
||||
|
||||
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
|
||||
}
|
||||
|
||||
@@ -212,8 +210,28 @@ func (m Model) detailTypeLine(site models.Site) []string {
|
||||
return parts
|
||||
}
|
||||
|
||||
func (m Model) detailKeys() string {
|
||||
return m.st.subtleStyle.Render("[e] Edit [h] History [s] SLA [Esc] Back")
|
||||
func (m Model) detailFooter(width int) string {
|
||||
dot := m.st.subtleStyle.Render(" · ")
|
||||
var parts []string
|
||||
|
||||
switch m.detailMode {
|
||||
case detailSLA:
|
||||
for i, p := range slaPeriods {
|
||||
if i == m.slaPeriodIdx {
|
||||
parts = append(parts, m.st.titleStyle.Render(p.key)+" "+m.st.titleStyle.Render(p.label))
|
||||
} else {
|
||||
parts = append(parts, m.hotkey(p.key, p.label))
|
||||
}
|
||||
}
|
||||
parts = append(parts, m.hotkey("Esc", "Back"))
|
||||
case detailHistory:
|
||||
parts = append(parts, m.hotkey("Esc", "Back"))
|
||||
default:
|
||||
parts = append(parts, m.hotkey("e", "Edit"), m.hotkey("h", "History"), m.hotkey("s", "SLA"), m.hotkey("Esc", "Back"))
|
||||
}
|
||||
|
||||
content := " " + strings.Join(parts, dot)
|
||||
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(content)
|
||||
}
|
||||
|
||||
func (m Model) fmtStatusWord(status string) string {
|
||||
@@ -235,7 +253,7 @@ func (m Model) fmtStatusWord(status string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) viewSLASidebar(width int) string {
|
||||
func (m Model) viewSLASidebar(width, _ int) string {
|
||||
var b strings.Builder
|
||||
label := m.st.subtleStyle
|
||||
innerW := width - 4
|
||||
@@ -292,24 +310,10 @@ 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())
|
||||
}
|
||||
|
||||
func (m Model) viewHistorySidebar(width int) string {
|
||||
func (m Model) viewHistorySidebar(width, _ int) string {
|
||||
var b strings.Builder
|
||||
label := m.st.subtleStyle
|
||||
innerW := width - 4
|
||||
@@ -368,7 +372,6 @@ func (m Model) viewHistorySidebar(width int) string {
|
||||
statParts = append(statParts, "avg "+fmtDuration(avg))
|
||||
}
|
||||
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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user