Skip to content

Commit 9565a9c

Browse files
committed
feat(tui): add 'b' shortcut to manage blocking relationships
- Create blockingpicker.go with toggle-based blocking management - Show all beans with visual indicator (● blocking, ○ not blocking) - Toggle blocking on/off with enter key - Add 'b' keybinding to list and detail views - Picker refreshes after each toggle to show updated state
1 parent ddb1754 commit 9565a9c

File tree

5 files changed

+313
-18
lines changed

5 files changed

+313
-18
lines changed

.beans/beans-lrbf--tui-b-shortcut-to-manage-blocking-relationships.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
---
22
title: 'TUI: ''b'' shortcut to manage blocking relationships'
3-
status: todo
3+
status: completed
44
type: feature
55
priority: normal
66
created_at: 2025-12-12T22:38:27Z
7-
updated_at: 2025-12-12T23:04:44Z
7+
updated_at: 2025-12-12T23:35:43Z
88
parent: beans-xnp8
99
---
1010

internal/tui/blockingpicker.go

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
package tui
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"sort"
8+
"strings"
9+
10+
"github.com/charmbracelet/bubbles/list"
11+
tea "github.com/charmbracelet/bubbletea"
12+
"github.com/charmbracelet/lipgloss"
13+
"github.com/hmans/beans/internal/bean"
14+
"github.com/hmans/beans/internal/config"
15+
"github.com/hmans/beans/internal/graph"
16+
"github.com/hmans/beans/internal/ui"
17+
)
18+
19+
// blockingToggledMsg is sent when a blocking relationship is toggled
20+
type blockingToggledMsg struct {
21+
beanID string // the bean we're modifying
22+
targetID string // the bean being blocked/unblocked
23+
added bool // true if blocking was added, false if removed
24+
}
25+
26+
// closeBlockingPickerMsg is sent when the blocking picker is cancelled
27+
type closeBlockingPickerMsg struct{}
28+
29+
// openBlockingPickerMsg requests opening the blocking picker for a bean
30+
type openBlockingPickerMsg struct {
31+
beanID string
32+
beanTitle string
33+
currentBlocking []string // IDs of beans currently being blocked
34+
}
35+
36+
// blockingItem wraps a bean to implement list.Item for the blocking picker
37+
type blockingItem struct {
38+
bean *bean.Bean
39+
cfg *config.Config
40+
isBlocking bool // true if current bean is blocking this one
41+
}
42+
43+
func (i blockingItem) Title() string { return i.bean.Title }
44+
func (i blockingItem) Description() string { return i.bean.ID }
45+
func (i blockingItem) FilterValue() string { return i.bean.Title + " " + i.bean.ID }
46+
47+
// blockingItemDelegate handles rendering of blocking picker items
48+
type blockingItemDelegate struct {
49+
cfg *config.Config
50+
}
51+
52+
func (d blockingItemDelegate) Height() int { return 1 }
53+
func (d blockingItemDelegate) Spacing() int { return 0 }
54+
func (d blockingItemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil }
55+
56+
func (d blockingItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
57+
item, ok := listItem.(blockingItem)
58+
if !ok {
59+
return
60+
}
61+
62+
var cursor string
63+
if index == m.Index() {
64+
cursor = lipgloss.NewStyle().Foreground(ui.ColorPrimary).Bold(true).Render("▌") + " "
65+
} else {
66+
cursor = " "
67+
}
68+
69+
// Show blocking indicator
70+
var blockingIndicator string
71+
if item.isBlocking {
72+
blockingIndicator = lipgloss.NewStyle().Foreground(ui.ColorDanger).Bold(true).Render("● ") // Red dot for blocking
73+
} else {
74+
blockingIndicator = lipgloss.NewStyle().Foreground(ui.ColorMuted).Render("○ ") // Empty circle for not blocking
75+
}
76+
77+
// Get colors from config
78+
colors := d.cfg.GetBeanColors(item.bean.Status, item.bean.Type, item.bean.Priority)
79+
80+
// Format: [indicator] [type] title (id)
81+
typeBadge := ui.RenderTypeText(item.bean.Type, colors.TypeColor)
82+
title := item.bean.Title
83+
if colors.IsArchive {
84+
title = ui.Muted.Render(title)
85+
}
86+
id := ui.Muted.Render(" (" + item.bean.ID + ")")
87+
88+
fmt.Fprint(w, cursor+blockingIndicator+typeBadge+" "+title+id)
89+
}
90+
91+
// blockingPickerModel is the model for the blocking picker view
92+
type blockingPickerModel struct {
93+
list list.Model
94+
beanID string // the bean we're setting blocking for
95+
beanTitle string // the bean's title
96+
currentBlocking []string // IDs currently being blocked
97+
width int
98+
height int
99+
}
100+
101+
func newBlockingPickerModel(beanID, beanTitle string, currentBlocking []string, resolver *graph.Resolver, cfg *config.Config, width, height int) blockingPickerModel {
102+
// Fetch all beans
103+
allBeans, _ := resolver.Query().Beans(context.Background(), nil)
104+
105+
// Create a set of currently blocked IDs for quick lookup
106+
blockingSet := make(map[string]bool)
107+
for _, id := range currentBlocking {
108+
blockingSet[id] = true
109+
}
110+
111+
// Filter out the current bean and build items
112+
var eligibleBeans []*bean.Bean
113+
for _, b := range allBeans {
114+
if b.ID != beanID {
115+
eligibleBeans = append(eligibleBeans, b)
116+
}
117+
}
118+
119+
// Sort by type order, then by title
120+
typeNames := cfg.TypeNames()
121+
typeOrder := make(map[string]int)
122+
for i, t := range typeNames {
123+
typeOrder[t] = i
124+
}
125+
sort.Slice(eligibleBeans, func(i, j int) bool {
126+
ti, tj := typeOrder[eligibleBeans[i].Type], typeOrder[eligibleBeans[j].Type]
127+
if ti != tj {
128+
return ti < tj
129+
}
130+
return strings.ToLower(eligibleBeans[i].Title) < strings.ToLower(eligibleBeans[j].Title)
131+
})
132+
133+
delegate := blockingItemDelegate{cfg: cfg}
134+
135+
// Build items list
136+
items := make([]list.Item, 0, len(eligibleBeans))
137+
for _, b := range eligibleBeans {
138+
items = append(items, blockingItem{
139+
bean: b,
140+
cfg: cfg,
141+
isBlocking: blockingSet[b.ID],
142+
})
143+
}
144+
145+
// Calculate modal dimensions (60% width, 60% height, with min/max constraints)
146+
modalWidth := max(40, min(80, width*60/100))
147+
modalHeight := max(10, min(20, height*60/100))
148+
listWidth := modalWidth - 6
149+
listHeight := modalHeight - 7
150+
151+
l := list.New(items, delegate, listWidth, listHeight)
152+
l.Title = "Manage Blocking"
153+
l.SetShowStatusBar(false)
154+
l.SetFilteringEnabled(true)
155+
l.SetShowHelp(false)
156+
l.SetShowPagination(false)
157+
l.Styles.Title = listTitleStyle
158+
l.Styles.TitleBar = lipgloss.NewStyle().Padding(0, 0, 0, 0)
159+
l.Styles.FilterPrompt = lipgloss.NewStyle().Foreground(ui.ColorPrimary)
160+
l.Styles.FilterCursor = lipgloss.NewStyle().Foreground(ui.ColorPrimary)
161+
162+
return blockingPickerModel{
163+
list: l,
164+
beanID: beanID,
165+
beanTitle: beanTitle,
166+
currentBlocking: currentBlocking,
167+
width: width,
168+
height: height,
169+
}
170+
}
171+
172+
func (m blockingPickerModel) Init() tea.Cmd {
173+
return nil
174+
}
175+
176+
func (m blockingPickerModel) Update(msg tea.Msg) (blockingPickerModel, tea.Cmd) {
177+
var cmd tea.Cmd
178+
179+
switch msg := msg.(type) {
180+
case tea.WindowSizeMsg:
181+
m.width = msg.Width
182+
m.height = msg.Height
183+
modalWidth := max(40, min(80, msg.Width*60/100))
184+
modalHeight := max(10, min(20, msg.Height*60/100))
185+
listWidth := modalWidth - 6
186+
listHeight := modalHeight - 7
187+
m.list.SetSize(listWidth, listHeight)
188+
189+
case tea.KeyMsg:
190+
if m.list.FilterState() != list.Filtering {
191+
switch msg.String() {
192+
case "enter":
193+
if item, ok := m.list.SelectedItem().(blockingItem); ok {
194+
// Toggle the blocking relationship
195+
return m, func() tea.Msg {
196+
return blockingToggledMsg{
197+
beanID: m.beanID,
198+
targetID: item.bean.ID,
199+
added: !item.isBlocking, // toggle
200+
}
201+
}
202+
}
203+
case "esc", "backspace":
204+
return m, func() tea.Msg {
205+
return closeBlockingPickerMsg{}
206+
}
207+
}
208+
}
209+
}
210+
211+
m.list, cmd = m.list.Update(msg)
212+
return m, cmd
213+
}
214+
215+
func (m blockingPickerModel) View() string {
216+
if m.width == 0 {
217+
return "Loading..."
218+
}
219+
220+
return renderPickerModal(pickerModalConfig{
221+
Title: "Manage Blocking",
222+
BeanTitle: m.beanTitle,
223+
BeanID: m.beanID,
224+
ListContent: m.list.View(),
225+
Description: "● = blocking, ○ = not blocking",
226+
Width: m.width,
227+
WidthPct: 60,
228+
MaxWidth: 80,
229+
})
230+
}
231+
232+
// ModalView returns the picker rendered as a centered modal overlay on top of the background
233+
func (m blockingPickerModel) ModalView(bgView string, fullWidth, fullHeight int) string {
234+
modal := m.View()
235+
return overlayModal(bgView, modal, fullWidth, fullHeight)
236+
}

