Skip to content

Commit 185f276

Browse files
authored
Merge pull request #2339 from dgageot/board/implement-docker-agent-issue-2336endtml-bbde5179
feat: add mouse drag-to-move support for TUI dialogs
2 parents c54f7a2 + 0e7510b commit 185f276

1 file changed

Lines changed: 169 additions & 44 deletions

File tree

pkg/tui/dialog/dialog.go

Lines changed: 169 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -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
3753
type 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
4360
func 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)
101125
func (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
111235
func (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)
128252
func (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
137261
func (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
143268
func (d *manager) Open() bool {
144-
return len(d.dialogStack) > 0
269+
return len(d.stack) > 0
145270
}
146271

147272
func (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)
168293
func (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

Comments
 (0)