Created
January 21, 2024 01:46
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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