#!/usr/bin/env python
#
# elistbox.py - An alternative to wx.gizmos.EditableListBox.
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
"""This module provides the :class:`EditableListBox` class, an alternative to
:class:`wx.gizmos.EditableListBox`.
"""
import math
import logging
import wx
import wx.lib.newevent as wxevent
import wx.lib.stattext as stattext
log = logging.getLogger(__name__)
[docs]
class EditableListBox(wx.Panel):
"""A panel which displays a list of items.
An ``EditableListBox`` contains a :class:`wx.Panel` which in turn contains
a collection of :class:`wx.StaticText` widgets, which are laid out
vertically, and display labels for each of the items in the list. Some
rudimentary wrapper methods for modifying the list contents are provided
by an ``EditableListBox`` object, with an interface similar to that of the
:class:`wx.ListBox` class.
In addition to displaying ``StaticText`` controls, the ``EditableListBox``
can also display arbitrary panels/controls associated with each label -
see the :meth:`Insert` and :meth:`SetItemWidget`` methods.
.. warning:: If you are using an ``EditableListBox`` to display arbitrary
controls/panels it is important to know that the
``EditableListBox`` assumes that all items are of the same
size. Sizing/scrolling will not work properly if
controls/panels for different list items are of different
sizes.
The following style flags are available:
.. autosummary::
ELB_NO_SCROLL
ELB_NO_ADD
ELB_NO_REMOVE
ELB_NO_MOVE
ELB_REVERSE
ELB_TOOLTIP
ELB_EDITABLE
ELB_NO_LABELS
ELB_WIDGET_RIGHT
ELB_TOOLTIP_DOWN
ELB_SCROLL_BUTTONS
An ``EditableListBox`` generates the following events:
.. autosummary::
ListSelectEvent
ListAddEvent
ListRemoveEvent
ListMoveEvent
ListEditEvent
ListDblClickEvent
.. note::
The ``EditableListBox`` is an alternative to the
:class:`wx.gizmos.EditableListBox`. The latter is buggy under OS X,
and getting tooltips working with the :class:`wx.ListBox` is an
absolute pain in the behind. So I felt the need to replicate its
functionality. This implementation supports single selection only.
"""
_selectedFG = None
"""Default foreground colour for the currently selected item. Initialised
in :meth:`__init__` to a default based on the system appearance.
"""
_defaultFG = None
"""Default foreground colour for unselected items. Initialised
in :meth:`__init__` to a default based on the system appearance.
"""
_selectedBG = None
"""Background colour for the currently selected item. Initialised
in :meth:`__init__` to a default based on the system appearance.
"""
_defaultBG = None
"""Background colour for the unselected items. Initialised
in :meth:`__init__` to a default based on the system appearance.
"""
def __init__(
self,
parent,
labels=None,
clientData=None,
tooltips=None,
style=0,
vgap=0):
"""Create an ``EditableListBox``.
:arg parent: :mod:`wx` parent object
:arg labels: List of strings, the items in the list
:arg clientData: List of data associated with the list items.
:arg tooltips: List of strings, tooltips for each item.
:arg style: Style bitmask - accepts :data:`ELB_NO_SCROLL`,
:data:`ELB_NO_ADD`, :data:`ELB_NO_REMOVE`,
:data:`ELB_NO_MOVE`, :data:`ELB_REVERSE`,
:data:`ELB_TOOLTIP`, :data:`ELB_EDITABLE`,
:data:`ELB_NO_LABEL`, :data:`ELB_WIDGET_RIGHT`,
:data:`ELB_TOOLTIP_DOWN`, and
:data:`ELB_SCROLL_BUTTONS`.
:arg vgap: Vertical gap in pixels between each item. Ignored if
:data:`ELB_NO_LABEL` is set.
"""
wx.Panel.__init__(self, parent, style=wx.WANTS_CHARS)
defaultfg = wx.SystemSettings.GetColour(wx.SYS_COLOUR_LISTBOXTEXT)
defaultbg = wx.SystemSettings.GetColour(wx.SYS_COLOUR_LISTBOX)
selectfg = wx.SystemSettings.GetColour(
wx.SYS_COLOUR_LISTBOXHIGHLIGHTTEXT)
selectbg = wx.SystemSettings.GetColour(wx.SYS_COLOUR_HIGHLIGHT)
if EditableListBox._selectedFG is None:
EditableListBox._selectedFG = selectfg
if EditableListBox._selectedBG is None:
EditableListBox._selectedBG = selectbg
if EditableListBox._defaultFG is None:
EditableListBox._defaultFG = defaultfg
if EditableListBox._defaultBG is None:
EditableListBox._defaultBG = defaultbg
reverseOrder = style & ELB_REVERSE
addScrollbar = not (style & ELB_NO_SCROLL)
addSupport = not (style & ELB_NO_ADD)
removeSupport = not (style & ELB_NO_REMOVE)
moveSupport = not (style & ELB_NO_MOVE)
editSupport = style & ELB_EDITABLE
showTooltips = style & ELB_TOOLTIP
noLabels = style & ELB_NO_LABELS
widgetOnRight = style & ELB_WIDGET_RIGHT
tooltipDown = style & ELB_TOOLTIP_DOWN and not showTooltips
scrollButtons = style & ELB_SCROLL_BUTTONS and addScrollbar
noButtons = not any((addSupport, removeSupport, moveSupport))
if noLabels:
editSupport = False
self.__reverseOrder = reverseOrder
self.__showTooltips = showTooltips
self.__moveSupport = moveSupport
self.__editSupport = editSupport
self.__noLabels = noLabels
self.__widgetOnRight = widgetOnRight
self.__tooltipDown = tooltipDown
self.__scrollButtons = scrollButtons
self.__vgap = vgap
if labels is None: labels = []
if clientData is None: clientData = [None] * len(labels)
if tooltips is None: tooltips = [None] * len(labels)
# index of the currently selected
# item, and the list of items
# (_ListItem instances).
self.__selection = wx.NOT_FOUND
self.__listItems = []
# the panel containing the list items
# This is laid out with two sizers -
# the sizer contains the list items, and
# the SizerSizer contains scroll buttons
# (if enabled) and the item sizer.
self.__listPanel = wx.Panel(self, style=wx.WANTS_CHARS)
self.__listSizerSizer = wx.BoxSizer(wx.VERTICAL)
self.__listSizer = wx.BoxSizer(wx.VERTICAL)
self.__listSizerSizer.Add(self.__listSizer,
flag=wx.EXPAND,
proportion=1)
self.__listPanel.SetSizer(self.__listSizerSizer)
self.__listPanel.SetBackgroundColour(EditableListBox._defaultBG)
if addScrollbar:
self.__scrollbar = wx.ScrollBar(self, style=wx.SB_VERTICAL)
else:
self.__scrollbar = None
# A panel containing buttons for doing stuff with the list
if not noButtons:
self.__buttonPanel = wx.Panel(self)
self.__buttonPanelSizer = wx.BoxSizer(wx.VERTICAL)
self.__buttonPanel.SetSizer(self.__buttonPanelSizer)
# Buttons for moving the selected item up/down
if moveSupport:
self.__upButton = wx.Button(self.__buttonPanel,
label='\u25B2',
style=wx.BU_EXACTFIT)
self.__downButton = wx.Button(self.__buttonPanel,
label='\u25BC',
style=wx.BU_EXACTFIT)
self.__upButton .Bind(wx.EVT_BUTTON, self.__moveItemUp)
self.__downButton.Bind(wx.EVT_BUTTON, self.__moveItemDown)
self.__buttonPanelSizer.Add(self.__upButton, flag=wx.EXPAND)
self.__buttonPanelSizer.Add(self.__downButton, flag=wx.EXPAND)
# Button for adding new items
if addSupport:
self.__addButton = wx.Button(self.__buttonPanel, label='+',
style=wx.BU_EXACTFIT)
self.__addButton.Bind(wx.EVT_BUTTON, self.__addItem)
self.__buttonPanelSizer.Add(self.__addButton, flag=wx.EXPAND)
# Button for removing the selected item
if removeSupport:
self.__removeButton = wx.Button(self.__buttonPanel, label='-',
style=wx.BU_EXACTFIT)
self.__removeButton.Bind(wx.EVT_BUTTON, self.__removeItem)
self.__buttonPanelSizer.Add(self.__removeButton, flag=wx.EXPAND)
# Up/down scroll buttons above/below the list
if self.__scrollButtons:
# Using wx.lib.stattext instead
# of wx.StaticText because GTK
# can't horizontally align text.
self.__scrollUp = stattext.GenStaticText(self.__listPanel,
label='\u25B2',
style=wx.ALIGN_CENTRE)
self.__scrollDown = stattext.GenStaticText(self.__listPanel,
label='\u25BC',
style=wx.ALIGN_CENTRE)
self.__scrollUp .SetFont(self.__scrollUp .GetFont().Smaller())
self.__scrollDown.SetFont(self.__scrollDown.GetFont().Smaller())
self.__listSizerSizer.Insert(0, self.__scrollUp, flag=wx.EXPAND)
self.__listSizerSizer.Insert(2, self.__scrollDown, flag=wx.EXPAND)
self.__scrollUp .Enable(False)
self.__scrollDown.Enable(False)
self.__scrollUp .Bind(wx.EVT_LEFT_UP, self.__onScrollButton)
self.__scrollDown.Bind(wx.EVT_LEFT_UP, self.__onScrollButton)
else:
self.__scrollUp = None
self.__scrollDown = None
self.__sizer = wx.BoxSizer(wx.HORIZONTAL)
self.SetSizer(self.__sizer)
if not noButtons:
self.__sizer.Add(self.__buttonPanel, flag=wx.EXPAND)
self.__sizer.Add(self.__listPanel, flag=wx.EXPAND, proportion=1)
if addScrollbar:
self.__sizer.Add(self.__scrollbar, flag=wx.EXPAND)
self.__scrollbar.Bind(wx.EVT_SCROLL, self.__drawList)
self .Bind(wx.EVT_MOUSEWHEEL, self.__onMouseWheel)
self.__listPanel.Bind(wx.EVT_MOUSEWHEEL, self.__onMouseWheel)
def refresh(ev):
self.__updateScrollbar()
self.__drawList()
ev.Skip()
# We use CHAR_HOOK for key events,
# because we want to capture key
# presses whenever this panel or
# any of its children has focus.
self.Bind(wx.EVT_CHAR_HOOK, self.__onKeyboard)
self.Bind(wx.EVT_PAINT, refresh)
self.Bind(wx.EVT_SIZE, refresh)
for label, data, tooltip in zip(labels, clientData, tooltips):
self.Append(label, data, tooltip)
# Figure out a nice minimum width
dummyLabel = wx.StaticText(self, label='w' * 25)
lblWidth, lblHeight = dummyLabel.GetBestSize().Get()
dummyLabel.Destroy()
# If there are buttons on this listbox,
# set the minimum height to the height
# of said buttons, or the height of
# four items, whichever is bigger
if not noButtons:
self.SetMinSize((
lblWidth,
max(4 * lblHeight, self.__buttonPanelSizer.CalcMin()[1])))
# Otherwise set the minimum height
# to the height of four items
else:
self.SetMinSize((lblWidth, 4 * lblHeight))
self.Layout()
def __onKeyboard(self, ev):
"""Called when a key is pressed. On up/down arrow key presses,
changes the selected item, and scrolls if necessary.
"""
key = ev.GetKeyCode()
# GTK seemingly randomly steals focus from
# this control, and gives it to something
# else, unless we force-retain focus.
wx.CallAfter(self.SetFocus)
# We're only interested in
# up/down key presses
if ev.HasModifiers() or (key not in (wx.WXK_UP, wx.WXK_DOWN)):
ev.Skip()
return
# On up/down keys, we want to
# select the next/previous item
if key == wx.WXK_UP: offset = -1
elif key == wx.WXK_DOWN: offset = 1
selected = self.__selection + offset
if any((selected < 0, selected >= self.GetCount())):
return
# Change the selected item, simulating
# a mouse click so that event listeners
# are notified
self.__itemClicked(None, self.__listItems[selected].labelWidget)
# Update the scrollbar position, to make
# sure the newly selected item is visible
if self.__scrollbar is not None:
scrollPos = self.__scrollbar.GetThumbPosition()
if any((selected < scrollPos,
selected >= scrollPos + self.__scrollbar.GetPageSize())):
self.__onMouseWheel(None, -offset)
def __onMouseWheel(self, ev=None, move=None):
"""Called when the mouse wheel is scrolled over the list. Scrolls
through the list accordingly.
:arg ev: A :class:`wx.MouseEvent`
:arg move: If called programmatically, a number indicating the
direction in which to scroll.
"""
if self.__scrollbar is None:
return
if ev is not None:
move = ev.GetWheelRotation()
scrollPos = self.__scrollbar.GetThumbPosition()
if move < 0: self.__scrollbar.SetThumbPosition(scrollPos + 1)
elif move > 0: self.__scrollbar.SetThumbPosition(scrollPos - 1)
self.__drawList()
self.SetFocus()
def __onScrollButton(self, ev):
"""Called when either of the scroll up/down buttons are clicked (if
the :data:`.ELB_SCROLL_BUTTONS` style is active). Scrolls the list
up/down, if possible.
"""
button = ev.GetEventObject()
if not button.IsEnabled():
return
if button is self.__scrollUp: move = 1
else: move = -1
self.__onMouseWheel(move=move)
[docs]
def VisibleItemCount(self):
"""Returns the number of items in the list which are visible
(i.e. which have not been hidden via a call to :meth:`ApplyFilter`).
"""
nitems = 0
for item in self.__listItems:
if not item.hidden:
nitems += 1
return nitems
def __drawList(self, ev=None):
"""'Draws' the set of items in the list according to the
current scrollbar thumb position.
"""
nitems = self.VisibleItemCount()
if self.__scrollbar is not None:
thumbPos = self.__scrollbar.GetThumbPosition()
itemsPerPage = self.__scrollbar.GetPageSize()
else:
thumbPos = 0
itemsPerPage = len(self.__listItems)
if itemsPerPage >= nitems:
start = 0
end = nitems
else:
start = thumbPos
end = thumbPos + itemsPerPage
if end > nitems:
start = start - (end - nitems)
end = nitems
visI = 0
for i, item in enumerate(self.__listItems):
if item.hidden:
self.__listSizer.Show(item.container, False)
continue
if (visI < start) or (visI >= end):
self.__listSizer.Show(item.container, False)
else:
self.__listSizer.Show(item.container, True)
visI += 1
if self.__scrollButtons:
self.__scrollUp .Enable(thumbPos > 0)
self.__scrollDown.Enable(end < nitems)
self.__listSizer.Layout()
if ev is not None:
ev.Skip()
def __updateScrollbar(self, ev=None):
"""Updates the scrollbar parameters according to the number of items
in the list, and the screen size of the list panel. If there is
enough room to display all items in the list, the scroll bar is
hidden.
"""
if self.__scrollbar is None:
return
nitems = self.VisibleItemCount()
pageHeight = self.GetSize().GetHeight() - 5
if self.__scrollUp is not None:
pageHeight -= self.__scrollUp.GetSize().GetHeight()
if self.__scrollDown is not None:
pageHeight -= self.__scrollDown.GetSize().GetHeight()
# Yep, I'm assuming that all
# items are the same size
if nitems > 0:
sizer = self.__listItems[0].container.GetSizer()
itemHeight = sizer.CalcMin().GetHeight()
else:
itemHeight = 0
if pageHeight == 0 or itemHeight == 0:
itemsPerPage = nitems
else:
itemsPerPage = math.floor(pageHeight / float(itemHeight))
thumbPos = self.__scrollbar.GetThumbPosition()
itemsPerPage = min(itemsPerPage, nitems)
# Hide the scrollbar if there is enough
# room to display the entire list (but
# configure the scrollbar correctly)
if nitems == 0 or itemsPerPage >= nitems:
self.__scrollbar.SetScrollbar(0,
nitems,
nitems,
nitems,
True)
self.__sizer.Show(self.__scrollbar, False)
else:
self.__sizer.Show(self.__scrollbar, True)
self.__scrollbar.SetScrollbar(thumbPos,
itemsPerPage,
nitems,
itemsPerPage,
True)
self.Layout()
def __fixIndex(self, idx):
"""If the :data:`ELB_REVERSE` style is active, this method will return
an inverted version of the given index. Otherwise it returns the index
value unchanged.
"""
if idx is None: return idx
if idx == wx.NOT_FOUND: return idx
if not self.__reverseOrder: return idx
fixIdx = len(self.__listItems) - idx - 1
# if len(self.__listItems) is passed to Insert
# (i.e. an item is to be appended to the list)
# the above formula will produce -1
if (idx == len(self.__listItems)) and (fixIdx == -1):
fixIdx = 0
return fixIdx
[docs]
def GetCount(self):
"""Returns the number of items in the list."""
return len(self.__listItems)
[docs]
def Clear(self):
"""Removes all items from the list."""
nitems = len(self.__listItems)
for i in range(nitems):
self.Delete(0)
[docs]
def ClearSelection(self):
"""Ensures that no items are selected."""
for i, item in enumerate(self.__listItems):
item.labelWidget.SetForegroundColour(item.defaultFGColour)
item.labelWidget.SetBackgroundColour(item.defaultBGColour)
item.container .SetBackgroundColour(item.defaultBGColour)
item.labelWidget.Refresh()
item.container .Refresh()
if item.extraWidget is not None:
item.extraWidget.SetBackgroundColour(item.defaultBGColour)
item.extraWidget.Refresh()
self.__selection = wx.NOT_FOUND
[docs]
def SetSelection(self, n):
"""Selects the item at the given index."""
if n != wx.NOT_FOUND and (n < 0 or n >= len(self.__listItems)):
raise IndexError('Index {} out of bounds'.format(n))
self.ClearSelection()
if n == wx.NOT_FOUND: return
self.__selection = self.__fixIndex(n)
item = self.__listItems[self.__selection]
item.labelWidget.SetForegroundColour(item.selectedFGColour)
item.labelWidget.SetBackgroundColour(item.selectedBGColour)
item.container .SetBackgroundColour(item.selectedBGColour)
item.labelWidget.Refresh()
item.container .Refresh()
if item.extraWidget is not None:
item.extraWidget.SetBackgroundColour(item.selectedBGColour)
item.extraWidget.Refresh()
self.__updateMoveButtons()
[docs]
def GetSelection(self):
"""Returns the index of the selected item, or :data:`wx.NOT_FOUND`
if no item is selected.
"""
return self.__fixIndex(self.__selection)
[docs]
def Insert(self,
label,
pos,
clientData=None,
tooltip=None,
extraWidget=None):
"""Insert an item into the list.
:arg label: The label to be displayed.
:arg pos: Index at which the item is to be inserted.
:arg clientData: Data associated with the item.
:arg tooltip: Tooltip to be shown, if the :data:`ELB_TOOLTIP`
style is active.
:arg extraWidget: A widget to be displayed alongside the label.
"""
if pos < 0 or pos > self.GetCount():
raise IndexError('Index {} out of bounds'.format(pos))
pos = self.__fixIndex(pos)
if self.__noLabels or label is None:
label = ''
# StaticText under Linux/GTK poses problems -
# we cannot set background colour, nor can we
# intercept mouse motion events. So we embed
# the StaticText widget within a wx.Panel.
container = wx.Panel(self.__listPanel, style=wx.WANTS_CHARS)
labelWidget = wx.StaticText(container,
label=label,
style=(wx.ST_ELLIPSIZE_MIDDLE |
wx.ALIGN_CENTRE_VERTICAL |
wx.WANTS_CHARS))
sizer = wx.BoxSizer(wx.HORIZONTAL)
container.SetSizer(sizer)
sizerItems = [labelWidget]
if self.__noLabels:
sizerFlags = [{}]
else:
sizerFlags = [{'flag' : wx.ALIGN_CENTRE | wx.BOTTOM,
'border' : self.__vgap,
'proportion' : 1}]
if extraWidget is not None:
extraWidget.Reparent(container)
if self.__widgetOnRight: index = 1
else: index = 0
sizerItems.insert(index, extraWidget)
sizerFlags.insert(index, {})
for item, flags in zip(sizerItems, sizerFlags):
sizer.Add(item, **flags)
labelWidget.Bind(wx.EVT_LEFT_DOWN, self.__itemClicked)
container .Bind(wx.EVT_LEFT_DOWN, self.__itemClicked)
# Under linux/GTK, mouse wheel handlers
# need to be added to children, not
# just the top level container
if self.__scrollbar is not None:
labelWidget.Bind(wx.EVT_MOUSEWHEEL, self.__onMouseWheel)
container .Bind(wx.EVT_MOUSEWHEEL, self.__onMouseWheel)
item = _ListItem(label,
clientData,
tooltip,
labelWidget,
container,
EditableListBox._defaultFG,
EditableListBox._selectedFG,
EditableListBox._defaultBG,
EditableListBox._selectedBG,
extraWidget)
def onEdit(ev):
self.__onEdit(ev, item)
def onDblClick(ev):
self.__onDoubleClick(ev, item)
# If the items are editable,
# double clicking will call
# the __onEdit method
if self.__editSupport:
labelWidget.Bind(wx.EVT_LEFT_DCLICK, onEdit)
container .Bind(wx.EVT_LEFT_DCLICK, onEdit)
# Otherwise, double clicking will
# call the __onDoubleClick method
else:
labelWidget.Bind(wx.EVT_LEFT_DCLICK, onDblClick)
container .Bind(wx.EVT_LEFT_DCLICK, onDblClick)
log.debug('Inserting item ({}) at index {}'.format(label, pos))
self.__listItems.insert(pos, item)
self.__listSizer.Insert(pos, container, flag=wx.EXPAND)
self.__listSizer.Layout()
# if an item was inserted before the currently
# selected item, the __selection index will no
# longer be valid - fix it.
if self.__selection != wx.NOT_FOUND and pos < self.__selection:
self.__selection = self.__selection + 1
# Make sure item fg/bg colours are up to date
self.SetSelection(self.__fixIndex(self.__selection))
self.__updateMoveButtons()
if self.__tooltipDown: self.__configTooltipDown(item)
else: self.__configTooltip( item)
# Make sure the enabled state of the
# new label/widget is consistent with
# the state of this elistbox.
container.Enable(self.IsEnabled())
self.Refresh()
def __configTooltip(self, listItem):
"""If the :data:`ELB_TOOLTIP` style was enabled, this method
configures mouse-over listeners on the widget representing the given
list item, so the item displays the tool tip on mouse overs.
If :data:`ELB_TOOLTIP` is not enabled, a regular tooltip is configured.
"""
if not self.__showTooltips:
if listItem.tooltip is not None:
tooltip = wx.ToolTip(listItem.tooltip)
listItem.labelWidget.SetToolTip(tooltip)
else:
def mouseOver(ev):
if listItem.tooltip is not None:
listItem.labelWidget.SetLabel(listItem.tooltip)
def mouseOut(ev):
listItem.labelWidget.SetLabel(listItem.label)
# Register motion listeners on the widget
# container so it works under GTK
listItem.container.Bind(wx.EVT_ENTER_WINDOW, mouseOver)
listItem.container.Bind(wx.EVT_LEAVE_WINDOW, mouseOut)
def __configTooltipDown(self, listItem):
"""If the :data:`ELB_TOOLTIP_DOWN` style was enabled, this method
configures mouse-down listeners on the given
list item widget, so the item displays the tool tip on mouse down.
This method is not called if :data:`ELB_TOOLTIP_DOWN` is not enabled.
"""
# The tooltip is shown only after the mouse
# has been held down for a short period of
# time. This is required so the tooltip is
# not shown on regular clicks/double clicks.
listItem._cancelTooltipDown = False
def changeLabel(lbl):
if not listItem._cancelTooltipDown:
listItem.labelWidget.SetLabel(lbl)
def mouseDown(ev):
ev.Skip()
listItem._cancelTooltipDown = False
if listItem.tooltip is not None:
wx.CallLater(300, changeLabel, listItem.tooltip)
def mouseUp(ev):
ev.Skip()
listItem._cancelTooltipDown = True
listItem.labelWidget.SetLabel(listItem.label)
listItem.labelWidget.Bind(wx.EVT_LEFT_DOWN, mouseDown)
listItem.labelWidget.Bind(wx.EVT_LEFT_UP, mouseUp)
[docs]
def Append(self, label, clientData=None, tooltip=None, extraWidget=None):
"""Appends an item to the end of the list.
:arg label: The label to be displayed
:arg clientData: Data associated with the item
:arg tooltip: Tooltip to be shown, if the :data:`ELB_TOOLTIP`
style is active.
:arg extraWidget: A widget to be displayed alonside the item.
"""
self.Insert(label,
len(self.__listItems),
clientData,
tooltip,
extraWidget)
[docs]
def Delete(self, n):
"""Removes the item at the given index from the list."""
n = self.__fixIndex(n)
if n < 0 or n >= len(self.__listItems):
raise IndexError('Index {} out of bounds'.format(n))
item = self.__listItems.pop(n)
self.__listSizer.Remove(n)
# Destroying the container will result in the
# child widget(s) being destroyed as well.
item.container.Destroy()
self.__listSizer.Layout()
# if the deleted item was selected, clear the selection
if self.__selection == n:
self.ClearSelection()
# or if the deleted item was before the
# selection, fix the selection index
elif self.__selection > n:
self.__selection = self.__selection - 1
self.__updateMoveButtons()
self.__updateScrollbar()
self.Refresh()
[docs]
def IndexOf(self, clientData):
"""Returns the index of the list item with the specified
``clientData``.
"""
for i, item in enumerate(self.__listItems):
if item.data == clientData:
return self.__fixIndex(i)
return -1
[docs]
def GetLabels(self):
"""Returns the labels of all items in the list."""
indices = map(self.__fixIndex, range(self.GetCount()))
return [self.__listItems[i].label for i in indices]
[docs]
def GetData(self):
"""Returns the data associated with every item in the list."""
indices = map(self.__fixIndex, range(self.GetCount()))
return [self.__listItems[i].data for i in indices]
[docs]
def SetItemLabel(self, n, s):
"""Sets the label of the item at index ``n`` to the string ``s``.
:arg n: Index of the item.
:arg s: New label for the item.
"""
if n < 0 or n >= self.GetCount():
raise IndexError('Index {} is out of bounds'.format(n))
n = self.__fixIndex(n)
self.__listItems[n].labelWidget.SetLabel(s)
self.__listItems[n].label = s
[docs]
def GetItemLabel(self, n):
"""Returns the label of the item at index ``n``.
:arg n: Index of the item.
"""
if n < 0 or n >= self.GetCount():
raise IndexError('Index {} is out of bounds'.format(n))
n = self.__fixIndex(n)
return self.__listItems[n].label
[docs]
def SetItemData(self, n, data=None):
"""Sets the data associated with the item at index ``n``."""
n = self.__fixIndex(n)
self.__listItems[n].data = data
[docs]
def GetItemData(self, n):
"""Returns the data associated with the item at index ``n``."""
n = self.__fixIndex(n)
return self.__listItems[n].data
[docs]
def SetItemForegroundColour(self,
n,
defaultColour=None,
selectedColour=None):
"""Sets the foreground colour of the item at index ``n``."""
if defaultColour is None:
defaultColour = EditableListBox._defaultFG
selectedColour = EditableListBox._selectedFG
if selectedColour is None:
selectedColour = defaultColour
item = self.__listItems[self.__fixIndex(n)]
item.defaultFGColour = defaultColour
item.selectedFGColour = selectedColour
self.SetSelection(self.__fixIndex(self.__selection))
[docs]
def SetItemBackgroundColour(self,
n,
defaultColour=None,
selectedColour=None):
"""Sets the background colour of the item at index ``n``."""
if defaultColour is None:
defaultColour = EditableListBox._defaultBG
selectedColour = EditableListBox._selectedBG
if selectedColour is None:
selectedColour = defaultColour
item = self.__listItems[self.__fixIndex(n)]
item.defaultBGColour = defaultColour
item.selectedBGColour = selectedColour
self.SetSelection(self.__fixIndex(self.__selection))
[docs]
def SetItemFont(self, n, font):
"""Sets the font for the item label at index ``n``."""
li = self.__listItems[self.__fixIndex(n)]
li.labelWidget.SetFont(font)
[docs]
def GetItemFont(self, n):
"""Returns the font for the item label at index ``n``."""
li = self.__listItems[self.__fixIndex(n)]
return li.labelWidget.GetFont()
[docs]
def Enable(self, enable=True):
"""Enables/disables this ``EditableListBox`` and all of its children.
"""
wx.Panel.Enable(self, enable)
for item in self.__listItems:
item.container.Enable(enable)
[docs]
def Disable(self):
"""Equivalent to ``Enable(False)``. """
self.Enable(False)
[docs]
def ApplyFilter(self, filterStr=None, ignoreCase=False):
"""Hides any items for which the label does not contain the given
``filterStr``.
To clear the filter (and hence show all items), pass in
``filterStr=None``.
"""
if filterStr is None:
filterStr = ''
filterStr = filterStr.strip().lower()
for item in self.__listItems:
item.hidden = filterStr not in item.label.lower()
self.__updateScrollbar()
self.__drawList()
def __getSelection(self, fix=False):
"""Returns a 3-tuple containing the (uncorrected) index, label,
and associated client data of the currently selected list item,
or (None, None, None) if no item is selected.
"""
idx = self.__selection
label = None
data = None
if idx == wx.NOT_FOUND:
idx = None
else:
label = self.__listItems[idx].label
data = self.__listItems[idx].data
if fix:
idx = self.__fixIndex(idx)
return idx, label, data
def __itemClicked(self, ev=None, widget=None):
"""Called when an item in the list is clicked. Selects the item
and posts an :data:`EVT_ELB_SELECT_EVENT`.
This method may be called programmatically, by explicitly passing
in the target ``widget``. This functionality is used by the
:meth:`__onKeyboard` event.
:arg ev: A :class:`wx.MouseEvent`.
:arg widget: The widget on which to simulate a mouse click. Must
be provided when called programmatically.
"""
# Give focus to the top level panel,
# otherwise it will not receive char events
self.SetFocusIgnoringChildren()
if ev is not None:
widget = ev.GetEventObject()
itemIdx = -1
for i, listItem in enumerate(self.__listItems):
if widget in (listItem.labelWidget, listItem.container):
itemIdx = i
break
if itemIdx == -1:
return
self.SetSelection(self.__fixIndex(itemIdx))
idx, label, data = self.__getSelection(True)
log.debug('ListSelectEvent (idx: {}; label: {})'.format(idx, label))
ev = ListSelectEvent(idx=idx, label=label, data=data)
wx.PostEvent(self, ev)
[docs]
def MoveItem(self, offset, event=False):
"""Move the currently selected item the specified offset.
Called when the *move up* or *move down* buttons are pushed.
Moves the selected item by the specified offset unless it doesn't make
sense to do the move.
If the item is moved, and ``event is True``, posts a
:data:`EVT_ELB_MOVE_EVENT`,
"""
oldIdx, label, data = self.__getSelection()
if oldIdx is None: return
newIdx = oldIdx + offset
# the selected item is at the top/bottom of the list.
if oldIdx < 0 or oldIdx >= self.GetCount(): return
if newIdx < 0 or newIdx >= self.GetCount(): return
widget = self.__listSizer.GetItem(oldIdx).GetWindow()
self.__listItems.insert(newIdx, self.__listItems.pop(oldIdx))
self.__listSizer.Detach(oldIdx)
self.__listSizer.Insert(newIdx, widget, flag=wx.EXPAND)
oldIdx = self.__fixIndex(oldIdx)
newIdx = self.__fixIndex(newIdx)
self.SetSelection(newIdx)
self.__listSizer.Layout()
log.debug('ListMoveEvent (oldIdx: {}; newIdx: {}; label: {})'.format(
oldIdx, newIdx, label))
if event:
ev = ListMoveEvent(
oldIdx=oldIdx, newIdx=newIdx, label=label, data=data)
wx.PostEvent(self, ev)
def __moveItemDown(self, ev):
"""Called when the *move down* button is pushed. Calls the
:meth:`__moveItem` method.
"""
self.MoveItem(1, event=True)
def __moveItemUp(self, ev):
"""Called when the *move up* button is pushed. Calls the
:meth:`__moveItem` method.
"""
self.MoveItem(-1, event=True)
def __addItem(self, ev):
"""Called when the *add item* button is pushed.
Does nothing but post an :data:`EVT_ELB_ADD_EVENT` - it is up to a
registered handler to implement the functionality of adding an item to
the list.
"""
idx, label, data = self.__getSelection(True)
log.debug('ListAddEvent (idx: {}; label: {})'.format(idx, label))
ev = ListAddEvent(idx=idx, label=label, data=data)
wx.PostEvent(self, ev)
def __removeItem(self, ev):
"""Called when the *remove item* button is pushed.
Posts an :data:`EVT_ELB_REMOVE_EVENT` and removes the
selected item from the list.
Event listeners may call ``Veto()`` on the event object to cancel
the removal.
"""
idx, label, data = self.__getSelection(True)
if idx is None: return
ev = ListRemoveEvent(idx=idx, label=label, data=data)
log.debug('ListRemoveEvent (idx: {}; label: {})'.format(
idx, label))
# We use ProcessEvent instead of wx.PostEvent,
# because the latter processes the event
# asynchronously, and we need to check whether
# the event handler vetoed the event.
self.GetEventHandler().ProcessEvent(ev)
if ev.GetVeto():
log.debug('ListRemoveEvent vetoed (idx: {}; label: {})'.format(
idx, label))
return
self.Delete(idx)
if self.GetCount() > 0:
if idx == self.GetCount():
self.SetSelection(idx - 1)
else:
self.SetSelection(idx)
def __onEdit(self, ev, listItem):
"""Called when an item is double clicked.
This method is only called if the :data:`ELB_EDITABLE` style flag is
set.
Creates and displays a :class:`wx.TextCtrl` allowing the user to edit
the item label. A :class:`ListEditEvent` is posted every time the text
changes.
"""
idx = self.__listItems.index(listItem)
idx = self.__fixIndex(idx)
sizer = listItem.container.GetSizer()
editCtrl = wx.TextCtrl(listItem.container, style=wx.TE_PROCESS_ENTER)
editCtrl.SetValue(listItem.label)
# Listens to key presses. The edit is
# cancelled if the escape key is pressed.
def onKey(ev):
ev.Skip()
key = ev.GetKeyCode()
if key == wx.WXK_ESCAPE: onFinish()
# Destroyes the textctrl, and re-shows the item label.
def onFinish(ev=None):
if ev is not None:
ev.Skip()
def _onFinish():
sizer.Detach(editCtrl)
editCtrl.Destroy()
sizer.Show(listItem.labelWidget, True)
sizer.Layout()
self.Refresh()
wx.CallAfter(_onFinish)
# Sets the list item label to the new
# value, and posts a ListEditEvent.
def onText(ev):
oldLabel = listItem.labelWidget.GetLabel()
newLabel = editCtrl.GetValue()
listItem.label = newLabel
listItem.labelWidget.SetLabel(newLabel)
log.debug('ListEditEvent (idx: {}, oldLabel: {}, newLabel: {})'
.format(idx, oldLabel, newLabel))
ev = ListEditEvent(idx=idx, label=newLabel, data=listItem.data)
wx.PostEvent(self, ev)
editCtrl.Bind(wx.EVT_TEXT, onText)
editCtrl.Bind(wx.EVT_KEY_DOWN, onKey)
editCtrl.Bind(wx.EVT_TEXT_ENTER, onFinish)
editCtrl.Bind(wx.EVT_KILL_FOCUS, onFinish)
if self.__widgetOnRight:
sizer.Insert(0, editCtrl, flag=wx.EXPAND, proportion=1)
else:
sizer.Insert(2, editCtrl, flag=wx.EXPAND, proportion=1)
sizer.Show(listItem.labelWidget, False)
sizer.Layout()
editCtrl.SetFocus()
def __onDoubleClick(self, ev, listItem):
"""Called when an item is double clicked. See the :data:`ELB_EDITABLE`
style.
This method is only called if the :data:`ELB_EDITABLE` style flag is
not set.
Posts a :class:`ListDblClickEvent`.
"""
idx = self.__listItems.index(listItem)
idx = self.__fixIndex(idx)
ev = ListDblClickEvent(idx=idx,
label=listItem.label,
data=listItem.data)
wx.PostEvent(self, ev)
def __updateMoveButtons(self):
if self.__moveSupport:
self.__upButton .Enable((self.__selection != wx.NOT_FOUND) and
(self.__selection != 0))
self.__downButton.Enable((self.__selection != wx.NOT_FOUND) and
(self.__selection != self.GetCount() - 1))
class _ListItem:
"""Internal class used to represent items in the list."""
def __init__(self,
label,
data,
tooltip,
labelWidget,
container,
defaultFGColour,
selectedFGColour,
defaultBGColour,
selectedBGColour,
extraWidget=None):
"""Create a _ListItem object.
:param str label: The item label which will be displayed.
:param data: User data associated with the item.
:param str tooltip: A tooltip to be displayed when the mouse
is moved over the item.
:param labelWidget: The :mod:`wx` object which represents the
list item.
:param container: The :mod:`wx` object used as a container for
the ``widget``.
:param defaultFGColour: Foreground colour to use when the item is
not selected.
:param selectedFGColour: Foreground colour to use when the item is
selected.
:param defaultBGColour: Background colour to use when the item is
not selected.
:param selectedBGColour: Background colour to use when the item is
selected.
:param extraWidget: A user-settable widget to be displayed
alongside this item.
"""
self.label = label
self.data = data
self.labelWidget = labelWidget
self.container = container
self.tooltip = tooltip
self.defaultFGColour = defaultFGColour
self.selectedFGColour = selectedFGColour
self.defaultBGColour = defaultBGColour
self.selectedBGColour = selectedBGColour
self.extraWidget = extraWidget
self.hidden = False
_ListSelectEvent, _EVT_ELB_SELECT_EVENT = wxevent.NewEvent()
_ListAddEvent, _EVT_ELB_ADD_EVENT = wxevent.NewEvent()
_ListRemoveEvent, _EVT_ELB_REMOVE_EVENT = wxevent.NewEvent()
_ListMoveEvent, _EVT_ELB_MOVE_EVENT = wxevent.NewEvent()
_ListEditEvent, _EVT_ELB_EDIT_EVENT = wxevent.NewEvent()
_ListDblClickEvent, _EVT_ELB_DBLCLICK_EVENT = wxevent.NewEvent()
EVT_ELB_SELECT_EVENT = _EVT_ELB_SELECT_EVENT
"""Identifier for the :data:`ListSelectEvent` event."""
EVT_ELB_ADD_EVENT = _EVT_ELB_ADD_EVENT
"""Identifier for the :data:`ListAddEvent` event."""
EVT_ELB_REMOVE_EVENT = _EVT_ELB_REMOVE_EVENT
"""Identifier for the :data:`ListRemoveEvent` event."""
EVT_ELB_MOVE_EVENT = _EVT_ELB_MOVE_EVENT
"""Identifier for the :data:`ListMoveEvent` event."""
EVT_ELB_EDIT_EVENT = _EVT_ELB_EDIT_EVENT
"""Identifier for the :data:`ListEditEvent` event."""
EVT_ELB_DBLCLICK_EVENT = _EVT_ELB_DBLCLICK_EVENT
ListSelectEvent = _ListSelectEvent
"""Event emitted when an item is selected. A ``ListSelectEvent``
has the following attributes (all are set to ``None`` if no
item was selected):
- ``idx``: Index of selected item
- ``label``: Label of selected item
- ``data``: Client data associated with selected item
"""
ListAddEvent = _ListAddEvent
"""Event emitted when the 'add item' button is pushed. It is
up to a listener of this event to actually add a new item
to the list. A ``ListAddEvent`` has the following attributes
(all are set to ``None`` if no item was selected):
- ``idx``: Index of selected item
- ``label``: Label of selected item
- ``data``: Client data associated with selected item
"""
ListRemoveEvent = _ListRemoveEvent
"""Event emitted when the 'remove item' button is pushed. A
``ListRemoveEvent`` has the following attributes:
- ``idx``: Index of removed item
- ``label``: Label of removed item
- ``data``: Client data associated with removed item
An event handler can call ``ListRemoveEvent.Veto()`` to cancel
the item removal.
"""
[docs]
def Veto(self):
self._vetoed = True
[docs]
def GetVeto(self):
return getattr(self, '_vetoed', False)
ListRemoveEvent.Veto = Veto
ListRemoveEvent.GetVeto = GetVeto
ListMoveEvent = _ListMoveEvent
"""Event emitted when one of the 'move up'/'move down'
buttons is pushed. A ``ListMoveEvent`` has the following
attributes:
- ``oldIdx``: Index of item before move
- ``newIdx``: Index of item after move
- ``label``: Label of moved item
- ``data``: Client data associated with moved item
"""
ListEditEvent = _ListEditEvent
"""Event emitted when a list item is edited by the user (see the
:data:`ELB_EDITABLE` style). A ``ListEditEvent`` has the following
attributes:
- ``idx``: Index of edited item
- ``label``: New label of edited item
- ``data``: Client data associated with edited item.
"""
ListDblClickEvent = _ListDblClickEvent
"""Event emitted when a list item is double-clicked onthe user (see
the :data:`ELB_EDITABLE` style). A ``ListDblClickEvent`` has the
following attributes:
- ``idx``: Index of clicked item
- ``label``: Label of clicked item
- ``data``: Client data associated with clicked item.
"""
ELB_NO_SCROLL = 1
"""If enabled, there will be no scrollbar. """
ELB_NO_ADD = 2
"""If enabled, there will be no *add item* button."""
ELB_NO_REMOVE = 4
"""If enabled, there will be no *remove item* button."""
ELB_NO_MOVE = 8
"""If enabled there will be no *move item up* or *move item down* buttons. """
ELB_REVERSE = 16
"""If enabled, the first item in the list (index 0) will be shown at the
bottom, and the last item at the top.
"""
ELB_TOOLTIP = 32
"""If enabled, list items will be replaced with a tooltip on mouse-over. If
disabled, a regular tooltip is shown.
"""
ELB_EDITABLE = 64
"""If enabled, double clicking a list item will allow the user to edit the
item value.
If this style is disabled, the :attr:`EVT_ELB_DBLCLICK_EVENT` will not
be generated.
"""
ELB_NO_LABELS = 128
"""If enabled, item labels are not shown - this is intended for lists which
are to consist solely of widgets (see the ``extraWidget`` parameter to the
:meth:`Insert` method). This style flag will negate the :data:`ELB_EDITABLE`
flag.
"""
ELB_WIDGET_RIGHT = 256
"""If enabled, item widgets are shown to the right of the item label.
Otherwise (by default) item widgets are shown to the left.
"""
ELB_TOOLTIP_DOWN = 512
"""If enabled, when the left mouse button is clicked and held down on a list
item, the item label is replaced with its tooltip while the mouse is held down.
This style is ignored if the :data:`ELB_TOOLTIP` style is active.
"""
ELB_SCROLL_BUTTONS = 1024
"""If enabled, and :data:`ELB_NO_SCROLL` is not enabled, up/down buttons are
added above/below the list, which allow the user to scroll up/down.
"""