Introduction
Out of the box, a list will not scroll during drag and drop handling to show content that is scrolled out of view.
This is a little mixin to auto scroll a ListCtrl (report mode only) when it is being used as a drop target, and will optionally show an indicator line to show where the dragged content will be dropped.
It uses a timer to scroll the list based on a direction that you supply during an OnDragOver event handler. When the drop cursor is near the top or bottom of the list, it will scroll the list one item at a time as long as the cursor remains near the top or bottom. Moving the cursor off the list or to the center part of the list stops the scrolling.
The mixin itself is called ListDropScrollerMixin; in the sample below there's also a test program to demonstrate the mixin.
ListDropScrollerMixin
1 #-----------------------------------------------------------------------------
2 # Name: dropscroller.py
3 # Purpose: auto scrolling for a list that's being used as a drop target
4 #
5 # Author: Rob McMullen
6 #
7 # Created: 2007
8 # RCS-ID: $Id: $
9 # Copyright: (c) 2007 Rob McMullen
10 # License: wxPython
11 #-----------------------------------------------------------------------------
12 """
13 Automatic scrolling mixin for a list control, including an indicator
14 showing where the items will be dropped.
15
16 It would be nice to have somethin similar for a tree control as well,
17 but I haven't tackled that yet.
18 """
19 import sys
20
21 import wx
22
23 class ListDropScrollerMixin(object):
24 """Automatic scrolling for ListCtrls for use when using drag and drop.
25
26 This mixin is used to automatically scroll a list control when
27 approaching the top or bottom edge of a list. Currently, this
28 only works for lists in report mode.
29
30 Add this as a mixin in your list, and then call processListScroll
31 in your DropTarget's OnDragOver method. When the drop ends, call
32 finishListScroll to clean up the resources (i.e. the wx.Timer)
33 that the dropscroller uses and make sure that the insertion
34 indicator is erased.
35
36 The parameter interval is the delay time in milliseconds between
37 list scroll steps.
38
39 If indicator_width is negative, then the indicator will be the
40 width of the list. If positive, the width will be that number of
41 pixels, and zero means to display no indicator.
42 """
43 def __init__(self, interval=200, width=-1):
44 """Don't forget to call this mixin's init method in your List.
45
46 Interval is in milliseconds.
47 """
48 self._auto_scroll_timer = None
49 self._auto_scroll_interval = interval
50 self._auto_scroll = 0
51 self._auto_scroll_save_y = -1
52 self._auto_scroll_save_width = width
53 self.Bind(wx.EVT_TIMER, self.OnAutoScrollTimer)
54
55 def _startAutoScrollTimer(self, direction = 0):
56 """Set the direction of the next scroll, and start the
57 interval timer if it's not already running.
58 """
59 if self._auto_scroll_timer == None:
60 self._auto_scroll_timer = wx.Timer(self, wx.TIMER_ONE_SHOT)
61 self._auto_scroll_timer.Start(self._auto_scroll_interval)
62 self._auto_scroll = direction
63
64 def _stopAutoScrollTimer(self):
65 """Clean up the timer resources.
66 """
67 self._auto_scroll_timer = None
68 self._auto_scroll = 0
69
70 def _getAutoScrollDirection(self, index):
71 """Determine the scroll step direction that the list should
72 move, based on the index reported by HitTest.
73 """
74 first_displayed = self.GetTopItem()
75
76 if first_displayed == index:
77 # If the mouse is over the first index...
78 if index > 0:
79 # scroll the list up unless...
80 return -1
81 else:
82 # we're already at the top.
83 return 0
84 elif index >= first_displayed + self.GetCountPerPage() - 1:
85 # If the mouse is over the last visible item, but we're
86 # not at the last physical item, scroll down.
87 return 1
88 # we're somewhere in the middle of the list. Don't scroll
89 return 0
90
91 def getDropIndex(self, x, y, index=None, flags=None):
92 """Find the index to insert the new item, which could be
93 before or after the index passed in.
94 """
95 if index is None:
96 index, flags = self.HitTest((x, y))
97
98 if index == wx.NOT_FOUND: # not clicked on an item
99 if (flags & (wx.LIST_HITTEST_NOWHERE|wx.LIST_HITTEST_ABOVE|wx.LIST_HITTEST_BELOW)): # empty list or below last item
100 index = sys.maxint # append to end of list
101 #print "getDropIndex: append to end of list: index=%d" % index
102 elif (self.GetItemCount() > 0):
103 if y <= self.GetItemRect(0).y: # clicked just above first item
104 index = 0 # append to top of list
105 #print "getDropIndex: before first item: index=%d, y=%d, rect.y=%d" % (index, y, self.GetItemRect(0).y)
106 else:
107 index = self.GetItemCount() + 1 # append to end of list
108 #print "getDropIndex: after last item: index=%d" % index
109 else: # clicked on an item
110 # Get bounding rectangle for the item the user is dropping over.
111 rect = self.GetItemRect(index)
112 #print "getDropIndex: landed on %d, y=%d, rect=%s" % (index, y, rect)
113
114 # NOTE: On all platforms, the y coordinate used by HitTest
115 # is relative to the scrolled window. There are platform
116 # differences, however, because on GTK the top of the
117 # vertical scrollbar stops below the header, while on MSW
118 # the top of the vertical scrollbar is equal to the top of
119 # the header. The result is the y used in HitTest and the
120 # y returned by GetItemRect are offset by a certain amount
121 # on GTK. The HitTest's y=0 in GTK corresponds to the top
122 # of the first item, while y=0 on MSW is in the header.
123
124 # From Robin Dunn: use GetMainWindow on the list to find
125 # the actual window on which to draw
126 if self != self.GetMainWindow():
127 y += self.GetMainWindow().GetPositionTuple()[1]
128
129 # If the user is dropping into the lower half of the rect,
130 # we want to insert _after_ this item.
131 if y >= (rect.y + rect.height/2):
132 index = index + 1
133
134 return index
135
136 def processListScroll(self, x, y):
137 """Main handler: call this with the x and y coordinates of the
138 mouse cursor as determined from the OnDragOver callback.
139
140 This method will determine which direction the list should be
141 scrolled, and start the interval timer if necessary.
142 """
143 index, flags = self.HitTest((x, y))
144
145 direction = self._getAutoScrollDirection(index)
146 if direction == 0:
147 self._stopAutoScrollTimer()
148 else:
149 self._startAutoScrollTimer(direction)
150
151 drop_index = self.getDropIndex(x, y, index=index, flags=flags)
152 count = self.GetItemCount()
153 if drop_index >= count:
154 rect = self.GetItemRect(count - 1)
155 y = rect.y + rect.height + 1
156 else:
157 rect = self.GetItemRect(drop_index)
158 y = rect.y
159
160 # From Robin Dunn: on GTK & MAC the list is implemented as
161 # a subwindow, so have to use GetMainWindow on the list to
162 # find the actual window on which to draw
163 if self != self.GetMainWindow():
164 y -= self.GetMainWindow().GetPositionTuple()[1]
165
166 if self._auto_scroll_save_y == -1 or self._auto_scroll_save_y != y:
167 #print "main window=%s, self=%s, pos=%s" % (self, self.GetMainWindow(), self.GetMainWindow().GetPositionTuple())
168 if self._auto_scroll_save_width < 0:
169 self._auto_scroll_save_width = rect.width
170 dc = self._getIndicatorDC()
171 self._eraseIndicator(dc)
172 dc.DrawLine(0, y, self._auto_scroll_save_width, y)
173 self._auto_scroll_save_y = y
174
175 def finishListScroll(self):
176 """Clean up timer resource and erase indicator.
177 """
178 self._stopAutoScrollTimer()
179 self._eraseIndicator()
180
181 def OnAutoScrollTimer(self, evt):
182 """Timer event handler to scroll the list in the requested
183 direction.
184 """
185 #print "_auto_scroll = %d, timer = %s" % (self._auto_scroll, self._auto_scroll_timer is not None)
186 if self._auto_scroll == 0:
187 # clean up timer resource
188 self._auto_scroll_timer = None
189 else:
190 dc = self._getIndicatorDC()
191 self._eraseIndicator(dc)
192 if self._auto_scroll < 0:
193 self.EnsureVisible(self.GetTopItem() + self._auto_scroll)
194 self._auto_scroll_timer.Start()
195 else:
196 self.EnsureVisible(self.GetTopItem() + self.GetCountPerPage())
197 self._auto_scroll_timer.Start()
198 evt.Skip()
199
200 def _getIndicatorDC(self):
201 dc = wx.ClientDC(self.GetMainWindow())
202 dc.SetPen(wx.Pen(wx.WHITE, 3))
203 dc.SetBrush(wx.TRANSPARENT_BRUSH)
204 dc.SetLogicalFunction(wx.XOR)
205 return dc
206
207 def _eraseIndicator(self, dc=None):
208 if dc is None:
209 dc = self._getIndicatorDC()
210 if self._auto_scroll_save_y >= 0:
211 # erase the old line
212 dc.DrawLine(0, self._auto_scroll_save_y,
213 self._auto_scroll_save_width, self._auto_scroll_save_y)
214 self._auto_scroll_save_y = -1
215
216
217
218 if __name__ == '__main__':
219 import cPickle as pickle
220
221 class TestDataObject(wx.CustomDataObject):
222 """Sample custom data object"""
223 def __init__(self):
224 wx.CustomDataObject.__init__(self, "TestData")
225
226 class TestDropTarget(wx.PyDropTarget):
227 """Custom drop target modified from the wxPython demo."""
228
229 def __init__(self, window):
230 wx.PyDropTarget.__init__(self)
231 self.dv = window
232
233 # specify the type of data we will accept
234 self.data = TestDataObject()
235 self.SetDataObject(self.data)
236
237 def cleanup(self):
238 self.dv.finishListScroll()
239
240 # some virtual methods that track the progress of the drag
241 def OnEnter(self, x, y, d):
242 print "OnEnter: %d, %d, %d\n" % (x, y, d)
243 return d
244
245 def OnLeave(self):
246 print "OnLeave\n"
247 self.cleanup()
248
249 def OnDrop(self, x, y):
250 print "OnDrop: %d %d\n" % (x, y)
251 self.cleanup()
252 return True
253
254 def OnDragOver(self, x, y, d):
255 top = self.dv.GetTopItem()
256 print "OnDragOver: %d, %d, %d, top=%s" % (x, y, d, top)
257
258 self.dv.processListScroll(x, y)
259
260 # The value returned here tells the source what kind of visual
261 # feedback to give. For example, if wxDragCopy is returned then
262 # only the copy cursor will be shown, even if the source allows
263 # moves. You can use the passed in (x,y) to determine what kind
264 # of feedback to give. In this case we return the suggested value
265 # which is based on whether the Ctrl key is pressed.
266 return d
267
268 # Called when OnDrop returns True. We need to get the data and
269 # do something with it.
270 def OnData(self, x, y, d):
271 print "OnData: %d, %d, %d\n" % (x, y, d)
272
273 self.cleanup()
274 # copy the data from the drag source to our data object
275 if self.GetData():
276 # convert it back to a list of lines and give it to the viewer
277 items = pickle.loads(self.data.GetData())
278 self.dv.AddDroppedItems(x, y, items)
279
280 # what is returned signals the source what to do
281 # with the original data (move, copy, etc.) In this
282 # case we just return the suggested value given to us.
283 return d
284
285 class TestList(wx.ListCtrl, ListDropScrollerMixin):
286 """Simple list control that provides a drop target and uses
287 the new mixin for automatic scrolling.
288 """
289
290 def __init__(self, parent, name, count=100):
291 wx.ListCtrl.__init__(self, parent, style=wx.LC_REPORT)
292
293 # The mixin needs to be initialized
294 ListDropScrollerMixin.__init__(self, interval=200)
295
296 self.dropTarget=TestDropTarget(self)
297 self.SetDropTarget(self.dropTarget)
298
299 self.create(name, count)
300
301 self.Bind(wx.EVT_LIST_BEGIN_DRAG, self.OnStartDrag)
302
303 def create(self, name, count):
304 """Set up some test data."""
305
306 self.InsertColumn(0, "#")
307 self.InsertColumn(1, "Title")
308 for i in range(count):
309 self.InsertStringItem(sys.maxint, str(i))
310 self.SetStringItem(i, 1, "%s-%d" % (name, i))
311
312 def OnStartDrag(self, evt):
313 index = evt.GetIndex()
314 print "beginning drag of item %d" % index
315
316 # Create the data object containing all currently selected
317 # items
318 data = TestDataObject()
319 items = []
320 index = self.GetFirstSelected()
321 while index != -1:
322 items.append((self.GetItem(index, 0).GetText(),
323 self.GetItem(index, 1).GetText()))
324 index = self.GetNextSelected(index)
325 data.SetData(pickle.dumps(items,-1))
326
327 # And finally, create the drop source and begin the drag
328 # and drop opperation
329 dropSource = wx.DropSource(self)
330 dropSource.SetData(data)
331 print "Begining DragDrop\n"
332 result = dropSource.DoDragDrop(wx.Drag_AllowMove)
333 print "DragDrop completed: %d\n" % result
334
335 def AddDroppedItems(self, x, y, items):
336 index = self.getDropIndex(x, y)
337 print "At (%d,%d), index=%d, adding %s" % (x, y, index, items)
338
339 list_count = self.GetItemCount()
340 for item in items:
341 index = self.InsertStringItem(index, item[0])
342 self.SetStringItem(index, 1, item[1])
343 index += 1
344
345
346 class ListPanel(wx.SplitterWindow):
347 def __init__(self, parent):
348 wx.SplitterWindow.__init__(self, parent)
349
350 self.list1 = TestList(self, "left", 100)
351 self.list2 = TestList(self, "right", 10)
352 self.SplitVertically(self.list1, self.list2)
353 self.Layout()
354
355 app = wx.PySimpleApp()
356 frame = wx.Frame(None, -1, title='List Drag Test', size=(400,500))
357 frame.CreateStatusBar()
358
359 panel = ListPanel(frame)
360 label = wx.StaticText(frame, -1, "Drag items from a list to either list.\nThe lists will scroll when the cursor\nis near the first and last visible items")
361
362 sizer = wx.BoxSizer(wx.VERTICAL)
363 sizer.Add(label, 0, wx.ALIGN_CENTRE|wx.ALL, 5)
364 sizer.Add(panel, 1, wx.EXPAND | wx.ALL, 5)
365
366 frame.SetAutoLayout(1)
367 frame.SetSizer(sizer)
368 frame.Show(1)
369
370 app.MainLoop()
-- RobMcMullen