diff --git a/cmd/build/build.go b/cmd/build/build.go index e74e1309..d5c9a574 100644 --- a/cmd/build/build.go +++ b/cmd/build/build.go @@ -4,29 +4,17 @@ package build import ( "embed" - "github.com/charmbracelet/lipgloss" + "github.com/host-uk/core/cmd/shared" "github.com/spf13/cobra" ) -// Build command styles +// Style aliases from shared package var ( - buildHeaderStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#3b82f6")) // blue-500 - - buildTargetStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#e2e8f0")) // gray-200 - - buildSuccessStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#22c55e")) // green-500 - - buildErrorStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#ef4444")) // red-500 - - buildDimStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#6b7280")) // gray-500 + buildHeaderStyle = shared.TitleStyle + buildTargetStyle = shared.ValueStyle + buildSuccessStyle = shared.SuccessStyle + buildErrorStyle = shared.ErrorStyle + buildDimStyle = shared.DimStyle ) //go:embed all:tmpl/gui diff --git a/cmd/dev/dev_health.go b/cmd/dev/dev_health.go index b35b6a40..80e8dc06 100644 --- a/cmd/dev/dev_health.go +++ b/cmd/dev/dev_health.go @@ -6,6 +6,7 @@ import ( "os" "sort" + "github.com/host-uk/core/cmd/shared" "github.com/host-uk/core/pkg/git" "github.com/host-uk/core/pkg/repos" "github.com/spf13/cobra" @@ -142,51 +143,37 @@ func runHealth(registryPath string, verbose bool) error { } func printHealthSummary(total int, dirty, ahead, behind, errors []string) { - // Total repos - fmt.Print(valueStyle.Render(fmt.Sprintf("%d", total))) - fmt.Print(dimStyle.Render(" repos")) + parts := []string{ + shared.StatusPart(total, "repos", shared.ValueStyle), + } - // Separator - fmt.Print(dimStyle.Render(" | ")) - - // Dirty + // Dirty status if len(dirty) > 0 { - fmt.Print(warningStyle.Render(fmt.Sprintf("%d", len(dirty)))) - fmt.Print(dimStyle.Render(" dirty")) + parts = append(parts, shared.StatusPart(len(dirty), "dirty", shared.WarningStyle)) } else { - fmt.Print(successStyle.Render("clean")) + parts = append(parts, shared.StatusText("clean", shared.SuccessStyle)) } - // Separator - fmt.Print(dimStyle.Render(" | ")) - - // Ahead + // Push status if len(ahead) > 0 { - fmt.Print(valueStyle.Render(fmt.Sprintf("%d", len(ahead)))) - fmt.Print(dimStyle.Render(" to push")) + parts = append(parts, shared.StatusPart(len(ahead), "to push", shared.ValueStyle)) } else { - fmt.Print(successStyle.Render("synced")) + parts = append(parts, shared.StatusText("synced", shared.SuccessStyle)) } - // Separator - fmt.Print(dimStyle.Render(" | ")) - - // Behind + // Pull status if len(behind) > 0 { - fmt.Print(warningStyle.Render(fmt.Sprintf("%d", len(behind)))) - fmt.Print(dimStyle.Render(" to pull")) + parts = append(parts, shared.StatusPart(len(behind), "to pull", shared.WarningStyle)) } else { - fmt.Print(successStyle.Render("up to date")) + parts = append(parts, shared.StatusText("up to date", shared.SuccessStyle)) } // Errors (only if any) if len(errors) > 0 { - fmt.Print(dimStyle.Render(" | ")) - fmt.Print(errorStyle.Render(fmt.Sprintf("%d", len(errors)))) - fmt.Print(dimStyle.Render(" errors")) + parts = append(parts, shared.StatusPart(len(errors), "errors", shared.ErrorStyle)) } - fmt.Println() + fmt.Println(shared.StatusLine(parts...)) } func formatRepoList(reposList []string) string { diff --git a/cmd/shared/styles.go b/cmd/shared/styles.go index 30a2e26d..4c0fffe8 100644 --- a/cmd/shared/styles.go +++ b/cmd/shared/styles.go @@ -2,11 +2,97 @@ // // This package contains: // - Terminal styling using lipgloss with Tailwind colours -// - Common helper functions (truncation, confirmation prompts) +// - Unicode symbols for consistent visual indicators +// - Helper functions for common output patterns // - Git and GitHub CLI utilities package shared -import "github.com/charmbracelet/lipgloss" +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// ───────────────────────────────────────────────────────────────────────────── +// Tailwind Colour Palette +// ───────────────────────────────────────────────────────────────────────────── + +// Tailwind colours for consistent theming across the CLI. +const ( + ColourBlue50 = lipgloss.Color("#eff6ff") + ColourBlue100 = lipgloss.Color("#dbeafe") + ColourBlue200 = lipgloss.Color("#bfdbfe") + ColourBlue300 = lipgloss.Color("#93c5fd") + ColourBlue400 = lipgloss.Color("#60a5fa") + ColourBlue500 = lipgloss.Color("#3b82f6") + ColourBlue600 = lipgloss.Color("#2563eb") + ColourBlue700 = lipgloss.Color("#1d4ed8") + ColourGreen400 = lipgloss.Color("#4ade80") + ColourGreen500 = lipgloss.Color("#22c55e") + ColourGreen600 = lipgloss.Color("#16a34a") + ColourRed400 = lipgloss.Color("#f87171") + ColourRed500 = lipgloss.Color("#ef4444") + ColourRed600 = lipgloss.Color("#dc2626") + ColourAmber400 = lipgloss.Color("#fbbf24") + ColourAmber500 = lipgloss.Color("#f59e0b") + ColourAmber600 = lipgloss.Color("#d97706") + ColourOrange500 = lipgloss.Color("#f97316") + ColourViolet400 = lipgloss.Color("#a78bfa") + ColourViolet500 = lipgloss.Color("#8b5cf6") + ColourIndigo500 = lipgloss.Color("#6366f1") + ColourCyan500 = lipgloss.Color("#06b6d4") + ColourGray50 = lipgloss.Color("#f9fafb") + ColourGray100 = lipgloss.Color("#f3f4f6") + ColourGray200 = lipgloss.Color("#e5e7eb") + ColourGray300 = lipgloss.Color("#d1d5db") + ColourGray400 = lipgloss.Color("#9ca3af") + ColourGray500 = lipgloss.Color("#6b7280") + ColourGray600 = lipgloss.Color("#4b5563") + ColourGray700 = lipgloss.Color("#374151") + ColourGray800 = lipgloss.Color("#1f2937") + ColourGray900 = lipgloss.Color("#111827") +) + +// ───────────────────────────────────────────────────────────────────────────── +// Unicode Symbols +// ───────────────────────────────────────────────────────────────────────────── + +// Symbols for consistent visual indicators across commands. +const ( + // Status indicators + SymbolCheck = "✓" // Success, pass, complete + SymbolCross = "✗" // Error, fail, incomplete + SymbolWarning = "⚠" // Warning, caution + SymbolInfo = "ℹ" // Information + SymbolQuestion = "?" // Unknown, needs attention + SymbolSkip = "○" // Skipped, neutral + SymbolDot = "●" // Active, selected + SymbolCircle = "◯" // Inactive, unselected + + // Arrows and pointers + SymbolArrowRight = "→" + SymbolArrowLeft = "←" + SymbolArrowUp = "↑" + SymbolArrowDown = "↓" + SymbolPointer = "▶" + + // Decorative + SymbolBullet = "•" + SymbolDash = "─" + SymbolPipe = "│" + SymbolCorner = "└" + SymbolTee = "├" + SymbolSeparator = " │ " + + // Progress + SymbolSpinner = "⠋" // First frame of braille spinner + SymbolPending = "…" +) + +// ───────────────────────────────────────────────────────────────────────────── +// Base Styles +// ───────────────────────────────────────────────────────────────────────────── // Terminal styles using Tailwind colour palette. // These are shared across command packages for consistent output. @@ -14,38 +100,343 @@ var ( // RepoNameStyle highlights repository names (blue, bold). RepoNameStyle = lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.Color("#3b82f6")) // blue-500 + Foreground(ColourBlue500) // SuccessStyle indicates successful operations (green, bold). SuccessStyle = lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.Color("#22c55e")) // green-500 + Foreground(ColourGreen500) // ErrorStyle indicates errors and failures (red, bold). ErrorStyle = lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.Color("#ef4444")) // red-500 + Foreground(ColourRed500) // WarningStyle indicates warnings and cautions (amber, bold). WarningStyle = lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.Color("#f59e0b")) // amber-500 + Foreground(ColourAmber500) + + // InfoStyle for informational messages (blue). + InfoStyle = lipgloss.NewStyle(). + Foreground(ColourBlue400) // DimStyle for secondary/muted text (gray). DimStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#6b7280")) // gray-500 + Foreground(ColourGray500) + + // MutedStyle for very subtle text (darker gray). + MutedStyle = lipgloss.NewStyle(). + Foreground(ColourGray600) // ValueStyle for data values and output (light gray). ValueStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#e2e8f0")) // gray-200 + Foreground(ColourGray200) + + // AccentStyle for highlighted values (cyan). + AccentStyle = lipgloss.NewStyle(). + Foreground(ColourCyan500) // LinkStyle for URLs and clickable references (blue, underlined). LinkStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#3b82f6")). // blue-500 + Foreground(ColourBlue500). Underline(true) // HeaderStyle for section headers (light gray, bold). HeaderStyle = lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.Color("#e2e8f0")) // gray-200 + Foreground(ColourGray200) + + // TitleStyle for command titles (blue, bold). + TitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(ColourBlue500) + + // BoldStyle for emphasis without colour. + BoldStyle = lipgloss.NewStyle(). + Bold(true) + + // CodeStyle for inline code or paths. + CodeStyle = lipgloss.NewStyle(). + Foreground(ColourGray300). + Background(ColourGray800). + Padding(0, 1) + + // KeyStyle for labels/keys in key-value pairs. + KeyStyle = lipgloss.NewStyle(). + Foreground(ColourGray400) + + // NumberStyle for numeric values. + NumberStyle = lipgloss.NewStyle(). + Foreground(ColourBlue300) ) + +// ───────────────────────────────────────────────────────────────────────────── +// Status Styles (for consistent status indicators) +// ───────────────────────────────────────────────────────────────────────────── + +var ( + // StatusPendingStyle for pending/waiting states. + StatusPendingStyle = lipgloss.NewStyle().Foreground(ColourGray500) + + // StatusRunningStyle for in-progress states. + StatusRunningStyle = lipgloss.NewStyle().Foreground(ColourBlue500) + + // StatusSuccessStyle for completed/success states. + StatusSuccessStyle = lipgloss.NewStyle().Foreground(ColourGreen500) + + // StatusErrorStyle for failed/error states. + StatusErrorStyle = lipgloss.NewStyle().Foreground(ColourRed500) + + // StatusWarningStyle for warning states. + StatusWarningStyle = lipgloss.NewStyle().Foreground(ColourAmber500) +) + +// ───────────────────────────────────────────────────────────────────────────── +// Box Styles (for bordered content) +// ───────────────────────────────────────────────────────────────────────────── + +var ( + // BoxStyle creates a rounded border box. + BoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ColourGray600). + Padding(0, 1) + + // BoxHeaderStyle for box titles. + BoxHeaderStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(ColourBlue400). + MarginBottom(1) + + // ErrorBoxStyle for error message boxes. + ErrorBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ColourRed500). + Padding(0, 1) + + // SuccessBoxStyle for success message boxes. + SuccessBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ColourGreen500). + Padding(0, 1) +) + +// ───────────────────────────────────────────────────────────────────────────── +// Helper Functions +// ───────────────────────────────────────────────────────────────────────────── + +// Success returns a styled success message with checkmark. +func Success(msg string) string { + return fmt.Sprintf("%s %s", SuccessStyle.Render(SymbolCheck), msg) +} + +// Error returns a styled error message with cross. +func Error(msg string) string { + return fmt.Sprintf("%s %s", ErrorStyle.Render(SymbolCross), msg) +} + +// Warning returns a styled warning message with warning symbol. +func Warning(msg string) string { + return fmt.Sprintf("%s %s", WarningStyle.Render(SymbolWarning), msg) +} + +// Info returns a styled info message with info symbol. +func Info(msg string) string { + return fmt.Sprintf("%s %s", InfoStyle.Render(SymbolInfo), msg) +} + +// Pending returns a styled pending message with ellipsis. +func Pending(msg string) string { + return fmt.Sprintf("%s %s", DimStyle.Render(SymbolPending), DimStyle.Render(msg)) +} + +// Skip returns a styled skipped message with circle. +func Skip(msg string) string { + return fmt.Sprintf("%s %s", DimStyle.Render(SymbolSkip), DimStyle.Render(msg)) +} + +// KeyValue returns a styled "key: value" string. +func KeyValue(key, value string) string { + return fmt.Sprintf("%s %s", KeyStyle.Render(key+":"), value) +} + +// KeyValueBold returns a styled "key: value" with bold value. +func KeyValueBold(key, value string) string { + return fmt.Sprintf("%s %s", KeyStyle.Render(key+":"), BoldStyle.Render(value)) +} + +// StatusLine creates a pipe-separated status line from parts. +// Each part should be pre-styled. +func StatusLine(parts ...string) string { + return strings.Join(parts, DimStyle.Render(SymbolSeparator)) +} + +// StatusPart creates a styled count + label for status lines. +// Example: StatusPart(5, "repos", SuccessStyle) -> "5 repos" in green +func StatusPart(count int, label string, style lipgloss.Style) string { + return style.Render(fmt.Sprintf("%d", count)) + DimStyle.Render(" "+label) +} + +// StatusText creates a styled label for status lines. +// Example: StatusText("clean", SuccessStyle) -> "clean" in green +func StatusText(text string, style lipgloss.Style) string { + return style.Render(text) +} + +// Bullet returns a bulleted item. +func Bullet(text string) string { + return fmt.Sprintf(" %s %s", DimStyle.Render(SymbolBullet), text) +} + +// TreeItem returns a tree item with branch indicator. +func TreeItem(text string, isLast bool) string { + prefix := SymbolTee + if isLast { + prefix = SymbolCorner + } + return fmt.Sprintf(" %s %s", DimStyle.Render(prefix), text) +} + +// Indent returns text indented by the specified level (2 spaces per level). +func Indent(level int, text string) string { + return strings.Repeat(" ", level) + text +} + +// Separator returns a horizontal separator line. +func Separator(width int) string { + return DimStyle.Render(strings.Repeat(SymbolDash, width)) +} + +// Header returns a styled section header with optional separator. +func Header(title string, withSeparator bool) string { + if withSeparator { + return fmt.Sprintf("\n%s\n%s", HeaderStyle.Render(title), Separator(len(title))) + } + return fmt.Sprintf("\n%s", HeaderStyle.Render(title)) +} + +// Title returns a styled command/section title. +func Title(text string) string { + return TitleStyle.Render(text) +} + +// Dim returns dimmed text. +func Dim(text string) string { + return DimStyle.Render(text) +} + +// Bold returns bold text. +func Bold(text string) string { + return BoldStyle.Render(text) +} + +// Highlight returns text with accent colour. +func Highlight(text string) string { + return AccentStyle.Render(text) +} + +// Path renders a file path in code style. +func Path(p string) string { + return CodeStyle.Render(p) +} + +// Command renders a command in code style. +func Command(cmd string) string { + return CodeStyle.Render(cmd) +} + +// Number renders a number with styling. +func Number(n int) string { + return NumberStyle.Render(fmt.Sprintf("%d", n)) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Table Helpers +// ───────────────────────────────────────────────────────────────────────────── + +// TableRow represents a row in a simple table. +type TableRow []string + +// Table provides simple table rendering. +type Table struct { + Headers []string + Rows []TableRow + Widths []int // Optional: fixed column widths +} + +// Render returns the table as a formatted string. +func (t *Table) Render() string { + if len(t.Rows) == 0 && len(t.Headers) == 0 { + return "" + } + + // Calculate column widths if not provided + widths := t.Widths + if len(widths) == 0 { + cols := len(t.Headers) + if cols == 0 && len(t.Rows) > 0 { + cols = len(t.Rows[0]) + } + widths = make([]int, cols) + + for i, h := range t.Headers { + if len(h) > widths[i] { + widths[i] = len(h) + } + } + for _, row := range t.Rows { + for i, cell := range row { + if i < len(widths) && len(cell) > widths[i] { + widths[i] = len(cell) + } + } + } + } + + var sb strings.Builder + + // Headers + if len(t.Headers) > 0 { + for i, h := range t.Headers { + if i > 0 { + sb.WriteString(" ") + } + sb.WriteString(HeaderStyle.Render(padRight(h, widths[i]))) + } + sb.WriteString("\n") + + // Separator + for i, w := range widths { + if i > 0 { + sb.WriteString(" ") + } + sb.WriteString(DimStyle.Render(strings.Repeat("─", w))) + } + sb.WriteString("\n") + } + + // Rows + for _, row := range t.Rows { + for i, cell := range row { + if i > 0 { + sb.WriteString(" ") + } + if i < len(widths) { + sb.WriteString(padRight(cell, widths[i])) + } else { + sb.WriteString(cell) + } + } + sb.WriteString("\n") + } + + return sb.String() +} + +func padRight(s string, width int) string { + if len(s) >= width { + return s + } + return s + strings.Repeat(" ", width-len(s)) +}