dialogs.go

  1package dialogs
  2
  3import (
  4	"slices"
  5
  6	"github.com/charmbracelet/bubbles/v2/key"
  7	tea "github.com/charmbracelet/bubbletea/v2"
  8	"github.com/charmbracelet/lipgloss/v2"
  9	"github.com/opencode-ai/opencode/internal/tui/util"
 10)
 11
 12type DialogID string
 13
 14// DialogModel represents a dialog component that can be displayed.
 15type DialogModel interface {
 16	util.Model
 17	Position() (int, int)
 18	ID() DialogID
 19}
 20
 21// CloseCallback allows dialogs to perform cleanup when closed.
 22type CloseCallback interface {
 23	Close() tea.Cmd
 24}
 25
 26// OpenDialogMsg is sent to open a new dialog with specified dimensions.
 27type OpenDialogMsg struct {
 28	Model DialogModel
 29}
 30
 31// CloseDialogMsg is sent to close the topmost dialog.
 32type CloseDialogMsg struct{}
 33
 34// DialogCmp manages a stack of dialogs with keyboard navigation.
 35type DialogCmp interface {
 36	tea.Model
 37
 38	Dialogs() []DialogModel
 39	HasDialogs() bool
 40	GetLayers() []*lipgloss.Layer
 41	ActiveView() *tea.View
 42}
 43
 44type dialogCmp struct {
 45	width, height int
 46	dialogs       []DialogModel
 47	idMap         map[DialogID]int
 48	keyMap        KeyMap
 49}
 50
 51// NewDialogCmp creates a new dialog manager.
 52func NewDialogCmp() DialogCmp {
 53	return dialogCmp{
 54		dialogs: []DialogModel{},
 55		keyMap:  DefaultKeyMap(),
 56		idMap:   make(map[DialogID]int),
 57	}
 58}
 59
 60func (d dialogCmp) Init() tea.Cmd {
 61	return nil
 62}
 63
 64// Update handles dialog lifecycle and forwards messages to the active dialog.
 65func (d dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 66	switch msg := msg.(type) {
 67	case tea.WindowSizeMsg:
 68		var cmds []tea.Cmd
 69		d.width = msg.Width
 70		d.height = msg.Height
 71		for i := range d.dialogs {
 72			u, cmd := d.dialogs[i].Update(msg)
 73			d.dialogs[i] = u.(DialogModel)
 74			cmds = append(cmds, cmd)
 75		}
 76		return d, tea.Batch(cmds...)
 77	case OpenDialogMsg:
 78		return d.handleOpen(msg)
 79	case CloseDialogMsg:
 80		if len(d.dialogs) == 0 {
 81			return d, nil
 82		}
 83		inx := len(d.dialogs) - 1
 84		dialog := d.dialogs[inx]
 85		delete(d.idMap, dialog.ID())
 86		d.dialogs = d.dialogs[:len(d.dialogs)-1]
 87		if closeable, ok := dialog.(CloseCallback); ok {
 88			return d, closeable.Close()
 89		}
 90		return d, nil
 91	case tea.KeyPressMsg:
 92		if key.Matches(msg, d.keyMap.Close) {
 93			return d, util.CmdHandler(CloseDialogMsg{})
 94		}
 95	}
 96	if d.HasDialogs() {
 97		lastIndex := len(d.dialogs) - 1
 98		u, cmd := d.dialogs[lastIndex].Update(msg)
 99		d.dialogs[lastIndex] = u.(DialogModel)
100		return d, cmd
101	}
102	return d, nil
103}
104
105func (d dialogCmp) handleOpen(msg OpenDialogMsg) (tea.Model, tea.Cmd) {
106	if d.HasDialogs() {
107		dialog := d.dialogs[len(d.dialogs)-1]
108		if dialog.ID() == msg.Model.ID() {
109			return d, nil // Do not open a dialog if it's already the topmost one
110		}
111		if dialog.ID() == "quit" {
112			return d, nil // Do not open dialogs on top of quit
113		}
114	}
115	// if the dialog is already in the stack make it the last item
116	if _, ok := d.idMap[msg.Model.ID()]; ok {
117		existing := d.dialogs[d.idMap[msg.Model.ID()]]
118		// Reuse the model so we keep the state
119		msg.Model = existing
120		d.dialogs = slices.Delete(d.dialogs, d.idMap[msg.Model.ID()], d.idMap[msg.Model.ID()]+1)
121	}
122	d.idMap[msg.Model.ID()] = len(d.dialogs)
123	d.dialogs = append(d.dialogs, msg.Model)
124	var cmds []tea.Cmd
125	cmd := msg.Model.Init()
126	cmds = append(cmds, cmd)
127	_, cmd = msg.Model.Update(tea.WindowSizeMsg{
128		Width:  d.width,
129		Height: d.height,
130	})
131	cmds = append(cmds, cmd)
132	return d, tea.Batch(cmds...)
133}
134
135func (d dialogCmp) Dialogs() []DialogModel {
136	return d.dialogs
137}
138
139func (d dialogCmp) ActiveView() *tea.View {
140	if len(d.dialogs) == 0 {
141		return nil
142	}
143	view := d.dialogs[len(d.dialogs)-1].View()
144	return &view
145}
146
147func (d dialogCmp) GetLayers() []*lipgloss.Layer {
148	layers := []*lipgloss.Layer{}
149	for _, dialog := range d.Dialogs() {
150		dialogView := dialog.View().String()
151		row, col := dialog.Position()
152		layers = append(layers, lipgloss.NewLayer(dialogView).X(col).Y(row))
153	}
154	return layers
155}
156
157func (d dialogCmp) HasDialogs() bool {
158	return len(d.dialogs) > 0
159}