Grid layout widget and splitter-like resizing
-
I'm working on a project idea where I really want to provide the user the opportunity to place widgets in the working area and control their size and layout. The initial idea I had was to make a special layout wizard class that makes an overlay-type of widget that allows the user to place existing widgets, move them around, increase the rows/columns of the layout etc. It's halfway implemented, and it works fairly well. I thought I could achieve widget resizing easily with QSplitters but in my experimentation I haven't been able to get them to work well with a grid layout. You can sort of make them happen but then at the end of the day if you want every placed widget to be resizable, you're probably better off without a layout and just using splitters for layouting, which is very bad for moving widgets around from what I was able to play around with.
I have the code for my layout wizard, but I'm not sure it's going to do any good to provide it, because my problem is design -- I can't figure out where to take it next so that I can enable proper widget resizing. Am I better off creating a custom layout class? I assume that's very difficult, but I can probably manage it with time.
TL;DR is I want to understand what would be a decent way to implement a UI where the user can:
- place widgets in a grid-like layout (doesn't have to be a QGridLayout, but it would be good if it was)
- Move around widgets that are already on the grid.
- Resize widgets both in terms of grid space (optional) and with splitter-like behavior (very desirable)
The options I have considered:
- Creating a splitter-based pseudo-layout (managing widgets within the splitters is an incredible pain)
- Creating a custom layout that will hopefully allow me to do this out of the box (although I have no idea how to get there)
- Do some sort of weird grid layout rowstretch manipulation thing where my widgets have drag events that somehow imitate splitter behavior (feels like an incredible pain in implementation and maintenance).
I use Python/PyQt6, but I can probably read any code so examples/references in any language, so there's that. Let me know if showing the layout wizard code would help, but I'm pretty sure this is a conceptual question.
-
Just for example, currently I am able to
- start with an empty layout
- add whatever number of rows/columns desired
- Fill them with widgets (with visual aid, so place this widget in this position on the layout) and pretty much customize the relational locations perfectly.
Let's say I have a huge QGraphicsView that displays the important parts of things and a tiny QTextView that provides analysis or whatever. If I place them on a 1x2 grid, by default they will be of equal size. In a hardcoded layout this is not an issue because the developer can just calculate rowstretch/columnstretch before letting the user interact with the layout, but if you want dynamic widget placing that goes out the window.
-
@aemerzel
It's an interesting problem. I think I would approach it by not using QLayout at all in the working area, because you would end up fighting it, but position and resize the child widgets directly.Override the mouse pointer events to detect when the mouse click is over a widget or over a grid line, and move and resize the widgets that touch the gridline as the mouse is subsequently dragged, while updating the gridline geometry. Maybe.
-
@KenAppleby-0
You're right, I guess I am in essence fighting against the concept ofa grid layout, it's just that I thought that splitters were a very natural part of the grid allocated widget design, but I guess the Qt vision is different.You mention
and move and resize the widgets that touch the gridline
How would you implement this gridline with no layout? Just make each widget have a border and treat the border as a gridline conceptually?
Still feels like there should be a better way to do this, I don't believe I'm the first person to want this set of admittedly super obvious features.
-
@aemerzel said in Grid layout widget and splitter-like resizing:
How would you implement this gridline with no layout?
The grid lines would essentially be purely logical things defined by pixel coordinates. If you have a column gridline positioned at x, the mouse is deemed to be inside that gridline if the event position.x() is less than, say, 5 pixels from x.
That identifies the index of the column gridline in your list of column gridlines. You then start the drag and in the mouseMoveEvent change the x value of the gridline and resize or move the widgets that are adjacent to it.
Perhaps the gridline could be implemented as a first class object which keeps track of the widgets adjacent to it and handles their geometry.If the gridlines need to be explicitly made visible this could be done in the paintEvent() of the work area. The widgets would not have borders for this, just be positioned a distance from the gridlines.
There is, of course, an awful lot more to this, how to alter the gridlines on a window resize, minimum row/column sizes, whether edge columns and rows fill the available space, whether a gridline move requires other gridlines to move... It's a big piece of work.
-
It sure is. I'm thinking of giving the custom layout thing a shot, because the layout-less approach kinda feels like a layout anyway just without the formalization, and perhaps having the structure required by being a QLayout subclass would be a decent initial structure for development. Feeling kinda skeptical about the whole thing so far, though. I'll google around some more, maybe I'll find something similar. There was a similar requirement thread way back in 2016: https://forum.qt.io/topic/67579/dynamic-grid-layout-with-splitters where @joel-bodenmann decided that he's going to do it himself, then proceeded to succeed and never post his solution (as is typical for googling a solution to your coding issue :))
-
@aemerzel I agree that's worth trying.
But having thought about this a bit more I would change the approach I described above. I would first try nested QSplitters, using a derived class to deal with the widget taking, moving and dropping. -
@KenAppleby-0
I had another idea of perhaps making a wrapper widget that holds the widget we're actually placing on the layout, and have that widget event-coded to drag the edges, but prototyping has raised various issues with this approach.The splitter approach is also a bit problematic because implementing for example widgets that span multiple "rows" in a splitter based pseudo-grid sounds like a nightmare.
The custom layout feels like the "cleaner" solution, but I haven't gotten very far with prototyping that yet (I haven't made a custom layout ever and some of the things you gotta do with the geometry management etc. are giving me a headache)
-
@KenAppleby-0
I have an extremely stupid proof of concept that sort of does what I describe in the initial message, it's extremely rough and should not be considered a good idea by anybody, but here goes:from PyQt6.QtWidgets import QWidget, QPushButton, QGridLayout, QSizePolicy, QApplication from PyQt6.QtGui import QColor, QPalette from PyQt6.QtCore import Qt class BorderWidget(QWidget): def __init__(self, widget_id, side, layout=None, row=None, column=None): super(BorderWidget, self).__init__() self.widget_id = widget_id self.side = side self.layout = layout self.row = row self.column = column pal = self.palette() pal.setColor(QPalette.ColorRole.Window, QColor('white')) self.setAutoFillBackground(True) self.setPalette(pal) self.pressed = False def mousePressEvent(self, event): super(BorderWidget, self).mousePressEvent(event) print(f"Clicked on {self.side} border of widget {self.widget_id}") print(f"self.press is {self.pressed}") self.pressed = True self.last_x = event.globalPosition().x() self.last_y = event.globalPosition().y() def mouseReleaseEvent(self, event): self.pressed = False def mouseMoveEvent(self, event): if self.pressed and self.layout is not None: dx = event.globalPosition().x() - self.last_x self.last_x = event.globalPosition().x() dy = event.globalPosition().y() - self.last_y self.last_y = event.globalPosition().y() if dx != 0: if self.side in ['right', 'left']: new_stretch = max(1, self.layout.columnStretch(self.column) - dx) self.layout.setColumnStretch(self.column, int(new_stretch)) elif self.side in ['bottom', 'top']: new_stretch = max(1, self.layout.rowStretch(self.row) - dy) self.layout.setRowStretch(self.row, int(new_stretch)) class DraggableWrapper(QWidget): def __init__(self, widget, widget_id, main_layout, row, column): super(DraggableWrapper, self).__init__() self.layout = QGridLayout(self) self.layout.setSpacing(0) self.layout.setContentsMargins(0, 0, 0, 0) self.border_top = BorderWidget(widget_id, 'top', main_layout, row, column) self.border_bottom = BorderWidget(widget_id, 'bottom', main_layout, row + 1, column) self.border_left = BorderWidget(widget_id, 'left', main_layout, row, column) self.border_right = BorderWidget(widget_id, 'right', main_layout, row, column + 1) self.border_top_left_horizontal = BorderWidget(widget_id, 'topleft', main_layout, column) self.border_top_right_horizontal = BorderWidget(widget_id, 'topright', main_layout, column) self.border_bottom_left_horizontal = BorderWidget(widget_id, 'bottomleft', main_layout, column) self.border_bottom_right_horizontal = BorderWidget(widget_id, 'bottomright', main_layout, column) self.layout.addWidget(widget, 1, 1) self.layout.addWidget(self.border_top, 0, 1) self.layout.addWidget(self.border_bottom, 2, 1) self.layout.addWidget(self.border_left, 0, 0, 3, 1) # cover the corners self.layout.addWidget(self.border_right, 0, 2, 3, 1) # cover the corners self.layout.addWidget(self.border_top_left_horizontal, 0, 0) self.layout.addWidget(self.border_top_right_horizontal, 0, 2) self.layout.addWidget(self.border_bottom_left_horizontal, 2, 0) self.layout.addWidget(self.border_bottom_right_horizontal, 2, 2) border_width = 10 self.border_top.setFixedHeight(border_width) self.border_bottom.setFixedHeight(border_width) self.border_left.setFixedWidth(border_width) self.border_right.setFixedWidth(border_width) # apply size restrictions to corner border widgets to form L-shaped corners self.border_top_left_horizontal.setFixedSize(border_width, border_width) self.border_top_right_horizontal.setFixedSize(border_width, border_width) self.border_bottom_left_horizontal.setFixedSize(border_width, border_width) self.border_bottom_right_horizontal.setFixedSize(border_width, border_width) # Apply size policies self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) class MainWindow(QWidget): def __init__(self): super().__init__() layout = QGridLayout(self) layout.setSpacing(0) default_stretch = 300 layout.setRowStretch(0, default_stretch) layout.setRowStretch(1, default_stretch) layout.setColumnStretch(0, default_stretch) layout.setColumnStretch(1, default_stretch) widget1 = QPushButton("Button 1") wrapper1 = DraggableWrapper(widget1, '1', layout, 0, 0) widget2 = QPushButton("Button 2") wrapper2 = DraggableWrapper(widget2, '2', layout, 0, 1) widget3 = QPushButton("Button 3") wrapper3 = DraggableWrapper(widget3, '3', layout, 1, 0) widget4 = QPushButton("Button 4") wrapper4 = DraggableWrapper(widget4, '4', layout, 1, 1) layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(wrapper1, 0, 0) layout.addWidget(wrapper2, 0, 1) layout.addWidget(wrapper3, 1, 0) layout.addWidget(wrapper4, 1, 1) if __name__ == '__main__': app = QApplication([]) mainWin = MainWindow() mainWin.setMinimumSize(800, 800) mainWin.show() app.exec()