fix(tui): visual polish and layout improvements #18

Merged
lerko merged 3 commits from fix/tui-visual-polish into main 2026-05-23 16:12:58 +00:00
3 changed files with 114 additions and 13 deletions
Showing only changes of commit 88e4f0ed69 - Show all commits
+90 -3
View File
@@ -132,6 +132,93 @@ func heartbeatSparkline(statuses []bool, width int) string {
return sb.String() return sb.String()
} }
func (m Model) groupSparkline(groupID int, width int) string {
allSites := m.engine.GetAllSites()
var childStatuses [][]bool
for _, s := range allSites {
if s.ParentID == groupID && !s.Paused && !m.isMonitorInMaintenance(s.ID) {
hist, _ := m.engine.GetHistory(s.ID)
if len(hist.Statuses) > 0 {
childStatuses = append(childStatuses, hist.Statuses)
}
}
}
if len(childStatuses) == 0 {
return subtleStyle.Render(strings.Repeat("·", width))
}
maxLen := 0
for _, s := range childStatuses {
if len(s) > maxLen {
maxLen = len(s)
}
}
if maxLen > width {
maxLen = width
}
aggregated := make([]bool, maxLen)
for i := 0; i < maxLen; i++ {
allUp := true
for _, statuses := range childStatuses {
idx := len(statuses) - maxLen + i
if idx >= 0 && !statuses[idx] {
allUp = false
break
}
}
aggregated[i] = allUp
}
var sb strings.Builder
if remaining := width - len(aggregated); remaining > 0 {
sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
}
for _, up := range aggregated {
if up {
sb.WriteString(specialStyle.Render("●"))
} else {
sb.WriteString(dangerStyle.Render("●"))
}
}
return sb.String()
}
func (m Model) groupUptime(groupID int) string {
allSites := m.engine.GetAllSites()
var allStatuses [][]bool
for _, s := range allSites {
if s.ParentID == groupID && !s.Paused && !m.isMonitorInMaintenance(s.ID) {
hist, _ := m.engine.GetHistory(s.ID)
if len(hist.Statuses) > 0 {
allStatuses = append(allStatuses, hist.Statuses)
}
}
}
if len(allStatuses) == 0 {
return subtleStyle.Render("—")
}
total, up := 0, 0
for _, statuses := range allStatuses {
for _, s := range statuses {
total++
if s {
up++
}
}
}
return fmtUptime(func() []bool {
out := make([]bool, total)
idx := 0
for _, statuses := range allStatuses {
copy(out[idx:], statuses)
idx += len(statuses)
}
return out
}())
}
func fmtLatency(d time.Duration) string { func fmtLatency(d time.Duration) string {
ms := d.Milliseconds() ms := d.Milliseconds()
if ms == 0 { if ms == 0 {
@@ -227,7 +314,7 @@ func fmtStatus(status string, paused bool, inMaint bool) string {
func (m Model) dynamicWidths() (nameW, sparkW int) { func (m Model) dynamicWidths() (nameW, sparkW int) {
fixed := 6 + 10 + 10 + 8 + 8 + 7 + 9 // #, TYPE, STATUS, LATENCY, UPTIME, SSL, RETRY fixed := 6 + 10 + 10 + 8 + 8 + 7 + 9 // #, TYPE, STATUS, LATENCY, UPTIME, SSL, RETRY
overhead := 30 // cell padding + borders overhead := 30 // cell padding + borders
avail := m.termWidth - 6 - fixed - overhead avail := m.termWidth - chromePadH - 2 - fixed - overhead
if avail < 30 { if avail < 30 {
avail = 30 avail = 30
} }
@@ -285,8 +372,8 @@ func (m Model) viewSitesTab() string {
"group", "group",
fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)), fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)),
subtleStyle.Render("—"), subtleStyle.Render("—"),
subtleStyle.Render("—"), m.groupUptime(site.ID),
subtleStyle.Render(strings.Repeat("·", sparkWidth)), m.groupSparkline(site.ID, sparkWidth),
subtleStyle.Render("-"), subtleStyle.Render("-"),
subtleStyle.Render("—"), subtleStyle.Render("—"),
}) })
+10 -5
View File
@@ -38,7 +38,7 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
selectedVisual := m.cursor - m.tableOffset selectedVisual := m.cursor - m.tableOffset
rows := buildRows(m.tableOffset, end) rows := buildRows(m.tableOffset, end)
tableWidth := m.termWidth - 6 tableWidth := m.termWidth - chromePadH - 2
if tableWidth < 40 { if tableWidth < 40 {
tableWidth = 40 tableWidth = 40
} }
@@ -53,16 +53,21 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
if row == table.HeaderRow { if row == table.HeaderRow {
return tableHeaderStyle return tableHeaderStyle
} }
isSelected := row == selectedVisual
if styleOverride != nil { if styleOverride != nil {
if s := styleOverride(row, col); s != nil { if s := styleOverride(row, col); s != nil {
if col < len(colWidths) && colWidths[col] > 0 { style := *s
return s.Width(colWidths[col]) if isSelected {
style = tableSelectedStyle.Foreground(s.GetForeground())
} }
return *s if col < len(colWidths) && colWidths[col] > 0 {
style = style.Width(colWidths[col])
}
return style
} }
} }
base := tableCellStyle base := tableCellStyle
if row == selectedVisual { if isSelected {
base = tableSelectedStyle base = tableSelectedStyle
} }
if col < len(colWidths) && colWidths[col] > 0 { if col < len(colWidths) && colWidths[col] > 0 {
+14 -5
View File
@@ -31,6 +31,16 @@ var (
var pulseFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} var pulseFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
const (
chromePadV = 2 // outer Padding(1,2): 1 top + 1 bottom
chromePadH = 4 // outer Padding(1,2): 2 left + 2 right
chromeHeader = 1 // tab bar line
chromeGaps = 2 // "\n" separators: before content + before footer
chromeFooter = 2 // footer: "\n" prefix + text line
chromeTable = 3 // renderTable "\n" prefix + top border + header + bottom border (lipgloss collapses two into three rendered lines)
chromeBase = chromePadV + chromeHeader + chromeGaps + chromeFooter + chromeTable
)
type sessionState int type sessionState int
const ( const (
@@ -198,17 +208,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.termWidth = msg.Width m.termWidth = msg.Width
m.termHeight = msg.Height m.termHeight = msg.Height
// Chrome: 1 top pad + 1 tabs + 2 newlines + 3 table borders + 1 table header + 1 footer + 1 bottom pad = 10 chrome := chromeBase
chrome := 10 if m.filterMode || m.filterText != "" {
if m.filterText != "" {
chrome++ chrome++
} }
m.maxTableRows = msg.Height - chrome m.maxTableRows = msg.Height - chrome
if m.maxTableRows < 1 { if m.maxTableRows < 1 {
m.maxTableRows = 1 m.maxTableRows = 1
} }
m.logViewport.Width = msg.Width - 4 m.logViewport.Width = msg.Width - chromePadH
m.logViewport.Height = msg.Height - 8 m.logViewport.Height = msg.Height - (chromePadV + chromeHeader + chromeGaps + chromeFooter)
return m, tea.ClearScreen return m, tea.ClearScreen
case time.Time: case time.Time: