notifications.go

  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}