@@ -0,0 +1,310 @@
1+package dialog
2+
3+import (
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+
15+const (
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.
23+type NotificationStyle struct {
24+ ID string
25+ Title string
26+ Description string
27+}
28+
29+// AllNotificationStyles lists all available notification styles in order.
30+var 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.
39+type 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.
55+type 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.
67+func (n *NotificationItem) Finished() bool {
68+ return true
69+}
70+
71+var (
72+ _ Dialog = (*Notifications)(nil)
73+ _ ListItem = (*NotificationItem)(nil)
74+)
75+
76+// NewNotifications creates a new notification style picker dialog.
77+func 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.
116+func (n *Notifications) ID() string {
117+ return NotificationsID
118+}
119+
120+// HandleMsg implements [Dialog].
121+func (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.
169+func (n *Notifications) Cursor() *tea.Cursor {
170+ return InputCursor(n.com.Styles, n.input.Cursor())
171+}
172+
173+// Draw implements [Dialog].
174+func (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].
212+func (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].
221+func (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+
236+func (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.
264+func (n *NotificationItem) Filter() string {
265+ return n.style.Title
266+}
267+
268+// ID returns the unique identifier for the notification style.
269+func (n *NotificationItem) ID() string {
270+ return n.style.ID
271+}
272+
273+// SetFocused sets the focus state of the notification item.
274+func (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.
286+func (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.
298+func (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+}