Detailed changes
@@ -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.
@@ -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].
@@ -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) {