From 53db03814ea07cb032d4b8b6c7d685311713d959 Mon Sep 17 00:00:00 2001 From: swanadiva Date: Tue, 23 Jun 2026 15:42:03 +0700 Subject: [PATCH] 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 --- pkg/tui/host_list_tab.go | 98 ++++++++++++++++++++++++++++------------ pkg/tui/styles.go | 6 +++ 2 files changed, 74 insertions(+), 30 deletions(-) diff --git a/pkg/tui/host_list_tab.go b/pkg/tui/host_list_tab.go index 1fa1ca8..14953e6 100644 --- a/pkg/tui/host_list_tab.go +++ b/pkg/tui/host_list_tab.go @@ -5,6 +5,7 @@ import ( "strings" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "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 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 - 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") if len(t.hosts) == 0 { - b.WriteString(SubtitleStyle.Render("No hosts found. Add your first host with: hostkeeper add")) - b.WriteString("\n\n") - return b.String() - } - - for i, host := range t.hosts { - if i == t.selectedIndex { - b.WriteString(renderSelectedHost(host)) - } else { - b.WriteString(renderHost(host)) + b.WriteString(lipgloss.Place(contentW, 1, lipgloss.Center, lipgloss.Center, + SubtitleStyle.Render("No hosts found. Add with Ctrl+N"))) + } else { + cardW := contentW - 4 + if cardW < 30 { + cardW = 30 + } + for i, host := range t.hosts { + entry := renderHostEntry(host, i == t.selectedIndex, cardW) + 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 @@ -159,28 +187,40 @@ func FindHostListTab(tabs []Tab) *HostListTab { return nil } -// renderHost renders a single host -func renderHost(host *models.Host) string { - var b strings.Builder +// renderHostEntry renders a single host as a card +func renderHostEntry(host *models.Host, selected bool, width int) string { + // Build the card content + var inner strings.Builder - b.WriteString(HostNameStyle.Render(host.Name)) - b.WriteString("\n") - - details := fmt.Sprintf(" %s@%s:%d", host.Username, host.Hostname, host.Port) - b.WriteString(HostDetailStyle.Render(details)) + // First line: host name (bold) + name := truncateString(host.Name, width-4) + inner.WriteString(HostNameStyle.Render(" " + name)) + inner.WriteString("\n") + // Second line: user@host:port with tags + addr := fmt.Sprintf("%s@%s:%d", host.Username, host.Hostname, host.Port) if len(host.Tags) > 0 { - tags := formatTagsForTUI(host.Tags) - b.WriteString(" " + TagStyle.Render(tags)) + addr += " " + formatTagsForTUI(host.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 -func renderSelectedHost(host *models.Host) string { - hostText := renderHost(host) - return SelectedStyle.Render(hostText) +// truncateString truncates a string to the given max length, appending "…" if needed +func truncateString(s string, maxLen int) string { + if maxLen < 1 { + return "" + } + if len(s) <= maxLen { + return s + } + return s[:maxLen-1] + "…" } // formatTagsForTUI formats tags for TUI display @@ -188,11 +228,9 @@ func formatTagsForTUI(tags []string) string { if len(tags) == 0 { return "" } - var formatted []string for _, tag := range tags { formatted = append(formatted, "["+tag+"]") } - return strings.Join(formatted, " ") } diff --git a/pkg/tui/styles.go b/pkg/tui/styles.go index 2a21a81..1e00676 100644 --- a/pkg/tui/styles.go +++ b/pkg/tui/styles.go @@ -51,3 +51,9 @@ var ( StylePaneActive = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(gbGreen).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) +)