2

I want to add a QPushButton to the tree view that ends with .pdf and when I click it I want to return the path for that Index it's assigned at.

This might not even be possible with the Native QTreeView but if anyone could guide me in the right direction that would be awesome!

To conclude more of what I would want is to have a QPushButton appear where that red square is below.

view

Current code for the "Tree View":

from PyQt5.QtMultimediaWidgets import *
from PyQt5.QtMultimedia import *
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5 import *
import os, sys
class MainMenu(QWidget):
    def __init__(self, parent = None):
        super(MainMenu, self).__init__(parent)
        self.model = QFileSystemModel()
        self.model.setRootPath(QDir.rootPath())
        self.model.setFilter(QDir.NoDotAndDotDot | QDir.AllEntries | QDir.Dirs | QDir.Files)
        self.proxy_model = QSortFilterProxyModel(recursiveFilteringEnabled = True, filterRole = QFileSystemModel.FileNameRole)
        self.proxy_model.setSourceModel(self.model)
        self.model.setReadOnly(False)
        self.model.setNameFilterDisables(False)

        self.indexRoot = self.model.index(self.model.rootPath())

        self.treeView = QTreeView(self)
        self.treeView.setModel(self.proxy_model)

        self.treeView.setRootIndex(self.indexRoot)
        self.treeView.setAnimated(True)
        self.treeView.setIndentation(20)
        self.treeView.setSortingEnabled(True)
        self.treeView.setDragEnabled(False)
        self.treeView.setAcceptDrops(False)
        self.treeView.setDropIndicatorShown(True)
        self.treeView.setEditTriggers(QTreeView.NoEditTriggers)
        for i in range(1, self.treeView.model().columnCount()):
            self.treeView.header().hideSection(i)


if __name__ == '__main__':
    app = QApplication(sys.argv)
    main = MainMenu()
    main.show()
    sys.exit(app.exec_())
JareBear
  • 467
  • 9
  • 27

1 Answers1

2

For this you'll probably need an item delegate.

The idea is that you are going to leave basic item painting to the base class paint() function, and then paint a virtual button over it.
To achieve that, QStyleOptionButton is used against the view style (obtained from the option argument): you create a style option, init it from the view (option.widget, which will apply the basic rectangle of the widget, the font, palette, etc.), adjust the rectangle to suit your needs and finally paint it.

To better implement drawing (mouse hover effects, but also to ensure correct painting update), you'll also need to set mouse tracking to True for the tree view. This, amongst other checks explained in the code, allows you to draw the virtual button, including its hover or pressed states.

Finally, when the button is released and the mouse is within its boundaries, a buttonClicked signal is emitted, with the current index as argument.

screenshot with delegate while clicking

