dialog.go

  1package ui
  2
  3import (
  4	"github.com/charmbracelet/bubbles/v2/key"
  5	tea "github.com/charmbracelet/bubbletea/v2"
  6	"github.com/charmbracelet/lipgloss/v2"
  7)
  8
  9// DialogOverlayKeyMap defines key bindings for dialogs.
 10type DialogOverlayKeyMap struct {
 11	Close key.Binding
 12}
 13
 14// DefaultDialogOverlayKeyMap returns the default key bindings for dialogs.
 15func DefaultDialogOverlayKeyMap() DialogOverlayKeyMap {
 16	return DialogOverlayKeyMap{
 17		Close: key.NewBinding(
 18			key.WithKeys("esc", "alt+esc"),
 19		),
 20	}
 21}
 22
 23// Dialog is a component that can be displayed on top of the UI.
 24type Dialog interface {
 25	Model[Dialog]
 26	ID() string
 27}
 28
 29// Overlay manages multiple dialogs as an overlay.
 30type Overlay struct {
 31	dialogs []Dialog
 32	keyMap  DialogOverlayKeyMap
 33}
 34
 35// NewDialogOverlay creates a new [Overlay] instance.
 36func NewDialogOverlay(dialogs ...Dialog) *Overlay {
 37	return &Overlay{
 38		dialogs: dialogs,
 39		keyMap:  DefaultDialogOverlayKeyMap(),
 40	}
 41}
 42
 43// ContainsDialog checks if a dialog with the specified ID exists.
 44func (d *Overlay) ContainsDialog(dialogID string) bool {
 45	for _, dialog := range d.dialogs {
 46		if dialog.ID() == dialogID {
 47			return true
 48		}
 49	}
 50	return false
 51}
 52
 53// AddDialog adds a new dialog to the stack.
 54func (d *Overlay) AddDialog(dialog Dialog) {
 55	d.dialogs = append(d.dialogs, dialog)
 56}
 57
 58// BringToFront brings the dialog with the specified ID to the front.
 59func (d *Overlay) BringToFront(dialogID string) {
 60	for i, dialog := range d.dialogs {
 61		if dialog.ID() == dialogID {
 62			// Move the dialog to the end of the slice
 63			d.dialogs = append(d.dialogs[:i], d.dialogs[i+1:]...)
 64			d.dialogs = append(d.dialogs, dialog)
 65			return
 66		}
 67	}
 68}
 69
 70// Update handles dialog updates.
 71func (d *Overlay) Update(msg tea.Msg) (*Overlay, tea.Cmd) {
 72	if len(d.dialogs) == 0 {
 73		return d, nil
 74	}
 75
 76	idx := len(d.dialogs) - 1 // active dialog is the last one
 77	dialog := d.dialogs[idx]
 78	switch msg := msg.(type) {
 79	case tea.KeyPressMsg:
 80		if key.Matches(msg, d.keyMap.Close) {
 81			// Close the current dialog
 82			d.removeDialog(idx)
 83			return d, nil
 84		}
 85	}
 86
 87	updatedDialog, cmd := dialog.Update(msg)
 88	if updatedDialog == nil {
 89		// Dialog requested to be closed
 90		d.removeDialog(idx)
 91		return d, cmd
 92	}
 93
 94	// Update the dialog in the stack
 95	d.dialogs[idx] = updatedDialog
 96
 97	return d, cmd
 98}
 99
100// View implements [Model].
101func (d *Overlay) View() string {
102	if len(d.dialogs) == 0 {
103		return ""
104	}
105
106	// Compose all the dialogs into a single view
107	canvas := lipgloss.NewCanvas()
108	for _, dialog := range d.dialogs {
109		layer := lipgloss.NewLayer(dialog.View())
110		canvas.AddLayers(layer)
111	}
112
113	return canvas.Render()
114}
115
116// ShortHelp implements [help.KeyMap].
117func (d *Overlay) ShortHelp() []key.Binding {
118	return []key.Binding{
119		d.keyMap.Close,
120	}
121}
122
123// FullHelp implements [help.KeyMap].
124func (d *Overlay) FullHelp() [][]key.Binding {
125	return [][]key.Binding{
126		{d.keyMap.Close},
127	}
128}
129
130// removeDialog removes a dialog from the stack.
131func (d *Overlay) removeDialog(idx int) {
132	if idx < 0 || idx >= len(d.dialogs) {
133		return
134	}
135	d.dialogs = append(d.dialogs[:idx], d.dialogs[idx+1:]...)
136}