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 . You may also be able to use the widget soon in .
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 ().
Brian