"""
Contains classes that can be used to display a QTable that can show both
structures and text data in arbitrary cells.
Copyright Schrodinger, LLC. All rights reserved.
"""
import csv
import itertools
import warnings
from past.utils import old_div
import schrodinger.structure as struct
import schrodinger.ui.qt.structure2d as structure2d
from schrodinger.infra import canvas2d
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtGui
from schrodinger.Qt import QtWidgets
from schrodinger.Qt.QtCore import Qt
from schrodinger.structutils import smiles as smiles_mod
from schrodinger.ui import sketcher
from schrodinger.ui.qt import filedialog
from schrodinger.ui.qt import swidgets
from schrodinger.ui.qt import utils as qt_utils
from schrodinger.utils import csv_unicode
from schrodinger.utils.withnone import NO_ITEM
TIMEOUT = 200
[docs]class DataViewerTable(QtWidgets.QTableView):
[docs] def __init__(self,
model=None,
parent=None,
aspect_ratio=True,
fill='rows',
gang='both',
resizable='none',
fit_view='none',
vscrollbar='default',
hscrollbar='default',
cell_height=100,
cell_width=100,
enable_copy=True,
universal_row_gang_setting=False,
universal_column_gang_setting=False):
"""
:type model: QAbstractTableModel
:param model: The model for this table. The typical class to use with
this table is the StructureDataViewerModel class
:type parent: QWidget
:param parent: The widget that owns this table widget
:type aspect_ratio: bool
:param aspect_ratio: if True (default) keeps the cell height & width
aspect ratio locked at the ratio indicated by cell_height and
cell width. Note that True implies gang="both". If aspect_ratio
is true, fill can be "rows" OR "columns", but NOT "both".
:type fill: "rows", "columns", "both" or "none"
:param fill: The specified table feature will expand or contract to
exactly fit within the table's viewport.
- "none": means neither rows or columns will expand
- "rows": means rows will expand (default)
- "columns": means columns will expand
- "both": means both rows and columns will expand
fill cannot be "both" if aspect_ratio is True.
Setting a feature to fill will turn off the corresponding scrollbar.
:type gang: "rows", "columns", "both" or "none"
:param gang: Controls whether rows and/or columns can be independently
resized.
- "none" - rows and columns can be independently resized
- "rows" - rows are ganged so that changing the size of one row will
change all the rows
- "columns" - columns are ganged so that changing the size of one
column will change all the columns
- "both" - both rows and columns are ganged (default)
:type universal_row_gang_setting: bool
:param universal_row_gang_setting: If true, then the gang setting
applies to ALL rows and a subset of rows may not be ganged. If False
(the default), either a subset or all rows may be ganged. It is never
necessary to set this option, however setting it does allow for a slight
improvement in memory usage for very large tables. Note that this
setting applies whether the rows are all ganged or all not ganged. Note
that this setting in combination with ganging rows will prevent actions
such as hiding the row that change the row size.
:type universal_column_gang_setting: bool
:param universal_column_gang_setting: If true, then the gang setting
applies to ALL columns and a subset of columns may not be ganged.
If False (the default), either a subset or all columns may be ganged.
It is never necessary to set this option, however setting it does allow
for a slight improvement in memory usage for very large tables.
Note that this setting applies whether the columns are all ganged or all
not ganged. Note that this setting in combination with ganging columns
will prevent actions such as hiding the column that change the column
size.
:type resizable: "rows", "columns", "both" or "none"
:param resizable: the table features that can be resized by the user.
- "none" - neither rows nor columns can be resized by the user
- "rows" - rows can be resized by the user
- "columns" - columns can be resized by the user
- "both" - both rows and columns can be resized by the user (default)
Note - a feature that has fill turned on cannot be resized by the
user, and if fill is turned on and aspect_ratio is True, neither
rows nor columns can be resized by the user.
:type fit_view: "rows", "columns", "both" or "none"
:param fit_view: the table features that determine the size of the
table's viewport (the part visible to the user).
When this is turned on for a feature, the visible part of the table
resizes dynamically to exactly fit that feature. For instance, if
fit_view='rows', the visible part of the table will resize to
exactly fit all the rows any time the size of a row changes.
- "none" - neither rows nor columns determine the viewport size
- "rows" - rows determine the viewport height
- "columns" - columns determine the viewport width
- "both" - both rows and columns determine the viewport size
Note - a feature that has fill turned on cannot have viewport_fit
turned on. fill uses the size of the viewport to determine the size
of the cells, while fit_view uses the sie of the cells to
determine the size of the viewport.
Note 2 - Setting a feature to fit_view will turn off the
corresponding scrollbar.
Note 3 - User resizing of rows with both gang and fit_view='rows' or
'both' tends to be a bit wonky because the amount the row resizes
depends on the placement of the mouse relative to the table, and the
table moves as it resizes to the rows, so you tend to get very large
changes rapidly.
:type vscrollbar: "on", "off" or "default"
:param vscrollbar: Vertical scrollbar setting
- "on" - always on
- "off" - always off
- "default" - (default) on as needed
If either fit_view or fill = "rows" or "both", the vertical scrollbar is
turned off
:type hscrollbar: "on", "off" or "default"
:param hscrollbar: Horizontal scrollbar setting
- "on" - always on
- "off" - always off
- "default" - (default) on as needed
If either fit_view for fill ="columns" or "both", the horizontal
scrollbar is turned off
:type cell_width: int
:param cell_width: default width of cells in pixels
:type cell_height: int
:param cell_height: default height of cells in pixels
:type enable_copy: bool
:param enable_copy: True if data in the table can be copied to the
clipboard. Cells with structures copy the title of the structure to the
clipboard, else the string conversion of the structure to the clipboard.
"""
# Set up the table/model/delegate triumverant
QtWidgets.QTableView.__init__(self, parent)
self.setModel(model)
self.delegate = None
self.setHorizontalHeader(SHeaderView(QtCore.Qt.Horizontal, self))
self.setVerticalHeader(SHeaderView(QtCore.Qt.Vertical, self))
#: Whether the table should automatically resize in response to changes
self.auto_size = True
# gang info
self.column_is_ganged = []
self.row_is_ganged = []
self._universal_row_gang = universal_row_gang_setting
self._universal_column_gang = universal_column_gang_setting
self.setGangPolicy(gang, resize=False, policy_check=False)
# Set up the sizing/resizing parameters
self.setAspectRatioPolicy(aspect_ratio,
resize=False,
policy_check=False)
# fill info
self.setFillPolicy(fill,
resize=False,
policy_check=False,
enforce_scroll_bars=False)
# user resize info
self.setResizablePolicy(resizable, resize=False, policy_check=False)
# fit_viewport info
self.setFitViewPolicy(fit_view,
resize=False,
policy_check=False,
enforce_scroll_bars=False)
self.checkSizingPolicies()
# default sizes
self.default_cell_size = QtCore.QSize(cell_width, cell_height)
try:
self._default_ratio = old_div(cell_width, float(cell_height))
except ArithmeticError:
self._default_ratio = 1.0
self.tool_tip_size = (200, 200)
# Scrollbar setup
self.setHorizontalScrollBar(EmittingScrollBar(QtCore.Qt.Horizontal))
self.setVerticalScrollBar(EmittingScrollBar(QtCore.Qt.Vertical))
self.verticalScrollBar().shown.connect(self.scrollBarChanged)
self.horizontalScrollBar().shown.connect(self.scrollBarChanged)
self.verticalScrollBar().hidden.connect(self.scrollBarChanged)
self.horizontalScrollBar().hidden.connect(self.scrollBarChanged)
self._is_scrolling = False
self._is_actively_scrolling = False
self.verticalScrollBar().sliderPressed.connect(self._sliderPressed)
self.verticalScrollBar().sliderReleased.connect(self._sliderReleased)
self.verticalScrollBar().sliderMoved.connect(self._sliderMoved)
self.draw_timer = QtCore.QTimer()
sb_policies = {
'default': QtCore.Qt.ScrollBarAsNeeded,
'on': QtCore.Qt.ScrollBarAlwaysOn,
'off': QtCore.Qt.ScrollBarAlwaysOff
}
if self.fill_rows or self.fit_view_rows:
self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
else:
self.setVerticalScrollBarPolicy(
sb_policies.get(vscrollbar.lower(),
QtCore.Qt.ScrollBarAsNeeded))
if self.fill_columns or self.fit_view_columns:
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
else:
self.setHorizontalScrollBarPolicy(
sb_policies.get(hscrollbar.lower(),
QtCore.Qt.ScrollBarAsNeeded))
# Monitor when the column width and row height is adjusted
self.horizontalHeader().sectionResized.connect(self.columnResized)
self.verticalHeader().sectionResized.connect(self.rowResized)
self.copy_enabled = enable_copy
# When self.lock_aspect_ratio is True, either row or column is set to
# fill, and the scrollbars are set to default (as needed) we can run
# into infinite loops when the viewport is nearly identical to the
# table size. This happens (if fill=row) because the row heights
# adjust to the viewport size, then the columns adjust to keep the
# aspect ratio correct, which causes a change in state of the scrollbar
# (either hidden or not), which changes the viewport size, which causes
# the rows to adjust in size, which causes the columns to adjust to keep
# the aspect ratio correct, which causes a change in state of the
# scrollbar (either hidden or not) ... and so on. To avoid this, we use
# the following:
# self.avoid_loops: True if we need to watch out for this. We don't
# turn this on for the first sizing of the table, as that seems to
# cause some scrollbar toggling itself.
# self.stop_loop: True if we've detected a loop and need to stop it.
# We don't stop a loop immediately, because resizing once more
# gets the table to fit properly in the current viewport
# self.loop_sign: The pattern of scrollbar visibility that signals a
# loop
# self.toggle_length: The number of turns we let the scrollbar toggle
# before we decide that we're in a loop
# self.vbar_states = history of the vertical scrollbar visibility
# self.hbar_states = history of the horizontal scrollbar visibility
self.doing_row_resize = self.doing_col_resize = False
self.doing_fix_resize = False
self.row_remainder = self.col_remainder = 0
self.stop_loop = False
self.avoid_loops = False
self.loop_sign = [False, True, False]
self.toggle_length = len(self.loop_sign)
self.initiateCellSizes()
self.fixSize()
self.avoid_loops = self.lock_aspect_ratio and (self.fill_rows or
self.fill_columns)
self.vbar_states = []
self.hbar_states = []
self.model().layoutChanged.connect(self.fixSize)
self.setMouseTracking(True)
self.mouse_row = -1
self.mouse_column = -1
self.model().columnsInserted.connect(self.columnsInserted)
self.model().columnsMoved.connect(self.columnsMoved)
self.model().columnsRemoved.connect(self.columnsRemoved)
self.event_filter = ToolTipFilter(self)
self.viewport().installEventFilter(self.event_filter)
[docs] def copyImageToClipboard(self):
"""
Copy an image of the table to the clipboard
"""
size = self.size()
# Don't copy the scrollbars
vsb = self.verticalScrollBar()
if vsb.isVisible():
size.setWidth(size.width() - vsb.size().width())
hsb = self.horizontalScrollBar()
if hsb.isVisible():
size.setHeight(size.height() - hsb.size().height())
my_image = QtGui.QImage(size, QtGui.QImage.Format_RGB32)
origin = QtCore.QPoint(0, 0)
region = QtGui.QRegion(0, 0, size.width(), size.height())
self.render(my_image, origin, region)
QtWidgets.QApplication.clipboard().setImage(my_image)
[docs] def setFitViewPolicy(self,
fit_view,
resize=True,
policy_check=True,
enforce_scroll_bars=True):
"""
Sets whether the viewport should be resized to the rows/columns or not
:type fit_view: "rows", "columns", "both" or "none"
:param fit_view: the table features that determine the size of the
table's viewport (the part visible to the user).
When this is turned on for a feature, the visible part of the table
resizes dynamically to exactly fit that feature. For instance, if
fit_view='rows', the visible part of the table will resize to
exactly fit all the rows any time the size of a row changes.
- "none" - neither rows nor columns determine the viewport size
- "rows" - rows determine the viewport height
- "columns" - columns determine the viewport width
- "both" - both rows and columns determine the viewport size
Note - a feature that has fill turned on cannot have viewport_fit
turned on. fill uses the size of the viewport to determine the size
of the cells, while fit_view uses the sie of the cells to
determine the size of the viewport.
Note 2 - Setting a feature to fit_view will turn off the
corresponding scrollbar.
Note 3 - User resizing of rows with both gang and fit_view='rows' or
'both' tends to be a bit wonky because the amount the row resizes
depends on the placement of the mouse relative to the table, and the
table moves as it resizes to the rows, so you tend to get very large
changes rapidly.
:type resize: bool
:param resize: Whether to resize the table
:type policy_check: bool
:param policy_check: Whether to check for conflicting sizing policies.
:type enforce_scroll_bars: bool
:param enforce_scroll_bars: Whether to enforce the default scrollbar
policy for fit view settings
"""
fit_view = fit_view.lower()
self.fit_view_rows = fit_view in ['rows', 'both']
self.fit_view_columns = fit_view in ['columns', 'both']
if enforce_scroll_bars:
self.enforceScrollBarPolicies()
if policy_check:
self.checkSizingPolicies()
if resize:
self.fixSize()
[docs] def setFillPolicy(self,
fill,
resize=True,
policy_check=True,
enforce_scroll_bars=True):
"""
Sets whether columns or rows should fill the viewport or not
:type fill: "rows", "columns", "both" or "none"
:param fill: The specified table feature will expand or contract to
exactly fit within the table's viewport. "both" means both rows and
columns will expand, and "none" means neither will. fill cannot be
"both" if aspect_ratio is True. Setting a feature to fill will turn
off the corresponding scrollbar.
:type resize: bool
:param resize: Whether to resize the table
:type policy_check: bool
:param policy_check: Whether to check for conflicting sizing policies.
:type enforce_scroll_bars: bool
:param enforce_scroll_bars: Whether to enforce the default scrollbar
policy for fill settings
"""
fill = fill.lower()
self.fill_rows = fill in ['rows', 'both']
self.fill_columns = fill in ['columns', 'both']
self.avoid_loops = self.lock_aspect_ratio and (self.fill_rows or
self.fill_columns)
if enforce_scroll_bars:
self.enforceScrollBarPolicies()
if policy_check:
self.checkSizingPolicies()
if resize:
self.fixSize()
[docs] def setGangPolicy(self, gang, resize=True, policy_check=True):
"""
Sets whether the rows or columns are ganged or not
:type gang: "rows", "columns", "both" or "none"
:param gang: Controls whether rows and/or columns can be independently
resized.
- "none": rows and columns can be independently resized
- "rows": rows are ganged so that changing the size of one row will
change all the rows
- "columns": columns are ganged so that changing the size of one
column will change all the columns
- "both" - both rows and columns are ganged (default)
:type resize: bool
:param resize: Whether to resize the table
:type policy_check: bool
:param policy_check: Whether to check for conflicting sizing policies.
Not used for this routine, but kept for consistency with other sizing
policy routines
"""
gang = gang.lower()
self.gang_rows = gang in ['rows', 'both']
self.gang_columns = gang in ['columns', 'both']
if not self._universal_row_gang:
self.row_is_ganged = [self.gang_rows] * self.model().rowCount()
if not self._universal_column_gang:
self.column_is_ganged = [self.gang_columns
] * self.model().columnCount()
if resize:
self.fixSize()
[docs] def isRowGanged(self, row):
"""
Returns True if row is ganged, False if not
:type row: int
:param row: the row to check for ganging
"""
if self.gang_rows:
if self._universal_row_gang:
return True
else:
try:
return self.row_is_ganged[row]
except IndexError:
return False
else:
return False
[docs] def isColumnGanged(self, column):
"""
Returns True if column is ganged, False if not
:type column: int
:param column: the column to check for ganging
"""
if self.gang_columns:
if self._universal_column_gang:
return True
else:
try:
return self.column_is_ganged[column]
except IndexError:
return False
else:
return False
[docs] def setRowIsGanged(self, row, is_ganged):
"""
Sets a flag on row to indicate whether its size is ganged with other
rows or not. Note that this has no effect if rows are not already
ganged. This function should not be called if
universal_row_gang_settings was set to True.
:type row: int
:param row: the row number this applies to
:type is_ganged: bool
:param is_ganged: True if the row should be ganged, False if not
"""
if self._universal_row_gang:
raise AttributeError('Table was created with universal settings' +\
', individual rows may not be set.')
self.row_is_ganged[row] = is_ganged
[docs] def setColumnIsGanged(self, column, is_ganged):
"""
Sets a flag on column to indicate whether its size is ganged with other
columns or not. Note that this has no effect if columns are not already
ganged. This function should not be called if
universal_column_gang_setting was set to True.
:type column: int
:param column: the column number this applies to
:type is_ganged: bool
:param is_ganged: True if the column should be ganged, False if not
"""
if self._universal_column_gang:
raise AttributeError('Table was created with universal settings' +\
', individual columns may not be set.')
self.column_is_ganged[column] = is_ganged
[docs] def setAspectRatioPolicy(self, fixed, resize=True, policy_check=True):
"""
Sets whether the cell aspect ratio is locked or not
:type fixed: bool
:param fixed: If True, cell aspect ratio is locked. If False, it is not
:type resize: bool
:param resize: Whether to resize the table
:type policy_check: bool
:param policy_check: Whether to check for conflicting sizing policies.
"""
self.lock_aspect_ratio = fixed
if fixed:
self.setGangPolicy('both', resize=False, policy_check=False)
try:
self.avoid_loops = self.fill_rows or self.fill_columns
except AttributeError:
pass
if policy_check:
self.checkSizingPolicies()
if resize:
self.fixSize()
[docs] def setResizablePolicy(self, resizable, resize=True, policy_check=True):
"""
Sets whether the rows/columns can be resized by the user
:type resizable: "rows", "columns", "both" or "none"
:param resizable: the table features that can be resized by the user.
- "none" - neither rows nor columns can be resized by the user
- "rows" - rows can be resized by the user
- "columns" - columns can be resized by the user
- "both" - both rows and columns can be resized by the user (default)
Note - a feature that has fill turned on cannot be resized by the
user, and if fill is turned on and aspect_ratio is True, neither
rows nor columns can be resized by the user.
:type resize: bool
:param resize: Whether to resize the table
:type policy_check: bool
:param policy_check: Whether to check for conflicting sizing policies.
"""
# user resize info
resizable = resizable.lower()
self.resizable_rows = resizable in ['rows', 'both']
self.resizable_columns = resizable in ['columns', 'both']
# Give the headers the proper user resize settings - the default is
# user-resizable, so we only set them to fixed if need be.
if not self.resizable_columns:
self.horizontalHeader().setSectionResizeMode(
QtWidgets.QHeaderView.Fixed)
else:
self.horizontalHeader().setSectionResizeMode(
QtWidgets.QHeaderView.Interactive)
if not self.resizable_rows:
self.verticalHeader().setSectionResizeMode(
QtWidgets.QHeaderView.Fixed)
else:
self.verticalHeader().setSectionResizeMode(
QtWidgets.QHeaderView.Interactive)
if policy_check:
self.checkSizingPolicies()
if resize:
self.fixSize()
[docs] def setSortingEnabled(self, sorting):
"""
Sets sorting enabled
:type sorting: bool
:param sorting: If True, sorting is enabled
"""
if sorting:
self.horizontalHeader().sectionClicked.connect(self.headerClicked)
self.horizontalHeader().setSectionsClickable(True)
self.horizontalHeader().setSortIndicatorShown(True)
self.horizontalHeader().setSortIndicator(-1, 0)
else:
self.horizontalHeader().sectionClicked.disconnect(
self.headerClicked)
self.horizontalHeader().setSectionsClickable(False)
self.horizontalHeader().setSortIndicatorShown(False)
self.horizontalHeader().setSortIndicator(-1, 0)
[docs] def checkSizingPolicies(self):
"""
Raises a ValueError if we have conflicting sizing policies
"""
if all([self.lock_aspect_ratio, self.fill_rows, self.fill_columns]):
raise ValueError("fill cannot be 'both' if aspect_ratio is True")
# Look for fill/resizable policy conflicts
if (self.lock_aspect_ratio and (self.fill_columns or self.fill_rows)) \
and (self.resizable_rows or self.resizable_columns):
raise ValueError("rows or columns can not be resized if " \
"aspect_ratio is True and rows or columns " \
"are set to fill")
elif self.fill_columns and self.resizable_columns:
raise ValueError("columns can not be resized if they are set to " \
"fill")
elif self.fill_rows and self.resizable_rows:
raise ValueError("rows can not be resized if they are set to fill")
# Make sure we don't have conflicting fill/fit_view settings
if self.fit_view_rows and self.fill_rows:
raise ValueError("Both fill and fit_view cannot be set for rows")
elif self.fit_view_columns and self.fill_columns:
raise ValueError("Both fill and fit_view cannot be set for columns")
[docs] def initiateCellSizes(self, rows=True, columns=True):
"""
Sets all the cells to their intial default size
:type rows: bool
:param rows: whether to set the rows to their default height
:type columns: bool
:param columns: whether to set the columns to their default width
"""
self.doing_fix_resize = True
if rows:
for row in range(self.model().rowCount()):
self.setRowHeight(row, self.default_cell_size.height())
if columns:
for column in range(self.model().columnCount()):
self.setColumnWidth(column, self.default_cell_size.width())
self.doing_fix_resize = False
[docs] def rowsInserted(self, index, start, end):
"""
Slot that receives a signal when rows are inserted in the
model. Make sure our row_is_ganged list stays current.
:type index: QModelIndex
:param index: unused
:type start: int
:param start: The starting index of the rows inserted
:type end: int
:param end: The ending index of the rows inserted (inclusive)
"""
QtWidgets.QTableView.rowsInserted(self, index, start, end)
if not self._universal_row_gang:
try:
for ind in range(start, end + 1):
self.row_is_ganged.insert(start, True)
except IndexError:
pass
self.fixSize()
[docs] def rowsMoved(self, index1, start, end, index2, place):
"""
Slot that receives a signal when rows are moved in the
model. Make sure our row_is_ganged list stays current.
:type index1: QModelIndex
:param index1: unused
:type start: int
:param start: The starting index of the rows moved
:type end: int
:param end: The ending index of the rows moved (inclusive)
:type index2: QModelIndex
:param index2: unused
:type place: int
:param place: The new starting index of the moved rows
"""
if not self._universal_row_gang:
try:
myvals = []
for ind in range(end, start - 1, -1):
myvals.append(self.row_is_ganged.pop(ind))
for val in myvals:
self.row_is_ganged.insert(place, val)
except:
pass
[docs] def rowsRemoved(self, index, start, end):
"""
Slot that receives a signal when rows are removed in the
model. Make sure our row_is_ganged list stays current.
:type index: QModelIndex
:param index: unused
:type start: int
:param start: The starting index of the rows removed
:type end: int
:param end: The ending index of the rows removed (inclusive)
"""
# This slot gets called TWICE by model.endRemoveRows(), once by
# endRemoveRows and once by its call to layoutChanged, so only
# remove the list items for the first call.
if not self._universal_row_gang:
if self.row_is_ganged and \
len(self.row_is_ganged) != self.model().rowCount():
try:
for ind in range(start, end + 1):
del self.row_is_ganged[start]
except IndexError:
pass
self.fixSize()
[docs] def columnsInserted(self, index, start, end):
"""
Slot that receives a signal when columns are inserted in the
model. Make sure our column_is_ganged list stays current.
:type index: QModelIndex
:param index: unused
:type start: int
:param start: The starting index of the columns inserted
:type end: int
:param end: The ending index of the columns inserted (inclusive)
"""
if not self._universal_column_gang:
try:
for ind in range(start, end + 1):
self.column_is_ganged.insert(start, True)
except IndexError:
pass
self.fixSize()
[docs] def columnsMoved(self, index1, start, end, index2, place):
"""
Slot that receives a signal when columns are moved in the
model. Make sure our column_is_ganged list stays current.
:type index1: QModelIndex
:param index1: unused
:type start: int
:param start: The starting index of the columns moved
:type end: int
:param end: The ending index of the columns moved (inclusive)
:type index2: QModelIndex
:param index2: unused
:type place: int
:param place: The new starting index of the moved columns
"""
if not self._universal_column_gang:
try:
myvals = []
for ind in range(end, start - 1, -1):
myvals.append(self.column_is_ganged.pop(ind))
for val in myvals:
self.column_is_ganged.insert(place, val)
except:
pass
[docs] def columnsRemoved(self, index, start, end):
"""
Slot that receives a signal when columns are removed in the
model. Make sure our column_is_ganged list stays current.
:type index: QModelIndex
:param index: unused
:type start: int
:param start: The starting index of the columns removed
:type end: int
:param end: The ending index of the columns removed (inclusive)
"""
# This slot gets called TWICE by model.endRemoveColumns(), once by
# endRemoveColumns and once by its call to layoutChanged, so only
# remove the list items for the first call.
if not self._universal_column_gang:
if self.column_is_ganged and \
len(self.column_is_ganged) != self.model().columnCount():
try:
for ind in range(start, end + 1):
del self.column_is_ganged[start]
except IndexError:
pass
self.fixSize()
[docs] def columnCountChanged(self, old_count, new_count):
"""
Called when a column is added or deleted, and sets the columns to their
initial size.
:type old_count: int
:param old_count: the old number of columns
:type new_count: int
:param new_count: the num number of columns
"""
QtWidgets.QTableView.columnCountChanged(self, old_count, new_count)
self.initiateCellSizes(rows=False)
self.fixSize()
[docs] def rowCountChanged(self, old_count, new_count):
"""
Called when a row is added or deleted, and sets the rows to their
initial size.
:type old_count: int
:param old_count: the old number of rows
:type new_count: int
:param new_count: the num number of rows
"""
QtWidgets.QTableView.rowCountChanged(self, old_count, new_count)
self.initiateCellSizes(columns=False)
self.fixSize()
[docs] def keyPressEvent(self, event):
"""
:type event: QKeyEvent
:param event: The keypress event
Currently only does anything for Ctrl-C (copy to clipboard)
"""
if self.copy_enabled and event.matches(QtGui.QKeySequence.Copy):
# Iterate over all the selected cells, place a tab between cells in
# a row and a return between rows - then put this in the clipboard.
# This way the data will paste into Excel and other programs just
# like it is in the table.
def index_key(index):
return index.row(), index.column()
# Sort the data so it goes left to right across each row in
# descending rows.
indexes = self.selectedIndexes()
indexes.sort(key=index_key)
mydata = ""
current_row = None
for ind in indexes:
# For new rows, strip any trailing tab and add a return
if ind.row() != current_row:
if current_row is not None:
# Remove any trailing tab and add a return. Note: rstrip
# will remove multiple tabs if the cells are empty. We
# don't want that.
if mydata.endswith('\t'):
mydata = mydata[:-1]
mydata = mydata + '\n'
current_row = ind.row()
value = self.model().data(ind)
if value is None:
value = ""
elif self.delegate.isStructure(value):
if value.title:
value = value.title
try:
mydata = mydata + str(value) + '\t'
except ValueError:
mydata = mydata + '\t'
# Remove the last tab so the cursor stays in the last cell. Can't
# use rstrip because we only want to remove at most 1.
if mydata.endswith('\t'):
mydata = mydata[:-1]
# Put the data in to the clipboard
QtWidgets.QApplication.clipboard().setText(mydata)
else:
# This is the proper way to handle all other non-matching events
QtWidgets.QTableView.keyPressEvent(self, event)
[docs] def setItemDelegate(self, delegate):
"""
Set the delegate for this table
:type delegate: StructureDataViewerDelegate
:param delegate: delegate that draws the data for this table
"""
self.delegate = delegate
QtWidgets.QTableView.setItemDelegate(self, delegate)
[docs] def setDelegate(self, delegate):
"""
Set the delegate for this table
:type delegate: StructureDataViewerDelegate
:param delegate: delegate that draws the data for this table
"""
self.delegate = delegate
[docs] def rowResized(self, row, old_size, new_size, manual=False):
"""
Resizes any cells that need to be resized when a row size is changed
:type row: int
:param row: the row number that changed
:type old_size: int
:param old_size: the old size of the row
:type new_size: int
:param new_size: the new size of the row
:type manual: bool
:param manual: True if this a manual resize being called by a
script. If True, this forces the method to execute even if AutoResizing
has been set to false.
"""
if not self.auto_size and not manual:
return
if (not self.doing_row_resize and not self.doing_fix_resize):
# Need to set the doing_resize flag to avoid recursive calls to this
# routine, as it gets called any time a row size is changed by
# the user or by this routine.
self.doing_row_resize = True
if self.isRowGanged(row):
# We want to keep all rows the same size
last_row = self.model().rowCount() - 1
# Find the last visible row, as we'll adjust the size of that
# one slightly to fit things properly
last_visible_row = 0
if new_size != 0:
for myrow in range(last_row, 0, -1):
if not self.isRowHidden(myrow):
last_visible_row = myrow
break
for arow in range(last_row + 1):
if arow != row and self.isRowGanged(row):
if arow != last_visible_row:
self.setRowHeight(arow, new_size)
else:
self.setRowHeight(arow,
new_size + self.row_remainder)
if self.lock_aspect_ratio:
# Change the columns to have the same width as the row height
self.resizeColumnsToContents()
self.doing_row_resize = False
if self.fit_view_rows:
# Fix the viewport size to the total table height
self.fitToRows()
[docs] def columnResized(self, column, old_size, new_size, manual=False):
"""
Resizes any cells that need to be resized when a column size is changed
:type column: int
:param column: the column number that changed
:type old_size: int
:param old_size: the old size of the column
:type new_size: int
:param new_size: the new size of the column
:type manual: bool
:param manual: True if this a manual resize being called by a
script. If True, this forces the method to execute even if AutoResizing
has been set to false.
"""
if not self.auto_size and not manual:
return
if not self.doing_col_resize and not self.doing_fix_resize:
# Need to set the doing_resize flag to avoid recursive calls to this
# routine, as it gets called any time a column size is changed by
# the user or by this routine.
self.doing_col_resize = True
if self.isColumnGanged(column):
# We want to keep all columns the same size
last_column = self.model().columnCount() - 1
# Find the last visible column, as we'll adjust the size of that
# one slightly to fit things properly
last_visible_column = 0
# Make sure we aren't ganging all columns to be zero sized
# isColumnHidden is not usually set here if new_size is zero
if new_size != 0:
for col in range(last_column, 0, -1):
if not self.isColumnHidden(col):
last_visible_column = col
break
for col in range(last_column + 1):
if col != column and self.isColumnGanged(col):
if col != last_visible_column:
self.setColumnWidth(col, new_size)
else:
self.setColumnWidth(
col, new_size + self.col_remainder)
if self.lock_aspect_ratio:
# Change the columns to have the same width as the column width
self.resizeRowsToContents()
if self.fit_view_columns:
# Fix the viewport size to the total table width
self.fitToColumns()
# Delay reset of `self.doing_col_resize` to avoid recursive loops
QtCore.QTimer.singleShot(0, self.reEnableColumnResize)
[docs] def reEnableColumnResize(self):
self.doing_col_resize = False
[docs] def fitToRows(self):
"""
Fits the viewport to exactly show all the rows
"""
height = self.totalRowHeight()
height = height + self.horizontalHeader().height()
if self.horizontalScrollBar().isVisible():
height = height + self.horizontalScrollBar().height()
self.setFixedHeight(height)
[docs] def fitToColumns(self):
"""
Fits the viewport to exactly show all the columns
"""
width = self.totalColumnWidth()
width = width + self.verticalHeader().width()
if self.verticalScrollBar().isVisible():
width = width + self.verticalScrollBar().width()
self.setFixedWidth(width)
[docs] def totalRowHeight(self):
"""
The sum of the heights of all the rows
:rtype: int
:return: the sum of all the row heights
"""
height = 0
for row in range(self.model().rowCount()):
height = height + self.rowHeight(row)
return height
[docs] def averageGangedRowHeight(self):
"""
This routine has been changed to return the MOST COMMON non-zero height
of all the ganged rows
:rtype: int
:return: the most common integer average row height, or the default
height if there are no non-zero ganged rows
"""
heights = {}
for row in range(self.model().rowCount()):
if self.isRowGanged(row):
height = self.rowHeight(row)
if height > 0:
heights[height] = heights.get(height, 0) + 1
most_common = self.default_cell_size.height()
max_num = 0
for aheight, number in heights.items():
if number > max_num:
max_num = number
most_common = aheight
return most_common
[docs] def totalColumnWidth(self):
"""
The sum of the widths of all the columns
:rtype: int
:return: the sum of all the column widths
"""
width = 0
for col in range(self.model().columnCount()):
width = width + self.columnWidth(col)
return width
[docs] def averageGangedColumnWidth(self):
"""
This routine has been changed to return the MOST COMMON non-zero width
of all the ganged columns
:rtype: int
:return: the most common integer average column width, or the default
width if there are no non-zero ganged columns
"""
widths = {}
for column in range(self.model().columnCount()):
if self.isColumnGanged(column):
width = self.columnWidth(column)
if width > 0:
widths[width] = widths.get(width, 0) + 1
most_common = self.default_cell_size.width()
max_num = 0
for awidth, number in widths.items():
if number > max_num:
max_num = number
most_common = awidth
return most_common
[docs] def setAutoSizing(self, state):
"""
Turn the auto resizing of rows and column on or off. Normally this
should be on (default), however, if multiple changes will be made and no
resizing needs to be done until all changes are complete, then setting
AutoSizing to False will save considerable time for large tables. Be
sure to setAutoSizing(True) when finished so the table can adjust to the
changes made.
:type state: bool
:param state: True if rows and columns should be resized automatically
in response to table changes, False if not
"""
self.auto_size = state
if state:
self.fixSize()
[docs] def fixSize(self, manual=False):
"""
Resize everything as the user and program desires
:type manual: bool
:param manual: True if this a manual resize being called by a
script. If True, this forces the method to execute even if AutoResizing
has been set to false.
"""
if not self.auto_size and not manual:
return
self.doing_fix_resize = True
# Make sure to do a resize the very first time the table shows
self.resizeRowsToContents()
self.resizeColumnsToContents()
self.doing_fix_resize = False
[docs] def fitCellsToData(self, include_column_header=False):
"""
Resizes rows and columns to fit the data they contain using the sizeHint
from the delegate
:type include_column_header: bool
:param include_column_header: True if the column header should be
included when figuring the column width, False if not
"""
self.fitColumnsToData(include_header=include_column_header)
self.fitRowsToData()
[docs] def fitRowsToData(self):
"""
Resizes all the rows to fit the data they contain using the sizeHint
from the delegate.
"""
for row in range(self.model().rowCount()):
self.fitRowToData(row)
[docs] def fitRowToData(self, row):
"""
Resizes row to the largest sizeHint of any of its cells. Uses the
sizeHint of the delegate.
:type row: int
:param row: the row of interest
"""
hint = 0
if self.verticalHeader().isVisible():
buffer = 4
else:
buffer = 0
options = qt_utils.get_view_item_options(self)
for column in range(self.model().columnCount()):
index = self.model().index(row, column)
hint = max(
hint,
self.itemDelegate().sizeHint(options, index).height() + buffer)
self.setRowHeight(row, hint)
[docs] def fitColumnsToData(self, include_header=False):
"""
Resizes all the columns to fit the data they contain using the sizeHint
from the delegate.
:type include_header: bool
:param include_header: True if the header should be included in the
calculation of column width, False if not
"""
for column in range(self.model().columnCount()):
self.fitColumnToData(column, include_header=include_header)
[docs] def fitColumnToData(self, column, include_header=False):
"""
Resizes column to the largest sizeHint of any of its cells. Uses the
sizeHint of the delegate.
:type column: int
:param column: the column of interest
:type include_header: bool
:param include_header: True if the header should be included in the
calculation of column width, False if not
"""
hint = 0
options = qt_utils.get_view_item_options(self)
for row in range(self.model().rowCount()):
index = self.model().index(row, column)
shint = self.itemDelegate().sizeHint(options,
index,
actual_column_width=True)
hint = max(hint, shint.width())
if include_header:
shint = self.horizontalHeader().sectionSizeHint(column)
hint = max(hint, shint)
self.setColumnWidth(column, hint)
[docs] def resizeRowsToContents(self):
"""
Resizes all the rows according to all the resize properties
"""
for row in range(self.model().rowCount()):
self.setRowHeight(row, self.sizeHintForRow(row))
if self.fit_view_rows:
# Fix the viewport size to the total row height
self.fitToRows()
[docs] def resizeColumnsToContents(self):
"""
Resizes all the columns according to all the resize properties
"""
for col in range(self.model().columnCount()):
self.setColumnWidth(col, self.sizeHintForColumn(col))
if self.fit_view_columns:
# Fix the viewport size to the total row height
self.fitToColumns()
[docs] def resizeEvent(self, event):
"""
Called when the viewport changes size. Changes the size of any cells
that need to be changed in response to this - typically because fill has
been set on rows or columns.
:type event: QEvent
:param event: the event that occured
"""
# Infinite loop detection and elimination. See comments in __init__ as
# to what is going on here.
if self.stop_loop:
# A loop was detected last time through, stop it by not resizing
self.stop_loop = False
return
if self.avoid_loops:
# This is where we check to see if we are in a loop. Keep a history
# of the scrollbar visibilities. If they are toggling on and off,
# then we are in a loop
self.hbar_states.insert(0, self.horizontalScrollBar().isVisible())
self.vbar_states.insert(0, self.verticalScrollBar().isVisible())
try:
# Only save the important states so the lists don't grow large
self.hbar_states = self.hbar_states[:self.toggle_length]
self.vbar_states = self.vbar_states[:self.toggle_length]
except IndexError:
# Happens if the lists are small enough already
pass
if (self.hbar_states == self.loop_sign or
self.vbar_states == self.loop_sign):
# Found a loop, reset the history and set the stop loop flag.
self.hbar_states = []
self.vbar_states = []
self.stop_loop = True
if event.type() == 14 and event.oldSize() == QtCore.QSize(-1, -1):
# Make sure to do a resize the very first time the table shows
self.fixSize()
QtWidgets.QTableView.resizeEvent(self, event)
num_cols = self.model().columnCount()
num_rows = self.model().rowCount()
if num_cols <= 0:
return
# Fit the table into the viewport
if self.fill_columns:
# Calculate the column height needed to perfectly fill the viewport
width = self.sizeHintForColumn(0)
if self.gang_columns:
# Only set the first column, since that resize will trigger all
# the other columns.
self.setColumnWidth(0, width)
# Also have to set the last column, in case the sizeHint hasn't
# changed but self.col_remainder has.
self.setColumnWidth(num_cols - 1, width + self.col_remainder)
else:
# Set all the columns to this width
for col in range(num_cols - 1):
self.setColumnWidth(col, width)
self.setColumnWidth(num_cols - 1, width + self.col_remainder)
# Change the row heights if need be
if self.lock_aspect_ratio:
self.resizeRowsToContents()
if self.fill_rows:
# Calculate the row height needed to perfectly fill the viewport
height = self.sizeHintForRow(0)
# Set all the rows to this height
if self.gang_rows:
# Only set the first row, since that resize will trigger all
# the other rows to resize.
self.setRowHeight(0, height)
# Also have to set the last row, in case the sizeHint hasn't
# changed but self.row_remainder has.
self.setRowHeight(num_rows - 1, height + self.row_remainder)
else:
for row in range(num_rows - 1):
self.setRowHeight(row, height)
self.setRowHeight(num_rows - 1, height + self.row_remainder)
# Change the column widths if need be
if self.lock_aspect_ratio:
self.resizeColumnsToContents()
[docs] def sizeHintForColumn(self, column):
"""
This method gives the size hints for columns - its existance means that
the resizeColumnsToContents() method uses this hint rather than hints
from the delegate.
:type column: int
:param column: the column we want a size hint for
:rtype: int
:return: the size hint for column
"""
if self.lock_aspect_ratio and not self.fill_columns and \
self.isColumnGanged(column):
hint = self.averageGangedRowHeight() * self._default_ratio
elif self.fill_columns:
table_width = self.viewport().width()
hint, self.col_remainder = divmod(table_width,
self.model().columnCount())
if column == self.model().columnCount() - 1:
hint = hint + self.col_remainder
else:
# To use the delegate size hint for the cell, call delegate.sizeHint
# here (and implement the sizeHint method for the delegate).
hint = self.columnWidth(column)
return hint
[docs] def sizeHintForRow(self, row):
"""
This method gives the size hints for rows - its existance means that
the resizeRowsToContents() method uses this hint rather than hints
from the delegate.
:type row: int
:param row: the row we want a size hint for
:rtype: int
:return: the size hint for row
"""
if self.lock_aspect_ratio and not self.fill_rows and \
self.isRowGanged(row):
hint = old_div(self.averageGangedColumnWidth(), self._default_ratio)
elif self.fill_rows:
table_height = self.viewport().height()
hint, self.row_remainder = divmod(table_height,
self.model().rowCount())
if row == self.model().rowCount() - 1:
hint = hint + self.row_remainder
else:
# To use the delegate size hint for the cell, call delegate.sizeHint
# here (and implement the sizeHint method for the delegate).
hint = self.rowHeight(row)
return hint
def _sliderReleased(self):
self._redrawTable()
self.draw_timer.timeout.disconnect(self._timerFired)
self.draw_timer.stop()
self._is_scrolling = False
self._is_actively_scrolling = False
def _sliderPressed(self):
self._is_scrolling = True
self._is_actively_scrolling = True
# Fire one second since scroll bar is no longer moving:
self.draw_timer.setSingleShot(True)
self.draw_timer.start(TIMEOUT)
self.draw_timer.timeout.connect(self._timerFired)
def _sliderMoved(self, int):
# Keep re-setting the timer while the scroll bar is moved:
self._is_actively_scrolling = True
self.draw_timer.start(TIMEOUT)
def _timerFired(self):
self.itemDelegate().generate_one_structure = True
self._is_actively_scrolling = False
self._redrawTable()
def _redrawTable(self):
# Redraw the visible cells:
self.viewport().update()
[docs]class ViewerModel(QtCore.QAbstractTableModel):
sizeChanged = QtCore.pyqtSignal()
[docs] def __init__(self, rowcount=0, columncount=4, unique=False):
QtCore.QAbstractTableModel.__init__(self)
self._row_count = rowcount
self._column_count = columncount
self._structs = []
self._unique = unique
if self._unique:
self._structSmiles = set()
self._smiles_gen = \
smiles_mod.SmilesGenerator( \
stereo=smiles_mod.STEREO_FROM_ANNOTATION_AND_GEOM)
[docs] def structCount(self):
return len(self._structs)
[docs] def appendStruct(self, struct):
if self._unique:
if not self._addSmiles(struct):
return False
self._structs.append(struct)
self.sizeChanged.emit()
return True
def _addSmiles(self, struct):
"""
If structure already exists, then the smiles value is not added.
:return: Return whether the structure was successfully added or not
added if the structure already exists in model.
:rtype: bool
"""
smiles = self._smiles_gen.getSmiles(struct)
if smiles in self._structSmiles:
return False
self._structSmiles.add(smiles)
return True
[docs] def clearStructs(self):
self._structs = []
if self._unique:
self._structSmiles = set()
self.sizeChanged.emit()
[docs] def getStruct(self, index):
location = index.row() * self.columnCount() + index.column()
if location >= self.structCount():
return None
return self._structs[location]
[docs] def setStruct(self, index, struct):
"""
Given the index, replace the structure with the new value. If the
_unique flag is true and the new value already exists in table,
it is not added.
:return: Return whether the new structure was successfully set at the
specified index.
:rtype: bool
"""
location = index.row() * self.columnCount() + index.column()
if location >= self.structCount():
return False
if self._unique:
if not self._addSmiles(struct):
return False
self._removeSmiles(location)
self._structs[location] = struct
return True
[docs] def removeStruct(self, row, column):
location = row * self.columnCount() + column
if location >= self.structCount():
return None
if self._unique:
self._removeSmiles(location)
del self._structs[location]
self.sizeChanged.emit()
def _removeSmiles(self, location):
"""
Remove smiles value given the location.
:param location: the index in self._structs, which corresponds to a
structure, to be removed.
:type location: int
"""
smiles = self._smiles_gen.getSmiles(self._structs[location])
self._structSmiles.remove(smiles)
[docs] def rowCount(self, parent=QtCore.QModelIndex()): # noqa: M511
return self._row_count
[docs] def columnCount(self, parent=QtCore.QModelIndex()): # noqa: M511
return self._column_count
[docs] def data(self, index, role=QtCore.Qt.DisplayRole):
return
[docs] def insertRows(self, row, count, parent=QtCore.QModelIndex()): # noqa: M511
self.beginInsertRows(parent, row, row + count - 1)
self._row_count += count
self.endInsertRows()
return True
[docs] def insertColumns(self, row, count,
parent=QtCore.QModelIndex()): # noqa: M511
self.beginInsertColumns(parent, row, row + count - 1)
self._column_count += count
self.endInsertColumns()
return True
[docs] def removeRows(self, row, count, parent=QtCore.QModelIndex()): # noqa: M511
self.beginRemoveRows(parent, row, row + count - 1)
self._row_count -= count
self.endRemoveRows()
return True
[docs] def removeColumns(self, row, count,
parent=QtCore.QModelIndex()): # noqa: M511
self.beginRemoveColumns(parent, row, row + count - 1)
self._column_count -= count
self.endRemoveColumns()
return True
[docs] def resize(self, row, column):
self.beginResetModel()
if self.rowCount() > row:
self.removeRows(0, self.rowCount() - row)
if self.columnCount() > column:
self.removeColumns(0, self.columnCount() - column)
if self.rowCount() < row:
self.insertRows(0, row - self.rowCount())
if self.columnCount() < column:
self.insertColumns(0, column - self.columnCount())
self.endResetModel()
[docs] def resizeRows(self, row):
self.resize(row, self.columnCount())
[docs] def resizeColumns(self, cols):
rows = int(old_div(self.structCount(), cols))
if rows * cols < self.structCount():
rows += 1
self.resize(rows, cols)
[docs] def setTable(self, view):
pass
[docs] def reset(self):
"""
PANEL-8852: Method added to prevent QtUpgradeError. This preserves
backwards compatibility with QtCore.QAbstractTableModel.reset() but
does not reset anything.
"""
self.beginResetModel()
self.endResetModel()
[docs]class StructureDataViewerModel(ViewerModel):
"""
A table model that handles 2-D structures and data.
This inherits ViewerModel, but is designed to
be less 2-D structure-centric than that model, which assumes all the cells
contain structures.
"""
# setHeaderData emits headerDataChanged without any arguments
headerDataChanged = QtCore.pyqtSignal([Qt.Orientation, int, int], [])
[docs] def __init__(self, rows=4, columns=8, vlabels=None, hlabels=None):
"""
:type rows: int
:param rows: number of rows in the table
:type columns: int
:param columns: number of columns in the table
:type vlabels: list of strings
:param vlabels: labels for the row headers
:type hlabels: list of strings
:param hlabels: labels for the column headers
"""
QtCore.QAbstractTableModel.__init__(self)
self._row_count = rows
self._column_count = columns
self.columnSortOrderIndicator = []
self._v_headers = []
self._h_headers = []
self.setSize(rows, columns)
if vlabels:
# self._v_headers is initialize to numbers by the setSize call
self._v_headers = vlabels
if hlabels:
self._h_headers = hlabels
else:
# self._v_headers is not initialized to numbers by the setSize call
self._h_headers = [x + 1 for x in range(self._column_count)]
for col in range(self._column_count):
self.columnSortOrderIndicator.append(None)
self._unique = False
self.cur_id = 0
self.sort_v_headers = False
[docs] def setTable(self, table):
"""
:type table: DataViewerTable
:param table: the table this model contains data for
:deprecated table: This function should not be used, because
a model can refer to multiple views.
"""
self.table = table
[docs] def setDelegate(self, delegate):
"""
Sets the Delegate that draws the data from this model.
:type delegate: StructureDataViewerDelegate
:param delegate: delegate that draws the data from this model
:deprecated delegate: This function should not be used, because
a model can have many delegates
"""
self.delegate = delegate
[docs] def myDataChanged(self, row1, column1, row2, column2):
"""
Emits the proper signal to indicate that data in the table has changed.
Rows and columns are 0-indexed
:type row1: non-negative int
:param row1: row index of the top, left-most cell that changed
:type row2: non-negative int
:param row2: row index of the bottom, right-most cell that changed
:type column1: non-negative int
:param column1: column index of the top, left-most cell that changed
:type column2: non-negative int
:param column2: column index of the bottom, right-most cell changed
"""
[docs] def initializeValueMatrix(self,
reset_row_labels=True,
reset_column_labels=False):
"""
Resets all the cells to values of None and resets the row labels (by
default)
:type reset_row_labels: bool
:param reset_row_labels: if True, all row labels are reset to simple
numbers, if False, they are not. This is True by default.
:type reset_column_labels: bool
:param reset_column_labels: if True, all column labels are reset to
simple numbers, if False, they are not. This is False by default.
"""
self.beginResetModel()
self.values = []
for row in range(self._row_count):
self.values.append([])
for col in range(self._column_count):
self.values[-1].append(None)
if reset_row_labels:
self._v_headers = [x + 1 for x in range(self._row_count)]
if reset_column_labels:
self._h_headers = [x + 1 for x in range(self._column_count)]
self.endResetModel()
[docs] def insertColumn(self, column, index=None, renumber_headers=False):
"""
Insert a column into the model and table
:type column: int
:param column: the column index to insert a new column at
:type index: QModelIndex()
:param index: unused
:type renumber_headers: bool
:param renumber_headers: True if all the column headers should be
renumbered, false if not (leave as default if custom column headers have
been used)
"""
if index is None:
index = QtCore.QModelIndex()
self.insertColumns(column,
1,
index=index,
renumber_headers=renumber_headers)
[docs] def insertColumns(self, place, columns, index=None, renumber_headers=False):
"""
Insert columns into the model and table at index place
:type place: int
:param place: the index to insert the columns at
:type columns: int
:param columns: the number of columns to insert
:type index: QModelIndex()
:param index: unused
:type renumber_headers: bool
:param renumber_headers: True if all the column headers should be
renumbered, false if not (leave as default if custom column headers have
been used)
"""
if index is None:
index = QtCore.QModelIndex()
self.beginInsertColumns(index.parent(), place, place + columns - 1)
self._column_count += columns
for row in self.values:
for ind in range(columns):
row.insert(place, None)
for col in range(columns - 1, -1, -1):
self._h_headers.insert(place, None)
self.columnSortOrderIndicator.insert(place, None)
if renumber_headers:
self._h_headers = [x for x in range(1, self.columnCount() + 1)]
self.endInsertColumns()
[docs] def removeColumn(self, column, index=None, renumber_headers=False):
"""
Remove a column from the model and table
:type column: int
:param column: the column index to remove
:type index: QModelIndex()
:param index: unused
"""
if index is None:
index = QtCore.QModelIndex()
self.removeColumns(column,
1,
index=index,
renumber_headers=renumber_headers)
[docs] def removeColumns(self, place, columns, index=None, renumber_headers=False):
"""
Remove columns from the model and table starting at index place
:type place: int
:param place: the index to begin removing columns at
:type columns: int
:param columns: the number of columns to remove
:type index: QModelIndex()
:param index: unused
"""
if index is None:
index = QtCore.QModelIndex()
self.beginRemoveColumns(index.parent(), place, place + columns - 1)
self._column_count -= columns
for row in self.values:
for ind in range(columns):
del row[place]
if renumber_headers:
self._h_headers = [x for x in range(1, self.columnCount() + 1)]
self.endRemoveColumns()
[docs] def insertRow(self, row, index=None, renumber_headers=False):
"""
Insert a row into the model and table
:type row: int
:param row: the row index to insert a new row at
:type index: QModelIndex()
:param index: unused
:type renumber_headers: bool
:param renumber_headers: True if all the row headers should be
renumbered, false if not (leave as default if custom row headers have
been used)
"""
if index is None:
index = QtCore.QModelIndex()
self.insertRows(row, 1, index=index, renumber_headers=renumber_headers)
[docs] def insertRows(self, place, rows, index=None, renumber_headers=False):
"""
Insert rows into the model and table at index place
:type place: int
:param place: the index to insert the rows at
:type rows: int
:param rows: the number of rows to insert
:type index: QModelIndex()
:param index: unused
:type renumber_headers: bool
:param renumber_headers: True if all the row headers should be
renumbered, false if not (leave as default if custom row headers have
been used)
"""
if index is None:
index = QtCore.QModelIndex()
self.beginInsertRows(index.parent(), place, place + rows - 1)
self._row_count += rows
try:
blank_row = [None] * self.columnCount()
except IndexError:
blank_row = []
for row in range(rows - 1, -1, -1):
self.values.insert(place, blank_row[:])
if renumber_headers:
self._v_headers = [x for x in range(1, self.rowCount() + 1)]
self.endInsertRows()
[docs] def removeRow(self, row, index=None, renumber_headers=False):
"""
Remove a row from the model and table
:type row: int
:param row: the row index to remove
:type index: QModelIndex()
:param index: unused
"""
if index is None:
index = QtCore.QModelIndex()
self.removeRows(row, 1, index=index, renumber_headers=renumber_headers)
[docs] def removeRows(self, place, rows, index=None, renumber_headers=False):
"""
Remove rows from the model and table starting at index place
:type place: int
:param place: the index to begin removing rows at
:type rows: int
:param rows: the number of rows to remove
:type index: QModelIndex()
:param index: unused
"""
if index is None:
index = QtCore.QModelIndex()
# The third argument to beginRemoveRows should be the last row being
# removed. Using place+rows-1 accomplishes this.
self.beginRemoveRows(index.parent(), place, place + rows - 1)
self._row_count -= rows
for row in range(rows):
del self.values[place]
if renumber_headers:
self._v_headers = [x for x in range(1, self.rowCount() + 1)]
self.endRemoveRows()
[docs] def sortKey(self, myitem):
"""
Returns the key to sort item by
:type myitem: model item
:param myitem: base class expects an iterable with the sort data of
interest as the second item
:return: The second item in myitem, or `NO_ITEM` if myitem does not
have at least 2 items.
"""
try:
if self.sort_v_headers:
return myitem[0][self.sort_column][1]
else:
return myitem[self.sort_column][1]
except (TypeError, IndexError):
return NO_ITEM
[docs] def sortKeyUnsorted(self, myitem):
"""
Returns the key to sort item by. In this case, it returns the cur_id of
the data, allowing the sort to acheive the original data order.
:type myitem: model item
:param myitem: base class expects an iterable with the sort data of
interest as the second item
:return: The first item in myitem, or `NO_ITEM` if myitem does not have
at least 2 items.
"""
try:
if self.sort_v_headers:
return myitem[0][self.sort_column][0]
else:
return myitem[self.sort_column][0]
except (TypeError, IndexError):
return NO_ITEM
[docs] def sort(self, column, order=QtCore.Qt.AscendingOrder):
"""
This functions exists so that we can transition away from sorting
directly in the model.
If you are writing a new table, this function should in called
a QSortFilterProxyModel, not in the source model.
"""
self.sortByColumn(column, order)
[docs] def sortByColumn(self, column, order=QtCore.Qt.AscendingOrder):
"""
Sort by data by column
:type column: int
:param column: the index of the column to sort by
:type order: Qt.SortOrder
:param order: Qt.AscendingOrder (0), Qt.DescendingOrder (1) or -1 for
returning to original data order
"""
self.sort_column = column
self.layoutAboutToBeChanged.emit()
if self.sort_v_headers:
self.values = [
(x, y)
for x, y in itertools.zip_longest(self.values, self._v_headers)
]
if order == QtCore.Qt.AscendingOrder:
self.values.sort(key=self.sortKey)
elif order == QtCore.Qt.DescendingOrder:
self.values.sort(key=self.sortKey, reverse=True)
else:
self.values.sort(key=self.sortKeyUnsorted)
if self.sort_v_headers:
self._v_headers = [x[1] for x in self.values]
self.values = [x[0] for x in self.values]
self.layoutChanged.emit()
[docs] def setCellValue(self, row, column, value, timer=None):
"""
Sets the data for the cell located at row, column
:type row: int
:param row: row the cell is in (0-indexed)
:type column: int
:param column: column the cell is in (0-indexed)
:param value: value to store in the cell at (row, column)
value can be either a structure or numerical/string data
:type timer: bool
:deprecated timer: This variable is no longer necessary. Formerly, we would
perform a full scale update with 300ms timer. This caused an unnecessary race
condition when updating cells. Now we implement the dataChanged signal for
individual modelindexes, which should not have a performance penalty.
"""
if timer is not None:
warnings.warn("timer option is deprecated and no longer needed",
DeprecationWarning)
self.cur_id = self.cur_id + 1
self.values[row][column] = (self.cur_id, value)
index = self.index(row, column)
self.dataChanged.emit(index, index)
[docs] def getCellValue(self, row, column):
"""
Returns the value of the cell in the cell at row, column
:type row: int
:param row: the row of the cell of interest (0-indexed)
:type column: int
:param row: the column of the cell of interest (0-indexed)
:rtype: Any
:return: data or object stored in the model at row, column
"""
try:
return self.values[row][column][1]
except (IndexError, TypeError):
return None
[docs] def setSize(self,
numrows,
numcolumns,
reset_row_labels=True,
reset_column_labels=False):
"""
Sets the size of the table
:type numrows: int
:param numrows: the number of rows the table should have
:type numcolumns: int
:param numcolumns: the number of columns the table should have
:type reset_row_labels: bool
:param reset_row_labels: if True, all row labels are reset to simple
numbers, if False, they are not
:type reset_column_labels: bool
:param reset_column_labels: if True, all column labels are reset to
simple numbers, if False, they are not
"""
self.beginResetModel()
self.resize(numrows, numcolumns)
self.initializeValueMatrix(reset_row_labels=reset_row_labels,
reset_column_labels=reset_column_labels)
self.endResetModel()
[docs] def data(self, index, role=QtCore.Qt.DisplayRole):
"""
Returns the data (either Display or ToolTip) if requested.
The tooltips for structures are a different class, and handled manually
by the StructureToolTip class.
:type index: QModelIndex
:param index: index of the cell for which data is requested
:type role: Qt.ItemDataRole
:param role: role of the data requested. Currently only responds to
Qt.ToolTipRole
"""
# This routine gets called for many types of data, we only react to
# tooltip requests
row = index.row()
column = index.column()
value = self.getCellValue(row, column)
if role == QtCore.Qt.DisplayRole:
return value
elif role == QtCore.Qt.ToolTipRole:
return value
[docs] def clearTable(self):
"""
Resets all the data in the table to None
"""
self.beginResetModel()
self.initializeValueMatrix()
self.endResetModel()
[docs]class GenericViewerDelegate(QtWidgets.QItemDelegate):
"""
This is the class that handles the actual drawing of data. This doesn't
handle any actual data, so you'll have to subclass it to get it to work.
For your own widget table, you should subclass this class, add any data
input you need into the __init__, and reimplement the _paint class for
your needs, using your data.
An example of this is the StructureReaderDelegate in this file.
"""
[docs] def __init__(self, tableview, tablemodel=None):
"""
:type tableview: ViewerTable
:param tableview: Table this delegate paints to
:type tablemodel: StructureDataViewerModel
:param tablemodel: DEPRECATED Model containing the data this delegate paints
This parameter should not be used, because a delegate should only get
information from a QModelIndex, not a model directly
"""
QtWidgets.QItemDelegate.__init__(self)
self.table = tableview
if tablemodel:
self.model = tablemodel
self._paint_wait = False
self.qpolygon = QtGui.QPolygon(4) #Used to draw hourglass
self.table.setDelegate(self)
self.table.setItemDelegate(self)
[docs] def paint(self, painter, option, index):
"""
This handles the logic behind painting/not-painting when the scrollbar
is being dragged.
"""
painter.save()
if option.state & QtWidgets.QStyle.State_Selected:
painter.fillRect(option.rect, option.palette.highlight())
painter.setBrush(QtGui.QColor(0, 0, 0))
if self.table.isScrolling() and self.paintWait():
self._paint_passive(painter, option, index)
else:
self._paint(painter, option, index)
painter.restore()
[docs] def setPaintWait(self, val):
"""
Use this function to turn on/off delaying drawing of cells until the
scrollbar is not being used. This may be useful in CPU intensive
cell-drawing delegates.
"""
self._paint_wait = val
[docs] def paintWait(self):
""" Returns if we are drawing during while the scrollbar is dragged."""
return self._paint_wait
def _paint(self, painter, option, index):
""" Reimplement this to draw what you want in the cell. """
def _paint_passive(self, painter, option, index):
"""
This function paints a temporary cell if we're moving too
fast for real content to be drawn. By default this is just a
primitive hourglass, though you can implement to draw real info
if you desire.
"""
centerx = option.rect.left() + old_div(option.rect.width(), 2)
centery = option.rect.top() + old_div(option.rect.height(), 2)
self.qpolygon.setPoint(0, QtCore.QPoint(centerx - 5, centery - 10))
self.qpolygon.setPoint(1, QtCore.QPoint(centerx + 5, centery - 10))
self.qpolygon.setPoint(2, QtCore.QPoint(centerx - 5, centery + 10))
self.qpolygon.setPoint(3, QtCore.QPoint(centerx + 5, centery + 10))
painter.drawPolygon(self.qpolygon)
if not self.table.draw_timer.isActive():
self.table.draw_timer.start(TIMEOUT)
[docs]class StructureDataViewerDelegate(GenericViewerDelegate):
"""
A table that can display both 2-D structures and data.
This Delegate should be used with the StructureDataViewerModel
Much of this class is built off of the StructureViewerDelegate class.
Structures are cached for quicker drawing of the table while keeping memory
use low. Data is not cached.
"""
[docs] def __init__(self,
tableview,
tablemodel=None,
structure_class=None,
max_scale=0.5,
elide=QtCore.Qt.ElideRight,
alignment=None):
"""
Intialize the StructreDataViewerDelegate
:type tableview: ViewerTable
:param tableview: Table this delegate paints to
:type tablemodel: StructureDataViewerModel
:param tablemodel: DEPRECATED Model containing the data this delegate paints
This parameter should not be used, because a delegate should only get
information from a QModelIndex, not a model directly
:type structure_class: class
:keyword structure_class: class (or superclass) of the objects that
should be displayed in the table as 2D pictures - usually
schrodinger.structure.Structure (the default)
:type max_scale: float
:param max_scale: restricts the maximum scale-up of 2D structure images
so that very small molecules don't look so large.
:type elide: int
:param elide: Determines where '...' should occur in strings that are
too large to fit in the table cell. Common values are Qt.ElideLeft,
Qt.ElideRight, Qt.ElideMiddle, Qt.ElideNone
:type alignment: Qt.Alignment object
:param alignment: The alignment of text in the cells.
To put a structure in a cell, store a structure_class object in that
cell of the model.
To put any other type of data in a cell, store that data in that cell of
the model.
"""
GenericViewerDelegate.__init__(self, tableview, tablemodel=tablemodel)
self.picture_cache = _CacheClass()
self.generate_one_structure = False
self.max_scale = max_scale
if not structure_class:
self.structure_class = struct.Structure
else:
self.structure_class = structure_class
if tablemodel:
tablemodel.setDelegate(self)
self.elide = elide
if not alignment:
# Align text to the left of the cell and center vertically
self.alignment = QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter
else:
self.alignment = alignment
self.adaptor = canvas2d.ChmMmctAdaptor()
self.model2d = canvas2d.ChmRender2DModel()
self.renderer = canvas2d.Chm2DRenderer(self.model2d)
def _paint(self, painter, option, index, passive=False):
"""
Generates the object that should be painted on the cell.
If the cell contains a structure, it attempts to retrieve this structure
from the structure cache. If unsuccessful, it either generates a new
structure or paints an hourglass, depending on the value of passive.
Once a structure is obtained, it is passed on to the paintCell function.
If the cell contains data, it just passes the data on to the paintCell
function.
:type painter: QtGui.QPainter object
:param option: Appears to be the part of the gui that contains the cell
:param index: Index of the cell
:param passive: whether to generate a new structure or not if one is not
found in the cache.
"""
struct = index.data(QtCore.Qt.DisplayRole)
if struct is None:
#Skip painting if there's no structure for this cell
return
elif self.isStructure(struct):
# This cell contains a 2D chemical structure, try and grab it from
# the cache.
pic = self.picture_cache.get(struct)
if not pic:
if passive and self.generate_one_structure:
passive = False
self.generate_one_structure = False
if passive:
GenericViewerDelegate._paint_passive(
self, painter, option, index)
else:
# We need to plot a structure, and no structure is
# available, generate one and store it in the cache
pic = self.generatePicture(struct)
self.picture_cache.store(struct, pic)
self.paintStructure(painter, option, pic, index)
else:
self.paintStructure(painter, option, pic, index)
else:
self.paintCell(painter, option, struct)
[docs] def sizeHint(self,
option,
index,
actual_column_width=False,
actual_size=False,
actual_row_height=False):
"""
Returns the sizeHint for the data in the cell at index. If the data is
a structure, returns the default cell size for the table.
:type option: QStyleOptionViewItem
:param option: the style options for this item
:type index: QModelIndex
:param index: The index of the cell being examimed
:type actual_size: bool
:param actual_column_width: True if the column widths and row heights
should be calculated from the width of the data as a string. Normally,
sizeHint seems to return 0 for the width of text and an acceptable
constant for the height, so actual_column_width is the better option.
:type actual_row_height: bool
:param actual_column_width: True if the row heights should be
calculated from the height of the data as a string. Normally, sizeHint
returns an acceptable constant for the row height, so this is not
usually needed.
:type actual_column_width: bool
:param actual_column_width: True if the column widths should be
calculated from the width of the data as a string. Normally, sizeHint
seems to return 0 for the width of text.
"""
data = index.data(QtCore.Qt.DisplayRole)
if self.isStructure(data):
return self.table.default_cell_size
else:
hint = GenericViewerDelegate.sizeHint(self, option, index)
if actual_column_width or actual_row_height or actual_size:
qdata = str(data)
metrics = self.table.fontMetrics()
if actual_column_width or actual_size:
# The generic form of this routine always returns 0 for the
# width of text
width = metrics.horizontalAdvance(qdata)
hint.setWidth(width + 20)
if actual_row_height or actual_size:
# The generic form of this routine generally returns a correct
# answer, so this is typically not needed.
height = metrics.height()
hint.setHeight(height)
return hint
def _paint_passive(self, painter, option, index):
self._paint(painter, option, index, passive=True)
[docs] def clearCache(self):
"""
Will clear all QPictures that are cached.
"""
# Will cause previous QPictures to be garbage-collected:
self.picture_cache = _CacheClass()
[docs] def isStructure(self, object):
"""
Returns true if object is the type given as a structure
"""
return self.structure_class and \
isinstance(object, self.structure_class)
[docs] def generatePicture(self, struct):
"""
Generates a 2D chemical structure of the object
If object is a chemical structure, returns the picture is a 2D
rendering of that structure; None otherwise.
:type struct: Object of type StructureDataViewerDelegate.structure_class
:param struct: The structure object
:rtype: QPicture or None
:return: QPicture of struct or None
"""
if struct:
settings = sketcher.RendererSettings()
pic = sketcher.Renderer.pictureFromStructure(
struct.handle, settings)
# Will return an empty QPicture on failure
return pic
else:
return None
[docs] def paintStructure(self, painter, option, pic, index=None):
"""
Draws the given QPicture (typically a 2D structure image) into the
specified painter. Called from the paint() method of the delegate.
Adds a bit of padding all around the cell, then passes the data on to
the proper drawing routine.
:type painter: QtGui.QPainter object
:param option: Appears to be the part of the gui that contains the cell
:type pic: QPicture
:param pic: the picture to be painted
:param index: Model index for the cell which is being painted. Not used
in default implementation, but may be used in subclass implementations.
:type index: QModelIndex
"""
r = option.rect
padding_factor = 0.04
r.setLeft(option.rect.left() + padding_factor * option.rect.width())
r.setRight(option.rect.right() - padding_factor * option.rect.width())
r.setTop(option.rect.top() + padding_factor * option.rect.height())
r.setBottom(option.rect.bottom() -
padding_factor * option.rect.height())
swidgets.draw_picture_into_rect(painter,
pic,
r,
max_scale=self.max_scale)
[docs] def paintCell(self, painter, option, data):
"""
Adds a bit of padding all around the cell, then passes the data on to
the proper drawing routine.
:type painter: QtGui.QPainter object
:param option: Appears to be the part of the gui that contains the cell
:type data: type convertable to string
:param data: the data to be painted into the cell as a string
"""
r = option.rect
padding_factor = 0.04
r.setLeft(option.rect.left() + padding_factor * option.rect.width())
r.setRight(option.rect.right() - padding_factor * option.rect.width())
r.setTop(option.rect.top() + padding_factor * option.rect.height())
r.setBottom(option.rect.bottom() -
padding_factor * option.rect.height())
if option.state & QtWidgets.QStyle.State_Selected:
selected_brush = option.palette.highlightedText()
else:
selected_brush = False
self.drawDataIntoRect(painter, data, r, selected_brush)
[docs] def drawDataIntoRect(self, painter, data, rect, selected_brush=False):
"""
Draws string data onto a cell
:type painter: QtGui.QPainter
:param painter: The painter that is drawing the table
:type data: string, int, or float
:param data: the information to be drawn on the table
:type rect: QtCore.Qrect
:param rect: The rectangle that defines the current cell of the table
"""
fm = self.table.fontMetrics()
qdata = fm.elidedText(str(data), self.elide, rect.width())
# Get the bounding box of this text if it was drawn in rect
text_rect = painter.boundingRect(rect, self.alignment, qdata)
# We don't want to draw the text past the cell boundaries.
# rect = the cell rectangle, text_rect = the data rectangle
# Start at the left side of the cell, unless the data is smaller
text_rect.setLeft(max(rect.left(), text_rect.left()))
# Start at the top of the cell, unless the data is shorter
text_rect.setTop(max(rect.top(), text_rect.top()))
# If the data fits lengthwise in the cell, paint the whole thing,
# otherwise paint only as much as fits in the cell.
text_rect.setWidth(min(rect.width(), text_rect.width()))
# If the data fits heightwise in the cell, paint the whole thing,
# otherwise paint only as much as fits in the cell.
text_rect.setHeight(min(rect.height(), text_rect.height()))
if selected_brush:
painter.setBrush(selected_brush)
else:
painter.setBrush(QtGui.QBrush(QtCore.Qt.black, QtCore.Qt.NoBrush))
painter.drawText(text_rect, self.alignment, qdata)
class _CacheNode:
def __init__(self, reference):
self.reference = reference
self.next = None
class _CacheClass:
"""
This class caches the last few QPictures given to it. It is designed to
cache visible cells in the table.
"""
def __init__(self):
self.first = None
self.last = None
self.count = 0
self.max_sts = 500
self.lookup_dict = {}
def clear(self):
self.first = None
self.last = None
self.count = 0
self.max_sts = 500
self.lookup_dict = {}
def store(self, reference, picture):
if self.count == self.max_sts:
# Discard the first node:
node = self.first
self.first = node.next
self.count -= 1
del self.lookup_dict[node.reference]
# Create a node:
node = _CacheNode(reference)
if self.first: # if contains nodes already
self.last.next = node
else: # this is first node
self.first = node
# Point self.last to the new node
self.last = node
self.count += 1
self.lookup_dict[reference] = picture
def get(self, reference):
try:
return self.lookup_dict[reference]
except KeyError:
return None
def delete(self, reference):
current = self.first
if current is None:
return
if current.reference == reference:
# Discard the first node:
self.first = current.next
self.count -= 1
del self.lookup_dict[current.reference]
return
if current.next is None:
return
while current.next.reference != reference:
current = current.next
if current.next is None:
return
delnode = current.next
if self.last == delnode:
self.last == current
else:
current.next = delnode.next
self.count -= 1
del self.lookup_dict[delnode.reference]
[docs]class SEasySDTable(DataViewerTable):
"""
A simple class that automatically sets up the table/model/delegate trio for
a table object
"""
[docs] def __init__(self, **table_args):
"""
Creates an SEasySDTable class object. __init__ Keywords for the model,
delegate and table objects can be passed in and will be passed on as
appropriate.
By default, this will set up a sortable table using a proxymodel.
The sourcemodel should be used when need to add/remove data.
The proxymodel should be used when you need to reference data from
the view.
"""
def extract_kws(kw_list, kw_dict):
"""
Pull any keywords in kw_list out of the table_args dictionary
and put them into the given kw_dict
"""
for kw in kw_list:
if kw in table_args:
kw_dict[kw] = table_args[kw]
del table_args[kw]
# Extract the keyword arguments for the model
model_args = {}
model_keywords = ['rows', 'columns', 'vlabels', 'hlabels']
extract_kws(model_keywords, model_args)
# Extract the keyword arguments for the delegate
delegate_args = {}
delegate_keywords = [
'structure_class', 'max_scale', 'elide', 'alignment'
]
extract_kws(delegate_keywords, delegate_args)
# Create the model/table/delegate
self.sourcemodel = StructureDataViewerModel(**model_args)
self.proxymodel = QtCore.QSortFilterProxyModel()
# maintain Qt4 dynamicSortFilter default
self.proxymodel.setDynamicSortFilter(False)
self.proxymodel.setSourceModel(self.sourcemodel)
#Initially set the sourcemodel with the DataViewerTable object,
#because the signals for columnsInserted(), rowsInserted() need
#to be connected to the sourcemodel
DataViewerTable.__init__(self, self.sourcemodel, **table_args)
self.setSortingEnabled(True)
#After initialization, all model information should go through the
#proxymodel
self.setModel(self.proxymodel)
self.delegate = StructureDataViewerDelegate(self, **delegate_args)
self.setItemDelegate(self.delegate)
[docs]class VStackedDataViewerTable(DataViewerTable):
[docs] def __init__(self,
model,
model2,
scrollbar_at_bottom=True,
bottom_table=None,
**kwarg):
"""
Two DataViewerTables stacked on top of each other. They share a single
horizontal header and a single scrollbar. The object returned when this
class is created is the upper table. The lower table is accessed via
VStackedDataViewerTable.table2.
The VStackedDataViewerTable has a layout property .mylayout which is
what should be added to the GUI to properly place this widget.
:type model: QAbstractTableModel
:param model: The model for the upper table. The typical class to use
with this table is the StructureDataViewerModel class.
:type model2: QAbstractTableModel
:param model: The model for the lower table. The typical class to use
with this table is the StructureDataViewerModel class.
:type bottom_table: VSubDataViewerTable
:param model: The class for the lower table. If not supplied, a
VSubDataViewerTable object will be used.
Keyword arguments are documented in the DataViewerTable class
"""
# Set up the table/model/delegate triumverant
if bottom_table is None:
self.table2 = VSubDataViewerTable(model2, self, **kwarg)
else:
self.table2 = bottom_table(model2, self, **kwarg)
DataViewerTable.__init__(self, model, **kwarg)
if scrollbar_at_bottom:
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.table2.setHorizontalScrollBar(self.horizontalScrollBar())
else:
self.table2.setHorizontalScrollBarPolicy(
QtCore.Qt.ScrollBarAlwaysOff)
self.setHorizontalScrollBar(self.table2.horizontalScrollBar())
self.mylayout = swidgets.SVBoxLayout()
self.mylayout.addWidget(self)
self.mylayout.addWidget(self.table2)
[docs] def setColumnWidth(self, column, width):
"""
Sets column to width in both tables
:type column: int
:param column: the index of the column to change
:type width: int
:param width: the new width of the column
"""
DataViewerTable.setColumnWidth(self, column, width)
self.table2.setColumnWidth(column, width)
[docs] def resizeRowsToContents(self):
"""
Resizes all the rows according to all the resize properties
"""
DataViewerTable.resizeRowsToContents(self)
self.table2.resizeRowsToContents()
[docs] def setFixedWidth(self, width):
"""
Sets the viewport size to width
:type width: int
:param width: new viewport size in pixels
"""
DataViewerTable.setFixedWidth(self, width)
self.table2.setFixedWidth(self, width)
[docs] def fitColumnToData(self, column, include_header=False):
"""
Resizes column to the largest sizeHint of any of its cells. Uses the
sizeHint of the delegate.
:type column: int
:param column: the column of interest
"""
hint = 0
options = qt_utils.get_view_item_options(self)
for row in range(self.model().rowCount()):
index = self.model().index(row, column)
shint = self.itemDelegate().sizeHint(options,
index,
actual_column_width=True)
hint = max(hint, shint.width())
options2 = qt_utils.get_view_item_options(self.table2)
for row in range(self.table2.model().rowCount()):
index = self.table2.model().index(row, column)
shint = self.table2.itemDelegate().sizeHint(
options2, index, actual_column_width=True)
hint = max(hint, shint.width())
if include_header:
shint = self.horizontalHeader().sectionSizeHint(column)
hint = max(hint, shint)
self.setColumnWidth(column, hint)
[docs] def resizeEvent(self, event):
"""
Called when the viewport changes size. Changes the size of any cells
that need to be changed in response to this - typically because fill has
been set on rows or columns.
:type event: QEvent
:param event: the event that occured
"""
# Infinite loop detection and elimination. See comments in __init__ as
# to what is going on here.
if self.stop_loop:
# A loop was detected last time through, stop it by not resizing
self.stop_loop = False
return
if self.avoid_loops:
# This is where we check to see if we are in a loop. Keep a history
# of the scrollbar visibilities. If they are toggling on and off,
# then we are in a loop
self.hbar_states.insert(0, self.horizontalScrollBar().isVisible())
self.vbar_states.insert(0, self.verticalScrollBar().isVisible())
try:
# Only save the important states so the lists don't grow large
self.hbar_states = self.hbar_states[:self.toggle_length]
self.vbar_states = self.vbar_states[:self.toggle_length]
except IndexError:
# Happens if the lists are small enough already
pass
if (self.hbar_states == self.loop_sign or
self.vbar_states == self.loop_sign):
# Found a loop, reset the history and set the stop loop flag.
self.hbar_states = []
self.vbar_states = []
self.stop_loop = True
if event.type() == 14 and event.oldSize() == QtCore.QSize(-1, -1):
# Make sure to do a resize the very first time the table shows
self.fixSize()
QtWidgets.QTableView.resizeEvent(self, event)
num_cols = self.model().columnCount()
num_rows = self.model().rowCount()
if num_cols <= 0:
return
# Fit the table into the viewport
if self.fill_columns:
# Calculate the column height needed to perfectly fill the viewport
width = self.sizeHintForColumn(0)
if self.gang_columns:
# Only set the first column, since that resize will trigger all
# the other columns.
self.setColumnWidth(0, width)
# Also have to set the last column, in case the sizeHint hasn't
# changed but self.col_remainder has.
self.setColumnWidth(num_cols - 1, width + self.col_remainder)
else:
# Set all the columns to this width
for col in range(num_cols - 1):
self.setColumnWidth(col, width)
self.setColumnWidth(num_cols - 1, width + self.col_remainder)
# Change the row heights if need be
if self.lock_aspect_ratio:
self.resizeRowsToContents()
if self.fill_rows:
# Calculate the row height needed to perfectly fill the viewport
height = self.sizeHintForRow(0)
# Set all the rows to this height
if self.gang_rows:
# Only set the first row, since that resize will trigger all
# the other rows to resize.
self.setRowHeight(0, height)
self.table2.setRowHeight(0, height)
# Also have to set the last row, in case the sizeHint hasn't
# changed but self.row_remainder has.
self.table2.setRowHeight(num_rows - 1,
height + self.row_remainder)
else:
for row in range(num_rows - 1):
self.setRowHeight(row, height)
self.setRowHeight(num_rows - 1, height + self.row_remainder)
# Change the column widths if need be
if self.lock_aspect_ratio:
self.resizeColumnsToContents()
[docs] def sizeHintForRow(self, row):
"""
This method gives the size hints for rows - its existance means that
the resizeRowsToContents() method uses this hint rather than hints
from the delegate.
:type row: int
:param row: the row we want a size hint for
:rtype: int
:return: the size hint for row
"""
if self.lock_aspect_ratio and not self.fill_rows and \
self.isRowGanged(row):
hint = old_div(self.averageGangedColumnWidth(), self._default_ratio)
elif self.fill_rows:
table_height = self.viewport().height()
hint, self.row_remainder = divmod(table_height,
self.model().rowCount())
else:
# To use the delegate size hint for the cell, call delegate.sizeHint
# here (and implement the sizeHint method for the delegate).
hint = self.rowHeight(row)
return hint
[docs]class VSubDataViewerTable(DataViewerTable):
[docs] def __init__(self, model, master_table, **kwarg):
"""
Sub-table for a vertically stacked table.
:type master_table: DataViewerTable
:param master_table: the table that controls this one (the upper table
in a vertically stacked pair of tables).
All other parameters are documented in DataViewerTable class.
"""
# Set up the table/model/delegate triumverant
DataViewerTable.__init__(self, model, **kwarg)
self.horizontalHeader().setVisible(False)
self.master_table = master_table
[docs] def initiateCellSizes(self, rows=True, columns=True):
"""
Sets all the cells to their intial default size
:type rows: bool
:param rows: whether to set the rows to their default height
:type columns: bool
:param columns: whether to set the columns to their default width
"""
self.doing_fix_resize = True
if rows:
for row in range(self.model().rowCount()):
self.setRowHeight(row, self.default_cell_size.height())
self.doing_fix_resize = False
[docs] def columnsInserted(self, index, start, end):
"""
Slot that receives a signal when columns are inserted in the
model. Make sure our column_is_ganged list stays current.
:type index: QModelIndex
:param index: unused
:type start: int
:param start: The starting index of the columns inserted
:type end: int
:param end: The ending index of the columns inserted (inclusive)
"""
if not self._universal_column_gang:
try:
for ind in range(start, end + 1):
self.column_is_ganged.insert(start, True)
except IndexError:
pass
[docs] def columnsRemoved(self, index, start, end):
"""
Slot that receives a signal when columns are removed in the
model. Make sure our column_is_ganged list stays current.
:type index: QModelIndex
:param index: unused
:type start: int
:param start: The starting index of the columns removed
:type end: int
:param end: The ending index of the columns removed (inclusive)
"""
if not self._universal_column_gang:
try:
for ind in range(start, end + 1):
del self.column_is_ganged[start]
except IndexError:
pass
[docs] def columnCountChanged(self, old_count, new_count):
"""
Called when a column is added or deleted, and sets the columns to their
initial size.
:type old_count: int
:param old_count: the old number of columns
:type new_count: int
:param new_count: the num number of columns
"""
QtWidgets.QTableView.columnCountChanged(self, old_count, new_count)
[docs] def rowResized(self, row, old_size, new_size):
"""
Resizes any cells that need to be resized when a row size is changed
:type row: int
:param row: the row number that changed
:type old_size: int
:param old_size: the old size of the row
:type new_size: int
:param new_size: the new size of the row
"""
if (not self.doing_row_resize and not self.doing_fix_resize):
# Need to set the doing_resize flag to avoid recursive calls to this
# routine, as it gets called any time a row size is changed by
# the user or by this routine.
self.doing_row_resize = True
if self.rowIsGanged(row):
# We want to keep all rows the same size
last_row = self.model().rowCount() - 1
# Find the last visible row, as we'll adjust the size of that
# one slightly to fit things properly
last_visible_row = 0
if new_size != 0:
for myrow in range(last_row, 0, -1):
if not self.isRowHidden(myrow):
last_visible_row = myrow
break
for arow in range(last_row + 1):
if arow != row and self.rowIsGanged(row):
if arow != last_visible_row:
self.setRowHeight(arow, new_size)
else:
self.setRowHeight(arow,
new_size + self.row_remainder)
if self.lock_aspect_ratio:
# Change the columns to have the same width as the row height
self.resizeColumnsToContents()
self.doing_row_resize = False
if self.fit_view_rows:
# Fix the viewport size to the total table height
self.fitToRows()
[docs] def columnResized(self, column, old_size, new_size):
"""
Doesn't do anything - let the master table take care of this
:type column: int
:param column: the column number that changed
:type old_size: int
:param old_size: the old size of the column
:type new_size: int
:param new_size: the new size of the column
"""
[docs] def fitToRows(self):
"""
Fits the viewport to exactly show all the rows - master table does this
"""
[docs] def fitToColumns(self):
"""
Fits the viewport to exactly show all the columns - master table does
this
"""
[docs] def fixSize(self):
"""
Resize everything as the user and program desires
"""
self.doing_fix_resize = True
# Make sure to do a resize the very first time the table shows
self.resizeRowsToContents()
self.doing_fix_resize = False
[docs] def fitCellsToData(self):
"""
Resizes rows and columns to fit the data they contain using the sizeHint
from the delegate
"""
self.fitRowsToData()
[docs] def fitRowsToData(self):
"""
Resizes all the rows to fit the data they contain using the sizeHint
from the delegate.
"""
for row in range(self.model().rowCount()):
self.fitRowToData(row)
[docs] def fitRowToData(self, row):
"""
Resizes row to the largest sizeHint of any of its cells. Uses the
sizeHint of the delegate.
:type row: int
:param row: the row of interest
"""
hint = 0
if self.verticalHeader().isVisible():
buffer = 4
else:
buffer = 0
options = qt_utils.get_view_item_options(self)
for column in range(self.model().columnCount()):
index = self.model().index(row, column)
hint = max(
hint,
self.itemDelegate().sizeHint(options, index).height() + buffer)
self.setRowHeight(row, hint)
[docs] def fitColumnsToData(self):
"""
Resizes all the columns to fit the data they contain using the sizeHint
from the delegate. Master table does this.
"""
[docs] def fitColumnToData(self, column):
"""
Resizes column to the largest sizeHint of any of its cells. Uses the
sizeHint of the delegate. Master table does this.
:type column: int
:param column: the column of interest
"""
[docs] def resizeRowsToContents(self):
"""
Resizes all the rows according to all the resize properties
"""
for row in range(self.model().rowCount()):
self.setRowHeight(row, self.sizeHintForRow(row))
if self.fit_view_rows:
# Fix the viewport size to the total row height
self.fitToRows()
[docs] def resizeColumnsToContents(self):
"""
Resizes all the columns according to all the resize properties. Master
table does this.
"""
[docs] def resizeEvent(self, event):
"""
Called when the viewport changes size. Changes the size of any cells
that need to be changed in response to this - typically because fill has
been set on rows or columns.
:type event: QEvent
:param event: the event that occured
"""
QtWidgets.QTableView.resizeEvent(self, event)
[docs] def sizeHintForColumn(self, column):
"""
This method gives the size hints for columns - its existance means that
the resizeColumnsToContents() method uses this hint rather than hints
from the delegate.
:type column: int
:param column: the column we want a size hint for
:rtype: int
:return: the size hint for column
"""
if self.lock_aspect_ratio and not self.fill_columns and \
self.isColumnGanged(column):
hint = self.averageGangedRowHeight() * self._default_ratio
elif self.fill_columns:
table_width = self.viewport().width()
hint, self.col_remainder = divmod(table_width,
self.model().columnCount())
if column == self.model().columnCount() - 1:
hint = hint + self.col_remainder
else:
# To use the delegate size hint for the cell, call delegate.sizeHint
# here (and implement the sizeHint method for the delegate).
hint = self.columnWidth(column)
return hint
[docs] def sizeHintForRow(self, row):
"""
This method gives the size hints for rows - its existance means that
the resizeRowsToContents() method uses this hint rather than hints
from the delegate.
:type row: int
:param row: the row we want a size hint for
:rtype: int
:return: the size hint for row
"""
if self.lock_aspect_ratio and not self.fill_rows and \
self.rowIsGanged(row):
hint = old_div(self.averageGangedColumnWidth(), self._default_ratio)
elif self.fill_rows:
table_height = self.viewport().height()
hint, self.row_remainder = divmod(
table_height,
self.model().rowCount() + self.master_table.rowCount())
if row == self.model().rowCount() - 1:
hint = hint + self.row_remainder
else:
# To use the delegate size hint for the cell, call delegate.sizeHint
# here (and implement the sizeHint method for the delegate).
hint = self.rowHeight(row)
return hint
[docs]class ExportableProxyModel(QtCore.QSortFilterProxyModel):
"""
A proxy model that allows data to be exported to a CSV file
"""
[docs] def exportToCsv(self,
filename,
role=Qt.DisplayRole,
header_role=Qt.DisplayRole,
unicode_output=False):
"""
Export the contents of the model to a CSV file.
:param filename: The filename to write the CSV file to
:type filename: str
:param role: The data role to export for the table contents
:type role: int
:param header_role: The data role to export for the table headers
:param header_role: int
:param unicode_output: If `True`, Unicode characters are allowed. The
CSV file will contain a tag (UTF-8 BOM) that allows Excel and
LibreOffice to recognize the file as Unicode. (This tag will be
added regardless of whether the output contains any Unicode
characters.) If `False`, the CSV file will not contain the tag
and Unicode characters will lead to an exception.
:type unicode_output: bool
"""
rows = self.getRowData(role=role, header_role=header_role)
write_row_data(filename, rows, unicode_output=unicode_output)
[docs] def getRowData(self, role=Qt.DisplayRole, header_role=Qt.DisplayRole):
"""
Extract row data from table model.
:param role: The data role to export for the table contents
:type role: int
:param header_role: The data role to export for the table headers
:param header_role: int
:return: a list of row data, where the first item in the list contains
the headers
:rtype: list[list[object]]
"""
headers = [
self.headerData(idx, Qt.Horizontal, header_role)
for idx in range(self.columnCount())
]
rows = [headers]
for row_idx in range(self.rowCount()):
row = []
for col_idx in range(self.columnCount()):
index = self.index(row_idx, col_idx)
data = self.data(index, role)
if data is None:
# print Nones as empty string instead of "None" to match
# PyQt behavior
data = ""
row.append(data)
rows.append(row)
return rows
[docs]def get_row_data(model, role=Qt.DisplayRole, header_role=Qt.DisplayRole):
"""
Extract row data from table model.
:param model: a table model or proxy
:type model: QtCore.QAbstractItemModel
:param role: The data role to export for the table contents
:type role: int
:param header_role: The data role to export for the table headers
:param header_role: int
:return: a list of lists, where each list represents the row of a table
:rtype: list[list[object]]
"""
proxy_model = ExportableProxyModel()
proxy_model.setSourceModel(model)
return proxy_model.getRowData(role, header_role)
[docs]def write_row_data(file_path, rows, unicode_output=False):
"""
Write row data to a CSV file.
:param file_path: the path for the .csv file
:type file_path: str
:param rows: a list of row data lists, where the first element of the list
should contain the headers
:type rows: list[list[object]]
:param unicode_output: If `True`, Unicode characters are allowed. The CSV
file will contain a tag (UTF-8 BOM) that allows Excel and
LibreOffice to recognize the file as Unicode. (This tag will be
added regardless of whether the output contains any Unicode
characters.) If `False`, the CSV file will not contain the tag and
Unicode characters will lead to an exception.
:type unicode_output: bool
"""
if unicode_output:
csv_writer = csv_unicode.writer
else:
csv_writer = csv.writer
with csv_unicode.writer_open(file_path) as handle:
writer = csv_writer(handle)
writer.writerows(rows)
[docs]def export_to_csv(model,
filename,
role=Qt.DisplayRole,
header_role=Qt.DisplayRole,
unicode_output=False):
"""
Export the contents of the specified model to a CSV file
:param model: The model (or proxy model) to export
:type model: `PyQt5.QtCore.QAbstractItemModel`
:param filename: The filename to write the CSV file to
:type filename: str
:param role: The data role to export for the table contents
:type role: int
:param header_role: The data role to export for the table headers
:param header_role: int
:param unicode_output: If `True`, Unicode characters are allowed. The CSV
file will contain a tag (UTF-8 BOM) that allows Excel and
LibreOffice to recognize the file as Unicode. (This tag will be
added regardless of whether the output contains any Unicode
characters.) If `False`, the CSV file will not contain the tag and
Unicode characters will lead to an exception.
:type unicode_output: bool
"""
proxy_model = ExportableProxyModel()
proxy_model.setSourceModel(model)
proxy_model.exportToCsv(filename, role, header_role, unicode_output)
[docs]def export_to_csv_with_dialog(model,
parent,
caption="Export",
dialog_id=None,
role=Qt.DisplayRole,
header_role=Qt.DisplayRole,
unicode_output=False):
"""
Open a dialog prompting the user for a CSV export file and then export the
contents of the specified model to the selected file.
:param model: The model (or proxy model) to export
:type model: `PyQt5.QtCore.QAbstractItemModel`
:param parent: The parent widget of the export dialog
:type parent: `PyQt5.QtWidgets.QWidget`
:param caption: The title of the export dialog
:type caption: str
:param dialog_id: The id for the filedialog. Dialogs with the same
identifier will remember the last directory chosen by the user with any
dialog of the same id and open in that directory.
:type dialog_id: str, int, or float
:param role: The data role to export for the table contents
:type role: int
:param header_role: The data role to export for the table headers
:param header_role: int
:param unicode_output: If `True`, Unicode characters are allowed. The CSV
file will contain a tag (UTF-8 BOM) that allows Excel and
LibreOffice to recognize the file as Unicode. (This tag will be
added regardless of whether the output contains any Unicode
characters.) If `False`, the CSV file will not contain the tag and
Unicode characters will lead to an exception.
:type unicode_output: bool
"""
file_filter = "CSV file (*.csv)"
filename = filedialog.get_save_file_name(parent=parent,
caption=caption,
filter=file_filter,
id=dialog_id)
if filename:
export_to_csv(model, filename, role, header_role, unicode_output)
[docs]class PerColumnSortableTableView(QtWidgets.QTableView):
"""
A table view that prevents sorting on certain columns. Subclasses should
override `UNSORTABLE_COLS`.
:cvar UNSORTABLE_COLS: A set of columns to be made unsortable. This
variable should be overridden in subclasses.
:vartype UNSORTABLE_COLS: set
"""
UNSORTABLE_COLS = set()
[docs] def __init__(self, parent=None):
super(PerColumnSortableTableView, self).__init__(parent)
self.setSortingEnabled(True)
self._hheader = PerColumnSortableHeaderView(self.UNSORTABLE_COLS, self)
self.setHorizontalHeader(self._hheader)
[docs]class PerColumnSortableHeaderView(QtWidgets.QHeaderView):
"""
A horizontal table header that ignores clicks on certain columns, which
prevents sorting on these columns.
"""
[docs] def __init__(self, unclickable_cols, parent=None):
super(PerColumnSortableHeaderView, self).__init__(Qt.Horizontal, parent)
self.setSectionsClickable(True)
self._unclickable_cols = unclickable_cols
[docs] def mousePressEvent(self, event):
"""
If the user has pressed on an unsortable column, then make the header
non-clickable so the table won't be sorted and won't have a new sort
indicator drawn. If the user pressed on any other column, then make the
header clickable so things behave normally.
:param event: The mouse press event
:type event: `QtGui.QMouseEvent`
"""
self._disableClickableIfUnclickableCol(event)
super(PerColumnSortableHeaderView, self).mousePressEvent(event)
[docs] def mouseReleaseEvent(self, event):
"""
Make the header clickable after processing the mouse release. This
ensures that column headers will always respond properly to hovering,
even if the user just clicked on an unclickable column.
:param event: The mouse release event
:type event: `QtGui.QMouseEvent`
"""
if self.sectionsClickable():
self._disableClickableIfUnclickableCol(event)
super(PerColumnSortableHeaderView, self).mouseReleaseEvent(event)
self.setSectionsClickable(True)
def _disableClickableIfUnclickableCol(self, event):
"""
If the specified event has happened over an unclickable column, disable
clicking for the header.
:param event: The mouse event to check
:type event: `QtGui.QMouseEvent`
"""
# This idea is based on https://wiki.qt.io/Qt_project_org_faq#
# setClickable.28.29_and_setSortIndicatorShown.28.29_affect_the_entire_
# QHeaderView.2C_is_there_a_way_to_have_it_affect_only_certain_
# sections.3F
col = self.visualIndexAt(event.pos().x())
clickable = col not in self._unclickable_cols
self.setSectionsClickable(clickable)
if __name__ == '__main__':
print(__doc__)