pythonpyqt5qtreeview

Select QStandardItem inside a QTreeView based on another QTreeView's QStandardItem's visual position


i have a lot of data that i represent as QStandardItems inside two QTreeViews. I want to retrieve the visual position of an item inside the left tree in order to align the selection of it with the item inside the right tree. The user should be able to double-click on the desired item inside the left tree, afterwards which the right tree should represent the item corresponding to his selection. So far im using a function with the doubleClicked signal of the left tree as starting point for the logic.

self.tree_view_left.doubleClicked.connect(self.double_clicked)

Inside of double_clicked im retrieving the current index of the left tree and then im searching for his equivalent inside the right tree (NOTE i want to match only roots - not child elements).

    def double_clicked(self):
        index = self.selection_model.currentIndex()
        tree_left = self.tree_view_left
        tree_right = self.tree_view_right
        if index.isValid():
            item = index.model().itemFromIndex(index)
            if item.hasChildren():
                item_search = tree_right .model().findItems(item.text(), 
                Qt.MatchRecursive)
            # TODO: Find/Calc visual position of StandardItem inside TreeView
            left_vis_item = tree_left.indexAt(tree_left.rect().topLeft())
            right_vis_item =  tree_left.indexAt(tree_left.rect().bottomRight())
            # TODO: Move vertical scrollbar of right TreeView based on that visual position
            # Change right slider position
            tree_right.scrollTo(item_search[0].index())
            tree_right.selectionModel().select(item_search[0].index(), QItemSelectionModel.Select)

