Skip to content

Commit 8910c67

Browse files
committed
feat(tui): add type picker modal with 't' shortcut
- Add typepicker.go with filterable type selection - Press 't' in list or detail view to open the picker - Shows all available types with colors - Description shown below list for selected type - Renders as centered modal overlay
1 parent c3574a2 commit 8910c67

File tree

5 files changed

+269
-3
lines changed

5 files changed

+269
-3
lines changed

.beans/beans-coe1--tui-t-shortcut-to-change-bean-type.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
---
22
title: 'TUI: ''t'' shortcut to change bean type'
3-
status: backlog
3+
status: completed
44
type: feature
5+
priority: normal
56
created_at: 2025-12-12T22:38:27Z
6-
updated_at: 2025-12-12T22:38:27Z
7+
updated_at: 2025-12-12T23:03:02Z
78
parent: beans-xnp8
89
---
910

internal/tui/detail.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,15 @@ func (m detailModel) Update(msg tea.Msg) (detailModel, tea.Cmd) {
333333
currentStatus: m.bean.Status,
334334
}
335335
}
336+
337+
case "t":
338+
// Open type picker
339+
return m, func() tea.Msg {
340+
return openTypePickerMsg{
341+
beanID: m.bean.ID,
342+
currentType: m.bean.Type,
343+
}
344+
}
336345
}
337346
}
338347

@@ -402,6 +411,7 @@ func (m detailModel) View() string {
402411
footer += helpKeyStyle.Render("enter") + " " + helpStyle.Render("go to") + " "
403412
}
404413
footer += helpKeyStyle.Render("s") + " " + helpStyle.Render("status") + " " +
414+
helpKeyStyle.Render("t") + " " + helpStyle.Render("type") + " " +
405415
helpKeyStyle.Render("p") + " " + helpStyle.Render("parent") + " " +
406416
helpKeyStyle.Render("j/k") + " " + helpStyle.Render("scroll") + " " +
407417
helpKeyStyle.Render("esc") + " " + helpStyle.Render("back") + " " +

