dialogs.go 3.8 KB

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