Updated image:
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:
collapseOthers - when any item is expanded, collapse all others (except its ancestry), to save real estate
show full hierarchy of the hovered item in real time
also show 'data' (stored in UserRole) of hovered item in real time
populate from a list of (child,parent) tuples, each item being a string of '<displayText|data>'
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