class TreeButtonDelegate(QtWidgets.QStyledItemDelegate):
    buttonClicked = QtCore.pyqtSignal(QtCore.QModelIndex, int)

    def __init__(self, fsModel, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fsModel = fsModel

        self.clickedPaths = {}
        self._mousePos = None
        self._pressed = False
        self.minimumButtonWidth = 32

    def getOption(self, option, index):
        btnOption = QtWidgets.QStyleOptionButton()
        # initialize the basic options with the view
        btnOption.initFrom(option.widget)

        clickedCount = self.clickedPaths.get(self.fsModel.filePath(index), 0)
        if clickedCount:
            btnOption.text = '{}'.format(clickedCount)
        else:
            btnOption.text = 'NO'

        # the original option properties should never be touched, so we can't
        # directly use it's "rect"; let's create a new one from it
        btnOption.rect = QtCore.QRect(option.rect)

        # adjust it to the minimum size
        btnOption.rect.setLeft(option.rect.right() - self.minimumButtonWidth)

        style = option.widget.style()
        # get the available space for the contents of the button
        textRect = style.subElementRect(
            QtWidgets.QStyle.SE_PushButtonContents, btnOption)
        # get the margins between the contents and the border, multiplied by 2
        # since they're used for both the left and right side
        margin = style.pixelMetric(
            QtWidgets.QStyle.PM_ButtonMargin, btnOption) * 2

        # the width of the current button text
        textWidth = btnOption.fontMetrics.width(btnOption.text)

        if textRect.width() < textWidth + margin:
            # if the width is too small, adjust the *whole* button rect size
            # to fit the contents
            btnOption.rect.setLeft(btnOption.rect.left() - (
                textWidth - textRect.width() + margin))

        return btnOption

    def editorEvent(self, event, model, option, index):
        # map the proxy index to the fsModel
        srcIndex = index.model().mapToSource(index)
        # I'm just checking if it's a file, if you want to check the extension
        # you might need to use fsModel.fileName(srcIndex)
        if not self.fsModel.isDir(srcIndex):
            if event.type() in (QtCore.QEvent.Enter, QtCore.QEvent.MouseMove):
                self._mousePos = event.pos()
                # request an update of the current index
                option.widget.update(index)
            elif event.type() == QtCore.QEvent.Leave:
                self._mousePos = None
            elif (event.type() in (QtCore.QEvent.MouseButtonPress, QtCore.QEvent.MouseButtonDblClick)
                and event.button() == QtCore.Qt.LeftButton):
                    # check that the click is within the virtual button rectangle
                    if event.pos() in self.getOption(option, srcIndex).rect:
                        self._pressed = True
                    option.widget.update(index)
                    if event.type() == QtCore.QEvent.MouseButtonDblClick:
                        # do not send double click events
                        return True
            elif event.type() == QtCore.QEvent.MouseButtonRelease:
                if self._pressed and event.button() == QtCore.Qt.LeftButton:
                    # emit the click only if the release is within the button rect
                    if event.pos() in self.getOption(option, srcIndex).rect:
                        filePath = self.fsModel.filePath(srcIndex)
                        count = self.clickedPaths.setdefault(filePath, 0)
                        self.buttonClicked.emit(index, count + 1)
                        self.clickedPaths[filePath] += 1
                self._pressed = False
                option.widget.update(index)
        return super().editorEvent(event, model, option, index)

    def paint(self, painter, option, index):
        super().paint(painter, option, index)
        srcIndex = index.model().mapToSource(index)
        if not self.fsModel.isDir(srcIndex):
            btnOption = self.getOption(option, srcIndex)

            # remove the focus rectangle, as it will be inherited from the view
            btnOption.state &= ~QtWidgets.QStyle.State_HasFocus
            if self._mousePos is not None and self._mousePos in btnOption.rect:
                # if the style supports it, some kind of "glowing" border
                # will be shown on the button
                btnOption.state |= QtWidgets.QStyle.State_MouseOver
                if self._pressed == QtCore.Qt.LeftButton:
                    # set the button pressed state
                    btnOption.state |= QtWidgets.QStyle.State_On
            else:
                # ensure that there's no mouse over state (see above)
                btnOption.state &= ~QtWidgets.QStyle.State_MouseOver

            # finally, draw the virtual button
            option.widget.style().drawControl(
                QtWidgets.QStyle.CE_PushButton, btnOption, painter)


class MainMenu(QWidget):
    def __init__(self, parent = None):
        super(MainMenu, self).__init__(parent)
        # ...
        self.treeView = QTreeView(self)
        self.treeView.setMouseTracking(True)
        # ...
        self.treeDelegate = TreeDelegate(self.model)
        self.treeView.setItemDelegateForColumn(0, self.treeDelegate)
        self.treeDelegate.buttonClicked.connect(self.treeButtonClicked)
        # ...


    def treeButtonClicked(self, index, count):
        print('{} clicked {} times'.format(index.data(), count))

Note: I implemented the click counter as you asked in the comments (and used an helper function to accomodate the longer function that computes the button size accordingly), just remember that this doesn't take into account the possibility of files renamed, removed and/or recreated (or files renamed overwriting an existing one). To obtain that you'll need to use a more complex approach than a simple path-based dictionary, possibly by implementing QFileSystemWatcher and checking for files removed/renamed.
Also note that to speed up things a bit I'm adding the source filesystem model to the init of the delegate so that it doesn't need to be found each time it's required for painting or mouse tracking.

musicamante
  • 41,230
  • 6
  • 33
  • 58
  • This works really well!! Thank you so much for this. How would you change the name of the button you just pressed tho?? For example, if I press it 5 times, I would want to show how many times I've pressed it. Would that be possible? – JareBear Dec 05 '19 at 21:07
  • Do you need both icons and text or just text? Because there's a lot of difference, especially in computing margins between the button border, the icon and the text, and those margin have to be considered to ensure that the text is always shown (or at least, the most significant part of it). – musicamante Dec 05 '19 at 21:20
  • I just need text.. I don't think I'll need an icon on it.. but I just need text. – JareBear Dec 05 '19 at 21:21
  • @JareBear see the updates. Note that I changed an important part of the logic, be careful with the new updates in both `paint()` and `editorEvent()`. – musicamante Dec 05 '19 at 22:40
  • this looks perfect, I do not intend to be changing file names or anything like that. Thank you so much for your help on this. This has been really insightful. (: – JareBear Dec 05 '19 at 22:42
  • You're welcome. Anyway, remember that changes might not depend on what you do within the program. You could inadvertently do that from other programs (or they might just do that for their reasons, including the OS). Btw, I fixed a small typo about the clicked count signal, which didn't send the correct count + 1. – musicamante Dec 05 '19 at 22:47
  • oh ok! I'll keep that in mind! I'm not home yet yet, so I haven't put it into my project yet, but I'm super excited too!! Thank you once again for your help, have a wonderful day! – JareBear Dec 05 '19 at 22:49
  • I seem to be getting a error @musicamante. `Traceback (most recent call last): File "c:\Users\grossj\Github Repos\J-File Orginizer\test1.py", line 84, in editorEvent self.buttonClicked.emit(index, count + 1) TypeError: TreeDelegate.buttonClicked[QModelIndex] signal has 1 argument(s) but 2 provided` I don't really know what to do with this.. – JareBear Dec 06 '19 at 15:08
  • I forgot to correctly update back the clicked count signal (see the update: the signal only had one argument before); that's obviously not required, I only added it for convenience. – musicamante Dec 06 '19 at 15:11