@@ -33,17 +33,32 @@ type Manager interface {
3333 Open () bool
3434}
3535
36+ // dialogEntry pairs a dialog with its drag offset so the two stay in sync.
37+ type dialogEntry struct {
38+ dialog Dialog
39+ offsetX int // accumulated horizontal drag displacement
40+ offsetY int // accumulated vertical drag displacement
41+ }
42+
43+ // dragState tracks an in-progress drag operation.
44+ type dragState struct {
45+ active bool
46+ startX int // screen X where drag began
47+ startY int // screen Y where drag began
48+ origDX int // dialog offsetX at drag start
49+ origDY int // dialog offsetY at drag start
50+ }
51+
3652// manager implements Manager
3753type manager struct {
3854 width , height int
39- dialogStack []Dialog
55+ stack []dialogEntry
56+ drag dragState
4057}
4158
4259// New creates a new dialog component manager
4360func New () Manager {
44- return & manager {
45- dialogStack : make ([]Dialog , 0 ),
46- }
61+ return & manager {}
4762}
4863
4964// Init initializes the dialog component
@@ -57,24 +72,12 @@ func (d *manager) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
5772 case tea.WindowSizeMsg :
5873 d .width = msg .Width
5974 d .height = msg .Height
60- // Propagate resize to all dialogs in the stack
61- var cmds []tea.Cmd
62- for i := range d .dialogStack {
63- u , cmd := d .dialogStack [i ].Update (msg )
64- d .dialogStack [i ] = u .(Dialog )
65- cmds = append (cmds , cmd )
66- }
67- return d , tea .Batch (cmds ... )
75+ cmd := d .broadcastToAll (msg )
76+ return d , cmd
6877
6978 case messages.ThemeChangedMsg :
70- // Propagate theme change to all dialogs in the stack so they can invalidate caches
71- var cmds []tea.Cmd
72- for i := range d .dialogStack {
73- u , cmd := d .dialogStack [i ].Update (msg )
74- d .dialogStack [i ] = u .(Dialog )
75- cmds = append (cmds , cmd )
76- }
77- return d , tea .Batch (cmds ... )
79+ cmd := d .broadcastToAll (msg )
80+ return d , cmd
7881
7982 case OpenDialogMsg :
8083 return d .handleOpen (msg )
@@ -84,32 +87,153 @@ func (d *manager) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
8487
8588 case CloseAllDialogsMsg :
8689 return d .handleCloseAll ()
87- }
8890
89- // Forward messages to top dialog if it exists
90- // Only the topmost dialog receives input to prevent conflicts
91- if len (d .dialogStack ) > 0 {
92- topIndex := len (d .dialogStack ) - 1
93- u , cmd := d .dialogStack [topIndex ].Update (msg )
94- d .dialogStack [topIndex ] = u .(Dialog )
91+ case tea.MouseClickMsg :
92+ if msg .Button == tea .MouseLeft && d .handleDragStart (msg .X , msg .Y ) {
93+ return d , nil
94+ }
95+ cmd := d .forwardToTop (d .adjustMouseMsg (msg ))
96+ return d , cmd
97+
98+ case tea.MouseMotionMsg :
99+ if d .drag .active {
100+ d .handleDragMotion (msg .X , msg .Y )
101+ return d , nil
102+ }
103+ cmd := d .forwardToTop (d .adjustMouseMsg (msg ))
104+ return d , cmd
105+
106+ case tea.MouseReleaseMsg :
107+ if d .drag .active {
108+ d .drag .active = false
109+ return d , nil
110+ }
111+ cmd := d .forwardToTop (d .adjustMouseMsg (msg ))
112+ return d , cmd
113+
114+ case tea.MouseWheelMsg :
115+ cmd := d .forwardToTop (d .adjustMouseMsg (msg ))
95116 return d , cmd
96117 }
97- return d , nil
118+
119+ // Forward non-mouse messages to top dialog
120+ cmd := d .forwardToTop (msg )
121+ return d , cmd
98122}
99123
100124// View renders all dialogs (used for debugging, actual rendering uses GetLayers)
101125func (d * manager ) View () string {
102- // This is mainly for debugging - actual rendering uses GetLayers
103- if len (d .dialogStack ) == 0 {
126+ if len (d .stack ) == 0 {
104127 return ""
105128 }
106- // Return view of top dialog for debugging
107- return d .dialogStack [len (d .dialogStack )- 1 ].View ()
129+ return d .stack [len (d .stack )- 1 ].dialog .View ()
130+ }
131+
132+ // broadcastToAll sends a message to every dialog in the stack and batches the resulting commands.
133+ func (d * manager ) broadcastToAll (msg tea.Msg ) tea.Cmd {
134+ var cmds []tea.Cmd
135+ for i := range d .stack {
136+ u , cmd := d .stack [i ].dialog .Update (msg )
137+ d .stack [i ].dialog = u .(Dialog )
138+ cmds = append (cmds , cmd )
139+ }
140+ return tea .Batch (cmds ... )
141+ }
142+
143+ // forwardToTop forwards a message to the topmost dialog and returns the resulting command.
144+ func (d * manager ) forwardToTop (msg tea.Msg ) tea.Cmd {
145+ if len (d .stack ) == 0 {
146+ return nil
147+ }
148+ top := len (d .stack ) - 1
149+ u , cmd := d .stack [top ].dialog .Update (msg )
150+ d .stack [top ].dialog = u .(Dialog )
151+ return cmd
152+ }
153+
154+ // titleZoneHeight is the number of rows from the top of a dialog that form
155+ // the draggable title zone: border top + padding top + title line + separator.
156+ const titleZoneHeight = 4
157+
158+ // handleDragStart checks if a mouse click is in the title zone of the topmost
159+ // dialog (border, padding, title text, and separator). If so, it initiates a
160+ // drag operation and returns true.
161+ func (d * manager ) handleDragStart (x , y int ) bool {
162+ if len (d .stack ) == 0 {
163+ return false
164+ }
165+ top := len (d .stack ) - 1
166+ e := & d .stack [top ]
167+
168+ row , col := e .dialog .Position ()
169+ row += e .offsetY
170+ col += e .offsetX
171+ w := lipgloss .Width (e .dialog .View ())
172+
173+ // Check horizontal bounds
174+ if x < col || x >= col + w {
175+ return false
176+ }
177+ // Check vertical bounds: click must be within the title zone
178+ if y < row || y >= row + titleZoneHeight {
179+ return false
180+ }
181+
182+ d .drag = dragState {
183+ active : true ,
184+ startX : x ,
185+ startY : y ,
186+ origDX : e .offsetX ,
187+ origDY : e .offsetY ,
188+ }
189+ return true
190+ }
191+
192+ // handleDragMotion updates the drag offset during a drag operation.
193+ func (d * manager ) handleDragMotion (x , y int ) {
194+ if len (d .stack ) == 0 {
195+ return
196+ }
197+ e := & d .stack [len (d .stack )- 1 ]
198+ e .offsetX = d .drag .origDX + (x - d .drag .startX )
199+ e .offsetY = d .drag .origDY + (y - d .drag .startY )
200+ }
201+
202+ // adjustMouseMsg adjusts mouse coordinates in a message to account for the drag offset
203+ // of the top dialog, so that the dialog's internal hit-testing works correctly.
204+ func (d * manager ) adjustMouseMsg (msg tea.Msg ) tea.Msg {
205+ if len (d .stack ) == 0 {
206+ return msg
207+ }
208+ e := d .stack [len (d .stack )- 1 ]
209+ if e .offsetX == 0 && e .offsetY == 0 {
210+ return msg
211+ }
212+
213+ switch m := msg .(type ) {
214+ case tea.MouseClickMsg :
215+ m .X -= e .offsetX
216+ m .Y -= e .offsetY
217+ return m
218+ case tea.MouseMotionMsg :
219+ m .X -= e .offsetX
220+ m .Y -= e .offsetY
221+ return m
222+ case tea.MouseReleaseMsg :
223+ m .X -= e .offsetX
224+ m .Y -= e .offsetY
225+ return m
226+ case tea.MouseWheelMsg :
227+ m .X -= e .offsetX
228+ m .Y -= e .offsetY
229+ return m
230+ }
231+ return msg
108232}
109233
110234// handleOpen processes dialog opening requests and adds to stack
111235func (d * manager ) handleOpen (msg OpenDialogMsg ) (layout.Model , tea.Cmd ) {
112- d .dialogStack = append (d .dialogStack , msg .Model )
236+ d .stack = append (d .stack , dialogEntry { dialog : msg .Model } )
113237
114238 var cmds []tea.Cmd
115239 cmd := msg .Model .Init ()
@@ -126,22 +250,23 @@ func (d *manager) handleOpen(msg OpenDialogMsg) (layout.Model, tea.Cmd) {
126250
127251// handleClose processes dialog closing requests (pops top dialog from stack)
128252func (d * manager ) handleClose () (layout.Model , tea.Cmd ) {
129- if len (d .dialogStack ) > 0 {
130- d .dialogStack = d .dialogStack [:len (d .dialogStack )- 1 ]
253+ if len (d .stack ) > 0 {
254+ d .stack = d .stack [:len (d .stack )- 1 ]
131255 }
132-
256+ d . drag . active = false
133257 return d , nil
134258}
135259
136260// handleCloseAll closes all dialogs in the stack
137261func (d * manager ) handleCloseAll () (layout.Model , tea.Cmd ) {
138- d .dialogStack = make ([]Dialog , 0 )
262+ d .stack = nil
263+ d .drag .active = false
139264 return d , nil
140265}
141266
142267// Open returns true if there is at least one active dialog
143268func (d * manager ) Open () bool {
144- return len (d .dialogStack ) > 0
269+ return len (d .stack ) > 0
145270}
146271
147272func (d * manager ) SetSize (width , height int ) tea.Cmd {
@@ -166,15 +291,15 @@ func CenterPosition(screenWidth, screenHeight, dialogWidth, dialogHeight int) (r
166291// GetLayers returns lipgloss layers for rendering all dialogs in the stack
167292// Dialogs are returned in order from bottom to top (index 0 is bottom-most)
168293func (d * manager ) GetLayers () []* lipgloss.Layer {
169- if len (d .dialogStack ) == 0 {
294+ if len (d .stack ) == 0 {
170295 return nil
171296 }
172297
173- layers := make ([]* lipgloss.Layer , 0 , len (d .dialogStack ))
174- for _ , dialog := range d .dialogStack {
175- dialogView := dialog .View ()
176- row , col := dialog .Position ()
177- layers = append (layers , lipgloss .NewLayer (dialogView ).X (col ).Y (row ))
298+ layers := make ([]* lipgloss.Layer , 0 , len (d .stack ))
299+ for _ , e := range d .stack {
300+ view := e . dialog .View ()
301+ row , col := e . dialog .Position ()
302+ layers = append (layers , lipgloss .NewLayer (view ).X (col + e . offsetX ).Y (row + e . offsetY ))
178303 }
179304
180305 return layers
0 commit comments