79802210

Date: 2025-10-27 20:22:07
Score: 0.5
Natty:
Report link

Updated image:

enter image description here

Updated code, which includes some key functionality that is debatable not 'minimal' but debatably is minimal if we want to have something that mimics the functionality of a combo box as in the initial question, including:

from PyQt5.QtWidgets import (
    QApplication, QWidget, QHBoxLayout, QVBoxLayout, QTreeView,QMainWindow,QPushButton,QDialog,QLabel
)
from PyQt5.QtGui import QStandardItemModel, QStandardItem, QFontMetrics
from PyQt5.QtCore import QModelIndex,Qt,QPoint,QTimer

class MyPopup(QDialog):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.parent=parent

        # Create the TreeView for the dropdown popup
        self.tree_view = QTreeView(self)
        self.tree_view.setHeaderHidden(True)  # Hide the header to look like a simple tree
        self.tree_view.setSelectionMode(QTreeView.SingleSelection)
        self.tree_view.setEditTriggers(QTreeView.NoEditTriggers)
        self.tree_view.setExpandsOnDoubleClick(False)
        self.tree_view.setAnimated(True)

        self.tree_view.setFixedHeight(300)

        # Create a model for the tree view
        self.model = QStandardItemModel()
        self.tree_view.setModel(self.model)

        self.tree_view.entered.connect(self.enteredCB)
        self.tree_view.clicked.connect(self.clickedCB)
        self.tree_view.expanded.connect(self.expandedCB)

        self.setWindowTitle("Popup Dialog")
        self.setWindowFlags(Qt.Popup)
        layout = QVBoxLayout(self)
        layout.setContentsMargins(0,0,0,0)
        layout.addWidget(self.tree_view)
        self.setLayout(layout)
        self.tree_view.setMouseTracking(True)
        # blockPopup: don't try to show the popup for a quarter second after it's been closed;
        #  this allows a second click on the button to close the popup, in the same
        #  manner as for a combo box
        self.blockPopup=False

    def closeEvent(self,e):
        self.blockPopup=True
        QTimer.singleShot(250,self.clearBlock)

    def clearBlock(self):
        self.blockPopup=False
        
    def enteredCB(self,i):
        self.setFullLabel(i)

    def expandedCB(self,i):
        self.collapseOthers(i)

    def clickedCB(self,i):
        self.setFullLabel(i)
        self.close() # close the popup
        self.parent.button.clearFocus() # do this AFTER self.close to prevent the button from staying blue

    def setFullLabel(self,i):
        # Get the full hierarchy path for display
        current_index = i
        path_list = [self.model.data(i)]
        while current_index.parent().isValid():
            parent_index = current_index.parent()
            parent_text = self.model.data(parent_index)
            path_list.insert(0, parent_text)
            current_index = parent_index
        # Join path with a separator and set the text
        self.parent.button.setText(' > '.join(path_list))
        self.parent.label.setText('selected ID: '+self.model.data(i,Qt.UserRole))
    
    # recursive population code taken from https://stackoverflow.com/a/53747062/3577105
    #  add code to alphabetize within each branch
    def fill_model_from_json(self,parent, d):
        if isinstance(d, dict):
            for k, v in sorted(d.items(),key=lambda item: item[0].lower()): # case insensitive alphabetical sort by key
                [title,id]=k.split('|')
                child = QStandardItem(title)
                child.setData(id,Qt.UserRole)
                parent.appendRow(child)
                self.fill_model_from_json(child, v)
        elif isinstance(d, list):
            for v in d:
                self.fill_model_from_json(parent, v)
        else:
            parent.appendRow(QStandardItem(str(d)))

    # adapted from https://stackoverflow.com/a/45461474/3577105
    # hierFromList: given a list of (child,parent) tuples, returns a nested dict of key=name, val=dict of children
    def hierFromList(self,lst):
        # Build a directed graph and a list of all names that have no parent
        graph = {name: set() for tup in lst for name in tup}
        has_parent = {name: False for tup in lst for name in tup}
        for child,parent in lst:
            graph[parent].add(child)
            has_parent[child] = True
        # All names that have absolutely no parent:
        roots = [name for name, parents in has_parent.items() if not parents]

        # traversal of the graph (doesn't care about duplicates and cycles)
        def traverse(hierarchy, graph, names):
            for name in names:
                hierarchy[name] = traverse({}, graph, graph[name])
            return hierarchy

        idHier=traverse({}, graph, roots)['Top|Top']
        return idHier   

    def populate(self,tuples):
        # Populates the tree model from a list of (child,parent) tuples of text|ID strings
        self.model.clear()
        # make sure <Top Level> is always the first (and possibly only) entry
        topLevelItem=QStandardItem('<Top Level>')
        topLevelItem.setData('0',Qt.UserRole) # UserRole is used to store folder ID; use a dummy value here
        self.model.appendRow(topLevelItem)
        data=self.hierFromList(tuples)
        self.fill_model_from_json(self.model.invisibleRootItem(),data)

    # collapse all other indeces, from all levels of nesting, except for ancestors of the index in question
    def collapseOthers(self,expandedIndex):
        QApplication.processEvents()
        print('collapse_others called: expandedIndex='+str(expandedIndex))
        ancesterIndices=[]
        parent=expandedIndex.parent() # returns a new QModelIndex instance if there are no parents
        while parent.isValid():
            ancesterIndices.append(parent)
            parent=parent.parent() # ascend and recurse while valid (new QModelIndex instance if there are no parents)
        def _collapse_recursive(parent_index: QModelIndex,sp='  '):
            for row in range(self.model.rowCount(parent_index)):
                index = self.model.index(row, 0, parent_index)
                item=self.model.itemFromIndex(index)
                txt=item.text()
                if index.isValid() and index!=expandedIndex and index not in ancesterIndices:
                    self.tree_view.collapse(index)
                    # Recursively process children
                    if self.model.hasChildren(index):
                        _collapse_recursive(index,sp+'  ')
        # Start the recursion from the invisible root item
        _collapse_recursive(QModelIndex())
        QApplication.processEvents()


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Main Window")
        self.setGeometry(100, 100, 400, 50)
        central_widget = QWidget()
        self.setCentralWidget(central_widget)
        layout = QHBoxLayout(central_widget)
        self.button = QPushButton("Show Popup", self)
        self.button.pressed.connect(self.buttonPressed)
        layout.addWidget(self.button)
        self.label=QLabel()
        layout.addWidget(self.label)
        self.fm=QFontMetrics(self.button.font())
        # create and populate the popup
        self.popup = MyPopup(self)
        self.popup.populate([
            ['aa|10','a|1'],
            ['aaa|100','aa|10'],
            ['a|1','Top|Top'],
            ['b|2','Top|Top'],
            ['bb|20','b|2'],
            ['c|3','Top|Top']])
        self.popup.setFullLabel(self.popup.model.index(0,0))

    def buttonPressed(self):
        if self.popup.blockPopup:
            print('  blockPopup is True (popup was recently closed); popup not shown; returning')
            return
        # Get the global position of the button's top-left corner
        button_pos = self.button.mapToGlobal(QPoint(0, 0))
        # Calculate the desired position for the popup
        popup_x = button_pos.x()
        popup_y = button_pos.y() + self.button.height()
        popup_h=self.popup.height()
        screen_bottom_y=self.button.screen().geometry().height()
        if popup_y+popup_h>screen_bottom_y:
            popup_y=button_pos.y()-popup_h
        self.popup.move(popup_x, popup_y)
        self.popup.setFixedWidth(self.button.width())
        self.popup.exec_() # Show as a modal dialog


if __name__ == "__main__":
    app = QApplication([])
    window = MainWindow()
    window.show()
    app.exec_()

The full in-situ widgets are beyond the scope of this question, in the github.com/ncssar/radiolog development code tree as of this commit:

https://github.com/ncssar/radiolog/tree/44afdde291ec79cd8a0c08c8b41cd387e2174e2d

Reasons:
  • Blacklisted phrase (1): stackoverflow
  • Long answer (-1):
  • Has code block (-0.5):
  • Self-answer (0.5):
  • Low reputation (0.5):
Posted by: Tom Grundy