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// AbsolutePositionable is an interface for components that can set their position
 27type AbsolutePositionable interface {
 28	SetPosition(x, y int)
 29}
 30
 31// OpenDialogMsg is sent to open a new dialog with specified dimensions.
 32type OpenDialogMsg struct {
 33	Model DialogModel
 34}
 35
 36// CloseDialogMsg is sent to close the topmost dialog.
 37type CloseDialogMsg struct{}
 38
 39// DialogCmp manages a stack of dialogs with keyboard navigation.
 40type DialogCmp interface {
 41	tea.Model
 42
 43	Dialogs() []DialogModel
 44	HasDialogs() bool
 45	GetLayers() []*lipgloss.Layer
 46	ActiveView() *tea.View
 47}
 48
 49type dialogCmp struct {
 50	width, height int
 51	dialogs       []DialogModel
 52	idMap         map[DialogID]int
 53	keymap        KeyMap
 54}
 55
 56// NewDialogCmp creates a new dialog manager.
 57func NewDialogCmp() DialogCmp {
 58	return dialogCmp{
 59		dialogs: []DialogModel{},
 60		keymap:  DefaultKeymap(),
 61		idMap:   make(map[DialogID]int),
 62	}
 63}
 64
 65func (d dialogCmp) Init() tea.Cmd {
 66	return nil
 67}
 68
 69// Update handles dialog lifecycle and forwards messages to the active dialog.
 70func (d dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 71	switch msg := msg.(type) {
 72	case tea.WindowSizeMsg:
 73		var cmds []tea.Cmd
 74		d.width = msg.Width
 75		d.height = msg.Height
 76		for i := range d.dialogs {
 77			u, cmd := d.dialogs[i].Update(msg)
 78			d.dialogs[i] = u.(DialogModel)
 79			cmds = append(cmds, cmd)
 80		}
 81		return d, tea.Batch(cmds...)
 82	case OpenDialogMsg:
 83		return d.handleOpen(msg)
 84	case CloseDialogMsg:
 85		if len(d.dialogs) == 0 {
 86			return d, nil
 87		}
 88		inx := len(d.dialogs) - 1
 89		dialog := d.dialogs[inx]
 90		delete(d.idMap, dialog.ID())
 91		d.dialogs = d.dialogs[:len(d.dialogs)-1]
 92		if closeable, ok := dialog.(CloseCallback); ok {
 93			return d, closeable.Close()
 94		}
 95		return d, nil
 96	case tea.KeyPressMsg:
 97		if key.Matches(msg, d.keymap.Close) {
 98			return d, util.CmdHandler(CloseDialogMsg{})
 99		}
100	}
101	if d.HasDialogs() {
102		lastIndex := len(d.dialogs) - 1
103		u, cmd := d.dialogs[lastIndex].Update(msg)
104		d.dialogs[lastIndex] = u.(DialogModel)
105		return d, cmd
106	}
107	return d, nil
108}
109
110func (d dialogCmp) handleOpen(msg OpenDialogMsg) (tea.Model, tea.Cmd) {
111	if d.HasDialogs() {
112		dialog := d.dialogs[len(d.dialogs)-1]
113		if dialog.ID() == msg.Model.ID() {
114			return d, nil // Do not open a dialog if it's already the topmost one
115		}
116		if dialog.ID() == "quit" {
117			return d, nil // Do not open dialogs ontop of quit
118		}
119	}
120	// if the dialog is already in thel stack make it the last item
121	if _, ok := d.idMap[msg.Model.ID()]; ok {
122		existing := d.dialogs[d.idMap[msg.Model.ID()]]
123		// Reuse the model so we keep the state
124		msg.Model = existing
125		d.dialogs = slices.Delete(d.dialogs, d.idMap[msg.Model.ID()], d.idMap[msg.Model.ID()]+1)
126	}
127	d.idMap[msg.Model.ID()] = len(d.dialogs)
128	d.dialogs = append(d.dialogs, msg.Model)
129	var cmds []tea.Cmd
130	cmd := msg.Model.Init()
131	cmds = append(cmds, cmd)
132	_, cmd = msg.Model.Update(tea.WindowSizeMsg{
133		Width:  d.width,
134		Height: d.height,
135	})
136	cmds = append(cmds, cmd)
137	return d, tea.Batch(cmds...)
138}
139
140func (d dialogCmp) Dialogs() []DialogModel {
141	return d.dialogs
142}
143
144func (d dialogCmp) ActiveView() *tea.View {
145	if len(d.dialogs) == 0 {
146		return nil
147	}
148	view := d.dialogs[len(d.dialogs)-1].View()
149	return &view
150}
151
152func (d dialogCmp) GetLayers() []*lipgloss.Layer {
153	layers := []*lipgloss.Layer{}
154	for _, dialog := range d.Dialogs() {
155		dialogView := dialog.View().String()
156		row, col := dialog.Position()
157		layers = append(layers, lipgloss.NewLayer(dialogView).X(col).Y(row))
158	}
159	return layers
160}
161
162func (d dialogCmp) HasDialogs() bool {
163	return len(d.dialogs) > 0
164}