package internal import ( "fmt" "strings" "github.com/charmbracelet/lipgloss" ) // ──────────────────────────────── // STYLES // ──────────────────────────────── type Styles struct { Title lipgloss.Style Box lipgloss.Style Active lipgloss.Style Status lipgloss.Style Error lipgloss.Style // NEW: for red bold error lines Subtle lipgloss.Style } func NewStyles() Styles { border := lipgloss.RoundedBorder() return Styles{ Title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")), Box: lipgloss.NewStyle().Border(border).Padding(0, 1), Active: lipgloss.NewStyle(). Border(border). BorderForeground(lipgloss.Color("#FA8072")). // Not pink, its Salmon obviously Padding(0, 1), Status: lipgloss.NewStyle().Foreground(lipgloss.Color("8")).MarginTop(1), Error: lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true), Subtle: lipgloss.NewStyle().Foreground(lipgloss.Color("243")), } } // ──────────────────────────────── // GENERIC LIST COLUMN (SCROLLABLE) // ──────────────────────────────── type renderer[T any] func(T) string type ListColumn[T any] struct { title string items []T selected int scroll int width int height int render renderer[T] } func NewListColumn[T any](title string, r renderer[T]) *ListColumn[T] { return &ListColumn[T]{title: title, render: r, width: 30, height: 20} } func truncateToWidth(text string, width int) string { if width <= 0 { return "" } if lipgloss.Width(text) <= width { return text } runes := []rune(text) total := 0 for i, r := range runes { rWidth := lipgloss.Width(string(r)) if total+rWidth > width { return string(runes[:i]) } total += rWidth } return text } func (c *ListColumn[T]) SetItems(items []T) { c.items = items c.selected = 0 c.scroll = 0 } func (c *ListColumn[T]) SetTitle(title string) { c.title = title } func (c *ListColumn[T]) SetWidth(w int) { // w is the total width the app wants to allocate to the box. // Subtract 4 for border (2) + padding (2) to get interior content width. if w < 4 { c.width = 0 return } c.width = w - 4 } func (c *ListColumn[T]) SetHeight(h int) { if h > 6 { c.height = h - 6 } } func (c *ListColumn[T]) CursorUp() { if c.selected > 0 { c.selected-- } if c.selected < c.scroll { c.scroll = c.selected } } func (c *ListColumn[T]) CursorDown() { if c.selected < len(c.items)-1 { c.selected++ } if c.selected >= c.scroll+c.height { c.scroll = c.selected - c.height + 1 } } func (c *ListColumn[T]) Selected() (T, bool) { var zero T if len(c.items) == 0 { return zero, false } return c.items[c.selected], true } func (c *ListColumn[T]) View(styles Styles, focused bool) string { box := styles.Box if focused { box = styles.Active } titleText := fmt.Sprintf("%s (%d)", c.title, len(c.items)) if focused { titleText = fmt.Sprintf("▶ %s", titleText) } head := styles.Title.Render(titleText) meta := styles.Subtle.Render("Waiting for data…") lines := []string{} if len(c.items) == 0 { lines = append(lines, "(no items)") } else { start := c.scroll end := start + c.height if end > len(c.items) { end = len(c.items) } meta = styles.Subtle.Render(fmt.Sprintf("Showing %d–%d of %d", start+1, end, len(c.items))) for i := start; i < end; i++ { cursor := " " lineText := c.render(c.items[i]) contentWidth := c.width - lipgloss.Width(cursor) if contentWidth > 1 && lipgloss.Width(lineText) > contentWidth { lineText = fmt.Sprintf("%s…", truncateToWidth(lineText, contentWidth-1)) } if i == c.selected { cursor = "▸ " lineText = lipgloss.NewStyle(). Foreground(lipgloss.Color("#FA8072")). // Not pink, its Salmon obviously Bold(true). Render(lineText) } line := fmt.Sprintf("%s%s", cursor, lineText) lines = append(lines, line) } } // Fill remaining lines if fewer than height for len(lines) < c.height { lines = append(lines, "") } content := strings.Join(lines, "\n") // IMPORTANT: width = interior content width + 4 (border+padding) return box.Width(c.width + 4).Render(head + "\n" + meta + "\n" + content) }