feat: centered card-style host list layout

- Each host rendered as a bordered card (HostCardStyle / HostCardActiveStyle)
- Title centered horizontally
- Content constrained to 80 columns max
- Vertical centering padding
- Selected host card gets green border (gbGreen #a9b665)
- Empty state message centered
- Removed old plain-text renderHost/renderSelectedHost functions
This commit is contained in:
swanadiva
2026-06-23 15:42:03 +07:00
parent c957a97cdb
commit 53db03814e
2 changed files with 74 additions and 30 deletions
+68 -30
View File
@@ -5,6 +5,7 @@ import (
"strings" "strings"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models" "git.tukangketik.id/swanadiva/hostkeeper/internal/models"
) )
@@ -105,27 +106,54 @@ func (t *HostListTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
// View renders the host list // View renders the host list
func (t *HostListTab) View() string { func (t *HostListTab) View() string {
availW := t.width
if availW < 40 {
availW = 80
}
// Constrain content width so cards don't stretch too wide
contentW := availW
if contentW > 80 {
contentW = 80
}
var b strings.Builder var b strings.Builder
b.WriteString(AppTitleStyle.Render("HOSTKEEPER - SSH Manager")) // Centered title
title := AppTitleStyle.Render(" HOSTKEEPER - SSH Manager ")
b.WriteString(lipgloss.Place(contentW, 1, lipgloss.Center, lipgloss.Center, title))
b.WriteString("\n\n") b.WriteString("\n\n")
if len(t.hosts) == 0 { if len(t.hosts) == 0 {
b.WriteString(SubtitleStyle.Render("No hosts found. Add your first host with: hostkeeper add")) b.WriteString(lipgloss.Place(contentW, 1, lipgloss.Center, lipgloss.Center,
b.WriteString("\n\n") SubtitleStyle.Render("No hosts found. Add with Ctrl+N")))
return b.String() } else {
} cardW := contentW - 4
if cardW < 30 {
for i, host := range t.hosts { cardW = 30
if i == t.selectedIndex { }
b.WriteString(renderSelectedHost(host)) for i, host := range t.hosts {
} else { entry := renderHostEntry(host, i == t.selectedIndex, cardW)
b.WriteString(renderHost(host)) b.WriteString(entry)
b.WriteString("\n")
} }
b.WriteString("\n")
} }
return b.String() // Center horizontally within the full terminal width
content := lipgloss.Place(availW, 1, lipgloss.Center, lipgloss.Top, b.String())
// Add vertical padding to push content toward center of screen
availH := t.height - 5
if availH < 0 {
availH = 0
}
contentLines := strings.Count(content, "\n") + 1
if contentLines < availH {
topPad := (availH - contentLines) / 2
content = strings.Repeat("\n", topPad) + content
}
return content
} }
// Close is a no-op for host list tab // Close is a no-op for host list tab
@@ -159,28 +187,40 @@ func FindHostListTab(tabs []Tab) *HostListTab {
return nil return nil
} }
// renderHost renders a single host // renderHostEntry renders a single host as a card
func renderHost(host *models.Host) string { func renderHostEntry(host *models.Host, selected bool, width int) string {
var b strings.Builder // Build the card content
var inner strings.Builder
b.WriteString(HostNameStyle.Render(host.Name)) // First line: host name (bold)
b.WriteString("\n") name := truncateString(host.Name, width-4)
inner.WriteString(HostNameStyle.Render(" " + name))
details := fmt.Sprintf(" %s@%s:%d", host.Username, host.Hostname, host.Port) inner.WriteString("\n")
b.WriteString(HostDetailStyle.Render(details))
// Second line: user@host:port with tags
addr := fmt.Sprintf("%s@%s:%d", host.Username, host.Hostname, host.Port)
if len(host.Tags) > 0 { if len(host.Tags) > 0 {
tags := formatTagsForTUI(host.Tags) addr += " " + formatTagsForTUI(host.Tags)
b.WriteString(" " + TagStyle.Render(tags)) }
inner.WriteString(HostDetailStyle.Render(" " + truncateString(addr, width-2)))
cardStyle := HostCardStyle
if selected {
cardStyle = HostCardActiveStyle
} }
return b.String() return cardStyle.Width(width).Render(inner.String())
} }
// renderSelectedHost renders the selected host with highlight // truncateString truncates a string to the given max length, appending "…" if needed
func renderSelectedHost(host *models.Host) string { func truncateString(s string, maxLen int) string {
hostText := renderHost(host) if maxLen < 1 {
return SelectedStyle.Render(hostText) return ""
}
if len(s) <= maxLen {
return s
}
return s[:maxLen-1] + "…"
} }
// formatTagsForTUI formats tags for TUI display // formatTagsForTUI formats tags for TUI display
@@ -188,11 +228,9 @@ func formatTagsForTUI(tags []string) string {
if len(tags) == 0 { if len(tags) == 0 {
return "" return ""
} }
var formatted []string var formatted []string
for _, tag := range tags { for _, tag := range tags {
formatted = append(formatted, "["+tag+"]") formatted = append(formatted, "["+tag+"]")
} }
return strings.Join(formatted, " ") return strings.Join(formatted, " ")
} }
+6
View File
@@ -51,3 +51,9 @@ var (
StylePaneActive = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(gbGreen).Padding(0, 1) StylePaneActive = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(gbGreen).Padding(0, 1)
StylePaneInactive = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(gbBorder).Padding(0, 1) StylePaneInactive = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(gbBorder).Padding(0, 1)
) )
// Host card styles
var (
HostCardStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(gbBorder).Padding(0, 1)
HostCardActiveStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(gbGreen).Padding(0, 1)
)