1package dialog
2
3import (
4 "charm.land/bubbles/v2/help"
5 "charm.land/bubbles/v2/key"
6 "charm.land/bubbles/v2/textinput"
7 tea "charm.land/bubbletea/v2"
8 "github.com/charmbracelet/crush/internal/ui/common"
9 "github.com/charmbracelet/crush/internal/ui/list"
10 "github.com/charmbracelet/crush/internal/ui/styles"
11 uv "github.com/charmbracelet/ultraviolet"
12 "github.com/sahilm/fuzzy"
13)
14
15const (
16 // NotificationsID is the identifier for the notification style picker dialog.
17 NotificationsID = "notifications"
18 notificationsDialogMaxWidth = 50
19 notificationsDialogMaxHeight = 12
20)
21
22// NotificationStyle represents a notification backend option.
23type NotificationStyle struct {
24 ID string
25 Title string
26 Description string
27}
28
29// AllNotificationStyles lists all available notification styles in order.
30var AllNotificationStyles = []NotificationStyle{
31 {ID: "auto", Title: "Auto", Description: "Automatically detect the best backend"},
32 {ID: "native", Title: "Native", Description: "Use system notifications (macOS/Linux/Windows)"},
33 {ID: "osc", Title: "OSC", Description: "Use terminal OSC escape sequences"},
34 {ID: "bell", Title: "Bell", Description: "Use terminal bell character"},
35 {ID: "disabled", Title: "Disabled", Description: "Turn off notifications"},
36}
37
38// Notifications represents a dialog for selecting notification style.
39type Notifications struct {
40 com *common.Common
41 help help.Model
42 list *list.FilterableList
43 input textinput.Model
44
45 keyMap struct {
46 Select key.Binding
47 Next key.Binding
48 Previous key.Binding
49 UpDown key.Binding
50 Close key.Binding
51 }
52}
53
54// NotificationItem represents a notification style list item.
55type NotificationItem struct {
56 *list.Versioned
57 style NotificationStyle
58 isCurrent bool
59 t *styles.Styles
60 m fuzzy.Match
61 cache map[int]string
62 focused bool
63}
64
65// Finished implements list.Item. Notification items are render-stable
66// outside of explicit SetFocused / SetMatch.
67func (n *NotificationItem) Finished() bool {
68 return true
69}
70
71var (
72 _ Dialog = (*Notifications)(nil)
73 _ ListItem = (*NotificationItem)(nil)
74)
75
76// NewNotifications creates a new notification style picker dialog.
77func NewNotifications(com *common.Common) *Notifications {
78 n := &Notifications{com: com}
79
80 h := help.New()
81 h.Styles = com.Styles.DialogHelpStyles()
82 n.help = h
83
84 n.list = list.NewFilterableList()
85 n.list.Focus()
86
87 n.input = textinput.New()
88 n.input.SetVirtualCursor(false)
89 n.input.Placeholder = "Type to filter"
90 n.input.SetStyles(com.Styles.TextInput)
91 n.input.Focus()
92
93 n.keyMap.Select = key.NewBinding(
94 key.WithKeys("enter", "ctrl+y"),
95 key.WithHelp("enter", "confirm"),
96 )
97 n.keyMap.Next = key.NewBinding(
98 key.WithKeys("down", "ctrl+n"),
99 key.WithHelp("↓", "next item"),
100 )
101 n.keyMap.Previous = key.NewBinding(
102 key.WithKeys("up", "ctrl+p"),
103 key.WithHelp("↑", "previous item"),
104 )
105 n.keyMap.UpDown = key.NewBinding(
106 key.WithKeys("up", "down"),
107 key.WithHelp("↑/↓", "choose"),
108 )
109 n.keyMap.Close = CloseKey
110
111 n.setItems()
112 return n
113}
114
115// ID implements Dialog.
116func (n *Notifications) ID() string {
117 return NotificationsID
118}
119
120// HandleMsg implements [Dialog].
121func (n *Notifications) HandleMsg(msg tea.Msg) Action {
122 switch msg := msg.(type) {
123 case tea.KeyPressMsg:
124 switch {
125 case key.Matches(msg, n.keyMap.Close):
126 return ActionClose{}
127 case key.Matches(msg, n.keyMap.Previous):
128 n.list.Focus()
129 if n.list.IsSelectedFirst() {
130 n.list.SelectLast()
131 n.list.ScrollToBottom()
132 break
133 }
134 n.list.SelectPrev()
135 n.list.ScrollToSelected()
136 case key.Matches(msg, n.keyMap.Next):
137 n.list.Focus()
138 if n.list.IsSelectedLast() {
139 n.list.SelectFirst()
140 n.list.ScrollToTop()
141 break
142 }
143 n.list.SelectNext()
144 n.list.ScrollToSelected()
145 case key.Matches(msg, n.keyMap.Select):
146 selectedItem := n.list.SelectedItem()
147 if selectedItem == nil {
148 break
149 }
150 notifItem, ok := selectedItem.(*NotificationItem)
151 if !ok {
152 break
153 }
154 return ActionSelectNotificationStyle{Style: notifItem.style.ID}
155 default:
156 var cmd tea.Cmd
157 n.input, cmd = n.input.Update(msg)
158 value := n.input.Value()
159 n.list.SetFilter(value)
160 n.list.ScrollToTop()
161 n.list.SetSelected(0)
162 return ActionCmd{cmd}
163 }
164 }
165 return nil
166}
167
168// Cursor returns the cursor position relative to the dialog.
169func (n *Notifications) Cursor() *tea.Cursor {
170 return InputCursor(n.com.Styles, n.input.Cursor())
171}
172
173// Draw implements [Dialog].
174func (n *Notifications) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
175 t := n.com.Styles
176 width := max(0, min(notificationsDialogMaxWidth, area.Dx()))
177 height := max(0, min(notificationsDialogMaxHeight, area.Dy()))
178 innerWidth := width - t.Dialog.View.GetHorizontalFrameSize()
179 heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
180 t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
181 t.Dialog.HelpView.GetVerticalFrameSize() +
182 t.Dialog.View.GetVerticalFrameSize()
183
184 n.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1)
185 n.list.SetSize(innerWidth, height-heightOffset)
186 n.help.SetWidth(innerWidth)
187
188 rc := NewRenderContext(t, width)
189 rc.Title = "Notification Style"
190 inputView := t.Dialog.InputPrompt.Render(n.input.View())
191 rc.AddPart(inputView)
192
193 visibleCount := len(n.list.FilteredItems())
194 if n.list.Height() >= visibleCount {
195 n.list.ScrollToTop()
196 } else {
197 n.list.ScrollToSelected()
198 }
199
200 listView := t.Dialog.List.Height(n.list.Height()).Render(n.list.Render())
201 rc.AddPart(listView)
202 rc.Help = n.help.View(n)
203
204 view := rc.Render()
205
206 cur := n.Cursor()
207 DrawCenterCursor(scr, area, view, cur)
208 return cur
209}
210
211// ShortHelp implements [help.KeyMap].
212func (n *Notifications) ShortHelp() []key.Binding {
213 return []key.Binding{
214 n.keyMap.UpDown,
215 n.keyMap.Select,
216 n.keyMap.Close,
217 }
218}
219
220// FullHelp implements [help.KeyMap].
221func (n *Notifications) FullHelp() [][]key.Binding {
222 m := [][]key.Binding{}
223 slice := []key.Binding{
224 n.keyMap.Select,
225 n.keyMap.Next,
226 n.keyMap.Previous,
227 n.keyMap.Close,
228 }
229 for i := 0; i < len(slice); i += 4 {
230 end := min(i+4, len(slice))
231 m = append(m, slice[i:end])
232 }
233 return m
234}
235
236func (n *Notifications) setItems() {
237 cfg := n.com.Config()
238 currentStyle := "auto"
239 if cfg != nil && cfg.Options != nil && cfg.Options.NotificationStyle != "" {
240 currentStyle = cfg.Options.NotificationStyle
241 }
242
243 items := make([]list.FilterableItem, 0, len(AllNotificationStyles))
244 selectedIndex := 0
245 for i, style := range AllNotificationStyles {
246 item := &NotificationItem{
247 Versioned: list.NewVersioned(),
248 style: style,
249 isCurrent: style.ID == currentStyle,
250 t: n.com.Styles,
251 }
252 items = append(items, item)
253 if style.ID == currentStyle {
254 selectedIndex = i
255 }
256 }
257
258 n.list.SetItems(items...)
259 n.list.SetSelected(selectedIndex)
260 n.list.ScrollToSelected()
261}
262
263// Filter returns the filter value for the notification item.
264func (n *NotificationItem) Filter() string {
265 return n.style.Title
266}
267
268// ID returns the unique identifier for the notification style.
269func (n *NotificationItem) ID() string {
270 return n.style.ID
271}
272
273// SetFocused sets the focus state of the notification item.
274func (n *NotificationItem) SetFocused(focused bool) {
275 if n.focused == focused {
276 return
277 }
278 n.cache = nil
279 n.focused = focused
280 if n.Versioned != nil {
281 n.Bump()
282 }
283}
284
285// SetMatch sets the fuzzy match for the notification item.
286func (n *NotificationItem) SetMatch(m fuzzy.Match) {
287 if sameFuzzyMatch(n.m, m) {
288 return
289 }
290 n.cache = nil
291 n.m = m
292 if n.Versioned != nil {
293 n.Bump()
294 }
295}
296
297// Render returns the string representation of the notification item.
298func (n *NotificationItem) Render(width int) string {
299 info := ""
300 if n.isCurrent {
301 info = "current"
302 }
303 st := ListItemStyles{
304 ItemBlurred: n.t.Dialog.NormalItem,
305 ItemFocused: n.t.Dialog.SelectedItem,
306 InfoTextBlurred: n.t.Dialog.ListItem.InfoBlurred,
307 InfoTextFocused: n.t.Dialog.ListItem.InfoFocused,
308 }
309 return renderItem(st, n.style.Title, info, n.focused, width, n.cache, &n.m)
310}