I have found similiar issues in the forum here, here and here, but either they had a used a different widget (QTreeWidget) or they used a function which i cannot implement (QAbstractItemView::visualRect. My approach was at first to retrieve the visual position by finding out the visible indices (left_vis_item, right_vis_item), but then i thought of using the mouse position. The last one is new to me. Any ideas?

EDIT: I have now added a MRE with a bare minimum of data from a mockup created with this website. The MainWindow Class, which holds (here minimum) main features, looks like this:

class MainWindow(QMainWindow):
def __init__(self):
    super().__init__()
    self.setWindowTitle("Example")
    window_geometry = self.frameGeometry()
    center_screen = QDesktopWidget().availableGeometry().center()
    window_geometry.moveCenter(center_screen)
    self.move(window_geometry.topLeft())
    group_box_left = QGroupBox('1. Tree view')
    group_box_right = QGroupBox('2. Tree view')
    self.tree_view_left = QTreeView(self)
    self.tree_view_left.setEditTriggers(QAbstractItemView.NoEditTriggers)
    tree_view_left_header = self.tree_view_left.header()
    tree_view_left_header.setDefaultSectionSize(200)
    tree_model_left = QStandardItemModel()
    self.tree_view_right = QTreeView(self)
    tree_model_right = QStandardItemModel()
    # Add data
    with open("mockup.json", 'r') as f:
        data = json.load(f)
    self.import_data(data, tree_model_left)
    self.import_data(data, tree_model_right)
    # Use filled model for tree view
    self.tree_view_left.setModel(tree_model_left)
    self.tree_view_left.setVisible(True)
    self.tree_view_left.setExpandsOnDoubleClick(False)
    self.tree_view_right.setModel(tree_model_right)
    self.selection_model = self.tree_view_left.selectionModel()
    self.tree_view_left.doubleClicked.connect(self.double_clicked)
    # Layout
    hor_box_left = QVBoxLayout()
    hor_box_left.addWidget(self.tree_view_left)
    group_box_left.setLayout(hor_box_left)
    hor_box_right = QVBoxLayout()
    hor_box_right.addWidget(self.tree_view_right)
    group_box_right.setLayout(hor_box_right)
    main_layout = QHBoxLayout()
    main_layout.addWidget(group_box_left)
    main_layout.addWidget(group_box_right)
    main_layout.setContentsMargins(100, 0, 100, 0)
    main_layout.setSpacing(50)
    widget = QWidget()
    widget.setLayout(main_layout)
    self.setCentralWidget(widget)

And the function for data import is:

def import_data(self, data, model):
    for i in range(0, len(data)):
        root = QStandardItem(str(i))
        root.appendRows([QStandardItem(data[i]["Firstname"]), QStandardItem(data[i]["Lastname"]),
                         QStandardItem(data[i]["City"])])
        model.appendRow(root)

I will explain the issue with snapshots. In the first snapshot you can see that there is an offset between the selected item in the left Tree and the right.Offset explained. The desired behaviour would be to remove this offset. The current implementation works with no offset ONLY if the items are collapsed (which will not always be the case) Without offset. I will also provide the used data for this example below:

[
{
    "Firstname": "Dotty",
    "Lastname": "O'Neill",
    "City": "Tucson"
},
{
    "Firstname": "Libbie",
    "Lastname": "Shuler",
    "City": "Surat Thani"
},
{
    "Firstname": "Caressa",
    "Lastname": "Henebry",
    "City": "Tucson"
},
{
    "Firstname": "Larine",
    "Lastname": "Schalles",
    "City": "Gdańsk"
},
{
    "Firstname": "Maryellen",
    "Lastname": "Bord",
    "City": "Palembang"
},
{
    "Firstname": "Genovera",
    "Lastname": "Desai",
    "City": "Basra"
},
{
    "Firstname": "Fina",
    "Lastname": "Thad",
    "City": "Mecca"
},
{
    "Firstname": "Emylee",
    "Lastname": "Flita",
    "City": "Vancouver"
},
{
    "Firstname": "Aurore",
    "Lastname": "Lipson",
    "City": "Makati City"
},
{
    "Firstname": "Florie",
    "Lastname": "Hachmin",
    "City": "Douala"
},
{
    "Firstname": "Brooks",
    "Lastname": "Grayce",
    "City": "Birmingham"
},
{
    "Firstname": "Kristan",
    "Lastname": "Allina",
    "City": "Bahía Blanca"
},
{
    "Firstname": "Dorothy",
    "Lastname": "Remmer",
    "City": "Nouméa"
},
{
    "Firstname": "Kittie",
    "Lastname": "Kussell",
    "City": "Detroit"
},
{
    "Firstname": "Lexine",
    "Lastname": "Letsou",
    "City": "Palikir"
},
{
    "Firstname": "Madelle",
    "Lastname": "Joseph",
    "City": "Mwanza"
},
{
    "Firstname": "Olivette",
    "Lastname": "Faust",
    "City": "Pelotas"
},
{
    "Firstname": "Damaris",
    "Lastname": "Slifka",
    "City": "Graz"
},
{
    "Firstname": "Dennie",
    "Lastname": "Merat",
    "City": "Abuja"
},
{
    "Firstname": "Augustine",
    "Lastname": "Burnside",
    "City": "Arbil"
}
]

Solution

  • Qt doesn't provide a way to position an item at a specific position. But scrollTo() actually has a second optional argument that allows to ensure that the item is shown at a "reference" position.

    Knowing that, we can try to scroll to the index and show it at the bottom, then manually update the vertical scroll bar value until it reaches the required position.

    WARNING: this can only work properly as long as:

    If any of the above is not respected, you'll get unexpected results. There might also be inconsistencies in case one of the view is showing the horizontal scroll bar and the other isn't, specifically when the left view is scrolled to the bottom and when the horizontal scroll bar is shown the viewport cannot show full items only.

    I modified your function also adding the index parameter that the doubleClicked signal automatically provides.

    I also changed the search function to the more conventional QAbstractItemModel.match(), removing the Qt.MatchRecursive flag, since you only want to match top level items of both the source and destination models.

    Finally, you generally only need to use setCurrentIndex(), and using the Select flag alone will update the selection, possibly showing multiple selected items (the proper flag would be ClearAndSelect). Also, you should never use persistent references to the selection model (the reason is explained in the setModel()) documentation).

        def double_clicked(self, index):
            if not index.isValid() or index.parent().isValid():
                return
    
            right_model = self.tree_view_right.model()
            matches = right_model.match(right_model.index(0, 0), 
                Qt.DisplayRole, index.data())
            if not matches:
                return
    
            right_index = matches[0]
            self.tree_view_right.setCurrentIndex(right_index)
            self.tree_view_right.scrollTo(right_index, 
                QAbstractItemView.PositionAtBottom)
    
            leftRect = self.tree_view_left.visualRect(index)
            sb = self.tree_view_right.verticalScrollBar()
            sbMax = sb.maximum()
            while sb.value() < sbMax:
                rightRect = self.tree_view_right.visualRect(right_index)
                if (
                    leftRect.contains(rightRect) # the visual rects match
                    or leftRect.y() > rightRect.y() # the right rect is above
                ):
                    break
                sb.setValue(sb.value() + 1)
    

    Note that the leftRect.y() > rightRect.y() condition is required in case the source item is next the first top rows of the model and any item above it is expanded, while the left match is not; consider this case:

    1. at their current sizes, the views can only show 10 items;
    2. expand the first two top level items on the left view;
    3. the right view has all items collapsed;
    4. double click the last visible item on the left, which is now the eighth top level item;
    5. scrollTo(index, PositionAtBottom) can only try to show the index at the bottom of the view, but since the views can only show 10 items, the eighth item on the right will never be at the bottom;