== 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 == {{{#!python ''' schedule.py Version .6 A wx.grid.Grid-based scheduling widget written by Josiah Carlson for the GNUMED project (http://www.gnumed.org) and related projects. This widget and is licensed under the GNU GPL v. 2. If you would like an alternately licensed version, please contact the author via phone, email, IM, or in writing; it is likely that you will be able to get the widget in a license that you desire. josiah.carlson@gmail.com or http://dr-josiah.blogspot.com/ The base control ---------------- The object that you will find of the most use is: ScheduleGrid(parent, data=None, from_time="8:00", to_time="17:00", default_timeslot=15) If data is a non-empty sequence of (time, appointment) pairs, then the content of the grid will become that data, and the size of the cells will be scaled based on the duration of the time slot. In this case, the from_time and default_timeslot arguments are ignored, but the to_time argument is not, and will become a hidden entry that determines the duration of the final slot. If data is None or an empty sequence, then from_time and to_time are taken as 'military' time literals, and sufficient time slots to fill the from_time to to_time slots, with a default duration of default_timeslot minutes. If default_time is None, it will use a time slot of 15 minutes, with a random extra duration of 0, 15, 30, or 45 minutes (0 occurring with probability 1/2, and each of the others occurring with probability 1/6). This random time slot assignment is for visual testing purposes only. Dragging a scheduled item from a control to another control (or itself) will move the scheduled item. If the destination is empty, the information will fill the empty slot. If the destination has a scheduled item already, it will split the destination time slot in half (rounding down to the nearest minute), then fill the newly created empty slot. Useful methods: SetSlot(time, text='', id=None, bkcolor=None, fgcolor=None) Set the slot at time 'time' with text 'text' and set it's client data to 'id', background colour is set to 'bkcolor' if specified, text colour to fgcolor if specified. If a slot with the specified time 'time' does not exist, create it and redraw the widget if necessary. SplitSlot(time, minutes=None, text='', id=None, bkcolor=None, fgcolor=None) If there is a slot that already exists, and it has content, split the slot so that the new time slot has duration minutes, or in half if minutes is None. All other arguments have the same semantics as in SetSlot(...) . The previously existing slot will result in a TextEntered event, providing the new time for the squeezed slot. No other "useful methods" cause a TextEntered event. GetSlot(time) Returns a dict with the keys time, text, id, bkcolor, fgcolor or None if that time slot does not exist. GetAllSlots() Returns a list of dicts as specified in GetSlot() in chronologic order for all slots of this widget. ClearSlot(time) Text and client data of this slot is erased, but slot remains. DeleteSlot(time) Slot is removed from the grid along with text and client data. [Get|Set|Clear]ClientData methods Gets/Sets/Clears per-time slot specified client data, specified as the 'id' argument in SetSlot(), SplitSlot(), GetSlot(), and GetAllSlots(). Usable event bindings --------------------- CellClicked and EVT_CELL_CLICKED If you use schedulewidget.Bind(EVT_CELL_CLICKED, fcn), whenever a cell is clicked, you will recieve a CellClicked event. You can discover the row and column of the click with evt.row and evt.col respectively, the time of the scheduled item evt.time, and the item text itself with evt.text . If you have set the menu items with .SetPopup(), you will not recieve this event when the right mouse button is clicked. TextEntered and EVT_TEXT_ENTERED If you use schedulewidget.Bind(EVT_TEXT_ENTERED, fcn), whenever the content of a row's 'appointment' has been changed, either by the user changing the content by keyboard, or by a squeezed item being cleared for widget to itself drags, your function will be called with a TextEntered event. You can discover the row, text, and time of the event with the same attributes as the CellClicked event. There is no col attribute. DroppedIn and EVT_DROPPED_IN DroppedOut and EVT_DROPPED_OUT BadDrop and EVT_BAD_DROP Events that are posted when a cell has been dropped into a control, dragged out of a control, or when a control has gotten bad data from a drop. ''' import random import time import wx import wx.grid import wx.lib.newevent printevent=0 dc = wx.DragCopy dm = wx.DragMove TextEntered, EVT_TEXT_ENTERED = wx.lib.newevent.NewEvent() CellClicked, EVT_CELL_CLICKED = wx.lib.newevent.NewEvent() DroppedIn, EVT_DROPPED_IN = wx.lib.newevent.NewEvent() DroppedOut, EVT_DROPPED_OUT = wx.lib.newevent.NewEvent() BadDrop, EVT_BAD_DROP = wx.lib.newevent.NewEvent() tp_to_name = {TextEntered:'Entered', CellClicked:'Clicked', DroppedIn:'Dropped In', DroppedOut:'Dropped Out', BadDrop:'Bad Drop' } tt = "%02i:%02i" def gethm(t): h,m = [int(i.lstrip('0') or '0') for i in t.split(':')] return h,m def cnt(): i = 0 while 1: yield i i += 1 _counter = cnt() def timeiter(st, en, incr): if incr is None: incr = 15 rr = lambda : random.choice((0, 0, 0, 15, 30, 45)) nx = lambda : str(_counter.next()) else: rr = lambda : 0 nx = lambda : '' hs, ms = gethm(st) he, me = gethm(en) while (hs, ms) < (he, me): yield tt%(hs, ms), nx() ms += incr + rr() hs += ms//60 ms %= 60 def timediff(t1, t2): h2, m2 = gethm(t2) h1, m1 = gethm(t1) h2 -= h1 m2 -= m1 m2 += 60*h2 return m2 def addtime(t1, delta): h1, m1 = gethm(t1) m1 += delta h1 += m1//60 m1 %= 60 return tt%(h1, m1) def timetoint(t): h,m=gethm(t) return h*60+m minh = 17 rightborder = 32 dragsource = None class ScheduleDrop(wx.TextDropTarget): def __init__(self, window): wx.TextDropTarget.__init__(self) self.window = window self.d = None def OnDropText(self, x, y, text): try: data = eval(text) except: to = min(max(self.window.YToRow(y), 1), self.window.GetNumberRows()-2) wx.PostEvent(self.window, BadDrop(text=text, dest=to)) else: self.window._dropped(y, data) def OnDragOver(self, x, y, d): self.d = d to = min(max(self.window.YToRow(y), 1), self.window.GetNumberRows()-2) if self.window[to,1]: return dc return dm class ScheduleGrid(wx.grid.Grid): def __init__(self, parent, data=None, from_time="8:00", to_time="17:00", default_timeslot=15): wx.grid.Grid.__init__(self, parent, -1) start, end, step = from_time, to_time, default_timeslot if data is None: times = list(timeiter(start, end, step)) else: times = data self.SetDropTarget(ScheduleDrop(self)) self.lasttime = addtime(end, 0) self.CreateGrid(len(times)+2, 2) global minh minh = self.GetRowSize(0) self.SetRowMinimalAcceptableHeight(0) self.SetColLabelSize(0) self.SetRowLabelSize(0) self.DisableDragColSize() self.DisableDragRowSize() self.Bind(wx.EVT_SIZE, self.OnSize) self.SetSelectionMode(1) self.SetReadOnly(0, 0, 1) for i,(j,k) in enumerate(times): i += 1 self.SetCellValue(i, 0, j) self.SetReadOnly(i, 0, 1) self.SetCellValue(i, 1, k) self.SetCellRenderer(i, 1, WrappingRenderer()) self.SetCellEditor(i, 1, WrappingEditor()) i += 1 self.SetReadOnly(i, 0, 1) self.SetCellValue(i, 0, self.lasttime) self.fixh() self.Bind(wx.grid.EVT_GRID_CELL_LEFT_CLICK, self._leftclick) self.Bind(wx.grid.EVT_GRID_CELL_RIGHT_CLICK, self._rightclick_menu_handler) self.Bind(wx.grid.EVT_GRID_EDITOR_SHOWN, self.OnShowEdit) self.Bind(wx.grid.EVT_GRID_EDITOR_HIDDEN, self.OnHideEdit) self.Bind(wx.grid.EVT_GRID_EDITOR_CREATED, self.OnCreateEdit) self.GetGridWindow().Bind(wx.EVT_MOTION, self._checkmouse) self.GetGridWindow().Bind(wx.EVT_LEFT_DOWN, self._checkmouse2) self.GetGridWindow().Bind(wx.EVT_LEFT_UP, self._checkmouse3) self.Bind(EVT_TEXT_ENTERED, self._handler) self.Bind(EVT_CELL_CLICKED, self._handler) self.Bind(EVT_DROPPED_IN, self._handler) self.Bind(EVT_DROPPED_OUT, self._handler) self.selected = 0 self.dragging = 0 self.evtseen = None self.dragstartok = 0 self.lastpos = None self.clientdata = {} self.menu = None self.ids = [] self.AutoSizeColumn(0, 0) self.p = wx.Panel(self, -1) self.p.Hide() def YToRow(self, y): _, y = self.CalcUnscrolledPosition(0, y) return wx.grid.Grid.YToRow(self, y) def _handler(self, evt): if printevent: print "[%s] %s"%(time.asctime(), tp_to_name[type(evt)]) evt.Skip() def _checkmouse(self, evt): ## print dir(evt) if dragsource or self.dragging or not self.dragstartok or not evt.Dragging(): return self._startdrag(evt.GetY()) def _checkmouse2(self, evt): self.dragstartok = 1 evt.Skip() def _checkmouse3(self, evt): self.dragstartok = 0 evt.Skip() def _dropped(self, y, data): to = min(max(self.YToRow(y), 1), self.GetNumberRows()-2) time = self[to,0] wx.CallAfter(self.SplitSlot, time, **data) wx.CallAfter(wx.PostEvent, self, DroppedIn(time=time, **data)) def _getduration(self, row): if row < self.GetNumberRows(): return timediff(self[row,0], self[row+1,0]) return timediff(self[row,0], self.lasttime) def __getitem__(self, key): if type(key) is tuple: row, col = key if row < 0: row += self.GetNumberRows() if row >= 0: return self.GetCellValue(row, col) raise KeyError("key must be a tuple of length 2") def __setitem__(self, key, value): if type(key) is tuple: row, col = key if row < 0: row += self.GetNumberRows() if row >= 0: return self.SetCellValue(row, col, value) raise KeyError("key must be a tuple of length 2") def _leftclick(self, evt): sel = evt.GetRow() if not self.GetGridCursorCol(): self.MoveCursorRight(0) gr = self.GetGridCursorRow() if gr < sel: for i in xrange(sel-gr): self.MoveCursorDown(0) else: for i in xrange(gr-sel): self.MoveCursorUp(0) wx.PostEvent(self, CellClicked(row=sel, col=evt.GetCol(), **self._getdict(sel))) evt.Skip() def _rightclick_menu_handler(self, evt): sel = evt.GetRow() if not self.menu: wx.PostEvent(self, CellClicked(row=sel, col=evt.GetCol(), time=self[sel,0], text=self[sel,1])) return time, text = self[sel,0], self[sel,1] cdata = self.GetClientData(time) evt.Skip() menu = wx.Menu() def item((name, fcn)): def f(evt): return fcn(time, text, cdata) id = wx.NewId() it = wx.MenuItem(menu, id, name) menu.AppendItem(it) self.Bind(wx.EVT_MENU, f, it) return id clear = map(item, self.menu) self.PopupMenu(menu) menu.Destroy() def SetPopup(self, menulist): self.menu = menulist def OnShowEdit(self, evt): x = self.GetCellEditor(evt.GetRow(), evt.GetCol()).GetControl() if x: wx.CallAfter(x.SetInsertionPointEnd) evt.Skip() def OnCreateEdit(self, evt): x = evt.GetControl() wx.CallAfter(x.SetInsertionPointEnd) evt.Skip() def OnHideEdit(self, evt): row = evt.GetRow() wx.CallAfter(self.OnDoneEdit, self[row,1], row) evt.Skip() def OnDoneEdit(self, old, row): check = (row, old, self[row,1]) if old != check[-1] and self.evtseen != check: wx.PostEvent(self, TextEntered(**self._getdict(row))) if not self.GetGridCursorCol(): self.MoveCursorRight(0) for i in xrange(self.GetGridCursorRow()-row): self.MoveCursorUp(0) self.evtseen = check def OnSize(self, evt): x,y = evt.GetSize() c1 = self.GetColSize(0) x -= c1 x -= rightborder #otherwise it creates an unnecessary horizontal scroll bar if x > 20: self.SetColSize(1, x) evt.Skip() def fixh(self): self.SetRowSize(0, 0) self.SetRowSize(self.GetNumberRows()-1, 0) for rown in xrange(1, self.GetNumberRows()-1): td = max(self._getduration(rown), minh) self.SetRowSize(rown, td) self.ForceRefresh() def _startdrag(self, y): global dragsource if dragsource or self.dragging: return self.dragging = 1 dragsource = self try: row = self.YToRow(y) if row < 1 or row >= self.GetNumberRows()-1: return data = row, self._getduration(row), self[row,1] time = self[row,0] data = self._getdict(row) dcpy = dict(data) data.pop('time', None) datar = repr(data) d_data = wx.TextDataObject() d_data.SetText(datar) dropSource = wx.DropSource(self) dropSource.SetData(d_data) result = dropSource.DoDragDrop(wx.Drag_AllowMove) if result in (dc, dm): self.ClearSlot(time) wx.CallAfter(wx.PostEvent, self, DroppedOut(**dcpy)) wx.CallAfter(self.SelectRow, row) wx.CallAfter(self.ForceRefresh) finally: dragsource = None self.dragging = 0 def _findslot(self, time): t = timetoint(time) for i in xrange(1, self.GetNumberRows()-1): tt = timetoint(self[i,0]) if tt >= t: break return i, t==tt def SetSlot(self, time, text='', id=None, bkcolor=None, fgcolor=None): ''' Set the slot at time 'time' with text 'text' and set it's client data to 'id', background colour is set to 'bkcolor' if specified, text colour to fgcolor if specified. If a slot with the specified time 'time' does not exist, create it and redraw the widget if necessary. ''' i, exact = self._findslot(time) if not exact: self.InsertRows(i, 1) self[i,0] = time self[i,1] = text if id: self.SetClientData(time, id) if bkcolor: self.SetCellBackgroundColour(i, 0, bkcolor) self.SetCellBackgroundColour(i, 1, bkcolor) if fgcolor: self.SetCellTextColour(i, 0, fgcolor) self.SetCellTextColour(i, 1, fgcolor) if not exact: self.fixh() if not self.GetGridCursorCol(): self.MoveCursorRight(0) for j in xrange(self.GetGridCursorRow()-i): self.MoveCursorUp(0) for j in xrange(i-self.GetGridCursorRow()): self.MoveCursorDown(0) wx.CallAfter(self.ClearSelection) wx.CallAfter(self.SelectRow, i) wx.CallAfter(self.ForceRefresh) def GetSlot(self, time): ''' Returns a dict with the keys time, text, id, bkcolor, fgcolor or None if that time slot does not exist. ''' i, exact = self._findslot(time) if not exact: return None return self._getdict(i) def _getdict(self, i): return dict(time=self[i,0], text=self[i,1], id=self.GetClientData(self[i,0]), bkcolor=self.GetCellBackgroundColour(i,0), fgcolor=self.GetCellTextColour(i,0)) def GetAllSlots(self): ''' Returns a list of dicts as specified in GetSlot() in chronologic order for all slots of this widget. ''' ret = [] for i in xrange(1, self.GetNumberRows()-1): ret.append(self._getdict(i)) return ret def ClearSlot(self, time): ''' Text and client data of this slot is erased, but slot remains. ''' i, exact = self._findslot(time) if not exact: return self[i,1] = '' self.ClearClientData(self[i,0]) #do we clear background and foreground colors? def DeleteSlot(self, time): ''' Slot is removed from the grid along with text and client data. ''' i, exact = self._findslot(time) if not exact: return self.ClearClientData(self[i,0]) self.DeleteRows(i, 1) self.fixh() def SplitSlot(self, time, minutes=None, text='', id=None, bkcolor=None, fgcolor=None): ''' If there is a slot that already exists, and it has content, split the slot so that the new time slot has duration minutes, or in half if minutes is None. All other arguments have the same semantics as in SetSlot(...) . The previously existing slot will result in a TextEntered event, providing the new time for the squeezed slot. No other "useful methods" cause a TextEntered event. ''' i, exact = self._findslot(time) if exact and self[i,1]: if not minutes: minutes = self._getduration(i)//2 cd = self.GetClientData(self[i,0]) self.ClearClientData(self[i,0]) nt = addtime(self[i,0], minutes) self[i,0] = nt self.SetClientData(nt, cd) wx.PostEvent(self, TextEntered(**self._getdict(i))) self.SetSlot(time, text, id, bkcolor, fgcolor) def GetClientData(self, time): return self.clientdata.get(timetoint(time), None) def SetClientData(self, time, data): if data != None: self.clientdata[timetoint(time)] = data def ClearClientData(self, time): self.clientdata.pop(timetoint(time), None) WrappingRenderer = wx.grid.GridCellAutoWrapStringRenderer WrappingEditor = wx.grid.GridCellAutoWrapStringEditor def pr(*args): print args if __name__ == '__main__': printevent = 1 a = wx.App(0) b = wx.Frame(None) p = wx.Panel(b) ## op = wx.Panel(p) s = wx.BoxSizer(wx.HORIZONTAL) c = ScheduleGrid(p, default_timeslot=None) d = ScheduleGrid(p, default_timeslot=None) lst = [('print information', pr)] c.SetPopup(lst) s.Add(c, 1, wx.EXPAND) ## s.Add(op, 1, wx.EXPAND) s.Add(d, 1, wx.EXPAND) p.SetSizer(s) b.Show(1) 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 [[http://wiki.wxpython.org/index.cgi/Josiah_Carlson|profile]]. You may also be able to use the widget soon in [[http://www.gnumed.org|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 ([[http://groups.google.com/group/wxpython-users/browse_thread/thread/d88fa5de925480b8|see discussion]]). Brian