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