Skip to content

Instantly share code, notes, and snippets.

@MitchellKehn
Created January 21, 2024 01:46
Show Gist options
  • Save MitchellKehn/52f47084eb484a9d0098c34d7c79160f to your computer and use it in GitHub Desktop.
Save MitchellKehn/52f47084eb484a9d0098c34d7c79160f to your computer and use it in GitHub Desktop.
[Excel-like drag to fill functionality] Adds drag-to-fill functionality to a QTableView #qt #pyside
from PySide2 import QtWidgets, QtGui, QtCore
def snap_value(value, snap_points, snap_threshold, verbose=False):
"""
Given a value and a list of "snapping points" it can snap to,
snap it to the closest value if it's within the snapping threshold.
"""
if not snap_points: return value
closest_snap_point = None
closest_proximity = None
for snap_point in snap_points:
proximity = abs(value - snap_point)
if closest_proximity is None or proximity < closest_proximity:
closest_snap_point = snap_point
closest_proximity = proximity
if closest_proximity <= snap_threshold:
if verbose:
print("snapping!")
return closest_snap_point
return value
def map_rect_to_global(widget, rect):
return QtCore.QRect(
widget.mapToGlobal(rect.topLeft()),
widget.mapToGlobal(rect.bottomRight())
)
def map_rect_from_global(widget, rect):
return QtCore.QRect(
widget.mapFromGlobal(rect.topLeft()),
widget.mapFromGlobal(rect.bottomRight())
)
class BoundingBox(QtWidgets.QWidget):
"""A generic interactive bounding box interface."""
SizeAdjusted = QtCore.Signal()
Released = QtCore.Signal()
def __init__(self):
super(BoundingBox, self).__init__()
self.margin = 12
self.snapDist = 20
self.handles_activated = [
True, True, True, True,
True, True, True, True,
True,
]
self.handles_visible = [
True, True, True, True,
True, True, True, True,
True,
]
self.clip_rect = None
self.snaps_to_self = True
self.allow_symmetry = True
self.snap_lines_x = []
self.snap_lines_y = []
self._setupUI()
self._setupCallbacks()
self._highlighted = None # currently highlighted element
self._startGrabPos = None
self._startGeometry = None
def _setupUI(self):
self.setWindowTitle("bounding box")
self.setWindowFlags(QtCore.Qt.FramelessWindowHint | QtCore.Qt.Tool | QtCore.Qt.WindowStaysOnTopHint)
self.setAttribute(QtCore.Qt.WA_NoSystemBackground)
self.setAttribute(QtCore.Qt.WA_TranslucentBackground)
self.setMouseTracking(True)
# --- create handle layout ---
handleTemplate = QtCore.QRect(0, 0, self.margin, self.margin)
self.topLeftRect = QtCore.QRect(handleTemplate)
self.topRightRect = QtCore.QRect(handleTemplate)
self.bottomLeftRect = QtCore.QRect(handleTemplate)
self.bottomRightRect = QtCore.QRect(handleTemplate)
self.leftRect = QtCore.QRect(handleTemplate)
self.topRect = QtCore.QRect(handleTemplate)
self.rightRect = QtCore.QRect(handleTemplate)
self.bottomRect = QtCore.QRect(handleTemplate)
self.bbox = QtCore.QRect(self.rect())
self._previous_geometry = QtCore.QRect(self.bbox)
self.handles = [
self.topLeftRect, self.topRightRect, self.bottomRightRect, self.bottomLeftRect,
self.leftRect, self.topRect, self.rightRect, self.bottomRect,
self.bbox
]
self.updateLayout()
def _setupCallbacks(self):
pass
def paintEvent(self, event):
super(BoundingBox, self).paintEvent(event)
painter = QtGui.QPainter(self)
if self.clip_rect:
clip_rect = map_rect_from_global(self, self.clip_rect)
painter.setClipRect(clip_rect, QtCore.Qt.ReplaceClip)
painter.setClipping(True)
color = QtCore.Qt.red
fillColor = QtGui.QColor(color)
fillColor.setAlphaF(0.08)
painter.setBrush(fillColor)
for handle, visible in zip(self.handles, self.handles_visible):
if not visible: continue
pen = QtGui.QPen(color)
if handle == self._highlighted:
pen.setWidth(3)
painter.setPen(pen)
painter.drawRect(handle)
def updateLayout(self):
rect = self.rect()
self.bbox.setRect(rect.left(), rect.top(), rect.right(), rect.bottom())
self.bbox.adjust(self.margin, self.margin, -self.margin, -self.margin)
centerOffset = QtCore.QPoint(self.margin // 2, self.margin // 2)
self.topLeftRect.moveTopLeft(self.bbox.topLeft() - centerOffset)
self.topRightRect.moveTopLeft(self.bbox.topRight() - centerOffset)
self.bottomLeftRect.moveTopLeft(self.bbox.bottomLeft() - centerOffset)
self.bottomRightRect.moveTopLeft(self.bbox.bottomRight() - centerOffset)
self.leftRect.moveTopLeft(QtCore.QPoint(self.bbox.left(), self.bbox.center().y()) - centerOffset)
self.topRect.moveTopLeft(QtCore.QPoint(self.bbox.center().x(), self.bbox.top()) - centerOffset)
self.rightRect.moveTopLeft(QtCore.QPoint(self.bbox.right(), self.bbox.center().y()) - centerOffset)
self.bottomRect.moveTopLeft(QtCore.QPoint(self.bbox.center().x(), self.bbox.bottom()) - centerOffset)
def resizeEvent(self, event):
super(BoundingBox, self).resizeEvent(event)
self.updateLayout()
def mouseMoveEvent(self, event):
if not (event.buttons() & QtCore.Qt.LeftButton):
# --- highlight the correct box ---
newItem = self.getHoveredItem()
if self._highlighted != newItem:
self._highlighted = newItem
self.update()
self.updateCursor(event)
return
# --- handle resize interactions ---
bbox = QtCore.QRect(self._startGeometry)
pos = self.mapToGlobal(event.pos())
delta = pos - self._startGrabPos
snaps_x = list(self.snap_lines_x)
snaps_y = list(self.snap_lines_y)
# calculate change to bbox
if self._highlighted == self.bbox:
bbox.translate(delta)
snaps_x = [bbox.left(), bbox.right()]
snaps_y = [bbox.bottom(), bbox.top()]
else:
if self.snaps_to_self:
snaps_x.extend([bbox.left(), bbox.right()])
snaps_y.extend([bbox.bottom(), bbox.top()])
# determine how to adjust
if self._highlighted == self.leftRect:
transform = (delta.x(), 0, 0, 0)
elif self._highlighted == self.topRect:
transform = (0, delta.y(), 0, 0)
elif self._highlighted == self.rightRect:
transform = (0, 0, delta.x(), 0)
elif self._highlighted == self.bottomRect:
transform = (0, 0, 0, delta.y())
elif self._highlighted == self.topLeftRect:
transform = (delta.x(), delta.y(), 0, 0)
elif self._highlighted == self.topRightRect:
transform = (0, delta.y(), delta.x(), 0)
elif self._highlighted == self.bottomLeftRect:
transform = (delta.x(), 0, 0, delta.y())
elif self._highlighted == self.bottomRightRect:
transform = (0, 0, delta.x(), delta.y())
else:
transform = (0, 0, 0, 0)
useSymmetry = event.modifiers() & QtCore.Qt.CTRL
isSymmetryActive = useSymmetry and self.allow_symmetry
if isSymmetryActive:
transform = [
value or -transform[(index + 2) % len(transform)] for
index, value in enumerate(transform)]
bbox.adjust(*transform)
# snap edges to snap lines
if self._highlighted in (self.topRect, self.topLeftRect, self.topRightRect, self.bbox):
bbox.setTop(snap_value(bbox.top(), snaps_y, self.snapDist))
if self._highlighted in (self.bottomRect, self.bottomLeftRect, self.bottomRightRect, self.bbox):
bbox.setBottom(snap_value(bbox.bottom(), snaps_y, self.snapDist))
if self._highlighted in (self.leftRect, self.topLeftRect, self.bottomLeftRect, self.bbox):
bbox.setLeft(snap_value(bbox.left(), snaps_x, self.snapDist))
if self._highlighted in (self.rightRect, self.topRightRect, self.bottomRightRect, self.bbox):
bbox.setRight(snap_value(bbox.right(), snaps_x, self.snapDist))
previous_bbox = self._previous_geometry
# apply change to bbox
if bbox != previous_bbox:
self.setGeometry(bbox)
self.SizeAdjusted.emit()
def updateCursor(self, event):
"""update cursor in response to a QMouseEvent"""
cursor = QtGui.QCursor()
if self._highlighted in [self.bbox]:
if event.buttons() & QtCore.Qt.LeftButton:
cursor.setShape(QtCore.Qt.ClosedHandCursor)
else:
cursor.setShape(QtCore.Qt.OpenHandCursor)
elif self._highlighted in [self.topLeftRect, self.bottomRightRect]:
cursor.setShape(QtCore.Qt.SizeFDiagCursor)
elif self._highlighted in [self.topRightRect, self.bottomLeftRect]:
cursor.setShape(QtCore.Qt.SizeBDiagCursor)
elif self._highlighted in [self.topRect, self.bottomRect]:
cursor.setShape(QtCore.Qt.SizeVerCursor)
elif self._highlighted in [self.leftRect, self.rightRect]:
cursor.setShape(QtCore.Qt.SizeHorCursor)
else:
pass
self.setCursor(cursor)
def getHoveredItem(self):
"""get the item under the mouse cursor"""
item = None
mousePos = self.mapFromGlobal(QtGui.QCursor.pos())
for handle, activated, visible in zip(self.handles, self.handles_activated, self.handles_visible):
if not (activated and visible): continue # we should only allow interaction with a visible handle
if handle.contains(mousePos):
item = handle
break
return item
def setGeometry(self, geometry):
self._previous_geometry = geometry
super(BoundingBox, self).setGeometry(geometry.adjusted(-self.margin, -self.margin, self.margin, self.margin))
def mousePressEvent(self, event):
super(BoundingBox, self).mousePressEvent(event)
self.updateCursor(event)
self._startGrabPos = self.mapToGlobal(event.pos())
self._startGeometry = self.geometry().adjusted(self.margin, self.margin, -self.margin, -self.margin)
def mouseReleaseEvent(self, event):
super(BoundingBox, self).mouseReleaseEvent(event)
self.updateCursor(event)
self._startGrabPos = None
self._startGeometry = None
self.Released.emit()
def show(self):
super(BoundingBox, self).show()
self.updateLayout()
self.activateWindow()
class ExcelDraggingObserver(QtCore.QObject):
"""
An object that you can attach to a QTableView instance to enable Excel-like
"drag to duplicate contents" behaviour. Currently only implemented for columns.
For anything clever and dynamic about how excel implements formula incrementing
in their drag behaviour, that is up to the model to implement in how it
receives new data.
"""
def __init__(self, tableView: QtWidgets.QTableView, parents):
super(ExcelDraggingObserver, self).__init__(tableView)
self.tableView = tableView
self.tableView.installEventFilter(self)
self.tableView.viewport().installEventFilter(self)
for parent in parents:
parent.installEventFilter(self)
self.boundingBox = BoundingBox()
self.boundingBox.allow_symmetry = False
self.boundingBox.handles_visible = [
False, False, False, False,
False, True, False, True,
True,
]
self.boundingBox.handles_activated[-1] = False
self.boundingBox.snapDist = 10000 # we should always snap
self.tableView.doubleClicked.connect(self.onIndexDoubleClicked)
self.tableView.horizontalScrollBar().valueChanged.connect(self.onScroll)
self.tableView.verticalScrollBar().valueChanged.connect(self.onScroll)
self.boundingBox.Released.connect(self.onBoundingBoxReleased)
self.boundingBox.SizeAdjusted.connect(self.onBoundingBoxSizeAdjusted)
self._index = None
def onIndexDoubleClicked(self, index):
self._index = index
self.updateBboxFromIndex(index)
def updateBboxFromIndex(self, index):
rect = self.tableView.visualRect(index)
widget = self.tableView.viewport()
cell_coords = set([rect.top(), rect.bottom()])
for i in range(self.tableView.model().rowCount(QtCore.QModelIndex())):
other_index = self.tableView.model().index(i, index.column())
other_rect = self.tableView.visualRect(other_index)
if i == 0:
cell_coords.add(other_rect.top())
if i != index.row() - 1:
cell_coords.add(other_rect.bottom())
global_y_values = []
for y_value in cell_coords:
point = QtCore.QPoint(0, y_value)
global_point = widget.mapToGlobal(point)
global_y_values.append(global_point.y())
self.boundingBox.snap_lines_y = global_y_values
clip_rect = widget.rect()
self.boundingBox.clip_rect = map_rect_to_global(widget, clip_rect)
self.boundingBox.setGeometry(map_rect_to_global(widget, rect))
self.boundingBox.show()
self.boundingBox.update()
def onScroll(self):
self.updateBboxFromIndex(self._index)
def eventFilter(self, obj, event):
if event.type() in (QtCore.QEvent.Resize,
QtCore.QEvent.Move):
if self._index and self.boundingBox.isVisible():
self.updateBboxFromIndex(self._index)
if event.type() in (QtCore.QEvent.Hide,
QtCore.QEvent.MouseButtonPress):
self.boundingBox.hide()
return False
def getDraggedIndexes(self):
"""Get a list of all indexes currently under the bbox"""
# this is probably not the most efficient way to get this...
indexes = []
model = self.tableView.model()
bbox_rect = map_rect_from_global(self.tableView.viewport(),
map_rect_to_global(self.boundingBox,
self.boundingBox.bbox))
bbox_rect.adjust(0, 2, 0, 0)
for i in range(model.rowCount()):
for j in range(model.columnCount()):
if i == self._index.row() and j == self._index.column(): continue
index = model.index(i, j)
rect = self.tableView.visualRect(index)
if bbox_rect.intersects(rect):
indexes.append(index)
return indexes
def onBoundingBoxReleased(self):
"""After releasing the bbox, duplicate the data to all other cells"""
self.boundingBox.hide()
model = self.tableView.model()
dragged_indexes = self.getDraggedIndexes()
if isinstance(self.tableView, QtWidgets.QTableWidget):
data = model.mimeData([self._index])
for index in dragged_indexes:
model.dropMimeData(data, QtCore.Qt.MoveAction, index.row(), index.column(), QtCore.QModelIndex())
else:
data = model.itemData(self._index)
for index in dragged_indexes:
model.setItemData(index, data)
def onBoundingBoxSizeAdjusted(self):
dragged_indexes = self.getDraggedIndexes()
if not dragged_indexes: return
cursor_pos = QtGui.QCursor.pos()
is_top_handle = self.boundingBox._highlighted is self.boundingBox.topRect
index = dragged_indexes[0] if is_top_handle else dragged_indexes[-1]
row_header = self.tableView.model().headerData(index.row(), QtCore.Qt.Vertical, QtCore.Qt.DisplayRole)
QtWidgets.QToolTip.showText(cursor_pos, str(row_header))
class TableViewExample(QtWidgets.QWidget):
def __init__(self):
super().__init__()
# # Create a QTableWidget
# self.table_view = QtWidgets.QTableWidget(self)
# self.table_view.setRowCount(5)
# self.table_view.setColumnCount(3)
#
# # Fill out cells with data
# for row in range(5):
# for col in range(3):
# item_text = f"Row {row}, Col {col}"
# item = QtWidgets.QTableWidgetItem(item_text)
# self.table_view.setItem(row, col, item)
# Create a QStandardItemModel
# model = OverwritingStandardItemModel(self)
model = QtGui.QStandardItemModel(self)
# Set the number of rows and columns
rows, cols = 5, 3
model.setRowCount(rows)
model.setColumnCount(cols)
# Fill the model with dummy data
for row in range(rows):
for col in range(cols):
item_text = f"Row {row}, Col {col}"
item = QtGui.QStandardItem(item_text)
item.setFlags(item.flags() | QtCore.Qt.ItemIsDropEnabled)
model.setItem(row, col, item)
# Create a QTableView and set the model
self.table_view = QtWidgets.QTableView(self)
self.table_view.setDragDropOverwriteMode(True)
self.table_view.setModel(model)
# Set up the layout
layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(self.table_view)
self.setLayout(layout)
self.excelObserver = ExcelDraggingObserver(self.table_view, [self])
if __name__ == '__main__':
w = TableViewExample()
w.show()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment