diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cc1ca57e..1bc18d1a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -31,7 +31,7 @@ on:
- main
env:
- VERSION_NUMBER: 'v1.8.7'
+ VERSION_NUMBER: 'v1.8.8'
DOCKERHUB_REGISTRY_NAME: 'digitalghostdev/poke-cli'
AWS_REGION: 'us-west-2'
diff --git a/.goreleaser.yml b/.goreleaser.yml
index e914dd78..17682f04 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -14,7 +14,7 @@ builds:
- windows
- darwin
ldflags:
- - -s -w -X main.version=v1.8.7
+ - -s -w -X main.version=v1.8.8
archives:
- formats: [ 'zip' ]
diff --git a/Dockerfile b/Dockerfile
index efc542d8..5aaa1e38 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
# build 1
-FROM golang:1.24.11-alpine3.23 AS build
+FROM golang:1.24.12-alpine3.23 AS build
WORKDIR /app
@@ -8,7 +8,7 @@ RUN go mod download
COPY . .
-RUN go build -ldflags "-X main.version=v1.8.7" -o poke-cli .
+RUN go build -ldflags "-X main.version=v1.8.8" -o poke-cli .
# build 2
FROM --platform=$BUILDPLATFORM alpine:3.23
diff --git a/README.md b/README.md
index 7254d01c..e1751aa3 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
-
+
@@ -96,11 +96,11 @@ Cloudsmith is a fully cloud-based service that lets you easily create, store, an
3. Choose how to interact with the container:
* Run a single command and exit:
```bash
- docker run --rm -it digitalghostdev/poke-cli:v1.8.7 [subcommand] [flag]
+ docker run --rm -it digitalghostdev/poke-cli:v1.8.8 [subcommand] [flag]
```
* Enter the container and use its shell:
```bash
- docker run --rm -it --name poke-cli --entrypoint /bin/sh digitalghostdev/poke-cli:v1.8.7 -c "cd /app && exec sh"
+ docker run --rm -it --name poke-cli --entrypoint /bin/sh digitalghostdev/poke-cli:v1.8.8 -c "cd /app && exec sh"
# placed into the /app directory, run the program with './poke-cli'
# example: ./poke-cli ability swift-swim
```
@@ -217,16 +217,17 @@ Below is a list of the planned/completed commands and flags:
---
## Tested Terminals
-| Terminal | OS | Status | Issues |
-|-------------------|:-------------------------:|:------:|----------------------------------------------------------------------------------------------|
-| Alacritty | macOS, Ubuntu,
Windows | 🟡 | - Does not support sixel for TCG images. |
-| Ghostty | macOS | 🟡 | - Does not support sixel for TCG images. |
-| HyperJS | macOS | 🟡 | - Does not support sixel for TCG images. |
-| iTerm2 | macOS | 🟢 | - None |
-| Built-in Terminal | Ubuntu, Debian,
Fedora | 🟡 | - Does not support sixel for TCG images. |
-| Built-in Terminal | Alpine | 🟡 | - Some colors aren't supported.
- `pokemon --image=xx` flag pixel issues. |
-| Built-in Terminal | macOS | 🟠 | - Does not support sixel for TCG images.
- `pokemon --image=xx` flag pixel issues. |
-| Foot | Ubuntu | 🟢 | - None |
-| Tabby | Ubuntu | 🟢 | - None |
-| WezTerm | macOS, Windows | 🟡 | - Windows version has issues with displaying TCG images. |
-| Built-in Terminal | Windows | 🟢 | - None |
\ No newline at end of file
+| Terminal | OS | Status | Issues |
+|--------------------|-------------------------------|:------:|-----------------------------------------------------------------------------------|
+| Alacritty | macOS, Ubuntu, Windows | 🟡 | No support for TCG images |
+| Foot | Ubuntu, Fedora | 🟢 | None |
+| Ghostty | macOS | 🟢 | None |
+| iTerm2 | macOS | 🟢 | None |
+| Kitty | macOS, Ubuntu, Debian, Fedora | 🟢 | None |
+| Rio | macOS | 🟢 | None |
+| Tabby | Ubuntu | 🟢 | None |
+| Terminal (Alpine) | Alpine | 🟡 | Some colors aren't supported
`pokemon --image=xx` flag has pixel issues |
+| Terminal (Linux) | Ubuntu, Debian, Fedora | 🟡 | No support for TCG images |
+| Terminal (macOS) | macOS | 🟠 | No support for TCG images
`pokemon --image=xx` flag has pixel issues |
+| Terminal (Windows) | Windows | 🟢 | None |
+| WezTerm | macOS, Windows | 🟡 | Windows version has issues with displaying TCG images |
\ No newline at end of file
diff --git a/card_data/pipelines/poke_cli_dbt/dbt_project.yml b/card_data/pipelines/poke_cli_dbt/dbt_project.yml
index ee0e8a3b..ee355c7d 100644
--- a/card_data/pipelines/poke_cli_dbt/dbt_project.yml
+++ b/card_data/pipelines/poke_cli_dbt/dbt_project.yml
@@ -1,5 +1,5 @@
name: 'poke_cli_dbt'
-version: '1.8.7'
+version: '1.8.8'
profile: 'poke_cli_dbt'
diff --git a/cmd/berry/berry.go b/cmd/berry/berry.go
index 5409065b..2cbef101 100644
--- a/cmd/berry/berry.go
+++ b/cmd/berry/berry.go
@@ -104,7 +104,7 @@ func (m model) View() string {
Width(50).
Height(29).
Border(lipgloss.RoundedBorder()).
- BorderForeground(lipgloss.Color("#FFCC00")).
+ BorderForeground(styling.YellowColor).
Padding(1).
Render(selectedBerry)
@@ -142,11 +142,11 @@ func tableGeneration() error {
s := table.DefaultStyles()
s.Header = s.Header.
BorderStyle(lipgloss.NormalBorder()).
- BorderForeground(lipgloss.Color("#FFCC00")).
+ BorderForeground(styling.YellowColor).
BorderBottom(true)
s.Selected = s.Selected.
Foreground(lipgloss.Color("#000")).
- Background(lipgloss.Color("#FFCC00"))
+ Background(styling.YellowColor)
t.SetStyles(s)
m := model{table: t}
diff --git a/cmd/berry/berry_test.go b/cmd/berry/berry_test.go
index 9632ef5e..d813416d 100644
--- a/cmd/berry/berry_test.go
+++ b/cmd/berry/berry_test.go
@@ -10,6 +10,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/exp/teatest"
+ "github.com/digitalghost-dev/poke-cli/styling"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -186,11 +187,11 @@ func createTestModel() model {
s := table.DefaultStyles()
s.Header = s.Header.
BorderStyle(lipgloss.NormalBorder()).
- BorderForeground(lipgloss.Color("#FFCC00")).
+ BorderForeground(styling.YellowColor).
BorderBottom(true)
s.Selected = s.Selected.
Foreground(lipgloss.Color("#000")).
- Background(lipgloss.Color("#FFCC00"))
+ Background(styling.YellowColor)
t.SetStyles(s)
return model{table: t}
diff --git a/cmd/card/card.go b/cmd/card/card.go
index 3276a26c..a5cc8a9f 100644
--- a/cmd/card/card.go
+++ b/cmd/card/card.go
@@ -40,7 +40,7 @@ func CardCommand() (string, error) {
seriesModel := SeriesList()
// Program 1: Series selection
- finalModel, err := tea.NewProgram(seriesModel).Run()
+ finalModel, err := tea.NewProgram(seriesModel, tea.WithAltScreen()).Run()
if err != nil {
return "", fmt.Errorf("error running series selection program: %w", err)
}
@@ -58,7 +58,7 @@ func CardCommand() (string, error) {
return "", fmt.Errorf("error loading sets: %w", err)
}
- finalSetsModel, err := tea.NewProgram(setsModel).Run()
+ finalSetsModel, err := tea.NewProgram(setsModel, tea.WithAltScreen()).Run()
if err != nil {
return "", fmt.Errorf("error running sets selection program: %w", err)
}
diff --git a/cmd/card/cardinfo.go b/cmd/card/cardinfo.go
index 19d8ed48..190430bd 100644
--- a/cmd/card/cardinfo.go
+++ b/cmd/card/cardinfo.go
@@ -8,9 +8,12 @@ import (
"io"
"net/http"
"net/url"
+ "os"
+ "strings"
"time"
"github.com/charmbracelet/x/ansi/sixel"
+ "github.com/dolmen-go/kittyimg"
"golang.org/x/image/draw"
)
@@ -20,45 +23,111 @@ func resizeImage(img image.Image, width, height int) image.Image {
return dst
}
-func CardImage(imageURL string) (string, error) {
+// supportsKittyGraphics checks if the terminal supports the Kitty graphics protocol
+func supportsKittyGraphics() bool {
+ // Check Kitty-specific window ID
+ if os.Getenv("KITTY_WINDOW_ID") != "" {
+ return true
+ }
+
+ // Check TERM_PROGRAM for known Kitty-compatible terminals
+ termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
+ switch termProgram {
+ case "kitty", "ghostty", "wezterm":
+ return true
+ }
+
+ // Check TERM variable for kitty or ghostty
+ term := strings.ToLower(os.Getenv("TERM"))
+ switch {
+ case strings.Contains(term, "kitty"):
+ return true
+ case strings.Contains(term, "ghostty"):
+ return true
+ }
+
+ return false
+}
+
+// supportsSixelGraphics checks if the terminal supports the Sixel graphics protocol
+func supportsSixelGraphics() bool {
+ session := os.Getenv("WT_SESSION")
+ termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
+ term := strings.ToLower(os.Getenv("TERM"))
+
+ if session != "" {
+ return true
+ }
+
+ // Check TERM_PROGRAM for known Sixel-supporting terminals
+ switch termProgram {
+ case "iterm.app", "wezterm", "konsole", "tabby", "rio":
+ return true
+ }
+
+ // Check TERM variable for known Sixel-supporting terminals
+ switch {
+ case term == "foot" || strings.HasPrefix(term, "foot-"):
+ return true
+ case term == "xterm-sixel" || strings.Contains(term, "sixel"):
+ return true
+ }
+
+ return false
+}
+
+// CardImage downloads and renders an image using Kitty protocol if supported, otherwise Sixel.
+func CardImage(imageURL string) (imageData string, protocol string, err error) {
client := &http.Client{
Timeout: time.Second * 60,
}
parsedURL, err := url.Parse(imageURL)
if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") {
- return "", errors.New("invalid URL scheme")
+ return "", "", errors.New("invalid URL scheme")
}
resp, err := client.Get(imageURL)
if err != nil {
- return "", fmt.Errorf("failed to fetch image: %w", err)
+ return "", "", fmt.Errorf("failed to fetch image: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
- return "", fmt.Errorf("non-200 response: %d", resp.StatusCode)
+ return "", "", fmt.Errorf("non-200 response: %d", resp.StatusCode)
}
// Read body into memory first to avoid timeout during decode
limitedBody := io.LimitReader(resp.Body, 10*1024*1024)
bodyBytes, err := io.ReadAll(limitedBody)
if err != nil {
- return "", fmt.Errorf("failed to read image data: %w", err)
+ return "", "", fmt.Errorf("failed to read image data: %w", err)
}
img, _, err := image.Decode(bytes.NewReader(bodyBytes))
if err != nil {
- return "", fmt.Errorf("failed to decode image: %w", err)
+ return "", "", fmt.Errorf("failed to decode image: %w", err)
}
resized := resizeImage(img, 500, 675)
- // Build Sixel string to return
var buf bytes.Buffer
- buf.WriteString("\x1bPq")
- if err := new(sixel.Encoder).Encode(&buf, resized); err != nil {
- return "", fmt.Errorf("failed to encode sixel: %w", err)
+
+ if supportsKittyGraphics() {
+ if err := kittyimg.Fprint(&buf, resized); err != nil {
+ return "", "", fmt.Errorf("failed to encode kitty image: %w", err)
+ }
+ return buf.String(), "Kitty", nil
+ }
+
+ // Fall back to Sixel
+ if supportsSixelGraphics() {
+ buf.WriteString("\x1bPq")
+ if err := new(sixel.Encoder).Encode(&buf, resized); err != nil {
+ return "", "", fmt.Errorf("failed to encode sixel: %w", err)
+ }
+ buf.WriteString("\x1b\\")
+ return buf.String(), "Sixel", nil
}
- buf.WriteString("\x1b\\")
- return buf.String(), nil
+ // Neither protocol is supported
+ return "", "", errors.New("your terminal does not support image rendering (Kitty or Sixel graphics protocols required)\n\nTry using: Kitty, Ghostty, WezTerm, iTerm2, or foot")
}
diff --git a/cmd/card/cardinfo_test.go b/cmd/card/cardinfo_test.go
index ba23f7d3..2e01c4e0 100644
--- a/cmd/card/cardinfo_test.go
+++ b/cmd/card/cardinfo_test.go
@@ -6,6 +6,7 @@ import (
"image/png"
"net/http"
"net/http/httptest"
+ "os"
"strings"
"testing"
)
@@ -90,20 +91,19 @@ func TestCardImage_Success(t *testing.T) {
}))
defer server.Close()
- result, err := CardImage(server.URL)
+ // Set up a supported terminal environment (Sixel)
+ os.Setenv("TERM_PROGRAM", "iTerm.app")
+ defer os.Unsetenv("TERM_PROGRAM")
+
+ result, protocol, err := CardImage(server.URL)
if err != nil {
t.Errorf("CardImage() error = %v, want nil", err)
return
}
- // Check that result is a valid Sixel string
- if !strings.HasPrefix(result, "\x1bPq") {
- t.Error("CardImage() should return string starting with Sixel header")
- }
-
- if !strings.HasSuffix(result, "\x1b\\") {
- t.Error("CardImage() should return string ending with Sixel terminator")
+ if protocol == "" {
+ t.Error("CardImage() should return a protocol name")
}
if len(result) == 0 {
@@ -123,7 +123,7 @@ func TestCardImage_EncodingError(t *testing.T) {
}))
defer server.Close()
- result, err := CardImage(server.URL)
+ result, protocol, err := CardImage(server.URL)
if err == nil {
t.Error("CardImage() should return error for invalid image data")
@@ -133,6 +133,10 @@ func TestCardImage_EncodingError(t *testing.T) {
t.Errorf("CardImage() on error should return empty string, got %v", result)
}
+ if protocol != "" {
+ t.Errorf("CardImage() on error should return empty protocol, got %v", protocol)
+ }
+
if !strings.Contains(err.Error(), "failed to decode image") {
t.Errorf("Error message should mention 'failed to decode image', got: %v", err)
}
@@ -145,7 +149,7 @@ func TestCardImage_Non200Response(t *testing.T) {
}))
defer server.Close()
- result, err := CardImage(server.URL)
+ result, protocol, err := CardImage(server.URL)
if err == nil {
t.Error("CardImage() should return error for non-200 response")
@@ -155,7 +159,275 @@ func TestCardImage_Non200Response(t *testing.T) {
t.Errorf("CardImage() on error should return empty string, got %v", result)
}
+ if protocol != "" {
+ t.Errorf("CardImage() on error should return empty protocol, got %v", protocol)
+ }
+
if !strings.Contains(err.Error(), "non-200 response") {
t.Errorf("Error message should mention 'non-200 response', got: %v", err)
}
}
+
+func TestSupportsKittyGraphics(t *testing.T) {
+ tests := []struct {
+ name string
+ envVars map[string]string
+ wantSupport bool
+ }{
+ {
+ name: "kitty terminal via KITTY_WINDOW_ID",
+ envVars: map[string]string{
+ "KITTY_WINDOW_ID": "1",
+ },
+ wantSupport: true,
+ },
+ {
+ name: "kitty via TERM_PROGRAM",
+ envVars: map[string]string{
+ "TERM_PROGRAM": "kitty",
+ },
+ wantSupport: true,
+ },
+ {
+ name: "kitty via TERM_PROGRAM uppercase",
+ envVars: map[string]string{
+ "TERM_PROGRAM": "KITTY",
+ },
+ wantSupport: true,
+ },
+ {
+ name: "ghostty via TERM_PROGRAM",
+ envVars: map[string]string{
+ "TERM_PROGRAM": "ghostty",
+ },
+ wantSupport: true,
+ },
+ {
+ name: "ghostty via TERM_PROGRAM uppercase",
+ envVars: map[string]string{
+ "TERM_PROGRAM": "Ghostty",
+ },
+ wantSupport: true,
+ },
+ {
+ name: "wezterm via TERM_PROGRAM",
+ envVars: map[string]string{
+ "TERM_PROGRAM": "WezTerm",
+ },
+ wantSupport: true,
+ },
+ {
+ name: "ghostty via TERM variable",
+ envVars: map[string]string{
+ "TERM": "xterm-ghostty",
+ },
+ wantSupport: true,
+ },
+ {
+ name: "kitty via TERM variable",
+ envVars: map[string]string{
+ "TERM": "xterm-kitty",
+ },
+ wantSupport: true,
+ },
+ {
+ name: "unsupported terminal - Apple Terminal",
+ envVars: map[string]string{
+ "TERM_PROGRAM": "Apple_Terminal",
+ "TERM": "xterm-256color",
+ },
+ wantSupport: false,
+ },
+ {
+ name: "unsupported terminal - iTerm2",
+ envVars: map[string]string{
+ "TERM_PROGRAM": "iTerm.app",
+ "TERM": "xterm-256color",
+ },
+ wantSupport: false,
+ },
+ {
+ name: "unsupported terminal - GNOME Terminal",
+ envVars: map[string]string{
+ "TERM": "xterm",
+ },
+ wantSupport: false,
+ },
+ {
+ name: "no environment variables set",
+ envVars: map[string]string{},
+ wantSupport: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Save original env vars
+ origVars := map[string]string{
+ "KITTY_WINDOW_ID": os.Getenv("KITTY_WINDOW_ID"),
+ "TERM_PROGRAM": os.Getenv("TERM_PROGRAM"),
+ "TERM": os.Getenv("TERM"),
+ }
+
+ // Clear all relevant env vars first
+ os.Unsetenv("KITTY_WINDOW_ID")
+ os.Unsetenv("TERM_PROGRAM")
+ os.Unsetenv("TERM")
+
+ // Set test env vars
+ for key, val := range tt.envVars {
+ os.Setenv(key, val)
+ }
+
+ // Cleanup after test
+ defer func() {
+ for key, val := range origVars {
+ if val == "" {
+ os.Unsetenv(key)
+ } else {
+ os.Setenv(key, val)
+ }
+ }
+ }()
+
+ got := supportsKittyGraphics()
+ if got != tt.wantSupport {
+ t.Errorf("supportsKittyGraphics() = %v, want %v", got, tt.wantSupport)
+ }
+ })
+ }
+}
+
+func TestSupportsSixelGraphics(t *testing.T) {
+ tests := []struct {
+ name string
+ envVars map[string]string
+ wantSupport bool
+ }{
+ {
+ name: "iterm2 via TERM_PROGRAM",
+ envVars: map[string]string{
+ "TERM_PROGRAM": "iTerm.app",
+ },
+ wantSupport: true,
+ },
+ {
+ name: "wezterm via TERM_PROGRAM",
+ envVars: map[string]string{
+ "TERM_PROGRAM": "WezTerm",
+ },
+ wantSupport: true,
+ },
+ {
+ name: "wezterm lowercase",
+ envVars: map[string]string{
+ "TERM_PROGRAM": "wezterm",
+ },
+ wantSupport: true,
+ },
+ {
+ name: "rio via TERM_PROGRAM",
+ envVars: map[string]string{
+ "TERM_PROGRAM": "rio",
+ },
+ wantSupport: true,
+ },
+ {
+ name: "konsole via TERM_PROGRAM",
+ envVars: map[string]string{
+ "TERM_PROGRAM": "Konsole",
+ },
+ wantSupport: true,
+ },
+ {
+ name: "foot via TERM",
+ envVars: map[string]string{
+ "TERM": "foot",
+ },
+ wantSupport: true,
+ },
+ {
+ name: "foot with suffix",
+ envVars: map[string]string{
+ "TERM": "foot-extra",
+ },
+ wantSupport: true,
+ },
+ {
+ name: "xterm-sixel via TERM",
+ envVars: map[string]string{
+ "TERM": "xterm-sixel",
+ },
+ wantSupport: true,
+ },
+ {
+ name: "unsupported terminal - Apple Terminal",
+ envVars: map[string]string{
+ "TERM_PROGRAM": "Apple_Terminal",
+ "TERM": "xterm-256color",
+ },
+ wantSupport: false,
+ },
+ {
+ name: "unsupported terminal - Alacritty",
+ envVars: map[string]string{
+ "TERM": "alacritty",
+ },
+ wantSupport: false,
+ },
+ {
+ name: "unsupported terminal - standard xterm",
+ envVars: map[string]string{
+ "TERM": "xterm",
+ },
+ wantSupport: false,
+ },
+ {
+ name: "unsupported terminal - xterm-256color",
+ envVars: map[string]string{
+ "TERM": "xterm-256color",
+ },
+ wantSupport: false,
+ },
+ {
+ name: "no environment variables set",
+ envVars: map[string]string{},
+ wantSupport: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Save original env vars
+ origVars := map[string]string{
+ "TERM_PROGRAM": os.Getenv("TERM_PROGRAM"),
+ "TERM": os.Getenv("TERM"),
+ }
+
+ // Clear all relevant env vars first
+ os.Unsetenv("TERM_PROGRAM")
+ os.Unsetenv("TERM")
+
+ // Set test env vars
+ for key, val := range tt.envVars {
+ os.Setenv(key, val)
+ }
+
+ // Cleanup
+ defer func() {
+ for key, val := range origVars {
+ if val == "" {
+ os.Unsetenv(key)
+ } else {
+ os.Setenv(key, val)
+ }
+ }
+ }()
+
+ got := supportsSixelGraphics()
+ if got != tt.wantSupport {
+ t.Errorf("supportsSixelGraphics() = %v, want %v", got, tt.wantSupport)
+ }
+ })
+ }
+}
diff --git a/cmd/card/cardlist.go b/cmd/card/cardlist.go
index b8e46b47..09883720 100644
--- a/cmd/card/cardlist.go
+++ b/cmd/card/cardlist.go
@@ -3,44 +3,58 @@ package card
import (
"encoding/json"
"fmt"
- "io"
- "net/http"
"strings"
- "time"
+ "github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
+ "github.com/digitalghost-dev/poke-cli/connections"
"github.com/digitalghost-dev/poke-cli/styling"
)
+var getCardData = connections.CallTCGData
+
type CardsModel struct {
+ AllRows []table.Row
Choice string
IllustratorMap map[string]string
ImageMap map[string]string
+ Loading bool
PriceMap map[string]string
- RegulationMarkMap map[string]string
- AllRows []table.Row
Quitting bool
+ RegulationMarkMap map[string]string
Search textinput.Model
SelectedOption string
SeriesName string
+ SetID string
+ Spinner spinner.Model
Table table.Model
TableStyles table.Styles
ViewImage bool
}
-const (
- activeTableSelectedBg lipgloss.Color = "#FFCC00"
- inactiveTableSelectedBg lipgloss.Color = "#808080"
+// Message type to carry fetched card data back to Update()
+type cardDataMsg struct {
+ allRows []table.Row
+ priceMap map[string]string
+ imageMap map[string]string
+ illustratorMap map[string]string
+ regulationMarkMap map[string]string
+ err error
+}
+
+var (
+ activeTableSelectedBg = styling.YellowColor
+ inactiveTableSelectedBg = lipgloss.Color("#808080")
)
func cardTableStyles(selectedBg lipgloss.Color) table.Styles {
s := table.DefaultStyles()
s.Header = s.Header.
BorderStyle(lipgloss.NormalBorder()).
- BorderForeground(lipgloss.Color("#FFCC00")).
+ BorderForeground(styling.YellowColor).
BorderBottom(true)
s.Selected = s.Selected.
Foreground(lipgloss.Color("#000")).
@@ -57,8 +71,65 @@ func (m *CardsModel) syncTableStylesForFocus() {
m.Table.SetStyles(m.TableStyles)
}
+// fetchCardsCmd does the actual data fetching and returns a cardDataMsg
+func fetchCardsCmd(setID string) tea.Cmd {
+ return func() tea.Msg {
+ url := fmt.Sprintf("https://uoddayfnfkebrijlpfbh.supabase.co/rest/v1/card_pricing_view?set_id=eq.%s&select=number_plus_name,market_price,image_url,illustrator,regulation_mark&order=localId", setID)
+ body, err := getCardData(url)
+ if err != nil {
+ return cardDataMsg{err: err}
+ }
+
+ var allCards []cardData
+ err = json.Unmarshal(body, &allCards)
+ if err != nil {
+ return cardDataMsg{err: err}
+ }
+
+ rows := make([]table.Row, len(allCards))
+ priceMap := make(map[string]string)
+ imageMap := make(map[string]string)
+ illustratorMap := make(map[string]string)
+ regulationMarkMap := make(map[string]string)
+
+ for i, card := range allCards {
+ rows[i] = []string{card.NumberPlusName}
+ if card.MarketPrice != 0 {
+ priceMap[card.NumberPlusName] = fmt.Sprintf("Price: $%.2f", card.MarketPrice)
+ } else {
+ priceMap[card.NumberPlusName] = "Pricing not available"
+ }
+
+ if card.Illustrator != "" {
+ illustratorMap[card.NumberPlusName] = "Illustrator: " + card.Illustrator
+ } else {
+ illustratorMap[card.NumberPlusName] = "Illustrator not available"
+ }
+
+ if card.RegulationMark != "" {
+ regulationMarkMap[card.NumberPlusName] = "Regulation: " + card.RegulationMark
+ } else {
+ regulationMarkMap[card.NumberPlusName] = "Regulation not available"
+ }
+
+ imageMap[card.NumberPlusName] = card.ImageURL
+ }
+
+ return cardDataMsg{
+ allRows: rows,
+ priceMap: priceMap,
+ imageMap: imageMap,
+ illustratorMap: illustratorMap,
+ regulationMarkMap: regulationMarkMap,
+ }
+ }
+}
+
func (m CardsModel) Init() tea.Cmd {
- return nil
+ return tea.Batch(
+ m.Spinner.Tick,
+ fetchCardsCmd(m.SetID),
+ )
}
func (m CardsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -82,6 +153,10 @@ func (m CardsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Quit
case "?":
if !m.Search.Focused() {
+ // Sync the selected option before quitting to ensure the correct card is shown
+ if row := m.Table.SelectedRow(); len(row) > 0 {
+ m.SelectedOption = row[0]
+ }
m.ViewImage = true
return m, tea.Quit
}
@@ -96,23 +171,66 @@ func (m CardsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.syncTableStylesForFocus()
return m, nil
}
- }
- if m.Search.Focused() {
- prev := m.Search.Value()
- m.Search, bubbleCmd = m.Search.Update(msg)
- if m.Search.Value() != prev {
- m.applyFilter()
+ case cardDataMsg:
+ // Data arrived - stop loading and build the table
+ if msg.err != nil {
+ m.Quitting = true
+ return m, tea.Quit
}
- } else {
- m.Table, bubbleCmd = m.Table.Update(msg)
+
+ ti := textinput.New()
+ ti.Placeholder = "type name..."
+ ti.Prompt = "🔎 "
+ ti.CharLimit = 24
+ ti.Width = 30
+ ti.Blur()
+
+ t := table.New(
+ table.WithColumns([]table.Column{{Title: "Card Name", Width: 35}}),
+ table.WithRows(msg.allRows),
+ table.WithFocused(true),
+ table.WithHeight(27),
+ )
+
+ styles := cardTableStyles(activeTableSelectedBg)
+ t.SetStyles(styles)
+
+ m.AllRows = msg.allRows
+ m.PriceMap = msg.priceMap
+ m.ImageMap = msg.imageMap
+ m.IllustratorMap = msg.illustratorMap
+ m.RegulationMarkMap = msg.regulationMarkMap
+ m.Search = ti
+ m.Table = t
+ m.TableStyles = styles
+ m.Loading = false
+ return m, nil
+
+ case spinner.TickMsg:
+ var cmd tea.Cmd
+ m.Spinner, cmd = m.Spinner.Update(msg)
+ return m, cmd
}
- // Keep the selected option in sync on every update
- if row := m.Table.SelectedRow(); len(row) > 0 {
- name := row[0]
- if name != m.SelectedOption {
- m.SelectedOption = name
+ // Only handle search/table updates when not loading
+ if !m.Loading {
+ if m.Search.Focused() {
+ prev := m.Search.Value()
+ m.Search, bubbleCmd = m.Search.Update(msg)
+ if m.Search.Value() != prev {
+ m.applyFilter()
+ }
+ } else {
+ m.Table, bubbleCmd = m.Table.Update(msg)
+ }
+
+ // Keep the selected option in sync on every update
+ if row := m.Table.SelectedRow(); len(row) > 0 {
+ name := row[0]
+ if name != m.SelectedOption {
+ m.SelectedOption = name
+ }
}
}
@@ -145,6 +263,11 @@ func (m CardsModel) View() string {
if m.Quitting {
return "\n Quitting card search...\n\n"
}
+ if m.Loading {
+ return lipgloss.NewStyle().Padding(2).Render(
+ m.Spinner.View() + " Loading cards...",
+ )
+ }
selectedCard := ""
if row := m.Table.SelectedRow(); len(row) > 0 {
@@ -165,7 +288,7 @@ func (m CardsModel) View() string {
Width(40).
Height(29).
Border(lipgloss.RoundedBorder()).
- BorderForeground(lipgloss.Color("#FFCC00")).
+ BorderForeground(styling.YellowColor).
Padding(1).
Render(selectedCard)
@@ -186,109 +309,15 @@ type cardData struct {
RegulationMark string `json:"regulation_mark"`
}
-// CardsList creates and returns a new CardsModel with cards from a specific set
+// CardsList returns a minimal model - data fetching happens via Init()
func CardsList(setID string) (CardsModel, error) {
- url := fmt.Sprintf("https://uoddayfnfkebrijlpfbh.supabase.co/rest/v1/card_pricing_view?set_id=eq.%s&select=number_plus_name,market_price,image_url,illustrator,regulation_mark&order=localId", setID)
- body, err := getCardData(url)
- if err != nil {
- return CardsModel{}, fmt.Errorf("failed to fetch card data: %w", err)
- }
-
- var allCards []cardData
- err = json.Unmarshal(body, &allCards)
- if err != nil {
- return CardsModel{}, fmt.Errorf("failed to unmarshal card data: %w", err)
- }
-
- // Extract card names and build table rows + price map
- rows := make([]table.Row, len(allCards))
- allRows := rows
-
- priceMap := make(map[string]string)
- imageMap := make(map[string]string)
- illustratorMap := make(map[string]string)
- regulationMarkMap := make(map[string]string)
-
- for i, card := range allCards {
- rows[i] = []string{card.NumberPlusName}
- if card.MarketPrice != 0 {
- priceMap[card.NumberPlusName] = fmt.Sprintf("Price: $%.2f", card.MarketPrice)
- } else {
- priceMap[card.NumberPlusName] = "Pricing not available"
- }
-
- if card.Illustrator != "" {
- illustratorMap[card.NumberPlusName] = "Illustrator: " + card.Illustrator
- } else {
- illustratorMap[card.NumberPlusName] = "Illustrator not available"
- }
-
- if card.RegulationMark != "" {
- regulationMarkMap[card.NumberPlusName] = "Regulation: " + card.RegulationMark
- } else {
- regulationMarkMap[card.NumberPlusName] = "Regulation not available"
- }
-
- imageMap[card.NumberPlusName] = card.ImageURL
- }
-
- ti := textinput.New()
- ti.Placeholder = "type name..."
- ti.Prompt = "🔎 "
- ti.CharLimit = 24
- ti.Width = 30
- ti.Blur()
-
- t := table.New(
- table.WithColumns([]table.Column{{Title: "Card Name", Width: 35}}),
- table.WithRows(rows),
- table.WithFocused(true),
- table.WithHeight(27),
- )
-
- styles := cardTableStyles(activeTableSelectedBg)
- t.SetStyles(styles)
+ s := spinner.New()
+ s.Spinner = spinner.Dot
+ s.Style = styling.Yellow
return CardsModel{
- AllRows: allRows,
- IllustratorMap: illustratorMap,
- ImageMap: imageMap,
- PriceMap: priceMap,
- RegulationMarkMap: regulationMarkMap,
- Search: ti,
- Table: t,
- TableStyles: styles,
+ SetID: setID,
+ Loading: true,
+ Spinner: s,
}, nil
}
-
-// creating a function variable to swap the implementation in tests
-var getCardData = CallCardData
-
-func CallCardData(url string) ([]byte, error) {
- req, err := http.NewRequest("GET", url, nil)
- if err != nil {
- return nil, fmt.Errorf("error creating request: %w", err)
- }
-
- req.Header.Add("apikey", "sb_publishable_oondaaAIQC-wafhEiNgpSQ_reRiEp7j")
- req.Header.Add("Authorization", "Bearer sb_publishable_oondaaAIQC-wafhEiNgpSQ_reRiEp7j")
- req.Header.Add("Content-Type", "application/json")
-
- client := &http.Client{Timeout: 60 * time.Second}
- resp, err := client.Do(req)
- if err != nil {
- return nil, fmt.Errorf("error making GET request: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
- }
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("error reading response body: %w", err)
- }
-
- return body, nil
-}
diff --git a/cmd/card/cardlist_test.go b/cmd/card/cardlist_test.go
index a016a01b..b4dd5cc5 100644
--- a/cmd/card/cardlist_test.go
+++ b/cmd/card/cardlist_test.go
@@ -2,8 +2,6 @@ package card
import (
"errors"
- "net/http"
- "net/http/httptest"
"strings"
"testing"
@@ -12,18 +10,12 @@ import (
tea "github.com/charmbracelet/bubbletea"
)
-// testSupabaseKey is the publishable API key used in tests.
-// Extracted to a constant for easier maintenance if the key changes.
-const testSupabaseKey = "sb_publishable_oondaaAIQC-wafhEiNgpSQ_reRiEp7j"
-
func TestCardsModel_Init(t *testing.T) {
- model := CardsModel{
- SeriesName: "sv",
- }
+ model, _ := CardsList("sv01")
cmd := model.Init()
- if cmd != nil {
- t.Error("Init() should return nil")
+ if cmd == nil {
+ t.Error("Init() should return commands (spinner tick + fetch)")
}
}
@@ -282,161 +274,124 @@ func TestCardsModel_View_MissingPrice(t *testing.T) {
}
}
-func TestCardsList_SuccessAndFallbacks(t *testing.T) {
- // Save and restore getCardData stub
- original := getCardData
- defer func() { getCardData = original }()
-
- var capturedURL string
- getCardData = func(url string) ([]byte, error) {
- capturedURL = url
- // Return two cards: one with price + illustrator, one with fallbacks
- json := `[
- {"number_plus_name":"001/198 - Bulbasaur","market_price":1.5,"image_url":"https://example.com/bulba.png","illustrator":"Ken Sugimori"},
- {"number_plus_name":"002/198 - Ivysaur","market_price":0,"image_url":"https://example.com/ivy.png","illustrator":""}
- ]`
- return []byte(json), nil
- }
-
+func TestCardsList_ReturnsLoadingModel(t *testing.T) {
model, err := CardsList("set123")
if err != nil {
t.Fatalf("CardsList returned error: %v", err)
}
- // URL should target the correct set id and select fields
- if !strings.Contains(capturedURL, "set_id=eq.set123") {
- t.Errorf("expected URL to contain set_id filter, got %s", capturedURL)
+ if model.SetID != "set123" {
+ t.Errorf("expected SetID 'set123', got %s", model.SetID)
+ }
+
+ if !model.Loading {
+ t.Error("expected Loading to be true")
+ }
+
+ // View should show loading spinner
+ view := model.View()
+ if !strings.Contains(view, "Loading cards") {
+ t.Errorf("expected view to show loading state, got: %s", view)
}
- if !strings.Contains(capturedURL, "select=number_plus_name,market_price,image_url,illustrator") {
- t.Errorf("expected URL to contain select fields, got %s", capturedURL)
+}
+
+func TestCardDataMsg_PopulatesModel(t *testing.T) {
+ model, _ := CardsList("set123")
+
+ // Simulate receiving data via cardDataMsg
+ msg := cardDataMsg{
+ allRows: []table.Row{
+ {"001/198 - Bulbasaur"},
+ {"002/198 - Ivysaur"},
+ },
+ priceMap: map[string]string{
+ "001/198 - Bulbasaur": "Price: $1.50",
+ "002/198 - Ivysaur": "Pricing not available",
+ },
+ imageMap: map[string]string{
+ "001/198 - Bulbasaur": "https://example.com/bulba.png",
+ "002/198 - Ivysaur": "https://example.com/ivy.png",
+ },
+ illustratorMap: map[string]string{
+ "001/198 - Bulbasaur": "Illustrator: Ken Sugimori",
+ "002/198 - Ivysaur": "Illustrator not available",
+ },
+ regulationMarkMap: map[string]string{},
+ }
+
+ newModel, _ := model.Update(msg)
+ resultModel := newModel.(CardsModel)
+
+ if resultModel.Loading {
+ t.Error("Loading should be false after receiving data")
}
// PriceMap expectations
- if got := model.PriceMap["001/198 - Bulbasaur"]; got != "Price: $1.50" {
+ if got := resultModel.PriceMap["001/198 - Bulbasaur"]; got != "Price: $1.50" {
t.Errorf("unexpected price for Bulbasaur: %s", got)
}
- if got := model.PriceMap["002/198 - Ivysaur"]; got != "Pricing not available" {
+ if got := resultModel.PriceMap["002/198 - Ivysaur"]; got != "Pricing not available" {
t.Errorf("unexpected price for Ivysaur: %s", got)
}
// IllustratorMap expectations
- if got := model.IllustratorMap["001/198 - Bulbasaur"]; got != "Illustrator: Ken Sugimori" {
+ if got := resultModel.IllustratorMap["001/198 - Bulbasaur"]; got != "Illustrator: Ken Sugimori" {
t.Errorf("unexpected illustrator for Bulbasaur: %s", got)
}
- if got := model.IllustratorMap["002/198 - Ivysaur"]; got != "Illustrator not available" {
+ if got := resultModel.IllustratorMap["002/198 - Ivysaur"]; got != "Illustrator not available" {
t.Errorf("unexpected illustrator for Ivysaur: %s", got)
}
// Image map
- if model.ImageMap["001/198 - Bulbasaur"] != "https://example.com/bulba.png" {
- t.Errorf("unexpected image url for Bulbasaur: %s", model.ImageMap["001/198 - Bulbasaur"])
- }
- if model.ImageMap["002/198 - Ivysaur"] != "https://example.com/ivy.png" {
- t.Errorf("unexpected image url for Ivysaur: %s", model.ImageMap["002/198 - Ivysaur"])
+ if resultModel.ImageMap["001/198 - Bulbasaur"] != "https://example.com/bulba.png" {
+ t.Errorf("unexpected image url for Bulbasaur: %s", resultModel.ImageMap["001/198 - Bulbasaur"])
}
-
- if row := model.Table.SelectedRow(); len(row) == 0 {
- if model.View() == "" {
- t.Error("model view should render even if no row is selected")
- }
+ if resultModel.ImageMap["002/198 - Ivysaur"] != "https://example.com/ivy.png" {
+ t.Errorf("unexpected image url for Ivysaur: %s", resultModel.ImageMap["002/198 - Ivysaur"])
}
}
-func TestCardsList_FetchError(t *testing.T) {
- original := getCardData
- defer func() { getCardData = original }()
-
- getCardData = func(url string) ([]byte, error) {
- return nil, errors.New("network error")
- }
+func TestCardDataMsg_Error_QuitsModel(t *testing.T) {
+ model, _ := CardsList("set123")
- _, err := CardsList("set123")
- if err == nil {
- t.Fatal("expected error when fetch fails")
+ // Simulate receiving an error via cardDataMsg
+ msg := cardDataMsg{
+ err: errors.New("network error"),
}
-}
-func TestCardsList_BadJSON(t *testing.T) {
- original := getCardData
- defer func() { getCardData = original }()
+ newModel, cmd := model.Update(msg)
+ resultModel := newModel.(CardsModel)
- getCardData = func(url string) ([]byte, error) {
- return []byte("not-json"), nil
+ if !resultModel.Quitting {
+ t.Error("Quitting should be true when error received")
}
- _, err := CardsList("set123")
- if err == nil {
- t.Fatal("expected error for bad JSON payload")
+ if cmd == nil {
+ t.Error("Should return tea.Quit command on error")
}
}
-func TestCardsList_EmptyResult(t *testing.T) {
- original := getCardData
- defer func() { getCardData = original }()
-
- getCardData = func(url string) ([]byte, error) {
- return []byte("[]"), nil
- }
-
- model, err := CardsList("set123")
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
+func TestCardDataMsg_EmptyResult(t *testing.T) {
+ model, _ := CardsList("set123")
- if len(model.PriceMap) != 0 || len(model.IllustratorMap) != 0 || len(model.ImageMap) != 0 {
- t.Errorf("expected empty maps, got price:%d illus:%d image:%d", len(model.PriceMap), len(model.IllustratorMap), len(model.ImageMap))
+ // Simulate receiving empty data
+ msg := cardDataMsg{
+ allRows: []table.Row{},
+ priceMap: map[string]string{},
+ imageMap: map[string]string{},
+ illustratorMap: map[string]string{},
+ regulationMarkMap: map[string]string{},
}
- if model.View() == "" {
- t.Error("expected view to render with empty data")
- }
-}
+ newModel, _ := model.Update(msg)
+ resultModel := newModel.(CardsModel)
-func TestCallCardData_SendsHeadersAndReturnsBody(t *testing.T) {
- // Start a test HTTP server that validates headers and returns a body
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if got := r.Header.Get("apikey"); got != testSupabaseKey {
- t.Fatalf("missing or wrong apikey header: %q", got)
- }
- if got := r.Header.Get("Authorization"); got != "Bearer "+testSupabaseKey {
- t.Fatalf("missing or wrong Authorization header: %q", got)
- }
- if got := r.Header.Get("Content-Type"); got != "application/json" {
- t.Fatalf("missing or wrong Content-Type header: %q", got)
- }
-
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte(`{"ok":true}`))
- }))
- defer srv.Close()
-
- body, err := CallCardData(srv.URL)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if string(body) != `{"ok":true}` {
- t.Fatalf("unexpected body: %s", string(body))
+ if resultModel.Loading {
+ t.Error("Loading should be false after receiving data")
}
-}
-
-func TestCallCardData_Non200Error(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- http.Error(w, "boom", http.StatusInternalServerError)
- }))
- defer srv.Close()
- _, err := CallCardData(srv.URL)
- if err == nil {
- t.Fatal("expected error for non-200 status")
- }
- if !strings.Contains(err.Error(), "unexpected status code: 500") {
- t.Fatalf("error should mention status code, got: %v", err)
+ if len(resultModel.PriceMap) != 0 || len(resultModel.IllustratorMap) != 0 || len(resultModel.ImageMap) != 0 {
+ t.Errorf("expected empty maps, got price:%d illus:%d image:%d", len(resultModel.PriceMap), len(resultModel.IllustratorMap), len(resultModel.ImageMap))
}
}
-func TestCallCardData_BadURL(t *testing.T) {
- _, err := CallCardData("http://%41:80/") // invalid URL host
- if err == nil {
- t.Fatal("expected error for bad URL")
- }
-}
diff --git a/cmd/card/design.go b/cmd/card/design.go
index 097144c1..975d09bd 100644
--- a/cmd/card/design.go
+++ b/cmd/card/design.go
@@ -8,12 +8,13 @@ import (
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
+ "github.com/digitalghost-dev/poke-cli/styling"
)
var (
titleStyle = lipgloss.NewStyle().MarginLeft(2)
itemStyle = lipgloss.NewStyle().PaddingLeft(4)
- selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.AdaptiveColor{Light: "#E1AD01", Dark: "#FFDE00"})
+ selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(styling.YellowAdaptive)
paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4)
helpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1)
quitTextStyle = lipgloss.NewStyle().Margin(1, 0, 2, 4)
diff --git a/cmd/card/imageviewer.go b/cmd/card/imageviewer.go
index 3cc85a29..0eaed385 100644
--- a/cmd/card/imageviewer.go
+++ b/cmd/card/imageviewer.go
@@ -14,21 +14,26 @@ type ImageModel struct {
Loading bool
Spinner spinner.Model
ImageData string
+ Protocol string
}
type imageReadyMsg struct {
- sixelData string
+ imageData string
+ protocol string
err error
}
// fetchImageCmd downloads and renders the image asynchronously
func fetchImageCmd(imageURL string) tea.Cmd {
return func() tea.Msg {
- sixelData, err := CardImage(imageURL)
+ imageData, protocol, err := CardImage(imageURL)
if err != nil {
return imageReadyMsg{err: err}
}
- return imageReadyMsg{sixelData: sixelData}
+ return imageReadyMsg{
+ imageData: imageData,
+ protocol: protocol,
+ }
}
}
@@ -48,13 +53,16 @@ func (m ImageModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.ImageData = ""
} else {
m.Error = nil
- m.ImageData = msg.sixelData
+ m.ImageData = msg.imageData
+ m.Protocol = msg.protocol
}
return m, nil
+
case spinner.TickMsg:
var cmd tea.Cmd
m.Spinner, cmd = m.Spinner.Update(msg)
return m, cmd
+
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc":
@@ -71,8 +79,14 @@ func (m ImageModel) View() string {
)
}
if m.Error != nil {
- return styling.Red.Render(m.Error.Error())
+ // Styling the error message with padding for better readability
+ return lipgloss.NewStyle().
+ Padding(2).
+ BorderStyle(lipgloss.RoundedBorder()).
+ BorderForeground(styling.YellowColor).
+ Render(styling.Red.Render(m.Error.Error()))
}
+
return m.ImageData
}
diff --git a/cmd/card/imageviewer_test.go b/cmd/card/imageviewer_test.go
index 4d86e98c..381014d9 100644
--- a/cmd/card/imageviewer_test.go
+++ b/cmd/card/imageviewer_test.go
@@ -136,7 +136,7 @@ func TestImageRenderer_InitializesCorrectly(t *testing.T) {
func TestImageModel_Update_ImageReady(t *testing.T) {
model := ImageRenderer("Charizard", "http://example.com/charizard.png")
- msg := imageReadyMsg{sixelData: "test-sixel-data-456"}
+ msg := imageReadyMsg{imageData: "test-image-data-456", protocol: "Kitty"}
newModel, cmd := model.Update(msg)
if cmd != nil {
@@ -148,9 +148,13 @@ func TestImageModel_Update_ImageReady(t *testing.T) {
t.Error(`Update with imageReadyMsg should set Loading to false`)
}
- if updatedModel.ImageData != "test-sixel-data-456" {
+ if updatedModel.ImageData != "test-image-data-456" {
t.Errorf("Update with imageReadyMsg should set ImageData, got %v", updatedModel.ImageData)
}
+
+ if updatedModel.Protocol != "Kitty" {
+ t.Errorf("Update with imageReadyMsg should set Protocol, got %v", updatedModel.Protocol)
+ }
}
func TestImageModel_Update_SpinnerTick(t *testing.T) {
diff --git a/cmd/card/setslist.go b/cmd/card/setslist.go
index df75aefb..69afbaff 100644
--- a/cmd/card/setslist.go
+++ b/cmd/card/setslist.go
@@ -3,25 +3,72 @@ package card
import (
"encoding/json"
"fmt"
- "io"
- "net/http"
- "time"
"github.com/charmbracelet/bubbles/list"
+ "github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/digitalghost-dev/poke-cli/connections"
+ "github.com/digitalghost-dev/poke-cli/styling"
)
+var getSetsData = connections.CallTCGData
+
type SetsModel struct {
- List list.Model
Choice string
- SetID string
+ Loading bool
+ List list.Model
Quitting bool
SeriesName string
- setsIDMap map[string]string // Maps set name -> set_id
+ SetID string
+ SetsIDMap map[string]string // Maps set name -> set_id
+ Spinner spinner.Model
+}
+
+// Message type to carry fetched data back to Update()
+type setsDataMsg struct {
+ items []list.Item
+ setsIDMap map[string]string
+ seriesID string
+ err error
+}
+
+func fetchSetsCmd(seriesID string) tea.Cmd {
+ return func() tea.Msg {
+ body, err := getSetsData("https://uoddayfnfkebrijlpfbh.supabase.co/rest/v1/sets")
+ if err != nil {
+ return setsDataMsg{err: err}
+ }
+
+ var allSets []setData
+ err = json.Unmarshal(body, &allSets)
+ if err != nil {
+ return setsDataMsg{err: err}
+ }
+
+ // Filter sets by series_id and build ID map
+ var items []list.Item
+ setsIDMap := make(map[string]string)
+ for _, set := range allSets {
+ if set.SeriesID == seriesID {
+ items = append(items, item(set.SetName))
+ setsIDMap[set.SetName] = set.SetID
+ }
+ }
+
+ return setsDataMsg{
+ items: items,
+ setsIDMap: setsIDMap,
+ seriesID: seriesID,
+ }
+ }
}
func (m SetsModel) Init() tea.Cmd {
- return nil
+ return tea.Batch(
+ m.Spinner.Tick,
+ fetchSetsCmd(m.SeriesName),
+ )
}
func (m SetsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -35,28 +82,67 @@ func (m SetsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
i, ok := m.List.SelectedItem().(item)
if ok {
m.Choice = string(i)
- m.SetID = m.setsIDMap[string(i)] // Look up the set_id
+ m.SetID = m.SetsIDMap[string(i)]
}
return m, tea.Quit
}
+ case setsDataMsg:
+ // Data arrived - stop loading and build the list
+ if msg.err != nil {
+ m.Quitting = true
+ return m, tea.Quit
+ }
+
+ const listWidth = 20
+ const listHeight = 20
+
+ l := list.New(msg.items, itemDelegate{}, listWidth, listHeight)
+ l.Title = fmt.Sprintf("Pick a set from the %s series", msg.seriesID)
+ l.SetShowStatusBar(false)
+ l.SetFilteringEnabled(false)
+ l.Styles.Title = titleStyle
+ l.Styles.PaginationStyle = paginationStyle
+ l.Styles.HelpStyle = helpStyle
+
+ m.List = l
+ m.SetsIDMap = msg.setsIDMap
+ m.Loading = false
+ return m, nil
+
+ case spinner.TickMsg:
+ var cmd tea.Cmd
+ m.Spinner, cmd = m.Spinner.Update(msg)
+ return m, cmd
+
case tea.WindowSizeMsg:
- m.List.SetWidth(msg.Width)
+ if !m.Loading {
+ m.List.SetWidth(msg.Width)
+ }
return m, nil
}
- var cmd tea.Cmd
- m.List, cmd = m.List.Update(msg)
- return m, cmd
+ // Only update the list if it's been initialized
+ if !m.Loading {
+ var cmd tea.Cmd
+ m.List, cmd = m.List.Update(msg)
+ return m, cmd
+ }
+ return m, nil
}
func (m SetsModel) View() string {
- if m.Quitting {
- return "\n Quitting card search...\n\n"
- }
if m.Choice != "" {
return quitTextStyle.Render("Set selected:", m.Choice)
}
+ if m.Loading {
+ return lipgloss.NewStyle().Padding(2).Render(
+ m.Spinner.View() + "Loading sets...",
+ )
+ }
+ if m.Quitting {
+ return "\n Quitting card search...\n\n"
+ }
return "\n" + m.List.View()
}
@@ -71,75 +157,16 @@ type setData struct {
Symbol string `json:"symbol"`
}
-// creating a function variable to swap the implementation in tests
-var getSetsData = callSetsData
-
+// SetsList returns a minimal model - data fetching happens via Init()
func SetsList(seriesID string) (SetsModel, error) {
- body, err := getSetsData("https://uoddayfnfkebrijlpfbh.supabase.co/rest/v1/sets")
- if err != nil {
- return SetsModel{}, fmt.Errorf("error getting sets data: %v", err)
- }
- var allSets []setData
-
- err = json.Unmarshal(body, &allSets)
- if err != nil {
- return SetsModel{}, fmt.Errorf("error parsing sets data: %v", err)
- }
-
- // Filter sets by series_id and build ID map
- var items []list.Item
- setsIDMap := make(map[string]string)
- for _, set := range allSets {
- if set.SeriesID == seriesID {
- items = append(items, item(set.SetName))
- setsIDMap[set.SetName] = set.SetID // Map name -> ID
- }
- }
-
- const listWidth = 20
- const listHeight = 20
-
- l := list.New(items, itemDelegate{}, listWidth, listHeight)
- l.Title = fmt.Sprintf("Pick a set from the %s series", seriesID)
- l.SetShowStatusBar(false)
- l.SetFilteringEnabled(false)
- l.Styles.Title = titleStyle
- l.Styles.PaginationStyle = paginationStyle
- l.Styles.HelpStyle = helpStyle
+ s := spinner.New()
+ s.Spinner = spinner.Dot
+ s.Style = styling.Yellow
return SetsModel{
- List: l,
- SeriesName: seriesID,
- setsIDMap: setsIDMap,
- },
- nil
+ SeriesName: seriesID,
+ Loading: true,
+ Spinner: s,
+ }, nil
}
-func callSetsData(url string) ([]byte, error) {
- req, err := http.NewRequest("GET", url, nil)
- if err != nil {
- return nil, fmt.Errorf("error creating request: %w", err)
- }
-
- req.Header.Add("apikey", "sb_publishable_oondaaAIQC-wafhEiNgpSQ_reRiEp7j")
- req.Header.Add("Authorization", "Bearer sb_publishable_oondaaAIQC-wafhEiNgpSQ_reRiEp7j")
- req.Header.Add("Content-Type", "application/json")
-
- client := &http.Client{Timeout: 60 * time.Second}
- resp, err := client.Do(req)
- if err != nil {
- return nil, fmt.Errorf("error making GET request: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
- }
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("error reading response body: %w", err)
- }
-
- return body, nil
-}
diff --git a/cmd/card/setslist_test.go b/cmd/card/setslist_test.go
index 1fcdc806..ee2d9178 100644
--- a/cmd/card/setslist_test.go
+++ b/cmd/card/setslist_test.go
@@ -2,8 +2,6 @@ package card
import (
"errors"
- "net/http"
- "net/http/httptest"
"strings"
"testing"
@@ -12,14 +10,11 @@ import (
)
func TestSetsModel_Init(t *testing.T) {
- model := SetsModel{
- SeriesName: "sv",
- Quitting: false,
- }
+ model, _ := SetsList("sv")
cmd := model.Init()
- if cmd != nil {
- t.Error("Init() should return nil")
+ if cmd == nil {
+ t.Error("Init() should return commands (spinner tick + fetch)")
}
}
@@ -179,7 +174,7 @@ func TestSetsModel_Update_EnterKey(t *testing.T) {
model := SetsModel{
List: l,
- setsIDMap: setsIDMap,
+ SetsIDMap: setsIDMap,
}
msg := tea.KeyMsg{Type: tea.KeyEnter}
@@ -190,169 +185,97 @@ func TestSetsModel_Update_EnterKey(t *testing.T) {
}
}
-func TestCallSetsData_SendsHeadersAndReturnsBody(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if got := r.Header.Get("apikey"); got != testSupabaseKey {
- t.Fatalf("missing or wrong apikey header: %q", got)
- }
- if got := r.Header.Get("Authorization"); got != "Bearer "+testSupabaseKey {
- t.Fatalf("missing or wrong Authorization header: %q", got)
- }
- if got := r.Header.Get("Content-Type"); got != "application/json" {
- t.Fatalf("missing or wrong Content-Type header: %q", got)
- }
-
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte(`{"ok":true}`))
- }))
- defer srv.Close()
-
- body, err := callSetsData(srv.URL)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if string(body) != `{"ok":true}` {
- t.Fatalf("unexpected body: %s", string(body))
- }
-}
-
-func TestCallSetsData_Non200Error(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- http.Error(w, "boom", http.StatusInternalServerError)
- }))
- defer srv.Close()
-
- _, err := callSetsData(srv.URL)
- if err == nil {
- t.Fatal("expected error for non-200 status")
- }
- if !strings.Contains(err.Error(), "unexpected status code: 500") {
- t.Fatalf("error should mention status code, got: %v", err)
- }
-}
-
-func TestCallSetsData_BadURL(t *testing.T) {
- _, err := callSetsData("http://%41:80/") // invalid URL host
- if err == nil {
- t.Fatal("expected error for bad URL")
- }
-}
-
func TestSetsList_Success(t *testing.T) {
- original := getSetsData
- defer func() { getSetsData = original }()
-
- getSetsData = func(url string) ([]byte, error) {
- json := `[
- {"series_id":"sv","set_id":"sv01","set_name":"Scarlet & Violet","official_card_count":198,"total_card_count":258,"logo":"https://example.com/sv01.png","symbol":"https://example.com/sv01-symbol.png"},
- {"series_id":"sv","set_id":"sv02","set_name":"Paldea Evolved","official_card_count":193,"total_card_count":279,"logo":"https://example.com/sv02.png","symbol":"https://example.com/sv02-symbol.png"},
- {"series_id":"swsh","set_id":"swsh01","set_name":"Sword & Shield","official_card_count":202,"total_card_count":216,"logo":"https://example.com/swsh01.png","symbol":"https://example.com/swsh01-symbol.png"}
- ]`
- return []byte(json), nil
- }
-
model, err := SetsList("sv")
if err != nil {
t.Fatalf("SetsList returned error: %v", err)
}
- // Should only have 2 sets (filtered by series_id "sv")
+ // SetsList now returns minimal model with Loading=true
if model.SeriesName != "sv" {
t.Errorf("expected SeriesName 'sv', got %s", model.SeriesName)
}
- // Check setsIDMap has correct mappings
- if model.setsIDMap["Scarlet & Violet"] != "sv01" {
- t.Errorf("expected setsIDMap['Scarlet & Violet'] = 'sv01', got %s", model.setsIDMap["Scarlet & Violet"])
- }
- if model.setsIDMap["Paldea Evolved"] != "sv02" {
- t.Errorf("expected setsIDMap['Paldea Evolved'] = 'sv02', got %s", model.setsIDMap["Paldea Evolved"])
- }
-
- // swsh set should not be in the map
- if _, exists := model.setsIDMap["Sword & Shield"]; exists {
- t.Error("Sword & Shield should not be in setsIDMap (different series)")
+ if !model.Loading {
+ t.Error("expected Loading to be true")
}
+ // View should show loading spinner
if model.View() == "" {
- t.Error("model view should render")
+ t.Error("model view should render loading state")
}
}
-func TestSetsList_FetchError(t *testing.T) {
- original := getSetsData
- defer func() { getSetsData = original }()
+func TestSetsDataMsg_PopulatesModel(t *testing.T) {
+ // Start with a loading model
+ model, _ := SetsList("sv")
- getSetsData = func(url string) ([]byte, error) {
- return nil, errors.New("network error")
+ // Simulate receiving data via setsDataMsg
+ msg := setsDataMsg{
+ items: []list.Item{
+ item("Scarlet & Violet"),
+ item("Paldea Evolved"),
+ },
+ setsIDMap: map[string]string{
+ "Scarlet & Violet": "sv01",
+ "Paldea Evolved": "sv02",
+ },
+ seriesID: "sv",
}
- _, err := SetsList("sv")
- if err == nil {
- t.Fatal("expected error when fetch fails")
- }
- if !strings.Contains(err.Error(), "error getting sets data") {
- t.Errorf("error should mention 'error getting sets data', got: %v", err)
- }
-}
+ newModel, _ := model.Update(msg)
+ resultModel := newModel.(SetsModel)
-func TestSetsList_BadJSON(t *testing.T) {
- original := getSetsData
- defer func() { getSetsData = original }()
-
- getSetsData = func(url string) ([]byte, error) {
- return []byte("not-json"), nil
+ if resultModel.Loading {
+ t.Error("Loading should be false after receiving data")
}
- _, err := SetsList("sv")
- if err == nil {
- t.Fatal("expected error for bad JSON payload")
+ if resultModel.SetsIDMap["Scarlet & Violet"] != "sv01" {
+ t.Errorf("expected SetsIDMap['Scarlet & Violet'] = 'sv01', got %s", resultModel.SetsIDMap["Scarlet & Violet"])
}
- if !strings.Contains(err.Error(), "error parsing sets data") {
- t.Errorf("error should mention 'error parsing sets data', got: %v", err)
+ if resultModel.SetsIDMap["Paldea Evolved"] != "sv02" {
+ t.Errorf("expected SetsIDMap['Paldea Evolved'] = 'sv02', got %s", resultModel.SetsIDMap["Paldea Evolved"])
}
}
-func TestSetsList_EmptyResult(t *testing.T) {
- original := getSetsData
- defer func() { getSetsData = original }()
+func TestSetsDataMsg_Error_QuitsModel(t *testing.T) {
+ model, _ := SetsList("sv")
- getSetsData = func(url string) ([]byte, error) {
- return []byte("[]"), nil
+ // Simulate receiving an error via setsDataMsg
+ msg := setsDataMsg{
+ err: errors.New("network error"),
}
- model, err := SetsList("sv")
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
+ newModel, cmd := model.Update(msg)
+ resultModel := newModel.(SetsModel)
- if len(model.setsIDMap) != 0 {
- t.Errorf("expected empty setsIDMap, got %d entries", len(model.setsIDMap))
+ if !resultModel.Quitting {
+ t.Error("Quitting should be true when error received")
}
- if model.View() == "" {
- t.Error("expected view to render with empty data")
+ if cmd == nil {
+ t.Error("Should return tea.Quit command on error")
}
}
-func TestSetsList_NoMatchingSeries(t *testing.T) {
- original := getSetsData
- defer func() { getSetsData = original }()
+func TestSetsDataMsg_EmptyResult(t *testing.T) {
+ model, _ := SetsList("sv")
- getSetsData = func(url string) ([]byte, error) {
- json := `[
- {"series_id":"swsh","set_id":"swsh01","set_name":"Sword & Shield","official_card_count":202,"total_card_count":216,"logo":"","symbol":""}
- ]`
- return []byte(json), nil
+ // Simulate receiving empty data
+ msg := setsDataMsg{
+ items: []list.Item{},
+ setsIDMap: map[string]string{},
+ seriesID: "sv",
}
- model, err := SetsList("sv")
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ newModel, _ := model.Update(msg)
+ resultModel := newModel.(SetsModel)
+
+ if resultModel.Loading {
+ t.Error("Loading should be false after receiving data")
}
- // No sets match "sv" series
- if len(model.setsIDMap) != 0 {
- t.Errorf("expected empty setsIDMap when no series match, got %d entries", len(model.setsIDMap))
+ if len(resultModel.SetsIDMap) != 0 {
+ t.Errorf("expected empty SetsIDMap, got %d entries", len(resultModel.SetsIDMap))
}
}
diff --git a/cmd/types/types.go b/cmd/types/types.go
index 918c1772..db0f4839 100644
--- a/cmd/types/types.go
+++ b/cmd/types/types.go
@@ -119,11 +119,11 @@ func createTypeSelectionTable() model {
s := table.DefaultStyles()
s.Header = s.Header.
BorderStyle(lipgloss.NormalBorder()).
- BorderForeground(lipgloss.Color("#FFCC00")).
+ BorderForeground(styling.YellowColor).
BorderBottom(true)
s.Selected = s.Selected.
Foreground(lipgloss.Color("#000")).
- Background(lipgloss.Color("#FFCC00"))
+ Background(styling.YellowColor)
tbl.SetStyles(s)
return model{table: tbl}
diff --git a/cmd/types/types_test.go b/cmd/types/types_test.go
index 995b90c3..52894ba1 100644
--- a/cmd/types/types_test.go
+++ b/cmd/types/types_test.go
@@ -74,11 +74,11 @@ func createTestModel() model {
s := table.DefaultStyles()
s.Header = s.Header.
BorderStyle(lipgloss.NormalBorder()).
- BorderForeground(lipgloss.Color("#FFCC00")).
+ BorderForeground(styling.YellowColor).
BorderBottom(true)
s.Selected = s.Selected.
Foreground(lipgloss.Color("#000")).
- Background(lipgloss.Color("#FFCC00"))
+ Background(styling.YellowColor)
t.SetStyles(s)
return model{table: t}
diff --git a/connections/connection.go b/connections/connection.go
index cbe9c287..3c35ffb8 100644
--- a/connections/connection.go
+++ b/connections/connection.go
@@ -102,3 +102,32 @@ func PokemonSpeciesApiCall(endpoint string, pokemonSpeciesName string, baseURL s
func TypesApiCall(endpoint string, typesName string, baseURL string) (structs.TypesJSONStruct, string, error) {
return FetchEndpoint[structs.TypesJSONStruct](endpoint, typesName, baseURL, "Type")
}
+
+func CallTCGData(url string) ([]byte, error) {
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("error creating request: %w", err)
+ }
+
+ req.Header.Add("apikey", "sb_publishable_oondaaAIQC-wafhEiNgpSQ_reRiEp7j")
+ req.Header.Add("Authorization", "Bearer sb_publishable_oondaaAIQC-wafhEiNgpSQ_reRiEp7j")
+ req.Header.Add("Content-Type", "application/json")
+
+ client := &http.Client{Timeout: 60 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("error making GET request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("error reading response body: %w", err)
+ }
+
+ return body, nil
+}
\ No newline at end of file
diff --git a/connections/connection_test.go b/connections/connection_test.go
index f2fdb824..8da96adc 100644
--- a/connections/connection_test.go
+++ b/connections/connection_test.go
@@ -329,3 +329,47 @@ func TestPokemonSpeciesApiCall(t *testing.T) {
assert.Contains(t, err.Error(), "Perhaps a typo?", "Expected helpful suggestion in error message")
})
}
+
+// testSupabaseKey is the publishable API key used in tests.
+const testSupabaseKey = "sb_publishable_oondaaAIQC-wafhEiNgpSQ_reRiEp7j"
+
+func TestCallTCGData(t *testing.T) {
+ t.Run("sends correct headers and returns body", func(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Validate headers
+ if got := r.Header.Get("apikey"); got != testSupabaseKey {
+ t.Fatalf("missing or wrong apikey header: %q", got)
+ }
+ if got := r.Header.Get("Authorization"); got != "Bearer "+testSupabaseKey {
+ t.Fatalf("missing or wrong Authorization header: %q", got)
+ }
+ if got := r.Header.Get("Content-Type"); got != "application/json" {
+ t.Fatalf("missing or wrong Content-Type header: %q", got)
+ }
+
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"ok":true}`))
+ }))
+ defer srv.Close()
+
+ body, err := CallTCGData(srv.URL)
+ require.NoError(t, err)
+ assert.Equal(t, `{"ok":true}`, string(body))
+ })
+
+ t.Run("returns error for non-200 status", func(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "boom", http.StatusInternalServerError)
+ }))
+ defer srv.Close()
+
+ _, err := CallTCGData(srv.URL)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "unexpected status code: 500")
+ })
+
+ t.Run("returns error for bad URL", func(t *testing.T) {
+ _, err := CallTCGData("http://%41:80/") // invalid URL host
+ require.Error(t, err)
+ })
+}
diff --git a/flags/pokemonflagset.go b/flags/pokemonflagset.go
index 27f86040..5c920a21 100644
--- a/flags/pokemonflagset.go
+++ b/flags/pokemonflagset.go
@@ -45,7 +45,7 @@ func header(header string) string {
HeaderBold := lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
- BorderForeground(lipgloss.Color("#FFCC00")).
+ BorderForeground(styling.YellowColor).
BorderTop(true).
Bold(true).
Render(header)
diff --git a/go.mod b/go.mod
index 0876fed9..956b178c 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module github.com/digitalghost-dev/poke-cli
-go 1.24.11
+go 1.24.12
require (
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
@@ -12,6 +12,8 @@ require (
github.com/charmbracelet/x/exp/teatest v0.0.0-20251110210702-903592506081
github.com/charmbracelet/x/term v0.2.2
github.com/disintegration/imaging v1.6.2
+ github.com/dolmen-go/kittyimg v0.0.0-20250610224728-874967bd8ea4
+ github.com/schollz/closestmatch v2.1.0+incompatible
github.com/stretchr/testify v1.11.1
golang.org/x/image v0.33.0
golang.org/x/text v0.31.0
@@ -47,7 +49,6 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
- github.com/schollz/closestmatch v2.1.0+incompatible // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/sys v0.38.0 // indirect
diff --git a/go.sum b/go.sum
index 78e742dc..2ee4fadb 100644
--- a/go.sum
+++ b/go.sum
@@ -52,6 +52,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
+github.com/dolmen-go/kittyimg v0.0.0-20250610224728-874967bd8ea4 h1:KGRb+vxMx5pGsfDjDSW2Th+b2OEflb0yC3s0daCmiYU=
+github.com/dolmen-go/kittyimg v0.0.0-20250610224728-874967bd8ea4/go.mod h1:2vk7ATPVcI7uW4Sh6PrSQvtO+Czmq8509xcg/y8Osd0=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
diff --git a/nfpm.yaml b/nfpm.yaml
index 25201c97..e2fded10 100644
--- a/nfpm.yaml
+++ b/nfpm.yaml
@@ -1,7 +1,7 @@
name: "poke-cli"
arch: "arm64"
platform: "linux"
-version: "v1.8.7"
+version: "v1.8.8"
section: "default"
version_schema: semver
maintainer: "Christian S"
diff --git a/styling/styling.go b/styling/styling.go
index 33bd823f..ecc1db1a 100644
--- a/styling/styling.go
+++ b/styling/styling.go
@@ -14,21 +14,33 @@ import (
const (
HyphenHint = "Use a hyphen when typing a name with a space."
+
+ // Brand colors - use these instead of hardcoding hex values
+ PrimaryYellow = "#FFCC00" // Main accent color for borders, highlights
+ LightYellow = "#FFDE00" // Used in dark mode adaptive colors
+ DarkYellow = "#E1AD01" // Used in light mode adaptive colors
+)
+
+// Pre-defined lipgloss colors for convenience
+var (
+ YellowColor = lipgloss.Color(PrimaryYellow)
+ YellowAdaptive = lipgloss.AdaptiveColor{Light: DarkYellow, Dark: LightYellow}
+ YellowAdaptive2 = lipgloss.AdaptiveColor{Light: DarkYellow, Dark: PrimaryYellow}
)
var (
Green = lipgloss.NewStyle().Foreground(lipgloss.Color("#38B000"))
Red = lipgloss.NewStyle().Foreground(lipgloss.Color("#D00000"))
Gray = lipgloss.Color("#777777")
- Yellow = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#E1AD01", Dark: "#FFDE00"})
+ Yellow = lipgloss.NewStyle().Foreground(YellowAdaptive)
ColoredBullet = lipgloss.NewStyle().
SetString("•").
- Foreground(lipgloss.Color("#FFCC00"))
- CheckboxStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFCC00"))
+ Foreground(YellowColor)
+ CheckboxStyle = lipgloss.NewStyle().Foreground(YellowColor)
KeyMenu = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777"))
DocsLink = lipgloss.NewStyle().
- Foreground(lipgloss.AdaptiveColor{Light: "#E1AD01", Dark: "#FFCC00"}).
+ Foreground(YellowAdaptive2).
Render("\x1b]8;;https://docs.poke-cli.com\x1b\\docs.poke-cli.com\x1b]8;;\x1b\\")
StyleBold = lipgloss.NewStyle().Bold(true)
@@ -36,7 +48,7 @@ var (
StyleUnderline = lipgloss.NewStyle().Underline(true)
HelpBorder = lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
- BorderForeground(lipgloss.Color("#FFCC00"))
+ BorderForeground(YellowColor)
ErrorColor = lipgloss.NewStyle().Foreground(lipgloss.Color("#F2055C"))
ErrorBorder = lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
@@ -47,7 +59,7 @@ var (
BorderForeground(lipgloss.Color("#FF8C00"))
TypesTableBorder = lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
- BorderForeground(lipgloss.Color("#FFCC00"))
+ BorderForeground(YellowColor)
ColorMap = map[string]string{
"normal": "#B7B7A9",
"fire": "#FF4422",
@@ -146,7 +158,7 @@ func (col Color) Hex() string {
func FormTheme() *huh.Theme {
var (
- yellow = lipgloss.Color("#FFDE00")
+ yellow = lipgloss.Color(LightYellow)
blue = lipgloss.Color("#3B4CCA")
red = lipgloss.Color("#D00000")
black = lipgloss.Color("#000000")
diff --git a/testdata/main_latest_flag.golden b/testdata/main_latest_flag.golden
index cd926498..306c19b5 100644
--- a/testdata/main_latest_flag.golden
+++ b/testdata/main_latest_flag.golden
@@ -2,6 +2,6 @@
┃ ┃
┃ Latest available release ┃
┃ on GitHub: ┃
-┃ • v1.8.6 ┃
+┃ • v1.8.7 ┃
┃ ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