Skip to content

Commit 216b90c

Browse files
adding search feature to card list view (#233)
1 parent 4908e7d commit 216b90c

2 files changed

Lines changed: 203 additions & 26 deletions

File tree

cmd/card/cardlist.go

Lines changed: 113 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import (
55
"fmt"
66
"io"
77
"net/http"
8+
"strings"
89
"time"
910

1011
"github.com/charmbracelet/bubbles/table"
12+
"github.com/charmbracelet/bubbles/textinput"
1113
tea "github.com/charmbracelet/bubbletea"
1214
"github.com/charmbracelet/lipgloss"
1315
"github.com/digitalghost-dev/poke-cli/styling"
@@ -19,13 +21,42 @@ type CardsModel struct {
1921
ImageMap map[string]string
2022
PriceMap map[string]string
2123
RegulationMarkMap map[string]string
24+
AllRows []table.Row
2225
Quitting bool
26+
Search textinput.Model
2327
SelectedOption string
2428
SeriesName string
2529
Table table.Model
30+
TableStyles table.Styles
2631
ViewImage bool
2732
}
2833

34+
const (
35+
activeTableSelectedBg lipgloss.Color = "#FFCC00"
36+
inactiveTableSelectedBg lipgloss.Color = "#808080"
37+
)
38+
39+
func cardTableStyles(selectedBg lipgloss.Color) table.Styles {
40+
s := table.DefaultStyles()
41+
s.Header = s.Header.
42+
BorderStyle(lipgloss.NormalBorder()).
43+
BorderForeground(lipgloss.Color("#FFCC00")).
44+
BorderBottom(true)
45+
s.Selected = s.Selected.
46+
Foreground(lipgloss.Color("#000")).
47+
Background(selectedBg)
48+
return s
49+
}
50+
51+
func (m *CardsModel) syncTableStylesForFocus() {
52+
if m.Search.Focused() {
53+
m.TableStyles = cardTableStyles(inactiveTableSelectedBg)
54+
} else {
55+
m.TableStyles = cardTableStyles(activeTableSelectedBg)
56+
}
57+
m.Table.SetStyles(m.TableStyles)
58+
}
59+
2960
func (m CardsModel) Init() tea.Cmd {
3061
return nil
3162
}
@@ -36,16 +67,46 @@ func (m CardsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
3667
switch msg := msg.(type) {
3768
case tea.KeyMsg:
3869
switch msg.String() {
39-
case "esc", "ctrl+c":
70+
case "ctrl+c":
4071
m.Quitting = true
4172
return m, tea.Quit
42-
case " ":
43-
m.ViewImage = true
73+
case "esc":
74+
// If in the search bar, exit search mode instead of quitting.
75+
if m.Search.Focused() {
76+
m.Search.Blur()
77+
m.Table.Focus()
78+
m.syncTableStylesForFocus()
79+
return m, nil
80+
}
81+
m.Quitting = true
4482
return m, tea.Quit
83+
case "?":
84+
if !m.Search.Focused() {
85+
m.ViewImage = true
86+
return m, tea.Quit
87+
}
88+
case "tab":
89+
if m.Search.Focused() {
90+
m.Search.Blur()
91+
m.Table.Focus()
92+
} else {
93+
m.Table.Blur()
94+
m.Search.Focus()
95+
}
96+
m.syncTableStylesForFocus()
97+
return m, nil
4598
}
4699
}
47100

48-
m.Table, bubbleCmd = m.Table.Update(msg)
101+
if m.Search.Focused() {
102+
prev := m.Search.Value()
103+
m.Search, bubbleCmd = m.Search.Update(msg)
104+
if m.Search.Value() != prev {
105+
m.applyFilter()
106+
}
107+
} else {
108+
m.Table, bubbleCmd = m.Table.Update(msg)
109+
}
49110

50111
// Keep the selected option in sync on every update
51112
if row := m.Table.SelectedRow(); len(row) > 0 {
@@ -58,6 +119,28 @@ func (m CardsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
58119
return m, bubbleCmd
59120
}
60121

122+
func (m *CardsModel) applyFilter() {
123+
q := strings.TrimSpace(strings.ToLower(m.Search.Value()))
124+
if q == "" {
125+
m.Table.SetRows(m.AllRows)
126+
m.Table.SetCursor(0)
127+
return
128+
}
129+
130+
filtered := make([]table.Row, 0, len(m.AllRows))
131+
for _, r := range m.AllRows {
132+
if len(r) == 0 {
133+
continue
134+
}
135+
if strings.Contains(strings.ToLower(r[0]), q) {
136+
filtered = append(filtered, r)
137+
}
138+
}
139+
140+
m.Table.SetRows(filtered)
141+
m.Table.SetCursor(0)
142+
}
143+
61144
func (m CardsModel) View() string {
62145
if m.Quitting {
63146
return "\n Quitting card search...\n\n"
@@ -75,7 +158,8 @@ func (m CardsModel) View() string {
75158
selectedCard = cardName + "\n---\n" + price + "\n---\n" + illustrator + "\n---\n" + regulationMark
76159
}
77160

78-
leftPanel := styling.TypesTableBorder.Render(m.Table.View())
161+
leftContent := lipgloss.JoinVertical(lipgloss.Left, m.Search.View(), m.Table.View())
162+
leftPanel := styling.TypesTableBorder.Render(leftContent)
79163

80164
rightPanel := lipgloss.NewStyle().
81165
Width(40).
@@ -87,9 +171,10 @@ func (m CardsModel) View() string {
87171

88172
screen := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel)
89173

90-
return fmt.Sprintf("Highlight a card!\n%s\n%s",
174+
return fmt.Sprintf(
175+
"Highlight a card!\n%s\n%s",
91176
screen,
92-
styling.KeyMenu.Render("↑ (move up)\n↓ (move down)\nspace (view image)\nctrl+c | esc (quit)"))
177+
styling.KeyMenu.Render("↑ (move up)\n↓ (move down)\n? (view image)\ntab (toggle search)\nctrl+c | esc (quit)"))
93178
}
94179

95180
type cardData struct {
@@ -117,10 +202,13 @@ func CardsList(setID string) (CardsModel, error) {
117202

118203
// Extract card names and build table rows + price map
119204
rows := make([]table.Row, len(allCards))
205+
allRows := rows
206+
120207
priceMap := make(map[string]string)
121208
imageMap := make(map[string]string)
122209
illustratorMap := make(map[string]string)
123210
regulationMarkMap := make(map[string]string)
211+
124212
for i, card := range allCards {
125213
rows[i] = []string{card.NumberPlusName}
126214
if card.MarketPrice != 0 {
@@ -144,29 +232,32 @@ func CardsList(setID string) (CardsModel, error) {
144232
imageMap[card.NumberPlusName] = card.ImageURL
145233
}
146234

235+
ti := textinput.New()
236+
ti.Placeholder = "type name..."
237+
ti.Prompt = "🔎 "
238+
ti.CharLimit = 24
239+
ti.Width = 30
240+
ti.Blur()
241+
147242
t := table.New(
148243
table.WithColumns([]table.Column{{Title: "Card Name", Width: 35}}),
149244
table.WithRows(rows),
150245
table.WithFocused(true),
151-
table.WithHeight(28),
246+
table.WithHeight(27),
152247
)
153248

154-
s := table.DefaultStyles()
155-
s.Header = s.Header.
156-
BorderStyle(lipgloss.NormalBorder()).
157-
BorderForeground(lipgloss.Color("#FFCC00")).
158-
BorderBottom(true)
159-
s.Selected = s.Selected.
160-
Foreground(lipgloss.Color("#000")).
161-
Background(lipgloss.Color("#FFCC00"))
162-
t.SetStyles(s)
249+
styles := cardTableStyles(activeTableSelectedBg)
250+
t.SetStyles(styles)
163251

164252
return CardsModel{
165-
IllustratorMap: illustratorMap,
166-
ImageMap: imageMap,
167-
PriceMap: priceMap,
168-
RegulationMarkMap: regulationMarkMap,
169-
Table: t,
253+
AllRows: allRows,
254+
IllustratorMap: illustratorMap,
255+
ImageMap: imageMap,
256+
PriceMap: priceMap,
257+
RegulationMarkMap: regulationMarkMap,
258+
Search: ti,
259+
Table: t,
260+
TableStyles: styles,
170261
}, nil
171262
}
172263

cmd/card/cardlist_test.go

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"testing"
99

1010
"github.com/charmbracelet/bubbles/table"
11+
"github.com/charmbracelet/bubbles/textinput"
1112
tea "github.com/charmbracelet/bubbletea"
1213
)
1314

@@ -91,7 +92,58 @@ func TestCardsModel_Update_CtrlC(t *testing.T) {
9192
}
9293
}
9394

94-
func TestCardsModel_Update_SpaceBar(t *testing.T) {
95+
func TestCardsModel_Update_TabTogglesSearchFocusAndTableSelectedBackground(t *testing.T) {
96+
rows := []table.Row{{"001/198 - Bulbasaur"}}
97+
columns := []table.Column{{Title: "Card Name", Width: 35}}
98+
99+
tbl := table.New(
100+
table.WithColumns(columns),
101+
table.WithRows(rows),
102+
table.WithFocused(true),
103+
)
104+
105+
search := textinput.New()
106+
search.Blur()
107+
108+
initialStyles := cardTableStyles(activeTableSelectedBg)
109+
tbl.SetStyles(initialStyles)
110+
111+
model := CardsModel{
112+
Search: search,
113+
Table: tbl,
114+
TableStyles: initialStyles,
115+
}
116+
117+
// Tab into the search bar.
118+
newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyTab})
119+
m1 := newModel.(CardsModel)
120+
if !m1.Search.Focused() {
121+
t.Fatal("expected search to be focused after tab")
122+
}
123+
124+
bg1 := m1.TableStyles.Selected.GetBackground()
125+
r1, g1, b1, a1 := bg1.RGBA()
126+
re, ge, be, ae := inactiveTableSelectedBg.RGBA()
127+
if r1 != re || g1 != ge || b1 != be || a1 != ae {
128+
t.Fatalf("expected selected background to be gray when searching; got RGBA(%d,%d,%d,%d)", r1, g1, b1, a1)
129+
}
130+
131+
// Tab back to the table.
132+
newModel2, _ := m1.Update(tea.KeyMsg{Type: tea.KeyTab})
133+
m2 := newModel2.(CardsModel)
134+
if m2.Search.Focused() {
135+
t.Fatal("expected search to be blurred after tabbing back")
136+
}
137+
138+
bg2 := m2.TableStyles.Selected.GetBackground()
139+
r2, g2, b2, a2 := bg2.RGBA()
140+
re2, ge2, be2, ae2 := activeTableSelectedBg.RGBA()
141+
if r2 != re2 || g2 != ge2 || b2 != be2 || a2 != ae2 {
142+
t.Fatalf("expected selected background to be yellow when table is focused; got RGBA(%d,%d,%d,%d)", r2, g2, b2, a2)
143+
}
144+
}
145+
146+
func TestCardsModel_Update_ViewImageKey_QuestionMark(t *testing.T) {
95147
rows := []table.Row{
96148
{"001/198 - Bulbasaur"},
97149
}
@@ -109,17 +161,51 @@ func TestCardsModel_Update_SpaceBar(t *testing.T) {
109161
ViewImage: false,
110162
}
111163

112-
msg := tea.KeyMsg{Type: tea.KeySpace}
164+
msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}
113165
newModel, cmd := model.Update(msg)
114166

115167
resultModel := newModel.(CardsModel)
116168

117169
if !resultModel.ViewImage {
118-
t.Error("ViewImage should be set to true when SPACE is pressed")
170+
t.Error("ViewImage should be set to true when '?' is pressed")
119171
}
120172

121173
if cmd == nil {
122-
t.Error("Update with SPACE should return tea.Quit command")
174+
t.Error("Update with '?' should return tea.Quit command")
175+
}
176+
}
177+
178+
func TestCardsModel_Update_ViewImageKey_DoesNotOverrideSearch(t *testing.T) {
179+
rows := []table.Row{{"001/198 - Bulbasaur"}}
180+
columns := []table.Column{{Title: "Card Name", Width: 35}}
181+
182+
tbl := table.New(
183+
table.WithColumns(columns),
184+
table.WithRows(rows),
185+
table.WithFocused(true),
186+
)
187+
188+
search := textinput.New()
189+
search.Focus()
190+
191+
model := CardsModel{
192+
Search: search,
193+
Table: tbl,
194+
ViewImage: false,
195+
}
196+
197+
msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}
198+
newModel, _ := model.Update(msg)
199+
resultModel := newModel.(CardsModel)
200+
201+
if resultModel.ViewImage {
202+
t.Fatal("expected ViewImage to remain false when typing '?' in the search field")
203+
}
204+
if resultModel.Quitting {
205+
t.Fatal("expected Quitting to remain false when typing in the search field")
206+
}
207+
if got := resultModel.Search.Value(); got != "?" {
208+
t.Fatalf("expected search input to receive '?'; got %q", got)
123209
}
124210
}
125211

0 commit comments

Comments
 (0)