dialogs.go 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. package dialogs
  2. import (
  3. "slices"
  4. tea "github.com/charmbracelet/bubbletea/v2"
  5. "github.com/charmbracelet/crush/internal/tui/util"
  6. "github.com/charmbracelet/lipgloss/v2"
  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. tea.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) (tea.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) handleOpen(msg OpenDialogMsg) (tea.Model, tea.Cmd) {
  88. if d.HasDialogs() {
  89. dialog := d.dialogs[len(d.dialogs)-1]
  90. if dialog.ID() == msg.Model.ID() {
  91. return d, nil // Do not open a dialog if it's already the topmost one
  92. }
  93. if dialog.ID() == "quit" {
  94. return d, nil // Do not open dialogs on top of quit
  95. }
  96. }
  97. // if the dialog is already in the stack make it the last item
  98. if _, ok := d.idMap[msg.Model.ID()]; ok {
  99. existing := d.dialogs[d.idMap[msg.Model.ID()]]
  100. // Reuse the model so we keep the state
  101. msg.Model = existing
  102. d.dialogs = slices.Delete(d.dialogs, d.idMap[msg.Model.ID()], d.idMap[msg.Model.ID()]+1)
  103. }
  104. d.idMap[msg.Model.ID()] = len(d.dialogs)
  105. d.dialogs = append(d.dialogs, msg.Model)
  106. var cmds []tea.Cmd
  107. cmd := msg.Model.Init()
  108. cmds = append(cmds, cmd)
  109. _, cmd = msg.Model.Update(tea.WindowSizeMsg{
  110. Width: d.width,
  111. Height: d.height,
  112. })
  113. cmds = append(cmds, cmd)
  114. return d, tea.Batch(cmds...)
  115. }
  116. func (d dialogCmp) Dialogs() []DialogModel {
  117. return d.dialogs
  118. }
  119. func (d dialogCmp) ActiveModel() util.Model {
  120. if len(d.dialogs) == 0 {
  121. return nil
  122. }
  123. return d.dialogs[len(d.dialogs)-1]
  124. }
  125. func (d dialogCmp) ActiveDialogID() DialogID {
  126. if len(d.dialogs) == 0 {
  127. return ""
  128. }
  129. return d.dialogs[len(d.dialogs)-1].ID()
  130. }
  131. func (d dialogCmp) GetLayers() []*lipgloss.Layer {
  132. layers := []*lipgloss.Layer{}
  133. for _, dialog := range d.Dialogs() {
  134. dialogView := dialog.View()
  135. row, col := dialog.Position()
  136. layers = append(layers, lipgloss.NewLayer(dialogView).X(col).Y(row))
  137. }
  138. return layers
  139. }
  140. func (d dialogCmp) HasDialogs() bool {
  141. return len(d.dialogs) > 0
  142. }