diff --git a/internal/tui/panel.go b/internal/tui/panel.go index 6935334..4d90525 100644 --- a/internal/tui/panel.go +++ b/internal/tui/panel.go @@ -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 diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 77c2834..d54d23e 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -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 diff --git a/internal/tui/update.go b/internal/tui/update.go index 9ddcbb6..97e9183 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -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 diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index d98d564..9d8aae0 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -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) diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go index 5999fbb..9b33d5e 100644 --- a/internal/tui/view_dashboard.go +++ b/internal/tui/view_dashboard.go @@ -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) } diff --git a/internal/tui/view_detail_inline.go b/internal/tui/view_detail_inline.go index dbb4127..1d35f30 100644 --- a/internal/tui/view_detail_inline.go +++ b/internal/tui/view_detail_inline.go @@ -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()) }