Skip to content

Commit ad3382e

Browse files
committed
feat(tui): add status picker modal with 's' shortcut
- Add statuspicker.go with filterable status selection - Press 's' in list or detail view to open the picker - Shows all available statuses with colors and descriptions - Highlights current status in the list - Renders as centered modal overlay like parent picker
1 parent 30ff569 commit ad3382e

File tree

5 files changed

+287
-16
lines changed

5 files changed

+287
-16
lines changed

.beans/beans-szz8--tui-s-shortcut-to-change-bean-status.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
---
22
title: 'TUI: ''s'' shortcut to change bean status'
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-12T22:51:37Z
78
parent: beans-xnp8
89
---
910

internal/tui/detail.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,15 @@ func (m detailModel) Update(msg tea.Msg) (detailModel, tea.Cmd) {
324324
currentParent: m.bean.Parent,
325325
}
326326
}
327+
328+
case "s":
329+
// Open status picker
330+
return m, func() tea.Msg {
331+
return openStatusPickerMsg{
332+
beanID: m.bean.ID,
333+
currentStatus: m.bean.Status,
334+
}
335+
}
327336
}
328337
}
329338

@@ -392,7 +401,8 @@ func (m detailModel) View() string {
392401
}
393402
footer += helpKeyStyle.Render("enter") + " " + helpStyle.Render("go to") + " "
394403
}
395-
footer += helpKeyStyle.Render("p") + " " + helpStyle.Render("parent") + " " +
404+
footer += helpKeyStyle.Render("s") + " " + helpStyle.Render("status") + " " +
405+
helpKeyStyle.Render("p") + " " + helpStyle.Render("parent") + " " +
396406
helpKeyStyle.Render("j/k") + " " + helpStyle.Render("scroll") + " " +
397407
helpKeyStyle.Render("esc") + " " + helpStyle.Render("back") + " " +
398408
helpKeyStyle.Render("q") + " " + helpStyle.Render("quit")

internal/tui/list.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,16 @@ func (m listModel) Update(msg tea.Msg) (listModel, tea.Cmd) {
268268
}
269269
}
270270
}
271+
case "s":
272+
// Open status picker for selected bean
273+
if item, ok := m.list.SelectedItem().(beanItem); ok {
274+
return m, func() tea.Msg {
275+
return openStatusPickerMsg{
276+
beanID: item.bean.ID,
277+
currentStatus: item.bean.Status,
278+
}
279+
}
280+
}
271281
case "esc", "backspace":
272282
// If we have an active filter, clear it instead of quitting
273283
if m.hasActiveFilter() {
@@ -325,11 +335,13 @@ func (m listModel) View() string {
325335
var help string
326336
if m.hasActiveFilter() {
327337
help = helpKeyStyle.Render("enter") + " " + helpStyle.Render("view") + " " +
338+
helpKeyStyle.Render("s") + " " + helpStyle.Render("status") + " " +
328339
helpKeyStyle.Render("p") + " " + helpStyle.Render("parent") + " " +
329340
helpKeyStyle.Render("esc") + " " + helpStyle.Render("clear filter") + " " +
330341
helpKeyStyle.Render("q") + " " + helpStyle.Render("quit")
331342
} else {
332343
help = helpKeyStyle.Render("enter") + " " + helpStyle.Render("view") + " " +
344+
helpKeyStyle.Render("s") + " " + helpStyle.Render("status") + " " +
333345
helpKeyStyle.Render("p") + " " + helpStyle.Render("parent") + " " +
334346
helpKeyStyle.Render("/") + " " + helpStyle.Render("filter") + " " +
335347
helpKeyStyle.Render("g t") + " " + helpStyle.Render("filter by tag") + " " +

internal/tui/statuspicker.go

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

0 commit comments

Comments
 (0)