Source code for fsleyes_widgets.widgetgrid

#!/usr/bin/env python
#
# widgetgrid.py - A tabular grid of widgets.
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
"""This module provides the :class:`WidgetGrid` class, which can display a
tabular grid of arbitrary widgets.
"""


import functools as ft
import              logging

import wx
import wx.lib.newevent as wxevent

import fsleyes_widgets.utils.b64icon as b64icon


log = logging.getLogger(__name__)


[docs] class WidgetGrid(wx.ScrolledWindow): """A scrollable panel which displays a tabular grid of widgets. A ``WidgetGrid`` looks something like this: .. image:: images/widgetgrid.png :scale: 50% :align: center The most important methods are: .. autosummary:: :nosignatures: GetGridSize SetGridSize Refresh DeleteRow InsertRow SetColours SetWidget SetText ClearGrid *Labels* .. autosummary:: :nosignatures: ShowRowLabels ShowColLabels SetRowLabel SetColLabel *Selections* .. autosummary:: :nosignatures: GetSelection SetSelection *Styles* The ``WidgetGrid`` supports the following styles: =============================== ================================== ``wx.HSCROLL`` Use a horizontal scrollbar. ``wx.VSCROLL`` Use a vertical scrollbar. :data:`WG_SELECTABLE_CELLS` Individual cells are selectable. :data:`WG_SELECTABLE_ROWS` Rows are selectable. :data:`WG_SELECTABLE_COLUMN` Columns are selectable. :data:`WG_KEY_NAVIGATION` The keyboard can be used for navigation. :data:`WG_DRAGGABLE_COLUMNS` Columns can be dragged to re-order them (see also the :meth:`ReorderColumns` method) =============================== ================================== The ``*_SELECTABLE_*`` styles are mutualliy exclusive; their precedence is equivalent to their order in the above table. By default, the arrow keys are used for keyboard navigation, but these are customisable via the :meth:`SetNavKeys` method. *Events* The following events may be emitted by a ``WidgetGrid``: .. autosummary:: :nosignatures: :data:`WidgetGridSelectEvent` :data:`WidgetGridReorderEvent` """ _defaultBorderColour = None """The colour of border a border which is shown around every cell in the grid. Initialised in :meth:`__init__`. """ _defaultOddColour = None """Background colour for cells on odd rows. Initialised in :meth:`__init__`. """ _defaultEvenColour = None """Background colour for cells on even rows. Initialised in :meth:`__init__`. """ _defaultLabelColour = None """Background colour for row and column labels. Initialised in :meth:`__init__`. """ _defaultSelectedColour = None """Background colour for selected cells. Initialised in :meth:`__init__`. """ _defaultDragColour = None """Background colour for columns being dragged. Initialised in :meth:`__init__`. """ def __init__(self, parent, style=None): """Create a ``WidgetGrid``. :arg parent: The :mod:`wx` parent object. :arg style: Style flags - can be a combination of ``wx.HSCROLL``, ``wx.VSCROLL``, :data:`WG_SELECTABLE_CELLS`, :data:`WG_SELECTABLE_ROWS`, :data:`WG_SELECTABLE_COLUMNS`, :data:`WG_KEY_NAVIGATION`, and :data:`WG_DRAGGABLE_COLUMNS`. """ border = wx.SystemSettings.GetColour(wx.SYS_COLOUR_ACTIVEBORDER) odd = wx.SystemSettings.GetColour(wx.SYS_COLOUR_LISTBOX) even = wx.SystemSettings.GetColour(wx.SYS_COLOUR_LISTBOX)\ .ChangeLightness(90) label = wx.SystemSettings.GetColour(wx.SYS_COLOUR_WINDOW) select = wx.SystemSettings.GetColour(wx.SYS_COLOUR_HIGHLIGHT) drag = wx.SystemSettings.GetColour(wx.SYS_COLOUR_HIGHLIGHT) if WidgetGrid._defaultBorderColour is None: WidgetGrid._defaultBorderColour = border if WidgetGrid._defaultOddColour is None: WidgetGrid._defaultOddColour = odd if WidgetGrid._defaultEvenColour is None: WidgetGrid._defaultEvenColour = even if WidgetGrid._defaultLabelColour is None: WidgetGrid._defaultLabelColour = label if WidgetGrid._defaultSelectedColour is None: WidgetGrid._defaultSelectedColour = select if WidgetGrid._defaultDragColour is None: WidgetGrid._defaultDragColour = drag if style is None: style = wx.HSCROLL | wx.VSCROLL self.__hscroll = style & wx.HSCROLL self.__vscroll = style & wx.VSCROLL self.__keynav = style & WG_KEY_NAVIGATION self.__draggable = style & WG_DRAGGABLE_COLUMNS self.__dragLimit = -1 self.__dragStartCol = None self.__dragCurrentCol = None if style & WG_SELECTABLE_CELLS: self.__selectable = 'cells' elif style & WG_SELECTABLE_ROWS: self.__selectable = 'rows' elif style & WG_SELECTABLE_COLUMNS: self.__selectable = 'columns' else: self.__keynav = False self.__selectable = None # clear WG_* flags before passing # style to ScrolledWindow style = (style & wx.HSCROLL) | (style & wx.VSCROLL) | wx.WANTS_CHARS wx.ScrolledWindow.__init__(self, parent, style=style) hrate = 1 if self.__hscroll else 0 vrate = 1 if self.__vscroll else 0 self.SetScrollRate(hrate, vrate) self.__gridPanel = wx.Panel(self, style=wx.WANTS_CHARS) self.__sizer = wx.BoxSizer(wx.VERTICAL) self.SetSizer(self.__sizer) # if column drag is enabled, we use a separate # strip above the grid to show where a dragged # column will be placed when it is dropped. if self.__draggable: self.__dragIcon = b64icon.loadBitmap(TRIANGLE_ICON) height = self.__dragIcon.GetSize()[1] self.__dragPanel = wx.Window(self) self.__dragPanel.SetMinSize((-1, height)) self.__dragPanel.SetMaxSize((-1, height)) self.__sizer.Add(self.__dragPanel, flag=wx.EXPAND) self.__dragPanel.Bind(wx.EVT_PAINT, self.__dragPanelPaint) self.__sizer.Add(self.__gridPanel, flag=wx.EXPAND) # The __widgets array contains wx.Panel # objects which are used as containers # for all widgets added to the grid. # The __widgetRefs array contains the # actual widget objects that were passed # to the SetWidget method. self.__gridSizer = None self.__nrows = 0 self.__ncols = 0 self.__widgets = [] self.__widgetRefs = [] self.__rowLabels = [] self.__colLabels = [] self.__selected = None self.__showRowLabels = False self.__showColLabels = False self.__borderColour = WidgetGrid._defaultBorderColour self.__labelColour = WidgetGrid._defaultLabelColour self.__oddColour = WidgetGrid._defaultOddColour self.__evenColour = WidgetGrid._defaultEvenColour self.__selectedColour = WidgetGrid._defaultSelectedColour self.__dragColour = WidgetGrid._defaultDragColour self.__upKey = wx.WXK_UP self.__downKey = wx.WXK_DOWN self.__leftKey = wx.WXK_LEFT self.__rightKey = wx.WXK_RIGHT self.Bind(wx.EVT_SIZE, self.__onResize) if self.__keynav: # 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) if self.__selectable: # A silly internal multi-level semaphore # used to ignore child focus events when # this WidgetGrid generated them. See # the __selectCell method for more silly # comments. self.__ignoreFocus = 0 self.Bind(wx.EVT_CHILD_FOCUS, self.__onChildFocus) @property def rowLabels(self): """Returns the ``wx.StaticText`` objects used for the row labels. """ return [l[1] for l in self.__rowLabels] @property def colLabels(self): """Returns the ``wx.StaticText`` objects used for the column labels. """ return [l[1] for l in self.__colLabels] @property def widgets(self): """Returns a list of lists, containing all widgets in the grid. """ return [list(row) for row in self.__widgets]
[docs] def Refresh(self): """Redraws the contents of this ``WidgetGrid``. This method must be called after the contents of the grid are changed. """ self.__refresh()
def __recurse(self, obj, funcname, *args, **kwargs): """Recursively call ``funcname`` on ``obj`` and all its children. This really is something which ``wxwidgets`` should be able to do for me (e.g. enable/disable a window *and* all of its children). """ if obj is self: func = ft.partial(getattr(wx.ScrolledWindow, funcname), self) else: func = getattr(obj, funcname) func(*args, **kwargs) for child in obj.GetChildren(): self.__recurse(child, funcname, *args, **kwargs)
[docs] def Disable(self): """Disables this ``WidgetGrid``. """ self.Enable(False)
[docs] def Enable(self, enable=True): """Enables/disable this ``WidgetGrid``, and recursively does the same to all of its children. """ self.__recurse(self, 'Enable', enable)
[docs] def Hide(self): """Hides this ``WidgetGrid``. """ self.Show(False)
[docs] def Show(self, show=True): """Shows/hides this ``WidgetGrid``, and recursively does the same to all of its children. """ self.__recurse(self, 'Show', show)
[docs] def SetEvtHandlerEnabled(self, enable=True): """Enables/disables events on this ``WidgetGrid``, and recursively does the same to all of its children. """ self.__recurse(self, 'SetEvtHandlerEnabled', enable)
[docs] def SetColours(self, **kwargs): """Set the colours used in this ``WidgetGrid``. The :meth:`Refresh` method must be called afterwards for this method to take effect. :arg border: The cell border colour. :arg label: Background colour for row and column labels. :arg odd: Background colour for cells on odd rows. :arg even: Background colour for cells on even rows. :arg selected: Background colour for selected cells. :arg drag: Background colour for columns being dragged. """ border = kwargs.get('border', self) label = kwargs.get('label', self) odd = kwargs.get('odd', self) even = kwargs.get('even', self) selected = kwargs.get('selected', self) drag = kwargs.get('drag', self) if border is not self: self.__borderColour = border if label is not self: self.__labelColour = label if odd is not self: self.__oddColour = odd if even is not self: self.__evenColour = even if selected is not self: self.__selectedColour = selected if drag is not self: self.__dragColour = drag
[docs] def SetNavKeys(self, **kwargs): """Set the keys used for keyboard navigation (if the :data:`WG_KEY_NAVIGATION` style is enabled). Setting an argument to ``None`` will disable navigation in that direction. :arg up: Key to use for up navigation. :arg down: Key to use for down navigation. :arg left: Key to use for left navigation. :arg right: Key to use for right navigation. """ up = kwargs.get('up', self) down = kwargs.get('down', self) left = kwargs.get('left', self) right = kwargs.get('right', self) self.__upKey = up self.__downKey = down self.__leftKey = left self.__rightKey = right
[docs] def SetDragLimit(self, limit): """Set the index of the highest column that can be dragged. Only columns before this limit can be dragged, and they can only be dropped onto a location before the limit. Only relevant if :data:`WG_DRAGGABLE_COLUMNS` is enabled. """ self.__dragLimit = limit
def __onResize(self, ev): """Called when this ``WidgetGrid`` is resized. Makes sure the scrollbars are up to date. """ self.FitInside() def __reparent(self, widget, parent): """Convenience method which re-parents the given widget. If ``widget`` is a :class:`wx.Sizer` the sizer children are re-parented. """ if isinstance(widget, wx.Sizer): widget = [c.GetWindow() for c in widget.GetChildren()] else: widget = [widget] for w in widget: if w is not None: w.Reparent(parent) def __setBackgroundColour(self, widget, colour): """Convenience method which changes the background colour of the given widget. If ``widget`` is a :class:`wx.Sizer` the background colours of the sizer children is updated. """ if isinstance(widget, wx.Sizer): widget = [c.GetWindow() for c in widget.GetChildren()] else: widget = [widget] for w in widget: if w is not None: w.SetBackgroundColour(colour) def __refresh(self): """Lays out and re-sizes the entire widget grid. """ borderColour = self.__borderColour labelColour = self.__labelColour oddColour = self.__oddColour evenColour = self.__evenColour ncols = self.__ncols if borderColour is None: borderColour = WidgetGrid._defaultBorderColour if labelColour is None: labelColour = WidgetGrid._defaultLabelColour if oddColour is None: oddColour = WidgetGrid._defaultOddColour if evenColour is None: evenColour = WidgetGrid._defaultEvenColour # Grid is empty if self.__gridSizer is None: self.FitInside() self.Layout() return self.__gridPanel.SetBackgroundColour(borderColour) # Clear the sizer per-item, as the # wx.Sizer.Clear will destroy any # child sizers, and we don't want that for i in reversed(range(self.__gridSizer.GetItemCount())): self.__gridSizer.Detach(i) # empty cell in top left of grid self.__gridSizer.Add((-1, -1), flag=wx.EXPAND) # column labels for coli, (lblPanel, colLabel) in enumerate(self.__colLabels): lblPanel._wg_row = -1 lblPanel._wg_col = coli colLabel._wg_row = -1 colLabel._wg_col = coli # If drag limit is set, add a border # between the last draggable column, # unless all columns are draggable. if (coli == self.__dragLimit) and (coli < ncols - 1): flag = wx.EXPAND | wx.RIGHT border = 2 else: flag = wx.EXPAND border = 0 lblPanel.SetBackgroundColour(labelColour) colLabel.SetBackgroundColour(labelColour) self.__gridSizer.Add( lblPanel, border=border, flag=flag) self.__gridSizer.Show(lblPanel, self.__showColLabels) # Rows for rowi, (lblPanel, rowLabel) in enumerate(self.__rowLabels): lblPanel._wg_row = rowi lblPanel._wg_col = -1 rowLabel._wg_row = rowi rowLabel._wg_col = -1 if rowi == self.__nrows - 1: flag = wx.TOP | wx.LEFT | wx.BOTTOM else: flag = wx.TOP | wx.LEFT lblPanel.SetBackgroundColour(labelColour) rowLabel.SetBackgroundColour(labelColour) self.__gridSizer.Add( lblPanel, flag=wx.EXPAND) self.__gridSizer.Show(lblPanel, self.__showRowLabels) # Widgets for coli in range(self.__ncols): widget = self.__widgetRefs[rowi][coli] container = self.__widgets[ rowi][coli] widget ._wg_row = rowi widget ._wg_col = coli container._wg_row = rowi container._wg_col = coli # border at drag limit if (coli == self.__dragLimit) and (coli < ncols - 1): flag = wx.EXPAND | wx.RIGHT border = 2 else: flag = wx.EXPAND border = 0 self.__gridSizer.Add(container, flag=flag, border=border, proportion=1) if rowi % 2: colour = oddColour else: colour = evenColour self.__setBackgroundColour(container, colour) self.__setBackgroundColour(widget, colour) if self.__selected is not None: row, col = self.__selected self.SetSelection(row, col) self.FitInside() self.Layout()
[docs] def GetGridSize(self): """Returns the current grid size as a tuple containing ``(rows, cols)``. """ return self.__nrows, self.__ncols
[docs] def SetGridSize(self, nrows, ncols, growCols=None): """Set the size of the widdget grid. The :meth:`Refresh` method must be called afterwards for this method to take effect. :arg nrows: Number of rows :arg ncols: Number of columns :arg growCols: A sequence specifying which columns should be stretched to fit. """ if nrows < 0 or ncols < 0: raise ValueError(f'Invalid size ({nrows}, {ncols})') # If the caller has not specified which columns # should stretch, then stretch them all so the # grid is sized to fit the available space. if growCols is None: growCols = range(ncols) self.ClearGrid() self.__nrows = nrows self.__ncols = ncols self.__dragLimit = -1 # set hgap and vgap so we get # a 1px border between cells self.__gridSizer = wx.FlexGridSizer(nrows + 1, ncols + 1, 1, 1) self.__gridSizer.SetFlexibleDirection(wx.BOTH) for col in growCols: self.__gridSizer.AddGrowableCol(col + 1) self.__widgets = [[None] * ncols for i in range(nrows)] self.__widgetRefs = [[None] * ncols for i in range(nrows)] self.__rowLabels = [None] * nrows self.__colLabels = [None] * ncols for rowi in range(nrows): for coli in range(ncols): self.__initCell(rowi, coli) for rowi in range(nrows): self.__initRowLabel(rowi) for coli in range(ncols): self.__initColLabel(coli) # Put the main grid sizer inside # another sizer, so we get a # border around the entire thing # (this can't be done in the grid # sizer itself, because the borders # between columns potentially vary) self.__gridBorderSizer = wx.BoxSizer(wx.VERTICAL) self.__gridBorderSizer.Add(self.__gridSizer, flag=wx.EXPAND | wx.ALL, border=1) self.__gridPanel.SetSizer(self.__gridBorderSizer)
def __initCell(self, row, col): """Called by :meth:`SetGridSize` and :meth:`InsertRow`. Creates a placeholder ``wx.Panel`` at the specified cell. """ placeholder = wx.Panel(self.__gridPanel) self.__widgets[ row][col] = placeholder self.__widgetRefs[row][col] = placeholder def __initRowLabel(self, row): """Called by :meth:`SetGridSize` and :meth:`InsertRow`. Creates a label widget at the specified row. """ panel = wx.Panel(self.__gridPanel) sizer = wx.BoxSizer(wx.HORIZONTAL) lbl = wx.StaticText( panel, style=wx.ALIGN_LEFT | wx.ALIGN_CENTER_VERTICAL) # See comment in SetWidget panel._wg_cell = True panel.SetSizer(sizer) sizer.Add(lbl, flag=wx.CENTRE) self.__initWidget(panel, row, -1) self.__initWidget(lbl, row, -1) self.__rowLabels[row] = (panel, lbl) def __initColLabel(self, col): """Called by :meth:`SetGridSize`. Creates a label widget at the specified column """ panel = wx.Panel(self.__gridPanel) sizer = wx.BoxSizer(wx.HORIZONTAL) label = wx.StaticText( panel, style=wx.ALIGN_CENTRE_HORIZONTAL | wx.ALIGN_CENTRE_VERTICAL) # See comment in SetWidget panel._wg_cell = True panel.SetSizer(sizer) sizer.Add(label, flag=wx.CENTRE) self.__initWidget(panel, -1, col) self.__initWidget(label, -1, col) self.__colLabels[col] = (panel, label) if self.__draggable: label.Bind(wx.EVT_LEFT_DOWN, self.__onColumnLabelMouseDown) label.Bind(wx.EVT_LEFT_UP, self.__onColumnLabelMouseUp) label.Bind(wx.EVT_MOTION, self.__onColumnLabelMouseDrag) panel.Bind(wx.EVT_LEFT_DOWN, self.__onColumnLabelMouseDown) panel.Bind(wx.EVT_LEFT_UP, self.__onColumnLabelMouseUp) panel.Bind(wx.EVT_MOTION, self.__onColumnLabelMouseDrag) def __getCellPanel(self, widget): """Returns the parent ``wx.Panel`` for the given ``widget``, or ``None`` if the widget is not in the grid. """ while widget is not None: if hasattr(widget, '_wg_cell'): break widget = widget.GetParent() return widget
[docs] def GetRow(self, widget): """Returns the index of the row in which the given ``widget`` is located, or ``-1`` if it is not in the ``WidgetGrid``. """ try: return widget._wg_row except AttributeError: return -1
[docs] def GetColumn(self, widget): """Returns the index of the column in which the given ``widget`` is located, or ``-1`` if it is not in the ``WidgetGrid``. """ try: return widget._wg_col except AttributeError: return -1
[docs] def DeleteRow(self, row): """Removes the specified ``row`` from the grid, destroying all widgets on that row. This method does not need to be followed by a call to :meth:`Refresh`, but a call to ``Layout`` may be required. .. note:: Make sure you reparent any widgets that you do not want destroyed before calling this method. """ if self.__gridSizer is None: raise ValueError('No grid') if row < 0 or row >= self.__nrows: raise ValueError(f'Invalid row index {row}') log.debug('Deleting row %s (sizer indices %s - %s)', row, (row + 1) * (self.__ncols + 1), (row + 1) * (self.__ncols + 1) + self.__ncols) # Remove from the grid for col in reversed(range(self.__ncols + 1)): self.__gridSizer.Detach((row + 1) * (self.__ncols + 1) + col) # Update row/col references # on all widgets/labels for ri in range(row, self.__nrows): for ci in range(self.__ncols): self.__widgetRefs[ri][ci]._wg_row -= 1 self.__widgets[ ri][ci]._wg_row -= 1 self.__rowLabels[ri][0]._wg_row -= 1 self.__rowLabels[ri][1]._wg_row -= 1 # Destroy the widgets and the row label for widget in self.__widgets[row]: widget.Destroy() self.__rowLabels .pop(row)[0].Destroy() # Remove references to them self.__widgetRefs.pop(row) self.__widgets .pop(row) # Update the grid self.__nrows -= 1 self.__gridSizer.SetRows(self.__nrows + 1) # Update selected widget if necessary if self.__selected is not None: srow, scol = self.__selected if srow == row: self.__selected = None elif srow > row and srow > 0: self.__selected = (srow - 1, scol) self.FitInside()
[docs] def InsertRow(self, row): """Inserts a new row into the ``WidgetGrid`` at the specified ``row`` index. This method must be followed by a call to :meth:`Refresh`. """ if self.__gridSizer is None: raise ValueError('No grid') if row < 0: raise ValueError(f'Invalid row index {row}') if row >= self.__nrows: row = self.__nrows log.debug('Inserting row at %s', row) # Add empty label/cell # values for the new row self.__rowLabels .insert(row, None) self.__widgets .insert(row, [None] * self.__ncols) self.__widgetRefs.insert(row, [None] * self.__ncols) # Update the grid self.__nrows += 1 self.__gridSizer.SetRows(self.__nrows + 1) # Initialise the contents # of the new row self.__initRowLabel(row) for col in range(self.__ncols): self.__initCell(row, col) # update row/col indices for ri in range(row + 1, self.__nrows): for ci in range(self.__ncols): self.__widgetRefs[ri][ci]._wg_row += 1 self.__widgets[ ri][ci]._wg_row += 1 self.__rowLabels[ri][0]._wg_row += 1 self.__rowLabels[ri][1]._wg_row += 1 # Update selected widget if necessary if self.__selected is not None: srow, scol = self.__selected if srow >= row and srow < self.__nrows - 1: self.__selected = (srow + 1, scol)
[docs] def ClearGrid(self): """Removes and destroys all widgets from the grid, and sets the grid size to ``(0, 0)``. The :meth:`Refresh` method must be called afterwards for this method to take effect. """ if self.__gridSizer is not None: self.__gridSizer.Clear(True) self.__gridSizer = None self.__nrows = 0 self.__ncols = 0 self.__dragLimit = -1 self.__widgets = [] self.__widgetRefs = [] self.__rowLabels = [] self.__colLabels = [] self.__selected = None self.__gridPanel.SetSizer(None)
[docs] def SetText(self, row, col, text): """Convenience method which creates a :class:`wx.StaticText` widget with the given text, and passes it to the :meth:`SetWidget` method. If there is already a ``wx.StaticText`` widget at the given ``row``/``col``, it is re-used, and its label simply updated. :arg row: Row index. :arg col: Column index. :arg text: Text to display. """ txt = self.GetWidget(row, col) if isinstance(txt, wx.StaticText): txt.SetLabel(text) else: txt = wx.StaticText(self.__gridPanel, label=text, style=wx.WANTS_CHARS) self.SetWidget(row, col, txt)
[docs] def GetWidget(self, row, col): """Returns the widget located at the specified row/column. """ return self.__widgetRefs[row][col]
[docs] def SetWidget(self, row, col, widget): """Adds the given widget to the grid. The :meth:`Refresh` method must be called afterwards for this method to take effect. The parent of the widget is changed to this ``WidgetGrid``. .. note:: The provided widget may alternately be a :class:`wx.Sizer`. However, nested sizers, i.e. sizers which contain other sizers, are not supported. :arg row: Row index. :arg col: Column index. :arg widget: The widget or sizer to add to the grid. Raises an :exc:`IndexError` if the specified grid location ``(row, col)`` is invalid. """ if row < 0 or \ col < 0 or \ row >= self.__nrows or \ col >= self.__ncols: raise IndexError(f'Grid location ({row}, {col}) out of bounds ' f'({self.__nrows}, {self.__ncols})') # Embed the widget in a panel, # as Linux/GTK has trouble # changing the background colour # of some controls panel = wx.Panel(self.__gridPanel, style=wx.WANTS_CHARS) sizer = wx.BoxSizer(wx.HORIZONTAL) panel.SetSizer(sizer) # Put a marker on the cell panel # so the __gelCellPanel method # can identify it - cell panels # are used in certain event # handlers (e.g. __onColumnLabelMouse*) panel._wg_cell = True self.__reparent(widget, panel) self.__initWidget(widget, row, col) self.__initWidget(panel, row, col) sizer.Add(widget, flag=wx.EXPAND, proportion=1) if self.__widgets[row][col] is not None: self.__widgets[row][col].Destroy() self.__widgetRefs[row][col] = widget self.__widgets[ row][col] = panel
def __initWidget(self, widget, row, col): """Called by the :meth:`AddWidget` method. Performs some initialisation on a widget which has just been added to the grid. :arg widget: The widget to initialise :arg row: Row index of the widget in the grid. :arg col: Column index of the widget in the grid. """ def scroll(ev): posx, posy = self.GetViewStart() rotation = ev.GetWheelRotation() if rotation > 0: delta = 5 elif rotation < 0: delta = -5 else: return if ev.GetWheelAxis() == wx.MOUSE_WHEEL_VERTICAL: posy -= delta else: posx += delta self.Scroll(posx, posy) def initWidget(w): # Under Linux/GTK, we need to bind a mousewheel # listener to every child of the panel in order # for scrolling to work correctly. This is not # necessary under OSX/cocoa. if wx.Platform == '__WXGTK__': w.Bind(wx.EVT_MOUSEWHEEL, scroll) # Listen for mouse down events # if cells are selectable if self.__selectable and not w.AcceptsFocus(): w.Bind(wx.EVT_LEFT_DOWN, self.__onLeftMouseDown) # Attach the row/column indices to the # widget - they are used in the # __onLeftDown and __onChildFocus methods w._wg_row = row w._wg_col = col if isinstance(widget, wx.Sizer): for c in widget.GetChildren(): c = c.GetWindow() if c is not None: initWidget(c) else: initWidget(widget) def __selectCell(self, row, col): """Called by the :meth:`__onChildFocus` and :meth:`__onLeftMouseDown` methods. Selects the specified row/column, and generates an :data:`EVT_WG_SELECT` event. """ if self.__selectable == 'rows': col = -1 elif self.__selectable == 'columns': row = -1 try: if not self.SetSelection(row, col): return except ValueError: return log.debug('Posting grid select event (%s, %s)', row, col) # This is a ridiculous workaround to a # ridiculous problem. Certain users of # the WidgetGrid focus a widget within # the grid on a select event. This # triggers a call to __onChildFocus. # Now, the logic in __onChildFocus # should be smart enough to not generate # another select event when the selected # cell hasn't changed. But under GTK it # seems that if keyboard/mouse events # occur fast enough, the order in which # event objects are passed becomes # unstable. This means that we can get # caught in a silly infinite focus # switching loop. # # To avoid this, I'm setting a flag here, # to tell the __onChildFocus method to # do nothing while any grid select event # handlers are running. self.__ignoreFocus += 1 event = WidgetGridSelectEvent(row=row, col=col) event.SetEventObject(self) wx.PostEvent(self, event) def resetFocus(): self.__ignoreFocus -= 1 wx.CallAfter(resetFocus) def __onChildFocus(self, ev): """If this ``WidgetGrid`` is selectable, this method is called when a widget in the grid gains focus. Ensures that the containing cell is selected. """ # We explicitly do not call ev.Skip # because otherwise the native wx code # will automatically scroll to show the # focused child, which potentially # interferes with application code. The # __selectCell method calls SetSelection # which then calls __scrollTo, which # makes sure that the selected cell is # visible. # See silliness in __selectCell if self.__ignoreFocus > 0: return gridWidget = ev.GetEventObject() row = None col = None # The event source may be a child of the # widget that was added to the grid, so we # search up the hierarchy to find the # parent that has row and column attributes while gridWidget is not None: if hasattr(gridWidget, '_wg_row'): row = gridWidget._wg_row col = gridWidget._wg_col break else: gridWidget = gridWidget.GetParent() if row is not None and col is not None: log.debug('Focus on cell (%s, %s)', row, col) self.__selectCell(row, col) def __onLeftMouseDown(self, ev): """If this ``WidgetGrid`` is selectable, this method is called whenever an left mouse down event occurs on an item in the grid. """ widget = ev.GetEventObject() row = widget._wg_row col = widget._wg_col # Make sure the panel has focus; this # will result in a call to __onChildFocus, # so tell it not to emit an event if not widget.AcceptsFocus(): self.__ignoreFocus += 1 self.SetFocusIgnoringChildren() self.__ignoreFocus -= 1 log.debug('Left mouse down on cell (%s, %s)', row, col) self.__selectCell(row, col) def __onKeyboard(self, ev): """If the :data:`WG_KEY_NAVIGATION` style is enabled, this method is called when the user pushes a key while this ``WidgetGrid`` has focus. It changes the currently selected cell, row, or column. """ ev.ResumePropagation(wx.EVENT_PROPAGATE_MAX) key = ev.GetKeyCode() up = self.__upKey down = self.__downKey left = self.__leftKey right = self.__rightKey log.debug('Keyboard event (%s)', key) # ignore modified keypresses, and all # keypresses that are not arrows if ev.HasModifiers() or (key not in (up, down, left, right)): ev.Skip() return # if up/down, but we can't select rows if key in (up, down) and self.__selectable == 'columns': ev.Skip() return # if left/right, but we can't select columns if key in (left, right) and self.__selectable == 'rows': ev.Skip() return row, col = self.__selected if key == up: row -= 1 elif key == down: row += 1 elif key == left: col -= 1 elif key == right: col += 1 log.debug('Keyboard nav on cell %s (new cell: ' '(%s, %s))', self.__selected, row, col) self.__selectCell(row, col)
[docs] def GetSelection(self): """Returns the currently selected item, as a tuple of ``(row, col)`` indices. If an entire row has been selected, the ``col`` index will be -1, and vice-versa. If nothing is selected, ``None`` is returned. """ return self.__selected
[docs] def SetSelection(self, row, col): """Select the given item. A :exc:`ValueError` is raised if the selection is invalid. :arg row: Row index of item to select. Pass in -1 to select a whole column. :arg col: Column index of item to select. Pass in -1 to select a whole row. :returns: ``True`` if the selected item was changed, ``False`` otherwise. """ if self.__selected == (row, col): return False nrows, ncols = self.GetGridSize() if self.__selectable == 'rows': if col != -1 or row < 0 or row >= nrows: raise ValueError(f'Invalid row: {row}') elif self.__selectable == 'columns': if row != -1 or col < 0 or col >= ncols: raise ValueError(f'Invalid column: {col}') elif self.__selectable == 'cells': if row < 0 or row >= nrows or col < 0 or col >= ncols: raise ValueError(f'Invalid cell: {row}, {col}') if self.__selected is not None: lsrow, lscol = self.__selected self.__selected = None self.__select(lsrow, lscol, self.__selectable, False) self.__selected = row, col self.__select(row, col, self.__selectable, True) self.__scrollTo(row, col) return True
def __scrollTo(self, row, col): """If scrolling is enabled, this method makes sure that the specified row/column is visible. """ # No scrolling if not (self.__hscroll or self.__vscroll): return if row == -1: row = 0 if col == -1: col = 0 # We're assuming that the # scroll rate is in pixels startx, starty = self .GetViewStart() sizex, sizey = self .GetClientSize() posx, posy = self.__widgets[row][col].GetPosition() widgSizex, widgSizey = self.__widgets[row][col].GetSize() # Take into account the size # of the widget in the cell sizex -= widgSizex sizey -= widgSizey # take into account the drag # panel if it is visible if self.__draggable: sizey -= self.__dragPanel.GetClientSize().GetHeight() scrollx = startx scrolly = starty # Figure out if the widget is # currently visible and, if # not, scroll so that it is. if posx < startx: scrollx = posx elif posx > startx + sizex: scrollx = posx - sizex if posy < starty: scrolly = posy elif posy > starty + sizey: scrolly = posy - sizey if scrollx != startx or scrolly != starty: self.Scroll(scrollx, scrolly) def __select(self, row, col, selectType, select=True): """Called by the :meth:`SetSelection` method. Sets the background colour of the specified row/column to the selection colour, or the default colour. :arg row: Row index. If -1, the colour of the entire column is toggled. :arg col: Column index. If -1, the colour of the entire row is toggled. :arg selectType: Either ``'rows'``, ``'columns'``, or ``'cells'``. :arg select: If ``True``, the item background colour is set to the selected colour, otherwise it is set to its default colour. """ nrows, ncols = self.GetGridSize() if row == -1 and col == -1: return if nrows == 0 or ncols == 0: return if row == -1: rows = range(nrows) cols = [col] * nrows elif col == -1: rows = [row] * ncols cols = range(ncols) else: rows = [row] cols = [col] for row, col in zip(rows, cols): if select: colour = self.__selectedColour elif row % 2: colour = self.__oddColour else: colour = self.__evenColour container = self.__widgets[ row][col] widget = self.__widgetRefs[row][col] self.__setBackgroundColour(container, colour) self.__setBackgroundColour(widget, colour) widget .Refresh() container.Refresh()
[docs] def ShowRowLabels(self, show=True): """Shows/hides the grid row labels. The :meth:`Refresh` method must be called afterwards for this method to take effect. """ self.__showRowLabels = show
[docs] def ShowColLabels(self, show=True): """Shows/hides the grid column labels. The :meth:`Refresh` method must be called afterwards for this method to take effect. """ self.__showColLabels = show
[docs] def SetRowLabel(self, row, label): """Sets a label for the specified row. Raises an :exc:`IndexError` if the row is invalid. """ if row < 0 or row >= self.__nrows: raise IndexError(f'Row {row} out of bounds ({self.__nrows})') self.__rowLabels[row][1].SetLabel(label)
[docs] def SetColLabel(self, col, label): """Sets a label for the specified column. Raises an :exc:`IndexError` if the column is invalid. """ if col < 0 or col >= self.__ncols: raise IndexError(f'Column {col} out of bounds ({self.__ncols})') self.__colLabels[col][1].SetLabel(label)
[docs] def SetRowLabels(self, labels): """Sets the label for every row. """ if len(labels) != self.__nrows: raise ValueError('Wrong number of row labels ' f'({len(labels)} != {self.__nrows})') for i, label in enumerate(labels): self.__rowLabels[i][1].SetLabel(label)
[docs] def SetColLabels(self, labels): """Sets the label for every column. """ if len(labels) != self.__ncols: raise ValueError('Wrong number of column labels ' f'({len(labels)} != {self.__ncols})') for i, label in enumerate(labels): self.__colLabels[i][1].SetLabel(label)
[docs] def GetRowLabel(self, row): """Return the label of the specified ``row``. """ return self.__rowLabels[row][1].GetLabel()
[docs] def GetColLabel(self, col): """Return the label of the specified ``column``. """ return self.__colLabels[col][1].GetLabel()
[docs] def GetRowLabels(self): """Return all row labels. """ return [self.__rowLabels[i][1].GetLabel() for i in range(self.__nrows)]
[docs] def GetColLabels(self): """Return all column labels. """ return [self.__colLabels[i][1].GetLabel() for i in range(self.__ncols)]
[docs] def ReorderColumns(self, order): """Re-orders the grid columns according to the given sequence of column indices. A call to this method must be followed by a call to :meth:`Refresh`. :arg order: Sequence of column indices (starting from 0) specifying the new column ordering. """ if list(sorted(order)) != list(range(self.__ncols)): raise ValueError('Invalid column order (ncols: ' f'{self.__ncols}): {order}') self.__colLabels = [self.__colLabels[i] for i in order] for rowi in range(self.__nrows): widgets = self.__widgets[ rowi] widgetRefs = self.__widgetRefs[rowi] self.__widgets[ rowi] = [widgets[i] for i in order] self.__widgetRefs[rowi] = [widgetRefs[i] for i in order]
def __onColumnLabelMouseDown(self, ev): """Called on mouse down events on a column label. """ ev.Skip() lbl = ev.GetEventObject() col = self.GetColumn(lbl) if col == -1 or (self.__dragLimit > -1 and col > self.__dragLimit): return self.__dragStartCol = col self.__dragCurrentCol = col self.__colLabels[col][0].SetBackgroundColour(self.__dragColour) self.__colLabels[col][1].SetBackgroundColour(self.__dragColour) # on macOS, an explicit refresh is # needed on the backing wx.Panel to # ensure that its background colour # is updated self.__colLabels[col][0].Refresh() def __getColumnDragPosition(self): """Called during a column drag/drop. Returns the current insert index of the dragged column, if it were to be dropped now. """ startcol = self.__dragStartCol atpos = wx.FindWindowAtPointer()[0] atpos = self.__getCellPanel(atpos) endcol = self.GetColumn(atpos) if endcol == -1 or startcol == endcol: return endcol # Figure out which side of the drop # column to place the dragged column # (i.e. on either its left or right # side). lenx = atpos.GetSize().GetWidth() posx = atpos.ScreenToClient(wx.GetMouseState().GetPosition()).x posx = posx / lenx endcol = int(round(endcol + posx)) # Clip to the drag limit column if # it is set if self.__dragLimit > -1: endcol = min(endcol, self.__dragLimit + 1) return endcol def __onColumnLabelMouseDrag(self, ev): """Called during a column drag. Updates the marker location on the drag panel. """ if self.__dragStartCol is None: return startcol = self.__dragStartCol lastcol = self.__dragCurrentCol currentcol = self.__getColumnDragPosition() panel = self.__dragPanel # mouse is off the grid, or # current drop position would # not move the column if currentcol == -1 or \ startcol == currentcol or \ startcol == currentcol - 1: self.__dragCurrentCol = None panel.ClearBackground() return # No change since last draw if lastcol == currentcol: return self.__dragCurrentCol = currentcol self.__dragPanel.Refresh() def __onColumnLabelMouseUp(self, ev): """Called on the mouse up event at the end of a column drag. Re-orders the grid columns. """ if self.__dragStartCol is None: return # The start column was saved in the # mousedown handler. Figure out the # column that mouseup occurred in. startcol = self.__dragStartCol endcol = self.__getColumnDragPosition() self.__dragStartCol = None self.__dragCurrentCol = None self.__dragPanel.ClearBackground() self.__dragPanel.Refresh() self.__colLabels[startcol][0].SetBackgroundColour(self.__labelColour) self.__colLabels[startcol][1].SetBackgroundColour(self.__labelColour) self.__colLabels[startcol][0].Refresh() if endcol == -1 or startcol == endcol: return # Offset the insertion index if # the starting column is before # it, as it gets removed before # being re-added if startcol < endcol: endcol = endcol - 1 # Generate a new column ordering order = list(range(self.__ncols)) order.pop(startcol) order.insert(endcol, startcol) self.ReorderColumns(order) self.Refresh() event = WidgetGridReorderEvent(order=order) event.SetEventObject(self) wx.PostEvent(self, event) def __dragPanelPaint(self, ev): """Paints the current column drop location on the drag panel. """ currentcol = self.__dragCurrentCol dc = wx.PaintDC(self.__dragPanel) if not dc.IsOk(): return if currentcol is None: return dwidth, dheight = dc.GetSize().Get() if dwidth == 0 or dheight == 0: return if currentcol == self.__ncols: szitem = self.__gridSizer.GetItem(self.__colLabels[-1][0]) xpos = szitem.GetPosition()[0] + szitem.GetSize()[0] else: szitem = self.__gridSizer.GetItem(self.__colLabels[currentcol][0]) xpos = szitem.GetPosition()[0] xpos -= 0.5 * self.__dragIcon.GetSize()[0] self.__dragCurrentCol = currentcol dc.Clear() dc.DrawBitmap(self.__dragIcon, int(xpos), 0, False)
WG_SELECTABLE_CELLS = 1 """If this style is enabled, individual cells can be selected. """ WG_SELECTABLE_ROWS = 2 """If this style is enabled, whole rows can be selected. """ WG_SELECTABLE_COLUMNS = 4 """If this style is enabled, whole columns can be selected. """ WG_KEY_NAVIGATION = 8 """If this style is enabled along with one of the ``*_SELECTABLE_*`` styles, the user may use the keyboard to navigate between cells, rows, or columns. """ WG_DRAGGABLE_COLUMNS = 16 """If this style is enabled, column names can be dragged with the mouse to re-order them. """ _WidgetGridSelectEvent, _EVT_WG_SELECT = wxevent.NewEvent() _WidgetGridReorderEvent, _EVT_WG_REORDER = wxevent.NewEvent() EVT_WG_SELECT = _EVT_WG_SELECT """Identifier for the :data:`WidgetGridSelectEvent`. """ EVT_WG_REORDER = _EVT_WG_REORDER """Identifier for the :data:`WidgetGridReorderEvent`. """ WidgetGridSelectEvent = _WidgetGridSelectEvent """Event generated when an item in a ``WidgetGrid`` is selected. A ``WidgetGridSelectEvent`` has the following attributes: - ``row`` Row index of the selected item. If -1, an entire column has been selected. - ``col`` Column index of the selected item. If -1, an entire row has been selected. """ WidgetGridReorderEvent = _WidgetGridReorderEvent """Event generated when the columns in a ``WidgetGrid`` are reordered. A ``WidgetGridReorderEvent`` has the following attributes: - ``order`` The new column order. """ TRIANGLE_ICON = b''' iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAABmJLR0QA/wD/AP+gvaeTAAAA CXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4wIFDQoeGSImZAAAACZpVFh0Q29tbWVudAAA AAAAQ3JlYXRlZCB3aXRoIEdJTVAgb24gYSBNYWOV5F9bAAAAZklEQVQY053PwQmEUAyE4U8F tw5L8KAlbksetgIrsBLRy8O9RBDxCTowhyTDH4YH+iFhyzhFRn8T2t3v1DFDTXEDJdobWouy imHBig51AGZ8MWAtTsW201xctf+gObxsYpfVFH6nP5vqKwqbBq3zAAAAAElFTkSuQmCC '''.strip().replace(b'\n', b'') """Icon used as the drop marker when columns are being re-ordered by mouse drag. """