// grid.go — Renders the 8x4 Stream Deck key grid in the terminal. // // Uses lipgloss for styling: free slots are green with [index], occupied slots // are dimmed with a short label (function name, icon name, or command prefix). // // ## Layout // // The grid mirrors the physical Stream Deck XL layout: // // 0 1 2 3 4 5 6 7 // 8 9 10 11 12 13 14 15 // 16 17 18 19 20 21 22 23 // 24 25 26 27 28 29 30 31 // // ## Expanding // // To support non-XL models (e.g. MK.2 with 15 keys in a 5x3 grid), make // gridCols and gridRows parameters instead of constants, and derive them // from the device config's product_id. The device model → key count mapping // is in internal/device/streamdeck.go. // // To add richer labels (e.g. showing params or the icon filename alongside // the function name), modify keyLabel(). Keep labels under 8 chars to fit // the cell width. package main import ( "fmt" "strings" "git.i0t.app/lwoodard/streamdeck-go/internal/config" "github.com/charmbracelet/lipgloss" ) // Grid dimensions — hardcoded for Stream Deck XL (8 columns × 4 rows = 32 keys). const ( gridCols = 8 gridRows = 4 gridKeys = gridCols * gridRows ) // Styles for the grid cells. Color numbers are ANSI 256-color palette indices: // // 10 = bright green (free slots — draws the eye) // 8 = dark gray (occupied slots — visually recedes) // 12 = bright blue (title) var ( freeStyle = lipgloss.NewStyle().Width(9).Height(2).Align(lipgloss.Center, lipgloss.Center).Bold(true).Foreground(lipgloss.Color("10")) occupiedStyle = lipgloss.NewStyle().Width(9).Height(2).Align(lipgloss.Center, lipgloss.Center).Foreground(lipgloss.Color("8")) borderStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("8")) titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) ) // renderGrid builds a string containing the full 8x4 key grid with a legend. // Free slots show [index] in green; occupied slots show a short label in gray. // The grid is printed to stdout before the key-picking form. func renderGrid(keys map[int]config.KeyConfig) string { var b strings.Builder b.WriteString(titleStyle.Render(" Stream Deck XL — 8×4 grid")) b.WriteString("\n\n") for row := 0; row < gridRows; row++ { cells := make([]string, gridCols) for col := 0; col < gridCols; col++ { idx := row*gridCols + col keyCfg, occupied := keys[idx] if occupied { label := keyLabel(keyCfg) cells[col] = borderStyle.Render(occupiedStyle.Render(label)) } else { cells[col] = borderStyle.Render(freeStyle.Render(fmt.Sprintf("[%d]", idx))) } } // JoinHorizontal places cells side by side, aligned at the top. b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, cells...)) b.WriteString("\n") } // Legend explaining the color coding. b.WriteString("\n") b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render("[n]")) b.WriteString(" = free ") b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("dim")) b.WriteString(" = occupied\n") return b.String() } // keyLabel returns a short display label for an occupied key (max 8 chars). // // Priority order: // 1. Module function name (if module-based key) // 2. Icon filename without extension (if static key) // 3. Command prefix (fallback) // 4. "???" (shouldn't happen with valid config) func keyLabel(k config.KeyConfig) string { if k.Module != "" { label := k.Function if len(label) > 8 { label = label[:8] } return label } if k.Icon != "" { label := k.Icon // Strip file extension for brevity (e.g. "firefox.png" → "firefox"). if dot := strings.LastIndex(label, "."); dot > 0 { label = label[:dot] } if len(label) > 8 { label = label[:8] } return label } if k.Command != "" { label := k.Command if len(label) > 8 { label = label[:8] } return label } return "???" }