internal/tui/detail.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,16 @@ func (m detailModel) Update(msg tea.Msg) (detailModel, tea.Cmd) {
345345
currentType: m.bean.Type,
346346
}
347347
}
348+
349+
case "b":
350+
// Open blocking picker
351+
return m, func() tea.Msg {
352+
return openBlockingPickerMsg{
353+
beanID: m.bean.ID,
354+
beanTitle: m.bean.Title,
355+
currentBlocking: m.bean.Blocking,
356+
}
357+
}
348358
}
349359
}
350360

@@ -416,6 +426,7 @@ func (m detailModel) View() string {
416426
footer += helpKeyStyle.Render("s") + " " + helpStyle.Render("status") + " " +
417427
helpKeyStyle.Render("t") + " " + helpStyle.Render("type") + " " +
418428
helpKeyStyle.Render("p") + " " + helpStyle.Render("parent") + " " +
429+
helpKeyStyle.Render("b") + " " + helpStyle.Render("blocking") + " " +
419430
helpKeyStyle.Render("j/k") + " " + helpStyle.Render("scroll") + " " +
420431
helpKeyStyle.Render("esc") + " " + helpStyle.Render("back") + " " +
421432
helpKeyStyle.Render("q") + " " + helpStyle.Render("quit")

internal/tui/list.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,17 @@ func (m listModel) Update(msg tea.Msg) (listModel, tea.Cmd) {
291291
}
292292
}
293293
}
294+
case "b":
295+
// Open blocking picker for selected bean
296+
if item, ok := m.list.SelectedItem().(beanItem); ok {
297+
return m, func() tea.Msg {
298+
return openBlockingPickerMsg{
299+
beanID: item.bean.ID,
300+
beanTitle: item.bean.Title,
301+
currentBlocking: item.bean.Blocking,
302+
}
303+
}
304+
}
294305
case "esc", "backspace":
295306
// If we have an active filter, clear it instead of quitting
296307
if m.hasActiveFilter() {
@@ -351,15 +362,16 @@ func (m listModel) View() string {
351362
helpKeyStyle.Render("s") + " " + helpStyle.Render("status") + " " +
352363
helpKeyStyle.Render("t") + " " + helpStyle.Render("type") + " " +
353364
helpKeyStyle.Render("p") + " " + helpStyle.Render("parent") + " " +
365+
helpKeyStyle.Render("b") + " " + helpStyle.Render("blocking") + " " +
354366
helpKeyStyle.Render("esc") + " " + helpStyle.Render("clear filter") + " " +
355367
helpKeyStyle.Render("q") + " " + helpStyle.Render("quit")
356368
} else {
357369
help = helpKeyStyle.Render("enter") + " " + helpStyle.Render("view") + " " +
358370
helpKeyStyle.Render("s") + " " + helpStyle.Render("status") + " " +
359371
helpKeyStyle.Render("t") + " " + helpStyle.Render("type") + " " +
360372
helpKeyStyle.Render("p") + " " + helpStyle.Render("parent") + " " +
373+
helpKeyStyle.Render("b") + " " + helpStyle.Render("blocking") + " " +
361374
helpKeyStyle.Render("/") + " " + helpStyle.Render("filter") + " " +
362-
helpKeyStyle.Render("g t") + " " + helpStyle.Render("filter by tag") + " " +
363375
helpKeyStyle.Render("q") + " " + helpStyle.Render("quit")
364376
}
365377

0 commit comments

Comments
 (0)