Introduction
The original bounty (whose content is farther down) required a scheduling widget for handling appointments in a medical doctor's office. This widget needed to support initially fixed time slots, dragging and dropping of appointments, etc.
What Objects/Features are Involved
- wx.grid
- wx.lib.newevent
- custom dragging source based on mouse click and movement
Process Overview
Generally speaking, the scheduling widget starts out in a fairly regular manner by hiding the row and column labels and disabling row/column resizing. It uses a custom EVT_SIZE handler to resize the schedule information column and leave 32 pixels to the right empty. On the platforms tested (Windows and Ubuntu), this resulted in no horizontal scrollbars.
The content is initially populated with either fixed-length time slots and empty schedule information, or user-specified time slots and schedule information. Added to this is a slightly-more-than-minimal set of functionality to allow for the intuitive manipulation of schedule information (API spec due to Dr. Horst Herb), along with 'client data' information, which can be used as row ids for database entries or otherwise.
Included are a handful of event bindings for a cell's contents being modified, a cell being clicked on, a cell dragged out (for deletion from a database), or a cell dragged in (for isnertion into a database). Modifying the content with the API or does not cause events to be posted for handling.
The real trick with this widget was getting the grid to not select cells during drag. Some initial implementations used background color tricks to mask the selections, but the current version captures mouse click and drag events to usurp dragging behavior, disabling the underlying 'select all cells that have been dragged over', and which makes implementing drag and drop behavior fairly easy. The method used with the wx.grid can be used on other widgets to offer drag and drop behavior where previously such events weren't possible.
Implementation with documentation
1
2 '''
3 schedule.py
4
5 Version .6
6
7 A wx.grid.Grid-based scheduling widget written by Josiah Carlson for the
8 GNUMED project (http://www.gnumed.org) and related projects. This widget and
9 is licensed under the GNU GPL v. 2. If you would like an alternately licensed
10 version, please contact the author via phone, email, IM, or in writing; it is
11 likely that you will be able to get the widget in a license that you desire.
12
13 josiah.carlson@gmail.com or http://dr-josiah.blogspot.com/
14
15 The base control
16 ----------------
17
18 The object that you will find of the most use is:
19 ScheduleGrid(parent, data=None, from_time="8:00", to_time="17:00",
20 default_timeslot=15)
21
22 If data is a non-empty sequence of (time, appointment) pairs, then the content
23 of the grid will become that data, and the size of the cells will be scaled
24 based on the duration of the time slot. In this case, the from_time and
25 default_timeslot arguments are ignored, but the to_time argument is not, and
26 will become a hidden entry that determines the duration of the final slot.
27
28 If data is None or an empty sequence, then from_time and to_time are taken as
29 'military' time literals, and sufficient time slots to fill the from_time to
30 to_time slots, with a default duration of default_timeslot minutes. If
31 default_time is None, it will use a time slot of 15 minutes, with a random
32 extra duration of 0, 15, 30, or 45 minutes (0 occurring with probability 1/2,
33 and each of the others occurring with probability 1/6). This random time slot
34 assignment is for visual testing purposes only.
35
36
37 Dragging a scheduled item from a control to another control (or itself) will
38 move the scheduled item. If the destination is empty, the information will
39 fill the empty slot. If the destination has a scheduled item already, it will
40 split the destination time slot in half (rounding down to the nearest minute),
41 then fill the newly created empty slot.
42
43
44 Useful methods:
45
46 SetSlot(time, text='', id=None, bkcolor=None, fgcolor=None)
47 Set the slot at time 'time' with text 'text' and set it's client data
48 to 'id', background colour is set to 'bkcolor' if specified, text
49 colour to fgcolor if specified. If a slot with the specified time
50 'time' does not exist, create it and redraw the widget if necessary.
51
52 SplitSlot(time, minutes=None, text='', id=None, bkcolor=None, fgcolor=None)
53 If there is a slot that already exists, and it has content, split the
54 slot so that the new time slot has duration minutes, or in half if
55 minutes is None. All other arguments have the same semantics as in
56 SetSlot(...) .
57
58 The previously existing slot will result in a TextEntered event,
59 providing the new time for the squeezed slot. No other "useful
60 methods" cause a TextEntered event.
61
62
63 GetSlot(time)
64 Returns a dict with the keys time, text, id, bkcolor, fgcolor or None
65 if that time slot does not exist.
66
67 GetAllSlots()
68 Returns a list of dicts as specified in GetSlot() in chronologic order
69 for all slots of this widget.
70
71 ClearSlot(time)
72 Text and client data of this slot is erased, but slot remains.
73
74 DeleteSlot(time)
75 Slot is removed from the grid along with text and client data.
76
77 [Get|Set|Clear]ClientData methods
78 Gets/Sets/Clears per-time slot specified client data, specified as the
79 'id' argument in SetSlot(), SplitSlot(), GetSlot(), and GetAllSlots().
80
81
82 Usable event bindings
83 ---------------------
84
85 CellClicked and EVT_CELL_CLICKED
86
87 If you use schedulewidget.Bind(EVT_CELL_CLICKED, fcn), whenever a cell is
88 clicked, you will recieve a CellClicked event. You can discover the row and
89 column of the click with evt.row and evt.col respectively, the time of the
90 scheduled item evt.time, and the item text itself with evt.text .
91
92 If you have set the menu items with .SetPopup(), you will not recieve this
93 event when the right mouse button is clicked.
94
95 TextEntered and EVT_TEXT_ENTERED
96
97 If you use schedulewidget.Bind(EVT_TEXT_ENTERED, fcn), whenever the content
98 of a row's 'appointment' has been changed, either by the user changing the
99 content by keyboard, or by a squeezed item being cleared for widget to itself
100 drags, your function will be called with a TextEntered event. You can discover
101 the row, text, and time of the event with the same attributes as the
102 CellClicked event. There is no col attribute.
103
104 DroppedIn and EVT_DROPPED_IN
105 DroppedOut and EVT_DROPPED_OUT
106 BadDrop and EVT_BAD_DROP
107
108 Events that are posted when a cell has been dropped into a control, dragged
109 out of a control, or when a control has gotten bad data from a drop.
110
111 '''
112
113 import random
114 import time
115
116 import wx
117 import wx.grid
118 import wx.lib.newevent
119
120 printevent=0
121
122 dc = wx.DragCopy
123 dm = wx.DragMove
124
125 TextEntered, EVT_TEXT_ENTERED = wx.lib.newevent.NewEvent()
126 CellClicked, EVT_CELL_CLICKED = wx.lib.newevent.NewEvent()
127 DroppedIn, EVT_DROPPED_IN = wx.lib.newevent.NewEvent()
128 DroppedOut, EVT_DROPPED_OUT = wx.lib.newevent.NewEvent()
129 BadDrop, EVT_BAD_DROP = wx.lib.newevent.NewEvent()
130
131 tp_to_name = {TextEntered:'Entered',
132 CellClicked:'Clicked',
133 DroppedIn:'Dropped In',
134 DroppedOut:'Dropped Out',
135 BadDrop:'Bad Drop'
136 }
137
138 tt = "%02i:%02i"
139 def gethm(t):
140 h,m = [int(i.lstrip('0') or '0') for i in t.split(':')]
141 return h,m
142
143 def cnt():
144 i = 0
145 while 1:
146 yield i
147 i += 1
148
149 _counter = cnt()
150 def timeiter(st, en, incr):
151 if incr is None:
152 incr = 15
153 rr = lambda : random.choice((0, 0, 0, 15, 30, 45))
154 nx = lambda : str(_counter.next())
155 else:
156 rr = lambda : 0
157 nx = lambda : ''
158 hs, ms = gethm(st)
159 he, me = gethm(en)
160 while (hs, ms) < (he, me):
161 yield tt%(hs, ms), nx()
162 ms += incr + rr()
163 hs += ms//60
164 ms %= 60
165
166 def timediff(t1, t2):
167 h2, m2 = gethm(t2)
168 h1, m1 = gethm(t1)
169 h2 -= h1
170 m2 -= m1
171 m2 += 60*h2
172 return m2
173
174 def addtime(t1, delta):
175 h1, m1 = gethm(t1)
176 m1 += delta
177 h1 += m1//60
178 m1 %= 60
179 return tt%(h1, m1)
180
181 def timetoint(t):
182 h,m=gethm(t)
183 return h*60+m
184
185 minh = 17
186 rightborder = 32
187 dragsource = None
188
189 class ScheduleDrop(wx.TextDropTarget):
190 def __init__(self, window):
191 wx.TextDropTarget.__init__(self)
192 self.window = window
193 self.d = None
194
195 def OnDropText(self, x, y, text):
196 try:
197 data = eval(text)
198 except:
199 to = min(max(self.window.YToRow(y), 1), self.window.GetNumberRows()-2)
200 wx.PostEvent(self.window, BadDrop(text=text, dest=to))
201 else:
202 self.window._dropped(y, data)
203
204 def OnDragOver(self, x, y, d):
205 self.d = d
206 to = min(max(self.window.YToRow(y), 1), self.window.GetNumberRows()-2)
207 if self.window[to,1]:
208 return dc
209 return dm
210
211 class ScheduleGrid(wx.grid.Grid):
212 def __init__(self, parent, data=None, from_time="8:00", to_time="17:00", default_timeslot=15):
213 wx.grid.Grid.__init__(self, parent, -1)
214 start, end, step = from_time, to_time, default_timeslot
215 if data is None:
216 times = list(timeiter(start, end, step))
217 else:
218 times = data
219
220 self.SetDropTarget(ScheduleDrop(self))
221
222 self.lasttime = addtime(end, 0)
223 self.CreateGrid(len(times)+2, 2)
224 global minh
225 minh = self.GetRowSize(0)
226
227 self.SetRowMinimalAcceptableHeight(0)
228 self.SetColLabelSize(0)
229 self.SetRowLabelSize(0)
230 self.DisableDragColSize()
231 self.DisableDragRowSize()
232 self.Bind(wx.EVT_SIZE, self.OnSize)
233 self.SetSelectionMode(1)
234
235 self.SetReadOnly(0, 0, 1)
236 for i,(j,k) in enumerate(times):
237 i += 1
238 self.SetCellValue(i, 0, j)
239 self.SetReadOnly(i, 0, 1)
240 self.SetCellValue(i, 1, k)
241 self.SetCellRenderer(i, 1, WrappingRenderer())
242 self.SetCellEditor(i, 1, WrappingEditor())
243 i += 1
244 self.SetReadOnly(i, 0, 1)
245 self.SetCellValue(i, 0, self.lasttime)
246
247 self.fixh()
248
249 self.Bind(wx.grid.EVT_GRID_CELL_LEFT_CLICK, self._leftclick)
250 self.Bind(wx.grid.EVT_GRID_CELL_RIGHT_CLICK, self._rightclick_menu_handler)
251 self.Bind(wx.grid.EVT_GRID_EDITOR_SHOWN, self.OnShowEdit)
252 self.Bind(wx.grid.EVT_GRID_EDITOR_HIDDEN, self.OnHideEdit)
253 self.Bind(wx.grid.EVT_GRID_EDITOR_CREATED, self.OnCreateEdit)
254 self.GetGridWindow().Bind(wx.EVT_MOTION, self._checkmouse)
255 self.GetGridWindow().Bind(wx.EVT_LEFT_DOWN, self._checkmouse2)
256 self.GetGridWindow().Bind(wx.EVT_LEFT_UP, self._checkmouse3)
257
258
259 self.Bind(EVT_TEXT_ENTERED, self._handler)
260 self.Bind(EVT_CELL_CLICKED, self._handler)
261 self.Bind(EVT_DROPPED_IN, self._handler)
262 self.Bind(EVT_DROPPED_OUT, self._handler)
263
264 self.selected = 0
265 self.dragging = 0
266 self.evtseen = None
267 self.dragstartok = 0
268 self.lastpos = None
269 self.clientdata = {}
270 self.menu = None
271 self.ids = []
272 self.AutoSizeColumn(0, 0)
273 self.p = wx.Panel(self, -1)
274 self.p.Hide()
275
276 def YToRow(self, y):
277 _, y = self.CalcUnscrolledPosition(0, y)
278 return wx.grid.Grid.YToRow(self, y)
279
280 def _handler(self, evt):
281 if printevent:
282 print "[%s] %s"%(time.asctime(), tp_to_name[type(evt)])
283 evt.Skip()
284
285 def _checkmouse(self, evt):
286 ## print dir(evt)
287 if dragsource or self.dragging or not self.dragstartok or not evt.Dragging():
288 return
289 self._startdrag(evt.GetY())
290
291 def _checkmouse2(self, evt):
292 self.dragstartok = 1
293 evt.Skip()
294
295 def _checkmouse3(self, evt):
296 self.dragstartok = 0
297 evt.Skip()
298
299 def _dropped(self, y, data):
300 to = min(max(self.YToRow(y), 1), self.GetNumberRows()-2)
301 time = self[to,0]
302 wx.CallAfter(self.SplitSlot, time, **data)
303 wx.CallAfter(wx.PostEvent, self, DroppedIn(time=time, **data))
304
305 def _getduration(self, row):
306 if row < self.GetNumberRows():
307 return timediff(self[row,0], self[row+1,0])
308 return timediff(self[row,0], self.lasttime)
309
310 def __getitem__(self, key):
311 if type(key) is tuple:
312 row, col = key
313 if row < 0:
314 row += self.GetNumberRows()
315 if row >= 0:
316 return self.GetCellValue(row, col)
317 raise KeyError("key must be a tuple of length 2")
318
319 def __setitem__(self, key, value):
320 if type(key) is tuple:
321 row, col = key
322 if row < 0:
323 row += self.GetNumberRows()
324 if row >= 0:
325 return self.SetCellValue(row, col, value)
326 raise KeyError("key must be a tuple of length 2")
327
328 def _leftclick(self, evt):
329 sel = evt.GetRow()
330
331 if not self.GetGridCursorCol():
332 self.MoveCursorRight(0)
333 gr = self.GetGridCursorRow()
334 if gr < sel:
335 for i in xrange(sel-gr):
336 self.MoveCursorDown(0)
337 else:
338 for i in xrange(gr-sel):
339 self.MoveCursorUp(0)
340 wx.PostEvent(self, CellClicked(row=sel, col=evt.GetCol(), **self._getdict(sel)))
341 evt.Skip()
342
343 def _rightclick_menu_handler(self, evt):
344 sel = evt.GetRow()
345 if not self.menu:
346 wx.PostEvent(self, CellClicked(row=sel, col=evt.GetCol(), time=self[sel,0], text=self[sel,1]))
347 return
348
349 time, text = self[sel,0], self[sel,1]
350 cdata = self.GetClientData(time)
351
352 evt.Skip()
353
354 menu = wx.Menu()
355 def item((name, fcn)):
356 def f(evt):
357 return fcn(time, text, cdata)
358 id = wx.NewId()
359 it = wx.MenuItem(menu, id, name)
360 menu.AppendItem(it)
361 self.Bind(wx.EVT_MENU, f, it)
362 return id
363 clear = map(item, self.menu)
364 self.PopupMenu(menu)
365 menu.Destroy()
366
367 def SetPopup(self, menulist):
368 self.menu = menulist
369
370 def OnShowEdit(self, evt):
371 x = self.GetCellEditor(evt.GetRow(), evt.GetCol()).GetControl()
372 if x:
373 wx.CallAfter(x.SetInsertionPointEnd)
374 evt.Skip()
375
376 def OnCreateEdit(self, evt):
377 x = evt.GetControl()
378 wx.CallAfter(x.SetInsertionPointEnd)
379 evt.Skip()
380
381 def OnHideEdit(self, evt):
382 row = evt.GetRow()
383 wx.CallAfter(self.OnDoneEdit, self[row,1], row)
384 evt.Skip()
385
386 def OnDoneEdit(self, old, row):
387 check = (row, old, self[row,1])
388 if old != check[-1] and self.evtseen != check:
389 wx.PostEvent(self, TextEntered(**self._getdict(row)))
390 if not self.GetGridCursorCol():
391 self.MoveCursorRight(0)
392 for i in xrange(self.GetGridCursorRow()-row):
393 self.MoveCursorUp(0)
394 self.evtseen = check
395
396 def OnSize(self, evt):
397 x,y = evt.GetSize()
398 c1 = self.GetColSize(0)
399 x -= c1
400 x -= rightborder #otherwise it creates an unnecessary horizontal scroll bar
401 if x > 20:
402 self.SetColSize(1, x)
403
404 evt.Skip()
405
406 def fixh(self):
407 self.SetRowSize(0, 0)
408 self.SetRowSize(self.GetNumberRows()-1, 0)
409 for rown in xrange(1, self.GetNumberRows()-1):
410 td = max(self._getduration(rown), minh)
411 self.SetRowSize(rown, td)
412 self.ForceRefresh()
413
414 def _startdrag(self, y):
415 global dragsource
416 if dragsource or self.dragging:
417 return
418
419 self.dragging = 1
420 dragsource = self
421
422 try:
423
424 row = self.YToRow(y)
425 if row < 1 or row >= self.GetNumberRows()-1:
426 return
427
428 data = row, self._getduration(row), self[row,1]
429
430 time = self[row,0]
431 data = self._getdict(row)
432 dcpy = dict(data)
433 data.pop('time', None)
434 datar = repr(data)
435
436 d_data = wx.TextDataObject()
437 d_data.SetText(datar)
438
439 dropSource = wx.DropSource(self)
440 dropSource.SetData(d_data)
441 result = dropSource.DoDragDrop(wx.Drag_AllowMove)
442 if result in (dc, dm):
443 self.ClearSlot(time)
444 wx.CallAfter(wx.PostEvent, self, DroppedOut(**dcpy))
445 wx.CallAfter(self.SelectRow, row)
446 wx.CallAfter(self.ForceRefresh)
447 finally:
448 dragsource = None
449 self.dragging = 0
450
451 def _findslot(self, time):
452 t = timetoint(time)
453 for i in xrange(1, self.GetNumberRows()-1):
454 tt = timetoint(self[i,0])
455 if tt >= t:
456 break
457 return i, t==tt
458
459 def SetSlot(self, time, text='', id=None, bkcolor=None, fgcolor=None):
460 '''
461 Set the slot at time 'time' with text 'text' and set it's client data
462 to 'id', background colour is set to 'bkcolor' if specified, text
463 colour to fgcolor if specified. If a slot with the specified time
464 'time' does not exist, create it and redraw the widget if necessary.
465 '''
466
467 i, exact = self._findslot(time)
468 if not exact:
469 self.InsertRows(i, 1)
470 self[i,0] = time
471 self[i,1] = text
472 if id:
473 self.SetClientData(time, id)
474 if bkcolor:
475 self.SetCellBackgroundColour(i, 0, bkcolor)
476 self.SetCellBackgroundColour(i, 1, bkcolor)
477 if fgcolor:
478 self.SetCellTextColour(i, 0, fgcolor)
479 self.SetCellTextColour(i, 1, fgcolor)
480 if not exact:
481 self.fixh()
482 if not self.GetGridCursorCol():
483 self.MoveCursorRight(0)
484 for j in xrange(self.GetGridCursorRow()-i):
485 self.MoveCursorUp(0)
486 for j in xrange(i-self.GetGridCursorRow()):
487 self.MoveCursorDown(0)
488 wx.CallAfter(self.ClearSelection)
489 wx.CallAfter(self.SelectRow, i)
490 wx.CallAfter(self.ForceRefresh)
491
492 def GetSlot(self, time):
493 '''
494 Returns a dict with the keys time, text, id, bkcolor, fgcolor or None
495 if that time slot does not exist.
496 '''
497
498 i, exact = self._findslot(time)
499 if not exact:
500 return None
501 return self._getdict(i)
502
503 def _getdict(self, i):
504 return dict(time=self[i,0], text=self[i,1],
505 id=self.GetClientData(self[i,0]),
506 bkcolor=self.GetCellBackgroundColour(i,0),
507 fgcolor=self.GetCellTextColour(i,0))
508
509 def GetAllSlots(self):
510 '''
511 Returns a list of dicts as specified in GetSlot() in chronologic order
512 for all slots of this widget.
513 '''
514
515 ret = []
516 for i in xrange(1, self.GetNumberRows()-1):
517 ret.append(self._getdict(i))
518 return ret
519
520 def ClearSlot(self, time):
521 '''
522 Text and client data of this slot is erased, but slot remains.
523 '''
524
525 i, exact = self._findslot(time)
526 if not exact:
527 return
528 self[i,1] = ''
529 self.ClearClientData(self[i,0])
530 #do we clear background and foreground colors?
531
532 def DeleteSlot(self, time):
533 '''
534 Slot is removed from the grid along with text and client data.
535 '''
536
537 i, exact = self._findslot(time)
538 if not exact:
539 return
540 self.ClearClientData(self[i,0])
541 self.DeleteRows(i, 1)
542 self.fixh()
543
544 def SplitSlot(self, time, minutes=None, text='', id=None, bkcolor=None, fgcolor=None):
545 '''
546 If there is a slot that already exists, and it has content, split the
547 slot so that the new time slot has duration minutes, or in half if
548 minutes is None. All other arguments have the same semantics as in
549 SetSlot(...) .
550
551 The previously existing slot will result in a TextEntered event,
552 providing the new time for the squeezed slot. No other "useful
553 methods" cause a TextEntered event.
554 '''
555
556 i, exact = self._findslot(time)
557 if exact and self[i,1]:
558 if not minutes:
559 minutes = self._getduration(i)//2
560 cd = self.GetClientData(self[i,0])
561 self.ClearClientData(self[i,0])
562 nt = addtime(self[i,0], minutes)
563 self[i,0] = nt
564 self.SetClientData(nt, cd)
565 wx.PostEvent(self, TextEntered(**self._getdict(i)))
566 self.SetSlot(time, text, id, bkcolor, fgcolor)
567
568 def GetClientData(self, time):
569 return self.clientdata.get(timetoint(time), None)
570
571 def SetClientData(self, time, data):
572 if data != None:
573 self.clientdata[timetoint(time)] = data
574
575 def ClearClientData(self, time):
576 self.clientdata.pop(timetoint(time), None)
577
578 WrappingRenderer = wx.grid.GridCellAutoWrapStringRenderer
579 WrappingEditor = wx.grid.GridCellAutoWrapStringEditor
580
581 def pr(*args):
582 print args
583
584 if __name__ == '__main__':
585 printevent = 1
586 a = wx.App(0)
587 b = wx.Frame(None)
588 p = wx.Panel(b)
589 ## op = wx.Panel(p)
590 s = wx.BoxSizer(wx.HORIZONTAL)
591 c = ScheduleGrid(p, default_timeslot=None)
592 d = ScheduleGrid(p, default_timeslot=None)
593
594 lst = [('print information', pr)]
595
596 c.SetPopup(lst)
597
598 s.Add(c, 1, wx.EXPAND)
599 ## s.Add(op, 1, wx.EXPAND)
600 s.Add(d, 1, wx.EXPAND)
601 p.SetSizer(s)
602 b.Show(1)
603 a.MainLoop()
The original bounty from Dr. Horst Herb
I need a grid-like GUI element that
- has a set start end end time
- has slots in set time increments (e.g. 10 minutes each slot) but allows other time spans for each individual slot programmatically
- allows to "squeeze in" slots, diminishing the size of the squeezed slot
- displays the slots in a height proportional to their allocated time span
- allows to drag slots somewhere else, with (=shifting) or without (=squeezing in) reallocating the times for all later slots
Initialization:
init( ..., from_time='08:00', to_time='19:00', default_timeslot=15, slot_width=150) where slot_width is the initial width in pixels
the widget is painted like this:
|^^^^^^^^^^^^^^^^^^^| <- if the user clicks here, the thing scrolls to earlier times |08:00| (some text) | |08:15| | |08:30| | ... |"""""""""""""""""""| <- if the user clicks here, the thing scrolls to later
- the minimum height of the cell is determined by the font size
- if total height of from_time to to_time exceeds displayed height, a scroll bar appears to the right side
- the space to the right of the displayed time is a text control like widget, allowing immediate text entry when getting the focus, end emitting a "TEXT_ENTERED" event with time and text content if the content was changed and the focus is lost
- right and left clicking any cell emits an event with both the time of the clicked cell and the cell text content as event parameter
I offer a bounty of $150 for this. If you need more details, please contact me. I'd imagine this won't be too difficult using the fantastic wxGrid widget
Comments
If you have any questions, please feel free to contact the author, whose information is available in his profile profile. You may also be able to use the widget soon in GNUmed.
Note for wxPython < 2.9, this code needs to be changed to associate the drop target with windows within the grid, e.g., self.GetGridWindow().SetDropTarget(ScheduleDrop(self)) rather than self.SetDropTarget(ScheduleDrop(self)) to be portable (see discussion).
Brian