internal/tui/list.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,16 @@ func (m listModel) Update(msg tea.Msg) (listModel, tea.Cmd) {
278278
}
279279
}
280280
}
281+
case "t":
282+
// Open type picker for selected bean
283+
if item, ok := m.list.SelectedItem().(beanItem); ok {
284+
return m, func() tea.Msg {
285+
return openTypePickerMsg{
286+
beanID: item.bean.ID,
287+
currentType: item.bean.Type,
288+
}
289+
}
290+
}
281291
case "esc", "backspace":
282292
// If we have an active filter, clear it instead of quitting
283293
if m.hasActiveFilter() {
@@ -336,12 +346,14 @@ func (m listModel) View() string {
336346
if m.hasActiveFilter() {
337347
help = helpKeyStyle.Render("enter") + " " + helpStyle.Render("view") + " " +
338348
helpKeyStyle.Render("s") + " " + helpStyle.Render("status") + " " +
349+
helpKeyStyle.Render("t") + " " + helpStyle.Render("type") + " " +
339350
helpKeyStyle.Render("p") + " " + helpStyle.Render("parent") + " " +
340351
helpKeyStyle.Render("esc") + " " + helpStyle.Render("clear filter") + " " +
341352
helpKeyStyle.Render("q") + " " + helpStyle.Render("quit")
342353
} else {
343354
help = helpKeyStyle.Render("enter") + " " + helpStyle.Render("view") + " " +
344355
helpKeyStyle.Render("s") + " " + helpStyle.Render("status") + " " +
356+
helpKeyStyle.Render("t") + " " + helpStyle.Render("type") + " " +
345357
helpKeyStyle.Render("p") + " " + helpStyle.Render("parent") + " " +
346358
helpKeyStyle.Render("/") + " " + helpStyle.Render("filter") + " " +
347359
helpKeyStyle.Render("g t") + " " + helpStyle.Render("filter by tag") + " " +

internal/tui/tui.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const (
1919
viewTagPicker
2020
viewParentPicker
2121
viewStatusPicker
22+
viewTypePicker
2223
)
2324

2425
// beansChangedMsg is sent when beans change on disk (via file watcher)
@@ -50,6 +51,7 @@ type App struct {
5051
tagPicker tagPickerModel
5152
parentPicker parentPickerModel
5253
statusPicker statusPickerModel
54+
typePicker typePickerModel
5355
history []detailModel // stack of previous detail views for back navigation
5456
core *beancore.Core
5557
resolver *graph.Resolver
@@ -121,7 +123,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
121123
case "ctrl+c":
122124
return a, tea.Quit
123125
case "q":
124-
if a.state == viewDetail || a.state == viewTagPicker || a.state == viewParentPicker || a.state == viewStatusPicker {
126+
if a.state == viewDetail || a.state == viewTagPicker || a.state == viewParentPicker || a.state == viewStatusPicker || a.state == viewTypePicker {
125127
return a, tea.Quit
126128
}
127129
// For list, only quit if not filtering
@@ -208,6 +210,35 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
208210
}
209211
return a, a.list.loadBeans
210212

213+
case openTypePickerMsg:
214+
a.previousState = a.state
215+
a.typePicker = newTypePickerModel(msg.beanID, msg.currentType, a.config, a.width, a.height)
216+
a.state = viewTypePicker
217+
return a, a.typePicker.Init()
218+
219+
case closeTypePickerMsg:
220+
a.state = a.previousState
221+
return a, nil
222+
223+
case typeSelectedMsg:
224+
// Update the bean's type via GraphQL mutation
225+
_, err := a.resolver.Mutation().UpdateBean(context.Background(), msg.beanID, model.UpdateBeanInput{
226+
Type: &msg.beanType,
227+
})
228+
if err != nil {
229+
a.state = a.previousState
230+
return a, nil
231+
}
232+
// Return to the previous view and refresh
233+
a.state = a.previousState
234+
if a.state == viewDetail {
235+
updatedBean, _ := a.resolver.Query().Bean(context.Background(), msg.beanID)
236+
if updatedBean != nil {
237+
a.detail = newDetailModel(updatedBean, a.resolver, a.config, a.width, a.height)
238+
}
239+
}
240+
return a, a.list.loadBeans
241+
211242
case parentSelectedMsg:
212243
// Set the new parent via GraphQL mutation
213244
var parentID *string
@@ -271,6 +302,8 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
271302
a.parentPicker, cmd = a.parentPicker.Update(msg)
272303
case viewStatusPicker:
273304
a.statusPicker, cmd = a.statusPicker.Update(msg)
305+
case viewTypePicker:
306+
a.typePicker, cmd = a.typePicker.Update(msg)
274307
}
275308

276309
return a, cmd
@@ -307,6 +340,8 @@ func (a *App) View() string {
307340
return a.parentPicker.ModalView(a.getBackgroundView(), a.width, a.height)
308341
case viewStatusPicker:
309342
return a.statusPicker.ModalView(a.getBackgroundView(), a.width, a.height)
343+
case viewTypePicker:
344+
return a.typePicker.ModalView(a.getBackgroundView(), a.width, a.height)
310345
}
311346
return ""
312347
}

internal/tui/typepicker.go

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
package tui
2+
3+
import (
4+
"fmt"
5+
"io"
6+
7+
"github.com/charmbracelet/bubbles/list"
8+
tea "github.com/charmbracelet/bubbletea"
9+
"github.com/charmbracelet/lipgloss"
10+
"github.com/hmans/beans/internal/config"
11+
"github.com/hmans/beans/internal/ui"
12+
)
13+
14+
// typeSelectedMsg is sent when a type is selected from the picker
15+
type typeSelectedMsg struct {
16+
beanID string
17+
beanType string
18+
}
19+
20+
// closeTypePickerMsg is sent when the type picker is cancelled
21+
type closeTypePickerMsg struct{}
22+
23+
// openTypePickerMsg requests opening the type picker for a bean
24+
type openTypePickerMsg struct {
25+
beanID string
26+
currentType string
27+
}
28+
29+
// typeItem wraps a type to implement list.Item
30+
type typeItem struct {
31+
name string
32+
description string
33+
color string
34+
isCurrent bool
35+
}
36+
37+
func (i typeItem) Title() string { return i.name }
38+
func (i typeItem) Description() string { return i.description }
39+
func (i typeItem) FilterValue() string { return i.name + " " + i.description }
40+
41+
// typeItemDelegate handles rendering of type picker items
42+
type typeItemDelegate struct{}
43+
44+
func (d typeItemDelegate) Height() int { return 1 }
45+
func (d typeItemDelegate) Spacing() int { return 0 }
46+
func (d typeItemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil }
47+
48+
func (d typeItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
49+
item, ok := listItem.(typeItem)
50+
if !ok {
51+
return
52+
}
53+
54+
var cursor string
55+
if index == m.Index() {
56+
cursor = lipgloss.NewStyle().Foreground(ui.ColorPrimary).Bold(true).Render("▌") + " "
57+
} else {
58+
cursor = " "
59+
}
60+
61+
// Render type with color
62+
typeText := ui.RenderTypeText(item.name, item.color)
63+
64+
// Add current indicator
65+
var currentIndicator string
66+
if item.isCurrent {
67+
currentIndicator = ui.Muted.Render(" (current)")
68+
}
69+
70+
fmt.Fprint(w, cursor+typeText+currentIndicator)
71+
}
72+
73+
// typePickerModel is the model for the type picker view
74+
type typePickerModel struct {
75+
list list.Model
76+
beanID string
77+
currentType string
78+
width int
79+
height int
80+
}
81+
82+
func newTypePickerModel(beanID, currentType string, cfg *config.Config, width, height int) typePickerModel {
83+
// Get all types (hardcoded in config package)
84+
types := config.DefaultTypes
85+
86+
delegate := typeItemDelegate{}
87+
88+
// Build items list
89+
items := make([]list.Item, 0, len(types))
90+
selectedIndex := 0
91+
92+
for i, t := range types {
93+
isCurrent := t.Name == currentType
94+
if isCurrent {
95+
selectedIndex = i
96+
}
97+
items = append(items, typeItem{
98+
name: t.Name,
99+
description: t.Description,
100+
color: t.Color,
101+
isCurrent: isCurrent,
102+
})
103+
}
104+
105+
// Calculate modal dimensions
106+
modalWidth := max(40, min(60, width*50/100))
107+
modalHeight := max(10, min(16, height*50/100))
108+
listWidth := modalWidth - 6
109+
listHeight := modalHeight - 7
110+
111+
l := list.New(items, delegate, listWidth, listHeight)
112+
l.Title = "Select Type"
113+
l.SetShowStatusBar(false)
114+
l.SetFilteringEnabled(true)
115+
l.SetShowHelp(false)
116+
l.SetShowPagination(false)
117+
l.Styles.Title = listTitleStyle
118+
l.Styles.TitleBar = lipgloss.NewStyle().Padding(0, 0, 0, 0)
119+
l.Styles.FilterPrompt = lipgloss.NewStyle().Foreground(ui.ColorPrimary)
120+
l.Styles.FilterCursor = lipgloss.NewStyle().Foreground(ui.ColorPrimary)
121+
122+
// Select the current type
123+
if selectedIndex < len(items) {
124+
l.Select(selectedIndex)
125+
}
126+
127+
return typePickerModel{
128+
list: l,
129+
beanID: beanID,
130+
currentType: currentType,
131+
width: width,
132+
height: height,
133+
}
134+
}
135+
136+
func (m typePickerModel) Init() tea.Cmd {
137+
return nil
138+
}
139+
140+
func (m typePickerModel) Update(msg tea.Msg) (typePickerModel, tea.Cmd) {
141+
var cmd tea.Cmd
142+
143+
switch msg := msg.(type) {
144+
case tea.WindowSizeMsg:
145+
m.width = msg.Width
146+
m.height = msg.Height
147+
modalWidth := max(40, min(60, msg.Width*50/100))
148+
modalHeight := max(10, min(16, msg.Height*50/100))
149+
listWidth := modalWidth - 6
150+
listHeight := modalHeight - 7
151+
m.list.SetSize(listWidth, listHeight)
152+
153+
case tea.KeyMsg:
154+
if m.list.FilterState() != list.Filtering {
155+
switch msg.String() {
156+
case "enter":
157+
if item, ok := m.list.SelectedItem().(typeItem); ok {
158+
return m, func() tea.Msg {
159+
return typeSelectedMsg{beanID: m.beanID, beanType: item.name}
160+
}
161+
}
162+
case "esc", "backspace":
163+
return m, func() tea.Msg {
164+
return closeTypePickerMsg{}
165+
}
166+
}
167+
}
168+
}
169+
170+
m.list, cmd = m.list.Update(msg)
171+
return m, cmd
172+
}
173+
174+
func (m typePickerModel) View() string {
175+
if m.width == 0 {
176+
return "Loading..."
177+
}
178+
179+
modalWidth := max(40, min(60, m.width*50/100))
180+
181+
subtitle := ui.Muted.Render(fmt.Sprintf("Changing type for %s", m.beanID))
182+
183+
// Get description of currently selected type
184+
var description string
185+
if item, ok := m.list.SelectedItem().(typeItem); ok && item.description != "" {
186+
description = ui.Muted.Render(item.description)
187+
}
188+
189+
help := helpKeyStyle.Render("enter") + " " + helpStyle.Render("select") + " " +
190+
helpKeyStyle.Render("/") + " " + helpStyle.Render("filter") + " " +
191+
helpKeyStyle.Render("esc") + " " + helpStyle.Render("cancel")
192+
193+
border := lipgloss.NewStyle().
194+
Border(lipgloss.RoundedBorder()).
195+
BorderForeground(ui.ColorPrimary).
196+
Padding(0, 1).
197+
Width(modalWidth)
198+
199+
content := subtitle + "\n\n" + m.list.View() + "\n\n" + description + "\n\n" + help
200+
201+
return border.Render(content)
202+
}
203+
204+
// ModalView returns the picker rendered as a centered modal overlay on top of the background
205+
func (m typePickerModel) ModalView(bgView string, fullWidth, fullHeight int) string {
206+
modal := m.View()
207+
return overlayModal(bgView, modal, fullWidth, fullHeight)
208+
}

0 commit comments

Comments
 (0)