Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 169 additions & 44 deletions pkg/tui/dialog/dialog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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()
Expand All @@ -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 {
Expand All @@ -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
Expand Down
Loading