diff --git a/pkg/tui/dialog/dialog.go b/pkg/tui/dialog/dialog.go index 6671a2753..2dc0e4f1a 100644 --- a/pkg/tui/dialog/dialog.go +++ b/pkg/tui/dialog/dialog.go @@ -33,17 +33,32 @@ type Manager interface { Open() bool } +// dialogEntry pairs a dialog with its drag offset so the two stay in sync. +type dialogEntry struct { + dialog Dialog + offsetX int // accumulated horizontal drag displacement + offsetY int // accumulated vertical drag displacement +} + +// dragState tracks an in-progress drag operation. +type dragState struct { + active bool + startX int // screen X where drag began + startY int // screen Y where drag began + origDX int // dialog offsetX at drag start + origDY int // dialog offsetY at drag start +} + // manager implements Manager type manager struct { width, height int - dialogStack []Dialog + stack []dialogEntry + drag dragState } // New creates a new dialog component manager func New() Manager { - return &manager{ - dialogStack: make([]Dialog, 0), - } + return &manager{} } // Init initializes the dialog component @@ -57,24 +72,12 @@ func (d *manager) Update(msg tea.Msg) (layout.Model, tea.Cmd) { case tea.WindowSizeMsg: d.width = msg.Width d.height = msg.Height - // Propagate resize to all dialogs in the stack - var cmds []tea.Cmd - for i := range d.dialogStack { - u, cmd := d.dialogStack[i].Update(msg) - d.dialogStack[i] = u.(Dialog) - cmds = append(cmds, cmd) - } - return d, tea.Batch(cmds...) + cmd := d.broadcastToAll(msg) + return d, cmd case messages.ThemeChangedMsg: - // Propagate theme change to all dialogs in the stack so they can invalidate caches - var cmds []tea.Cmd - for i := range d.dialogStack { - u, cmd := d.dialogStack[i].Update(msg) - d.dialogStack[i] = u.(Dialog) - cmds = append(cmds, cmd) - } - return d, tea.Batch(cmds...) + cmd := d.broadcastToAll(msg) + return d, cmd case OpenDialogMsg: return d.handleOpen(msg) @@ -84,32 +87,153 @@ func (d *manager) Update(msg tea.Msg) (layout.Model, tea.Cmd) { case CloseAllDialogsMsg: return d.handleCloseAll() - } - // Forward messages to top dialog if it exists - // Only the topmost dialog receives input to prevent conflicts - if len(d.dialogStack) > 0 { - topIndex := len(d.dialogStack) - 1 - u, cmd := d.dialogStack[topIndex].Update(msg) - d.dialogStack[topIndex] = u.(Dialog) + case tea.MouseClickMsg: + if msg.Button == tea.MouseLeft && d.handleDragStart(msg.X, msg.Y) { + return d, nil + } + cmd := d.forwardToTop(d.adjustMouseMsg(msg)) + return d, cmd + + case tea.MouseMotionMsg: + if d.drag.active { + d.handleDragMotion(msg.X, msg.Y) + return d, nil + } + cmd := d.forwardToTop(d.adjustMouseMsg(msg)) + return d, cmd + + case tea.MouseReleaseMsg: + if d.drag.active { + d.drag.active = false + return d, nil + } + cmd := d.forwardToTop(d.adjustMouseMsg(msg)) + return d, cmd + + case tea.MouseWheelMsg: + cmd := d.forwardToTop(d.adjustMouseMsg(msg)) return d, cmd } - return d, nil + + // Forward non-mouse messages to top dialog + cmd := d.forwardToTop(msg) + return d, cmd } // View renders all dialogs (used for debugging, actual rendering uses GetLayers) func (d *manager) View() string { - // This is mainly for debugging - actual rendering uses GetLayers - if len(d.dialogStack) == 0 { + if len(d.stack) == 0 { return "" } - // Return view of top dialog for debugging - return d.dialogStack[len(d.dialogStack)-1].View() + return d.stack[len(d.stack)-1].dialog.View() +} + +// broadcastToAll sends a message to every dialog in the stack and batches the resulting commands. +func (d *manager) broadcastToAll(msg tea.Msg) tea.Cmd { + var cmds []tea.Cmd + for i := range d.stack { + u, cmd := d.stack[i].dialog.Update(msg) + d.stack[i].dialog = u.(Dialog) + cmds = append(cmds, cmd) + } + return tea.Batch(cmds...) +} + +// forwardToTop forwards a message to the topmost dialog and returns the resulting command. +func (d *manager) forwardToTop(msg tea.Msg) tea.Cmd { + if len(d.stack) == 0 { + return nil + } + top := len(d.stack) - 1 + u, cmd := d.stack[top].dialog.Update(msg) + d.stack[top].dialog = u.(Dialog) + return cmd +} + +// titleZoneHeight is the number of rows from the top of a dialog that form +// the draggable title zone: border top + padding top + title line + separator. +const titleZoneHeight = 4 + +// handleDragStart checks if a mouse click is in the title zone of the topmost +// dialog (border, padding, title text, and separator). If so, it initiates a +// drag operation and returns true. +func (d *manager) handleDragStart(x, y int) bool { + if len(d.stack) == 0 { + return false + } + top := len(d.stack) - 1 + e := &d.stack[top] + + row, col := e.dialog.Position() + row += e.offsetY + col += e.offsetX + w := lipgloss.Width(e.dialog.View()) + + // Check horizontal bounds + if x < col || x >= col+w { + return false + } + // Check vertical bounds: click must be within the title zone + if y < row || y >= row+titleZoneHeight { + return false + } + + d.drag = dragState{ + active: true, + startX: x, + startY: y, + origDX: e.offsetX, + origDY: e.offsetY, + } + return true +} + +// handleDragMotion updates the drag offset during a drag operation. +func (d *manager) handleDragMotion(x, y int) { + if len(d.stack) == 0 { + return + } + e := &d.stack[len(d.stack)-1] + e.offsetX = d.drag.origDX + (x - d.drag.startX) + e.offsetY = d.drag.origDY + (y - d.drag.startY) +} + +// adjustMouseMsg adjusts mouse coordinates in a message to account for the drag offset +// of the top dialog, so that the dialog's internal hit-testing works correctly. +func (d *manager) adjustMouseMsg(msg tea.Msg) tea.Msg { + if len(d.stack) == 0 { + return msg + } + e := d.stack[len(d.stack)-1] + if e.offsetX == 0 && e.offsetY == 0 { + return msg + } + + switch m := msg.(type) { + case tea.MouseClickMsg: + m.X -= e.offsetX + m.Y -= e.offsetY + return m + case tea.MouseMotionMsg: + m.X -= e.offsetX + m.Y -= e.offsetY + return m + case tea.MouseReleaseMsg: + m.X -= e.offsetX + m.Y -= e.offsetY + return m + case tea.MouseWheelMsg: + m.X -= e.offsetX + m.Y -= e.offsetY + return m + } + return msg } // handleOpen processes dialog opening requests and adds to stack func (d *manager) handleOpen(msg OpenDialogMsg) (layout.Model, tea.Cmd) { - d.dialogStack = append(d.dialogStack, msg.Model) + d.stack = append(d.stack, dialogEntry{dialog: msg.Model}) var cmds []tea.Cmd cmd := msg.Model.Init() @@ -126,22 +250,23 @@ func (d *manager) handleOpen(msg OpenDialogMsg) (layout.Model, tea.Cmd) { // handleClose processes dialog closing requests (pops top dialog from stack) func (d *manager) handleClose() (layout.Model, tea.Cmd) { - if len(d.dialogStack) > 0 { - d.dialogStack = d.dialogStack[:len(d.dialogStack)-1] + if len(d.stack) > 0 { + d.stack = d.stack[:len(d.stack)-1] } - + d.drag.active = false return d, nil } // handleCloseAll closes all dialogs in the stack func (d *manager) handleCloseAll() (layout.Model, tea.Cmd) { - d.dialogStack = make([]Dialog, 0) + d.stack = nil + d.drag.active = false return d, nil } // Open returns true if there is at least one active dialog func (d *manager) Open() bool { - return len(d.dialogStack) > 0 + return len(d.stack) > 0 } func (d *manager) SetSize(width, height int) tea.Cmd { @@ -166,15 +291,15 @@ func CenterPosition(screenWidth, screenHeight, dialogWidth, dialogHeight int) (r // GetLayers returns lipgloss layers for rendering all dialogs in the stack // Dialogs are returned in order from bottom to top (index 0 is bottom-most) func (d *manager) GetLayers() []*lipgloss.Layer { - if len(d.dialogStack) == 0 { + if len(d.stack) == 0 { return nil } - layers := make([]*lipgloss.Layer, 0, len(d.dialogStack)) - for _, dialog := range d.dialogStack { - dialogView := dialog.View() - row, col := dialog.Position() - layers = append(layers, lipgloss.NewLayer(dialogView).X(col).Y(row)) + layers := make([]*lipgloss.Layer, 0, len(d.stack)) + for _, e := range d.stack { + view := e.dialog.View() + row, col := e.dialog.Position() + layers = append(layers, lipgloss.NewLayer(view).X(col+e.offsetX).Y(row+e.offsetY)) } return layers