diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go index a61b33c8fc9c0eca88425bdef03499a5c387b378..046e73aa6b6466fe179f6358fc23a93774b5ca74 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -4,7 +4,12 @@ import ( "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/ui/common" +) + +// CloseKey is the default key binding to close dialogs. +var CloseKey = key.NewBinding( + key.WithKeys("esc", "alt+esc"), + key.WithHelp("esc", "exit"), ) // OverlayKeyMap defines key bindings for dialogs. @@ -12,35 +17,50 @@ type OverlayKeyMap struct { Close key.Binding } -// DefaultOverlayKeyMap returns the default key bindings for dialogs. -func DefaultOverlayKeyMap() OverlayKeyMap { - return OverlayKeyMap{ - Close: key.NewBinding( - key.WithKeys("esc", "alt+esc"), - ), - } +// ActionType represents the type of action taken by a dialog. +type ActionType int + +const ( + // ActionNone indicates no action. + ActionNone ActionType = iota + // ActionClose indicates that the dialog should be closed. + ActionClose +) + +// Action represents an action taken by a dialog. +// It can be used to signal closing or other operations. +type Action struct { + Type ActionType + Payload any } // Dialog is a component that can be displayed on top of the UI. type Dialog interface { - common.Model[Dialog] ID() string + Update(msg tea.Msg) (Action, tea.Cmd) + Layer() *lipgloss.Layer } // Overlay manages multiple dialogs as an overlay. type Overlay struct { dialogs []Dialog - keyMap OverlayKeyMap } // NewOverlay creates a new [Overlay] instance. func NewOverlay(dialogs ...Dialog) *Overlay { return &Overlay{ dialogs: dialogs, - keyMap: DefaultOverlayKeyMap(), } } +// IsFrontDialog checks if the dialog with the specified ID is at the front. +func (d *Overlay) IsFrontDialog(dialogID string) bool { + if len(d.dialogs) == 0 { + return false + } + return d.dialogs[len(d.dialogs)-1].ID() == dialogID +} + // HasDialogs checks if there are any active dialogs. func (d *Overlay) HasDialogs() bool { return len(d.dialogs) > 0 @@ -83,54 +103,31 @@ func (d *Overlay) Update(msg tea.Msg) (*Overlay, tea.Cmd) { dialog := d.dialogs[idx] switch msg := msg.(type) { case tea.KeyPressMsg: - if key.Matches(msg, d.keyMap.Close) { + if key.Matches(msg, CloseKey) { // Close the current dialog d.removeDialog(idx) return d, nil } } - updatedDialog, cmd := dialog.Update(msg) - if updatedDialog == nil { - // Dialog requested to be closed + action, cmd := dialog.Update(msg) + switch action.Type { + case ActionClose: + // Close the current dialog d.removeDialog(idx) return d, cmd } - // Update the dialog in the stack - d.dialogs[idx] = updatedDialog - return d, cmd } -// View implements [Model]. -func (d *Overlay) View() string { - if len(d.dialogs) == 0 { - return "" - } - - // Compose all the dialogs into a single view - dialogs := make([]*lipgloss.Layer, len(d.dialogs)) +// Layers returns the current stack of dialogs as lipgloss layers. +func (d *Overlay) Layers() []*lipgloss.Layer { + layers := make([]*lipgloss.Layer, len(d.dialogs)) for i, dialog := range d.dialogs { - dialogs[i] = lipgloss.NewLayer(dialog.View()) - } - - comp := lipgloss.NewCompositor(dialogs...) - return comp.Render() -} - -// ShortHelp implements [help.KeyMap]. -func (d *Overlay) ShortHelp() []key.Binding { - return []key.Binding{ - d.keyMap.Close, - } -} - -// FullHelp implements [help.KeyMap]. -func (d *Overlay) FullHelp() [][]key.Binding { - return [][]key.Binding{ - {d.keyMap.Close}, + layers[i] = dialog.Layer() } + return layers } // removeDialog removes a dialog from the stack. diff --git a/internal/ui/dialog/quit.go b/internal/ui/dialog/quit.go index 1ec187d36654420a61e367bc51829a44d4c3a14d..77bcedaaa473fec6a7f79339ddba6ac92f594d34 100644 --- a/internal/ui/dialog/quit.go +++ b/internal/ui/dialog/quit.go @@ -73,30 +73,30 @@ func (*Quit) ID() string { } // Update implements [Model]. -func (q *Quit) Update(msg tea.Msg) (Dialog, tea.Cmd) { +func (q *Quit) Update(msg tea.Msg) (Action, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch { case key.Matches(msg, q.keyMap.LeftRight, q.keyMap.Tab): q.selectedNo = !q.selectedNo - return q, nil + return Action{}, nil case key.Matches(msg, q.keyMap.EnterSpace): if !q.selectedNo { - return q, tea.Quit + return Action{}, tea.Quit } - return nil, nil + return Action{}, nil case key.Matches(msg, q.keyMap.Yes): - return q, tea.Quit + return Action{}, tea.Quit case key.Matches(msg, q.keyMap.No, q.keyMap.Close): - return nil, nil + return Action{}, nil } } - return q, nil + return Action{}, nil } -// View implements [Model]. -func (q *Quit) View() string { +// Layer implements [Model]. +func (q *Quit) Layer() *lipgloss.Layer { const question = "Are you sure you want to quit?" baseStyle := q.com.Styles.Base buttonOpts := []common.ButtonOpts{ @@ -113,7 +113,7 @@ func (q *Quit) View() string { ), ) - return q.com.Styles.BorderFocus.Render(content) + return lipgloss.NewLayer(q.com.Styles.BorderFocus.Render(content)) } // ShortHelp implements [help.KeyMap]. diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 910d41ea75aa8c67f37b356837f6ca9ba912980c..be06824d42e934f158370e8dd5167bb6e30cbe3f 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -335,28 +335,48 @@ func (m *UI) loadSession(sessionID string) tea.Cmd { } func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { - if m.dialog.HasDialogs() { - return m.updateDialogs(msg) + handleQuitKeys := func(msg tea.KeyPressMsg) bool { + switch { + case key.Matches(msg, m.keyMap.Quit): + if !m.dialog.ContainsDialog(dialog.QuitDialogID) { + m.dialog.AddDialog(dialog.NewQuit(m.com)) + return true + } + } + return false } - handleGlobalKeys := func(msg tea.KeyPressMsg) { + handleGlobalKeys := func(msg tea.KeyPressMsg) bool { + if handleQuitKeys(msg) { + return true + } switch { case key.Matches(msg, m.keyMap.Tab): case key.Matches(msg, m.keyMap.Help): m.help.ShowAll = !m.help.ShowAll m.updateLayoutAndSize() - case key.Matches(msg, m.keyMap.Quit): - if !m.dialog.ContainsDialog(dialog.QuitDialogID) { - m.dialog.AddDialog(dialog.NewQuit(m.com)) - return - } + return true case key.Matches(msg, m.keyMap.Commands): // TODO: Implement me case key.Matches(msg, m.keyMap.Models): // TODO: Implement me case key.Matches(msg, m.keyMap.Sessions): // TODO: Implement me + return true } + return false + } + + if m.dialog.HasDialogs() { + // Always handle quit keys first + if handleQuitKeys(msg) { + return cmds + } + + updatedDialog, cmd := m.dialog.Update(msg) + m.dialog = updatedDialog + cmds = append(cmds, cmd) + return cmds } switch m.state { @@ -501,12 +521,19 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) { // This needs to come last to overlay on top of everything if m.dialog.HasDialogs() { - if dialogView := m.dialog.View(); dialogView != "" { - dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView) - dialogArea := common.CenterRect(area, dialogWidth, dialogHeight) - dialog := uv.NewStyledString(dialogView) - dialog.Draw(scr, dialogArea) + dialogLayers := m.dialog.Layers() + layers := make([]*lipgloss.Layer, 0) + for _, layer := range dialogLayers { + if layer == nil { + continue + } + layerW, layerH := layer.Width(), layer.Height() + layerArea := common.CenterRect(area, layerW, layerH) + layers = append(layers, layer.X(layerArea.Min.X).Y(layerArea.Min.Y)) } + + comp := lipgloss.NewCompositor(layers...) + comp.Draw(scr, area) } } @@ -650,14 +677,6 @@ func (m *UI) FullHelp() [][]key.Binding { return binds } -// updateDialogs updates the dialog overlay with the given message and returns cmds -func (m *UI) updateDialogs(msg tea.KeyPressMsg) (cmds []tea.Cmd) { - updatedDialog, cmd := m.dialog.Update(msg) - m.dialog = updatedDialog - cmds = append(cmds, cmd) - return cmds -} - // updateFocused updates the focused model (chat or editor) with the given message // and appends any resulting commands to the cmds slice. func (m *UI) updateFocused(msg tea.KeyPressMsg) (cmds []tea.Cmd) {