== 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